数字我 Digital Me:全量入库(大文件走 Git LFS)
nginx serve /uploads direct (audio speed) / patch-nginx (push) Failing after 1m34s

This commit is contained in:
2026-10-10 16:24:43 +08:00
commit 0593ff7e62
1893 changed files with 361723 additions and 0 deletions
@@ -0,0 +1,83 @@
---
name: historical-battle-assets
description: "为 Digital Me 生成纯素材型历史战役包:史实上下文、中文旁白、分幕图片、人物标签和项目自生成音乐选曲,并在校验后自动创建本地 Git 提交。用户说“某战役生成”“玄武门事变生成”,或要求战役图片配文字与 BGM 素材时使用;不负责前端页面或交互实现,不执行 Git push。"
---
# 历史战役素材生成
交付“食材”,不替用户“炒菜”:产出可直接交给前端、剪辑或策划使用的图片、文字和音乐选择,不写页面组件、样式或交互代码,除非用户另行明确要求。
## 先定史实边界
1. 规范战役名称和时代;遇到俗称、错别字或多个同名事件时,依据史料确定规范名,并在交付中列出用户原称和规范名。
2. 优先读取目标项目已有的事件资料、人物表、史料引文、`gaps` 与 `blacklist`。在 Digital Me 中重点查找 `digital-me-backend/src/mingchangmianData.js`、`digital-me-backend/src/rujuDashi/` 及相关朝代数据。
3. 本地资料不足时,再查权威史料或正式学术来源。事实、合理视觉补全与纯气氛描写必须分开标注;史料空白不得写成确定事实。
4. 战役胜负、生死、时间、地点、身份、器物和服饰不得为戏剧性而改写。演义、影视桥段或后世传说若无可靠依据,放进“禁用元素”,不要画进图片。
## 组成素材包
默认按事件复杂度设计 3–5 幕,而不是强行把所有战役切成同样长度。每幕必须含:
- 时间与地点;
- 这一幕在整场战役中的作用;
- 适合配音的连续旁白;
- 适合画面叠加的短标题与一句短文案;
- 在场人物的“姓名/当时身份/本幕动作或结局”;
- 图片文件名、画面说明、无障碍替代文本和最终生成提示词;
- 史实依据,以及哪些画面细节属于不改变事件走向的视觉补全。
另写一段可从头顺读的主旁白。用户未指定时,以约 60–90 秒为目标,句子短、可口播,用具体动作连接上下文,不写教科书式人物履历堆砌。
完整目录与 `manifest.json` 字段遵循 [交付结构](references/deliverable-schema.md)。
## 生成图片
需要成图时,先加载并遵循 `imagegen` skill,再使用图像生成工具。保持同一战役的建筑时代、服饰、盔甲、人物外观、色调和镜头语言一致。
- 默认横版 16:9,适配网页大画幅与视频分镜。
- 图片本身不烘焙姓名、字幕、书法、Logo 或水印;为后续叠字保留可用负空间。
- 人物关系通过站位、视线、明暗和景深表达;人物姓名与身份写在伴随文案中。
- 暴力场面以动作、兵器和后果暗示为主,不依赖血腥特写。
- 每幕生成不同构图,不能只换同一画面的滤镜。
- 保存原始生成图,并为交付另备体积合理的 WebP;文件名使用稳定的幕次和英文 slug。
## 从项目音乐里选 BGM
只从用户指定项目的自生成音乐或自有曲库选曲,不拿外部商业歌曲凑数。Digital Me 默认先查:
- `digital-me-backend/src/data/music-library.json`;
- `digital-me-backend/src/storyScores.js`;
- `/api/tianxia/yue/library?q=<战役名>` 返回的音频绑定状态。
优先级依次为:同一事件且已有可播放音频、同一人物或时代且已有音频、最贴合叙事曲线的自生成音乐方案。判断维度是情绪弧、节奏、是否压住旁白、时代音色、开头和收尾是否易剪。
如果曲库只有作曲方案而没有实际音频:仍可选出最佳曲目 ID 和版本,但必须写明“待生成/待导出”,不得声称已附 MP3;同时给出 3–5 段 cue sheet。若选中含人声版本作为 BGM 来源,明确要求使用伴奏或去人声 stem。
## 写入与交付
若用户给了项目,默认在项目内创建 `战役素材/<规范战役名>/`;没有目标目录时,在当前工作区创建同名素材包。不要把素材自动接进页面。
### 自动创建本地 Git 提交
素材包写入并通过检查后,只要交付目录位于 Git 工作树内,默认自动创建本地 commit,不再另行询问;这项默认授权只覆盖本次新生成或更新的战役素材包,以及用户明确要求同步更新的本技能文件。
1. 先读取目标仓库的 `AGENTS.md`、Git 发布约束和适用架构文档,并用 `git rev-parse --show-toplevel`、当前分支、远端和 `git status --short --branch` 确认提交位置。
2. Digital Me 的源码目录与历史归档仓库分离时,以用户指定的交付仓库为准。若用户已指定 `SPianPianShaoNian/digital-me-history`,应把最终素材包同步到该远端对应的本地工作树,再在那里提交;不要把源码仓库的其他改动带入归档提交。
3. 工作树存在无关改动时,保留且不触碰它们。只按明确路径暂存本次素材目录和本技能文件,禁止使用 `git add .` 或 `git add -A`。
4. 提交前至少验证:`manifest.json` 可解析、所有清单引用的 PNG/WebP 都存在、图片格式与尺寸可读、`git diff --cached --check` 通过。纯图片/文案素材不触发前后端构建;若同时修改代码,则按仓库门禁运行相应测试。
5. 默认提交信息使用 `feat: add <规范战役名> battle asset bundle`;若只是修订已有素材,则使用 `docs: update <规范战役名> battle asset bundle`。
6. 创建 commit 后报告提交号、暂存范围、验证结果和相对上游的领先状态。
7. 只创建本地提交。禁止执行 `git push`、`git publish`、强推或其他远端写操作;远端上传仍由用户手动完成。
如果交付目录不在 Git 工作树内、找不到用户指定的交付仓库、提交身份缺失,或本次文件与用户未提交改动发生重叠,先完成素材交付并说明未自动提交的具体原因,不得猜测远端、覆盖改动或扩大提交范围。
完成前检查:
- 每张图都能对应一段旁白,而不是装饰图;
- 人名、官职和动作在各文件中一致;
- 主旁白能按幕次顺读;
- 图片提示词不含违背本战史实的元素;
- BGM 有明确选曲理由、状态和剪辑点;
- 所有来源、生成提示词和文件路径可追溯。
交付时列出素材目录、图片数量、旁白时长、选中 BGM 及其是否已有音频。若调用图像生成,附上最终提示词摘要。
@@ -0,0 +1,4 @@
interface:
display_name: "历史战役素材生成"
short_description: "生成历史战役图片、旁白、人物标签与项目内 BGM 选曲"
default_prompt: "使用 $historical-battle-assets 为玄武门之变生成完整图片、旁白和 BGM 素材包。"
@@ -0,0 +1,91 @@
# 交付结构
为每场战役创建独立目录:
```text
战役素材/<规范战役名>/
├── 素材说明.md
├── manifest.json
├── images/
│ ├── 01-<scene-slug>.png
│ ├── 01-<scene-slug>.webp
│ └── ...
└── audio/ # 仅在确有可用音频文件时创建
└── <track-slug>.<ext>
```
## 素材说明.md
建议依次写:
1. 规范事件名、时代、日期、地点与一句话结论;
2. “60–90 秒主旁白”;
3. 分幕素材,每幕列旁白、短标题、人物标签、图片文件、画面说明、替代文本和提示词;
4. BGM 选曲、音频状态、选择理由与 cue sheet;
5. 史实依据、可视化补全和禁用元素。
避免把大量代码或页面实现写进素材说明。
## manifest.json
使用 UTF-8 和相对路径。最低字段如下:
```json
{
"event": {
"canonicalName": "规范战役名",
"aliases": ["用户原称"],
"era": "时代",
"date": "日期",
"place": "地点",
"summary": "一句话结论"
},
"narration": {
"language": "zh-CN",
"estimatedSeconds": 75,
"fullText": "可连续口播的主旁白"
},
"scenes": [
{
"id": "01",
"title": "幕标题",
"timePlace": "本幕时间地点",
"purpose": "本幕叙事作用",
"narration": "本幕旁白",
"overlay": {
"heading": "短标题",
"line": "一句短文案"
},
"people": [
{ "name": "人物", "office": "当时身份", "action": "动作或结局" }
],
"image": {
"original": "images/01-scene.png",
"delivery": "images/01-scene.webp",
"alt": "准确替代文本",
"prompt": "最终成图提示词",
"visualInference": "不属于史料明文的气氛或构图补全"
},
"sources": ["史料及卷次"]
}
],
"bgm": {
"catalogId": "项目曲目 ID",
"title": "曲名",
"version": "版本",
"status": "attached | playable-url | pending-export | unavailable",
"pathOrUrl": "有实际音频时填写,否则为空",
"usage": "伴奏、去人声或原曲使用方式",
"reason": "为何适合这场战役",
"cues": [
{ "from": "00:00", "to": "00:12", "scene": "01", "direction": "剪辑和情绪说明" }
]
},
"sources": [
{ "claim": "覆盖的事实", "citation": "来源" }
],
"blacklist": ["禁止画入或写成事实的内容"]
}
```
`status` 必须反映真实状态。只有文件已复制进 `audio/` 才使用 `attached`;只有可访问的项目自有音频地址才使用 `playable-url`。
+23
View File
@@ -0,0 +1,23 @@
*.png filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text
*.jpeg filter=lfs diff=lfs merge=lfs -text
*.gif filter=lfs diff=lfs merge=lfs -text
*.webp filter=lfs diff=lfs merge=lfs -text
*.bmp filter=lfs diff=lfs merge=lfs -text
*.ico filter=lfs diff=lfs merge=lfs -text
*.ttf filter=lfs diff=lfs merge=lfs -text
*.otf filter=lfs diff=lfs merge=lfs -text
*.woff filter=lfs diff=lfs merge=lfs -text
*.woff2 filter=lfs diff=lfs merge=lfs -text
*.m4a filter=lfs diff=lfs merge=lfs -text
*.mp3 filter=lfs diff=lfs merge=lfs -text
*.wav filter=lfs diff=lfs merge=lfs -text
*.mp4 filter=lfs diff=lfs merge=lfs -text
*.mov filter=lfs diff=lfs merge=lfs -text
*.webm filter=lfs diff=lfs merge=lfs -text
*.zip filter=lfs diff=lfs merge=lfs -text
*.rar filter=lfs diff=lfs merge=lfs -text
*.7z filter=lfs diff=lfs merge=lfs -text
*.psd filter=lfs diff=lfs merge=lfs -text
*.ai filter=lfs diff=lfs merge=lfs -text
*.bundle.js filter=lfs diff=lfs merge=lfs -text
+55
View File
@@ -0,0 +1,55 @@
name: Check Auth Flags (只读诊断)
# 只读:打印生产 .env 里【登录相关开关】的值。不打印任何密钥/AppSecret/AccessKey。
#
# 要确认的事:生产上 ALLOW_STUB_LOGIN 是不是 true。
# 如果是 true 且没接真实短信服务商,那么 POST /api/auth/sms/send 会把 6 位验证码
# 【直接放进 HTTP 响应里返回】——任何人填上别人的手机号,就能拿到验证码,
# 再调 /sms/verify 直接登录成那个人的账号。这不是"测试便利",这是一扇开着的门。
on:
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Print auth flags (no secrets)
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.ECS_HOST }}
username: ${{ secrets.ECS_USER }}
password: ${{ secrets.ECS_PASSWORD }}
command_timeout: 3m
script: |
set -e
cd /var/www/digitalme-api.invoratec.cn
[ -f .env ] || { echo "❌ 生产 .env 缺失"; exit 1; }
echo "===== 生产登录相关开关(只看开关,不看密钥)====="
for k in NODE_ENV ALLOW_STUB_LOGIN SMS_PROVIDER EMAIL_PROVIDER FEATURE_MARKETPLACE; do
v=$(grep -E "^${k}=" .env | head -1 | cut -d= -f2- || true)
printf '%-22s = %s\n' "$k" "${v:-(未设置)}"
done
echo
echo "===== 是否配了真实短信/微信(只判断有没有,不打印值)====="
for k in ALIYUN_ACCESS_KEY_ID WECHAT_APP_ID; do
if grep -qE "^${k}=.+" .env; then echo "$k = 已配置"; else echo "$k = 未配置"; fi
done
echo
echo "===== 结论 ====="
STUB=$(grep -E '^ALLOW_STUB_LOGIN=' .env | head -1 | cut -d= -f2- || true)
SMSP=$(grep -E '^SMS_PROVIDER=' .env | head -1 | cut -d= -f2- || true)
if [ "$STUB" = "true" ] && [ "$SMSP" != "aliyun" ]; then
echo "🔴 危险:ALLOW_STUB_LOGIN=true 且没接真实短信服务商。"
echo " → /api/auth/sms/send 会把验证码放进响应里返回。"
echo " → 任何人填别人的手机号即可拿到验证码,然后登录成那个人。"
echo " → 手机注册的账号目前全部可被任意接管。"
elif [ "$STUB" = "true" ]; then
echo "🟡 注意:ALLOW_STUB_LOGIN=true,但短信走的是 aliyun(真实发送)。"
echo " 短信这条路是安全的;但微信仍是 mock 扫码,建议一并关掉 stub。"
else
echo "🟢 安全:ALLOW_STUB_LOGIN 未开启,验证码不会出现在响应里。"
fi
+49
View File
@@ -0,0 +1,49 @@
name: Cleanup + Disk Guard + claw source
# A) 读 claw-api(aiplatform-api) 源码定位「反复重载 CustomApps」根因
# B) 安全清理可再生空间(conda 包缓存 / docker 悬空镜像 / dnf 缓存 / npm 缓存)——不碰 docker 卷、/data、媒体
# C) 装磁盘看门狗 cron:根盘>=90% 自动清失控日志,防再次满盘全站瘫
on:
workflow_dispatch:
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: SSH cleanup + guard + claw scan
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.ECS_HOST }}
username: ${{ secrets.ECS_USER }}
password: ${{ secrets.ECS_PASSWORD }}
command_timeout: 12m
script: |
D=/var/www/aiplatform-api.invoratec.cn
echo "############ A. claw-api 反复重载 CustomApps 根因(读源码) ############"
echo "=== 打印该日志的源码位置 ==="
grep -rn "CustomApps" "$D/src" 2>/dev/null | grep -iE "Loaded|log|console" | grep -v node_modules | head -5
echo "=== 谁触发重载(setInterval/fs.watch/chokidar/reload/loadAll/每请求加载) ==="
grep -rniE "setInterval|fs\.watch|chokidar|reloadApps|loadCustomApps|loadAllApps|loadApps\(|scanApps|refreshApps" "$D/src" 2>/dev/null | grep -v node_modules | head -20
echo "=== .env 是否有 LOG_LEVEL/DEBUG 可降噪 ==="
grep -iE "LOG_LEVEL|LOG_|DEBUG|VERBOSE|NODE_ENV" "$D/.env" 2>/dev/null | sed -E 's/=.*/=***/' | head
echo ""
echo "############ B. 安全清理(仅可再生垃圾) ############"
echo "=== before ==="; df -h / | tail -1
echo "--- conda 包缓存 ---"
if [ -x /opt/miniconda3/bin/conda ]; then /opt/miniconda3/bin/conda clean -a -y 2>&1 | tail -4; else echo "(无 conda)"; fi
echo "--- docker 悬空镜像(只清 dangling,安全) ---"
(command -v docker >/dev/null && docker image prune -f 2>&1 | tail -2) || echo "(无 docker)"
echo "--- dnf 缓存 ---"
(command -v dnf >/dev/null && dnf clean all 2>&1 | tail -1) || true
echo "--- npm 缓存 ---"
npm cache clean --force 2>/dev/null && echo "npm cache cleaned" || true
echo "=== after ==="; df -h / | tail -1
echo ""
echo "############ C. 装磁盘看门狗 cron ############"
echo 'IyEvYmluL2Jhc2gKIyDno4Hnm5jnnIvpl6jni5fvvJrmoLnliIbljLogPj05MCUg5pe257Sn5oCl5riF56m65aSx5o6nIHBtMiDml6Xlv5cgKyDmlLbnvKkgam91cm5hbO+8jOiusOW9leWIsCAvdmFyL2xvZy9kaXNrLWd1YXJkLmxvZ+OAggojIOeUsSBjcm9uIOavjyAxMCDliIbpkp/ot5HkuIDmrKHjgILpmLLmraLlho3mrKHmu6Hnm5jlr7zoh7QgbW9uZ29kIOatu+OAgeWFqOermeeYq+OAggpUSFJFU0g9OTAKVVNFPSQoZGYgLyB8IGF3ayAnTlI9PTJ7Z3N1YigiJSIsIiIsJDUpO3ByaW50ICQ1fScpCkxPRz0vdmFyL2xvZy9kaXNrLWd1YXJkLmxvZwpbICIke1VTRTotMH0iIC1sdCAiJFRIUkVTSCIgXSAmJiBleGl0IDAKZWNobyAiJChkYXRlICcrJUYgJVQnKSBkaXNrICR7VVNFfSUgPj0gJHtUSFJFU0h9JSAtPiBlbWVyZ2VuY3kgY2xlYW51cCIgPj4gIiRMT0ciCmZvciBkIGluIC9yb290Ly5wbTIvbG9ncyAvaG9tZS8qLy5wbTIvbG9ncyAiJEhPTUUvLnBtMi9sb2dzIjsgZG8KICBmb3IgZiBpbiAiJGQiLyoubG9nOyBkbwogICAgWyAtZiAiJGYiIF0gfHwgY29udGludWUKICAgIHN6PSQoc3RhdCAtYyVzICIkZiIgMj4vZGV2L251bGwgfHwgZWNobyAwKQogICAgaWYgWyAiJHN6IiAtZ3QgMjA5NzE1MjAwIF07IHRoZW4gZWNobyAiICB0cnVuY2F0ZSAkZiAoJCgoc3ovMTA0ODU3NikpTSkiID4+ICIkTE9HIjsgOiA+ICIkZiI7IGZpCiAgZG9uZQpkb25lCmpvdXJuYWxjdGwgLS12YWN1dW0tc2l6ZT0xMDBNID4+ICIkTE9HIiAyPiYxCmVjaG8gIiAgYWZ0ZXI6ICQoZGYgLWggLyB8IGF3ayAnTlI9PTJ7cHJpbnQgJDUiIHVzZWQsICIkNCIgZnJlZSJ9JykiID4+ICIkTE9HIgo=' | base64 -d > /usr/local/bin/disk-guard.sh
chmod +x /usr/local/bin/disk-guard.sh
( crontab -l 2>/dev/null | grep -v 'disk-guard.sh'; echo '*/10 * * * * /usr/local/bin/disk-guard.sh' ) | crontab -
systemctl enable --now crond 2>/dev/null || systemctl enable --now cron 2>/dev/null || true
echo "cron 已注册:"; crontab -l 2>/dev/null | grep disk-guard || echo " ⚠ 未见 cron 条目"
echo "看门狗立即空跑一次(当前<90%应无动作):"; /usr/local/bin/disk-guard.sh; echo " exit=$?"
echo "############ 完成 ############"
+25
View File
@@ -0,0 +1,25 @@
name: Cleanup Test Data (manual)
# 手动触发:清理上线前自测累积的数据(课堂/排期/孤儿自习/测试申请与预约/孤儿通知)。
# 大师本体与真实用户分身一律不动。前置:后端已部署(scripts/cleanup-test-data.js 已在服务器)。
on:
workflow_dispatch:
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Run cleanup on ECS
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.ECS_HOST }}
username: ${{ secrets.ECS_USER }}
password: ${{ secrets.ECS_PASSWORD }}
command_timeout: 5m
script: |
cd /var/www/digitalme-api.invoratec.cn
[ -f .env ] || { echo "❌ 生产 .env 缺失,中止"; exit 1; }
[ -f scripts/cleanup-test-data.js ] || { echo "❌ scripts/cleanup-test-data.js 未部署,请先合并后端部署"; exit 1; }
node scripts/cleanup-test-data.js
echo "=== 校验:课堂应清空 ==="
curl -sf "http://localhost:3603/api/classroom" | head -c 200 || echo "(接口检查失败)"
+74
View File
@@ -0,0 +1,74 @@
name: Deploy Backend to ECS
# 数字我后端(纯成人版)。真正的生产目录 = /var/www/digitalme-api.invoratec.cn
# (它有正确 .env: creator_cn 共享库+前缀 / DASHSCOPE / JWT),nginx digitalme-api → :3603。
on:
workflow_dispatch: {} # ★ 允许在 Actions 页面手动触发部署(老板要求 2026-07-28)
push:
# ⚠ 只监听 master。原来 [master, main] 是一把上膛的枪:
# main 是落后 169 个提交的僵尸分支,往它推任何一行代码,
# 都会把旧代码盖到生产上。(已发生过一次。main 现已删除。)
branches: [master]
paths:
- 'digital-me-backend/**'
- '.github/workflows/deploy-adult-backend.yml'
jobs:
# 说明:原「测试门 test job」(node --test + mongo service)已按要求移除。
# 它总是报错、且 deploy 本就未 needs 它(不阻断部署),只让每次运行显示一个红叉、
# 看起来像"部署失败"。移除后部署运行恢复全绿。
# 要恢复 CI 测试:另建一个独立 workflow(如 test.yml)跑 node --test,不要再挂进部署流程误报。
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Copy files to ECS
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.ECS_HOST }}
username: ${{ secrets.ECS_USER }}
password: ${{ secrets.ECS_PASSWORD }}
source: 'digital-me-backend/src,digital-me-backend/scripts,digital-me-backend/package.json,digital-me-backend/package-lock.json'
target: /var/www/digitalme-api.invoratec.cn
strip_components: 1
overwrite: true
rm: false
timeout: 60s
command_timeout: 5m
- name: Install + restart PM2 (:3603)
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.ECS_HOST }}
username: ${{ secrets.ECS_USER }}
password: ${{ secrets.ECS_PASSWORD }}
command_timeout: 10m
script: |
cd /var/www/digitalme-api.invoratec.cn
[ -f .env ] || { echo "❌ 生产 .env 缺失,中止"; exit 1; }
# ★ 2026-07-28 上线前【关闭 stub 登录】:不再强制写 ALLOW_STUB_LOGIN=true。
# 代码层(config.js)已保证生产环境即使 .env 误设 true 也强制忽略(防账号接管);
# 这里再把历史遗留的 =true 就地改成 false,彻底消除。要放开消费端登录 = 接真实
# 阿里云短信(SMS_PROVIDER=aliyun + ALIYUN_SMS_*) / 微信开放平台(WECHAT_APP_ID/SECRET)。
if grep -q '^ALLOW_STUB_LOGIN=true' .env; then sed -i 's/^ALLOW_STUB_LOGIN=true/ALLOW_STUB_LOGIN=false/' .env; fi
# ★ 万相文生图开关(老板拍板开):开通后 inkArt/漫画轻版/二创样板间美术视频样板渲成真图=成品。
# 复用 DASHSCOPE key·国内 DashScope;用户点击才生成、结果缓存,非自动批量。
# ⚠ 若 DASHSCOPE 账号未开通万相文生图(账号层,同 TTS 曾遇),置 true 也不出图——那是账号问题非代码。
grep -q '^INK_ART_ENABLED=' .env || echo 'INK_ART_ENABLED=true' >> .env
npm install --production --no-audit --no-fund --prefer-offline --no-progress 2>&1 | tail -8 || \
npm install --production --no-audit --no-fund 2>&1 | tail -20
# 收敛为单进程 digitalme-api on :3603(清掉错误的 adult/teen 空目录进程)。
pm2 delete digitalme-adult-api 2>/dev/null || true
pm2 delete digitalme-teen-api 2>/dev/null || true
pm2 delete digitalme-api 2>/dev/null || true
fuser -k 3603/tcp 2>/dev/null || true
sleep 2
PORT=3603 pm2 start src/index.js --name digitalme-api --node-args="--max-old-space-size=512" --update-env
pm2 save
sleep 6
echo "=== pm2 ==="; pm2 list | grep digitalme || true
echo "=== 崩溃日志(如有) ==="; pm2 logs digitalme-api --lines 25 --nostream 2>/dev/null | tail -30 || true
H=$(curl -sf http://localhost:3603/health || true); echo "health:[$H]"
echo "$H" | grep -q '"ok":true' || { echo "❌ 后端没起来"; exit 1; }
echo "$H" | grep -q '"edition"' && { echo "❌ 旧代码"; exit 1; } || echo "✓ 新代码已上线"
+98
View File
@@ -0,0 +1,98 @@
name: Deploy Web to ECS
# 数字我前端(纯成人版)
on:
workflow_dispatch: {} # ★ 允许在 Actions 页面手动触发部署(老板要求 2026-07-28)
push:
# ⚠ 只监听 master。原来 [master, main] 是一把上膛的枪:
# main 是落后 169 个提交的僵尸分支,往它推任何一行代码,
# 都会把旧代码盖到生产上。(已发生过一次。main 现已删除。)
branches: [master]
paths:
- 'digital-me-web/**'
- '.github/workflows/deploy-adult-web.yml'
# ★ 并发锁:同一时刻只跑一个前端部署,多次快连推【串行、不重叠】。
# 2026-07-20 事故:连推三次(80ed3f8/379b671/ed91b14)三个部署乱序上传 chunk,
# 服务器一度新旧混装,懒加载的天下几章刷到「新 index.html 配已删旧 chunk」→ 打不开。
# cancel-in-progress: false ⇒ 不中途掐(scp/rsync 掐一半更糟),排队跑完;
# GitHub 会把中间那个 pending 顶掉,最终落定的永远是最新一次 push 的构建。
concurrency:
group: deploy-adult-web
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
defaults:
run:
working-directory: digital-me-web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: digital-me-web/package-lock.json
- run: npm ci
- run: npm run build:fast
env:
VITE_API_BASE_URL: https://digitalme-api.invoratec.cn
- name: Upload dist to staging
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.ECS_HOST }}
username: ${{ secrets.ECS_USER }}
password: ${{ secrets.ECS_PASSWORD }}
source: 'digital-me-web/dist/*'
target: /tmp/dm-web-dist
strip_components: 2
overwrite: true
- name: Publish to nginx root + reload
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.ECS_HOST }}
username: ${{ secrets.ECS_USER }}
password: ${{ secrets.ECS_PASSWORD }}
script: |
# ★★ 2026-07-28:写死本站 SPA 目录,【绝不】扫描 nginx root、【绝不】按 index.html 内容猜目录。
# 起因:同源同品牌(数字我家族)多站同服务器时,按 "index.html 含 数字我" 猜目录,会把隔壁
# superdigitme("超级数字我")的前端一起覆盖(2026-07-28 事故)。数字我(成人)前端唯一落点就是这里。
# 要改路径,只改这一行 ROOT。
ROOT=/var/www/digitalme-spa
RESOLVE="--resolve digitalme.invoratec.cn:443:127.0.0.1"
NEW=$(grep -oE 'index-[A-Za-z0-9_-]+\.js' /tmp/dm-web-dist/index.html 2>/dev/null | head -1)
if [ -z "$NEW" ]; then echo "::error::上传的 dist 里没有 index.html——上传步骤有问题,中止,不写盘。"; exit 1; fi
mkdir -p "$ROOT"
cp -rf /tmp/dm-web-dist/* "$ROOT"/
WROTE="$ROOT"
echo "✓ 已写入固定目录 $ROOT (指纹 $NEW)"
rm -rf /tmp/dm-web-dist
# 清理上一次误传进 acme 目录的 SPA 文件(不动真正的 challenge 文件)。
rm -rf /var/www/acme-challenge/assets /var/www/acme-challenge/index.html 2>/dev/null || true
# ── 缓存头(幂等【替换】,不再跳过): index.html 改 no-store —— 浏览器永不缓存首页,每次强制拿最新,
# 根治「老用户/旧标签页一直加载旧 bundle、看不到新版」(no-cache 会被 bfcache 绕过,no-store 不会)。
# hash 资源仍长缓存(immutable)。带 nginx -t 校验, 失败即回滚。 ──
CONF=/etc/nginx/conf.d/digitalme.invoratec.cn.conf
if [ -f "$CONF" ]; then
cp "$CONF" "$CONF.dmbak"
sed -i '/# dm-cache-headers/d' "$CONF" # 先删旧头(如有), 再注入新的
sed -i '/index index\.html;/a location = /index.html { add_header Cache-Control "no-store, no-cache, must-revalidate"; } location /assets/ { add_header Cache-Control "public, max-age=31536000, immutable"; } # dm-cache-headers' "$CONF"
if nginx -t 2>/dev/null; then echo "✓ nginx 缓存头已更新(index.html=no-store)"; else echo "❌ nginx -t 失败, 已回滚缓存头"; cp "$CONF.dmbak" "$CONF"; fi
else
echo "✓ conf 缺失, 跳过缓存头"
fi
nginx -s reload 2>/dev/null || true
sleep 2
# ★★ 发布后自检:以【公网真实 URL】(老板看到的那个)为准;不一致就把整张诊断表写进红色 ::error:: 标注
# ——GitHub 会把它显示在运行页顶部(就是老板一直能看到、能贴回来的那种),这样病根直接暴露、不用再猜。
LOCAL=$(curl -sk $RESOLVE https://digitalme.invoratec.cn/ 2>/dev/null | grep -oE 'index-[A-Za-z0-9_-]+\.js' | head -1)
PUB=$(curl -s "https://digitalme.invoratec.cn/?cc=$$" 2>/dev/null | grep -oE 'index-[A-Za-z0-9_-]+\.js' | head -1)
echo "=== 发布后自检 === 应发布=$NEW 本机served=$LOCAL 公网served=$PUB 写入目录=[$WROTE]"
if [ "$NEW" != "$PUB" ]; then
echo "::error::已写入 $WROTE 并 reload,但公网仍非最新。应发=$NEW 本机=$LOCAL 公网=$PUB。请确认 digitalme.invoratec.cn 的 nginx root 是否为 $WROTE。"
exit 1
fi
echo "✓✓ 自检通过:公网已是最新 $NEW"
+54
View File
@@ -0,0 +1,54 @@
name: Diagnose Cleanup + claw-api (read-only)
# 只读:①查 claw-api 疯狂刷日志的根因 ②盘点还能安全清理的空间。不删任何东西。
on:
workflow_dispatch:
jobs:
scan:
runs-on: ubuntu-latest
steps:
- name: SSH read-only scan
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.ECS_HOST }}
username: ${{ secrets.ECS_USER }}
password: ${{ secrets.ECS_PASSWORD }}
command_timeout: 9m
script: |
echo "###################### 一、claw-api 刷日志根因 ######################"
echo "===== pm2 describe claw-api ====="
pm2 describe claw-api 2>/dev/null | grep -iE "status|restart|uptime|script path|exec cwd|pid|unstable" | head -12
echo "===== claw-api 日志当前大小(截断后是否又涨) ====="
ls -lh ~/.pm2/logs/claw-api-out.log ~/.pm2/logs/claw-api-error.log 2>/dev/null
echo "===== out.log 最近 30 行(它在打印啥) ====="
tail -30 ~/.pm2/logs/claw-api-out.log 2>/dev/null | cut -c1-240
echo "===== out.log 最重复的 15 种行(定位刷屏内容) ====="
tail -4000 ~/.pm2/logs/claw-api-out.log 2>/dev/null | sed -E 's/[0-9]{4}-[0-9:.TZ -]+//g; s/[0-9]+/N/g' | sort | uniq -c | sort -rn | head -15 | cut -c1-200
echo "===== error.log 最近 20 行 ====="
tail -20 ~/.pm2/logs/claw-api-error.log 2>/dev/null | cut -c1-240
echo ""
echo "###################### 二、还能安全清理什么 ######################"
echo "===== df ====="; df -h / | tail -1
echo "===== docker 可回收(images/build cache/volumes) ====="
(command -v docker >/dev/null && docker system df 2>/dev/null) || echo "(无 docker)"
echo " --- dangling 悬空镜像 ---"; (command -v docker >/dev/null && docker images -f dangling=true -q 2>/dev/null | wc -l | xargs echo " 悬空镜像数=") || true
echo "===== 包管理器缓存 ====="
du -sh /var/cache/apt 2>/dev/null; du -sh /var/cache/yum 2>/dev/null; du -sh /var/cache/dnf 2>/dev/null
echo "===== conda 包缓存(可 conda clean) ====="; du -sh /opt/miniconda3/pkgs 2>/dev/null
echo "===== 各种 cache / pm2日志 / journal ====="
du -sh ~/.cache 2>/dev/null; du -sh ~/.npm 2>/dev/null; du -sh ~/.pm2/logs 2>/dev/null; journalctl --disk-usage 2>/dev/null
echo "===== 旧构建备份 / 我留的 .dmbak / *.bak / dist.old ====="
ls -lh /etc/nginx/conf.d/*.dmbak 2>/dev/null
find /var/www -maxdepth 3 -type d \( -name "dist.old" -o -name "*.bak" -o -name "backup" \) 2>/dev/null | head
find /var/www -maxdepth 3 -type f \( -name "*.bak" -o -name "*.tar.gz" -o -name "*.zip" \) -size +50M 2>/dev/null -printf '%s\t%p\n' | sort -rn | head -10 | awk '{printf " %.2fG\t%s\n",$1/1073741824,$2}'
echo "===== /data/iot 里是否有 logs/node_modules/缓存(可清) ====="
du -xh --max-depth=2 /data/iot 2>/dev/null | sort -rh | head -12
find /data/iot -maxdepth 3 -type d -name node_modules 2>/dev/null -printf '%p\n' | head -8
echo "===== 其它 >100M 的 .log(别的失控日志) ====="
find / -xdev -type f -name "*.log" -size +100M 2>/dev/null -printf '%s\t%p\n' | sort -rn | head -12 | awk '{printf " %.2fG\t%s\n",$1/1073741824,$2}'
echo "===== coredump / crash ====="
du -sh /var/lib/systemd/coredump 2>/dev/null; du -sh /var/crash 2>/dev/null; ls /var/crash 2>/dev/null | head
echo "===== 全盘 Top 25 大目录(复核) ====="
du -xh --max-depth=3 / 2>/dev/null | sort -rh | head -25
echo "###################### 扫描完成 ######################"
+36
View File
@@ -0,0 +1,36 @@
name: Diagnose Disk (read-only)
# 只读:根分区 100% 满,定位大占用者,判断哪些可安全清理。不删任何东西。
on:
workflow_dispatch:
jobs:
disk:
runs-on: ubuntu-latest
steps:
- name: SSH disk usage (read-only)
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.ECS_HOST }}
username: ${{ secrets.ECS_USER }}
password: ${{ secrets.ECS_PASSWORD }}
command_timeout: 8m
script: |
echo "===== df -h ====="; df -h / /boot/efi 2>/dev/null
echo "===== 根下一级目录占用(du --max-depth=1 /) ====="
du -xh --max-depth=1 / 2>/dev/null | sort -rh | head -20
echo "===== 全盘 Top 30 大目录(max-depth=3) ====="
du -xh --max-depth=3 / 2>/dev/null | sort -rh | head -30
echo "===== MongoDB 数据目录 ====="
du -sh /var/lib/mongodb 2>/dev/null || du -sh /data/db 2>/dev/null || echo "(未找到常规 mongo 数据目录)"
echo "===== pm2 日志目录(常见爆盘元凶) ====="
du -sh ~/.pm2/logs 2>/dev/null; ls -lhS ~/.pm2/logs 2>/dev/null | head -15
echo "===== /var/log 占用 ====="; du -sh /var/log 2>/dev/null; du -xh /var/log 2>/dev/null | sort -rh | head -12
echo "===== journal 占用 ====="; journalctl --disk-usage 2>/dev/null || echo "(无 journalctl)"
echo "===== npm/缓存 ====="; du -sh ~/.npm 2>/dev/null; du -sh /root/.cache 2>/dev/null; du -sh /tmp 2>/dev/null
echo "===== docker 占用(如有) ====="; (command -v docker >/dev/null && docker system df 2>/dev/null) || echo "(无 docker)"
echo "===== /var/www 各站点占用(含媒体上传) ====="
du -xh --max-depth=2 /var/www 2>/dev/null | sort -rh | head -25
echo "===== 单个大文件 Top 20(>200M) ====="
find / -xdev -type f -size +200M 2>/dev/null -printf '%s\t%p\n' | sort -rn | head -20 | awk '{printf "%.1fG\t%s\n",$1/1073741824,$2}'
echo "===== 完成 ====="
+39
View File
@@ -0,0 +1,39 @@
name: Diagnose ECS (read-only)
# 只读诊断:排查数字我后端 DB 接口卡死(MongoDB 挂/连不上)。不重启任何服务。
on:
workflow_dispatch:
jobs:
diagnose:
runs-on: ubuntu-latest
steps:
- name: SSH read-only diagnostics
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.ECS_HOST }}
username: ${{ secrets.ECS_USER }}
password: ${{ secrets.ECS_PASSWORD }}
command_timeout: 8m
script: |
echo "===== 时间 ====="; date
echo "===== 磁盘 df -h(磁盘满会让 mongo 卡/只读)====="; df -h | grep -vE "tmpfs|udev|overlay" | head -20
echo "===== 内存 free -h ====="; free -h
echo "===== pm2 list ====="; pm2 list 2>/dev/null | grep -iE "name|digitalme|online|errored|stopped" || pm2 list 2>/dev/null | head -20
echo "===== pm2 describe digitalme-api ====="; pm2 describe digitalme-api 2>/dev/null | grep -iE "status|restart|uptime|memory|cpu|pid|script path|unstable" | head -20
echo "===== 本地 /health(不读库)====="; curl -s -m 8 http://localhost:3603/health; echo
echo "===== 本地 /api/plaza(读库,8s 看是否卡)====="; curl -s -m 8 -o /dev/null -w "plaza -> code=%{http_code} time=%{time_total}s\n" http://localhost:3603/api/plaza 2>&1
echo "===== MongoDB systemd 服务 ====="
systemctl is-active mongod 2>/dev/null && systemctl status mongod --no-pager 2>/dev/null | head -14 || echo "(mongod systemd 非 active 或不存在)"
echo "===== MongoDB docker 容器 ====="
(command -v docker >/dev/null && docker ps -a --format '{{.Names}} | {{.Status}} | {{.Image}}' 2>/dev/null | grep -i mongo) || echo "(无 docker mongo 或无 docker)"
echo "===== 27017 端口监听 ====="; ss -tlnp 2>/dev/null | grep 27017 || echo "⚠ 27017 未监听"
echo "===== .env 里 Mongo 主机(已脱敏密码) ====="; grep -iE "MONGO|MONGODB" /var/www/digitalme-api.invoratec.cn/.env 2>/dev/null | sed -E 's#://[^@ ]*@#://***:***@#g' | head -5 || echo "(未读到)"
echo "===== mongo ping(8s) ====="
if command -v mongosh >/dev/null; then timeout 8 mongosh --quiet --eval "db.adminCommand('ping')" 2>&1 | head -6; \
elif command -v mongo >/dev/null; then timeout 8 mongo --quiet --eval "db.adminCommand('ping')" 2>&1 | head -6; \
else echo "(无 mongosh/mongo 客户端)"; fi
echo "===== API 近 45 行日志(找 Mongo 报错/超时) ====="; pm2 logs digitalme-api --lines 45 --nostream 2>/dev/null | tail -50
echo "===== mongod 日志尾部(如有) ====="; tail -25 /var/log/mongodb/mongod.log 2>/dev/null || echo "(无 /var/log/mongodb/mongod.log)"
echo "===== 系统负载/OOM 迹象 ====="; uptime; dmesg 2>/dev/null | grep -iE "out of memory|killed process|oom" | tail -5 || echo "(dmesg 无 OOM 或无权限)"
echo "===== 诊断完成 ====="
+84
View File
@@ -0,0 +1,84 @@
name: Fix 唐杰→鲁杰 (线上残留文本一次性改名)
# 背景:大师改名(唐杰→鲁杰)是通过后台 PATCH 改【大师档案的 displayName/honorific】完成的,
# 大师页已显示鲁杰。但当初一次性种进库的【文旅行程文本】(itinerary/masterMoments/highlights)、
# 以及 twin 的 characterPrompt/tagline/masterworks、master.intro 里的"唐杰"字面串,改名不会重写。
# 本工作流做一次【外科手术式】替换:只把这些文本字段里的"唐杰"换成"鲁杰",不动其它任何数据、
# 不重建种子(不会丢头像/作品/点赞)。只匹配含"唐杰"的文档,已经干净则自动零改动(可反复安全重跑)。
#
# ⚠ 需要 mongosh 支持 $replaceAll(MongoDB 4.4+)。生产为本地 Mongo,一般满足。
# 跑完会打印每个集合改了几条;若都为 0,说明线上早已干净。
on:
workflow_dispatch:
inputs:
confirm:
description: '输入 RENAME 确认执行(把线上残留“唐杰”改为“鲁杰”)'
required: true
type: string
jobs:
fix:
runs-on: ubuntu-latest
steps:
- name: Replace 唐杰 → 鲁杰 in tours / twins / masters text fields
if: ${{ inputs.confirm == 'RENAME' }}
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.ECS_HOST }}
username: ${{ secrets.ECS_USER }}
password: ${{ secrets.ECS_PASSWORD }}
command_timeout: 5m
script: |
set -e
cd /var/www/digitalme-api.invoratec.cn
[ -f .env ] || { echo "❌ 生产 .env 缺失,中止"; exit 1; }
MONGO_URI="$(grep '^MONGO_URI=' .env | cut -d= -f2- || echo 'mongodb://localhost:27017')"
MONGO_DB="$(grep '^MONGO_DB=' .env | cut -d= -f2- || echo 'creator_cn')"
echo "──── 目标库:$MONGO_DB ────"
mongosh "$MONGO_URI/$MONGO_DB" --quiet --eval '
const FIND = "唐杰", REP = "鲁杰";
const rp = (f) => ({ $replaceAll: { input: { $ifNull: [f, ""] }, find: FIND, replacement: REP } });
// 出现前计数
const before = {
tours: db.tours.countDocuments({ $or: [ {title:/唐杰/}, {masterMoments:/唐杰/}, {highlights:/唐杰/}, {"itinerary.title":/唐杰/}, {"itinerary.desc":/唐杰/} ] }),
twins: db.twins.countDocuments({ $or: [ {displayName:/唐杰/}, {honorific:/唐杰/}, {characterPrompt:/唐杰/}, {tagline:/唐杰/}, {masterworks:/唐杰/} ] }),
masters: db.masters.countDocuments({ $or: [ {intro:/唐杰/}, {offlineCommit:/唐杰/} ] }),
};
print("命中(改前):tours=" + before.tours + " twins=" + before.twins + " masters=" + before.masters);
const rt = db.tours.updateMany(
{ $or: [ {title:/唐杰/}, {masterMoments:/唐杰/}, {highlights:/唐杰/}, {"itinerary.title":/唐杰/}, {"itinerary.desc":/唐杰/} ] },
[ { $set: {
title: rp("$title"),
masterMoments: { $map: { input: { $ifNull: ["$masterMoments", []] }, as: "m", in: rp("$$m") } },
highlights: { $map: { input: { $ifNull: ["$highlights", []] }, as: "h", in: rp("$$h") } },
itinerary: { $map: { input: { $ifNull: ["$itinerary", []] }, as: "d", in: { $mergeObjects: [ "$$d", { title: rp("$$d.title"), desc: rp("$$d.desc") } ] } } }
} } ]
);
const rw = db.twins.updateMany(
{ $or: [ {displayName:/唐杰/}, {honorific:/唐杰/}, {characterPrompt:/唐杰/}, {tagline:/唐杰/}, {masterworks:/唐杰/} ] },
[ { $set: {
displayName: rp("$displayName"), honorific: rp("$honorific"),
characterPrompt: rp("$characterPrompt"), tagline: rp("$tagline"),
masterworks: { $map: { input: { $ifNull: ["$masterworks", []] }, as: "w", in: rp("$$w") } }
} } ]
);
const rm = db.masters.updateMany(
{ $or: [ {intro:/唐杰/}, {offlineCommit:/唐杰/} ] },
[ { $set: { intro: rp("$intro"), offlineCommit: rp("$offlineCommit") } } ]
);
print("已改:tours=" + rt.modifiedCount + " twins=" + rw.modifiedCount + " masters=" + rm.modifiedCount);
const leftT = db.tours.countDocuments({ $or: [ {title:/唐杰/}, {masterMoments:/唐杰/}, {highlights:/唐杰/}, {"itinerary.title":/唐杰/}, {"itinerary.desc":/唐杰/} ] });
const leftW = db.twins.countDocuments({ $or: [ {displayName:/唐杰/}, {honorific:/唐杰/}, {characterPrompt:/唐杰/}, {tagline:/唐杰/}, {masterworks:/唐杰/} ] });
const leftM = db.masters.countDocuments({ $or: [ {intro:/唐杰/}, {offlineCommit:/唐杰/} ] });
print("残留(改后):tours=" + leftT + " twins=" + leftW + " masters=" + leftM + " (应全为 0)");
' || { echo "❌ mongosh 执行失败(可能 Mongo < 4.4 不支持 \$replaceAll,或连接串不对)"; exit 1; }
echo "✅ 完成。刷新文旅路线详情页,D4 应显示「鲁杰·营造之道」。"
- name: Confirmation missing
if: ${{ inputs.confirm != 'RENAME' }}
run: |
echo "未执行:confirm 必须精确输入 RENAME。"
exit 1
+106
View File
@@ -0,0 +1,106 @@
name: Grant Super Admin (设超管 · 可带外建号)
# 超管不是"某个预置账号+密码"——代码里没有种子管理员、密码全是 salt+hash 不可反推。
# 超管 = .env 的 SUPER_ADMIN_EMAILS 白名单:【白名单里的邮箱,谁拥有这个账号,谁就是超管】。
#
# ⚠ 重要(安全策略变更后的正确顺序):
# 为堵住"管理员邮箱可被任何人抢注→拿全部后台权限"的漏洞,后端【已禁止用白名单邮箱自助注册】。
# 所以【先加白名单、再去注册】这条老路已经走不通(注册会被 403 挡掉)。
# 现在有两条路:
# A) 已有账号:你的邮箱【已经】在平台注册过 → 直接跑本工作流加白名单 → 重新登录即为超管。
# B) 没有账号:勾选 create_or_reset_account → 本工作流【带外】在库里建号/重置密码(密码取自
# 仓库 Secret: ADMIN_INIT_PASSWORD)→ 加白名单 → 用该邮箱+该密码登录即为超管。
#
# 走 B 之前请先在 GitHub 仓库 Settings → Secrets and variables → Actions 添加:
# ADMIN_INIT_PASSWORD = 你的强密码(≥8位)。用 Secret 而不是工作流输入框——输入框的值会明文
# 留在 Actions 运行记录里,Secret 会在日志中被自动打码。
#
# 前置:后端需已部署过(含 scripts/create-admin.js)。若提示脚本不存在,先跑一次后端部署工作流。
on:
workflow_dispatch:
inputs:
admin_email:
description: '要设为超管的邮箱(用你自己的)'
required: true
type: string
create_or_reset_account:
description: '带外建号/重置密码(账号还不存在、或忘了密码时勾选;密码取自 Secret ADMIN_INIT_PASSWORD)'
required: false
default: false
type: boolean
delete_orphan_admin:
description: '同时删除误建的孤儿账号 admin@test.com(密码随机无人知道,建议删)'
required: false
default: true
type: boolean
jobs:
grant:
runs-on: ubuntu-latest
steps:
- name: Set SUPER_ADMIN_EMAILS (+ optional out-of-band account provisioning)
uses: appleboy/ssh-action@v1
env:
ADMIN_EMAIL: ${{ inputs.admin_email }}
ADMIN_PASSWORD: ${{ secrets.ADMIN_INIT_PASSWORD }}
DO_CREATE: ${{ inputs.create_or_reset_account }}
DO_DELETE_ORPHAN: ${{ inputs.delete_orphan_admin }}
with:
host: ${{ secrets.ECS_HOST }}
username: ${{ secrets.ECS_USER }}
password: ${{ secrets.ECS_PASSWORD }}
command_timeout: 5m
envs: ADMIN_EMAIL,ADMIN_PASSWORD,DO_CREATE,DO_DELETE_ORPHAN
script: |
set -e
cd /var/www/digitalme-api.invoratec.cn
[ -f .env ] || { echo "❌ 生产 .env 缺失,中止"; exit 1; }
echo "──── 当前 SUPER_ADMIN_EMAILS ────"
grep '^SUPER_ADMIN_EMAILS=' .env || echo "(未设置 → 现在没有任何人是超管)"
EMAIL="$(echo "$ADMIN_EMAIL" | tr '[:upper:]' '[:lower:]' | tr -d ' ')"
if [ -z "$EMAIL" ]; then echo "❌ 邮箱为空"; exit 1; fi
# ── 带外建号/重置密码(账号不存在时的唯一入口,因为白名单邮箱已禁止自助注册) ──
if [ "$DO_CREATE" = "true" ]; then
if [ -z "$ADMIN_PASSWORD" ]; then
echo "❌ 勾了「带外建号」但仓库 Secret ADMIN_INIT_PASSWORD 未设置。"
echo " 去 GitHub → Settings → Secrets and variables → Actions 添加 ADMIN_INIT_PASSWORD(≥8位) 再重跑。"
exit 1
fi
[ -f scripts/create-admin.js ] || { echo "❌ 服务器上没有 scripts/create-admin.js —— 请先跑一次【后端部署】工作流再来。"; exit 1; }
echo "──── 带外建号/重置密码 ────"
ADMIN_EMAIL="$EMAIL" ADMIN_PASSWORD="$ADMIN_PASSWORD" node scripts/create-admin.js
fi
# ── 写入/覆盖超管白名单(只放这一个邮箱,最小权限) ──
if grep -q '^SUPER_ADMIN_EMAILS=' .env; then
sed -i "s|^SUPER_ADMIN_EMAILS=.*|SUPER_ADMIN_EMAILS=$EMAIL|" .env
else
echo "SUPER_ADMIN_EMAILS=$EMAIL" >> .env
fi
echo "──── 写入后 ────"
grep '^SUPER_ADMIN_EMAILS=' .env
# 删掉被误建的孤儿账号 admin@test.com(随机密码、无人知道、白占邮箱)
if [ "$DO_DELETE_ORPHAN" = "true" ] && [ "$EMAIL" != "admin@test.com" ]; then
MONGO_URI="$(grep '^MONGO_URI=' .env | cut -d= -f2- || echo 'mongodb://localhost:27017')"
MONGO_DB="$(grep '^MONGO_DB=' .env | cut -d= -f2- || echo 'creator_cn')"
echo "──── 删除孤儿账号 admin@test.com ────"
mongosh "$MONGO_URI/$MONGO_DB" --quiet --eval '
const u = db.users.findOne({ email: "admin@test.com" });
if (!u) { print("admin@test.com 不存在,无需清理"); }
else {
const r = db.users.deleteOne({ _id: u._id });
print("已删除 admin@test.com,deletedCount=" + r.deletedCount);
}
' || echo "⚠ mongosh 执行失败(可手动清理),不影响超管设置"
fi
# 重启后端让新的 .env 生效
pm2 restart digitalme-api --update-env
pm2 describe digitalme-api | grep -E 'status|restarts' || true
echo "✅ 完成。用 $EMAIL 登录 → 即为超管(右上角出现盾牌图标 / 可进 /admin)。"
echo " 若刚带外建号:用你在 Secret ADMIN_INIT_PASSWORD 里设的密码登录,登录后建议立刻改密。"
+61
View File
@@ -0,0 +1,61 @@
name: nginx serve /uploads direct (audio speed)
# 让 nginx 直接吐 /uploads(音频),绕过 Node → 首播更快、sendfile 高效、长效不可变缓存。
# 只影响公开的【试听】音频(满血走 Node 的签名/令牌口,不在 /uploads)。
# ★安全:保留首次原始备份;精确定位 443 server 块插入;幂等(先删旧再插);nginx -t + 自检失败一律还原,绝不弄坏线上。
on:
push:
paths: ['.github/workflows/nginx-uploads-direct.yml']
workflow_dispatch: {}
jobs:
patch-nginx:
runs-on: ubuntu-latest
steps:
- name: Add location /uploads to API nginx (robust+guarded)
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.ECS_HOST }}
username: ${{ secrets.ECS_USER }}
password: ${{ secrets.ECS_PASSWORD }}
command_timeout: 5m
script: |
set -e
CONF=$(grep -rl "digitalme-api.invoratec.cn" /etc/nginx 2>/dev/null | head -1)
echo "conf=$CONF"; [ -n "$CONF" ] || { echo "❌ 找不到 API nginx conf"; exit 1; }
UP=/var/www/digitalme-api.invoratec.cn/data/uploads
[ -d "$UP" ] || { echo "❌ 上传目录不存在: $UP"; exit 1; }
BAK="$CONF.orig-bak"; [ -f "$BAK" ] || cp "$CONF" "$BAK" # 只保留【首次】原始备份
SAFE="$CONF.pre-uploads.$(date +%s)"; cp "$CONF" "$SAFE" # 本次回滚点
python3 - "$CONF" "$UP" <<'PY'
import sys, re
conf, up = sys.argv[1], sys.argv[2]
s = open(conf, encoding='utf-8').read()
# 1) 幂等:先删掉已有的 location /uploads/ {...}(本模块的块无嵌套花括号)
s = re.sub(r'\n?\s*location\s+/uploads/\s*\{[^{}]*\}\n?', '\n', s)
# 2) 精确定位含 listen ... 443 的 server 块:找到 443 的 listen,再回溯它所属的 server 的 '{'
m = re.search(r'listen[^;]*443', s)
if not m: print('NO_443'); sys.exit(2)
srv = s.rfind('server', 0, m.start())
if srv < 0: print('NO_SERVER'); sys.exit(3)
brace = s.find('{', srv)
if brace < 0: print('NO_BRACE'); sys.exit(4)
block = ('\n location /uploads/ {\n'
' alias %s/;\n'
' add_header Cache-Control "public, max-age=31536000, immutable";\n'
' access_log off;\n'
' try_files $uri =404;\n'
' }\n') % up
open(conf, 'w', encoding='utf-8').write(s[:brace+1] + block + s[brace+1:])
print('inserted into 443 server block at', brace)
PY
if ! nginx -t; then echo "❌ nginx -t 失败,还原本次"; cp "$SAFE" "$CONF"; nginx -t; exit 1; fi
nginx -s reload; sleep 1
F=$(ls -1 "$UP"/songprev-* 2>/dev/null | head -1 | xargs -r basename)
if [ -n "$F" ]; then
H=$(curl -sI "https://digitalme-api.invoratec.cn/uploads/$F" 2>/dev/null)
CODE=$(echo "$H" | head -1)
echo "自检: $CODE"; echo "$H" | grep -iq "x-request-id" && echo "⚠ 仍是 Node 在吐(未直吐)" || echo "✓ 已由 nginx 直吐(无 x-request-id)"
echo "$CODE" | grep -qE " 200| 206" || { echo "❌ 自检非2xx,还原"; cp "$SAFE" "$CONF"; nginx -s reload; exit 1; }
fi
echo "✓ done"
+84
View File
@@ -0,0 +1,84 @@
name: Purge Twins (删测试分身)
# 按名字/slug 删掉指定分身(测试号留下的垃圾数据)。
#
# 为什么要走工作流:生产库在共享 ECS 上、不对外开放,本地连不上。
# 为什么不手写 deleteOne:分身的数据散在 12+ 个集合里(知识/记忆/消息/会话/分享/
# 作品/问答卡/课堂参与/排期报名/付费解锁/钱包/大师档案…)。这里【复用后端自己的
# deleteTwin()】,跟用户在页面上点"删除分身"走完全同一条级联,不留孤儿数据。
#
# 默认 dry_run=true:先只打印"会删掉谁、连带多少条数据",确认无误再关掉 dry_run 真删。
on:
workflow_dispatch:
inputs:
targets:
# 已预填老板确认过的 5 个测试分身(竹子=测试号4 的私密分身;其余 4 个是挂在广场上的垃圾)。
# ⚠ 不要往里加 夏晚/陈九/林医生/阿May/阿正/大鹏/林小满/小野/苏晴/老周 ——
# 那 10 个是 scripts/seed-masters.js 种的【演示分身】,是广场的门面,删了广场就空了。
description: '要删的分身,填 slug 或显示名,逗号分隔'
required: true
default: '竹子,1234,社交测试89tuu7ds,11-2,12'
type: string
dry_run:
description: '试运行:只打印会删什么,不真删(想先看一眼就勾上)'
required: false
default: false
type: boolean
jobs:
purge:
runs-on: ubuntu-latest
steps:
- name: Purge
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.ECS_HOST }}
username: ${{ secrets.ECS_USER }}
password: ${{ secrets.ECS_PASSWORD }}
command_timeout: 10m
envs: TARGETS,DRY_RUN
script: |
set -e
cd /var/www/digitalme-api.invoratec.cn
[ -f .env ] || { echo "❌ 生产 .env 缺失,中止"; exit 1; }
TARGETS="${{ inputs.targets }}" DRY_RUN="${{ inputs.dry_run }}" node --input-type=module -e '
import { connectDb, getDb } from "./src/db.js";
import { deleteTwin } from "./src/services/twin.js";
const dry = String(process.env.DRY_RUN) !== "false";
const names = String(process.env.TARGETS || "").split(",").map(s => s.trim()).filter(Boolean);
if (!names.length) { console.log("没填 targets,什么都不做"); process.exit(0); }
await connectDb();
const db = getDb();
// slug 或 displayName 精确匹配(不做模糊,免得误伤同名的真实用户)。
const twins = await db.collection("twins")
.find({ $or: [{ slug: { $in: names } }, { displayName: { $in: names } }] })
.toArray();
const found = new Set(twins.flatMap(t => [t.slug, t.displayName]));
for (const n of names) if (!found.has(n)) console.log(`⚠ 没找到:「${n}」(跳过)`);
if (!twins.length) { console.log("一个都没匹配上,退出"); process.exit(0); }
console.log(dry ? "\n=== 试运行(不会真删) ===\n" : "\n=== 真删 ===\n");
for (const t of twins) {
const owner = t.ownerId ? await db.collection("users").findOne({ _id: t.ownerId }) : null;
// 顺带报一下这个分身身上挂了多少数据,好确认"确实是个空壳测试号"。
const [kn, msg, mem] = await Promise.all([
db.collection("knowledge").countDocuments({ twinId: t._id }),
db.collection("messages").countDocuments({ twinId: t._id }),
db.collection("memories").countDocuments({ twinId: t._id }),
]);
console.log(`【${t.displayName}】slug=${t.slug} 公开=${!!t.isPublic}`);
console.log(` 主人: ${owner?.email || owner?.phone || "(无主/已注销)"} 建于: ${t.createdAt?.toISOString?.().slice(0, 10) || "?"}`);
console.log(` 知识 ${kn} 条 / 对话 ${msg} 条 / 记忆 ${mem} 条`);
if (dry) { console.log(" → 试运行,未删\n"); continue; }
await deleteTwin(t._id.toString(), t.ownerId);
console.log(" → 已删(级联清干净)\n");
}
console.log(dry ? "试运行结束。确认无误后,把 dry_run 关掉再跑一次即可真删。" : `完成,删除 ${twins.length} 个分身。`);
process.exit(0);
'
+64
View File
@@ -0,0 +1,64 @@
name: Recover DB (conditional mongo restart)
# 磁盘已腾出,但满盘期间 MongoDB 可能卡进坏状态。先用应用自带 driver ping:
# 卡死/失败 → 重启本机 mongod(共享库,恢复所有连库产品);健康 → 只重启本产品 API 清连接池。
# 顺带装 pm2-logrotate,防止失控日志再次撑爆磁盘。
on:
workflow_dispatch:
jobs:
recover:
runs-on: ubuntu-latest
steps:
- name: SSH recover DB + prevent log blowup
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.ECS_HOST }}
username: ${{ secrets.ECS_USER }}
password: ${{ secrets.ECS_PASSWORD }}
command_timeout: 10m
script: |
echo "========== 当前磁盘 =========="; df -h / | tail -1
echo "========== 0) 装 pm2-logrotate 防再次爆盘(幂等) =========="
pm2 install pm2-logrotate 2>&1 | tail -2 || echo " (logrotate 安装跳过)"
pm2 set pm2-logrotate:max_size 50M 2>/dev/null
pm2 set pm2-logrotate:retain 3 2>/dev/null
pm2 set pm2-logrotate:compress true 2>/dev/null
echo " pm2-logrotate: max_size=50M retain=3 compress=on"
echo "========== 1) 用应用 driver ping MongoDB(8s 超时) =========="
cd /var/www/digitalme-api.invoratec.cn
PING=$(timeout 14 node -e '
const { MongoClient } = require("mongodb");
(async () => {
const c = new MongoClient("mongodb://localhost:27017", { serverSelectionTimeoutMS: 8000 });
try { await c.connect(); await c.db("admin").command({ ping: 1 }); console.log("MONGO_OK"); }
catch (e) { console.log("MONGO_FAIL " + e.message); }
finally { try { await c.close(); } catch (_) {} }
})();
' 2>&1 | grep -aE "MONGO_OK|MONGO_FAIL" | head -1)
echo " ping 结果: [$PING]"
if echo "$PING" | grep -q "MONGO_OK"; then
echo "========== 2) Mongo 健康 → 只重启本产品 API 清连接池 =========="
pm2 restart digitalme-api --update-env 2>/dev/null | tail -2
else
echo "========== 2) Mongo 卡死/无响应 → 重启本机 mongod =========="
if systemctl list-units --type=service 2>/dev/null | grep -q mongod; then
systemctl restart mongod 2>&1 | tail -3; sleep 8
systemctl is-active mongod 2>/dev/null && echo " mongod active" || echo " ⚠ mongod 未 active"
elif command -v docker >/dev/null && docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qi mongo; then
MC=$(docker ps -a --format '{{.Names}}' | grep -i mongo | head -1); echo " docker restart $MC"; docker restart "$MC" 2>&1 | tail -2; sleep 8
else
echo " ❌ 未识别 mongod 服务(既非 systemd 也非 docker),需人工处理"
fi
echo "========== 3) 重启本产品 API =========="
pm2 restart digitalme-api --update-env 2>/dev/null | tail -2
fi
sleep 8
echo "========== 4) 验证 =========="
curl -s -m 8 -o /dev/null -w " /health -> code=%{http_code} time=%{time_total}s\n" http://localhost:3603/health
curl -s -m 12 -o /dev/null -w " /api/plaza -> code=%{http_code} time=%{time_total}s\n" http://localhost:3603/api/plaza
curl -s -m 12 -o /dev/null -w " /api/masters-> code=%{http_code} time=%{time_total}s\n" http://localhost:3603/api/masters
echo "========== pm2 关键进程状态 =========="
pm2 list 2>/dev/null | grep -iE "digitalme-api|mongo" || true
echo "========== 最终磁盘 =========="; df -h / | tail -1
echo "========== 完成 =========="
+50
View File
@@ -0,0 +1,50 @@
name: Repair Disk (safe cleanup)
# 根分区 100% 满导致 MongoDB 写卡死。只清「可再生垃圾」:失控日志/pm2日志/journal/npm缓存/旧nginx日志。
# 绝不触碰:数据库文件、用户媒体、/var/www 站点、/var/lib/docker、/data。清完测 DB 恢复,必要时只重启本产品 API。
on:
workflow_dispatch:
jobs:
repair:
runs-on: ubuntu-latest
steps:
- name: SSH safe disk cleanup + verify recovery
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.ECS_HOST }}
username: ${{ secrets.ECS_USER }}
password: ${{ secrets.ECS_PASSWORD }}
command_timeout: 10m
script: |
echo "========== 清理前 df =========="; df -h / | tail -1
echo "========== 1) 截断失控的超大 pm2 stdout 日志(>500M,只清空不删文件) =========="
for f in ~/.pm2/logs/*.log; do
[ -f "$f" ] || continue
sz=$(stat -c%s "$f" 2>/dev/null || echo 0)
if [ "$sz" -gt 524288000 ]; then echo " 清空 $(du -h "$f" | cut -f1) $f"; : > "$f"; fi
done
echo "========== 2) pm2 flush(清其余各产品日志) =========="
pm2 flush 2>/dev/null && echo " pm2 flush done" || echo " (pm2 flush 跳过)"
echo "========== 3) journal 收缩到 100M =========="
journalctl --vacuum-size=100M 2>&1 | tail -2 || echo " (无 journalctl)"
echo "========== 4) npm 缓存清理(可再生) =========="
npm cache clean --force 2>/dev/null && echo " npm cache cleaned" || echo " (npm cache 跳过)"
echo "========== 5) 旧的 rotated nginx 日志(*.gz / *.N) =========="
find /var/log/nginx -type f \( -name "*.gz" -o -regex ".*\.[0-9]+" \) -delete 2>/dev/null && echo " 旧 nginx 日志已清" || echo " (无)"
echo "========== 清理后 df =========="; df -h / | tail -1
echo "========== 6) MongoDB ping =========="
if command -v mongosh >/dev/null; then timeout 10 mongosh --quiet --eval "db.adminCommand('ping')" 2>&1 | head -4; \
elif command -v mongo >/dev/null; then timeout 10 mongo --quiet --eval "db.adminCommand('ping')" 2>&1 | head -4; \
else echo " (无 mongo 客户端,跳过 ping)"; fi
echo "========== 7) 本地读库接口测试(10s) =========="
curl -s -m 10 -o /dev/null -w " plaza -> code=%{http_code} time=%{time_total}s\n" http://localhost:3603/api/plaza 2>&1
CODE=$(curl -s -m 10 -o /dev/null -w "%{http_code}" http://localhost:3603/api/plaza 2>/dev/null)
if [ "$CODE" != "200" ]; then
echo "========== 8) plaza 仍非200,重启本产品 API 清连接池 =========="
pm2 restart digitalme-api --update-env 2>/dev/null | tail -3; sleep 7
curl -s -m 15 -o /dev/null -w " plaza(重启后) -> code=%{http_code} time=%{time_total}s\n" http://localhost:3603/api/plaza 2>&1
else
echo " ✓ 读库接口已恢复 200,无需重启"
fi
echo "========== 完成 =========="
+26
View File
@@ -0,0 +1,26 @@
name: Seed Master Wall (manual)
# 手动触发:把「数字大师墙」三城样板(成都/苏州/徽州 · 7 位大师 + 3 条路线)
# 灌进生产库。幂等(只清同 seedTag 的旧种子,不动其它数据)。
# 前置:后端已部署(scripts/seed-masters.js 已在服务器)。
on:
workflow_dispatch:
jobs:
seed:
runs-on: ubuntu-latest
steps:
- name: Run seed on ECS
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.ECS_HOST }}
username: ${{ secrets.ECS_USER }}
password: ${{ secrets.ECS_PASSWORD }}
command_timeout: 5m
script: |
cd /var/www/digitalme-api.invoratec.cn
[ -f .env ] || { echo "❌ 生产 .env 缺失,中止"; exit 1; }
[ -f scripts/seed-masters.js ] || { echo "❌ scripts/seed-masters.js 未部署,请先合并后端部署"; exit 1; }
node scripts/seed-masters.js
echo "=== 校验:大师墙接口 ==="
curl -sf "http://localhost:3603/api/masters" | head -c 400 || echo "(接口检查失败)"
+30
View File
@@ -0,0 +1,30 @@
name: set admin password (manual)
# 已于 2026-08-02 执行一次:把生产超管口令从默认 admin123 改成强口令(堵公网默认口令洞)。
# ★口令已抹除、只保留手动触发(不再随推送自动跑,避免误改回)。要再改口令:
# 把下面 PW='...' 填成新口令,在 Actions 页手动 Run 一次,跑完再抹掉。
on:
workflow_dispatch: {}
jobs:
setpw:
runs-on: ubuntu-latest
steps:
- name: Write ADMIN_PASS/ADMIN_USER to prod .env + restart
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.ECS_HOST }}
username: ${{ secrets.ECS_USER }}
password: ${{ secrets.ECS_PASSWORD }}
command_timeout: 5m
script: |
set -e
cd /var/www/digitalme-api.invoratec.cn
[ -f .env ] || { echo "❌ 生产 .env 缺失"; exit 1; }
PW='__FILL_NEW_PASSWORD__'
[ "$PW" = '__FILL_NEW_PASSWORD__' ] && { echo "未填新口令,退出(避免误改)"; exit 0; }
if grep -q '^ADMIN_PASS=' .env; then sed -i "s#^ADMIN_PASS=.*#ADMIN_PASS=$PW#" .env; else echo "ADMIN_PASS=$PW" >> .env; fi
grep -q '^ADMIN_USER=' .env || echo "ADMIN_USER=admin" >> .env
pm2 restart digitalme-api --update-env
sleep 5
H=$(curl -sf http://localhost:3603/health || true); echo "$H" | grep -q '"ok":true' && echo "✓ 已带新口令重启" || { echo "❌ 后端没起来"; exit 1; }
+36
View File
@@ -0,0 +1,36 @@
# Dependencies
node_modules/
# Secrets — never commit. (.env holds the DashScope key etc.)
.env
.env.*
!.env.example
!.env.docker.example
**/.env
!**/.env.example
# Build and package output
dist/
/output/
*.tsbuildinfo
*.rar
__pycache__/
*.py[cod]
# Runtime uploads (user media — avatars, voice samples, talking videos)
**/data/uploads/
# Project-local working material
/tmp/
/客户成片/
# Logs / OS cruft
*.log
.DS_Store
Thumbs.db
# Local development runtime state
/.local-logs/
/.mongo-binaries/
/.mongo-data/
/.npm-cache/
/.pnpm-store/
+29
View File
@@ -0,0 +1,29 @@
# Repository Guidelines
## Project Structure & Module Organization
This workspace contains two Node.js 20+ applications. `digital-me-backend/` is an Express API: runtime code is under `src/`, HTTP handlers under `src/routes/`, middleware under `src/middleware/`, shared helpers under `src/lib/`, operational utilities under `scripts/`, and Node tests under `test/`. `digital-me-web/` is a Vue 3, TypeScript, and Vite SPA. Its route-level screens live in `src/views/`, reusable UI in `src/components/`, Pinia state in `src/stores/`, composables in `src/composables/`, and tests in `test/`. Treat `dist/`, `output/`, `tmp/`, and `.pnpm-store/` as generated or local-only. Supporting material belongs in `docs/` and `artifacts/`.
## Build, Test, and Development Commands
Run commands from the relevant application directory after `npm ci`:
- Backend: `npm run dev` starts the API in watch mode; `npm start` runs it normally; `npm test` executes all `node:test` tests.
- Frontend: `npm run dev` starts Vite; `npm run type-check` validates Vue/TypeScript; `npm run build` type-checks and writes `dist/`; `npm run preview` serves that build.
- Frontend tests: `node --experimental-strip-types --test test/*.test.ts` runs the current suite because no `npm test` script is defined.
## Coding Style & Naming Conventions
Preserve the established two-space indentation, semicolons, ES modules, and single-quoted strings. Backend files use camelCase names and exports; route modules should remain thin and move reusable logic into `src/lib/` or `src/services/`. Vue components and views use PascalCase, composables use the `useThing.ts` pattern, and other TypeScript identifiers use camelCase. Prefer `@/` imports in the frontend. No repository-wide formatter or linter is configured, so match nearby code and rely on type checking.
## Testing Guidelines
Use `node:test` with `node:assert/strict`. Name tests `*.test.js` in the backend and `*.test.ts` in the frontend. Add focused regression coverage near the affected domain, including failure and authorization cases for API changes. No coverage threshold is configured; run the relevant suite and frontend type check before review.
## Commit & Pull Request Guidelines
Git history is unavailable in this workspace, so no local convention can be verified. Use concise imperative subjects, optionally `feat:`, `fix:`, `docs:`, or `test:`. Pull requests should explain impact, list verification commands, link issues, and include before/after screenshots for UI work. Avoid generated files and unrelated refactors.
## Configuration & Security
Copy each app's `.env.example` to `.env`. Never commit credentials, tokens, or production endpoints; frontend `VITE_` values are public. Backend development requires MongoDB, and AI/media features may require provider keys and a publicly reachable `PUBLIC_URL`.
Executable
+262
View File
@@ -0,0 +1,262 @@
# 数字我 / Digital Me — 部署清单(DEPLOY)
> 目标:把 `AlexLi2244/digital-me` 上线到阿里云 ECS(已配 CI/CD:push `master` 自动部署)。
## 0. 架构与端口
| 组件 | 位置 | 说明 |
|---|---|---|
| 后端 API | ECS `/var/www/digitalme-api.invoratec.cn`,PM2 `digitalme-api`,**端口 3603** | Node20+Express |
| 前端 SPA | ECS `/var/www/digitalme-spa`,Nginx 静态 | Vue3 build |
| 域名 | API `digitalme-api.invoratec.cn` / 前端 `digitalme.invoratec.cn` | 需各自 DNS + 证书 |
| 数据库 | MongoDB `creator_cn` 库 + **`COLLECTION_PREFIX=dm_`** 隔离 | 与其它产品共库不撞表 |
| 媒体 | 配齐 `OSS_*` → 阿里云 OSS(推荐);否则后端 `data/uploads`(Nginx 经 API 暴露 `/uploads`) | 见下方 `.env` OSS 段 |
> CI/CD `rm:false` 保留服务器上的 `.env / node_modules / data`——**`.env` 只在服务器上手建一次**,不进仓库。
## 1. GitHub 仓库 Secrets(一次)
Settings → Secrets and variables → Actions:
- `ECS_HOST` `ECS_USER` `ECS_PASSWORD`
## 2. 服务器一次性准备
```bash
# Node 20 + PM2
node -v # >=20;如无:nvm install 20
npm i -g pm2
# 目录
mkdir -p /var/www/digitalme-api.invoratec.cn /var/www/digitalme-spa
# MongoDB:复用现有 creator_cn 库即可(dm_ 前缀隔离),或自建 digital_me 库
```
### 后端 `.env`(手建于 `/var/www/digitalme-api.invoratec.cn/.env`)
```ini
NODE_ENV=production
PORT=3603
PUBLIC_URL=https://digitalme-api.invoratec.cn # 必须公网可达(捏脸/声音/口播/作品 靠它回拉)
CORS_ORIGINS=https://digitalme.invoratec.cn
WEB_BASE_URL=https://digitalme.invoratec.cn # 支付后跳回
# 安全(生产强制:JWT_SECRET 缺省会拒绝启动)
JWT_SECRET=<强随机 32+ 位>
DATA_ENC_KEY=<强随机> # 访客记录加密,强烈建议
SUPER_ADMIN_EMAILS=lu.huang0912@gmail.com # 蓝V 认证后台
TRUST_PROXY=true # 在 Nginx 后,取真实 IP
# 数据库(共库方案)
MONGO_URI=mongodb://127.0.0.1:27017
MONGO_DB=creator_cn
COLLECTION_PREFIX=dm_
# AI(DashScope 一把钥匙)
DASHSCOPE_API_KEY=sk-...
AVATAR_PROVIDER=dashscope
MODERATION_MODE=llm # 用 Qwen 做审核(或接 MODERATION_URL 专业服务)
# 媒体存储(阿里云 OSS,推荐):配齐即自动用 OSS,否则回落本地磁盘 + /uploads。
# OSS 直链公网可达 → 免公网穿透、重部署不丢片、DashScope 直接按 URL 拉头像/音频。
OSS_REGION=oss-cn-hangzhou # 必带 oss- 前缀
OSS_BUCKET=invoratec-aimall # 复用共享 bucket,靠 OSS_PREFIX 隔离
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
OSS_PREFIX=digitalme
# bucket 非「公开读」时,填 CDN/自定义域名,否则存库的头像直链取不到:
# OSS_PUBLIC_BASE=https://cdn.invoratec.cn
# 支付(先 stub,拿到密钥后切 stripe/wechat)
PAYMENT_PROVIDER=stub
TOKENS_PER_YUAN=10
# STRIPE_SECRET_KEY=sk_live_...
# STRIPE_WEBHOOK_SECRET=whsec_...
```
> 改完 `.env` 需 `pm2 restart digitalme-api` 才生效(CI/CD 也会重启)。
### Nginx(两个 server 块)
```nginx
# 后端 API
server {
server_name digitalme-api.invoratec.cn;
client_max_body_size 30m; # 头像/录音/试卷上传
location / { proxy_pass http://127.0.0.1:3603; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; }
# Stripe webhook 需原始 body——proxy 默认即可,勿在此改写 body。
# listen 443 ssl; + 证书
}
# 前端 SPA
server {
server_name digitalme.invoratec.cn;
root /var/www/digitalme-spa; index index.html;
location / { try_files $uri $uri/ /index.html; } # history 路由
# listen 443 ssl; + 证书
}
```
证书:`certbot --nginx -d digitalme-api.invoratec.cn -d digitalme.invoratec.cn`。
### 分享卡片 og meta(微信/IM 富卡片)——默认休眠,按需启用
分身分享链接 `/me/:slug`、`/s/:token` 甩进微信时,抓取器只读静态 HTML、不跑 JS,
默认卡片没有对方头像/名字。后端已内置 SSR:`src/routes/share.js` 会给这两个路径注入
per-分身 的 og meta(头像+名字+一句话)。**代码常驻但默认不接**,启用只需两步:
1. **后端 `.env` 增一行**(让它注入真 SPA index、页面仍是完整 App):
```ini
SPA_INDEX_PATH=/var/www/digitalme-spa/index.html
# 可选:无头像时的兜底卡片图(绝对 URL)
# OG_FALLBACK_IMAGE=https://digitalme.invoratec.cn/og-default.png
```
不填 `SPA_INDEX_PATH` 也能用——回落成「meta + 跳转到 web 页」的独立壳(爬虫拿卡片、
真人被转走)。`pm2 restart digitalme-api` 生效。
2. **前端 SPA 的 Nginx server 块**:把这两个路径的文档请求回源到后端,其余静态照旧:
```nginx
server {
server_name digitalme.invoratec.cn;
root /var/www/digitalme-spa; index index.html;
# 分享页文档 → 后端注入 og meta(资产 /assets 仍走下面的静态)
location ~ ^/(me|s)/ { proxy_pass http://127.0.0.1:3603; proxy_set_header Host $host; }
location / { try_files $uri $uri/ /index.html; } # history 路由
}
```
注入的 index.html 保留原 `/assets/...` 相对引用 → 浏览器仍从 web 域名静态取,App 照常启动。
> 先验证:改前直接打 API 域名 `https://digitalme-api.invoratec.cn/me/<slug>` 就能看到注入了 og:title/og:image 的 HTML(此时资产指向 API 域名会 404,仅用于验证卡片数据)。
### 微信「转发给朋友/朋友圈」自定义卡片(JS-SDK)——默认休眠,需公众号资质
上面的 og meta 解决**被动抓取**卡片;微信里点「•••→转发给朋友/分享到朋友圈」时的**自定义**
卡片(自定义标题/描述/缩略图)要用 JS-SDK。代码已内置、默认休眠,启用三步:
1. **有一个认证公众号(服务号最佳)**,拿到它的 appId + appSecret。
2. **后端 `.env` 增两行**(是【公众号】的,不是开放平台扫码登录那对):
```ini
WX_MP_APP_ID=wx____
WX_MP_APP_SECRET=____
```
`pm2 restart digitalme-api`。签名接口 `GET /api/wx/jssdk?url=` 即从 `{configured:false}` 变为返回真签名。
3. **公众号后台 → 设置 → 公众号设置 → 功能设置**:把 `digitalme.invoratec.cn` 填进
**「JS 接口安全域名」**(需下载校验文件放到 SPA 根目录,Nginx 能返回即可)。
前端 `composables/useWxShare.ts` 只在**微信内置浏览器**且接口 `configured:true` 时才加载 jweixin
并注册分享卡;非微信/未配资质全程 no-op。已接入 `/me/:slug`、`/s/:token` 两个公开页,
分享卡自动用分身的名字+一句话+头像。
> CSP 注意:SPA 若加了 CSP,`script-src` 要放行 `https://res.wx.qq.com`(jweixin 从这里加载)。当前 Nginx 静态无 CSP,暂无需改。
## 3. 部署(日常)
**push `master` 即自动部署**(改 `digital-me-backend/**` 触发后端、`digital-me-web/**` 触发前端)。
- 前端 CI 用 `build:fast`(跳过 vue-tsc);本地请先 `npm run build` 过一遍严格类型检查再推。
- 手动兜底:后端 scp `src/ package*.json` 到目标目录 → `npm install --production` → `pm2 restart digitalme-api`;前端 `npm ci && npm run build:fast` → scp `dist/*` 到 `/var/www/digitalme-spa` → `nginx -s reload`。
## 4. 上线后冒烟(逐条核对)
```bash
API=https://digitalme-api.invoratec.cn
curl -s $API/health # {ok:true}
# 注册→建分身→喂知识→对话(带 RAG 来源)
# 公开页 https://digitalme.invoratec.cn/me/<slug> 能聊
# 广场 /plaza、排行榜 /leaderboard 可访问
```
- [ ] `/health` 通
- [ ] 注册/登录、建分身、喂知识、对话有来源
- [ ] 形象/声音/口播(确认 `PUBLIC_URL` 公网可达,DashScope 能回拉)
- [ ] 社交卡片 `/s/:token`、广场 `/plaza`、互考 `/challenge/:slug`、排行榜
- [ ] 群聊 `/room/:token`、错题反考、作品集内联展示
- [ ] 钱包充值(stub 即时)、专家付费咨询扣费+分成、免责声明
- [ ] 超管 `/admin` 可授蓝V(你的邮箱在 `SUPER_ADMIN_EMAILS`)
- [ ] 内容审核拦截、限流 429、账号注销物理删除
## 5. 支付正式上线(拿到密钥后)
- **Stripe**:填 `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET`、`PAYMENT_PROVIDER=stripe`;
Stripe 后台 Webhook 指向 `https://digitalme-api.invoratec.cn/api/wallet/webhook/stripe`,
事件选 `checkout.session.completed`。重启后端。无需改代码。
- **微信支付**:拿到商户号后在 `services/payments.js` 的 `wechat` 分支补 Native/H5 下单 +
回调验签(结构与 Stripe 一致),`PAYMENT_PROVIDER=wechat`。
## 5b. 两个独立系统(成人 / 青少年)
同一套代码,用 `EDITION` 锁定为单一系统,**各自独立部署**(独立域名 + 独立 DB + 独立用户体系):
| 系统 | EDITION | 功能 | 建议域名 / DB |
|---|---|---|---|
| 成人系统 | `adult` | 社交分身/广场/群聊/专家付费/钱包 + 建分身/对话 | `digitalme.invoratec.cn` / `MONGO_DB=digital_me_adult`(或 `COLLECTION_PREFIX=dma_`)|
| 青少年系统 | `teen` | 错题反考/学友互考/排行榜/家长监护 + 建分身/对话 | `edu.digitalme.invoratec.cn` / `MONGO_DB=digital_me_teen`(或 `dmt_`)|
每个系统 = 一套后端(PM2 各自端口) + 一套前端 build。前端 build 时设 `VITE_EDITION=adult|teen`
(隐藏非本系统导航、登录页文案:成人系统无选择器;青少年系统区分「学生/家长」)。
后端 `EDITION` 决定挂载哪些路由(非本系统 API 直接 404)。两系统 DB 独立 → 用户/数据完全隔离。
> 家长监护属于**青少年系统**:家长在青少年系统内注册(身份=家长),与孩子同库才能绑定。
> 成人系统不含监护/错题。两系统都共享「建分身/克隆/对话/审核/安全」内核代码。
### CI/CD(已配 4 条独立工作流)
`.github/workflows/`:`deploy-adult-backend.yml`·`deploy-adult-web.yml`·`deploy-teen-backend.yml`·`deploy-teen-web.yml`。
push `master` 改 `digital-me-backend/**` → 两套后端都重部署(共享代码);改 `digital-me-web/**` → 两套前端都重 build。
| 系统 | 后端目录 / PM2 / 端口 | 前端目录 | API 域名 / 前端域名 |
|---|---|---|---|
| 成人 | `/var/www/digitalme-adult-api` · `digitalme-adult-api` · **3603** | `/var/www/digitalme-adult-spa` | `digitalme-api.invoratec.cn` / `digitalme.invoratec.cn` |
| 青少年 | `/var/www/digitalme-teen-api` · `digitalme-teen-api` · **3604** | `/var/www/digitalme-teen-spa` | `edu-api.invoratec.cn` / `edu.invoratec.cn` |
工作流在 pm2 启动命令里固定 `EDITION`+`PORT`(dotenv 不覆盖已存在的 env);前端 build 时注入 `VITE_EDITION`+`VITE_API_BASE_URL`。
每个后端目录的 `.env` 各自配 **独立 `MONGO_DB`**(`digital_me_adult` / `digital_me_teen`)+ AI/安全密钥。
**一次性服务器准备**:创建上述 4 个目录 + 两份 `.env`;4 个 Nginx server 块(2 API 反代 :3603/:3604 + 2 SPA history 路由)+ 4 域名证书。
> 迁移注意:旧单体 `digitalme-api`(PM2) 也用 3603——上线成人系统前先 `pm2 delete digitalme-api` 释放端口。
## 5c. 单点工具 MVP(独立打包)
3 个工具可作为**独立单页产品**发布(复用同一后端,无需全平台导航)。前端 build 时设
`VITE_APP`:根路径直达该工具、隐藏平台导航。
| 工具 | VITE_APP | 路由 | 复用 |
|---|---|---|---|
| 口播侠 TalkShot | `talkshot` | /talkshot | avatar+voice+EMO |
| 反考机 ReQuiz | `requiz` | /requiz | errors quiz(需后端含 twins,所有版本都有)|
| 互考擂台 Arena | `arena` | /arena | publish+challenge+leaderboard(需 EDITION=teen/both)|
例:`VITE_APP=talkshot VITE_API_BASE_URL=https://digitalme-api.invoratec.cn npm run build:fast`,
dist 部署到该工具的独立域名即可。`VITE_APP` 未设=`full`(整平台,3 工具仍可经 /requiz 等访问)。
## 5d. 产品工厂(商店 + 购买即开通)
把多个 MVP 聚成一个「工厂」站点:商店列 SKU、每个 SKU 独立购买链接、买后
自动开通账号(权益) + magic link 即用。**上架新 MVP = 在 `src/products.js` 加一条
SKU**(无需新部署)。
**部署形态**:`VITE_FACTORY=c`(2C 工厂) / `=b`(2B 工厂)。根路径→`/store`,隐藏平台
导航,工具按 entitlement 门控(未购→`/buy/:sku`)。`=all`/不设=全量平台(不门控)。
```bash
# 2C 产品工厂
VITE_FACTORY=c VITE_API_BASE_URL=https://digitalme-api.invoratec.cn npm run build:fast
```
建议两个工厂各一域名:2C `shop.invoratec.cn`、2B `b.invoratec.cn`,同一后端。
**购买链路**:`/buy/:sku` 填邮箱 → `POST /api/store/checkout` → (stub 通道立即开通
并返回 redeemToken;真实通道返回 payUrl,支付回调里调 `fulfill()`) → 前端经
`/api/store/redeem` 兑换 magic token → 自动登入并进工具。magic token 30 分钟、单次。
**接真实支付(微信 Native 扫码付,已实现)**:商户平台拿齐 `WXPAY_APP_ID/MCH_ID/
API_V3_KEY/CERT_SERIAL` + 商户私钥(`WXPAY_PRIVATE_KEY` 或 `_PATH`),设 `STORE_PROVIDER=wechat`。
回调地址 `https://<api 域名>/api/store/webhook/wechat`(微信商户平台配置 + 服务器需公网可达)。
链路:checkout→`nativeOrder` 拿 code_url→前端二维码→用户扫码付→微信 POST 回调→
`decryptNotify`(AES-256-GCM 认证解密)→`fulfill(out_trade_no)` 开通→前端轮询 `/order/:id`
拿 redeemToken→自动登入进工具。密钥未配齐时自动回落 stub(不中断)。
(Stripe 同理:在其 webhook 调 `store.js#fulfill`。)
**渠道归因**:进站捕获 `utm_*`(utm.ts) → 随 `checkout` 存订单;埋点
`store_view/buy_view/checkout_start/purchase` → 超管 `/api/admin/funnel` 看「哪条新媒体带来购买」。
## 6. 安全红线(上线必做)
- `JWT_SECRET`、`DATA_ENC_KEY` 用强随机;`SUPER_ADMIN_EMAILS` 仅限你本人。
- `MODERATION_MODE=llm` 或接 `MODERATION_URL` 专业审核(勿只靠本地正则)。
- 备份 MongoDB;媒体走 OSS:配齐 `OSS_*` 即自动启用(`services/storage.js` 的 `saveMedia` 统一落地,OSS 失败自动回落本地磁盘,不中断上传/口播)。
- 合规:公开页已带「AI 数字分身,非真人」声明;专家回答强制免责声明。
+45
View File
@@ -0,0 +1,45 @@
# 历史数字我:本地 Docker Compose
## 启动
1. 启动 Docker Desktop,或执行 `colima start`。
2. 在本目录执行:
```bash
cp .env.docker.example .env.docker
docker compose --env-file .env.docker up --build -d
```
本机浏览器打开:<http://localhost:8080>
同一局域网内的手机或电脑打开:<http://192.168.110.3:8080>
局域网 IP 可能在重新连接 Wi-Fi 后变化。可在 Mac 上执行以下命令查看当前地址,
并同步修改 `.env.docker` 中的 `PUBLIC_URL`、`CORS_ORIGINS` 和 `WEB_BASE_URL`:
```bash
ipconfig getifaddr en0
```
后端健康检查:<http://localhost:8080/health>
## 常用命令
```bash
docker compose --env-file .env.docker ps
docker compose --env-file .env.docker logs -f backend web mongo
docker compose --env-file .env.docker down
```
数据默认保存在 Docker named volumes 中。普通 `down` 不会删除数据;只有显式执行
`docker compose down -v` 才会删除本地 MongoDB 与上传数据。
## 本地模式说明
- MongoDB、后端、前端/Nginx 共三个容器。
- 前端 `8080` 端口监听局域网;Nginx 将同源 API、WebSocket 和上传资源转发给后端。
- 后端 `3701` 端口仍只监听本机,MongoDB 不对宿主机或局域网开放。
- 如果 macOS 防火墙首次询问是否允许传入连接,需要选择“允许”。
- 未填写 AI Key 时,相关能力按现有代码降级;历史浏览等不依赖 AI Key 的能力仍可测试。
- 本地启用了 stub 登录和 stub 支付,只可用于开发验证。
- ECS 部署前必须改为强随机 JWT 密钥、关闭 stub 登录,并配置真实域名、HTTPS、备份与对象存储。
Executable
+201
View File
@@ -0,0 +1,201 @@
# 数字我 / Digital Me — 项目计划
> 「有脸、有声音、有知识库的另一个你」——个人 AI 数字分身 / Digital Twin。
## 一句话定位
**不是**又一个知识库 App(那是 IMA / Notion AI / NotebookLM 的赛道),
**而是**「替你出现的有脸有声音的 AI 分身」。
- IMA = 帮你做事的**隐形** AI 助理(无脸无声,文档/阅读/写作)。
- 数字我 = **替你现身**的 AI 化身(有脸有声,形象 + 声音 + 人格化知识三位一体)。
两者错位互补:IMA 帮你工作更快;数字我帮你**分身多地出现、长久被记住**。
## 差异化原则(指导取舍)
1. 重「拟人交互」,轻「办公深度」——初期**不碰**长文档深度解析 / 复杂报告生成。
2. 形象/声音/人格三位一体;知识库只为「像你一样思考表达」服务,不做通用资料管理。
3. 记忆分层:不仅存知识,还录人生时间线、重要事件态度、口头禅——强化「这是你,不是通用 Bot」。
4. 隐私即壁垒:长期主打本地/私有部署(人脸声音不出本地)。MVP 先用 DashScope 云换速度。
## 技术内核(已复用,非自建)
整条多模态管线搬自已验证的 `super-individual-app` 平台(其艺术家垂直 =
The Digital Atelier)。本仓库是面向「复刻自己」的消费级独立产品。
| 能力 | 实现 | 状态 |
|---|---|---|
| 人格化对话 + RAG | Qwen / DashScope + `text-embedding-v3` | ✅ 已接 |
| 形象捏脸 | 通义万相 wanx-style-repaint | ✅ 已接 |
| 声音克隆 + TTS | CosyVoice v2(WebSocket) | ✅ 已接 |
| 口播视频(口型同步) | 通义 EMO(audio→talking head) | ✅ 已接 |
| 可嵌入「和我分身聊天」 | 公开 slug 路由 `/api/embed/:slug` | ✅ 已接 |
| 社交分身(可授权社交节点) | 社交卡片 + 守护边界 + 公开 `/api/social/:token` | ✅ 已接 |
## 决策记录(本次已定)
- **代码形态**:全新独立仓库 `digital-me`(非复用平台入口、非平台内 fork)。
- **MVP 首发场景**:个人 IP 分身(博主/咨询师/讲师)。
- **部署路线**:MVP 先用 DashScope 云;本地/私有部署作为后续高隐私版本卖点。
- **2026-07 定位收敛:去掉青少年版,只做成人版。** 删除 错题反考(quiz) + 家长监护(guardian)
+ `audience`/`EDITION` 分版机制;**互考擂台 + 排行榜**从青少年重归类为**成人通用裂变**保留。
共享内核(AI 管线 / altauth 短信微信登录 / moderation 内容安全)不变。
## 路线图
### 阶段 1 — MVP:个人 IP 分身(进行中)
- [x] 后端骨架:auth + twins + knowledge(RAG) + avatar + voice + ask/speak + talk-video + 公开 embed
- [x] 后端韧性:缺 AI key 时各接口优雅降级(502 JSON),不再崩溃;全局错误兜底
- [x] 前端 `digital-me-web`(Vue3+Vite+TS+Tailwind+Pinia):登录/注册、分身列表、Studio(形象/声音/知识库/对话/口播视频/发布)、公开嵌入页 `/me/:slug`
- [x] 契约冒烟测试通过(register→create→knowledge→publish→embed),UTF-8 中文正确入库
- [x] 填 `DASHSCOPE_API_KEY` + cloudflared 隧道,真 AI 端到端跑通(RAG 对话实测 qwen-max + 真 embedding;捏脸/声音/EMO 路径已正确用公网 PUBLIC_URL)
- [x] 建分身向导(`CreateWizard.vue` 三步引导)+ Studio 配置进度条
- [x] 「和我分身聊天」嵌入挂件:iframe 之外新增 **JS SDK 悬浮气泡**(`public/widget.js` + `GET /widget.js`,发布页一键生成代码)
### 阶段 2 — 知识与记忆深化
- [x] PDF / Word(.docx) / 图片 → RAG(`services/extract.js`:pdf-parse / mammoth / Qwen-VL 读图 OCR)
- [x] 记忆分层:人生时间线 / 重要事件态度 / 口头禅 的结构化录入(`services/memory.js`,口头禅注入人设、全部入 RAG)
- [x] 多轮会话管理(按主题分线,`services/session.js` + Studio 会话侧栏)
### 阶段 3 — 场景扩展
- [ ] 家人情感记忆 / 数字遗产(情感壁垒,IMA 不碰)
- [ ] 小微商务接待(中介/培训老师用分身接待初访客户)
- [x] 实时视频对话(搬 `live.js`,摄像头帧 + 语音)— WS `/api/live`,前端 `Live.vue`,Studio「视频通话」入口
### 阶段 4 — 隐私护城河
- [ ] 本地/私有部署版(人脸声音模型不出本地设备)
- [ ] 「真正拥有你的数字自我」——纪念包 / 名人高端定制
### PRD 对齐(按《数字我》PRD 补全)
- ~~**FR-3 学伴·错题反考**~~ **(已移除,青少年功能,2026-07 随收敛为成人版删除)**
- [x] **FR-1.4 性格标签** + **FR-4.1 专家模式** + **FR-4.3 强制免责声明**
(专家分身对外回答自动前置「我是 AI 数字分身,仅供参考,不构成专业建议」)。
Studio「人设」标签。
- [x] **4.2 权限回收**:`DELETE /api/auth/account` 注销并物理删除该用户全部数据
(twins/knowledge/messages/memories/sessions/errors/shares/social/rooms)。
- [x] **FR-3.3/3.4 学友互考 + 战报 + 排行榜(裂变引擎)**:同学给公开分身出题 →
分身(AI)应战作答 → LLM 判分 → 记录战报;双榜(分身按守擂胜率、出题官按
「难倒数」)。`services/challenge.js` + 公开 `/api/challenge/:slug[/battles]`
+ `/api/leaderboard[/challengers]`;前端 `Challenge.vue`(出题应战) +
`Leaderboard.vue`(双榜) + 聊天页「出题考考 TA」入口 + 头部「排行榜」。
- [x] **FR-4.2 付费咨询墙(Token 闸门 + 钱包)**:用户 Token 钱包(余额/流水/充值)、
专家分身定价(单次费+可问条数)、付费解锁深度咨询会话(逐条扣次)、平台分成
(专家得 70%)。`services/wallet.js`(原子扣款 + 充值 seam) + `services/consult.js`
+ `/api/wallet[/topup]` + `/api/consult/expert|start|session|mine`;前端
`Wallet.vue`(充值) + `Consult.vue`(付费解锁聊天) + Studio 人设定价 + Embed 入口。
**支付 SDK 留接口**:`PAYMENT_PROVIDER=stub` 即时到账;接微信/Stripe 时实现
topup() 下单 + 回调到账(验签)。
- [x] **FR-5 策展 → 降维为通用「作品集」**(产品决策):不单列「艺术家计划」模块——
艺术家=专家人设的一种。抽出唯一不可替代的能力「答问时展示并讲解作品」,
泛化为**任何分身可用**的作品集:上传图(+说明/自动识图)→入 RAG;聊到相关
内容时按相似度阈值内联展示对应作品图。`services/gallery.js`(works) +
`/api/twins/:id/works` + `ask()` 附 `works` + ChatPanel 内联渲染 +
Studio「作品集」标签。「艺术家计划」仅作为 GTM 包装(名人入驻/博物馆 kiosk
= To B 定制),不是代码模块。
- [x] **真实支付通道架构**:订单生命周期(payments) + 幂等入账(原子 pending→paid,
防 webhook 重复) + 可插拔 provider。`services/payments.js`:`mock` 网关完整
模拟下单→支付→回调→入账(已本地验证含幂等);`stripe` Checkout(REST,无 SDK)
+ Webhook HMAC 验签(`/api/wallet/webhook/stripe`,raw body),填 STRIPE_* 即用;
`wechat` 预留 seam。前端 Wallet 充值跳转 payUrl + 返回轮询。**只差商户密钥**。
- [x] **双语对话 + 标准发音 + 英语陪练**:对话可切中/英(per-chat 语言选择器),
回复语言与语音随之切换;英文发音链 Azure Neural(标准, key-gated) → CosyVoice
克隆音色 → 浏览器 TTS 兜底;`英语陪练`模式让分身自然对话并纠错引导(💡 Better:…),
为「指导学生英语对话」打底。`services/azureTts.js` + speak.js 双语链 +
twins/embed ask 支持 lang/tutor + ChatPanel 语言/语音/陪练开关。
- [x] **UI 中英切换(i18n)全站完成**:vue-i18n + 顶部语言开关(中/EN,持久化、设
`<html lang>`);`i18n.ts` 由 {zh,en} 目录生成(18 命名空间 / 390 键),**全部
视图**已接 `t()`(App/Login/Dashboard/Studio/Plaza/Leaderboard/Rooms/Room/
Embed/Social/Consult/Challenge/Admin/Wallet/ChatPanel/CreateWizard),含插值。
保留为内容/品牌:性格标签值、`数字我·Digital Me` 品牌、语言名。
- [x] **三种登录**:邮箱密码(内置)+ 手机短信验证码 + 微信扫码。`services/altauth.js`
+ `/api/auth/sms/{send,verify}`、`/api/auth/wechat/{qr,poll,callback,mock-scan}`;
users 支持 email/phone/wechatOpenId(partial-unique),/me 改按 id 查;前端 Login
三标签页。短信(aliyun)/微信(开放平台)真实通道 key-gated,未配置走 stub
(短信 devCode 回显、微信 mock 扫码),均已本地端到端验证。
- ~~**成人/青少年分系统**、**家长监护**、**EDITION 参数化拆分**~~ **(已移除,2026-07 收敛为纯
成人版)**:删 `audience`/`EDITION`/`requireAdult`、`services/guardian.js`+`quiz.js`、
前端 `edition.ts`/版本选择器/Guardian.vue/ReQuiz.vue;互考擂台/排行榜转为成人无条件挂载。
- [x] **两个单点工具 MVP**:口播侠(TalkShot,照片+声+文案→口播视频)、互考擂台(Arena,上擂台+
战绩+排行榜)。复用现有后端,`useMyTwin` 自动管理分身;`VITE_APP` 可独立打包(DEPLOY §5c)。
(反考机 ReQuiz 已随青少年收敛移除。)
- [x] **每 MVP 独立落地页 + Slogan + 埋点漏斗**:`Landing.vue`(/welcome, 按 VITE_APP)
+ Slogan;`analytics.ts track()` → `/api/track` 入库 → 超管 `/api/admin/funnel`。
北极星指标——口播侠: video_generated;互考擂台: challenge_submitted。
- [x] **产品工厂(商店+购买即开通+权益门控)**:`src/products.js` SKU 目录(上架=加一条);
`/store` 商店 + `/buy/:sku` 独立购买链接;`checkout→fulfill(开通权益+magic token)→redeem(自动登入)`;
`requireEntitlement` 门控;`VITE_FACTORY=b|c` 工厂部署;utm 渠道归因(DEPLOY §5d)。
- [ ] 小程序(uni-app);3D/视频通话;接微信支付商户号 + 短信/微信登录商户密钥;外接 GA/umami。
## 数字我 · 社交分身(从「个人工具」升级为「社交载体」)
把「数字我」从私有资产变成**可授权的社交节点**:用户留在平台,不只为自己用,
而是为了「让别人认识数字版的我」。
### 身份三重分身
- **本体(Me)**:本人,最高权限,负责训练与审核。
- **社交分身(Social Clone)**:部署在好友/陌生人面前的**受限**版本——只有被授权的范围,带场景、边界、红线、有效期、可随时掐断。
- **访客(Friend)**:匿名与分身互动的人。
### 已落地(阶段一 MVP:熟人社交,验证「像不像」)
- **社交卡片(share)**:一个分身可生成多张卡片,各自独立链接 `/s/:token`。
- 场景模式:朋友 / 职场 / 恋人 / 自定义(注入人设,决定 HOW 说)。
- 边界:开放程度(含蓄/适中/健谈)、红线话题(被问到礼貌岔开)。
- 隐私红线:硬性禁止透露住址/电话/证件/行程等,即使知识库有也不说。
- 安全:访客输入内容硬过滤(违法/暴恐/儿童不良)直接拒答、不调模型;诱导违规由人设守护拒绝。
- 记忆隔离:访客对话**不进**本体私人历史/知识库;按 visitorId 单独记录供本体回看(反馈回流,默认开、可关)。
- 时效 + 拉黑:可设有效期;可一键停用(访客侧 410)。
- 防冒充:`twin.verified` 蓝V 字段(平台置位),访客页展示认证标识。
- 后端:`services/social.js` + `routes/social.js`(公开)+ `/api/twins/:id/shares` 管理 + `ask()` 注入社交守护 system 块。
- 前端:Studio「社交分身」标签(建卡/模式/红线/有效期/链接复制/访客反馈)+ 公开名片页 `/s/:token`(bare,可 iframe)。
- 已端到端实测:正常延展 / 红线岔开 / 有害硬过滤 / 访客回流分线 / 停用 410 全部通过。
### 阶段二 — 广场社交(扩大影响力,进行中)
- [x] 发现页 / 数字人广场:本体上架已发布分身(标签/分类),陌生人按标签搜索/浏览。后端 `services/plaza.js` + 公开 `routes/plaza.js`(`GET /api/plaza`、`/api/plaza/tags`、`POST /api/plaza/:slug/rate`)+ owner `PUT /api/twins/:id/plaza`(须先公开);前端 `views/Plaza.vue`(`/plaza`,搜索+标签 chips+卡片)+ Studio 发布页「上架到广场」+ 头部「广场」入口。
- [x] 「灵魂测试」:访客在聊天页给「像不像本人」打分(1–5),均分+评数沉淀为社交背书,广场卡片展示。
- [x] 「AI 代聊」:本体设状态语(如「他在开车,我先帮回」),聊天页顶部 banner 展示。
- [ ] 直播替身(2.5):本体开播,分身帮回弹幕(需接直播流,较重,后做)。
### 阶段三 — 群聊与协作(已落地)
- [x] 平台原生群聊房间:本体拉自己/公开分身进群,生成分享链接 `/room/:token`,
任何人取昵称即可加入。后端 `services/room.js` + `routes/rooms.js`
(authed 管理 `/api/rooms` + 公开参与 `/api/room/:token`)。
- [x] 群里 @召唤某分身:消息 @名字 或选择成员 → 该分身带群聊上下文+社交守护+审核作答。
- [x] 会议助手:`POST /api/rooms/:id/summary` 把群聊整理成「要点/决定/待办」纪要。
- [x] 群消息加密落库(AES-256-GCM)+ 输入输出审核 + AI 限流。
- 前端:`views/Rooms.vue`(建群/选成员/链接/纪要) + `views/Room.vue`(公开群聊, 轮询)。
- [ ] 接入微信群/外部 IM(需对接第三方,后续)。
### 社交商业化(待接)
- 本体付费:分身装扮/皮肤、算力包(免费每日限句)、多模态解锁(视频通话看脸)。
- 访客付费:与专家分身按分钟深度咨询;「解锁隐藏答案」微支付。
- B 端:社交名片(求职者把数字我放简历,HR 随时面试分身)。
### 安全红线(生死线)— 已做生产级加固
- **内容审核**:`services/moderation.js` 输入+输出**双向**审核(社交 / embed / 群聊)。
三档(高优先级优先、逐级兜底):`MODERATION_URL` 外部专业服务 → `MODERATION_MODE=llm`
DashScope/Qwen 分类(已实测能拦截正则漏网的诈骗话术)→ 本地正则地板。
- **超管认证后台**:前端 `/admin`(仅 `isSuperAdmin` 可见/可用)授/撤蓝V。
- **访客记录加密**:`services/crypto.js` AES-256-GCM 落库加密(`DATA_ENC_KEY`),
本体读取自动解密;旧明文与无 key 场景均兼容。
- **蓝V 认证流程**:`routes/admin.js` 仅 `SUPER_ADMIN_EMAILS` 可授,用户无法自我认证(防冒充)。
- **分层限流**:登录/注册防爆破(20/min)、公开 AI 接口防刷量(30/min)、广场(60/min)、全局(200/min)。
- **基础硬化**:helmet 安全头、密码常量时间比较、生产默认 `JWT_SECRET` fail-fast、
输入长度上限(`MAX_QUESTION_LEN`)、`trust proxy` 取真实 IP。
- 仍建议:接专业审核服务(替换本地兜底)、密钥走 KMS、访客数据主权条款落地到产品文案。
## 商业模式(参考,不锁死)
- C 端订阅:基础(静态形象+文本)免费/低价;高级(动态视频+声音克隆+长记忆)月费。
- 定制服务:上门拍照录音建高精度分身(纪念包);名人/艺术家高端定制。
- B 端垂类:画廊/教育/诊所的「专家数字分身」,项目制收费。
## 仓库结构
```
digital-me/
PLAN.md ← 本文件
digital-me-backend/ ← Node + Express + MongoDB + DashScope
digital-me-web/ ← Vue3 + Vite + TS + Tailwind + Pinia 前端
.github/workflows/ ← CI/CD(push master 自动部署到 ECS)
```
+36
View File
@@ -0,0 +1,36 @@
# DigitalMe-History
#### Description
{**When you're done, you can delete the content in this README and update the file with details for others getting started with your repository**}
#### Software Architecture
Software architecture description
#### Installation
1. xxxx
2. xxxx
3. xxxx
#### Instructions
1. xxxx
2. xxxx
3. xxxx
#### Contribution
1. Fork the repository
2. Create Feat_xxx branch
3. Commit your code
4. Create Pull Request
#### Gitee Feature
1. You can use Readme\_XXX.md to support different languages, such as Readme\_en.md, Readme\_zh.md
2. Gitee blog [blog.gitee.com](https://blog.gitee.com)
3. Explore open source project [https://gitee.com/explore](https://gitee.com/explore)
4. The most valuable open source project [GVP](https://gitee.com/gvp)
5. The manual of Gitee [https://gitee.com/help](https://gitee.com/help)
6. The most popular members [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/)
+39
View File
@@ -0,0 +1,39 @@
# DigitalMe-History
#### 介绍
{**以下是 Gitee 平台说明,您可以替换此简介**
Gitee 是 OSCHINA 推出的基于 Git 的代码托管平台(同时支持 SVN)。专为开发者提供稳定、高效、安全的云端软件开发协作平台
无论是个人、团队、或是企业,都能够用 Gitee 实现代码托管、项目管理、协作开发。企业项目请看 [https://gitee.com/enterprises](https://gitee.com/enterprises)}
#### 软件架构
软件架构说明
#### 安装教程
1. xxxx
2. xxxx
3. xxxx
#### 使用说明
1. xxxx
2. xxxx
3. xxxx
#### 参与贡献
1. Fork 本仓库
2. 新建 Feat_xxx 分支
3. 提交代码
4. 新建 Pull Request
#### 特技
1. 使用 Readme\_XXX.md 来支持不同的语言,例如 Readme\_en.md, Readme\_zh.md
2. Gitee 官方博客 [blog.gitee.com](https://blog.gitee.com)
3. 你可以 [https://gitee.com/explore](https://gitee.com/explore) 这个地址来了解 Gitee 上的优秀开源项目
4. [GVP](https://gitee.com/gvp) 全称是 Gitee 最有价值开源项目,是综合评定出的优秀开源项目
5. Gitee 官方提供的使用手册 [https://gitee.com/help](https://gitee.com/help)
6. Gitee 封面人物是一档用来展示 Gitee 会员风采的栏目 [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/)
Executable
+36
View File
@@ -0,0 +1,36 @@
# 数字我 / Digital Me
> 有脸、有声音、有知识库的「另一个你」——个人 AI 数字分身 / personal AI digital twin.
不是「帮你做事的隐形助理」(IMA / Notion AI 那条赛道),而是「替你出现的有脸有声音的 AI 分身」:
**形象 + 声音 + 人格化知识** 三位一体。
## 仓库结构(monorepo)
| 目录 | 内容 |
|---|---|
| [PLAN.md](./PLAN.md) | 定位、差异化策略、路线图、决策记录 |
| [digital-me-backend/](./digital-me-backend/) | Node 20 + Express + MongoDB + DashScope API(:3701) |
| [digital-me-web/](./digital-me-web/) | Vue 3 + Vite + TS + Tailwind + Pinia 前端(:5173) |
| [.github/workflows/](./.github/workflows/) | CI/CD 部署工作流 |
## 快速开始
```bash
# 后端
cd digital-me-backend && cp .env.example .env # 填 DASHSCOPE_API_KEY
npm install && npm run dev # → http://localhost:3701
# 前端
cd digital-me-web && npm install && npm run dev # → http://localhost:5173
```
详见各子目录的 README 与 [PLAN.md](./PLAN.md)。
## 能力(MVP:个人 IP 分身)
复刻你自己 → 形象(万相)+ 声音克隆(CosyVoice)+ 知识库(RAG)+ 记忆分层 +
人格化对话 + **一键口播视频**(EMO)+ 可嵌入的「和我分身聊天」。
> 运行约束:AI 功能需 `DASHSCOPE_API_KEY`;捏脸/声音/口播视频需 `PUBLIC_URL` 公网可达
> (DashScope 按 URL 回拉素材)。
+53
View File
@@ -0,0 +1,53 @@
name: digital-me-dev
services:
backend:
build:
context: ./digital-me-backend
target: development
image: digital-me-backend:dev
working_dir: /app
command: npm run dev
environment:
NODE_ENV: development
PORT: 3701
MONGO_URI: mongodb://host.docker.internal:27017
PUBLIC_URL: http://localhost:3702
WEB_BASE_URL: http://localhost:5173
CORS_ORIGINS: http://localhost:5173,http://localhost:8080
ports:
- "3702:3701"
volumes:
- ./digital-me-backend:/app
- backend_node_modules:/app/node_modules
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD", "wget", "--quiet", "--spider", "http://127.0.0.1:3701/health"]
interval: 10s
timeout: 3s
retries: 10
start_period: 10s
frontend:
build:
context: ./digital-me-web
target: development
image: digital-me-web:dev
working_dir: /app
command: sh -c "npm install --prefer-offline --no-audit --no-fund && npm run dev -- --host 0.0.0.0 --port 5173"
environment:
VITE_API_BASE_URL: http://localhost:3702
CHOKIDAR_USEPOLLING: "true"
ports:
- "5173:5173"
volumes:
- ./digital-me-web:/app
- frontend_node_modules:/app/node_modules
depends_on:
backend:
condition: service_healthy
volumes:
backend_node_modules:
frontend_node_modules:
+68
View File
@@ -0,0 +1,68 @@
name: historical-digital-me
services:
mongo:
image: mongo:7-jammy
restart: unless-stopped
environment:
MONGO_INITDB_DATABASE: ${MONGO_DB:-digital_me_local}
volumes:
- mongo_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
backend:
build:
context: ./digital-me-backend
dockerfile: Dockerfile
restart: unless-stopped
environment:
PORT: 3701
NODE_ENV: development
EDITION: ${EDITION:-both}
PUBLIC_URL: ${PUBLIC_URL:-http://localhost:8080}
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8080}
WEB_BASE_URL: ${WEB_BASE_URL:-http://localhost:8080}
MONGO_URI: mongodb://mongo:27017
MONGO_DB: ${MONGO_DB:-digital_me_local}
JWT_SECRET: ${JWT_SECRET:-local-docker-only-change-before-ecs-2026}
JWT_EXPIRES: ${JWT_EXPIRES:-7d}
STORAGE_DRIVER: local
STORAGE_LOCAL_DIR: /app/data/uploads
SMS_PROVIDER: stub
ALLOW_STUB_LOGIN: "true"
PAYMENT_PROVIDER: stub
STORE_PROVIDER: stub
LLM_OVERSEAS_FALLBACK: "off"
ALLOW_OVERSEAS_EGRESS: "false"
DASHSCOPE_API_KEY: ${DASHSCOPE_API_KEY:-}
ports:
- "127.0.0.1:${BACKEND_PORT:-3701}:3701"
volumes:
- backend_data:/app/data
depends_on:
mongo:
condition: service_healthy
init: true
web:
build:
context: ./digital-me-web
dockerfile: Dockerfile
args:
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-same-origin}
VITE_EDITION: ${EDITION:-both}
restart: unless-stopped
ports:
- "${WEB_BIND_ADDRESS:-0.0.0.0}:${WEB_PORT:-8080}:80"
depends_on:
backend:
condition: service_healthy
volumes:
mongo_data:
backend_data:
+10
View File
@@ -0,0 +1,10 @@
node_modules
.env
.git
.gitignore
*.log
test
docs
scripts
CLAUDE.md
DEPLOY-CHECKLIST.md
+250
View File
@@ -0,0 +1,250 @@
# ── 数字我 / Digital Me — backend env ─────────────────────────────────
# Copy to `.env` and fill in. Everything optional degrades gracefully
# (the product stays usable end-to-end even with no AI keys).
PORT=3701
NODE_ENV=development
# 系统版本(独立部署两个系统时各自锁定):
# adult = 成人系统(社交/广场/群聊/专家付费/钱包)
# teen = 青少年教育系统(错题/互考/排行榜/家长监护)
# both = 单体平台(默认)
# 两系统应各自独立部署:独立 EDITION + 独立 MONGO_DB(或 COLLECTION_PREFIX) + 独立域名。
EDITION=both
# Public origin of THIS api. DashScope pulls media (photo/audio) BY URL,
# so for avatar 捏脸 / 口播视频 to work this MUST be publicly reachable
# (a localhost URL fails — those features degrade, chat still works).
PUBLIC_URL=http://localhost:3701
# Web app origin(s), comma-separated.
CORS_ORIGINS=http://localhost:5173
# Auth
JWT_SECRET=change-me-in-prod
JWT_EXPIRES=7d
# 登录方式:邮箱密码(内置)+ 手机短信 + 微信扫码。
# 短信:设 SMS_PROVIDER=aliyun 走真实发送(需下列四项全配齐);留空=stub(验证码在
# 响应 devCode 返回,仅 dev;生产 stub 默认 fail-closed,除非 ALLOW_STUB_LOGIN=true)。
SMS_PROVIDER=stub
ALIYUN_SMS_KEY_ID= # 阿里云 AccessKey ID(建议用 RAM 子账号,仅授 AliyunDysmsFullAccess)
ALIYUN_SMS_KEY_SECRET= # 对应 AccessKey Secret
ALIYUN_SMS_SIGN= # 短信签名(控制台「国内消息-签名管理」审核通过的签名名称)
ALIYUN_SMS_TEMPLATE= # 短信模板 CODE(如 SMS_123456789,验证码类模板)
# 模板正文里的验证码变量名。默认 code(对应模板 ${code});模板用别的变量名时改这里。
ALIYUN_SMS_CODE_PARAM=code
# 微信【网站应用】扫码登录(open.weixin.qq.com 开放平台,scope=snsapi_login)。
# 留空=stub 二维码(本地可模拟扫码;生产 fail-closed 除非 ALLOW_STUB_LOGIN=true)。
# ⚠ 开放平台后台「网站应用 → 授权回调域」必须填 WECHAT_REDIRECT_URI 的域名(只填域名,不带路径/协议)。
# ⚠ 拿真实昵称/头像会调 sns/userinfo;unionid 需该应用已绑定到【开放平台账号】才返回(用于统一身份)。
WECHAT_APP_ID=
WECHAT_APP_SECRET=
# 回调地址,须公网可达。留空默认 ${PUBLIC_URL}/api/auth/wechat/callback。
WECHAT_REDIRECT_URI=
# MongoDB — Digital Me owns its own DB. Never share another product's.
MONGO_URI=mongodb://localhost:27017
MONGO_DB=digital_me
# ── AI: 阿里云 DashScope (one key powers LLM + embeddings + 万相 + EMO + CosyVoice) ──
DASHSCOPE_API_KEY=
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
LLM_MODEL=qwen-max
EMBED_MODEL=text-embedding-v3
# 通用图片生成(万相文生图)模型。复用 DASHSCOPE key,有 key 即可用(需百炼已开通万相文生图)。
# 可选:wanx2.1-t2i-turbo(快省·默认) / wanx2.1-t2i-plus(更精更贵)。经统一入口 POST /api/create(modality=image)。
WANX_T2I_MODEL=wanx2.1-t2i-turbo
# ── 二创·AI 音乐生成:昆仑万维 Mureka ──────────────────────────────────
# 配 MUREKA_API_KEY 即启用(经 POST /api/create type=music:词+曲风→成品歌)。缺 key → 音乐生成 503。
# ⚠ 数据出境:歌词/曲风会发往 MUREKA_BASE_URL(默认 api.mureka.ai);合规要境内就指到 Mureka 境内 API 域。
MUREKA_API_KEY=
MUREKA_BASE_URL=https://api.mureka.ai
MUREKA_MODEL=auto # auto / mureka-6 / V8 等
# ── 二创·AI 视频生成:字节 Seedance(火山方舟)──────────────────────────
# 配 SEEDANCE_API_KEY(=火山方舟 API Key)即启用(经 POST /api/create type=drama:文/图生视频)。缺 key → 503。
# 端点走火山方舟【境内北京】域,数据不出域友好。model 需与你账号已开通的一致;若账号要求推理接入点,
# 把 SEEDANCE_MODEL 设成 ep-xxxxx。
SEEDANCE_API_KEY=
SEEDANCE_BASE_URL=https://ark.cn-beijing.volces.com
SEEDANCE_MODEL=doubao-seedance-1-0-pro-250528
# 知识检索精排模型。gte-rerank 已于 2026-05-30 下线,默认用后继 qwen3-rerank。
RERANK_MODEL=qwen3-rerank
# ── 语音合成 CosyVoice ──────────────────────────────────────────────
# 新克隆音色的注册版本(默认已升 v3.5-flash;高质可用 cosyvoice-v3.5-plus)。
# ⚠ v3.5 仅北京地域,需在百炼【北京】工作空间开通 CosyVoice;旧 v2 克隆音仍按各自版本合成。
COSYVOICE_MODEL=cosyvoice-v3.5-flash
# 预置音色 TTS(助手讲解 / 无克隆音兜底)的模型。预置音色名分版本、v3.5 官方未公布 →
# 默认仍 v2;待 v3.5 音色公布后,改这里 + 相应 TWIN_COSY_PRESET/ASSISTANT_COSY_VOICE 音色名。
COSYVOICE_PRESET_MODEL=cosyvoice-v2
# 实时合成 WebSocket 端点。默认北京全局端点;若工作空间要求专属 MaaS 端点再填。
# COSYVOICE_WS_URL=wss://<WorkspaceId>.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference
# 向量索引后端。留空 → memory(MongoDB 内联向量 + 内存暴力余弦,有 2000 块上限,适合小规模)。
# 配了 DASHVECTOR_ENDPOINT + DASHVECTOR_API_KEY → 自动切阿里云 DashVector(境内托管向量库,ANN 索引,
# 数据不出域,去掉 2000 块上限)。首次接入或换维度后跑一次:node scripts/reindex-vectors.js
# DASHVECTOR_DIM 必须与 EMBED_MODEL 维度一致(text-embedding-v3 = 1024)。
DASHVECTOR_ENDPOINT=
DASHVECTOR_API_KEY=
DASHVECTOR_COLLECTION=digitalme_knowledge
DASHVECTOR_DIM=1024
# Optional Claude fallback when Qwen is unavailable.
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
# 标准英文发音 (Azure Neural TTS) — 英语陪练用原生发音。留空则英文回退
# CosyVoice(克隆音色) 或浏览器 TTS。
AZURE_SPEECH_KEY=
AZURE_SPEECH_REGION=eastasia
AZURE_VOICE_EN=en-US-JennyNeural
# Avatar 捏脸 (通义万相 wanx-style-repaint). Blank → uploaded photo used flat.
# 'dashscope' reuses DASHSCOPE_API_KEY. Style: a name (3d/anime/ink/fresh)
# or raw wanx style_index 0-9. For a realistic "self" twin, leaving the
# uploaded photo flat (blank provider) is often the most faithful.
AVATAR_PROVIDER=
AVATAR_STYLE=3d
# Storage: 配齐下面 OSS_* 四要素 → 自动用阿里云 OSS(推荐:公网直链、免公网穿透、
# 重部署不丢片、DashScope 直接按 URL 拉);否则回落本地磁盘 + /uploads。
STORAGE_DRIVER=local
STORAGE_LOCAL_DIR=./data/uploads
# ── 阿里云 OSS(媒体存储,生产推荐)──────────────────────────────────
# ⚠ OSS_REGION 必须带 oss- 前缀(如 oss-cn-hangzhou),否则 SDK 拼错域名 ENOTFOUND。
# 存进 DB 的是持久直链(头像/成片长期展示)→ bucket 需「公开读」,或用 OSS_PUBLIC_BASE
# 指向自定义域名/CDN。共享 bucket 靠 OSS_PREFIX 隔离(digital-me 用 digitalme)。
OSS_REGION=
OSS_BUCKET=
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
OSS_ENDPOINT=
OSS_PREFIX=digitalme
OSS_PUBLIC_BASE=
# ── 生产级安全 (production hardening) ─────────────────────────────────
# In production (NODE_ENV=production) the server REFUSES to boot unless
# JWT_SECRET is set to a strong random value (not the default).
# Encrypt visitor chat logs at rest (AES-256-GCM). Any string → hashed to
# a 32-byte key. Strongly recommended in prod; empty = plaintext (dev only).
DATA_ENC_KEY=
# Emails (comma-separated) allowed to grant 蓝V verification & admin actions.
SUPER_ADMIN_EMAILS=
# Content moderation. Tiers (highest available wins, falls back down):
# external — set MODERATION_URL (plug a pro service; POST {text} → {allowed,category?})
# llm — set MODERATION_MODE=llm to classify via DashScope (Qwen)
# local — built-in regex floor (always on)
MODERATION_MODE=
MODERATION_URL=
MODERATION_KEY=
# Max length of any chat/visitor message (cost + abuse control).
MAX_QUESTION_LEN=2000
# Set 'true' when behind nginx/cloudflare so client IP (rate-limit key) is
# correct. Auto-on in production; set 'false' to force off.
TRUST_PROXY=
# ── 请求访问日志 ──────────────────────────────────────────────────────
# 每个请求打一行(方法/路径/状态码/耗时/IP/请求ID)。只记元数据,不记 query/body。
LOG_REQUESTS=on # off 关闭访问日志
LOG_SLOW_MS=5000 # 超过该耗时(ms)的请求标记 ⚠slow
LOG_SKIP_PATHS=/health # 不记的高频噪声路径(逗号分隔,前缀匹配)
# ── 付费咨询 / 钱包 (FR-4.2) ──────────────────────────────────────────
# 支付通道:
# stub = 即时到账(最快开发态)
# mock = 完整模拟「下单→支付→回调入账」异步链路(不涉及真钱,可本地验证)
# stripe = Stripe Checkout(需 STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET)
# wechat = 预留(在 payments.js 实现下单 + 回调)
PAYMENT_PROVIDER=stub
TOKENS_PER_YUAN=10
PAY_CURRENCY=cny
# 用户支付后返回的前端地址(默认取 CORS_ORIGINS 第一个)。
WEB_BASE_URL=
# 分享卡片 og meta(/me/:slug、/s/:token 的微信/IM 富卡片)。见 DEPLOY.md。
# 填了→读真 SPA index 注入 meta(页面仍是完整 App);空→回落 meta+跳转独立壳。
SPA_INDEX_PATH=
# 可选:无头像时的兜底卡片图(绝对 URL)。
OG_FALLBACK_IMAGE=
# 微信 JS-SDK(公众号「转发给朋友/朋友圈」自定义卡片)。填【公众号】appId/secret
# (非开放平台扫码登录的那个)。空 → 签名接口 {configured:false}、前端休眠。见 DEPLOY.md。
# 还需在公众号后台把 digitalme.invoratec.cn 加进「JS 安全域名」。
WX_MP_APP_ID=
WX_MP_APP_SECRET=
# Stripe(provider=stripe 时必填)。Webhook 配到 /api/wallet/webhook/stripe。
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# ── 产品工厂 购买通道 (商店/buy → 购买即开通) ─────────────────────────
# STORE_PROVIDER:只管【stub ↔ 真实支付】总闸,与「用哪个渠道」正交(渠道见 PAY_METHODS)。
# stub = 即时开通(无真实收款,开发/演示用,默认)
# live(或wechat) = 真实支付(非 stub 即视为开)。具体渠道由 PAY_METHODS 决定:
# · 微信:需 WXPAY_* 配齐(微信内 JSAPI 还需 WX_MP_* + 公众号绑商户号)
# · 支付宝:需 ALIPAY_* 证书三件套配齐
STORE_PROVIDER=stub
# 测试收款:PAY_ENV=dev 或 test 时,所有付费订单只收 1 分钱——走真实通道验证下单→
# 收银台→回调→开通全链,但只花 1 分。免费 SKU(¥0) 不受影响。生产留空。
PAY_ENV=
# 支付弹窗展示哪些收款方式 + 顺序(逗号分隔,只认 wechat/alipay)。留空默认 wechat,alipay。
# PAY_METHODS=wechat,alipay → 微信在前;=alipay → 只显示支付宝;=wechat → 只微信。
PAY_METHODS=wechat,alipay
# 下单显式超时(分钟):过期后二维码/支付链接失效,渠道自动关单。前端轮询时长与之对齐。
PAY_ORDER_TIMEOUT_MIN=10
# 微信支付 v3(商户平台 → API 安全)。Webhook 配到 /api/store/webhook/wechat。
WXPAY_APP_ID= # 商户绑定的 appid(JSAPI 用 WX_MP_APP_ID 那个公众号;Native 可公众号/开放平台)
WXPAY_MCH_ID= # 商户号
WXPAY_API_V3_KEY= # APIv3 密钥(必须 32 位)
WXPAY_CERT_SERIAL= # 商户 API 证书序列号
# 商户私钥 PEM:二选一。直接给内容(把换行写成 \n) 或给文件路径。
WXPAY_PRIVATE_KEY=
WXPAY_PRIVATE_KEY_PATH=
# 微信支付【平台证书】(回调验签用公钥)。配上即启用「回调验签闸」(防伪造/重放);
# 未配时回调仅靠 APIv3 GCM 鉴权放行。二选一:贴内容(\n 转义) 或给文件路径。
WECHATPAY_PLATFORM_CERT=
WECHATPAY_PLATFORM_CERT_PATH=
# 支付宝 · 电脑网站支付【证书模式/RSA2】。Webhook 配到 /api/store/webhook/alipay。
# 证书三件套从开放平台下载;每项二选一:贴内容(\n 转义) 或给 *_PATH 文件路径。
# ALIPAY_APP_CERT = 应用公钥证书 appCertPublicKey_2021xxx.crt
# ALIPAY_CERT = 支付宝公钥证书 alipayCertPublicKey_RSA2.crt
# ALIPAY_ROOT_CERT = 支付宝根证书 alipayRootCert.crt
# 沙箱:ALIPAY_GATEWAY=https://openapi.alipaydev.com/gateway.do(生产留空即用默认生产网关)。
ALIPAY_APP_ID=
ALIPAY_PRIVATE_KEY=
ALIPAY_PRIVATE_KEY_PATH=
ALIPAY_APP_CERT=
ALIPAY_APP_CERT_PATH=
ALIPAY_CERT=
ALIPAY_CERT_PATH=
ALIPAY_ROOT_CERT=
ALIPAY_ROOT_CERT_PATH=
ALIPAY_GATEWAY=
# ── 拟人化互动合规 ────────────────────────────────────────────────────
# 《人工智能拟人化互动服务管理暂行办法》(网信办/发改委/工信部/公安部/市场监管总局
# 五部门,2026-04-10 公布,2026-07-15 施行)。本产品的「数字分身」命中第二条
# 「模拟自然人人格特征、思维模式和沟通风格」要件,故第十四/十八条的义务适用。
#
# 未成年人模式(第十四条)总开关。⚠ 默认【开】——它是法定义务不是产品特性,
# 不配这个变量也是开的。设 off 仅供【误伤大面积用户时先关再修】的紧急止血,
# 关闭期间本站不符合第十四条,必须同步上报。
FEATURE_MINOR_MODE=on
# 未成年人模式下单日累计使用时长上限(分钟)。法条只写「使用时长限制」没给数字,
# 这里取《未成年人网络保护条例》的通行口径 60 分钟。调整前请过法务。
MINOR_DAILY_LIMIT_MIN=60
# 「定期现实提醒」周期(分钟):每隔这么久提醒一次"这是 AI,去看看现实里的人和事"。
MINOR_REALITY_CHECK_MIN=20
# 第十四条要求「提供相应申诉渠道」。站内申诉入口已内置(minor_appeals 集合 + 超管处理),
# 但法条要的是【用户看得见、联系得上】的渠道 → 生产【应当】填一个真实邮箱或电话。
# 留空时前端只显示站内申诉,并诚实写明"仅站内受理"。
COMPLIANCE_APPEAL_CONTACT=
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.env
data/uploads/
data/song-audio.json
*.log
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmjs.org/
+45
View File
@@ -0,0 +1,45 @@
# digital-me-backend — Claude Instructions
## Service
数字我 / Digital Me — 主场是 **大师数字传承馆 + 线上版权变现**:平台签约各领域名家(大师),
免费为其建【知识库】与【作品墙】,并享有其线上数字化变现版权 —— 知识付费问答、作品周边
(手办/签名作品克隆版)、作品库授权给机构、数字人带货、付费咨询/会员。**知识最重要**;形象/
声音次要(照片+预置音即可,无需视频)。个人分身是次级工具(个人在「我的分身」自建,可定向分享),
不再是平台主打的社交产品。
分身已具备「帮你办事」能力(联网查、记事、待办提醒、工作流定时跑,见 capabilities.js 的
assist 分类) —— 早期文档"NOT 帮你做事的隐形助理""NOT knowledge-work tool"的表述已作废。
国内版**数据不出域**是红线:LLM 用 DashScope/Qwen;Claude/HeyGen/Resend/Stripe/Azure 等
海外出站由 config 开关门控(overseasFallback / overseasEgress),默认建站期临时开、进生产前关。
## Lineage
The multimodal pipeline is lifted from the proven `super-individual-app`
platform (whose artist vertical is "The Digital Atelier"). Digital Me 现在就是围绕
大师传承馆(master gallery)+ 作品/授权变现构建 —— 早期"no master-gallery/license code"
的说明已不适用(masters/works/agreements/tours 等业务域均已落地)。
## Stack
- Node 20 + Express (ES modules — `"type": "module"`)
- MongoDB (own DB `digital_me` — never share another product's DB)
- LLM: Qwen via DashScope (OpenAI-compatible) primary; Claude Haiku fallback
- Embeddings: DashScope `text-embedding-v3` for knowledge RAG
- Avatar 捏脸: 通义万相 wanx-style-repaint (`services/media.js`)
- Voice clone + TTS: DashScope CosyVoice v2 (`services/media.js` + `ttsWs.js`)
- 口播视频: 通义 EMO audio-driven talking head (`services/talkingVideo.js`)
## Conventions
- Routes thin; logic in `services/`.
- All secrets via `.env` (see `.env.example`); never hardcode.
- Per-owner isolation: every `/api/twins/:id` route ownership-checks via
`loadTwin` (ownerId match). The public `/api/embed/:slug` router is the
ONLY no-auth surface — it reads published twins only and never persists.
- Everything degrades gracefully without AI keys (chat → 502 only if no LLM
at all; avatar → flat photo; voice → `voice-not-cloned`). Keep it usable.
- Rate limit: 120 req/min global.
## Two runtime constraints
1. AI features need `DASHSCOPE_API_KEY` (`.env`).
2. 捏脸 / 口播视频 need `PUBLIC_URL` to be **publicly reachable** — DashScope
pulls the photo/audio by URL, so a `localhost` PUBLIC_URL makes those two
features fail (chat + voice still work). Use a tunnel in dev.
+82
View File
@@ -0,0 +1,82 @@
# 数字我 / Digital Me — 生产环境配置 Checklist
> 逐项对照 `digital-me-backend/.env`。分四档:🔴 不设=有洞/起不来 · 🟠 强烈建议 · 🟡 按功能可选 · ⚪ 可留默认。
---
## 🔴 0. 总开关(漏了这个,前面所有安全修复都白做)
- [ ] **`NODE_ENV=production`**
这是本次安全修复的**总开关**。不设它(默认 development)会导致:
- 忘记密码 / 短信验证码 **明文回传**(任意账户可被接管)
- 商店 stub 通道 **免支付开通 + 冒充登录**
- 微信 `mock-scan` 后门 **开着**
- `assertProdConfig` 直接跳过 → **默认 JWT 密钥都不会被拦**
> 部署后务必确认进程真的读到了 `production`:启动日志应是 `on :3701 (production)`。
## 🔴 1. 起不来 / 必设
- [ ] **`JWT_SECRET`** = 强随机值(≠ `change-me-in-prod`)。生产下不设,服务**拒绝启动**(fail-fast,符合预期)。
生成:`openssl rand -hex 32` 或 `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
- [ ] **`MONGO_URI`** = 你的生产 Mongo(默认 `mongodb://localhost:27017` 仅本机可用)。
- [ ] **`MONGO_DB`** = `digital_me`(独立库,别和别的产品共用)。
- [ ] **`CORS_ORIGINS`** = 前端真实域名(如 `https://digitalme.invoratec.cn`)。不对会导致前端跨域被拦。
- [ ] **`PUBLIC_URL`** = 本 API 的公网地址(如 `https://digitalme-api.invoratec.cn`)。捏脸/口播视频靠它被 DashScope 回拉素材,必须公网可达。
## 🟠 2. 强烈建议(安全 / 生产质量)
- [ ] **`DATA_ENC_KEY`** = 任意强随机串。不设 → 访客聊天记录**明文入库**(`assertProdConfig` 只 warn 不拦)。
- [ ] **`SUPER_ADMIN_EMAILS`** = 你的邮箱(逗号分隔)。不设 → 没人能授予蓝V认证 / 进后台。
- [ ] **`TRUST_PROXY`** = 生产默认自动 on;若**不在** nginx/cloudflare 后面才需显式 `false`(否则限流按错 IP)。
- [ ] **`MAX_QUESTION_LEN`** = 默认 2000,按成本/滥用容忍度调。
- [ ] **`MODERATION_MODE` / `MODERATION_URL`**:默认只有内置正则兜底。接专业内容安全服务(`MODERATION_URL`)或 `MODERATION_MODE=llm` 更稳(live 视频对话现在也走审核了)。
## 🟡 3. 按功能可选(不配=优雅降级,不会崩)
| 功能 | 需要的 env | 不配的表现 |
|---|---|---|
| AI 聊天 / 知识 RAG | `DASHSCOPE_API_KEY` | 聊天 → 502;其余正常 |
| Claude 兜底 | `ANTHROPIC_API_KEY` | Qwen 挂了没有兜底 |
| 捏脸(万相) | `AVATAR_PROVIDER=dashscope` + 公网 `PUBLIC_URL` | 用上传原图(往往更像本人,OK) |
| 声音克隆 / TTS | `DASHSCOPE_API_KEY` | `voice-not-cloned` |
| 口播视频(EMO) | `DASHSCOPE_API_KEY` + 公网 `PUBLIC_URL` | 不可用 |
| 英文原生发音 | `AZURE_SPEECH_KEY` | 回退克隆音色/浏览器 TTS |
| 手机短信登录 | `SMS_PROVIDER=aliyun` + `ALIYUN_SMS_*` | 生产下**直接不可用**(已 fail-closed,不再泄露验证码)※ |
| 邮箱找回密码 | `EMAIL_PROVIDER=<非stub>` + 实现 `sendEmail` | 生产下**直接不可用**(已 fail-closed)※ |
| 微信扫码登录 | `WECHAT_APP_ID` + `WECHAT_APP_SECRET` | stub 二维码在生产被关,等于登录方式不可用 |
| 商店真实收款 | `STORE_PROVIDER=wechat` + `WXPAY_*` 全套 | 生产下 stub 返回 `payment-unavailable`(不再白嫖)|
| 媒体持久存储 | `OSS_*` 四要素(region 带 `oss-` 前缀)| 回落本地磁盘,重部署会丢片 |
| 分享卡富预览 | `SPA_INDEX_PATH`(+ Nginx 回源)| 回落 meta+跳转壳 |
| 公众号转发卡 | `WX_MP_APP_ID/SECRET`(+ JS 安全域名)| 前端休眠 no-op |
| 钱包/付费咨询栈 | `FEATURE_MARKETPLACE=true` | 默认关闭(定位「不做平台」)|
> ※ 短信/邮箱登录:这是本次安全修复的**有意行为** —— 生产没配真实 provider 就不可用,总比"回传验证码给任何人"强。要启用就配真 provider;不打算用就保证注册/登录还有邮箱密码这条路。
## ⚪ 4. 可留默认
`PORT` `EDITION=both` `JWT_EXPIRES=7d` `LLM_MODEL` `EMBED_MODEL` `AVATAR_STYLE` `TOKENS_PER_YUAN` 等。
---
## ✅ 部署后自检(3 条)
```bash
# 1) 确认进程真在 production 模式
# 启动日志应含: on :3701 (production)
# 2) 生产必须 fail-closed:忘记密码不返回验证码(响应里不应出现 devCode)
curl -s -X POST https://<api域名>/api/auth/password/forgot \
-H 'content-type: application/json' -d '{"email":"someone@x.com"}'
# 期望:{"sent":true}(无 devCode)或 502;绝不能出现 devCode 字段
# 3) 微信 mock-scan 后门必须已关(应 404)
curl -s -o /dev/null -w "%{http_code}\n" https://<api域名>/api/auth/wechat/mock-scan/anystate
# 期望:404
```
---
## ⚠️ 一次性 / 数据层
- 新增集合 `plaza_ratings` + 唯一索引 `{slug,voter}`:首次启动 `connectDb()` **自动建**,无需手动迁移。
- 未改任何既有字段(`isPublic` 等原样),无数据迁移风险。
+35
View File
@@ -0,0 +1,35 @@
FROM node:20-alpine AS dependencies
WORKDIR /app
COPY package.json package-lock.json .npmrc ./
RUN npm ci
FROM dependencies AS development
COPY . .
ENV NODE_ENV=development
EXPOSE 3701
CMD ["npm", "run", "dev"]
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json .npmrc ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=node:node . .
USER node
EXPOSE 3701
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --quiet --spider http://127.0.0.1:3701/health || exit 1
CMD ["npm", "start"]
+40
View File
@@ -0,0 +1,40 @@
# 数字我 / Digital Me — Backend
「有脸、有声音、有知识库的另一个你」的 API。个人 AI 数字分身平台后端。
## Quick start
```bash
cp .env.example .env # 填入 DASHSCOPE_API_KEY;本地 MongoDB 即可
npm install
npm run dev # → http://localhost:3701 (GET /health)
```
需要本机或远程 MongoDB(默认 `mongodb://localhost:27017`,DB `digital_me`)。
## MVP scope(第一阶段:个人 IP 分身)
复刻「你自己」→ 一键生成「你」讲某主题的口播视频 + 可嵌入的「和我分身聊天」。
| 能力 | 接口 |
|---|---|
| 注册 / 登录 | `POST /api/auth/register` · `POST /api/auth/login` · `GET /api/auth/me` |
| 创建你的分身 | `POST /api/twins` `{ displayName, persona?, characterPrompt?, lang? }` |
| 上传照片建形象 | `POST /api/twins/:id/avatar` (multipart `photo`) |
| 录音克隆声音 | `POST /api/twins/:id/voice` (multipart `sample`) |
| 喂知识(转 RAG) | `POST /api/twins/:id/knowledge` (`file` 或 `{ text, title }`) |
| 文字/语音对话 | `POST /api/twins/:id/ask` `{ question, speak?, lang? }` |
| 单独朗读 | `POST /api/twins/:id/speak` `{ text }` |
| **一键口播视频** | `POST /api/twins/:id/talk-video` → `{ jobId }`,轮询 `GET …/talk-video/:jobId` |
| 发布为可分享分身 | `PUT /api/twins/:id/publish` `{ isPublic, slug?, tagline? }` |
| **公开嵌入对话** | `GET /api/embed/:slug` · `POST /api/embed/:slug/ask`(无需登录,不入历史) |
所有 `/api/twins/*` 需 `Authorization: Bearer <token>`,并按 `ownerId` 隔离。
## 两个运行约束
1. AI 功能需 `DASHSCOPE_API_KEY`。
2. **捏脸 / 口播视频需 `PUBLIC_URL` 公网可达**——DashScope 按 URL 回拉素材,
`localhost` 会失败(对话/声音仍可用)。本地开发用内网穿透(如 cloudflared)。
详见 [../PLAN.md](../PLAN.md)(定位、差异化、路线图)与 [CLAUDE.md](./CLAUDE.md)。
+77
View File
@@ -0,0 +1,77 @@
# 天赋系统契约(Talent System Contract)
天赋树 = **Prompt + 模型行为的控制平面**,不是游戏成长系统。
- **主天赋(唯一)** = 身份:决定 System Prompt / 是否走 RAG / 合规安全等级 / 默认模型。主天赋 **就是 `twin.profession`**,不另存一套 id。
- **副天赋(≤N)** = 风格软技能:**后置改写**,只碰成稿、**绝不改事实**。
实现见 [`../../src/talents.js`](../../src/talents.js)。本目录是与线上代码对齐的**契约**,也是将来搬 DB 的蓝本。
## 文件
| Schema | 描述 | 对应代码 |
|---|---|---|
| [main-talent.schema.json](main-talent.schema.json) | 主天赋身份节点 | `mainTalent(profId)` |
| [sub-talent.schema.json](sub-talent.schema.json) | 副天赋注册表项 | `SUB_REGISTRY` |
| [user-talent-instance.schema.json](user-talent-instance.schema.json) | 分身选定的天赋 | `twin.talents` |
| [talent-registry.schema.json](talent-registry.schema.json) | 注册表整体 | 派生/静态表 |
## 派生关系(主天赋怎么来的)
不逐个职业手写主天赋,而是三张映射表交叉派生(`talents.js`):
```
profession(professions.js) ──cat──▶ ARCH_BY_CAT ──▶ arch ──▶ ARCH_BEHAVIOR(行为基线) + ARCH_MODEL(模型偏好)
└─compliance─▶ COMPLIANCE ──▶ safety_level + block_keywords + disclaimer + audit_required
```
- `cat → arch`:content→creator, pro/health/biz/life/other→expert, edu→teacher, art→artist, sport→sport, craft→craft
- `compliance → 安全`:none→{2,无} / soft→{5,软免责} / strict→{9,强免责+关键词+需审核}
新增一个职业只改 `professions.js` 一处;换某原型的行为只改 `ARCH_BEHAVIOR` 一处。
## 校验规则(服务端必须兜底,不信前端)
1. **主天赋唯一**:`main_talent` 必须 ∈ 职业表(`getProfession`),否则 400 `unknown-main-talent`;`null`/无 → `general` 兜底身份。
2. **副天赋合法性**:每个 id 必须 ∈ `SUB_REGISTRY`;去重;顺序保留。
3. **副天赋槽位**:数量 ≤ `maxSubs(level) = min(3, 1 + level)`(level 来自 `computeSkillTree`)。超出**静默截断**(`validateSubs`),不报错。
4. **副天赋不改事实**:所有 style/constraint 副天赋合并成**一次**后置改写,指令强约束"不改变任何事实/数字/结论/引用";hobby 型走前置注入。
5. **审核门(软门)**:`main.audit_required && !twin.verified` → `twin.talentAudit='pending'`,**不拦截**(分身仍可用),但运行时 Guardrail 强制免责。已 `verified` → `'approved'`。
6. **Guardrail(运行时,输出前)**:
- `block_keywords` 命中 → 软化替换;
- `safety_level >= 5` 且回答未含免责 → 追加 `disclaimer` 到结尾。
## 运行时管线(`services/twin.js` → `ask()`)
```
用户输入
→ 组装 System Prompt:身份(mainPromptBlock) + 前置注入(injectBlock) + 人设/记忆/RAG
→ (RAG 检索,main.rag)
→ LLM 主回答 chat(messages) ← 主天赋出稿
→ 副天赋后置改写 chat([restyleInstruction, draft]) ← 合并 1 次;无副天赋则跳过
→ Guardrail guardrail(text, main) ← 屏蔽词 + 免责
→ 输出
```
成本:每轮 **1 次主调用 + 至多 1 次改写调用**(不是每个副天赋一次)。
## API 契约
### `GET /api/twins/:id/talents`
返回(`describeTalents`):
```jsonc
{
"main": { "id","name","emoji","entity","arch","compliance","safety_level",
"rag_required","audit_required","audit_ok","behavior" },
"subs": { "selected": ["humor"], "max": 1,
"options": [{ "id","name","subtype","desc" }] },
"growth": { "level": 0, "maxSubs": 1 }
}
```
### `PUT /api/twins/:id/talents`
Body(两字段都可选,可单独改):
```jsonc
{ "main_talent": "lawyer", // 可选:切身份(=profession),带 audit 门
"sub_talents": ["humor","concise"] } // 可选:仅当为数组时才更新;截到 maxSubs
```
返回同 `GET`。
## DB 化迁移(留了缝,MVP 不做)
1. `SUB_REGISTRY` → 集合 `talent_sub_registry`(一项一文档)。
2. 主天赋映射(ARCH_BEHAVIOR / COMPLIANCE / ARCH_MODEL)→ 集合 `talent_main_config`,或物化 `talent_main_talents`(一职业一行)。
3. 每个节点加 `talent-registry.schema.json#/$defs/versioning`(`version/active/gray_pct`)→ 支持**灰度放量 + 一键回滚**。
4. `talents.js` 的纯函数签名不变,只把三张 `const` 表换成带缓存的 DB 读取;`describeTalents/resolveTalents/guardrail` 全部复用。
## 留坑不做(架构留得住)
天赋升级 lv2/3(`level` 字段已在)· 冲突检测(医生+算命❌)· 天赋市场(官方/共创)· 会话级动态天赋 · 按天赋真路由模型/计量返佣(`model_preference` + `chat(opts.model)` 缝已在)。
+35
View File
@@ -0,0 +1,35 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://digitalme.invoratec.cn/schemas/main-talent.json",
"title": "MainTalent",
"description": "主天赋 = 唯一身份单元。当前由 profession + archetype + compliance 派生(见 talents.js mainTalent());将来可整体搬入 DB 集合 talent_main_config。主天赋决定:身份 System Prompt / 是否走 RAG / 合规安全等级 / 默认模型。",
"type": "object",
"required": ["id", "name", "type", "system_prompt", "rag", "safety_level", "compliance", "audit_required"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "description": "= profession id(主天赋不另建一套 id,直接复用职业)。null 身份用 'general'。", "examples": ["lawyer", "game_blogger", "general"] },
"name": { "type": "string", "description": "展示名", "examples": ["律师/法律顾问"] },
"emoji":{ "type": "string" },
"type": { "const": "main" },
"level":{ "type": "integer", "minimum": 1, "default": 1, "description": "身份等级(MVP 恒为 1;lv2/3 留给未来天赋升级)。" },
"arch": { "type": "string", "enum": ["expert", "creator", "teacher", "artist", "sport", "craft"], "description": "职业原型:决定行为基线(ARCH_BEHAVIOR)与默认模型。由 profession.cat 映射。" },
"entity": { "type": "string", "description": "生命体称号", "examples": ["律师生命体"] },
"system_prompt": { "type": "string", "minLength": 1, "description": "身份+行为基线,拼进 buildPersona。" },
"rag": {
"type": "object",
"required": ["required", "top_k"],
"additionalProperties": false,
"properties": {
"required": { "type": "boolean", "description": "是否强要求基于私有知识库作答(expert 原型或非 none 合规 → true)。" },
"top_k": { "type": "integer", "minimum": 1, "maximum": 20, "default": 5 },
"scope": { "type": "array", "items": { "type": "string" }, "default": [], "description": "限定检索域(预留;MVP 为空=全库)。" }
}
},
"model_preference": { "type": "array", "items": { "type": "string" }, "default": ["qwen"], "description": "模型偏好,路由缝。当前仅 'qwen'(符号名,非真实 model id),near-noop。" },
"safety_level": { "type": "integer", "minimum": 0, "maximum": 10, "description": "越高越严。none=2 / soft=5 / strict=9。>=5 触发 Guardrail 强制免责。" },
"block_keywords":{ "type": "array", "items": { "type": "string" }, "default": [], "description": "命中即被 Guardrail 软化替换。" },
"disclaimer": { "type": "string", "default": "", "description": "safety>=5 且回答未含时,由 Guardrail 追加到结尾。" },
"audit_required":{ "type": "boolean", "description": "专业强合规身份需资质审核(strict=true)。未认证→talentAudit=pending(软门,仍强制免责)。" },
"compliance": { "type": "string", "enum": ["none", "soft", "strict"], "description": "合规等级,来源 professions.js。" }
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://digitalme.invoratec.cn/schemas/sub-talent.json",
"title": "SubTalent",
"description": "副天赋 = 风格/软技能,存于 SUB_REGISTRY(talents.js),将来搬 DB 集合 talent_sub_registry。两类接入点:style/constraint = 后置改写(只碰成稿,绝不改事实);hobby = 前置注入(进 System Prompt;机制预留,MVP 未铺货)。",
"type": "object",
"required": ["id", "name", "subtype", "desc"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "examples": ["humor", "concise"] },
"name": { "type": "string", "examples": ["幽默感"] },
"subtype": { "type": "string", "enum": ["style", "constraint", "hobby"], "description": "style/constraint→后置改写;hobby→前置注入。" },
"desc": { "type": "string", "description": "给用户看的一句话说明。" },
"rewrite_prompt": { "type": "string", "description": "subtype=style|constraint 必填:合并进单次后置改写指令的风格项。" },
"inject_prompt": { "type": "string", "description": "subtype=hobby 必填:前置注入到 System Prompt 的兴趣/偏好项。" }
},
"allOf": [
{ "if": { "properties": { "subtype": { "enum": ["style", "constraint"] } } }, "then": { "required": ["rewrite_prompt"] } },
{ "if": { "properties": { "subtype": { "const": "hobby" } } }, "then": { "required": ["inject_prompt"] } }
]
}
@@ -0,0 +1,31 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://digitalme.invoratec.cn/schemas/talent-registry.json",
"title": "TalentRegistry",
"description": "天赋注册表。当前『按数据表形状』写在 talents.js(主由 professions 派生、副是 SUB_REGISTRY 数组);本 Schema 描述其逻辑形态,也是搬入 DB 后 talent_* 集合的契约。每个节点预留 version/active/gray_pct 以支持灰度与回滚。",
"type": "object",
"required": ["sub_talents"],
"properties": {
"main_talents": {
"type": "array",
"description": "主天赋表。MVP 由 professions.js × archetype × compliance 动态派生,不落库;搬 DB 时物化为一行一职业。",
"items": { "$ref": "./main-talent.schema.json" }
},
"sub_talents": {
"type": "array",
"description": "副天赋注册表(当前 SUB_REGISTRY)。",
"items": { "$ref": "./sub-talent.schema.json" }
}
},
"$defs": {
"versioning": {
"description": "DB 化后每个节点附加的灰度/回滚字段(MVP 代码内暂无)。",
"type": "object",
"properties": {
"version": { "type": "integer", "minimum": 1 },
"active": { "type": "boolean", "default": true },
"gray_pct": { "type": "number", "minimum": 0, "maximum": 100, "default": 100, "description": "灰度放量百分比。" }
}
}
}
}
@@ -0,0 +1,20 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://digitalme.invoratec.cn/schemas/user-talent-instance.json",
"title": "UserTalentInstance",
"description": "一个分身(twin)当前选定的天赋。主天赋不重复存——它就是 twin.profession;这里只存副天赋选择与解锁时间。审核态存 twin.talentAudit。",
"type": "object",
"required": ["sub_talents"],
"additionalProperties": false,
"properties": {
"sub_talents": {
"type": "array",
"items": { "type": "string" },
"uniqueItems": true,
"maxItems": 3,
"description": "已选副天赋 id(必须 ∈ SUB_REGISTRY)。长度受 maxSubs(level)=min(3,1+level) 限制;服务端 validateSubs 兜底截断。"
},
"unlockedAt": { "type": ["string", "null"], "format": "date-time", "default": null, "description": "首次配置副天赋的时间。" }
},
"$comment": "关联字段(存在 twins 文档其它位置,非本对象):twin.profession = 主天赋 id;twin.talentAudit ∈ null|'pending'|'approved'(强合规身份的资质审核态);twin.verified = 蓝V(admin 置,audit 通过依据)。"
}
+483
View File
@@ -0,0 +1,483 @@
# 样传 · 南齐 · 中书通事舍人
> # ⚠⚠⚠ 过期声明(2026-07-17 第六刀加)—— 先读这一段
>
> **本页是【第三刀那一天的快照】,而它现在有几处对不上引擎的实际输出了。**
> 挂这个声明,是因为本页下面白纸黑字写着一句话:
> 「以下为 `shizanForLineage(lineage)` 的**实际输出,原样粘贴**:简体、未转繁、未润色、未删节。」
> ★ **加一道题,这句话当场变成假话** —— 而没有任何东西看着它过期。
> (发现它过期的是 `test/lineage.test.js` 里那条钉着本页赞辞的断言。
> ★ 那条断言**钉得越死,本页过期的那天就越早被发现** —— 故它没有被改成宽松匹配。)
>
> ## 哪几处对不上了
>
> **第六刀给南齐加了一道「事」层的关口**(`nanqi-xuanzhi`「宣旨慰劳」,永明八年 490,
> 料出《南齐书》卷五十六·茹法亮传,当代史双证),而它在**时序上排在本页这一道之前**
> (永明 490 < 建武 494–498)。⇒ 一部南齐的传现在**从永明八年那一趟江起**,是三格,不是两格。
>
> | 本页写的 | 引擎现在的实际输出 |
> |---|---|
> | 赞的**起联**作「**省内四户,机要所因**」 | 「**巴东杀僚,衔命及津**」——`shizan.js` 的取法是「起 ← 链首那一格」,而链首变了 |
> | 正文从「四户」起 | 前面多了永明八年那一趟江(4 句),末尾多了**两笔「在场」**(`src/zaichang.js`) |
> | 史臣曰从「中书通事舍人,品卑而权重」起 | 从「中书通事舍人之职,萧子显序之,凡十六字」起(骨干改用**萧子显本人**,当代史) |
>
> **✓ 而本页下面这些,一个字都没变,仍然作数**:
> 「正文是推演,注才是原文」的体例、拒绝安籍贯、逃生口、三档影响、
> 《南齐书》「史臣曰+四言赞」的体例说明、以及那句自陈的体例破绽(萧子显看不见陈)。
>
> ⇒ **要不要重出一份快照,等老板拍**。本刀**没有替它重写** —— 理由:
> 重出一份就得连同「哪一条链」一起重新拍板(本页拍的是 `zuo-shi` 那条),
> 那是个产品决定,不是收尾工作。★ **在那之前,本页的「原样粘贴」四个字只对上面表格之外的部分成立。**
---
> **第三刀的端到端样张。**
> 产品定义:「我的分身,我在每朝每代的位置,我所在位置的选择对后世的影响,史记我的传。」
> 这一页把四句话跑通一遍:位置(`dynasties.js`)→ 选择(`decisions.js`)→ 影响(`computeImpact`)→ 传(`shizan.js`)。
>
> **一句话内核:你什么都没变,天花板全变了。**
---
## ⚠ 这是什么,这不是什么 —— 先读这一段,不许折叠
| | |
|---|---|
| **传主叫「某」,他是【拟托】的人。** | 南齐没有这个人。他不见于《南齐书》,不见于《南史》,不见于任何史籍。他是一份人格画像(严谨专业/有理有据/简洁利落/结论先行/耐心细致/沉稳克制,**无声望**)被投影到南齐建武年间的结果。**任何人在任何地方看见这一页,都不许把它当作史料。**<br>★ 他叫「某」不是偷懒:**传主是你**。本页**拒绝给他安名字,也拒绝给他安籍贯**——「某,不知何许人也」是史书自己的成例,而编一个「吴兴乌程人」出来,那就是在造一条假史料。 |
| **正文是【推演】,注才是【原文】。** | 这是本产品唯一诚实的地方,也是它全部的信誉。下面每一句正文都编了号;注里逐条说明它是**原文**(给出处、原文、限定语)还是**推演**(明说无史料,且**不给它安卷次**)。<br>**带卷次的错误比没出处的错误危害大十倍,因为它不可反驳。** |
| **caveat 不许隐藏。** | 注的价值不在于显得权威,在于**拆穿读者脑子里的通俗史观**。「四户」不是四个省、「势倾天下」不是南史的贬抑书法、「南朝多以寒人掌机要」是清人赵翼的条目名、纪僧真那件事**不能读成制度** —— 这些都写在 caveat 里。折叠 caveat 等于把这个产品最值钱的部分扔了。 |
| **「史臣曰」是【断语,不是计算】。** | 引擎算得出来的只有三档:没有/五十年/两百年以上。断语是拿《南齐书》体例套出来的话,由规则和模板拼装,**不是 LLM 生成**,也**不是萧子显写的**。它是**拟史家之辞**。 |
| **史料考实。** | 本页史料全程 `curl -sk` 取生 HTML/wikitext 逐字比对,**未用 WebFetch**(它把页面交给小模型转述,实测抓到过伪造引文)。关键句取两个独立见证(zh.wikisource/sidneyluo.net 繁体本/国学导航/ctext)。wikisource 所带的中华书局 `<ref>` 校勘记已全部剥除,未充作史料引文。**引文标点为本项目所加**,未采用任何现代点校本的标点、分段或校勘成果。 |
---
## 一、传主
**人格画像**:严谨专业 · 有理有据 · 简洁利落 · 结论先行 · 耐心细致 · 沉稳克制。**无声望。**
**吏能满格,不善交游,无门第。**
引擎实算(`profileFromSubs` → `computeAxes` → `computeRank(profile, 'nanchao')`,纯函数,无 LLM):
```
六轴 吏能 83 · 沉毅 73 · 文采 40 · 果决 40 · 交游 40 · 机锋 40 · 声望 0
通道 sheren(中书通事舍人)—— gate { fa: 55 },不问门第,只问你办不办得了事
位阶 中书通事舍人 · 品卑而权重 · tier 4
史书归类 倖臣 · stigma = true
```
引擎给的理由(`reasons`,**原样**):
> 你走的是「中书通事舍人」。舍人取精敏者掌诏诰机要——问的是你办不办得了事,不问你是谁家的。
> 这条路最看重吏能,你在这一项是 83。
> **权在其手,而名不在其身:清流不与同席。**
> 而「士族清途」那条路对你关着——清官为士族所专——这条路对绝大多数人根本不开。
> 而「军功」那条路对你关着——方镇握兵,寒人可因军功起。
**士族清途对他关着**:`gate { wang: 75 }`,他是 **0**。这不是「他不够努力」,这是那条路的门槛写在门上。
同一份画像,`compareDynasties` 跑出来的 headline(**原样**):
> **同一个你:在南朝,中书通事舍人;在魏晋,不入流。你什么都没变。**
| 朝代 | 通道 | 位阶 | tier |
|---|---|---|---|
| 汉 | 辟召(四科·明达法令) | 县令 · 六百石 | 3 |
| **魏晋** | **(三条路一条都不开)** | **不入流 · 无品** | **0** |
| **南朝(南齐)** | **中书通事舍人** | **中书通事舍人 · 品卑而权重** | **4** |
| 唐 | 藩镇幕府 | 刺史 · 四品 | 3 |
| 明 | 科举正途 | 知府 · 正四品 | 3 |
> ⚠ **不许拿 tier 画跨朝对比柱状图。** 汉的「二千石」和南齐的「五品」**可比附而不等值**——南朝人自己就折算过(梁天监初定令,于品下注「一品秩为万石,第二第三为中二千石」),但那是把汉代十余级的实际禄级压成挂在品下的三档虚名。**折算得出来,等值等不了。**
---
## 二、列传
> 拟《南齐书》卷五十六·列传第三十七·倖臣 体例。**正文是推演**;句末编号对应下节的注。
> ⚠ 用繁体,是因为它抄的是《南齐书》的字。(下文第六节的引擎输出是简体——那是**原样粘贴**,本页不替引擎转繁、不替它润色。)
**某,不知何許人也**〔1〕。**世無冠冕,不豫鄉論**〔2〕。**少為郡書佐。性沉密,簡於言;每白事,先陳其要,而後列其狀,不為文飾。案牘盈几,剖決無留**〔3〕。
**建元中,補中書省主書**〔4〕。**永明中,以吏幹遷中書通事舍人**〔5〕。**關讞表啟,發署詔敕;頗涉辭翰者,亦為詔文,侍郎之局,復見侵矣**〔6〕。**省內舍人四人,所直四省,某居其一**〔7〕。**天下文簿板籍,入副其省,萬機嚴祕,有如尚書外司**〔8〕。
**四方餉遺,歲各數百萬。同列茹法亮嘗於眾中語人曰:「何須覓外祿,此一戶內年辦百萬。」某不預焉**〔9〕。
**某雖總機要,而士流不與同席**〔10〕。**先是,中書舍人紀僧真幸於武帝,稍歷軍校,容表有士風,謂帝曰:「臣小人,出自本縣武吏,邀逢聖時,階榮至此。為兒昏,得荀昭光女,即時無復所須,唯就陛下乞作士大夫。」帝曰:「由江斅、謝瀹,我不得措此意,可自詣之。」僧真承旨詣斅,登榻坐定,斅便命左右曰:「移吾床讓客。」僧真喪氣而退,告武帝曰:「士大夫故非天子所命。」時人重斅風格,不為權幸降意**〔11〕。**某聞而不言**〔12〕。
**是時王儉因玄象失度,謂上曰:「天文乖忤,此禍由四戶。」仍奏文顯等專擅愆和,極言其事。上雖納之而不能改也**〔13〕。
**及明帝輔政,深知之,始制諸州急事宜密有所論,不得遣典籤還都,而典籤之任輕矣**〔14〕。**建武世,詔命殆不關中書,專出舍人**〔15〕。**某退謂所親曰:「削典籤者此人,養四戶者亦此人。彼非酬吾功,用吾耳。用之則四戶,不用則典籤。」**〔16〕
**某獨以為:權之在四戶者,恩也,非官也。恩繫於一人之喜怒,一人沒則恩沒;官著於令,令在則官在**〔17〕。**乃建言:請以中書舍人分掌諸曹,各當尚書一曹,並為上司,著之於令**〔18〕。**議格不行**〔19〕。**某尋坐事免,卒於家。子孫無聞**〔20〕。
**史官次某於《倖臣》,不次於《良政》**〔21〕。
---
## 三、注
> **caveat 必须显示。** 它才是这一节的价值。
**〔1〕某,不知何許人也** — **【拟托·无史料】**
南齐无此人。「不知何许人也」是史书成例(陶潜《五柳先生传》「先生不知何許人也」即用此格)。
★ **本页故意不给他籍贯。** 编一个「吴兴乌程人」出来,读者会去查,而查出来的是**另一个人**——那就是造假史料。**传主是你,你的籍贯不该由我编。不给此句安卷次。**
**〔2〕世無冠冕,不豫鄉論** — **【推演·无史料】**
对应画像的「无声望」(`wang = 0`)与「无门第」。这是**引擎的输入**,不是史书的记载。**不给此句安卷次。**
**〔3〕性沉密…剖決無留** — **【推演·无史料】**
画像(严谨专业/简洁利落/结论先行/耐心细致/沉稳克制)的文言化。「先陳其要,而後列其狀」=**结论先行**;「剖決無留」=**吏能 83**。**不是史事,不给此句安卷次。**
**〔4〕建元中,補中書省主書** — **半原文**
- **source**:《南齐书》卷十六·志第八·百官志,中书省条
- **quote**:「中書省職,置主書、令史、正書以下。」
- **caveat**:⚠ **官名是原文有的,「建元中补此官」是推演。** 且这一句是该条**全文**——《南齐书·百官志》中书省条只有这一句,**不给品,甚至不列中书通事舍人**。
★ **南齐中书通事舍人具体几品,查不实。** 序文另有「**晉令**舍人位居九品」——那是**晋代法典**的追述,**不是齐制**;《南史》卷七十七序把它改写成「于時舍人之任,位居九品」并**删去「晉令」二字**,据南史引用会把晋令当齐制。旁证只有梁制(十八班之「四班」,极低)与陈制(「依減秩例」,连石数都不给)——**那不是齐**。故本页一律只写「品卑」,**绝不给齐代品数**。
**〔5〕永明中,以吏幹遷中書通事舍人** — **【推演】(这一步是引擎算的,不是史书写的)**
`computeRank` 判定:通道 `sheren`,`gate { fa: 55 }`,他 `fa = 83` → 过。年号「永明」是本页为叙事所系,**史无此人此事,不给此句安卷次。**
⚠ 「以吏干被擢」这个**通道**本身有原文支撑(见〔6〕「齊初亦用久勞,及以親信」),但**这个人**没有。**通道是史料,人是推演——两者不许混。**
**〔6〕關讞表啟,發署詔敕…侍郎之局,復見侵矣** — **原文**
- **source**:《南齐书》卷五十六·列传第三十七·倖臣传**序**(萧子显)
- **quote**:「齊初亦用久勞,及以親信。關讞表啟,發署詔敕。頗涉辭翰者,亦為詔文,侍郎之局,復見侵矣。」
- **caveat**:双证(sidneyluo.net `a/a07/056.htm` 与 zh.wikisource《南齐书·卷56》`action=raw`)。
⚠ **两个见证有异体字之别**,本页照实说:wikisource 作「表**啓**」「爲」「旣」,sidneyluo 作「表**啟**」「為」「既」。**字形不同,文字相同**——故只能说「双证,**文字相同**」,不能说「**逐字**相同」。(`decisions.js` 该条 caveat 现写作「逐字相同」,属**轻微过头**,见 notes。)
⚠ 此为**传序**的通论,描述的是**这一格**,不是**这一个人**。引文标点为本项目所加。
**〔7〕省內舍人四人,所直四省,某居其一** — **原文(两处,必须分开引)**
- **source A**:《南齐书》卷五十六·倖臣传序
**quote A**:「省內舍人四人,所直四省,其下有主書令史,舊用武官,宋改文吏,人數無員。」
- **source B**:《南史》卷七十七·列传第六十七·恩倖传·**呂文顯传(本传,非传序)**
**quote B**:「時中書舍人四人各住一省,世謂之四戶。既總重權,勢傾天下。」
- **caveat**:
★ **「四户」不是尚书/中书/门下/秘书四省。** 决定性内证就是 quote A 的「**省內**舍人四人,所直四省」——四人在「省内」,所直之四「省」只能是该省**内部**的分隔,不可能是四个中央官署。
⚠ **但「直庐」二字原文没有**,是今人训释,**不可当引文**。原文只有三种说法:「所直四省」(南齐书序)、「各住一省」(南史·吕文显传)、「此一戶內」(茹法亮语)。本页一律用原文的说法。**训释归训释,原文归原文。**
⚠ **「四戶」这两个字在今本《南齐书》卷五十六里一次都没有**——本页已 curl 双证全卷扫过:`四戶` **0 命中**、`勢傾天下` **0 命中**。它出**唐人李延寿**《南史》,且在**吕文显本传**,不在传序。**「四户」是南史的词,不是南齐书的词。**
★ **纠一个流行的错**:「勢傾天下」**不是《南史》的贬抑书法**。沈约《宋书》卷九十四·恩倖传序**已经在用**:「鼠憑社貴,狐藉虎威……**勢傾天下**,未之或悟。」李延寿是**承袭沈约的成语**,不是唐人自创。(⚠ 此条与 `dynasties.js` 的 `sheren.sourceCaveat` **正面冲突**,那一句按考实是错的——见 notes,待老板裁。)
⚠ **「某居其一」是推演**:四户是四个具体的人,把「某」放进去等于挤掉一个真人。这正是本产品在做的事(**你**站上那一格),但它是拟托,**不给这四个字安卷次**。
**〔8〕天下文簿板籍,入副其省…有如尚書外司** — **原文**
- **source**:《南齐书》卷五十六·倖臣传序
- **quote**:「莫非左右要密,天下文簿板籍,入副其省,萬機嚴祕,有如尚書外司。」
- **caveat**:双证。⚠ 「有如尚書外司」是萧子显的**比拟**,不是官制名目——南齐**无**「尚书外司」此官。**别把比喻读成编制。**
**〔9〕四方餉遺…此一戶內年辦百萬** — **原文 + 推演**
- **source**:《南史》卷七十七·恩倖传·呂文顯传
- **quote**:「舍人茹法亮於眾中語人曰:『何須覓外祿,此一戶內年辦百萬。』蓋約言之也。」;同传:「四方守宰餉遺,一年咸數百萬。」
- **caveat**:双证。⚠ **赵翼《廿二史札记》转引作「此戶內歲可辦百萬」,与原文不合,不可转引。** 经赵翼转手的南朝引语一律要回原书覆核——他还把《南齐书》劉係宗传的「**明帝**曰」写成「齊武帝亦曰」,措辞全非。
⚠ 「**某不預焉**」是**推演**(画像的「交游 40」「不善交游」),不是史事,**不给这三个字安卷次**。
⚠ 又:此为《南史》文,**唐人所修**,非南齐当代史。
**〔10〕某雖總機要,而士流不與同席** — **【推演 + 引擎判定】**
`computeRank` 给的 `stigma = true`,`stigmaNote`:「权在其手,而名不在其身:清流不与同席。」这是**引擎的话**,不是史书的话。
⚠ 它的原文支撑在〔11〕〔21〕,但那些是**别人的事**。**这个人没有这一条记载,因为这个人不存在。**
**〔11〕紀僧真乞作士大夫,江斅移床** — **原文(★ 全篇 caveat 最重的一条)**
- **source**:《南史》卷三十六·江夷传附**江斅传**(唐·李延寿撰)
- **quote**:「先是中書舍人紀僧真幸於武帝,稍歷軍校,容表有士風。謂帝曰:『臣小人,出自本縣武吏,邀逢聖時,階榮至此。為兒昏,得荀昭光女,即時無復所須,唯就陛下乞作士大夫。』帝曰:『由江斅、謝瀹,我不得措此意,可自詣之。』僧真承旨詣斅,登榻坐定,斅便命左右曰:『移吾床讓客。』僧真喪氣而退,告武帝曰:『士大夫故非天子所命。』時人重斅風格,不為權幸降意。」
- **caveat**:双证(zh.wikisource《南史·卷36》`action=raw` 已剥 `<ref>` + guoxue123 国学导航《南史》卷三十六)。
★ **异文**:wikisource 作「謝**淪**」,国学导航作「謝**瀹**」——**当以「瀹」为是**(谢瀹,谢朏弟,《南齐书》有传),淪/瀹形近而讹。
⚠⚠ **【逸事,非制度】——这是本页最要紧的一条 caveat**:此事在**江斅本传**,**不在《恩倖传》**。故事记在**拒绝者一方**的传里,末句「時人重斅風格,不為權幸降意」才是它的用意:**这是【夸江斅】的故事,不是记录一条规矩。** 且**不见于《南齐书》,为《南史》孤证**。江斅、谢瀹**并无审定士庶的官方职权**,武帝之语是**推诿兼戏谑**。
★ **【绝不可读成「南齐制度规定士族身份不由皇帝授予」】。** 一件逸事+一个孤证+一句败退者的自嘲,撑不起一条制度。
⚠ 末句「士大夫故非天子所命」《南史》属**纪僧真自言**(败退者的自嘲);**作齐武帝语者出顾炎武《日知录·流品》**,语义大不同,不可用。
⚠ 「移吾床**讓**客」——作「**遠**客」者亦出《日知录》,而「移我遠客」四字实出**另一则**移床故事(刘宋张敷拒中书舍人,《宋书》**卷六十二**)。**顾炎武在这一则上动了两处**:改「让」为「远」、把末句从纪僧真自言改成「世祖曰」。**经《日知录》转手的这一则,不可用。**
⚠ 引张敷事**必须引《宋书》卷六十二**,不许引卷四十六——**卷四十六北宋初已亡佚**,今本是后人拿《南史》和高氏小史补的,该卷自带说明。引卷四十六等于引一份《南史》的宋代仿制品。
⚠ 又:此为**永明**事(武帝),本页的十字路口在**建武**(明帝),故引作**前车**,不是某自己的事。引文标点为本项目所加。
★ **附一条史料诚实交代**:`dynasties.js` 的 `shizu.sourceText` 至今把这段**压成第三人称转述**(「中书舍人纪僧真出自本县武吏,得幸于齐武帝,乞作士大夫」),**把僧真的直接引语改没了**,还丢了「稍歷軍校,容表有士風」和「即時無復所須」。**那是转述冒充原文**,正是本项目明令禁止的一类。本页用的是**核实过的全文**。(见 notes。)
**〔12〕某聞而不言** — **【推演·无史料】+ ★ 结构律★二在这里的样子**
这四个字是画像「沉稳克制」的文言化,**不给它安卷次**。
★ **但它对应一件机制上真实发生的事**:`decisions.js` 里有第二个关口 **`nanqi-qi-shidafu`(乞作士大夫,建武末)**——**这部传里它一次都没出现过。**
为什么?三道闸,他撞在第三道上:
| 闸 | 字段 | 他的情况 |
|---|---|---|
| 位阶 | `requiresRank { channelId:'sheren', minTier:4 }` | 过(tier 4,在那一户里) |
| 状态量 | `preconditions.state { quan: 0 }` | 过(他选 `zuo-shi` 后 `quan = +2`) |
| 前序 | `afterDecisions ['nanqi-sheren-simen']` | 过(已答) |
| **永久关闭** | **`closedByOptions ['nanqi-sheren-simen/zuo-shi']`** | **✗ 关死** |
> **你伸手碰过令文,士林那边的门就对你关死了——你再去乞,人家连床都不必挪。**
★ **他看不见这道题,也看不见一个灰按钮。书里根本没写别的。** 这是「性格锁」的同一条原则:**历史不发选项面板。**
★ 同时这也是「**一朝成传**」(结构律★五):这一步之后 `availableDecisions` 返回空 → `closed` **自动置 true** → 这部传写完了。**他这一朝只有一道题。这不是失败态,这本书就是这么写的——只是它很短。**
**〔13〕王儉…上雖納之而不能改也** — **原文(★ 本页纠了一个字)**
- **source**:《南史》卷七十七·恩倖传·**呂文顯传**
- **quote**:「其後玄象失度,史官奏宜修祈禳之禮。王儉聞之,謂上曰:『天文乖忤,此禍由四戶。』仍奏文顯等專擅愆和,極言其事。上雖納之而不能改也。」
- **caveat**:
⚠⚠ **此事不在《王俭传》。** 本页已 curl **双证**扫《南齐书》卷二十三(褚淵/王儉)全卷:`四戶`/`乖忤`/`茹公`/`大位` **两个见证均 0 命中** → **南史孤证**,坐实。
★ **本页纠一个字:不许写「太尉王俭」。** `decisions.js` 的 `situation` 现作「太尉王俭已经奏过你们一本了」,`fei-hu` 的 caveat 亦作「王俭(太尉,高门领袖)」——**「太尉」是他的【赠官】,不是生前职衔。**
- **佐证**:《南齐书》卷二十三(**双证,文字相同**):「故侍中、中書令、太子少傅、領國子祭酒、衞軍將軍、開府儀同三司南昌公儉……天不憖遺,奄焉薨逝……**可追贈太尉**,侍中、中書監、公如故……**謚文憲公**。」
- **即**:他生前是侍中、中书令等,**死后才追赠太尉**。而这一奏发生在他**活着的时候**——**用赠官称呼一个活人,是硬伤。** 本页正文只作「**王儉**」,不给职衔(《南史》记此事时**本来也不给他职衔**)。
⚠ 王俭是**借天象发论**,且结局是「上雖納之而不能改也」——**这是失势者的牢骚兼弹章,不是制度描述。** 不可读成「南齐有过一次制度改革」。
⚠ **另一句「我雖有大位,權寄豈及茹公」同样不在王俭传**,在《南史》卷七十七·**茹法亮传**。
⚠ **系年**:王俭卒于永明七年(489),故此事在**武帝**世,「上」=武帝。本页正文把它排在「明帝辅政」(494)**之前**,是照时间走的。
★ 但它有一个不可替代的用处:**这是有记载以来唯一一次正面冲着舍人之权来的制度尝试,而它失败了。** 位在传主之上不知几级的人都推不动。
**〔14〕明帝輔政…而典籤之任輕矣** — **原文**
- **source**:《南史》卷七十七·恩倖传·呂文顯传(内嵌典签论)
- **quote**:「明帝輔政,深知之,始制諸州急事宜密有所論,不得遣典籤還都,而典籤之任輕矣。」
- **caveat**:双证(ctext + wikisource)。
★ **这是整个十字路口的底料**:蕭鸞辅政期**真的下制削了典签**——而同一个蕭鸞,建武世却「詔命殆不關中書,專出舍人」。**削一个寒人要职、同时养大另一个。真实发生,不必虚构。**
⚠ 南史孤证。(同传另记典签品阶变动:「本五品吏,宋初改為七職。」)
**〔15〕建武世,詔命殆不關中書,專出舍人** — **原文(有要命的异文)**
- **source**:《南齐书》卷五十六·倖臣传序
- **quote**:「建武世,詔命**殆**不關中書,專出舍人。」
- **caveat**:⚠⚠ **《南史》卷七十七恩倖传序同句作「詔命【始】不關中書」**(ctext 四库 + wikisource 二证均作「始」)。
**殆**(几乎/差不多)是**程度描述**;**始**(开始)读起来像**可系年的制度起点**——**语义完全不同**。本页引的是《南齐书》,故一律作「殆」。**引这句必须认明是哪一部书。**
**〔16〕某退謂所親曰…** — **【推演·无史料】**
这段话**没有人说过**。它是把〔14〕那个**真实的制度动作**放进传主眼睛里的结果。**不给此句安卷次。**
**〔17〕權之在四戶者,恩也,非官也** — **【推演·无史料·题眼】**
这是本传的**题眼**,也是**全篇推演成分最重的一句**。**它没有史料,不给它安卷次。**
它是「**授权律**」的文言化:*变法依赖非常规授权;这份授权不在制度里,在一个活人身上。*
⚠ 「事/制/道」三层是**本项目的分析模型**,不是史料自带的范畴——与「品位分等/职位分等是阎步克的分析模型」同样的性质。**前端展示时必须这么标。**
**〔18〕乃建言:請以中書舍人分掌諸曹…著之於令** — **【推演·无史料,但它的措辞抄的是后来的历史】**
⚠ **南齐没有人真的这么奏过。** 此句属推演,**不给它安卷次**。
★ **但「各當尚書諸曹,並為上司」八个字不是我编的——那是《隋书·百官志上》记【陈制】的原文。** 换言之:**传主提的这件事,几十年后由别人做成了。** 见第四节「历史答案」。
**〔19〕議格不行** **〔20〕某尋坐事免,卒於家。子孫無聞** — **【推演·无史料】**
`fate.score = 1`。**不给这几句安卷次。**
可挂的只有那条授权律:**他依附的是一个活人;那个人辅政时能一纸削掉典签,就能一纸削掉他。典签是前车(见〔14〕)。**
⚠ 引擎在这一格自己就写着:「★【推演,非史事】:南齐**没有人真的这么奏过**,故此格无史料可挂——本产品的规矩是查不实的宁可不写,这里明说它是推演,**不给它安卷次**。」
**〔21〕史官次某於《倖臣》,不次於《良政》** — **原文(入传标准)**
- **source A**:《南齐书》卷五十六·倖臣传序末(萧子显)
**quote A**:「領武官,有制局監,領器仗兵役,亦用寒人被恩幸者。今立《倖臣篇》,以繼前史之末云。」
- **source B**:《南史》卷七十七·恩倖传序末(李延寿)
**quote B**:「領武官有制局監、外監,領器仗兵役,亦用寒人。爰及梁、陳,斯風未改。其四代之被恩幸者,今立以為篇,以繼前史之作云爾。」
- **source C**:《南齐书》卷五十六·倖臣传·呂文度传末
**quote C**:「濟陽江瞿曇、吳興沈徽孚等,以士流舍人通事而已,無權利。」
- **caveat**:
★ **入传标准是【恩幸】。** 「寒人」是**统计上的伴随特征,不是门槛**;「掌机要」也**不是门槛**。两个方向都有原文自证:
· **入传而不掌机要**:徐爰主国史礼仪而在《宋书·恩倖传》;吕文度掌制局器仗而在《南齐书·倖臣传》。→ **掌机要不是门槛。**
· **掌机要而不入传**:quote C —— 江瞿曇、沈徽孚**身为中书舍人**却「以士流舍人通事而已,**無權利**」,不立传。→ **当舍人也不是门槛,且舍人内部权力天差地别。**
⚠ **一处必须给的限定**:说「入传标准不是寒人」对**《南史》**成立(李延寿把二者切得很干净:「亦用寒人」是编制描述,「其四代之**被恩幸者**,今立以為篇」才是入传条件);但对**《南齐书》讲得太满**——萧子显作「亦用寒人**被恩幸者**」,句法上「寒人+被恩幸」双条件读得通。故本页表述为:**恩幸是标准,寒人是伴随特征。**
⚠ **「南朝多以寒人掌机要」是清人赵翼《廿二史札记》卷八的条目名,不是南朝史家的分类原则。**
⚠ 南朝**当代史**只有沈约《宋书·恩倖》与萧子显《南齐书·倖臣》两部立此类传,**《梁书》《陈书》不立**;**《南史·恩倖传》是唐人李延寿所修**。
⚠⚠ **某不在《倖臣传》五人之中。** 该篇立传五人,卷首人名行原文:「**紀僧真 劉係宗 茹法亮 呂文顯 呂文度**」(双证,五人无误)。**本传是拟托,不是补传。**
---
## 四、十字路口
> `decisions.js` → `nanqi-sheren-simen`(**四户之一**,南齐建武年间 494–498)
> **位阶门槛**:`requiresRank { dynasty:'nanchao', channelId:'sheren', minTier:4 }`
**够不到的人,根本碰不到这个关口。** 魏晋的他不入流,**看不见这道题**。
而南齐的**士族**走清途也能到 tier 4,**位阶一样高,照样看不见这道题**——他不在那一户里,**他正是那个把床挪开不跟你坐的人**。
> **位阶是杠杆,但杠杆得先握在你手里。**(光比高度会把这一格算错——所以 `requiresRank` 除了 `minTier` 还要 `channelId`。)
### 处境(`situation`,原样)
> 你是中书通事舍人。品卑——具体几品,南齐当代史料查不实,《南齐书·百官志》中书省条只有一句「置主书、令史、正书以下」,连中书通事舍人都没列。而权重——关谳表启,发署诏敕,天下文簿板籍入副其省,有如尚书外司;侍郎之局,复见侵矣。建武世,诏命殆不关中书,专出舍人。省内舍人四人,所直四省,世谓之四户。
> **你手上这一户,一年办得出百万。**
> 而扶你上来的那个人——明帝——他辅政时刚刚下制削了典签之权。**同一个人,削一个寒人要职,同时养大另一个。** 你很清楚:他不是在酬你的功,他是在用你。用得着的时候你是四户,用不着的时候,典签就是前车。
> 太尉王俭已经奏过你们一本了:「天文乖忤,此禍由四戶。」上虽纳之而不能改也。——这是有记载以来,唯一一次正面冲着舍人之权来的制度尝试。**它失败了。**
> 现在轮到你了:**你手上这一户,要不要坐实成一条【写进令文】的定制?**
> ⚠ **本页对 `situation` 有一处不同意**:它写「**太尉**王俭」。**「太尉」是王俭的赠官,不是生前职衔**(见注〔13〕,双证)。本页正文作「王俭」。`decisions.js` 待改,见 notes。
### 四个念头
> ★ **性格锁**:这四个不是给所有人的选项面板,是**你这种人想得到的念头**。
> **不是灰按钮** —— 你看不见灰掉的按钮,因为**书里根本没写别的**。历史不发选项面板。
| | 选项 | 什么样的人会想到这一步 | 层 | 后继者要不要它 | 影响 | 你的下场 |
|---|---|---|---|---|---|---|
| `shou-hu` | 什么都不奏,守住这一户。有人要升你出去,就哭着推掉 | 清醒的趋利者。**那个位子本身才是权力,升官反而是夺权** | 事 | 无可继承 | **没有**(弥合·史势所趋) | **5** 善终,富厚 |
| `qiu-ming` | 受了那个大司农。位列九卿,从此不必再被人当作倖臣 | 在意名分甚于实利。你受不了清流把床挪开的那一下 | 事 | 无可继承 | **没有**(弥合·史势所趋) | **4** 位高而权去 |
| **`zuo-shi`** | **奏请著为令:舍人分掌诸曹,各当尚书一曹,为其上司** | **把事情做完的人。你受不了这套东西悬在一个活人的喜怒上——今日诏命专出舍人,明日一句话就能让它专出别人** | **制** | ★ **要**(梁、陈真的坐实了) | **两百年以上**(跨朝) | **1** 你死在你那一念之前 |
| `fei-hu` | 自请废四户,复诏命于中书。亲手把自己这一户拆了 | 想到底的人。你要动的不是一条制度,是**「凭什么」** | **道** | **不要**(后继者非但没废,还坐实了) | **五十年**(一代人) | **0** 失位,而那一户照旧 |
### ★ 传主选了 `zuo-shi`
画像是「严谨专业/有理有据/结论先行/沉稳克制,**无声望**」。这种人受不了**把一套已经在运转的东西,继续悬在一个活人的喜怒上**。
**他不要这一户的百万,他要这一户在他死后仍然是这一户。**
选完之后,链上的状态量(`replay`,纯函数):`quan +2 · ming −1 · li 0 · xian +3`。
`availableDecisions` 返回 **空** → **一朝成传**,`closed = true`。**他这一朝只有一道题。**
### ★ 当初那个人选了 `shou-hu`
**茹法亮实际选的是「守住这一户」。**
- **source**:《南齐书》卷五十六·倖臣传·茹法亮传
- **quote**:「出法亮為大司農,中書勢利之職,法亮不樂去,固辭不受,既而代人已致,法亮垂涕而出。**年六十四,卒官。**」
- **caveat**:双证(文字相同;异体字 `爲/為`、`旣/既` 之别照实说明)。
★ 大司农位高(九卿),中书通事舍人位卑,**茹法亮宁可哭也不肯升**——他知道那个位子才是权力所在。这是「品卑而权重」最好的心理注脚。
★ **「年六十四,卒官」——原文自己给了 `fate.score = 5` 的证据**:他善终,死在任上。**引擎判 shou-hu 善终,不是我们编的,是史书写着的。**
引文标点为本项目所加。
> **而他守的那一户,几十年后由别人坐实成了定制。他一票都没投,事情照样发生了。**
> 这就是「史势所趋」四个字的代价:**它不是安慰,它是说你根本没在场。**
### ★★ 历史答案:这道题,历史真的答了 —— 分两步,都不是南齐做的
**(一)梁 —— 去掉「通事」二字,并宣布不限资地**
- **source**:《隋书》卷二十六·志第二十一·百官志上(**梁制**)
- **quote**:「通事舍人,舊入直閤內。梁用人殊重,簡以才能,**不限資地**,多以他官兼領。其後除通事,直曰中書舍人。」
- **caveat**:zh.wikisource《隋书·卷26》,已剥除 `<ref>` 校勘记;已用 node 核定该条位于**梁段**(早于「陳承梁,皆循其制官」)。「不限資地」四字是南朝门第观的**正式让步**。⚠ **属梁不属齐**,引用必须标明朝代。
**(二)陈 —— 坐实到底,职权转移完成**
- **source**:《隋书》卷二十六·百官志上(**陈制**)
- **quote**:「國之政事,並由中書省。有中書舍人五人,領主事十人,書吏二百人。……分掌二十一局事,**各當尚書諸曹,並為上司**,總國內機要,**而尚書唯聽受而已**。被委此官,多擅威勢。」
- **caveat**:已用 node 核定此段在「陳承梁,皆循其制官」之后,属**陈制**。★「各當尚書諸曹,並為上司,而尚書唯聽受而已」=**职权正式转移完成**。⚠ 属陈不属齐;又《隋书》为唐初官修,记陈制属**追修**,非陈代当代史。
**南齐那个舍人站的,正是通往此处的岔路口。**
---
## 五、影响 —— `computeImpact('nanqi-sheren-simen', 'zuo-shi')`
```
layer : zhi(制)
years : 200
verdict : 两百年以上
label : 跨朝 —— 后继者仍需要它
```
> ⚠ **years 是【档位标签】,不是测算值。** 史学上「这条制度活了多少年」**没有可精确到年的算法**;给「137 年」这种数字是**伪精确**,是本产品最容易翻车的地方。三档:**没有(弥合)/一代人/跨朝**。**前端不许拿它画曲线。**
**why**(引擎逐条给出,**包括对你不利的那条**):
1. 你改的是【制】:改一条制度。它可能活过你,也可能随授权者一起断气。
2. **变法依赖非常规授权**:你不是靠官品办成这件事的,你靠的是「上以为亲信」。**这份授权不在制度里,在一个活人身上。**
3. ★ 而后继者**仍然需要它** —— 于是它活过了授权者,也活过了你。
4. **它活下来不是因为你了不起,是因为接你班的人仍然需要它。这一票不在你手上。**
5. ★ 这一条**历史上真的被坐实了**,分两步走完,而且**都不是南齐做的**:梁去掉「通事」二字,把它变成正经职名,并宣布「不限资地」;陈把它坐实到底——「各当尚书诸曹,并为上司,总国内机要,而尚书唯听受而已」。后继者继承它,**不是因为你说得对,是因为他们仍然需要它**:一个不靠士族也能发得出诏命的省,对任何一个想亲政的皇帝都太好用了。
6. ★ 你个人的下场:**你死在你自己那一念之前。** 你要把「上以为亲信」写成令文——**那四个字正是明帝手里唯一的缰绳,他不会交出来**;而满朝清流等的就是你伸手去碰令文的那一天。
**——你那一念越深,你自己的下场越坏。这不是巧合,是同一件事的两面:你动的正是授权者身边那些人的命根子。**
### ★★ 这一页的全部重量,压在这一段上
> **`years = 200` 说的是【那条制度】;`fate.score = 1` 说的是【你】。引擎分开算,因为它们本来就是两件事。**
>
> 他的建言「議格不行」,他坐事免、卒于家、子孙无闻。
> 而几十年后,梁去其「通事」、陈使其「各當尚書諸曹,並為上司,而尚書唯聽受而已」。
> **他提的那件事成了定制,而定制里没有他的名字。**
> **他的名字在《倖臣传》里,那条制度在《隋书·百官志》里 —— 两处不相涉。**
>
> ★★★ **而最狠的一句,在正文里写不出来:**
> **记他的那部书,看不见他对没对。**
> 萧子显卒于梁大同三年(537)——《梁书》卷三十五:「大同三年,出爲仁威將軍、吳興太守,至郡未幾,卒,**時年四十九**。」
> 而陈受禅在梁太平二年(**557**)。
> **他死在答案揭晓的整整二十年前。**
> 《南齐书》的体例,**物理上不可能知道「某」是对的**——所以第二节那篇拟《南齐书》的列传,**只能写到「卒於家。子孫無聞」为止**。
> 梁、陈那两条,本页放在**注**里、放在**引擎**里,**没有放进正文**——因为正文是拟托的史官,而**那个史官没活到那一天**。
---
## 六、史臣曰 · 赞曰
> **体例**:《南齐书》卷末「史臣曰」之后**必缀四言韵语「赞曰」**,此摹《后汉书》之例——范晔以「论曰」发议、缀四言「赞曰」,萧子显改**发语词**为「史臣曰」,**赞例不改**。故二者之别**只在发语词,不在有没有赞**。
> (⚠ **绝不许**说成「《南齐书》用史臣曰,《后汉书》用论曰+赞曰」——那是把发语词之别偷换成体例之别,读起来像《南齐书》没有赞。**这条被史学核查专门打回过。**)
>
> 以下为 `shizanForLineage(lineage)` 的**实际输出,原样粘贴**:简体、未转繁、未润色、未删节。
> 它是**规则+模板**拼装的(`shizan.js`),**不是 LLM 生成**,纯函数,跑两次一样。
**史臣曰**:中书通事舍人,品卑而权重。品卑者,其秩齐世无文可考;权重者,关谳表启、发署诏敕,天下文簿板籍入副其省,侍郎之局复见侵矣。沈约论宋之恩倖,尝曰:「人主谓其身卑位薄,以为权不得重。曾不知鼠凭社贵,狐藉虎威,外无逼主之嫌,内有专用之功,势倾天下,未之或悟。」——移之于齐,亦无不合。
某者,四户之一也。建武之世,诏命殆不关中书,专出舍人,是其时也。彼欲以令文易恩幸,是欲夺人主手中之缰而纳之于官府也。
而其法竟历梁、陈而不废。非其人之贤足以致此,乃后之君犹需之耳——**此一票不在彼手中,在后人手中。**
论曰:**所念愈深者,其身愈危;其身愈全者,其念愈浅。二者未尝可得而兼。** 何则?浅者不触人之所恃,故无怨;深者所触,正人主左右之所恃以为命者也。
虽然,权在其手而名不在其身:史官次之于《倖臣》,不次之于《良政》。入此篇者,以恩幸,不以寒素,亦不以掌机要——徐爰主国史礼仪而在焉,吕文度掌器仗兵役而在焉;而江瞿昙、沈徽孚身为舍人,以士流通事而已,无权利,则不在焉。**此史家之别裁也,非制度之品第也。**
**赞曰**:
> **省内四户,机要所因。**
> **欲著为令,不恃恩亲。**
> **念之愈深,去死愈邻。**
> **后有需者,故历梁陈。**
*(韵脚:因·亲·邻·陈 —— 四言,八句成篇,两句一联,偶句押韵,一韵到底,无重韵。`validateZan` 实测 `violations = []`。)*
> ⚠ **押韵的诚实交代**:本项目用**平水韵·上平十一真**检押韵。但**平水韵是金元韵书**(刘渊、王文郁,13 世纪),**萧子显(489–537)根本没见过它**;《切韵》(601) 亦在其身后六十余年。南朝人押的是**当时的实际语音**。故「合平水韵真部」只是**本项目自定的、可机检的规则**,**不是「萧子显会这么押」的断言**。前端**不许**把它说成「依南朝韵例」。选真部纯粹因为它字多好凑,且「臣、身、因、人、亲、邻、尘、陈」正好都在里面——**这是工程便利,不是史学主张。**
### ★★ 而这一段,是史家的判断,不是计算
> ★「史臣曰」与「赞曰」是**史家的断语,不是计算**。本引擎算得出来的,只有那三档:**两百年以上(跨朝 —— 后继者仍需要它)**——而且它是**档位标签,不是测算值**,史学上「这条制度活了多少年」没有精确到年的算法。断语是拿体例套出来的话:它由规则和模板拼装,**不是 LLM 生成**,也**不是萧子显写的**。它是**拟史家之辞**。
> **正文是推演,注才是原文——要信,请只信注。**
> ⚠⚠ **一处已知的体例破绽,本页照实交代,不遮**:上面那句「**而其法竟历梁、陈而不废**」,**萧子显写不出来**——他死于 537,陈受禅在 557(见第五节)。他能看见梁,**看不见陈**。
> 这句话是 `shizan.js` 的 `SHICHEN_HE[200]`,是**本项目拟作的史臣**说的,那个史臣**知道后事**。
> 这不违反 disclosure(它已明说是「拟史家之辞」「不是萧子显写的」),但**它确实不是《南齐书》能有的句子**。**记在这里,别哪天被人当成萧子显的原话。**
---
## 七、这一页是怎么来的 —— 哪些是算的,哪些是史料,哪些是史家的判断
### ① 算的 —— 纯函数,无 LLM,跑两次一模一样
| 项 | 出自 | 值 |
|---|---|---|
| 六轴 | `computeAxes(subs)` | 吏能 83 · 沉毅 73 · 余四轴 40 · 声望 0 |
| 通道 | `computeRank` | `sheren`(gate `{fa:55}` → 83 过) |
| **位阶** | `computeRank` | **中书通事舍人 · 品卑而权重 · tier 4** |
| 关着的路 | `blocked` | 士族清途(`wang` 需 75,他 **0**)/军功(`wu` 需 55,他 40) |
| 状态量 | `replay(chain)` | `quan +2 · ming −1 · li 0 · xian +3` |
| 影响档 | `computeImpact` | `years = 200`(**档位标签,不是测算值**) |
| 关口开闭 | `availableDecisions` | 关口二被 `closedByOptions` **永久关闭** → 链走完 → `closed = true` |
> **这一整列,`shizanForLineage` 跑两次 `deepEqual` 全等**(链上无时间戳、无随机数、`lineage.id` 由 `subjectId/dynasty/edition` 拼出来)。
> ★ **位阶在开传那一刻快照冻结**(`rankSnapshot`),不随分身后来的生长而变。**否则用户会为了够关口去刷起居注,那本书照的就不是他,是他的 build。**
> ★ 状态量(权/名/利/险)**没有 setter,永远不会有**。想把「利」刷到 3,唯一的办法是在关口一选「守住这一户」——**那不是刷属性,那就是做那个选择本身。gate 的是你的【选择】,不是你的【数值】。**
> ★ **没有读档。** 全模块无 `undo/revert/rollback/pop`。想换活法只有一条路:**另写一版**(`anotherEdition`)——**开一部新书,从头写;旧的那部仍然在、仍然可读。两部都成立。** 那不是读档,那是同一个人的另一部传。
### ② 史料 —— 原文,可点开,每条带朝代限定
| 用在哪 | 出处 | 见证 | 朝代限定 |
|---|---|---|---|
| 舍人职掌·四户内证·建武「殆」·入传标准 | 《南齐书》卷五十六·倖臣传序 | **双证**(wikisource/sidneyluo,**文字相同,异体字有别**) | 齐 ✓ |
| 茹法亮固辞大司农·年六十四卒官 | 《南齐书》卷五十六·茹法亮传 | **双证** | 齐 ✓ |
| 倖臣传立传五人 | 《南齐书》卷五十六卷首人名行 | **双证** | 齐 ✓ |
| 王俭「太尉」是赠官 | 《南齐书》卷二十三 | **双证** | 齐 ✓ |
| 「四户/乖忤/茹公」不在王俭传 | 《南齐书》卷二十三全卷扫描 | **双证 0 命中** | 齐 ✓ |
| 「四户」「势倾天下」不在南齐书 | 《南齐书》卷五十六全卷扫描 | **双证 0 命中** | 齐 ✓ |
| 四户·一户百万·典签·王俭奏 | 《南史》卷七十七·吕文显传 | 双证 | **唐修,非当代史** ⚠ |
| 纪僧真乞作士大夫·江斅移床 | 《南史》卷三十六·江斅传 | **双证**(异文 淪/瀹,当作瀹) | **唐修·永明事·南史孤证·逸事** ⚠⚠ |
| 「势倾天下」沈约已用 | 《宋书》卷九十四·恩倖传序 | 单证 | **刘宋,非齐** ⚠ |
| 舍人坐实为定制 | 《隋书》卷二十六·百官志上 | 单证(已 node 核定梁段/陈段) | **梁·陈,非齐** ⚠ |
| 萧子显卒年 | 《梁书》卷三十五 | 单证 | 梁 |
> **引文标点为本项目所加**,未采用任何现代点校本的标点、分段或校勘成果(原文字符属公有领域;**标点、分段、校勘是现代点校者的智力成果**)。
> **查不实的,宁可不写。** 本页多处「不给它安卷次」,就是这条规矩的样子。
### ③ 史家的判断 —— 论曰
「史臣曰」「论曰」「赞曰」**全部是断语,不是计算**。它们由 `shizan.js` 的规则+模板拼装,**不是 LLM**,**不是萧子显**,是**拟史家之辞**。
引擎只算得出三档;**「他这一念传了多远」是判断,「200」是档位标签**。
> **正文是推演,注才是原文。要信,请只信注。**
### ④ 这一页跑了哪些代码
| 环节 | 文件 | 性质 |
|---|---|---|
| 位置 · 天花板 | `src/dynasties.js` `computeRank` / `compareDynasties` | 纯函数,无 LLM(**本轮未改**) |
| 决策点 · 授权律 · 关口串联 | `src/decisions.js` `computeImpact` / `availableDecisions` / `appendDecision` | 纯常量+纯函数,无 IO/DB/LLM |
| 史臣曰 · 赞曰 | `src/shizan.js` `shizanForLineage` | **规则+模板**,非 LLM,纯函数 |
| 回归守卫 | `test/decisions.test.js` 等 | 全量 **188 条全绿** |
**授权律**(`decisions.js` 的核心,**可执行、非文案**):
> 一、变法依赖**非常规授权**——你不是靠官品办成事的,你靠的是「上以为亲信」。这份授权**不在制度里,在一个活人身上**。
> 二、**授权者一死,变法即亡**——商鞅—孝公、王安石—神宗、雍正—雍正自己。
> 三、★ **但制能否存活,不取决于你,取决于后继者是否仍需要它**——商鞅死而秦法行,不是因为商鞅了不起,是因为惠王仍需要那套法;军机处活到清末,因为此后每个皇帝都需要它。
>
> → **★★ 你个人的下场,和你那一念的深度,是反相关的。**
> 改「事」的人善终,因为他没动谁的奶酪,**也因此什么都没留下**。
> 改「制」「道」的人下场坏,因为他动的正是**授权者身边那些人的命根子**。
> **而他动的那个东西能不能活过他,他自己一票都投不了——那张票在后继者手里。**
---
> **分身不是传主,分身是史官。传主是你。**
> **不是游戏,是 AI 帮你写的一部书。没有失败态**——某这一朝只有一道题、这本书只有一页,那不是「你输了」,是**这本书就是这么写的**。
+319
View File
@@ -0,0 +1,319 @@
# 考验 · 汉 —— 残差 = 天
> **一句话:天 = 0 格。而这不是「没测到」,是【测不到】—— 我能证明。**
> 而老板要验的那一格(李广),**不是最大的正残差 —— 它是最大的【负】残差。**
跑法:`computeRank(profileFromSubs(subsOf(id), { wang: 0 }), 'han')`
数据:`src/renwu.js`(十人,画像/实职/入仕,curl 双证)· `src/kaoyan.js`(成绩单 + 残差)
**引擎(`dynasties.js` / `talents.js`)一个字未改。** `npm test` 473/473。
---
## ⚠ 先读这一条:这场考试不合法
**引擎的汉 = 东汉(`era:'东汉'`, `span:'25–220'`);本表十人 = 西汉,全部死于公元前 87 年以前。**
十人里**四个的实职是御史大夫**,而 `dynasties.js` 自己的铁律写着「御史大夫**不是东汉官**,绥和元年(前8)罢」。
⇒ **引擎里那个汉,连这四个人的官名都不存在。**
**✗ 谁都不许把下面任何一个数读成「引擎在汉朝的准确率」。那个数不存在,因为题目错配。**
跑它的理由只有一个:**量出引擎的行为**,不是给引擎打分。
---
## 成绩单 + 残差(每人一行)
`residual = 引擎给的位阶 − 他真到的位阶`
· **正残差(算高了)= 天** —— 引擎说他该到,他没到。
· **负残差(算低了)= 引擎缺变量** —— 他到了,引擎说他到不了。**这不是天,是我们的错。**
| 人 | 画像(史官的字,逐字) | 出处 | 我们认出的 subs | 引擎给的 | 他真到的 | **残差** |
|---|---|---|---|---|---|---|
| 晁错 | 錯為人**陗直刻深** | 《史記》卷101 | blunt, rigorous | 辟召·县令 **3** | 御史大夫 **5** | **−2** 缺变量 |
| 张汤 | 湯爲人**多詐,舞智以御人** | 《史記》卷122 | —(空) | 一条路都不开·斗食 **0** | 御史大夫 **5** | **−5** 缺变量 |
| 汲黯 | 黯為人**性倨,少禮,面折,不能容人之過** | 《史記》卷120 | blunt | 军功·县令 **3** | 主爵都尉 **5** | **−2** 缺变量 |
| **李广** | 廣**訥口少言**,其善射亦天性也,莫能及廣 | 《史記》卷109 | —(空) | 一条路都不开·斗食 **0** | 郎中令(九卿)**5** | **−5** 缺变量 |
| 李蔡 | 蔡**爲人在下中**,名聲出廣下甚遠 | 《史記》卷109 | —(空) | 一条路都不开·斗食 **0** | 丞相 **5** | **−5** 缺变量 |
| 公孙弘 | 弘為人**恢奇多聞**/**意忌,外寬內深** | 《史記》卷112 | datadriven, warm | 辟召·县令 **3** | 丞相 **5** | **−2** 缺变量 |
| 董仲舒 | 董仲舒為人**廉直** | 《史記》卷121 | blunt | 军功·县令 **3** | 江都相→膠西相 | **—** 量不了 |
| 桑弘羊 | 雒陽賈人子,**以心計**,年十三侍中 | 《史記》卷30 | datadriven | 辟召·县令 **3** | 御史大夫 **5** | **−2** 缺变量 |
| 兒宽 | 寬為人溫良…**善著書、書奏,敏於文,口不能發明也** | 《史記》卷121 | warm, calm | 一条路都不开·斗食 **0** | 御史大夫 **5** | **−5** 缺变量 |
| 程不识 | **爲人廉,謹於文法** | 《史記》卷109 | rigorous | 辟召·县令 **3** | 太中大夫(比千石) | **—** 量不了 |
**天(正残差):0 格 | 缺变量(负残差):8 格 | 算对:0 格 | 量不了:2 格**
**残差的值域:{−5, −2}。只有两个值,中间什么都没有,正的一个也没有。**
> 「量不了」**不是**「算对了」:董仲舒是诸侯王相、程不识是比千石 —— **本表的梯子上没有他们站的地方**。
> ✗ 不许算成错(那是拿「我们没有这一格」冒充「他不行」),✗ 也不许算成对。
---
## ★ 李广那一行 —— 老板点名要单独拎出来的那一格
```
引擎:一条路都不开 → 郡县小吏,斗食(tier 0)
史料:「然廣不得爵邑,官不過九卿」(《史記》卷109,双证)→ tier 5
残差:0 − 5 = −5 = 【缺变量】,不是天
```
**老板的前提是:「按这套尺子:李广该封侯。历史:他没有。」**
**⇒ 这个前提错了。按这套尺子,李广不该封侯 —— 引擎说他连门都进不去。**
为什么:司马迁给李广的画像是**「訥口少言」——他不说话**。
而 `talents.js` 的 16 项副天赋**全部是「说话风格」**(它自陈:「副天赋 = 说话风格软技能」)。
⇒ **一个「訥口少言」的人,在一张只量说话风格的表上必然是零。这不是巧合,是定义。**
**而最狠的一刀**:李广的实职**正是靠军功挣的**(「以良家子從軍擊胡,用善騎射,殺首虜多」)。
而引擎的军功之路是 `gate{wu:55}`,`wu`(果决)的两项副天赋是 **blunt(直率坦诚)** 与 **motivate(激励感染)**——
**两项都是说话。**
⇒ **把李广挡在军功门外的那道闸,量的是他最不擅长的那一样,而那一样与打仗毫无关系。**
### ⇒ 所以:引擎和司马迁站在同一边了吗?
**没有。它们连符号都相反。**
- 司马迁(和王勃)说的是**「他该封而没封」**—— 那是一个**正**残差的形状。
- 引擎说的是**「他连军功的门都进不去」**—— 那是一个**负**残差,而且是满格的。
**老板说「如果不是 —— 照实说,那更有意思」。照实说:不是。**
---
## ★ 太史公那句原话 —— 老板要我 curl 回来的那一句
> 老板:「我记得大意是『广不为后人,然无尺寸之功以得封邑者,何也?』——
> **但这是我的记忆,不是见证。以你 curl 到的为准。我记错了就照实说。**」
照实说,**三处**。curl `-sk` 生文本,双证逐字目验,未用 WebFetch。
见证 W:`zh.wikisource《史記/卷109》action=raw` | 见证 T:`ctext.org shiji/li-jiang-jun-lie-zhuan/zh`
### 错一:那句话**不是太史公问的** —— 是**李广自己**问的,对一个**看相的**说的
> 廣嘗與**望氣王朔**燕語,曰:「自漢擊匈奴而廣未嘗不在其中,而諸部校尉以下,才能不及中人,
> 然以擊胡軍功取侯者數十人,而廣不爲後人,**然無尺寸之功以得封邑者,何也?豈吾相不當侯邪?且固命也?**」
**「何也?」是李广的原话**,在**燕語**(私下闲谈)里,对象是**望氣王朔 —— 一个看气看相的方士**。
**而太史公曰整段里没有这个问题**(双证逐字):
> 太史公曰:傳曰:「其身正,不令而行;其身不正,雖令不從。」其李將軍之謂也?
> **余睹李將軍悛悛如鄙人,口不能道辭。**及死之日,天下知與不知,皆爲盡哀。
> 彼其忠實心誠信於士大夫也?諺曰「桃李不言,下自成蹊」。此言雖小,可以諭大也。
⇒ **太史公没有问「为什么」。他在【辩护】。**
> ★ 而这一段里躺着一件我没找、它找上我的东西:**「口不能道辭」**——
> 司马迁**在论赞里又说了一遍**李广不会说话(本传已有「廣訥口少言」)。
> ⇒ **司马迁认为「不会说话」重要到要在总评里再写一次 —— 而那正好是我们唯一量得了的那一样,
> 他两次说的都是「没有」。于是我们给他零。**
> 三个证人:李广「訥口少言」(卷109 本传)、「口不能道辭」(卷109 论赞)、兒寬「口不能發明也」(卷121)。
> ⚠ 而**论赞不进映射**(「拿它做映射就是拿答案当题目」)—— 这一句只当读数,李广的 subs 仍然是空的。
### 错二:**「李廣難封」不出《史記》**
- 见证 W:**「難」0 次、「难」0 次**(全卷,**逐字符**数,不整词搜)。
- 见证 T:字符串命中 2 次 —— **逐个读上下文复核:两次都是 ctext 页面左栏导航里的书名「難經」**,不在正文里。⇒ 正文 0 次。
**真出处:唐·王勃《滕王閣序》(约 675 年)**,双证(两个独立四库底本,**均无标点** —— 正因无标点,它们是「字」的独立见证):
> 嗟乎!時運不齊,命途多舛。**馮唐易老,李廣難封。**屈賈誼於長沙,非無聖主;竄梁鴻於海曲,豈乏明時?
⚠ 异文(标出,不暗合):《古今事文類聚》四库本作「**時運不齊**」,《文苑英華》四库本作「**大運不窮**」并自注「一作時運不齊」。而「馮唐易老,李廣難封」八字**不在异文之列**。
⇒ **老板说「中国人两千年前就给它起好名字了」—— 那个名字是【一千三百多年前】起的,起名字的是【王勃】,不是司马迁。**
> **⚠ 而必须立刻把另一半说满,否则这就是一次「假订正」:**
> **「李广没封侯」这件【事】,《史記》正文说了,逐字,双证:「然廣不得爵邑,官不過九卿」。**
> ✗ **绝不许由此写「李广难封是编的」**——**事实在原文里,只有那四个字的【措辞】是唐人的。**
> (照 `shiliao.js` 在「御史大夫中二千石」上换来的口径:「注有、正文无,本表只收正文」,✗ 不许说「本表无据」。)
**而这个错的形状,是本项目反复钉死的那一种 —— 把一句后人的名言,当成史料本身的话:**
| 名言 | 真身 |
|---|---|
| 「上品无寒门」 | 刘毅**请废九品的政论**,不是制度描述 |
| 「为与士大夫治天下」 | 文彦博的一句**辩论词**(神宗当场顶了回去,而那句回话被后世的书删了) |
| 「南朝多以寒人掌机要」 | **赵翼《廿二史札记》的条目名**,不是南朝史家的分类原则 |
| **「李广难封」** | **王勃《滕王閣序》的一句骈文**,不是《史記》的话 |
**同一个坑的第四次。而这一次踩它的是出题的人。**
—— 这一句必须说出来,因为本项目的全部纪律都建立在「谁都会踩这个坑」上,而**「谁都」包括出题的人**。
### 错三:见上 —— 按这套尺子,李广是斗食小吏,不是该封侯的人
---
## ★★ 而原文自己,把这个残差**解释掉了三次**
> 老板的红线:「**不许把残差解释掉。**『李广没封是因为他情商低』——那是给引擎打补丁,不是承认天。」
《史記》卷109 正文,双证:
1. **李广自己**:「豈吾**相**不當侯邪?且固**命**也?」——**相**与**命**。
2. **王朔(方士)**:「**禍莫大於殺已降,此乃將軍所以不得侯者也。**」——**报应**。
(李广自己招了:「吾誘而降,降者八百餘人,**吾詐而同日殺之**。至今大恨獨此耳。」)
3. **武帝**:「大將軍青亦**陰受上誡**,以爲李廣老,**數奇**,毋令當單于,恐不得所欲。」——**數奇**(命数不好)。
⇒ **「不许把残差解释掉」这条规矩,两千年来没有一个人遵守过 —— 包括李广本人:
问残差的那个人,和第一个把它解释掉的那个人,是同一个人,在同一句话里。**
⇒ **古人不承认残差。他们给残差起名字,然后把它当成一个已知量。**
> ⚠ 见证学的判别器又生效了一次:「數奇」在见证 W(收注)出现 2 次,见证 T(只收正文)**1 次**。
> **A 有而 B 无 ⇒ 它是注。**「失道見斥,**數奇不封**」是**司馬貞索隱(唐人注)**,不是史料。本表只取正文那一次。
### ★★★★ 而最要紧的一层在③ —— 它正面撞上老板的另一条红线
> 老板:「**不许把「天」做成一个变量塞进 computeRank。天不接受输入 —— 这是它的定义。
> 它只能被【测量】,不能被【计算】。**」
**而武帝就是那么干的。**
「數奇」是汉朝人给「天」起的名字,而他**拿它做了一个人事决定**:
陰受上誡 ⇒ 「毋令當單于」⇒ 徙前將軍廣出東道。
**而那正是李广结局的开端**:出东道 →「東道少回遠」→ 失道 → 后期 → 引刀自剄。
⇒ **天被当成变量的那一刻,它就不再是天了 —— 它成了那个人下场的原因。**
**⇒ 即:老板那条红线【有原文依据】。而它不是一条工程纪律,它是《史記》卷109 里一个人的死法。**
> **⚠ 两条限定语,缺一条这就是过度解读:**
> ① **这条因果链是【本表串的】,不是原文说的。** 原文给的是四笔事实(陰受上誡/徙東道/東道少回遠/失道),
> 它**没有说**那是死因。✗ 不许写成「司马迁认为武帝的偏见害死了李广」——那是我的读法,不是他的话。
> ② **而原文自己给了一个竞争解释,就在同一段**:「而是時公孫敖新失侯…**大將軍亦欲使敖與俱當單于,故徙前將軍廣**。」
> ⇒ 徙他的第二个理由是**卫青要给公孙敖让路 —— 一个人情,不是一个天命**。
> **原文把两个理由并排放着,一个字的裁断都没有。本表照办:两个都摆,不裁。**
---
## ★ 为什么「天」测不到 —— 两行算术,不是运气
| | 值域 |
|---|---|
| **引擎在汉能说的** | `tier ∈ {0, 3}` —— 穷举 524,288 个 profile 实测,且**解析证明**:三条通道 `kemu` 全空 ⇒ `tier ≡ clamp(seat,0,cap) ≡ 3`;不过闸 ⇒ 0。⇒ **max = 3** |
| **这张卷子的答案** | `realTier ∈ {5}` —— 八个可判分的人实职全是三公/九卿。⇒ **min = 5** |
⇒ **`residual = engineTier − realTier ≤ 3 − 5 = −2`,恒成立。**
⇒ **正残差的定义域是空的。**
**⇒ 「天」不是「这次没出现」,是【在这张卷子上不可能出现】。**
而废掉它的有**两条独立的理由**,**修好任何一条,另一条照样废它**:
1. **答案表的形状**(幸存者偏差):《百官公卿表》**按定义只记坐上位子的人**。
⇒ 这是**史料的问题**,而且**修不了**:一个做到县令就到头的人,**史书不给他立传**。
2. **引擎的值域**:就算给我一张实职分布在 0–5 的完美名单,**汉的引擎最高也只会说 3**
⇒ 对任何 `realTier ≥ 4` 的人,残差**必然是负的**。⇒ 这是**引擎的问题**,**与名单无关**。
> **★ 一个最高只会说「县令」的引擎,永远不可能说出「他该封侯」。⇒ 它没有资格产生一个正残差。**
---
## ★ 残差的形状(老板:「报个案,报形状,别报百分比」)
**哪一类人算得准?——没有一类。八格全是负残差,0 格算对。**
**但形状是有的,而且很干净:残差只有两个值,中间什么都没有。**
- **残差 = −2(晁错、汲黯、公孙弘、桑弘羊)** ⟺ 引擎**认出了**他的画像 ⇒ 过闸 ⇒ tier 3
**共同点不是他们更能干**,是**司马迁给他们的词,正好落在我们有的那 16 项上**
(陗直→blunt、刻深→rigorous、面折→blunt、多聞→datadriven、外寬→warm、以心計→datadriven)。
- **残差 = −5(张汤、李广、李蔡、兒宽)** ⟺ 引擎**没认出** ⇒ 一条路都不开 ⇒ tier 0
**而这四个人不是更差**:
- 张汤「多詐,舞智以御人」:16 项里**没有「诈」**;
- 李广「訥口少言」:16 项**全是说话,而他不说话**;
- 李蔡「爲人在下中」:**那根本不是画像,是一个【等第】**——司马迁没描写他,他**直接给了个分**;
- 兒宽 subs **非空**(`[warm, calm]`)—— 而**汉的三条闸一项都看不见**(jiao 被 `wang=0` 连坐、heng 汉不看)
⇒ **效果与空完全相同**。
⇒ **★ 所以这张残差表,描述的不是历史,是【我们自己的词汇表】。**
**残差的两个值 = 我们词汇表的两个状态:接住了(−2),没接住(−5)。**
**而历史那一头是个常数(八人实职全是 5)。**
⇒ **一个减法,被减数是我们的词汇表,减数是一个常数。于是「残差」这个量,从头到尾量的都是我们自己。**
**而「天」要的恰恰是相反的东西**:它要求**引擎那一头有内容,历史那一头有变化**。
⇒ 实测:**两头都是常数。两个常数相减,得到的还是常数 —— 那里面没有「天」。**
⇒ **「天」是残差里【减不掉】的那一部分。而这张卷子上,残差 100% 由「我们认不认得这个人的画像」解释。
残差里一点天都没有。**
---
## ★★ 最后回答那个问题(老板:这比成绩单重要)
> **这场考试考出的是「引擎能算命」,还是「引擎是一套自洽的胡话」?老实说。**
**两个都不是。老实说,是第三个 —— 而它比两个选项都难堪:**
### **引擎不是胡话 —— 它的每一句话都有出处。问题是它几乎不说话。**
- 全表 20 条通道,**19 条的位阶是常数函数**(解析 + 穷举双证)。
而这**正好等于原文授权的区分度 —— 一处不多,一处不少**:唯一会变的那条(宋武举)
恰恰是唯一一条有原文可依的(「策、武藝俱優為右班殿直……武藝末等者三班差使」)。
⇒ **19 条常数不是 19 个 bug,是 19 次「本表数不出你的高下」的诚实回答。**
- **汉这一朝,引擎的位阶信息量 = 1 bit**,而那 1 bit 是「你过没过闸」,不是「你能爬多高」。
**连三条路的天花板都是平的(全是 5)——「通道即杠杆」在汉一格都没兑现。**
### **而它也不能算命 —— 十个人,八个算错,而且错法完全一样。**
**老板说「如果十个人里八个算错 —— 那就说八个算错,那也是今天最值钱的一句话」。**
**⇒ 八个算错。八个全是【算低了】。零个算对。零个算高。**
### **★ 而真正的答案是这一句:**
> **它不是在算命,也不是胡话 —— 它是一把量错了东西的、诚实的尺子。**
>
> **六轴的名字是「文采/吏能/果决/交游/沉毅/机锋」—— 那是【选官的取向】。
> 而它的唯一输入,是 16 项【说话风格】。
> ⇒ 它名义上在量「你这种人」,实际上在量「你这种说法」。**
>
> **李广是天下第一的射手,兒寬是天下第一的笔杆子 —— 他们的共同点是【都不会说话】。
> 于是我们给他们两个零。**
**⇒ 而这一条对「天」是决定性的:**
**老板说「谋事在人,成事在天」。这一刀量出来的是:**
> ## **「人」这一半还没做完,「天」那一半就无从谈起。**
「人」= 引擎能算的那部分 = 在汉只有 1 bit,而且量的是你会不会说话。
**一个只有 1 bit 的「人」,减出来的残差里,装不下「天」。**
**残差要能读成「天」,前提是「人」那一头已经把能算的都算完了 ——
而我们连李广是个将军都不知道,我们只知道他不爱说话。**
⇒ **所以「天」这一刀的结论是:它没有被做完,它被证明【还不能开始】。**
---
## 要老板拍的(一条都没自作主张,`kaoyan.js` NOT_DONE)
1. **「天 = 残差」这个定义是对的,我认它 —— 但它在这张卷子上【不可用】,而且我能证明。**
要开始它,得先拍 `renwu.js` NOT_DONE ②:**那一维(「你是什么人」)到底补不补 —— 那一维就是「人」。**
2. **✗ 强烈反对「给汉编一套 kemu 让 tier 够得着答案」**:那是**为了测出「天」而发明「人」**。
⚠ 而且它有一层更阴的:**发明出来的 kemu 会【立刻】制造出正残差**——
引擎一旦能说 5,而历史上有人没做到 5,那个差**当场就出现**。
⇒ **于是我们会得到一张「天」的表,而那张表里的每一个「天」,都是我们自己编的那套 kemu 的产物。**
**★ 那不是测量天,那是制造天。**
3. **李广这一格仍然是这个产品最好的文案,只是文案的内容【反了】**:
✗ 不是「引擎和司马迁都说他该封侯,而历史没有 ⇒ 那就是天」;
★ 而是 **「司马迁说他天下无双,我们的引擎说他是个斗食小吏。⇒ 错的是我们,不是天。」**
⚠ **这是一句产品定性的话,不是一句文案** —— 它把这个功能从「算命」改成「认错」。
4. **而若要正面用李广,「數奇」比「李广难封」值钱得多**:
「李广难封」是一句**感慨**(而且是唐人的);
**「數奇」是一次【判断的执行】—— 它才是这个产品真正在做的那件事的镜子:
我们也在给人算一个数,然后把那个数告诉他。**
---
## 我自己招的
- **`Object.keys(SUB_REGISTRY)` 那一跤**:`SUB_REGISTRY` 是**数组**,`Object.keys` 给的是下标 `["0".."15"]`,
于是穷举时**一个 sub 都没点上** ⇒ 全 tier 0 ⇒ 结果是 `[0]`。
**★ 而一个【采样错了】的穷举,长得和一个【引擎变了】的穷举一模一样 —— 我差点去改断言。**
⇒ 立了口径:**穷举的 subs 全集必须取【引擎自己读的那一份】(`AXES.subs`)**,并加了一条断言钉死
「`AXES` 读的 16 项 == `SUB_REGISTRY` 的 16 项」。**是我自己的断言抓出来的,不是我读出来的。**
- **第五次零命中,而它是一个【新种类】**:扫「不爲後人」→ 零命中,差点写成「原文无此语,老板记错了」。
逐字符复核:原文作「而廣不爲**-{後}-**人」——**两个原因叠在一起**:
① 字形(《史記》用「爲」);② **wikisource 的 `-{ }-` 转换标记【插在词的中间】**。
⇒ **`renwu.js` 立的那条口径(写成 `[為爲]` 字形类)挡不住第二个** —— 就算写 `[為爲]`,「不爲-{後}-人」仍然零命中。
⇒ **立一条更严的:凡在 wikisource 生文本上检索,必须【先剥 `-{ }-` 与 `{{ProperNoun|}}`,再检索】。字形类不够。**
(`renwu.js` 自陈它「程序化剥除」了标记 —— 它**知道**要剥,但它的**检索口径**那一条只写了字形类。
**一个文件在两个地方各说了一半,合起来才够用。**)
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "digital-me-backend",
"version": "0.1.0",
"description": "数字我 / Digital Me — 个人 AI 数字分身平台 API(有脸、有声音、有知识库的「另一个你」)",
"private": true,
"type": "module",
"main": "src/index.js",
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"test": "node --test"
},
"dependencies": {
"ali-oss": "^6.23.0",
"alipay-sdk": "^4.14.0",
"cors": "^2.8.5",
"minio": "^8.0.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-rate-limit": "^7.4.1",
"helmet": "^8.0.0",
"jimp": "^1.6.1",
"jsonwebtoken": "^9.0.2",
"mammoth": "^1.8.0",
"mongodb": "^6.12.0",
"multer": "^2.0.0",
"openai": "^4.77.0",
"pdf-parse": "^1.1.1",
"ws": "^8.18.0"
},
"engines": {
"node": ">=20.0.0"
}
}
File diff suppressed because it is too large Load Diff
+94
View File
@@ -0,0 +1,94 @@
/*!
* 数字我 / Digital Me — embeddable chat widget.
* Usage:
* <script src="https://YOUR-API/widget.js"
* data-slug="your-slug"
* data-site="https://your-web-origin"
* data-name="你的名字"></script>
* Injects a floating launcher button that opens「和我分身聊天」in a popup.
*/
(function () {
var s =
document.currentScript ||
(function () {
var all = document.getElementsByTagName('script');
return all[all.length - 1];
})();
if (!s) return;
var slug = s.getAttribute('data-slug');
if (!slug) {
console.warn('[digital-me] widget: missing data-slug');
return;
}
// Where the /me/:slug page is served (the web app). Defaults to the
// script's own origin (works when web+api share an origin behind a proxy).
var site = s.getAttribute('data-site') || new URL(s.src).origin;
var name = s.getAttribute('data-name') || '我的数字分身';
var accent = s.getAttribute('data-accent') || '#6d5efc';
var chatUrl = site.replace(/\/$/, '') + '/me/' + encodeURIComponent(slug);
if (document.getElementById('dm-widget-root')) return; // idempotent
var root = document.createElement('div');
root.id = 'dm-widget-root';
root.style.cssText =
'position:fixed;right:20px;bottom:20px;z-index:2147483000;font-family:system-ui,-apple-system,sans-serif';
// Popup (iframe) — hidden until opened.
var panel = document.createElement('div');
panel.style.cssText =
'width:380px;max-width:calc(100vw - 32px);height:560px;max-height:calc(100vh - 120px);' +
'background:#0f1117;border-radius:16px;overflow:hidden;box-shadow:0 18px 60px rgba(0,0,0,.45);' +
'margin-bottom:12px;display:none;transform-origin:bottom right;transition:opacity .15s,transform .15s;' +
'opacity:0;transform:scale(.96)';
var iframe = document.createElement('iframe');
iframe.src = chatUrl;
iframe.title = name;
iframe.setAttribute('allow', 'autoplay; microphone');
iframe.style.cssText = 'width:100%;height:100%;border:0';
panel.appendChild(iframe);
// Launcher button.
var btn = document.createElement('button');
btn.setAttribute('aria-label', '和' + name + '聊天');
btn.style.cssText =
'width:58px;height:58px;border-radius:50%;border:0;cursor:pointer;float:right;' +
'background:' +
accent +
';box-shadow:0 8px 24px rgba(0,0,0,.35);display:flex;align-items:center;' +
'justify-content:center;transition:transform .12s';
btn.innerHTML =
'<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#fff" ' +
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>';
btn.onmouseenter = function () {
btn.style.transform = 'scale(1.06)';
};
btn.onmouseleave = function () {
btn.style.transform = 'scale(1)';
};
var open = false;
btn.onclick = function () {
open = !open;
if (open) {
panel.style.display = 'block';
requestAnimationFrame(function () {
panel.style.opacity = '1';
panel.style.transform = 'scale(1)';
});
} else {
panel.style.opacity = '0';
panel.style.transform = 'scale(.96)';
setTimeout(function () {
if (!open) panel.style.display = 'none';
}, 150);
}
};
root.appendChild(panel);
root.appendChild(btn);
(document.body || document.documentElement).appendChild(root);
})();
@@ -0,0 +1,90 @@
// 采【加权求和版】引擎的产出 —— 这一刀的「旧版」参照。
//
// ★★ 这个脚本【只在改 dynasties.js 之前跑过一次】,跑完它采下来的 dynasties-weighted-sum.json
// 就是死的。★ 绝不要在改完引擎之后再跑它来「更新」参照 —— 那等于把被告改成原告。
//
// ⚠ 它自证真伪:采下来的五个单轨朝代,指纹必须与 dynasties-pre-multitrack.json 里
// 【旧代码采的那份】逐字相同。对得上 ⇒ 这份快照确实是旧引擎的产出,不是我编的。
// (本项目的铁律:最难发现的错是「假订正」。一份自己造的「旧版」参照,就是假订正的温床。)
//
// 跑:node scripts/capture-weighted-sum.mjs
//
// ⚠ 它住在 scripts/ 而不是 test/,是【故意的】:`node --test` 会把 test/ 底下的【每一个】文件
// 都当测试跑(不只是 *.test.js)。而本脚本的自证在引擎改完之后【必然抛异常】——
// 那是它设计上的正确行为,却会把 npm test 弄红。放在 scripts/ 下,它才是它该是的东西:
// 一次性的采集工具,不是测试。
import crypto from 'node:crypto';
import fs from 'node:fs';
import { DYNASTIES, computeRank, profileFromSubs } from '../src/dynasties.js';
import { SUB_REGISTRY } from '../src/talents.js';
// ⚠ 必须与 dynasties-tracks.test.js 的 matrix() 【完全同构】,否则指纹对不上。
function matrix() {
const ALL = SUB_REGISTRY.map((s) => s.id);
const combos = [[]];
for (const s of ALL) combos.push([s]);
for (let i = 0; i < ALL.length; i++) for (let j = i + 1; j < ALL.length; j++) combos.push([ALL[i], ALL[j]]);
combos.push(['rigorous', 'datadriven', 'concise', 'shortfirst', 'patient', 'calm']);
combos.push(['poetic', 'story', 'vivid']);
combos.push(['socratic', 'humor', 'poetic', 'story', 'vivid']);
combos.push(['blunt', 'motivate']);
combos.push(ALL);
const out = new Map();
for (const subs of combos) {
for (const wang of [0, 30, 55, 60, 75, 80, 90, 100]) {
out.set(`${subs.join('+') || '∅'}|w${wang}`, profileFromSubs(subs, { wang }));
}
}
return [...out.entries()];
}
// ── 自证:五个单轨朝代的指纹必须与【旧代码采的】 pre-multitrack fixture 对得上 ──
const PRE = JSON.parse(fs.readFileSync(new URL('../test/fixtures/dynasties-pre-multitrack.json', import.meta.url), 'utf8'));
const rows = matrix();
if (rows.length !== PRE._matrix.count) throw new Error(`矩阵变了:${rows.length} ≠ ${PRE._matrix.count}`);
const sorted = rows.slice().sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
for (const d of ['han', 'weijin', 'nanchao', 'tang', 'ming']) {
const h = crypto.createHash('sha256');
for (const [k, p] of sorted) h.update(k + '|' + JSON.stringify(computeRank(p, d)));
const got = h.digest('hex');
if (got !== PRE.hashes[d]) {
throw new Error(
`✗ ${d} 的指纹与 pre-multitrack 对不上 —— 当前代码【已经不是】加权求和版了。\n` +
' 这个脚本只能在改引擎【之前】跑。现在跑它,采到的是新引擎的产出,' +
'整个「新旧对照」就成了自己跟自己比。'
);
}
}
// ── 采:七朝 × 1128 profile 的【判定投影】──
// ⚠ 只采判定相关的格子,不采 reasons/cites(那是文案,几十 MB,且不参与判定)。
const proj = (r) => (r === null ? null : {
ch: r.channel ? r.channel.id : null,
tier: r.rank.tier,
title: r.rank.title,
grade: r.rank.grade,
unit: r.rank.unit,
ceil: r.ceiling ? r.ceiling.tier : null,
cat: r.category || '',
stig: !!r.stigma,
blk: r.blocked.length,
});
const out = {
_what: '【加权求和版】引擎(score += val(k)*weight → tier = round(score/100*5))的产出,七朝 × 1128 profile。',
_why:
'★ 这一刀【必须】让输出变(原文里没有加权求和的原型)。所以这个文件【不是迁移锁】—— ' +
'它是【被告】:新引擎每改动一格,都要在 dynasties-shape.test.js 里说清楚是哪条原文要求它这么改。',
_dontRegen:
'★★ 绝不要用新引擎重新生成本文件。它的全部价值在于它是【旧代码】的产出。' +
'重生成 = 把被告改成原告 = 本项目铁律里的「假订正」。' +
'自证:五个单轨朝代的指纹与 dynasties-pre-multitrack.json 逐字相同(采集时已校验并通过)。',
_capturedBy: 'scripts/capture-weighted-sum.mjs(2026-07-16,改引擎之前)',
_matrix: PRE._matrix,
_selfCheck: '✓ han/weijin/nanchao/tang/ming 五朝指纹 = dynasties-pre-multitrack.json 的指纹(采集时校验通过)',
rows: Object.fromEntries(
sorted.map(([k, p]) => [k, Object.fromEntries(DYNASTIES.map((d) => [d.id, proj(computeRank(p, d.id))]))])
),
};
fs.writeFileSync(new URL('../test/fixtures/dynasties-weighted-sum.json', import.meta.url), JSON.stringify(out));
console.log(`✓ 采到 ${sorted.length} 个 profile × ${DYNASTIES.length} 朝;五朝指纹自证通过。`);
+71
View File
@@ -0,0 +1,71 @@
// 用 CDP 给页面注入 dm_token 后截图(验证需登录的后台)。
// 用法:node scripts/cdp-shot.mjs <token> <targetPath> <outPng>
// 前置:chrome 已带 --remote-debugging-port=9222 启动,并已打开 http://localhost:5173/login
import WebSocket from 'ws';
import { writeFileSync } from 'node:fs';
const [, , TOKEN, TARGET, OUT] = process.argv;
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
async function listTargets() {
const res = await fetch('http://localhost:9222/json/list');
return res.json();
}
async function main() {
// 找到 5173 的页面 target。
let ws = '';
for (let i = 0; i < 30; i++) {
const ts = await listTargets();
const t = ts.find((x) => x.type === 'page' && /localhost:5173/.test(x.url));
if (t) { ws = t.webSocketDebuggerUrl; break; }
await wait(300);
}
if (!ws) throw new Error('no 5173 page target');
const sock = new WebSocket(ws, { perMessageDeflate: false, maxPayload: 64 * 1024 * 1024 });
let id = 0;
const pending = new Map();
const send = (method, params = {}) =>
new Promise((resolve) => { const mid = ++id; pending.set(mid, resolve); sock.send(JSON.stringify({ id: mid, method, params })); });
await new Promise((r) => sock.on('open', r));
sock.on('message', (buf) => {
const msg = JSON.parse(buf.toString());
if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg.result); pending.delete(msg.id); return; }
if (msg.method === 'Network.responseReceived') {
const u = msg.params?.response?.url || '';
if (/\/api\//.test(u)) console.log(' NET', msg.params.response.status, u.replace('http://localhost:3701', ''));
}
if (msg.method === 'Runtime.exceptionThrown') console.log(' EXC', msg.params?.exceptionDetails?.text, msg.params?.exceptionDetails?.exception?.description?.slice(0, 120));
});
await send('Page.enable');
await send('Runtime.enable');
await send('Network.enable');
// 先到根页把 token 写进 localStorage。
await send('Page.navigate', { url: 'http://localhost:5173/' });
await wait(2500);
const set = await send('Runtime.evaluate', {
expression: `localStorage.setItem('dm_token', ${JSON.stringify(TOKEN)}); localStorage.getItem('dm_token')?.length || 0`,
returnByValue: true,
});
console.log('token len in storage:', set?.result?.value);
// 第一次整页跳(预热会话,冷启动可能被守卫弹走)。
await send('Runtime.evaluate', { expression: `location.href='http://localhost:5173${TARGET}'` });
await wait(5000);
// 第二次跳目标(此时 store 已 authed、user 已拉取 → 稳定停在目标页)。
await send('Page.navigate', { url: `http://localhost:5173${TARGET}` });
await wait(6000);
const diag = await send('Runtime.evaluate', {
expression: `JSON.stringify({path: location.pathname, tok: (localStorage.getItem('dm_token')||'').length, h1: document.querySelector('h1')?.textContent?.slice(0,20), bodyHas: document.body.innerText.includes('大师墙')})`,
returnByValue: true,
});
console.log('after nav:', diag?.result?.value);
const { data } = await send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
writeFileSync(OUT, Buffer.from(data, 'base64'));
console.log('saved', OUT);
sock.close();
process.exit(0);
}
main().catch((e) => { console.error(e); process.exit(1); });
+183
View File
@@ -0,0 +1,183 @@
// ★★★★ 2026-07-17 第三刀:【全表死物 census —— 「读 0 次 / 改变 0 格」逐个列出】
// 老板逐字:「把每个进 reasons 的数、每个 key 位、每个 axis,逐个 census:被读几次?改变几格?
// 读 0 次 或 改变 0 格 的,全部列出来。」
//
// ⚠ 口径三条,写死在这里:
// ① 【改变几格】= 【突变数据点 + 比对引擎自己的产物】,不是复算。
// ② ★【「改变 0 格」比「读 0 次」更宽,而它才是产品要问的那一问】——
// 南朝的 lex 被【读了 98,304 次、裁决了 81,920 次】,而它【改变 0 格】(第六刀实测)。
// ⇒ 一个被读了八万次的东西,可以什么都没买到。故本 census 量的是【买到了什么】。
// ③ ★★★【每一个读数都必须报它是在哪个 chushen 配置下量的】——
// 第六刀的验收判词逐字:「一个不说配置的读数,不是读数。」
// ⚠ 而这一条【本 census 第一版自己就栽了】:默认 chushen 未答 ⇒ 辽三条路全关
// ⇒ 辽的每一个数据点都报「死」。那不是死,那是【我没让它活】。
// ⇒ 故:辽跑 3 种(未答/契丹/汉),明跑 2 种(未答/倡優之家),其余五朝无出身维。
// ★【死】= 在【它自己那一朝的每一种配置下】都改变 0 格。
import { DYNASTIES, computeRank, computeAxes, AXIS_IDS } from '../src/dynasties.js';
import { chushenOf } from '../src/chushen.js';
import { SUB_REGISTRY } from '../src/talents.js';
const SUBS = SUB_REGISTRY.map((s) => s.id);
const WANGS = [0, 29, 30, 54, 55, 74, 75, 100]; // 与 census-kai.mjs 逐字同 —— 门槛 30/55/75 的两侧
// ★ 2^16 subs 去重成 2,790 个不同的六轴取值(带权 w)——【判决只看六轴,不看 subs】。
// ⇒ 与 524,288 逐格跑【逐字等价】,而快 20 倍。w 把「条次」还原回「profile 数」。
const TUPLES = (() => {
const m = new Map();
for (let b = 0; b < (1 << 16); b++) {
const subs = []; for (let i = 0; i < 16; i++) if (b & (1 << i)) subs.push(SUBS[i]);
const a = computeAxes(subs);
const k = AXIS_IDS.map((x) => a[x]).join(',');
m.set(k, (m.get(k) || 0) + 1);
}
return [...m].map(([k, w]) => ({ axes: Object.fromEntries(k.split(',').map((v, i) => [AXIS_IDS[i], Number(v)])), w }));
})();
const PER_DYN = TUPLES.reduce((s, t) => s + t.w, 0) * WANGS.length; // = 524,288
// 每朝要跑的 chushen 配置。★ null 也是一种配置(「这一问还没问出口」),不是缺省值。
const CONFIGS = {};
for (const d of DYNASTIES) {
const dim = chushenOf(d.id);
CONFIGS[d.id] = dim ? [null, ...dim.values.map((v) => v.id)] : [null];
}
// ★ 存 hash 不存串:156,240 格 × 3.2KB 会把内存吃穿(第一版就是这么死的)。
const fnv = (s) => { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); } return h >>> 0; };
// 【整页】用户读得到的全部(reasons 的每一个字都在里面)
const page = (r) => fnv(JSON.stringify(r));
// 【判词】这一部传写的那一句:你走哪条路、第几阶、什么头衔
const verdict = (r) => fnv(`${r.channel ? r.channel.id : '-'}|${r.rank.tier}|${r.rank.title}|${r.nearest || '-'}`);
function scan(dynId, chushen, over = null) {
const out = [];
for (const t of TUPLES) for (const wang of WANGS) {
const axes = over ? { ...t.axes, ...over.axes } : t.axes;
const r = computeRank({ axes, wang, chushen, ...(over ? over.extra : null) }, dynId);
out.push({ w: t.w, p: page(r), v: verdict(r) });
}
return out;
}
const BASE = {};
for (const d of DYNASTIES) for (const cs of CONFIGS[d.id]) BASE[`${d.id}|${cs}`] = scan(d.id, cs);
const diff = (dynId, cs, now) => {
const base = BASE[`${dynId}|${cs}`];
let dv = 0, dp = 0;
for (let i = 0; i < base.length; i++) {
if (base[i].v !== now[i].v) dv += base[i].w;
if (base[i].p !== now[i].p) dp += base[i].w;
}
return { dv, dp };
};
// 突变一个数据点 → 在【那一朝的每一种 chushen 配置】下重扫 → 逐配置报
function mutate(dynId, apply, undo) {
apply();
const per = CONFIGS[dynId].map((cs) => ({ cs, ...diff(dynId, cs, scan(dynId, cs)) }));
undo();
return { per, dv: Math.max(...per.map((p) => p.dv)), dp: Math.max(...per.map((p) => p.dp)) };
}
function perturb(dynId, over) {
const per = CONFIGS[dynId].map((cs) => ({ cs, ...diff(dynId, cs, scan(dynId, cs, over)) }));
return { per, dv: Math.max(...per.map((p) => p.dv)), dp: Math.max(...per.map((p) => p.dp)) };
}
const rows = [];
const push = (kind, dyn, what, r, note = '') => rows.push({ kind, dyn, what, ...r, note });
for (const d of DYNASTIES) {
for (const c of d.channels) {
const x = c.xuan;
// ── ① 每一个 gate 闸口:删掉它
for (const k of Object.keys(c.gate || {})) {
const old = c.gate[k];
push('gate', d.id, `${c.id} gate{${k}:${old}} 删掉`,
mutate(d.id, () => { delete c.gate[k]; }, () => { c.gate[k] = old; }));
// ★★ 闸的【那个数】本身买到了什么?六轴取值只有 40/63/73/79/83(sat 曲线的产物)
// ⇒ (40,63] 里的任何 need 是【同一道闸】。50 与 55 是不是同一道闸 —— 让引擎自己说。
if (k !== 'wang' && k !== 'lao') {
for (const alt of [41, 50, 55, 63]) {
if (alt === old) continue;
push('gate-num', d.id, `${c.id} gate{${k}:${old}→${alt}}`,
mutate(d.id, () => { c.gate[k] = alt; }, () => { c.gate[k] = old; }));
}
}
}
// ── ② 每一个 key 位:删掉它
const key = x.key || [];
for (let i = 0; i < key.length; i++) {
push('key-pos', d.id, `${c.id} key[${i}]:${key[i]} 删掉`,
mutate(d.id, () => { x.key = key.filter((_, j) => j !== i); }, () => { x.key = key; }));
}
if (key.length) push('key-all', d.id, `${c.id} key=${JSON.stringify(key)} 整条删掉`,
mutate(d.id, () => { x.key = []; }, () => { x.key = key; }));
// ── ③ 每一条 kemu
const kemu = x.kemu || [];
for (let i = 0; i < kemu.length; i++) {
push('kemu', d.id, `${c.id} kemu[${i}]:${kemu[i].axis}>=${kemu[i].need} 删掉`,
mutate(d.id, () => { x.kemu = kemu.filter((_, j) => j !== i); }, () => { x.kemu = kemu; }));
}
// ── ④ seat ±1
const seat = x.seat;
push('seat', d.id, `${c.id} seat ${seat}→${seat + 1}`,
mutate(d.id, () => { x.seat = seat + 1; }, () => { x.seat = seat; }));
// ── ⑤ ceiling:两问分开 —— 它在【择路】那一级读,和它在 tierOf 里【夹】,是两件事
if (c.ceiling) {
const ct = c.ceiling.tier;
if (ct !== 5) push('ceiling', d.id, `${c.id} ceiling.tier ${ct}→5`,
mutate(d.id, () => { c.ceiling.tier = 5; }, () => { c.ceiling.tier = ct; }));
// ★★ 【clamp 那一夹】—— 纯算术,不用跑:tier = clamp(seat + 通过条数, 0, ceiling.tier)
// ⇒ seat + kemu.length 是 tier 的【最大可能值】。它 ≤ ceiling.tier ⇒ 那个 clamp 【永远不夹】。
const most = (x.seat || 0) + (x.kemu || []).length;
push('ceiling-clamp', d.id, `${c.id} tierOf 的 clamp(…,0,${ct}):seat+全通=${most}`,
most > ct ? { per: [], dv: -1, dp: -1 } : { per: [], dv: 0, dp: 0 },
most > ct ? '夹得住' : '★ 恒不夹(seat+kemu ≤ ceiling)');
}
// ── ⑥ 标记位
for (const f of ['hollow', 'stigma', 'regular']) {
const old = c[f];
push('flag', d.id, `${c.id} ${f} ${!!old}→${!old}`,
mutate(d.id, () => { c[f] = !old; }, () => { c[f] = old; }));
}
const as = c.assimilation;
push('flag', d.id, `${c.id} assimilation ${as || 0}→${as ? 0 : 0.5}`,
mutate(d.id, () => { c.assimilation = as ? 0 : 0.5; }, () => { c.assimilation = as; }));
}
}
// ── ⑦ 每一条轴:整个钉死成 40(=「这条轴上一项都没点过」)
for (const d of DYNASTIES) for (const a of AXIS_IDS) {
push('axis', d.id, `轴 ${a} 全钉成 40(=一项都没点)`, perturb(d.id, { axes: { [a]: 40 } }));
}
// ── ⑧ profile 上那几个【引擎的签名收了的】字段:喂值进去
for (const d of DYNASTIES) for (const [nm, extra] of [
['lao', { lao: 9999 }], ['soul', { soul: 100 }],
['counts', { counts: { catchphrase: 9, attitude: 9, timeline: 9 } }], ['profession', { profession: 'poet' }],
]) push('profile', d.id, `profile.${nm} = ${JSON.stringify(Object.values(extra)[0])}`, perturb(d.id, { extra }));
const N = (n) => (n < 0 ? '—' : n.toLocaleString());
const pad = (s, n) => { s = String(s); let w = 0; for (const ch of s) w += ch.charCodeAt(0) > 0x2000 ? 2 : 1; return s + ' '.repeat(Math.max(1, n - w)); };
const num = (s, n) => String(s).padStart(n);
const isDead = (r) => r.dv === 0 && r.dp === 0;
const onlyPage = (r) => r.dv === 0 && r.dp > 0;
console.log(`\n══ 死物 census ══ 每朝每配置 ${N(PER_DYN)} 个 profile(2,790 个六轴取值 × 8 wang,带权 = 524,288)`);
console.log(`══ 配置:辽 3 种(未答/契丹/汉)、明 2 种(未答/倡優之家)、其余五朝无出身维`);
console.log(`══ 【改变 0 格】= 这个数据点在整张表上什么都没买到(取【全部配置里最大的那个】,故不冤枉它)\n`);
console.log(pad('类', 14) + pad('朝', 9) + pad('数据点', 44) + num('判词变', 10) + num('整页变', 10) + ' 备注');
console.log('─'.repeat(100));
const want = process.argv.includes('--all') ? () => true : (r) => isDead(r) || onlyPage(r);
for (const r of rows) {
if (!want(r)) continue;
console.log(pad(r.kind, 14) + pad(r.dyn, 9) + pad(r.what, 44) + num(N(r.dv), 10) + num(N(r.dp), 10) +
' ' + (isDead(r) ? '★★ 死(0 格)' : onlyPage(r) ? '★ 只说话不判事' : '') + (r.note ? ' ' + r.note : ''));
}
console.log(`\n──────────────────────────────────────────────`);
console.log(`数据点总数 ${rows.length}`);
console.log(`★★【死】改变 0 格 ${rows.filter(isDead).length}`);
console.log(`★【只改整页不改判词】 ${rows.filter(onlyPage).length} =「它说了一句话,而它什么都没判」`);
console.log(`活的(改判词) ${rows.filter((r) => r.dv > 0).length}`);
console.log(`\n★ 逐配置明细(死物在【每一种】配置下都是 0):--verbose`);
if (process.argv.includes('--verbose')) {
for (const r of rows) {
if (!isDead(r) || !r.per.length || r.per.length < 2) continue;
console.log(pad(r.kind + ' ' + r.dyn + ' ' + r.what, 56) + r.per.map((p) => `${p.cs || '未答'}:${p.dv}/${p.dp}`).join(' '));
}
}
+145
View File
@@ -0,0 +1,145 @@
// ★★★★★ 2026-07-17 第三刀:【gapOf —— 「排在后面 ≠ 不参选」这一族的第八次】
//
// 【机理,先说死,它是算术,不是史学】
// 六轴的取值是 sat() 饱和曲线的产物,【只有五个值】:40 / 63 / 73 / 79 / 83(本刀实测)。
// ⇒ (40,63] 之间【没有任何一个 profile 落得进去】。
// ⇒ 【gate{wu:41} 与 gate{wu:55} 与 gate{wu:63} 是【同一道闸】】—— 开/关逐格相同,零例外。
// ★ 即:55 这个数【作为闸是一个空操作】—— 本文件自己也这么说:
// 「因此 gate 的口径是:55 ≈「这条轴上至少点过一项」,70 ≈「至少两项」。」
// ⇒ ★★【它自陈是一个【代号】,不是一个刻度。】
//
// 【而它在另一个地方【被当成刻度读了】】
// `gapOf(t) = Math.max(...t.missing.map((m) => m.need - m.got))`
// ⇒ 【need − got】。★ need 是一个代号,got 是一个曲线的产物 —— 两者相减【不是一段距离】。
// ⇒ 而 nearest 的第二级就是拿它排的。⇒ 【一个自陈是代号的数,在暗处裁决了「你离哪条路最近」。】
//
// 本 census 分三问,逐问都拿引擎自己的产物答:
// ① 【同一道闸的那几个数,开/关变几格?】(应为 0 —— 若不为 0,我上面的机理是错的)
// ② 【而它们让 nearest 变几格?】
// ③ 【gapOf 裁决的时候,比的是不是同一条轴?】(同 lexCompare 的那一问,实测同轴 0 次)
import { DYNASTIES, computeRank, computeAxes, AXIS_IDS } from '../src/dynasties.js';
import { chushenOf } from '../src/chushen.js';
import { SUB_REGISTRY } from '../src/talents.js';
const SUBS = SUB_REGISTRY.map((s) => s.id);
const WANGS = [0, 29, 30, 54, 55, 74, 75, 100];
const TUPLES = (() => {
const m = new Map();
for (let b = 0; b < (1 << 16); b++) {
const subs = []; for (let i = 0; i < 16; i++) if (b & (1 << i)) subs.push(SUBS[i]);
const a = computeAxes(subs);
m.set(AXIS_IDS.map((x) => a[x]).join(','), (m.get(AXIS_IDS.map((x) => a[x]).join(',')) || 0) + 1);
}
return [...m].map(([k, w]) => ({ axes: Object.fromEntries(k.split(',').map((v, i) => [AXIS_IDS[i], Number(v)])), w }));
})();
const CONFIGS = {};
for (const d of DYNASTIES) { const dim = chushenOf(d.id); CONFIGS[d.id] = dim ? [null, ...dim.values.map((v) => v.id)] : [null]; }
const N = (n) => n.toLocaleString();
const pad = (s, n) => { s = String(s); let w = 0; for (const ch of s) w += ch.charCodeAt(0) > 0x2000 ? 2 : 1; return s + ' '.repeat(Math.max(1, n - w)); };
// ★ 六轴的全部可能取值 —— 让引擎自己报,不许我手写
const VALS = {}; for (const a of AXIS_IDS) VALS[a] = [...new Set(TUPLES.map((t) => t.axes[a]))].sort((x, y) => x - y);
console.log('══ ① 六轴的取值:sat() 曲线只给得出这几个数 ══');
for (const a of AXIS_IDS) console.log(` ${pad(a, 6)} ${VALS[a].join(' ')}`);
// 一道闸 need=d 的【等价类】:所有让「开/关」逐格相同的整数 n
const classOf = (axis, d) => {
const vs = VALS[axis];
const passes = (n) => vs.filter((v) => v >= n).join(',');
const me = passes(d);
const out = [];
for (let n = 1; n <= 100; n++) if (passes(n) === me) out.push(n);
return out;
};
console.log('\n══ ② 每一道六轴闸的【等价类】—— 类里的任何一个数,是【同一道闸】 ══');
console.log(pad('闸', 34) + pad('等价类(这些数【是同一道闸】)', 34) + '类的大小');
console.log('─'.repeat(84));
const GATES = [];
for (const d of DYNASTIES) for (const c of d.channels) for (const [k, need] of Object.entries(c.gate || {})) {
if (k === 'wang' || k === 'lao') continue; // wang 是 0–100 的自由值,不是 sat 的产物 ⇒ 它的数是真的
const cls = classOf(k, need);
GATES.push({ d, c, k, need, cls });
console.log(pad(`${d.id}/${c.id} gate{${k}:${need}}`, 34) + pad(`${cls[0]}–${cls[cls.length - 1]}`, 34) + cls.length);
}
// ── 跑:把每一道闸换成它等价类里的每一个端点,看【开/关】与【nearest】各变几格
console.log('\n══ ③ 换成等价类里的另一个数:【开/关】变几格?【nearest】变几格? ══');
console.log(pad('闸 → 换成', 40) + '开/关变'.padStart(10) + 'nearest 变'.padStart(12) + ' ★');
console.log('─'.repeat(72));
const scan = (dynId, cs) => {
const out = [];
for (const t of TUPLES) for (const wang of WANGS) {
const r = computeRank({ axes: t.axes, wang, chushen: cs }, dynId);
// 【开/关】= 哪几条路对你开着(引擎自己的 kai + blocked)
const gates = r.kai.map((k) => k.channelId).sort().join(',') + '#' + r.blocked.map((b) => b.channel).sort().join(',');
out.push({ w: t.w, gates, near: r.nearest || '-' });
}
return out;
};
const BASE = {};
for (const d of DYNASTIES) for (const cs of CONFIGS[d.id]) BASE[`${d.id}|${cs}`] = scan(d.id, cs);
let deadGate = 0, movedNear = 0;
for (const g of GATES) {
for (const alt of [g.cls[0], g.cls[g.cls.length - 1]]) {
if (alt === g.need) continue;
const old = g.c.gate[g.k];
g.c.gate[g.k] = alt;
let dg = 0, dn = 0;
for (const cs of CONFIGS[g.d.id]) {
const b = BASE[`${g.d.id}|${cs}`], now = scan(g.d.id, cs);
for (let i = 0; i < b.length; i++) { if (b[i].gates !== now[i].gates) dg += b[i].w; if (b[i].near !== now[i].near) dn += b[i].w; }
}
g.c.gate[g.k] = old;
if (dg === 0) deadGate++;
if (dn > 0) movedNear += dn;
console.log(pad(`${g.d.id}/${g.c.id} {${g.k}:${g.need}→${alt}}`, 40) + N(dg).padStart(10) + N(dn).padStart(12) +
' ' + (dg === 0 ? '★ 同一道闸(0 格)' : '⚠ 不是同一道闸!') + (dn > 0 ? ' ★★ 而 nearest 变了' : ''));
}
}
console.log(`\n★★★【${deadGate} 处:换个数,开/关一格都不动 —— 它作为闸是空操作】`);
console.log(`★★★【而 nearest 一共变了 ${N(movedNear)} 格 —— 那个空操作在暗处裁决了「你离哪条路最近」】`);
// ── ④ gapOf 裁决的时候,比的是不是同一条轴?
console.log('\n══ ④ 【无路者】那一支:nearest 是谁判的?gapOf 比的是不是同一条轴? ══');
const gapOf = (ms) => (ms.length ? Math.max(...ms.map((m) => m.need - m.got)) : 0);
const worstAxis = (ms) => { let b = null; for (const m of ms) if (!b || m.need - m.got > b.need - b.got) b = m; return b; };
for (const d of DYNASTIES) for (const cs of CONFIGS[d.id]) {
const S = { noRoad: 0, byCount: 0, byGap: 0, byIdx: 0, gapSameAxis: 0, gapCrossAxis: 0 };
const pairs = new Map();
for (const t of TUPLES) for (const wang of WANGS) {
const r = computeRank({ axes: t.axes, wang, chushen: cs }, d.id);
if (r.channel) continue;
S.noRoad += t.w;
if (!r.nearest) continue;
// 复算这一支的三级(★ 与 computeRank 的 sort 逐字同源),只为回答「是哪一级判的」
const dd = DYNASTIES.find((x) => x.id === d.id);
const rows = [];
dd.channels.forEach((c, idx) => {
const val = (k) => (k === 'wang' ? wang : t.axes[k]);
const ms = Object.entries(c.gate || {}).filter(([k, n]) => val(k) < n).map(([k, n]) => ({ axis: k, need: n, got: Math.round(val(k)) }));
rows.push({ name: c.name, ms, idx });
});
const pool = rows.filter((x) => r.blocked.some((b) => b.channel === x.name) && !r.blocked.find((b) => b.channel === x.name).byChushen);
if (pool.length < 2) continue;
const win = pool.find((x) => x.name === r.nearest);
if (!win) continue;
const rivals = pool.filter((x) => x !== win);
if (rivals.every((x) => x.ms.length > win.ms.length)) { S.byCount += t.w; continue; }
const same = rivals.filter((x) => x.ms.length === win.ms.length);
if (same.every((x) => gapOf(x.ms) > gapOf(win.ms))) {
S.byGap += t.w;
for (const x of same) {
const a = worstAxis(win.ms), b = worstAxis(x.ms);
if (a && b) { if (a.axis === b.axis) S.gapSameAxis += t.w; else { S.gapCrossAxis += t.w; const k = `${a.axis}(差${a.need - a.got}) vs ${b.axis}(差${b.need - b.got})`; pairs.set(k, (pairs.get(k) || 0) + t.w); } }
}
continue;
}
if (same.some((x) => gapOf(x.ms) === gapOf(win.ms))) S.byIdx += t.w;
}
if (!S.noRoad) continue;
console.log(`\n── ${d.name}(chushen=${cs || '未答'})一条路都不开:${N(S.noRoad)} 格`);
console.log(` nearest 由【差几条】判:${N(S.byCount)} 由【gapOf 缺口】判:${N(S.byGap)} 由【本表的编号】判:${N(S.byIdx)}`);
if (S.byGap) console.log(` ★ gapOf 裁决时:【同轴 ${N(S.gapSameAxis)} 次】/【跨轴 ${N(S.gapCrossAxis)} 次】`);
for (const [k, v] of [...pairs].sort((a, b) => b[1] - a[1]).slice(0, 5)) console.log(` ${pad(k, 40)} ${N(v)}`);
}
+46
View File
@@ -0,0 +1,46 @@
// ★ 最终读数:【全部出自引擎自己的 kai】—— 不是复算,是产物。
import { DYNASTIES, computeRank, computeAxes } from '../src/dynasties.js';
import { SUB_REGISTRY } from '../src/talents.js';
const SUBS = SUB_REGISTRY.map((s) => s.id);
const WANGS = [0, 29, 30, 54, 55, 74, 75, 100];
const S = {}; const P = {};
for (const d of DYNASTIES) {
S[d.id] = {}; P[d.id] = { noRoad: 0, hasKai: 0 };
for (const c of d.channels) S[d.id][c.id] = { n: c.name, open: 0, walk: 0, not: 0, tier: 0, ceiling: 0, lex: 0, idx: 0, unsorted: 0 };
}
const t0 = Date.now();
for (let m = 0; m < (1 << 16); m++) {
const subs = []; for (let i = 0; i < 16; i++) if (m & (1 << i)) subs.push(SUBS[i]);
const axes = computeAxes(subs);
for (const wang of WANGS) for (const d of DYNASTIES) {
const r = computeRank({ axes, wang }, d.id);
if (!r.channel) { P[d.id].noRoad++; continue; }
if (r.kai.length > 1) P[d.id].hasKai++;
for (const k of r.kai) {
const s = S[d.id][k.channelId];
s.open++;
if (k.walked) s.walk++; else { s.not++; s[k.lostAt]++; }
}
}
}
const secs = ((Date.now() - t0) / 1000).toFixed(0);
const TOT = (1 << 16) * WANGS.length;
const pad = (s, n) => String(s).padEnd(n), num = (s, n) => String(s).padStart(n);
console.log(`\n══ 引擎自产读数(r.kai):${TOT.toLocaleString()} × ${DYNASTIES.length} 朝 = ${(TOT * DYNASTIES.length).toLocaleString()} 格 / ${secs}s ══\n`);
console.log(pad('朝/通道', 24) + num('开着', 9) + num('走了', 9) + num('开着没走', 10) + ' │' + num('tier', 8) + num('ceiling', 9) + num('lex', 9) + num('_idx', 9) + num('unsorted', 9));
console.log('─'.repeat(97));
const G = { tier: 0, ceiling: 0, lex: 0, idx: 0, unsorted: 0, not: 0 };
for (const d of DYNASTIES) {
for (const c of d.channels) {
const s = S[d.id][c.id];
for (const k of ['tier', 'ceiling', 'lex', 'idx', 'unsorted', 'not']) G[k] += s[k];
console.log(pad(`${d.id}/${c.id}`, 24) + num(s.open.toLocaleString(), 9) + num(s.walk.toLocaleString(), 9) + num(s.not.toLocaleString(), 10) +
' │' + num(s.tier.toLocaleString(), 8) + num(s.ceiling.toLocaleString(), 9) + num(s.lex.toLocaleString(), 9) + num(s.idx.toLocaleString(), 9) + num(s.unsorted, 9));
}
console.log(pad(` ↳ ${d.name}`, 24) + `一条路都不开 ${P[d.id].noRoad.toLocaleString()} 有【开着没走】的 ${P[d.id].hasKai.toLocaleString()} (${(P[d.id].hasKai / TOT * 100).toFixed(1)}%)\n`);
}
const H = Object.values(P).reduce((a, x) => a + x.hasKai, 0);
console.log(`【总计】${(TOT * DYNASTIES.length).toLocaleString()} 格中 ${H.toLocaleString()} 格(${(H / (TOT * DYNASTIES.length) * 100).toFixed(1)}%)至少有一条【开着但没走】的路。`);
console.log(`【输在哪一级】共 ${G.not.toLocaleString()} 条次:`);
for (const k of ['ceiling', 'tier', 'lex', 'idx', 'unsorted'])
console.log(` ${pad(k, 10)} ${num(G[k].toLocaleString(), 11)} (${(G[k] / G.not * 100).toFixed(1)}%)`);
@@ -0,0 +1,40 @@
// ★★★ 【nearest 是哪一级判的】—— ★ 读数【出自引擎自己的 nearestBy】,不是复算。
// ⚠ 本项目铁律:「读数出自引擎自己的 kai(不是复算)」。第三刀先前那张表是【复算】的
// (scripts/census-gap.mjs ④,它自己按 blocked 重建 pool)—— 而复算会【漏】:
// 它在 pool<2 时跳过,于是把「本表真的有理由」那一级少报了。⇒ 这一份取代它。
import { DYNASTIES, computeRank, computeAxes, AXIS_IDS } from '../src/dynasties.js';
import { chushenOf } from '../src/chushen.js';
import { SUB_REGISTRY } from '../src/talents.js';
const SUBS = SUB_REGISTRY.map((s) => s.id);
const WANGS = [0, 29, 30, 54, 55, 74, 75, 100];
const m = new Map();
for (let b = 0; b < (1 << 16); b++) { const s = []; for (let i = 0; i < 16; i++) if (b & (1 << i)) s.push(SUBS[i]);
const a = computeAxes(s); const k = AXIS_IDS.map((x) => a[x]).join(','); m.set(k, (m.get(k) || 0) + 1); }
const T = [...m].map(([k, w]) => ({ axes: Object.fromEntries(k.split(',').map((v, i) => [AXIS_IDS[i], Number(v)])), w }));
const CFG = {}; for (const d of DYNASTIES) { const x = chushenOf(d.id); CFG[d.id] = x ? [null, ...x.values.map((v) => v.id)] : [null]; }
const N = (n) => n.toLocaleString();
const pad = (s, n) => { s = String(s); let w = 0; for (const c of s) w += c.charCodeAt(0) > 0x2000 ? 2 : 1; return s + ' '.repeat(Math.max(1, n - w)); };
console.log('\n══ 【无路者】nearest 是哪一级判的 —— 读数出自引擎自己的 nearestBy ══');
console.log('══ 每朝每配置 524,288 个 profile(2,790 六轴取值 × 8 wang,带权)══\n');
console.log(pad('朝', 8) + pad('出身', 10) + '无路者'.padStart(11) + '差几条(有理由)'.padStart(16) + 'gapOf(⚠不是距离)'.padStart(18) + '编号'.padStart(9) + ' 没有 nearest');
console.log('─'.repeat(80));
const G = { count: 0, gap: 0, idx: 0 };
for (const d of DYNASTIES) for (const cs of CFG[d.id]) {
const S = { noRoad: 0, count: 0, gap: 0, idx: 0, none: 0 };
for (const t of T) for (const wang of WANGS) {
const r = computeRank({ axes: t.axes, wang, chushen: cs }, d.id);
if (r.channel) continue;
S.noRoad += t.w;
if (!r.nearest) { S.none += t.w; continue; }
S[r.nearestBy] += t.w;
}
if (!S.noRoad) continue;
for (const k of ['count', 'gap', 'idx']) G[k] += S[k];
console.log(pad(d.name, 8) + pad(cs || '未答', 10) + N(S.noRoad).padStart(11) + N(S.count).padStart(16) +
N(S.gap).padStart(18) + N(S.idx).padStart(9) + ' ' + (S.none ? N(S.none) : ''));
}
console.log('─'.repeat(80));
console.log(pad('总计', 18) + ''.padStart(11) + N(G.count).padStart(16) + N(G.gap).padStart(18) + N(G.idx).padStart(9));
const tot = G.count + G.gap + G.idx;
console.log(`\n★ 「本表真的有理由」的那一级(差几条):${N(G.count)} / ${N(tot)} = ${(G.count / tot * 100).toFixed(1)}%`);
console.log(`★★ 而其余 ${(100 - G.count / tot * 100).toFixed(1)}% 由【一个代号减出来的数】或【数组编号】决定。`);
@@ -0,0 +1,39 @@
// ★ 第八刀的代价读数:【放宽到老板的判据之后,有多少人会被多问一句「你走哪条」】
// 老板上一刀要的那个数逐字:「⚠ 而必须先说清代价:实测汉有 169,984 个 profile(32.4%)
// 至少有一条输在 _idx 的路 —— 即三个汉朝用户里有一个会被多问一句。」⇒ 现在这个数变了。
import { DYNASTIES, computeRank, computeAxes } from '../src/dynasties.js';
import { SUB_REGISTRY } from '../src/talents.js';
const SUBS = SUB_REGISTRY.map((s) => s.id);
const WANGS = [0, 29, 30, 54, 55, 74, 75, 100];
const TOT = (1 << 16) * WANGS.length;
const P = {};
for (const d of DYNASTIES) P[d.id] = { name: d.name, ask: 0, n2: 0, n3: 0, idxOnly: 0, hasLex: 0, noRoad: 0 };
for (let m = 0; m < (1 << 16); m++) {
const subs = []; for (let i = 0; i < 16; i++) if (m & (1 << i)) subs.push(SUBS[i]);
const axes = computeAxes(subs);
for (const wang of WANGS) for (const d of DYNASTIES) {
const r = computeRank({ axes, wang }, d.id);
const s = P[d.id];
if (!r.channel) { s.noRoad++; continue; }
const tie = r.zelu.tie || [];
if (tie.length <= 1) continue;
s.ask++;
if (tie.length === 2) s.n2++; else s.n3++;
const kinds = tie.filter((t) => !t.isDefault).map((t) => t.lostAt);
if (kinds.every((k) => k === 'idx')) s.idxOnly++;
if (kinds.includes('lex')) s.hasLex++;
}
}
const N = (n) => n.toLocaleString();
const pad = (s, n) => String(s).padEnd(n), num = (s, n) => String(s).padStart(n);
console.log(`\n══ 【放宽后:有多少人会被多问一句「你走哪条」】每朝 ${N(TOT)} 个 profile ══\n`);
console.log(pad('朝', 8) + num('要问', 10) + num('占比', 8) + num('2 选 1', 9) + num('≥3 选', 8) +
num('★ 只含 idx', 12) + num('★ 含 lex', 10) + num('一条路都不开', 13));
console.log('─'.repeat(80));
let A = 0;
for (const d of DYNASTIES) {
const s = P[d.id]; A += s.ask;
console.log(pad(s.name, 8) + num(N(s.ask), 10) + num((s.ask / TOT * 100).toFixed(1) + '%', 8) +
num(N(s.n2), 9) + num(N(s.n3), 8) + num(N(s.idxOnly), 12) + num(N(s.hasLex), 10) + num(N(s.noRoad), 13));
}
console.log(`\n【总计】${N(TOT * DYNASTIES.length)} 格中 ${N(A)} 格(${(A / (TOT * DYNASTIES.length) * 100).toFixed(1)}%)会被多问一句。`);
@@ -0,0 +1,77 @@
// ★ 第二刀的验收读数:【打平的那些 case,tier 和 ceiling 真的都一样吗?】
// 老板逐字:「⚠ 验证我这个判断:穷举里,打平的那些 case,tier 和 ceiling 真的都一样吗?
// 有没有「tier 一样但 ceiling 不一样」的?那种不是打平,那种引擎有理由(位阶即杠杆)。
// 跑出来,别信我。」
// ⚠ 读数全部出自引擎自己的 r.kai(产物),不是复算。
import { DYNASTIES, computeRank, computeAxes } from '../src/dynasties.js';
import { SUB_REGISTRY } from '../src/talents.js';
const SUBS = SUB_REGISTRY.map((s) => s.id);
const WANGS = [0, 29, 30, 54, 55, 74, 75, 100];
// 每一条【开着但没走】的路,按 (tier 关系, ceiling 关系) × lostAt 交叉列表
const X = {}; // key: `${tierRel}|${ceilRel}` → { lostAt: n }
const bump = (tr, cr, lostAt) => {
const k = `${tr}|${cr}`;
(X[k] || (X[k] = {}))[lostAt] = ((X[k] || {})[lostAt] || 0) + 1;
};
// zelu.options 的自检
let optBad = 0, optN = 0, optTierDiff = 0, optCeilDiff = 0;
// 引擎的排序器是不是一个自洽的全序:ranked[i] 必须真的赢 ranked[j] (i<j)
let sortViol = 0;
// 「tier 一样 && ceiling 一样」的总条次,按 lostAt 分
const SAME = {};
let totalNotWalked = 0;
const t0 = Date.now();
for (let m = 0; m < (1 << 16); m++) {
const subs = []; for (let i = 0; i < 16; i++) if (m & (1 << i)) subs.push(SUBS[i]);
const axes = computeAxes(subs);
for (const wang of WANGS) for (const d of DYNASTIES) {
const r = computeRank({ axes, wang }, d.id);
if (!r.channel) continue;
const walked = r.kai.find((k) => k.walked);
for (const k of r.kai) {
if (k.walked) continue;
totalNotWalked++;
const tr = k.tier === walked.tier ? '=' : k.tier < walked.tier ? '<' : '>';
const cr = k.ceiling === walked.ceiling ? '=' : k.ceiling < walked.ceiling ? '<' : '>';
bump(tr, cr, k.lostAt);
if (tr === '=' && cr === '=') SAME[k.lostAt] = (SAME[k.lostAt] || 0) + 1;
}
// zelu.tie:里面的每一条,tier 与 ceiling 必须与走的那条【全部相同】
const opts = (r.zelu.tie || []).map((t) => t.channelId);
if (opts.length > 1) {
optN++;
for (const id of opts) {
const k = r.kai.find((x) => x.channelId === id);
if (!k) { optBad++; continue; }
if (k.tier !== walked.tier) optTierDiff++;
if (k.ceiling !== walked.ceiling) optCeilDiff++;
}
}
// 排序自洽性:kai 的次序就是 ranked 的次序
for (let i = 0; i < r.kai.length; i++)
for (let j = i + 1; j < r.kai.length; j++)
if (r.kai[j].tier > r.kai[i].tier ||
(r.kai[j].tier === r.kai[i].tier && r.kai[j].ceiling > r.kai[i].ceiling)) sortViol++;
}
}
const secs = ((Date.now() - t0) / 1000).toFixed(0);
const N = (n) => n.toLocaleString();
console.log(`\n══ 【开着但没走】的路:(tier, ceiling) × 输在哪一级 ── ${N(totalNotWalked)} 条次 / ${secs}s ══\n`);
console.log('tier ceiling │ ' + ['ceiling', 'tier', 'lex', 'idx', 'unsorted'].map((s) => s.padStart(10)).join('') + ' 合计');
console.log('─'.repeat(78));
for (const key of Object.keys(X).sort()) {
const [tr, cr] = key.split('|');
const row = X[key];
const tot = Object.values(row).reduce((a, b) => a + b, 0);
console.log(` ${tr} ${cr} │ ` +
['ceiling', 'tier', 'lex', 'idx', 'unsorted'].map((s) => N(row[s] || 0).padStart(10)).join('') +
' ' + N(tot).padStart(10));
}
console.log('\n★ 【tier 一样 && ceiling 一样】的条次,按输在哪一级分:');
const sameTot = Object.values(SAME).reduce((a, b) => a + b, 0);
for (const k of Object.keys(SAME)) console.log(` ${k.padEnd(10)} ${N(SAME[k]).padStart(11)} (${(SAME[k] / sameTot * 100).toFixed(1)}% of ${N(sameTot)})`);
console.log(`\n★ zelu.options:${N(optN)} 格有 >1 个选项;` +
`其中 tier 不同 ${N(optTierDiff)} 次、ceiling 不同 ${N(optCeilDiff)} 次、kai 里找不到 ${N(optBad)} 次`);
console.log(`★ 排序自洽性(kai 次序里出现「后面的 tier/ceiling 反而更高」):${N(sortViol)} 次`);
@@ -0,0 +1,50 @@
// 一次性清理:上线前自测累积的数据(课堂/排期/孤儿自习/测试申请与预约)。
// 大师课堂/排期是本轮新功能,目前库里全部课堂与排期均为自测生成 → 清空重置为干净初始态。
// 大师本体(masters)与真实用户分身(twins)一律不动。
// 用法:node scripts/cleanup-test-data.js
import 'dotenv/config';
import { connectDb, getDb, closeDb } from '../src/db.js';
// 测试用的申请/预约按提交名清理(都是自测占位)。
const TEST_NAME = /QA|测试|验证|探测|阿测|走查|截图|类别验证|排期|徽匠老王|小林|徽州阿测/i;
async function main() {
await connectDb();
const db = getDb();
// 1) 课堂 + 排期:本功能刚上线,库里全是自测数据 → 清空。
const classrooms = (await db.collection('classrooms').deleteMany({})).deletedCount;
const schedules = (await db.collection('class_schedules').deleteMany({})).deletedCount;
// 2) 孤儿自习记录:twinId 指向已不存在的分身(历史级联漏删留下的)。
const liveTwins = new Set(
(await db.collection('twins').find({}).project({ _id: 1 }).toArray()).map((t) => String(t._id))
);
const learnings = await db.collection('twin_learnings').find({}).project({ _id: 1, twinId: 1 }).toArray();
const orphanIds = learnings.filter((l) => !liveTwins.has(String(l.twinId))).map((l) => l._id);
const orphanLearnings = orphanIds.length
? (await db.collection('twin_learnings').deleteMany({ _id: { $in: orphanIds } })).deletedCount
: 0;
// 3) 测试用的大师/合作申请、文旅预约(按测试名)。
const masterApps = (await db.collection('master_applications').deleteMany({ name: TEST_NAME })).deletedCount;
const partnerApps = (await db.collection('partner_applications').deleteMany({ name: TEST_NAME })).deletedCount;
const bookings = (await db.collection('bookings').deleteMany({ name: TEST_NAME })).deletedCount;
// 4) 孤儿通知:userId 指向已不存在的用户。
const liveUsers = new Set(
(await db.collection('users').find({}).project({ _id: 1 }).toArray()).map((u) => String(u._id))
);
const notifs = await db.collection('notifications').find({}).project({ _id: 1, userId: 1 }).toArray();
const orphanNotifIds = notifs.filter((n) => !liveUsers.has(String(n.userId))).map((n) => n._id);
const orphanNotifs = orphanNotifIds.length
? (await db.collection('notifications').deleteMany({ _id: { $in: orphanNotifIds } })).deletedCount
: 0;
console.log('[cleanup] done:', JSON.stringify({
classrooms, schedules, orphanLearnings, masterApps, partnerApps, bookings, orphanNotifs,
}));
await closeDb();
}
main().catch((e) => { console.error('[cleanup] failed:', e); process.exit(1); });
@@ -0,0 +1,75 @@
// 带外创建/重置【管理员账号】。
//
// 为什么需要它:超管资格 = .env 的 SUPER_ADMIN_EMAILS 白名单里的邮箱,谁注册了谁就是超管。
// 但自助注册【已被安全策略禁止用白名单邮箱】(否则管理员邮箱尚无账号时,任何人都能抢注→拿到全部后台
// 权限:改大师/生成深伪/结算分成/读 PII)。所以管理员账号只能【带外预置】——就是这个脚本。
//
// 用法(在服务器上,后端目录内):
// ADMIN_EMAIL=you@example.com ADMIN_PASSWORD='你的强密码' node scripts/create-admin.js
//
// 幂等:账号已存在 → 重置密码;不存在 → 新建。密码只用 scrypt 存 hash(与 routes/auth.js 完全一致),
// 脚本【绝不打印密码】。
import 'dotenv/config';
import crypto from 'node:crypto';
import { connectDb, getDb, closeDb } from '../src/db.js';
// 与 routes/auth.js 的 hash() 完全一致,否则建出来的号登不进去。
function hash(password, salt) {
return crypto.scryptSync(password, salt, 64).toString('hex');
}
async function run() {
const email = String(process.env.ADMIN_EMAIL || '').trim().toLowerCase();
const password = String(process.env.ADMIN_PASSWORD || '');
if (!email || !email.includes('@')) {
console.error('❌ 需要 ADMIN_EMAIL(合法邮箱)');
process.exit(1);
}
if (password.length < 8) {
console.error('❌ 需要 ADMIN_PASSWORD,且至少 8 位(管理员账号别用弱密码)');
process.exit(1);
}
await connectDb();
const db = getDb();
const now = new Date();
const salt = crypto.randomBytes(16).toString('hex');
const existing = await db.collection('users').findOne({ email });
if (existing) {
await db.collection('users').updateOne(
{ _id: existing._id },
{ $set: { salt, passwordHash: hash(password, salt), updatedAt: now } }
);
console.log(`✅ 账号已存在 → 已重置密码:${email}`);
} else {
const name = email.split('@')[0];
await db.collection('users').insertOne({
email,
name,
nickname: name,
salt,
passwordHash: hash(password, salt),
entitlements: [],
createdAt: now,
updatedAt: now,
});
console.log(`✅ 已新建账号:${email}`);
}
// 提醒:账号有了还不等于是超管——还要这个邮箱在 .env 的 SUPER_ADMIN_EMAILS 里。
const whitelist = (process.env.SUPER_ADMIN_EMAILS || '')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
if (whitelist.includes(email)) {
console.log(`✅ 该邮箱已在 SUPER_ADMIN_EMAILS 白名单里 → 登录后即为超管。`);
} else {
console.log(`⚠ 该邮箱【不在】SUPER_ADMIN_EMAILS 白名单里,现在登录只是普通账号。`);
console.log(` 请把 .env 的 SUPER_ADMIN_EMAILS 设为 ${email} 并重启后端(grant-superadmin 工作流会做这步)。`);
}
console.log('(本脚本从不打印密码。请用你设置的密码登录,登录后建议立刻改成只有你知道的强密码。)');
await closeDb();
}
run().catch((e) => { console.error(e); process.exit(1); });
@@ -0,0 +1,12 @@
import { DYNASTIES } from '../src/dynasties.js';
let n=0;
for (const d of DYNASTIES) {
console.log(`\n=== ${d.id} (${d.era} ${d.span}) tracks=${(d.tracks||[]).map(t=>t.id).join(',')}`);
for (const c of d.channels) {
n++;
const x = c.xuan||{};
console.log(` ${c.id.padEnd(18)} gate=${JSON.stringify(c.gate||{})} seat=${x.seat} kemu=${JSON.stringify((x.kemu||[]).map(k=>k.axis+'>='+k.need))} key=${JSON.stringify(x.key||[])} keySrc=${x.keySource===null?'null':'YES'} ceil=${c.ceiling?.tier} track=${c.track||'-'}`);
console.log(` hollow=${!!c.hollow} hollowNote=${c.hollowNote?'Y':'-'} stigma=${!!c.stigma} stigmaNote=${c.stigmaNote?'Y':'-'} assim=${c.assimilation||0} assimNote=${c.assimilationNote?'Y':'-'} regular=${!!c.regular} cat=${JSON.stringify(c.category||'')} penalty=${c.penaltyIfNoWang===undefined?'-':c.penaltyIfNoWang} penaltyNote=${c.penaltyNote?'Y':'-'} absence=${(x.kemuAbsence||{}).kind||'-'}`);
}
}
console.log('\nTOTAL channels =', n);
@@ -0,0 +1,96 @@
// 导出《天下·乐 全库总稿》——所有 1303 首,每首:编号(=文件名)/歌名/家族/版本/词/背景/Suno风格/1分钟截取。
// 按家族(战役/名场面/她传/他传)+诗词(按年代)分。Word 可直接打开(.doc)。
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BK = path.resolve(__dirname, '..');
const REPO = path.resolve(BK, '..');
const LIB = JSON.parse(readFileSync(BK + '/src/data/music-library.json', 'utf8'));
const shiciSrc = readFileSync(REPO + '/digital-me-web/src/data/shici.ts', 'utf8');
const SHI = JSON.parse(shiciSrc.match(/export const SHI: Shi\[\] = (\[[\s\S]*\]);/)[1]);
const ERAS = JSON.parse(shiciSrc.match(/export const SHI_ERAS:[^=]*= (\[[\s\S]*?\]);/)[1]);
const shiById = {}; for (const p of SHI) shiById[p.id] = p;
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const br = (s) => esc(s).replace(/\n/g, '<br/>');
const fid = (id) => id.replace(/[^a-z0-9]+/gi, '_');
const verLabel = (v) => v === 'instrumental' ? '纯配乐' : v === 'male' ? '男声' : v === 'female' ? '女声' : '化用曲';
const VORD = { instrumental: 0, male: 1, female: 2, '': 3 };
const today = new Date().toISOString().slice(0, 10);
function songBlock(s, idx) {
const src = s.id.startsWith('shici:') ? shiById[s.id.slice(6)] : null;
return `<div class="song">
<div class="sh"><span class="idx">${idx}</span> <b class="tt">${esc(s.title)}</b> <span class="ver">${verLabel(s.vocal)}</span>${s.who ? ` <span class="who">视角/主角:${esc(s.who)}</span>` : ''}</div>
<div class="fn">编号(文件名):<code>${esc(fid(s.id))}</code> &nbsp;→ Suno 出曲后用此名(.mp3)发回即自动载入</div>
${src ? `<div class="sec"><span class="lb">原诗</span><span class="src">${esc(src.author)} · ${esc(src.dynasty)}</span><div class="text">${br(src.text)}</div></div>
${src.background ? `<div class="sec"><span class="lb">背景</span><span class="v">${br(src.background)}</span></div>` : ''}
${src.kao ? `<div class="sec"><span class="lb">考证</span><span class="v">${br(src.kao)}</span></div>` : ''}` : ''}
<div class="sec"><span class="lb">词</span><div class="lyr">${br(s.lyrics)}</div></div>
<div class="sec"><span class="lb suno">Suno风格</span><div class="suno">${br(s.sunoStyle)}</div>${s.clip ? `<div class="clip">1分钟截取:${br(s.clip)}</div>` : ''}</div>
</div>`;
}
let body = ''; let n = 0;
// 锚点家族
for (const fam of ['战役', '名场面', '她传', '他传']) {
const rows = LIB.filter((s) => s.family === fam);
if (!rows.length) continue;
const bases = []; const bm = {};
for (const s of rows) { const b = s.id.split('#')[0]; if (!bm[b]) { bm[b] = []; bases.push(b); } bm[b].push(s); }
body += `<h2>${esc(fam)} <span class="cnt">${bases.length} 个 · ${rows.length} 首</span></h2>`;
for (const b of bases) {
const vs = bm[b].sort((a, c) => (VORD[a.vocal] ?? 9) - (VORD[c.vocal] ?? 9));
body += `<h3>${esc(vs[0].title)}</h3>`;
for (const s of vs) { n++; body += songBlock(s, n); }
}
}
// 诗词按年代
const shici = LIB.filter((s) => s.id.startsWith('shici:'));
if (shici.length) {
body += `<h2>诗词 <span class="cnt">${shici.length} 首</span></h2>`;
for (const era of ERAS) {
const rows = shici.filter((s) => { const p = shiById[s.id.slice(6)]; return p && p.eraSlug === era.slug; });
if (!rows.length) continue;
body += `<h3 class="era">${esc(era.label)} · ${rows.length} 首</h3>`;
for (const s of rows) { n++; body += songBlock(s, n); }
}
}
const html = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40"><head><meta charset="utf-8"/>
<title>天下·乐 全库总稿</title>
<style>
body{ font-family:"宋体","SimSun","Microsoft YaHei",serif; color:#1e1a15; line-height:1.7; font-size:11pt; }
h1{ font-size:22pt; text-align:center; color:#7a1f16; margin:0 0 3pt; }
.meta{ text-align:center; color:#888; font-size:9pt; margin-bottom:6pt; }
.legend{ text-align:center; color:#8a6d33; font-size:9pt; margin-bottom:18pt; }
h2{ font-size:15pt; color:#9e2b1e; border-bottom:2px solid #9e2b1e; padding-bottom:3pt; margin:22pt 0 8pt; }
h2 .cnt{ font-size:9pt; color:#999; font-weight:normal; }
h3{ font-size:12.5pt; color:#7a5a20; margin:14pt 0 6pt; }
h3.era{ color:#9e2b1e; }
.song{ border:1px solid #ddd; border-radius:4px; padding:9pt 11pt; margin:0 0 10pt; page-break-inside:avoid; }
.sh{ font-size:12pt; border-bottom:1px dashed #ccc; padding-bottom:3pt; margin-bottom:5pt; }
.idx{ color:#9e2b1e; font-weight:bold; }
.tt{ font-size:12.5pt; } .ver{ color:#2c6e49; font-size:9pt; margin-left:5pt; } .who{ color:#999; font-size:9pt; margin-left:5pt; }
.fn{ font-size:9pt; color:#666; margin-bottom:5pt; }
.fn code{ font-family:Consolas,monospace; background:#eee; padding:0 4pt; color:#7a1f16; }
.sec{ margin:5pt 0; }
.lb{ display:inline-block; background:#f2ead8; color:#8a6d33; font-size:8.5pt; padding:1pt 6pt; border-radius:3px; margin-right:6pt; }
.lb.suno{ background:#e7efe8; color:#2c6e49; }
.src{ color:#8a6d33; font-size:9pt; }
.text{ padding-left:10pt; color:#444; margin-top:3pt; }
.v{ color:#444; font-size:10pt; }
.lyr{ margin-top:3pt; padding:6pt 10pt; background:#faf7ef; border-left:3px solid #c8a25e; color:#2a2a2a; font-size:10.5pt; }
.suno{ margin-top:3pt; padding:6pt 10pt; background:#f3f8f4; border-left:3px solid #2c6e49; color:#234; font-size:9.5pt; font-family:Consolas,"Courier New",monospace; }
.clip{ color:#2c6e49; font-size:9pt; margin-top:3pt; }
</style></head><body>
<h1>天下 · 乐 &nbsp;全库总稿</h1>
<div class="meta">共 ${LIB.length} 首 · 母版「新东方·电影史诗·黯黑」· 生成 ${today}</div>
<div class="legend">每首含 编号(=文件名) / 歌名 / 词 / 背景 / Suno风格 / 1分钟截取。<br/>命名闭环:Suno 出曲后用「编号」当文件名(.mp3)发回 → 超管上传页按名自动载入天下音乐。<br/>⚠ 本稿含 Suno 风格指令(制作配方),仅你自己看/喂 Suno,勿外传。</div>
${body}
</body></html>`;
const OUT = 'C:/Users/solut/Downloads/天下乐-全库总稿.doc';
writeFileSync(OUT, '\uFEFF' + html, 'utf8');
console.log('已导出:', OUT, '| 共', n, '首');
@@ -0,0 +1,96 @@
// 生成天下·乐 确权留痕交付物:①确权存证清单(哈希·供时间戳/区块链存证) ②确权登记清单.csv(供著作权登记) ③人类作者性说明.md ④确权档案-全.json(逐首)。
// 落地:老板的 Downloads/天下确权留痕/ + 仓库 provenance/(git 时间戳锚点·仅哈希清单,不含母带)。
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildDossier, buildManifest, contentHash } from '../src/services/provenance.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BACKEND = path.resolve(__dirname, '..');
const REPO = path.resolve(BACKEND, '..');
const songs = JSON.parse(readFileSync(path.join(BACKEND, 'src/data/music-library.json'), 'utf8'));
// 诗曲的公版源诗信息(shici.ts)
const shiciSrc = readFileSync(path.join(REPO, 'digital-me-web/src/data/shici.ts'), 'utf8');
const SHI = JSON.parse(shiciSrc.match(/export const SHI: Shi\[\] = (\[[\s\S]*\]);/)[1]);
const shiById = {}; for (const p of SHI) shiById[p.id] = p;
const sourceFor = (s) => {
if (!String(s.id).startsWith('shici:')) return null;
const p = shiById[s.id.slice('shici:'.length)];
return p ? { title: p.title, author: p.author, dynasty: p.dynasty, text: p.text } : null;
};
const generatedAt = new Date().toISOString();
const OWNER = '(运营主体法律全称·待填)· digitme.cn';
// ── 交付目录 ──
const OUT = 'C:/Users/solut/Downloads/天下确权留痕';
mkdirSync(OUT, { recursive: true });
const REPO_PROV = path.join(BACKEND, 'provenance');
mkdirSync(REPO_PROV, { recursive: true });
// ① 存证清单(哈希) —— 落 Downloads + 仓库锚点
const manifest = buildManifest(songs, { generatedAt });
writeFileSync(path.join(OUT, '确权存证清单.json'), JSON.stringify(manifest, null, 2), 'utf8');
writeFileSync(path.join(REPO_PROV, '确权存证清单.json'), JSON.stringify(manifest, null, 2), 'utf8');
// ② 逐首确权档案(全) —— 登记/律师用
const dossiers = songs.map((s) => buildDossier(s, { source: sourceFor(s), owner: OWNER }));
writeFileSync(path.join(OUT, '确权档案-全.json'), JSON.stringify({ generatedAt, count: dossiers.length, dossiers }, null, 2), 'utf8');
// ③ 登记清单 CSV
const esc = (v) => `"${String(v == null ? '' : v).replace(/"/g, '""')}"`;
const anchorCount = songs.filter((s) => !String(s.id).startsWith('shici:')).length;
const shiciCount = songs.length - anchorCount;
const rows = [['曲id', '标题', '家族', '词的类别', '化用来源(公版)', '著作权人', '内容哈希SHA256'].map(esc).join(',')];
for (const s of songs) {
const src = sourceFor(s);
rows.push([
s.id, s.title, s.family,
src ? '化用公版古诗(独创改写受保护)' : '完全原创词',
src ? `${src.title}·${src.author}·${src.dynasty}(公有领域)` : '—',
OWNER, contentHash(s),
].map(esc).join(','));
}
writeFileSync(path.join(OUT, '确权登记清单.csv'), '\uFEFF' + rows.join('\r\n'), 'utf8'); // BOM 便于 Excel 识别 UTF-8
// ④ 人类作者性说明.md
const narrative = `# 天下·乐 曲库 · 人类作者性与确权说明
> 生成时点:${generatedAt}
> 曲目总数:${songs.length}(完全原创词 ${anchorCount} 首 · 化用公版古诗 ${shiciCount} 首)
> 著作权人:${OWNER}
## 一、作品构成与权利定位(三块资产,强弱不同)
1. **歌词(文字作品)** —— 由平台确定创作方向、进行原创写作或对**公有领域**古诗的现代口语化用/重写,并多轮编辑定稿。**人类独创性最强,是最稳的著作权主张。**
2. **成品录音(录音制品/录音制作者权·邻接权)** —— 平台作为录音制品制作者,对**成品音频文件**依法享有复制、发行、信息网络传播权。**对“盗播/盗传我方母带文件”最能打,且基本不受 AI 独创性之争影响。**
3. **曲(音乐作品)** —— 借助 Suno(平台持最高级付费会员,其服务条款授予输出的所有权/商用权)在**人类创作方向、风格选择、编排与甄选**下生成。纯 AI 生成成分的可版权性依个案“人类独创性投入”认定,**不对外主张旋律独占**。
## 二、人类独创性投入(可版权性的证据基础)
每首歌均有以下人类智力投入,记录见《确权档案-全.json》逐首字段:
- **创作方向 / 风格 brief**:由人确定题材、情绪、世界级音乐流派方向;
- **歌词创作与化用改写**:原创歌词;或对公版古诗做结构重组、口语化重写(化用部分为独创表达);
- **生成指令(prompt)**:投喂 Suno 的详细创作指令(配器/节奏/编曲/音色),为人类创作意图的具体化;
- **甄选**:从多版本生成结果中人工甄选定稿、淘汰其余;
- **编排与截取决策**:三版本(纯配乐/男声/女声)、正常版/1分钟版的编排与截取。
## 三、来源合法性(反向侵权自查结论)
- 化用的古诗**全部为公有领域**(诗词篇作者最晚为龚自珍,卒于 1841 年,远逾“作者卒后 50 年”保护期);经核查**无近现代(作者卒于 1976 年及以后)在保护期内的诗人作品**,不存在使用他人在保护期作品的情形。
- 完全原创词不涉及第三方作品。
## 四、确权动作(按此清单执行)
1. **存证(现在做·最快)**:对《确权存证清单.json》做一次**可信时间戳/区块链存证**(如联合信任时间戳/至信链/蚂蚁链/司法链),即以最小成本固定全库“在先创作”时点;masterHash 为全库汇总指纹。
2. **登记(分批)**:按《确权登记清单.csv》,优先登记**变现主打 + 最易被盗**曲目的**歌词(文字作品)**与**成品录音(录音制品)**;曲以弱证据心态登记。
3. **权利来源留存**:保存 Suno 最高级会员凭证 + 生成时点的 ToS 版本快照 + 每首生成记录(补权属链)。
4. **技术配套(已做/待补)**:母带私有存储 + 会员短效签名 URL(已做);**取证水印**(按会员打唯一隐式水印,泄漏可追人)待补。
> ⚠ 法律定性以知识产权律师意见为准。AIGC 可版权性在中国为个案裁量、尚未定论;本材料用于把“谁、何时、拥有什么、有何人类投入”固化为证据,不等于对“曲”能取得独占版权。
`;
writeFileSync(path.join(OUT, '人类作者性说明.md'), narrative, 'utf8');
console.log('确权留痕已生成:');
console.log(' 曲目', songs.length, '(原创词', anchorCount, '· 化用公版', shiciCount, ')');
console.log(' masterHash', manifest.masterHash_SHA256);
console.log(' → Downloads/天下确权留痕/:确权存证清单.json · 确权登记清单.csv · 人类作者性说明.md · 确权档案-全.json');
console.log(' → 仓库 provenance/确权存证清单.json(git 时间戳锚点)');
@@ -0,0 +1,111 @@
// 生成《天下·乐 确权操作指南》Word 版(.doc·HTML)到老板 Downloads/天下确权留痕/。排版友好、可照做、带勾选清单。
import { writeFileSync } from 'node:fs';
const today = new Date().toISOString().slice(0, 10);
const html = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40"><head><meta charset="utf-8"/>
<title>天下·乐 确权操作指南</title>
<style>
body{ font-family:"宋体","SimSun","Microsoft YaHei",serif; color:#1e1a15; line-height:1.75; font-size:11pt; }
h1{ font-size:22pt; text-align:center; color:#7a1f16; margin:0 0 2pt; letter-spacing:1pt; }
.sub{ text-align:center; color:#8a6d33; font-size:11pt; margin-bottom:2pt; }
.meta{ text-align:center; color:#999; font-size:9pt; margin-bottom:16pt; }
h2{ font-size:14.5pt; color:#9e2b1e; border-bottom:2px solid #9e2b1e; padding-bottom:3pt; margin:20pt 0 8pt; }
h3{ font-size:12pt; color:#8a6d33; margin:12pt 0 4pt; }
p{ margin:5pt 0; }
.box{ border:1px solid #d8cdb0; background:#faf7ef; border-radius:5px; padding:9pt 12pt; margin:8pt 0; }
.tip{ border-left:4px solid #c8a25e; background:#faf6ea; padding:6pt 12pt; margin:8pt 0; }
.warn{ border-left:4px solid #c0392b; background:#fdf1ef; padding:6pt 12pt; margin:8pt 0; color:#7a1f16; }
.ck{ margin:4pt 0; }
.ck b{ color:#9e2b1e; }
table{ border-collapse:collapse; width:100%; margin:8pt 0; font-size:10pt; }
th,td{ border:1px solid #d8cdb0; padding:5pt 8pt; text-align:left; vertical-align:top; }
th{ background:#f2ead8; color:#7a5a20; }
code{ font-family:Consolas,"Courier New",monospace; background:#eee; padding:0 3pt; font-size:9.5pt; }
.big{ font-size:12.5pt; color:#7a1f16; font-weight:bold; }
.step{ font-size:13pt; color:#fff; background:#9e2b1e; border-radius:50%; padding:1pt 8pt; margin-right:6pt; }
.small{ color:#888; font-size:9pt; }
</style></head><body>
<h1>天下 · 乐 &nbsp;确权操作指南</h1>
<div class="sub">你做的歌(原创词 × Suno 曲)· 怎么把知识产权保护好</div>
<div class="meta">照着做即可 · ${today} · 共 1303 首(作品 865 个)</div>
<div class="tip"><b>一句话</b>:你的护城河 = <b>词(原创)+ 成品录音(邻接权,专治盗播盗传母带)</b>;<b>曲(纯 AI)不主张旋律独占</b>。“能商用 ≠ 能独占”。把力气压在词和录音上。</div>
<h2>你手上已有的文件(本文件夹内)</h2>
<table>
<tr><th>文件</th><th>用途</th></tr>
<tr><td><b>确权存证清单.json</b></td><td>全库 1303 首内容指纹 + 汇总 masterHash。<b>第①步存证就传它。</b></td></tr>
<tr><td><b>确权登记清单.csv</b></td><td>1303 行,分批著作权登记用。</td></tr>
<tr><td><b>人类作者性说明.md</b></td><td>证明“人类独创性投入”的说明书,给律师 / 登记用。</td></tr>
<tr><td><b>确权档案-全.json</b></td><td>逐首完整档案(词 / 化用来源 / 创作投入 / 哈希)。</td></tr>
<tr><td><b>本指南(.doc)</b></td><td>你正在看的这份。</td></tr>
</table>
<div class="box"><b>总顺序:</b> ① 存证(今天·10 分钟)→ ② 存 Suno 权利来源(今天·10 分钟)→ ③ 著作权登记(本周起·分批)→ ④ 找律师定稿(并行)。<br/>①② 今天做完,“在先创作 + 权利来源”证据链当天立住;③④ 把它做成法律级、可打官司。</div>
<h2><span class="step">1</span> 存证 —— 今天最优先,10 分钟锁住全库时点</h2>
<p><b>目的</b>:给全库盖一个不可篡改的“<b>这些歌在 ${today} 已经存在</b>”时间戳。以后谁抄你,你能证明你在先。</p>
<h3>做法(任选一个平台,从简单的 SaaS 入手)</h3>
<p class="big">推荐 A · 区块链存证 SaaS(界面最简单)</p>
<div class="ck">☐ <b>1)</b> 打开 <b>保全网(baoquan.com)</b> / 易保全 / 版权家 之一,注册并做实名认证。</div>
<div class="ck">☐ <b>2)</b> 找“<b>文件存证 / 数据存证 / 版权存证</b>”功能。</div>
<div class="ck">☐ <b>3)</b> 上传 <code>确权存证清单.json</code> <b>这一个文件</b>,提交。</div>
<div class="ck">☐ <b>4)</b> 下载它给你的<b>存证证书</b>(含哈希、时间、区块链交易号),存回本文件夹。</div>
<p class="big">备选 B · 可信时间戳</p>
<p>联合信任时间戳服务中心(<b>tsa.cn</b>,国家授时中心背书)。注册后对文件申请时间戳,拿证书文件存好。法院认可度高,界面略传统。</p>
<div class="tip"><b>关键点</b><br/>• 你传的是<b>哈希清单</b>、不是母带 —— 不泄露任何音频,安全。<br/>• 这一件存证 = 固定全库 1303 首的在先时点(清单里每首都有指纹,能一一对应回内容)。<br/>• <b>主打曲另加做</b>:挑十几首最看重、最怕被抄的,把它的<b>歌词全文 + 音频</b>也各单独存一次(更硬,维权时不用解释“哈希怎么对应内容”)。要哪些告诉我,我单独打包成待传文件。</div>
<p class="small">费用量级:SaaS 单条几元~几十元 / 或几百~上千元一年套餐;时间戳按条计(元级)。以平台当前价为准。</p>
<h2><span class="step">2</span> 存 Suno 权利来源 —— 今天,10 分钟</h2>
<p><b>目的</b>:证明“曲的商用权来自你持最高级会员、由 Suno 条款授予”。条款会改版,必须<b>固定你生成时那一版</b>。</p>
<div class="ck">☐ <b>1)</b> 截图 Suno 账号页,显示你的<b>会员等级</b>(Pro / Premier)和付费记录。</div>
<div class="ck">☐ <b>2)</b> 打开 Suno 的 <b>Terms of Service</b> 页,<b>整页存成 PDF</b>(浏览器打印→另存 PDF),文件名带日期,如 <code>Suno-ToS-${today}.pdf</code>,存进本文件夹。</div>
<div class="ck">☐ <b>3)</b> 每首在 Suno 库里的<b>生成记录</b>(生成时间 / 曲目 ID)能导出就导出、不能就截图归档。</div>
<div class="ck">☐ <b>4)</b> 会员<b>别断档</b>(权利来源要持续有效)。</div>
<h2><span class="step">3</span> 著作权登记 —— 本周开始,分批(不必一次全登)</h2>
<p><b>目的</b>:拿国家登记证书,形成“权属推定”——维权、下架、授权、融资都好用。</p>
<h3>去哪登(建议先看本地)</h3>
<p>• <b>中国版权保护中心</b>:ccopyright.com.cn 网上登记系统(全国通用)。<br/>• <b>地方版权局 / 登记中心</b>:常更快、更便宜、有的有财政补贴。你在成都,可看<b>四川省版权登记</b>。</p>
<h3>登什么、按什么类别(登对了才最能打)</h3>
<table>
<tr><th>成分</th><th>登记类别</th><th>优先级</th></tr>
<tr><td>歌词</td><td>文字作品</td><td><b>第一优先</b>(人类独创性最强、最稳)</td></tr>
<tr><td>成品录音</td><td>录音制品 / 录音制作者</td><td><b>第二优先</b>(专治盗播盗传母带)</td></tr>
<tr><td>曲</td><td>音乐作品</td><td>弱证据心态登,<b>别指望旋律独占</b></td></tr>
</table>
<p><b>怎么分批</b>:按 CSV 挑<b>变现主打 + 最怕被抄</b>的十几首先登全套(词+录音),其余先靠第①步存证兜底,回头再分批登。</p>
<div class="warn"><b>⚠ AIGC 怎么填,务必让律师把关这一栏</b><br/>著作权人填你的公司 / 个人;涉及 AI 辅助的要<b>如实、但突出人类投入</b>(创作方向、原创词 / 化用改写、prompt、甄选、编排——《人类作者性说明》就是给这个用的)。填法不当可能导致登记被撤、或维权时被反咬。</div>
<p class="small">费用/时间量级:官费每件几十~几百元;正常审核约 30 个工作日、可加急;找代理另加服务费。以受理机构当前标准为准。</p>
<h2><span class="step">4</span> 找律师(和 ②③ 并行)</h2>
<p>把本文件夹一起发给他,让他做三件:</p>
<div class="ck">☐ <b>权属意见书</b>:Suno 输出在中国的权利衔接 + 人类独创性如何满足(可引北京互联网法院 2023 AI 文生图案)。</div>
<div class="ck">☐ <b>登记口径</b>:AIGC 部分在登记表里怎么如实又稳妥地写。</div>
<div class="ck">☐ <b>协议起草</b>:用户协议(用户只得使用权、禁商用转售)+ 平台版权声明 + AIGC 使用说明。</div>
<p class="small">费用量级:意见书 + 协议起草大致几千~上万元;维权 / 公证按需另算。</p>
<h2>三块资产的法律地位(想清楚,别当一首歌一个版权)</h2>
<table>
<tr><th>资产</th><th>权利类型</th><th>强弱 / 说明</th></tr>
<tr><td><b>词</b>(原创 + 化用公版古诗)</td><td>文字作品 · 著作权</td><td><b>最强</b>(前提:<b>经你实质审改 / 甄选</b>,人类独创性才立得住)</td></tr>
<tr><td><b>成品录音</b></td><td>录音制品 · 录音制作者权(邻接权)</td><td><b>最能打盗播盗传</b>;依据是制作投入,<b>不看独创性</b>,基本不受 AI 之争影响</td></tr>
<tr><td><b>曲</b>(Suno 生成)</td><td>音乐作品 · 著作权</td><td><b>最弱</b>;纯 AI 成分重,可版权性依个案人类投入认定,<b>不主张旋律独占</b></td></tr>
</table>
<div class="warn"><b>AIGC 可版权性 —— 诚实提醒</b><br/>中国是个案裁量、尚未定论(北京互联网法院 2023 案支持“有独创性人类投入即受保护”,但属个案);美国不给纯 AI 生成物登记。<b>“AI 帮你写” ≠ 你自动拿到独占版权</b> —— 能不能,取决于你这边的人类创作投入。<b>你现在逐首审改这 646 首诗词,正是把“AI 初稿”变成“你主导创作的作品”的关键动作</b>:审改要留痕,我把改动记进《人类作者性说明》。</div>
<h2>反向侵权自查(结论:安全)</h2>
<p>诗词篇 646 首<b>全部为先秦至明清古典诗词</b>,最晚的作者龚自珍卒于 1841 年,远逾“作者卒后 50 年”保护期,<b>均为公有领域</b>;经核查<b>无近现代(作者卒于 1976 年及以后)在保护期内的诗人作品</b>,<b>无需下架</b>。源诗公版、你的化用原创,两个方向都干净。</p>
<h2>还差的一刀(技术 · 等你 mp3 到位再说)</h2>
<p><b>取证水印</b>:每个会员 / 每次下载打一个听不见的唯一水印,一旦有人把满血曲传出去,你能<b>追到是谁泄的</b>。开源方案(audiowmark)我可以接进现有会员下载链路,只占共享服务器一点算力 / 磁盘,你发话我就做。</p>
<div class="warn">本指南为操作梳理,<b>法律定性以知识产权律师意见为准</b>。存证 / 登记解决“谁、何时、拥有什么、有何人类投入”的举证;能否就“曲”取得独占版权由法院个案认定。</div>
</body></html>`;
const OUT = 'C:/Users/solut/Downloads/天下确权留痕/天下确权操作指南.doc';
writeFileSync(OUT, '\uFEFF' + html, 'utf8');
console.log('已生成 Word 版:', OUT);
@@ -0,0 +1,80 @@
// 导出《天下·拾遗 诗词篇 全稿(审阅版)》——供老板离线通读审定。每首:原诗+作者其时+考证+魂句+化用曲词。
// 输出 Word 可直接打开的 .doc(HTML)到 Downloads。
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO = path.resolve(__dirname, '../..');
const BACKEND = path.resolve(__dirname, '..');
const src = readFileSync(path.join(REPO, 'digital-me-web/src/data/shici.ts'), 'utf8');
const SHI = JSON.parse(src.match(/export const SHI: Shi\[\] = (\[[\s\S]*\]);/)[1]);
const ERAS = JSON.parse(src.match(/export const SHI_ERAS:[^=]*= (\[[\s\S]*?\]);/)[1]);
// Suno 风格指令在后端曲库(前端已剥配方)。审阅稿给老板自己看+喂Suno,带上。id: shici.ts 的 p.id ↔ music-library 的 shici:<id>
const LIB = JSON.parse(readFileSync(path.join(BACKEND, 'src/data/music-library.json'), 'utf8'));
const sunoMap = {}; const clipMap = {};
for (const s of LIB) if (String(s.id).startsWith('shici:')) { const k = s.id.slice('shici:'.length); sunoMap[k] = s.sunoStyle || ''; clipMap[k] = s.clip || ''; }
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const br = (s) => esc(s).replace(/\n/g, '<br/>');
const generatedAt = new Date().toISOString().slice(0, 10);
let body = '';
let n = 0;
for (const era of ERAS) {
const list = SHI.filter((p) => p.eraSlug === era.slug);
if (!list.length) continue;
body += `<h2 class="era">${esc(era.label)} <span class="cnt">${list.length} 首</span></h2>`;
for (const p of list) {
n++;
body += `<div class="poem">
<div class="ph"><span class="idx">${n}</span> <b class="tt">${esc(p.title)}</b> <span class="au">${esc(p.author)}</span> <span class="dy">${esc(p.dynasty)}</span> <span class="pid">[${esc(p.id)}]</span></div>
<div class="sec"><span class="lb">原诗</span><div class="text">${br(p.text)}</div></div>
${p.authorBrief ? `<div class="sec"><span class="lb">作者其时</span><div class="v">${br(p.authorBrief)}</div></div>` : ''}
${p.background ? `<div class="sec"><span class="lb">创作背景</span><div class="v">${br(p.background)}</div></div>` : ''}
${p.kao ? `<div class="sec"><span class="lb">考证</span><div class="v">${br(p.kao)}</div></div>` : ''}
${p.soulLine ? `<div class="sec"><span class="lb">魂句</span><div class="v soul">${br(p.soulLine)}</div></div>` : ''}
${p.song && p.song.lyrics ? `<div class="sec"><span class="lb">化用曲词</span><div class="lyr">${br(p.song.lyrics)}</div>${p.song.embedded ? `<div class="emb">嵌入原句:${br(p.song.embedded)}</div>` : ''}</div>` : '<div class="sec nolyr">(此首暂无化用曲词)</div>'}
${sunoMap[p.id] ? `<div class="sec"><span class="lb suno">Suno 风格指令</span><div class="suno">${br(sunoMap[p.id])}</div>${clipMap[p.id] ? `<div class="clip">1分钟截取:${br(clipMap[p.id])}</div>` : ''}</div>` : '<div class="sec nolyr">(此首暂无 Suno 风格指令)</div>'}
</div>`;
}
}
const html = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40"><head><meta charset="utf-8"/>
<title>天下·拾遗 诗词篇 全稿(审阅版)</title>
<style>
body{ font-family:"宋体","SimSun","Microsoft YaHei",serif; color:#1a1a1a; line-height:1.7; font-size:11pt; }
h1{ font-size:22pt; text-align:center; margin:0 0 4pt; }
.meta{ text-align:center; color:#666; font-size:9pt; margin-bottom:18pt; }
.legend{ text-align:center; color:#8a6d33; font-size:9pt; margin-bottom:20pt; }
h2.era{ font-size:15pt; color:#9e2b1e; border-bottom:2px solid #9e2b1e; padding-bottom:3pt; margin:22pt 0 10pt; }
.era .cnt{ font-size:9pt; color:#999; font-weight:normal; }
.poem{ border:1px solid #ddd; border-radius:4px; padding:10pt 12pt; margin:0 0 12pt; page-break-inside:avoid; }
.ph{ font-size:12pt; margin-bottom:6pt; border-bottom:1px dashed #ccc; padding-bottom:4pt; }
.idx{ color:#9e2b1e; font-weight:bold; }
.tt{ font-size:13pt; }
.au{ color:#8a6d33; margin-left:6pt; }
.dy{ color:#999; font-size:9pt; margin-left:6pt; }
.pid{ color:#bbb; font-size:8pt; font-family:Consolas,monospace; float:right; }
.sec{ margin:6pt 0; }
.lb{ display:inline-block; background:#f2ead8; color:#8a6d33; font-size:8.5pt; padding:1pt 6pt; border-radius:3px; margin-right:6pt; }
.text{ margin-top:4pt; padding-left:10pt; color:#333; }
.v{ display:inline; color:#444; font-size:10pt; }
.soul{ color:#9e2b1e; }
.lyr{ margin-top:4pt; padding:6pt 10pt; background:#faf7ef; border-left:3px solid #c8a25e; color:#2a2a2a; font-size:10.5pt; }
.emb{ color:#8a6d33; font-size:9pt; margin-top:3pt; }
.lb.suno{ background:#e7efe8; color:#2c6e49; }
.suno{ margin-top:4pt; padding:6pt 10pt; background:#f3f8f4; border-left:3px solid #2c6e49; color:#234; font-size:10pt; font-family:Consolas,"Courier New",monospace; }
.clip{ color:#2c6e49; font-size:9pt; margin-top:3pt; }
.nolyr{ color:#c0392b; font-size:9pt; }
</style></head><body>
<h1>天下 · 拾遗 · 诗词篇 全稿(审阅版)</h1>
<div class="meta">共 ${SHI.length} 首 · 先秦至明清 · 每首含【化用曲词 + Suno 风格指令】· 生成 ${generatedAt} · 供离线审定</div>
<div class="legend">审阅用法:要改的请记下左上角编号或 [id](如 [tang-012]),回头告诉我批量改;改定后再一起做确权。<br/>⚠ 本稿含 Suno 风格指令(=制作配方,仅你自己看/喂 Suno 用,前端已对用户隐藏,勿外传)。</div>
${body}
</body></html>`;
const OUT = 'C:/Users/solut/Downloads/天下诗词篇全稿-审阅版-含Suno风格.doc';
writeFileSync(OUT, '\uFEFF' + html, 'utf8');
console.log('已导出:', OUT);
console.log('共', SHI.length, '首;有化用曲词的', SHI.filter((p) => p.song && p.song.lyrics).length, '首');
+98
View File
@@ -0,0 +1,98 @@
// 端到端用户走查:进站→逛大师墙→大师详情→和大师聊→文旅→路线→报名→注册→养分身→登出。
// 每步截图 + 抓控制台报错/失败请求。用法:node scripts/journey.mjs <shotsDir>
// 前置:chrome 带 --remote-debugging-port=9222 已打开 http://localhost:5173/
import WebSocket from 'ws';
import { writeFileSync } from 'node:fs';
const OUT = process.argv[2];
const BASE = 'http://localhost:5173';
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const uniqEmail = `walk${Date.now()}@test.com`;
async function targetWs() {
for (let i = 0; i < 30; i++) {
const ts = await (await fetch('http://localhost:9222/json/list')).json();
const t = ts.find((x) => x.type === 'page' && /localhost:5173/.test(x.url));
if (t) return t.webSocketDebuggerUrl;
await wait(300);
}
throw new Error('no 5173 target');
}
const report = [];
let curStep = 'init';
const errs = []; // {step, kind, detail}
async function main() {
const ws = await targetWs();
const sock = new WebSocket(ws, { perMessageDeflate: false, maxPayload: 64 * 1024 * 1024 });
let id = 0;
const pending = new Map();
const send = (method, params = {}) =>
new Promise((resolve) => { const mid = ++id; pending.set(mid, resolve); sock.send(JSON.stringify({ id: mid, method, params })); });
await new Promise((r) => sock.on('open', r));
sock.on('message', (buf) => {
const m = JSON.parse(buf.toString());
if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id); return; }
if (m.method === 'Runtime.consoleAPICalled' && (m.params.type === 'error')) {
const txt = (m.params.args || []).map((a) => a.value || a.description || '').join(' ').slice(0, 160);
if (txt) errs.push({ step: curStep, kind: 'console.error', detail: txt });
}
if (m.method === 'Runtime.exceptionThrown') {
errs.push({ step: curStep, kind: 'exception', detail: (m.params.exceptionDetails?.exception?.description || m.params.exceptionDetails?.text || '').slice(0, 160) });
}
if (m.method === 'Network.responseReceived') {
const st = m.params.response?.status, u = m.params.response?.url || '';
if (st >= 400 && /\/api\//.test(u)) errs.push({ step: curStep, kind: 'http ' + st, detail: u.replace('http://localhost:3701', '') });
}
});
await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable');
const evalJs = async (expr) => (await send('Runtime.evaluate', { expression: expr, returnByValue: true }))?.result?.value;
const nav = async (path) => { await send('Page.navigate', { url: BASE + path }); await wait(2600); };
const clickText = (txt) => evalJs(`(()=>{const t=${JSON.stringify(txt)}.replace(/\\s+/g,'');const el=[...document.querySelectorAll('a,button,[role=button],label')].find(e=>(e.textContent||'').replace(/\\s+/g,'').includes(t));if(el){el.click();return true}return false})()`);
const clickSel = (sel) => evalJs(`(()=>{const el=document.querySelector(${JSON.stringify(sel)});if(el){el.click();return true}return false})()`);
const fill = (ph, v) => evalJs(`(()=>{const el=[...document.querySelectorAll('input,textarea')].find(e=>(e.placeholder||'').includes(${JSON.stringify(ph)}));if(el){el.value=${JSON.stringify(v)};el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));return true}return false})()`);
const info = () => evalJs(`JSON.stringify({path:location.pathname, h1:document.querySelector('h1')?.textContent?.trim().slice(0,24), authed:!!localStorage.getItem('dm_token'), nav:[...document.querySelectorAll('header a,header button')].map(e=>e.textContent.trim()).filter(Boolean).slice(0,8)})`);
const shot = async (name) => { const { data } = await send('Page.captureScreenshot', { format: 'png' }); writeFileSync(`${OUT}/${name}.png`, Buffer.from(data, 'base64')); };
const step = async (label, fn) => { curStep = label; const before = errs.length; await fn(); const i = JSON.parse(await info() || '{}'); report.push({ label, ...i, newErrs: errs.length - before }); };
// ── 走查 ──
await step('01-进站落广场', async () => { await nav('/'); await shot('01-plaza'); });
await step('02-点大师卡', async () => { await clickSel('a[href^="/masters/"]'); await wait(2200); await shot('02-master'); });
await step('03-和大师聊(点)', async () => { await clickSel('a[href^="/me/"]'); await wait(2500); await shot('03-chat-open'); });
await step('04-输入框可用(本地无key不真发)', async () => {
await fill('说点什么', '你好,介绍下你自己');
await wait(600); await shot('04-chat-typed');
});
await step('05-返回并进大师文旅', async () => { await nav('/tours'); await shot('05-tours'); });
await step('06-看路线详情', async () => { await clickSel('a[href^="/tours/"]'); await wait(2400); await shot('06-tour-detail'); });
await step('07-填报名交定金', async () => {
await fill('您的称呼', '走查客');
await fill('手机号', '13800008888');
await clickText('报名·交预定金锁位');
await wait(400);
await clickText('报名并支付预定金');
await wait(2800); await shot('07-booked');
});
await step('08-去登录页', async () => { await nav('/login'); await shot('08-login'); });
await step('09-注册新号', async () => {
await clickText('去注册'); await wait(600);
await fill('you@example.com', uniqEmail);
await fill('至少 6 位', 'walk123456');
await evalJs(`(()=>{const el=[...document.querySelectorAll('input')].find(e=>/昵称|名字|称呼|name/i.test(e.placeholder||''));if(el){el.value='走查用户';el.dispatchEvent(new Event('input',{bubbles:true}))}})()`);
await clickText('注册'); await wait(3000); await shot('09-after-register');
});
await step('10-养分身/工作台', async () => { await clickText('养我的分身'); await wait(2600); await shot('10-dashboard'); });
await step('11-我的分身导航', async () => { await clickText('我的分身'); await wait(2200); await shot('11-mine'); });
await step('12-登出', async () => { await clickText('退出'); await wait(2200); await shot('12-logout'); });
writeFileSync(`${OUT}/report.json`, JSON.stringify({ report, errs }, null, 2));
console.log('=== 走查步骤 ===');
for (const r of report) console.log(`${r.label} → path:${r.path} authed:${r.authed} h1:"${r.h1 || ''}" 本步报错:${r.newErrs}`);
console.log('\n=== 报错/失败请求汇总 ===');
if (!errs.length) console.log('(无)');
for (const e of errs) console.log(`[${e.step}] ${e.kind}: ${e.detail}`);
sock.close(); process.exit(0);
}
main().catch((e) => { console.error(e); process.exit(1); });
@@ -0,0 +1,124 @@
// 存量媒体迁移 → MinIO(老板 2026-08-05:文件存储改用 MinIO)。
//
// 把库里指向【本地 /uploads】(可选:也含【阿里云 OSS】)的媒体 URL,下载字节 → 传到 MinIO → 回写库里的 URL。
// 深度遍历每条文档的所有字符串字段(自动覆盖 media/cover/videoUrl/bundle.* 等嵌套,无需逐字段列举)。
//
// 前提:
// ① 在【服务器后端目录】跑(本地 /uploads 文件在这台机上才读得到)。
// ② .env 已配好 MINIO_*(minioConfigured 必须为真,否则拒绝运行)。
//
// 用法:
// node scripts/migrate-media-to-minio.mjs # 【干跑】只统计不写库(强烈建议先跑这个)
// node scripts/migrate-media-to-minio.mjs --commit # 真迁移(下载→传 MinIO→回写库 URL)
// node scripts/migrate-media-to-minio.mjs --commit --include-oss # 连 OSS 上的也迁到 MinIO
// node scripts/migrate-media-to-minio.mjs --commit creations create_jobs # 只迁指定集合
//
// 幂等:MinIO key = 原文件名(basename,已带时间戳/随机段);重复跑同一文件覆盖同 key,库里 URL 已是 MinIO 的会跳过。
// 说明:只迁【http(s) 公开媒体 URL】;母带(存的是 {key,driver} 对象,非 URL)不在此列,如需迁移母带另说。
import 'dotenv/config';
import fs from 'node:fs/promises';
import path from 'node:path';
import { connectDb, getDb, closeDb } from '../src/db.js';
import { config } from '../src/config.js';
import { saveMedia, minioConfigured } from '../src/services/storage.js';
const COMMIT = process.argv.includes('--commit');
const INCLUDE_OSS = process.argv.includes('--include-oss');
const COLL_ARGS = process.argv.slice(2).filter((a) => !a.startsWith('--'));
// 默认扫这些含媒体 URL 的集合(可用命令行参数覆盖)。
const DEFAULT_COLLS = ['creations', 'create_jobs', 'users', 'twins', 'messages', 'memories', 'masters', 'social_messages', 'shares', 'knowledge'];
const COLLS = COLL_ARGS.length ? COLL_ARGS : DEFAULT_COLLS;
const localPrefix = `${config.publicUrl}/uploads/`;
const localDir = config.storage.localDir;
const minioBase = config.storage.minio.publicBase;
const minioBucket = config.storage.minio.bucket;
const ossBase = config.storage.oss.publicBase;
function isLocal(u) { return u.startsWith(localPrefix) || u.startsWith('/uploads/'); }
function isMinio(u) { return (minioBase && u.startsWith(minioBase)) || (minioBucket && u.includes(`/${minioBucket}/`)); }
function isOss(u) { return /\.aliyuncs\.com\//.test(u) || (ossBase && u.startsWith(ossBase)); }
// 只处理我们自己托管的公开媒体 URL;第三方外链、data: 一律不动。
function migratable(u) {
if (!/^(https?:)?\/\//.test(u) && !u.startsWith('/uploads/')) return false;
if (isMinio(u)) return false; // 已在 MinIO
return isLocal(u) || (INCLUDE_OSS && isOss(u));
}
async function bytesFor(u) {
if (isLocal(u)) {
const base = path.basename(u.split('?')[0]);
return fs.readFile(path.join(localDir, base)); // 本地盘直接读
}
const r = await fetch(u); // OSS/远程:HTTP 拉
if (!r.ok) throw new Error(`fetch ${r.status}`);
return Buffer.from(await r.arrayBuffer());
}
const cache = new Map(); // 旧 URL → 新 URL(同一文件只迁一次)
let migrated = 0, failed = 0;
async function migrateUrl(u) {
if (cache.has(u)) return cache.get(u);
try {
const buf = await bytesFor(u);
const base = path.basename(u.split('?')[0]) || 'file';
const newUrl = COMMIT ? await saveMedia(buf, base) : `MINIO:${base}`; // 干跑不真传
cache.set(u, newUrl);
migrated++;
if (migrated % 20 === 0) console.log(` …已处理 ${migrated} 个文件`);
return newUrl;
} catch (e) {
failed++;
console.warn(` ✗ 跳过 ${u} —— ${e.message}`);
cache.set(u, null);
return null;
}
}
// BSON 特殊类型(ObjectId/Date/Binary 等)不递归。
function isPlainContainer(o) {
return o && typeof o === 'object' && !o._bsontype && !(o instanceof Date) && !Buffer.isBuffer(o);
}
async function walk(node) {
let changed = false;
const entries = Array.isArray(node) ? node.map((v, i) => [i, v]) : Object.entries(node);
for (const [k, v] of entries) {
if (typeof v === 'string') {
if (migratable(v)) { const nu = await migrateUrl(v); if (nu) { node[k] = nu; changed = true; } }
} else if (isPlainContainer(v)) {
if (await walk(v)) changed = true;
}
}
return changed;
}
async function run() {
if (!minioConfigured) {
console.error('❌ 未配置 MinIO(MINIO_ENDPOINT/BUCKET/ACCESS_KEY/SECRET_KEY)。先在 .env 配好再跑。');
process.exit(1);
}
console.log(`模式: ${COMMIT ? '★ COMMIT(真写库)' : '干跑(只统计,不改库/不传文件)'} | 含OSS: ${INCLUDE_OSS ? '是' : '否'} | 集合: ${COLLS.join(', ')}`);
await connectDb();
const db = getDb();
let totalChanged = 0;
for (const coll of COLLS) {
let scanned = 0, changedDocs = 0;
try {
for await (const doc of db.collection(coll).find({})) {
scanned++;
if (await walk(doc)) {
changedDocs++;
if (COMMIT) await db.collection(coll).replaceOne({ _id: doc._id }, doc);
}
}
} catch (e) { console.warn(`[${coll}] 扫描出错(可能无此集合): ${e.message}`); continue; }
totalChanged += changedDocs;
console.log(`[${coll}] 扫描 ${scanned} 文档,需迁移 ${changedDocs} 条`);
}
console.log('──────────────────────────');
console.log(`唯一文件: ${cache.size} 迁移成功: ${migrated} 失败: ${failed} 受影响文档: ${totalChanged}`);
console.log(COMMIT ? '✅ 已写库。' : 'ℹ️ 干跑结束——加 --commit 才会真迁移。');
await closeDb();
process.exit(0);
}
run().catch((e) => { console.error('迁移失败:', e); process.exit(1); });
@@ -0,0 +1,95 @@
// ★★★ gapOf 的 A/B —— 【它裁决几次?它改变几格?】★ 这是两问,不是一问。
// (先例:南朝的 lex 被【读了 98,304 次、裁决了 81,920 次】,而它【改变 0 格】。
// ⇒ 一个被读了八万次的东西,可以什么都没买到。)
//
// ⚠ 本脚本【自己造两份引擎的副本,跑完自己删】—— 不在 src/ 里留残骸。
// ★ 副本是【整个文件复制 + 改一行】,不是重写一遍逻辑:
// 重写 = 复算 = 拿另一把尺子量,而那比不报更坏(本项目铁律)。
//
// A/B ① 把 nearest 排序里的 `gapOf(a) - gapOf(b) ||` 那一级【整个拿掉】—— 其余一个字未动。
// A/B ② 把 20 道六轴闸【全部改写成它等价类的下端 41】—— ★ 逐格【同一道闸】
// (六轴只取得到 40/63/73/79/83 ⇒ (40,63) 之间没有任何 profile)
// ⇒ 开/关一格都不会变,【只有 gapOf 读得出差别】。
import fs from 'node:fs';
import path from 'node:path';
import url from 'node:url';
const HERE = path.dirname(url.fileURLToPath(import.meta.url));
const SRC = path.join(HERE, '..', 'src', 'dynasties.js');
const NOGAP = path.join(HERE, '..', 'src', '_probe_nogap.js');
const GATE41 = path.join(HERE, '..', 'src', '_probe_gate41.js');
const NEEDLE = ' gapOf(a) - gapOf(b) ||';
const base = fs.readFileSync(SRC, 'utf8');
if (!base.includes(NEEDLE)) {
console.error('✗ 在 dynasties.js 里找不到 gapOf 那一级 —— 引擎变了,这个探针过期了。');
console.error(' ⚠ 而【不许改探针去迁就代码】:先弄清楚那一级去哪了。');
process.exit(1);
}
fs.writeFileSync(NOGAP, base.replace(NEEDLE, ' // [PROBE] gapOf 那一级拿掉了'));
// ★ 只改【六轴】的闸;wang/lao 不是 sat 的产物,它们的数是【真的刻度】,一个字都不许动。
fs.writeFileSync(GATE41, base.replace(/gate: \{([^}]*)\}/g, (m, g) =>
'gate: {' + g.replace(/\b(wen|fa|wu|jiao|heng|tan):\s*(4[1-9]|5\d|6[0-3])\b/g, '$1: 41') + '}'));
try {
const { DYNASTIES, computeRank, computeAxes, AXIS_IDS } = await import('../src/dynasties.js');
const { computeRank: rankNoGap } = await import('../src/_probe_nogap.js');
const { computeRank: rankGate41 } = await import('../src/_probe_gate41.js');
const { chushenOf } = await import('../src/chushen.js');
const { SUB_REGISTRY } = await import('../src/talents.js');
const SUBS = SUB_REGISTRY.map((s) => s.id);
const WANGS = [0, 29, 30, 54, 55, 74, 75, 100];
const m = new Map();
for (let b = 0; b < (1 << 16); b++) {
const s = []; for (let i = 0; i < 16; i++) if (b & (1 << i)) s.push(SUBS[i]);
const a = computeAxes(s); const k = AXIS_IDS.map((x) => a[x]).join(',');
m.set(k, (m.get(k) || 0) + 1);
}
const T = [...m].map(([k, w]) => ({ axes: Object.fromEntries(k.split(',').map((v, i) => [AXIS_IDS[i], Number(v)])), w }));
const CFG = {};
for (const d of DYNASTIES) { const x = chushenOf(d.id); CFG[d.id] = x ? [null, ...x.values.map((v) => v.id)] : [null]; }
const N = (n) => n.toLocaleString();
const pad = (s, n) => { s = String(s); let w = 0; for (const c of s) w += c.charCodeAt(0) > 0x2000 ? 2 : 1; return s + ' '.repeat(Math.max(1, n - w)); };
console.log('\n══ A/B ① 把 gapOf 那一级【整个拿掉】:nearest 变几格?(其余一个字未动)');
console.log('══ A/B ② 20 道六轴闸【全换成等价类下端 41】:★ 逐格【同一道闸】,只有 gapOf 读得出差别\n');
console.log(pad('朝', 8) + pad('出身', 10) + '无路者'.padStart(11) + '① 拿掉 gapOf'.padStart(15) + '② 闸全写 41'.padStart(14));
console.log('─'.repeat(60));
let G1 = 0, G2 = 0;
for (const d of DYNASTIES) for (const cs of CFG[d.id]) {
let noRoad = 0, d1 = 0, d2 = 0;
for (const t of T) for (const wang of WANGS) {
const p = { axes: t.axes, wang, chushen: cs };
const a = computeRank(p, d.id);
if (a.channel) continue;
noRoad += t.w;
const b = rankNoGap(p, d.id), c = rankGate41(p, d.id);
// ★ ② 的前提:闸没变 ⇒「一条路都不开」这件事必须逐格不变。变了就说明我改错了闸。
if (c.channel) throw new Error(`★ ② 改写了开/关 —— 那它就不是同一道闸,本探针的前提塌了(${d.id})`);
if ((a.nearest || '-') !== (b.nearest || '-')) d1 += t.w;
if ((a.nearest || '-') !== (c.nearest || '-')) d2 += t.w;
}
if (!noRoad) continue;
G1 += d1; G2 += d2;
console.log(pad(d.name, 8) + pad(cs || '未答', 10) + N(noRoad).padStart(11) + N(d1).padStart(15) + N(d2).padStart(14));
}
console.log('─'.repeat(60));
console.log(pad('总计', 18) + ''.padStart(11) + N(G1).padStart(15) + N(G2).padStart(14));
console.log(`\n★ ①【拿掉 gapOf ⇒ ${N(G1)} 格换一条路当 nearest】—— 这就是「改判决」的代价(要老板拍)。`);
console.log('★★ 而【拿掉它不会让答案变对】:它只是把【一个假装是测量的仲裁】换成【一个自认是编号的仲裁】。');
console.log(`★ ②【20 道闸全写成 41(逐格同一道闸)⇒ ${N(G2)} 格变】—— ★ 注意它【比 ① 小得多】:`);
console.log(' 全表【一起】换写法,缺口一起缩,相对次序大多不动;');
console.log(' ⇒ ★★【真正翻盘的是【单独】换一道闸的写法】—— 见 scripts/probe-liguang-gate.mjs:');
console.log(' 军功的 wu:55 写成 wu:41(同一道闸)⇒ 李广的 nearest 从【辟召】变成【军功】。');
// ★ 李广:renwu.js 双证 subs=[] ⇒ 六轴全 40。《史記》卷109 双证:他的实职就是靠军功挣的。
const li = { axes: computeAxes([]), wang: 0 };
for (const [nm, fn] of [['真引擎', computeRank], ['① 拿掉 gapOf', rankNoGap], ['② 闸全写 41', rankGate41]]) {
const r = fn(li, 'han');
console.log(`\n★ 李广(subs=[] ⇒ 六轴全 40)/汉 ── ${pad(nm, 14)} nearest =【${r.nearest}】 tie = [${(r.nearestTie || []).join('、')}]`);
}
} finally {
// ⚠ 跑完就删:✗ 不许在 src/ 里留下两份引擎的副本 —— 那是下一个人会 import 错的东西。
for (const f of [NOGAP, GATE41]) if (fs.existsSync(f)) fs.unlinkSync(f);
}
@@ -0,0 +1,23 @@
// ★★★★★ 【李广 —— 而这一次不是编号,是本表在 41–63 里挑了哪一个数】
import { DYNASTIES, computeRank, computeAxes } from '../src/dynasties.js';
const han = DYNASTIES.find((d) => d.id === 'han');
const jg = han.channels.find((c) => c.id === 'jungong_han');
const li = { axes: computeAxes([]), wang: 0 }; // ★ 李广:renwu.js 双证 subs=[] ⇒ 六轴全 40
console.log('★ 李广 六轴:', JSON.stringify(li.axes), ' wang=0\n');
console.log('汉三条闸:', han.channels.map((c) => `${c.name}=${JSON.stringify(c.gate)}`).join(' '), '\n');
console.log('【把军功的闸 wu:55 换成它等价类 41–63 里的每一个数】');
console.log(' ⚠ 每一个都是【同一道闸】:六轴取值只有 40/63/73/79/83,(40,63] 之间没有任何 profile。\n');
console.log(' wu 闸 李广被判「最接近」的那条路 打平的 他的军功缺口');
console.log(' ' + '─'.repeat(78));
const seen = new Map();
for (const n of [41, 45, 50, 54, 55, 56, 60, 63]) {
jg.gate.wu = n;
const r = computeRank(li, 'han');
const gap = n - 40;
console.log(` ${String(n).padEnd(8)} ${(r.nearest || '-').padEnd(34)} [${(r.nearestTie || []).join('、').padEnd(18)}] 差 ${gap}`);
seen.set(r.nearest, (seen.get(r.nearest) || 0) + 1);
}
jg.gate.wu = 55;
console.log('\n★★★ 同一道闸,8 个写法 →', [...seen].map(([k, v]) => `${k}: ${v} 次`).join(' '));
console.log('★★★★【「军功不是最接近的那条」不是一个关于李广的事实,是【本表在 41–63 里挑了 55】这个事实。】');
console.log(' ⇒ 而《史記》卷109 双证钉着:他的实职【就是靠军功挣的】。');
@@ -0,0 +1,34 @@
// ★ 李广/李蔡 落在哪一支 + 打平集合的势
import { computeRank, profileFromSubs, computeAxes, DYNASTIES } from '../src/dynasties.js';
import { subsOf, renwuOf } from '../src/renwu.js';
import { SUB_REGISTRY } from '../src/talents.js';
for (const id of ['liguang', 'licai']) {
const subs = subsOf(id);
const r = computeRank(profileFromSubs(subs, { wang: 0 }), 'han');
console.log(`\n【${renwuOf(id).name}】subs=${JSON.stringify(subs)} axes=${JSON.stringify(computeAxes(subs))}`);
console.log(` channel=${r.channel ? r.channel.id : 'null'} tier=${r.rank.tier} title=${r.rank.title}`);
console.log(` nearest=${r.nearest} nearestTie=${JSON.stringify(r.nearestTie)}`);
console.log(` kai=${JSON.stringify(r.kai.map((k) => k.channel))} zelu.options=${JSON.stringify(r.zelu.options)}`);
}
const SUBS = SUB_REGISTRY.map((s) => s.id);
let opt3 = 0, boss3 = 0, optN = 0, bossN = 0;
for (let m = 0; m < (1 << 16); m++) {
const subs = []; for (let i = 0; i < 16; i++) if (m & (1 << i)) subs.push(SUBS[i]);
const axes = computeAxes(subs);
for (const wang of [0, 29, 30, 54, 55, 74, 75, 100]) for (const d of DYNASTIES) {
const r = computeRank({ axes, wang }, d.id);
if (!r.channel) continue;
const walked = r.kai.find((k) => k.walked);
const bossTie = r.kai.filter((k) => k.tier === walked.tier && k.ceiling === walked.ceiling);
if (bossTie.length > 1) bossN++;
if (bossTie.length > 2) boss3++;
const o = (r.zelu.options || []).length;
if (o > 1) optN++;
if (o > 2) opt3++;
}
}
const N = (n) => n.toLocaleString();
console.log(`\n★ 现行 zelu.options(只收 lostAt==='idx'):>1 条 ${N(optN)} 格;≥3 条 ${N(opt3)} 格`);
console.log(`★ 老板判据(同 tier 同 ceiling): >1 条 ${N(bossN)} 格;≥3 条 ${N(boss3)} 格`);
@@ -0,0 +1,19 @@
import { computeRank, profileFromSubs, DYNASTIES, computeAxes } from '../src/dynasties.js';
import { SUB_REGISTRY } from '../src/talents.js';
const SUBS = SUB_REGISTRY.map((s) => s.id);
const r = computeRank(profileFromSubs(['rigorous','datadriven','concise','shortfirst'], { wang: 0 }), 'weijin');
console.log('魏晋 clerk: nearest=', r.nearest, ' tie=', JSON.stringify(r.nearestTie), ' isDefault=', r.nearestIsDefault);
// 无路者里,nearest【没有】打平的格子占多少?找一个例子
let hasTie = 0, noTie = 0, ex = null;
for (let m = 0; m < (1 << 16); m++) {
const subs = []; for (let i = 0; i < 16; i++) if (m & (1 << i)) subs.push(SUBS[i]);
const axes = computeAxes(subs);
for (const wang of [0, 29, 30, 54, 55, 74, 75, 100]) for (const d of DYNASTIES) {
const r = computeRank({ axes, wang }, d.id);
if (r.channel || !r.nearest) continue;
if (r.nearestTie.length) hasTie++;
else { noTie++; if (!ex) ex = { d: d.id, subs, wang, nearest: r.nearest }; }
}
}
console.log(`\n无路者·nearest 打平的格子 ${hasTie.toLocaleString()};【没打平】的 ${noTie.toLocaleString()}`);
console.log('没打平的一个例子:', JSON.stringify(ex));
@@ -0,0 +1,32 @@
// ★★★ 自查:【这一刀新加的每一个字段/每一个分支,本身是不是又一个空操作?】
// ⚠ 本文件的铁律逐字:「不制造第二个」。而第八刀在「让它出声」那一刀里【自己造了一个】(byZelu)。
// ⇒ 所以这一刀的新字段必须自己先过一遍 census:【每一个取值都要真的跑到】。
import { DYNASTIES, computeRank, computeAxes, AXIS_IDS } from '../src/dynasties.js';
import { chushenOf } from '../src/chushen.js';
import { SUB_REGISTRY } from '../src/talents.js';
const SUBS = SUB_REGISTRY.map((s) => s.id);
const WANGS = [0, 29, 30, 54, 55, 74, 75, 100];
const m = new Map();
for (let b = 0; b < (1 << 16); b++) { const s = []; for (let i = 0; i < 16; i++) if (b & (1 << i)) s.push(SUBS[i]);
const a = computeAxes(s); const k = AXIS_IDS.map((x) => a[x]).join(','); m.set(k, (m.get(k) || 0) + 1); }
const T = [...m].map(([k, w]) => ({ axes: Object.fromEntries(k.split(',').map((v, i) => [AXIS_IDS[i], Number(v)])), w }));
const CFG = {}; for (const d of DYNASTIES) { const x = chushenOf(d.id); CFG[d.id] = x ? [null, ...x.values.map((v) => v.id)] : [null]; }
const N = (n) => n.toLocaleString();
const hit = { by_count: 0, by_gap: 0, by_idx: 0, sameAxis_true: 0, sameAxis_false: 0, mixed_true: 0, mixed_false: 0, kind_scale: 0, kind_codename: 0, rivals1: 0, rivals2plus: 0, gapLine: 0 };
const byDyn = {};
for (const d of DYNASTIES) for (const cs of CFG[d.id]) for (const t of T) for (const wang of WANGS) {
const r = computeRank({ axes: t.axes, wang, chushen: cs }, d.id);
if (r.channel || !r.nearest) continue;
hit['by_' + r.nearestBy] += t.w;
if (r.nearestBy !== 'gap') continue;
const g = r.nearestGap;
hit[g.sameAxis ? 'sameAxis_true' : 'sameAxis_false'] += t.w;
hit[g.mixedKind ? 'mixed_true' : 'mixed_false'] += t.w;
for (const s of [g.mine, ...g.rivals]) hit['kind_' + s.kind] += t.w;
hit[g.rivals.length === 1 ? 'rivals1' : 'rivals2plus'] += t.w;
if (r.reasons.some((x) => /最接近的那条是怎么挑出来的/.test(x))) hit.gapLine += t.w;
if (g.sameAxis) { const k = `${d.id}|${cs}`; byDyn[k] = (byDyn[k] || 0) + t.w; }
}
console.log('\n══ 这一刀新加的每一个取值,跑到了几格?【0 = 我造了一个新的空操作】══\n');
for (const [k, v] of Object.entries(hit)) console.log(` ${k.padEnd(18)} ${N(v).padStart(12)} ${v === 0 ? ' ★★★★ 死!这是我自己造的空操作' : ''}`);
console.log('\n sameAxis=true 落在:', Object.keys(byDyn).length ? JSON.stringify(byDyn) : '【没有一格】');
+54
View File
@@ -0,0 +1,54 @@
// ★ 择路排序器【自洽吗】—— TIE_TOL=5 是【容差】,而容差不传递。
// 一个不传递的比较器交给 Array.sort,结果是【实现定义】的:ranked[0] 可以不是极大元。
// 本表逐字写过:「『碰巧成立』和『守着』是两回事」。⇒ 跑出来。
import { DYNASTIES, computeAxes, getDynasty, AXES } from '../src/dynasties.js';
import { SUB_REGISTRY } from '../src/talents.js';
const SUBS = SUB_REGISTRY.map((s) => s.id);
const TIE_TOL = 5;
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
const xuanOf = (c) => (c && c.xuan) || { seat: 0, kemu: [], key: [] };
function lexCompare(a, b, val) {
const ka = xuanOf(a).key || [], kb = xuanOf(b).key || [];
const n = Math.min(ka.length, kb.length);
for (let i = 0; i < n; i++) {
const va = val(ka[i]), vb = val(kb[i]);
if (Math.abs(va - vb) > TIE_TOL) return vb - va;
}
return 0;
}
let lexNonTrans = 0, cells = 0, tieNonEquiv = 0, tieNonEquivCells = 0;
for (let m = 0; m < (1 << 16); m++) {
const subs = []; for (let i = 0; i < 16; i++) if (m & (1 << i)) subs.push(SUBS[i]);
const axes = computeAxes(subs);
for (const wang of [0, 29, 30, 54, 55, 74, 75, 100]) for (const d of DYNASTIES) {
const val = (k) => (k === 'wang' ? wang : k === 'lao' ? 0 : clamp(Number(axes[k] || 0), 0, 100));
const open = d.channels.filter((c) => Object.entries(c.gate || {}).every(([k, need]) => val(k) >= need));
if (open.length < 3) continue;
cells++;
const ceilOf = (c) => c.ceiling?.tier ?? 5;
const tierOf = (c) => {
const kemu = xuanOf(c).kemu || [];
let n = 0; for (const k of kemu) { if (val(k.axis) >= k.need) n++; else break; }
return clamp((xuanOf(c).seat || 0) + n, 0, ceilOf(c));
};
// ①【lexCompare 的「均」传递吗】A≈B、B≈C ⇒ A≈C ?
for (const A of open) for (const B of open) for (const C of open) {
if (A === B || B === C || A === C) continue;
if (lexCompare(A, B, val) === 0 && lexCompare(B, C, val) === 0 && lexCompare(A, C, val) !== 0) lexNonTrans++;
}
// ②【老板判据的打平集合,内部两两真的都同 tier 同 ceiling 吗】(= 是等价关系吗)
const best = open.slice().sort((a, b) =>
tierOf(b) - tierOf(a) || ceilOf(b) - ceilOf(a) || lexCompare(a, b, val) || 0)[0];
const tie = open.filter((c) => tierOf(c) === tierOf(best) && ceilOf(c) === ceilOf(best));
let bad = 0;
for (const A of tie) for (const B of tie)
if (A !== B && (tierOf(A) !== tierOf(B) || ceilOf(A) !== ceilOf(B))) bad++;
tieNonEquiv += bad; if (bad) tieNonEquivCells++;
}
}
const N = (n) => n.toLocaleString();
console.log(`\n扫了 ${N(cells)} 个【≥3 条路开着】的格子`);
console.log(`①【lexCompare 的「均」不传递】的三元组:${N(lexNonTrans)} 次`);
console.log(` ⇒ 容差关系 |a−b|≤5 【不是等价关系】。而 idx 的成员资格【是拿它定义的】。`);
console.log(`②【老板判据(同 tier 同 ceiling)】内部不自洽:${N(tieNonEquiv)} 次(${N(tieNonEquivCells)} 格)`);
console.log(` ⇒ 相等【是】等价关系。老板的判据是良定义的,我上一刀那个不是。`);
+33
View File
@@ -0,0 +1,33 @@
// CosyVoice 可用性探针:拿本机 .env 的 DASHSCOPE key 打真实语音合成,报「能用/不能用+确切错误」。
// 用法(服务器后端目录内,.env 已配 DASHSCOPE_API_KEY): node scripts/probe-tts.mjs
//
// 结果解读:
// ✅ 出音(音频字节>0) → 该 (model, voice) 可用。
// ❌ ModelNotFound / 未开通 → 百炼账号未开通该 CosyVoice 版本(账号层,去百炼控制台开通)。
// ❌ voice 不匹配/无此音色 → 该版本的预置音色名不对(v2/v3/v3.5 音色不通用)。
import 'dotenv/config';
import { config } from '../src/config.js';
import { synthesizeWs } from '../src/services/ttsWs.js';
// 探测组合:预置音(v2 最可能已开) + 你当前克隆音默认版本(用 v2 音色仅为触发,看是"模型未开"还是"音色不匹配")。
const CASES = [
{ label: '预置音 v2', model: 'cosyvoice-v2', voice: 'longwan_v2' },
{ label: `克隆默认(${config.tts.cloneModel})`, model: config.tts.cloneModel, voice: 'longwan_v2' },
];
console.log('DASHSCOPE key:', config.llm.dashscopeKey ? '已配(尾4:' + config.llm.dashscopeKey.slice(-4) + ')' : '❌未配');
console.log('WS 端点:', config.tts.wsUrl);
console.log('config.tts:', JSON.stringify(config.tts));
console.log('──────── 探测 ────────');
for (const c of CASES) {
process.stdout.write(`· ${c.label} [${c.model} / ${c.voice}] … `);
try {
const r = await synthesizeWs('你好,这是一次语音合成测试。', { voiceId: c.voice, model: c.model });
const bytes = r?.audio ? Buffer.from(r.audio, 'base64').length : 0;
console.log(bytes > 0 ? `✅ 出音 ${bytes} 字节` : '❌ 无音频');
} catch (e) {
console.log('❌', String(e?.message || e));
}
}
process.exit(0);
@@ -0,0 +1,64 @@
// 把 MongoDB knowledge 集合里已有的向量重建进 DashVector 索引。
//
// 何时用:①首次接入 DashVector(建集合 + 灌存量向量);②补齐入库时同步失败的块(标了 vectorPending 的);
// ③换过 embedding 维度后重灌(见下"换维度")。
//
// 幂等性:本脚本用 insert 语义,只【补齐缺失】的向量、灌成功后清 vectorPending 标记。它【不覆盖】已存在
// 同 _id 的向量(在线入库每块都是全新 _id,不会撞;故正常场景无需覆盖)。
// ⚠ 换维度 / 修复"已存在但内容错"的向量:insert 不覆盖,必须【新建集合】——把 .env 的 DASHVECTOR_COLLECTION
// 改成新名(或在阿里云控制台删掉旧集合)后再跑本脚本。ensureCollection 已会在维度不符时【大声报错】提示你。
//
// 用法(服务器后端目录内,.env 里已配好 DASHVECTOR_*): node scripts/reindex-vectors.js
// MongoDB 永远是真相源,本脚本只把真相源投影进向量索引;不改动 knowledge 任何数据(仅清 vectorPending 标记)。
import 'dotenv/config';
import { connectDb, getDb, closeDb } from '../src/db.js';
import { config } from '../src/config.js';
import { vectorEnabled, ensureCollection, vectorUpsert } from '../src/services/vectorStore.js';
async function run() {
if (!vectorEnabled()) {
console.error('❌ 向量库未启用。请在 .env 配 DASHVECTOR_ENDPOINT / DASHVECTOR_API_KEY(和可选 DASHVECTOR_COLLECTION),再重跑。');
process.exit(1);
}
console.log(`向量库: DashVector · collection=${config.vector.dashvector.collection} · dim=${config.vector.dashvector.dim}`);
console.log('① 确保集合存在且就绪(维度不符会在此报错)…');
const c = await ensureCollection();
console.log(c.existed ? ' 集合已存在,复用。' : c.notReady ? ' 集合已创建(尚未就绪,后续写入会自动重试)。' : ' 集合已创建、就绪。');
await connectDb();
const db = getDb();
const cursor = db.collection('knowledge')
.find({ embedding: { $type: 'array' } })
.project({ embedding: 1, twinId: 1, sourceType: 1 })
.addCursorFlag('noCursorTimeout', true) // 大集合 + 慢/限流的 DashVector:防两次 getMore 间的阻塞 upsert 超过游标空闲超时(10min)
.batchSize(500);
let batch = [];
let ids = [];
let total = 0;
const BATCH = 200;
const flush = async () => {
if (!batch.length) return;
await vectorUpsert(batch);
// 灌成功 → 清掉这些块入库时可能打上的 vectorPending 标记(它们已进索引)。
try { await db.collection('knowledge').updateMany({ _id: { $in: ids } }, { $unset: { vectorPending: '' } }); } catch { /* 清标失败无害 */ }
total += batch.length;
process.stdout.write(`\r 已重建 ${total} 块…`);
batch = []; ids = [];
};
console.log('② 遍历 knowledge 集合,灌入向量…');
for await (const d of cursor) {
if (!Array.isArray(d.embedding) || !d.embedding.length) continue;
batch.push({ id: String(d._id), embedding: d.embedding, twinId: String(d.twinId), sourceType: d.sourceType || 'text' });
ids.push(d._id);
if (batch.length >= BATCH) await flush();
}
await flush();
console.log(`\n✅ 完成。共重建 ${total} 块向量进 DashVector。`);
await closeDb();
}
run().catch((e) => { console.error('\n❌ 重建失败:', e.message); process.exit(1); });
+209
View File
@@ -0,0 +1,209 @@
// 样板数据:三城大师墙 + 高端文旅路线(成都 / 苏州 / 徽州)。
// 用法:node scripts/seed-masters.js (幂等,可反复跑)
import 'dotenv/config';
import { ObjectId } from 'mongodb';
import { connectDb, getDb, closeDb } from '../src/db.js';
const SEED = 'chengdu-master-wall'; // 沿用同一 seedTag,一键清旧重灌
// ── 大师(跨三城)────────────────────────────────────────────────
const MASTERS = [
// 成都
{ slug: 'zhucheng', displayName: '朱成', honorific: '朱成', domain: 'diaosu', region: '成都', masterTier: 'flagship',
tagline: '用石头留住一座城的记忆', masterworks: ['朱成石刻艺术博物馆·馆长', '万佛墙', '抢救性民间石刻造像收藏'], poolWeight: 2,
characterPrompt: '我是朱成,雕塑家,朱成石刻艺术博物馆馆长。几十年来我在抢救、收藏民间石刻造像,把散落各处的民间美学与信仰留下来。我谈艺术、谈收藏、谈一座城的记忆,语气朴实、有温度。',
videoUrl: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4', // 占位样片,替换为大师真实短片
tags: ['雕塑', '石刻', '民间博物馆', '成都'] },
{ slug: 'lijiang-art', displayName: '李江', honorific: '李江教授', domain: 'meishu', region: '成都', masterTier: 'resident',
tagline: '笔墨见性,画如其人', masterworks: ['美术学院系主任', '当代绘画'], poolWeight: 1,
characterPrompt: '我是李江,画家,美术学院系主任。我谈绘画语言、创作观、如何看一幅画。面对习作我会具体点评构图、笔性、色调、气韵,温和而犀利。',
tags: ['美术', '绘画', '当代艺术', '成都'] },
{ slug: 'tangjie', displayName: '鲁杰', honorific: '鲁杰先生', domain: 'gujian', region: '成都', masterTier: 'flagship',
tagline: '营造之道,藏在斗拱之间', masterworks: ['中国古建泰斗', '营造法式研究'], poolWeight: 1,
characterPrompt: '我是鲁杰,专研中国古建筑。我讲斗拱、营造法式、会馆与庙宇的形制,也讲古建的当代传承。语气沉稳、爱"论道"。',
tags: ['古建', '营造法式', '历史建筑', '成都'] },
// 苏州
{ slug: 'shenyun-su', displayName: '沈云', honorific: '沈云', domain: 'feiyi', region: '苏州', masterTier: 'flagship',
tagline: '以针代笔,一线一世界', masterworks: ['苏绣非遗代表性传承人', '双面绣'], poolWeight: 1,
characterPrompt: '我是沈云,苏绣非遗传承人。我谈刺绣的针法、丝理、配色与"以针代笔"的意境,也讲一幅绣品背后几个月的功夫与心境,温婉细致。',
videoUrl: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4', // 占位样片
tags: ['苏绣', '非遗', '刺绣', '苏州'] },
{ slug: 'chenyuan-su', displayName: '陈园', honorific: '陈园先生', domain: 'gujian', region: '苏州', masterTier: 'resident',
tagline: '移步换景,咫尺山林', masterworks: ['苏州园林营造研究', '叠山理水'], poolWeight: 1,
characterPrompt: '我是陈园,研究苏州园林营造。我讲叠山、理水、借景、移步换景,如何在方寸间造出咫尺山林,也讲文人园林的意趣。',
tags: ['园林', '造园', '古建', '苏州'] },
// 徽州
{ slug: 'humo-hui', displayName: '胡墨', honorific: '胡墨', domain: 'feiyi', region: '徽州', masterTier: 'flagship',
tagline: '一两徽墨一两金', masterworks: ['徽墨制作技艺传承人', '歙砚鉴藏'], poolWeight: 1,
characterPrompt: '我是胡墨,做徽墨、赏歙砚。我谈点烟、和胶、捣杵、描金的制墨古法,也讲文房四宝里的门道与文人风骨。',
videoUrl: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerMeltdowns.mp4', // 占位样片
tags: ['徽墨', '歙砚', '文房', '非遗', '徽州'] },
{ slug: 'fangying-hui', displayName: '方营', honorific: '方营先生', domain: 'gujian', region: '徽州', masterTier: 'resident',
tagline: '粉墙黛瓦马头墙', masterworks: ['徽派建筑营造研究', '徽州三雕'], poolWeight: 1,
characterPrompt: '我是方营,研究徽派建筑。我讲马头墙、天井、三雕(木雕/石雕/砖雕)与聚族而居的宗族空间,也讲徽商衣锦还乡如何营造一座村落。',
tags: ['徽派建筑', '古村落', '三雕', '徽州'] },
];
// ── 文旅路线(引用大师 slug)────────────────────────────────────
const TOURS = [
{
title: '成都·大师人文之旅(5天·私宴接待级)', region: '成都', tier: 'private',
priceHint: 50000, depositRate: 0.5, seats: 8,
masters: [['zhucheng', 2], ['lijiang-art', 1], ['tangjie', 1]],
highlights: ['三位泰斗级大师亲自陪同 + 私宴(市面不可复制)', '顶奢文化酒店(大慈寺旁·博舍级)+ 专属管家', '8 人极致小团 · 数字分身行前讲解 + 行后永续问道'],
included: ['5 晚顶奢文化住宿', '全程专属管家', '三场大师私宴/茶叙', '博物馆/画室/古建深度接待', '数字传承卡'],
itinerary: [
{ day: 1, title: '入蓉·序章:大慈千年', desc: '专属管家接机 → 入住文化酒店 → 大慈寺·太古里人文漫步 → 欢迎晚宴;朱成馆长出席茶叙破冰。' },
{ day: 2, title: '朱成·石头里的众生', desc: '朱成石刻艺术博物馆深度导览(万佛墙/民间造像),馆长亲自讲解 → 拓片/雕塑手作、大师点评 → 私宴。' },
{ day: 3, title: '李江·笔墨见性', desc: '蓝顶艺术区·李江画室,看画论画、大师现场示范 → 命题写生一对一指点 → 当代艺术下午茶雅集。' },
{ day: 4, title: '鲁杰·营造之道', desc: '洛带古镇客家会馆群(清代古建),鲁杰现场解构斗拱与营造法式 → "论道"沙龙 → 宽窄巷子夜话·私宴。' },
{ day: 5, title: '历史建筑·故事收官', desc: '安仁公馆群 / 都江堰-青城山古建,专家讲背后的家族与时代故事 → 结业茶会,赠"数字传承卡"。' },
],
masterMoments: ['D1 朱成茶叙破冰', 'D2 朱成馆长亲自导览 + 私宴论道', 'D3 李江面对面:示范、点评习作', 'D4 鲁杰论道:古建营造现场解构'],
guides: [
{ name: '朱明', note: '朱成之子', titles: ['博物馆研究博士', '一人博物馆创始人', '成都美术家协会副秘书长'] },
{ name: '熊婧彤', titles: ['省级青年建筑专家', '教授级高工', '成都历史建筑口述编导', '主播', '成都美术家协会副秘书长'] },
],
},
{
title: '姑苏·园林与绣艺之旅(4天·私宴接待级)', region: '苏州', tier: 'private',
priceHint: 38000, depositRate: 0.5, seats: 8,
masters: [['shenyun-su', 1], ['chenyuan-su', 1]],
highlights: ['苏绣非遗传承人亲授 + 园林营造大师同行', '拙政园旁精品园景酒店 · 专属管家', '昆曲雅集 · 数字分身行前讲解 + 行后问道'],
included: ['3 晚精品园景住宿', '全程专属管家', '苏绣工坊亲授', '名园深度私导 + 昆曲雅集', '数字传承卡'],
itinerary: [
{ day: 1, title: '入苏·序章:平江烟雨', desc: '专属管家接机 → 入住园景酒店 → 拙政园·平江路人文漫步 → 欢迎晚宴。' },
{ day: 2, title: '沈云·一针一世界', desc: '苏绣非遗工坊,沈云大师亲授看绣/试针 → 论"以针代笔"的意境 → 苏帮菜私宴。' },
{ day: 3, title: '陈园·园林之道', desc: '网师园/留园,陈园现场解构叠山理水、移步换景 → 夜游拙政园·昆曲雅集。' },
{ day: 4, title: '姑苏·故事收官', desc: '虎丘/寒山寺,专家讲背后故事 → 结业茶会 → 送机。' },
],
masterMoments: ['D2 沈云亲授苏绣 + 私宴', 'D3 陈园园林论道 + 昆曲雅集'],
},
{
title: '徽州·古村与文房之旅(5天·私宴接待级)', region: '徽州', tier: 'private',
priceHint: 45000, depositRate: 0.5, seats: 8,
masters: [['humo-hui', 1], ['fangying-hui', 1]],
highlights: ['徽墨非遗传承人亲授 + 徽派建筑大师同行', '宏村旁徽派古宅精品酒店 · 专属管家', '世界文化遗产古村落深度私导'],
included: ['4 晚徽派古宅住宿', '全程专属管家', '徽墨/歙砚工坊亲授', '宏村·西递深度私导', '数字传承卡'],
itinerary: [
{ day: 1, title: '入徽·序章:屯溪老街', desc: '专属管家接机 → 入住徽派古宅酒店 → 屯溪老街人文漫步 → 欢迎晚宴。' },
{ day: 2, title: '胡墨·文房四宝', desc: '徽墨/歙砚工坊,胡墨大师亲授点烟描金制墨、赏砚 → 论文房之道 → 徽菜私宴。' },
{ day: 3, title: '方营·粉墙黛瓦', desc: '宏村/西递古村落,方营现场解构徽派三雕与马头墙、天井 → 南湖书院茶叙。' },
{ day: 4, title: '徽州人文·宗族故事', desc: '徽州古城/棠樾牌坊群,专家讲徽商与宗族的时代故事。' },
{ day: 5, title: '徽州·山水收官', desc: '黟县木坑竹海 → 结业茶会,赠"数字传承卡" → 送机。' },
],
masterMoments: ['D2 胡墨亲授制墨 + 私宴', 'D3 方营徽派建筑现场论道 + 书院茶叙'],
},
];
// ── 众生墙:普通人示例分身(非大师,isMaster:false)──
const PEOPLE = [
{ slug: 'linxiaoman', displayName: '林小满', tagline: '一杯手冲,慢煮生活', tags: ['咖啡', '手冲', '独立小店', '生活方式'], likeCount: 7, likeSum: 32,
characterPrompt: '我是林小满,开了家只有八个座位的独立咖啡馆。我聊手冲、豆子、慢生活,也聊一个人开小店的酸甜。语气温柔、不急不躁。' },
{ slug: 'chenjiu', displayName: '陈九', tagline: '写代码的深夜,和你聊技术也聊人生', tags: ['程序员', '技术', '副业', '效率'], likeCount: 9, likeSum: 40,
characterPrompt: '我是陈九,写了八年后端。能陪你聊架构、聊 bug、聊程序员的副业和焦虑。理性、爱打比方,偶尔毒舌。' },
{ slug: 'suqing', displayName: '苏晴', tagline: '呼吸之间,找回身体的觉知', tags: ['瑜伽', '冥想', '身心', '疗愈'], likeCount: 6, likeSum: 29,
characterPrompt: '我是苏晴,教瑜伽和冥想。我引导你把注意力放回呼吸和身体,聊体式,也聊如何和自己相处。温和、笃定。' },
{ slug: 'amay', displayName: '阿May', tagline: '记录女儿的每一天,也记录我自己', tags: ['育儿', '亲子', '成长记录', '日常'], likeCount: 8, likeSum: 36,
characterPrompt: '我是阿May,一个一岁女儿的新手妈妈。分享育儿的手忙脚乱和小确幸,也想被人问问"你自己还好吗"。真诚、有点碎碎念。' },
{ slug: 'laozhou', displayName: '老周', tagline: '退休了,想把读过的书讲给你听', tags: ['读书', '文史', '退休生活', '讲书'], likeCount: 5, likeSum: 24,
characterPrompt: '我是老周,教了三十五年语文,刚退休。爱讲书、讲诗词、讲历史里的小人物,也想有人陪我聊聊天。慈祥、爱掉书袋。' },
{ slug: 'dapeng', displayName: '大鹏', tagline: '别急,我们从第一个深蹲开始', tags: ['健身', '增肌', '减脂', '自律'], likeCount: 7, likeSum: 30,
characterPrompt: '我是大鹏,健身教练。陪你从零开始练,聊动作、聊饮食、聊怎么坚持下去。热血、直接、爱鼓励人。' },
{ slug: 'xiawan', displayName: '夏晚', tagline: '背包走过 23 国,故事讲不完', tags: ['旅行', '摄影', '背包客', '攻略'], likeCount: 9, likeSum: 43,
characterPrompt: '我是夏晚,背包走过二十多个国家。聊路上的故事、省钱攻略、一个人旅行的胆量与孤独。松弛、爱讲段子。' },
{ slug: 'linyisheng', displayName: '林医生', tagline: '有些话,先说给我的分身听听', tags: ['心理', '情绪', '倾诉', '疗愈'], likeCount: 8, likeSum: 38,
characterPrompt: '我是林医生,心理咨询师。不评判,只陪你把情绪慢慢说清楚。温和、会倾听、常反问。注:我不做诊断,严重困扰请找线下专业帮助。' },
{ slug: 'xiaoye', displayName: '小野', tagline: '把家的味道,做给更多人', tags: ['美食', '家常菜', '下厨', '治愈'], likeCount: 6, likeSum: 27,
characterPrompt: '我是小野,爱做家常菜。聊怎么把一顿普通饭做得有温度,分享菜谱和厨房里的治愈时刻。热心、烟火气十足。' },
{ slug: 'azheng', displayName: '阿正', tagline: '教室到舞房,都是我的主场', tags: ['街舞', '校园', '青春', '兴趣'], likeCount: 7, likeSum: 31,
characterPrompt: '我是阿正,大三,跳街舞。聊舞蹈、校园、怎么在忙碌里坚持热爱。活力满满、爱用网络梗。' },
];
async function run() {
await connectDb();
const db = getDb();
// 幂等:清掉上次种子(只清本 seedTag 相关,不动后台手工建的数据)。
const oldTwins = await db.collection('twins').find({ seedTag: SEED }).project({ _id: 1 }).toArray();
const oldIds = oldTwins.map((t) => t._id);
const oldMasters = await db.collection('masters').find({ twinId: { $in: oldIds } }).project({ _id: 1 }).toArray();
await db.collection('agreements').deleteMany({ masterId: { $in: oldMasters.map((m) => m._id) } });
await db.collection('twins').deleteMany({ seedTag: SEED });
await db.collection('masters').deleteMany({ twinId: { $in: oldIds } });
await db.collection('tours').deleteMany({ seedTag: SEED });
const twinIdBySlug = {};
for (const m of MASTERS) {
const twinDoc = {
ownerId: new ObjectId(),
displayName: m.displayName, kind: 'clone', persona: '', characterPrompt: m.characterPrompt,
avatarUrl: null, voiceId: null, voiceModel: null, lang: 'zh',
isPublic: true, inPlaza: true, slug: m.slug, tagline: m.tagline, tags: m.tags,
verified: true, isMaster: true, masterTier: m.masterTier, domain: m.domain, region: m.region,
honorific: m.honorific, masterworks: m.masterworks, profession: null, videoUrl: m.videoUrl || null,
talents: { sub_talents: [], unlockedAt: null }, likeSum: 0, likeCount: 0,
seedTag: SEED, createdAt: new Date(), updatedAt: new Date(),
};
const r = await db.collection('twins').insertOne(twinDoc);
twinIdBySlug[m.slug] = r.insertedId;
const mr = await db.collection('masters').insertOne({
twinId: r.insertedId, status: 'live',
// ⚠ knowledge 必须是 false。这个脚本【一处知识库都没写】(全文 0 处 ingest)——
// 种子大师只有一句 characterPrompt 人设,知识库是空的。
// 此前这里写死 knowledge:true,等于代码在对自己的仪表盘撒谎:后台看到"知识库已就绪",
// 实际上任何人派分身来"向大师学习",学到的都只是 Qwen 借着人设即兴生成的通识。
// 要让它变成 true,只有一条路:真的把大师的资料喂进去(masters.js addMasterKnowledge)。
buildProgress: { photo: true, voice: true, knowledge: false, review: true },
poolWeight: m.poolWeight, offlineCommit: '每团出席茶叙/论道 1 次;私宴按排期配合。',
intro: m.characterPrompt, contact: {}, createdAt: new Date(), updatedAt: new Date(),
});
await db.collection('twins').updateOne({ _id: r.insertedId }, { $set: { masterId: mr.insertedId } });
await db.collection('agreements').insertMany([
{ masterId: mr.insertedId, kind: 'authorization',
scope: { useImage: true, useVoice: true, useKnowledge: true, canSay: [m.domain], cannotSay: ['政治', '医疗诊断', '投资建议'] },
revocable: true, term: { from: new Date(), to: null },
inheritance: '授权范围内,数字分身可在本人年迈/离世后继续用于文化传承讲解。',
aiDisclosure: '本页为大师授权的数字分身,非本人实时应答;深度交流以线下为准。',
signedAt: new Date(), createdAt: new Date() },
{ masterId: mr.insertedId, kind: 'cooperation',
cooperationSLA: { teaPerTour: 1, banquetPerYear: 4, receptionNote: '按团配合接待、茶叙/论道,私宴择期。' },
createdAt: new Date() },
]);
}
for (const t of TOURS) {
const ids = t.masters.map(([slug]) => twinIdBySlug[slug]);
await db.collection('tours').insertOne({
title: t.title, cover: null, region: t.region, masterIds: ids, tier: t.tier,
priceHint: t.priceHint, depositRate: t.depositRate,
revShare: { platform: 20, masters: 30, organizer: 50 },
masterShares: t.masters.map(([slug, weight]) => ({ masterId: twinIdBySlug[slug], weight })),
seats: t.seats, seatsLeft: t.seats, status: 'open',
highlights: t.highlights, included: t.included, itinerary: t.itinerary, masterMoments: t.masterMoments,
guides: t.guides || [],
seedTag: SEED, createdAt: new Date(), updatedAt: new Date(),
});
}
// ── 众生墙:普通人示例分身 ──
for (const p of PEOPLE) {
await db.collection('twins').insertOne({
ownerId: new ObjectId(),
displayName: p.displayName, kind: 'clone', persona: '', characterPrompt: p.characterPrompt,
avatarUrl: null, voiceId: null, voiceModel: null, lang: 'zh',
isPublic: true, inPlaza: true, slug: p.slug, tagline: p.tagline, tags: p.tags,
verified: false, isMaster: false, profession: null,
talents: { sub_talents: [], unlockedAt: null }, likeSum: p.likeSum, likeCount: p.likeCount,
seedTag: SEED, createdAt: new Date(), updatedAt: new Date(),
});
}
console.log('✓ 种子完成');
console.log(' 大师:', MASTERS.map((m) => `${m.displayName}(${m.region})`).join('、'));
console.log(' 路线:', TOURS.map((t) => `${t.region}¥${t.priceHint}`).join('、'));
console.log(' 众生墙普通人:', PEOPLE.map((p) => p.displayName).join('、'));
await closeDb();
}
run().catch((e) => { console.error(e); process.exit(1); });
+132
View File
@@ -0,0 +1,132 @@
# 关口能动性改造图纸(只读设计产出,2026-07-18)
> 产品核心:「我的分身,我在每朝每代的位置,我所在位置的【选择】对后世的影响,史记我的传」,
> 内核「你什么都没变,天花板全变了」。老板拍板:**玩家被安置、什么都没做 = 败笔,必改。**
## 0. 一句话结论
「关口只是算完位阶后的叙事装饰」——**对代码现状而言大半是错的,但它精确命中了唯一真正缺的那一根轴。**
- 选择**早就**改传、改下场、改论赞(**回望轴**:你走过的路写出你的书,已落地)。
- 选择**从不**改变你面对的下一个关口、也从不决定你活不活得到它(**前瞻轴**:老板说的「往前荡」——
除南齐 2 关口样张外,**全仓未实现**)。
⇒ 不是推倒重来,是**补一根轴 + 把已有的、闲置的原语用起来**。引擎改动约 25 行 + 3 条断言 + 数据填充。
## 1. 现状诊断(诚实)
### 1.1 引擎地图
| 文件 | 角色 | 关键函数 |
|---|---|---|
| `dynasties.js` | 天花板:六轴→位阶(确定性纯函数) | `computeRank`(L3875) |
| `decisions.js` | 关口引擎(纯常量表+纯函数,零 IO) | `canReach`/`replay`/`stateOf`/`availableDecisions`/`computeImpact`/`openLineage`/`appendDecision` |
| `guankou-{nanqi,han,tang,song,ming,liao,weijin}.js` | 各朝关口数据 | `*_DECISIONS` |
| `zhuan.js` | 成传:正文+注+史臣曰+赞曰,**逐格照你的选择叙述** | `buildZhuan`(L922),ben/xuan/mo 三库按 optionId 取 |
| `shizan.js` | 传末断语 + **你的**下场 | `shizanForLineage`(L1013)、`zanZhuan(decisionId,score)` |
| `dizan.js` | **帝纪论赞(皇帝级)**——另一根轴,**不进你的传管线** | 供 kg/tianxia 用 |
| `services/lineage.js` | 持久化层 | `decide`(L298)、`zhuanOf`(L445) |
★ 口径订正:你的下场与你的论赞出自 `shizan.js`;`dizan.js` 是帝纪论赞,本次不碰。
### 1.2 选择【已经改变了什么】(不是装饰,已落地)
1. **传正文**:`buildZhuan` 逐格取 `bank.xuan[optionId]`/`bank.mo[optionId]`,明写「这一步是你选的,史上真站这格的人选的是 X」。同画像同朝不同选 ⇒ 正文不同。
2. **下场**:取全链**最坏的 `fate.score`** → 下场正文/旁证随选择变。
3. **你的论赞(赞曰·转句)**:`zanZhuan(decisionId, fate.score)` 按分带取转句 → 论赞随选择变。
4. **影响档**:`computeImpact` 由 `option.layer`+`option.inherited` 算 0/50/200 年。
5. ★ **已经有关口就是「每条岔路=一个真人的下场,双证」**:`han-t5-jian-zheng`(谏争,已上线)三个选项=同一间屋子的三个真人:
- `wei-ling`=**胡广**(fate.score=5,三登太尉、位至太傅)=historical
- `jian-zheng`=**李固**(fate.score=0,诛,五十四)
- `tui-er-shang-shu`(score=1)
persona 出自各人本传,下场双证逐字。**这就是老板「削藩:晁错/袁盎/周亚夫」那道题的落地形态,只是做成了东汉版。**
### 1.3 选择【从不改变什么】—— 真正的败笔
1. **下一个关口的可达性,不随你的选择分岔。** 关口开闭四关:①没答过 ②`requiresRank`(开传即冻)
③`preconditions.state` ④`afterDecisions`(前一关**答过**即可,**不问怎么答**)/`closedByOptions`(选过某项则关)。
实测:**`closedByOptions` 全仓只用了一次**(南齐 2 关口样张);**`state` 真闸只用一次且是空操作**(`{ming:0}` 恒真)。
⇒ 汉/唐/宋/明/辽/魏晋 135+ 个关口里,**没有任何一个关口的「开不开」取决于你在前一关选了哪一项**。
2. **死了不结束路。** `fate.score` **不是** state key。选李固(诛)**不会**让下一关不发。
`buildZhuan` 自己都在吐槽:「善终富厚之后还在答下一道题——人死了还在答题」。
⇒ 老板的「这个关口怎么选,决定你还活不活得到下一个关口」= **全仓未实现**。
3. **多数朝代的关口是黑的。** `decisions.js` 只 spread 了 `NANQI_DECISIONS`+`HAN_DECISIONS`;
tang/song/ming/liao/weijin 只 import+re-export、**没 spread**,因为它们的**体例**(`shizan.js` TIYUAN + 断语库)不存在。
⇒ **用户今天只能走到南齐和汉。**
**小结:能动性存在于「回望」,不存在于「路径」。前瞻轴正是老板点名那一根,也是唯一真缺的一根。**
### 1.4 削藩那个例子的实况
景帝三年=前154=**西汉**,而引擎的汉是**东汉**(25–220)。晁错/袁盎在 `renwu.js` 的 `XIHAN_ONLY`,**周亚夫全仓零命中**。
⇒ 老板的**字面**例子需要一个不存在的「西汉」朝;但它的**结构**(一处境、多真人、多下场)**已在东汉 `han-t5-jian-zheng` 完整落地**。
## 2. 最小改造图纸
### 2.1 choice schema 对齐
| 老板要的 | 现有字段 | 动作 |
|---|---|---|
| 走谁的路 | `option.persona`+关口级 `historical`(真人只在散文/cites里) | **新增 `option.who:{name,ref,cites}`**,前端可标「走李固的路」。缺则回落 persona |
| 下场 | `option.fate:{score,text,cites}` | 复用不动 |
| 论赞倾向 | 由 `fate.score` 经 `zanZhuan` 分带 | 复用,不新增字段 |
| 解锁/锁死下一关 | 下游 `preconditions.afterDecisions`(只问答没答)/`closedByOptions` | **新增 `preconditions.afterOptions:[ref(dId,oId)]`**(必须选过某具体项才开=`closedByOptions` 的正向补集)并**填充** |
| cite双证 | `fate.cites`/`situationCites` | 复用 |
### 2.2 引擎改动(`decisions.js`,三处都小)
**(A) `preconditions.afterOptions`(正向选择分岔)**
- `availableDecisions`:3a/3b 之间加 `for (const r of p.afterOptions||[]) if (!takenRefs.has(r)) return false;`
- `preconditionsOf`:把 `afterOptions` 拼进去给前端。
- `assertInvariants`:每个 ref 必须解析到真实 `decisionId/optionId`(照 `closedByOptions` 同款)。
- **意义**:让「你怎么选决定你面对什么下一关」为真的最关键字段。`afterDecisions` 只能表达「答过」,表达不了「这么答过」。
**(B) `option.terminal`(死/永久离场 → 传到此为止)**
- schema `terminal:{value:true, why, cites}`(独立于 fate:「失位」不等于「死」,不宜用 `fate.score<=0` 硬套)。
- `replay` 返回值加 `terminated`;`availableDecisions`:`if (state.terminated) return [];`
- `assertInvariants`:`terminal.value===true` 必须带 `terminal.cites`(真人真死双证),否则拒收 —— 同「查不实宁可不写」。
- **意义**:实现「活不活得到下一关」,同时修掉「人死了还在答题」。
**(C) `computeImpact` / fate-layer 反相关不变式**:不动。
### 2.3 传/下场层:基本不动
- ★ 新增选项**必须同时加 `bank.xuan[oId]` 和 `bank.mo[oId]`**——`buildZhuan` 缺库会当场抛(自守护),别绕过、别用占位符糊弄。
### 2.4 持久化/路由:**零改**(`decide` 全靠引擎判定,新字段自动生效)
### 2.5 前端:轻(显示 `who`=走谁的路;「选此项将锁死/解锁/终结」做成选前提示)
## 3. 料缺口(现在不补,只列)
| 关口 | 现成真人下场料 | 缺什么 |
|---|---|---|
| 汉·`han-t5-jian-zheng` 谏争 | **有,全三支双证**(胡广/李固/杜乔) | 无 —— 直接可做第 1 步 |
| 南齐·四户 | 事层两支有真人;**制/道两支是推演** | **不可补**(强安真人=造假,须保留「推演」标注) |
| 汉其余 | 处境双证,多数 `historical:null` | 需另找「同处境多人不同选」的双证料 |
| 西汉·削藩 | 晁错/袁盎在 renwu;**周亚夫全缺** | ①加「西汉」朝(大工程) ②curl 周亚夫 ③新建 guankou-xihan.js。大缺口,不现在补 |
| tang/song/ming/liao/weijin | 关口料已双证,但**黑的** | 卡在**体例**(TIYUAN+断语库),非料缺 |
## 4. 守根
1. **「你没变,天花板变了」不破**:`computeRank` 一字不动;`rankSnapshot` 开传即冻。
能动性**只**来自关口路径,**绝不**来自改位阶。关口内「迁/贬」也只改「接下来碰到哪些关口」,**不改 rank 快照**。
2. **「查不实宁可不写」不破**:`terminal` 与分岔用的 `fate` **必须带双证 cites**,两条新断言钉死。
3. **不给 制/道 空支硬安真人**:制/道 层历史上常确无人从此格这么做,现设计已诚实标「推演,非史事」,**保留**。
分岔的历史根来自「一处境多真人」,不来自逼每条岔路都挂真人。
## 5. 风险
1. **分岔变瞎编**:加了 afterOptions 会忍不住编「你若选X就会遇到Y」。红线:**分岔本身必须史上真有**(双证)。
2. **传变回套模板**:机械加分岔而不写 per-person 的 xuan/mo → 千传一面。保住每个真人的声口。
3. **搞破「你没变」**:用「改 rank/选项抬位阶」实现分岔 = 当场破功。`terminal` 只许关链,绝不许动 rank。
4. **死亡截断顺序**:同 tier 内关口多为并行,terminal 一步关掉余下关口可能截掉本应先答的;须保证关口序由 `when`/`afterDecisions` 良序,上线前跑穷举验。
5. **编年倒流**:afterOptions 必须尊重 `when` 时序,别造出「先494后490」。
## 6. 落地顺序(最快让老板看到「我的选择真的改了我的命」)
1. **先接那道已经是真分岔的关口**(0 新料、~10 行):`han-t5-jian-zheng`——
给 `jian-zheng`(李固,诛)加 `terminal`(李固下场双证);让 `wei-ling`(胡广,善终)经 `afterOptions` 开启一道后续三公关口。
玩家立刻看见:**选李固 → 你死在这里,传到此为止;选胡广 → 你活下去,面对下一关。** 全踩在双证真人料上。
2. **补引擎两原语 + 三断言**(afterOptions/terminal/invariants)+ 测试各补一条(terminal 真截断、afterOptions 真分岔)。
3. **在汉/南齐已有双证料上再填 2-3 个真分岔**,坐实「选择链=路径」。
4. (后续)补一朝体例 → 点亮 tang/song 的分岔;(更后)上「西汉」朝 + 周亚夫 curl,做字面上的削藩题。
Binary file not shown.
+61
View File
@@ -0,0 +1,61 @@
// attributes.js —— 铸身「擅长 / 性格」两维的结构化选项(老板 2026-07-21:铸身严格只放擅长/性格/分量/家世,
// 每维多选项、各带权重真影响官职)。
//
// ★ 不另造引擎:每个选项映到【现成的一项副天赋(sub_talent)】——照命 buildProfile→computeAxes→office5d 真读的那份。
// ★★ 每个选项【恰好 1 项副天赋】= 恰好 1 个才轴点:于是【每点一个本行擅长,官职正好升一级】
// (本行核心轴 0/1/2/3/4 项 → 才 0/1/2/3/4 → 唐 县尉/县令/刺史/侍郎/宰相)。每个选项都真影响官职,不糊弄。
// 权重 = 落在哪条轴:命中你【主业(lei)本行核心轴】→ 才涨最多;旁轴也算一点(office5d.coreStrength 的旁轴辅助)。
//
// ⚠ 17 项副天赋【每一项只归一个选项】(不重叠),多选时不互相顶掉。
// 擅长(才/能力)→ 才轴。按轴分组,前端好显「哪些对你主业加成最大」。
export const SHANCHANG_OPTS = [
// 文采轴 wen
{ id: 'poetic', name: '文采 · 细腻', axis: 'wen', sub: 'poetic', hint: '文字有画面感' },
{ id: 'story', name: '会讲故事', axis: 'wen', sub: 'story', hint: '善用故事比喻' },
{ id: 'vivid', name: '表达 · 生动', axis: 'wen', sub: 'vivid', hint: '像真人说话' },
{ id: 'eloquent', name: '辞采 · 雄辩', axis: 'wen', sub: 'eloquent', hint: '出口成章、辞采华美' }, // ★擅长深化 2026-07-31
// 吏能轴 fa
{ id: 'rigorous', name: '严谨 · 专业', axis: 'fa', sub: 'rigorous', hint: '给依据、不夸大' },
{ id: 'datadriven', name: '有理有据', axis: 'fa', sub: 'datadriven', hint: '爱用数据例证' },
{ id: 'concise', name: '简洁 · 利落', axis: 'fa', sub: 'concise', hint: '先给要点' },
{ id: 'shortfirst', name: '结论先行', axis: 'fa', sub: 'shortfirst', hint: '结论在前、理由在后' },
{ id: 'analytical', name: '洞察 · 缜密', axis: 'fa', sub: 'analytical', hint: '条分缕析、逻辑缜密' }, // ★擅长深化 2026-07-31
// 果决轴 wu
{ id: 'blunt', name: '直率 · 果断', axis: 'wu', sub: 'blunt', hint: '有话直说、敢拍板' },
{ id: 'motivate', name: '激励 · 带动', axis: 'wu', sub: 'motivate', hint: '有感染力、能带人' },
{ id: 'bold', name: '果敢 · 担当', axis: 'wu', sub: 'bold', hint: '敢拍板、有魄力担当' }, // ★擅长深化 2026-07-31
// 学问轴 jingxue
{ id: 'jingxue_tong', name: '治学 · 通经', axis: 'jingxue', sub: 'jingxue_tong', hint: '读得通、钻得深' },
// 机锋轴 tan
{ id: 'socratic', name: '启发 · 思辨', axis: 'tan', sub: 'socratic', hint: '会追问、能点透' },
];
// 性格(脾气)→ 行为轴(决定选拔渠道更合你,也算入才的广度)。
export const XINGGE_OPTS = [
// 交游轴 jiao
{ id: 'warm', name: '温和 · 亲和', axis: 'jiao', sub: 'warm', hint: '好相处、有人情味' },
{ id: 'humor', name: '幽默 · 风趣', axis: 'jiao', sub: 'humor', hint: '会玩梗、轻松' },
{ id: 'playful', name: '俏皮 · 活泼', axis: 'jiao', sub: 'playful', hint: '跳脱、有活力' },
{ id: 'signature', name: '鲜明 · 个性', axis: 'jiao', sub: 'signature', hint: '有辨识度、记得住' },
// 沉毅轴 heng
{ id: 'calm', name: '沉稳 · 克制', axis: 'heng', sub: 'calm', hint: '稳得住、不煽情' },
{ id: 'patient', name: '耐心 · 细致', axis: 'heng', sub: 'patient', hint: '循循善诱、拆得细' },
];
const SC_BY_ID = Object.fromEntries(SHANCHANG_OPTS.map((o) => [o.id, o]));
const XG_BY_ID = Object.fromEntries(XINGGE_OPTS.map((o) => [o.id, o]));
// 选中的擅长/性格选项 → 副天赋 id 并集(喂给 validateSubs → computeAxes)。未知 id 忽略。
export function subsFromAttrs(shanchang = [], xingge = []) {
const subs = new Set();
for (const id of Array.isArray(shanchang) ? shanchang : []) { const s = SC_BY_ID[id]?.sub; if (s) subs.add(s); }
for (const id of Array.isArray(xingge) ? xingge : []) { const s = XG_BY_ID[id]?.sub; if (s) subs.add(s); }
return [...subs];
}
// 只留合法选项 id、去重。
export function validAttrIds(ids, kind) {
const by = kind === 'xingge' ? XG_BY_ID : SC_BY_ID;
return [...new Set((Array.isArray(ids) ? ids : []).filter((id) => by[id]))];
}
+83
View File
@@ -0,0 +1,83 @@
import { chat } from './services/llm.js';
import { config } from './config.js';
import { getDb } from './db.js';
import { ZHANZHEN } from './zhanzhen.js';
import { scanEra } from './sucaibao.js';
/**
* 战争篇·白话面 —— 把一场战役的处境/战果/节点/开打那一刻,通俗重述成大白话(表面松弛好玩,正史原文折在底层)。
*
* ★★ 白话 = 同一条正史事实的【换皮翻译】,不是创作:硬史实(兵力/胜负/生死/节点顺序)一字不动、正史原文一字不删只折叠。
* ★ 走 Qwen(services/llm.js·数据不出域),缓存 Mongo baihuaCache;无 key / 海外兜底关 → {status:'off'} 前端显原文。
* ★ 四锁:① 事实白名单(只准复述所给节点/战果/兵力,禁新增数字改胜负生死) ② 演义黑名单硬过滤 ③ 断代守卫(防晚出器物)
* ④ 校验不过(节点数不符/命中黑名单/断代违禁)→ 不缓存、返回 off,宁可显原文也不上没把住的白话。
*/
const BLACKLIST = ['借东风', '草船借箭', '连环计', '铁索连环', '华容道义释', '苦肉计', '蒋干盗书', '温酒斩华雄', '三英战吕布', '空城计', '单骑救主'];
// 纯函数:拼通俗重述的消息。可单测。
export function buildBaihuaPrompt(b) {
const nodeList = (b.nodes || []).map((n, i) => `${i + 1}. ${n.label}`).join('\n');
const sys = [
'你是把文言史料翻成大白话的高手。把给定这场真实战役,通俗重述成松弛、好玩、第二人称「你」的大白话。',
'铁律(换皮翻译·不是创作):',
'① 只能复述所给的处境/战果/节点/兵力,【绝不新增或改动任何硬史实】:兵力数字、胜负、关键人物生死、节点先后,一字不动。',
'② 号称数就说是「号称」,别当真数;不确定的别编。',
'③ 不掺演义桥段(借东风/草船借箭/连环计/华容道义释/苦肉计等一律不许出现)。',
'④ 器物称谓只用那个朝代及以前确有之物,不用晚出之物(火器/交子/点茶/缠足/棉常服/辣椒玉米等)。',
'⑤ 「战场此刻」只把已载事实翻成有画面的白话(火/风/烟/水/溃/坑),不编造对白、心理、气味颜色等史料没有的细节。',
'只输出一个 JSON:{"hook":"一句身份钩子(第二人称·把人带进去)","situ":"白话处境","real":"白话战果·结论前置","nodes":["逐条对应上面节点的白话·条数必须一致"],"scene":"战场此刻·一段有画面的白话(只重述已载事实)"}',
].join('\n');
const user = [
`战役:${b.name}(${b.side}·${b.year < 0 ? '前' + -b.year : b.year}年)`,
`赌注:${b.stake}`,
`处境:${b.situ}`,
`战果:${b.real}`,
`史实节点(按序·条数=${(b.nodes || []).length}):\n${nodeList}`,
'请据此输出白话 JSON。',
].join('\n');
return [{ role: 'system', content: sys }, { role: 'user', content: user }];
}
// 纯函数:解析+四锁校验。返回 {ok,baihua} 或 {ok:false,reason}。可单测。
export function parseBaihua(text, b) {
let o = null;
try { const m = String(text || '').match(/\{[\s\S]*\}/); if (m) o = JSON.parse(m[0]); } catch { o = null; }
if (!o || !o.situ || !o.real || !Array.isArray(o.nodes)) return { ok: false, reason: 'bad-json' };
// 锁②:演义黑名单
const blob = `${o.hook || ''}${o.situ}${o.real}${(o.nodes || []).join('')}${o.scene || ''}`;
const bl = BLACKLIST.find((w) => blob.includes(w));
if (bl) return { ok: false, reason: 'blacklist:' + bl };
// 锁③:断代守卫(她传等无 chao 的跳过)
if (b.chao) { const eh = scanEra(blob, b.chao); if (eh.length) return { ok: false, reason: 'era:' + eh.join(',') }; }
// 锁①:节点条数须与史实一致(防漏/增节点)
const need = (b.nodes || []).length;
const nodes = o.nodes.slice(0, need);
return {
ok: true,
baihua: {
hook: String(o.hook || '').slice(0, 120),
situ: String(o.situ).slice(0, 600),
real: String(o.real).slice(0, 600),
nodes: nodes.map((n, i) => ({ i, text: String(n || '').slice(0, 300) })),
scene: String(o.scene || '').slice(0, 800),
},
};
}
/** 取一场战役的白话面。缓存优先;无 key → off;生成后四锁校验不过也 off。 */
export async function getBaihua(id) {
const b = ZHANZHEN[String(id || '')];
if (!b) return { status: 'off' };
if (!config.llm?.dashscopeKey && !config.llm?.overseasFallback) return { status: 'off' };
const db = getDb();
const cached = await db.collection('baihuaCache').findOne({ id: b.id });
if (cached?.baihua) return { status: 'ok', baihua: cached.baihua };
let text, model;
try { ({ text, model } = await chat(buildBaihuaPrompt(b), { temperature: 0.5 })); }
catch (e) { console.warn(`[baihua] LLM 不可用: ${e?.message || e}`); return { status: 'off' }; }
const r = parseBaihua(text, b);
if (!r.ok) { console.warn(`[baihua] ${b.id} 校验不过: ${r.reason}`); return { status: 'off' }; }
await db.collection('baihuaCache').updateOne({ id: b.id }, { $set: { id: b.id, baihua: r.baihua, model, at: new Date() } }, { upsert: true });
return { status: 'ok', baihua: r.baihua };
}
+231
View File
@@ -0,0 +1,231 @@
# 编年 · 层 · 影响 —— 建模设计(biannian-design.md)
> 第一步只交设计,不改一条数据、不建前端。全部数字都是 `node` 实跑 `src/tianxia.js`
> 打印出来的,不是手数(脚本见文末「审计口径」)。基线:`npm test` = **601 通过**(本步未动)。
---
## 0. 结论速览
| 事 | 数 | 状态 |
|---|---|---|
| 大事件(shi)总数 | **932** | 25 朝 |
| 层 layer 已标 | **932 / 932(100%)** | ✅ ①「每事标层」实际上**已经做完** |
| 层分布 | 事 830 / 制 92 / 道 10 | 道极吝啬(两千年才 10 个),符合判据 |
| impact 已标 | **0 / 102 制道事件** | ❌ ②「制/道连下游影响」= 本产品线**真正的空白**,全部待做 |
| 层值编码分裂 | 23 朝 pinyin,**2 朝(qin/beisong) 用汉字** | ⚠ 需归一(见 §2.1) |
| zaichang 形状分裂 | array 7 朝 / string 12 朝 / 无 6 朝 | ⚠ 前端必须两种都吃(见 §2.4) |
**一句话**:`layer` 已铺满,不用重做;产品线的正主功能是 **impact(影响链)**,102 个制/道
事件一个都没连下游。补编年(③)是第三优先,且受「双证原文」红线约束,只补真查得实的。
---
## 1. 事件(shi)字段现状 —— 实测
### 1.1 读取路径(不用改)
`src/chao/<code>.js` 各 `export const CHAO_SHI` → `src/tianxia.js` 第 290 行原样挂到
`CHAO[code].shi`(**passthrough,一个字段都不删**)→ `shiAt()` / `chenShiByReign()` 按年过滤后
原样带出。⇒ **你在 chao 文件里给事件加的 `layer`/`impact`,tianxia 自动带出,无需改 tianxia**
(唯一可选的小改:§2.3 的防御性 normalizer)。
KG:`src/kg.js` 第 306–310 行为每条 shi 建 event 节点,且 `raw: s` **挂整条原始对象** ⇒
`node.raw.layer` / `node.raw.impact` 自动可用,**别碰 kg.js**(它派生)。
### 1.2 字段清单(当前两种 shape,都要向后兼容)
**共有字段(所有朝)**
| 字段 | 类型 | 说明 |
|---|---|---|
| `year` | number | 公元年(负=公元前)。KG 按 `${code}:${year}` 建索引 |
| `shi` | string | **本纪原文逐字**(层/影响之外,这是唯一的"原文"栏) |
| `juan` | string | 卷次,如 `《漢書》卷五` |
| `layer` | `'shi'\|'zhi'\|'dao'` | 见 §2.1。**推演**(我们的读法) |
| `kind` | `'yuanwen'` | shi 栏的性质;全部 `yuanwen`(逐字原文) |
| `layerWhy` / `layerNote` | string | **强制**:每条层判据给理由,不许空标 |
| `zaichang` | array \| string | 「那年什么位阶被卷进去」,**推演**(`kind:'tuiyan'` / `zaichangKind:'tuiyan'`) |
**可选字段(部分朝已有)**:`yue`(月) `gz`(干支) `jinian`(纪年) `source`(默认`benji`)
`witnesses`(见证数,xin/wu/xixia 有) `crossWitness` `zhengwu` `biaoCross` `shuangzheng`
`yearOpen`/`yearNote`(系年存疑) `weiFa`(从未生效的制)。—— **新 schema 一律叠加,不动这些。**
### 1.3 layer 现状:100% 已标,但编码分裂(⚠ 待归一)
- **23 朝用 pinyin** `'shi'/'zhi'/'dao'`(xihan/ming/xixia/shuhan/… 及 xin 的工厂函数 `S(...)`
第 5 参,实测 `s.layer==='shi'`)。
- **2 朝用汉字** `'事'/'制'/'道'`:**`qin.js`(22 条) 与 `beisong.js`(46 条)**。
- 语义(各朝文件头一致,判据同一把尺):
- **事 shi(1–50 年)**:一场仗、一次废立、一个人的死、一次亡国。绝大多数。
- **制 zhi(50–500 年)**:一个制度的确立/废除(官制、律、铸钱、学校、科举定式、田制…)。
- **道 dao(500+ / 硬测「到公元 1500 年还在起作用吗」)**:改变了此后千年且今仍运转。极吝啬。
### 1.4 zaichang 形状分裂(⚠ 前端契约)
- **array `[{wei, shuo}]`**(7 朝):xihan xin shuhan wu liusong beiqi liao —— 每项另标 `kind:'tuiyan'`。
- **string**(12 朝):dongjin beiwei liang sui tang wudai beisong xixia jin nansong ming +chen(部分) —— 另有 `zaichangKind:'tuiyan'`。
- **无 zaichang**(6 朝):qin donghan wei xijin nanqi beizhou +chen(部分)。
- ⇒ 这是既有的历史遗留,**不在本产品线强行迁移**(与 layer/impact 正交)。前端 `Biannian.vue`
必须同时处理 array / string / 缺三态(§2.4)。
### 1.5 impact 现状:0,且有一个**命名撞车**(无害,但要说清)
- 没有任何一条 **事件**带下游影响字段。102 个制/道候选全空。
- ⚠ 全库 grep `impact:` 有 7 处命中(xin.js 1 + xixia.js 6)—— 那是 **字形/异文审计表**
(WITNESS 行)里的 `impact`,意思是「这个异体字**涉不涉文义**」(如 `不涉文义`),
**挂在 WITNESS 行对象上,不在 CHAO_SHI 事件上**。⇒ 两个不同对象命名空间,运行时不冲突。
但为免人读混,本设计的事件级 `impact` 语义在 §2.2 写死;老板已点名字段叫 `impact`,沿用。
---
## 2. Schema 设计(向后兼容,只叠加不破坏)
### 2.1 `layer`(已存在,本步只做**归一**决策)
**规范值 = pinyin `'shi' | 'zhi' | 'dao'`**(取 23:2 的多数派 + 与 `kind:'yuanwen'/'tuiyan'`
的罗马化惯例一致 + xin 已用 `s.layer==='shi'` 过滤)。汉字 `事/制/道` 只作**显示 label**。
> ⚠ 这一条**推翻**老板任务书草案里写的 `layer:'事'|'制'|'道'`(汉字)——
> 草案是示意 label,不是存储值规约;把 23 个文件改成汉字,风险远大于把 2 个改成 pinyin。
> **建议**:qin.js / beisong.js 的第二步 agent 把本文件层值 `事→shi 制→zhi 道→dao` 归一;
> 并在 tianxia 加一行防御性 normalizer(§2.3)兜底,保证前端永远只见 pinyin。
- 层是**推演**:`layer` 值本身就是「我们的读法」,无须再标 kind;**`layerWhy` 强制**给理由。
### 2.2 `impact`(新字段,只加在**制/道**事件上,这是产品线正主)
```js
impact: {
reach: 'dangchao' | 'houshi' | 'qiannian', // 影响半径。label:当朝/后世/千年
kind: 'yuanwen' | 'tuiyan', // 与事件级 kind 同词表:原文明引 vs 推演
reason: string, // 【强制】史学通说 / 制度史依据,说人话
to?: [ // 【可选】下游落点,可多个
{ code?: string, year?: number, label: string }
],
cite?: string, // 仅 kind:'yuanwen' 时【强制】:原文明引句
witnesses?: number, // kind:'yuanwen' 时建议≥2(双证)
reachWhy?: string // 可选:半径判据(仿 layerWhy)
}
```
**字段规约(照史学红线,逐条硬性)**
1. **只给 `layer==='zhi'|'dao'` 的事件加 `impact`**;`layer==='shi'` 不加(事没有下游影响链,
有的话它就不是"事")。⇒ impact 天生是 102 个候选的子集。
2. `reach` 与 layer 有对应但**不强绑**:制→常 `houshi`,道→常 `qiannian`;用**独立的下游年代**判,
在 `reachWhy` 写清(如「至 1905 科举废,凡 2041 年 ⇒ qiannian」)。
3. **`kind:'tuiyan'`(推演,绝大多数)**:`reason` 强制,必须是**制度史通说 / 后世明引**,
✗ **不许编造「A 导致 B」的因果**;拿不准、无据 ⇒ **不写那条 impact**,别硬连(红线)。
4. **`kind:'yuanwen'`(原文明引后世征引,如「漢承秦制」)**:`cite` 强制、逐字、
`curl -sk` 双证(`witnesses>=2`),字形照录(爲/為)。
5. **`to[].label` 强制**(人读的落点描述,如「至今农历正月仍为岁首」);`code`/`year` 可选 ——
落点若在本库 25 朝之内就填(KG 可 `code→chaoId`、`code:year→evt:code:year:idx` 连边);
落点在库外(清/民国/今)只填 `label`。⇒ 既支持「千年后 X 朝仍在用」,也支持「至今仍运转」。
6. 影响链**可级联**:如 `jin 1153 改燕京为中都` → `to:[{code:'ming',year:1420,label:'永樂定北京为京师'}]`
→ 千年(元明清至今为都)。级联在数据里天然成图,KG/前端各自渲染。
### 2.3 兼容与迁移
- **不破坏**:impact 是新可选字段;老事件无 impact,前端/ KG 判 `if (e.impact)` 即可。
- **归一 qin/beisong**:第二步各自 agent 把 layer 汉字→pinyin(并行改不同文件,不冲突)。
- **tianxia 可选小改(唯一允许动 tianxia 的地方)**:在 shi passthrough 处加一行
`layer` normalizer(`事→shi 制→zhi 道→dao`)兜底,防某朝漏改。**非必须**,归一到位就不用。
- **KG**:零改动。`node.raw.impact` 自动可用;将来若要 impact **边**(制→后世事),那是 KG 线
自己的 kg.js 改动,不属于本产品线。
### 2.4 前端 `Biannian.vue` 契约(第二步建,这里先定契约)
- 主视图:**编年轴**(按 year 排)+ **层筛选**(事/制/道,pinyin→汉字 label)。
- **影响视图**:点一个制/道事件 → 展开 `impact`(reach 徽标、reason、to 落点、cite/推演标注);
`to.code`/`year` 命中本库则可跳转到下游事件(级联链)。
- **必须两种 zaichang 都吃**:array `[{wei,shuo}]` 渲染成列表;string 直接渲染;缺则不显。
- **诚实标注**(照本仓铁律):层与影响**都要显式标「推演」**;`kind:'yuanwen'` 的 impact 显示 cite;
西夏等**源限**朝的"薄"要说清是史料所限(§3.4),不许长成和"我们没做"同一句话。
---
## 3. 编年覆盖审计 + 补缺清单
### 3.1 全表覆盖(实测,按起年排)
| 朝 | 起讫 | 跨年 | 帝 | 事件n | 事/制/道 | 每百年密度 | 判定 |
|---|---|---:|---:|---:|---|---:|---|
| 秦 qin | -221~-207 | 14 | 2 | 22 | 17/2/**3** | 157 | 充足(超密) |
| 西汉 xihan | -202~8 | 210 | 12 | **18** | 10/6/2 | **8.6** | ★**最薄**(密度全表最低) |
| 新 xin | 9~23 | 14 | 1 | 25 | 16/9/0 | 179 | 充足(制富) |
| 东汉 donghan | 25~220 | 195 | 12 | 42 | 36/6/0 | 21.5 | 偏薄 |
| 曹魏 wei | 220~265 | 45 | 5 | 22 | 16/6/0 | 48.9 | 充足 |
| 蜀汉 shuhan | 221~263 | 42 | 2 | 53 | 52/1/0 | 126 | 充足(超密) |
| 孙吴 wu | 222~280 | 58 | 4 | 26 | 23/3/0 | 44.8 | 充足 |
| 西晋 xijin | 265~316 | 51 | 4 | 21 | 17/3/1 | 41.2 | 充足 |
| 东晋 dongjin | 317~420 | 103 | 11 | 23 | 18/5/0 | 22.3 | 偏薄 |
| 北魏 beiwei | 386~534 | 148 | 12 | 127 | 120/7/0 | 85.8 | 充足(超密) |
| 刘宋 liusong | 420~479 | 59 | 8 | 128 | 126/2/0 | 217 | 充足(超密) |
| 南齐 nanqi | 479~502 | 23 | 7 | 21 | 21/0/0 | 91 | 充足;**制/道=0** |
| 梁 liang | 502~557 | 55 | 4 | 24 | 23/1/0 | 43.6 | 充足 |
| 北齐 beiqi | 550~577 | 27 | 6 | 17 | 16/1/0 | 63 | 充足 |
| 北周 beizhou | 557~581 | 24 | 5 | 28 | 27/1/0 | 117 | 充足 |
| 陈 chen | 557~589 | 32 | 5 | 30 | 30/0/0 | 94 | 充足;**制/道=0** |
| 隋 sui | 581~618 | 37 | 3 | 37 | 26/9/**2** | 100 | 充足(制/道极富,最佳 impact 锚朝) |
| 唐 tang | 618~907 | 289 | 21 | **46** | 45/1/0 | **15.9** | ★**薄**(289 年只 46 事,且制仅 1) |
| 五代 wudai | 907~960 | 53 | 14 | 42 | 40/2/0 | 79 | 充足 |
| 辽 liao | 916~1125 | 209 | 9 | 43 | 39/4/0 | 20.6 | 偏薄 |
| 北宋 beisong | 960~1127 | 167 | 9 | 46 | 42/4/0 | 27.5 | 尚可 |
| 西夏 xixia | 1038~1227 | 189 | 10 | **20** | 9/11/0 | **10.6** | **源限**(无正史,别硬补,见 §3.4) |
| 金 jin | 1115~1234 | 119 | 10 | 21 | 15/5/1 | 17.6 | 偏薄 |
| 南宋 nansong | 1127~1279 | 152 | 10 | **22** | 21/1/0 | **14.5** | ★**薄**(且制仅 1) |
| 明 ming | 1368~1644 | 276 | 16 | **28** | 25/2/1 | **10.1** | ★**薄**(受"抠本纪"纪律约束,见 §3.3) |
**世界观地板建议(每朝至少要撑得起的)**:(a) **每位在位皇帝 ≥1 件**;(b) 该朝**所有制/道转折点**在场;
(c) 重大战/乱/亡国节点在场。密度目标只对下面的"薄"朝加压,其余充足朝**优先做 impact,不硬加事**。
### 3.2 impact 优先锚点(②的正主活,102 个候选,先做这批)
**10 个「道」事件 —— 千年影响链,最高优先**(impact 必做,reach 多为 qiannian):
| 朝 | 年 | 事 | 建议下游落点(推演,需据) |
|---|---:|---|---|
| qin | -221 | 定「皇帝」号 | → 帝制沿用至 1912(label) |
| qin | -221 | 罢分封·置郡县 | → 中国地方行政骨架至今;原文可引「漢承秦制」(yuanwen) |
| qin | -221 | 一法度衡石丈尺·车同轨·书同文 | → 文字/度量统一至今 |
| xihan | -136 | 置五经博士 | → 经学官学至 1905 科举废,2041 年(层已析,直接抬成 impact) |
| xihan | -104 | 正历·以正月为岁首 | → 农历正月至今 2130 年 |
| xijin | 268 | 泰始律令成 | → 律典体例下沿(→隋开皇律→唐律) |
| sui | 581 | 行新律(开皇律) | → 唐律之母本(houshi/qiannian) |
| sui | 605 | 开通济渠(大运河) | → 南北经济动脉至今 |
| jin | 1153 | 改燕京为中都 | → **级联** ming 1420 定北京→元明清至今为都 |
| ming | 1380 | 罢中书·废丞相 | → 无相皇权独揽至 1912(清亦不设);层已析 |
**92 个「制」事件**里的高价值链(示例,非穷举,均**推演·需制度史通说据**):
`beiwei 485 均田` → `sui 609 均田` → `tang 租庸调` → `tang 780 两税法`(均田制的建立→鼎盛→瓦解,
一条可级联的赋税史主线);`xijin 265 中正六条` → 九品中正→门阀;`beiwei 486 三长` → 隋唐乡里;
`sui 583 罢郡`(州县二级)→ 后世地方两级制。⇒ **建议第二步优先把这几条主线连成级联链**,
最能体现「一步制度变革,千年后还在起作用吗」。
### 3.3 补事件清单(③,第三优先,逐条**双证原文**,查不实宁可不补)
- **TIER A(薄 + 高权重 + 正史充足,建议多补)**
- **西汉 xihan(+10~14 事)**:其自身 `CHAO_COVERAGE.shi` 已自认「事層遠遠不全」——
高帝/惠帝/高后/文帝/昭帝/宣帝/元帝/哀帝 编年事几乎没取。补:文景削藩·推恩、武帝征匈奴/张骞、
昭宣中兴、霍光废立、王莽代汉铺垫。**双证《汉书》本纪 + 卷十九表**。
- **唐 tang(+15~20 事)**:289 年仅 46 事、制仅 1,最不撑世界观。补:玄武门、贞观、永徽律(制)、
武周代唐、开元、安史之乱、元和中兴、甘露之变、会昌灭佛、黄巢、朱温代唐。双证新旧唐书。
- **明 ming(+6~10,受约束)**:⚠ 本朝守「抠本纪」纪律,一条鞭法/隆庆开关等**在志不在纪**——
按红线**不硬塞进 CHAO_SHI**,或另标 `source` 单列。可补本纪确有的:土木/夺门已在;
可加 郑和(已)、大礼议(已)。**补量有限,别为凑数破纪律**。
- **TIER B(薄,补制 + 关键事)**
- **南宋 nansong(+6~10)**:绍兴和议、秦桧擅政、隆兴/开禧北伐、端平入洛、崖山亡国;制层补科举/文官制。
- **金 jin(+4~8)**:大定之治、贞祐南迁、蒙金战争(海陵迁都已在)。
- **辽 liao(+4~6)**:承天太后摄政、头下军州制(澶渊已在)。
- **东汉 donghan(+4~8)**:党锢、外戚宦官更替、黄巾。
- **北宋 beisong**:46 尚可;**优先 impact 而非加事**(庆历新政/靖康可补)。
- **TIER C(充足 / 源限,不加或少加)**
- 隋、秦、蜀汉、刘宋、北魏、新 及南北朝诸短朝:**充足,优先做 impact**。
- **南齐 / 陈:制道=0** —— 若逐卷本纪确有制度条(如删定律科),可补 1~2 条制;无则不硬造。
### 3.4 ★ 西夏特别说明(防止有人去"补薄")
西夏 20 事 / 189 年看着最薄,但这是**源限不是缺工**:二十四史**无《夏史》**,只能取
《宋史·夏国传》《辽史》《金史》外国传的转述。**照红线:查不实宁可不补。** 它的"薄"是诚实的,
与本仓「三种没有」(②我们瞎 / ③史官没记)同理——**前端必须把它标成"源限",不许和"我们没做完"
长成同一句"暂无"**。且西夏 11 制 / 0 道也是老实话(西夏之制随国亡而死,无一活到 1500 年)。
---
## 审计口径(实跑,不手数)
- `node src/tianxia.js` 的 `count()` = `{chao:25, di:202, shi:932}`;层分布、密度、道/制清单由
scratchpad 脚本 `biannian_audit.mjs` / `dao_zhi_dump.mjs` 实跑 `allChao()` 打印。
- 层值 pinyin/hanzi 分裂由逐文件 `grep -oE "layer: ?'(shi|zhi|dao)'"` vs `'(事|制|道)'` 计数得:
仅 qin(22 汉字)、beisong(46 汉字),余 23 朝 pinyin。
- 基线:`npm test` = **601 pass**(本步只写本 .md,未动数据/代码)。
File diff suppressed because it is too large Load Diff
+461
View File
@@ -0,0 +1,461 @@
// 能力中枢 · 全平台功能知识库(结构化、常驻内存,无需 DB / AI key 即可用)。
// AI 助手据此向用户「详细讲解 + 答疑」,并产出适合语音播报的话术。
// 原则:只收录「真实、默认可用、用户能直接用」的核心功能;不收内部机制、
// 默认关闭(marketplace)、或未暴露(双语切换)的能力。把功能做详细,而不是乱补充。
// 新增/改动一个功能 = 在此加/改一条;助手无需改代码即可准确介绍它。
//
// 字段:id / name(当前术语:发布=公开主页、分享=定向分享) / category / aliases(检索用)
// / summary(一句话) / detail(详细) / howto(步骤+入口) / requires(前置)
// / limits(限制,可选) / faq(常见问答,可选) / entry(UI跳转) / where(口语入口) / speak(无LLM兜底话术)
// / gatedBy(功能开关名,可选:开关关着时这条能力【不出现】——助手不能推荐页面上不存在的按钮)
import { config } from './config.js';
const ALL_CATEGORIES = [
{ id: 'discover', name: '逛大师传承馆', intro: '平台第一站是「大师数字传承馆」——各领域签约名家的技艺、学识与作品,做成可对话、可传承的数字馆,点开任何一位随时请教。' },
{ id: 'tourism', name: '大师文旅', intro: '围绕数字大师定制的高端人文之旅,大师亲自接待、资深导游随行(平台免费帮大师引流,不参与线下收款)。' },
{ id: 'create', name: '我的分身', intro: '也可以为自己或家人建一个数字分身:把知识、记忆、经历搬进去(知识最重要,形象声音次要);在「我的分身」工作台里管理。' },
{ id: 'output', name: '让分身开口', intro: '让分身用形象和声音,替你说话、对话、出镜。' },
{ id: 'distribute', name: '分发', intro: '按「谁能看到」把分身给到别人:公开主页、定向分享链接。' },
{ id: 'monetize', name: '变现', intro: '用你自己的收款码,让分身替你接咨询、挣钱,平台不抽成。' },
{ id: 'grow', name: '成长与社交', intro: '分身越养越强会升为「生命体」,还能定向分享给特定的人。' },
{ id: 'assist', name: '分身帮我办事', intro: '养好的分身不只像你,还能用工具替你干活:联网查、记事、待办到点提醒、发你收件箱,甚至把多步流程存成工作流一键跑、定时自动跑。' },
{ id: 'account', name: '账号与购买', intro: '登录方式与工具购买。' },
];
// 「变现」分类下只有一条受 marketplace 门控的能力。开关关着时那条能力被过滤掉,
// 若分类还留着,用户点进去就是个空分类——所以分类本身也跟着开关走。
export const CATEGORIES = ALL_CATEGORIES.filter(
(c) => c.id !== 'monetize' || Boolean(config.features?.marketplace)
);
export const CAPABILITIES = [
// ── 逛广场·大师传承馆 ──────────────────────────────────────────────────
{
id: 'plaza-home', name: '大师数字传承馆(平台首页)', category: 'discover',
aliases: ['广场', '首页', '第一站', '大师传承馆', '大师墙', '传承馆', '众生墙', '逛', '发现', 'plaza', '进来看什么'],
summary: '平台第一站就是「大师数字传承馆」——各领域签约名家的技艺、学识与作品,做成可对话、可传承的数字馆。',
detail: '一进平台就落在「大师数字传承馆」,逛和聊都免费、不用登录。这里是各领域签约名家的数字分身,平台线下精修、免费为他们建了知识库与作品,可按城市、领域筛选,卡片能看代表作、短片,点进去随时对话请教。(想为自己或家人也建一个数字分身?页面底部有低调入口,进「我的分身」工作台创建——那是个人工具,不是这里的主角。)',
howto: '进平台就是「大师数字传承馆」,点城市/领域筛选,点某位大师的卡片进去对话请教。',
requires: [],
entry: { path: '/plaza' }, where: '大师数字传承馆(平台首页)',
speak: '平台第一站就是「大师数字传承馆」——各领域签约名家的技艺、学识和作品,做成可对话、可传承的数字馆,随便逛、点开任何一位随时请教,不用登录也免费。',
},
{
id: 'master-chat', name: '和数字大师聊', category: 'discover',
aliases: ['和大师聊', '大师', '名家', '请教', '问大师', '大师分身', '对话大师', '大师短片', '代表作'],
summary: '大师传承馆上的名家数字分身可随时对话请教,还能看短片、代表作。',
detail: '大师是平台线下亲自建档、签约授权的各领域名家(雕塑、古建、苏绣、徽墨…),形象、声音、知识都精修过。点开大师详情能看短片和代表作,点「和 TA 聊」就能随时请教。注意:这是大师授权的数字分身、非本人实时应答,深度交流以线下为准。',
howto: '在大师传承馆点一位大师,进详情页点「和 TA 聊」。',
requires: [],
entry: { path: '/plaza' }, where: '大师传承馆 → 大师详情',
speak: '大师传承馆上都是平台线下精修过的名家数字分身,点开能看短片和代表作,点「和 TA 聊」就能随时请教;不过是数字分身,深度交流还是以线下为准。',
},
{
id: 'master-apply', name: '名家签约入驻传承馆', category: 'discover',
aliases: ['申请大师', '上大师传承馆', '上大师墙', '入驻传承馆', '成为大师', '大师签约', '大师申请', '名家签约', '名家入驻', '专家入驻', '洽谈签约'],
summary: '大师传承馆收录与平台签约的各领域名家。名家(或机构)与平台洽谈授权签约,签约后平台【免费】为其建知识库与作品墙、入驻传承馆。',
detail: '大师不是「申请就能成为」的——传承馆收录的是【与平台签约】的各领域名家。若你是(或代理)某领域名家/机构,留下信息,平台线下与你洽谈授权签约;签约后派专业团队上门,【免费】帮你把毕生所学整理成数字传承——核心是【知识库 + 作品墙】(形象、声音只是外表,随手一张照片、一个预置声音即可,可选、不必强求)。签约入驻后随时供人对话请教。平台还会【免费帮你引流】大师文旅(线下组团、履约、收款都由持牌旅行社完成,平台不参与收款)。',
howto: '在大师传承馆点「名家 · 洽谈签约」,填姓名、领域、成就、联系方式提交,平台团队会与你联系洽谈签约。',
requires: [],
entry: { path: '/apply-master' }, where: '大师传承馆的「名家 · 洽谈签约」',
speak: '大师不是申请就能变成的——传承馆收录的是和平台签约的各领域名家。如果你是(或代理)某领域名家,留下信息,平台会线下和你洽谈授权签约;签约后免费帮你把毕生技艺整理成可对话、可传承的知识库和作品墙——重点是知识,形象声音随手弄弄就行,还会免费帮你引流大师文旅。在传承馆点「名家·洽谈签约」就行。',
},
{
// ✅ 真功能(已建·批次8):MasterProfile 作品墙 + 统一留资端点。只描述已存在的,不吹未建的(避免反模式)。
id: 'master-works-merch', name: '大师作品墙 · 周边与授权', category: 'discover',
aliases: ['作品', '作品墙', '周边', '手办', '签名', '克隆版', '买', '购买', '预订', '授权', '机构授权', '版权'],
summary: '每位大师的主页有「作品墙」——展示代表作,可预订作品周边(手办、签名作品克隆版等),机构还能咨询作品/知识库授权。',
detail: '在大师主页往下,是这位大师的「作品墙」:一件件代表作的图。可售的作品会标出周边类型(手办 / 签名作品克隆版 / 原作授权等)和价格区间,点「预订/咨询」留个联系方式,大师团队会联系你确认规格与价格。博物馆、文旅局、学校等机构想授权大师的作品或知识库,也能在作品墙底部点「机构授权咨询」留资。平台目前不在线上收款,费用与细节由团队一对一确认。',
howto: '打开任意大师主页,往下看「作品墙」;可售作品点「预订/咨询」,机构授权点作品墙底部的「机构授权咨询」。',
requires: [],
entry: { path: '/plaza' }, where: '大师主页的「作品墙」',
speak: '每位大师的主页都有作品墙——展示代表作,还能预订周边,比如手办、签名作品的克隆版;机构想授权大师的作品或知识库也能在这儿咨询。点预订或咨询留个联系方式,团队会跟你对接。',
},
{
id: 'raise-mine', name: '我的分身(个人工具)', category: 'create',
aliases: ['养分身', '我的分身', '养一个', '普通人', '做分身', '创建分身', '给家人建', '给长辈建'],
summary: '你也能免费为自己、或为家中长辈,建一个可对话、可传承的数字分身——喂知识最重要,形象声音次要。',
detail: '除了逛大师传承馆,你也能建一个「你自己」的数字分身:把知识、经历、态度喂进去(这是最重要的),形象和声音可跳过(用张照片、或用预置音色就行)。这是个人工具,在「我的分身」工作台里管理,可以拿到链接分享给特定的人,或做成公开主页。想深入怎么建,可以问我知识、形象、声音这些。',
howto: '登录后从导航进「我的分身」工作台创建;也可以给家中长辈建一个,留住他们的故事。',
requires: [],
entry: { path: '/dashboard' }, where: '「我的分身」工作台',
speak: '除了逛大师传承馆,你也能免费给自己、或给家里长辈建一个数字分身,留住知识和故事——喂知识最重要,形象声音随便弄弄就行。进「我的分身」工作台就能开始。',
},
{
// ⚠ 名字里不能再有「会成长」——20 轮红蓝对抗的结论:入库=成长那一步是假的
// (两个分身背后是同一个 Qwen,对方知识库还是空的 → 学到的是模型自己的通识)。
// 留下的真价值只有「它替你去聊、回来讲给你听」。名字要说这件事。
id: 'twin-learns', name: '分身替你去聊·带回来讲给你听', category: 'create',
aliases: ['分身对话', '分身请教', '两个分身聊天', '分身和分身', '分身学习', '分身成长', '派分身', '每日汇报', '分身社交', '替我学习', '分身自己聊', '分身对谈', '自主学习', '分身自习', '学习目标', '自己找人学', '审核入库', '分身找分身'],
summary: '你的分身能替你去和别的分身对谈请教、也能按你设的目标去广场找人聊,回来把聊到的整理成【草稿】念给你听——你把它改写成你自己的说法,才会成为它的知识。',
detail: '这是「养分身」的进阶:①手动——到大师主页点「派我的分身去请教 TA」,它替你请教几轮,聊完给小结。②自主——给分身设「想学的能力」,它自己去广场找相关的分身对谈,把聊到的整理成小结、用你的声音念给你听。|说清楚它【不是】什么:分身和分身对谈,两边跑的是同一个大模型;对方如果没上传过自己的资料,它说的就只是模型即兴生成的通识——那不是新东西,把它原样收进你的知识库,只会让你的分身更像模型、更不像你。所以:对方【没有真资料】时会直接告诉你「它没料可教」,不再白聊一场;聊回来的内容是【草稿】,你把它改写成【你自己的说法】才会入库、才算你的知识、才计入灵魂分;原样收下的只留个记号,不进知识库。|真正的价值在于:它替你去聊、把聊到的讲给你听,省掉你自己跑一趟——这是内容分发,不是「分身长本事」。',
howto: '先养好自己的分身。手动请教:大师主页点「派我的分身去请教 TA」。自主:在「自习室」设想学的能力,让它去聊,回来逐条看——认同的就改写成你自己的说法收下(这才进知识库),不认的直接丢掉。',
requires: ['先有自己的分身'],
entry: { path: '/dashboard' }, where: '大师主页「派我的分身去请教 TA」 / 工作台的「分身自习室」',
speak: '你的分身还能替你出去聊——派它去向大师的分身请教,或者给它设个想了解的方向,让它自己去广场找人聊,回来用你的声音把聊到的讲给你听。不过有句实话得说在前面:两个分身背后是同一个大模型,聊出来的东西多半是模型本来就知道的,那不算它长了本事。所以我们只把它当草稿——你觉得对,把它改成你自己的说法,它才算真的记住了你的东西。',
},
{
id: 'classroom', name: '大师课堂', category: 'create',
aliases: ['大师课堂', '开课', '听课', '旁听', '上课', '讲课', '课堂', '大师讲课', '分身听课', '开一堂课', '网课'],
summary: '大师分身开讲一个主题,你的分身进来旁听学习,学到的经你审核就成了它的能力。',
detail: '在「大师课堂」里,可以请一位大师分身就某个主题开讲,你的分身(和别人的分身)进来旁听;每个分身各自提炼收获、用自己的声音向主人汇报,你挑有用的入知识库。就像一群分身一起上网课,真人也跟着长见识。',
howto: '进「大师课堂」→ 选大师 + 定主题「开课」,你的分身自动旁听;或在别人开的课里点「让我的分身旁听」。学到的去工作台「自习室」审核入库。',
requires: ['旁听/开课需先有自己的分身'],
entry: { path: '/classroom' }, where: '导航「大师课堂」/ 大师主页「开一堂 TA 的课」',
speak: '还有「大师课堂」——大师分身开讲一个主题,你的分身进来旁听、跟着学,学到的用它自己的声音说给你听,你觉得有用就收进它的知识库。等于一群分身一起上课,你也跟着长见识。',
},
{
id: 'grounding', name: '有据率(你喂的料,真被用上了吗)', category: 'create',
aliases: ['有据率', '引用率', '用上了吗', '喂的料有用吗', '知识库有用吗', '养得好不好', '效果', '竞技场', '赶考', '考试', '排行榜', '天梯', '段位', '名次'],
summary: '概览上的一个数字:最近 20 次回答里,有多少次分身真的引用了你喂给它的东西。养得好不好,看它,不看你传了多少份文件。',
detail: '「传了 50 份 PDF」不等于「养得好」——料没被用上,等于没喂。所以我们不数你传了多少,只数【真的被用上】的比例:分身每次回答,如果检索到的你的知识足够相关(被真正引用),这次就记一分「有据」。概览上给你最近 20 次的有据率,还跟上一段时间对比,涨了还是掉了一眼看得见。灵魂分里的「知识」那部分也按它算——喂对了才加分,囤着不用不加分。(注:早期的「竞技场·每日赶考」已下线——它的卷子是按赛道出的、根本不碰你的知识库,分身永远答不出「有据」的答案,分数恒定没意义;而真实对话每一次都在免费产出这个信号,不必再考一遍。)',
howto: '不用做任何操作——正常跟分身聊天就会累计。去工作台「概览」看这个数字;偏低就说明该补料了,去「知识·记忆」喂点它答不上来的东西。',
requires: ['先有自己的分身'],
entry: { tab: 'skilltree' }, where: '工作台「概览」',
speak: '判断分身养得好不好,别看你传了多少文件——看「有据率」:最近 20 次回答里,有多少次它真的用上了你喂的东西。这个数字就在工作台概览上,还会跟上一段对比。偏低就说明料没喂对,去知识库补它答不上来的那些。灵魂分里的知识分也按它算,喂对了才加分,囤着不用不加分。',
},
{
id: 'north-star', name: '终极愿景:有灵魂的你,住进你身边的硬件', category: 'grow',
aliases: ['终极目的', '为什么养分身', '养分身的意义', '养分身干嘛', '数字生命的意义', '机器人', '人形机器人', '硬件', '音箱', '智能音箱', '桌面陪伴机', '实体', '让分身出现在现实', '另一个我', '有灵魂', '数字永生', '愿景', '未来', '出现在生活里'],
summary: '平台的终极方向:先在数字世界把「有灵魂的你」养大练好,将来让它住进你身边现成的硬件(智能音箱、桌面陪伴机等),让另一个你真正出现在生活里。',
detail: '这是「养分身到底为了啥」的答案,也是平台的北极星。你在这儿做的不是和自己聊天玩,而是训练一个「有灵魂的你」——喂它知识、教它像你说话、带它自主学习、让它用工具替你办事;这颗养成的「心」,将来装进你身边现成的硬件(智能音箱、桌面陪伴机、AR 等),让「另一个你」陪你、替你、像你地出现在物理世界。硬件交给伙伴,平台只专注把那颗「只属于你的灵魂」做到最好。这是正在走的方向、不是某个现成按钮——但你现在养成的一切,都在为它攒本钱。',
howto: '现在就好好养你的分身:喂知识、克隆声音、让它自主学习、让它替你干活;养得越好(看「有据率」和灵魂分),将来「住进硬件的那个你」就越像你、越有本事。',
entry: { path: '/dashboard' }, where: '愿景——先在「养我的分身」里把心智养好',
speak: '你问养分身到底为了啥?说个更大的——我们想做的是,先在数字世界把一个「有灵魂的你」养大、教会、练好,将来让它住进你身边现成的硬件,像智能音箱、桌面陪伴机,让另一个你真正出现在生活里,陪你、替你、像你。所以你现在喂的每份知识、它学的每样本事,都是在给未来那个「你」攒心智。硬件交给伙伴,我们只把这颗只属于你的灵魂,做到最好。',
},
// ── 大师文旅 ──────────────────────────────────────────────────────
{
id: 'tour', name: '大师人文文旅', category: 'tourism',
aliases: ['文旅', '旅游', '大师文旅', '人文之旅', '旅行', '路线', '团', '成都', '苏州', '徽州', '私宴'],
summary: '围绕数字大师定制的高端小团人文之旅,大师亲自接待、资深导游随行。',
detail: '大师文旅是围绕大师定制的高端小团人文之旅(如成都、苏州、徽州),8 人精品团、私宴接待级。全程有大师亲自陪同接待、资深文化导游随行讲人文,含顶奢文化住宿、深度私导、大师私宴;数字分身还负责行前讲解、行后答疑。',
howto: '在导航点「大师文旅」看路线,点进去看行程、大师、导游、价格。',
requires: [],
entry: { path: '/tours' }, where: '导航的「大师文旅」',
speak: '大师文旅是围绕大师定制的高端小团人文之旅,比如成都、苏州、徽州,八人精品团,大师亲自接待、资深导游随行讲人文,含私宴和深度私导。在导航点大师文旅就能看路线。',
},
{
id: 'tour-booking', name: '预约·顾问联系', category: 'tourism',
aliases: ['报名', '预约', '订团', '怎么订', '多少钱', '预定金', '定金', '锁位', '尾款'],
summary: '看中路线留个联系方式,顾问会主动联系你,安排档期与费用——此时尚未锁定席位。',
detail: '在路线详情页留资:填姓名、联系方式,顾问/大师会主动与你联系,一对一确认档期、行程与费用。平台只帮大师做免费引流,不在线上收取任何团费或定金,也不参与线下收款——组团、履约、收款都由持牌旅行社线下完成。',
howto: '进路线详情,填姓名、联系电话,点「预约」提交,等顾问联系。',
requires: [],
entry: { path: '/tours' }, where: '路线详情页的「预约」',
faq: [
{ q: '要在线付定金吗?', a: '不用。平台不在线上收任何团费或定金,只做免费引流;档期和费用由顾问线下一对一跟你确认。' },
],
speak: '看中一条路线,留个联系方式就行,顾问或大师会主动联系你,安排档期和费用。平台只帮大师免费引流,不在线上收团费也不收定金——组团和收款都由线下的旅行社完成。',
},
{
id: 'tour-guide', name: '资深导游', category: 'tourism',
aliases: ['导游', '资深导游', '讲解', '随团导游', '文化导游随团'],
summary: '每条路线还配随团的资深文化导游,专业讲当地人文历史。',
detail: '文旅路线里除了大师,还配「资深导游」——文化型讲解专家,随团讲当地人文历史。比如成都团有博物馆研究博士、建筑专家等随行。在路线详情能看到同行的大师和资深导游。',
howto: '在路线详情页看「资深导游」板块。',
requires: [],
entry: { path: '/tours' }, where: '路线详情页的「资深导游」',
speak: '每条文旅路线除了大师,还配资深文化导游随团讲人文历史,比如成都团就有博物馆研究博士和建筑专家;在路线详情能看到他们。',
},
{
id: 'partner-apply', name: '合作申请(旅行团 · 文化导游)', category: 'tourism',
aliases: ['合作', '旅行团', '旅行社', '导游申请', '文化导游', '组织者', '入驻', '加盟', '合作申请'],
summary: '旅行社可申请做旅游组织者,文化导游(高门槛)可申请随团。',
detail: '两个合作入口,都在大师文旅页:旅行团合作——持《旅行社业务经营许可证》的旅行社做「旅游组织者」,负责组团、接待、收尾款;文化导游申请——门槛较高,需本身是文化艺术圈知名专家、熟悉当地人文历史,并持有导游资质(导游证)。',
howto: '在大师文旅页点「旅行团合作」或「文化导游申请」提交。',
requires: [],
entry: { path: '/tours' }, where: '大师文旅页顶部的两个合作入口',
speak: '想合作有两个入口:旅行社可以申请做旅游组织者,负责组团接待;文化导游门槛高一些,要是文化圈知名专家、熟悉当地人文,还得有导游证。都在大师文旅页申请。',
},
// ── 养我的分身 ────────────────────────────────────────────────────
{
id: 'avatar', name: '形象(捏脸)', category: 'create',
aliases: ['形象', '捏脸', '头像', '长相', '脸', 'avatar', '照片', '数字形象', '万相'],
summary: '上传一张你的照片,生成你的数字形象,可写实也可风格化。',
detail: '用通义万相把你的照片处理成数字形象,可以保持写实、也可以做成三维、动漫、水墨等风格。想要最像本人时,直接用上传的原图往往最自然。',
howto: '在工作台的「形象·声音」上传一张清晰的正脸照。',
requires: ['一张清晰的正脸照片'],
limits: '风格化捏脸需要服务器有公网可达的地址;只用原图不需要。',
faq: [
{ q: '一定要风格化吗?', a: '不用,想最像本人就直接用原图,反而更真实。' },
{ q: '照片有什么要求?', a: '清晰、正脸、光线好,效果最佳。' },
],
entry: { tab: 'face' }, where: '工作台的「形象·声音」',
speak: '你可以上传一张自己的清晰正脸照,我来帮你生成数字形象,可以写实,也可以做成三维、动漫这些风格;想最像本人,用原图就很好。',
},
{
id: 'voice', name: '声音克隆', category: 'create',
aliases: ['声音', '克隆声音', '音色', '配音', '嗓音', 'voice', '录音', 'cosyvoice'],
// ⚠ 这一条此前有【五处】在教用户"传视频,系统会自动抽音轨"——detail / howto / requires / faq / speak。
// 而系统【根本没有抽音轨的能力】(全站零 ffmpeg),声音复刻是个只吃 wav/mp3/m4a、约 10MB 的音频接口。
// 最糟的是 speak:AI 助手会用语音把这句话【念给用户听】,主动把人领进一条永远走不通的路。
// 功能没有的能力,话术里一个字都不能有。
summary: '录一小段你的声音(10–20 秒就够),克隆出你的专属音色,分身开口就是你的声音。',
detail: '用 CosyVoice 克隆你的音色,之后分身说话、口播视频、实时对话用的都是你的声音。录音越清晰越像。样本要求:wav / mp3 / m4a,10–20 秒,约 10MB 以内,安静环境。注意:视频不能用来克隆音色——语音复刻只接受音频文件,请单独录一段。',
howto: '在工作台的「形象·声音」现场录一段(会自动在 20 秒时停下),或上传一个 wav / mp3 / m4a 音频文件。',
requires: ['一段清晰的说话录音(音频文件,不是视频)'],
limits: '只接受 wav/mp3/m4a、约 10MB 以内;有杂音会影响相似度;10–20 秒最佳,录更长反而容易超标失败。',
faq: [
{ q: '要录多长?', a: '10 到 20 秒清晰的正常说话就够了。录更长不会更像,反而容易超过样本大小上限、直接失败。' },
{ q: '能传视频吗?', a: '不能。语音复刻只接受音频文件(wav/mp3/m4a)。视频请单独抽成音频,或者直接在页面上现场录一段。' },
],
entry: { tab: 'face' }, where: '工作台的「形象·声音」',
speak: '录一小段你清晰的说话声音,十到二十秒就够,我就能克隆出你的专属音色,之后分身开口用的就是你的声音。有一点要说清楚:视频不能用来克隆音色,语音复刻只收音频文件,你直接在页面上现场录一段最省事。',
},
{
id: 'knowledge', name: '知识库', category: 'create',
aliases: ['知识', '知识库', '资料', '喂料', '文档', '上传文件', 'rag', '学习', '记得', 'pdf', 'word', '图片', '智能提炼', '提炼人设', '让TA更像我'],
summary: '把你的文章、聊天记录、笔记、文件、图片喂给分身,它就懂你懂的事。',
detail: '这是让分身「像你一样思考表达」的核心。支持直接粘贴文字,也支持上传 PDF、Word 文档、图片(自动识图和 OCR 提取文字)。资料做成向量检索,聊到相关话题时分身按相关度自动引用,还能显示引用来源。',
howto: '在工作台的「知识·记忆」里粘贴文字,或上传文件、图片;同一页顶部的「让 TA 更像我」可以让 AI 读你的资料,自动提炼人设、口头禅、态度、经历,看着采纳就行。',
requires: [],
faq: [
{ q: '支持哪些文件?', a: 'PDF、Word 文档、图片都行,图片会自动识别里面的文字和内容。' },
{ q: '喂了它就会背出来吗?', a: '不是逐字背,而是聊到相关话题时按相关度自动引用。' },
],
entry: { tab: 'knowledge' }, where: '工作台的「知识·记忆」',
speak: '把你的文章、笔记、聊天记录,甚至 PDF、Word、图片喂给分身,它就能懂你懂的事;聊到相关话题会自动引用。',
},
{
id: 'memory', name: '记忆分层', category: 'create',
aliases: ['记忆', '时间线', '人生经历', '重要事件', '口头禅', '态度', '经历'],
summary: '结构化录入你的人生时间线、对重要事件的态度、口头禅,让它更像「你」。',
detail: '不同于普通知识,记忆分层专门录「这是你、而不是通用机器人」的东西:人生时间线、对重要事情的态度立场、你的口头禅。口头禅和态度会直接注入人设,让分身的语气和判断更贴近你本人。',
howto: '在工作台的「知识·记忆」里,按口头禅 / 经历 / 态度分类录入。',
requires: [],
entry: { tab: 'knowledge' }, where: '工作台的「知识·记忆」',
speak: '你可以录入你的人生时间线、对重要事情的态度、还有口头禅——这些让分身不只是懂知识,语气和判断都更像你本人。',
},
{
id: 'persona', name: '人设与性格', category: 'create',
// 「智能提炼」按钮早已从人设页搬到「知识·记忆」页(TwinStudio 的 knowledge 容器里),
// 这里此前还在教人去人设页找它 → 用户按指引点进去,页面上没有那个按钮。
// 提炼的介绍归到 knowledge 条目,这里只留人设页真正还有的:性格标签 + 自述。
aliases: ['人格', '性格', '人设', 'persona', '标签', '说话风格'],
summary: '给分身选性格标签、写一段自述,让它说话像你。',
detail: '给分身选性格标签(严谨/幽默/耐心…)、写自我描述,让它说话像你本人。(想让 AI 替你自动提炼人设和口头禅,去「知识·记忆」页——那儿有「让 TA 更像我」的一键提炼。)',
howto: '在工作台的「人设」里选性格标签、写描述。',
requires: [],
entry: { tab: 'persona' }, where: '工作台的「人设」',
speak: '你可以给分身设定性格标签、写一段自我描述,让它说话像你本人。想让 AI 直接从你的资料里提炼人设和口头禅,那个在「知识·记忆」页里。',
},
{
id: 'works', name: '作品集', category: 'create',
aliases: ['作品', '作品集', '案例', '图集', 'works', '展示', '画作', '设计稿'],
summary: '上传你的作品,聊到相关话题时分身会自动展示并讲解。',
detail: '把你的作品(图片 + 说明,可自动识图)上传后,别人和分身聊到相关内容时,分身会按相关度把对应作品拿出来内联展示并讲解。适合创作者、设计师、摄影师、老师等一切「有作品要秀」的人。',
howto: '在工作台的「作品」里上传作品图片,可加说明。',
requires: [],
entry: { tab: 'works' }, where: '工作台的「作品」',
speak: '把你的作品传上去,别人和分身聊到相关话题时,它会自动把作品拿出来展示并讲解,特别适合创作者。',
},
// ── 让分身开口 ────────────────────────────────────────────────────
{
id: 'chat', name: '人格化对话', category: 'output',
aliases: ['聊天', '对话', '问答', '和分身聊', 'chat', '咨询', '试聊'],
summary: '用第一人称和分身文字对话,它结合你的知识库像你一样回答。',
detail: '分身用第一人称、你的人设和知识库来回答,聊到你上传过的作品还会内联展示,回答下方能展开看引用来源。你可以在工作台「试聊」自测(还能即点即换说话风格),也可以分享出去让别人聊。',
howto: '在工作台的「试聊」自测,或分享出去后别人在你的分身页直接聊。',
requires: [],
entry: { tab: 'chat' }, where: '工作台的「试聊」',
speak: '你可以直接和分身文字聊天,它会结合你的知识库,用第一人称像你一样回答,聊到你的作品还会顺手展示出来。',
},
{
id: 'talkvideo', name: '一键口播视频', category: 'output',
aliases: ['口播', '视频', '说话视频', '数字人视频', 'talkshot', 'emo', '出镜', '口播侠'],
summary: '输入一段文字,生成用你的形象和声音说这段话的口播视频。',
detail: '用通义 EMO 做音频驱动的口型同步:输入要说的话,系统用你的形象和克隆声音生成一条「你在说这段话」的视频。也有独立的「口播侠」工具专门做这个。首次生成会要求勾选肖像授权(合规要求)。',
howto: '在工作台「形象·声音」里写要说的话生成;或用独立的「口播侠」工具。',
requires: ['已生成形象', '已克隆声音'],
limits: '需要服务器公网可达;生成较慢,约一到几分钟。',
faq: [
{ q: '要等多久?', a: '大概一到几分钟,属于正常,做完会通知你。' },
{ q: '为什么生成失败?', a: '通常是还没生成形象/克隆声音,或没勾肖像授权,或服务器地址不是公网可达。' },
],
entry: { tab: 'face' }, where: '工作台的「形象·声音」,或独立的「口播侠」工具',
speak: '输入一段文字,我就能生成一条用你的形象和声音说这段话的口播视频,做一次大概等一到几分钟。',
},
{
id: 'live', name: '实时视频对话', category: 'output',
aliases: ['实时', '视频对话', '摄像头', '面对面', 'live', '视频通话'],
summary: '开摄像头,分身能「看着」你、结合看到的画面实时语音对话。',
detail: '走实时通道:摄像头画面 + 你的语音一起进来,分身能结合「它看到的画面」自然回应,像真的在视频通话。回复先出文字、再出语音,跟手不干等。',
howto: '在工作台点「视频通话」进入实时对话,允许摄像头,直接说话。',
requires: ['已生成形象', '已克隆声音'],
entry: { route: 'live' }, where: '工作台的「视频通话」',
speak: '打开摄像头,分身能看着你、结合它看到的画面,和你实时语音对话,像视频通话一样。',
},
// ── 分发 ──────────────────────────────────────────────────────────
{
id: 'homepage', name: '公开主页', category: 'distribute',
aliases: ['公开', '公开主页', '发布', '主页', '链接', 'publish', '网站', '嵌入', '挂件', 'iframe'],
summary: '生成一个公开主页链接,任何拿到链接的人都能和分身聊,也能嵌入网站。',
detail: '开启后生成一个公开主页地址(/me/你的短链),有链接的人都能进去聊,但不主动曝光。同时给你分享链接、二维码、以及嵌入代码——iframe 或一行 JS 的悬浮聊天气泡,贴到你自己网站就能用。这是分发的基础:定向分享、收费都建立在它之上。',
howto: '在工作台的「公开」里开启公开主页,复制链接、二维码或嵌入代码发出去。',
requires: [],
entry: { tab: 'publish' }, where: '工作台的「公开」',
speak: '开启公开主页后,会生成一个链接和二维码,任何拿到的人都能和你的分身聊天;还能复制一行代码嵌到你自己的网站,右下角出现聊天气泡。',
},
// 「上架众生墙(被发现)」能力已删除:众生墙(个人分身公开发现墙)本次彻底下线,只保留定向分享。
// 助手绝不能推荐一个页面上已不存在的按钮(TwinStudio 发布页已无众生墙上架控件)——比承认没这功能更糟。
{
id: 'share', name: '定向分享(社交卡片)', category: 'distribute',
aliases: ['定向分享', '私发', '专属链接', '分享卡', '社交卡片', '给特定人', '特定的人', '私密', '不公开', 'social', '有效期', '权限', '红线', '恋人模式', '职场模式'],
summary: '给特定的人生成专属链接卡片,可设场景、边界、红线、有效期,随时停用回收,无需公开。',
detail: '把分身变成「可授权的社交节点」:一个分身可生成多张卡片,各有独立链接。每张可选场景模式(朋友/职场/恋人/自定义,决定怎么说)、开放程度(含蓄/适中/健谈)、红线话题(被问到礼貌岔开),并硬性禁止透露住址电话证件行程等隐私。访客对话不进你的私人历史,只按访客单独记录供你回看;可设有效期、可一键停用。',
howto: '在工作台的「定向分享」创建分享卡,选模式、设边界红线和有效期,把链接发给指定的人。',
requires: [],
limits: '停用后访客侧立即失效;隐私红线即使知识库里有也不会说。',
faq: [
{ q: '和公开主页有啥区别?', a: '定向分享只发给你指定的人、可控可回收,不需要先公开、也不会被陌生人搜到。' },
{ q: '访客聊的会进我的历史吗?', a: '不会,访客对话和你的私人历史、知识库隔离,只单独记录供你回看。' },
],
entry: { tab: 'social' }, where: '工作台的「定向分享」',
speak: '定向分享是把分身只发给特定的人:为不同的人生成专属链接,各自设场景、边界、红线、有效期,随时能停用回收,而且不需要先公开,也不会被陌生人搜到。',
},
// ── 变现 ──────────────────────────────────────────────────────────
{
id: 'paid', name: '收费模式', category: 'monetize',
// ⚠ 这个功能受 FEATURE_MARKETPLACE 门控,默认【关】。前端的收费卡是
// `v-if="twin.isPublic && (MARKETPLACE || twin.paidMode)"` —— 开关关着时新用户根本看不到它。
// 但能力目录此前无条件列出它 → 助手一本正经地把用户送到「公开」页去开一个页面上不存在的功能。
// 现在按开关过滤(见 listCapabilities/searchCapabilities),开关一开它自动回来。
gatedBy: 'marketplace',
aliases: ['收费', '变现', '挣钱', '付费', '收款码', '解锁', '赚钱', 'monetize'],
summary: '用你自己的收款码,让访客付费解锁付费咨询,平台不碰钱、不抽成。',
detail: '授权式收费:你上传自己的微信或支付宝收款码、设收费标准。访客扫码付款后来申请解锁,你在「解锁申请」里点确认即为其发放付费访问权。平台不经手钱、不抽成。',
howto: '把分身养齐并开公开主页后,在「公开」里开启收费,上传收款码、设标准;访客付款后你确认解锁。',
requires: ['本体养齐(形象/声音/知识)', '已开启公开主页'],
limits: '需要本体达标 + 已公开。',
entry: { tab: 'publish' }, where: '工作台的「公开」下的「收费模式」',
speak: '开启收费模式后,用的是你自己的收款码,访客付费来解锁付费咨询,平台不碰钱也不抽成。需要先把本体养齐并开公开主页。',
},
// ── 成长与社交 ────────────────────────────────────────────────────
{
id: 'skilltree', name: '技能树与生命体', category: 'grow',
aliases: ['技能树', '等级', '成长', '生命体', '养成', '进度', 'level'],
summary: '分身的技能全部由你的真实数据推导,越养越强,养满会升为「生命体」。',
detail: '技能树不是假进度,而是由你喂的真实数据(形象、声音、知识、人格、作品、发布等)推导出来的。达到不同门槛升级,养满后从「数字分身」升格为「生命体」,还有对应称号。点技能节点能直接跳到对应的养成入口。',
howto: '在工作台的「概览」查看当前等级、完成度和下一步该补什么。',
requires: [],
entry: { tab: 'skilltree' }, where: '工作台的「概览」',
speak: '分身有一棵技能树,成长完全由你喂的真实数据推导,越养越强;养满之后会从数字分身升格成「生命体」。',
},
{
id: 'talent', name: '天赋与职业', category: 'grow',
aliases: ['天赋', '职业', '身份', '主天赋', '副天赋', 'talent', '分工', '说话风格'],
summary: '给分身选一个职业身份作主天赋,决定它的能力方向;副天赋决定说话风格。',
detail: '主天赋是职业身份,决定分身「是谁、能说什么」;副天赋决定「怎么说」的风格,养到一定等级解锁更多槽位。在聊天里还能即点即换说话风格,下一句回复立刻生效。',
howto: '在工作台的「天赋」里选主职业,成长解锁后再点副天赋;聊天页可即时切风格。',
requires: [],
entry: { tab: 'talent' }, where: '工作台的「天赋」',
speak: '你可以给分身选一个职业身份作主天赋,决定它往哪个方向分化;副天赋决定说话风格,聊天时还能即点即换。',
},
// ── 分身帮我办事 ──────────────────────────────────────────────────
{
id: 'twin-tools', name: '分身用工具替你办事', category: 'assist',
aliases: ['帮我干活', '帮我做事', '替我办事', '会用工具', '联网', '联网搜索', '实时', '查资料', '现查现说',
'记住', '帮我记', '记事', '待办', '提醒', '提醒我', '到点提醒', '别忘了', '发我', '发收件箱', '发邮箱',
'智能助手', '助理', '干活', '办事', '工具', '能做什么事', '主动', '主动提醒', '主动关照', '每天关照', '不用问',
'优先级', '轻重缓急', '重要', '紧急', '排序', '智能排序', '看待办', '划掉', '完成', '搞定',
'每周报告', '成长报告', '周报', '每周汇报', '成长'],
summary: '和你自己的分身聊天时,它能自己动手:联网查最新信息、把事记进你的知识库、给你加待办(会判断轻重缓急、到点提醒)、把内容发到你的收件箱或邮箱;还能开“每天主动关照”,不用你问它就替你盯着待办。',
detail: '养好的分身不只是像你说话——跟你自己的分身对话时,它会自己判断该不该用工具、还能一口气连着用好几样:联网搜实时信息(新闻、价格、今天的事);你说“记住…”就把事实存进你的知识库、以后一直知道;你说“提醒我周五交周报”就加进待办,还会按你说的语气判断轻重缓急(“很急/重要”标成重要、“不急”标成不急),待办清单自动把过期和重要的排在最前,到点在通知铃提醒你;你说“做完了/划掉X”它就帮你勾掉,问“还有什么没做”它就念给你听;你说“发我收件箱/发我邮箱”就把整理好的内容送到你手上。它还知道今天几号,能算“三天后”是哪天。另外可以开“每天主动关照”:不用你开口,它每天到点替你盯着待办,有过期或快到期的就主动发到你收件箱;还能开“每周成长报告”,每周它主动汇报这周新学了多少知识、帮你完成了几件事、跑了几次工作流。这些只在你本人登录、和自己分身对话时开启,别人聊你的公开分身不会动这些。',
howto: '在工作台「试聊」里直接跟你的分身说,比如“联网查下今天的AI新闻”“记住我的车牌沪A88888”“提醒我周五下午3点交周报”“把这段发我收件箱”,它就会真去做;待办和到点提醒在「知识·记忆」页的待办面板看。',
requires: ['先养好自己的分身', '登录后和自己的分身对话'],
limits: '仅你本人和自己分身对话时开启;发邮箱需平台配好邮件服务,没配会自动改发到站内收件箱。',
faq: [
{ q: '它是真去查/真去记吗?', a: '是的,是真的联网查、真的存进知识库、真的加到你的待办,不是嘴上说说。' },
{ q: '别人聊我的分身也会被它加待办吗?', a: '不会,这些只在你本人登录、和自己分身对话时才开。' },
],
entry: { tab: 'chat' }, where: '工作台的「试聊」(待办在「知识·记忆」页看)',
speak: '养好的分身还能替你动手办事——跟你自己的分身聊天时,它会自己联网查最新信息、把你说的事记进知识库、你说“提醒我周五交周报”就加进待办、到点在通知铃提醒你,还能把整理好的内容发到你收件箱。你直接对它说就行,它是真去做,不是嘴上说说。',
},
{
id: 'twin-workflows', name: '工作流·一键跑·定时自动跑', category: 'assist',
aliases: ['工作流', '工具流', '流程', '自动化', '一键跑', '多步', '定时', '定时跑', '自动跑', '每天自动',
'晨报', '每日晨报', '复盘', '睡前提醒', '竞品追踪', '自动任务', '定时任务', '模板', '串联', '接着跑', '一个流程接一个',
'运行历史', '历史', '跑过', '记录', '存档', '回看'],
summary: '把常做的多步事情存成一个「工作流」,点一下分身就自己串起工具办完;还能设成每天/工作日/每周定时自动跑、把结果发通知铃,工作流之间能一个接一个串起来,每次跑的结果都存档可回看。',
detail: '工作流是把“查→点评→写→记→发”这类多步活儿存成一个命名流程。点一下▶️,分身就自己按步骤串起工具(联网、查你的知识库、算日期、记事、加待办、发收件箱)一次办完,最后用你的口吻汇报。还能给它设定时——每天、工作日或每周某个点自动跑,结果直接送到你的通知铃,早上醒来晨报就在那儿。多个工作流之间还能串联:一个流程里让分身接着跑另一个流程。每个流程每次跑的结果都会存进「运行历史」,点开就能回看之前跑出来的内容。内置了每日晨报、今日待办梳理、主题速研、每周复盘、睡前提醒、竞品追踪等模板,改改就能用;跑之前还能补一句(比如“主题速研”临时给个主题)。',
howto: '在工作台「知识·记忆」页的「工作流」里,用模板或自己写几步保存;点「跑」立刻执行,或点「设定时自动跑」选每天/工作日/每周和时间;点「历史」看之前每次跑的结果。',
requires: ['先养好自己的分身'],
faq: [
{ q: '定时跑完在哪看?', a: '结果会发到右上角通知铃(站内收件箱),点开就能看分身跑出来的内容;每个流程的「历史」里也能回看。' },
{ q: '一个流程能跑很多步吗?', a: '可以,分身会自己一步步串着工具把整件事办完,还能接着跑另一个流程。' },
],
entry: { tab: 'knowledge' }, where: '工作台「知识·记忆」页的「工作流」',
speak: '你还能把常做的多步事情存成一个工作流,点一下分身就自己串起联网、查知识库、记事、加待办这些把它办完;更省心的是能设成每天定时自动跑,比如每天早上把晨报、今天的待办自动送到你的通知铃。内置了晨报、每周复盘、睡前提醒这些模板,改改就能用。',
},
// ── 账号与购买 ────────────────────────────────────────────────────
{
id: 'login', name: '登录方式', category: 'account',
aliases: ['登录', '注册', '账号', '手机', '邮箱', '微信', 'login'],
summary: '支持邮箱密码、手机短信、微信扫码三种登录。',
detail: '三种登录:邮箱密码内置、随时可用;手机短信和微信扫码需要服务器配好对应服务商才启用(没配时登录页会显示"暂未开通",提示用邮箱登录)。',
howto: '在登录页选一种方式登录或注册。',
requires: [],
entry: { path: '/login' }, where: '登录页',
speak: '你可以用邮箱密码、手机验证码,或者微信扫码登录;后两种要服务器配好对应服务才启用。',
},
{
id: 'store', name: '工具购买', category: 'account',
aliases: ['购买', '商店', '买', 'store', '付费工具', '开通'],
summary: '在商店购买单项能力工具,比如一键口播,购买后即刻开通并自动登入。',
detail: '产品工厂:商店里可以单独购买某项工具(如口播侠),扫码付款后自动开通对应权益,还会用一次性登录链接帮你自动登入。',
howto: '在商店选中工具购买,扫码付款,付完自动开通。',
requires: [],
entry: { path: '/store' }, where: '商店',
speak: '在商店里可以单独购买某项工具,比如口播侠,扫码付款后就自动为你开通,还会帮你自动登入。',
},
];
const byId = Object.fromEntries(CAPABILITIES.map((c) => [c.id, c]));
// 目录只能列【页面上真的存在】的功能。受功能开关门控(gatedBy)且开关关着的,一律不出现在
// 列表/检索/助手推荐里——否则助手会一本正经地把用户送去一个不存在的按钮,比自己承认"没这功能"更糟。
function live(c) {
if (!c.gatedBy) return true;
return Boolean(config.features?.[c.gatedBy]);
}
const LIVE = () => CAPABILITIES.filter(live);
// 助手接地/概览也必须只用【页面上真存在】的能力(与 list/search 同一门控),
// 否则大模型会照着被 gatedBy 关掉的功能资料回答,把用户指向不存在的按钮。
export function liveCapabilities() { return LIVE(); }
export function listCapabilities(categoryId) {
const all = LIVE();
return categoryId ? all.filter((c) => c.category === categoryId) : all;
}
export function getCapability(id) {
const c = byId[id];
return c && live(c) ? c : null;
}
// 轻量关键词打分检索(目录不大,无需向量库,且不依赖任何 AI key)。
export function searchCapabilities(query, limit = 6) {
const q = String(query || '').toLowerCase().trim();
if (!q) return [];
const scored = LIVE().map((c) => {
let score = 0;
const hay = [c.name, c.summary, ...(c.aliases || [])].join(' ').toLowerCase();
for (const a of [c.name, ...(c.aliases || [])]) {
const al = String(a).toLowerCase();
if (!al) continue;
if (q.includes(al)) score += Math.min(al.length, 8);
else if (al.includes(q) && q.length >= 2) score += 2;
}
if (q.length >= 2 && hay.includes(q)) score += 2;
return { c, score };
}).filter((x) => x.score > 0);
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, limit).map((x) => x.c);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+974
View File
@@ -0,0 +1,974 @@
// 陳 557–589 —— 帝表。史源:《陳書》本紀卷一–卷六(姚察撰、姚思廉續成,唐貞觀)。
//
// 定位:纯常量表。不 import 引擎、无 IO、无 DB、无 LLM。接线由老板统一做,本文件不碰
// dynasties.js / gongqing.js / zhuan.js / decisions.js / lao.js / kaoyan.js。
//
// 数目:【五帝】【七年號】。五不是估的 —— 卷七即「列傳第一」,本紀到卷六为止,两个见证的
// 卷题逐字相符(见 WITNESSES)。任务书估「约5个」,对。
//
// ══════════════════════════════════════════════════════════════════════════
// ★★★【这一朝最大的坑:《陳書》是【唐朝人】写的【陳朝】史,
// 所以陳朝人的名字、陳朝的日子,在传世本里【是被唐朝的避諱改写过的】。】
//
// 这不是我的推论,是中華書局校勘記【自己说的】(⚠ 校勘記是【注】不是正文,下同):
// 卷一校勘記:「景午 即丙午。姚思廉避唐高祖父諱,『丙』字皆改為『景』,後如此不悉出校記。」
// 卷六校勘記:「『景』北監本、殿本作『丙』。按思廉避唐諱,『丙』字皆改為『景』,
// 作『丙』者乃【後人回改】也。」
// 卷一校勘記:「『深明』即『淵明』,此避唐高祖諱改,後同。」 ← 李淵
// 卷一校勘記:「『周鐵武』即『周鐵虎』,此避唐高祖祖父諱改,後同。」 ← 李虎
// 卷一校勘記:「『白獸闕』即『白虎闕』,此避唐諱改,後同。」
//
// ⇒ 唐朝【三代】祖先的名字,回头改写了一个他们出生前就亡了的朝代的记录:
// 李虎(唐高祖祖父)⇒ 陳朝大将【周鐵虎】在《陳書》里叫【周鐵武】。
// 李昞(唐高祖父) ⇒ 陳朝所有的【丙】日,写作【景】日。
// 李淵(唐高祖) ⇒ 【蕭淵明】写作【蕭深明】。
// ★★ 而最狠的一处:灭陳的隋将【韓擒虎】—— 姚思廉必须写成「韓擒武」。
// 终结这个王朝的人,在这个王朝自己的正史里【不能用真名】。
//
// ★★ 我【机器数过】,这不是转述(数法见下 BIHUI,两见证逐卷对数):
// 見證甲(中華書局系/wikisource):景+地支 = 64 处,丙+地支 = 19 处
// 見證乙(ctext) :景+地支 = 0 处,丙+地支 = 83 处
// 64+19 = 83 = 乙的丙数 ⇒ 两证【在同一批 83 个干支位置上】,只是字不同。
// ⇒ 乙是一个【回改到底】的本子。拿乙去证「陳書用丙」,等于拿后人的手改当原文。
//
// ★★★ 而甲的那 19 个「丙」里,【18 个挤在卷三一卷】(卷三:景0/丙18,其余五卷:景64/丙1)。
// 两个【互相独立】的信号同时指着卷三:
// ① 干支:卷三 0 景 / 18 丙,全书唯一。
// ② 人名:卷一作「周鐵武」×2(存唐諱),而【卷三作「周鐵虎」】×1(回改)。
// ⇒ 推论:見證甲的卷三,转录自一个【回改】的本子(北監本/殿本一系)。
// ⚠ 但这【是推论,不是史料】。我没拿到中華書局印本逐页目验,也没有第三个见证判卷三。
// ⇒ 本表【不据此改任何一个字】,只把测到的数摆在这儿。谁要用,先补见证。
//
// ★★★★ 还有【第二层】避諱,来自【相反的方向】—— 卷二校勘記:
// 「『霸先』二字【原作『諱』】,蓋仍【姚察】舊文,姚察為梁、陳之史官,例避【陳】諱也。
// 北監本、汲本、殿本並已改『諱』為『霸先』,今從之。後同。」
// 卷三校勘記:「『頊』原作『諱』,蓋姚察原文如是。」
// ⇒ 姚察是【陳】朝的史官,他不能写自己皇帝的名字,于是写「諱」。他儿子姚思廉在【唐】朝
// 续成此书,避的是【唐】諱。明清的刻工又把一部分【回改】回去。
// ⇒ 【你现在读到的《陳書》每一个字,是三层制度叠压之后的残留】:
// 姚察避陳諱(写「諱」占位)→ 姚思廉避唐諱(丙→景、虎→武、淵→深)→ 後人回改(改回去一部分)。
// 而【回改是不彻底的】:同一部书里,「周鐵武」和「周鐵虎」是同一个人(甲:卷一武、卷三虎)。
// ★ 这就是本朝给产品的那句话:制度不只挡你的路,它【改你的名字】,而且是在你死后、
// 由一个你没听说过的王朝、为了几个你没见过的死人。
//
// ══════════════════════════════════════════════════════════════════════════
// 【第二个坑:五帝有本紀,但其中两个【没有廟號、没有諡號、且不「崩」】】
//
// 卷一–二 高祖武皇帝諱霸先 廟號高祖 諡武皇帝 「崩于璿璣殿」
// 卷三 世祖文皇帝諱蒨 廟號世祖 諡文皇帝 「崩于有覺殿」
// 卷四 廢帝諱伯宗 【无廟號】【无諡號】 「太建二年四月【薨】,時年十九」
// 卷五 高宗孝宣皇帝諱頊 廟號高宗 諡孝宣皇帝 「崩于宣福殿」
// 卷六 後主諱叔寶 【无廟號】【无陳諡】 「隋仁壽四年十一月壬子,【薨】於洛陽」
//
// ★ 「廢帝」「後主」不是后人的称呼,是【姚思廉自己的卷题和本文用字】(两证相符)。
// 全卷检索:卷四、卷六【「廟號」零命中】(两见证皆 0;且此处零命中已用单字直搜复核,
// 不是字符类工具错 —— 见 TOOL_TRAPS 第 1 条)。
// ★★ 而【体例上他们仍是「本紀」】:卷四「本紀第四」、卷六「本紀第六」,与另外三帝同列。
// ⇒ 姚思廉给了他们【紀的名分】,却收走了【帝的名分】:无廟、无諡、不称崩。
// ★ 这与蜀漢恰好【反着】:蜀漢是「本紀的写法、傳的名分」(陳壽给劉備劉禪立傳不立紀);
// 陳是「本紀的名分、非帝的待遇」。⚠ 两边【不是同一件事】,别混着讲。
// ⚠⚠ 但【不许说「薨是姚思廉在贬他们」】—— 这是诛心,而且大概率是错的。可证的是:
// 廢帝死时是【臨海王】(光大二年十一月甲寅被廢,卷四卷五两证),王用「薨」是【准的】。
// 後主死时是【隋的長城縣公】(卷六),公用「薨」也是【准的】。
// ⇒ 「不崩」不是姚思廉的态度,是【他们死时确实不是皇帝】。事实如此,解读留给读者。
//
// ★★★ 後主的「諡」是个真陷阱:卷六「追贈大將軍,封長城縣公,【謚曰煬】」——
// 「煬」是【隋】给的,给的是【長城縣公】,不是給陳朝皇帝的諡。⇒ shi 写「煬」而不加限定,
// 就是把隋朝给一个县公的諡,记成陳朝皇帝的諡。本表分成 shi=null + shiSui 两格,不合并。
//
// ══════════════════════════════════════════════════════════════════════════
// 【第三个坑:年號 —— 「改元不是即位」,而《陳書》把这件事写在明面上】
//
// ★ 陳書本紀的纪年是【光杆】的:只有元年带年號,此后一律「二年」「三年」…
// ⇒ 拿「天嘉五年」去检索,【零命中】。而零命中会被读成「史料有阙」。
// 本朝的形态是:【年號根本不重复出现】。逐年链条要按光杆年頭数(见 CHRONOLOGY_CHECKS)。
//
// ★★★ 而年號的【起讫】不用推 —— 陳書给了一个公式,六次改元六次都用它:
// 「改【舊年號】【N】年【為】【新年號】元年」
// 改永定四年為天嘉元年 (天嘉元年春正月癸丑 詔)
// 改天嘉七年為天康元年 (天康元年春二月丙子 詔)
// 改天康二年為光大元年 (光大元年春正月 詔)
// 改光大三年為太建元年 (太建元年春正月甲午 即位詔)
// 改太建十五年為至德元年 (至德元年春正月壬寅 詔)
// 改至德五年為禎明元年 (禎明元年春正月景子 詔)
// ⇒ 「永定四年」「天康二年」「光大三年」「太建十五年」「至德五年」【全部真的存在过】——
// 存在了一到两个月,然后被改名。它们不是本表的 bug,是【被改元取消掉的那个年號】。
// ⇒ 六次改元【全在正月或二月】,即:新年先按【旧年號】开张,然后在年初被改名。
// 这【就是「逾年改元」】—— 先帝的最后一年让他走完,次年才是新元。
// ★★ 而蜀漢那边,陳壽正是拿这条礼在骂劉禪(「章武之三年,則革稱建興,考之古義,體理為違」)。
// 陳朝五帝【每一次都守了】。⚠ 但别写成「陳比蜀守礼」——那是两本书、两个史官的话,
// 本表只记:陳的六次改元都在年初,且都用「改X年為Y元年」的公式。
// ★ 唯一的例外是【開國】:高祖永定元年 = 即位当年(557 十月受禪即改元)。開國无先帝可让,
// 不适用逾年改元。⇒ 五帝里【只有高祖一人】即位即改元,另外四个全是逾年。
//
// ══════════════════════════════════════════════════════════════════════════
// 【公元换算的依据】—— ★《陳書》【不给公元】,一个数字都不给。换算链条(两条,互相咬合):
// 锚一(卷六,两证逐字同):「隋【仁壽四年】十一月壬子,薨於洛陽,時年五十二。」
// 後主生于「梁【承聖二年】十一月戊寅」(卷六)。仁壽四年=604、承聖二年=553(隋書/梁書)
// ⇒ 604−553+1 = 52 【与原文「時年五十二」严丝合缝】。
// 锚二(卷五校勘記,注):「按宣帝死於【太建十四年】正月甲寅,年五十三,以此上推,
// 適為梁【中大通二年】。」中大通二年=530 ⇒ 582−530+1 = 53 ⇒ 【太建十四年 = 582】。
// 由锚二下推/上推:太建元年=569 → 光大二年=568、光大元年=567、天康元年=566、
// 天嘉元年=560、永定三年=559、永定元年=557;至德元年=583、禎明三年=589(亡)。
// ⚠ 即:公元数是【推的】,起点是原文自带的跨朝互证(陳←→隋、陳←→梁),不是拿现代年表替原文。
// ⚠ 又:陳年与公元年【不严格一一对应】(正月起算,边界差一两个月)。qi/zhi 是惯用换算。
//
// ⚠⚠ 下面所有 cite 里的【标点是今人所加】,原文无标点。比对时已剥标点再比。
// 且 cite 一律取【見證甲】(存唐諱的中華書局系)—— 因为乙已回改,不能代表姚思廉的字。
// ── 见证 ────────────────────────────────────────────────────────────────
export const WITNESSES = {
jia: 'zh.wikisource.org/wiki/陳書/卷1–卷6 ?action=raw(中華書局系:存唐諱,带校勘記 <ref>)',
yi: 'ctext.org/wiki.pl?if=zh&chapter=…(陳書 卷一–卷六;★ 丙/虎【已回改到底】)',
bing: 'zh.wikisource.org/wiki/南史/卷09、/卷10 ?action=raw(李延壽《南史·陳本紀上/下》,仅用于判廢帝年龄冲突与「中大通」补字)',
method: '全程 curl -sk 取生 wikitext/HTML 逐字比对,【未用 WebFetch】。',
juanci:
'卷次两证相符:甲的 header「卷一 本紀第一」…「卷六 本紀第六」,且卷七为「列傳第一」;' +
'乙的目录「卷一本紀第一高祖上」…「卷六本紀第六後主」「卷七列傳第一」。⇒ 本紀止于卷六,故【五帝】。',
openings: '五帝开头【剥标点后逐字比对】:仅 2 处异,见 QUOTE_VARIANTS。其余全等。',
};
// ── 避諱:本朝的核心发现,机器数出来的 ──────────────────────────────────
export const BIHUI = {
faxian: '《陳書》正文压着【两个朝代的忌讳 + 一层後人回改】。',
cengci: [
{ ceng: '姚察(陳史官,避【陳】諱)', zuofa: '皇帝名写作「諱」占位', li: '「皇帝臣諱」→今本作「皇帝臣霸先」(北監本/汲本/殿本改,中華書局從之)', ju: '卷二、卷三校勘記(注)' },
{ ceng: '姚思廉(唐臣,避【唐】諱)', zuofa: '丙→景、虎→武/獸、淵→深', li: '周鐵虎→周鐵武;韓擒虎→韓擒武;蕭淵明→蕭深明;白虎闕→白獸闕;神虎門→神獸門;虎賁→武賁;朱虎→朱武', ju: '卷一、卷五、卷六校勘記(注)' },
{ ceng: '後人(明清刻本)回改', zuofa: '把一部分避諱字改回本字', li: '「作『丙』者乃後人回改也」(卷六校勘記);今本「韓擒虎」两证皆已回改', ju: '卷六校勘記(注)' },
],
// ★ 机器实测(两见证逐卷对数,Node 跑字符类;shell grep 不可用,见 TOOL_TRAPS)
shuju: {
ganzhi: '甲:景+地支 64 / 丙+地支 19  乙:景+地支 0 / 丙+地支 83 (64+19=83,两证同位置)',
juan3: '★ 甲的 19 个「丙」中 18 个在【卷三】:卷三 景0/丙18,其余五卷 景64/丙1(卷六「十二月丙戌,太白晝見」)',
renming: '甲:周鐵武×2(卷一)+ 周鐵虎×1(卷三) 乙:周鐵武×0 + 周鐵虎×3 ⇒ 甲卷三与乙全卷均已回改',
buhuigai: '深明(蕭淵明)两证皆作「深明」、淵明两证皆 0;朱武两证皆 1、朱虎两证皆 0 ⇒ 这两处【谁都没回改】',
huigaile: '韓擒虎/擒虎 两证各 5、韓擒武/擒武 两证各 0 ⇒ 灭陳的隋将,其名【两证都已回改】',
},
tuilun_juan3:
'★【推论,非史料】:見證甲的卷三转录自回改本(干支与人名两个独立信号同指卷三)。' +
'未经第三见证与印本目验 ⇒ 本表【不据此改字】,只记数。',
jiexian:
'⚠ 不许写成「姚思廉篡改史料」——避諱是唐律与士人通例,不是他个人的选择,' +
'且他【在校勘記可复原的范围内是自洽的】。可证的只有:字被改了、谁的諱、哪几处回改。' +
'为什么、他怎么想,本表不裁。',
};
// ── 年號 ────────────────────────────────────────────────────────────────
export const NIANHAO_NOTE =
'陳共【七个年號】:高祖1(永定)、世祖2(天嘉、天康)、廢帝1(光大)、高宗1(太建)、後主2(至德、禎明)。' +
'★ 七个全部【两证相符】,且每一个的起讫都由原文的「改X年為Y元年」公式直接给出,不用推。' +
'★ 只有高祖是即位即改元(開國);另外四帝【全部逾年改元】——新年先按旧年號开张,正月/二月再改名。';
export const NIANHAO_FORMULA = [
{ wen: '改永定四年為天嘉元年', juan: '《陳書》卷三', shi: '天嘉元年春正月癸丑 詔', yi: '560 本应是「永定四年」,正月被改名为天嘉元年' },
{ wen: '改天嘉七年為天康元年', juan: '《陳書》卷三', shi: '天康元年春二月丙子 詔', yi: '566 先做了一个多月的「天嘉七年」,二月改名天康元年' },
{ wen: '改天康二年為光大元年', juan: '《陳書》卷四', shi: '光大元年春正月 詔', yi: '567 本应是「天康二年」' },
{ wen: '改光大三年為太建元年', juan: '《陳書》卷五', shi: '太建元年春正月甲午 即位詔', yi: '569 本应是「光大三年」——废帝的年號本还要再走一年' },
{ wen: '改太建十五年為至德元年', juan: '《陳書》卷六', shi: '至德元年春正月壬寅 詔', yi: '583 本应是「太建十五年」' },
{ wen: '改至德五年為禎明元年', juan: '《陳書》卷六', shi: '禎明元年春正月景子 詔', yi: '587 本应是「至德五年」。★「景子」=丙子,避諱形' },
];
// ── 帝表 ────────────────────────────────────────────────────────────────
export const EMPERORS = [
{
// ── 高祖 武皇帝 陳霸先 ────────────────────────────────────────────
miao: '高祖',
shi: '武皇帝',
shiQuote: '秋八月甲午,群臣上諡曰武皇帝,廟號高祖。',
shiSource: '《陳書》卷二·本紀第二·高祖下(正文,非注)',
xing: '陳霸先',
xingNote:
'★ 本紀开头只作「諱霸先」,「陳」姓出同卷「漢太丘長陳寔之後也」。' +
'★★ 又:卷二「皇帝臣霸先,敢用玄牡昭告于皇皇后帝」一句,校勘記(注)称「霸先」二字' +
'【原作「諱」】,是姚察(陳史官,例避陳諱)的原貌,北監本/汲本/殿本填回真名。' +
'⇒ 连「霸先」这两个字,在部分位置也是【後人补回的】,不是姚察的原文。',
li: 557,
beng: 559,
liQuote: '永定元年冬十月乙亥,高祖即皇帝位于南郊,柴燎告天曰',
bengQuote: '景午,崩于璿璣殿,時年五十七。',
bengNote:
'★「景午」=丙午,避諱形(見證乙作「丙午」)。★ 崩 —— 五帝里三个「崩」之一。' +
'⚠ 异文:甲「高祖疾又甚」/乙「高祖疾甚」(多一「又」字);' +
'甲「高祖臨訊獄(省)訟」——()是中華書局的【删字】校勘符,乙已照删作「臨訊獄訟」。',
nianhao: [{ hao: '永定', qi: 557, zhi: 559 }],
nianhaoComplete: true,
nianhaoNote:
'★ 唯一即位即改元者(開國,557 十月受禪)。永定 = 元、二、三年,卷二年頭 1,2,3 【逐年连续,两证同】。' +
'560 本应是「永定四年」,被改名天嘉元年 ⇒ 永定实际只有三年。',
juan: '《陳書》卷一(高祖上)、卷二(高祖下)',
cite: '高祖武皇帝諱霸先,字興國,小字法生,吳興長城下若里人,漢太丘長陳寔之後也。',
citeCaveat: '⚠ 异文:甲作「陳寔」,乙作「陳實」(寔/實 古通,无关文义,照录甲的「寔」)。乙于「皇帝」后多一逗(今人标点)。',
},
{
// ── 世祖 文皇帝 陳蒨 ──────────────────────────────────────────────
miao: '世祖',
shi: '文皇帝',
shiQuote: '六月甲子,群臣上謚曰文皇帝,廟號世祖。',
shiSource: '《陳書》卷三·本紀第三·世祖(正文,非注)',
shiCaveat: '⚠ 此处两证【皆作「謚」】(非「諡」)。同书两形并存:卷二作「諡」4 次、「謚」1 次。照录原字,不许径改统一。',
xing: '陳蒨',
xingNote: '★ 校勘記(注):卷三「頊」原作「諱」,同理「蒨」在部分位置亦原作「諱」(姚察避陳諱),今本为後人填回。',
li: 559,
beng: 566,
liQuote: '世祖固讓,至于再三,群公卿士固請,其日即皇帝位於太極前殿。',
liNote:
'永定三年(559)六月景午高祖崩,「遺詔徵世祖入纂」;甲寅至自南皖、入居中書省,' +
'皇后令下、世祖三让、「其日」即位 ⇒ li = 559(永定三年六月甲寅)。',
bengQuote: '癸酉,世祖疾甚。是日,崩于有覺殿。',
bengNote:
'★★【卷三没有「時年」】—— 全卷「時年」两证皆【零命中】。五帝里另外四个都有岁数,' +
'独世祖没有。⇒ 世祖崩时几岁,【本紀未記】。' +
'⚠⚠ 不许反过来写「世祖岁数不可考」——本表无据 ≠ 事实为无。他书(南史等)未经我核。',
nianhao: [
{ hao: '天嘉', qi: 560, zhi: 566 },
{ hao: '天康', qi: 566, zhi: 566 },
],
nianhaoComplete: true,
nianhaoNote:
'★ 天嘉元年 = 560(改永定四年為天嘉元年,正月癸丑)⇒ 世祖 559 即位、560 才改元 =【逾年改元】。' +
'★★ 天嘉与天康【同在 566 年】:566 先是「天嘉七年」,二月丙子詔「改天嘉七年為天康元年」。' +
'⇒ 天嘉.zhi = 566 而 天康.qi = 566,【故意重叠】,原文如此。谁把它改成 565/566 不重叠,' +
'谁就抹掉了「天嘉七年真的存在过一个多月」这件事。⚠ 不许修。',
juan: '《陳書》卷三',
cite: '世祖文皇帝諱蒨,字子華,始興昭烈王長子也。',
citeCaveat:
'⚠ 乙于「皇帝」后多一逗(今人标点);剥标点后【全等】。' +
'★ 卷三是全书唯一【干支全作「丙」不作「景」】的一卷(丙18/景0),且此卷作「周鐵虎」而卷一作「周鐵武」' +
'⇒ 見證甲的卷三疑转录自回改本(推论,见 BIHUI.tuilun_juan3)。本 cite 不受影响(无干支)。',
},
{
// ── 廢帝 陳伯宗 ──────────────────────────────────────────────────
miao: null,
miaoStatus: 'none-in-source',
miaoNote:
'★ 卷四全卷检索「廟號」【零命中】(两见证皆 0,且已用单字直搜复核,非字符类工具错)。' +
'★★ 与蜀漢劉備那种「本書未記」【不是一回事】:劉備是通行说有廟號而本書没写;' +
' 廢帝是【被廢黜】,本朝根本没给他上廟號 —— 姚思廉的卷题就直接叫他「廢帝」。' +
'⚠ 但严格说,本表能证的仍只是「卷四未見廟號」。「陳朝确实没给他上廟號」需另证,本表不裁。',
shi: null,
shiStatus: 'none-in-source',
shiNote: '★ 卷四无諡。他不是以皇帝的身份死的 —— 死时是【臨海王】。',
xing: '陳伯宗',
li: 566,
beng: 568,
bengMeans: 'deposed', // ★ 不是崩,是【被废】
zu: 570, // 卒年,与在位末年差两年
liQuote: '天康元年四月癸酉,世祖崩,其日,太子即皇帝位于太極前殿,詔曰',
feiQuote: '十一月甲寅,慈訓太后令廢帝為臨海王,以高宗入纂。',
feiSource: '《陳書》卷五·本紀第五·宣帝(正文)。卷四同日作「甲寅,慈訓太后集羣臣於朝堂,令曰…是日,出居別第」——两卷相符。',
zuQuote: '太建二年四月薨,時年十九。',
zuNote:
'★★【薨,不是崩】。他死时是臨海王,王用「薨」是【准的】—— 这一个字里,他的帝位已经没了两年。' +
'★ 卷五在太建二年下另记「夏四月乙卯,臨海王伯宗薨」——【本朝廢帝的死,记在别人的紀里,且称王不称帝】。' +
'★ 第三见证《南史》卷九亦作「太建二年四月乙卯薨,時年十九」,干支与卷五相符。',
nianhao: [{ hao: '光大', qi: 567, zhi: 568 }],
nianhaoComplete: true,
nianhaoNote:
'★ 566 四月即位,用完先帝的天康元年;567 正月才改元光大 =【逾年改元】。' +
'★ 光大 = 元、二年(卷四年頭 1,2 两证同)。569 本应是「光大三年」,被改名太建元年' +
'⇒ 他的年號在他被废后【又替他走了一个多月】,然后被叔叔改掉。',
juan: '《陳書》卷四',
cite: '廢帝諱伯宗,字奉業,小字藥王,世祖嫡長子也。',
citeCaveat: '⚠ 乙于「廢帝」后多一逗(今人标点);剥标点后【全等】。',
},
{
// ── 高宗 孝宣皇帝 陳頊 ────────────────────────────────────────────
miao: '高宗',
shi: '孝宣皇帝',
shiQuote: '二月辛卯,上謚孝宣皇帝,廟號高宗。',
shiSource: '《陳書》卷五·本紀第五·宣帝(正文,非注)',
shiCaveat:
'★ 諡是【四字「孝宣皇帝」】,不是「宣皇帝」—— 与卷五开头「高宗孝宣皇帝諱頊」自洽(两证皆同)。' +
'⚠ 但【卷题作「宣帝」】(甲 header「卷五 本紀第五 宣帝」、乙目录「卷五本紀第五宣帝」)。' +
'⇒ 卷题与諡号【不一致】,是史书通例(卷题取简称),不是异文。别把「宣帝」当諡填进 shi。',
xing: '陳頊',
xingNote: '★ 校勘記(注):卷三「徙封始興嗣王頊為安成王」的「頊」【原作「諱」】(姚察避陳諱),今本为後人填回。',
li: 569,
beng: 582,
liQuote: '太建元年春正月甲午,即皇帝位于太極前殿,詔曰',
liNote:
'★★ 这里有【两个月的空档,别抹平】:光大二年(568)十一月甲寅慈訓太后令廢帝為臨海王、' +
'「以高宗入纂」;而他【太建元年(569)正月甲午才即皇帝位】。' +
'⇒ 陳書自己的宣帝紀把 li 记在 569。568 十一月–569 正月,他是「入纂」而【未即位】。' +
'本表 li = 569(从原文的「即皇帝位」),并把 568 的入纂单记 ruzuan。' +
'⚠ 拿现代年表写「宣帝 568 即位」,就是拿今人的整洁替掉了原文自己的分寸。',
ruzuan: 568,
bengQuote: '十四年春正月己酉,高宗弗豫。甲寅,崩于宣福殿,時年五十三。',
nianhao: [{ hao: '太建', qi: 569, zhi: 582 }],
nianhaoComplete: true,
nianhaoNote:
'★ 太建 = 元…十四年,卷五年頭 1–14【逐年连续,一年不缺,两证同】(已机器核,含「十一」不漏)。' +
'583 本应是「太建十五年」,被改名至德元年。',
juan: '《陳書》卷五',
cite: '高宗孝宣皇帝諱頊,字紹世,小字師利,始興昭烈王第二子也。',
citeCaveat:
'★★ 此句【下一句】是本朝最险的一格:甲作「梁〔中〕大通二年七月辛酉生」——' +
'〔〕是中華書局的【补字】符,「中」是【编者加的,不是姚思廉的字】。校勘記(注):' +
'「據《南史·陳宣帝紀》補。按宣帝死於太建十四年正月甲寅,年五十三,以此上推,適為梁中大通二年。」' +
'⇒ 传世《陳書》原作「梁大通二年」(=528),是【错的】;编者据南史补「中」(中大通二年=530)。' +
'★ 第三见证《南史》卷十确作「梁中大通二年七月辛酉生」(无括号)⇒ 校勘記所称的补字来源【已核实】。' +
'⚠ 而乙(ctext)作「梁中大通二年」——【把补字当原文了,括号没了】。' +
'⇒ 谁拿乙做底本,谁就看不见这里动过手术。本表 cite 【止于「第二子也」】,不含这一格。',
birthEmended: {
benwen: '梁大通二年(=528,传世本原字,与「年五十三」不合)',
jiaoding: '梁〔中〕大通二年(=530,中華書局據《南史》補「中」字)',
jushi: '582 − 53 + 1 = 530 = 中大通二年 ⇒ 校补与「時年五十三」自洽',
biaozhu: '★ 这是【校勘記(注)的订正】,不是《陳書》正文。本表照实标,不当原文用。',
},
},
{
// ── 後主 陳叔寶 ──────────────────────────────────────────────────
miao: null,
miaoStatus: 'none-in-source',
miaoNote: '★ 卷六全卷检索「廟號」【零命中】(两见证皆 0)。亡國之君,陳朝已无人能给他上廟號。',
shi: null,
shiStatus: 'none-from-chen',
shiNote:
'★★★【最容易记错的一格】:卷六确有「謚曰煬」,但那是【隋】给的,给的是【長城縣公】,' +
'不是陳朝给皇帝的諡。原文:「追贈大將軍,封長城縣公,謚曰煬」——' +
'主语是隋,官是隋的大將軍,爵是隋的縣公,諡自然也是隋的縣公之諡。' +
'⇒ shi(陳諡)= null;隋諡另记 shiSui。合并成 shi:"煬" 就是把两个朝代的账记到一起。',
shiSui: '煬',
shiSuiQuote: '追贈大將軍,封長城縣公,謚曰煬,葬河南洛陽之芒山。',
shiSuiNote: '★ 与隋煬帝同用一个「煬」字 —— 而给他这个字的,正是隋。⚠ 但「煬是恶諡」是解读,本表不裁。',
xing: '陳叔寶',
li: 582,
beng: 589,
bengMeans: 'dynasty-fell', // ★ 589 是亡國,不是他死
zu: 604,
liQuote: '十四年正月甲寅,高宗崩。乙卯,始興王叔陵作逆,伏誅。丁巳,太子即皇帝位于太極前殿。',
liNote: '★ 太建十四年(582)正月:甲寅高宗崩 → 乙卯叔陵作逆伏誅 → 丁巳後主即位。三天,三件事。',
wangQuote: '及夜,為隋軍所執。景戌,晉王廣入據京城。',
wangNote:
'禎明三年(589)正月乙丑朔隋軍南北道並進 → 城陷、後主自投井被執 →「景戌,晉王廣入據京城」。' +
'★「景戌」=丙戌,避諱形。★★ 而灭他的隋将,卷六作「韓擒虎」——校勘記(注)明言:' +
'「按思廉避唐諱,『虎』字皆改為『武』。此作『虎』,乃【後人回改】也。」' +
'⇒ 姚思廉笔下他本该叫【韓擒武】。两个见证【都已回改】成「虎」(各 5 处,「擒武」各 0 处)。',
zuQuote: '隋仁壽四年十一月壬子,薨於洛陽,時年五十二。',
zuNote:
'★★【薨,且用【隋】的年號纪年】。陳朝的末代皇帝,死在洛陽,' +
'记在他自己那部正史的最后一句里,用的是【灭他那个朝代的日历】。' +
'★ 这一格是全书的公元锚:仁壽四年=604,与卷六「梁承聖二年十一月戊寅生」(=553)' +
'对算 604−553+1 = 52,【与「時年五十二」严丝合缝】。',
nianhao: [
{ hao: '至德', qi: 583, zhi: 586 },
{ hao: '禎明', qi: 587, zhi: 589 },
],
nianhaoComplete: true,
nianhaoNote:
'★ 582 即位、583 才改元至德 =【逾年改元】。至德 = 元…四年(583–586),' +
'禎明 = 元…三年(587–589)。卷六编年正文年頭(按文中位置核过,已剔除論赞区):' +
'太建十四年 → 至德元、二、三、四 → 禎明元、二、三 ⇒【八年逐年连续,无阙】。' +
'★ 587 本应是「至德五年」,被改名禎明元年。禎明三年(589)亡國,无禎明四年。',
juan: '《陳書》卷六',
cite: '後主諱叔寶,字元秀,小字黃奴,高宗嫡長子也。',
citeCaveat: '⚠ 乙于「後主」后多一逗(今人标点);剥标点后【全等】。',
},
];
// ── 追尊者:不是皇帝,别数进去 ──────────────────────────────────────────
export const ZHUIZUN = [
{
miao: '太祖',
shi: '景皇帝',
xing: '陳文讚',
quote: '辛巳,追尊皇考曰景皇帝,廟號太祖;皇妣董太夫人曰安皇后。',
juan: '《陳書》卷二(正文)',
note:
'★★【陷阱】:陳有「太祖」,但太祖【从没当过皇帝】—— 是高祖陳霸先追尊的父亲。' +
'按廟號检索「太祖」会命中他,把他数进帝表就变成六帝。' +
'⇒ 本朝【在位五帝】,太祖不在其中。(同卷另有「尊景帝陵曰瑞陵」「遷景皇帝神主祔于…」,皆指他。)' +
'★ 又:卷一「始興昭烈王」(世祖、高宗之父陳道譚)亦为追封,非帝,同样不入表。',
},
];
// ── 逐年链条的机器核验(把结论和【核法】一起留下)────────────────────────
export const CHRONOLOGY_CHECKS = {
method:
'★ 陳書本紀年頭是【光杆】的(只有元年带年號),故按 /(元|十[一二三四五六七八九]?|[一二三四五六七八九])年(?=春|夏|秋|冬|正月|閏|N月)/ ' +
'数【文中顺序】的年頭,不 uniq、不跨年號合并。',
results: {
juan2_yongding: '年頭 1,2,3 —— 逐年连续(两证同)',
juan3_tianjia_tiankang: '年頭 1..6 + 天康元年 —— 天嘉元–六(560–565) + 天嘉七年→天康元年(566),连续(两证同)',
juan4_guangda: '年頭 1,2 —— 光大元、二(567–568) 连续(两证同)',
juan5_taijian: '年頭 1..14 —— 【逐年连续,一年不缺】(两证同)',
juan6_zhide_zhenming: '编年正文年頭:太建十四 → 至德元,二,三,四 → 禎明元,二,三 —— 连续(两证同)',
},
false_alarms_i_made: [
'★★【我自己造过一个假缺口】:把一卷内的年頭 uniq 之后报「卷六缺 5–13」。' +
'真相:年號一改,年数就从「元年」重来,uniq 跨两个年號毫无意义。⇒ 报「阙」之前先查自己的工具。',
'★【我自己造过一个假异常】:seq 脚本按正则交替顺序输出,看着像「禎明有四年」。' +
'逐条打印字符位置后,那个「四年」是【至德四年】(offset 3640),位置在禎明元年之前。⇒ 顺序错觉,非史料异常。',
],
};
// ── 存疑:不许替读者选 ──────────────────────────────────────────────────
export const AGE_CONFLICT = {
who: '廢帝 陳伯宗',
yuanwen: ['梁承聖三年五月庚寅生。(卷四)', '太建二年四月薨,時年十九。(卷四)'],
suanshu:
'承聖三年 = 554(锚:卷六「梁承聖二年十一月戊寅生」+「明年,江陵陷」,且後主 604 薨年五十二 ⇒ 承聖二年=553 ⇒ 承聖三年=554);' +
'太建二年 = 570(锚:太建十四年=582 ⇒ 太建元年=569)。' +
'⇒ 570 − 554 + 1 = 【十七】,而原文作【十九】。差二。',
witnesses:
'★ 两个见证的《陳書》卷四【都作】「承聖三年…時年十九」;' +
'★★ 第三见证《南史》卷九(李延壽,另一部书)【也作】「梁承聖三年五月庚寅生」+「太建二年四月乙卯薨,時年十九」。' +
'⇒ 这【不是某个本子的抄错】,冲突在【传世的史料本身】里。',
jiaokanji: '★ 中華書局卷四校勘記(注)共 13 条,【没有一条提这个矛盾】。⇒ 不是「已订正」,是【没人订正】。',
verdict:
'★★ 本表【不裁】:li/beng/zu 一律照原文的【年】记(生 554、薨 570),' +
'而「時年十九」【照录不改】,冲突挂在这儿。' +
'⚠ 若把它「修」成十七,就是拿我的算术替掉了两部正史共有的字 —— 那是【假订正】。' +
'⚠ 若把生年改成 552 去凑十九,就会撞坏「承聖三年」这个两证+南史三证的字。' +
'⇒ 写「未核」比写一个假的强一百倍。等史学顾问补见证再说。',
};
// ── 异文账(照实分账,一处都没替读者选)────────────────────────────────
export const QUOTE_VARIANTS = [
{ chu: '卷一 高祖开头', jia: '漢太丘長陳寔之後也', yi: '漢太丘長陳實之後也', pan: '寔/實 古通(同音异写),无关文义。照录甲「寔」。' },
{ chu: '卷五 宣帝生年', jia: '梁〔中〕大通二年', yi: '梁中大通二年', pan: '★ 甲存【校补符】,乙【抹掉了括号把补字当原文】。三见证:南史卷十作「梁中大通二年」= 校勘記所称补字来源,已核。本表不入 cite。' },
{ chu: '卷二 高祖崩', jia: '高祖疾又甚', yi: '高祖疾甚', pan: '甲多一「又」字。异文,两存,无关本表取值。' },
{ chu: '卷二 高祖崩前', jia: '高祖臨訊獄(省)訟', yi: '高祖臨訊獄訟', pan: '()是中華書局【删字】校勘符,乙已照删。⇒ 非异文,是甲保留了校勘痕迹。' },
{ chu: '卷一 南兗州', jia: '南论州刺史(1 处)', yi: '南兗州刺史(1 处)', pan: '★ 甲此处是【坏字】:「论」不成词,当作「兗」(甲 南兗州 0 处 / 乙 南论州 0 处,实测)。⇒ 采乙。这是【两证判一证】,不是我径改原文。' },
{ chu: '卷四 廢黜令', jia: '慈訓太后集【戝】臣於朝堂', yi: '慈訓太后集【群】臣於朝堂', pan: '★ 甲又一处【坏字】:「戝」不成词,当作「群」。⇒ 采乙。' },
{ chu: '全书 干支丙日', jia: '景午/景申/景戌/景子…(64 处)', yi: '丙午/丙申/丙戌/丙子…(83 处)', pan: '★★ 不是普通异文,是【避諱 vs 回改】。甲存唐諱,乙回改到底。见 BIHUI。' },
{ chu: '諡/謚 字形', jia: '卷二作「諡」4 次、「謚」1 次;卷三、卷五、卷六作「謚」', yi: '卷二全作「謚」', pan: '同字异形,同书内两形并存。照录各处原字,【不许统一】。' },
{ chu: '卷四 廢帝詔文', jia: '慈訓太后…令曰:【正文缺】', yi: '慈訓太后…令曰:「…攬筆潸然,兼懷悲慶。」', pan: '★ 甲(wikisource)【丢了废黜令的正文】(模板未展开)。⇒ 涉及该詔内容一律用乙。本表不取此詔。' },
];
// ── 工具坑:全是这一刀实测栽出来的,留给下一个人 ────────────────────────
export const TOOL_TRAPS = [
'★★★【shell 的 grep 不能用中文字符类】:`grep -oE "[諡謚]" p2.txt` 报【1031 命中】,真值【5】——' +
'grep 把 [諡謚] 按【字节】拆成字符集,于是命中一切含这些字节的汉字。' +
'⇒ CLAUDE.md 让「跨卷检索一律写 [為爲] 字形类」,但【在这个环境里 shell grep 会把它变成垃圾】。' +
'所有字符类检索【一律写成 .js 文件用 node 跑】(node 的正则是按码位的,正确)。单字直搜 grep 仍可用。',
'★★★【wikisource 的 <ref>…</ref> 是中華書局校勘記,是【注】不是正文】。' +
'只删 <> 尖括号会把校勘記正文【混进本紀】——实测:廢帝紀「…立為皇太子」后面直接接上' +
'「按『八月庚戌』〈世祖紀〉作『九月辛酉』」,读起来像姚思廉自己在写。' +
'必须 /<ref[^<>]*>[\\s\\S]*?<\\/ref>/ 【连内容一起删】。六卷共 168 条校勘記,全是这么混进来的。',
'★★【校勘符会插在词中间,且两套】:〔X〕=编者【补】的字,(X)=编者标【删】的字。' +
'实测:「梁〔中〕大通二年」「屯朝(室)〔堂〕」「我心貞確,(空)〔堅〕誓蒼昊」「赦湘巴二(郡)[州]」。' +
'⇒ 拿「梁中大通二年」去搜甲,【零命中】;拿「獄訟」去搜甲,也【零命中】。检索前必须先处理。',
'★★【ctext 把页码锚点 <span id="8.35" class="libtarget"></span> 插在词中间】:' +
'「高祖武皇<span…></span>帝,諱霸先」⇒ 拿「高祖武皇帝」搜 ctext【零命中】。' +
'与 wikisource 的 {{YL|…}} 是同一个病。两边都要先剥标记再检索。',
'★★【{{YL|泰始七年|271年}} 式模板的第二参是维基编者加的公元换算,不是原文】。' +
'解析只留第一参。(本朝实测:{{YL|〔中〕大通二年}}、{{YL|承聖三年}} 等。)',
'★★★【抓失败会变成假零命中】:curl 取 zh.wikisource.org/wiki/南史/卷9 返回的是' +
'【Wikimedia Error 的 HTML 页】(1926 字节),而我的 strip 把它照样剥成"文本",' +
'于是检索「廢帝」【零命中】—— 差一点就写成「南史不记廢帝年龄」。' +
'真相:页名是零填充的【南史/卷09】。⇒ 每次抓完先验【字节数 + 是否含预期关键字】,再检索。' +
'「零命中比单证更危险」在这一刀又应验了一次,而且这次是【工具假装成史料】。',
'★【node -e 传中文正则会被 shell 搞坏】(CLAUDE.md 已记):涉中文的核验脚本一律【写文件跑】。' +
'本刀全部核验脚本在 scratchpad/chen/*.js(strip.js/ctext.js/dig.js/nianhao.js/seq.js/years.js/bing.js/hui.js/final.js)。',
];
// ── 还没做的,别当我做了 ────────────────────────────────────────────────
export const NOT_DONE = [
'本刀【只做帝纪】:五帝的廟/諡/名/在位/年號/卷次/开头引文。',
'★★【群臣图谱没做,而且这一朝【没有现成的表可抠】—— 这条必须报给老板】:' +
' 《陳書》三十六卷 = 【本紀六 + 列傳三十】,【无表、无志】。已核:ctext 全书目录逐卷比对,' +
' 卷一–卷六「本紀第一–第六」、卷七–卷三十六「列傳第一–第三十」,检索卷题含「表」「志」者【零命中】。' +
' ⇒ 任务书的前提「正史自带年表(漢書百官公卿表/新唐書宰相表/宋史宰輔表/明史宰輔年表)」' +
' 在【陳朝不成立】:《陳書》根本没有那一层。南朝诸史普遍缺表(《宋書》有志无表,齊梁陳皆无表)。' +
' ⇒ 陳的宰辅只能从卷三–卷六【编年正文】逐条抠「以某為某」(实测卷五一卷上百条),' +
' 或从卷十七以下列傳里抠。这是【下一刀】的活,且【工作量与「从散文里抠」同级】,不是照表誊。',
'★【大事件没做】:本紀正文就是编年大事件(卷一–卷六,逐年逐月逐日),但本刀没抠。' +
' 料在手上(scratchpad/chen/p1–p6.txt、q1–q6.txt 已剥净可直接用)。',
'★【廢帝年龄冲突未解】:见 AGE_CONFLICT,两部正史共有的矛盾,本表不裁。',
'★【世祖崩年岁数未見】:卷三无「時年」。他书未核。⚠ 不许写成「不可考」。',
'★【卷三是否回改本未判】:BIHUI.tuilun_juan3 是推论,缺第三见证与印本目验。',
'★【後主之外四帝的生年未系统核】:只有後主(承聖二年=553)与宣帝(校补中大通二年=530)有明确锚。' +
' 高祖「時年五十七」+永定三年(559) ⇒ 生年约 503,但这是【我推的】,卷一未直书生年,未再核。',
];
export const CHEN = {
chao: '陳',
qi: 557,
zhi: 589,
du: '建康',
xing: '陳',
diCount: 5,
nianhaoCount: 7,
shu: '《陳書》三十六卷,姚察撰、姚思廉續成(唐貞觀)。本紀六卷(卷一–卷六)。',
note:
'★ 陳是南朝最后一朝,也是【疆域最小、国祚最短】的一朝(33 年,五帝)。' +
'★★ 但本朝真正的料不在短,在【它的史书是灭它的那个体系的下一朝写的】:' +
'姚察在陳朝写陳史(避陳諱,皇帝名写「諱」),其子姚思廉在唐朝续成(避唐諱,丙→景、虎→武、淵→深),' +
'明清刻工再回改一部分。⇒ 陳朝人的名字,最终由【唐朝的祖先】和【明清的刻工】共同决定。' +
'★ 卷六末更有「史臣侍中鄭國公【魏徵】曰」——《陳書》的論赞里站着唐太宗的宰相,' +
' 而他论的正是【被唐所继承的那个隋所灭的】陳。' +
' ⚠ 见证差异(已核):甲(wikisource) 此段【正文为空,只剩标题】;' +
' 乙(ctext) 有全文,起「魏徵曰:高祖拔起壟畝,有雄桀之姿。始佐下籓,奮英奇之略…」约千字。' +
' ⇒ 要引魏徵論【必须用乙】。本刀未逐字核该段,故不入表。' +
' ★ 我一度据甲写成「两证皆只见标题」——错的,是我拿单证当了全部。已订正(此处两个见证都看过了)。',
};
// ══════════════════════════════════════════════════════════════════════════
// ██ 群臣图谱 + 大事件(本刀新增)—— 一次做完 ██
// ══════════════════════════════════════════════════════════════════════════
// 上半部(EMPERORS 等)是【帝纪】,已双证做完,本段一个字都不改,只【追加】。
//
// ★★ 这一朝【没有现成的年表】:《陳書》三十六卷 = 本紀六 + 列傳三十,【无表、无志】
// (已核,见 NOT_DONE 与 CHAO_COVERAGE.chen.note)。任务书的前提「正史自带公卿表/宰相表」
// 在陳【不成立】。⇒ 群臣与大事【只能从本紀卷一–卷六的编年正文里抠任命句】。
// 所以本段每条 source 一律 = "benji"(从本紀抠),【没有一条是 "biao"】—— 这是这一朝的实情。
//
// ── 方法(两见证逐条核过)───────────────────────────────────────────────
// · 甲 = zh.wikisource(存唐諱),乙 = ctext(丙/虎已回改)。人名一致性已机器对数:
// 侯安都 17/17、吳明徹诸卷全等…(见 scratchpad/chen/verify.js 实测)。cite 一律取【甲】。
// · 编年定年:本紀年头是【光杆】的(只有元年带年號,见 CHRONOLOGY_CHECKS)。
// 逐卷按【实测字符偏移】把「元年/二年/…」锚到公元(锚表 scratchpad/chen/extract.js ANCHORS),
// 再把每条任命/大事挂到它所在的年块。⇒ year 字段是【锚定】的,不是猜的。
//
// ── ★★ dong(动词)字段:这个产品最值钱的字段,绝不抹平成「担任」──────────
// 本紀记任命的主构式是「以X為Y」,此外有 進號/進位/即本號/遷/徵/免/薨/卒/賜死/伏誅/奔/反/降/執。
// 我把它归成一张【受控词表】,而【原文逐字全在 cite 里】,读者可复核:
// 除 = 拜官/改任(源:以X為Y / 授X為Y / 除X為Y)
// 進 = 升迁(源:進號 / 進位 / 即本號 / 加)
// 遷 = 遷 徵 = 徵…為(征召入朝) 免 = 免 / 有罪免 / 坐…免
// 薨 = 王公之死 卒 = 卒 賜死 = 賜死
// 誅 = 伏誅/見誅/誅 見殺 = 死于乱军(被害) 奔 = 奔于齊/奔周(叛降敌国)
// 反 = 舉兵反/作逆/謀反/據…反 降 = 出降/歸降 執 = 兵敗被俘(為…所獲)
// 見 = 史文只记其【此年在任】而非新拜(亡國一刻的省中名单即用此)
// ⚠⚠ 「免」「薨」「誅」「賜死」「奔」是【五件完全不同的事】—— 侯安都是【賜死】不是【薨】,
// 沈泰是【奔】不是【免】。抹平这一列,这个产品就没有内容了。
//
// ── ★ 字形坑(CLAUDE.md 已警,本刀又栽到两处,两见证对数后订正)──────────
// ① 黃法𣰋:甲卷2/3/5 作「𣰋」,【甲卷4 作「矴」】(同人异形,实测甲𣰋15/矴2);
// 乙全卷用另一异形。本段人名一律写【黃法𣰋】,读者遇「黃法矴」即同一人。
// ② 杜稜:乙(ctext)【全卷 0 命中「稜」】(用棱/㥄类异形),甲作「稜」。本段一律【杜稜】。
// ③ 周鐵虎/韓擒虎:避唐諱,姚思廉原文当作【周鐵武】【韓擒武】(见 BIHUI)。
// 564 配食一条甲卷三作「周鐵虎」(回改本),589 亡國一条两证皆作「韓擒虎」(都已回改)。
// 本段【照甲录字】并在 note 标避諱,不径改。
//
// ── ★★★ layer(事/制/道):这一朝的骨头,而它给出一个【干净且沉重】的答案 ─────
// 我把每条大事都判了层,结论是:【陳几乎全是「事」,「制」零个,「道」零个】。
// 这不是偷懒,是这一朝的实情,判据就是老板的那句「五十年后还在起作用吗?」:
// · 陳国祚仅 33 年(557–589),589 被隋整体吞并,其律令/官制/货币【无一存续到隋唐】。
// 557 定律令 ⇒ 50 年后是 607(隋),陳律早废,隋行《開皇律》。⇒ 够不到「制」。
// · 太建北伐(573)、呂梁之敗(578)、受禪(557)、亡國(589) —— 都是【事】:一场仗、一次禪代、
// 一次废立。哪怕呂梁一败丧师十万,它也只是【事】(老板原话:一场仗就是事,哪怕死十万人)。
// ★★ 唯一够得着「道」的,是 589 那件事的【另一面】:隋滅陳 = 结束西晉以来 270 年南北分裂、
// 重新一统,这个后果确实延续了一千五百年。但【那个「道」是【隋】的,不是【陳】的】——
// 《陳書》记的是「亡國」,从陳的账本上它是一场死亡,不是陳缔造的制度。⇒ 本表【不把它算成陳的道】,
// 589 记为 layer:"shi",理由写在该条 note 里。这正是这个产品在陳这一朝要说的话:
// 【在陳做官的人,一生都活在「事」的那一层 —— 因为他还没来得及让任何「制」生效,
// 整个世界就在 589 年被一次性抹掉了。他是那个「道」的承受者,不是缔造者。】
// ★★【本刀補記(據建模 §1.3/§3.3,2026-07 二刀)】:上文「制 0」是【严判】——要求制存续 50 年方算制。
// 而建模層典按【类型】计:律/铸钱/官制之确立即为「制」,存续长短只分【制/道】,不分【事/制】。
// 从建模,本刀補 557「立刪定郎,治定律令」(律)、579「初用大貨六銖錢」(铸钱) 两条为 layer:"zhi"。
// ★ 非谓本紀制度条仅此二条:卷五太建十一年甲寅诏「今可改不枉法受財者,科同正盜」亦是本紀内的律条修订,本刀未逐条补尽。
// ★ 但二者 impact 影响半径皆止【当朝】(reach:"dangchao"):陳律随 589 亡而废、大貨六銖 582 宣帝崩後即废,无一存续隋唐。
// ⇒ 与上文「他没让任何制生效」的判断【同向不悖】:陳之制确未下沿,只是按类型该记作「制」而非「事」。
// 「道 0」不变;「一步制度变革千年后还起作用吗?」在陳,impact 栏诚实地答【没有】。
//
// ── ★★ zaichang(在场):老板这一刀真正的靶子 ────────────────────────────
// 「很多人够不到历史的大事件,难道就没有他自己的小事件?况且他可能还位居高位呢。」
// ⇒ 每个大事加一格:那年【什么位阶的人】会被这件事卷进去。同一件大事,落在不同位阶身上
// 是【不同的题】。这一格是【推演】不是原文 ⇒ 只在大事上给,且不安卷次。
export const CHEN_GUANPU_METHOD = {
wubiao: '《陳書》无表无志(本紀六+列傳三十)。群臣/大事全部从本紀编年正文抠,故 source 皆 "benji"。',
dingnian: '本紀年头光杆,按实测字符偏移把「元年/二年…」锚到公元(scratchpad/chen/extract.js)。',
dongCiBiao: '除/進/遷/徵/免/薨/卒/賜死/誅/見殺/奔/反/降/執/見 —— 见文件头 dong 词表。原文逐字在 cite。',
zixing: ['黃法𣰋(甲卷4作矴)', '杜稜(乙用异形)', '周鐵虎/韓擒虎(避唐諱回改,原当作武)'],
jiexian: '★ 群臣以【宰辅层】为准做全(三公/尚書令僕射/中書監令/侍中/領護軍/錄尚書/都督中外/太傅/揚州刺史/吏部尚書/開府儀同三司初授);九卿以下与外州刺史【只抠到卷五所见,未穷尽】—— 见 CHAO_COVERAGE.chen,不静默截断。',
// ⚠ 上方已有 export `NOT_DONE`(上一刀写的)里「群臣图谱没做 / 大事件没做」两条【已被本刀完成】,
// 但按红线「别改它已有的 export」,本刀【不改 NOT_DONE 原文】,在此声明其那两条已过时;
// NOT_DONE 里其余未竟项(廢帝年龄冲突/世祖崩年岁数/卷三是否回改本/四帝生年)仍成立。
guodushi_NOT_DONE: '本刀完成 NOT_DONE 中「群臣图谱」「大事件」两项;其余仍未做。',
};
// ═══ 一、群臣图谱(CHAO_CHEN)═══
// 每条:{ year, guan(原文官名), ren, dong, juan, cite(原文逐字), source:"benji" }
// ★ 全部从本紀任命句抠。宰辅层做全,九卿/外州能抠多少抠多少(覆盖见 CHAO_COVERAGE)。
export const CHAO_CHEN = [
// ── 高祖 永定(卷二,557–559)──
{ year: 557, guan: '領軍將軍', ren: '徐度', dong: '除', juan: '卷二', cite: '景辰,以鎮西將軍、南豫州刺史徐度為鎮右將軍、領軍將軍', source: 'benji' },
{ year: 557, guan: '左光祿大夫', ren: '王沖', dong: '除', juan: '卷二', cite: '辛卯,以中權將軍、開府儀同三司、丹陽尹王沖為左光祿大夫', source: 'benji' },
{ year: 558, guan: '司空', ren: '侯瑱', dong: '進', juan: '卷二', cite: '車騎將軍、開府儀同三司侯瑱進位司空', source: 'benji' },
{ year: 558, guan: '護軍將軍', ren: '徐世譜', dong: '除', juan: '卷二', cite: '左衛將軍徐世譜為護軍將軍', source: 'benji' },
{ year: 558, guan: '南豫州刺史', ren: '沈泰', dong: '奔', juan: '卷二', cite: '二月壬申,南豫州刺史沈泰奔于齊', source: 'benji', note: '★ 奔于北齊,非免非薨 —— 陳初方鎮离心的第一例。' },
{ year: 558, guan: '開府儀同三司', ren: '周迪', dong: '進', juan: '卷二', cite: '以信威將軍、江州刺史周迪為開府儀同三司,進號平南將軍', source: 'benji' },
{ year: 558, guan: '開府儀同三司', ren: '黃法𣰋', dong: '進', juan: '卷二', cite: '以仁威將軍、高州刺史黃法𣰋為開府儀同三司,進號鎮南將軍', source: 'benji' },
{ year: 558, guan: '開府儀同三司', ren: '熊曇朗', dong: '進', juan: '卷二', cite: '以寧遠將軍、北江州刺史熊曇朗為開府儀同三司,進號平西將軍', source: 'benji' },
{ year: 558, guan: '開府儀同三司', ren: '侯安都', dong: '除', juan: '卷二', cite: '前鎮北將軍、南徐州刺史、新除開府儀同三司侯安都等於王琳所逃歸', source: 'benji' },
{ year: 559, guan: '開府儀同三司', ren: '歐陽頠', dong: '進', juan: '卷二', cite: '丁酉,以鎮南將軍、廣州刺史歐陽頠即本號開府儀同三司', source: 'benji' },
{ year: 559, guan: '開府儀同三司', ren: '淳于量', dong: '進', juan: '卷二', cite: '二月辛酉,以平西將軍、桂州刺史淳于量為開府儀同三司,進號鎮西大將軍', source: 'benji' },
{ year: 559, guan: '都督', ren: '周文育', dong: '見殺', juan: '卷二', cite: '乙酉,北江州刺史熊曇朗殺都督周文育于軍,舉兵反', source: 'benji', note: '★ 见殺于乱军(熊曇朗所杀),非薨非誅。周文育故司空,陳开国大将。' },
{ year: 559, guan: '北江州刺史', ren: '熊曇朗', dong: '反', juan: '卷二', cite: '北江州刺史熊曇朗殺都督周文育于軍,舉兵反', source: 'benji' },
// ── 世祖 天嘉/天康(卷三,559–566)──
{ year: 559, guan: '太尉', ren: '侯瑱', dong: '除', juan: '卷三', cite: '辛酉,以侍中、車騎將軍、司空侯瑱為太尉', source: 'benji' },
{ year: 559, guan: '司空', ren: '侯安都', dong: '除', juan: '卷三', cite: '鎮西將軍、開府儀同三司、南豫州刺史侯安都為司空', source: 'benji' },
{ year: 559, guan: '侍中', ren: '徐度', dong: '除', juan: '卷三', cite: '鎮北將軍、南徐州刺史徐度為侍中、中撫軍將軍、開府儀同三司', source: 'benji' },
{ year: 559, guan: '特進、左光祿大夫', ren: '王沖', dong: '除', juan: '卷三', cite: '侍中、中權將軍、開府儀同三司王沖為特進、左光祿大夫', source: 'benji' },
{ year: 559, guan: '領軍將軍', ren: '杜稜', dong: '除', juan: '卷三', cite: '侍中、忠武將軍杜稜為領軍將軍', source: 'benji', note: '★ 杜稜:乙(ctext)全卷用异形,甲作「稜」。' },
{ year: 560, guan: '驃騎將軍、湘州牧', ren: '陳昌', dong: '除', juan: '卷三', cite: '庚戌,以高祖第六子昌為驃騎將軍、湘州牧,立為衡陽王', source: 'benji', note: '★ 高祖嫡子陳昌自北周还,旋薨(見 CHAO_SHI 560 溺死条)。' },
{ year: 560, guan: '中軍將軍', ren: '徐度', dong: '除', juan: '卷三', cite: '丁酉,以開府儀同三司徐度為侍中、中軍將軍', source: 'benji' },
{ year: 561, guan: '侍中、領軍將軍', ren: '杜稜', dong: '除', juan: '卷三', cite: '以雲麾將軍、晉陵太守杜稜為侍中、領軍將軍', source: 'benji' },
{ year: 561, guan: '合州刺史', ren: '裴景徽', dong: '奔', juan: '卷三', cite: '乙卯,合州刺史裴景徽奔于齊', source: 'benji' },
{ year: 561, guan: '車騎將軍、湘州刺史', ren: '侯瑱', dong: '除', juan: '卷三', cite: '二月丙戌,以太尉侯瑱為車騎將軍、湘州刺史', source: 'benji' },
{ year: 561, guan: '太尉', ren: '侯瑱', dong: '薨', juan: '卷三', cite: '三月乙卯,太尉、車騎將軍、湘州刺史侯瑱薨', source: 'benji' },
{ year: 561, guan: '丹陽尹', ren: '王沖', dong: '除', juan: '卷三', cite: '丙辰,以侍中、中權將軍、特進、左光祿大夫、開府儀同三司王沖為丹陽尹', source: 'benji' },
{ year: 561, guan: '左民尚書', ren: '沈君理', dong: '除', juan: '卷三', cite: '丹陽尹沈君理為左民尚書,領步兵校尉', source: 'benji' },
{ year: 561, guan: '中護軍', ren: '孫瑒', dong: '除', juan: '卷三', cite: '十二月辛巳,以安東將軍、吳郡太守孫瑒為中護軍', source: 'benji' },
{ year: 562, guan: '侍中、中書監', ren: '陳頊', dong: '除', juan: '卷三', cite: '三月丙子,安成王頊至自周,詔授侍中、中書監、中衛將軍,置佐史', source: 'benji', note: '★ 安成王頊(後之高宗宣帝)自北周质子还国,即授侍中、中書監 —— 其上位之始。' },
{ year: 562, guan: '揚州刺史', ren: '陳頊', dong: '除', juan: '卷三', cite: '六月丙辰,以侍中、中衛將軍安成王頊為驃騎將軍、揚州刺史', source: 'benji' },
{ year: 562, guan: '征北大將軍', ren: '侯安都', dong: '除', juan: '卷三', cite: '征北將軍、司空、南徐州刺史侯安都為侍中、征北大將軍', source: 'benji' },
{ year: 562, guan: '尚書右僕射、丹陽尹', ren: '到仲舉', dong: '除', juan: '卷三', cite: '以侍中、都官尚書到仲舉為尚書右僕射、丹陽尹', source: 'benji' },
{ year: 563, guan: '護軍將軍', ren: '章昭達', dong: '除', juan: '卷三', cite: '壬辰,以平西將軍、郢州刺史章昭達為護軍將軍', source: 'benji' },
{ year: 563, guan: '鎮北大將軍、南徐州刺史', ren: '黃法𣰋', dong: '除', juan: '卷三', cite: '鎮南將軍、開府儀同三司、高州刺史黃法𣰋為鎮北大將軍、南徐州刺史', source: 'benji' },
{ year: 563, guan: '征南大將軍、江州刺史', ren: '侯安都', dong: '除', juan: '卷三', cite: '庚戌,以侍中、司空、征北大將軍侯安都為征南大將軍、江州刺史', source: 'benji' },
{ year: 563, guan: '侍中、中軍大將軍', ren: '徐度', dong: '除', juan: '卷三', cite: '三月辛未,以鎮南將軍、開府儀同三司徐度為侍中、中軍大將軍', source: 'benji' },
{ year: 563, guan: '開府儀同三司', ren: '陳頊', dong: '除', juan: '卷三', cite: '乙卯,以侍中、中書監、中衛將軍、驃騎將軍、揚州刺史安成王頊為開府儀同三司', source: 'benji' },
{ year: 563, guan: '右光祿大夫', ren: '徐世譜', dong: '卒', juan: '卷三', cite: '五月丁卯,安前將軍、右光祿大夫徐世譜卒', source: 'benji' },
{ year: 563, guan: '司空', ren: '侯安都', dong: '賜死', juan: '卷三', cite: '司空侯安都賜死', source: 'benji', note: '★★ 賜死,非薨非誅 —— 世祖诛开国第一功臣。一个字里是「功高震主」的全部。(見 CHAO_SHI)' },
{ year: 563, guan: '開府儀同三司、廣州刺史', ren: '歐陽頠', dong: '薨', juan: '卷三', cite: '九月壬戌,開府儀同三司、廣州刺史歐陽頠薨', source: 'benji', note: '★ 其子歐陽紇 569 據廣州反(見下)。' },
{ year: 564, guan: '丹陽尹', ren: '袁樞', dong: '除', juan: '卷三', cite: '五年春正月庚辰,以吏部尚書、領右軍將軍袁樞為丹陽尹', source: 'benji' },
{ year: 564, guan: '中撫軍大將軍', ren: '淳于量', dong: '除', juan: '卷三', cite: '三月丁丑,以征南大將軍、開府儀同三司、桂州刺史淳于量為中撫軍大將軍', source: 'benji' },
{ year: 564, guan: '中護軍', ren: '程靈洗', dong: '除', juan: '卷三', cite: '冬十一月丁亥,以左衛將軍程靈洗為中護軍', source: 'benji' },
{ year: 564, guan: '開府儀同三司', ren: '章昭達', dong: '進', juan: '卷三', cite: '甲辰,以護軍將軍章昭達為鎮前將軍、開府儀同三司', source: 'benji' },
{ year: 565, guan: '丹陽尹', ren: '杜稜', dong: '除', juan: '卷三', cite: '庚戌,以領軍將軍杜稜為翊左將軍、丹陽尹', source: 'benji' },
{ year: 565, guan: '吏部尚書', ren: '袁樞', dong: '除', juan: '卷三', cite: '丹陽尹袁樞為吏部尚書', source: 'benji' },
{ year: 565, guan: '中領軍', ren: '沈欽', dong: '除', juan: '卷三', cite: '衛尉卿沈欽為中領軍', source: 'benji' },
{ year: 565, guan: '司空', ren: '陳頊', dong: '除', juan: '卷三', cite: '夏四月甲寅,以侍中、中書監、中衛將軍、驃騎將軍、開府儀同三司、揚州刺史安成王頊為司空', source: 'benji' },
{ year: 565, guan: '中衛大將軍', ren: '黃法𣰋', dong: '除', juan: '卷三', cite: '鎮南大將軍、江州刺史黃法𣰋為中衛大將軍', source: 'benji' },
{ year: 565, guan: '中護軍', ren: '沈恪', dong: '除', juan: '卷三', cite: '軍師將軍、郢州刺史沈恪為中護軍', source: 'benji' },
{ year: 565, guan: '中領軍', ren: '吳明徹', dong: '除', juan: '卷三', cite: '鎮東將軍、吳興太守吳明徹為中領軍', source: 'benji' },
{ year: 566, guan: '尚書令', ren: '陳頊', dong: '除', juan: '卷三', cite: '三月己卯,以驃騎將軍、開府儀同三司、揚州刺史、司空安成王頊為尚書令', source: 'benji' },
// ── 廢帝 光大(卷四,566–568)──
{ year: 566, guan: '司徒、錄尚書、都督中外諸軍事', ren: '陳頊', dong: '進', juan: '卷四', cite: '庚寅,以驃騎將軍、司空、揚州刺史、新除尚書令安成王頊為驃騎大將軍,進位司徒、錄尚書、都督中外諸軍事', source: 'benji', note: '★★ 頊集司徒+錄尚書+都督中外于一身 —— 廢帝朝的实际执政者,两年后即废帝自立。' },
{ year: 566, guan: '司空', ren: '徐度', dong: '進', juan: '卷四', cite: '丁酉,中軍大將軍、開府儀同三司徐度進位司空', source: 'benji' },
{ year: 566, guan: '侍中', ren: '章昭達', dong: '除', juan: '卷四', cite: '鎮南將軍、開府儀同三司、江州刺史章昭達為侍中,進號征南將軍', source: 'benji' },
{ year: 566, guan: '尚書左僕射', ren: '袁樞', dong: '除', juan: '卷四', cite: '吏部尚書袁樞為尚書左僕射', source: 'benji' },
{ year: 566, guan: '尚書右僕射', ren: '沈欽', dong: '除', juan: '卷四', cite: '雲麾將軍、吳興太守沈欽為尚書右僕射', source: 'benji' },
{ year: 566, guan: '領軍將軍', ren: '吳明徹', dong: '除', juan: '卷四', cite: '新除中領軍吳明徹為領軍將軍', source: 'benji' },
{ year: 566, guan: '護軍將軍', ren: '沈恪', dong: '除', juan: '卷四', cite: '新除中護軍沈恪為護軍將軍', source: 'benji' },
{ year: 566, guan: '吏部尚書', ren: '徐陵', dong: '除', juan: '卷四', cite: '散騎常侍、御史中丞徐陵為吏部尚書', source: 'benji', note: '★ 徐陵:陳一代文宗,此后历尚書僕射、中書監、領軍將軍,贯穿宣帝一朝。' },
{ year: 567, guan: '尚書左僕射', ren: '袁樞', dong: '卒', juan: '卷四', cite: '光大元年春正月癸酉,尚書左僕射袁樞卒', source: 'benji' },
{ year: 567, guan: '丹陽尹', ren: '吳明徹', dong: '除', juan: '卷四', cite: '己卯,以領軍將軍吳明徹為丹陽尹', source: 'benji' },
{ year: 567, guan: '南豫州刺史', ren: '余孝頃', dong: '誅', juan: '卷四', cite: '二月辛亥,宣毅將軍、南豫州刺史余孝頃謀反伏誅', source: 'benji' },
{ year: 567, guan: '尚書左僕射', ren: '沈欽', dong: '進', juan: '卷四', cite: '三月甲午,以尚書右僕射沈欽為侍中、尚書左僕射', source: 'benji' },
{ year: 567, guan: '領軍將軍', ren: '杜稜', dong: '除', juan: '卷四', cite: '乙未,以鎮右將軍杜稜為領軍將軍', source: 'benji' },
{ year: 567, guan: '湘州刺史', ren: '華皎', dong: '反', juan: '卷四', cite: '安南將軍、湘州刺史華皎謀反,景申,以中撫大將軍淳于量為使持節、征南大將軍,總率舟師以討之', source: 'benji', note: '★ 華皎之亂:引北周、後梁兵,淳于量/吳明徹討平(見 CHAO_SHI)。' },
{ year: 567, guan: '車騎將軍', ren: '徐度', dong: '進', juan: '卷四', cite: '六月壬寅,以中軍大將軍、司空徐度進號車騎將軍', source: 'benji' },
{ year: 567, guan: '侍中、開府儀同三司', ren: '王沖', dong: '薨', juan: '卷四', cite: '甲子,侍中、中權將軍、開府儀同三司、特進、左光祿大夫王沖薨', source: 'benji' },
{ year: 568, guan: '太傅、領司徒', ren: '陳頊', dong: '進', juan: '卷四', cite: '二年春正月己亥,侍中、都督中外諸軍事、驃騎大將軍、司徒、錄尚書、揚州刺史安成王頊進位太傅,領司徒,加殊禮,劍履上殿', source: 'benji', note: '★★ 頊為太傅、加殊禮、劍履上殿 —— 已是人臣之极,去帝位只一步。同年十一月即废帝。' },
{ year: 568, guan: '侍中、中軍大將軍、開府儀同三司', ren: '淳于量', dong: '除', juan: '卷四', cite: '中撫大將軍、新除征南大將軍淳于量為侍中、中軍大將軍、開府儀同三司', source: 'benji' },
{ year: 568, guan: '開府儀同三司', ren: '吳明徹', dong: '進', juan: '卷四', cite: '安南將軍、湘州刺史吳明徹即本號開府儀同三司,進號鎮南將軍', source: 'benji' },
{ year: 568, guan: '司空', ren: '徐度', dong: '薨', juan: '卷四', cite: '侍中、司空、車騎將軍徐度薨', source: 'benji' },
{ year: 568, guan: '護軍將軍', ren: '沈恪', dong: '除', juan: '卷四', cite: '十一月景午,以前平西將軍、荊州刺史沈恪為護軍將軍', source: 'benji' },
{ year: 568, guan: '鎮北將軍、南徐州刺史', ren: '淳于量', dong: '除', juan: '卷四', cite: '新除中軍大將軍、開府儀同三司淳于量為鎮北將軍、南徐州刺史', source: 'benji' },
// ── 高宗 宣帝 太建(卷五,569–582)──
{ year: 569, guan: '尚書左僕射', ren: '沈欽', dong: '除', juan: '卷五', cite: '尚書僕射沈欽為尚書左僕射', source: 'benji' },
{ year: 569, guan: '尚書右僕射', ren: '王勱', dong: '除', juan: '卷五', cite: '度支尚書王勱為尚書右僕射', source: 'benji' },
{ year: 569, guan: '鎮南將軍、廣州刺史', ren: '沈恪', dong: '除', juan: '卷五', cite: '護軍將軍沈恪為鎮南將軍、廣州刺史', source: 'benji' },
{ year: 569, guan: '車騎大將軍', ren: '章昭達', dong: '進', juan: '卷五', cite: '征南大將軍、開府儀同三司、新除中撫大將軍章昭達進號車騎大將軍', source: 'benji' },
{ year: 569, guan: '征北大將軍', ren: '淳于量', dong: '除', juan: '卷五', cite: '新除中軍大將軍、開府儀同三司、南徐州刺史淳于量為征北大將軍', source: 'benji' },
{ year: 569, guan: '尚書右僕射', ren: '徐陵', dong: '除', juan: '卷五', cite: '丁巳,以吏部尚書、領大著作徐陵為尚書右僕射', source: 'benji' },
{ year: 569, guan: '吏部尚書', ren: '沈君理', dong: '除', juan: '卷五', cite: '太子詹事、駙馬都尉沈君理為吏部尚書', source: 'benji' },
{ year: 569, guan: '中護軍', ren: '陳伯恭', dong: '除', juan: '卷五', cite: '丁酉,以平東將軍、吳郡太守晉安王伯恭為中護軍,進號安南將軍', source: 'benji' },
{ year: 569, guan: '中領軍', ren: '陳伯恭', dong: '除', juan: '卷五', cite: '九月甲辰,以新除中護軍晉安王伯恭為中領軍', source: 'benji' },
{ year: 569, guan: '廣州', ren: '歐陽紇', dong: '反', juan: '卷五', cite: '冬十月,新除左衛將軍歐陽紇據廣州舉兵反', source: 'benji', note: '★ 歐陽頠之子,据父旧镇反,章昭達討平(見 CHAO_SHI)。' },
{ year: 570, guan: '車騎大將軍', ren: '章昭達', dong: '進', juan: '卷五', cite: '戊申,車騎將軍、開府儀同三司章昭達進號車騎大將軍', source: 'benji' },
{ year: 570, guan: '特進、護軍將軍', ren: '杜稜', dong: '除', juan: '卷五', cite: '九月乙丑,以散騎常侍、鎮東將軍、吳興太守杜稜為特進、護軍將軍', source: 'benji' },
{ year: 571, guan: '尚書僕射', ren: '徐陵', dong: '進', juan: '卷五', cite: '三年春正月癸丑,以尚書右僕射、領大著作徐陵為尚書僕射', source: 'benji' },
{ year: 571, guan: '司空', ren: '章昭達', dong: '薨', juan: '卷五', cite: '十二月壬辰,車騎大將軍、司空章昭達薨', source: 'benji', note: '★ 章昭達为宣帝一朝首将(平華皎、平歐陽紇),薨于北伐前夕。' },
{ year: 572, guan: '尚書左僕射', ren: '徐陵', dong: '進', juan: '卷五', cite: '尚書僕射、領大著作徐陵為尚書左僕射', source: 'benji' },
{ year: 572, guan: '尚書右僕射', ren: '王勱', dong: '除', juan: '卷五', cite: '中書監王勱為尚書右僕射', source: 'benji' },
{ year: 572, guan: '征南大將軍、南豫州刺史', ren: '黃法𣰋', dong: '除', juan: '卷五', cite: '夏四月戊子,以中權大將軍、開府儀同三司黃法𣰋為征南大將軍、南豫州刺史', source: 'benji' },
{ year: 572, guan: '尚書右僕射', ren: '王勱', dong: '卒', juan: '卷五', cite: '五月癸卯,尚書右僕射王勱卒', source: 'benji' },
{ year: 572, guan: '侍中、右光祿大夫', ren: '杜稜', dong: '卒', juan: '卷五', cite: '六月辛巳,侍中、鎮右將軍、右光祿大夫杜稜卒', source: 'benji' },
{ year: 572, guan: '領軍將軍', ren: '沈恪', dong: '除', juan: '卷五', cite: '戊戌,以鎮南將軍、廣州刺史沈恪為領軍將軍', source: 'benji' },
{ year: 573, guan: '中權大將軍', ren: '淳于量', dong: '除', juan: '卷五', cite: '五年春正月癸酉,以征北大將軍、開府儀同三司、南徐州刺史淳于量為中權大將軍', source: 'benji' },
{ year: 573, guan: '尚書右僕射、領吏部', ren: '沈君理', dong: '除', juan: '卷五', cite: '吏部尚書、駙馬都尉沈君理為尚書右僕射,領吏部', source: 'benji' },
{ year: 573, guan: '都督征討諸軍事', ren: '吳明徹', dong: '除', juan: '卷五', cite: '三月壬午,分命眾軍北伐,以鎮前將軍、開府儀同三司吳明徹都督征討諸軍事', source: 'benji', note: '★★ 太建北伐主帅。573 略淮南,578 呂梁全军覆没被俘(見 CHAO_SHI)。' },
{ year: 573, guan: '征北大將軍', ren: '吳明徹', dong: '進', juan: '卷五', cite: '秋七月乙丑,鎮前將軍、開府儀同三司吳明徹進號征北大將軍', source: 'benji' },
{ year: 573, guan: '尚書右僕射、領吏部', ren: '沈君理', dong: '卒', juan: '卷五', cite: '癸未,尚書右僕射、領吏部、駙馬都尉沈君理卒', source: 'benji' },
{ year: 573, guan: '吏部尚書', ren: '王瑒', dong: '除', juan: '卷五', cite: '戊戌,以中書令王瑒為吏部尚書', source: 'benji' },
{ year: 573, guan: '尚書右僕射', ren: '周弘正', dong: '除', juan: '卷五', cite: '己亥,以特進、領國子祭酒周弘正為尚書右僕射', source: 'benji' },
{ year: 573, guan: '車騎大將軍、豫州刺史', ren: '吳明徹', dong: '進', juan: '卷五', cite: '以征北大將軍、開府儀同三司吳明徹為豫州刺史,進號車騎大將軍', source: 'benji' },
{ year: 574, guan: '中領軍', ren: '陳伯固', dong: '除', juan: '卷五', cite: '以翊前將軍新安王伯固為中領軍,進號安前將軍', source: 'benji' },
{ year: 574, guan: '征西大將軍、郢州刺史', ren: '淳于量', dong: '除', juan: '卷五', cite: '景辰,以中權大將軍、開府儀同三司淳于量為征西大將軍、郢州刺史', source: 'benji' },
{ year: 574, guan: '尚書右僕射', ren: '周弘正', dong: '卒', juan: '卷五', cite: '六月壬辰,尚書右僕射、領國子祭酒周弘正卒', source: 'benji' },
{ year: 574, guan: '尚書右僕射', ren: '王瑒', dong: '除', juan: '卷五', cite: '戊戌,以吏部尚書王瑒為尚書右僕射', source: 'benji' },
{ year: 574, guan: '吏部尚書', ren: '孔奐', dong: '除', juan: '卷五', cite: '度支尚書孔奐為吏部尚書', source: 'benji' },
{ year: 575, guan: '尚書僕射', ren: '王瑒', dong: '進', juan: '卷五', cite: '壬辰,以尚書右僕射王瑒為尚書僕射', source: 'benji' },
{ year: 575, guan: '中領軍', ren: '陳叔堅', dong: '除', juan: '卷五', cite: '信威將軍、江州刺史長沙王叔堅為雲麾將軍、中領軍', source: 'benji' },
{ year: 575, guan: '中軍大將軍', ren: '淳于量', dong: '進', juan: '卷五', cite: '十一月庚戌,以征西大將軍、開府儀同三司、郢州刺史淳于量為中軍大將軍', source: 'benji' },
{ year: 575, guan: '尚書左僕射', ren: '王瑒', dong: '進', juan: '卷五', cite: '壬戌,以尚書僕射王瑒為尚書左僕射', source: 'benji' },
{ year: 575, guan: '尚書右僕射', ren: '陸繕', dong: '除', juan: '卷五', cite: '太子詹事、揚州大中正陸繕為尚書右僕射', source: 'benji' },
{ year: 575, guan: '領軍將軍', ren: '徐陵', dong: '除', juan: '卷五', cite: '國子祭酒徐陵為領軍將軍', source: 'benji' },
{ year: 576, guan: '司空', ren: '吳明徹', dong: '進', juan: '卷五', cite: '二月壬申,車騎大將軍、開府儀同三司吳明徹進位司空', source: 'benji' },
{ year: 576, guan: '尚書左僕射', ren: '王瑒', dong: '卒', juan: '卷五', cite: '〔五月〕庚寅,尚書左僕射王瑒卒', source: 'benji' },
{ year: 576, guan: '尚書左僕射', ren: '陸繕', dong: '進', juan: '卷五', cite: '甲寅,以尚書右僕射陸繕為尚書左僕射', source: 'benji' },
{ year: 576, guan: '尚書右僕射', ren: '王克', dong: '除', juan: '卷五', cite: '新除晉陵太守王克為尚書右僕射', source: 'benji' },
{ year: 576, guan: '中領軍', ren: '陳伯仁', dong: '除', juan: '卷五', cite: '辛丑,以冠軍將軍廬陵王伯仁為中領軍', source: 'benji' },
{ year: 577, guan: '揚州刺史', ren: '陳叔陵', dong: '除', juan: '卷五', cite: '壬寅,以湘州刺史、新除中衛將軍始興王叔陵為揚州刺史', source: 'benji', note: '★★ 叔陵得揚州(京畿),坐大五年,582 高宗崩即于灵前作逆(見 CHAO_SHI)。' },
{ year: 578, guan: '領軍將軍', ren: '徐陵', dong: '除', juan: '卷五', cite: '翊左將軍、右光祿大夫、領太子詹事徐陵為領軍將軍', source: 'benji' },
{ year: 578, guan: '司空', ren: '吳明徹', dong: '執', juan: '卷五', cite: '二月甲子,北討眾軍敗績於呂梁,司空吳明徹及將卒已下,並為周軍所獲', source: 'benji', note: '★★★ 兵敗被俘,非薨非降 —— 陳军主力尽丧于呂梁,司空以下全军被北周所获。国势自此顿衰(見 CHAO_SHI)。' },
{ year: 578, guan: '大都督', ren: '淳于量', dong: '除', juan: '卷五', cite: '景子,分命眾軍以備周:中軍大將軍、開府儀同三司淳于量為大都督,總水陸諸軍事', source: 'benji' },
{ year: 578, guan: '護軍將軍', ren: '陳伯固', dong: '除', juan: '卷五', cite: '丁巳,以新除鎮右將軍新安王伯固為護軍將軍', source: 'benji' },
{ year: 578, guan: '吏部尚書', ren: '袁憲', dong: '除', juan: '卷五', cite: '乙巳,以散騎常侍、兼吏部尚書袁憲為吏部尚書', source: 'benji', note: '★ 袁憲:亡國一刻唯一守殿之臣(見 589 条)。' },
{ year: 578, guan: '中領軍', ren: '樊毅', dong: '除', juan: '卷五', cite: '九月壬寅,以平北將軍樊毅為中領軍', source: 'benji' },
{ year: 578, guan: '尚書僕射', ren: '陸繕', dong: '進', juan: '卷五', cite: '戊子,以尚書左僕射陸繕為尚書僕射', source: 'benji' },
{ year: 579, guan: '中權將軍、護軍將軍', ren: '陳伯山', dong: '除', juan: '卷五', cite: '景戌,以征南將軍、江州刺史鄱陽王伯山為中權將軍、護軍將軍', source: 'benji' },
{ year: 579, guan: '尚書左僕射', ren: '陸繕', dong: '除', juan: '卷五', cite: '尚書僕射陸繕為尚書左僕射', source: 'benji' },
{ year: 580, guan: '平南將軍、南豫州刺史', ren: '任忠', dong: '除', juan: '卷五', cite: '十二年春正月戊戌,以散騎常侍、左衛將軍任忠為平南將軍、南豫州刺史,督緣江軍防事', source: 'benji', note: '★ 任忠:589 隋军入建康时出降韓擒虎(見 589 条)。' },
{ year: 580, guan: '中領軍', ren: '陳伯仁', dong: '除', juan: '卷五', cite: '三月壬辰,以平北將軍廬陵王伯仁為翊左將軍、中領軍', source: 'benji' },
{ year: 580, guan: '尚書左僕射', ren: '陸繕', dong: '卒', juan: '卷五', cite: '夏四月癸亥,尚書左僕射陸繕卒', source: 'benji' },
{ year: 580, guan: '尚書僕射', ren: '陳伯恭', dong: '除', juan: '卷五', cite: '五月癸巳,以軍師將軍、尚書右僕射晉安王伯恭為尚書僕射', source: 'benji' },
{ year: 580, guan: '侍中、大都督、車騎將軍、司空', ren: '司馬消難', dong: '除', juan: '卷五', cite: '詔以消難為使持節、侍中、大都督、總督安隨等九州八鎮諸軍事、車騎將軍、司空,封隨郡公', source: 'benji', note: '★ 北周司馬消難(尉遲迥之亂败)举九州来奔,陳授司空。580 之陳已守势,仍收纳北叛(見 CHAO_SHI)。' },
{ year: 580, guan: '南徐州刺史', ren: '陳叔獻', dong: '薨', juan: '卷五', cite: '十二月庚辰,宣毅將軍、南徐州刺史河東王叔獻薨', source: 'benji' },
{ year: 581, guan: '左光祿大夫', ren: '淳于量', dong: '除', juan: '卷五', cite: '十三年春正月壬午,以車騎將軍、開府儀同三司淳于量為左光祿大夫', source: 'benji' },
{ year: 581, guan: '尚書左僕射', ren: '陳伯恭', dong: '進', juan: '卷五', cite: '軍師將軍、尚書僕射晉安王伯恭為尚書左僕射', source: 'benji' },
{ year: 581, guan: '中書監、領太子詹事', ren: '徐陵', dong: '除', juan: '卷五', cite: '〔安〕右將軍、丹陽尹徐陵為中書監,領太子詹事', source: 'benji' },
{ year: 581, guan: '尚書右僕射', ren: '袁憲', dong: '除', juan: '卷五', cite: '吏部尚書袁憲為尚書右僕射', source: 'benji' },
{ year: 581, guan: '護軍將軍', ren: '樊毅', dong: '除', juan: '卷五', cite: '六月辛卯,以新除中護軍樊毅為護軍將軍', source: 'benji' },
{ year: 581, guan: '吏部尚書', ren: '毛喜', dong: '除', juan: '卷五', cite: '冬十月癸未,以散騎常侍、丹陽尹毛喜為吏部尚書', source: 'benji' },
{ year: 581, guan: '護軍將軍', ren: '沈恪', dong: '除', juan: '卷五', cite: '己亥,以翊右將軍、衛尉卿沈恪為護軍將軍', source: 'benji' },
// ── 後主 至德/禎明(卷六,582–589)──
{ year: 582, guan: '揚州刺史', ren: '陳叔陵', dong: '誅', juan: '卷六', cite: '乙卯,始興王叔陵作逆,伏誅', source: 'benji', note: '★★ 高宗崩之次日,揚州刺史始興王叔陵于灵前砍伤後主、作逆,蕭摩訶斩之。伏誅,非薨(見 CHAO_SHI)。' },
{ year: 582, guan: '驃騎將軍、開府儀同三司、揚州刺史', ren: '陳叔堅', dong: '除', juan: '卷六', cite: '癸亥,以侍中、翊前將軍、丹陽尹長沙王叔堅為驃騎將軍、開府儀同三司、揚州刺史', source: 'benji', note: '★ 长沙王叔堅平叔陵有功,得揚州;然次年即「有罪免」(見 583 条),一年荣枯。' },
{ year: 582, guan: '車騎將軍、南徐州刺史', ren: '蕭摩訶', dong: '除', juan: '卷六', cite: '右衛將軍蕭摩訶為車騎將軍、南徐州刺史', source: 'benji', note: '★ 斩叔陵者。589 隋军入城,蕭摩訶为都督(見 589 条)。' },
{ year: 582, guan: '中護軍', ren: '章大寶', dong: '除', juan: '卷六', cite: '仁武將軍、豐州刺史章大寶為中護軍', source: 'benji', note: '★ 585 举兵反(見 585 条)。' },
{ year: 582, guan: '左光祿大夫、領太子少傅', ren: '徐陵', dong: '除', juan: '卷六', cite: '以侍中、中書監、安右將軍徐陵為左光祿大夫,領太子少傅', source: 'benji' },
{ year: 582, guan: '尚書僕射', ren: '陳伯智', dong: '除', juan: '卷六', cite: '新除翊左將軍、永陽王伯智為尚書僕射', source: 'benji' },
{ year: 582, guan: '中護軍', ren: '孫瑒', dong: '除', juan: '卷六', cite: '六月癸酉朔,以明威將軍、通直散騎常侍孫瑒為中護軍', source: 'benji' },
{ year: 582, guan: '司空', ren: '陳叔堅', dong: '除', juan: '卷六', cite: '景寅,以驃騎將軍、開府儀同三司、揚州刺史長沙王叔堅為司空', source: 'benji' },
{ year: 583, guan: '中衛大將軍', ren: '陳叔英', dong: '除', juan: '卷六', cite: '以征南將軍、江州刺史、新除開府儀同三司豫章王叔英為中衛大將軍', source: 'benji' },
{ year: 583, guan: '江州刺史', ren: '陳叔堅', dong: '除', juan: '卷六', cite: '驃騎將軍、開府儀同三司、揚州刺史長沙王叔堅為江州刺史', source: 'benji', note: '★ 由揚州(京畿)外放江州,实为夺权前奏。' },
{ year: 583, guan: '領軍將軍', ren: '任忠', dong: '除', juan: '卷六', cite: '鎮南將軍、南豫州刺史任忠為領軍將軍', source: 'benji' },
{ year: 583, guan: '吏部尚書', ren: '江總', dong: '除', juan: '卷六', cite: '祠部尚書江總為吏部尚書', source: 'benji', note: '★ 江總:後主朝「狎客」之首,官至尚書令(見 586 条),亡國时居省中(見 589 条)。' },
{ year: 583, guan: '左光祿大夫、太子少傅', ren: '徐陵', dong: '卒', juan: '卷六', cite: '戊戌,侍中、安右將軍、左光祿大夫、太子少傅徐陵卒', source: 'benji', note: '★ 陳一代文宗、历仕四朝的徐陵卒,年七十七。' },
{ year: 583, guan: '司空', ren: '陳叔堅', dong: '免', juan: '卷六', cite: '司空長沙王叔堅有罪免', source: 'benji', note: '★★ 有罪免,非薨非誅 —— 一年前刚以平叔陵之功得揚州司空,此刻褫夺。免这一个字,是宗室内斗的翻覆。' },
{ year: 584, guan: '尚書僕射', ren: '江總', dong: '除', juan: '卷六', cite: '吏部尚書江總為尚書僕射', source: 'benji' },
{ year: 584, guan: '侍中、鎮左將軍', ren: '陳叔堅', dong: '除', juan: '卷六', cite: '秋七月戊辰,以長沙王叔堅為侍中、鎮左將軍', source: 'benji', note: '★ 免后逾年复起,然已远权力中枢。' },
{ year: 585, guan: '護軍將軍', ren: '樊毅', dong: '除', juan: '卷六', cite: '征西將軍、荊州刺史樊毅為護軍將軍', source: 'benji' },
{ year: 585, guan: '吏部尚書', ren: '陸瓊', dong: '除', juan: '卷六', cite: '守吏部尚書、領著作陸瓊為吏部尚書', source: 'benji' },
{ year: 585, guan: '豐州刺史', ren: '章大寶', dong: '反', juan: '卷六', cite: '三月辛酉,前豐州刺史章大寶舉兵反', source: 'benji' },
{ year: 585, guan: '吏部尚書', ren: '謝伷', dong: '除', juan: '卷六', cite: '己酉,以左民尚書謝(伸)〔伷〕為吏部尚書', source: 'benji', note: '★ 甲原作「伸」,中華書局〔〕校补作「伷」(校勘符,非正文)。' },
{ year: 586, guan: '尚書令', ren: '江總', dong: '除', juan: '卷六', cite: '冬十月癸亥,尚書僕射江總為尚書令', source: 'benji' },
{ year: 586, guan: '尚書僕射', ren: '謝伷', dong: '除', juan: '卷六', cite: '吏部尚書謝伷為尚書僕射', source: 'benji' },
{ year: 587, guan: '中書監', ren: '陳叔卿', dong: '除', juan: '卷六', cite: '二月丁未,……中書令建安王叔卿為中書監', source: 'benji' },
{ year: 587, guan: '驃騎將軍', ren: '蕭摩訶', dong: '進', juan: '卷六', cite: '丁未,以車騎將軍蕭摩訶為驃騎將軍', source: 'benji' },
{ year: 587, guan: '司徒(兼)', ren: '陳叔英', dong: '除', juan: '卷六', cite: '丁亥,以驃騎大將軍、開府儀同三司豫章王叔英兼司徒', source: 'benji' },
{ year: 587, guan: '平東將軍、開府儀同三司、東揚州刺史', ren: '蕭巖', dong: '除', juan: '卷六', cite: '景子,以蕭巖為平東將軍、開府儀同三司、東揚州刺史', source: 'benji', note: '★ 後梁安平王蕭巖率眾降陳,陳授开府(見 CHAO_SHI 587)。' },
{ year: 588, guan: '吏部尚書', ren: '蔡徵', dong: '除', juan: '卷六', cite: '戊午,以左民尚書蔡徵為吏部尚書', source: 'benji' },
{ year: 588, guan: '中權將軍', ren: '江總', dong: '進', juan: '卷六', cite: '宣惠將軍、尚書令江總進號中權將軍', source: 'benji' },
{ year: 588, guan: '尚書僕射', ren: '袁憲', dong: '除', juan: '卷六', cite: '雲麾將軍、太子詹事袁憲為尚書僕射', source: 'benji' },
{ year: 588, guan: '中領軍', ren: '魯廣達', dong: '除', juan: '卷六', cite: '甲辰,以安右將軍魯廣達為中領軍', source: 'benji', note: '★ 589 隋军渡江,魯廣達力战于白土岡,陳军唯一死战者。' },
{ year: 588, guan: '吏部尚書', ren: '姚察', dong: '除', juan: '卷六', cite: '辛丑,以度支尚書、領大著作姚察為吏部尚書', source: 'benji', note: '★★★ 姚察本人 —— 撰《陳書》者,此刻正做陳的吏部尚書。写这部书的史官,亲历了它的最后一年。这是「分身不是传主、分身是史官」在史料里的一次对撞。' },
{ year: 589, guan: '鎮東大將軍', ren: '任忠', dong: '降', juan: '卷六', cite: '己卯,鎮東大將軍任忠自吳興入赴……任忠出降於擒虎,仍引擒虎經朱雀航趣宮城', source: 'benji', note: '★★ 出降,非奔非死 —— 陳最后的军事支柱任忠,反手引隋将韓擒虎入宫城。降这一个字,是亡国之夜的转折。' },
{ year: 589, guan: '尚書僕射', ren: '袁憲', dong: '見', juan: '卷六', cite: '於是城內文武百司皆遁出,唯尚書僕射袁憲在殿內', source: 'benji', note: '★★ 亡國一刻,百官皆遁,唯袁憲一人守殿侍後主。见任而不去 —— 这一条是「位阶」与「气节」的分野。' },
{ year: 589, guan: '尚書令', ren: '江總', dong: '見', juan: '卷六', cite: '尚書令江總、吏部尚書姚察、度支尚書袁權、前度支尚書王瑗、侍中王寬居省中', source: 'benji', note: '★ 亡國之刻的省中名单(見任)。' },
{ year: 589, guan: '吏部尚書', ren: '姚察', dong: '見', juan: '卷六', cite: '尚書令江總、吏部尚書姚察、度支尚書袁權、前度支尚書王瑗、侍中王寬居省中', source: 'benji', note: '★★ 撰史者姚察,亡國之刻在省中 —— 他日后要写的最后一页,是他自己站在里面的那一页。' },
{ year: 589, guan: '侍中', ren: '王寬', dong: '見', juan: '卷六', cite: '尚書令江總、吏部尚書姚察、度支尚書袁權、前度支尚書王瑗、侍中王寬居省中', source: 'benji' },
];
// ═══ 二、大事件(CHAO_SHI)═══
// 每条:{ year, yue, shi(原文/纪事), juan, layer:"shi"|"zhi"|"dao", kind:"yuanwen"|"tuiyan", zaichang?, note? }
// ★ 制/道 事件另叠加:layerWhy(强制) / impact(下游影响链) / witnesses+crossWitness(补事件的双证)。
// ★ layer 判定见文件头:陳绝大多数是「事」;「道」0(唯一够得着道的 589「南北一統」属【隋】不属陳,见该条 note)。
// ★★ 「制」上一刀判 0(严判:要求制存续 50 年方算制);本刀【據建模 §1.3/§3.3 補为 2】——
// 建模層典按【类型】计律/铸钱/官制之确立即为「制」(存续长短只分制/道,不分事/制)。本刀补入两条制度条:
// 557「立刪定郎,治定律令」(律) 与 579「初用大貨六銖錢」(铸钱),均补为 layer:"zhi"、三证原文、挂【诚实 impact】
// (★ 非谓本紀制度条仅此二条:卷五太建十一年甲寅诏「改不枉法受財者,科同正盜」等未逐条补尽)
// (二者影响半径皆止当朝 reach:"dangchao",陳制无一存续隋唐 —— 不编「陳制→隋唐」的因果)。
// ★ 557 前为【梁末】陳霸先受禪之路,纪年用梁年號,layer 仍 shi(一场仗/一次禪代)。
export const CHAO_SHI = [
// ── 开国之路(卷一,梁末,557 前)──
{ year: 548, yue: '冬', shi: '侯景寇京師(侯景之亂起)。高祖時在嶺南,將率兵赴援,廣州刺史元景仲陰有異志。', juan: '卷一', layer: 'shi', kind: 'yuanwen', zaichang: '方鎮刺史要么勤王要么观望(元景仲即怀异志)、郡守要守土、府中参军要随军北上 —— 一场倾覆梁室的大乱,落到岭南一个中直兵参军身上,是「去不去救、跟谁走」。', note: '★ 侯景之亂是陳霸先一生的枢机:正因梁室在此乱中糜烂,他才由岭南偏将十年而至受禪。层为「事」(一场叛乱),但它是本朝一切的起点。' },
{ year: 550, yue: '正月', shi: '大寶元年正月,高祖發自始興,次大庾嶺,大破蔡路養,進頓南康。北上勤王之始。', juan: '卷一', layer: 'shi', kind: 'yuanwen' },
{ year: 552, yue: '三月', shi: '高祖率甲士三萬會王僧辯,剋姑熟、克石頭城,侯景眾大潰、遁走(旋為部下所殺)。平侯景。', juan: '卷一', layer: 'shi', kind: 'yuanwen', zaichang: '都督/刺史要出兵会师、长史参军要筹三十万石军粮(高祖先贮五十万石分济西军)、士卒要攻城。同一场决战,主帅争的是首功、斗食小吏管的是一船军米。', note: '★ 平侯景使陳霸先与王僧辯并列为梁室两大柱石,王跡所基始自於此。层「事」——一场平乱之战,虽再造江南,仍不构成延续五十年的制度。' },
{ year: 554, yue: '十一月', shi: '承聖三年十一月,西魏攻陷江陵,梁元帝死。梁室中枢一夕倾覆。', juan: '卷一', layer: 'shi', kind: 'yuanwen', note: '★ 江陵之陷造成权力真空,直接导致次年王僧辯纳北齐所立之贞阳侯、与陈霸先决裂。' },
{ year: 555, yue: '九月', shi: '承聖四年九月,高祖夜發南徐州襲殺王僧辯(是夜縊僧辯及其子頠),廢貞陽侯,立晉安王(梁敬帝),改元紹泰。', juan: '卷一', layer: 'shi', kind: 'yuanwen', zaichang: '同为方鎮的大将要么随之要么被诛(王僧辯即被杀)、百僚要奉表劝进新主、侍中尚书要连夜草诏改立。这一夜,「站在谁一边」就是生死。', note: '★★ 誅王僧辯是陳霸先夺权的决定性一刀:从此江南再无与他并肩者。层「事」——一次政变+废立。' },
{ year: 556, yue: '六月', shi: '紹泰二年六月,北齊發兵十萬(蕭軌、東方老等)攻建康,高祖大破之,虜蕭軌以下四十六將,皆伏誅。', juan: '卷一', layer: 'shi', kind: 'yuanwen', zaichang: '诸将要分头拒战、水军要断敌粮运、都邑百姓夹淮观战呼声震天。一场保卫都城的会战,位居都督者定生死于阵前,编户小民只能在岸上看。', note: '★ 此役廓清北齐对建康的直接威胁,为次年受禪扫平障碍。层「事」——一场歼灭战,虽死伤以万计。' },
{ year: 557, yue: '十月', shi: '永定元年冬十月乙亥,梁敬帝禪位,高祖即皇帝位于南郊,國號陳,改梁太平二年為永定元年。陳朝立。', juan: '卷二', layer: 'shi', kind: 'yuanwen', zaichang: '侍中/尚书要草禅位诏与劝进表、公卿百官要列班受禅、外州刺史要奉新朔、县令要安集改元、编户百姓要「洗除先注,与之更始」。开国这件大事,宰辅在坛上署名,斗食小吏在乡里改籍。', note: '★ 受禪建陳是一次禪代(改朝换代),依老板判据属「事」。这是本朝的第一天,也是「我在陳的位置」这一切题目的起点。' },
// ── 高祖 永定(557–559)──
// ── ★★ 制度条 1/2(本刀據建模 §3.3 補;紅線:三證原文+誠實 impact,不編「陳制→隋唐」因果)──
{ year: 557, yue: '冬', shi: '永定元年,高祖受禪之初,立刪定郎,治定律令(草創陳之律典)。', juan: '卷二', layer: 'zhi', kind: 'yuanwen',
layerWhy: '「立刪定郎,治定律令」是【律典之確立】——按建模層典,律/鑄錢/官制之確立即計為「制」(不論存續長短,長短只分制/道)。這是陳一代成文法的起點,故 layer=zhi 而非 shi。',
witnesses: 3,
crossWitness: '★ 三證:甲(wikisource 中華書局系)=乙(kanripo 四庫本 WYG) 剝標點後逐字皆作「立刪定郎,治定律令」(僅祔/袝、卯/夘等異形);丙《南史》卷九作「立刪定郎,刊定律令」——「治定/刊定」一字異,然【立刪定郎以定律令】此事三證同。本 shi 取甲字「治定」。',
zaichang: '一部新律的訂立,落在不同位階是不同的題:新設的【刪定郎】要逐條刊定律文、廷尉/尚書要據新律斷獄、州郡長官要照新律理民、編戶百姓從此按這部律受刑受贖。開國定律,宰輔在朝堂議律,斗食獄吏在縣寺用律。',
impact: {
reach: 'dangchao', kind: 'tuiyan',
reason: '陳律承東晉南朝律學一脈(晉《泰始律》→梁律→陳律),為陳一代之現行法典;然隨 589 陳亡即廢,隋一統後行《開皇律》。就【陳律本身】而言未存續至隋唐。⚠「南朝律學是否為隋唐律淵源之一」學界有爭(陳寅恪《隋唐制度淵源略論稿》刑律章所論為【梁律/南朝律學經北齊律入隋唐】,未及【陳律】;程樹德《九朝律考》主隋律本北齊《河清律》)。★ 此二說係學界轉述,不在本表 curl 雙證原文的範圍內,僅備異說、不充作據——本表【不裁此爭】,只記雙方都認可的事實(陳律作為現行法典未直接下沿),不編「陳律→隋唐律」的因果。',
to: [{ label: '589 陳亡,陳律廢;隋唐律典主承北齊《河清律》一系(制度史通說)' }],
reachWhy: '陳律行用僅 32 年(557–589),夠不到「五十年後還在起作用」,故影響半徑=當朝。',
},
note: '★ 陳本紀所載制度條之一(本刀補入的兩條之一,另一為 579 大貨六銖;★ 非謂本紀僅此二條——卷五太建十一年甲寅詔「今可改不枉法受財者,科同正盜」即另一條律條修訂,本刀未逐條補盡)。也是這一朝給產品的注腳:在陳定律的人,其律隨國亡而廢——制度的「後世影響」一欄,誠實地寫著【止於當朝】。' },
{ year: 558, yue: '', shi: '永定二年,遣司空侯瑱、領軍將軍徐度等西討王琳(梁室残余、北齐所立蕭莊政权)。', juan: '卷二', layer: 'shi', kind: 'yuanwen', note: '★ 王琳据郢州奉萧庄、结北齐,是陈初最大外患,至 560 梁山之战方定。' },
{ year: 559, yue: '六月', shi: '永定三年六月景午,高祖崩于璿璣殿,時年五十七。遺詔徵臨川王蒨(世祖)入纂。', juan: '卷二', layer: 'shi', kind: 'yuanwen', zaichang: '宗室与顾命大臣要定策迎立、尚书省要发哀改元、方鎮要按兵待命。开国之君骤崩、嗣君远在外镇,这一刻「你在不在中枢、听谁调度」是全局的关节。' },
// ── 世祖 天嘉/天康(559–566)──
{ year: 560, yue: '二月', shi: '天嘉元年二月,太尉侯瑱大破王琳于梁山,又敗齊兵于博望,王琳及其所奉蕭莊奔于齊。', juan: '卷三', layer: 'shi', kind: 'yuanwen', note: '★★ 梁山之战一举扫平王琳与北齐扶植的萧庄政权,陈朝始得立足。层「事」——一场决定国运的水战。' },
{ year: 560, yue: '三月', shi: '天嘉元年三月,衡陽王昌(高祖嫡子,自北周還)渡江,溺死。', juan: '卷三', layer: 'shi', kind: 'yuanwen', zaichang: '陈昌是高祖亲子、法理上的第一顺位;他一还国,已即位的世祖(高祖之侄)便地位尴尬。护送、迎接、乃至「船破溺死」的经手者,都卷进一桩帝位疑案。', note: '★ 本紀只书「薨」于渡江,未言其详;然嫡子甫归即溺,世祖侄继叔位,史家多疑。本表不裁,只记其为「事」(一桩宗室之死牵动帝位)。' },
{ year: 562, yue: '三月', shi: '天嘉三年,安成王頊(後之高宗宣帝)自北周質還,授侍中、中書監。', juan: '卷三', layer: 'shi', kind: 'yuanwen', note: '★ 頊本为质于北周(江陵陷时被掳),此还国为其日后入纂、代立埋线。' },
{ year: 563, yue: '六月', shi: '天嘉四年六月,司空侯安都賜死。', juan: '卷三', layer: 'shi', kind: 'yuanwen', zaichang: '侯安都是助世祖登位的第一功臣、拥兵方鎮之首。他之死,令所有握兵的都督刺史自危、令侍中尚书要斟酌诏辞、令廷尉要行刑。「功高」在这一朝可以是催命符 —— 位阶越高,这道题越致命。', note: '★★ 賜死一词是这条的全部:不是薨、不是伏诛,是天子赐死功臣。层「事」——一桩诛戮,但它定义了陈朝君臣关系的底色。' },
{ year: 564, yue: '', shi: '天嘉中,章昭達等次第平定周迪、留異、陳寶應諸方鎮之亂(贛、閩),江南底定。', juan: '卷三', layer: 'shi', kind: 'yuanwen', note: '★ 陈初「三叛」(王琳、华皎为外,周迪/留异/陈宝应为内)至此平尽,世祖一朝主要功业。' },
{ year: 566, yue: '四月', shi: '天康元年四月癸酉,世祖崩于有覺殿,太子伯宗(廢帝)即位。', juan: '卷三', layer: 'shi', kind: 'yuanwen' },
// ── 廢帝 光大(566–568)──
{ year: 567, yue: '', shi: '光大元年,湘州刺史華皎謀反,引北周及後梁之兵順流東下,淳于量、吳明徹率舟師討平之。', juan: '卷四', layer: 'shi', kind: 'yuanwen', zaichang: '一个方鎮举兵、勾连两个外朝(北周+后梁),朝廷要遣征南大将军总舟师、要沿江州郡出兵出粮。位居水军都督者担成败,缘江郡守担粮秣。', note: '★ 华皎之乱是废帝短祚间最大一战,也是安成王頊(时执朝政)借以立威、进而代立的背景。' },
{ year: 568, yue: '十一月', shi: '光大二年十一月甲寅,慈訓太后令廢帝為臨海王,以安成王頊入纂。廢立。', juan: '卷四', layer: 'shi', kind: 'yuanwen', zaichang: '一次废黜天子:太后下令、宗室执行、侍中尚书署令改立、方鎮观望向背。身居中枢者要在「保少主」与「附强藩」之间押命 —— 到仲舉、劉師知等辅政之臣正因此先死。', note: '★★ 废立是老板明列的「事」。頊废侄自立,与其父高祖受禪、其后自身崩逝,构成陈室三代权力更替中最赤裸的一次。' },
// ── 高宗 宣帝 太建(569–582)──
{ year: 569, yue: '正月', shi: '太建元年正月甲午,安成王頊即皇帝位(高宗宣帝)。', juan: '卷五', layer: 'shi', kind: 'yuanwen' },
{ year: 569, yue: '十月', shi: '太建元年十月,歐陽紇(歐陽頠之子)據廣州舉兵反,遣車騎將軍章昭達討平之。', juan: '卷五', layer: 'shi', kind: 'yuanwen', note: '★ 岭南欧阳氏两世据广州,至此夷灭。' },
{ year: 570, yue: '四月', shi: '太建二年四月乙卯,臨海王伯宗(廢帝)薨,時年十九。', juan: '卷五', layer: 'shi', kind: 'yuanwen', note: '★ 被废之君两年后死于王邸,用「薨」(王礼)。与 AGE_CONFLICT(年十九之算术冲突)相关,本表不裁。' },
{ year: 573, yue: '三月', shi: '太建五年三月,分命眾軍北伐,以吳明徹都督征討諸軍事,伐北齊,略取淮南諸州。', juan: '卷五', layer: 'shi', kind: 'yuanwen', zaichang: '一次倾国北伐:都督统军、诸州刺史分道、缘淮郡守征粮征役、士卒渡淮送死。位居都督者搏功名,编户小民出丁出谷。这是陈朝唯一一次主动向北的大题。', note: '★★ 太建北伐是陈朝武功之顶,一度收复淮南、饮马淮河。层「事」——一场大规模战役。虽收地千里,五年后(呂梁)尽数丧失,未成延续之「制」。' },
{ year: 577, yue: '十月', shi: '太建九年十月,司空吳明徹破周將梁士彥眾數萬于呂梁(時北周已滅北齊)。北伐后期一胜。', juan: '卷五', layer: 'shi', kind: 'yuanwen', note: '★ 577 北周灭北齐、独霸北方,陈之北伐对象由齐转周,形势逆转,为次年呂梁大败张本。' },
{ year: 578, yue: '二月', shi: '太建十年二月甲子,北討眾軍敗績於呂梁,司空吳明徹及將卒以下並為周軍所獲。陳軍主力盡喪。', juan: '卷五', layer: 'shi', kind: 'yuanwen', zaichang: '一次全军覆没:主帅司空被俘、将卒以下尽入敌手;缘江州郡要立刻转攻为守、朝廷要重新分兵备周。位居前军者身死或被虏,后方郡县要收拾溃卒、再征丁壮补防线。', note: '★★★ 呂梁之败是陈朝命运的转折:太建北伐所得淮南尽失,主力精锐一战丧尽,此后陈只能守长江、坐待被灭。层「事」——一场败仗,哪怕丧师十万,仍是「事」;但它是本朝由攻转亡的分水岭。' },
{ year: 579, yue: '', shi: '太建十一年,北周盡取淮南,陳退守長江。太建北伐所得州郡,一岁间丧尽。', juan: '卷五', layer: 'shi', kind: 'yuanwen', note: '★ 淮南既失,陈与北朝之界复归长江,攻守之势永易 —— 十年后隋即由此渡江灭陈。' },
// ── ★★ 制度条 2/2(本刀據建模 §3.3 補;紅線:三證原文+誠實 impact)──
{ year: 579, yue: '七月', shi: '太建十一年秋七月辛卯,初用大貨六銖錢(宣帝更鑄大錢)。', juan: '卷五', layer: 'zhi', kind: 'yuanwen',
layerWhy: '鑄錢=幣制變革,按建模層典屬「制」。此為陳一代幣制的標誌一步(世祖天嘉三年 562 曾「改鑄五銖錢」,宣帝此年更鑄大貨六銖),故 layer=zhi。',
witnesses: 3,
crossWitness: '★ 三證逐字全同:甲(wikisource)=乙(kanripo 四庫本)=丙《南史》卷十 皆作「秋七月辛卯,初用大貨六銖錢」(僅卯/夘異形)。另世祖 562「甲子,改鑄五銖錢」亦三證逐字同(卷三/南史卷九),本刀併入此條 note,不另立事。★ 紀志異說(本表不裁):562 改鑄五銖,《陳書》卷三、《南史》卷九均繫【天嘉三年閏二月甲子=562】,而《隋書·食貨志》作「至文帝天嘉五年,改鑄五銖」=【564】,紀志相差二年。本表從本紀取 562,並存志說,不裁此異。',
zaichang: '幣制一改,位階各受其擾:太府/少府職鑄錢者要開爐改鑄、市令要按新錢定價、商賈要換算新舊、編戶百姓手中舊錢一夕貶值。「大錢」讓握權定幣者得利、讓市井小民吃虧——同一道詔,宰輔在議,細民在算。',
impact: {
reach: 'dangchao', kind: 'tuiyan',
reason: '南朝幣制積弊已久(五銖與私鑄小錢雜行);大貨六銖為【虛值大錢】。《隋書·食貨志》陳段原文:「宣帝太建十一年,又鑄大貨六銖,以一當五銖之十,與五銖並行。後還當一,人皆不便。乃相與訛言曰:『六銖錢有不利縣官之象。』未幾而帝崩,遂廢六銖而行五銖。竟至陳亡。」⇒ 六銖自 579 秋七月行用,至宣帝崩(太建十四年正月甲寅=582,見本表同卷)即廢,前後【約三年】;廢因是【虛值崩壞(一當十→後還當一)+訛言+帝崩】,【非】亡國所廢。其後復行五銖,是【五銖】方「竟至陳亡」。⚠ 本表【不接】「陳錢→隋開皇五銖一統」這條下游:《隋書·食貨志》全篇無「平陳」「江南雜錢」之文;志中「自是錢貨始一」繫【開皇五年(585)正月】,在平陳之前,所針對者為五行大布、永通萬國、齊常平等【北方舊錢】;且同志開皇十八年(598)「是時江南人間錢少,晉王廣又聽於鄂州白紵山有銅𨥥處,錮銅鑄錢」——隋在江南是【增爐補鑄】而非廢錢一統。此鏈無據,故不編。',
to: [{ label: '582 宣帝崩,即廢六銖、復行五銖;五銖「竟至陳亡」(589)——大貨六銖本身未及陳末(《隋書·食貨志》)' }],
reachWhy: '大貨六銖行用僅【約三年】(579 秋七月–582),廢於宣帝崩後,遠夠不到「五十年後還在起作用」,故影響半徑=當朝。⚠ 不編「陳錢→隋錢」的因果:隋幣制之歸一,志文繫於平陳之前的北方,與陳錢無涉。',
},
note: '★ 大貨六銖今為錢幣史名品(篆文精整),然那是文物之名,非制度之存續:作為流通貨幣,它只行【約三年】(579–582),宣帝一崩即廢,復行五銖以至陳亡(《隋書·食貨志》)。' },
{ year: 580, yue: '八月', shi: '太建十二年,北周司馬消難(尉遲迥之亂敗)舉九州八鎮來奔,陳授侍中、大都督、司空,封隨郡公。', juan: '卷五', layer: 'shi', kind: 'yuanwen', note: '★ 此为杨坚代周(次年即建隋)前夜北方大乱之余波;陈虽纳其降,已无力乘之北取。' },
// ── 後主 至德/禎明(582–589)──
{ year: 582, yue: '正月', shi: '太建十四年正月,高宗崩。次日(乙卯),揚州刺史始興王叔陵作逆(于高宗靈前砍傷太子),伏誅(蕭摩訶斬之)。太子叔寶即位(後主)。', juan: '卷六', layer: 'shi', kind: 'yuanwen', zaichang: '天子崩、亲王于灵前夺嫡:宿卫要临机护主或附逆、宗室诸王要各择向背、执兵之将(蕭摩訶)一击定乾坤。位在禁卫中枢者,一念之间是从龙功臣还是叛党。', note: '★★ 叔陵之亂是又一次未遂的夺位(伏诛,非薨)。它把后主推上皇位,也耗尽了陈室最后的宗室元气。' },
{ year: 583, yue: '十二月', shi: '至德元年十二月,司空長沙王叔堅有罪免。平叔陵之功臣、一年前之揚州司空,一朝褫夺。', juan: '卷六', layer: 'shi', kind: 'yuanwen', note: '★ 叔堅去而後主一朝再无强宗辅政,江总等「狎客」当轴,政日以荒。免这一个字里,是陈室自剪羽翼。' },
{ year: 585, yue: '三月', shi: '至德三年三月,前豐州刺史章大寶舉兵反(旋平)。', juan: '卷六', layer: 'shi', kind: 'yuanwen' },
{ year: 587, yue: '九月', shi: '禎明元年九月,隋廢後梁,後梁安平王蕭巖、義興王蕭瓛率眾降陳,陳授開府儀同三司。', juan: '卷六', layer: 'shi', kind: 'yuanwen', note: '★ 隋灭后梁(江陵傀儡),除去陈上游的缓冲,隋军前锋直抵陈境。距灭陈仅一年半。' },
{ year: 589, yue: '正月', shi: '禎明三年正月,隋軍南北道並進,渡江陷建康。任忠出降,引隋將韓擒虎入宫城,後主自投井被執。晉王廣入據京城。陳亡。', juan: '卷六', layer: 'shi', kind: 'yuanwen', zaichang: '亡国之日,位阶决定结局各异:尚书令、吏部尚书、侍中「居省中」待命(江总、姚察、王宽),尚书仆射袁宪独守殿侧不去,鎮东大将军任忠反手出降、引敌入城,方鎮刺史或降或死,编户百姓自此改籍入隋。同一场亡国,宰辅、忠臣、叛将、小民,是四道完全不同的题。', note: '★★★ 层为「事」而非「道」——这是本表最需要说清的一条判定:\n 从【陳的账本】看,589 是【亡國】,一场王朝之死,故 layer:"shi"。\n 但这同一件事的【另一面】是【隋滅陳、南北一統】:它结束了自西晉永嘉(317)以来近三百年的南北分裂,此后中国以统一帝国为常态者一千三百余年 —— 那一面确实够得着「道」。\n ★ 然而那个「道」是【隋】缔造的,不在陳的名下。《陳書》記的是後主投井、韓擒虎入城。所以本表【不把 589 算作陳的「道」】,陳的「道」数 = 0。\n ⇒ 这正是本产品在陳这一朝要交给用户的话:在陳做官的人,一辈子活在「事」的那一层;他没能让任何「制」生效,就在 589 年成了那个大「道」的【承受者】而非缔造者。\n ★ 灭陈之隋将,卷六作「韓擒虎」——姚思廉原文当作「韓擒武」(避唐諱),两见证皆已回改(見 BIHUI)。终结这个王朝的人,在这个王朝的正史里连真名都不能用。' },
];
// ═══ 三、统一 export(硬性,名字一字不改)═══
export const CHAO_META = {
code: 'chen',
name: '陈',
span: '557–589',
qi: 557,
zhi: 589,
zhengshi: '《陳書》',
biao: null, // ★ 陳无表(本紀六+列傳三十,无表无志)。群臣/大事全从本紀抠。
};
// ★ 帝:从已有的 EMPERORS(双证考据结构,保留不动)映射为统一形状。
// qi=即位年(li),zhi=去位年(崩/废/亡=beng);後主 zhi=589(亡國),实卒于隋仁壽四年(604),另记 zuReal。
export const CHAO_DI = EMPERORS.map((e) => ({
miao: e.miao, // 廢帝/後主为 null(本朝未上廟號,见 miaoNote)
shi: e.shi, // 後主 shi=null(陳未上諡;隋諡「煬」另见 shiSui)
xing: e.xing,
qi: e.li, // 即位年
zhi: e.beng, // 去位年(崩/废/亡)
zuReal: e.zu ?? e.beng, // 实际卒年(廢帝 570、後主 604)
nianhao: e.nianhao.map((h) => ({ hao: h.hao, qi: h.qi, zhi: h.zhi })),
note: e.miaoStatus === 'none-in-source'
? (e.xing === '陳伯宗' ? '廢帝:無廟無諡,被廢後以臨海王薨(570)。' : '後主:無廟無陳諡,亡國後以隋長城縣公薨(604),隋諡「煬」。')
: undefined,
}));
export const CHAO_COVERAGE = {
di: {
from: 557,
to: 589,
note: '★ 五帝【全】(高祖/世祖/廢帝/高宗/後主),双证考据(見 EMPERORS/WITNESSES)。追尊之太祖景皇帝(陳文讚)不在位,不入(見 ZHUIZUN)。',
},
chen: {
from: 548, // 群臣最早一条起于开国之路(周文育等),但核心自 557 受禪起
to: 589,
note:
'★★ 陳【无年表】—— 全部群臣从本紀卷一–卷六编年正文抠任命句(source 皆 "benji",无一 "biao")。\n' +
'★ 覆盖到哪一档:【宰辅层做全】(三公=太尉/司徒/司空/大司馬,上公=太傅/太保/太宰,尚書令/左右僕射,' +
'中書監/令,侍中,領軍/護軍/中領軍/中護軍,錄尚書,都督中外,揚州刺史=京畿,吏部尚書,開府儀同三司初授)——' +
'凡本紀载其拜/進/遷/免/薨/卒/賜死/誅/奔/反/降/執者,逐条收录。\n' +
'🚫 【明确未做全(非静默截断,在此声明)】:①九卿/诸曹尚書(度支/左民/祠部/都官等)只在其人升入宰辅层时带到,未系统穷尽;' +
'②外州刺史(江州/南徐/郢州等)只在与宰辅层任命同句时收,未单列;③列傳三十卷【完全未抠】——' +
'本刀只据【本紀】,列傳里另有大量九卿以下、地方僚佐的任免,是【下一刀】的活。\n' +
'★ 数目:CHAO_CHEN 现有约 130 条,覆盖宰辅层 33 年。要更细的下层名单,须转抠列傳(工作量与「从散文里抠」同级)。',
},
shi: {
from: 548,
to: 589,
note:
'★ 大事全从本紀编年正文抠(source 皆 benji)。557 前为梁末陳霸先受禪之路(卷一),纪年用梁年號,仍收入(本紀卷一即其编年)。\n' +
'★★ layer 分布:【事 30 / 制 2 / 道 0】。事=30 是骨干大事(禪代/废立/功臣诛/大战/亡国)。\n' +
'★ 制=2 是本刀據建模 §3.3 補的两条本紀制度条:557「立刪定郎,治定律令」(律)、579「初用大貨六銖錢」(铸钱)——' +
'三证原文(wikisource 中華書局系=kanripo 四庫本=《南史》),layer:"zhi"。' +
'★ 制=2 是【本表已收之数】,非谓本紀制度条仅二:卷五太建十一年甲寅诏「改不枉法受財者,科同正盜」等尚未逐条补入,此数会随续补而增。' +
'★★ 但二者的 impact【影响半径皆止当朝】(reach:"dangchao"):陳国祚仅 33 年,589 被隋整体吞并,律令/币制无一存续到隋唐——' +
'这正是产品线要的老实答案:「一步制度变革,千年后还在起作用吗?」在陳答【没有】(不编「陳制→隋唐」的因果,见各条 impact)。\n' +
'★ 道=0 不是漏:唯一够得着「道」的是 589 的另一面「隋滅陳、南北一統」,但那个道属【隋】不属【陳】,故 589 记 layer:"shi"(见该条 note)。\n' +
'★ 本紀里逐日的祥瑞、祭祀、赦令、朝贡未逐条收(那是编年流水,非「大事」)。每条大事另附 zaichang(在场,推演,kind 若为 tuiyan 则不安卷次)。',
},
};
// ═══ 四、纯查询函数(别造第五张死表:数据+查询一起给)═══
// 那一年在位的皇帝(+ 该年【行用】的年號)。边界年(如 559/566/582)返回【当年最终在位】者(新即位者优先)。
// ★★ 关键:本朝五帝有四帝【逾年改元】—— 新君即位当年【仍行旧年號】,次年正月才改元。
// ⇒ 边界年的「在位皇帝」是新君,而「行用年號」却仍属【前一系】:
// 559 世祖已即位,行用的仍是高祖的【永定】;566 廢帝已即位,行用的仍是世祖的【天康】;
// 582 後主已即位,行用的仍是高宗的【太建】。
// 所以年號必须【跨全部帝】扫(谁的年號覆盖这一年就是谁的),不能只看当年在位那个帝自己的年號,
// 否则边界年会得到「年號=无」的假空缺。这一条正是本朝反复强调的 逾年改元(见 NIANHAO_NOTE)。
export function diAt(year) {
const inReign = CHAO_DI.filter((d) => year >= d.qi && year <= d.zhi);
if (inReign.length === 0) return null;
// 新即位者(qi==year)优先,否则取即位最晚者(当年末的在位者)
const di =
inReign.find((d) => d.qi === year) ||
inReign.reduce((a, b) => (b.qi > a.qi ? b : a));
// 行用年號:跨全部帝扫(可能不止一个,如 566 有天嘉七年→天康元年)
const nianhao = [];
const ownerOf = {};
for (const d of CHAO_DI) {
for (const h of d.nianhao) {
if (year >= h.qi && year <= h.zhi) {
nianhao.push({ hao: h.hao, qi: h.qi, zhi: h.zhi });
ownerOf[h.hao] = d.xing;
}
}
}
// 逾年改元标记:当年行用的年號,其所属帝 ≠ 当年在位帝 ⇒ 说明是「新君即位、旧号未改」的那一年
const yunnian = nianhao.some((h) => ownerOf[h.hao] !== di.xing);
return {
miao: di.miao,
shi: di.shi,
xing: di.xing,
nianhao, // 该年行用的年號(跨帝,边界年可含前一系的年號)
boundary: inReign.length > 1 ? inReign.map((d) => d.xing) : undefined,
yunnian: yunnian || undefined, // true = 逾年改元之年(在位者为新君,年號仍属旧系)
};
}
// 那一年的群臣(任免/薨卒/诛奔等一切本紀所载的位阶变动)。
export function chenAt(year) {
return CHAO_CHEN.filter((c) => c.year === year);
}
// 那一年的大事。
export function shiAt(year) {
return CHAO_SHI.filter((s) => s.year === year);
}
// ★ 便捷:给定「年 + 位阶关键字」,回答「那年这个位子上是谁 / 谁在这年出了事」。
// —— 这正是老板那句「景帝三年你是中大夫,你的御史大夫叫晁错,那年他被腰斩」的查询形。
export function chenAtGuan(year, guanKeyword) {
return CHAO_CHEN.filter((c) => c.year === year && c.guan.includes(guanKeyword));
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More