Files
fanqie-go/internal/handlers/review.go
T
chenxin af9004adaf 🐛 fix(review): 弹窗内切换排序加载失败,评论页固定顶栏
- 评论页注入绝对 API_BASE:弹窗 WebView(loadDataWithBaseURL) 中相对路径
  fetch 解析不可靠,切换排序/懒加载/回复全部改走绝对地址
- externalBaseURL 识别 X-Forwarded-Proto:正文气泡 ident 与评论页地址
  不再硬编码 http://,避免反代 301
- 布局改造:顶栏固定(含拖拽把手视觉)+评论区独立滚动容器,滚动/回顶
  逻辑迁移至容器;switchSort 显式传参替代隐式 event
2026-10-10 12:49:00 +08:00

450 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
_ "embed"
"encoding/json"
"fanqie/internal/fqapi"
"fmt"
"html/template"
"net/http"
"strconv"
"strings"
"time"
)
// externalBaseURL 取外部可访问的基础 URL(nginx 反代下识别 X-Forwarded-Proto,
// 避免生成 http:// 链接被 301,弹窗 WebView 内 fetch 对 301 处理不稳)
func externalBaseURL(r *http.Request) string {
scheme := "http"
if p := r.Header.Get("X-Forwarded-Proto"); p != "" {
scheme = p
}
return scheme + "://" + r.Host
}
// validateParams 验证必需参数
func validateParams(r *http.Request, required ...string) (map[string]string, error) {
params := make(map[string]string)
for _, key := range required {
value := r.URL.Query().Get(key)
if value == "" {
return nil, fmt.Errorf("缺少 %s 参数", key)
}
params[key] = value
}
return params, nil
}
// getOptionalParam 获取可选参数(带默认值)
func getOptionalParam(r *http.Request, key, defaultValue string) string {
value := r.URL.Query().Get(key)
if value == "" {
return defaultValue
}
return value
}
// parseIntParam 解析整数参数
func parseIntParam(value string, paramName string) (int, error) {
if value == "" {
return 0, nil
}
result, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf("%s 参数格式错误", paramName)
}
return result, nil
}
// handleReviewError 统一处理段评 API 错误
func handleReviewError(w http.ResponseWriter, errType string, err error) {
status := http.StatusInternalServerError
msg := fmt.Sprintf("%s失败: %s", errType, err.Error())
if strings.Contains(err.Error(), "缺少") || strings.Contains(err.Error(), "格式错误") {
status = http.StatusBadRequest
}
respondJSON(w, status, map[string]interface{}{
"code": -1,
"msg": msg,
})
}
// handleReviewSuccess 统一处理成功响应
func handleReviewSuccess(w http.ResponseWriter, data interface{}) {
respondJSON(w, http.StatusOK, map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
})
}
// HandleChapterReview 处理章节评论请求(混合数据)
func HandleChapterReview(w http.ResponseWriter, r *http.Request) {
incrementStat("total")
// 验证必需参数
params, err := validateParams(r, "item_id")
if err != nil {
handleReviewError(w, "参数验证", err)
return
}
// 获取可选参数
bookID := getOptionalParam(r, "book_id", "")
// 调用 API
data, err := fqapi.GetChapterMixData(bookID, params["item_id"])
if err != nil {
handleReviewError(w, "获取章节评论", err)
return
}
handleReviewSuccess(w, data)
}
// HandleReview 处理段落评论请求
func HandleReview(w http.ResponseWriter, r *http.Request) {
incrementStat("total")
// 验证必需参数
params, err := validateParams(r, "item_id")
if err != nil {
handleReviewError(w, "参数验证", err)
return
}
// 获取可选参数
bookID := getOptionalParam(r, "book_id", "")
commentID := getOptionalParam(r, "comment_id", "")
paraStr := getOptionalParam(r, "para", "0")
sortStr := getOptionalParam(r, "sort", "1")
offsetStr := getOptionalParam(r, "offset", "0")
countStr := getOptionalParam(r, "count", "20")
// 解析整数参数
para, err := parseIntParam(paraStr, "para")
if err != nil {
handleReviewError(w, "参数解析", err)
return
}
sort, err := parseIntParam(sortStr, "sort")
if err != nil {
sort = 1 // 默认热门
}
offset, _ := parseIntParam(offsetStr, "offset")
count, _ := parseIntParam(countStr, "count")
if count <= 0 {
count = 20
}
// 调用 API
data, err := fqapi.GetParaReview(params["item_id"], bookID, commentID, para, sort, offset, count)
if err != nil {
handleReviewError(w, "获取段落评论", err)
return
}
handleReviewSuccess(w, data)
}
// HandleReviewHTML 处理段落评论 HTML 显示
func HandleReviewHTML(w http.ResponseWriter, r *http.Request) {
incrementStat("total")
// 验证必需参数
params, err := validateParams(r, "item_id", "para")
if err != nil {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "<html><body><h3>%s</h3></body></html>", err.Error())
return
}
// 解析整数参数
para, err := parseIntParam(params["para"], "para")
if err != nil {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "<html><body><h3>%s</h3></body></html>", err.Error())
return
}
// 获取可选参数
bookID := getOptionalParam(r, "book_id", "")
pageStr := getOptionalParam(r, "page", "1")
pageSizeStr := getOptionalParam(r, "page_size", "20")
sortStr := getOptionalParam(r, "sort", "1")
// 解析分页参数
page, _ := parseIntParam(pageStr, "page")
if page < 1 {
page = 1
}
pageSize, _ := parseIntParam(pageSizeStr, "page_size")
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
// 解析排序参数
sort, _ := parseIntParam(sortStr, "sort")
if sort != 1 && sort != 3 {
sort = 1 // 默认热门
}
// 计算 offset(基于页码)
offset := (page - 1) * pageSize
// 调用 API
data, err := fqapi.GetParaReview(params["item_id"], bookID, "", para, sort, offset, pageSize)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "<html><body><h3>获取评论失败: %s</h3></body></html>", err.Error())
return
}
// 如果是 AJAX 请求,直接返回 JSON 数据
if r.Header.Get("X-Requested-With") == "XMLHttpRequest" {
respondJSON(w, http.StatusOK, map[string]interface{}{
"code": 0,
"data": data,
})
return
}
// 生成完整 HTML 页面(仅首次加载)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
html := generateReviewHTML(r, data, params["item_id"], bookID, para, page, pageSize, sort)
fmt.Fprint(w, html)
}
// 在 init 函数中初始化表情管理器
func init() {
// 初始化表情管理器
em := fqapi.GetEmojiManager()
em.LoadEmojis()
}
// replaceEmojiWithImages 将表情文本替换为图片标签
func replaceEmojiWithImages(text string) string {
return fqapi.ReplaceEmojisInText(text)
}
// parseCommentData 解析评论数据的通用函数
type CommentInfo struct {
UserName string
UserAvatar string
IsAuthor bool
StickerImageURL string
CreateTime string
CommentText string
CommentTextHTML template.HTML // 预计算:转义+表情替换后的正文
CommentID string
DiggCount int
ReplyCount int
ImageURL string
ImageLargeURL string
}
func parseCommentData(comment map[string]interface{}) CommentInfo {
info := CommentInfo{
UserName: "匿名用户",
}
// 提取 comment_id
if cid, ok := comment["comment_id"].(string); ok {
info.CommentID = cid
}
// 用户信息
if userInfo, ok := comment["user_info"].(map[string]interface{}); ok {
if name, ok := userInfo["user_name"].(string); ok {
info.UserName = name
}
if avatar, ok := userInfo["user_avatar"].(string); ok {
info.UserAvatar = avatar
}
if author, ok := userInfo["is_author"].(bool); ok {
info.IsAuthor = author
}
}
// 创建时间
if timestamp, ok := comment["create_timestamp"].(float64); ok {
t := time.Unix(int64(timestamp), 0)
info.CreateTime = t.Format("2006-01-02 15:04")
}
// 评论内容
if text, ok := comment["text"].(string); ok {
info.CommentText = text
}
// 提取图片
if imageData, ok := comment["image_data"].([]interface{}); ok && len(imageData) > 0 {
if imgMap, ok := imageData[0].(map[string]interface{}); ok {
if url, ok := imgMap["web_uri"].(string); ok && url != "" {
info.ImageURL = url
}
if largeURL, ok := imgMap["expand_web_url"].(string); ok && largeURL != "" {
info.ImageLargeURL = largeURL
} else {
info.ImageLargeURL = info.ImageURL
}
}
}
// 统计信息
if count, ok := comment["digg_count"].(float64); ok {
info.DiggCount = int(count)
} else if count, ok := comment["digg_count"].(int); ok {
info.DiggCount = count
}
// 回复数量
if replyCountVal, ok := comment["reply_count"].(float64); ok {
info.ReplyCount = int(replyCountVal)
} else if replyCountVal, ok := comment["reply_count"].(int); ok {
info.ReplyCount = replyCountVal
} else if replyCnt, ok := comment["reply_cnt"].(float64); ok {
info.ReplyCount = int(replyCnt)
} else if replyCnt, ok := comment["reply_cnt"].(int); ok {
info.ReplyCount = replyCnt
}
return info
}
// reviewTmpl 评论页模板(embed 内嵌;用户内容由 html/template 按上下文自动转义)
//
//go:embed templates/review.html
var reviewHTML string
var reviewTmpl = template.Must(template.New("review").Parse(reviewHTML))
// reviewPageData 评论页模板数据
type reviewPageData struct {
ItemID, BookID string
BaseURL string
ParaPlus1, Para, Sort int
Total, NextOffset int
PageSize, CommentCount int
HasMore bool
EmojiMapJS template.JS
Comments []CommentInfo
}
// generateReviewHTML 生成评论 HTML 页面
func generateReviewHTML(r *http.Request, data map[string]interface{}, itemID, bookID string, para, page, pageSize, sort int) string {
total := 0
hasMore := false
nextOffset := 0
// 提取评论总数(新接口 count 字段)
if countVal, ok := data["count"].(float64); ok {
total = int(countVal)
} else if countVal, ok := data["count"].(int); ok {
total = countVal
}
if hasMoreVal, ok := data["has_more"].(bool); ok {
hasMore = hasMoreVal
} else if hasMoreVal, ok := data["has_more"].(float64); ok {
hasMore = hasMoreVal > 0
} else if hasMoreVal, ok := data["has_more"].(int); ok {
hasMore = hasMoreVal > 0
}
if nextOffsetVal, ok := data["next_offset"].(float64); ok {
nextOffset = int(nextOffsetVal)
} else if nextOffsetVal, ok := data["next_offset"].(int); ok {
nextOffset = nextOffsetVal
}
// 解析评论数据
comments := []map[string]interface{}{}
if commentsList, ok := data["comments"].([]interface{}); ok {
for _, item := range commentsList {
if comment, ok := item.(map[string]interface{}); ok {
comments = append(comments, comment)
}
}
}
// 计算分页
startIdx := (page - 1) * pageSize
endIdx := startIdx + pageSize
if endIdx > len(comments) {
endIdx = len(comments)
}
var pageComments []map[string]interface{}
if startIdx < len(comments) {
pageComments = comments[startIdx:endIdx]
}
pageInfos := make([]CommentInfo, 0, len(pageComments))
for _, comment := range pageComments {
info := parseCommentData(comment)
// 正文先转义再替换表情为 <img>,预计算后模板按 HTML 输出
info.CommentTextHTML = template.HTML(replaceEmojiWithImages(htmlEscape(info.CommentText)))
pageInfos = append(pageInfos, info)
}
var b strings.Builder
if err := reviewTmpl.Execute(&b, reviewPageData{
ItemID: itemID, BookID: bookID,
BaseURL: externalBaseURL(r),
ParaPlus1: para + 1, Para: para, Sort: sort,
Total: total, NextOffset: nextOffset,
PageSize: pageSize, CommentCount: len(pageComments),
HasMore: hasMore,
EmojiMapJS: template.JS(generateEmojiMapJS()),
Comments: pageInfos,
}); err != nil {
return "<p>评论页渲染失败: " + htmlEscape(err.Error()) + "</p>"
}
return b.String()
}
// htmlEscape HTML 转义
func htmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "'", "&#39;")
return s
}
// generateEmojiMapJS 生成 JavaScript 格式的表情映射
func generateEmojiMapJS() string {
em := fqapi.GetEmojiManager()
emojiMap := em.GetAllEmojis()
var sb strings.Builder
sb.WriteString("{")
first := true
for emoji, url := range emojiMap {
if !first {
sb.WriteString(",")
}
first = false
// 转义特殊字符
escapedEmoji := strings.ReplaceAll(emoji, "\\", "\\\\")
escapedEmoji = strings.ReplaceAll(escapedEmoji, "'", "\\'")
escapedURL := strings.ReplaceAll(url, "\\", "\\\\")
escapedURL = strings.ReplaceAll(escapedURL, "'", "\\'")
sb.WriteString(fmt.Sprintf("'%s':'%s'", escapedEmoji, escapedURL))
}
sb.WriteString("}")
return sb.String()
}
// respondJSON 响应 JSON
func respondJSON(w http.ResponseWriter, statusCode int, data interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(data)
}