合并最新master并融合图片生成链路
保留BgFilter complex、cross-check、单次重试、全帧并发排空与任务阶段上报 采用master的生成产物一次上传、真实素材类型及成本和供应商归因 同步后台多账号、Dashboard、SpacetimeDB schema、迁移与生成绑定 融合项目决策记录和相关前后端文档
This commit is contained in:
@@ -57,8 +57,8 @@
|
||||
- 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。
|
||||
- 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/<objectKey>"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/<objectKey>`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态,生成请求仍使用临时状态中的图片源或 `objectKey`。
|
||||
- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色` 或 `抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口所需的图片 Data URL、signed URL 或 `objectKey` 只存在于提交前的临时参考图状态和请求体字段,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。
|
||||
- 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面并写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。
|
||||
- 生成面板的资源命名统一写入可选 `assetLabel`,最大 80 字符并在提交时 trim。留空时提交模型保留原有自动编号;非空时同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样支持该字段,中间原图使用主名称加固定后缀,拆分素材继续按素材描述命名。
|
||||
- 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。角色、图标图集、UI 提取和角色动作等多产物任务把 provider 原始输出及后处理结果分别入库:所有条目沿用 `character`、`icon-spritesheet`、`character-animation` 等真实类型,provider 原始输出承载任务模型成本,后处理派生产物阶段成本为 0。后台素材查询以最终产物为父行、每个中间产物为可展开的独立子行,分页只计算父任务。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。
|
||||
- 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀,拆分素材继续按素材描述命名。
|
||||
- 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和 SSE 最终写回由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。
|
||||
- Agent 消息附件只允许引用当前工程画布资源或账号素材库图片,来源类型为 `canvas_resource` / `library_asset`,最多 9 张。附件请求可携带展示用 `imageSrc/thumbnailSrc/objectKey/width/height/label`,但持久化真相仍以后端校验后的 resource / asset 行和 OSS 对象为准;不得把 Data URL、signed URL 或 blob URL 当作会话长期事实。
|
||||
- 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。
|
||||
@@ -87,12 +87,12 @@
|
||||
- `POST /api/editor/assets`:批量或单个创建账号级素材,登录态上传必须写入 OSS / asset object 引用和 `/<objectKey>` 轻量路径,不允许把 Data URL / signed URL 写入素材库。
|
||||
- `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。
|
||||
- `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。
|
||||
- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`,生成成功后 api-server 先保存带纯色背景源图,再调用 BgFilter 并传入 `screen_color=<screenColor>`、`seg_model=<segModel>` 生成透明 PNG。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成成功后创建 project resource / 账号素材并在响应中返回 resource / asset 快照。
|
||||
- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;普通图片的 provider 回图先留在内存,尺寸变换成功后只上传变换结果,变换失败则只上传 provider 原图,主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`,生成成功后 api-server 先保存带纯色背景源图,再调用 BgFilter 并传入 `screen_color=<screenColor>`、`seg_model=<segModel>` 生成透明 PNG。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits;`nanobanana2` 的 `512 / 1024 / 2K` 是标量清晰度档位,后端保留 provider 输出几何尺寸,不按 `宽x高` 解析。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成成功后创建 project resource / 账号素材并在响应中返回 resource / asset 快照。
|
||||
- `POST /api/editor/images/background-removals`:接收当前图片源,校验登录态后由 api-server 解析为图片文件并转发到 BiRefNet 去背景服务;请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`,有 `canvasCompletion` 时完成后按生成占位写入结果图层,否则沿用旧的目标图层替换路径;响应返回 `imageSrc`、`objectKey`、`assetObjectId`、`width`、`height`、`taskId`、`elapsedMs`、`provider` 和可选 `project` 快照。服务地址由 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL` 配置,令牌只在服务端通过 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 注入。
|
||||
- 2026-07-14 更新:该接口的上游已替换为 BgFilter complex;请求字段和回包结构保持不变,服务端 multipart 固定为 `file + background_mode=complex + seg_model=birefnet + cross_check=off`,provider 返回 `BgFilter`。
|
||||
- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,生成成功后 api-server 先保存带纯色背景 spritesheet 源图,再调用 BgFilter 生成透明 spritesheet。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。后端保存透明 spritesheet project resource / 账号素材,并随响应返回对应快照。
|
||||
- `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet,生成成功后 api-server 先保存带纯色背景 spritesheet 源图,再调用 BgFilter 生成透明 spritesheet,并按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。
|
||||
- `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 调用 VectorEngine edits,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`。画布快速编辑必须把源图精确 `originalWidth x originalHeight` 作为业务目标 `size` 提交,不能重新映射为近似比例或 1K / 2K 预设;api-server 在 VectorEngine provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数,回图后恢复到业务目标精确尺寸,再落 OSS、project resource、账号素材和画布快照。16 对齐尺寸不得泄漏到响应、持久化资源或图层 Resolution。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。
|
||||
- `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 调用 VectorEngine edits,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`。画布快速编辑必须把源图精确 `originalWidth x originalHeight` 作为业务目标 `size` 提交,不能重新映射为近似比例或 1K / 2K 预设;api-server 在 VectorEngine provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数,回图后在内存恢复业务目标尺寸,成功时只上传恢复结果,失败时只上传 provider 原图。无论是否发生尺寸恢复都只创建一个 project resource / 账号素材,不显示重复“原始输出”。16 对齐尺寸不得泄漏到正常完成的最终响应、资源或图层 Resolution;变换失败降级时以实际 provider 原图尺寸为准。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。
|
||||
- `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音、默认联网搜索标记和泥点价格生成视频。前端可选模型为 `seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端允许 6 类比例、4 到 15 秒整数、`480p / 720p / 1080p`,并拒绝 `seedance2.0-fast + 1080p`;`sound=on/off` 映射 Ark `generate_audio=true/false`。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `asset` 快照,基础响应返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。
|
||||
- `POST /api/editor/audios/sound-effects/generations` 与 `POST /api/editor/audios/background-music/generations`:按音效 / 背景音乐参数生成音频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `resource` / `asset` 快照,基础响应返回 `audioSrc`、prompt、model、provider、taskId、duration、歌词和 `priceMudPoints`。
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
- 后台管理默认入口为 `#dashboard`,原服务 / 数据库状态页保留为 `#overview`,导航展示名为“服务总览”。
|
||||
- Dashboard 由 `GET /admin/api/dashboard` 提供统一 BFF 投影,前端只展示后端返回的 `range`、`metrics`、`charts`、`operations` 和 `warnings`。
|
||||
- 本次不修改 SpacetimeDB schema,不新增统计表;读取现有 private 表后在 api-server 聚合。
|
||||
- 不新增持久化统计表或字段;profile / tracking 私有事实由仅 runtime service identity 可调用的 `get_admin_dashboard_stats_and_return` procedure 在同一事务快照内聚合,再经 `spacetime-client` facade 返回 api-server;素材与钱包继续复用原有查询。api-server 不再通过固定 `LIMIT` 拉取原始访问明细后自行拼装精确指标;该权威聚合失败时整个 Dashboard 请求失败,不用 0 伪装未知值。
|
||||
- 当前 profile / tracking 表没有覆盖这些跨 scope、跨日期统计的现成索引,因此 procedure 为保证精确性仍需遍历相关事实;上线后需监控调用耗时,数据规模继续增长时再以日期前缀索引或持久化日聚合事实替换,不能重新引入固定行数截断。
|
||||
|
||||
## 查询参数
|
||||
|
||||
@@ -13,10 +14,10 @@
|
||||
- `granularity` 默认为 `day`。
|
||||
- `anchor` 使用北京时间日历日期,保留给 `day` / `week` / `month` 兼容旧查询口径。`week` 和 `month` 选择包含该日期的自然周 / 自然月。
|
||||
- `period` 使用 `startDate` / `endDate` 作为闭区间自定义时段,最多 366 天。
|
||||
- 前端统一展示起始日期和终止日期两个日期选择器;点击“本日 / 本周 / 本月”按当天真实日期自动填充对应自然日 / 自然周 / 自然月范围并刷新,手动修改起止日期后按当前日期范围查询。
|
||||
- 前端统一展示起始日期和终止日期两个日期选择器;点击“本日 / 本周 / 本月”分别填充当天、当周周一至当天、当月月初至当天并刷新,不生成北京时间今天之后的未来日期;手动修改起止日期也不能超过北京时间今天。
|
||||
- 前端默认日期和“本日 / 本周 / 本月”快捷入口都按 `Asia/Shanghai` 计算,不使用浏览器本地时区。
|
||||
- 前端每 5 分钟自动刷新一次,同时保留手动刷新按钮。
|
||||
- 图表横轴按后端返回的 bucket label 展示;本月和较长自定义时段使用固定最小日期列宽并允许横向滚动,避免日期刻度互相挤压。
|
||||
- 图表横轴按后端返回的 bucket label 展示;本月和较长自定义时段使用固定最小日期列宽并允许横向滚动,四张趋势图同步滚动位置,保证同屏日期可横向比较。0 值 bucket 不绘制伪柱。
|
||||
|
||||
## 指标口径
|
||||
|
||||
@@ -25,14 +26,19 @@
|
||||
- 总注册用户:`profile_dashboard_state` 行数。
|
||||
- 新增用户数:`profile_dashboard_state` 中 `created_at` 落在当前筛选时间窗内的账号数,按北京时间业务日归属,支持本日 / 本周 / 本月快捷日期范围。
|
||||
- 访问次数:`tracking_daily_stat` 中 `scope_kind = site` 的日聚合次数。它表示站点级成功路由 / 站点级事件,不把用户级钱包、任务、生成等业务操作混入访问次数。
|
||||
- 访问人数:当前数据只具备登录用户维度,按 `tracking_daily_stat` 中 `scope_kind = user` 的 `scope_id` 去重;匿名访问人数需要未来补充稳定 visitor id 后才能统计。
|
||||
- 当前使用人数:最近 5 分钟内 `tracking_event` 中有 `user_id` 的登录用户去重;不跟随页面选择的历史日 / 周 / 月。
|
||||
- 访问人数:当前数据只具备登录用户维度,按 `tracking_daily_stat` 中 `scope_kind = user`、`count > 0` 且 `scope_id` 非空、非 `anonymous` 的用户去重;匿名访问人数需要未来补充稳定 visitor id 后才能统计。时段指标按整个筛选范围跨日去重,趋势图按 `day_key + scope_id` 每日去重,不能把时段 UV 塞到终止日,也不能把每日 UV 之和当成时段 UV。
|
||||
- 近 5 分钟活跃用户:以请求时刻为终点,最近 5 分钟内 `tracking_event` 中有 `user_id` 的登录用户去重;不跟随页面选择的历史日 / 周 / 月。这是滚动窗口,不是每 5 分钟才采样一次。
|
||||
- 次日 / 七日留存 cohort:`profile_dashboard_state.created_at` 按北京时间归日,注册日落在当前筛选闭区间内。该投影是现有运营注册口径,不等同认证账号表;未来若切换正式注册事实,必须单独变更契约,不能静默替换。
|
||||
- 留存活跃:用户在注册日恰好 `D+1` / `D+7` 的 `tracking_daily_stat` 中存在上述有效 user scope 行;同一用户同日多个事件只计一次,不按“1 / 7 天内累计回访”计算。筛选范围约束注册 cohort,观察日允许晚于筛选结束日。
|
||||
- 留存成熟条件:目标观察日必须早于当前北京时间业务日;观察日为今天时因当天尚未完整结束而排除。D1、D7 的可观察人数通常不同,分别返回 `eligibleUsers`、`retainedUsers` 与 `rateBasisPoints = round(retainedUsers * 10000 / eligibleUsers)`;分母为 0 时 DTO 返回 0,前端百分比显示 `-`。汇总率按总人数加权,不平均每日百分比。
|
||||
- 运营汇总页签:复用同一时间窗,展示运营指标卡、素材类型分布和访问模块分布。
|
||||
|
||||
## 前后端文件
|
||||
|
||||
- 后端路由:`server-rs/crates/api-server/src/modules/admin.rs`
|
||||
- 后端聚合:`server-rs/crates/api-server/src/admin.rs`
|
||||
- SpacetimeDB 聚合 procedure:`server-rs/crates/spacetime-module/src/admin_dashboard.rs`
|
||||
- 后端访问 facade:`server-rs/crates/spacetime-client/src/admin_dashboard.rs`
|
||||
- 契约:`server-rs/crates/shared-contracts/src/admin.rs`、`apps/admin-web/src/api/adminApiTypes.ts`
|
||||
- 前端页面:`apps/admin-web/src/pages/AdminDashboardPage.tsx`
|
||||
- 后台路由:`apps/admin-web/src/app/adminRoutes.ts`
|
||||
@@ -47,5 +53,8 @@
|
||||
## 验证
|
||||
|
||||
- `cargo test -p api-server --manifest-path server-rs/Cargo.toml admin`
|
||||
- `cargo test -p spacetime-module --manifest-path server-rs/Cargo.toml admin_dashboard`
|
||||
- `npm run check:spacetime-schema`
|
||||
- `npm run check:spacetime-runtime-access`
|
||||
- `npm run admin-web:typecheck`
|
||||
- `npx vitest run apps/admin-web/src/pages/AdminDashboardPage.test.tsx apps/admin-web/src/app/adminRoutes.test.ts --reporter verbose`
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
# 后台管理多账号与 Tab 访问权限方案
|
||||
|
||||
更新时间:`2026-07-14`
|
||||
|
||||
## 1. 文档定位
|
||||
|
||||
本文定义陶泥儿后台从单一环境变量管理员扩展为“1 个 owner 引导账号 + 多个 member 持久账号”的编码契约,并为每个一级 Tab 建立前后端一致的访问权限。
|
||||
|
||||
本次只增加后台管理员账号与整页访问权限,不引入页面内按钮级、字段级或只读权限。正式实现必须同时完成前端导航过滤和后端 API 鉴权;前端过滤只改善体验,不能作为安全边界。
|
||||
|
||||
## 2. 当前基线与目标
|
||||
|
||||
当前后台由 `GENARRATIVE_ADMIN_USERNAME`、`GENARRATIVE_ADMIN_PASSWORD` 提供唯一管理员账号,`apps/admin-web/src/app/adminRoutes.ts` 定义 18 个一级 Tab,`server-rs/crates/api-server/src/modules/admin.rs` 中的后台路由只校验统一的管理员 JWT。
|
||||
|
||||
改造后的目标如下:
|
||||
|
||||
1. 现有环境变量账号升级为 `owner`,仍由部署环境提供,不迁移、不复制到 SpacetimeDB。
|
||||
2. owner 始终拥有全部 18 个业务 Tab 权限,并独占“账号管理”Tab 和账号管理 API。
|
||||
3. owner 可以创建、修改、启停 member;member 保存在 SpacetimeDB 私有表 `admin_account`。
|
||||
4. member 按一级 Tab 分配权限;获得一个 Tab 权限即获得该页面内全部读写能力,页面内部二级 Tab、弹窗和操作区继承一级权限。
|
||||
5. member JWT 每次请求都重新读取当前账号并校验 `enabled`、`token_version` 和实时权限,权限、密码或启停变更应立即让旧 JWT 失效。
|
||||
|
||||
## 3. 角色与不可变规则
|
||||
|
||||
### 3.1 owner
|
||||
|
||||
- owner 用户名和密码继续读取 `GENARRATIVE_ADMIN_USERNAME`、`GENARRATIVE_ADMIN_PASSWORD`。
|
||||
- owner 是环境变量构造的虚拟账号,不写入 `admin_account`,不允许通过后台改名、改密、禁用或删除。
|
||||
- owner 始终拥有本文列出的全部 18 个可分配权限,不能在前端取消,也不从数据库加载权限。
|
||||
- “账号管理”是 owner-only 能力。它可以作为新增一级路由 `accounts` / `#accounts` 展示,但 `accounts` 不进入 `ADMIN_TAB_PERMISSIONS`,不能写入 member 的 `permissions_json`。
|
||||
- owner 会话返回 `accountRole = "owner"`、`roles = ["admin", "owner"]`;账号管理权限必须根据服务端确认的 `accountRole` 判断,不能只相信前端角色字符串。
|
||||
- owner 配置缺失时,后台整体保持未启用状态;不能依赖数据库中的 member 绕过 owner 引导配置启动后台。
|
||||
|
||||
### 3.2 member
|
||||
|
||||
- member 只来自 `admin_account`,不新增第二套环境变量账号。
|
||||
- member 会话返回 `accountRole = "member"`、`roles = ["admin", "member"]` 和当前实时 `tabPermissions`。
|
||||
- member 永远不能访问账号管理页面或账号管理 API,也不能给自己或他人分配 `accounts`。
|
||||
- member 的一个一级 Tab 权限覆盖该页面的查询、创建、修改、启停、退款等全部现有操作,不拆成 `read` / `write`。
|
||||
- 页面内二级 Tab、筛选视图、抽屉、弹窗和共享详情弹窗继承触发它的一级 Tab 权限,不另设 permission id。
|
||||
|
||||
## 4. 权限标识
|
||||
|
||||
`ADMIN_TAB_PERMISSIONS` 必须是 shared-contracts 与 admin-web 共用的闭合集合,值与现有 `AdminRouteId` 一致。18 个可分配权限如下,顺序同时作为前端寻找“第一可访问项”的稳定顺序:
|
||||
|
||||
| permission id | 一级 Tab | hash |
|
||||
| --- | --- | --- |
|
||||
| `dashboard` | Dashboard | `#dashboard` |
|
||||
| `overview` | 服务总览 | `#overview` |
|
||||
| `tables` | 表查询 | `#tables` |
|
||||
| `debug` | API 调试 | `#debug` |
|
||||
| `tracking` | 埋点数据 | `#tracking` |
|
||||
| `gray-release` | 灰度发布 | `#gray-release` |
|
||||
| `redeem` | 兑换码 | `#redeem` |
|
||||
| `invite` | 邀请码 | `#invite` |
|
||||
| `profile-wallet` | 账号配置 | `#profile-wallet` |
|
||||
| `tasks` | 任务配置 | `#tasks` |
|
||||
| `recharge-products` | 充值商品 | `#recharge-products` |
|
||||
| `recharge-orders` | 充值管理 | `#recharge-orders` |
|
||||
| `editor-generation-pricing` | 模型定价 | `#editor-generation-pricing` |
|
||||
| `editor-showcase` | 精选审核 | `#editor-showcase` |
|
||||
| `editor-assets` | 素材查询 | `#editor-assets` |
|
||||
| `creation-announcement` | 入口公告 | `#creation-announcement` |
|
||||
| `creation-entry` | 入口开关 | `#creation-entry` |
|
||||
| `work-visibility` | 作品可见性 | `#work-visibility` |
|
||||
|
||||
权限数组必须去重并按上表顺序规范化后保存。保存时拒绝未知值和 `accounts`;读取旧数据时遇到未知值应忽略并记录告警,绝不能将未知值解释为全权限。空数组合法,表示 member 可以登录但没有业务页面权限。
|
||||
|
||||
后续新增一级 Tab 时,必须在同一次改动中更新:
|
||||
|
||||
- shared-contracts 的 `ADMIN_TAB_PERMISSIONS`。
|
||||
- admin-web 的路由定义、权限标签和第一可访问项顺序。
|
||||
- 本文 API-to-Tab 权限矩阵。
|
||||
- 后端路由权限测试;没有明确权限映射的新 `/admin/api/*` 路由必须默认拒绝 member,而不是默认放行。
|
||||
|
||||
## 5. 数据模型
|
||||
|
||||
新增 SpacetimeDB 私有表 `admin_account`。表不能标记 `public`,浏览器不能订阅或直查;所有读写都由 `api-server -> spacetime-client facade -> 受限 procedure` 完成。
|
||||
|
||||
| 字段 | Rust / SpacetimeDB 类型 | 约束与语义 |
|
||||
| --- | --- | --- |
|
||||
| `account_id` | `String` | 主键;服务端生成不可变 opaque id,建议 `admin-account-<uuid>`,请求体不得指定 |
|
||||
| `username` | `String` | `unique`;登录名,创建后不可修改;按 `trim + ASCII lowercase` 规范化 |
|
||||
| `display_name` | `String` | 展示名,去除首尾空白后 1 至 64 字符 |
|
||||
| `password_hash` | `String` | Argon2id PHC 字符串;只在内部登录查询中返回给 api-server,永不进入 HTTP DTO、日志或前端状态 |
|
||||
| `permissions_json` | `String` | 规范化后的 Tab permission JSON;只允许第 4 节 18 个值,空数组为 `[]` |
|
||||
| `enabled` | `bool` | 是否允许登录和继续使用现有 JWT |
|
||||
| `token_version` | `u64` | 初始为 `1`;权限、密码或启停状态发生有效变化时加 `1` |
|
||||
| `created_by` | `String` | 创建者后台 subject;当前只能是 owner subject |
|
||||
| `updated_by` | `String` | 最近更新者后台 subject;当前只能是 owner subject |
|
||||
| `created_at` | `Timestamp` | 创建时间,使用 `ctx.timestamp` |
|
||||
| `updated_at` | `Timestamp` | 最近更新时间,使用 `ctx.timestamp` |
|
||||
|
||||
账号规则:
|
||||
|
||||
- `username` 建议限制为 3 至 64 个字符,只允许 ASCII 字母、数字、`.`、`_`、`-`;规范化后做唯一性校验。
|
||||
- owner 用户名属于保留名称。创建 member 时必须同时与当前规范化后的 owner 用户名比较并拒绝冲突,不能只依赖 `admin_account.username` 唯一索引。
|
||||
- 密码明文只存在于登录、创建和改密请求生命周期内;限制为 6 至 128 个字符,并复用 `platform-auth` 的 Argon2id 哈希与校验能力。Argon2id 必须在 blocking 任务中执行,api-server 通过有界信号量限制同时 hash / verify 数量,不得占用 Tokio worker 或无界堆积高成本任务。
|
||||
- 不提供物理删除 API。离职或停用通过 `enabled = false` 完成,以保留 `created_by`、`updated_by` 和账号标识。
|
||||
- `display_name` 单独变化只更新 `updated_by`、`updated_at`,不要求递增 `token_version`;权限、密码、`enabled` 任一有效变化必须在同一事务中递增版本。
|
||||
- `u64` 版本到达上限时更新失败关闭,不能回绕。
|
||||
|
||||
## 6. SpacetimeDB 与 facade 边界
|
||||
|
||||
建议新增 `server-rs/crates/spacetime-module/src/admin_account.rs`,并在 `spacetime-client` 增加对应 admin facade。至少提供以下 typed procedures:
|
||||
|
||||
| procedure | 用途 | 是否可返回 `password_hash` |
|
||||
| --- | --- | --- |
|
||||
| `get_admin_account_by_username_and_return` | member 登录查询 | 是,仅返回给 api-server 内部认证路径 |
|
||||
| `get_admin_account_by_id_and_return` | member JWT 逐请求校验 | 否 |
|
||||
| `list_admin_accounts_and_return` | owner 账号列表 | 否 |
|
||||
| `create_admin_account_and_return` | owner 创建 member | 否 |
|
||||
| `update_admin_account_and_return` | owner 更新展示名、密码 hash、权限、启停 | 否 |
|
||||
|
||||
所有 `admin_account` procedures 都必须在事务入口调用现有 `require_editor_generation_runtime_service_identity(...)` 等价的统一 runtime service identity 守卫,只允许 api-server 当前 runtime service identity 调用。不能因为它们位于后台命名空间就接受任意 SpacetimeDB client identity,也不能新增 public table/view 暴露账号或 hash。
|
||||
|
||||
写 procedure 接收由 api-server 从已认证 owner 会话生成的 `actor_subject`,校验非空后写入 `created_by` / `updated_by`。SpacetimeDB 仍以 `ctx.sender()` 校验调用方是 runtime service identity;不能把请求体中的 actor 当成调用身份。
|
||||
|
||||
procedure result 使用 typed snapshot,不使用不透明 `row_json`。账号列表 snapshot 明确排除 `password_hash`;登录专用 snapshot 与普通账号 DTO 分离,避免序列化时误回传 hash。
|
||||
|
||||
## 7. 登录与 JWT 契约
|
||||
|
||||
### 7.1 登录优先级
|
||||
|
||||
`POST /admin/api/login` 按下列固定顺序处理:
|
||||
|
||||
1. 规范化提交的用户名。
|
||||
2. 若用户名等于 owner 用户名,直接校验 `GENARRATIVE_ADMIN_PASSWORD`。
|
||||
3. owner 密码不匹配时返回统一的“管理员用户名或密码错误”,不得继续查询同名 member。
|
||||
4. 用户名不等于 owner 时,按规范化用户名查询 `admin_account`。
|
||||
5. member 不存在、`enabled = false` 或 Argon2id 校验失败时返回同一登录错误,不泄露账号是否存在或被停用。
|
||||
6. member 登录成功后,用当前 `account_id`、`token_version` 签发后台 JWT。
|
||||
|
||||
owner 优先既保持原账号行为,也防止数据库同名记录遮蔽或降级 owner。密码比较不得写日志;member 必须复用 Argon2id 校验,不能存明文或可逆密文。未知 member、已停用 member 和 owner 错误密码路径仍执行同成本 dummy Argon2id 校验,避免从响应耗时枚举启用账号。
|
||||
|
||||
### 7.2 JWT claims 与逐请求校验
|
||||
|
||||
后台 JWT 继续使用后台独立 TTL 与签名配置。claims 至少能区分:
|
||||
|
||||
- `account_type`: `owner | member`。
|
||||
- owner 的稳定 subject,或 member 的 `account_id` subject。
|
||||
- `token_version`:member 使用表中当前值;owner 使用固定虚拟值,不能由数据库覆盖。
|
||||
- `roles`:保留 `admin`,并追加 `owner` 或 `member`。
|
||||
|
||||
权限不作为 JWT 中的授权真相。即使为调试在 claims 中携带 permissions,后端也必须忽略它并读取实时账号。
|
||||
|
||||
`require_admin_auth` 每次请求都要构造“当前账号”:
|
||||
|
||||
1. 验签并校验后台 issuer、过期时间和 `admin` role。
|
||||
2. owner JWT:与当前环境变量构造的 owner subject 匹配,得到始终启用、全权限的虚拟当前账号;owner 不查 `admin_account`。
|
||||
3. member JWT:按 claim 中 `account_id` 调用 `get_admin_account_by_id_and_return`,账号不存在或 `enabled = false` 时拒绝。只有明确的账号不存在才视为凭据失效;SpacetimeDB 超时、断连或 procedure 故障必须失败关闭但保留 `502/503` 依赖错误语义,不能伪装成 `401` 导致前端清除 token。
|
||||
4. member claim 的 `token_version` 必须与表中当前值完全一致,否则返回 `401 Unauthorized` 并要求重新登录。
|
||||
5. 将服务端实时构造的 `AuthenticatedAdmin` 放入 request extensions,后续权限 middleware 只读取该对象,不再相信原始 claims。
|
||||
|
||||
权限、密码、启停更新递增 `token_version` 后,目标 member 的所有旧 JWT 从下一次请求开始失效。owner 修改自己的环境变量密码仍通过部署配置和服务重启完成;owner 不入表,因此不使用 member 的 `token_version` 机制。
|
||||
|
||||
### 7.3 会话响应
|
||||
|
||||
`AdminSessionPayload` 在现有字段基础上增加:
|
||||
|
||||
```text
|
||||
accountRole: "owner" | "member"
|
||||
tabPermissions: string[]
|
||||
```
|
||||
|
||||
owner 返回全部 18 个 permission id;member 返回数据库中的实时规范化数组。`GET /admin/api/me` 同样执行逐请求校验并返回实时权限,供刷新页面后恢复导航。
|
||||
|
||||
后台所有面向运营展示的管理员身份统一使用 `displayName`。审计表继续保存稳定 subject,例如 owner subject 或 `admin-account-<uuid>`;api-server 在返回兑换码、邀请码等操作记录时,按 owner 运行态和 `admin_account` 批量解析显示名称,同时兼容历史用户名记录。已无法解析的历史主体统一展示“已停用管理员”,前端不得直接渲染 `operatorUserId`、账号 ID 或登录用户名代替显示名称。对写接口,显示名目录必须在主事务前加载,或在主事务成功后降级为占位文案;不得因二次读取失败把已提交写入伪装成失败。
|
||||
|
||||
## 8. 权限中间件与错误语义
|
||||
|
||||
在统一 `require_admin_auth` 之后增加可复用的权限守卫,支持:
|
||||
|
||||
- `require_admin_permission(permission)`:owner 自动通过;member 必须包含该 permission。
|
||||
- `require_any_admin_permission([permission...])`:owner 自动通过;member 至少包含一个,用于共享 API。
|
||||
- `require_admin_owner`:只接受服务端确认的 owner。
|
||||
|
||||
返回语义统一如下:
|
||||
|
||||
- `401 Unauthorized`:token 缺失、无效、过期,member 不存在、被停用或 `token_version` 过期。
|
||||
- `403 Forbidden`:会话有效但缺少目标 Tab 权限,或 member 请求 owner-only API。
|
||||
- 前端收到 `401` 清除本地 token 并回到登录页;收到 `403` 不应伪装成掉线,应刷新 `/me` 权限并跳转到第一可访问项或零权限空态。
|
||||
|
||||
## 9. API-to-Tab 权限矩阵
|
||||
|
||||
下表覆盖 `server-rs/crates/api-server/src/modules/admin.rs` 当前全部路由,并追加账号管理 API。`A OR B` 表示 member 拥有任一权限即可;owner 对全部行自动通过。
|
||||
|
||||
| Method | 路径 | 权限 |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/admin/api/login` | 公开登录入口,不要求 JWT |
|
||||
| `GET` | `/admin/api/me` | 任意有效后台会话 |
|
||||
| `GET` | `/admin/api/overview` | `overview` |
|
||||
| `GET` | `/admin/api/dashboard` | `dashboard` |
|
||||
| `POST` | `/admin/api/debug/http` | `debug` |
|
||||
| `GET` | `/admin/api/tracking/events` | `tracking` |
|
||||
| `GET` | `/admin/api/tracking/event-keys` | `tracking OR tasks` |
|
||||
| `GET` | `/admin/api/database/tables` | `tables` |
|
||||
| `GET` | `/admin/api/database/tables/{table_name}/rows` | `tables` |
|
||||
| `GET` | `/admin/api/creation-entry/config` | `gray-release OR creation-announcement OR creation-entry` |
|
||||
| `POST` | `/admin/api/creation-entry/config` | `creation-entry` |
|
||||
| `POST` | `/admin/api/creation-entry/config/banners` | `creation-announcement` |
|
||||
| `POST` | `/admin/api/creation-entry/config/interactions` | `creation-entry` |
|
||||
| `GET` | `/admin/api/feature-gates` | `gray-release` |
|
||||
| `PUT` | `/admin/api/feature-gates` | `gray-release` |
|
||||
| `GET` | `/admin/api/editor-generation-pricing` | `editor-generation-pricing` |
|
||||
| `POST` | `/admin/api/editor-generation-pricing` | `editor-generation-pricing` |
|
||||
| `GET` | `/admin/api/editor-assets` | `editor-assets` |
|
||||
| `GET` | `/admin/api/assets/read-url` | `editor-assets OR editor-showcase` |
|
||||
| `GET` | `/admin/api/editor-showcase/assets` | `editor-showcase` |
|
||||
| `POST` | `/admin/api/editor-showcase/assets/review` | `editor-showcase` |
|
||||
| `POST` | `/admin/api/editor-showcase/assets/display` | `editor-showcase` |
|
||||
| `GET` | `/admin/api/editor-showcase/campaign` | `editor-showcase` |
|
||||
| `POST` | `/admin/api/editor-showcase/campaign` | `editor-showcase` |
|
||||
| `POST` | `/admin/api/editor-showcase/campaign/image-upload-ticket` | `editor-showcase` |
|
||||
| `GET` | `/admin/api/works/visibility` | `work-visibility` |
|
||||
| `POST` | `/admin/api/works/visibility` | `work-visibility` |
|
||||
| `GET` | `/admin/api/profile/redeem-codes` | `redeem` |
|
||||
| `POST` | `/admin/api/profile/redeem-codes` | `redeem` |
|
||||
| `POST` | `/admin/api/profile/redeem-codes/disable` | `redeem` |
|
||||
| `GET` | `/admin/api/profile/invite-codes` | `invite` |
|
||||
| `POST` | `/admin/api/profile/invite-codes` | `invite` |
|
||||
| `GET` | `/admin/api/profile/tasks` | `tasks` |
|
||||
| `POST` | `/admin/api/profile/tasks` | `tasks` |
|
||||
| `POST` | `/admin/api/profile/tasks/disable` | `tasks` |
|
||||
| `GET` | `/admin/api/profile/wallet-config` | `profile-wallet` |
|
||||
| `POST` | `/admin/api/profile/wallet-config` | `profile-wallet` |
|
||||
| `GET` | `/admin/api/profile/recharge-products` | `recharge-products` |
|
||||
| `POST` | `/admin/api/profile/recharge-products` | `recharge-products` |
|
||||
| `GET` | `/admin/api/profile/recharge-orders` | `recharge-orders` |
|
||||
| `POST` | `/admin/api/profile/recharge-refunds/preview` | `recharge-orders` |
|
||||
| `POST` | `/admin/api/profile/recharge-refunds/execute` | `recharge-orders` |
|
||||
| `POST` | `/admin/api/profile/recharge-refunds/register` | `recharge-orders` |
|
||||
| `POST` | `/admin/api/profile/recharge-refunds/manual-review/resolve` | `recharge-orders` |
|
||||
| `GET` | `/admin/api/profile/users/detail` | `tables OR tracking OR recharge-orders OR editor-showcase OR editor-assets OR work-visibility` |
|
||||
| `POST` | `/admin/api/profile/wallet-restriction` | `recharge-orders` |
|
||||
| `GET` | `/admin/api/accounts` | owner-only |
|
||||
| `POST` | `/admin/api/accounts` | owner-only |
|
||||
| `PUT` | `/admin/api/accounts/{account_id}` | owner-only |
|
||||
|
||||
三个共享读取接口必须按 OR 规则实现,不能为了复用简单中间件扩大成“任意 member 可访问”:
|
||||
|
||||
- `/admin/api/assets/read-url` 只服务素材查询和精选审核。
|
||||
- `/admin/api/profile/users/detail` 只服务当前实际包含用户详情入口的表查询、埋点数据、充值管理、精选审核、素材查询和作品可见性页面。
|
||||
- `GET /admin/api/creation-entry/config` 同时为灰度发布、入口公告和入口开关提供页面初始化数据;写操作仍按具体页面单独收紧。
|
||||
|
||||
## 10. 账号管理 HTTP 契约
|
||||
|
||||
账号管理 API 放在现有 `/admin/api` 命名空间,统一使用现有 success/error envelope。
|
||||
|
||||
### 10.1 `GET /admin/api/accounts`
|
||||
|
||||
返回:
|
||||
|
||||
```text
|
||||
accounts: Array<{
|
||||
accountId,
|
||||
username,
|
||||
displayName,
|
||||
tabPermissions,
|
||||
enabled,
|
||||
tokenVersion,
|
||||
createdBy,
|
||||
updatedBy,
|
||||
createdAt,
|
||||
updatedAt
|
||||
}>
|
||||
```
|
||||
|
||||
首项由 api-server 根据环境变量合成只读 owner 记录,后续 member 按用户名和账号 ID 稳定排序。不得返回 `password` 或 `passwordHash`,owner 记录必须带 `accountRole = "owner"` 且前后端都拒绝编辑。
|
||||
|
||||
### 10.2 `POST /admin/api/accounts`
|
||||
|
||||
请求:
|
||||
|
||||
```text
|
||||
{
|
||||
username: string,
|
||||
displayName: string,
|
||||
password: string,
|
||||
tabPermissions: string[],
|
||||
enabled?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
`password` 创建时必填;`enabled` 默认 `true`。api-server 完成用户名、密码和权限校验,使用 Argon2id 生成 hash 后调用创建 procedure。用户名冲突返回 `409 Conflict`;参数非法返回 `400 Bad Request`。响应为不含任何密码字段的 `account`。
|
||||
|
||||
### 10.3 `PUT /admin/api/accounts/{account_id}`
|
||||
|
||||
请求:
|
||||
|
||||
```text
|
||||
{
|
||||
displayName: string,
|
||||
password?: string,
|
||||
tabPermissions: string[],
|
||||
enabled: boolean
|
||||
}
|
||||
```
|
||||
|
||||
`username` 和 `account_id` 不可修改。更新请求完整提交显示名称、Tab 权限和启停状态;密码省略表示不修改,空字符串密码作为非法参数拒绝。api-server 只在提供新密码时生成新 hash。procedure 比较有效变化,在权限、密码或启停任一变化时只递增一次 `token_version`,并在同一事务写入账号字段、`updated_by`、`updated_at`。响应仍不返回密码或 hash。
|
||||
|
||||
## 11. admin-web 行为
|
||||
|
||||
### 11.1 路由与导航
|
||||
|
||||
- `adminRoutes` 增加权限元数据;18 个业务路由使用同名 permission id。
|
||||
- `accounts` 路由只在 `admin.accountRole === "owner"` 时加入侧栏和移动底栏,不属于 member 可分配列表。
|
||||
- member 导航只渲染 `admin.tabPermissions` 包含的业务路由。页面组件也必须只在当前路由已授权时挂载,避免隐藏导航后仍发起无权限 API。
|
||||
- owner 渲染全部业务路由和账号管理路由。
|
||||
|
||||
### 11.2 hash 回落
|
||||
|
||||
登录成功、`GET /me` 恢复会话、权限刷新和 `hashchange` 时都执行同一解析:
|
||||
|
||||
1. 当前 hash 对应可访问路由时保持不变。
|
||||
2. hash 未知、属于无权限业务 Tab,或 member 访问 `#accounts` 时,使用 `replaceState` 回落到按第 4 节顺序找到的第一可访问业务 Tab。
|
||||
3. member 权限为空时,不回落 Dashboard;渲染独立的零权限空态,只保留账号信息和退出登录,不挂载任何业务页,也不发起业务 API。
|
||||
4. owner 的默认项仍可保持 Dashboard;账号管理不改变 18 个业务路由的排序。
|
||||
|
||||
后端返回 `403` 时,前端重新请求 `/me` 获取实时权限并执行上述回落。即使前端状态陈旧或被篡改,后端权限 middleware 仍必须拒绝越权请求。
|
||||
|
||||
### 11.3 账号管理页
|
||||
|
||||
- 权限编辑器展示 18 个明确的 checkbox,每项使用现有 Tab 中文名称;不能展示或提交 `accounts`。
|
||||
- 创建和编辑使用独立弹窗或抽屉,不在列表下方追加表单。
|
||||
- 编辑时密码字段默认空,空表示请求中省略 `password`;页面永不展示现有密码或 hash。
|
||||
- 停用使用开关并二次确认。保存成功后以 API 返回 account snapshot 更新列表。
|
||||
- owner 以只读项显示在账号列表首位,页面顶部同时标明当前 owner;owner 行不可进入编辑表单,后端也拒绝以 owner subject 调用更新接口。
|
||||
|
||||
## 12. 实现落点
|
||||
|
||||
建议按以下边界落地,避免在前端或 `api-server` 重新发明持久化规则:
|
||||
|
||||
- `shared-contracts`:`ADMIN_TAB_PERMISSIONS`、扩展后的 `AdminSessionPayload`、账号管理 request/response DTO。
|
||||
- `spacetime-module`:私有表、输入类型、typed procedures、唯一性与版本递增事务。
|
||||
- `spacetime-client`:生成绑定、row mapper、登录查询与账号管理 facade。
|
||||
- `api-server`:owner/member 登录编排、Argon2id、逐请求账号解析、权限 middleware、账号管理 handlers。
|
||||
- `apps/admin-web`:权限感知路由、hash 回落、零权限空态、owner-only 账号管理页。
|
||||
|
||||
不能将 `permissions_json` 的解析与授权只放在前端;不能让 admin-web 直连 SpacetimeDB;不能用进程内 member 列表替代 `admin_account`。
|
||||
|
||||
## 13. 迁移、绑定与发布顺序
|
||||
|
||||
`admin_account` 是新增私有表,没有旧数据回填。原环境变量 owner 不入表,因此迁移不创建 owner 行。
|
||||
|
||||
实现 schema 后必须:
|
||||
|
||||
1. 将 `admin_account` 加入 `server-rs/crates/spacetime-module/src/migration.rs` 的导入导出/迁移表目录,保证备份迁移保留 member。
|
||||
2. 将表和 procedures 加入 `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` 的机器可读表目录与后台契约。
|
||||
3. 生成并提交 `spacetime-client` bindings,不手改生成文件。
|
||||
4. 先发布 SpacetimeDB module,再发布依赖新 procedures 的 api-server,最后发布 admin-web。
|
||||
|
||||
本地生成与门禁命令:
|
||||
|
||||
```bash
|
||||
npm run spacetime:generate
|
||||
npm run check:admin-account-procedures
|
||||
npm run check:spacetime-runtime-access
|
||||
npm run check:spacetime-schema
|
||||
npm run check:server-rs-ddd
|
||||
```
|
||||
|
||||
本地迁移联调优先使用项目脚本:
|
||||
|
||||
```bash
|
||||
npm run dev:spacetime
|
||||
npm run dev:api-server
|
||||
```
|
||||
|
||||
需要人工发布到指定 SpacetimeDB 时必须显式目标,不使用 `spacetime --root-dir`:
|
||||
|
||||
```bash
|
||||
spacetime publish <database> \
|
||||
--server <server-url> \
|
||||
--module-path server-rs/crates/spacetime-module \
|
||||
--yes=migrate
|
||||
```
|
||||
|
||||
回滚旧 api-server 时保留 `admin_account` 表和数据;旧版本只认识 owner,不会读取 member。不得为了回滚删除表或清空 member 数据。
|
||||
|
||||
## 14. 测试与验收
|
||||
|
||||
### 14.1 后端与数据
|
||||
|
||||
- owner 使用原环境变量账号密码登录成功,且不生成 `admin_account` 行。
|
||||
- owner 用户名匹配但密码错误时,不回退到 member 查询。
|
||||
- member 密码使用 Argon2id 校验;账号不存在、密码错误、停用账号返回相同登录错误。
|
||||
- member 私表不能被普通 SpacetimeDB identity 查询或调用 procedure;只有 runtime service identity 可读写。
|
||||
- 创建重复规范化用户名、owner 保留用户名、未知权限或 `accounts` 权限均失败。
|
||||
- GET/POST/PUT 账号 API 任何响应和日志都不包含明文密码或 `password_hash`。
|
||||
- 权限、密码、启停更新各自会递增 `token_version`;同一次请求修改多项只递增一次;仅改展示名不递增。
|
||||
- member 被停用、改密或改权限后,旧 JWT 下一次请求返回 401;重新登录后获得实时权限。
|
||||
- API-to-Tab 矩阵逐路由覆盖 `modules/admin.rs`,每条路由至少测试 owner 成功、具备权限的 member 成功、缺权限 member 返回 403。
|
||||
- 三个共享读取接口分别覆盖每个允许 permission 的成功用例,以及无关 permission 的 403 用例。
|
||||
- owner-only 账号 API 对任意 member 都返回 403,即使其 `permissions_json` 被污染为包含 `accounts`。
|
||||
|
||||
### 14.2 前端
|
||||
|
||||
- owner 看到 18 个业务 Tab 和账号管理;member 只看到被分配的业务 Tab。
|
||||
- 每个一级 Tab 内的二级 Tab、弹窗和写操作继承一级权限并正常使用,不出现“页面可见但内部 API 403”的错误映射。
|
||||
- 直接输入无权限 hash 自动替换为第一可访问项,不短暂挂载无权限页面。
|
||||
- 当前 Tab 权限被 owner 收回后,下一请求触发重新登录;新会话恢复后落到第一可访问项。
|
||||
- 零权限 member 登录后显示空态,不回落 Dashboard、不发送 Dashboard 或其它业务请求,并可正常退出。
|
||||
- 桌面侧栏和移动底栏应用同一过滤结果;账号创建/编辑弹层在移动端和桌面端都可操作。
|
||||
- 编辑账号时页面不读取、不显示、不回填密码;不改密码时 PUT 请求不包含 `password`。
|
||||
|
||||
### 14.3 建议验证命令
|
||||
|
||||
```bash
|
||||
cargo test -p spacetime-module --manifest-path server-rs/Cargo.toml admin_account
|
||||
cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml admin_account
|
||||
cargo test -p api-server --manifest-path server-rs/Cargo.toml admin
|
||||
npm run check:admin-account-procedures
|
||||
npx vitest run apps/admin-web/src/app/adminRoutes.test.ts \
|
||||
apps/admin-web/src/app/AdminApp.test.tsx \
|
||||
apps/admin-web/src/pages/AdminAccountManagementPage.test.tsx
|
||||
npm run admin-web:typecheck
|
||||
npm run check:encoding
|
||||
git diff --check
|
||||
```
|
||||
|
||||
API smoke 使用 `npm run dev:api-server` 启动后端,先检查 `/healthz`,再用 owner 和至少两个不同权限集合的 member 验证登录、`/admin/api/me`、共享 OR 路由、越权 403 和 `token_version` 即时失效。
|
||||
|
||||
## 15. 非目标
|
||||
|
||||
- 不提供 member 自助改密、忘记密码、MFA、SSO 或外部身份源。
|
||||
- 不提供按钮级、字段级、只读/读写分离权限。
|
||||
- 不提供 owner 数据库化、多个 owner 或 member 删除。
|
||||
- 不改变普通用户认证、普通用户 `token_version` 或主站权限体系。
|
||||
- 不把后台账号暴露为公开 SpacetimeDB 表、浏览器 subscription 或普通用户账号。
|
||||
Reference in New Issue
Block a user