Files
fanqie-go/internal/handlers/review.go
T
chenxin 55398c8cb7 ✨ feat(bookSource): 光遇式聚合书源适配阅读 app 并完善评论体验
- 聚合书源:发现页筛选宫格/分类宫格、x:t:m:d: 前缀搜索、fqtab 类型随书切换、
  段评气泡(光遇 svgs 六样式);showCmt 能力检测,魔改版原地弹窗(点遮罩关闭)、
  官方阅读降级 startBrowser;jsLib 不引用规则变量,searchUrl 修正编码与作用域
- 评论页:注入绝对 API_BASE,修复弹窗 WebView 内切换排序/懒加载失败;
  externalBaseURL 识别 X-Forwarded-Proto,气泡 ident 不再硬编码 http;
  顶栏固定(拖拽把手)+评论区独立滚动
- 部署:证书方案从 certbot 迁移到 acme.sh(CERT_DIR 可配置)
- 忽略 .codegraph 本地索引目录
2026-10-10 12:54:33 +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)
}