FSD M9 · 剧本库与投稿
← 总则 | 附录 → | 上游:PRD §4 M9 实现落点:
backend/internal/domain/scriptlib/(model/repo/service〔scriptlib.js 9+ensureSeed〕/service_studio〔studio.js 16〕/seeds)+cmd/server/scriptlib_handlers.go+cmd/server/routes.go:167-202(挂载)。事实基准=Go 代码;冻结版qmtv/server/scriptlib.js/studio.js仅差异对拍参照。 域内要点:admin 9 端点叠AdminMiddleware(403 forbidden);wire 全蛇形(model.go json tag 亲证零驼峰);两套正则刻意勿合并(seedEpisodeRe 带\s*service.go:78 vs catalogRe 无:196);两套Genres 清单(scriptlib.js 11 项 service.go:25 vs studio.js studioGenres 11 项 service_studio.go:21——冻结死导出未消费,Go 不移植);ID 前缀 sl_/su_/sub_/wp_/up_/na_(ids.go:29-50)。
M9 剧本库与投稿
25 端点全量(scriptlib.js 9:用户 4+admin 5;studio.js 16:market 2/cover·rating 2/submissions 4/writer-profile 2/writers 2/admin·submissions 2/narrations 2)。公开写面唯一扣费点=解说成片 30 积分;审核 publish 多表写禁事务(置位→自动建档→入库→通知逐条)。PRD §4 M9 已登记差异 ①~⑨(prd-qmtv-v1.md:196)引用不重立。
F9.01 剧本库目录与检索(genres / list / :id / use)
对应 PRD:§4 M9 · GET /api/script-library/genres、GET /api/script-library、GET /api/script-library/:id、POST /api/script-library/:id/use | 状态:实现 | 实现落点:scriptlib_handlers.go:31-80、service.go:157-280、repo.go:285-391 | UI:ScriptLibraryView + DashboardView 剧本库弹窗 | 场景挂钩:无专门 e2e(E2E-SUB-01 间接)
1 功能定义
平台剧本库只读消费面:五维目录(genres/markets/audiences/forms/stats)、四维筛选列表(含 7 日热度排序)、详情(试读+故事目录+使用计数)、「使用」埋点。
2 触发条件与前置状态
- 四端点均登录态(amw);genres/list 首触惰性种子
ensureSeed(service.go:92-142:空表整插 3 条种子剧;否则按标题找 enabled=1 行仅回填占位值——market「中国内地」/""、audience「大众」/""、form「短剧」/"",service.go:70-74;content 无「第X集」标题整替为新分集版,seedEpisodeRe service.go:78)。 - 详情/使用前置:script 存在且
enabled=1(repo.go:347-357),否则 404「剧本不存在或已下架」。
3 输入与校验
- list query 五参:genre/market/audience/form/search/sort——四维「全部」或空=忽略该维(repo.go:307-323);search TrimSpace 后 LIKE title/description/tags 三字段(repo.go:324-328);sort 三态
rating/new/缺省 hot(repo.go:331-337)。 - detail/use 路径参数 :id 无格式校验(查无→404 哨兵)。
4 处理流程
- genres:ensureSeed → GROUP BY genre 产 stats(scripts/uses_7d/uses_total,repo.go:285-296,ORDER BY uses_7d DESC)→ 五键 payload 固定序 genres/markets/audiences/forms/stats(model.go:124-130;常量 11/3/4/3 项 service.go:25-28)。
- detail:preview=
preview_text||content 前 800 字(service.go:248-251)→ char_count=rune 数(service.go:262,PRD ⑥)→ catalog 目录切分(第X集lookahead 手动切——RE2 不支持,service.go:202-238;filter(Boolean) 后 >1 段才产出〔前言段也算一段照抄〕;title=首行 ≤40 字,空回退「第 N 集」)→ uses_total/uses_7d 两计数。 - use:存在校验 → INSERT script_uses(su_ 前缀,service.go:276)→ 响应恒
true(不回显行)。
5 输出与结果状态
- 列表行=
s.*+char_count/uses_total/uses_7d 三别名尾插(model.go:90-95,冻结 scriptlib.js:152 STATS_SELECT 键序);详情=行+char_count/preview/catalog/uses_total/uses_7d(catalog 在 uses 前,model.go:105-112)。 - genres stats 行 {genre, scripts, uses_7d, uses_total}(model.go:116-121)。
6 状态流转
script_library.enabled 只被 admin 面(F9.05 PUT enabled)翻转;本组四端点零写状态(use 仅追加 script_uses 流水)。
7 边界与异常
| 条件 | 系统行为 | 提示/码 |
|---|---|---|
| 详情/使用 id 不存在或已下架 | 404 | 「剧本不存在或已下架」(model.go:201) |
| uses_7d 时间窗 | created_at >= NOW() - INTERVAL 7 DAY(repo.go:377,MySQL 方言) | — |
| 空表 stats | GROUP BY 零行→空数组(service.go:168-170 nil→[],DIF-29-1 同族形态) | — |
| content 无分集标题 | catalog 空数组(≤1 段,service.go:226-228) | — |
8 权限与数据规则
四端点 authed + amw;script_uses 不去重(同一用户重复 use 多计——冻结同形照抄);列表默认排 hot=uses_7d DESC, uses_total DESC, rating DESC(repo.go:331)。
F9.02 剧本市集(market/scripts 二件)
对应 PRD:§4 M9 · GET /api/market/scripts、GET /api/market/scripts/:id | 状态:实现 | 实现落点:scriptlib_handlers.go:214-237、service_studio.go:31-46、repo.go:421-468 | UI:ScriptLibraryView 市集 tab | 场景挂钩:无
1 功能定义
已上架剧本的市集浏览面:与 F9.01 同构的列表/详情,但独立筛选维(无 audience)与不同 404 文案。
2 触发条件与前置状态
登录态;列表无 ensureSeed(与 scriptlib 面刻意不同——种子只保证库面有货);详情前置 enabled=1。
3 输入与校验
query 四参 genre/market/form/search/sort——「全部」忽略同 F9.01;search 直用原值不 TrimSpace(repo.go:439-440 注释「两处刻意不同源勿统一」——与 F9.04 writers trim 对比,PRD ⑧)。
4 处理流程
列表 SELECT 与 F9.01 同 statsSelect 投影但独立 WHERE(market 面固定 enabled=1);详情 GetMarketScriptEnabled(repo.go:457-468)。
5 输出与结果状态
列表行同 ScriptListRow;详情=ScriptLibrary 裸行(无 preview/catalog/uses 派生键——与 F9.01 详情不同构,model.go:11-34)。
6 状态流转
零写面(纯读)。
7 边界与异常
| 条件 | 系统行为 | 提示/码 |
|---|---|---|
| 详情不存在/下架 | 404 | 「剧本不存在」(model.go:203——与 F9.01「剧本不存在或已下架」不同字勿统一) |
8 权限与数据规则
authed + amw;market 面不展示 disabled 行;sort 三态同 F9.01。
F9.03 投稿工作流(submissions 四件)
对应 PRD:§4 M9 · GET/POST /api/studio/submissions、PUT /api/studio/submissions/:id、POST /api/studio/submissions/:id/submit | 状态:实现 | 实现落点:scriptlib_handlers.go:271-329、service_studio.go:102-223、repo.go:532-593 | UI:StudioView 投稿区 | 场景挂钩:E2E-SUB-01(投稿链,待实现——人工步骤即未来断言主干)
1 功能定义
编剧投稿全生命周期自助面:建稿(存草稿或直提)、编辑、提交送审、我的稿件列表。
2 触发条件与前置状态
登录态(amw);编辑/提交前置:行存在且属主(WHERE id=? AND user_id=?,repo.go:557-568),否则 404「not found」。
3 输入与校验
- create:title/content 必填 400「标题与内容必填」(service_studio.go:131-133);截断 title 80/genre 12/market 20/form 12/audience 60/description 300(service_studio.go:137-142);genre/market/form 缺省「都市」/「中国内地」/「短剧」。
- edit:全字段指针可选(nil=不动,COALESCE 形态 service_studio.go:182-203);
Submit bool直传。
4 处理流程
- create:status=
submit?'pending':'draft'(service_studio.go:145-149)→ INSERT sub_ 行 → 响应{id, status}。 - edit:状态守卫(仅 draft/needs_change/rejected 可编辑,:175-177)→ status=
submit?'pending':原值→ COALESCE 更新 → 恒 true。 - submit:仅 draft 可提交(:216-218)→ 置 pending(note 不动)→ 恒 true。
5 输出与结果状态
列表=本人窄投影 10 列不含 content(model.go:133-144,冻结 studio.js:171 列序);create 响应 {id, status}(handlers :298)。
6 状态流转
needs_change/rejected 可再编辑回 pending;published/pending 不可编辑(400)。
7 边界与异常
| 条件 | 系统行为 | 提示/码 |
|---|---|---|
| 编辑/提交非本人或不存在的 :id | 404 | 「not found」(model.go:205) |
| 编辑非 draft/needs_change/rejected | 400 | 「当前状态不可编辑」(model.go:218) |
| submit 非 draft | 400 | 「该稿件已提交过」(model.go:220) |
| 越权探测 | 属主过滤 404 不暴露存在性 | — |
8 权限与数据规则
四端点 authed + amw 无 admin;全部 user_id 属主过滤;content 全文仅 create/PUT 入库,列表不下发。
F9.04 编剧档案与编剧名录(writer-profile 二件 + writers 二件)
对应 PRD:§4 M9 · GET/POST /api/studio/writer-profile、GET /api/writers、GET /api/writers/:id | 状态:实现 | 实现落点:scriptlib_handlers.go:333-385、service_studio.go:227-320、repo.go:622-749 | UI:StudioView 档案区 + WritersView | 场景挂钩:无
1 功能定义
编剧自助档案(一人一份 user_id UNIQUE)+ 公开编剧名录(列表/详情带 published 计数)。
2 触发条件与前置状态
profile GET 无前置(查无=null);save 笔名必填;writers/:id 前置档案存在,否则 404「编剧不存在」。
3 输入与校验
save:name 必填 400「笔名必填」(service_studio.go:258-260);截断 name 30/bio 500/genres 120/works 500(:261-264)。
4 处理流程
- GET:{profile, published} 双读(published=本人 script_library published 计数,repo.go:669-680);profile 查无 null 非 404(service_studio.go:233)。
- save:exist→UPDATE 返回原 id;否则 INSERT wp_(:265-282)。
- writers 列表:6 列+published 子查询尾插(model.go:153-160);search TrimSpace 后 LIKE(PRD ⑧ 用户裁决保持)。
- 详情:{…row, scripts, joined_at}(model.go:175-179);scripts=该编剧 published LIMIT 30(repo.go:727-737);joined_at=users.created_at 别名。
5 输出与结果状态
save 响应 {id};writers 列表行/详情键序照冻结 studio.js:98-105(列表 SELECT)/:111-119(详情 {...w, scripts, joined_at})。
6 状态流转
writer_profiles 无状态列;published 计数随 admin review(F9.07)联动。
7 边界与异常
| 条件 | 系统行为 | 提示/码 |
|---|---|---|
| 编剧详情 :id 不存在 | 404 | 「编剧不存在」(model.go:207) |
| joined_at 底层 users 行查无 | GetWriterJoinedAt 原生 QueryRow 透传错误→500(repo.go:713;冻结 null 兜底——FK 语义死路径,DIF-30-1 登记) | — |
8 权限与数据规则
四端点 authed + amw(writers 名录非公开面);档案一人一份(UNIQUE upsert 语义);越权不可改他人档案(按 token user_id 定位)。
F9.05 管理面剧本库维护(admin script-library CRUD 四件)
对应 PRD:§4 M9 · GET/POST /api/admin/script-library、PUT/DELETE /api/admin/script-library/:id | 状态:实现 | 实现落点:scriptlib_handlers.go:84-210、service.go:284-492、repo.go:298/:393-420/:516-531 | UI:AdminView 剧本库 tab | 场景挂钩:无
1 功能定义
平台剧本库管理面:全量列表(含 disabled)、建库、21 列可选更新、删除。
2 触发条件与前置状态
adminMw(403 forbidden);list 首触 ensureSeed 同 F9.01;PUT/DELETE 不校验存在性——id 不存在也恒 true(repo.go:397 注释「id 不存在也恒 ok」照抄冻结)。
3 输入与校验
- create:title/content 必填 400「标题与剧本内容必填」(service.go:363-365);截断 title 80/genre 12 缺省「其他」/description 300;ADMIN_FIELDS 11 扩展字段——TEXTUAL 6 项(highlights/preview_text/characters/worldview/synopsis/setting)截 4000、其余截 100(extraOf service.go:321-344,create 分叉 maxTextual=4000);episodes/planned_chars 负数归 0(nonNeg service.go:516-521);rating clamp 0..5(service.go:542-550)。
- put:同构全字段指针可选;COALESCE 21 列(repo.go:398-409);不传字段不动。
4 处理流程
create:19 列 INSERT(6 TEXT 列落字符串空串非 NULL——冻结显式绑定形态,service.go:144-155 注)→ 响应 {id}。delete:先删 uses 再删主行(两删序固定 service.go:484-492,禁事务铁律同形)。
5 输出与结果状态
list=ScriptListRow 同构但 AdminAll 无 enabled 过滤且固定 created_at DESC(repo.go:338-340);put/delete 恒 true。
6 状态流转
enabled 由 PUT 翻转(F9.01/F9.02 消费面即时可见);无其他状态。
7 边界与异常
| 条件 | 系统行为 | 提示/码 |
|---|---|---|
| create 缺 title/content | 400 | 「标题与剧本内容必填」(model.go:210) |
| PUT/DELETE id 不存在 | 200 恒 true(0 行 UPDATE/先删 uses 0 行)——无 404 面(quirk 照抄冻结) | — |
| 非管理员访问 | 403 | 「forbidden」 |
8 权限与数据规则
四端点 authed + adminMw;删除级联仅两表(uses→主行;无 submissions 关联);bind 失败静默按空载荷(_ = c.Bind handlers :119/:189——DIF-30-2 登记,DIF-29-23 同族)。
F9.06 批量上传与封面评分(upload + cover + rating)
对应 PRD:§4 M9 · POST /api/admin/script-library/upload、POST /api/admin/script-library/:id/cover、PUT /api/admin/script-library/:id/rating | 状态:实现 | 实现落点:scriptlib_handlers.go:132-182/:239-267、service.go:411-449、service_studio.go:48-98、repo.go:469-484 | UI:AdminView 剧本库 tab(上传/封面/评分)| 场景挂钩:无
1 功能定义
管理面批量导入 txt 剧本(GBK 自动回退)、单封面图上传、评分设定。
2 触发条件与前置状态
adminMw;upload 前置 multipart files 字段非空;cover 前置文件非空;upload 修后语义(PRD ①):冻结 :248 run 16 参数对 9 占位符恒 RangeError——冻结版 upload 从未可用,Go 按 9 列 INSERT 修后实现(service.go:412-417 注)。
3 输入与校验
- upload:单文件 ≤2MB(超→500「File too large」handlers :149-151 multer LIMIT_FILE_SIZE 语义)、≤20 文件(超→500「Unexpected field」:144-146);路由级 BodyLimit 45mb 双保险(PRD ④);文件名过
gateway.FixFilename(:162,GBK 坑守卫);extra 统一截 100 不走 TEXTUAL 4000(service.go:426,与 create 分叉)。 - 内容判定:UTF-8 解码统计 U+FFFD 乱码率 > max(2, 长度×0.002) 回退 GB18030 解码(decodeUploadContent service.go:591-600,PRD ③);TrimSpace 后 <30 字跳过(:430-432)。
- cover:缺文件 400「缺少封面图」(service_studio.go:52-54);扩展名缺省 .png(extOf :70-75)。
- rating:clamp 0..5(service_studio.go:92-98);nil rating 传 0(clampRating nil→0)。
4 处理流程
- upload:逐文件解码→过滤→INSERT(title=
prefix+stripExt(文件名)截 80,genre 缺省「其他」截 12,service.go:432-443)→{created:[{id,title}], count};全跳过→400「没有有效剧本(文件过短或非文本)」(:445-447)。 - cover:storage.Put(up_ 前缀 ObjectKey 日期目录)→ cover_path 落库
PathOf(key)(F8 口径绝对 URL,PRD ②;冻结 /files/ 相对)→ 响应{cover: path}。 - rating:UPDATE rating 单列 → 恒 true。
5 输出与结果状态
upload {created, count};cover {cover};rating true。
6 状态流转
新库行 enabled=1 直接上架(upload 语义);cover/rating 零状态。
7 边界与异常
| 条件 | 系统行为 | 提示/码 |
|---|---|---|
| upload 无 files 字段 | 400 | 「未选择文件」(handlers :141-143) |
| 单文件 >2MB / >20 文件 | 500(multer 语义对齐,PRD ④) | 「File too large」/「Unexpected field」 |
| 全部文件 <30 字或非文本 | 400 | 「没有有效剧本(文件过短或非文本)」(model.go:214) |
| cover 缺文件 | 400 | 「缺少封面图」(model.go:224) |
| storage 未配置(降级模式) | NoopStore.Put 报错 | 500(ErrNotConfigured 在 errRespond 零映射落 default;503 仅 requireDB 前置可达——storage.New 恒装配 NoopStore) |
8 权限与数据规则
三端点 authed + adminMw;upload 每文件独立 su_ 无预生成(9 列 INSERT 不产 uses);cover ObjectKey=up_ 前缀+原始扩展名。
F9.07 投稿审核与自动上架(admin/submissions 二件)
对应 PRD:§4 M9 · GET /api/admin/submissions、POST /api/admin/submissions/:id/review 🔒 | 状态:实现 | 实现落点:scriptlib_handlers.go:389-414、service_studio.go:325-417、repo.go:594-621/:738-759 | UI:AdminView 投稿审核 tab | 场景挂钩:E2E-SUB-01(publish 分支,待实现)
1 功能定义
投稿审核台:按状态筛选全量列表 + 三动作审核(needs_change/reject/publish),publish 附带自动上架与建档。
2 触发条件与前置状态
adminMw;review 前置:投稿行存在(不限属主,repo.go:611-621),否则 404「not found」;action 三值白名单。
3 输入与校验
GET query status(「全部」或空=全量,repo.go:594-610);review body {action, note}——action ∉ {needs_change, reject, publish} → 400「未知操作」(service_studio.go:360-364);note 截 200(:359)。
4 处理流程(publish 四步禁事务)
- 置状态+note(映射 needs_change→needs_change/reject→rejected/publish→published,:371;note 空回默认文案
reviewNoteDefault——publish 默认「已发布」,:344-349)。 - publish 且无档案→自动建档 wp_(nickname 空回退「编剧」+user_id 前 4,:385-387)。
- publish 入库 script_library(sl_ 前缀、preview_text=content 前 400,:392-405)。
- 通知「投稿状态更新」:《title》:reviewNotice 文案(publish=「已发布到剧本库」——与 UPDATE note 默认「已发布」不同字勿统一,:336-342);括号注记用原始 note 判空非默认值(:365-366 注)。
5 输出与结果状态
list=sub.*+u.email 尾插(model.go:147-150);review 恒 true。
6 状态流转
与 F9.03 §6 状态机同一张表(admin 侧三出边);publish 终态+库行落地上架。
7 边界与异常
| 条件 | 系统行为 | 提示/码 |
|---|---|---|
| :id 不存在 | 404 | 「not found」 |
| action 非三值 | 400 | 「未知操作」(model.go:222) |
| publish 四步中途失败 | 无事务回滚——半态留存(禁事务铁律=冻结无事务同形) | — |
8 权限与数据规则
authed + adminMw;review 不校验投稿当前状态(pending/rejected 均可再 publish——无状态机出边限制,quirk 照抄冻结);通知零 SSE(M5 读面 60s 轮询消费)。
F9.08 解说一键成片(tools/narrations 二件)
对应 PRD:§4 M9 · POST /api/tools/narrations、GET /api/tools/narrations | 状态:实现 | 实现落点:scriptlib_handlers.go:418-443、service_studio.go:421-565、repo.go:760-784 | UI:IndependentToolsView 解说工具 | 场景挂钩:无
1 功能定义
剧本解说词一键成片:同步 Mock 出 SVG 占位视频+解说分段 JSON,单次扣 30 积分。
2 触发条件与前置状态
登录态;script_text TrimSpace 后 <50 字 → 400「剧本文本过短(至少 50 字)」(service_studio.go:450-452);余额 <30 → 402。
3 输入与校验
{title?, script_text};title 空→「解说成片」落库截 60(:457-458/:478);script_text 落库截 100000 rune(:469)。
4 处理流程
扣费先行(TryDeduct 30 type='video'「解说剧一键成片(-30)」taskID=NULL——0003 后 ''→NULL 绑定收口,:453)→ 空行切段(/\n\s*\n/ 等价贪婪切,splitOnBlankLines :527-555)≤30 段 → 每段取首个含「:」或「:」的行 ≤80 字(无则整段,firstColonLine :558-565)→ visual 按 i 奇偶交替(偶「主体中景推进」/奇「城市夜景空镜」:512-515)→ MakeVideo SVG 占位落盘(mgv_ 族 key)→ result JSON 固定键序 segments/cost/engine(:425-429;engine=管理台 video 引擎名)→ INSERT na_ 行 status=‘done’(同步完成无轮询)。
5 输出与结果状态
create 响应 {id, url, segments, cost}(url=落盘 PathOf,降级模式空串);list=本人 6 列窄投影(id/title/status/file_path/result/created_at,model.go:182-189);result 原样 JSON 字符串下发。
6 状态流转
status 恒 ‘done’(同步 Mock 无 processing/failed——Mock 行为是契约的一部分,总则 §2.4)。
7 边界与异常
| 条件 | 系统行为 | 提示/码 |
|---|---|---|
| <50 字 | 400(扣费前校验) | 「剧本文本过短(至少 50 字)」(model.go:228) |
| 余额不足 | 402 | credits.ErrInsufficient 统一信封 |
| storage 未配置 | NoopStore.Put 报错→500,扣 30 不退、任务不落(service_studio.go:462-468 nil 守卫恒不触发——storage.New 恒装配 NoopStore,非「跳过照常」;参照 m13 同位置死路径表述) | — |
8 权限与数据规则
authed + amw;扣费台账 type=‘video’ 与视频生成同族(credit-records 聚合归视频面);PRD ⑨ task_id 已随 0003 NULL 化收口。
册尾端点覆盖对账(25/25)
对账 backend/cmd/server/routes.go:167-202 亲数 25 条 ↔ 本册 F9.01~F9.08:
| # | 端点(routes.go:line) | 节 | # | 端点(routes.go:line) | 节 |
|---|---|---|---|---|---|
| 1 | GET /script-library/genres(:176) | F9.01 | 14 | GET /studio/submissions(:191) | F9.03 |
| 2 | GET /script-library(:177) | F9.01 | 15 | POST /studio/submissions(:192) | F9.03 |
| 3 | GET /script-library/:id(:178) | F9.01 | 16 | PUT /studio/submissions/:id(:193) | F9.03 |
| 4 | POST /script-library/:id/use(:179) | F9.01 | 17 | POST /studio/submissions/:id/submit(:194) | F9.03 |
| 5 | GET /admin/script-library(:180)🔒 | F9.05 | 18 | GET /studio/writer-profile(:195) | F9.04 |
| 6 | POST /admin/script-library(:181)🔒 | F9.05 | 19 | POST /studio/writer-profile(:196) | F9.04 |
| 7 | POST /admin/script-library/upload(:182)🔒 | F9.06 | 20 | GET /writers(:197) | F9.04 |
| 8 | PUT /admin/script-library/:id(:183)🔒 | F9.05 | 21 | GET /writers/:id(:198) | F9.04 |
| 9 | DELETE /admin/script-library/:id(:184)🔒 | F9.05 | 22 | GET /admin/submissions(:199)🔒 | F9.07 |
| 10 | GET /market/scripts(:187) | F9.02 | 23 | POST /admin/submissions/:id/review(:200)🔒 | F9.07 |
| 11 | GET /market/scripts/:id(:188) | F9.02 | 24 | POST /tools/narrations(:201) | F9.08 |
| 12 | POST /admin/script-library/:id/cover(:189)🔒 | F9.06 | 25 | GET /tools/narrations(:202) | F9.08 |
| 13 | PUT /admin/script-library/:id/rating(:190)🔒 | F9.06 |
注:🔒=adminMw;admin 9 端点(#5-9/12-13/22-23)。PRD §4 M9 差异 ①~⑨ 全引用不重立;本册新立 DIF-30-1(joined_at 500 vs 冻结 null 死路径)/ DIF-30-2(bind 静默按空载荷,DIF-29-23 同族)入 99-附录 B。