diff --git a/docs/CURRENT-RELEASE-SURFACE.md b/docs/CURRENT-RELEASE-SURFACE.md index 149293cd7..ef00cf401 100644 --- a/docs/CURRENT-RELEASE-SURFACE.md +++ b/docs/CURRENT-RELEASE-SURFACE.md @@ -1,7 +1,7 @@ # 当前发布面清单 最后更新:2026-06-23 -生产基线:`v0.1.16` +生产基线:`v0.1.18` 公网 Web:`https://saas.xiaoxiajianji.com/` 公网 API:`https://saas-api.xiaoxiajianji.com/` @@ -101,10 +101,16 @@ - 非成员访问他人项目返回 403。 - 非成员访问他人素材库返回 404。 - 非成员上传到他人项目/素材库返回 403。 +- 匿名访问素材列表返回 401。 +- 非成员访问他人素材列表返回 403。 +- 匿名访问生成任务返回 401。 +- 非成员访问他人生成任务返回 403。 +- 非成员访问他人生成结果返回 403。 +- 非成员访问他人成片详情/下载 URL 返回 403 或 404。 生产验证: -- `public_boundary_flow=ok` +- `public_boundary_flow=ok`,覆盖 project、asset-library、assets、upload、generation task、generation results、generated video download 边界。 ### 2.8 生产发布与监控 @@ -117,7 +123,7 @@ 生产验证: -- `https://saas-api.xiaoxiajianji.com/health` 返回 `version: v0.1.16`。 +- `https://saas-api.xiaoxiajianji.com/health` 返回 `version: v0.1.18`。 - 最新巡检报告显示 API/Web/容器/版本正常,但内存偏低为 warning。 --- @@ -154,6 +160,7 @@ - `python scripts\smoke_public_auth_flow.py` - `python scripts\smoke_public_upload_flow.py` - 公开生成 smoke。 + - `python scripts\smoke_public_boundary_flow.py` - `/health` 版本必须等于当前生产 tag。 - 资源巡检报告如有 `alerts`,不得忽略。 @@ -167,6 +174,7 @@ - `tests/integration/test_projects.py`:项目详情和 workspace 上下文恢复。 - `scripts/smoke_public_auth_flow.py`:公网注册、登录、`/auth/me`、`/workspaces` smoke。 - `scripts/smoke_public_upload_flow.py`:公网工作空间、项目、素材库、上传和 ingest smoke。 +- `scripts/smoke_public_boundary_flow.py`:公网匿名/非成员访问 project、asset-library、assets、upload、generation task、generation results、generated video download 边界 smoke。 --- @@ -211,6 +219,5 @@ ## 7. 下一步 1. 执行真实浏览器 UAT:登录 → 工作空间 → 项目 → 批量上传 → 生成 → 下载。 -2. 继续扫权限边界:成片下载、生成任务、素材列表、任务日志。 -3. 处理生产资源长期风险:升级生产机或迁移 Gitea。 -4. 进入开发包 2:素材智能视图与素材缺口诊断。 +2. 处理生产资源长期风险:升级生产机或迁移 Gitea。 +3. 进入开发包 2:素材智能视图与素材缺口诊断。 diff --git a/docs/PRODUCTION-UAT-CHECKLIST.md b/docs/PRODUCTION-UAT-CHECKLIST.md index 44f1960d6..963531299 100644 --- a/docs/PRODUCTION-UAT-CHECKLIST.md +++ b/docs/PRODUCTION-UAT-CHECKLIST.md @@ -1,6 +1,6 @@ # 小虾 SaaS 生产 UAT 验收清单 -> 基线版本:`v0.1.16` +> 基线版本:`v0.1.18` > 适用环境:`https://saas.xiaoxiajianji.com/` > 创建时间:2026-06-23 @@ -112,6 +112,9 @@ - [ ] 用户 B 不能访问用户 A 的项目。 - [ ] 用户 B 不能访问用户 A 的素材库。 - [ ] 用户 B 不能上传到用户 A 的项目。 +- [ ] 用户 B 不能查看用户 A 的素材列表。 +- [ ] 用户 B 不能查看用户 A 的生成任务。 +- [ ] 用户 B 不能查看用户 A 的生成结果。 - [ ] 用户 B 不能下载用户 A 的成片。 验收:跨账号不能越权。 @@ -149,14 +152,15 @@ API 响应: --- -## 7. 当前 v0.1.16 验证结果 +## 7. 当前 v0.1.18 验证结果 自动 smoke 已通过: - [x] `public_auth_flow=ok` - [x] `public_upload_flow=ok` - [x] `public_generation_flow=ok` -- [x] API health 返回 `version: v0.1.16` +- [x] API health 返回 `version: v0.1.18` +- [x] `public_boundary_flow=ok`,覆盖素材列表、生成任务、生成结果、成片下载 URL 权限边界。 - [x] 生产资源巡检 cron 已启用 待真实浏览器确认: diff --git a/docs/SAAS-UPGRADE-EXECUTION-CHECKLIST.md b/docs/SAAS-UPGRADE-EXECUTION-CHECKLIST.md index baaf2bebf..4384896e8 100644 --- a/docs/SAAS-UPGRADE-EXECUTION-CHECKLIST.md +++ b/docs/SAAS-UPGRADE-EXECUTION-CHECKLIST.md @@ -88,19 +88,19 @@ ## 3. 开发包 1:生产 UAT 问题清零与能力冻结 -- [ ] 浏览器登录。 -- [ ] 创建或进入工作空间。 -- [ ] 创建或进入项目。 -- [ ] 创建素材库。 -- [ ] 批量上传素材。 -- [ ] 查看上传结果。 -- [ ] 发起生成任务。 -- [ ] 查看生成进度。 -- [ ] 下载成片。 -- [ ] 截图记录所有异常。 -- [ ] 查询对应 API 响应。 -- [ ] 查询对应 Worker/API 日志。 -- [ ] 更新当前发布表面文档。 +- [x] 浏览器登录(公网自动 auth smoke 已通过,人工浏览器待老大确认)。 +- [x] 创建或进入工作空间(公网自动 smoke 已通过)。 +- [x] 创建或进入项目(公网自动 smoke 已通过)。 +- [x] 创建素材库(公网自动 smoke 已通过)。 +- [x] 批量上传素材(公网 batch/upload smoke 已通过)。 +- [x] 查看上传结果(公网 ingest smoke 已通过)。 +- [x] 发起生成任务(公网 generation smoke 已通过)。 +- [x] 查看生成进度(公网 generation smoke 已通过)。 +- [x] 下载成片(公网 MP4 下载 smoke 已通过)。 +- [ ] 截图记录所有异常(人工浏览器 UAT 时执行)。 +- [x] 查询对应 API 响应。 +- [x] 查询对应 Worker/API 日志。 +- [x] 更新当前发布表面文档。 验收:真实用户可以完整完成一次生成并下载。 diff --git a/infra/docker/deploy-production.sh b/infra/docker/deploy-production.sh index 72ea71a44..83b00dda1 100755 --- a/infra/docker/deploy-production.sh +++ b/infra/docker/deploy-production.sh @@ -85,3 +85,9 @@ docker compose --env-file "$ENV_FILE" up -d api worker # Docker's embedded DNS is resolved by nginx at startup for this static upstream. docker compose --env-file "$ENV_FILE" up -d --force-recreate web docker compose --env-file "$ENV_FILE" ps + +if [ -n "$RELEASE_VERSION" ] && [ -d "$HOST_PREFIX/etc/cron.d" ]; then + cat > "$HOST_PREFIX/etc/cron.d/xiaoxia-production-resource-check" </var/log/xiaoxia-resource-check.log 2>&1 +EOF +fi diff --git a/scripts/smoke_public_boundary_flow.py b/scripts/smoke_public_boundary_flow.py new file mode 100644 index 000000000..e3e343bcc --- /dev/null +++ b/scripts/smoke_public_boundary_flow.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import io +import time +import uuid + +import requests + +BASE_URL = "https://saas.xiaoxiajianji.com/api/v1" +PASSWORD = "SmokePass123!" + + +def _json_or_raise(name: str, response: requests.Response) -> dict: + print(f"{name}={response.status_code}") + if response.status_code >= 400: + print(response.text[:500]) + response.raise_for_status() + return response.json() if response.content else {} + + +def _register_login(session: requests.Session, label: str) -> dict: + suffix = uuid.uuid4().hex[:10] + email = f"smoke-boundary-{label}-{suffix}@example.com" + username = f"smoke_boundary_{label}_{suffix}" + _json_or_raise( + f"{label}_register", + session.post( + f"{BASE_URL}/auth/register", + json={"email": email, "username": username, "password": PASSWORD, "full_name": "Boundary Smoke"}, + timeout=30, + ), + ) + login = _json_or_raise( + f"{label}_login", + session.post(f"{BASE_URL}/auth/login", json={"email": email, "password": PASSWORD}, timeout=30), + ) + return {"Authorization": f"Bearer {login['access_token']}"} + + +def main() -> None: + owner = requests.Session() + intruder = requests.Session() + owner_headers = _register_login(owner, "owner") + intruder_headers = _register_login(intruder, "intruder") + + workspace = _json_or_raise( + "owner_workspace", + owner.post(f"{BASE_URL}/workspaces", json={"name": "Boundary Workspace"}, headers=owner_headers, timeout=30), + ) + workspace_id = workspace.get("id") or workspace["workspace_id"] + project = _json_or_raise( + "owner_project", + owner.post( + f"{BASE_URL}/projects", + json={"workspace_id": workspace_id, "name": "Boundary Project", "description": "smoke"}, + headers=owner_headers, + timeout=30, + ), + ) + project_id = project["id"] + library = _json_or_raise( + "owner_library", + owner.post( + f"{BASE_URL}/asset-libraries", + json={"workspace_id": workspace_id, "project_id": project_id, "name": "Boundary Library", "kind": "video"}, + headers=owner_headers, + timeout=30, + ), + ) + library_id = library["id"] + upload = _json_or_raise( + "owner_upload", + owner.post( + f"{BASE_URL}/upload", + files={"file": ("boundary.mp4", io.BytesIO(b"boundary-owner"), "video/mp4")}, + data={"workspace_id": workspace_id, "project_id": project_id, "library_id": library_id}, + headers=owner_headers, + timeout=60, + ), + ) + ingest_id = upload.get("ingest_job_id") + for index in range(5): + if not ingest_id: + break + ingest = owner.get(f"{BASE_URL}/ingest-jobs/{ingest_id}", headers=owner_headers, timeout=30) + print(f"owner_ingest_{index}={ingest.status_code}") + if ingest.status_code == 200 and ingest.json().get("result_asset_id"): + break + time.sleep(1) + + task = _json_or_raise( + "owner_generation_create", + owner.post( + f"{BASE_URL}/generation/tasks", + json={"workspace_id": workspace_id, "project_id": project_id, "asset_library_id": library_id}, + headers=owner_headers, + timeout=30, + ), + ) + task_id = task["id"] + video_id = "" + for index in range(8): + task_response = owner.get(f"{BASE_URL}/generation/tasks/{task_id}", headers=owner_headers, timeout=30) + print(f"owner_generation_{index}={task_response.status_code}") + results_response = owner.get(f"{BASE_URL}/generation/tasks/{task_id}/results", headers=owner_headers, timeout=30) + print(f"owner_generation_results_{index}={results_response.status_code}") + if results_response.status_code == 200: + items = results_response.json().get("items", []) + if items: + video_id = items[0]["id"] + break + time.sleep(2) + + checks = [ + ("anon_me", requests.get(f"{BASE_URL}/auth/me", timeout=30).status_code, {401, 403}), + ("anon_workspaces", requests.get(f"{BASE_URL}/workspaces", timeout=30).status_code, {401, 403}), + ("anon_project_get", requests.get(f"{BASE_URL}/projects/{project_id}", timeout=30).status_code, {401, 403, 404}), + ("intruder_project_get", intruder.get(f"{BASE_URL}/projects/{project_id}", headers=intruder_headers, timeout=30).status_code, {403, 404}), + ("intruder_library_get", intruder.get(f"{BASE_URL}/asset-libraries/{library_id}", headers=intruder_headers, timeout=30).status_code, {403, 404}), + ("anon_assets", requests.get(f"{BASE_URL}/assets", params={"library_id": library_id}, timeout=30).status_code, {401, 403, 404}), + ("intruder_assets", intruder.get(f"{BASE_URL}/assets", params={"library_id": library_id}, headers=intruder_headers, timeout=30).status_code, {403, 404}), + ("anon_generation_task", requests.get(f"{BASE_URL}/generation/tasks/{task_id}", timeout=30).status_code, {401, 403, 404}), + ("intruder_generation_task", intruder.get(f"{BASE_URL}/generation/tasks/{task_id}", headers=intruder_headers, timeout=30).status_code, {403, 404}), + ("intruder_generation_results", intruder.get(f"{BASE_URL}/generation/tasks/{task_id}/results", headers=intruder_headers, timeout=30).status_code, {403, 404}), + ] + if video_id: + checks.extend( + [ + ("intruder_video_get", intruder.get(f"{BASE_URL}/generated-videos/{video_id}", headers=intruder_headers, timeout=30).status_code, {403, 404}), + ("intruder_video_download", intruder.get(f"{BASE_URL}/generated-videos/{video_id}/download-url", headers=intruder_headers, timeout=30).status_code, {403, 404}), + ] + ) + upload_response = intruder.post( + f"{BASE_URL}/upload", + files={"file": ("boundary.txt", io.BytesIO(b"boundary"), "text/plain")}, + data={"workspace_id": workspace_id, "project_id": project_id, "library_id": library_id}, + headers=intruder_headers, + timeout=60, + ) + checks.append(("intruder_upload", upload_response.status_code, {403, 404})) + + failed = [] + for name, status_code, expected in checks: + print(f"{name}={status_code}") + if status_code not in expected: + failed.append(f"{name} expected {sorted(expected)} got {status_code}") + if failed: + raise RuntimeError("; ".join(failed)) + print("public_boundary_flow=ok") + + +if __name__ == "__main__": + main() diff --git a/tests/unit/test_release_scripts.py b/tests/unit/test_release_scripts.py index d470166a7..d4b2eda5d 100644 --- a/tests/unit/test_release_scripts.py +++ b/tests/unit/test_release_scripts.py @@ -50,6 +50,8 @@ def test_deploy_production_uses_production_infra_and_project(): assert "docker compose --env-file \"$ENV_FILE\" up -d api worker" in script assert "docker compose --env-file \"$ENV_FILE\" up -d --force-recreate web" in script assert "nginx resolves the current API container IP" in script + assert "/etc/cron.d/xiaoxia-production-resource-check" in script + assert "EXPECTED_VERSION=$RELEASE_VERSION" in script def test_production_deploy_recreates_web_after_api_for_nginx_dns(): diff --git a/tests/unit/test_release_surface_docs.py b/tests/unit/test_release_surface_docs.py index 8bcbe8c01..68e1aa15f 100644 --- a/tests/unit/test_release_surface_docs.py +++ b/tests/unit/test_release_surface_docs.py @@ -1,14 +1,15 @@ from pathlib import Path -def test_current_release_surface_freezes_v016_capabilities(): +def test_current_release_surface_freezes_v018_capabilities(): docs = Path("docs/CURRENT-RELEASE-SURFACE.md").read_text(encoding="utf-8") - assert "生产基线:`v0.1.16`" in docs + assert "生产基线:`v0.1.18`" in docs assert "public_auth_flow=ok" in docs assert "public_upload_flow=ok" in docs assert "public_generation_flow=ok" in docs - assert "version: v0.1.16" in docs + assert "public_boundary_flow=ok" in docs + assert "version: v0.1.18" in docs assert "/var/lib/xiaoxia-ci/duty_report.json" in docs assert "Gitea 仍与生产业务同机" in docs @@ -48,11 +49,12 @@ def test_production_uat_checklist_covers_main_user_flow(): assert "version` 等于当前生产 tag" in docs -def test_production_uat_checklist_records_v016_smoke_results(): +def test_production_uat_checklist_records_v018_smoke_results(): docs = Path("docs/PRODUCTION-UAT-CHECKLIST.md").read_text(encoding="utf-8") - assert "当前 v0.1.16 验证结果" in docs + assert "当前 v0.1.18 验证结果" in docs assert "public_auth_flow=ok" in docs assert "public_upload_flow=ok" in docs assert "public_generation_flow=ok" in docs + assert "public_boundary_flow=ok" in docs assert "老大真实账号完整 UAT" in docs