package handlers import ( _ "embed" "encoding/json" "fanqie/internal/fqapi" "fmt" "html/template" "net/http" "strconv" "strings" "time" ) // 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, "

%s

", err.Error()) return } // 解析整数参数 para, err := parseIntParam(params["para"], "para") if err != nil { w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(w, "

%s

", 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, "

获取评论失败: %s

", 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(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 ParaPlus1, Para, Sort int Total, NextOffset int PageSize, CommentCount int HasMore bool EmojiMapJS template.JS Comments []CommentInfo } // generateReviewHTML 生成评论 HTML 页面 func generateReviewHTML(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) // 正文先转义再替换表情为 ,预计算后模板按 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, 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 "

评论页渲染失败: " + htmlEscape(err.Error()) + "

" } return b.String() } // htmlEscape HTML 转义 func htmlEscape(s string) string { s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") s = strings.ReplaceAll(s, "\"", """) s = strings.ReplaceAll(s, "'", "'") 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) }