docs(release): close package one boundary validation

This commit is contained in:
Xiaoxia AI
2026-06-23 10:04:56 +08:00
parent 51eb9381f4
commit b7b0765ba6
7 changed files with 201 additions and 27 deletions
+13 -6
View File
@@ -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:素材智能视图与素材缺口诊断
+7 -3
View File
@@ -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 已启用
待真实浏览器确认:
+13 -13
View File
@@ -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] 更新当前发布表面文档。
验收:真实用户可以完整完成一次生成并下载。
+6
View File
@@ -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" <<EOF
*/5 * * * * root cd /var/lib/xiaoxia-saas-production/repo && EXPECTED_VERSION=$RELEASE_VERSION sh scripts/production_resource_check.sh >/var/log/xiaoxia-resource-check.log 2>&1
EOF
fi
+153
View File
@@ -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()
+2
View File
@@ -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():
+7 -5
View File
@@ -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