Skip to content

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_days
  • backend/app/utils/jwt.py — 加 create_access_token_with_type / create_refresh_token / 校验 type
  • backend/app/services/auth_service.py — 加 issue_refresh_token / revoke_refresh_token / verify_refresh_token
  • backend/app/routers/auth.py — 加 /refresh + /logout
  • backend/app/deps.pyget_current_user 增加 type=access 校验
  • backend/app/services/article_service.pyupdate_article 加乐观锁;_save_article 注入 source_client
  • backend/app/routers/article.pyPUT /:id 接受 If-Match 头,409 冲突
  • backend/app/models/article.pyArticleResponsesource_client + version
  • backend/tests/conftest.py — 加真实 DB fixtures
  • backend/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.html
  • web/.env.development / web/.env.staging / web/.env.production / web/.env.example
  • web/src/main.tsx / web/src/App.tsx
  • web/src/types/api.ts — 与小程序对齐的 TypeScript 类型
  • web/src/api/signature.ts — WebCrypto HMAC-SHA256
  • web/src/api/client.ts — axios + 拦截器
  • web/src/api/auth.ts / web/src/api/article.ts
  • web/src/stores/auth.ts — Zustand auth store
  • web/src/layouts/AppLayout.tsx / web/src/layouts/BlankLayout.tsx
  • web/src/router/index.tsx / web/src/router/guard.tsx
  • web/src/pages/Login/index.tsx
  • web/src/pages/ArticleList/index.tsx
  • web/src/pages/ArticleEditor/index.tsx
  • web/src/pages/NotFound/index.tsx
  • web/src/hooks/useAuth.ts / web/src/hooks/useArticle.ts
  • web/src/components/PageContainer.tsx
  • web/src/test/setup.ts / web/src/test/fixtures.ts
  • web/src/api/signature.test.ts
  • web/src/stores/auth.test.ts
  • web/src/pages/ArticleList.test.tsx
  • web/deploy/nginx-web.conf.template
  • web/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 列

articlesCREATE TABLE 语句之后(约第 96 行后,CREATE INDEX 之前)追加:

sql
-- 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_coversCREATE INDEX 之后、-- RLS 策略 注释之前追加:

sql
-- ============================================================================
-- 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 安全注释 + 新配置项

在文件顶部追加:

bash
# ============================================================================
# 数据库安全约束(必读)
# ============================================================================
# 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 末尾追加:

bash
# ============================================================================
# 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
bash
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 行)追加:

python
    # 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 函数末尾追加断言:

python
    # 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
bash
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.py

  • Modify: backend/tests/test_smoke.py

  • [ ] Step 1: 在 backend/tests/test_smoke.py 追加失败测试

在文件末尾追加:

python
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 签发

完整替换文件内容:

python
"""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
bash
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 函数

python
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:

python
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,追加:

python
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
bash
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.pyget_current_user 加 type=access 校验)

  • [ ] Step 1: 修改 backend/app/models/auth.py,加 RefreshResponse

TokenResponse 之后追加:

python
class RefreshResponse(BaseModel):
    access_token: str
    token_type: str = "bearer"
    merchant_id: str
  • [ ] Step 2: 修改 backend/app/deps.pyget_current_user 增加 type=access 校验

get_current_user 函数完整替换为:

python
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:

python
from fastapi import Cookie, Response
from app.models.auth import RefreshResponse
from app.security.rate_limit import RateLimit

/me 路由之后追加:

python
@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 补充:

python
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
bash
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

python
@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
bash
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.pyArticleResponse 加 source_client + version)

  • Modify: backend/app/routers/article.py(generate 接口接受 X-Client-Type 头)

  • [ ] Step 1: 修改 backend/app/models/article.py,ArticleResponse 加字段

ArticleResponse 类完整替换为:

python
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" 之后)追加两行:

python
        "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 是:

python
INSERT INTO articles (merchant_id, account_type, topic, style, title, content, tags, image_slots, warnings, word_count, task_id, status)
VALUES ($1, $2, ...)

改为:

python
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 路由完整替换为:

python
@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 区追加:

python
from fastapi import Header
  • [ ] Step 5: 运行 smoke 测试

Run: cd backend && pytest tests/test_smoke.py -v Expected: 全部 PASS

  • [ ] Step 6: Commit
bash
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.pyupdate_article 加乐观锁)

  • Modify: backend/app/routers/article.py(PUT 接受 If-Match,409 冲突)

  • [ ] Step 1: 修改 backend/app/services/article_service.pyupdate_article 函数签名加 expected_version

update_article 函数完整替换为:

python
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 路由完整替换为:

python
@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
bash
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

完整替换文件:

python
"""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
bash
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

python
"""测试 /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
bash
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

python
"""测试 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
bash
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

python
"""测试 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
bash
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.json

  • Create: web/vite.config.ts

  • Create: web/tsconfig.json

  • Create: web/tsconfig.node.json

  • Create: web/index.html

  • Create: web/src/main.tsx

  • Create: web/src/App.tsx

  • Create: web/src/vite-env.d.ts

  • Create: web/.gitignore

  • [ ] Step 1: 创建 web/package.json

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
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
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
json
{
  "compilerOptions": {
    "composite": true,
    "skipLibCheck": true,
    "module": "ESNext",
    "moduleResolution": "bundler",
    "allowSyntheticDefaultImports": true
  },
  "include": ["vite.config.ts"]
}
  • [ ] Step 5: 创建 web/index.html
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
ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_BASE: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}
  • [ ] Step 7: 创建 web/src/main.tsx
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 替换)
tsx
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
bash
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.development

  • Create: web/.env.staging

  • Create: web/.env.production

  • Create: 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
bash
# 复制为 .env.development / .env.staging / .env.production 之一
# Vite 按模式自动加载对应文件

# 后端 API base URL (必填)
VITE_API_BASE=http://127.0.0.1:8000
  • [ ] Step 5: Commit
bash
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

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
bash
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

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
bash
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.ts

  • Create: web/src/stores/auth.ts(依赖项, 提前创建最小版本)

  • [ ] Step 1: 创建 web/src/stores/auth.ts(最小版本, 后续 task 扩展)

ts
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
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
bash
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.ts

  • Create: web/src/api/article.ts

  • [ ] Step 1: 创建 web/src/api/auth.ts

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
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
bash
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.tsx

  • Create: web/src/layouts/BlankLayout.tsx

  • Create: web/src/router/index.tsx

  • Create: web/src/router/guard.tsx

  • Create: web/src/pages/NotFound/index.tsx

  • Modify: web/src/App.tsx

  • [ ] Step 1: 创建 web/src/layouts/BlankLayout.tsx

tsx
import { Outlet } from 'react-router-dom';

export default function BlankLayout() {
  return <Outlet />;
}
  • [ ] Step 2: 创建 web/src/layouts/AppLayout.tsx
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
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
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
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
tsx
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
bash
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

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
bash
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.tsx

  • Create: web/src/hooks/useArticle.ts

  • [ ] Step 1: 创建 web/src/hooks/useArticle.ts

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
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
bash
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

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
bash
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.ts

  • Create: web/src/api/signature.test.ts

  • [ ] Step 1: 创建 web/src/test/setup.ts

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
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
bash
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

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
bash
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.tsx

  • Create: web/src/test/fixtures.ts

  • [ ] Step 1: 创建 web/src/test/fixtures.ts

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
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
bash
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.template

  • Create: web/DEPLOYMENT.md

  • [ ] Step 1: 创建 web/deploy/nginx-web.conf.template

nginx
# 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
markdown
# 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 生成自签名证书

bash
openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 90 -subj "/CN=<PUBLIC_IP>"

1.2 构建 Web 端

bash
cd web
cp .env.staging .env.production
# 编辑 .env.production 替换 <PUBLIC_IP>
npm run build
# 输出 dist/ 目录

1.3 部署 Nginx

bash
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 证书

bash
sudo certbot certonly --nginx -d app.example.com -d api.example.com

2.2 切换 env

bash
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 行。

后端 /api/auth/refresh 接口在 HTTPS 下 Set-Cookie 自动带 Secure; HttpOnly; SameSite=Strict

修改 backend/app/routers/auth.pyissue_refresh_token 调用处, 加 Set-Cookie:

python
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 切换失败时:

  1. 恢复 web/.env.production.bak
  2. 重新 npm run build && sudo cp -r dist/* /var/www/web/
  3. 恢复 Nginx 配置注释状态
  4. 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.example

  • Manual: 端到端验证

  • [ ] Step 1: 确认 backend/.env 包含 Web 端 origin

修改 backend/.env.exampleCORS_ORIGINS 行(如 Task 1 已加则跳过):

bash
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: 手动冒烟测试
  1. 浏览器访问 http://localhost:5175/login
  2. 用一个测试手机号注册(如 13900000001 / password123
  3. 验证跳转到 /articles 显示空列表
  4. 通过 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';
  5. Web 端点"刷新",应看到该文章,"来源"列显示"小红书小程序"
  6. 点"编辑"进入编辑器,修改标题,点"保存"
  7. 应看到"保存成功"toast
  8. psql 验证 SELECT title, source_client, version FROM articles WHERE merchant_id=(...), version 应为 2
  • [ ] Step 5: 验证 409 冲突
  1. 在 Web 端打开一篇文章编辑器
  2. 通过 psql 直接更新该文章 version:UPDATE articles SET title='SQL改的', version=version+1 WHERE id='...'
  3. 在 Web 端点"保存"
  4. 应看到"文章已被另一端修改,正在刷新..."
  • [ ] Step 6: 运行所有后端测试

Run: cd backend && pytest -v Expected: 全部 PASS

  • [ ] Step 7: 运行所有 Web 测试

Run: cd web && npm test Expected: 全部 PASS

  • [ ] Step 8: Commit 集成验证记录(如修改了配置)

如果有任何配置文件改动:

bash
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 authTask 17 + Task 24
§3.3 路由表Task 19
§3.4 复用策略 (signature)Task 16
§3.5 Tiptap 编辑器Task 22
§3.6 ProTableTask 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 错误处理 401Task 17 (axios 401 拦截)
§4.5 错误处理 409Task 8 (后端) + Task 22 (前端)
§5.1 威胁面 CSPTask 26 (Nginx)
§5.2 JWT 双 TokenTask 2 + 3 + 4 + 5 + 6
§5.3 CSPTask 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 DoDTask 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 个任务暂停一次,向用户报告进度并允许中断。

基于 VitePress 构建 · 由 GitHub Actions 自动部署