docs: record P4-04..P4-07 admin acceptance and leftover items

This commit is contained in:
flym 2026-09-10 02:59:36 +08:00
parent 2aa1af97a4
commit 956862215d
3 changed files with 247 additions and 16 deletions

View File

@ -81,15 +81,15 @@ Phase 1 当前可在本地继续开发,也可在测试机仅部署官网骨架
### Phase 4CMS 与管理后台
当前状态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集成与上线准备

View File

@ -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]=<slug>`(返回 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 Chromiumadmin_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-05VIP 授予/撤销(含有效期)、额度明细、调整记录+原因+幂等+余额约束。
- P4-06TTS 任务按用户/状态/时间筛选并显示计量/耗时/错误/结算、用量汇总与流水一致、审计不含用户文本且不显示秘密。
- P4-07真实浏览器桌面 1440x900 / 移动 390x844完成查用户、禁用/恢复、授予/撤销 VIP、调整额度、查询任务与审计普通用户页面与接口均无权限受控测试账户验证 VIP 权益反映到账户并约束 TTS 提交。
### 遗留项
- `/cms/api/products/:slug``id` 路径查询返回 500Payload 行为);公网展示走 `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`),未纳入仓库依赖管理。

View File

@ -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()