diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index dcf5520..da78f3b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -26,7 +26,7 @@ |---|--------|------|----------|------|------| | P4 | 作者主页 | `/users/:user` 聚合页(该作者全部已上架作品,前端按命名空间过滤,**零后端改动**),按最新上架排序;顺带 `/games/:user/:slug` 面包屑与作者名互链。spec:`docs/specs/2026-09-29-author-page-design.md` | crearte(前端) | 无强依赖,可提前并行 | **完成**(crearte `f12cbf1`,2026-09-29 合并推送) | | P5 | 收藏 / 评分 | 新数据表 + 用户态 API + 目录/详情页 UI;产品线里最大的一块 | 三仓 | 建议 P2 先落地 | **完成**(server `e3da246` / crearte `eb5fbed`,2026-09-30 合并推送;范围修订 deploy 零改动,见 spec) | -| P6 | hosted 作品投稿 | 范围模糊,需先定义 hosted 托管语义(只存 URL 还是真托管文件),再定投稿/审核流 | 三仓 | 需先决策 | 待澄清 | +| P6 | hosted 作品投稿(方案 A 自托管内嵌) | 作者把游戏部署在别处,投稿填 https URL,站内沙箱 iframe 播放;播放端机件已全存在,打通的是入口:后端 D-B runtime 不可变 + D-C hosted 禁 bundle 两护栏,前端表单第三档+预填解锁 | 三仓 | owner 已拍 A | **进行中**(方案 A:只存 URL、站内嵌播;语义定案见 spec) | ## 第三波:治理与长尾(随时可插队) @@ -53,6 +53,7 @@ | P4 作者主页 | `docs/specs/2026-09-29-author-page-design.md` | `docs/plans/2026-09-29-author-page.md`(已执行,2026-09-29) | | P1 prod 写侧可用 | `docs/specs/2026-09-29-prod-write-side-design.md` | `docs/plans/2026-09-29-prod-write-side.md`(已执行,2026-09-29) | | P5 收藏/评分 | `docs/specs/2026-09-30-favorites-ratings-design.md` | `docs/plans/2026-09-30-favorites-ratings.md`(已执行,2026-09-30) | +| P6 hosted 投稿(方案 A) | `docs/specs/2026-09-30-hosted-submission-design.md` | `docs/plans/2026-09-30-hosted-submission.md`(执行中,2026-09-30) | | 创作者中心 `/creator`(维护者直接需求) | `docs/specs/2026-09-30-creator-center-design.md` | `docs/plans/2026-09-30-creator-center.md`(已执行,2026-09-30) | | P2 CI+测试基线 | `docs/specs/2026-09-30-ci-test-baseline-design.md` | `docs/plans/2026-09-30-ci-test-baseline.md`(已执行,2026-09-30) | | P3 备份+可观测 | `docs/specs/2026-09-30-backup-observability-design.md` | `docs/plans/2026-09-30-backup-observability.md` | diff --git a/docs/plans/2026-09-30-hosted-submission.md b/docs/plans/2026-09-30-hosted-submission.md new file mode 100644 index 0000000..e278130 --- /dev/null +++ b/docs/plans/2026-09-30-hosted-submission.md @@ -0,0 +1,49 @@ +# P6 hosted 投稿(方案 A)· 实施计划 + +依据:`docs/specs/2026-09-30-hosted-submission-design.md`(D-A…D-F 定稿)。分支名统一 `feat/p6-hosted`,各仓各自实现者自拉自提交,**不 push**;合主由控制者 `--no-ff`。 + +## Task 1 · server 校验收紧(crearte-server) + +基线 master(`ca24805` 起)。改动点: + +1. `src/internal/service/content.go` `ValidateSubmission`(约 :327 起的 switch): + - `metadata_change` 分支:取现有 work 后新增不可变校验——`RuntimeOrDefault(p) != work.Runtime` → `ValidationError{Fields: []string{"runtime: immutable on metadata_change"}}`(D-B)。 + - 通用(new_work 与 metadata_change 都过):`runtime == hosted && bundleUploadID != ""` → `ValidationError{Fields: []string{"bundle_upload_id: hosted works cannot carry a bundle"}}`(D-C)。 + - `new_version` 分支已 virtual-only,不动。 +2. 单测(同文件所在包 service): + - hosted+bundle → 校验失败且字段名精确; + - metadata_change 改 runtime(hosted↔external/virtual 双向至少各一)→ 失败;runtime 相同 → 通过既有路径; + - 回归确认:hosted new_work 无 bundle、带合法 https hostedUrl 仍通过(validate.go:140 既有)。 +3. 集成测试(带 `TEST_DATABASE_URL` 的既有风格,参考 admin 面集成六场景文件):hosted 草稿→submit-review→approve→`works` 行 `hosted_url/play_origin/fallback` 读回一致;`GET /api/games/` 载荷含 `runtime:"hosted"` 与 `hostedUrl`。 +4. `CHANGELOG.md` 中英成对,版本 **0.13.0**(Added=校验护栏+集成覆盖;Changed 若有措辞按实际)。 + +红线:零迁移、零新依赖、不动 handler 响应形状、不动 audit/admin 面、Approve 落库路径(PayloadToWork 映射已全,见 spec §1)不改。 + +验证(容器内,goproxy.cn 姿势同 AGENTS.md):`gofmt -l` 空、`go vet ./...`、`go build ./...`、`go test -p 1 ./internal/...`;带 db 的集成腿控制者跑,实现者确保无 `TEST_DATABASE_URL` 时 skip 语义与既有一致。 + +## Task 2 · crearte 前端表单打通 + +基线 master(`900f13a` 起),源码根 `crearte/src`。改动点: + +1. 内容层类型:提交 payload runtime 联合扩为三档,`hostedUrl`、`fallback` 字段落位(对齐 server `WorkPayload` json 名 `hostedUrl`/`fallback`/`runtime`)。 +2. `app/views/SubmitFormView.vue`: + - `:33` 表单 runtime 加 `'hosted'`;`:408-415` radio 组加第三项「自托管内嵌」(`data-testid="runtime-hosted"`)。 + - hosted 档字段:`hostedUrl`(必填 https)、`fallback` 选择(`external|hosted|none`,默认 `external`);fallback=external 时 `url` 必填(前端校验镜像 server)。 + - 提交形状:hosted 时 payload 带 `runtime:'hosted'` + `hostedUrl` + `fallback`,**不带** bundle/version/entry/features;`kind` 仅 new_work / metadata_change(new_version radio 保持 virtual-only 逻辑)。 + - 删除 `:144-147` hosted 预填拒绝;预填回填 hostedUrl/fallback(注意 server GET 载荷字段名)。 + - hosted 档下 cover 上传照常。 +3. vitest(SubmitFormView.test.ts 等):hosted 提交形状、必填校验失败面、预填回填、new_version 禁入 hosted。 +4. e2e:`admin-flow.spec.ts` 或提交流既有夹具内加 hosted 提交流(表单→draft payload 断言);**不动** `full-loop.spec.ts` / `playwright.stack.config.ts`。 +5. `docs/CHANGELOG.md` 中英成对,版本 **0.19.0**。 + +红线:不动运行时三件套(sw/agent/bootstrap/GameHost/useGameFrame——播放端零改动是 spec 决策);不动管理面;apiRepo 无新端点。 + +验证(`crearte/src`):`npx vitest run`、`npm run typecheck`、`npx playwright test e2e/admin-flow.spec.ts`、`npm run e2e` 全 RC=0。 + +## Task 3 · deploy 核证(控制者,非实现者任务) + +生产/dev 主站响应头无 `frame-src` 限制即记录在案;若有则模板补丁+复验。 + +## 合主与验收(控制者) + +双路独立审查(spec→代码逐条,PASS with notes 起)→ 六腿验收(spec §5)→ `--no-ff` 合主推送 → wrapper 记账(ROADMAP P6 行 + spec/plan 索引行 + CHANGELOG 0.2.7)→ 删分支。 diff --git a/docs/specs/2026-09-30-hosted-submission-design.md b/docs/specs/2026-09-30-hosted-submission-design.md new file mode 100644 index 0000000..2f42877 --- /dev/null +++ b/docs/specs/2026-09-30-hosted-submission-design.md @@ -0,0 +1,72 @@ +# P6 hosted 作品投稿 · 设计 spec(方案 A:自托管内嵌) + +日期:2026-09-30 | 决策人:owner(选 A)| 前置勘查:控制者本会话完成,证据均带 file:line。 + +## 0. 一句话 + +作者把游戏部署在别处(itch.io / 自建站),投稿填一个 https URL,平台站内用沙箱 iframe 播放;打通的是「入口」,播放机件早已全存在。 + +## 1. 现状勘查(grounded) + +**后端(crearte-server)hosted 半边已通:** +- `works` 表(migration 0002)含 `runtime IN ('external','virtual','hosted')`、`hosted_url`(CHECK https)、`play_origin`、`fallback IN ('external','hosted','none')`、`entry`。 +- 校验已认:`service/validate.go:133-141` —— `hosted` 合法 runtime,且要求 `HostedURL` 匹配 `payloadHTTPSPattern`。 +- **落库映射不缺**:`service/import.go:39 PayloadToWork` 已映射 `PlayOrigin/HostedURL/Fallback/Entry`(此前「Approve 落库缺口」系误报——grep 打在 content.go 而映射在 import.go);`repository/work.go:73/96/109` Create/Upsert/UpdateMetadata 全列含 `hosted_url`。 +- 提交创建 `content.go:327-358`:virtual 必须有 bundle;hosted 无 bundle 要求但也**未拒绝**携带 bundle;metadata_change 对 runtime **无不可变性校验**(作者可借元数据变更改 runtime)。 +- `PlaySubdomain(workID)`(`namespace.go:16`)确定性派生 sha256 前 8 字节 hex,对全部 runtime 生效;hosted 不需要平台签发子域(播放源即 hostedUrl),playOrigin 可显式覆盖(CONTRIBUTING:42 已写明)。 + +**前端(crearte)播放端全存在、入口焊死:** +- `GameView.vue:30`:`playable = virtual || hosted`;`runtime/host/GameHost.vue:98-101`:iframe `:src="iframeSrc"`、`:sandbox`、`:allow`、`referrerpolicy="no-referrer"`、降级外链按钮(`:108` `degradedToExternal`)。 +- `runtime/host/useGameFrame.ts:58/159/162`:hosted 目标直接置 `ready`(不等 agent:boot),3 秒未收到 `agent:boot` 仅 console warn(桥不存在属预期)。 +- `types.ts:16`:`GameRuntimeMode` 三值齐全;但 `SubmitFormView.vue:33` 表单 runtime 仅 `external|virtual`,`:144-147` 对 hosted 作品预填**直接拒绝**(防元数据变更静默降级——该风险根源在后端缺 D-B,见下)。 +- 内容层提交 payload 的 FE 类型同样只两档(SubmitFormView:144 注释)。 + +**deploy / CSP:** +- `sw/csp.ts:29`、`serve-runtime.mjs:41` 的 `frame-src 'none'` 是**运行时子域页面自身**的 CSP(管 play 页里再嵌 iframe),不是主站宿主页面——hosted iframe 挂在主站页面,不受此约束。 +- deploy 仓 grep 无主站 CSP frame-src 下发(nginx 模板未含);验收腿 T5 实核生产响应头。 + +**结论**:方案 A 的真实工作量 = 后端两处校验收紧 + 前端表单第三档 + 预填解锁 + 测试与验收,播放链路零改动。 + +## 2. 决策(D-A…D-F) + +- **D-A hostedUrl 政策**:必须 https(已有校验复用);**任意域名**、不做白名单——第三方禁嵌(X-Frame-Options/CSP frame-ancestors)平台不可预判,由播放端既有 fallback 链兜底(降级外链)。iframe 安全参数沿用现状(sandbox 旗标集 + no-referrer + 外链 `rel=noopener noreferrer`),不扩权。 +- **D-B runtime 不可变**(根治,后端):`metadata_change` 提交的 `payload.runtime`(经 `RuntimeOrDefault`)与作品现存 runtime 不一致 → `ValidationError{Fields:["runtime: immutable on metadata_change"]}`。此后前端 `:144-147` 拒绝分支删除(表单能表达 hosted,预填安全)。收紧只作用于**新提交**:审批路径不复跑 ValidateSubmission(Approve 只做 ParseEnvelope+落库,`content.go:596+`),存量 draft 无 retroactive 失效。 +- **D-C hosted 语义纯度**:`runtime=hosted` 且带 `bundle_upload_id` → `ValidationError`(hosted 不吃平台文件);`features` 允许提交但桥不存在(armBridgeWarn 已容忍);`entry`/`playSubdomain` 对 hosted 无意义,存而不使用,不报错。cover 上传与 runtime 无关,照常允许。 +- **D-D 审核面最小改动**:AdminView 待审卡片对 hosted 提交展示 `hostedUrl` 文本行(不内嵌播放预览——审核者安全 + 最小变更)。 +- **D-E fallback**:复用现有列;表单在 hosted 档下提供 fallback 选择(`external|hosted|none`),默认引导 `external`(需 url 字段配合,表单校验:fallback=external 时 url 必填)。 +- **D-F deploy 仅核证**:实探主站 CSP 头;无 frame 限制则零改动记录在案,有则补模板并复验(T5)。 + +## 3. 范围 + +**server(→0.13.0)** +1. D-B:metadata_change runtime 不可变校验 + 单测。 +2. D-C:hosted 禁 bundle 校验 + 单测。 +3. 集成(`-count=1` 空库腿):hosted new_work 全路径 create→approve→`works.hosted_url/play_origin/fallback` 落库读回;GET 公开作品含 `runtime=hosted` + `hostedUrl`。 +4. CHANGELOG 中英成对。 + +**crearte(→0.19.0)** +1. FE 内容层提交 payload 类型三档 + `hostedUrl`/`fallback` 字段。 +2. SubmitFormView:第三 radio「自托管内嵌」+ hostedUrl 输入 + fallback 选择;校验(https、hosted 必填 hostedUrl、fallback=external 时 url 必填);预填解除 `:144-147` 拒绝、正确回填 hosted 字段。 +3. vitest:表单 hosted 分支(提交形状 / 校验失败面 / 预填回填)。 +4. e2e:admin-flow 夹具补 hosted 投稿流(表单提交形状断言);主套件全绿。 +5. CHANGELOG 中英成对。 + +**deploy**:仅 T5 核证,预期零改动。 + +**不做**(明确排除):URL 抓取/健康探测、域名信任列表、hosted 的版本更新(new_version 仍 virtual-only)、转码代理、离线缓存、播放端任何改动。 + +## 4. 红线 + +- 不动 `full-loop.spec.ts` / `playwright.stack.config.ts` / 运行时三件套(sw/agent/bootstrap)。 +- 不动生产凭据与 `.env`;deploy 改动(如有)只出模板级。 +- server 迁移零新增(hosted 列 0002 起就在)。 +- 前端不新增后端端点依赖;管理面仅 D-D 一行展示。 + +## 5. 验收(控制者本机,同 P7 六腿口径) + +1. server 容器四连(gofmt/vet/build/test)RC=0。 +2. fresh 空库 `-count=1` 集成全过、零 FAIL 零静默 skip。 +3. curl 冒烟真栈:hosted 草稿→submit→approve→DB 行 `hosted_url` 非空、`GET /api/games/` 含 runtime/hostedUrl;负面:runtime 篡改元数据变更 400、hosted 带 bundle 400。 +4. FE:vitest 全量 / typecheck / admin-flow / 主套件 RC=0。 +5. 真栈 full-loop 若分支后端被触及照跑 1 passed。 +6. 双路独立审查(spec→代码逐条)PASS 后合主,wrapper 记账(ROADMAP P6 行、CHANGELOG 0.2.7、spec/plan 索引)。