- 聚合书源取代早期书源版本:发现页筛选宫格/分类宫格、x:t:m:d: 前缀搜索、 fqtab 类型随书切换、段评气泡(内置六种样式可配);showCmt 能力检测,魔改版 原地弹窗(点遮罩关闭)、官方阅读降级 startBrowser;jsLib 不引用规则变量, searchUrl 修正编码与作用域 - 评论页:注入绝对 API_BASE,修复弹窗 WebView 内切换排序/懒加载失败; externalBaseURL 识别 X-Forwarded-Proto,气泡 ident 不再硬编码 http; 顶栏固定(拖拽把手)+评论区独立滚动 - 部署:证书方案从 certbot 迁移到 acme.sh(CERT_DIR 可配置);新增服务生命 周期参数 --stop/--start/--no-restart/--no-autostart;--clean 一键清理全部 部署内容(服务/nginx/二进制/证书,含 certbot 残留),acme.sh 定位抽为 find_acme_sh 供 init/clean 共用 - 忽略 .codegraph 本地索引目录
450 lines
12 KiB
Go
450 lines
12 KiB
Go
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, "&", "&")
|
||
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)
|
||
}
|