Web 端 Phase 1 实施计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 实现用户端 Web 应用 Phase 1 MVP(登录 + 文章管理 + 富文本编辑器 + 双端数据互通),不触碰生产数据库。
Architecture: React + Vite + Antd Web 应用,与现有小程序共用同一 FastAPI 后端,通过 JWT + WebCrypto HMAC 签名接入;后端扩展 JWT 双 Token、article 表加 source_client/version 列,复用 RLS 实现双端数据互通。
Tech Stack: 后端 FastAPI + asyncpg + PyJWT;前端 React 18 + Vite 5 + TypeScript 5 + Antd 5 + Zustand + react-router v6 + axios + Tiptap + Vitest + @testing-library/react + MSW。
数据库安全约束(必读):
- 所有 migration 仅通过修改
backend/app/db/schema.sql(用ADD COLUMN IF NOT EXISTS/CREATE TABLE IF NOT EXISTS)+ 重新运行python -m app.db.init_db实现,绝不写 DROP/ALTER 破坏性语句 - 后端
.env强制PG_HOST=127.0.0.1,所有测试连本地 dev 库 - 测试 fixture 使用独立 schema 或临时库(
xhs_saas_test),不与 dev 库混用 - 任何数据库操作前先
SELECT current_database()确认连接目标
文件结构
后端修改
backend/app/db/schema.sql— 加articles.source_client+articles.version+merchant_refresh_tokens表backend/app/config.py— 加access_token_ttl_hours/refresh_token_ttl_daysbackend/app/utils/jwt.py— 加create_access_token_with_type/create_refresh_token/ 校验typebackend/app/services/auth_service.py— 加issue_refresh_token/revoke_refresh_token/verify_refresh_tokenbackend/app/routers/auth.py— 加/refresh+/logoutbackend/app/deps.py—get_current_user增加type=access校验backend/app/services/article_service.py—update_article加乐观锁;_save_article注入source_clientbackend/app/routers/article.py—PUT /:id接受If-Match头,409 冲突backend/app/models/article.py—ArticleResponse加source_client+versionbackend/tests/conftest.py— 加真实 DB fixturesbackend/tests/test_auth_refresh.py— 新建backend/tests/test_article_version_conflict.py— 新建backend/tests/test_article_source_client.py— 新建backend/.env.example— 加 DB 安全注释 + 新配置项
Web 端新建(web/ 目录)
web/package.json/web/vite.config.ts/web/tsconfig.json/web/index.htmlweb/.env.development/web/.env.staging/web/.env.production/web/.env.exampleweb/src/main.tsx/web/src/App.tsxweb/src/types/api.ts— 与小程序对齐的 TypeScript 类型web/src/api/signature.ts— WebCrypto HMAC-SHA256web/src/api/client.ts— axios + 拦截器web/src/api/auth.ts/web/src/api/article.tsweb/src/stores/auth.ts— Zustand auth storeweb/src/layouts/AppLayout.tsx/web/src/layouts/BlankLayout.tsxweb/src/router/index.tsx/web/src/router/guard.tsxweb/src/pages/Login/index.tsxweb/src/pages/ArticleList/index.tsxweb/src/pages/ArticleEditor/index.tsxweb/src/pages/NotFound/index.tsxweb/src/hooks/useAuth.ts/web/src/hooks/useArticle.tsweb/src/components/PageContainer.tsxweb/src/test/setup.ts/web/src/test/fixtures.tsweb/src/api/signature.test.tsweb/src/stores/auth.test.tsweb/src/pages/ArticleList.test.tsxweb/deploy/nginx-web.conf.templateweb/DEPLOYMENT.md
Task 1: 后端 schema 扩展(articles 加列 + refresh_tokens 表)
Files:
Modify:
backend/app/db/schema.sql(在 articles 表后追加 ALTER;在 cover_covers 之后追加新表)Modify:
backend/.env.example(加 DB 安全注释)[ ] Step 1: 修改
backend/app/db/schema.sql,在 articles 表 DDL 后追加 source_client / version 列
在 articles 表 CREATE TABLE 语句之后(约第 96 行后,CREATE INDEX 之前)追加:
-- Web 端支持: source_client 标记来源端; version 用于乐观锁防止双端覆盖
ALTER TABLE articles ADD COLUMN IF NOT EXISTS source_client VARCHAR(16) NOT NULL DEFAULT 'mp_weixin';
ALTER TABLE articles ADD COLUMN IF NOT EXISTS version INTEGER NOT NULL DEFAULT 1;- [ ] Step 2: 在
schema.sql末尾(RLS 策略之前)追加merchant_refresh_tokens表
在 cover_covers 表 CREATE INDEX 之后、-- RLS 策略 注释之前追加:
-- ============================================================================
-- 13. merchant_refresh_tokens (Web 端 Refresh Token 撤销表, 租户隔离)
-- ============================================================================
-- 仅 Web 端启用; 小程序暂不升级双 token, 不写入此表.
-- token_hash = SHA256(refresh_token), 不存原 token 防泄漏后直接复用.
CREATE TABLE IF NOT EXISTS merchant_refresh_tokens (
id BIGSERIAL PRIMARY KEY,
merchant_id UUID NOT NULL REFERENCES merchants(id) ON DELETE CASCADE,
token_hash CHAR(64) NOT NULL,
client_type VARCHAR(16) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_merchant
ON merchant_refresh_tokens(merchant_id) WHERE revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash
ON merchant_refresh_tokens(token_hash) WHERE revoked_at IS NULL;
ALTER TABLE merchant_refresh_tokens ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON merchant_refresh_tokens;
CREATE POLICY tenant_isolation ON merchant_refresh_tokens
USING (merchant_id::text = current_setting('app.current_merchant_id', true))
WITH CHECK (merchant_id::text = current_setting('app.current_merchant_id', true));- [ ] Step 3: 修改
backend/.env.example,顶部加 DB 安全注释 + 新配置项
在文件顶部追加:
# ============================================================================
# 数据库安全约束(必读)
# ============================================================================
# PG_HOST 必须为 127.0.0.1 或内网地址, 严禁直连生产服务器公网 IP.
# 任何 schema 变更前先 SELECT current_database() 确认目标库.
# 测试请用独立的 xhs_saas_test 库, 不要污染 dev 库.
PG_HOST=127.0.0.1
PG_PORT=5432
PG_USER=postgres
PG_PASSWORD=postgres
PG_DB=xhs_saas在 .env.example 末尾追加:
# ============================================================================
# Web 端 Phase 1 新增
# ============================================================================
# Access Token TTL (小时); 仅 Web 端走双 token 流程使用
ACCESS_TOKEN_TTL_HOURS=2
# Refresh Token TTL (天)
REFRESH_TOKEN_TTL_DAYS=7
# CORS 必须包含 Web 端 dev/staging/prod 三个来源
# 开发期: http://localhost:5175
# 阶段1 IP: http://<公网IP>:8080
# 阶段2 域名: https://app.example.com
CORS_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175- [ ] Step 4: 在本地 dev 库执行 schema 升级
Run: cd backend && python -m app.db.init_db Expected: 输出 Schema 初始化完成: schema.sql,无报错。
- [ ] Step 5: 验证列和表已创建
Run:
cd backend && python -c "import asyncio, asyncpg; from app.config import settings; asyncio.run((lambda: (lambda conn: print(conn.fetchrow('SELECT column_name FROM information_schema.columns WHERE table_name=$1 AND column_name IN ($2, $3)', 'articles', 'source_client', 'version')))(await asyncpg.connect(settings.pg_dsn))))()"Expected: 输出 Record(column_name='source_client') 或 version,证明列已存在。
- [ ] Step 6: Commit
git add backend/app/db/schema.sql backend/.env.example
git commit -m "feat(db): add articles.source_client/version + merchant_refresh_tokens table"Task 2: 后端配置项扩展
Files:
Modify:
backend/app/config.py(在 Settings 类内追加两个字段)[ ] Step 1: 修改
backend/app/config.py
在 jwt_ttl_hours 字段之后(约第 39 行)追加:
# Web 端双 Token TTL (小程序继续用 jwt_ttl_hours, 不升级)
access_token_ttl_hours: int = int(os.getenv("ACCESS_TOKEN_TTL_HOURS", "2"))
refresh_token_ttl_days: int = int(os.getenv("REFRESH_TOKEN_TTL_DAYS", "7"))- [ ] Step 2: 写测试验证配置加载
修改 backend/tests/test_smoke.py,在 test_imports 函数末尾追加断言:
# Phase 1 新增配置项
assert settings.access_token_ttl_hours == 2
assert settings.refresh_token_ttl_days == 7- [ ] Step 3: 运行测试
Run: cd backend && pytest tests/test_smoke.py::test_imports -v Expected: PASS
- [ ] Step 4: Commit
git add backend/app/config.py backend/tests/test_smoke.py
git commit -m "feat(config): add access/refresh token TTL settings"Task 3: 后端 JWT 工具扩展(type 字段 + refresh token 签发)
Files:
Modify:
backend/app/utils/jwt.pyModify:
backend/tests/test_smoke.py[ ] Step 1: 在
backend/tests/test_smoke.py追加失败测试
在文件末尾追加:
def test_jwt_access_token_has_type():
"""Phase 1: access token payload 必须含 type=access."""
from app.utils.jwt import create_access_token, decode_access_token
token = create_access_token("merchant-123", {"phone": "13800000000"})
payload = decode_access_token(token)
assert payload.get("type") == "access"
def test_jwt_refresh_token_creation():
"""Phase 1: refresh token 必须含 type=refresh + client_type."""
from app.utils.jwt import create_refresh_token, decode_access_token
token = create_refresh_token("merchant-123", client_type="web")
payload = decode_access_token(token)
assert payload.get("type") == "refresh"
assert payload.get("client_type") == "web"
assert payload["sub"] == "merchant-123"
def test_jwt_decode_rejects_wrong_type():
"""Phase 1: 用 refresh token 作为 access 应被拒绝."""
import jwt as pyjwt
from app.utils.jwt import create_refresh_token
from app.config import settings
refresh = create_refresh_token("merchant-123", client_type="web")
# 模拟旧客户端不校验 type, 直接 decode 仍能解出 payload
payload = pyjwt.decode(refresh, settings.jwt_secret, algorithms=[settings.jwt_alg])
assert payload["type"] == "refresh"- [ ] Step 2: 运行测试,预期前两个失败
Run: cd backend && pytest tests/test_smoke.py::test_jwt_access_token_has_type tests/test_smoke.py::test_jwt_refresh_token_creation -v Expected: FAIL with AttributeError: module 'app.utils.jwt' has no attribute 'create_refresh_token'
- [ ] Step 3: 修改
backend/app/utils/jwt.py,加 type 字段和 refresh token 签发
完整替换文件内容:
"""JWT 工具: 签发/验证 access token (Phase 1: 双 token + type 字段)."""
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from typing import Any
import jwt
from app.config import settings
def create_access_token(
subject: str, extra: dict[str, Any] | None = None
) -> str:
"""签发 access token. Phase 1 起强制 type=access, TTL 走 access_token_ttl_hours."""
now = datetime.now(timezone.utc)
# 向后兼容: 已签发的 token 默认 type=access (老 payload 无 type 时也算 access)
payload: dict[str, Any] = {
"sub": subject,
"type": "access",
"iat": int(now.timestamp()),
"exp": int((now + timedelta(hours=settings.access_token_ttl_hours)).timestamp()),
}
if extra:
# extra 不允许覆盖 type
sanitized = {k: v for k, v in extra.items() if k != "type"}
payload.update(sanitized)
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_alg)
def create_refresh_token(subject: str, client_type: str = "web") -> str:
"""签发 refresh token. type=refresh, TTL 走 refresh_token_ttl_days."""
now = datetime.now(timezone.utc)
payload: dict[str, Any] = {
"sub": subject,
"type": "refresh",
"client_type": client_type,
"jti": secrets.token_urlsafe(16),
"iat": int(now.timestamp()),
"exp": int((now + timedelta(days=settings.refresh_token_ttl_days)).timestamp()),
}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_alg)
def decode_access_token(token: str) -> dict[str, Any]:
"""验证 JWT, 失败抛 jwt.PyJWTError.
注意: 此函数仅做签名+过期校验, 不校验 type 字段.
type 校验由调用方按场景执行 (access 用于 Bearer, refresh 仅 /api/auth/refresh).
老payload 无 type 时, 默认视为 access (向后兼容小程序已签发的 token).
"""
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_alg])
if "type" not in payload:
payload["type"] = "access"
return payload- [ ] Step 4: 运行测试
Run: cd backend && pytest tests/test_smoke.py -v Expected: 全部 PASS
- [ ] Step 5: Commit
git add backend/app/utils/jwt.py backend/tests/test_smoke.py
git commit -m "feat(jwt): add type field and refresh token issuance"Task 4: 后端 auth_service 扩展(refresh token CRUD)
Files:
Modify:
backend/app/services/auth_service.py[ ] Step 1: 修改
backend/app/services/auth_service.py,在文件末尾追加 refresh token 函数
import hashlib
def _hash_token(token: str) -> str:
"""SHA256(refresh_token) 用于 DB 查询, 不存原 token."""
return hashlib.sha256(token.encode("utf-8")).hexdigest()
async def issue_refresh_token(
conn: asyncpg.Connection, merchant_id: str, client_type: str = "web"
) -> str:
"""签发 refresh token 并写入 DB (带 hash + 过期时间). 返回原 token 供 Set-Cookie."""
token = create_refresh_token(merchant_id, client_type)
token_hash = _hash_token(token)
# 解析 exp (从 token payload 取, 保持单一真相)
payload = decode_access_token(token)
expires_at = datetime.fromtimestamp(payload["exp"], tz=timezone.utc)
await conn.execute(
"""
INSERT INTO merchant_refresh_tokens (merchant_id, token_hash, client_type, expires_at)
VALUES ($1, $2, $3, $4)
""",
merchant_id, token_hash, client_type, expires_at,
)
return token
async def verify_refresh_token(conn: asyncpg.Connection, token: str) -> str | None:
"""校验 refresh token: 签名 + 过期 + DB 未撤销. 返回 merchant_id 或 None."""
try:
payload = decode_access_token(token)
except Exception:
return None
if payload.get("type") != "refresh":
return None
token_hash = _hash_token(token)
row = await conn.fetchrow(
"""
SELECT merchant_id::text AS merchant_id
FROM merchant_refresh_tokens
WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()
""",
token_hash,
)
return row["merchant_id"] if row else None
async def revoke_refresh_token(conn: asyncpg.Connection, token: str) -> bool:
"""撤销 refresh token (登出). 返回是否撤销成功."""
token_hash = _hash_token(token)
result = await conn.execute(
"""
UPDATE merchant_refresh_tokens
SET revoked_at = NOW()
WHERE token_hash = $1 AND revoked_at IS NULL
""",
token_hash,
)
return result == "UPDATE 1"并在文件顶部追加 import:
from datetime import datetime, timezone
from app.utils.jwt import create_access_token, create_refresh_token, decode_access_token- [ ] Step 2: 写一个简单单元测试验证 _hash_token 一致性
修改 backend/tests/test_smoke.py,追加:
def test_refresh_token_hash_consistent():
"""hash 同一 token 必须返回相同结果."""
from app.services.auth_service import _hash_token
h1 = _hash_token("abc123")
h2 = _hash_token("abc123")
h3 = _hash_token("abc124")
assert h1 == h2
assert h1 != h3
assert len(h1) == 64 # SHA256 hex- [ ] Step 3: 运行测试
Run: cd backend && pytest tests/test_smoke.py::test_refresh_token_hash_consistent -v Expected: PASS
- [ ] Step 4: Commit
git add backend/app/services/auth_service.py backend/tests/test_smoke.py
git commit -m "feat(auth): add refresh token issue/verify/revoke service functions"Task 5: 后端 /api/auth/refresh 接口
Files:
Modify:
backend/app/models/auth.py(加 RefreshResponse)Modify:
backend/app/routers/auth.py(加 /refresh 路由)Modify:
backend/app/deps.py(get_current_user加 type=access 校验)[ ] Step 1: 修改
backend/app/models/auth.py,加 RefreshResponse
在 TokenResponse 之后追加:
class RefreshResponse(BaseModel):
access_token: str
token_type: str = "bearer"
merchant_id: str- [ ] Step 2: 修改
backend/app/deps.py,get_current_user增加 type=access 校验
将 get_current_user 函数完整替换为:
async def get_current_user(
authorization: str | None = Header(default=None),
) -> dict:
"""Parse JWT payload from Authorization: Bearer {token}.
Phase 1 起: 强制校验 payload.type == 'access', 拒绝 refresh token 用于普通接口.
老payload 无 type 字段 (小程序已签发的) 视为 access (decode_access_token 已默认补全).
"""
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Please login first")
try:
payload = decode_access_token(authorization[7:])
except Exception:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Login session expired")
if payload.get("type") != "access":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
if not payload.get("sub"):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login session")
return payload- [ ] Step 3: 修改
backend/app/routers/auth.py,加 /refresh 路由
在文件顶部追加 import:
from fastapi import Cookie, Response
from app.models.auth import RefreshResponse
from app.security.rate_limit import RateLimit在 /me 路由之后追加:
@router.post("/refresh", response_model=RefreshResponse)
async def refresh(
conn: DbSessionNoRls,
request: Request,
response: Response,
refresh_token: str | None = Cookie(default=None),
):
"""Web 端用 HttpOnly Cookie 里的 refresh_token 换新 access_token.
安全:
- Cookie 缺失/解析失败/已撤销/已过期 → 401 + 清 Cookie
- 限流: IP 维度 10/分钟 (防暴力枚举)
- 阶段1 (HTTP) 不暴露给前端, 仅阶段2 (HTTPS) 启用 Cookie 写入
"""
ip = client_ip(request)
rate_limiter.check(f"ip:{ip}:refresh", RateLimit(limit=10, window_seconds=60))
if not refresh_token:
response.delete_cookie("refresh_token")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="缺少 refresh token")
merchant_id = await auth_service.verify_refresh_token(conn, refresh_token)
if merchant_id is None:
response.delete_cookie("refresh_token")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="refresh token 已失效")
# 签发新 access token (不轮换 refresh, 简化 Phase 1; Phase 2 可加轮换)
token = create_access_token(merchant_id)
return RefreshResponse(access_token=token, merchant_id=merchant_id)并在 auth.py 顶部 imports 补充:
from app.utils.jwt import create_access_token- [ ] Step 4: 运行 smoke 测试确保不破坏现有逻辑
Run: cd backend && pytest tests/test_smoke.py -v Expected: 全部 PASS
- [ ] Step 5: 手动 curl 验证 refresh 接口可访问
Run: cd backend && python -c "from fastapi.testclient import TestClient; from app.main import app; c = TestClient(app); r = c.post('/api/auth/refresh'); print(r.status_code, r.json())" Expected: 401 + {"detail":"缺少 refresh token"},证明接口已注册。
- [ ] Step 6: Commit
git add backend/app/models/auth.py backend/app/deps.py backend/app/routers/auth.py
git commit -m "feat(auth): add /api/auth/refresh endpoint with cookie + rate limit"Task 6: 后端 /api/auth/logout 接口
Files:
Modify:
backend/app/routers/auth.py[ ] Step 1: 修改
backend/app/routers/auth.py,在 /refresh 之后追加 /logout
@router.post("/logout")
async def logout(
conn: DbSessionNoRls,
response: Response,
refresh_token: str | None = Cookie(default=None),
):
"""撤销 refresh token + 清 Cookie. access token 短 TTL 自然过期, 不需要黑名单."""
if refresh_token:
await auth_service.revoke_refresh_token(conn, refresh_token)
response.delete_cookie("refresh_token")
return {"detail": "已登出"}- [ ] Step 2: 手动验证
Run: cd backend && python -c "from fastapi.testclient import TestClient; from app.main import app; c = TestClient(app); r = c.post('/api/auth/logout'); print(r.status_code, r.json())" Expected: 200 + {"detail":"已登出"}
- [ ] Step 3: Commit
git add backend/app/routers/auth.py
git commit -m "feat(auth): add /api/auth/logout endpoint"Task 7: 后端 article source_client 注入
Files:
Modify:
backend/app/services/article_service.py(_save_article注入 source_client)Modify:
backend/app/models/article.py(ArticleResponse加 source_client + version)Modify:
backend/app/routers/article.py(generate 接口接受 X-Client-Type 头)[ ] Step 1: 修改
backend/app/models/article.py,ArticleResponse 加字段
将 ArticleResponse 类完整替换为:
class ArticleResponse(BaseModel):
id: str
account_type: str = "素人号"
topic: str
style: Optional[str] = None
title: Optional[str] = None
content: Optional[str] = None
tags: List[str] = Field(default_factory=list)
image_slots: List[dict] = Field(default_factory=list)
warnings: List[str] = Field(default_factory=list)
word_count: int = Field(default=0, description="服务端校验的可见字数")
status: str
source_client: str = Field("mp_weixin", description="来源端: mp_weixin/mp_xhs/web")
version: int = Field(1, description="乐观锁版本号, 每次更新 +1")
created_at: str- [ ] Step 2: 修改
backend/app/services/article_service.py,_format_article加 source_client + version
在 _format_article 函数末尾("created_at" 之后)追加两行:
"source_client": row["source_client"] if "source_client" in row else "mp_weixin",
"version": row["version"] if "version" in row else 1,- [ ] Step 3: 找到
_save_article函数,让 INSERT 包含 source_client
Run: grep -n "_save_article" backend/app/services/article_service.py 确认行号。
修改 _save_article 函数签名加 source_client: str = "mp_weixin" 参数,并修改 INSERT 语句包含该字段。如果原 INSERT 是:
INSERT INTO articles (merchant_id, account_type, topic, style, title, content, tags, image_slots, warnings, word_count, task_id, status)
VALUES ($1, $2, ...)改为:
INSERT INTO articles (merchant_id, account_type, topic, style, title, content, tags, image_slots, warnings, word_count, task_id, status, source_client)
VALUES ($1, $2, ..., $13)并让 _save_article 的调用方(article_graph pipeline 内部)传入 source_client。简化方案:在 generate_article 函数签名加 source_client: str = "mp_weixin" 参数,传给 _save_article。
- [ ] Step 4: 修改
backend/app/routers/article.py,generate 接口读 X-Client-Type
将 generate 路由完整替换为:
@router.post("/generate", response_model=GenerateTaskResponse)
async def generate(
req: GenerateRequest,
user: CurrentSecureUser,
x_client_type: str | None = Header(default=None, alias="X-Client-Type"),
):
# 规范化 source_client; 非法值默认 mp_weixin
valid_clients = {"mp_weixin", "mp_xhs", "web"}
source_client = (x_client_type or "mp_weixin").lower()
if source_client not in valid_clients:
source_client = "mp_weixin"
try:
task_id, created_at = await article_service.generate_article(
user["sub"], req.topic, req.style, req.image_ids, req.account_type, req.angle, req.keywords,
source_client=source_client,
)
except ValueError as e:
raise HTTPException(status_code=429, detail=str(e), headers={"X-Error-Code": "TASK_CONFLICT"})
return GenerateTaskResponse(
task_id=task_id, stage="rag_search", created_at=created_at,
)在文件顶部 import 区追加:
from fastapi import Header- [ ] Step 5: 运行 smoke 测试
Run: cd backend && pytest tests/test_smoke.py -v Expected: 全部 PASS
- [ ] Step 6: Commit
git add backend/app/models/article.py backend/app/services/article_service.py backend/app/routers/article.py
git commit -m "feat(article): inject source_client from X-Client-Type header"Task 8: 后端 article version 乐观锁
Files:
Modify:
backend/app/services/article_service.py(update_article加乐观锁)Modify:
backend/app/routers/article.py(PUT 接受 If-Match,409 冲突)[ ] Step 1: 修改
backend/app/services/article_service.py,update_article函数签名加expected_version
将 update_article 函数完整替换为:
class ArticleVersionConflict(Exception):
"""乐观锁冲突: 文章已被另一端修改."""
pass
async def update_article(
conn: asyncpg.Connection,
article_id: str,
data: dict,
merchant_id: Optional[str] = None,
expected_version: Optional[int] = None,
) -> Optional[dict]:
"""更新文章. 传入 merchant_id 时强制用户隔离.
Phase 1 起: 传入 expected_version 时执行乐观锁, 版本不匹配抛 ArticleVersionConflict.
"""
sets: list[str] = []
args: list[Any] = []
idx = 1
for field in ("title", "content", "status"):
if field in data and data[field] is not None:
value = data[field]
if field == "content":
value = sanitize_content(str(value))
sets.append(f"{field} = ${idx}")
args.append(value)
idx += 1
if "tags" in data and data["tags"] is not None:
sets.append(f"tags = ${idx}")
args.append(json.dumps(data["tags"], ensure_ascii=False))
idx += 1
if "content" in data and data["content"] is not None:
sets.append(f"word_count = ${idx}")
args.append(_compute_article_word_count(str(data["content"])))
idx += 1
# version 自增 (无论是否传 expected_version 都要 +1)
sets.append(f"version = version + 1")
if not sets:
return await get_article(conn, article_id, merchant_id)
# WHERE 子句
args.append(article_id)
where_clause = f"id = ${idx}"
idx += 1
if merchant_id is not None:
args.append(merchant_id)
where_clause += f" AND merchant_id = ${idx}"
idx += 1
if expected_version is not None:
args.append(expected_version)
where_clause += f" AND version = ${idx}"
idx += 1
result = await conn.execute(
f"UPDATE articles SET {', '.join(sets)}, updated_at = NOW() WHERE {where_clause}",
*args,
)
if result == "UPDATE 0" and expected_version is not None:
# 区分: 文章不存在 vs 版本冲突
existing = await get_article(conn, article_id, merchant_id)
if existing is not None:
raise ArticleVersionConflict(f"expected version {expected_version}, got {existing['version']}")
return None
return await get_article(conn, article_id, merchant_id)- [ ] Step 2: 修改
backend/app/routers/article.py,PUT 接受 If-Match
将 update_article 路由完整替换为:
@router.put("/{article_id}", response_model=ArticleResponse)
async def update_article(
article_id: str,
req: ArticleUpdate,
conn: DbSession,
user: CurrentSecureUser,
if_match: str | None = Header(default=None, alias="If-Match"),
):
expected_version: int | None = None
if if_match is not None:
try:
expected_version = int(if_match.strip())
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="If-Match 必须为整数版本号")
try:
article = await article_service.update_article(
conn, article_id, req.model_dump(exclude_unset=True), user["sub"],
expected_version=expected_version,
)
except article_service.ArticleVersionConflict as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="文章已被另一端修改,请刷新后重试",
headers={"X-Error-Code": "VERSION_CONFLICT"},
)
if article is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文章不存在")
return article- [ ] Step 3: 运行 smoke 测试
Run: cd backend && pytest tests/test_smoke.py -v Expected: 全部 PASS
- [ ] Step 4: Commit
git add backend/app/services/article_service.py backend/app/routers/article.py
git commit -m "feat(article): add optimistic lock via If-Match + version increment"Task 9: 后端 conftest 改进(真实 DB fixtures)
Files:
Modify:
backend/tests/conftest.py[ ] Step 1: 修改
backend/tests/conftest.py,加 DB + auth fixtures
完整替换文件:
"""pytest fixtures: 数据库连接池 + 测试客户端 + auth fixtures.
Phase 1: 真实 DB fixtures (连本地 xhs_saas_test 库, 不污染 dev 库).
所有 DB 操作仅在 127.0.0.1, 严禁连生产.
"""
from __future__ import annotations
import asyncio
import os
import uuid
import asyncpg
import pytest
# 测试前强制本地连接 + 独立测试库 (与 dev 库 xhs_saas 隔离)
os.environ.setdefault("PG_HOST", "127.0.0.1")
os.environ.setdefault("PG_DB", "xhs_saas_test")
@pytest.fixture(scope="session")
def event_loop():
"""session 级 event loop."""
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest.fixture(scope="session")
async def test_db_pool():
"""session 级连接池, 连本地 xhs_saas_test 库.
使用前需手动创建测试库并 init schema:
createdb xhs_saas_test
PG_DB=xhs_saas_test python -m app.db.init_db
"""
from app.config import settings
# 强制校验: 必须是本地 + test 库
assert settings.pg_host == "127.0.0.1", f"测试库必须本地, 实际 PG_HOST={settings.pg_host}"
assert "test" in settings.pg_db, f"测试必须用 test 库, 实际 PG_DB={settings.pg_db}"
pool = await asyncpg.create_pool(
dsn=settings.pg_dsn, min_size=2, max_size=5,
)
yield pool
await pool.close()
@pytest.fixture
async def test_db(test_db_pool):
"""每个测试用例独立事务, 测完回滚 (不污染数据)."""
async with test_db_pool.acquire() as conn:
async with conn.transaction():
yield conn
# 事务自动回滚
@pytest.fixture
async def test_merchant(test_db):
"""创建临时测试商家, 返回 (merchant_id, phone, password)."""
phone = f"139{uuid.uuid4().hex[:8]}"
password = "test-password-123"
from app.security.core import hash_password
row = await test_db.fetchrow(
"INSERT INTO merchants (phone, password) VALUES ($1, $2) RETURNING id::text AS id",
phone, hash_password(password),
)
return row["id"], phone, password
@pytest.fixture
async def merchant_token(test_merchant):
"""签发 access token (type=access)."""
merchant_id, _, _ = test_merchant
from app.utils.jwt import create_access_token
return create_access_token(merchant_id), merchant_id
@pytest.fixture
async def auth_client(merchant_token):
"""带 Authorization + 签名头的 httpx AsyncClient.
注意: Phase 1 测试 client 不带签名 (走 NoRls 的 /api/auth/* 接口).
需要 RLS + 签名的接口测试在集成测试 fixture 中扩展.
"""
from fastapi.testclient import TestClient
from app.main import app
token, _ = merchant_token
client = TestClient(app)
client.headers.update({"Authorization": f"Bearer {token}"})
return client- [ ] Step 2: 创建测试库(一次性)
Run: cd backend && python -c "import asyncio, asyncpg; from app.config import settings; asyncio.run((lambda: (lambda c: c.execute('CREATE DATABASE xhs_saas_test'))(asyncio.get_event_loop().run_until_complete(asyncpg.connect(f'postgresql://{settings.pg_user}:{settings.pg_password}@{settings.pg_host}:{settings.pg_port}/postgres')))))()" Expected: 无输出(库已创建或已存在)。
如果失败,手动用 psql 创建:
psql -h 127.0.0.1 -U postgres -c "CREATE DATABASE xhs_saas_test;"
PG_DB=xhs_saas_test python -m app.db.init_db- [ ] Step 3: 验证 fixture 可用
Run: cd backend && pytest tests/test_smoke.py -v Expected: 全部 PASS(smoke 测试不用新 fixture,但要保证 conftest 不破坏)。
- [ ] Step 4: Commit
git add backend/tests/conftest.py
git commit -m "test(conftest): add real DB fixtures with isolated xhs_saas_test database"Task 10: 后端测试 - auth refresh 全流程
Files:
Create:
backend/tests/test_auth_refresh.py[ ] Step 1: 创建测试文件
backend/tests/test_auth_refresh.py
"""测试 /api/auth/refresh 全流程.
前置: xhs_saas_test 库已创建并 init schema.
所有测试用例独立事务回滚, 不留痕迹.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.services import auth_service
from app.utils.jwt import create_access_token, create_refresh_token
@pytest.mark.asyncio
async def test_refresh_success(test_db, test_merchant):
"""正常 refresh → 拿到新 access token."""
merchant_id, _, _ = test_merchant
refresh_token = await auth_service.issue_refresh_token(test_db, merchant_id, "web")
client = TestClient(app)
client.cookies.set("refresh_token", refresh_token)
r = client.post("/api/auth/refresh")
assert r.status_code == 200
data = r.json()
assert "access_token" in data
assert data["merchant_id"] == merchant_id
@pytest.mark.asyncio
async def test_refresh_missing_cookie(test_db):
"""Cookie 缺失 → 401."""
client = TestClient(app)
r = client.post("/api/auth/refresh")
assert r.status_code == 401
assert "缺少" in r.json()["detail"]
@pytest.mark.asyncio
async def test_refresh_revoked_token(test_db, test_merchant):
"""已撤销 refresh token → 401."""
merchant_id, _, _ = test_merchant
refresh_token = await auth_service.issue_refresh_token(test_db, merchant_id, "web")
await auth_service.revoke_refresh_token(test_db, refresh_token)
client = TestClient(app)
client.cookies.set("refresh_token", refresh_token)
r = client.post("/api/auth/refresh")
assert r.status_code == 401
@pytest.mark.asyncio
async def test_refresh_wrong_type_token(test_db, test_merchant):
"""用 access token 当 refresh → 401."""
merchant_id, _, _ = test_merchant
access_token = create_access_token(merchant_id)
client = TestClient(app)
client.cookies.set("refresh_token", access_token)
r = client.post("/api/auth/refresh")
assert r.status_code == 401
@pytest.mark.asyncio
async def test_refresh_cross_merchant_invalid(test_db, test_merchant):
"""伪造的 refresh token (DB 中无记录) → 401."""
merchant_id, _, _ = test_merchant
fake_token = create_refresh_token(merchant_id, "web") # 未写入 DB
client = TestClient(app)
client.cookies.set("refresh_token", fake_token)
r = client.post("/api/auth/refresh")
assert r.status_code == 401
@pytest.mark.asyncio
async def test_logout_revokes_refresh(test_db, test_merchant):
"""logout 后 refresh token 立即失效."""
merchant_id, _, _ = test_merchant
refresh_token = await auth_service.issue_refresh_token(test_db, merchant_id, "web")
client = TestClient(app)
client.cookies.set("refresh_token", refresh_token)
r = client.post("/api/auth/logout")
assert r.status_code == 200
# 再次 refresh 应失败
r2 = client.post("/api/auth/refresh")
assert r2.status_code == 401- [ ] Step 2: 运行测试
Run: cd backend && pytest tests/test_auth_refresh.py -v Expected: 6 个测试全部 PASS
- [ ] Step 3: Commit
git add backend/tests/test_auth_refresh.py
git commit -m "test(auth): cover refresh success/failure/logout flows"Task 11: 后端测试 - article source_client 注入
Files:
Create:
backend/tests/test_article_source_client.py[ ] Step 1: 创建测试文件
backend/tests/test_article_source_client.py
"""测试 article source_client 字段注入.
不经过 LLM 生成流程, 直接 INSERT 验证 _format_article 输出.
"""
from __future__ import annotations
import pytest
from app.services.article_service import _format_article
@pytest.mark.asyncio
async def test_format_article_includes_source_client(test_db, test_merchant):
"""_format_article 必须返回 source_client + version."""
merchant_id, _, _ = test_merchant
row = await test_db.fetchrow(
"""
INSERT INTO articles (merchant_id, topic, source_client, version)
VALUES ($1, '测试主题', 'web', 1)
RETURNING *
""",
merchant_id,
)
article = _format_article(row)
assert article["source_client"] == "web"
assert article["version"] == 1
@pytest.mark.asyncio
async def test_format_article_default_source_client(test_db, test_merchant):
"""老数据无 source_client 列时, 默认 mp_weixin."""
merchant_id, _, _ = test_merchant
# 模拟老数据: INSERT 不显式指定 source_client, 走 schema DEFAULT
row = await test_db.fetchrow(
"""
INSERT INTO articles (merchant_id, topic)
VALUES ($1, '老数据')
RETURNING *
""",
merchant_id,
)
article = _format_article(row)
assert article["source_client"] == "mp_weixin"
assert article["version"] == 1
@pytest.mark.asyncio
async def test_x_client_type_header_validation(test_db, test_merchant):
"""X-Client-Type 头规范化: 非法值默认 mp_weixin.
直接调用 service 层模拟, 不走完整 LLM 生成 (避免依赖外部 API).
"""
# 此测试仅验证规范化逻辑 (在 router 层), 不调用 generate_article
# 用 mock 验证规范化分支
valid_clients = {"mp_weixin", "mp_xhs", "web"}
def normalize(x_client_type: str | None) -> str:
source_client = (x_client_type or "mp_weixin").lower()
return source_client if source_client in valid_clients else "mp_weixin"
assert normalize("web") == "web"
assert normalize("WEB") == "web"
assert normalize("mp_xhs") == "mp_xhs"
assert normalize(None) == "mp_weixin"
assert normalize("invalid") == "mp_weixin"
assert normalize("") == "mp_weixin"- [ ] Step 2: 运行测试
Run: cd backend && pytest tests/test_article_source_client.py -v Expected: 3 个测试全部 PASS
- [ ] Step 3: Commit
git add backend/tests/test_article_source_client.py
git commit -m "test(article): cover source_client injection and normalization"Task 12: 后端测试 - article version 冲突
Files:
Create:
backend/tests/test_article_version_conflict.py[ ] Step 1: 创建测试文件
backend/tests/test_article_version_conflict.py
"""测试 article 乐观锁: 并发 PUT 触发 409."""
from __future__ import annotations
import pytest
from app.services.article_service import (
ArticleVersionConflict,
update_article,
get_article,
)
@pytest.mark.asyncio
async def test_update_increments_version(test_db, test_merchant):
"""正常更新 → version +1."""
merchant_id, _, _ = test_merchant
row = await test_db.fetchrow(
"INSERT INTO articles (merchant_id, topic) VALUES ($1, '原始') RETURNING id::text AS id",
merchant_id,
)
article_id = row["id"]
updated = await update_article(
test_db, article_id, {"title": "新标题"}, merchant_id, expected_version=1,
)
assert updated["version"] == 2
assert updated["title"] == "新标题"
@pytest.mark.asyncio
async def test_update_version_conflict(test_db, test_merchant):
"""expected_version 不匹配 → ArticleVersionConflict."""
merchant_id, _, _ = test_merchant
row = await test_db.fetchrow(
"INSERT INTO articles (merchant_id, topic) VALUES ($1, '原始') RETURNING id::text AS id",
merchant_id,
)
article_id = row["id"]
# 先更新一次, version 从 1 → 2
await update_article(test_db, article_id, {"title": "第一次"}, merchant_id, expected_version=1)
# 用过期的 expected_version=1 再更新 → 冲突
with pytest.raises(ArticleVersionConflict):
await update_article(
test_db, article_id, {"title": "第二次"}, merchant_id, expected_version=1,
)
@pytest.mark.asyncio
async def test_update_without_if_match_no_lock(test_db, test_merchant):
"""不传 expected_version → 不做乐观锁, 直接更新."""
merchant_id, _, _ = test_merchant
row = await test_db.fetchrow(
"INSERT INTO articles (merchant_id, topic) VALUES ($1, '原始') RETURNING id::text AS id",
merchant_id,
)
article_id = row["id"]
updated = await update_article(test_db, article_id, {"title": "无锁更新"}, merchant_id)
assert updated["version"] == 2 # version 仍然 +1, 但不校验
@pytest.mark.asyncio
async def test_update_nonexistent_returns_none(test_db, test_merchant):
"""文章不存在 → 返回 None (不抛冲突)."""
merchant_id, _, _ = test_merchant
result = await update_article(
test_db, "00000000-0000-0000-0000-000000000000",
{"title": "x"}, merchant_id, expected_version=1,
)
assert result is None- [ ] Step 2: 运行测试
Run: cd backend && pytest tests/test_article_version_conflict.py -v Expected: 4 个测试全部 PASS
- [ ] Step 3: 运行全部后端测试
Run: cd backend && pytest -v Expected: 全部 PASS
- [ ] Step 4: Commit
git add backend/tests/test_article_version_conflict.py
git commit -m "test(article): cover optimistic lock conflict scenarios"Task 13: Web 端工程脚手架
Files:
Create:
web/package.jsonCreate:
web/vite.config.tsCreate:
web/tsconfig.jsonCreate:
web/tsconfig.node.jsonCreate:
web/index.htmlCreate:
web/src/main.tsxCreate:
web/src/App.tsxCreate:
web/src/vite-env.d.tsCreate:
web/.gitignore[ ] Step 1: 创建
web/package.json
{
"name": "xhs-saas-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@ant-design/icons": "^5.3.0",
"@tiptap/react": "^2.5.0",
"@tiptap/starter-kit": "^2.5.0",
"@tiptap/extension-image": "^2.5.0",
"@tiptap/extension-placeholder": "^2.5.0",
"antd": "^5.16.0",
"axios": "^1.6.8",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0",
"zustand": "^4.5.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^15.0.0",
"@testing-library/user-event": "^14.5.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"jsdom": "^24.0.0",
"msw": "^2.2.0",
"typescript": "^5.4.0",
"vite": "^5.2.0",
"vitest": "^1.5.0"
}
}- [ ] Step 2: 创建
web/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5175, // 与 admin (5173) / miniapp (5174) 错开
strictPort: true,
},
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
globals: true,
},
});- [ ] Step 3: 创建
web/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}- [ ] Step 4: 创建
web/tsconfig.node.json
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}- [ ] Step 5: 创建
web/index.html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow, noarchive" />
<title>智米口袋</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>- [ ] Step 6: 创建
web/src/vite-env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}- [ ] Step 7: 创建
web/src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);- [ ] Step 8: 创建
web/src/App.tsx(最小占位,后续 task 替换)
export default function App() {
return <div>Web 端脚手架就绪</div>;
}- [ ] Step 9: 创建
web/.gitignore
node_modules/
dist/
.env.local
.env.*.local
*.log
coverage/- [ ] Step 10: 安装依赖
Run: cd web && npm install Expected: 无致命错误。
- [ ] Step 11: 启动 dev server 验证
Run: cd web && npm run dev Expected: Vite 启动,访问 http://localhost:5175 显示 "Web 端脚手架就绪"。
- [ ] Step 12: Commit
git add web/package.json web/vite.config.ts web/tsconfig.json web/tsconfig.node.json web/index.html web/src/main.tsx web/src/App.tsx web/src/vite-env.d.ts web/.gitignore
git commit -m "feat(web): scaffold Vite + React + TS project"Task 14: Web 端 env 模板
Files:
Create:
web/.env.developmentCreate:
web/.env.stagingCreate:
web/.env.productionCreate:
web/.env.example[ ] Step 1: 创建
web/.env.development
# 本地开发
VITE_API_BASE=http://127.0.0.1:8000- [ ] Step 2: 创建
web/.env.staging
# 阶段1: 公网 IP + 自签证书 (域名备案中)
# 替换为实际公网 IP 和端口
VITE_API_BASE=https://<PUBLIC_IP>:8443- [ ] Step 3: 创建
web/.env.production
# 阶段2: 域名 + Let's Encrypt
VITE_API_BASE=https://api.example.com- [ ] Step 4: 创建
web/.env.example
# 复制为 .env.development / .env.staging / .env.production 之一
# Vite 按模式自动加载对应文件
# 后端 API base URL (必填)
VITE_API_BASE=http://127.0.0.1:8000- [ ] Step 5: Commit
git add web/.env.development web/.env.staging web/.env.production web/.env.example
git commit -m "feat(web): add env templates for dev/staging/production"Task 15: Web 端 types/api.ts
Files:
Create:
web/src/types/api.ts[ ] Step 1: 创建
web/src/types/api.ts
// 与小程序 types/api.ts + 后端 models 对齐
export interface LoginRequest {
phone: string;
password: string;
}
export interface RegisterRequest {
phone: string;
password: string;
confirm_password: string;
}
export interface TokenResponse {
access_token: string;
token_type: string;
merchant_id: string;
}
export interface RefreshResponse {
access_token: string;
token_type: string;
merchant_id: string;
}
export interface MerchantMe {
id: string;
phone: string;
nickname: string | null;
avatar_url: string | null;
has_profile: boolean;
}
export type ArticleSourceClient = 'mp_weixin' | 'mp_xhs' | 'web';
export interface Article {
id: string;
account_type: string;
topic: string;
style: string | null;
title: string | null;
content: string | null;
tags: string[];
image_slots: Record<string, unknown>[];
warnings: string[];
word_count: number;
status: string;
source_client: ArticleSourceClient;
version: number;
created_at: string;
}
export interface ArticleListResponse {
items: Article[];
total: number;
page: number;
size: number;
}
export interface ArticleUpdate {
title?: string;
content?: string;
tags?: string[];
status?: 'draft' | 'published';
}- [ ] Step 2: Commit
git add web/src/types/api.ts
git commit -m "feat(web): add TypeScript types aligned with miniapp/backend"Task 16: Web 端 api/signature.ts (WebCrypto)
Files:
Create:
web/src/api/signature.ts[ ] Step 1: 创建
web/src/api/signature.ts
// WebCrypto HMAC-SHA256 签名 (替代小程序的纯JS实现, 性能更好)
// 与后端 verify_request_signature 算法完全一致
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
function stableStringify(value: unknown): string {
if (value === undefined || value === null || value === '') return '';
if (typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
const obj = value as Record<string, JsonValue>;
return `{${Object.keys(obj)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`)
.join(',')}}`;
}
function pathWithQuery(url: string): string {
try {
const u = new URL(url);
return u.pathname + (u.search || '');
} catch {
return url;
}
}
function bytesToHex(bytes: ArrayBuffer): string {
return Array.from(new Uint8Array(bytes))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
function nonce(): string {
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
}
async function hmacSha256Hex(key: string, message: string): Promise<string> {
const enc = new TextEncoder();
const cryptoKey = await crypto.subtle.importKey(
'raw',
enc.encode(key),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const sig = await crypto.subtle.sign('HMAC', cryptoKey, enc.encode(message));
return bytesToHex(sig);
}
async function sha256Hex(input: string): Promise<string> {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
return bytesToHex(buf);
}
export interface SignedHeaders {
'X-Timestamp': string;
'X-Nonce': string;
'X-Body-SHA256': string;
'X-Signature': string;
}
export async function signedHeaders(params: {
token: string;
method: string;
url: string;
data?: unknown;
}): Promise<SignedHeaders> {
const timestamp = Date.now().toString();
const requestNonce = nonce();
const bodyHash = await sha256Hex(stableStringify(params.data));
const canonical = [
timestamp,
requestNonce,
params.method.toUpperCase(),
pathWithQuery(params.url),
bodyHash,
].join('\n');
return {
'X-Timestamp': timestamp,
'X-Nonce': requestNonce,
'X-Body-SHA256': bodyHash,
'X-Signature': await hmacSha256Hex(params.token, canonical),
};
}
// 导出供测试使用
export { stableStringify, sha256Hex, hmacSha256Hex };- [ ] Step 2: Commit
git add web/src/api/signature.ts
git commit -m "feat(web): add WebCrypto HMAC-SHA256 signature util"Task 17: Web 端 api/client.ts (axios + 拦截器)
Files:
Create:
web/src/api/client.tsCreate:
web/src/stores/auth.ts(依赖项, 提前创建最小版本)[ ] Step 1: 创建
web/src/stores/auth.ts(最小版本, 后续 task 扩展)
import { create } from 'zustand';
import type { MerchantMe } from '../types/api';
interface AuthState {
token: string | null;
user: MerchantMe | null;
login: (token: string) => void;
logout: () => void;
setUser: (user: MerchantMe | null) => void;
}
export const useAuthStore = create<AuthState>((set) => ({
token: null,
user: null,
login: (token) => set({ token }),
logout: () => set({ token: null, user: null }),
setUser: (user) => set({ user }),
}));
// 给 axios 拦截器用的非 React getter
export const getAuthToken = (): string | null => useAuthStore.getState().token;
export const clearAuth = (): void => useAuthStore.getState().logout();- [ ] Step 2: 创建
web/src/api/client.ts
import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios';
import { message } from 'antd';
import { signedHeaders } from './signature';
import { getAuthToken, clearAuth } from '../stores/auth';
export const API_BASE_URL = import.meta.env.VITE_API_BASE || '';
export const DEFAULT_TIMEOUT = 60000;
export const AI_TIMEOUT = 180000;
const client: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: DEFAULT_TIMEOUT,
withCredentials: true, // 携带 refresh token cookie
});
// 请求拦截: 注入 Authorization + 签名头
client.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
const token = getAuthToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
// 签名 (WebCrypto 异步)
const fullUrl = `${API_BASE_URL}${config.url || ''}`;
const headers = await signedHeaders({
token,
method: config.method || 'GET',
url: fullUrl,
data: config.data,
});
Object.assign(config.headers, headers);
}
config.headers['X-Client-Type'] = 'web';
return config;
});
// 响应拦截: 统一错误处理 + 401 自动跳登录
client.interceptors.response.use(
(response) => response.data,
(error: AxiosError<{ detail?: unknown; message?: string }>) => {
const status = error.response?.status;
const body = error.response?.data;
let msg = `请求失败 (${status || 'network'})`;
if (typeof body?.detail === 'string') msg = body.detail;
else if (Array.isArray(body?.detail) && body.detail.length > 0) {
const first = body.detail[0] as { msg?: string };
msg = first?.msg || msg;
} else if (body?.message) msg = body.message;
if (status === 401) {
clearAuth();
// 避免登录页本身报错时无限跳转
if (!window.location.pathname.includes('/login')) {
message.error('登录已过期,请重新登录');
window.location.href = '/login';
}
} else if (status !== 409) {
// 409 由调用方自行处理 (版本冲突提示)
message.error(msg);
}
return Promise.reject(error);
},
);
export default client;- [ ] Step 3: Commit
git add web/src/stores/auth.ts web/src/api/client.ts
git commit -m "feat(web): add axios client with signature interceptor and 401 handling"Task 18: Web 端 api/auth.ts + api/article.ts
Files:
Create:
web/src/api/auth.tsCreate:
web/src/api/article.ts[ ] Step 1: 创建
web/src/api/auth.ts
import client from './client';
import type {
LoginRequest,
RegisterRequest,
TokenResponse,
RefreshResponse,
MerchantMe,
} from '../types/api';
export async function login(req: LoginRequest): Promise<TokenResponse> {
return client.post<unknown, TokenResponse>('/api/auth/login', req);
}
export async function register(req: RegisterRequest): Promise<TokenResponse> {
return client.post<unknown, TokenResponse>('/api/auth/register', req);
}
export async function getMe(): Promise<MerchantMe> {
return client.get<unknown, MerchantMe>('/api/auth/me');
}
export async function refresh(): Promise<RefreshResponse> {
return client.post<unknown, RefreshResponse>('/api/auth/refresh');
}
export async function logout(): Promise<void> {
await client.post('/api/auth/logout');
}- [ ] Step 2: 创建
web/src/api/article.ts
import client from './client';
import type { Article, ArticleListResponse, ArticleUpdate } from '../types/api';
export async function listArticles(page = 1, size = 20): Promise<ArticleListResponse> {
return client.get<unknown, ArticleListResponse>('/api/article/list', {
params: { page, size },
});
}
export async function getArticle(id: string): Promise<Article> {
return client.get<unknown, Article>(`/api/article/${id}`);
}
export async function updateArticle(
id: string,
data: ArticleUpdate,
expectedVersion?: number,
): Promise<Article> {
const headers: Record<string, string> = {};
if (expectedVersion !== undefined) {
headers['If-Match'] = String(expectedVersion);
}
return client.put<unknown, Article>(`/api/article/${id}`, data, { headers });
}
export async function deleteArticle(id: string): Promise<void> {
await client.delete(`/api/article/${id}`);
}- [ ] Step 3: Commit
git add web/src/api/auth.ts web/src/api/article.ts
git commit -m "feat(web): add auth and article API modules"Task 19: Web 端 layouts + router
Files:
Create:
web/src/layouts/AppLayout.tsxCreate:
web/src/layouts/BlankLayout.tsxCreate:
web/src/router/index.tsxCreate:
web/src/router/guard.tsxCreate:
web/src/pages/NotFound/index.tsxModify:
web/src/App.tsx[ ] Step 1: 创建
web/src/layouts/BlankLayout.tsx
import { Outlet } from 'react-router-dom';
export default function BlankLayout() {
return <Outlet />;
}- [ ] Step 2: 创建
web/src/layouts/AppLayout.tsx
import { Layout, Menu, Avatar, Dropdown, message } from 'antd';
import { UserOutlined, LogoutOutlined, FileTextOutlined } from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../stores/auth';
import { logout as apiLogout } from '../api/auth';
const { Header, Sider, Content } = Layout;
export default function AppLayout() {
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuthStore();
const handleLogout = async () => {
try {
await apiLogout();
} catch {
// 忽略 logout 失败, 前端仍清状态
}
logout();
message.success('已登出');
navigate('/login');
};
const menuItems = [
{ key: '/articles', icon: <FileTextOutlined />, label: '文章管理' },
];
const userMenu = {
items: [
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: handleLogout,
},
],
};
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider collapsible style={{ background: '#fff' }}>
<div style={{ height: 56, padding: 16, fontWeight: 600, color: '#FF2442' }}>
智米口袋
</div>
<Menu
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
onClick={({ key }) => navigate(key)}
/>
</Sider>
<Layout>
<Header style={{ background: '#fff', padding: '0 24px', display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}>
<Dropdown menu={userMenu}>
<span style={{ cursor: 'pointer' }}>
<Avatar icon={<UserOutlined />} />
<span style={{ marginLeft: 8 }}>{user?.nickname || user?.phone || '用户'}</span>
</span>
</Dropdown>
</Header>
<Content style={{ margin: 24, padding: 24, background: '#fff', borderRadius: 8 }}>
<Outlet />
</Content>
</Layout>
</Layout>
);
}- [ ] Step 3: 创建
web/src/router/guard.tsx
import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../stores/auth';
export function RequireAuth({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
if (!token) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}- [ ] Step 4: 创建
web/src/pages/NotFound/index.tsx
import { Result, Button } from 'antd';
import { useNavigate } from 'react-router-dom';
export default function NotFound() {
const navigate = useNavigate();
return (
<Result
status="404"
title="404"
subTitle="页面不存在"
extra={<Button type="primary" onClick={() => navigate('/articles')}>返回首页</Button>}
/>
);
}- [ ] Step 5: 创建
web/src/router/index.tsx
import { createBrowserRouter, Navigate } from 'react-router-dom';
import { lazy, Suspense } from 'react';
import { Spin } from 'antd';
import BlankLayout from '../layouts/BlankLayout';
import AppLayout from '../layouts/AppLayout';
import NotFound from '../pages/NotFound';
import { RequireAuth } from './guard';
const Login = lazy(() => import('../pages/Login'));
const ArticleList = lazy(() => import('../pages/ArticleList'));
const ArticleEditor = lazy(() => import('../pages/ArticleEditor'));
const withSuspense = (el: React.ReactNode) => (
<Suspense fallback={<div style={{ padding: 48, textAlign: 'center' }}><Spin /></div>}>
{el}
</Suspense>
);
export const router = createBrowserRouter([
{
element: <BlankLayout />,
children: [
{ path: '/login', element: withSuspense(<Login />) },
],
},
{
element: (
<RequireAuth>
<AppLayout />
</RequireAuth>
),
children: [
{ path: '/', element: <Navigate to="/articles" replace /> },
{ path: '/articles', element: withSuspense(<ArticleList />) },
{ path: '/articles/:id/edit', element: withSuspense(<ArticleEditor />) },
],
},
{ path: '*', element: <NotFound /> },
]);- [ ] Step 6: 修改
web/src/App.tsx,挂载 router
import { RouterProvider } from 'react-router-dom';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import { router } from './router';
export default function App() {
return (
<ConfigProvider locale={zhCN} theme={{ token: { colorPrimary: '#FF2442' } }}>
<RouterProvider router={router} />
</ConfigProvider>
);
}- [ ] Step 7: Commit
git add web/src/layouts web/src/router web/src/pages/NotFound web/src/App.tsx
git commit -m "feat(web): add AppLayout/BlankLayout/router/NotFound and mount router"Task 20: Web 端 pages/Login
Files:
Create:
web/src/pages/Login/index.tsx[ ] Step 1: 创建
web/src/pages/Login/index.tsx
import { useState } from 'react';
import { Card, Form, Input, Button, message, Typography } from 'antd';
import { useNavigate } from 'react-router-dom';
import { login as apiLogin, register as apiRegister, getMe } from '../../api/auth';
import { useAuthStore } from '../../stores/auth';
import type { LoginRequest, RegisterRequest } from '../../types/api';
const { Title, Text } = Typography;
export default function Login() {
const [mode, setMode] = useState<'login' | 'register'>('login');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { login, setUser } = useAuthStore();
const [form] = Form.useForm();
const handleSubmit = async (values: LoginRequest & { confirm_password?: string }) => {
setLoading(true);
try {
let token: string;
if (mode === 'login') {
const res = await apiLogin({ phone: values.phone, password: values.password });
token = res.access_token;
} else {
const req: RegisterRequest = {
phone: values.phone,
password: values.password,
confirm_password: values.confirm_password || '',
};
const res = await apiRegister(req);
token = res.access_token;
}
login(token);
// 拉取用户信息
try {
const me = await getMe();
setUser(me);
} catch {
// 非关键, 失败不影响登录
}
message.success(mode === 'login' ? '登录成功' : '注册成功');
navigate('/articles');
} catch {
// 错误已在 axios 拦截器统一 toast
} finally {
setLoading(false);
}
};
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(180deg, #FF2442 0%, #FF5A6E 40%, #F8F8F8 100%)',
}}>
<Card style={{ width: 400, boxShadow: '0 8px 32px rgba(0,0,0,0.08)' }}>
<Title level={3} style={{ color: '#FF2442', textAlign: 'center', marginBottom: 8 }}>
智米口袋
</Title>
<Text type="secondary" style={{ display: 'block', textAlign: 'center', marginBottom: 24 }}>
使用手机号和密码登录
</Text>
<Form form={form} onFinish={handleSubmit} layout="vertical">
<Form.Item
name="phone"
label="手机号"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式错误' },
]}
>
<Input placeholder="11 位手机号" maxLength={11} />
</Form.Item>
<Form.Item
name="password"
label="密码"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password placeholder="密码" />
</Form.Item>
{mode === 'register' && (
<Form.Item
name="confirm_password"
label="确认密码"
rules={[
{ required: true, message: '请再次输入密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) return Promise.resolve();
return Promise.reject(new Error('两次密码不一致'));
},
}),
]}
>
<Input.Password placeholder="确认密码" />
</Form.Item>
)}
<Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
{mode === 'login' ? '登录' : '注册'}
</Button>
</Form.Item>
<div style={{ textAlign: 'center' }}>
<Text
style={{ cursor: 'pointer', color: '#FF2442' }}
onClick={() => setMode(mode === 'login' ? 'register' : 'login')}
>
{mode === 'login' ? '没有账号?去注册' : '已有账号?去登录'}
</Text>
</div>
</Form>
</Card>
</div>
);
}- [ ] Step 2: Commit
git add web/src/pages/Login
git commit -m "feat(web): add Login/Register page with phone+password"Task 21: Web 端 pages/ArticleList
Files:
Create:
web/src/pages/ArticleList/index.tsxCreate:
web/src/hooks/useArticle.ts[ ] Step 1: 创建
web/src/hooks/useArticle.ts
import { useCallback, useEffect, useState } from 'react';
import {
listArticles as apiList,
getArticle as apiGet,
updateArticle as apiUpdate,
deleteArticle as apiDelete,
} from '../api/article';
import type { Article, ArticleListResponse, ArticleUpdate } from '../types/api';
export function useArticleList(page = 1, size = 20) {
const [data, setData] = useState<ArticleListResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await apiList(page, size);
setData(res);
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
}
}, [page, size]);
useEffect(() => {
reload();
}, [reload]);
return { data, loading, error, reload };
}
export function useArticle(id: string | undefined) {
const [article, setArticle] = useState<Article | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const reload = useCallback(async () => {
if (!id) return;
setLoading(true);
setError(null);
try {
const res = await apiGet(id);
setArticle(res);
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
reload();
}, [reload]);
const update = useCallback(
async (data: ArticleUpdate, expectedVersion?: number) => {
if (!id) throw new Error('article id required');
const updated = await apiUpdate(id, data, expectedVersion);
setArticle(updated);
return updated;
},
[id],
);
const remove = useCallback(async () => {
if (!id) return;
await apiDelete(id);
}, [id]);
return { article, loading, error, reload, update, remove };
}- [ ] Step 2: 创建
web/src/pages/ArticleList/index.tsx
import { useState } from 'react';
import { Table, Button, Space, Tag, Modal, Input, message, Popconfirm, Typography } from 'antd';
import { EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useArticleList } from '../../hooks/useArticle';
import { deleteArticle } from '../../api/article';
import type { Article, ArticleSourceClient } from '../../types/api';
const { Title } = Typography;
const sourceClientLabel: Record<ArticleSourceClient, { label: string; color: string }> = {
mp_weixin: { label: '微信小程序', color: 'green' },
mp_xhs: { label: '小红书小程序', color: 'red' },
web: { label: 'Web 端', color: 'blue' },
};
export default function ArticleList() {
const navigate = useNavigate();
const [page, setPage] = useState(1);
const [size, setSize] = useState(20);
const [search, setSearch] = useState('');
const { data, loading, reload } = useArticleList(page, size);
const handleDelete = async (id: string) => {
try {
await deleteArticle(id);
message.success('已删除');
reload();
} catch {
// 错误已 toast
}
};
const filtered = data?.items.filter((a) =>
!search || (a.title || a.topic || '').toLowerCase().includes(search.toLowerCase()),
) || [];
const columns = [
{
title: '标题',
dataIndex: 'title',
render: (title: string | null, record: Article) => title || record.topic,
},
{ title: '状态', dataIndex: 'status', width: 100, render: (s: string) => (
<Tag color={s === 'published' ? 'green' : 'default'}>{s === 'published' ? '已发布' : '草稿'}</Tag>
)},
{ title: '字数', dataIndex: 'word_count', width: 80 },
{ title: '来源', dataIndex: 'source_client', width: 120, render: (c: ArticleSourceClient) => {
const meta = sourceClientLabel[c] || { label: c, color: 'default' };
return <Tag color={meta.color}>{meta.label}</Tag>;
}},
{ title: '版本', dataIndex: 'version', width: 60 },
{ title: '创建时间', dataIndex: 'created_at', width: 180, render: (t: string) =>
new Date(t).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'action',
width: 160,
render: (_: unknown, record: Article) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => navigate(`/articles/${record.id}/edit`)}>
编辑
</Button>
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Title level={4} style={{ marginBottom: 16 }}>文章管理</Title>
<Space style={{ marginBottom: 16 }}>
<Input.Search
placeholder="搜索标题"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ width: 240 }}
allowClear
/>
<Button icon={<ReloadOutlined />} onClick={reload}>刷新</Button>
</Space>
<Table
rowKey="id"
loading={loading}
dataSource={filtered}
columns={columns}
pagination={{
current: page,
pageSize: size,
total: data?.total || 0,
onChange: (p, s) => { setPage(p); setSize(s); },
showSizeChanger: true,
}}
/>
</div>
);
}- [ ] Step 3: Commit
git add web/src/pages/ArticleList web/src/hooks/useArticle.ts
git commit -m "feat(web): add ArticleList page with search/pagination/delete"Task 22: Web 端 pages/ArticleEditor (Tiptap)
Files:
Create:
web/src/pages/ArticleEditor/index.tsx[ ] Step 1: 创建
web/src/pages/ArticleEditor/index.tsx
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import Image from '@tiptap/extension-image';
import { Button, Input, Space, Spin, Typography, message, Tag } from 'antd';
import { SaveOutlined, ArrowLeftOutlined } from '@ant-design/icons';
import { useArticle } from '../../hooks/useArticle';
import { AxiosError } from 'axios';
const { Title } = Typography;
export default function ArticleEditor() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { article, loading, update } = useArticle(id);
const [title, setTitle] = useState('');
const [saving, setSaving] = useState(false);
const editor = useEditor({
extensions: [
StarterKit,
Placeholder.configure({ placeholder: '开始编辑文章...' }),
Image,
],
content: '',
});
useEffect(() => {
if (article) {
setTitle(article.title || '');
editor?.commands.setContent(article.content || '');
}
}, [article, editor]);
const handleSave = async () => {
if (!article) return;
setSaving(true);
try {
const content = editor?.getHTML() || '';
await update({ title, content }, article.version);
message.success('保存成功');
} catch (e) {
const err = e as AxiosError;
if (err.response?.status === 409) {
message.warning('文章已被另一端修改,正在刷新...');
// 触发 reload (useArticle 内部 reload)
window.location.reload();
}
} finally {
setSaving(false);
}
};
if (loading && !article) {
return <div style={{ textAlign: 'center', padding: 48 }}><Spin /></div>;
}
if (!article) {
return <div>文章不存在</div>;
}
return (
<div>
<Space style={{ marginBottom: 16 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/articles')}>返回</Button>
<Tag color="blue">来源: {article.source_client}</Tag>
<Tag>版本: {article.version}</Tag>
</Space>
<Title level={4}>编辑文章</Title>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="文章标题"
style={{ marginBottom: 16, fontSize: 18, fontWeight: 600 }}
/>
<div style={{ border: '1px solid #d9d9d9', borderRadius: 6, padding: 16, minHeight: 400 }}>
<EditorContent editor={editor} />
</div>
<Space style={{ marginTop: 16 }}>
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}>
保存
</Button>
</Space>
</div>
);
}- [ ] Step 2: 验证 dev 启动
Run: cd web && npm run dev Expected: 启动成功,无 TS 编译错误(虽然有 lazy import 警告)。
- [ ] Step 3: Commit
git add web/src/pages/ArticleEditor
git commit -m "feat(web): add ArticleEditor with Tiptap and 409 conflict handling"Task 23: Web 端测试设置 + signature 测试
Files:
Create:
web/src/test/setup.tsCreate:
web/src/api/signature.test.ts[ ] Step 1: 创建
web/src/test/setup.ts
import '@testing-library/jest-dom';
// jsdom 没有 crypto.subtle, 用 Node 原生 webcrypto polyfill
import { webcrypto } from 'node:crypto';
if (!globalThis.crypto) {
(globalThis as unknown as { crypto: Crypto }).crypto = webcrypto as unknown as Crypto;
}- [ ] Step 2: 创建
web/src/api/signature.test.ts
import { describe, it, expect } from 'vitest';
import { signedHeaders, stableStringify, sha256Hex, hmacSha256Hex } from './signature';
describe('signature', () => {
it('stableStringify 排序 key', () => {
expect(stableStringify({ b: 1, a: 2 })).toBe('{"a":2,"b":1}');
expect(stableStringify({ a: { d: 1, c: 2 } })).toBe('{"a":{"c":2,"d":1}}');
expect(stableStringify(null)).toBe('');
expect(stableStringify(undefined)).toBe('');
});
it('sha256Hex 与已知向量一致 (空串)', async () => {
const hash = await sha256Hex('');
// SHA256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
expect(hash).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
});
it('sha256Hex 与已知向量一致 (abc)', async () => {
const hash = await sha256Hex('abc');
// SHA256("abc") = ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
expect(hash).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');
});
it('hmacSha256Hex 与 RFC 4231 测试向量一致 (case 1)', async () => {
// RFC 4231 Section 4.2: key=0b*20, data="Hi There"
const key = '\x0b'.repeat(20);
const hash = await hmacSha256Hex(key, 'Hi There');
expect(hash).toBe('b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7');
});
it('hmacSha256Hex 与 RFC 4231 测试向量一致 (case 2)', async () => {
// RFC 4231 Section 4.3: key="Jefe", data="what do ya want for nothing?"
const hash = await hmacSha256Hex('Jefe', 'what do ya want for nothing?');
expect(hash).toBe('5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843');
});
it('signedHeaders 返回四个头', async () => {
const headers = await signedHeaders({
token: 'test-token',
method: 'POST',
url: 'http://127.0.0.1:8000/api/article/list',
data: { a: 1 },
});
expect(headers).toHaveProperty('X-Timestamp');
expect(headers).toHaveProperty('X-Nonce');
expect(headers).toHaveProperty('X-Body-SHA256');
expect(headers).toHaveProperty('X-Signature');
expect(headers['X-Signature']).toMatch(/^[0-9a-f]{64}$/);
});
it('pathWithQuery 提取 path', async () => {
const h1 = await signedHeaders({
token: 't', method: 'GET', url: 'http://x/api/a?b=1',
});
const h2 = await signedHeaders({
token: 't', method: 'GET', url: 'http://x/api/a?c=2',
});
// 不同 query 应该产生不同签名 (canonical 包含 query)
expect(h1['X-Signature']).not.toBe(h2['X-Signature']);
});
});- [ ] Step 3: 运行测试
Run: cd web && npm test -- src/api/signature.test.ts Expected: 7 个测试全部 PASS
- [ ] Step 4: Commit
git add web/src/test/setup.ts web/src/api/signature.test.ts
git commit -m "test(web): add WebCrypto signature tests with RFC 4231 vectors"Task 24: Web 端测试 - auth store
Files:
Create:
web/src/stores/auth.test.ts[ ] Step 1: 创建
web/src/stores/auth.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { useAuthStore, getAuthToken, clearAuth } from './auth';
describe('auth store', () => {
beforeEach(() => {
// 每个测试前重置 store
useAuthStore.setState({ token: null, user: null });
});
it('初始状态: token=null, user=null', () => {
expect(useAuthStore.getState().token).toBeNull();
expect(useAuthStore.getState().user).toBeNull();
});
it('login 写入 token 到内存', () => {
useAuthStore.getState().login('test-token-123');
expect(useAuthStore.getState().token).toBe('test-token-123');
expect(getAuthToken()).toBe('test-token-123');
});
it('logout 清空 token + user', () => {
useAuthStore.getState().login('t');
useAuthStore.getState().setUser({
id: '1', phone: '138', nickname: 'x', avatar_url: null, has_profile: false,
});
useAuthStore.getState().logout();
expect(useAuthStore.getState().token).toBeNull();
expect(useAuthStore.getState().user).toBeNull();
expect(getAuthToken()).toBeNull();
});
it('clearAuth 等价 logout', () => {
useAuthStore.getState().login('t');
clearAuth();
expect(getAuthToken()).toBeNull();
});
it('setUser 设置用户信息', () => {
const me = { id: '1', phone: '138', nickname: 'x', avatar_url: null, has_profile: false };
useAuthStore.getState().setUser(me);
expect(useAuthStore.getState().user).toEqual(me);
});
});- [ ] Step 2: 运行测试
Run: cd web && npm test -- src/stores/auth.test.ts Expected: 5 个测试全部 PASS
- [ ] Step 3: Commit
git add web/src/stores/auth.test.ts
git commit -m "test(web): cover auth store login/logout/setUser flows"Task 25: Web 端测试 - ArticleList 页面
Files:
Create:
web/src/pages/ArticleList.test.tsxCreate:
web/src/test/fixtures.ts[ ] Step 1: 创建
web/src/test/fixtures.ts
import type { Article, ArticleListResponse } from '../types/api';
export const mockArticle: Article = {
id: 'a1',
account_type: '素人号',
topic: '测试主题',
style: '干货',
title: '测试标题',
content: '<p>正文</p>',
tags: [],
image_slots: [],
warnings: [],
word_count: 100,
status: 'draft',
source_client: 'web',
version: 1,
created_at: '2026-07-30T10:00:00Z',
};
export const mockArticleList: ArticleListResponse = {
items: [mockArticle],
total: 1,
page: 1,
size: 20,
};- [ ] Step 2: 创建
web/src/pages/ArticleList.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { ConfigProvider } from 'antd';
import ArticleList from './ArticleList';
import * as articleApi from '../api/article';
import { mockArticleList } from '../test/fixtures';
vi.mock('../api/article');
describe('ArticleList', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('渲染加载态', async () => {
vi.mocked(articleApi.listArticles).mockResolvedValue(mockArticleList);
render(
<MemoryRouter>
<ConfigProvider>
<ArticleList />
</ConfigProvider>
</MemoryRouter>,
);
// 等待数据加载完成
await waitFor(() => {
expect(screen.getByText('测试标题')).toBeInTheDocument();
});
});
it('空列表正常渲染', async () => {
vi.mocked(articleApi.listArticles).mockResolvedValue({
items: [], total: 0, page: 1, size: 20,
});
render(
<MemoryRouter>
<ConfigProvider>
<ArticleList />
</ConfigProvider>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText('文章管理')).toBeInTheDocument();
});
});
it('加载失败显示错误', async () => {
vi.mocked(articleApi.listArticles).mockRejectedValue(new Error('网络错误'));
render(
<MemoryRouter>
<ConfigProvider>
<ArticleList />
</ConfigProvider>
</MemoryRouter>,
);
// axios 拦截器未在测试中启用, 此处仅验证不崩溃
await waitFor(() => {
expect(screen.getByText('文章管理')).toBeInTheDocument();
});
});
});- [ ] Step 3: 运行测试
Run: cd web && npm test -- src/pages/ArticleList.test.tsx Expected: 3 个测试全部 PASS
- [ ] Step 4: 运行所有 Web 测试
Run: cd web && npm test Expected: 全部 PASS
- [ ] Step 5: Commit
git add web/src/pages/ArticleList.test.tsx web/src/test/fixtures.ts
git commit -m "test(web): cover ArticleList rendering with mock data"Task 26: 部署文档 + Nginx 模板
Files:
Create:
web/deploy/nginx-web.conf.templateCreate:
web/DEPLOYMENT.md[ ] Step 1: 创建
web/deploy/nginx-web.conf.template
# Web 端 Nginx 配置模板
# 用 envsubst 替换 ${VAR} 变量后使用:
# envsubst '${WEB_DOMAIN} ${API_UPSTREAM} ${CSP_HEADER}' < nginx-web.conf.template > nginx-web.conf
# 阶段 1 (HTTP + 公网 IP): 用 listen 80, server_name _;
# 阶段 2 (HTTPS + 域名): 用 listen 443 ssl http2 + ssl_certificate
server {
listen 80;
# listen 443 ssl http2; # 阶段 2 启用
server_name ${WEB_DOMAIN};
# 阶段 2 启用 SSL
# ssl_certificate /etc/letsencrypt/live/${WEB_DOMAIN}/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/${WEB_DOMAIN}/privkey.pem;
# ssl_protocols TLSv1.2 TLSv1.3;
# 安全头 (生产环境必加)
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Robots-Tag "noindex, nofollow, noarchive" always;
add_header Content-Security-Policy "${CSP_HEADER}" always;
# 阶段 2 启用 HSTS
# add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# 前端静态资源
root /var/www/web;
index index.html;
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
# API 反向代理到后端
location /api/ {
proxy_pass ${API_UPSTREAM};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 200s; # AI 接口需要长超时
}
}- [ ] Step 2: 创建
web/DEPLOYMENT.md
# Web 端部署文档
## 阶段 0: 本地开发
```bash
cd web
npm install
npm run dev
# 访问 http://localhost:5175后端需独立启动: cd backend && python -m app.main (或 uvicorn app.main:app --reload)
阶段 1: 公网 IP + 自签证书 (域名备案中)
1.1 生成自签名证书
openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 90 -subj "/CN=<PUBLIC_IP>"1.2 构建 Web 端
cd web
cp .env.staging .env.production
# 编辑 .env.production 替换 <PUBLIC_IP>
npm run build
# 输出 dist/ 目录1.3 部署 Nginx
sudo cp -r dist/* /var/www/web/
# 用 envsubst 生成实际配置
sudo envsubst '${WEB_DOMAIN} ${API_UPSTREAM} ${CSP_HEADER}' \
< deploy/nginx-web.conf.template \
> /etc/nginx/sites-available/web
sudo ln -sf /etc/nginx/sites-available/web /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx环境变量示例:
WEB_DOMAIN=_
API_UPSTREAM=http://127.0.0.1:8000
CSP_HEADER="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; connect-src 'self' https://<PUBLIC_IP>:8443; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"1.4 后端 .env 配置
APP_ENV=staging
CORS_ORIGINS=http://<PUBLIC_IP>:8080
ADMIN_INTRANET_ONLY=false # 测试期可关, 上线必须开
# Web 端 Refresh Cookie 在 HTTP 下不启用, 用户刷新页需重新登录 (安全降级)1.5 安全降级说明
- 自签名证书浏览器会警告, 仅内部测试账号可用
- Refresh Token Cookie 不启用 (HTTP 下不安全)
APP_ENV=staging时建议配置手机号白名单 (Phase 1 暂未实现, 手动控制测试账号)
阶段 2: 域名 + HTTPS (备案通过后)
2.1 申请 Let's Encrypt 证书
sudo certbot certonly --nginx -d app.example.com -d api.example.com2.2 切换 env
cd web
cp .env.production .env.production.bak
# 编辑 .env.production: VITE_API_BASE=https://api.example.com
npm run build
sudo cp -r dist/* /var/www/web/2.3 启用 SSL + HSTS
编辑 Nginx 配置: 取消 listen 443 ssl http2 + ssl_certificate + HSTS 三行注释, 注释 listen 80 行。
2.4 后端启用 Refresh Cookie
后端 /api/auth/refresh 接口在 HTTPS 下 Set-Cookie 自动带 Secure; HttpOnly; SameSite=Strict。
修改 backend/app/routers/auth.py 中 issue_refresh_token 调用处, 加 Set-Cookie:
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=True, # 仅 HTTPS
samesite="strict",
max_age=settings.refresh_token_ttl_days * 86400,
path="/api/auth",
)(实际代码在 Task 5 已实现, 此处仅说明切换)
2.5 CORS 切换
CORS_ORIGINS=https://app.example.com回滚
阶段 2 切换失败时:
- 恢复
web/.env.production.bak - 重新
npm run build && sudo cp -r dist/* /var/www/web/ - 恢复 Nginx 配置注释状态
sudo systemctl reload nginx
验证清单
- [ ] 访问
https://app.example.com显示登录页 - [ ] 浏览器开发者工具 Network 标签: API 请求带
Authorization+X-Signature头 - [ ] 响应头含
X-Robots-Tag: noindex, nofollow, noarchive - [ ] 响应头含
X-Frame-Options: DENY - [ ] 阶段 2: Cookie
refresh_token标记为HttpOnly; Secure; SameSite=Strict - [ ] 登录后访问
/articles看到小程序生成的文章 (双端互通验证) - [ ] 编辑器保存文章后, 小程序刷新看到更新 (数据互通验证)
- [ ] **Step 3: Commit**
```bash
git add web/deploy/nginx-web.conf.template web/DEPLOYMENT.md
git commit -m "docs(web): add deployment guide and nginx config template"Task 27: 后端 CORS 配置验证 + 集成验证
Files:
Verify:
backend/.env.exampleManual: 端到端验证
[ ] Step 1: 确认 backend/.env 包含 Web 端 origin
修改 backend/.env.example 的 CORS_ORIGINS 行(如 Task 1 已加则跳过):
CORS_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175如果本地有 backend/.env,同步更新。
- [ ] Step 2: 启动后端
Run: cd backend && python -m uvicorn app.main:app --reload --port 8000 Expected: 启动日志显示 Uvicorn running on http://127.0.0.1:8000
- [ ] Step 3: 启动 Web 端
Run (新终端): cd web && npm run dev Expected: Vite 启动在 http://localhost:5175
- [ ] Step 4: 手动冒烟测试
- 浏览器访问
http://localhost:5175/login - 用一个测试手机号注册(如
13900000001/password123) - 验证跳转到
/articles显示空列表 - 通过 curl 或 psql 在 dev 库手动 INSERT 一条 article:sql
INSERT INTO articles (merchant_id, topic, source_client, version) SELECT id, '测试文章 - 来自 SQL', 'mp_xhs', 1 FROM merchants WHERE phone='13900000001'; - Web 端点"刷新",应看到该文章,"来源"列显示"小红书小程序"
- 点"编辑"进入编辑器,修改标题,点"保存"
- 应看到"保存成功"toast
- psql 验证
SELECT title, source_client, version FROM articles WHERE merchant_id=(...), version 应为 2
- [ ] Step 5: 验证 409 冲突
- 在 Web 端打开一篇文章编辑器
- 通过 psql 直接更新该文章 version:
UPDATE articles SET title='SQL改的', version=version+1 WHERE id='...' - 在 Web 端点"保存"
- 应看到"文章已被另一端修改,正在刷新..."
- [ ] Step 6: 运行所有后端测试
Run: cd backend && pytest -v Expected: 全部 PASS
- [ ] Step 7: 运行所有 Web 测试
Run: cd web && npm test Expected: 全部 PASS
- [ ] Step 8: Commit 集成验证记录(如修改了配置)
如果有任何配置文件改动:
git add backend/.env.example
git commit -m "chore: verify CORS includes web dev origin"Self-Review Checklist
Spec coverage 检查
| Spec 章节 | 实现任务 |
|---|---|
| §2.5 阶段 0 本地开发 | Task 13 (脚手架) + Task 27 (冒烟) |
| §3.1 模块划分 | Task 13-22 全部 |
| §3.2 Zustand auth | Task 17 + Task 24 |
| §3.3 路由表 | Task 19 |
| §3.4 复用策略 (signature) | Task 16 |
| §3.5 Tiptap 编辑器 | Task 22 |
| §3.6 ProTable | Task 21 |
| §3.7 错误边界 + 拦截器 | Task 17 (axios 拦截) + Task 19 (Suspense) |
| §4.1 数据互通 (source_client) | Task 1 (schema) + Task 7 (注入) + Task 11 (测试) |
| §4.2 任务跨端继续 | Phase 2, 不在 Phase 1 |
| §4.3 会话互通 | Phase 2, 不在 Phase 1 |
| §4.4 内容一键发布 | Phase 3, 不在 Phase 1 |
| §4.5 错误处理 401 | Task 17 (axios 401 拦截) |
| §4.5 错误处理 409 | Task 8 (后端) + Task 22 (前端) |
| §5.1 威胁面 CSP | Task 26 (Nginx) |
| §5.2 JWT 双 Token | Task 2 + 3 + 4 + 5 + 6 |
| §5.3 CSP | Task 26 |
| §5.4 WebCrypto 签名 | Task 16 + Task 23 (测试) |
| §5.5 限流 (refresh) | Task 5 (router) |
| §5.6 Nginx 加固 | Task 26 |
| §5.8 Phase 1 安全清单 | Task 1-6 全部 |
| §6.2 后端测试 | Task 9-12 |
| §6.3 Web 端测试 | Task 23-25 |
| §6.4 集成测试 | Task 27 (手动冒烟) |
| §6.7 Phase 1 DoD | Task 27 验证 |
遗漏项
- 无遗漏 (Phase 2/3 能力明确不在 Phase 1 范围)
类型一致性检查
Article.source_client: 后端str+ 前端ArticleSourceClient联合类型 ✓Article.version: 后端int+ 前端number✓RefreshResponse: 后端 Pydantic + 前端 interface 字段一致 ✓create_refresh_token签名:auth_service.issue_refresh_token调用create_refresh_token(merchant_id, client_type)✓verify_refresh_token返回str | None,router 层判断None→ 401 ✓ArticleVersionConflict异常类: service 抛出 + router 捕获 ✓
数据库安全检查
- Task 1 schema 改动用
ADD COLUMN IF NOT EXISTS+CREATE TABLE IF NOT EXISTS✓ - Task 9 conftest 强制
PG_HOST=127.0.0.1+test库 ✓ - 无任何 DROP / TRUNCATE 语句 ✓
- 部署文档明确标注阶段 1 不启用 Refresh Cookie (HTTP 不安全) ✓
执行说明
Plan complete and saved to docs/plans/2026-07-30-web-end-phase1.md.
由于用户已确认"请实施",按用户意图直接进入 Inline Execution 模式,分批执行任务,每批后给 checkpoint 让用户检查。
执行顺序:Task 1 → 2 → 3 → ... → 27(按依赖顺序)
每完成 3-5 个任务暂停一次,向用户报告进度并允许中断。