docs: record P4-04..P4-07 admin acceptance and leftover items
This commit is contained in:
parent
2aa1af97a4
commit
956862215d
@ -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:集成与上线准备
|
||||
|
||||
|
||||
@ -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 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`),未纳入仓库依赖管理。
|
||||
|
||||
185
services/api/tests/phase4_browser.py
Normal file
185
services/api/tests/phase4_browser.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user