跳到主要内容
奇点CMS v0.4.66 · 官方网站 登录 / 注册 在线激活

奇点CMS 网站 BUG 修复规范

定位根因后,必须识别所有受影响的文件,避免遗漏:

网站BUG修复.md 文档版本 v0.4.66 220 KB 更新于 2026-10-11
提示 该文档体积较大,本页仅渲染前 160 KB 内容;完整内容请查看交付包内 docs/网站BUG修复.md。

奇点CMS 网站 BUG 修复规范

每次修复 BUG 前后,必须按本规范逐项执行:修复前全站分析审查问题根因与影响范围,修复后全站适配验证确保无冲突。任何未经验证的修复不得交付。


一、修复前:全站分析审查

1. 问题定位

步骤动作要求
1.1 复现问题在实际环境或沙箱中复现 BUG记录复现步骤、预期行为、实际行为
1.2 查看错误日志检查 PHP error_log、Nginx error.log、Go 服务 stdout记录完整错误信息与堆栈
1.3 定位根因阅读相关代码,追溯调用链到源头根因必须是代码层面的具体位置(文件:行号),不能停留在"现象描述"

2. 受影响文件识别

定位根因后,必须识别所有受影响的文件,避免遗漏:

# 以修复目标关键词为线索,扫描全站涉及文件
# 示例:修复聊天功能时,搜索所有引用 chat 相关函数的文件
grep -rn 'sendChatMessage\|getChatHistory\|processChatMediaUpload' . --include='*.php'
grep -rn 'chat_messages\|chat_media' . --include='*.php' --include='*.go'

需识别的文件类别:

类别目录说明
PHP 页面根目录 *.php直接面向用户的前端页面
PHP 函数库includes/fn-*.php核心业务逻辑函数定义
PHP 类includes/*.php(大写开头)AgentHelper、DbUpdater、LicenseManager 等
PHP 模板includes/header.php、includes/footer.php全站公共头部尾部
AJAX 端点ajax-*.php、api-*.php前端异步请求的后端入口
后台管理admin/*.php管理界面
Go 服务go-service/*.goGo 辅助服务(Agent、知识库、缓存)
数据库database.sql表结构与初始数据
前端资源assets/js/*.js页面 JS 逻辑

3. 依赖关系分析

修复前必须分析以下依赖关系,确认修改是否会波及其他组件:

3.1 PHP 函数调用链

# 查看目标函数在哪些文件中被调用
grep -rn '函数名' . --include='*.php'

# 查看函数定义位置
grep -rn 'function 函数名' includes/*.php

关键依赖链:

includes/functions.php  ← 加载入口(加载所有 fn-*.php 模块)
  ├── fn-core.php       ← 基础工具:e()、redirect()、baseUrl()、csrfToken()、getSetting()
  ├── fn-user.php       ← 用户展示:头像、徽章、角色
  ├── fn-novel.php      ← 小说/章节内容
  ├── fn-message.php    ← 站内信、聊天、Agent 对话历史
  ├── fn-activity.php   ← 在线状态、通知推送
  ├── fn-upload.php     ← 图标上传、版本检测
  ├── fn-verification.php ← 认证系统
  └── fn-moments.php    ← 动态功能

includes/auth.php ← 用户认证(requireLogin、currentUser、isLoggedIn)
  └── 依赖 functions.php → db.php → config/config.php

includes/header.php ← 公共头部模板
  └── 依赖 auth.php(requireLogin)、LicenseManager(域名守卫)、fn-core(getSetting)

3.2 Go 函数调用链

# 查看目标函数在哪些 Go 文件中被调用
grep -rn '函数名' go-service/ --include='*.go'

关键依赖链:

go-service/main.go    ← 入口(LoadConfig、InitDB、NewRouter)
  ├── config.go        ← 配置加载(getEnv、getEnvInt、getEnvBool)
  ├── db.go            ← 数据库连接与查询(QueryRows、QueryRow、Execute)
  ├── router.go       ← 路由注册(NewRouter、writeJSON、writeSuccess、writeError)
  ├── middleware.go    ← 中间件(CORS、IP白名单、鉴权、限流、日志)
  ├── agent.go        ← Agent 通知(欢迎消息、系统更新、内容更新、登录信息)
  ├── agent-knowledge.go ← 知识库搜索/入库/标签/分片
  ├── agent-processor.go ← Agent 处理管线(本地搜索→外部搜索→综合)
  ├── cache.go        ← 内存缓存(LRU + TTL)
  └── system.go       ← 系统检查(版本、健康、统计、数据库查询)

3.3 PHP ↔ Go 跨语言调用

PHP 通过 GoAdapter.php 和 agent-search.php、agent-ai-api.php 等文件调用 Go 服务的 HTTP API。修改任一侧接口时必须同步检查另一侧。

# 查看 PHP 中对 Go 服务的调用
grep -rn 'GoAdapter\|goServiceUrl\|agent.*api\|curl.*go' . --include='*.php'

# 查看 Go 服务路由注册
grep -n 'HandleFunc\|PathPrefix\|router\|mux\.' go-service/router.go

3.4 数据库表与字段引用

# 查看目标表在 PHP 和 Go 中的所有引用
grep -rn '表名' . --include='*.php' --include='*.go' --include='*.sql'

# 确认表结构
grep -A 20 'CREATE TABLE.*表名' database.sql

4. 影响范围评估

完成上述分析后,填写影响范围评估表:

维度检查项结论
PHP 语法修改是否影响 PHP 语法正确性
Go 语法修改是否影响 Go 编译
函数签名是否修改了函数参数/返回值
函数调用被修改函数的所有调用方是否已同步更新
数据库是否涉及表结构变更(新增字段/修改类型)
数据库连接是否影响 db.php 或 config.go 的连接逻辑
PHP↔Go 接口是否影响 PHP 调用 Go 的 API 接口契约
前端 JS是否影响前端 AJAX 请求路径或响应格式
安装向导是否影响 install/ 中的安装流程
路由注册是否影响 Go router.go 中的路由注册
include 链是否影响 functions.php 的模块加载链

任何维度标记为"是"时,修复后必须执行第三节的全站适配验证。


二、修复中:代码修改原则

1. 通用原则

  • 先读后改:修改任何文件前先完整阅读当前代码,确认结构、缩进、命名风格
  • 最小改动:只修改导致 BUG 的最小代码段,不擅自重构无关代码
  • 保持风格:命名、缩进、注释风格与文件原有风格一致
  • 不越权:只做用户请求及其必需后续,不创建无关文件

2. 跨语言修改注意事项

场景注意事项
修改 PHP 端 AJAX 端点同步检查前端 JS 中的 fetch/XMLHttpRequest 路径和参数名
修改 Go 路由同步检查 PHP GoAdapter.php 中的调用 URL
修改数据库表结构同步更新 database.sql、PHP 查询语句、Go 查询语句
修改 PHP 函数签名同步更新所有调用该函数的 PHP 文件
修改 Go 函数签名同步更新所有调用该函数的 Go 文件
新增 PHP 文件确认 include/require 链正确;如需注册到 functions.php 加载链则更新

3. 字符与编码规范

  • 代码正文一律使用 ASCII 半角标点
  • 用户输入含全角标点时,先 replace 成半角再嵌入源码
  • 不假定文件编码,按 UTF-8 处理(环境已注入 PYTHONUTF8=1)

三、修复后:全站适配验证

1. PHP 语法检查

# 方法 A:有 PHP CLI 时
find . -name '*.php' -exec php -l {} \; | grep -v 'No syntax errors'
# 预期输出:无任何行(全部通过)

# 方法 B:无 PHP CLI 时,用括号配对脚本
python3 << 'PYEOF'
import re, os

def check_php(fp):
    with open(fp, 'r') as f:
        content = f.read()
    i = 0; in_php = False; in_s = False; in_d = False
    brace = 0; paren = 0
    while i < len(content):
        if not in_php and content[i:i+5] == '<?php':
            in_php = True; i += 5; continue
        if in_php and content[i:i+2] == '?>':
            in_php = False; i += 2; continue
        if in_php:
            if not in_s and not in_d:
                if content[i:i+2] == '//':
                    while i < len(content) and content[i] != '\n': i += 1
                    continue
                if content[i:i+2] == '/*':
                    i += 2
                    while i < len(content) and content[i:i+2] != '*/': i += 1
                    i += 2; continue
                if content[i] == "'": in_s = True; i += 1; continue
                if content[i] == '"': in_d = True; i += 1; continue
                if content[i] == '{': brace += 1
                elif content[i] == '}': brace -= 1
                elif content[i] == '(': paren += 1
                elif content[i] == ')': paren -= 1
            elif in_s:
                if content[i] == '\\' and i+1 < len(content): i += 2; continue
                if content[i] == "'": in_s = False
            elif in_d:
                if content[i] == '\\' and i+1 < len(content): i += 2; continue
                if content[i] == '"': in_d = False
        i += 1
    return brace == 0 and paren == 0

errors = []
for root, dirs, files in os.walk('.'):
    dirs[:] = [d for d in dirs if d not in {'.git', '__pycache__', '.dumate-data'}]
    for f in files:
        if f.endswith('.php'):
            fp = os.path.join(root, f)
            if not check_php(fp):
                errors.append(fp)

print(f"Checked {sum(1 for _ in os.walk('.'))} dirs")
if errors:
    print("FAIL:")
    for e in errors: print(f"  {e}")
else:
    print("ALL PASS")
PYEOF

2. Go 语法检查

cd go-service

# 方法 A:有 Go 环境时
go vet ./...     # 静态检查
go build -o /dev/null ./...  # 编译检查(不产出二进制)

# 方法 B:无 Go 环境时,用括号配对脚本
python3 << 'PYEOF'
import re, os

def check_go(fp):
    with open(fp, 'r') as f:
        content = f.read()
    # Go 没有 PHP 标签,直接处理全文
    # 去除字符串和注释
    result = []
    i = 0; in_s = False; in_d = False; in_raw = False; in_line_comment = False; in_block_comment = False
    while i < len(content):
        if not in_s and not in_d and not in_raw and not in_line_comment and not in_block_comment:
            if content[i:i+2] == '//':
                in_line_comment = True; i += 2; continue
            if content[i:i+2] == '/*':
                in_block_comment = True; i += 2; continue
            if content[i] == '`':
                in_raw = True; i += 1; continue
            if content[i] == "'":
                in_s = True; i += 1; continue
            if content[i] == '"':
                in_d = True; i += 1; continue
        if in_line_comment:
            if content[i] == '\n': in_line_comment = False
            i += 1; continue
        if in_block_comment:
            if content[i:i+2] == '*/': in_block_comment = False; i += 2; continue
            i += 1; continue
        if in_raw:
            if content[i] == '`': in_raw = False
            i += 1; continue
        if in_s:
            if content[i] == '\\' and i+1 < len(content): i += 2; continue
            if content[i] == "'": in_s = False
            i += 1; continue
        if in_d:
            if content[i] == '\\' and i+1 < len(content): i += 2; continue
            if content[i] == '"': in_d = False
            i += 1; continue
        result.append(content[i])
        i += 1
    clean = ''.join(result)
    brace = clean.count('{') - clean.count('}')
    paren = clean.count('(') - clean.count(')')
    bracket = clean.count('[') - clean.count(']')
    return brace == 0 and paren == 0 and bracket == 0

errors = []
for f in os.listdir('.'):
    if f.endswith('.go'):
        if not check_go(f):
            errors.append(f)
if errors:
    print("FAIL:")
    for e in errors: print(f"  {e}")
else:
    print("ALL PASS")
PYEOF

3. 函数定义与调用一致性检查

3.1 PHP 函数一致性

# 提取所有函数定义
grep -rn '^function ' includes/*.php *.php --include='*.php' | sed 's/(.*//;s/.*function //' | sort

# 对修改过的函数,检查所有调用方
for func_name in 被修改的函数名1 被修改的函数名2; do
    echo "=== $func_name ==="
    grep -rn "$func_name" . --include='*.php' | grep -v "^.*:function "
done

验证标准:

  • 被修改函数的所有调用方使用的参数数量/类型与新签名一致
  • 不存在调用了已被删除的函数的文件
  • 新增函数名不与已有函数重名(PHP 致命错误)

3.2 Go 函数一致性

# 提取所有 Go 函数定义
grep -rn '^func ' go-service/*.go | sort

# 对修改过的函数,检查所有调用方
for func_name in 被修改的函数名; do
    echo "=== $func_name ==="
    grep -rn "$func_name" go-service/ --include='*.go' | grep -v "^.*:func "
done

验证标准:

  • Go 中不允许重复定义同名函数(编译错误)
  • 被修改函数的所有调用方参数一致

4. 数据库表与字段引用一致性

# 提取所有 SQL 中引用的表名
grep -ohP 'FROM\s+`?\w+`?' database.sql includes/*.php *.php go-service/*.go \
  --include='*.php' --include='*.go' --include='*.sql' \
  | sed 's/FROM\s*//i; s/`//g' | sort -u

# 对修改过的表,检查 PHP 和 Go 中的所有引用
for table in 被修改的表名; do
    echo "=== $table ==="
    grep -rn "$table" . --include='*.php' --include='*.go' --include='*.sql'
done

验证标准:

  • PHP 和 Go 中查询的表名与 database.sql 中 CREATE TABLE 定义的表名一致
  • 新增字段在 PHP 查询的 SELECT/INSERT/UPDATE 语句中已同步
  • Go 查询中引用的字段名与 database.sql 定义一致
  • 不存在引用已删除表的查询

5. 数据库连接验证

# PHP 侧:检查 db.php 的连接参数来源
grep -n 'DB_HOST\|DB_PORT\|DB_NAME\|DB_USER\|DB_PASS' includes/db.php config/config.php

# Go 侧:检查 config.go 和 db.go 的连接参数
grep -n 'DB_HOST\|DB_PORT\|DB_NAME\|DB_USER\|DB_PASS\|DSN\|dsn' go-service/config.go go-service/db.go

# 确认两侧引用的表名一致
echo "=== PHP 查询的表 ==="
grep -ohP 'FROM\s+`?\w+`?' includes/*.php *.php --include='*.php' | sort -u
echo "=== Go 查询的表 ==="
grep -ohP 'FROM\s+`?\w+`?' go-service/*.go | sort -u
echo "=== database.sql 定义的表 ==="
grep 'CREATE TABLE' database.sql | sed 's/.*CREATE TABLE `//;s/`.//'

验证标准:

  • PHP 和 Go 连接同一个数据库(config 参数一致或来源相同)
  • 两侧查询的表名都在 database.sql 中有定义
  • config/generated.php 不出现在交付包中(仅运行时生成)

6. PHP ↔ Go 接口调用一致性

# PHP 侧:查看所有调用 Go 服务的代码
grep -rn 'GoAdapter\|goService\|curl.*localhost\|curl.*127.0.0.1\|file_get_contents.*localhost' \
  . --include='*.php'

# Go 侧:查看所有路由注册
grep -n 'HandleFunc\|PathPrefix' go-service/router.go

# 对比:PHP 调用的 URL 路径是否在 Go 路由中注册

验证标准:

  • PHP 调用的 URL 路径在 Go router.go 中有对应注册
  • PHP 发送的 HTTP 请求参数名与 Go handler 读取的参数名一致
  • Go 返回的 JSON 字段名与 PHP 解析的字段名一致

7. 脚本关联性检查

7.1 PHP include/require 链

# 检查所有 require_once / include_once 引用的文件是否存在
grep -rn 'require_once\|include_once\|require \|include ' . --include='*.php' \
  | sed "s/.*require_once *//;s/.*include_once *//;s/.*require *//;s/.*include *//" \
  | sed "s/[('\"\.\/]//g;s/).*$//" | sort -u
# 逐个确认文件存在

验证标准:

  • 所有 require_once/include_once 引用的文件实际存在
  • functions.php 加载链中引用的所有 fn-*.php 文件存在
  • 无循环依赖

7.2 前端 JS 与后端端点一致性

# 提取前端 JS 中的 AJAX 请求路径
grep -rn "fetch(\|XMLHttpRequest\|\.ajax(\|\.post(\|\.get(" . --include='*.js' --include='*.php'

# 提取所有 AJAX 端点文件
ls ajax-*.php api-*.php 2>/dev/null

# 对比:JS 请求的路径是否有对应的 PHP 端点文件

验证标准:

  • 前端 fetch()/XMLHttpRequest 请求的 URL 路径有对应的 PHP 文件
  • 前端发送的 FormData/JSON 字段名与 PHP 端点 $_POST/$_GET 读取的字段名一致
  • PHP 端点返回的 JSON 结构与前端解析逻辑一致

7.3 Go 路由完整性

# 确认所有 handler 函数都有路由注册
grep -n 'func.*Handler' go-service/*.go  # 所有 handler 定义
grep -n 'HandleFunc' go-service/router.go  # 所有路由注册
# 对比:每个 Handler 都有对应路由注册

8. 硬编码凭据零残留

# 按打包说明要求扫描
grep -rn 'YOUR_FORBIDDEN_PATTERNS_HERE' . \
  --include='*.php' --include='*.js' --include='*.json' \
  --include='*.md' --include='*.txt' --include='*.html' --include='*.css'
# 预期:零匹配

四、验证结果记录

每次修复后,将以下验证结果记录在 BUG 修复记录中:

验证项结果备注
PHP 语法检查PASS / FAIL修改文件数
Go 语法检查PASS / FAIL修改文件数
PHP 函数调用一致性PASS / FAIL检查的函数列表
Go 函数调用一致性PASS / FAIL检查的函数列表
数据库表/字段一致性PASS / FAIL检查的表列表
数据库连接参数一致PASS / FAIL
PHP↔Go 接口一致性PASS / FAIL检查的接口列表
include/require 链完整PASS / FAIL
前端 JS 与后端端点一致PASS / FAIL
Go 路由完整PASS / FAIL
硬编码凭据零残留PASS / FAIL

全部 PASS 后方可打包交付。任何 FAIL 必须修复后重新验证。


五、BUG 修复记录模板

每次修复 BUG 后,按以下模板记录,便于追溯:

### BUG-YYYY-NNNN:[BUG 简述]

- 修复日期:YYYY-MM-DD
- 修复文件:
  - 新增:file1.php, file2.php
  - 修改:file3.php, includes/fn-xxx.php
- 根因分析:
  [根因描述,含文件:行号]
- 修复方案:
  [修复方案描述]
- 影响范围:
  PHP:[涉及文件列表]
  Go:[涉及文件列表]
  数据库:[涉及表列表]
  前端:[涉及 JS/模板列表]
- 验证结果:
  [第四节验证表]
- 关联 BUG:
  [如有关联的其他 BUG 编号]

六、修复记录

BUG-2026-0001:沉浸式播放器 Agent 悬浮球遮挡静音键 / 扣款后默认静音 / 悬浮球不可移动

  • 修复日期:2026-09-27
  • 修复文件:
  • 修改:assets/js/video.js、assets/js/agent.js、video-play.php
  • 根因分析:
  1. 遮挡静音键:.agent-trigger 固定于视口左下角(agent.css:6-27,bottom:24px/left:24px, 移动端 bottom:16px/left:16px,z-index:10000),与沉浸式播放器静音键(video.js 动态创建, left:12px/bottom:24px/36x36,位于 z-index:9999 的 .video-player-overlay 内)位置重叠, 且悬浮球层级更高,静音键被盖住无法点击。
  2. 扣款后默认静音:未付费时 resumeVideo 将视频强制静音(video.js 原 352 行 v.muted = true), 扣款成功后 loadVideoAfterPayment(video.js 原 168-184 行)直接 play(),未恢复 muted = false,导致付费后无声播放;且静音按钮图标不随实际状态刷新,显示与状态不一致。
  3. 悬浮球不可移动:agent.js 悬浮球仅绑定 click 开关面板(原 108 行),无拖拽事件处理。
  • 修复方案:
  1. video-play.php 登录分支新增页面级样式 .agent-trigger { bottom: 76px; },播放页悬浮球默认 位置上移避开静音键区域(静音键占据 bottom 24~60px);仅影响播放页,其余页面维持原默认位置。
  2. loadVideoAfterPayment 付费播放前恢复 muted = false 并同步按钮图标;有声自动播放被浏览器 策略拦截时降级为静音播放(图标同步为静音态),用户点静音键即可恢复声音。resumeVideo 已付费播放路径增加同样兜底。新增 syncMuteBtn() 统一图标同步,updateActiveVideo 末尾 调用,保证初始化/切换后图标与真实静音状态一致。
  3. agent.js 新增悬浮球拖拽(触摸 + 鼠标):5px 阈值区分点击与拖拽、拖拽中关闭过渡/呼吸动画、 视口边界钳制、松手左右吸边、位置写入 localStorage 并在启动/窗口变化时恢复;拖拽后以 suppressClick 抑制合成 click 防误开关面板(与 video.js justSwiped 同一模式)。
  • 影响范围: PHP:video-play.php(仅新增页面级 style,无逻辑变更) Go:无 数据库:无 前端:assets/js/video.js、assets/js/agent.js(assetVersion 按 mtime 自动破缓存,无需清 CDN)
  • 验证结果:
验证项结果备注
PHP 语法检查PASSvideo-play.php(php -n -l)
JS 语法检查PASSvideo.js、agent.js(node --check)
Go 语法/接口一致性N/A未涉及
数据库一致性N/A未涉及
include/require 链完整PASS未改动引入链
前端 JS 与后端端点一致PASS未改动任何请求端点/参数
.agent-trigger 引用冲突扫描PASS全站仅 agent.js/agent.css 操作悬浮球
硬编码凭据零残留PASS新增代码无凭据/IP/密钥
页面可访问性PASS/video.php 200;/video-play.php 未登录 302→login(预期)
  • 关联 BUG: v0.3.75 播放器手势修复(同文件 video.js,不同逻辑段,互不影响)
  • 备注:本修复完成于 QDCMS-v0.3.75.zip 打包之后,未包含在 v0.3.75 交付包内,随下一版交付。

BUG-2026-0002:后台提交广告(及所有后台表单)后跳转空白页

  • 修复日期:2026-09-27
  • 修复文件:
  • 修改:admin/header.php(1 处,ob_start)
  • 根因分析: 后台所有 CRUD 页均为"先 require admin/header.php 输出 HTML,再处理 POST 并 redirect()"的结构(ads.php、categories.php 等 16 个页面)。admin/header.php 无输出缓冲,本机 php.ini output_buffering = 4096(有限缓冲),header.php 输出的 HTML 超过 4096 字节后响应头即已发送;随后 fn-core.php:23 redirect() 的 header("Location:...") 因 headers already sent 失效,exit 截断页面, 浏览器只收到 1310 字节半截 HTML → 表现为空白页。 实证(nginx 访问日志):POST /admin/ads.php → 200/1310B(正常应为 302)。 前台无此问题:includes/header.php:10 有 ob_start('imageAcceleratorBuffer') 全量缓冲兜底;system-update.php 等 JSON API 自带 ob_start 且不引入 admin/header.php,均不受影响。
  • 修复方案: admin/header.php 顶部(任何输出前)增加 ob_start(),与前台架构一致。 头部延迟到脚本结束/重定向时才发送,redirect() 恢复生效。一处修复覆盖 全部 16 个受影响后台页(ads/categories/chapters/comments/users/nav-links/ homepage-sections/service-agreements/customer-services/drama-list/ drama-episodes/novel-edit/chapter-edit/feedbacks/pretty-numbers/theme)。
  • 影响范围: PHP:admin/header.php(所有引入它的后台页面受益,行为不变、仅重定向恢复) Go:无 数据库:无(POST 数据本就入库成功,用户提交的广告 id=2 未丢失) 前端:无
  • 验证结果:
验证项结果备注
PHP 语法检查PASSadmin/header.php(php -n -l)
BUG 复现(真实 fpm 链路)PASS临时脚本 32KB 输出后 redirect:无缓冲=200 无跳转(复现);ob_start=302+Location(修复生效),验证后已删除
header() 冲突扫描PASSadmin 下原生 header() 仅存在于 3 个自带缓冲的 JSON API,不引入 header.php
流式输出排查PASSadmin 无 readfile/fpassthru 等流式下载页,全量缓冲安全
未登录回归PASS/admin/ads.php 302→login.php 正常
硬编码凭据零残留PASS新增仅 ob_start 与注释
  • 关联 BUG: BUG-2026-0001(广告模块为本次触发场景;本 BUG 为后台框架级问题,历史即存在,广告页是首个被实际提交使用而暴露的页面)
  • 备注:修复前提交的广告已正常入库(ads 表 id=2),无需重新提交;随下一版交付。

BUG-2026-0003:短视频/短剧上传失败(Failed to fetch / ERR_CONNECTION_ABORTED)

  • 修复日期:2026-09-27
  • 修复文件:
  • 修改:站点 nginx vhost 配置(面板 vhost 目录,改前已备份 .bak;环境配置,不进代码包)
  • 修改:nginx.conf.example(交付模板同步)、docs/安装配置说明.md(第五节新增上传容忍配置)
  • 根因分析:
  1. nginx 访问日志实证:POST /drama-upload.php → 408/0(21:49)、POST /post-video.php → 400/0 (21:57、21:58,手机与桌面各一次)。408 = client_body_timeout(默认 60s)内客户端未继续 发送请求体;400/0 = 传输中断连接关闭。浏览器侧对应 ERR_CONNECTION_ABORTED / Failed to fetch。
  2. 服务器收体链路实证健康:本机经 nginx→php-fpm POST 70MB 文件 0.46s 完成、FPM 完整解析 (探针 post_files_seen=1,用后已删);client_body_temp 可写、磁盘/inode/内存/OOM/WAF 均正常。
  3. PHP 层无瓶颈:FPM 实际生效 upload_max_filesize=500M、post_max_size=600M (此前 CLI php -i 读到 50M 系 CLI 加载了另一份 ini,与 FPM 无关,属排查误判已纠正)。
  4. 结论:失败发生在客户端→服务器传输阶段(两次失败均为移动线路、晚高峰时段;同日中午同线路 25MB 上传成功)。nginx 默认 60s 停顿容忍对弱网大文件上传过严,停顿即被掐断;且收体后 fastcgi_read_timeout 默认 60s,大文件落盘 + ffmpeg 缩略图处理存在 504 风险;vhost 继承的 client_max_body_size 500m 亦与 PHP 600M 不对齐。
  • 修复方案: 站点 vhost server 块新增:client_max_body_size 600m(对齐 PHP)、client_body_timeout 300s、 send_timeout 300s、fastcgi_send_timeout 300s、fastcgi_read_timeout 300s;nginx -t 校验后 reload。交付模板 nginx.conf.example 与安装文档同步该组配置(模板原 fastcgi_read_timeout 60 一并修正为 300)。
  • 影响范围: PHP:无代码改动 Go:无 数据库:无 nginx:仅本站点 vhost(另一站点 10123.xn--fiqs8s 的 location 级 fastcgi_read_timeout 60s 属其自身配置,与本站无关,未改动)
  • 验证结果:
验证项结果备注
nginx -t 配置校验PASSreload 成功
指令生效确认(nginx -T)PASS本站 server 块 5 项指令全部生效;本站 PHP location(enable-php-82.conf)无 location 级超时覆盖
70MB POST 回归(重载后)PASSpost-video.php 302 / 0.4s;探针 FPM 完整收体解析
站点可用性回归PASSGET / 200
临时探针/测试文件清理PASS_fpm_probe.php、/tmp/up*.bin 已删除
回滚路径具备vhost 备份 .bak-20260927*;php.ini 未改动(预防性备份 .bak 留存)
  • 关联 BUG: 无。注:上传成功后的 v0.3.75 压缩转码钩子(compressVideoAsync)为异步 nohup 执行,不在 本次故障链路上(当前无 ffmpeg 残留进程,负载 0.11 正常)。
  • 备注:服务器侧已放宽到 300s 停顿容忍;若客户端线路被运营商中断(跨境晚高峰常见),仍需 重试或错峰上传。随下一版交付(vhost 为环境配置,不进入代码包;模板与文档随包交付)。

BUG-2026-0004:短剧更新页背景色与短剧页不一致

  • 修复日期:2026-09-27
  • 修复文件:
  • 修改:assets/css/drama.css(新增 1 条规则)
  • 根因分析: drama-update.php:172 使用包裹类 drama-update-page,但 drama.css 中无该类定义, 页面无短剧专属背景(短剧首页 .drama-page 为 var(--drama-bg, #000000),drama.css:7-10), 回退为 body 默认背景 → 与短剧各页背景不一致。其余四个短剧页 wrapper 类均有定义(已核对)。
  • 修复方案: drama.css 上传页样式旁补充 .drama-update-page { background: var(--drama-bg, #000000); min-height: 100vh; color: #fff; },与 .drama-page 同源变量,主题后台改配色时两页同步。
  • 影响范围: PHP:无;Go:无;数据库:无 前端:assets/css/drama.css(assetVersion 按 mtime 自动破缓存)
  • 验证结果:
验证项结果备注
类名匹配PASS5 个短剧页 wrapper 类全部有 CSS 定义
CSS 完整性PASS花括号 71/71 配对
资源可访问/版本刷新PASSdrama.css 200,?v= 已更新
硬编码凭据零残留PASS仅样式规则
  • 关联 BUG: 无
  • 备注:完成于 QDCMS-v0.3.76.zip 打包之后,未包含在 v0.3.76 交付包内,随下一版交付。

BUG-2026-0005:发布短剧/短视频后其分类标签"污染"后台小说分类管理

  • 修复日期:2026-09-27
  • 修复文件:
  • 修改:admin/categories.php、includes/AgentHelper.php、includes/AgentProcessor.php、agent-search.php
  • 根因分析: 分类为单表 + content_type 判别列设计(novel/drama/video 共用 categories 表),短剧/短视频 发布时 getOrCreateCategoryId()(fn-upload.php:653)自动创建的用户分类类型写入正确 (数据库审计:novel 类型 0 条自定义分类、0 条小说挂错类型,无数据层污染),不存在"缺独立 分类表"问题。污染发生在展示/查询层:
  1. admin/categories.php 默认列出全部三种类型且无类型列,用户发布的短剧/短视频自定义 分类(sort 99)大量涌入后与小说分类无法区分 → 观感上"小说分类管理被污染"; 页面虽支持 ?type= 参数但无页签 UI,无法筛选。
  2. Agent 三处分类搜索(AgentHelper.php:301、AgentProcessor.php:179、agent-search.php:176) 缺 content_type 过滤,短剧/短视频自定义分类会混入小说搜索结果(消费端 category.php 为纯小说分类页,点进去为空列表)。
  • 修复方案:
  1. admin/categories.php:新增类型页签(小说/短剧/短视频/全部,含数量统计,默认小说); 列表新增「类型」彩色徽章列;创建/更新/删除/编辑/取消跳转均携带当前 type 保持页签; 页面增加说明文案(用户自定义分类归入对应页签)。
  2. 三处 Agent 分类搜索统一加 content_type = 'novel' AND (...) 过滤(OR 条件加括号)。
  3. 数据层无需清理:已自动创建的短剧/短视频分类均类型正确且被内容引用,属合法数据。
  • 影响范围: PHP:admin/categories.php(后台)、AgentHelper/AgentProcessor/agent-search(Agent 搜索) Go:无 数据库:无(结构与数据均未改动) 前端:无
  • 验证结果:
验证项结果备注
PHP 语法检查PASS4 个修改文件(php -n -l)
修改后 SQL 执行验证PASS新 WHERE 带括号语法可执行,模糊搜索返回正常
数据库污染审计(只读)PASSnovel 类型无自定义分类、无跨类型挂靠小说
页面回归PASSadmin/categories.php(含?type=all) 302→登录(守卫正常);category/drama/video.php 200
硬编码凭据零残留PASS无新增凭据
  • 关联 BUG: 无(与 getCategories() 既有的 content_type 过滤设计保持一致,本次补齐遗漏点)
  • 备注:完成于 QDCMS-v0.3.76.zip 打包之后,未包含在 v0.3.76 交付包内,随下一版交付。 新服务器(已部署 v0.3.76 包)如需本修复,重新打包升级或手动同步上述 4 个文件。

BUG-2026-0006:新部署站点"本地更新文件夹缺失"(packages/ 目录不在交付包 + 更新包命名不匹配)

  • 修复日期:2026-09-28
  • 修复文件:
  • 新增:packages/index.html、packages/.htaccess(占位 + 禁止 Web 访问)
  • 修改:includes/fn-upload.php(checkNewVersion)、admin/system-updater.php(白名单正则)、 admin/index.php(提示文案)、nginx.conf.example(packages deny)、docs/打包说明.md(占位说明与校验清单)
  • 环境:本站 nginx vhost 敏感目录正则追加 packages(已备份 .bak2-*,nginx -t 后 reload)
  • 根因分析:
  1. 交付包从未包含 packages/ 目录:zip 不存储空目录,且 v0.3.75/v0.3.76 打包脚本将 packages 列入 exclude_dirs(打包说明的示例命令并不排除它,属执行偏差)→ 全新部署站点 必然没有"本地更新文件夹",后台一键更新提示上传到不存在的目录。
  2. checkNewVersion()(fn-upload.php:426)对目录缺失静默按"无新版本"返回,前台提示 无法区分"目录缺失"与"没有新包"。
  3. 命名不匹配硬伤:交付包命名规范为 QDCMS-v*.zip(打包说明第二节),而更新机制只认 x-dream-cms-v*.zip(fn-upload.php:433 glob、system-updater.php:156 白名单正则)→ 即使目录存在,上传 QDCMS 包也永远检测不到新版本,在线更新链路整体失效。
  • 修复方案:
  1. packages/ 增加 index.html + .htaccess 占位(随包交付保证目录存在);nginx 模板与本站 vhost 均禁止 /packages/ Web 访问(更新包仅服务器端读取)。
  2. checkNewVersion() 兼容三种 glob(x-dream-cms-v* / QDCMS-v* / qdcms-v*),目录缺失时 返回 dir_missing=true;admin/index.php 一键更新提示区分两种状态并给出 mkdir 指引与 双命名说明。
  3. system-updater.php 白名单正则放宽为 ^(x-dream-cms|qdcms)-v.*\.zip$(保留 basename 防穿越)。
  4. 打包说明:明确 packages/ 占位必须随包、打包后校验清单新增 packages/index.html 与 packages/.htaccess;后续打包脚本不得再将 packages 列入 exclude_dirs。
  • 影响范围: PHP:fn-upload.php / system-updater.php / admin/index.php Go:无;数据库:无 nginx:模板 + 本站 vhost(packages deny;不影响任何 HTTP 功能路径) 新服务器(v0.3.76 已部署站点):需手动 mkdir packages 或等下一版包;用后台在线更新时 旧代码只认 x-dream-cms-v*.zip,QDCMS 包需改名或同步上述 3 个 PHP 文件
  • 验证结果:
验证项结果备注
PHP 语法检查PASS3 个修改文件(php -n -l)
checkNewVersion 实测PASS合成 QDCMS-v9.9.9 测试包被识别(has_new=true/file 正确);目录缺失返回 dir_missing=true;测试产物已清理
白名单正则实测PASSQDCMS-v*.zip 通过校验
nginx 配置PASSnginx -t 通过、reload 成功;/packages/index.html → 404;首页 200
占位文件就位PASSpackages/{index.html,.htaccess},属主 www
硬编码凭据零残留PASS无新增凭据
  • 关联 BUG: 无
  • 备注:完成于 QDCMS-v0.3.76.zip 打包之后,随下一版交付;下一版打包时验证 packages 占位入包。

BUG-2026-0007:移动端首页出现小说分类导航(与"导航不渲染分类"设计冲突)

  • 现象: 移动端(≤1024px)访问首页时,头部下方多出一条小说分类快捷导航条(玄幻/武侠/…/全部分类/短视频 横向 chips),桌面端无此条;用户视为 bug。
  • 根因: index.php 遗留的 <section class="mobile-cat-nav">(移动端分类快捷导航)基础样式为 display:none,但 content.css 响应式区块存在 @media (max-width:1024px){ .mobile-cat-nav{display:block;} } 开启规则。v0.3.70 起已确定"导航不再直接渲染分类"(分类入口收敛到 novels.php / category.php), 该开启规则属废弃功能残留,导致分类导航仅在移动端复活。
  • 修复方案: 删除 content.css 中上述媒体查询开启规则(基础 display:none 保留),全端不再渲染该导航条; index.php 中 markup 保留但 inert(不渲染、无副作用),如需彻底清理可后续删除该 section。
  • 影响范围: CSS:assets/css/content.css(仅删除 1 条媒体查询规则) PHP/Go/数据库/nginx:无
  • 验证结果:
验证项结果备注
下发 CSS 检查PASScontent.css 已无 mobile-cat-nav { display: block 开启规则(assetVersion 自动破缓存)
规则残留扫描PASS仅剩基础 display:none 定义与修复注释
首页 HTTP 回归PASS200,其余栏目渲染不受影响
移动端浏览器实测未执行环境无 Browser Worker,以 CSS 规则层面验证替代
  • 关联 BUG: 无
  • 备注:完成于 QDCMS-v0.3.79.zip 打包之后,随 v0.3.80 交付。

BUG-2026-0008:普通登录用户无法进入作者中心(登录页↔首页弹回循环)

  • 现象: role=0 的登录用户点击作者中心入口(个人主页磁贴 / 导航下拉)后被弹回首页,永远进不去; 仅 role>=1(作者/管理员)可进入。
  • 根因: 作者区守卫 requireAuthor()(role>=1)对普通用户执行 redirect(resolveLoginUrl()), 而 login.php 对已登录用户又 redirect('index.php'),形成"作者中心→登录页→首页"弹回循环。 同时成为 author 的唯一途径是后台人工改 role(admin/users.php),普通用户自行注册后 永远达不到 role>=1,作者中心对其实质不可达。
  • 修复方案:
  1. 作者中心定位调整为"登录即可使用的创作空间":author/header.php 守卫 requireAuthor() → requireLogin();author/novel-delete.php、author/chapter-delete.php 同步改为 requireLogin()。内容所有权安全边界不变:各页 SQL 均以 author_id = 当前用户 强制(novel-edit/novel-delete/chapter-edit/chapter-delete 已核查)。
  2. includes/header.php 移动端下拉中"作者中心"链接去掉 isAuthor() 条件,登录用户均可见 (与个人主页磁贴入口一致)。
  3. isAuthor()(role>=1)语义保留不动,仍用于作者数据卡/靓号/关注类型等展示逻辑。
  • 影响范围: PHP:author/header.php、author/novel-delete.php、author/chapter-delete.php、includes/header.php Go/数据库/nginx:无
  • 验证结果:
验证项结果备注
PHP 语法检查PASS4 个修改文件(php -n -l)
访客守卫PASS未登录访问 author/dashboard.php 仍 302 → login.php
所有权边界PASS删除/编辑类页面 SQL 均带 author_id 条件,越权操作取不到记录
requireAuthor 调用残留PASS仅剩注释提及,无实际调用
登录用户实测PASSrole=0 登录用户访问 author/{dashboard,novels,chapters,novel-edit}.php 均 200 无跳转;访客仍 302 → login.php
越权回归实测PASSrole=0 用户删除他人小说/章节 302 → novels.php / chapters.php 且数据存活;删自己的作品正常删除
  • 关联 BUG: 无
  • 备注:完成于 QDCMS-v0.3.79.zip 打包之后,随 v0.3.80 交付。
  • 回归记录(2026-09-30):工作区在 2026-09-29 01:18 的文件同步中丢失本修复, author/header.php、author/novel-delete.php、author/chapter-delete.php 三处守卫 回落为 requireAuthor(),现象复现(role=0 登录用户 302 → login.php → index.php, 两跳后落到首页;includes/header.php 的入口链接未受影响,仍无条件展示)。 已按本节方案重新落地三处 requireLogin()。打包/同步时请复核这三个文件, 勿用含旧守卫的副本整体覆盖 author/ 目录。

FEAT-2026-0001:部署检测新增「Agent+ 智能体服务环境与依赖」第三组

  • 记录日期:2026-09-29
  • 类型:功能新增(非 BUG 修复,仍按本文档第二、三节流程验证)
  • 变更文件:
  • 修改:includes/SystemRequirements.php、admin/system-requirements.php
  • 文档:docs/安装配置说明.md、docs/版本升级说明.md、docs/打包说明.md、go-service/README.md
  • 功能点:
  1. 注册表新增 agent 分组共 9 项(仅「知识库数据表」为必选):Go 辅助服务可用性(回环 /health)、 Go 服务部署产物、Go 服务常驻与开机自启、产物与源码一致性、PHP↔Go 共享密钥、 PHP 出站 HTTP 能力、Go 服务配置复用、知识库数据表、知识库中文全文索引(ngram)
  2. 判定语义:Go 常驻服务缺失或降级记「警告/信息」而非「不通过」——Agent+ 为双实现, 不可用时 PHP 微内核自动接管(功能一致、性能降低)
  3. Web 上下文适配:open_basedir 阻止直读站点外路径时,改由子进程 test/stat、 systemctl 与回环探测判定,避免把"读不到"误报成"未部署"
  4. 后台「系统 → 部署检测」新增第三段表格,顶部汇总统计合并三组条目
  5. 密钥类检测项只输出长度与配置状态,任何情况下不回显密钥明文
  • 验证结果:
验证项结果备注
PHP 语法检查PASS2 个修改文件(php -l 无错)
注册表分组条目PASSsite=13 / server=9 / agent=9,合计 31 项
www 用户 + 站点 open_basedir 运行PASSagent 组 8 通过 + 1 信息(共享密钥未配置);无 PHP 告警、无误判
HTTP 可达性与鉴权PASS未登录请求本页 → 302 跳 login.php,无 500
真实浏览器登录态渲染未执行无浏览器 Worker,且不获取管理员凭据;访问日志有本页 200(5400 字节)记录,但早于本次定稿改动
打包校验脚本PASS以构造包实测:关键文件齐全、Go 编译产物被检出
数据库结构不改仅 SHOW CREATE TABLE 只读检测;知识库 4 表已在 database.sql
  • 关联 BUG: 无
  • 备注:随 QDCMS-v0.3.80 交付。Go 服务为可选增强,客户未部署时该组呈"警告/信息"属预期状态; 交付与部署说明见 docs/打包说明.md 第一节第 7 项、第七节。

BUG-2026-0009:重装向导抹掉 GO_API_KEY 致 Go 加速静默失效 / 已安装向导未锁定可被 POST 重装接管 / CLI 建表静默失败

  • 修复日期:2026-09-29
  • 修复文件:
  • 修改:install/index.php(985 行)、install/auto-install.php(623 行)
  • 文档:docs/安装配置说明.md(第五节)、docs/打包说明.md(第一节第 1 项一致性条目)
  • 现象:
  1. 重装后 Go 加速静默失效:已在 systemd 单元设置 GO_API_KEY(启用 HMAC 校验)的站点, 重新执行安装向导或 CLI 安装后,PHP 侧 config/generated.php 不再含 GO_API_KEY,PHP→Go 调用 被拒(401/403)并静默回退 PHP 微内核——站点仍可用,仅 Go 加速失效,属最难察觉的部署缺陷。
  2. 已安装站点可被 POST 重装接管:历史实现仅以 $step=0 控制「已安装」页面显示, 对向导的 POST 动作未拦截,可直接 POST 覆写 config/generated.php 并创建 role=9 管理员。
  3. CLI 安装静默残缺:install/auto-install.php 以 explode(';') 切分 SQL,含分号 (HTML 实体如 &nbsp;)的语句被切碎,verifications 建表与部分种子数据静默丢失, 脚本仍以退出码 0 宣告「安装完成」。
  4. Web 安装进程取不到密钥且无提示:受 open_basedir 限制读不到 /etc/systemd、/proc, 无法继承 Go 侧密钥,全程无任何告警。
  • 根因分析:
  1. 向导与 CLI 生成 config/generated.php 时整文件覆盖,未保留既有非 DB 配置项 (install/auto-install.php:263 xdreamBuildConfig 原实现)。
  2. 锁定仅作用于页面渲染(原 $step=0 分支),未在任何 POST 处理前校验安装状态; 且只识别单一锁文件位置。
  3. SQL 切分未做引号/转义感知(auto-install.php 原 explode(';')),与向导 executeSqlFile() 行为不一致;建表结果亦未做完整性校验。
  4. 密钥探测链缺失:无「环境变量 → systemd 单元 → 运行进程」取值链,也无从 Go 侧确认密钥是否存在。
  • 修复方案:
  1. 只重写 DB_*:生成 config/generated.php 时仅覆盖 DB_* 系列常量,其余既有 define (GO_API_KEY、GO_SERVICE_URL 等)按原值保留(install/index.php:235-261、 install/auto-install.php:263 xdreamBuildConfig)。
  2. 密钥继承以 Go 侧为准:按「环境变量 → systemd 单元(含 .d/*.conf)→ 运行中的 x-dream-go 进程」顺序探测(xdreamDetectGoKey,install/index.php:143),以实际生效值写入; 多来源矛盾时只输出提示、不猜测不写入。
  3. 向导双重锁定:config/generated.php 或 install.lock/install/install.lock 任一存在 即判已安装,已安装时对一切 POST 返回 403 并给出解锁指引(install/index.php:315-335)。
  4. 引号/转义感知切分 + 建表完整性校验:新增 xdreamSplitSql(auto-install.php:85,与向导 executeSqlFile() 结果一致);安装后按 database.sql 的 CREATE TABLE 清单校验 (xdreamExpectedTables,auto-install.php:117),缺表时列出缺失表名(最多 10 张)并 exit(1)(auto-install.php:457-471)。
  5. 显式告警:Web 安装进程在生成配置前对回环 /api/stats 发起一次不带签名的只读探测 (xdreamProbeGoAuth,install/index.php:200),返回 403「缺少 CSRF 签名」即判定 Go 侧 使用非默认密钥,渲染显著告警(install/index.php:626-632 计算、861-863 输出),并提示改用 命令行安装(站点根目录 php install/auto-install.php,可正常继承密钥)或事后在 config/generated.php 手工写入同名常量。全程不输出密钥明文。
  • 影响范围: PHP:install/index.php、install/auto-install.php Go:无(仅对既有 /api/stats 做只读探测,未改 Go 代码) 数据库:无表结构变更(完整性校验为只读清单比对,不写入) 前端:install/index.php 内联表单新增告警块,无新增 JS 文件 文档:docs/安装配置说明.md、docs/打包说明.md
  • 验证结果:
验证项结果备注
PHP 语法检查PASSinstall/index.php、install/auto-install.php(php -l)
SQL 切分一致性PASS引号/转义感知切分与向导 executeSqlFile() 结果一致;脱敏比对 60 项全部 PASS / 0 FAIL
建表完整性校验PASS缺表可被检出并 exit(1)(列出缺失表名)
密钥探测链(真实现场)PASS现场 xdreamProbeGoAuth() 返回 hmac_required;xdreamDetectGoKey() 取到唯一非默认值(仅比对,未输出明文)
向导锁定PASS线上已安装(install.lock 存在),对向导 POST 返回 403
硬编码凭据零残留PASS新增代码仅含默认占位串 XDREAM_GO_DEFAULT_KEY(等同于「未配置」),无实际密钥/IP
真实浏览器安装会话端到端未执行线上 install.lock 存在,/install/ 仅进入「已安装」分支;无可用凭据,不做端到端重装实测
  • 关联 BUG: FEAT-2026-0001(同属 GO_API_KEY 部署口径;本次补齐「重装不丢密钥」的写入侧与向导侧安全边界)
  • 备注:随 QDCMS-v0.3.81.zip 交付。本修复不改动线上运行配置,仅加固安装向导与 CLI 脚本; 已启用 HMAC 的站点重装后请在「系统 → 部署检测」确认「PHP↔Go 共享密钥」项为通过。

BUG-2026-0010:动态点赞/评论/删除提交一律失败(前端提示「请求验证失败」)

  • 修复日期:2026-09-29
  • 修复文件:
  • 修改:moments.php(momentsInteractionScript() 内联脚本,约 454 行)
  • 根因分析: 该交互脚本在加载时一次性取值:var csrf = window.AGENT_CONFIG ? window.AGENT_CONFIG.csrfToken : '';, 而 window.AGENT_CONFIG(含 csrfToken)直到 includes/footer.php:74-86 才被定义; 脚本的两个调用点(详情页 moments.php:302、列表页 moments.php:442)都在 footer 之前输出, 渲染顺序为「var csrf(页面中段)→ window.AGENT_CONFIG(页尾)」,故 csrf 恒为空串。 点赞/评论/删除三处 AJAX 共用该变量,均以 csrf_token=(空值)提交, 服务端 csrfVerify('')(includes/fn-core.php:293)校验失败, 返回 HTTP 200 + {"success":false,"msg":"请求验证失败"}(moments.php:30-33), 前端 alert(data.msg || data.error || ...) 遂提示「请求验证失败」,表现为提交失败。
  • 修复方案: 改为服务端渲染时注入当前会话 Token:var csrf = '<?= csrfToken() ?>'; (与同文件扣费逻辑 moments.php:209 的既有写法一致),不再依赖脚本次序与页尾变量; 点赞、评论、删除三处共用同一变量,一处修复即三处生效。服务端校验逻辑未改动。
  • 影响范围: PHP:moments.php(仅内联 JS 的取值方式) Go:无 数据库:无表结构与数据变更 前端:moments.php 内联脚本(详情页与列表页共用) 文档:docs/网站BUG修复.md
  • 验证结果:
验证项结果备注
PHP 语法检查PASSphp -l moments.php:No syntax errors detected
渲染注入PASS补丁后 GET moments.php?id=1 输出 var csrf = '<64 位十六进制 token>';(补丁前该变量实际为空串)
CSRF 闸门端到端PASS取补丁后页面注入的 Token 同会话 POST:响应由「请求验证失败」变为「请先登录」,即已越过 CSRF 校验(探针为匿名请求,未产生任何写入)
失败响应取证PASS线上 3 次失败 POST 均为 200 / 89 字节,该值 = gzip({"success":false,"msg":"请求验证失败"})=78 字节 + chunked 分块框架 11 字节,与该分支唯一吻合(已用空 Token 探针复现出同样的 89 字节)
登录用户真实评论落库未执行无可用登录凭据,且评论属写操作,未在生产库写入测试数据
  • 关联 BUG: BUG-2026-0008(同属前端脚本与会话态相关;根因不同,无重复修复)
  • 备注:修复已同步至线上 moments.php;如需回退,仅需把该行还原为原 window.AGENT_CONFIG 取值写法。

BUG-2026-0011:沉浸式播放器音量键与右侧功能按钮不同侧 / 评论图标被全局 a 配色染色 / 底部控件排版

  • 修复日期:2026-09-29
  • 修复文件:
  • 修改:assets/js/video.js、assets/css/video.css、video-play.php
  • 根因分析:
  1. 音量键位置游离:静音键由 video.js 以 position:absolute;left:12px;bottom:24px(36x36) 挂在播放器容器左下角(video.js 原 617-638 行),与右侧 .video-actions 竖排操作栏 (video.css:222-231,right:12px / bottom:100px)分属两侧,不成一组;其占据的左下角又与 Agent 悬浮球重叠(agent.css:16-37,left:24px/bottom:24px,z-index:10000), 故 BUG-2026-0001 用 .agent-trigger{bottom:76px} 让位(video-play.php 原 123-128 行)。
  2. 评论图标颜色与整体不符:评论按钮是 <a class="video-action-btn" ... style="text-decoration:none;color:inherit;"> (video.js 原 276 行),而操作栏其余按钮均为 <div>、颜色取自 .video-action-btn{color:#fff} (video.css:232-241)。<a> 上的内联 color:inherit 优先级最高,覆盖了类规则,使锚点颜色 回退到父链继承值——.video-actions/video-slide-item/.video-player-container 均未声明 color,最终取 body{color:var(--text)=#333}(style.css:53、60-67),图标在深色视频背景上 呈深灰;同时 a:hover{color:var(--primary-dark)}(style.css:69)在内联样式失效后同样会 把图标染成主题紫。
  3. 底部控件拥挤:左下角静音键、时间显示(right:12px/bottom:28px)、广告横幅(bottom:64)、 悬浮球(播放页被抬到 bottom:76)叠在同一区域;操作栏若再增一键,矮视口(横屏手机高度 ~375px) 会顶出视口上沿。
  • 修复方案:
  1. 音量键并入右侧操作栏:createVideoElement 在 .video-actions 末尾渲染 <div class="video-action-btn" data-action="mute">(与点赞/评论/分享/播放次数同列、 同 44px 圆底、同 gap,天然共用右侧对齐与响应式规则);JS 端因操作栏随滑动项动态 创建/销毁,改为容器事件代理监听 [data-action="mute"] + 全实例图标同步 (container.querySelectorAll('[data-action="mute"] .icon i')),并删除原左下角独立静音键 DOM。 播放/暂停与滑动手势的既有排除逻辑(closest('.video-action-btn'))自动覆盖新按钮, 点音量键不会误触发播放/暂停或滑动切换。
  2. 评论按钮去掉内联 color:inherit,并在 video.css 显式锁定 .video-actions a.video-action-btn{, :hover}{ color:#fff; text-decoration:none } (选择器优先级 (0,3,1) 高于 a:hover(0,1,1)),评论图标与其余图标统一为白色。
  3. 底部排版:删除已无必要的播放页 .agent-trigger{bottom:76px} 覆盖(悬浮球回归站点默认 left/bottom 24px,37px 球顶边 61px < 广告条底边 64px,两者不再重叠);时间显示与右侧 操作栏统一 12px 右边距对齐;广告横幅维持 bottom:64(位于进度条 0~3px / 时间 28px 之上, 低于操作栏底边 100px);video.css 新增 @media (max-height:480px) 压缩操作栏尺寸与间距, 横屏下 5 键完整可见。
  • 影响范围: PHP:video-play.php(仅删除页面级 style 覆盖,无逻辑变更) Go:无 数据库:无表结构与数据变更 前端:assets/js/video.js、assets/css/video.css(assetVersion 按 mtime 自动破缓存,无需清 CDN) 文档:docs/网站BUG修复.md
  • 验证结果:
验证项结果备注
PHP 语法检查PASSphp -l video-play.php:No syntax errors detected
JS 语法检查PASSnode --check assets/js/video.js(面板 node v24.21.0)
操作栏生成结果PASS抽取真实 actions.innerHTML 模板求值:含 data-action="mute",顺序 like→comment→share→mute,评论锚点已无内联 style
音量键行为(最小 DOM 桩加载真实 video.js)PASS未付费态图标=volume-xmark;点击后该视频 muted 取反、图标=volume-high;再次点击复原;预加载邻居共 2 个实例图标同步;点击未触发 play()
配色冲突扫描PASS全站仅 content.css:542 一处 !important 配色且作用于 .moment-delete-btn,与该锚点无关;video.css 花括号平衡
线上可达性PASS/video-play.php 302 → login(未登录预期,响应体无 PHP 错误);video.css / video.js HTTP 200
浏览器/真机视觉核验未执行播放页需登录态,无可用凭据;未做真机截图核验
  • 关联 BUG: BUG-2026-0001(同属播放器控件与悬浮球位置;其 .agent-trigger{bottom:76px} 页面级覆盖在本修复中撤销)
  • 备注:完成于 QDCMS-v0.3.81 交付包之后,随下一版交付;如需回退,还原 video.js 操作栏模板与静音键创建段、video.css 两段规则、video-play.php 的 .agent-trigger 覆盖即可。

BUG-2026-0012:视频蒙版左上角未标注「当前视频总销售 Token 额度」(新增动态额度展示)

  • 修复日期:2026-09-29(首轮:沉浸式播放器;同日扩展:全站所有"视频 + Token 扣费"蒙版)
  • 修复文件:
  • 修改(首轮):video-play.php、assets/js/video.js、assets/css/video.css
  • 修改(全站推广):includes/token-functions.php、assets/css/style.css、moments.php、messages.php、 ajax-chat-history.php、drama-player.php、assets/im-plugin/modules/chat.js、assets/js/tab-card.js
  • 修改(视频封面蒙版):video.php、index.php、moments.php(列表卡片)
  • 修改(短剧封面蒙版):drama.php、index.php(首页短剧栏目)、drama-detail.php、assets/css/drama.css
  • 需求/根因分析:
  1. 未付费蒙版由 createTokenOverlay()(video.js:28-89)构建,仅含标题、单价、"确认播放"按钮、余额与错误行, 没有该视频销售数据的任何展示位——蒙版左上角为空白。
  2. 服务端同样没有可用的数据:video-play.php 的 VIDEO_LIST 只输出 token_price/original_price/is_repeat (video-play.php 原 63-84 行),未做任何按对象聚合,前端即使想显示也无值可用(故必须服务端动态聚合, 不能在前端写死或估算)。
  3. 数据源判定(只读核对):moments 表无额度/销量字段(database.sql 中 moments 仅 token_price), 无法从内容表取"销售额度";而 token_consumptions 表(database.sql:826-841)有 target_type/target_id/token_amount 且建了 idx_target(target_type,target_id) 索引, 是唯一能按视频聚合销售数据的表。两个扣费入口写入口径一致: video.js 扣费时 target_type='video'、target_id=moment_id(video.js:79-81), moments.php 详情页蒙版 data-target-type="video"(moments.php:716), 故 SUM(token_amount) WHERE target_type='video' AND target_id=视频ID 即为该视频累计销售额度。
  • 修复方案:
  1. 服务端聚合(避免 N+1):video-play.php 在取到本屏 $rows 后,用一条 SELECT target_id, COALESCE(SUM(token_amount),0) AS total_sales FROM token_consumptions WHERE target_type='video' AND target_id IN (?,…) GROUP BY target_id 批量聚合 (idx_target 命中,EXPLAIN type=ref),结果存入 $salesMap;每条视频在 $videos[] 中新增 total_sales 字段随 VIDEO_LIST 下发。聚合失败(如 token_consumptions 未建表)单独 try/catch 降级为 0, 不影响视频列表与播放流。
  2. 前端渲染:createTokenOverlay() 在蒙版内追加左上角徽标 .video-token-sales (position:absolute;top:58px;left:10px——蒙版铺满整屏,其几何左上角被 .video-player-header (z-index:30)的返回键占用(top:12~48px / left:16~52px),故下移到导航栏之下,仍在蒙版左上区域且不被遮挡; 徽标位于蒙版层内,故未付费时可见、付费后随蒙版一并移除);徽标内容 = 总销售 + Number(total_sales).toFixed(2) + ' TKN', 数值来自服务端下发的 data.total_sales,无任何硬编码;数值与文案用 createTextNode/textContent 写入 (非 innerHTML),且 total_sales 缺失或非法时回退 0.00 TKN。
  3. 样式:video.css 新增 .video-token-sales(半透明黑底 + 细边框 + 胶囊圆角,font-size:11px, 数字用 var(--warning) 金色以在 rgba(0,0,0,.7) 蒙版上可读),并在既有 @media (max-height:480px) 增补压缩规则(top:54px / 字号 10px),横屏/小窗下不与顶部导航争高。
  • 影响范围: PHP:video-play.php(新增一次只读聚合查询 + 列表字段 total_sales,无扣费/播放逻辑变更) Go:无 数据库:无表结构与数据变更(仅新增只读 SUM/ GROUP BY 查询,命中 idx_target) 前端:assets/js/video.js(createTokenOverlay 追加徽标)、assets/css/video.css(新增徽标样式与矮视口规则) 文档:docs/网站BUG修复.md
  • 验证结果:
验证项结果备注
PHP 语法检查PASSphp -l video-play.php:No syntax errors detected
JS 语法检查PASSnode --check assets/js/video.js(面板 node v24.21.0)
CSS 完整性PASSvideo.css 花括号 77/77 平衡
聚合 SQL 正确性(真实库只读)PASS实库:target_type 分布 video 4 笔/42.00 TKN;按 moments 真实视频 ID 聚合得 video#3=31.00、video#1=11.00(合计与总额一致);缺少消费记录的视频返回 0
聚合查询索引PASSEXPLAIN:table=token_consumptions, type=ref, key=idx_target, rows=4
徽标渲染(最小 DOM 桩加载真实 video.js,10 项断言全通过)PASS懒渲染的每个滑动项各带 1 枚徽标、均位于 .video-token-overlay 内、为蒙版首个子节点;文案 总销售 31.00 TKN/11.00 TKN 与服务端聚合值一致;缺失 total_sales 时降级 0.00 TKN 不抛错;点击徽标不误触发播放/暂停
徽标与顶部导航遮挡核对PASS依 CSS 几何:徽标 top:58px 已低于返回键底边 48px(.video-player-header padding 12px + 返回键 36px),矮视口为 top:54px 仍 > 48px;水平方向 left:10px 起,不与返回键(left:16~52px)在垂直方向重叠
线上可达性PASS/video-play.php 302 → login(未登录预期);assets/css/video.css、assets/js/video.js HTTP 200(assetVersion 按 mtime 破缓存)
浏览器/真机视觉核验未执行播放页需登录态验证,无可用凭据;徽标字号/配色未做真机截图核验
  • 关联 BUG: BUG-2026-0011(同一播放器蒙版/操作栏区域,避免新增元素与底部控件、悬浮球争位;徽标置于蒙版顶部,与 BUG-2026-0001 的悬浮球区域无重叠)
  • 备注:
  1. 口径确认:本修复按"该视频累计已售出(被消费)的 Token 合计"实现(含重复观看的平台流量费)。若管理者本意是 "后台为某视频配置的销售额度上限",则需在 moments 表新增额度字段并做后台录入,请告知后另行处理。
  2. 完成于 QDCMS-v0.3.81 交付包之后,随下一版交付;如需回退,删除 video-play.php 的 $salesMap 聚合段与 'total_sales' 字段、video.js 的 salesBadge 段、video.css 的 .video-token-sales 段即可。

全站推广(同一 BUG 的范围扩展)

  • 需求:把「蒙版左上角显示当前视频总销售 Token 额度」由沉浸式播放器推广到全站所有「视频 + Token 扣费」蒙版。
  • 蒙版清单(定案 5 个入口,均落地;小说章节锁为文本解锁、.chat-video-wrap 已付费分支无蒙版,明确跳过):
#入口target_type徽标位置聚合方式
1video-play.php 沉浸式播放器video蒙版左上 .video-token-sales(top:58px,避开顶部返回键)getTargetSalesMap('video', 本屏全部视频ID) → 随 VIDEO_LIST 下发 total_sales
2moments.php 动态详情视频蒙版video.moment-video-overlay 首子节点getTargetSalesTotal('video', moment_id)(仅详情模式查询,列表模式跳过)
3messages.php 私聊视频蒙版chat_video.chat-video-overlay 内左上(紧凑版)getTargetSalesMap('chat_video', 本屏全部视频消息ID),服务端渲染与 JS 渲染两条路径共用
4ajax-chat-history.php + im-plugin 付费卡片chat_video付费卡片「付费视频」上方同 #3 聚合结果写入每条消息 _total_sales,im-plugin chat.js 读取 msg._total_sales
5drama-player.php 短剧播放器蒙版drama#tokenOverlay 首子节点getTargetSalesTotal('drama', drama_id, episode_id)
  • 新增公共接口(includes/token-functions.php):
  1. getTargetSalesMap(string $targetType, array $targetIds, ?int $episodeId = null): array —— 去重并剔除非正整数后单条 IN 查询 + GROUP BY target_id,返回 [target_id => float]; 聚合失败(如未建表)try/catch 捕获、error_log 后返回 [],不影响页面其余渲染。
  2. getTargetSalesTotal(string $targetType, int $targetId, ?int $episodeId = null): float —— 单个对象合计,非法 id 返回 0.0。
  3. 两者均不做静态缓存,每次渲染取最新额度。
  • 样式统一:全站外观类收敛到 assets/css/style.css 的 .token-sales-badge(胶囊 + 半透明黑底 + 金色数值 var(--warning,#fdcb6e))与紧凑版 .token-sales-badge-sm(聊天窄卡片用,10px/2px 7px);各页面只负责定位, 不再各自定义外观。video.css 的 .video-token-sales 收敛为仅定位(absolute + top/left)。
  • im-plugin 资源变更后按约定递增 assets/js/tab-card.js 的 IM_BUILD:'20260929-4' → '20260929-5'。
  • 口径(与首轮一致):额度 = token_consumptions 中按 (target_type, target_id) 汇总的 token_amount 合计 (累计已消费,含重复观看的平台流量费),非后台配置的额度上限。
  • 短剧口径备注:drama 当前按本集聚合(target_type='drama' + drama_id + episode_id);若需改为"整剧合计", 移除 getTargetSalesTotal('drama', $dramaId, $episodeId) 的第三参(episode_id 一并从 WHERE 中去除)即可。
  • 验证结果(全站推广部分,复验时 video#3 因新增消费已由 31.00 增至 32.00,drama#1 各集 80.00~82.00):
验证项结果备注
PHP 语法检查PASSphp -l:token-functions.php、video-play.php、moments.php、messages.php、ajax-chat-history.php、drama-player.php
JS 语法检查PASSnode --check:assets/js/video.js、assets/js/tab-card.js、assets/im-plugin/modules/chat.js(面板 node v24.21.0)
CSS 完整性PASSstyle.css 花括号 194/194、video.css 75/75 平衡
聚合 helper 正确性(真实库只读)PASS各 target_type 的 map 合计 == 直查合计(video#1=11.00、video#3=32.00、chat_video#2=10.00);drama 按 (drama_id, episode_id) 逐集一致;边界(空数组、id=0/-5、重复 id、字符串 id、未知 type)安全降级;EXPLAIN 命中 idx_target
沉浸式播放器徽标渲染PASS最小 DOM 桩加载真实 video.js,17 项断言全通过:类名、位于 .video-token-overlay 首子节点、文案「总销售 31.00 TKN」、邻居项各自取数、字段缺失/负数/非数字降级 0.00、字符串 "12.5" → 12.50、徽标不可点击、videoTrack 缺失静默退出、空列表不抛错
video-play 服务端下发PASSCLI 渲染:VIDEO_LIST 中 id=3 的 total_sales=32、推荐项 id=1 为 11,与库内聚合一致
moments 详情蒙版PASSCLI 渲染 moments.php?id=3:.moment-video-overlay 内含 1 枚 token-sales-badge,值 32.00 TKN(与库内一致)
messages.php 私聊蒙版(服务端)PASS事务包裹渲染后回滚(渲染前后 chat_messages 未读计数均为 0,无写入生效):发送者/已付费消息渲染 <video> 播放器且无徽标;未付费接收者消息渲染 .chat-video-overlay 且徽标值 0.00 TKN(该消息无消费记录,与库一致);JS 模板路径同样含徽标
ajax-chat-history.php / im-plugin 数据链路PASS以 before_id(仅读模式,不触发已读写入)请求 JSON:video 消息携带 _total_sales(msg#2=10、msg#3=0,与库内一致);im-plugin chat.js 付费卡片分支读取 msg._total_sales 渲染同一徽标
drama-player 渲染PASSCLI 渲染 drama_id=1&ep=1 成功、无错误:#tokenOverlay 内含徽标,值 82.00 TKN(与库内 drama#1 第 1 集聚合一致)
静态交叉核对PASS5 个入口均含 .token-sales-badge 标记;badge 各 class 均在 style.css 定义;全站无 video-token-sales-label/value 残留
线上可达性PASSstyle.css / video.css / video.js / im-plugin chat.js HTTP 200 且含新类;tab-card.js HTTP 200 且 IM_BUILD='20260929-5'
浏览器/真机视觉核验未执行全站蒙版均需登录态,无可用凭据;聊天窄卡片与短剧蒙版中徽标的实际观感未做真机截图核验
  • 备注:全站推广部分同样完成于 QDCMS-v0.3.81 交付包之后;回退时按入口逐处撤销(各页面的徽标节点 + 定位样式、 token-functions.php 的两个 helper、style.css 的徽标外观类、tab-card.js 的 IM_BUILD 回退)。

视频封面蒙版(列表卡片封面)

  • 需求:在"视频封面蒙版"(列表页视频卡片/封面遮罩)上也展示该视频的累计销售额度。
  • 覆盖入口(全站视频封面共 3 处,均落地;post-video.php 为发布预览页、非封面列表,不含):
#入口徽标位置聚合方式
6video.php 短视频列表卡片封面.video-card-thumb 左上 top:6px;left:6px;z-index:2(紧凑版)getTargetSalesMap('video', 本页全部视频ID) → total_sales
7index.php 首页短视频卡片封面同上同上($newRows 全部卡片一次聚合)
8moments.php 动态列表视频封面遮罩封面遮罩层内左上 top:4px;left:4px(紧凑版)由调用方 getTargetSalesMap('video', 本页 items 的 id) 预取,经 renderMomentCard() 第 4 参传入
  • 实现要点:
  1. renderMomentCard($moment, $currentUser, $videoPlayable = 'list', $salesMap = null) 新增可选第 4 参: detail 模式仍按单条 getTargetSalesTotal() 聚合;list 模式从预取 $salesMap 取值, 调用方未传时降级为 0(不在函数内逐条查询,避免列表页 N+1)。
  2. 三个入口均复用全站外观类 .token-sales-badge + 紧凑版 .token-sales-badge-sm(style.css), 仅用内联样式定位,未新增 CSS 类;封面上的价格徽章(.video-card-duration / 右下 TKN 标签)位置不变。
  3. 与卡片现有元素不冲突:播放图标居中、价格徽章在右下,徽标置于左上,互不遮挡。
  • 验证结果:
验证项结果备注
PHP 语法检查PASSphp -l video.php、php -l index.php、php -l moments.php
video.php 列表卡片封面PASS登录态 CLI 渲染:2 张卡片各 1 枚封面徽标,值 32.00 TKN / 11.00 TKN,与库内聚合(video#3=32.00、video#1=11.00)一致
index.php 首页卡片封面PASS登录态 CLI 渲染:2 张卡片各 1 枚封面徽标,值 32.00 TKN / 11.00 TKN,同样与库内一致
moments.php 列表封面遮罩PASS登录态 CLI 渲染:2 个视频网格项各 1 枚遮罩内徽标,值 32.00 TKN / 11.00 TKN(与右侧价格标签 30.00 / 10.00 TKN 为不同语义,互不覆盖)
moments.php 详情蒙版回归PASSmoments.php?id=3:徽标仍为 1 枚、值 32.00 TKN、定位仍为 top:10px;left:10px(list 参数变更未影响 detail 分支)
浏览器/真机视觉核验未执行列表页需登录态,无可用凭据;封面左上徽标与价格标签在小屏卡片上的观感未做真机截图核验
  • 备注:与全站推广同批交付;回退时删除 video.php / index.php 的 $videoSalesMap 聚合与 total_sales 字段及 对应徽标节点、moments.php 的 $listSalesMap 预取与 renderMomentCard() 第 4 参及列表徽标节点即可。

短剧封面蒙版(列表卡片封面 / 详情页主图)

  • 需求:在"短剧封面蒙版"(短剧列表卡片、首页短剧栏目卡片、详情页主图封面)上也展示该短剧的累计销售额度。
  • 覆盖入口(全站短剧封面共 3 处,均落地;drama-player.php 沉浸式蒙版已在 #5 落地):
#入口徽标位置聚合方式
9drama.php 短剧列表卡片封面.drama-card-thumb 左上 top:6px;left:6px;z-index:2(紧凑版)getTargetSalesMap('drama', 本页全部短剧ID) → total_sales
10index.php 首页短剧栏目卡片封面同上同上
11drama-detail.php 详情页主图封面.drama-detail-cover 左上(同上定位)getTargetSalesTotal('drama', drama_id)(单条,无 episode_id → 整剧合计)
  • 聚合口径:整剧合计——getTargetSalesMap('drama', $ids) 不传第三参(episode_id), 即按 target_type='drama' + target_id=drama_id 汇总该剧全部已消费 Token(含各集、重复观看), 与封面代表"整部短剧"的语义一致(区别于 #5 播放器蒙版的按本集口径)。 若需改为按某一集统计,传入 episode_id 即可(单集封面场景)。
  • 实现要点:
  1. drama.php:取回 $rows 后新增 $dramaSalesMap = getTargetSalesMap('drama', array_column($rows,'id')), $dramas[] 增加 'total_sales';徽标插入在 .drama-card-thumb 内、右下"TKN/集"价格标签之前 (左上 vs 右下,互不遮挡)。
  2. index.php:$hotDramas 取回后同样一次 IN 聚合为 $hotDramaSalesMap,逐行写入 total_sales; 模板在 .drama-card-thumb 内、价格标签前插入同款徽标。
  3. 三处均复用全站外观类 .token-sales-badge + 紧凑版 .token-sales-badge-sm(style.css),仅内联定位, 未新增 CSS 类;短剧列表/详情对游客开放,故徽标对未登录用户亦可见(聚合值为公开的累计销售,非敏感数据)。
  4. drama-detail.php:$drama 校验通过后新增 $dramaSalesTotal = getTargetSalesTotal('drama', $dramaId), 徽标置于 .drama-detail-cover 内;该容器原无定位上下文,故在 assets/css/drama.css 的 .drama-detail-cover 补 position: relative(新增声明,未改动其余属性;该类仅此一处使用,且容器内 原无绝对定位子元素,不会影响既有布局)。
  • 验证结果:
验证项结果备注
PHP 语法检查PASSphp -l drama.php、php -l index.php、php -l drama-detail.php:No syntax errors detected
CSS 完整性PASSdrama.css 花括号 75/75 平衡
drama.php 列表卡片封面PASSCLI 渲染:1 张短剧卡片(「萌宝吖小萌宝」)含 1 枚封面徽标,值 643.00 TKN,与库内 drama#1 全部剧集聚合合计一致
index.php 首页短剧栏目PASSCLI 渲染 drama-section:1 张卡片含 1 枚徽标,值 643.00 TKN;同项右下价格标签仍为 80.00 TKN/集,二者互不覆盖
drama-detail.php 主图封面PASS事务包裹渲染后回滚(渲染前后 short_drama.view_count 均为 8,页面自增未生效):.drama-detail-cover 内含 1 枚徽标,值 643.00 TKN
聚合口径核对PASS整剧合计 643.00 = 各集 82+80+81+80+80+80+80+80;未传 episode_id,确实跨集汇总
浏览器/真机视觉核验未执行左侧徽标与右下价格标签在窄卡片/居中主图上的观感未做真机截图核验
  • 备注:短剧封面部分同批交付;回退时删除 drama.php 的 $dramaSalesMap 聚合与 total_sales 字段及徽标节点、 index.php 的 $hotDramaSalesMap 聚合与逐行赋值及徽标节点、drama-detail.php 的 $dramaSalesTotal 与徽标节点、 drama.css 的 position: relative 即可(聚合口径变更只需增删 episode_id 第三参)。

BUG-2026-0013:动态详情页短视频在"已消费"状态下无蒙版覆盖(可无蒙版直连播放,绕过重复观看费)

  • 修复日期:2026-09-29
  • 修复文件:
  • 修改:moments.php(renderMomentCard() 详情页视频分支合并)
  • 修改:docs/网站BUG修复.md(本记录)
  • 需求/根因分析:
  1. 动态详情页 moments.php?id=N 由第 138 行决定播放模式: renderMomentCard($moment, $currentUser, $alreadyConsumed ? true : 'detail') ($alreadyConsumed 于第 110-117 行由 hasConsumed($viewerId,'video',$momentId) 判定)。
  2. renderMomentCard() 中 $videoPlayable === true(已消费)分支(原 715-725 行)直接输出 <video controls autoplay preload="metadata"> + <source src="video-stream.php?id=..&tk=..">, 既不渲染 .moment-video-overlay 蒙版,也不渲染 .token-sales-badge 徽标 —— 只有 'detail'(未消费)分支才有蒙版(原 726-746 行)。故已消费用户在详情页看到的是 无蒙版、无徽标、自动播放的视频,与页面其他视频入口(video.php 卡片、动态列表封面)不一致。
  3. 同时该分支构成计费绕过:服务端对短视频重复观看按 1 TKN 计费 (ajax-token-deduct.php 第 134-140 行 $repeatViewFee = 1.0,target_type='video' 走该分支), 沉浸式播放器也以 is_repeat 展示「重复观看费 / 平台流量/运营费用 1.00 TKN」蒙版 (video-play.php 第 59/93 行 + assets/js/video.js 第 55-65 行), 唯独动态详情页的已消费分支不扣费、不限流,成为重复观看的免费入口。
  4. 真实库只读核对(为何"看不到蒙版"):全站仅两条含视频的动态 (moment#1「价格 10.00」、moment#3「价格 30.00」,作者均为 user 2「秀才」), 而 token_consumptions 中 video 消费记录全部来自 user 2 本人 (video#1 = 10.00 + 1.00、video#3 = 30.00 + 1.00 + 1.00), 即用该已登录账号(同时是作者)打开详情页必然命中 $videoPlayable === true 分支 → 无蒙版。 未登录/未消费用户(如 user 1「站长」)此前已有蒙版,与本报告现象一致。
  • 修复方案:
  1. 详情页两个分支合并为统一的「封面 + Token 蒙版」结构,条件改为 $videoPlayable === true || $videoPlayable === 'detail'(仍由第 138 行传入), 即无论是否已消费,详情页视频始终被蒙版覆盖。
  2. 价格按消费状态计算:$isRepeatView = ($videoPlayable === true), $chargePrice = $isRepeatView ? 1.0 : $momentPrice(1.0 与 ajax-token-deduct.php 的 $repeatViewFee、video-play.php 的 $displayPrice 同一口径;前端仅作展示, 实际扣费金额由服务端按 moments.token_price / 重复观看费重新计算)。 data-price 同步改为 $chargePrice,蒙版标题与说明文案按 $isRepeatView 切换为 「重复观看费 / 平台流量/运营费用 X TKN」或「Token 消费 / 观看此视频需消耗 X TKN」, 与 video.js 的 is_repeat 分支措辞一致。
  3. 视频元素统一为 controls preload="none" poster=封面 data-stream-src=video-stream.php?id=N 且不再预下发源文件地址与签名令牌(已消费分支此前直接输出 <source src=...&tk=...>, 现改为扣费成功后由页面 JS 挂载流代理地址),既统一了两种状态的行为,也避免了源地址提前暴露; 保留 controls,使付费后仍有完整播放控件(原未消费分支缺少 controls,属于同一处顺带修正)。
  4. $momentSales(蒙版左上「总销售」徽标数据)聚合条件由 $videoPlayable === 'detail' 放宽为 'detail' || true,使两种详情状态都显示该视频的累计销售额度; list 模式仍复用调用方预取的 $salesMap(不新增 N+1 查询)。
  5. 详情页扣费脚本(moments.php 第 194-299 行)未改动:其逻辑本就是 「扣费成功 → 移除蒙版 → 若视频无 src/source 则挂载 data-stream-src 并播放」, 与合并后的统一结构完全匹配;未扣费时的 play 事件拦截继续生效。
  • 影响范围: PHP:moments.php(renderMomentCard() 视频分支;仅渲染逻辑,无表结构/数据写入变更) Go:无 数据库:无表结构与数据变更(详情页仍为 1 次只读 SUM 聚合,命中 idx_target) 前端:动态详情页视频播放区(蒙版统一 + controls 属性),无 CSS/JS 文件变更,无需破缓存 文档:docs/网站BUG修复.md
  • 验证结果:
验证项结果备注
PHP 语法检查PASSphp -l moments.php:No syntax errors detected
三登录态渲染对比(moments.php?id=3)PASSCLI 真实渲染:游客 / user1(未消费)/ user2(已消费)均含 1 个 .moment-video-overlay + moment-confirm-play-btn + token-sales-badge;三态均无 autoplay、无预下发 <source src=
蒙版价格与文案(id=3,价格 30.00)PASS游客与 user1:data-price="30.00" /「观看此视频需消耗 30.00 TKN」;user2(已消费):data-price="1.00" /「平台流量/运营费用 1.00 TKN」+ 标题「重复观看费」,与 video.js is_repeat 文案一致
蒙版价格与文案(id=1,价格 10.00)PASS游客与 user1:data-price="10.00";user2:data-price="1.00" /「重复观看费」,与 video#1 的默认价/重复费口径一致
总销售徽标取值PASS同上两态渲染:id=3 徽标 32.00 TKN、id=1 徽标 11.00 TKN,与库内 token_consumptions 聚合一致;已消费态同样显示(此前无徽标)
播放控件保留PASS三态渲染的视频元素均含 controls preload="none"(已消费态由 controls autoplay 改为统一结构,付费后仍有控件,由 JS 挂载流地址后 play())
列表模式回归PASSmoments.php(列表,游客):视频网格项仍走封面遮罩分支(0 个 .moment-video-overlay),2 枚封面徽标值 32.00 / 11.00 TKN,与修复前一致
重复观看费口径核对(只读)PASSajax-token-deduct.php 第 134-140 行对 target_type='video' 且已消费的请求强制 $price = 1.0(前端 price 仅参考);库内 video#1/video#3 的重复观看记录余额确为 1.00/笔
线上可达性PASScurl -H "Host: 39.96.90.85" /moments.php?id=3 → HTTP 200,游客响应体含 2 处 moment-video-overlay 标记
浏览器/真机视觉核验未执行已登录点击「确认播放」会真实扣费 1 TKN,且无可用浏览器登录凭据,故未做端到端点击与截图核验
  • 关联 BUG: BUG-2026-0012(同一蒙版/徽标体系:本修复使详情页已消费态也显示「总销售」徽标)、 BUG-2026-0011(同属视频蒙版/播放器区域)
  • 备注:
  1. 行为变更说明:已消费用户再次在动态详情页观看同一视频,现需支付 1 TKN 平台服务费 (此前为无蒙版免费直连播放)。该收费口径并非新增规则,而是与沉浸式播放器 video-play.php / 服务端 ajax-token-deduct.php 既有重复观看规则对齐。 若产品意图是"详情页重复观看免费",则应改为"蒙版仅作封面遮罩、点击直接播放不扣费", 并在 renderMomentCard() 里跳过扣费脚本即可,请告知后调整。
  2. 完成于 QDCMS-v0.3.81 交付包之后,随下一版交付;如需回退,还原 renderMomentCard() 的 if ($videoPlayable === true) { … } elseif ($videoPlayable === 'detail') { … } 两分支结构 及 $momentSales 的判定条件即可。

BUG-2026-0014:后台 Token 数据面板「账户余额明细」标题与查询排序不符(标题称按未充值时长,实际按余额)+ 过时口径说明修正

  • 修复日期:2026-09-29
  • 修复文件:
  • 修改:admin/token-dashboard.php(账户余额明细标题、页面底部「统计口径」说明)
  • 修改:docs/网站BUG修复.md(本记录)
  • 根因分析:
  1. 账户余额明细表的数据查询(token-dashboard.php 第 209 行)为 ORDER BY u.token_balance DESC, u.id ASC,即按账户余额降序、余额相同按 id 升序; 而表头标题却写「账户余额明细(按未充值时长降序,最多 50 条)」,与实际排序不符, 会误导管理员以为列表按休眠时长排列。
  2. 该页「统计口径」说明沿用旧口径:原有一项写着「超期一年应过期额度……(系统当前未执行扣减)」, 在本版过期扣减 / 充值返还引擎落地后已过时——系统现在会真实扣减并在该用户新充值订单 审核通过时自动返还,旧文案会让管理员误以为过期额仍只是「应过期」的统计值。
  • 修复方案:
  1. 标题改为「账户余额明细(按账户余额降序,最多 50 条)」,与第 209 行查询一致。
  2. 「统计口径」列表将过时的「超期一年应过期额度……未执行扣减」一条替换为与引擎一致的说明: 「超期过期额度」(休眠满阈值账户被扣减的全部余留余额,逐户写入 token_expirations,仅显式触发扣减)、 「回归返还额度」(该用户新充值订单审核通过时自动全额返还)、 「对账恒等式」(总充值 − 已使用 − 累计已过期 + 已返还 = 有效额度)。
  • 影响范围: PHP:admin/token-dashboard.php(仅展示文案,无 SQL / 数据结构 / 数据写入变更) 数据库:无 前端:后台 Token 数据面板(文案,无需破缓存) 文档:docs/网站BUG修复.md
  • 验证结果:
验证项结果备注
PHP 语法检查PASSphp -l admin/token-dashboard.php:No syntax errors detected
标题与排序一致性PASS标题已为「按账户余额降序」,与第 209 行 ORDER BY u.token_balance DESC 一致
旧口径文案清除PASS全文已无「未执行扣减」字样;口径列表含过期 / 返还 / 对账恒等式三条
页面渲染回归PASSCLI 驱动渲染(get / 规则保存 / 结算预览 / 结算执行 / 错误 CSRF)均无 Fatal,结果与修复前一致,浏览与结算均未改余额与流水
浏览器视觉核验未执行需管理员凭据,未做真机截图核验
  • 关联 BUG:无
  • 备注:随 v0.3.82(后台 Token 数据面板 + 过期扣减 / 充值返还引擎)同一批交付。

FEAT-2026-0002:作者中心顶部导航条精简为「头像 + 昵称」

  • 记录日期:2026-09-30
  • 类型:界面变更(按需求调整,非 BUG 修复,仍按本文档第二、三节流程验证)
  • 变更文件:
  • 修改:author/header.php(第 46-63 行 .header-right 区块)
  • 文档:docs/网站BUG修复.md(本记录)
  • 变更点:
  1. 作者中心顶栏右侧只保留「头像 + 昵称」:移除「返回前台」链接、用户名旁验证徽章 (verifiedBadge() / renderVerificationBadges())、isAdmin() 条件下的「管理后台」链接、「退出」链接。
  2. 头像复用全站容器 .user-avatar(user_number 落在 10000-999999 时追加 vip-active 靓号流光描边,与前台 includes/header.php 同规则);avatar 为空时不输出 <img>, 与前台顶栏降级行为一致。
  3. 头像地址用 baseUrl() . avatar 拼接(baseUrl() 对 author 目录自动回退到站点根, 兼容子目录安装);昵称取 nickname ?: username,与个人主页 profile.php 展示口径一致 (对 role=9 亦只显示昵称,不外泄真实用户名)。
  • 影响范围: PHP:author/header.php(仅模板标记;无 SQL、无守卫/权限逻辑变更,requireLogin() 与作者统计查询不变) 数据库 / Go / nginx / 前端资源:无(未改 CSS/JS,昵称沿用原有行内样式,头像沿用既有类)
  • 验证结果:
验证项结果备注
PHP 语法检查PASSphp -l author/header.php:No syntax errors detected
顶栏渲染(role=0,user#2 秀才)PASS真实会话访问 author/dashboard.php → HTTP 200,.header-right 内仅 1 个 .user-menu(span.user-avatar.vip-active + img + 昵称「秀才」)
顶栏渲染(role=9,user#1 站长)PASS同上 → HTTP 200,显示昵称「站长」(非真实用户名 origin),无管理后台/退出链接
冗余元素清除PASS两态响应体中已无「返回前台 / 管理后台 / 退出 / badge-verified」字样(含侧栏、页脚)
头像可达性PASS响应中头像为 /uploads/avatars/2026/09/…,实测该 URL curl → HTTP 200 image/png
访问守卫回归PASS访客仍 302 → login.php(未登录不受本次改动影响)
浏览器视觉核验未执行无浏览器 Worker,仅以真实 HTTP 响应体核对标记与样式类
  • 关联 BUG:无(前置改动见 BUG-2026-0008)
  • 备注:
  1. 作者中心顶栏不再提供「退出登录 / 返回前台 / 管理后台」入口,全站仍可退出:站点 Logo 仍指向 ../index.php,前台导航下拉含「退出登录」「个人资料」「作者中心」; 若需在作者中心内直达个人中心,可把该「头像 + 昵称」改为指向 ../profile.php 的链接(未实施,待确认)。
  2. 未改动 includes/header.php(前台顶栏)、admin/header.php(后台顶栏)。
  3. 未提升 CMS_VERSION,随下一版交付包发布。

BUG-2026-0015:作者中心「发布小说」提交后返回「页面遇到系统异常」(activity_subscriptions 缺 3 个通知字段,动态订阅通知写入抛 PDO 致命错误)

  • 修复日期:2026-09-30
  • 修复文件:
  • 修改:database.sql(activity_subscriptions 建表语句补 3 列)
  • 文档:docs/网站BUG修复.md(本记录)
  • 文档:docs/版本升级说明.md(数据表变更登记)
  • 根因分析:
  1. includes/fn-activity.php 的 notifyActivityToSubscribers() 在内容发布后向订阅者推送动态通知: 读取订阅行时 SELECT id, user_id, target_id, target_type, notify_count(第 106 行), 在线推送分支 UPDATE activity_subscriptions SET notify_count = notify_count + 1, last_notify_at = NOW(), last_content = ?(约第 147 行), getPendingActivityNotifications() 亦 SELECT s.*;三处均依赖 notify_count / last_notify_at / last_content 三列。
  2. 该函数由四条发布链路在内容写库后调用: author/novel-edit.php:67、author/chapter-edit.php:80、admin/novel-edit.php:62、admin/chapter-edit.php:51。
  3. 但随包发布的 database.sql 中 activity_subscriptions 建表语句只有 id / user_id / target_type / target_id / created_at,缺少上述 3 列 —— 代码与建表脚本不一致(schema drift)。
  4. 现象:小说主记录 INSERT 已成功,紧接着通知查询抛 PDOException: Unknown column 'notify_count' in 'field list' 且未被捕获, 经全站异常处理器输出「页面遇到系统异常」HTTP 500 —— 用户看到「发布失败」,实际数据已入库,重复提交会产生重复小说。 错误日志(/www/wwwlogs/39.96.90.85.error.log)调用链: includes/db.php:157 ← includes/fn-activity.php:106 ← author/novel-edit.php:67。
  • 修复方案:
  1. 在 database.sql 的 activity_subscriptions 中 target_id 之后补齐 3 列(口径与 fn-activity.php 用法一致):
  • notify_count TINYINT UNSIGNED NOT NULL DEFAULT 0(在线 Agent 推送计数,上限 3 次)
  • last_notify_at TIMESTAMP NULL DEFAULT NULL(最近一次通知时间)
  • last_content VARCHAR(255) DEFAULT NULL(最近一次动态内容快照)
  1. 存量站点执行面板「数据库结构同步」(includes/DbUpdater.php::run() 按 database.sql 差量补列,幂等,仅加列、不动既有数据)。
  • 影响范围: PHP:database.sql(建表脚本);includes/fn-activity.php 业务逻辑未改动 数据库:activity_subscriptions 新增 3 列(存量行与数据全部保留) 前端 / Go / nginx:无 文档:docs/网站BUG修复.md、docs/版本升级说明.md
  • 验证结果:
验证项结果备注
修复前复现PASS修复前以 user#2 真实会话 POST author/novel-edit.php → HTTP 500「页面遇到系统异常」;日志 Unknown column 'notify_count' in 'field list'
表结构确认PASSSHOW COLUMNS FROM activity_subscriptions 现含 notify_count(tinyint unsigned, NOT NULL, DEFAULT 0) / last_notify_at(timestamp NULL) / last_content(varchar(255) NULL)
迁移审计PASSupgrade_logs id=17(version 1.5, status success)记录 ["添加字段 activity_subscriptions.notify_count","添加字段 activity_subscriptions.last_notify_at","添加字段 activity_subscriptions.last_content"];迁移后再比对 has_diff=false
存量数据保留PASS迁移前后 activity_subscriptions 均为 2 行;notify_count=0、last_notify_at/last_content=NULL
发布小说(真实 HTTP)PASSuser#2 会话 POST author/novel-edit.php → 302 → /author/chapters.php?novel_id=N&msg=created(不再 500);落地页 200 且含 alert-success
发布章节(作者端)PASSuser#2 POST author/chapter-edit.php?novel_id=N → 302 msg=created
发布章节(后台)PASSuser#1(role=9)POST admin/chapter-edit.php?novel_id=N → 302 msg=saved
订阅通知副作用PASS发布后按订阅关系生成站内信(messages 表,sender_id=0/related_type=activity),全程无异常
权限守卫回归PASS非管理员(user#2)访问 admin/chapters.php 仍 302(未因本次改动放宽)
验证数据清理PASS验证用小说/章节/站内信已删除,站点恢复原始数据
  • 关联 BUG:BUG-2026-0016(同一发布链路上的第二个致命错误)
  • 备注:
  1. activity_subscriptions 为动态订阅(好友/作者动态)通知表;本次仅补列,未改通知业务规则 (在线 Agent 推送上限 3 次、离线发送站内信)。
  2. 未提升 CMS_VERSION(维持 v0.3.81),随下一版交付包发布;存量站点由在线更新 / 后台「数据库结构同步」自动补列。
  3. 加固建议(未实施):notifyActivityToSubscribers() 目前异常会中断整条发布链路(内容已入库但响应 500)。 若需容忍通知失败,可在四处调用点或函数内加 try/catch 并记录日志,避免「数据已写入但用户看到失败」。待确认后再改。

BUG-2026-0016:作者/后台「保存章节」调用未定义的 countWords() 导致 HTTP 500(章节字数统计致命错误)

  • 修复日期:2026-09-30
  • 修复文件:
  • 修改:includes/fn-novel.php(新增 countWords())
  • 文档:docs/网站BUG修复.md(本记录)
  • 根因分析:
  1. author/chapter-edit.php:59 与 admin/chapter-edit.php:40 在保存章节前调用 countWords($content) 计算并写入 chapters.word_count; 但全站并无该函数定义(includes/ 各模块均无),调用即 Error: Call to undefined function countWords()。
  2. 该错误与 BUG-2026-0015 同处「发布/保存内容」链路,且在小说/章节记录写完之后才触发, 同样表现为「页面遇到系统异常」HTTP 500(数据已入库、用户看到失败)。
  3. 编辑器前端另有 JS 计数 value.replace(/\s+/g,'').length,后端缺少对应实现,需保证前后端口径一致。
  • 修复方案: 在 includes/fn-novel.php 新增 countWords($text): 先以 /[\s\x{00A0}\x{3000}]+/u 去除空白(普通空白 + 不换行空格 U+00A0 + 全角空格 U+3000), 再取 mb_strlen($stripped, 'UTF-8');当输入非法 UTF-8 使 preg_replace 返回 null 时,降级为对原文计数,保证发布不中断。 与编辑器 JS 的「去空白计字符数」口径一致。函数经 includes/functions.php 的模块加载清单生效。
  • 影响范围: PHP:includes/fn-novel.php(新增函数,无既有调用签名变更) 数据库 / 前端资源 / Go / nginx:无
  • 验证结果:
验证项结果备注
修复前复现PASS修复前保存章节 → HTTP 500「页面遇到系统异常」;日志 Call to undefined function countWords()
PHP 语法检查PASSphp -l includes/fn-novel.php:No syntax errors detected
计数:中文含空格PASS内容「你好 世界,这是 一个 测试章节内容。」→ word_count=16,与编辑器去空白口径一致
计数:ASCIIPASS「abc def」→ 6
计数:全角/不换行空格PASS「 全角 空格 」(U+3000 包围)→ 4
非法 UTF-8 容错PASS传入含 \xff\xfe 的非法序列 → 返回 6(降级计数),无 Warning / 无 Fatal
章节聚合字数PASS同一小说两章分别落库 16 / 9,novels.word_count=25(等于各章之和)
作者端保存PASSPOST author/chapter-edit.php?novel_id=N → 302 msg=created,落地页 200 含 alert-success
后台保存PASSPOST admin/chapter-edit.php?novel_id=N → 302 msg=saved
验证数据清理PASS验证用小说/章节/站内信已删除
  • 关联 BUG:BUG-2026-0015(同一发布链路上的第一个致命错误,先补列后暴露本错误)
  • 备注:
  1. 计数口径为「去除全部空白后的字符数(含标点)」,与小说按字数计费模块(includes/fn-novel.php 计费段)使用的 word_count 同源; 若产品希望改为「仅统计非标点字符」或「中文按字、英文按词」,需统一调整本函数与编辑器 JS 两处口径。
  2. 未提升 CMS_VERSION(维持 v0.3.81),随下一版交付包发布。

BUG-2026-0017:章节发布失败(保存后静默丢弃)+ 章节正文编辑器升级为多功能富文本编辑器

  • 修复日期:2026-09-30
  • 修复文件:
  • 修改:author/chapter-edit.php(表单 action 补全查询串;保存守卫兼容表单隐藏字段;接入新编辑器)
  • 修改:admin/chapter-edit.php(保存守卫兼容表单隐藏字段;接入新编辑器)
  • 修改:includes/fn-novel.php(新增章节 HTML 白名单净化与正文渲染:chapterAllowedTags()、chapterAttrValue()、chapterMediaSrcOk()、renderChapterHtml()、chapterContentIsEmpty()、renderChapterBody())
  • 新增:ajax-novel-image.php(编辑器图片上传接口)
  • 新增:assets/js/novel-editor.js(零依赖渐进增强编辑器,IIFE 暴露 window.NovelEditor)
  • 新增:assets/css/novel-editor.css(编辑器样式,适配本站浅色主题)
  • 文档:docs/网站BUG修复.md(本记录)
  • 根因分析:
  1. author/chapter-edit.php 原表单为 <form method="POST" action="chapter-edit.php">:相对 action 会让浏览器丢开当前 URL 的查询串,提交到 /author/chapter-edit.php(无 ?novel_id=…&id=…)。
  2. 而处理分支的守卫只读 $_GET($chapterId = (int)($_GET['id'] ?? 0)、$novelId = (int)($_GET['novel_id'] ?? 0)), 两者均为 0 → 命中 redirect('chapters.php'),章节内容被静默丢弃:用户只看到跳回列表,无任何错误提示, 章节数与小说字数都不变(由此表现为"发布成功但章节没了/发布失败")。
  3. admin/chapter-edit.php 的 action="chapter-edit.php" 同样裸相对路径,且守卫只读 $_GET,存在同类隐患。
  4. 编辑器方面:正文 textarea 原为纯文本,不支持加粗/引用/列表/插图;渲染端直接 nl2br(e($content)), 因此若要放开 HTML 又必须有服务端白名单净化,否则等于开放 XSS。
  • 修复方案:
  1. 表单与守卫对齐:作者端表单改为 action="chapter-edit.php?novel_id=<id>&amp;id=<章节ID>"; 同时两处守卫改为 GET/POST 双取(作者端 $_GET['id'] ?? $_POST['chapter_id']、$_GET['novel_id'] ?? $_POST['novel_id']; 后台端 $_POST['id'] / $_POST['novel_id']),使「带查询串」与「仅带隐藏字段」两种提交形态都能正确保存。
  2. 正文净化(renderChapterHtml()):先用正则匹配正文中所有标签,逐个校验;命中白名单者不原样透传属性, 而是按白名单取值重建标签(<p>/<h1>-<h6>/<strong>/<em>/<blockquote>/<ul><li>/<code><pre>/<a href>/<img src,class>/<video>), 替换为带随机 nonce 的占位令牌,最后整体转义并回贴令牌。未列入白名单的 <script>、<iframe>、<style>、 on* 事件属性、style、data-* 等一律转为可见纯文本,不执行、不渲染。 令牌带 nonce 是为避免「正文里恰好写了 @@XDMEDIA_0@@ 字面量」被误当作自插令牌导致插图错位。
  3. 地址白名单(chapterMediaSrcOk()):媒体/链接只放行 http(s):// 绝对地址与站内单斜杠相对路径; //evil.com/x.png 这类协议相对外链、javascript:、data: 一律拒绝(图片不渲染、链接降级为无 href 的 <a>)。
  4. 空内容判定(chapterContentIsEmpty()):富文本只有 <p><br></p> 之类空壳时 trim() 判不出空, 改为「按净化后的 HTML 去标签、去空白后是否为空」,含 <img>/<video> 的正文不算空。保存校验改用它。
  5. 统一渲染入口(renderChapterBody()):阅读页 chapter.php 与 JSON 接口 ajax-novel-chapter.php 共用; 富文本正文含块级标签时直接输出(避免 <p><p>…</p></p> 二次包裹),历史纯文本正文仍按换行逐段包 <p>, 空内容返回 <p class="empty-text">本章内容为空</p>。
  6. 前端编辑器(assets/js/novel-editor.js):零依赖渐进增强,自动挂载 [data-novel-editor]; 保留 <textarea name="content"> 作为唯一表单字段(并 removeAttribute('required')), 初始内容优先取 <template data-editor-source>(服务端已净化),否则取 cleanHtml(textarea.value); 图片上传读取容器上的 data-upload-url / data-csrf,请求 ajax-novel-image.php。 未加载 JS 时表单退化为原纯文本录入,不影响发布。
  7. 上传接口(ajax-novel-image.php):仅接受 POST、要求登录、csrfVerify() 校验; 用 getimagesize() 探测真实类型并以 jpeg/png/gif/webp 白名单放行(拒绝 SVG,防脚本型图片), 限制 ≤5MB(NOVEL_IMAGE_MAX_SIZE),扩展名由探测结果决定,随机文件名 novel_<hex16>.<ext>, 存 UPLOAD_DIR/novels/Y/m/。响应一律 HTTP 200,成败由 JSON success 字段表达, 避免面板/网关把 4xx 当作故障;成功返回 url = baseUrl().'uploads/novels/Y/m/xxx.ext'(/uploads/...,符合净化器的站内路径规则)。
  • 影响范围: PHP:author/chapter-edit.php、admin/chapter-edit.php(改表单与守卫)、includes/fn-novel.php(新增函数,无既有签名变更)、ajax-novel-image.php(新增) 前端资源:assets/js/novel-editor.js、assets/css/novel-editor.css(新增) 数据库:无(chapters.content 原本即 LONGTEXT,未改 schema,故未更新 docs/版本升级说明.md) Go / nginx / 认证:无 依赖:无新增第三方库(编辑器为原生 JS + 站点既有 e()/csrfToken()/baseUrl())
  • 验证结果:
验证项结果备注
修复前复现PASS对修复前副本以真实会话 POST author/chapter-edit.php(相对 action 形态,无查询串)→ HTTP 302 Location: chapters.php;小说 13 章节数 0、word_count=0,章节被静默丢弃
修复后发布(同一请求形态)PASSPOST author/chapter-edit.php(无查询串、仅带隐藏字段)→ 302 chapters.php?novel_id=13&msg=created;新章节 id=12、word_count=11,novels.word_count=11
富文本章节发布PASSPOST 含 <h2>/<strong>/<em>/<blockquote>/<ul><li> 的正文 → 302 msg=created;章节 id=13 word_count=46,小说汇总 57(=11+46)
阅读页渲染PASSGET chapter.php?id=13:<h2>/<strong>/<em>/<blockquote>/<ul><li> 正常透传,排版正确
接口渲染一致PASSajax-novel-chapter.php?id=13 输出与阅读页一致(同为 renderChapterBody())
XSS / 危险标签拦截PASS<script>、<iframe>、//evil.com/x.png 转义为可见文本;javascript: 链接降级为无 href 的 <a>坏链</a>;<p onclick=…> 事件属性被剥离
站内图片渲染PASS合法 /uploads/... → <img class="xd-content-img full-width" …>
令牌碰撞防护PASS正文中字面量 @@XDMEDIA_0@@ 未被替换(nonce 不匹配)
图片上传:成功PASSPOST 有效 PNG → {"success":true,"url":"/uploads/novels/2026/09/novel_404f1ed5ab797bd4.png"};落盘属主 www:www;GET 该 URL → 200 Content-Type: image/png
图片上传:伪造类型PASS改扩展名的假 PNG → 「无法识别该图片…」;SVG 文件同样被拒
图片上传:CSRFPASS缺 csrf_token → 「请求验证失败…」
图片上传:体积上限PASS超过 5MB → 「图片不能超过 5MB」,未落盘
图片上传:方法限制PASSGET → 「仅支持 POST 请求」
空正文校验PASSPOST content=<p><br></p> → 响应含 class="alert alert-error">请填写章节内容,章节数不变(仍 2)
编辑既有章节PASSPOST 带 chapter_id=13 且无查询串 → 302 msg=updated;标题已更新、word_count 保持 46、章节数仍 2
编辑态初始内容PASSGET author/chapter-edit.php?id=13 含 data-novel-editor 与 <template data-editor-source>,模板为净化后 HTML(textarea 内 <script> 已转义)
静态资源可访问PASSassets/css/novel-editor.css 200 text/css、assets/js/novel-editor.js 200 application/javascript、ajax-novel-image.php 200 application/json
PHP 语法检查PASSphp -l 通过:includes/fn-novel.php、author/chapter-edit.php、admin/chapter-edit.php、ajax-novel-image.php
净化器用例(CLI 直调)PASS六组用例(plain / legacy_media / rich / attack / only_script / empty_p)行为符合设计;countWords() 对 HTML 与纯文本同字数;3 < 5 且 7 > 2 计为 7,标签未被误判
后台页面渲染未验证admin/chapter-edit.php 未做运行时渲染验证(环境无管理员凭据,不伪造会话);其认证门正常(匿名访问 302)。编辑器标记 / JS / CSS 与作者端完全共享
JS 语法检查未验证环境无任何 JS 运行时(node/deno/bun/qjs/d8 均无),novel-editor.js 未能机器语法校验
  • 关联 BUG:BUG-2026-0015、BUG-2026-0016(同属「发布 / 保存内容」链路的相邻缺陷,本条目为其中唯一的前端表单 + 路由参数类问题)
  • 备注:
  1. 未改数据库结构(chapters.content 原本即 LONGTEXT),故未随动 docs/版本升级说明.md;未提升 CMS_VERSION(维持 v0.3.81)。
  2. 章节正文由纯文本升级为「受限 HTML」:所有落库内容仍是原文,净化只在输出时进行(读取侧统一走 renderChapterBody()), 因此历史存量章节无需数据迁移,效果与升级前一致或更好。
  3. 编辑器采用渐进增强:未加载 JS 时 <textarea name="content"> 仍可正常提交纯文本,发布链路不因前端资源失败而中断。
  4. ajax-novel-image.php 与编辑器共用站点既有会话 / CSRF 机制,未新增独立鉴权;上传目录沿用 UPLOAD_DIR,未开放额外 Web 目录。
  5. 后台端与作者端共用净化 / 渲染函数及同一套前端资源;作者端已做完整真机验证,后台端仅差凭据下的页面级回归(已在上表标注)。

BUG-2026-0018:章节阅读插件未默认全屏 / 退全屏即退阅读且带提醒 / 息屏丢进度 / 无翻页

  • 修复日期:2026-09-30
  • 修复文件:
  • 修改:assets/js/reader.js、assets/css/reader.css
  • 根因分析(项目无 VCS,改动前基线无法回看,以下按改动前后行为差异与现有代码证据描述):
  1. 未默认全屏:章节页只有显式入口 #enterReaderBtn(click → openReader()),进入后 默认不请求全屏;插件内还需再点一次底部「全屏」按钮。
  2. 退全屏 = 退阅读:原 fullscreenchange / webkitfullscreenchange 处理器直接调用 closeReader(),把「退出全屏」(含 Esc、息屏、切后台被动触发)等同于「退出阅读器」, 用户被迫中断阅读。
  3. 息屏丢进度:落盘时机不足 —— 页面隐藏(息屏 / 切后台)、被动退出全屏、翻章等路径上没有 立即写盘钩子;且持久化值只有滚动位置绝对值,没有百分比冗余。设备息屏后正文插图(.xd-dynimg) 异步加载改变内容高度,或在被浏览器回收后按旧长度恢复时,绝对位置就会偏掉,表现为进度丢失。
  4. 无翻页:底栏只有「上一章 / 下一章」与进度百分比,长章节只能纯滚动。
  • 修复方案(全部在 assets/js/reader.js + assets/css/reader.css,未改 PHP、未改接口、未改 DB):
  1. 默认进沉浸全屏阅读:脚本尾部 openReader({ fullscreen: false }) 默认开启(受 LS.auto 开关控制,可在「排版 → 行为」中关闭),先以模态沉浸层覆盖全屏;受浏览器「无用户手势不得 requestFullscreen」限制,改由 armAutoFullscreen() 挂一次性首个手势(pointerdown / touchstart / mousedown / keydown,捕获阶段)补一次真全屏升级,只升一次, 用户主动退出后不再抢回。原显式入口 #enterReaderBtn 仍保留,走 opts.fullscreen !== false 路径直接请求全屏。
  2. 退全屏不退出、不提醒:fullscreenchange / webkitfullscreenchange 处理器改为仅 saveProgress(true)(立即落盘),不再 closeReader();closeReader() 只在用户点击 「退出」时执行(且退出前先落盘)。reader.js 全文无 confirm / alert / prompt 调用(AST 断言 0 命中)。
  3. 息屏不丢进度:新增 visibilitychange(hidden 立即落盘;回前台若 scrollTop <= 1 且已保存位置 > 8px 则按上次位置复位)、pagehide / beforeunload、fullscreenchange、 翻章 loadChapter 前、关闭阅读器前共 6 处立即落盘;持久化格式升级为 {t 位置, p 百分比, ts 时间戳},新增 settleRestore():插图加载使内容高度漂移超 2% 时 按存下的百分比二次校正(6 秒窗口,用户一操作即放弃校正);resize 只钳制越界、不跳页。
  4. 翻页与进度同步:底栏新增「‹ 上一页 / 第 x / y 页 / 下一页 ›」行;totalPages() 由 视口高度推导,currentPage() 由进度百分比反推(保证「翻页 ⇄ 进度」单调一致), pageTarget(dir) 按视口步进并就近吸附段落起始(吸附必须产生实际位移,避免卡死); flipPage() 落位后立即 updateProgress() + saveProgress(true);按钮在首/末页自动置灰; 键盘 PageDown/↓/空格 下一页、PageUp/↑/Shift+空格 上一页(←/→ 仍为上下章); 排版偏好(字号 / 行距 / 页边距)改变后 applyPrefs() 末同步刷新页数读数并落盘。
  5. window.XDReader 扩展为 {isOpen, open(opts), close, info()},info() 返回只读快照 (open/chapterId/scrollTop/max/pct/page/pages/fullscreen/saved),供页面侧或自动化核对。
  • 影响范围: PHP:无(chapter.php 的 #xd-reader-config 与 #enterReaderBtn 未改动) Go / nginx / 认证:无 数据库:无(进度存浏览器 localStorage,键前缀 xd_reader_pos_) 前端资源:assets/js/reader.js、assets/css/reader.css(资源版本按 mtime 自动破缓存) 依赖:无新增第三方库
  • 验证结果:
验证项结果备注
JS 语法检查PASS真实解析器(esprima 4.0.1 parseScript)解析 reader.js 通过
无退出提醒(结构断言)PASSAST 全量扫描:confirm/alert/prompt 调用数 = 0;全站仅 drama-player.php 有独立全屏逻辑,与阅读器无关
退全屏不关阅读器PASSAST:fullscreenchange / webkitfullscreenchange 处理器内调用集为 ['saveProgress'],不含 closeReader
息屏落盘链PASSAST:visibilitychange 存在且内含落盘;pagehide / beforeunload / loadChapter / closeReader 均有立即落盘
续读链PASSAST:fillContent → restoreProgress → readPos(按章节键读取)
默认进入 / 手势补全屏PASSAST:脚本尾部 openReader(无手势路径)→ armAutoFullscreen → tryFullscreen;一次性监听、仅升级一次
翻页与进度同步PASSAST:flipPage 调用集含 pageTarget / updateProgress / saveProgress / cancelSettle / armHide;上一页 / 下一页按钮与键盘翻页均已绑定
静态行为断言合计PASS/tmp/xd_verify_static.py 20/20 PASS(对最新 reader.js 执行)
分页逻辑可达性(等价模型)PASS按 reader.js 公式逐字转写为等价模型跑 5 组合成正文(均匀短段 / 含大图稀疏候选 / 一屏内 / 1200 段长文 / 手机视口):均单调前进、不卡死、可达末页且末页页码 = 总页数,反向翻页可回页首,5/5 PASS
资源经 HTTP 可取且为最新PASSGET /assets/js/reader.js 200 且 md5 与磁盘一致(3135097fec6a69c4069c06add019a8e9);/assets/css/reader.css 200
章节页可访问 / 配置下发PASSGET /chapter.php?id=14 200,含 #xd-reader-config
增量后 PHP 未受影响PASS本次未改任何 PHP 文件
交互行为(真机)未验证环境无浏览器(面板 WebCheck 返回 browser_status: not_installed)、无 JS 运行时(node/deno/bun/qjs/d8 均无),无法机器验证点击 / 全屏 / 息屏 / 翻页的真实交互,需人工按核对清单验证(见备注 1)
  • 关联 BUG:BUG-2026-0017(同属小说阅读链路:0017 解决「章节发布与正文渲染」,本条目解决「阅读器交互与进度」)
  • 备注:
  1. 待人工验证清单(浏览器打开 http://<站点>/chapter.php?id=14): ① 打开即自动进入沉浸全屏阅读(无 toast 之外无其他打扰);② 按 Esc 退出全屏后阅读器仍在、 无任何确认 / 提醒弹窗,可继续阅读并用「全屏」按钮再次进入;③ 阅读到中部后锁屏(或切后台) 再唤醒,进度不丢(可在控制台执行 XDReader.info() 核对 scrollTop / pct / page / saved); ④ 翻页按钮与 ↓ / PgDn / 空格 可逐页前进、「↑ / PgUp」后退,首 / 末页按钮自动置灰, 翻页后 XDReader.info().pct 与 saved.t 同步更新。
  2. 未改数据库结构,未提升 CMS_VERSION(维持 v0.3.81),故未随动 docs/版本升级说明.md。
  3. 本次为纯前端行为改造,进度存储格式为 {t 位置, p 百分比, ts 时间戳};浏览器中若残留 非对象格式的同键值(历史 / 其它写入方),readPos() 的类型判断会安全降级为「从头开始」, 不影响阅读。
  4. 息屏恢复依赖浏览器是否重置滚动位置:若系统未重置则完全无感;若被重置且保存位置 > 8px, 则由 visibilitychange 的 visible 分支回位。

BUG-2026-0019:章节页自动进入后真全屏不生效(Android Chrome 实测,0018 的跟进修复)

  • 修复日期:2026-09-30
  • 修复文件:
  • 修改:assets/js/reader.js、assets/css/reader.css
  • 现象:BUG-2026-0018 交付后,Android Chrome(nginx 访问日志 UA: Mozilla/5.0 (Linux; Android 10; K) ... Chrome/154.0.0.0 Mobile Safari/537.36)打开章节页 进入沉浸层后,浏览器始终未进入真全屏(沉浸层正常、token_balance 请求可见,仅全屏未生效)。
  • 根因分析:
  1. 触发事件选错:自动升级监听 pointerdown / touchstart / mousedown。手机上第一下交互 通常是滑动翻页,滑动过程中的 requestFullscreen 会被浏览器判为无效手势而拒绝 (MDN《Element: requestFullscreen()》:Transient user activation is required)。
  2. 一次性设计:autoUpgradeDone 在首个手势即置 true 并移除监听,那一次失败之后永不重试, 用户随后点按也无法再进入全屏。
  3. 失败静默:requestFullscreen().catch(function () {}) 吞掉拒绝原因,页面无任何反馈与排查线索。
  • 修复方案:
  1. 触发事件改为 click + keydown(点按 / 按键才是确定的用户手势;滑动不产生 click, 既不误触发也不会因滚动导致请求被拒)。
  2. 去掉一次性语义,改为状态机 fsStat{attempts, entered, unsupported, error}:失败后继续等待 下一次手势重试(上限 6 次防打扰),直到成功进入(entered,此后不再抢回)或判定不支持; 用户主动退出全屏后不再自动抢回。
  3. tryFullscreen(cb) 以回调回报成功 / 失败原因;失败时 console.warn('[XDReader] requestFullscreen 被拒:…'), 第 2 次失败轻提示一次「浏览器未允许全屏,再点一次或点底部「全屏」」;底部「全屏」按钮失败时 提示具体原因。XDReader.info() 新增 fsEntered / fsAttempts / fsUnsupported / fsError 便于线上核对。
  4. document.fullscreenEnabled === false(如 iOS Safari 不支持元素全屏)直接判为不支持并提示一次 「当前浏览器不支持页面全屏,已用沉浸阅读」,不再反复尝试。
  5. CSS 补 .xd-reader-root:fullscreen 与 .xd-reader-root:-webkit-full-screen(各写一条, 避免选择器列表在 Firefox 中整体失效){ width:100%; height:100% },防止 dvh 与全屏视口 计算差异留白。
  6. 进入时的提示文案改为「已进入沉浸阅读,轻点屏幕变真全屏」,明确告知需要一次点按。
  • 影响范围: PHP / Go / nginx / 认证 / 数据库:无 前端资源:assets/js/reader.js、assets/css/reader.css 依赖:无新增
  • 验证结果:
验证项结果备注
JS 语法检查PASSesprima 4.0.1 parseScript 解析通过
行为断言(AST,28 项)PASS28/28。新增断言:自动全屏不再使用 pointerdown/touchstart/mousedown、改用 click/keydown、失败可重试且有次数上限、校验 document.fullscreenEnabled、tryFullscreen 以回调回报失败、fullscreenchange 记录 entered、info() 暴露 fs* 诊断字段
回归:退全屏不关阅读器 / 息屏落盘 / 续读 / 翻页同步PASS0018 的全部断言在本次改动后仍全部通过
分页等价模型PASS5/5(本次未触及分页逻辑,回归通过)
资源经 HTTP 可取且最新PASSGET /assets/js/reader.js 200 且 md5 与磁盘一致(40661b4e7e54b7ba753742ce15e0f53d);/assets/css/reader.css 200
真机全屏未验证环境无浏览器,需在 Android Chrome 复测:打开章节页 → 轻点屏幕一下 → 应进入真全屏(见备注 2)
  • 关联 BUG:BUG-2026-0018(本条目为其跟进修复)
  • 备注:
  1. 浏览器限制(不可绕过):requestFullscreen 必须由用户手势触发(MDN《Element: requestFullscreen()》 "Transient user activation is required")。因此「打开章节页零交互即浏览器真全屏」在任何普通网页中 都不成立;本插件的实现是「默认进入沉浸全屏层 + 首次点按/按键即升级为浏览器真全屏」, 并在进入时 toast 提示「轻点屏幕变真全屏」。
  2. 若复测时点按后仍无真全屏:页面会出现「浏览器未允许全屏…」提示,控制台有 [XDReader] requestFullscreen 被拒:<原因>;可执行 XDReader.info() 查看 fsUnsupported / fsError / fsAttempts。常见外部原因:iOS Safari 不支持元素全屏、 页面被 iframe 嵌入且缺 allow="fullscreen"(如面板内置预览窗口)。
  3. 验证脚本(/tmp/xd_verify_static.py、/tmp/xd_page_sim.py、esprima 临时安装)与临时自检页 在验证完成后已全部删除,项目内与 /tmp 均不留临时文件。

BUG-2026-0020:进入章节页后真全屏仍需「点一下」(0019 的跟进优化)

  • 修复日期:2026-09-30
  • 修复文件:
  • 修改:assets/js/reader.js(assets/css/reader.css 本轮未改)
  • 现象:0019 交付后用户真机复测反馈「进入全屏不彻底,要用户点击屏幕一下才会进入」, 并认为是被进入时的提示「已进入沉浸阅读,轻点屏幕变真全屏」所阻挡。
  • 根因分析:
  1. 那条提示不是原因:它只是文案,不拦截手势、不影响全屏结果。真正限制在浏览器侧—— Element.requestFullscreen() 需要 transient activation(MDN《User activation》), 而按 HTML 标准用户激活是按 Document 计量的(较新文档不会继承上一个文档的激活, "sticky activation state spans multiple navigations as long as the same Document gets reused")。 用户的实际入口是 novel.php 点章节链接跳到 chapter.php(nginx 日志 referer 可证), 这是创建一个新文档,落地时没有任何用户激活,因此「加载即进入浏览器真全屏」 在当前入口结构下无法自动完成,必须有一次本页手势。
  2. 旧实现把补全屏时机只挂在 click / keydown 上,且加载时从不尝试一次; 手机上用户第一下通常是滑动翻页(滑动不产生 click),于是变成必须专门「点一下」。
  • 修复方案:
  1. 进入时先直接尝试一次 requestFullscreen():若当前文档仍带有效激活 (BFCache 回退复用文档、同页触发等)即可零点击进入真全屏;被拒则静默交回手势监听, 不弹提示、不打断阅读。
  2. 补全屏的手势监听扩充为 touchend / pointerup(非鼠标)/ click / keydown—— 按 MDN 只有这几个是「激活触发事件」;手机上的第一个可用时机是首次手指抬起, 不再要求用户刻意点一下。仍排除 pointerdown / touchstart(不是激活触发事件, 且在滑动中会被判无效而拒绝),保留失败重试(上限 6 次)与「成功进入后不再抢回」。
  3. 新增在途去重 fsStat.pending:touchend 与 pointerup 会接连触发,避免重复请求; Esc 键不参与触发(不产生用户激活)。
  4. 移除「已进入沉浸阅读,轻点屏幕变真全屏」这条进入提示;仅在连续两次手势失败后 提示一次「浏览器未允许全屏,再点一次或点底部「全屏」」,避免刷屏打扰。
  • 影响范围: PHP / Go / nginx / 认证 / 数据库:无 前端资源:assets/js/reader.js 依赖:无新增
  • 验证结果:
验证项结果备注
JS 语法检查PASSesprima 4.0.1 parseScript 解析通过
行为断言(AST,32 项)PASS32/32。新增断言:进入即尝试一次真全屏、触发事件含 touchend/pointerup、在途去重 pending、Esc 被排除、已移除「轻点屏幕」提示;回归 0019/0018 全部断言(退全屏不关阅读器、息屏落盘、续读、翻页同步、重试上限、fullscreenEnabled 校验、info() 的 fs* 字段)
资源经 HTTP 可取且最新PASSGET /assets/js/reader.js 200 且 md5 与磁盘一致(a941597847e4d5a4bf9591b5f028a589);chapter.php?id=14 200;reader.css 200
真机全屏(首触即进)未验证环境无浏览器/无 JS 运行时,需在 Android Chrome 复测
  • 关联 BUG:BUG-2026-0019(本条目为其跟进优化,不改变 0018/0019 的四项验收)
  • 备注:
  1. 为何仍可能不是「零触摸」:真零交互的全屏要求发起全屏的那个用户手势与 requestFullscreen 处在同一个文档内。当前入口是跨文档跳转,因此最省也只能做到 「进入后首次手指抬起即全屏」。若要真正的零额外操作,只有两条路: ①把阅读器入口放到「点击发生的那一页」(在小说页/目录页点章节即在本页唤起阅读器并全屏, 页面不再跳转、或仅用 history.pushState 换 URL); ②把它做成 PWA(display: fullscreen,从桌面图标启动即无浏览器栏)/ 原生或 WebView 壳, PWA 需要 HTTPS 与 Service Worker,本机当前是 HTTP,故需先上 TLS。 两条路都会改动 chapter.php 以外的页面或站点基础设施,需另行确认后再做。
  2. 若复测时首次手指抬起后仍无真全屏:控制台有 [XDReader] requestFullscreen 被拒:&lt;原因&gt;, 可执行 XDReader.info() 查看 fsUnsupported / fsError / fsAttempts。

BUG-2026-0021:章节入口仍非零触摸(0020 备注① 的落地实现:小说页同页唤起阅读器)

  • 修复日期:2026-09-30
  • 修复文件:
  • 修改:assets/js/reader.js、assets/css/reader.css、novel.php、chapter.php
  • 现象:0020 交付后仍是「进入后要点/抬一次手指才真全屏」。按 0020 备注①,把阅读器入口 放到「点击发生的那一页」才能做到真正零额外触摸。
  • 根因:Element.requestFullscreen() 依赖 transient user activation,且用户激活按 Document 计量。原先小说页点章节 → location 跳到 chapter.php(新建文档), 落地时该文档不带任何激活,只能靠落地后的首次手势补全屏。
  • 修复方案:
  1. 小说页改为同文档唤起阅读器:novel.php 的「开始阅读」与章节目录链接点击时 preventDefault() 并调用新增的 XDReader.openAt(id);阅读器是当前文档里的模态层, tryFullscreen() 仍处在点击事件的同一任务内,因而当场即进浏览器真全屏, 无需任何额外触摸。
  2. 页面不跳转,只换 URL:进入时 history.pushState 到 chapter.php?id=N(不改动文档, 因此不打断用户激活),翻章时 replaceState 跟随(syncUrl());「返回」键由 popstate 关闭阅读器并留在小说页,closeReader() 退出时 history.back() 回到列表页。 刷新后落到真实阅读页,走 0020 的补全屏路径(渐进降级,不会失效)。
  3. 正文异步补齐:小说页没有服务端渲染的正文,先显示加载态(.xd-loading), 再由 loadChapter() 走既有的 ajax-novel-chapter.php 取正文 / 上下章 / 目录 / 锁状态; 首章接口异常时 opts.fallback 回退为整页跳转,入口可达性不降级。
  4. 插件不再强依赖配置块:#xd-reader-config 缺失不再 return(列表页只需下发 接口地址 / 登录态 / 余额),阅读页自动进入改为显式 autoOpen:true 开关下发, 避免列表页误触发自动进入。
  5. 保留既有语义:autoOpen 与排版面板偏好共同决定是否用阅读器打开;在面板里关掉 「自动进入阅读器」的用户,小说页点章节仍走原生跳转(偏好文案同步改为 「点开章节将进入沉浸阅读器 / 已关闭:点开章节走普通阅读页」)。
  6. 无 JS、插件未就绪、被接口判为不可见的章节:链接本身仍是原生 chapter.php?id=N, 不做任何劫持(渐进增强)。
  • 影响范围: PHP / Go / nginx / 认证 / 数据库:无(novel.php 仅新增配置块与前端脚本,无 SQL / 无新依赖) 前端资源:assets/js/reader.js、assets/css/reader.css、novel.php、chapter.php 依赖:无新增(复用既有 ajax-novel-chapter.php,只读、不扣费)
  • 验证结果:
验证项结果备注
PHP 语法检查PASSphp -l:novel.php / chapter.php / ajax-novel-chapter.php 均 No syntax errors
JS 语法检查PASSesprima 4.0.1 parseScript 解析通过
行为断言(静态 + AST,39 项)PASS39/39。新增:配置块缺失不再 return、openReader 支持 entry/chapterId/title、列表分支在 CFG.locked 之前、列表入口在点击同一任务内 tryFullscreen、失败交接手势补全程、openAt 公开 API、pushState/replaceState 与 popstate、closeReader 回列表且状态幂等复位、首章失败回退整页跳转、autoOpen 显式开关、info().entry、.xd-loading、novel.php 的 data 属性/资源引入/点击委托/修饰键放行/偏好尊重;回归 0018/0019/0020 全部断言(触发事件集合、Esc 排除、pending 去重、重试上限、fullscreenEnabled 校验、退全屏不关阅读器、息屏落盘与回位、翻页同步落盘、续读、真全屏 CSS)
真实 HTTP:novel.phpPASSGET /novel.php?id=3 200,两处入口均带 data-chapter="14" data-chapter-title="第1章 序章 痴儿醒来",下发 autoOpen:false,内联委托脚本含 XDReader.openAt
真实 HTTP:chapter.phpPASSGET /chapter.php?id=14 200,下发 autoOpen:true(阅读页行为不变)
真实 HTTP:章节 JSON 接口PASSGET /ajax-novel-chapter.php?id=14 200,success:true,访客付费章 locked:true / reason:login / html 为空(正文未越权下发)
资源经 HTTP 可取且最新PASSGET /assets/js/reader.js 200 且 md5 与磁盘一致(eb74716cee6a3d7ba3318f59db45f78c);GET /assets/css/reader.css 200 且 md5 一致(4c480e073f903bccc8b2bdf764840d27)
nginx 错误日志PASS验证期间(11:5x)无新增 error;日志中既有条目均为 09:54 / 10:16 / 10:41 的历史记录
真机零触摸全屏未验证环境无浏览器 / 无 JS 运行时,须在 Android Chrome 复测:小说页点章节 → 应直接进入真全屏且不换页(见备注 1)
  • 关联 BUG:BUG-2026-0020(本条目为其备注① 的落地实现)
  • 备注:
  1. 复测步骤与判据:小说页(/novel.php?id=3)点「开始阅读」或任一章节 → 应立刻满屏且 无浏览器工具栏、地址栏变为 chapter.php?id=14、页面未刷新(XDReader.info() 返回 entry:'list'、fullscreen:true)。若测试时仍非满屏:看控制台是否有 [XDReader] requestFullscreen 被拒:…(如被 iframe 预览窗口包裹且缺 allow="fullscreen" 会被拒,属宿主页面限制)。
  2. 仍未做的一条路:②PWA(display: fullscreen,桌面图标启动即无浏览器栏)需先上 HTTPS + Service Worker,属站点基础设施变更,本次未做,需另行确认。
  3. 未改数据库结构,未提升 CMS_VERSION(维持 v0.3.81),故未随动 docs/版本升级说明.md; 也未改动 ajax-novel-chapter.php / ajax-token-deduct.php 等扣费与鉴权链路。
  4. 验证脚本(/tmp/xd_verify_0021.py、pip 安装的 esprima)在验证完成后已删除, 项目内与 /tmp 均不留临时文件。

BUG-2026-0022:全屏落锁(阅读时禁止息屏把用户拉回浏览器窗口)

  • 修复日期:2026-09-30
  • 修复文件:
  • 修改:assets/js/reader.js、assets/css/reader.css
  • 新增:assets/media/keepawake.mp4(静音循环兜底视频,1.6 KB)
  • 现象:进入真全屏沉浸阅读后,若一段时间无操作,设备自动熄屏;屏幕一黑,浏览器 / 系统会把 用户从全屏丢回带地址栏的浏览器窗口。等用户再看屏幕时已不在全屏,而浏览器又不可能在无用户 手势的情况下重新进入全屏(requestFullscreen 依赖 transient user activation),于是每看 一章都可能被弹回一次。
  • 根因:
  1. 全屏与屏幕点亮是两件事:全屏只是「没有浏览器栏的渲染状态」,不会阻止系统进入休眠;系统 熄屏会令文档进入 visibilityState === 'hidden',按规范 UA 必须退出全屏(fullscreenchange 触发且 document.fullscreenElement 变空)。
  2. 退出后再想回到全屏必须由用户手势发起;息屏是被动事件,UA 不可能替用户「重新申请」。因此 只能在熄屏之前就阻止它发生。
  • 修复方案:
  1. 阅读期间申请屏幕常亮(新增 Wake 子系统):
  • 首选 navigator.wakeLock.request('screen')(Screen Wake Lock)。该 API 只在安全上下文 (HTTPS 或 localhost)暴露,本站当前是 HTTP(39.96.90.85.conf 无 443),故 navigator.wakeLock 不存在,会直接落到兜底。
  • 兜底:页面内挂一个静音循环短视频(assets/media/keepawake.mp4,32×32、10 fps、1 秒、 H.264 baseline、+faststart,约 1.6 KB)。muted + defaultMuted + playsinline 允许无手势 自动播放,播放期间系统不进入休眠(这是移动端通行且稳定的常亮手段)。
  • 视频元素懒创建、挂在阅读器根节点内,样式 .xd-keepawake 为 2×2 px、近乎透明 (opacity:.01)、pointer-events:none;不能用 display:none(不可见元素不计入常亮判定)。
  • 有 API 但被拒(如省电模式)时自动降级到视频兜底;连续失败 3 次后停止重试,避免每次手势白跑。
  1. 只在文档可见时持有:wakeAcquire() 在打开阅读器时、以及从后台回到前台 (visibilitychange → visible)时调用;wakeRelease() 在关闭阅读器时、以及切后台 / 息屏 (hidden)时调用(Wake Lock 会被浏览器自动释放,兜底视频须手动 pause)。用户关掉常亮 偏好时同样跳过。
  2. 被动退出全屏后「重新武装」(即「全屏落锁」):hidden 时若当时仍在全屏,标记 fsStat.passive = true;随后 UA 触发 fullscreenchange 退出时,若判定为被动退出 (fsStat.passive 或退出瞬间文档已不可见),则清掉 fsStat.entered 并调用既有的 armAutoFullscreen() —— 下一次用户手势(touchend/pointerup/click/keydown)即自动抢回 真全屏,无需用户手动点全屏按钮。用户主动退出(Esc)不受影响,不会被强拉回全屏。
  3. 可见性为偏好开关:排版面板「行为」行新增「屏幕常亮:开 / 关」(#xdKeepChip,默认开)。 关掉后设备可正常息屏(此时息屏仍会退回浏览器窗口,文案已提示);重新打开会立即 wakeAcquire()。
  4. 状态可见:XDReader.info() 新增 keepAwake(wakeLock / video / 空)、 keepAwakeFails、keepAwakeError,便于真机排查。
  • 影响范围: PHP / Go / nginx / 认证 / 数据库:无(仅前端资源与一个静态媒体文件,无 SQL、无新依赖) 前端资源:assets/js/reader.js、assets/css/reader.css(新增 assets/media/keepawake.mp4) 依赖:无新增(Wake Lock 与 <video> 均为浏览器原生能力)
  • 验证结果:
验证项结果备注
JS 语法检查PASSesprima 4.0.1 parseScript 解析通过
行为断言(静态 + AST,45 项)PASS45/45。新增:navigator.wakeLock 探测与视频降级、连续 3 次失败停止重试、隐藏时跳过、.xd-keepawake 不可 display:none、#xdKeepChip 默认开且开关写 LS.keep、openReader/closeReader 的 acquire/release 连接、visibilitychange 的 passive 预判与重新武装、passive:false、fullscreenchange + webkitfullscreenchange 统一入口;回归 0018–0021 全部断言
兜底视频文件PASSfile 识别为 ISO Media, MP4;ffmpeg -v error -i … -f null - 解码零报错;1619 字节
真实 HTTP:兜底视频PASSGET /assets/media/keepawake.mp4 200 且 Content-Type: video/mp4,1619 字节
真实 HTTP:页面PASSGET /chapter.php?id=14 200、GET /novel.php?id=3 200
资源经 HTTP 可取且最新PASSGET /assets/js/reader.js 200 且 md5 与磁盘一致(72af444ad87cc9742859f6aa6d2f1cb3);GET /assets/css/reader.css 200 且 md5 一致(f7759ed2db4071ed4225545bfea0aca6);页面已带 ?v=… 版本参数(缓存击穿生效)
nginx 错误日志PASS验证期间无新增 error;日志既有条目均为 09:54 / 10:16 / 10:41 的历史记录
真机常亮 / 息屏不弹回未验证环境无浏览器 / 无 JS 运行时,须在 Android Chrome 复测(见备注 1)
  • 关联 BUG:BUG-2026-0020、BUG-2026-0021(同属沉浸阅读体验)
  • 备注:
  1. 真机复测判据:进入全屏阅读后静置 ≥ 设备息屏时间(一般 30s–2min),屏幕应保持点亮、不出现 「黑屏后回到带地址栏的浏览器窗口」;XDReader.info() 中 keepAwake 应为 video(HTTP 站点 无 Wake Lock)。若仍会息屏:查 keepAwakeError / keepAwakeFails(=3 表示自动播放被策略拒绝); 部分国产 ROM 的省电策略会无视网页播放,需在系统设置里对本浏览器放开后台 / 省电限制。
  2. 本站是 HTTP,非安全上下文,navigator.wakeLock 不存在,故当前必然走静音视频兜底;一旦本站 接入 HTTPS,代码无需改动会自动优先使用原生 Wake Lock(更省电、更可靠)。
  3. 路径 ②(PWA display:fullscreen + Service Worker)仍未做,需先上 HTTPS,属站点基础设施变更, 另行确认。
  4. 书架的「继续阅读」仍是原生整页跳转(非本阅读器同文档入口),不享有零触摸全屏与本次常亮逻辑; 如需一致体验需另行改造。
  5. 未改数据库结构,未提升 CMS_VERSION(维持 v0.3.81),故未随动 docs/版本升级说明.md。
  6. 验证脚本(/tmp 下的临时校验脚本、pip 安装的 esprima)在验证完成后已删除,项目内与 /tmp 均不留 临时文件。

BUG-2026-0023:阅读器没有左右滑动翻页(只有按钮 / 键盘)

  • 修复日期:2026-09-30
  • 修复文件:assets/js/reader.js
  • 现象:手机上阅读时,左右滑动手指没有任何反应,无法翻页;只能点底栏「上一页 / 下一页」或按键盘。 用户预期的「左滑下一页、右滑上一页」在沉浸阅读里缺失。
  • 根因:reader.js 从未实现横向滑动手势。全文只有 flipPage()(按视口分页)+ 两个按钮 + 键盘(PgUp/PgDn/空格/↑↓)三条通道;既有的 touchstart 监听只用于「取消图片高度校正 (cancelSettle)」与「补全屏手势」,不参与翻页。(0018–0021 备注里把「滑动」当作既有习惯来描述, 实际并未落地。)
  • 修复方案(在正文区 #xdReaderBody 上新增手势识别):
  1. touchstart 记录单指起点(多指 / 非阅读态直接忽略);touchend 比较终点位移。
  2. 判定为翻页须同时满足:|dx| ≥ 45px(低于视为点按)、|dx| ≥ |dy| × 1.2(以水平为主)、 |dy| ≤ 60px(垂直漂移过大多半是上下滚动)。不满足即放行,交给原生滚动。
  3. 方向:向左滑(dx < 0)→ flipPage(1) 下一页;向右滑(dx > 0)→ flipPage(-1) 上一页。
  4. 不误伤既有交互:划词选中(window.getSelection() 非空)不翻页;touchcancel 复位;滑动完成后 350ms 内忽略 click,避免同一次滑动顺带呼出 / 收起控制层。
  5. 监听全部 { passive: true },不 preventDefault,不干扰正常滚动与浏览器手势;不改动按钮 / 键盘两条既有通道。
  • 影响范围:前端资源:assets/js/reader.js(唯一改动文件);PHP / nginx / 数据库 / 依赖:无
  • 验证结果:
验证项结果备注
JS 语法检查PASSesprima 4.0.1 parseScript 解析通过
滑动翻页断言(14 项)PASS阈值、单指守卫、changedTouches、identifier 一致、水平阈值 / 占优 / 垂直上限、左滑下一页、划词忽略、touchcancel 复位、click 抑制、监听挂在 #xdReaderBody
回归断言(6 项)PASS翻页按钮绑定、键盘翻页、flipPage 落盘 + 页码、全屏手势事件集未变、划词不触发控制层、常亮 / 落锁子系统仍在
合计PASS20/20
真实 HTTP:资源最新PASSGET /assets/js/reader.js 200 且 md5 与磁盘一致(fe6b40735f4bff6df648936c69ece268),页面已带 ?v=1790741740 版本串(filemtime 缓存击穿生效)
真实 HTTP:页面PASSGET /chapter.php?id=14 200、GET /novel.php?id=3 200
nginx 错误日志PASS验证期间无与本次改动相关的新增 error(12:07 的 /favicon.ico、/404.html 条目来自外部客户端,且属既有历史问题)
真机滑动翻页未验证环境无浏览器 / 无 JS 运行时,须真机复测(见备注 1)
  • 关联 BUG:BUG-2026-0018(翻页初版:按钮 + 键盘)
  • 备注:
  1. 真机复测判据:全屏阅读中在正文区向左滑(约 ≥45px、以水平为主)→ 前进一页;向右滑 → 后退一页; 页码 XDReader.info().page 与百分比同步跳变。上下滑仍应是正常滚动、不翻页;长按划词不应翻页。
  2. 阈值(45 / 60 / 1.2)为经验值;若真机上误触或触发不灵,只需调这三个常量,无需改结构。
  3. 未改数据库结构,未提升 CMS_VERSION(维持 v0.3.81)。
  4. 验证用的 esprima 在验证后已卸载,项目内与 /tmp 均不留临时文件。

BUG-2026-0024:阅读器只有「上下滚动」,缺「书籍翻页」(左右横向分页,与滚动互斥)

  • 修复日期:2026-09-30
  • 修复文件:assets/js/reader.js、assets/css/reader.css
  • 现象:沉浸阅读器只有一种版式——正文纵向长滚动,翻页只是「按视口往下挪一屏」。用户要的是书籍式翻页: 正文按屏横向分页、文字自左向右出现、左右滑动整页翻;并且这种模式必须与原「上下滚动」互斥、不同时存在。
  • 根因:0018 起 reader.js 的坐标体系是单一纵向:scrollTop/scrollHeight 贯穿 totalPages / currentPage / pageTarget / updateProgress / restoreProgress / flushSave 全链路,blockTops() 还依赖纵向段落坐标做吸附; CSS 上 .xd-reader-body 只有 overflow-y: auto,没有任何多列分页能力。因此仅加一个「翻页动画」无法满足需求, 必须把纵向单坐标抽象成「偏移」双坐标。
  • 修复方案:
  1. 抽象出「偏移」层,两种方式共用一套读数与进度:新增 isPaged()、maxOffset()(paged → scrollWidth - clientWidth, scroll → scrollHeight - clientHeight)、curOffset() / setOffset()(paged 读写 scrollLeft,scroll 读写 scrollTop), pctOf() 改以 maxOffset() 为分母,totalPages() / currentPage() / pageTarget() / updateProgress() / flipPage() 全部改为基于偏移推导,页码与进度保持单调一致。
  2. 书籍翻页的实现在 CSS 多列 + 横向滚动:relayoutPaged() 实测正文内容盒宽度 (clientWidth - paddingLeft - paddingRight)后写成内联 column-width,column-gap 取 PAGED_GAP = 32px; 页步进 = 页宽 + 页间距。CSS 侧新增 .xd-reader-root.paged .xd-reader-body { overflow: hidden; max-width: none; column-fill: auto; }——关掉纵向滚动、放开 860px 限制,让「一屏 = 一页」。翻页即 scrollLeft ± 步进, 文字自然自左向右出现。
  3. 互斥由单一出口保证:relayoutPaged() 是唯一写内联多列样式的函数——非 paged 时把 columnWidth / columnGap 清空并返回,paged 时才写入;applyMode() 是唯一 toggle paged class 的地方,由 isPaged() 决定。applyMode() 在 openReader() 里紧跟 applyPrefs() 调用,因此进入阅读器即确定模式,两个模式不可能同时生效;CSS 侧所有 .paged 规则都以 .xd-reader-root.paged 限定,不污染滚动模式。
  4. 续读跨模式可换算:进度记录新增 m 字段(存翻页方式)。restoreProgress() 读记录时——同一种方式用精确偏移 t; 换了方式(含 0024 之前的旧记录,缺 m 视为 scroll)用百分比 p 换算,paged 再 snapOffset() 吸附到整页边界, 保证切换模式后仍停在同一处内容。flushSave() 统一记 curOffset() 并带上 m。
  5. 开关与入口:面板「行为」行新增 #xdModeChip(翻页:左右(书籍) / 翻页:上下(滚动)),点击调 setMode()——按当前百分比换位落座、写 localStorage、刷新读数并落盘。默认取 paged(书籍翻页)。
  6. 收尾对齐:settleRestore() / paras 的 load 回调在 paged 下直接跳过「插图加载后按百分比二次校正」 (页宽固定,偏移不受插图影响);fillContent() / loadPlaceholder() / renderLock() 同时清 scrollTop 与 scrollLeft 并重排;resize 在 paged 下记住原页码、重新测量后落回原页;visibilitychange 的前台复位改用 curOffset() / setOffset() 并校验 lastSavedMode === state.mode;info() 增补 mode / pagedStep / offset。
  7. 改排版后不停在原页的补漏:页边距会改变「页宽」,字号 / 行距 / 段距会改变每页容量——分页模式下这些改动 都会让已算好的页步进与页码失准。故 applyPrefs() 在 paged 且阅读器已打开时,先记住 currentPage(), 再 relayoutPaged() 重新测量、按原页码重新落位。同时 relayoutPaged() 加了一道守卫:从未量到页宽时直接返回, 不把 column-width 写成 0px(否则会把整页版式压塌);已量到过、只是暂时不可测则沿用上次页宽。
  8. 分页版式细节(CSS):标题 / 插图 / 视频占位 / 锁面板 break-inside: avoid(不跨页拆开);多列环境下 左右浮动插图会跨列错位,故 paged 下 .xd-dynimg 一律 float: none;插图 width/height: auto 且 max-height: 72vh,按比例缩放到一页之内;加载占位 column-span: all。
  • 影响范围:前端资源:assets/js/reader.js、assets/css/reader.css(仅此两个文件);PHP / nginx / 数据库 / 依赖:无
  • 验证结果:
验证项结果备注
JS 语法检查PASSnode --check 通过;esprima 4.0.1 parseScript 解析通过
结构断言(分页核心 / 坐标抽象 / 互斥出口)PASS常量与默认值、maxOffset/curOffset/setOffset 双坐标、页宽测量与吸附、relayoutPaged 清空与写入、applyMode 单一 toggle、内联多列样式单一出口
结构断言(切换 / 进度 / 续读 / 入口)PASSsetMode 百分比换算 + 持久化 + 幂等、flushSave 带 m、restoreProgress 同模式用 t / 换模式用 p、paged 跳过二次校正、openReader 先 applyPrefs 后 applyMode、applyPrefs 改排版后重新测量并停回原页码、relayoutPaged 未量到页宽时不写 0px、#xdModeChip 绑定、info() 新增字段
结构断言(CSS 互斥)PASSpaged 正文规则 overflow:hidden + column-fill:auto;基础 .xd-reader-body 未被污染(仍 overflow-y:auto、无多列声明);所有 .paged 规则均由 .xd-reader-root.paged 限定;column-width/gap/fill 只出现在 .paged 规则中
分页数学自洽(沙箱执行抽取的核心函数)PASS400px 视口 → 页宽 352 / 页步进 384;10 列内容 → maxOffset 3408、总页数 10;1..10 页「页码 ⇄ 偏移」单调一致;吸附不越界;「偏移→百分比→偏移」往返稳定;改页边距后页宽重新测量(368 / 步进 400);从未量到页宽时不写 0px;切回滚动后内联多列样式被清空;内容不足一页 → maxOffset 0 / 总页数 1
回归断言(滑动 / 键盘 / 常亮 / 全屏 / 播放器)PASS0023 的左右滑动仍驱动 flipPage;PgUp/PgDn/空格/↑↓ 仍翻页;屏幕常亮与全屏落锁子系统未动
合计PASS100/100
真实 HTTP:资源最新PASSGET /assets/js/reader.js 200 且 md5 与磁盘一致(aa94abbef0c8679bdf1674ecf607ce0a);GET /assets/css/reader.css 200 且 md5 一致(20819c412720ef8b6ed254ddfb6e0cfd);页面已带新版本串 reader.js?v=1790742434、reader.css?v=1790742307(filemtime 缓存击穿生效)
真实 HTTP:页面PASSGET /chapter.php?id=14 200、GET /novel.php?id=3 200
nginx 错误日志PASS验证期间无与本次改动相关的新增 error(最近条目为外部客户端探测 /web/、/webpages/login.html、/favicon.ico,属既有历史问题)
真机书籍翻页未验证环境无浏览器 / 无 JS 运行时(见备注 1)
  • 关联 BUG:BUG-2026-0018(翻页初版:按钮 + 键盘)、BUG-2026-0023(左右滑动翻页)
  • 备注:
  1. 真机复测判据:进入阅读器后正文应整屏一页、不上下滚动;向左滑 → 前进一页(文字换到下一屏),向右滑 → 后退一页;底栏「上一页 / 下一页」与 PgUp/PgDn/空格/↑↓ 等效;顶栏「行为」行切换开关后立刻换版式, 且同一时刻只有一种(paged 时上下不可滚动、scroll 时左右滑动不换页);从 paged 切到 scroll 再切回, 应停在原处(按百分比换算);重开同一章应续读原页码。
  2. 本环境无法验证的关键假设:CSS 多列 + overflow: hidden 容器是否按预期上报增大的 scrollWidth(横向分页的 前提)。若某些浏览器不上报,现象是「永远只有 1 页、翻不动」——此时需改用 transform: translateX 方案 (页宽仍需实测),不必改数据与后端。
  3. 默认值是 paged(书籍翻页),由 modePref() 一行决定:把 localStorage 里 xd_reader_mode 置为 scroll 即回退到原上下滚动;若要改默认,只需改 modePref() 的回退分支,无需动结构。
  4. 阈值 / 常量集中可调:PAGED_GAP = 32(页间距)。页宽由实测内容盒宽度决定,不写死。
  5. 未改数据库结构,未提升 CMS_VERSION(维持 v0.3.81)。
  6. 验证用的 esprima 在验证后已卸载,项目内与 /tmp 均不留临时文件。

BUG-2026-0025:小说阅读器「按字数扣费」缺完整控制口,且费率粒度与余额精度不符(会 0 元解锁)

  • 修复日期:2026-09-30
  • 修复文件:includes/fn-novel.php、admin/token-pricing.php、chapter.php、assets/js/reader.js、database.sql
  • 现象:
  1. 后台「用户运营 → Token 消费管理 → 小说类消费」只有「小说每字费率」一项,没有任何开关:要临时停掉小说计费, 只能把费率改成 0,改回时原费率已丢失,无法「停计费但保留配置」。
  2. 费率步进是 0.0001 TKN/字(即「每字」量级),而资金链路的最小单位是「分」——users.token_balance、 token_consumptions.token_amount 均为 DECIMAL(10,2)。按字数算出的金额写库时被舍入到分,例如 30 字 × 0.0001 = 0.003 → 落库 0.00:余额没少,却插入了一笔消费记录,该章因此对用户永久免费 (hasConsumed 已为真),后台也多出一笔 0 元流水。低于 0.005 的章节都存在同一问题。
  3. 展示口径与扣款口径不一致:阅读页门控与阅读器锁面板按 4 位小数显示价格(如 0.0050 TKN、支付 0.0050 TKN), 实际扣款按 2 位小数落库,用户看到的金额与真正扣掉的金额不同。
  • 根因:
  1. calculateChapterPrice() 用 round($wordCount * $rate, 4) 出价,比资金精度(2 位)细两个数量级,既没有向下 对齐到分,也没有任何下限兜底——于是「判定为付费(price > 0)→ 实扣 0.00 → 记为已消费」这条路径成立;
  2. 后台的计费卡片用 $item['key'] === 'novel_price_per_word' 精确匹配单个键,结构上就没有「总开关」的位置, 费率本身既是开关又是价格;
  3. 价格展示在 chapter.php(number_format($chapterPrice, 4) ×3)与 reader.js(toFixed(4))写死 4 位小数, 与落库精度脱节。
  • 修复方案:
  1. 计费归一(includes/fn-novel.php):新增 isNovelBillingEnabled()(读 novel_billing_enabled,缺行默认开启) 与 getNovelMinChapterPrice()(读 novel_min_chapter_price,缺行默认 0.01);calculateChapterPrice() 改为 max(round(字数 × 费率, 2), 每章最低扣费),即出价与 DECIMAL(10,2) 对齐并加下限。返回 0(免费)的情形: 总开关关闭 / 章节记录缺失 / 字数或费率为 0 / 算出的金额不足 0.01 且最低扣费设为 0。费率与最低扣费均先夹到非负。
  2. 后台补齐控制口(admin/token-pricing.php):$priceItems 增加 novel_billing_enabled(type=select, 选项 1 开启 / 0 关闭,默认 1)与 novel_min_chapter_price(type=number,step 0.01,默认 0.01);「小说类消费」 卡片过滤由单键改为三键 in_array,并为 select 增加渲染分支(选项值统一 (string) 转换后比较,避免 PHP 把 '1'/'0' 键转成 int 导致回显错位)。保存路径无需改动——处理器本就遍历 $priceItems 读取同名字段。
  3. 展示对齐(chapter.php / assets/js/reader.js):门控 data-price、价格文案、解锁按钮与阅读器锁面板价格 全部由 4 位小数改为 2 位,与余额、实际扣款同一口径。
  4. 新装默认值(database.sql):novel_billing_enabled = '1'、novel_min_chapter_price = '0.01'。 已装站点无需迁移:缺行时由代码默认值兜底,管理员首次保存表单即写入实体行。
  • 关键前提与现网影响:现网费率为 0.01 TKN/字(由 chapter.php?id=14 门控 data-price 反推:1677 字 → 16.77), 该量级下 字数 × 费率 本就落在分位上,因此本次改动不改变现网任何一章的扣费金额;修复只在费率细于 0.01 (如 0.0001)或章节极短时生效,补的是「显示 = 扣款 = 记账」三者一致。
  • 影响范围:后台配置项 2 个(小说计费总开关、每章最低扣费);运行时计费公式与展示精度;新装 SQL 默认值。 未改数据库结构(不新增表/列),未提升 CMS_VERSION(维持 v0.3.81),未引入依赖。
  • 验证结果:
验证项结果备注
语法检查PASSphp -l 通过:includes/fn-novel.php、admin/token-pricing.php、chapter.php
计费函数实跑(16 个用例)PASS以桩 getTokenPrice 隔离数据库,直跑真实 calculateChapterPrice():0.0001/字 × 1000 字 = 0.1、× 3000 字 = 0.3、30 字 → 垫到最低 0.01、1 字 → 0.01、最低扣费 0 时 30 字(0.003)→ 免费、最低 0.05 时 300 字 0.03 → 0.05 而 1000 字 0.1 不受影响、总开关关闭 / 费率为 0 / 字数为 0 / 章节缺失 → 0、开关缺行默认开启、开关置 2 按开启、费率负值夹到 0
精度一致性PASS9 组字数(1 / 7 / 13 / 99 / 100 / 333 / 1000 / 4321 / 99999)的出价全部是 0.01 的整数倍,与 DECIMAL(10,2) 可精确存储
后台控件定义PASS从源码抽取 $priceItems 解析:共 8 项,新增 2 项;三张卡片的过滤列表与全部键恰好一一覆盖(无控件被静默丢弃、无失效键)
后台卡片渲染(真实源码片段 eval)PASS抽取「小说类消费」卡片模板实渲染:恰好 3 张控件卡;开关为 select 且默认选中「开启」;存 0 时选中「关闭」;费率项保持默认 0 / step 0.0001;最低扣费默认 0.01 / step 0.01;已存值(0.01 / 0.05,含整数键 1)回显正确;无 PHP 标签或报错文本泄漏到输出
POST 保存逻辑PASS以处理器同款逻辑模拟提交三个控件 → 分别存为 0 / 0.0001 / 0.01(select 的 '0'/'1' 通过 is_numeric 与 (float) 转换)
展示口径残留PASSchapter.php 无 number_format($chapterPrice, 4;reader.js 锁面板价格为 toFixed(2)(reader.js:788 的 toFixed(4) 是阅读进度百分比,非价格,不属本次范围)
真实 HTTP:页面PASSGET /chapter.php?id=14、?id=1、/novel.php?id=3 均 200;/admin/token-pricing.php 302(未登录跳转,既有行为)
真实 HTTP:资源PASSGET /assets/js/reader.js 200,服务端下发 md5 bad8d7fb608a52517b83b4dcd81e0560 与磁盘一致,页面引用已刷新为 reader.js?v=1790745895
nginx 错误日志PASS验证期间无与本次改动相关的新增 error(近期条目均为外部探测 /SDK/webLanguage、/favicon.ico 等既有问题)
登录态后台页面渲染未验证环境无后台会话凭据,无法真实登录查看卡片;已用「真实源码片段 eval 渲染」替代,覆盖值与分支但非完整页面
真实扣费链路(登录用户解锁章节)未验证需真实用户会话且会产生真实资金变动,未执行;扣费端点 ajax-token-deduct.php、幂等逻辑、resolveChapterAccess 门控本次均未改动
合计PASS20/20(2 项环境限制标注未验证)
  • 关联 BUG:无(小说按字数计费模块自身的首条修复记录;扣费链路本身见既有实现,未改动)
  • 备注:
  1. 三个控制口的语义:总开关关闭 → 整站小说阅读免费(费率与下限配置保留,随时可恢复);费率 0 → 免费; 最低扣费 0 → 不设下限,此时算出的金额不足 0.01 TKN 的章节按免费处理(不锁定、不记账);

原始文件:docs/网站BUG修复.md · 出于安全考虑,站点不直接暴露 docs/ 目录下的原始文件(其中含内部资料),请在交付包内查看全文。

全部文档
站点公告 官网试运行中,如需购买或试用请通过「联系我们」留言。