Files
fanqie-go/internal/handlers/review.go
T
chenxin 5bcc8a962e ♻️ refactor(handlers): status 与 review 页面模板化
- HTML 提取至 templates/status.html、templates/review.html(go:embed 内嵌)
- status.go 1022→271 行、review.go 1281→438 行,动态数据全部经 html/template 按上下文自动转义
- 评论循环/作者徽章/贴纸/图片条件渲染翻译为模板 range/if;emojiMap 以 template.JS 注入
- 与旧版渲染输出实测对比:22 条评论结构完全一致;差异仅为 JS 上下文安全转义(& 等)与 html/template 剥除 CSS 注释,均无功能影响
- 其余文件为 gofmt 顺手格式化
2026-09-30 00:25:09 +08:00

438 lines
12 KiB
Go

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, "<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(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)
// 正文先转义再替换表情为 <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,
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)
}