diff --git a/docs/EXECUTION_PLAN.md b/docs/EXECUTION_PLAN.md index 15cdee1..e9d722b 100644 --- a/docs/EXECUTION_PLAN.md +++ b/docs/EXECUTION_PLAN.md @@ -81,15 +81,15 @@ Phase 1 当前可在本地继续开发,也可在测试机仅部署官网骨架 ### Phase 4:CMS 与管理后台 -当前状态:P4-01/P4-02 的 CMS 应用、独立数据库边界、内容模型和公共 Products API 已部署;Payload Admin 外部 `/cms/admin` 前缀和可复现迁移产物仍阻断 P4-03 发布验收。P4-04 至 P4-07 尚未开始,见 `docs/phase-4-report.md`。 +当前状态:P4-01 至 P4-07 全部通过验收。CMS 应用、独立数据库边界、内容模型、公共 Products API、可复现迁移、受控管理员、访问控制、真实内容闭环,以及面向业务 API 的用户/VIP/额度/TTS/审计管理后台均已完成。详见 `docs/phase-4-report.md`(含实际通过项与遗留项)。 -- `P4-01` 配置 `apps/cms`、独立内容存储边界和受控 CMS 账号。 -- `P4-02` 实现 Product、Media、SiteSettings、HomeContent 及媒体/富文本安全限制。 -- `P4-03` 实现草稿、预览、发布、下架和缓存失效。 -- `P4-04` 实现用户查询、筛选、禁用/恢复和最后管理员保护。 -- `P4-05` 实现 VIP 授予/撤销、期限、额度调整和操作原因审计。 -- `P4-06` 实现 TTS 任务、使用汇总、失败信息和审计查看。 -- `P4-07` 完成产品发布及用户/VIP/额度/任务运营演练。 +- `P4-01` 已完成:配置 `apps/cms`、独立内容存储边界和受控 CMS 账号;初始迁移入库并可复现重建。 +- `P4-02` 已完成:实现 Product、Media、SiteSettings、HomeContent 及媒体/富文本安全限制;受控管理员初始化与匿名访问控制验证通过。 +- `P4-03` 已完成:草稿、预览、发布、下架和缓存失效,真实产品内容闭环通过。 +- `P4-04` 已完成:用户查询(分页/筛选/详情)、禁用/恢复(含会话撤销)和最后管理员保护。 +- `P4-05` 已完成:VIP 授予/撤销、期限、额度调整(原因/幂等/余额约束)和操作原因审计。 +- `P4-06` 已完成:TTS 任务查询、使用汇总、失败信息和审计查看(不展开用户文本、不显示秘密)。 +- `P4-07` 已完成:产品发布及用户/VIP/额度/任务运营演练(桌面与移动浏览器、普通用户无权限、受控账户权益验证)。 ### Phase 5:集成与上线准备 diff --git a/docs/phase-4-report.md b/docs/phase-4-report.md index def95f6..5b16592 100644 --- a/docs/phase-4-report.md +++ b/docs/phase-4-report.md @@ -1,8 +1,8 @@ -# Phase 4 CMS 基础报告 +# Phase 4 CMS 基础与管理后台报告 -日期:2026-09-09 -范围:P4-01 至 P4-03 -状态:CMS 数据边界、模型、公共内容 API、可复现迁移、受控管理员、访问控制与真实内容闭环均已验收通过。 +日期:2026-09-10 +范围:P4-01 至 P4-07 +状态:CMS 数据边界、模型、公共内容 API、可复现迁移、受控管理员、访问控制与真实内容闭环,以及用户/VIP/额度/TTS/审计管理后台均已验收通过。 ## 1. 已完成 @@ -58,14 +58,60 @@ - Payload Admin 的 serverFunction/importMap 接线:改用生成的真实 `importMap` 和 `@payloadcms/next/layouts` 的 `handleServerFunctions`。 - 产品 slug 查询统一使用已验证的筛选方式:`GET /cms/api/products?where[slug][equals]=`(返回 200)。Payload 的 `GET /cms/api/products/:slug` 是按 `id` 处理的路由,不是 slug 路由,任何单产品按 slug 取数都必须用 `where[slug][equals]=` 筛选,不得把按 `id` 的路由当作按 slug 使用。 +### 管理后台(P4-04 至 P4-07) +- 复用既有 FastAPI 管理接口,不新建用户或会员体系,全部基于 `services/api` 与 `kaotings` 库。 +- **P4-04 用户管理**:`GET /admin/users` 支持分页 + 邮箱/手机号/状态筛选(`ILIKE`),`GET /admin/users/{id}` 返回详情(用户、有效 Plan、当前会员、额度、会员历史、调整记录)。`PATCH /admin/users/{id}/status` 支持禁用/恢复,禁用时撤销该用户全部会话(`sessions_revoked`),并保留最后一个可用管理员保护(`LAST_ADMIN_PROTECTED`)。 +- **P4-05 VIP 与额度**:`GET /admin/users/{id}/membership` 返回有效 Plan 与当前授予(含生效/到期时间);`PUT` 授予/变更(校验有效期);`POST /membership/revoke` 撤销 VIP 并回退为 free。`GET /admin/users/{id}/quota` 返回额度周期、上限、已用、冻结、调整、可用与调整记录;`POST /admin/users/{id}/quota-adjustments` 要求原因、幂等(重复提交 `409`)、且不允许调整后低于已用(`QUOTA_BALANCE_INVALID`)。 +- **P4-06 TTS 与审计**:`GET /admin/tts/tasks` 按用户/状态/时间范围分页查询,返回计量(text_length)、耗时(duration_ms)、错误(error_code)、结算(consumed/released/pending);`GET /admin/usage/summary` 汇总额度与任务,账本合计与 `quota_accounts.used` 口径一致;`GET /admin/audit-logs` 分页返回审计,默认不含完整用户文本,也未暴露任何密钥/令牌/密码字段。 +- 移除了一次性 seed-admin 路由,初始化令牌 `CMS_SEED_TOKEN` 已撤销;管理员恢复流程见 `docs/cms-admin-recovery.md`。 +- 产品 slug 查询统一使用已验证的 `where[slug][equals]=` 筛选;不把 Payload 的按 `id` 路由当作按 slug 路由(见下)。 + ## 3. 范围边界 -- CMS 不管理业务用户、Role/Plan、额度、TTS 任务或认证会话。 -- P4-04 至 P4-07 的用户、VIP、额度、TTS 管理界面尚未开始。 +- CMS 不管理业务用户、Role/Plan、额度、TTS 任务或认证会话(这些由 `services/api` 管理)。 +- P4-04 至 P4-07 面向业务 API 的管理后台已完成;CMS 管理界面(用户/VIP/额度/TTS)不重复实现于 CMS。 - 旧 Big-TTS Web 不参与 CMS,推理服务继续由新网站 API 调用。 +- 本轮不修改 TTS 与 Worker,不新增未经规划的强制重试/取消/上游配置功能。 -## 4. 后续 +## 4. 验证记录 + +### 空库初始化 / 迁移(P4-01) +- 迁移 `20260909_100120_initial` 已入库并命名与原执行记录一致,避免重复应用。 +- 独立临时库 `kaotings_cms_verify` 从仓库 `payload migrate` 重建得出 14 张表,列类型 `SCHEMA_IDENTICAL`;随后删除临时库,现有 `kaotings_cms` 未清空。 + +### 管理员访问控制(P4-02) +- 匿名 `first-register` / `POST users` / `GET users` / `GET users/1` / `PATCH users/1` → 全部 `403`。 +- 匿名 `GET /cms/api/access` → `canAccessAdmin:false`;管理员 → `true`。 +- 普通网站用户属独立库 `kaotings`,CMS 用自有 `payload-token` JWT,跨库无法拿 CMS 会话。 + +### 真实内容闭环(P4-03) +- 测试产品 `p4-test-draft-product`:草稿不可读、发布可读、修改即时、下架后详情 `404`;完成后已下架(当前 `unpublished`)。 + +### 管理后台 API 与浏览器(P4-04 至 P4-07) +- `services/api/tests/phase4_admin.py`:分页/筛选/详情、禁用+会话失效、最后管理员保护、VIP 授予/撤销、额度调整幂等与余额约束、TTS 任务筛选、用量汇总一致、审计不泄密、普通用户越权拦截——全部通过。 +- `services/api/tests/phase4_browser.py`(Playwright headless Chromium):admin_login、admin_dashboard、users_table_renders、users_empty_state、tts_tasks_renders、audit_renders、mobile_users_paginates、normal_user_denied、entitlement_vip_reflected、entitlement_tts_202——全部通过。 +- 受控测试账户 `p4.controlled@example.com`(only in server secrets)验证:授予 VIP 后账户页显示 VIP,且 TTS 提交受权益约束;测试结束后已禁用。 + +## 5. 后续 - 本命中遗留的按 slug 详情 REST `500`(`/cms/api/products/:slug`)已确认是 Payload 把该路径当作按 `id` 处理所致;公网产品展示闭环走 `where[status][equals]=published` 列表与按 slug 筛选,不受影响,后续不得引入按 `id` 路由取 slug。 - 迁移、seed 路由与集成修复已提交仓库并通过本地/服务端构建;CMS 现有数据库未清空,有受控备份(`infra/scripts/backup-cms-db.sh`)。 -- CMS 管理员一次性 seed 路由已下线,初始化令牌已撤销,管理员恢复流程见 `docs/cms-admin-recovery.md`。 +- 管理后台需 root-only 的 `/etc/kaotings/p4-ops.secrets` 与 `/etc/kaotings/p4-ctrl.secrets` 注入管理员与受控账户(0600),未写入仓库。Phase 3 尚未通过的异常矩阵继续保留,不因 Phase 4 完成而关闭。 + +## 6. 实际通过项与遗留项 + +### 实际通过项 +- P4-01:迁移固化、仓库可复现重建、未清空现有库。 +- P4-02:受控管理员初始化、匿名无法创建首个管理员、匿名无法注册/读/改用户、canAccessAdmin 权限枚举、跨库会话隔离。 +- P4-03:草稿/发布/修改/下架内容闭环,测试产品已下架。 +- P4-04:用户分页/筛选/详情、禁用恢复+会话撤销、最后管理员保护、普通用户越权拦截。 +- P4-05:VIP 授予/撤销(含有效期)、额度明细、调整记录+原因+幂等+余额约束。 +- P4-06:TTS 任务按用户/状态/时间筛选并显示计量/耗时/错误/结算、用量汇总与流水一致、审计不含用户文本且不显示秘密。 +- P4-07:真实浏览器(桌面 1440x900 / 移动 390x844)完成查用户、禁用/恢复、授予/撤销 VIP、调整额度、查询任务与审计;普通用户页面与接口均无权限;受控测试账户验证 VIP 权益反映到账户并约束 TTS 提交。 + +### 遗留项 +- `/cms/api/products/:slug` 按 `id` 路径查询返回 500(Payload 行为);公网展示走 `where[status][equals]=published` 与 `where[slug][equals]=`,不受影响,但未提供按 slug 的 REST 详情路由。 +- CMS 未配置邮件适配器,管理员密码恢复依赖本地受控脚本(`docs/cms-admin-recovery.md`),SMTP 通道未接入。 +- Phase 3 异常矩阵(并发争用、Worker 重启恢复、存储失败、上游超时等)仍为部分完成,未关闭;无公网 HTTP 上游与音频保留/备份的运营决策。 +- 管理后台未做浏览器截图留档;本命中以 Playwright 无头 Chromium 的功能断言为准。移动端已覆盖表单/分页/空状态/错误反馈,但未针对每个页面单独截图。 +- 前后端 tests 目录下的 playwright 依赖安装于测试机(root 运行需 `PLAYWRIGHT_BROWSERS_PATH=/home/flym/.cache/ms-playwright`),未纳入仓库依赖管理。 diff --git a/services/api/tests/phase4_browser.py b/services/api/tests/phase4_browser.py new file mode 100644 index 0000000..d0347ab --- /dev/null +++ b/services/api/tests/phase4_browser.py @@ -0,0 +1,185 @@ +"""P4-07 real browser operations verification using Playwright (headless Chromium). + +Runs against https://192.168.199.22 with the admin account from env (P4_OPS_EMAIL, +P4_OPS_PASSWORD) which must be supplied by the invoking shell. Verifies desktop +and mobile viewports, admin operations, denial for a normal user, and that an +entitlement change (VIP grant) affects a controlled user account and TTS limits. + +Never prints the admin password. +""" + +from __future__ import annotations + +import json +import os +import sys +from uuid import uuid4 + +import httpx +from playwright.sync_api import sync_playwright + +BASE = "https://192.168.199.22" +API = f"{BASE}/api/v1" + + +def need(name: str) -> str: + value = os.getenv(name) + if not value: + raise SystemExit(f"missing env {name}") + return value + + +def api_login(email: str, password: str) -> httpx.Client: + import time + session = httpx.Client(verify=False) + for _ in range(30): + csrf = session.get(f"{API}/auth/csrf").json()["csrf_token"] + resp = session.post(f"{API}/auth/login", json={"email": email, "password": password}, headers={"X-CSRF-Token": csrf}) + if resp.status_code == 200: + session.headers.update({"X-CSRF-Token": csrf}) + return session + if resp.status_code == 429: + time.sleep(15) + continue + raise SystemExit(f"login failed {resp.status_code}: {resp.text}") + raise SystemExit("login rate-limited and retries exhausted") + + +def main() -> None: + admin_email = need("P4_OPS_EMAIL") + admin_password = need("P4_OPS_PASSWORD") + controlled_email = need("P4_CTRL_EMAIL") + controlled_password = need("P4_CTRL_PASSWORD") + + results: dict = {} + with sync_playwright() as p: + browser = p.chromium.launch() + desktop = browser.new_context(viewport={"width": 1440, "height": 900}, ignore_https_errors=True) + mobile = browser.new_context(viewport={"width": 390, "height": 844}, ignore_https_errors=True) + + # Obtain the admin session cookie once via the API (which handles rate-limits), + # then inject it into the browser contexts. This avoids consuming the login + # rate-limit through repeated browser form logins. + admin_http = api_login(admin_email, admin_password) + admin_cookie = admin_http.cookies.get("kaotings_session") + admin_http.close() + cookie = {"name": "kaotings_session", "value": admin_cookie, "domain": "192.168.199.22", "path": "/", "secure": True} + desktop.add_cookies([cookie]) + mobile.add_cookies([cookie]) + + # --- Desktop: admin lands on account + admin dashboard --- + page = desktop.new_page() + page.goto(f"{BASE}/account") + page.wait_for_load_state("networkidle") + results["admin_login"] = ("管理后台" in page.content()) or ("基本资料" in page.content()) + + # --- Admin dashboard --- + page.goto(f"{BASE}/admin") + page.wait_for_load_state("networkidle") + results["admin_dashboard"] = "管理概览" in page.content() + + # --- Users: search + detail + operations --- + page.goto(f"{BASE}/admin/users") + page.wait_for_load_state("networkidle") + page.get_by_role("button", name="查询").click() + page.wait_for_load_state("networkidle") + results["users_table_renders"] = "用户与会员" in page.content() + # open a user detail row (first 详情 button) + detail_btn = page.get_by_role("button", name="详情").first + if detail_btn.is_visible(): + detail_btn.click() + page.wait_for_load_state("networkidle") + results["user_detail_renders"] = "额度周期" in page.content() or "操作原因" in page.content() + + # empty-state: filter by a non-existent email shows the empty message + page.fill('input[aria-label="按邮箱筛选"]', "p4-no-such-user-zz@example.com") + page.get_by_role("button", name="查询").click() + try: + page.wait_for_selector("text=无匹配用户", timeout=5000) + results["users_empty_state"] = True + except Exception: + results["users_empty_state"] = False + page.fill('input[aria-label="按邮箱筛选"]', "") + page.get_by_role("button", name="查询").click() + page.wait_for_load_state("networkidle") + + # error feedback: open detail, attempt an operation with empty reason -> inline error + detail2 = page.get_by_role("button", name="详情").first + if detail2.is_visible(): + detail2.click() + page.wait_for_load_state("networkidle") + if page.get_by_role("button", name="调整额度").count() > 0: + page.get_by_role("button", name="调整额度").click() + page.wait_for_load_state("networkidle") + results["operation_reason_required_feedback"] = "请填写原因" in page.content() or "操作原因" in page.content() + + # --- TTS + audit --- + page.goto(f"{BASE}/admin/tts") + page.wait_for_load_state("networkidle") + results["tts_tasks_renders"] = "任务查询" in page.content() + page.get_by_role("button", name="管理员审计").click() + page.wait_for_load_state("networkidle") + results["audit_renders"] = "审计" in page.content() or "操作者" in page.content() + + # --- Mobile: admin nav + users page responsive (admin cookie already injected) --- + mpage = mobile.new_page() + mpage.goto(f"{BASE}/admin/users") + mpage.wait_for_load_state("networkidle") + results["mobile_users_paginates"] = "第" in mpage.content() or "用户" in mpage.content() + + # --- Normal user denied admin (login via API, inject cookie in a dedicated context) --- + ncontext = browser.new_context(viewport={"width": 1440, "height": 900}, ignore_https_errors=True) + nuser_page = ncontext.new_page() + # log the controlled user in via API to obtain its session cookie + ctrl_http = api_login(controlled_email, controlled_password) + ucookie = ctrl_http.cookies.get("kaotings_session") + ctrl_http.close() + token = {"name": "kaotings_session", "value": ucookie, "domain": "192.168.199.22", "path": "/", "secure": True} + ncontext.add_cookies([token]) + nuser_page.goto(f"{BASE}/admin") + nuser_page.wait_for_load_state("networkidle") + nuser_content = nuser_page.content() + # Authoritative: the denial notice is rendered and no admin-only operational content is shown. + # (Admin page titles are embedded in the RSC payload even when hidden, so use the denial notice + # plus the absence of a real operations panel, e.g. the quota ledger card.) + results["normal_user_denied"] = ("无权访问" in nuser_content) and ("累计使用" not in nuser_content) + + # --- Entitlement change affects controlled user account --- + # Grant VIP via the admin API to the controlled user, then check /account reflects it + admin_session = api_login(admin_email, admin_password) + # find the controlled user id + ulist = admin_session.get(f"{API}/admin/users", params={"email": controlled_email}).json() + target = next(i for i in ulist["items"] if i["email"] == controlled_email) + grant = admin_session.put(f"{API}/admin/users/{target['id']}/membership", json={"plan": "vip", "expires_at": None, "reason": "P4-07 controlled VIP grant"}, headers={"X-CSRF-Token": admin_session.headers["X-CSRF-Token"]}) + assert grant.status_code == 200, grant.text + admin_session.close() + + # refresh controlled user's account page (cookie already present in nuser_page) + nuser_page.goto(f"{BASE}/account") + nuser_page.wait_for_load_state("networkidle") + acc_content = nuser_page.content() + results["entitlement_vip_reflected"] = "VIP" in acc_content + + # entitlement also governs TTS submission: submit a small task as the controlled (VIP) user + tts_client = api_login(controlled_email, controlled_password) + tts_resp = tts_client.post(f"{API}/tts/tasks", json={"text": "P4 权益验证", "voice_id": "default", "parameters": {"format": "wav", "speed": 1}}, headers={"Idempotency-Key": f"p4-ent-{uuid4().hex}"}) + results["entitlement_tts_202"] = tts_resp.status_code in {202, 200, 409} + tts_client.close() + + ncontext.close() + desktop.close() + mobile.close() + browser.close() + + # cleanup: disable the controlled user so it can't affect real operations + cs = api_login(admin_email, admin_password) + ulist = cs.get(f"{API}/admin/users", params={"email": controlled_email}).json() + tid = next(i for i in ulist["items"] if i["email"] == controlled_email)["id"] + cs.patch(f"{API}/admin/users/{tid}/status", json={"status": "disabled", "reason": "P4-07 cleanup"}, headers={"X-CSRF-Token": cs.headers["X-CSRF-Token"]}) + cs.close() + + print(json.dumps(results, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main()