- 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 顺手格式化
438 lines
12 KiB
Go
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, "&", "&")
|
|
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)
|
|
}
|