Procházet zdrojové kódy

init:项目初始化

yangxiaolong před 1 měsícem
revize
fa0f7d2ea1
100 změnil soubory, kde provedl 10753 přidání a 0 odebrání
  1. 15 0
      .gitignore
  2. 5 0
      .idea/.gitignore
  3. 1 0
      .python-version
  4. 5 0
      backend/.idea/.gitignore
  5. 17 0
      backend/.idea/backend.iml
  6. 6 0
      backend/.idea/inspectionProfiles/profiles_settings.xml
  7. 6 0
      backend/.idea/misc.xml
  8. 8 0
      backend/.idea/modules.xml
  9. 144 0
      backend/README.md
  10. 14 0
      backend/alembic.ini
  11. 1 0
      backend/app/__init__.py
  12. 1 0
      backend/app/api/__init__.py
  13. 23 0
      backend/app/api/responses.py
  14. 1 0
      backend/app/api/v1/__init__.py
  15. 118 0
      backend/app/api/v1/admin_auth.py
  16. 553 0
      backend/app/api/v1/admin_business.py
  17. 209 0
      backend/app/api/v1/agent.py
  18. 207 0
      backend/app/api/v1/enrollment.py
  19. 140 0
      backend/app/api/v1/h5_auth.py
  20. 41 0
      backend/app/api/v1/h5_products.py
  21. 17 0
      backend/app/api/v1/system.py
  22. 1 0
      backend/app/commands/__init__.py
  23. 57 0
      backend/app/commands/build_analytics.py
  24. 175 0
      backend/app/commands/evaluate_agents.py
  25. 59 0
      backend/app/commands/init_databases.py
  26. 32 0
      backend/app/commands/reset_cache.py
  27. 509 0
      backend/app/commands/seed.py
  28. 583 0
      backend/app/commands/seed_business_data.py
  29. 56 0
      backend/app/commands/verify_seed.py
  30. 202 0
      backend/app/commands/verify_stage_one.py
  31. 1 0
      backend/app/core/__init__.py
  32. 107 0
      backend/app/core/config.py
  33. 11 0
      backend/app/core/errors.py
  34. 9 0
      backend/app/core/identifiers.py
  35. 12 0
      backend/app/core/passwords.py
  36. 129 0
      backend/app/core/security.py
  37. 1 0
      backend/app/domains/__init__.py
  38. 1 0
      backend/app/domains/agent/__init__.py
  39. 40 0
      backend/app/domains/agent/models.py
  40. 58 0
      backend/app/domains/agent/repository.py
  41. 32 0
      backend/app/domains/agent/runtime.py
  42. 541 0
      backend/app/domains/agent/service.py
  43. 526 0
      backend/app/domains/agent/tools.py
  44. 1 0
      backend/app/domains/attribution/__init__.py
  45. 46 0
      backend/app/domains/attribution/models.py
  46. 73 0
      backend/app/domains/attribution/repository.py
  47. 174 0
      backend/app/domains/attribution/service.py
  48. 1 0
      backend/app/domains/catalog/__init__.py
  49. 59 0
      backend/app/domains/catalog/models.py
  50. 56 0
      backend/app/domains/catalog/repository.py
  51. 608 0
      backend/app/domains/catalog/service.py
  52. 1 0
      backend/app/domains/enrollment/__init__.py
  53. 124 0
      backend/app/domains/enrollment/models.py
  54. 178 0
      backend/app/domains/enrollment/repository.py
  55. 557 0
      backend/app/domains/enrollment/service.py
  56. 83 0
      backend/app/domains/enrollment/tasks.py
  57. 1 0
      backend/app/domains/identity/__init__.py
  58. 49 0
      backend/app/domains/identity/models.py
  59. 115 0
      backend/app/domains/identity/repository.py
  60. 495 0
      backend/app/domains/identity/service.py
  61. 6 0
      backend/app/harness/__init__.py
  62. 13 0
      backend/app/harness/assets/prompts/customer.md
  63. 28 0
      backend/app/harness/assets/prompts/operation.md
  64. 10 0
      backend/app/harness/assets/skills/enrollment-guidance/SKILL.md
  65. 10 0
      backend/app/harness/assets/skills/insurance-consultation/SKILL.md
  66. 10 0
      backend/app/harness/assets/skills/operation-insights/SKILL.md
  67. 31 0
      backend/app/harness/events.py
  68. 245 0
      backend/app/harness/kernel.py
  69. 31 0
      backend/app/harness/model_gateway.py
  70. 66 0
      backend/app/harness/policy.py
  71. 155 0
      backend/app/harness/registries.py
  72. 138 0
      backend/app/harness/schemas.py
  73. 127 0
      backend/app/harness/tooling.py
  74. 1 0
      backend/app/infrastructure/__init__.py
  75. 1 0
      backend/app/infrastructure/mysql/__init__.py
  76. 68 0
      backend/app/infrastructure/mysql/agent_models.py
  77. 157 0
      backend/app/infrastructure/mysql/agent_repositories.py
  78. 85 0
      backend/app/infrastructure/mysql/analytics_models.py
  79. 173 0
      backend/app/infrastructure/mysql/attribution_repositories.py
  80. 519 0
      backend/app/infrastructure/mysql/core_models.py
  81. 396 0
      backend/app/infrastructure/mysql/enrollment_repositories.py
  82. 367 0
      backend/app/infrastructure/mysql/repositories.py
  83. 60 0
      backend/app/infrastructure/mysql/sessions.py
  84. 40 0
      backend/app/infrastructure/mysql/types.py
  85. 1 0
      backend/app/infrastructure/redis/__init__.py
  86. 137 0
      backend/app/infrastructure/redis/agent_state.py
  87. 50 0
      backend/app/infrastructure/redis/keys.py
  88. 292 0
      backend/app/main.py
  89. 3 0
      backend/evals/customer-full.jsonl
  90. 1 0
      backend/evals/customer-smoke.jsonl
  91. 3 0
      backend/evals/operation-full.jsonl
  92. 1 0
      backend/evals/operation-smoke.jsonl
  93. 12 0
      backend/migrations/README.md
  94. 40 0
      backend/migrations/agent/env.py
  95. 20 0
      backend/migrations/agent/script.py.mako
  96. 26 0
      backend/migrations/agent/versions/agent_0001_initial.py
  97. 40 0
      backend/migrations/analytics/env.py
  98. 20 0
      backend/migrations/analytics/script.py.mako
  99. 34 0
      backend/migrations/analytics/versions/analytics_0001_initial.py
  100. 41 0
      backend/migrations/core/env.py

+ 15 - 0
.gitignore

@@ -0,0 +1,15 @@
+.env
+.venv/
+__pycache__/
+*.py[cod]
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+.coverage
+htmlcov/
+dist/
+node_modules/
+.pnpm-store/
+frontend/*/dist/
+backend/.env
+*.log

+ 5 - 0
.idea/.gitignore

@@ -0,0 +1,5 @@
+# 默认忽略的文件
+/shelf/
+/workspace.xml
+# 基于编辑器的 HTTP 客户端请求
+/httpRequests/

+ 1 - 0
.python-version

@@ -0,0 +1 @@
+3.12

+ 5 - 0
backend/.idea/.gitignore

@@ -0,0 +1,5 @@
+# 默认忽略的文件
+/shelf/
+/workspace.xml
+# 基于编辑器的 HTTP 客户端请求
+/httpRequests/

+ 17 - 0
backend/.idea/backend.iml

@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<module type="PYTHON_MODULE" version="4">
+  <component name="NewModuleRootManager">
+    <content url="file://$MODULE_DIR$">
+      <excludeFolder url="file://$MODULE_DIR$/.venv" />
+    </content>
+    <orderEntry type="jdk" jdkName="uv (backend)" jdkType="Python SDK" />
+    <orderEntry type="sourceFolder" forTests="false" />
+  </component>
+  <component name="PyDocumentationSettings">
+    <option name="format" value="PLAIN" />
+    <option name="myDocStringFormat" value="Plain" />
+  </component>
+  <component name="TestRunnerService">
+    <option name="PROJECT_TEST_RUNNER" value="py.test" />
+  </component>
+</module>

+ 6 - 0
backend/.idea/inspectionProfiles/profiles_settings.xml

@@ -0,0 +1,6 @@
+<component name="InspectionProjectProfileManager">
+  <settings>
+    <option name="USE_PROJECT_PROFILE" value="false" />
+    <version value="1.0" />
+  </settings>
+</component>

+ 6 - 0
backend/.idea/misc.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="Black">
+    <option name="sdkName" value="uv (backend)" />
+  </component>
+</project>

+ 8 - 0
backend/.idea/modules.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ProjectModuleManager">
+    <modules>
+      <module fileurl="file://$PROJECT_DIR$/.idea/backend.iml" filepath="$PROJECT_DIR$/.idea/backend.iml" />
+    </modules>
+  </component>
+</project>

+ 144 - 0
backend/README.md

@@ -0,0 +1,144 @@
+# 智保通后端
+
+本目录是智保通的统一后端,使用 Python 3.12 和 FastAPI 开发,负责 API、
+业务规则、身份认证及数据库访问。
+
+## 技术栈
+
+- Python 3.12
+- FastAPI、Pydantic
+- SQLAlchemy、Alembic
+- LangChain 1.x、DeepSeek、LangSmith
+- MySQL 8、Redis
+- uv(依赖与虚拟环境管理)
+- pytest、Ruff、mypy(测试与代码检查)
+
+项目使用 `pyproject.toml` 和 `uv.lock` 管理依赖,不使用
+`requirements.txt`、Poetry 或 Conda。
+
+## 目录结构
+
+```text
+backend/
+├── app/
+│   ├── api/               # HTTP 路由、请求参数和响应格式
+│   ├── commands/          # 初始化数据等命令行工具
+│   ├── core/              # 配置、安全、密码和公共错误
+│   ├── domains/           # 业务模型、仓储接口和业务服务
+│   ├── infrastructure/    # MySQL、Redis 等基础设施实现
+│   └── main.py            # FastAPI 应用入口
+├── migrations/            # 三个数据库的 Alembic 迁移
+├── tests/                 # 自动化测试
+├── alembic.ini
+├── pyproject.toml
+└── uv.lock
+```
+
+代码主要调用方向:
+
+```text
+HTTP 请求 → api 路由 → domain service → repository → MySQL
+```
+
+AI Agent 的主要调用方向:
+
+```text
+Agent API → Agent service → LangChain create_agent
+          → DeepSeek(OpenAI 兼容接口)→ 产品目录 Tool
+          → Agent MySQL 持久化 + LangSmith Trace
+```
+
+## 环境配置
+
+后端读取当前 `backend` 目录中的 `.env`。
+
+首次使用时,在 `backend` 目录创建并配置 `.env`:
+
+```powershell
+notepad .env
+```
+
+常用配置包括:
+
+- `MYSQL_HOST`、`MYSQL_PORT`:MySQL 地址和端口
+- `MYSQL_USER`、`MYSQL_PASSWORD`:后端数据库账号
+- `REDIS_URL`:Redis 连接地址
+- `JWT_ACCESS_SECRET`、`JWT_REFRESH_SECRET`:登录令牌密钥
+- `DEV_FIXED_OTP`:开发环境固定验证码
+- `DEEPSEEK_API_KEY`、`DEEPSEEK_BASE_URL`、`DEEPSEEK_MODEL`:模型配置
+- `LANGSMITH_API_KEY`、`LANGSMITH_ENDPOINT`、`LANGSMITH_PROJECT`、
+  `LANGSMITH_TRACING`:Agent 链路追踪配置
+- `MOCK_PAYMENT_CALLBACK_SECRET`:本地模拟支付回调的 HMAC 签名密钥
+
+不要把包含真实密码和密钥的 `.env` 提交到 Git。
+
+## 安装后端依赖
+
+```powershell
+Set-Location C:\Users\yxl\Desktop\new_harness\01-zhibaotong-smart-enrollment\backend
+uv sync --locked
+```
+
+`uv sync --locked` 会按照 `uv.lock` 创建或更新 `.venv`,确保每台电脑安装
+相同版本的依赖。
+
+## 初始化数据库
+
+请先启动 MySQL 8 和 Redis,然后在项目根目录执行:
+
+```powershell
+.\scripts\check-env.ps1
+.\scripts\init-databases.ps1
+.\scripts\migrate.ps1
+.\scripts\seed.ps1
+.\scripts\build-analytics.ps1
+.\scripts\verify-seed.ps1
+```
+
+这些脚本依次完成环境检查、创建数据库、建表、写入初始数据、构建分析数据
+和验证初始化结果。
+
+## 单独启动后端
+
+```powershell
+Set-Location C:\Users\yxl\Desktop\new_harness\01-zhibaotong-smart-enrollment\backend
+uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
+```
+
+启动后访问:
+
+- API 文档:<http://127.0.0.1:8000/docs>
+- OpenAPI JSON:<http://127.0.0.1:8000/openapi.json>
+- 健康检查:<http://127.0.0.1:8000/api/v1/system/health/live>
+
+按 `Ctrl + C` 停止后端。
+
+## 测试和代码检查
+
+在 `backend` 目录执行:
+
+```powershell
+uv run pytest
+uv run ruff check .
+uv run mypy app
+```
+
+也可以在项目根目录执行 `.\scripts\test.ps1`,同时检查前端和后端。
+
+## 数据库迁移
+
+项目使用三个相互独立的数据库:
+
+- `insurance_s1_core`
+- `insurance_s1_agent`
+- `insurance_s1_analytics`
+
+需要手动执行迁移时,在 `backend` 目录运行:
+
+```powershell
+uv run alembic -c alembic.ini --name alembic_core upgrade head
+uv run alembic -c alembic.ini --name alembic_agent upgrade head
+uv run alembic -c alembic.ini --name alembic_analytics upgrade head
+```
+
+已经发布的迁移文件不要直接修改;表结构发生变化时应新增 revision。

+ 14 - 0
backend/alembic.ini

@@ -0,0 +1,14 @@
+[alembic_core]
+script_location = %(here)s/migrations/core
+prepend_sys_path = .
+path_separator = os
+
+[alembic_agent]
+script_location = %(here)s/migrations/agent
+prepend_sys_path = .
+path_separator = os
+
+[alembic_analytics]
+script_location = %(here)s/migrations/analytics
+prepend_sys_path = .
+path_separator = os

+ 1 - 0
backend/app/__init__.py

@@ -0,0 +1 @@
+"""智保通第一阶段后端。"""

+ 1 - 0
backend/app/api/__init__.py

@@ -0,0 +1 @@
+"""HTTP API。"""

+ 23 - 0
backend/app/api/responses.py

@@ -0,0 +1,23 @@
+"""API 响应格式工具,保证成功响应拥有一致的 JSON 结构。"""
+
+from datetime import UTC, datetime
+from typing import Any
+
+from fastapi import Request
+
+
+def utc_server_time() -> str:
+    """返回毫秒精度的UTC时间,供API包络统一使用。"""
+    return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
+
+
+def success_response(request: Request, data: Any) -> dict[str, Any]:
+    """包装业务数据,并附上当前请求的追踪ID和服务器时间。"""
+    return {
+        "success": True,
+        "data": data,
+        "meta": {
+            "request_id": request.state.request_id,
+            "server_time": utc_server_time(),
+        },
+    }

+ 1 - 0
backend/app/api/v1/__init__.py

@@ -0,0 +1 @@
+"""V1 API。"""

+ 118 - 0
backend/app/api/v1/admin_auth.py

@@ -0,0 +1,118 @@
+"""后台管理员登录和当前用户接口。"""
+
+from typing import Annotated, Any
+
+from fastapi import APIRouter, Depends, Request, Response
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from pydantic import BaseModel, Field
+
+from app.api.responses import success_response
+from app.api.v1.h5_auth import RefreshRequest
+from app.core.config import Settings
+from app.core.errors import AppError
+from app.domains.identity.models import AdminUser
+from app.domains.identity.service import AdminAuthService
+
+admin_bearer_scheme = HTTPBearer(auto_error=False)
+ADMIN_REFRESH_COOKIE = "zbt_admin_refresh"
+ADMIN_REFRESH_COOKIE_PATH = "/api/v1/admin/auth"
+
+
+class AdminLoginRequest(BaseModel):
+    """管理员登录请求体;Field 同时生成接口文档并执行长度校验。"""
+
+    username: str = Field(min_length=3, max_length=64)
+    password: str = Field(min_length=8, max_length=128)
+
+
+def create_admin_router(auth_service: AdminAuthService, settings: Settings) -> APIRouter:
+    """创建后台认证路由。"""
+    router = APIRouter(tags=["admin-auth"])
+
+    def current_admin(
+        credentials: Annotated[
+            HTTPAuthorizationCredentials | None,
+            Depends(admin_bearer_scheme),
+        ],
+    ) -> AdminUser:
+        # 该依赖校验 Bearer Token,成功后把管理员对象传给具体接口。
+        if credentials is None:
+            raise AppError("AUTH_REQUIRED", "请先登录后台", 401)
+        return auth_service.authenticate_access(credentials.credentials)
+
+    def set_refresh_cookie(response: Response, refresh_token: str) -> None:
+        response.set_cookie(
+            key=ADMIN_REFRESH_COOKIE,
+            value=refresh_token,
+            max_age=settings.refresh_token_days * 24 * 60 * 60,
+            httponly=True,
+            secure=settings.app_env == "production",
+            samesite="lax",
+            path=ADMIN_REFRESH_COOKIE_PATH,
+        )
+
+    @router.post("/admin/auth/login")
+    def login(
+        payload: AdminLoginRequest,
+        request: Request,
+        response: Response,
+    ) -> dict[str, Any]:
+        result = auth_service.login(payload.username, payload.password)
+        set_refresh_cookie(response, result["tokens"]["refresh_token"])
+        return success_response(request, result)
+
+    @router.post("/admin/auth/refresh")
+    def refresh(
+        request: Request,
+        response: Response,
+        payload: RefreshRequest | None = None,
+    ) -> dict[str, Any]:
+        refresh_token = (
+            payload.refresh_token
+            if payload is not None
+            else request.cookies.get(ADMIN_REFRESH_COOKIE)
+        )
+        if refresh_token is None:
+            raise AppError("AUTH_REQUIRED", "缺少刷新凭证", 401)
+        result = auth_service.refresh(refresh_token)
+        set_refresh_cookie(response, result["tokens"]["refresh_token"])
+        return success_response(request, result)
+
+    @router.post("/admin/auth/logout")
+    def logout(
+        request: Request,
+        response: Response,
+        user: Annotated[AdminUser, Depends(current_admin)],
+        credentials: Annotated[
+            HTTPAuthorizationCredentials,
+            Depends(admin_bearer_scheme),
+        ],
+    ) -> dict[str, Any]:
+        del user
+        auth_service.logout(credentials.credentials)
+        response.delete_cookie(
+            key=ADMIN_REFRESH_COOKIE,
+            path=ADMIN_REFRESH_COOKIE_PATH,
+            samesite="lax",
+        )
+        return success_response(request, {"logged_out": True})
+
+    @router.get("/admin/me")
+    def me(
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        return success_response(
+            request,
+            {
+                "id": user.id,
+                "username": user.username,
+                "display_name": user.display_name,
+                "status": user.status,
+                "roles": sorted(user.roles),
+                "permissions": sorted(user.permissions),
+                "data_scope": user.data_scope,
+            },
+        )
+
+    return router

+ 553 - 0
backend/app/api/v1/admin_business.py

@@ -0,0 +1,553 @@
+from datetime import datetime
+from typing import Annotated, Any
+
+from fastapi import APIRouter, Depends, Request
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from pydantic import BaseModel, Field
+
+from app.api.responses import success_response
+from app.core.errors import AppError
+from app.domains.attribution.service import AttributionService
+from app.domains.catalog.service import ProductCatalogService
+from app.domains.enrollment.service import EnrollmentService
+from app.domains.identity.models import AdminUser
+from app.domains.identity.service import AdminAuthService
+
+admin_business_bearer = HTTPBearer(auto_error=False)
+
+
+class RoleUpdateRequest(BaseModel):
+    permissions: list[str] = Field(default_factory=list, max_length=50)
+    data_scope: str = Field(min_length=2, max_length=32)
+
+
+class AdminUserCreateRequest(BaseModel):
+    username: str = Field(min_length=3, max_length=32)
+    password: str = Field(min_length=8, max_length=128)
+    display_name: str = Field(min_length=1, max_length=64)
+    role_code: str = Field(min_length=2, max_length=64)
+
+
+class AdminUserUpdateRequest(BaseModel):
+    display_name: str = Field(min_length=1, max_length=64)
+    status: str = Field(min_length=2, max_length=32)
+    role_code: str = Field(min_length=2, max_length=64)
+
+
+class PasswordResetRequest(BaseModel):
+    new_password: str = Field(min_length=8, max_length=128)
+
+
+class UserStatusRequest(BaseModel):
+    status: str = Field(min_length=2, max_length=32)
+
+
+class ProductStatusRequest(BaseModel):
+    status: str = Field(pattern="^(ACTIVE|INACTIVE)$")
+
+
+class ProductUpdateRequest(BaseModel):
+    name: str = Field(min_length=1, max_length=128)
+    category: str = Field(pattern="^(MEDICAL|ACCIDENT)$")
+    summary: str = Field(min_length=1, max_length=500)
+
+
+class ProductVersionCreateRequest(BaseModel):
+    version_no: str = Field(min_length=1, max_length=32)
+    effective_from: datetime
+    source_version_id: str | None = Field(default=None, max_length=26)
+
+
+class ProductPlanRequest(BaseModel):
+    id: str | None = Field(default=None, max_length=26)
+    code: str = Field(min_length=1, max_length=32)
+    name: str = Field(min_length=1, max_length=128)
+    summary: str = Field(default="", max_length=500)
+    status: str = Field(pattern="^(ACTIVE|INACTIVE)$")
+    premium_cents: int = Field(ge=0, le=100_000_000)
+    coverage_amount_cents: int = Field(ge=0, le=100_000_000_000)
+    min_age: int = Field(ge=0, le=120)
+    max_age: int = Field(ge=0, le=120)
+
+
+class ProductCreateRequest(BaseModel):
+    product_code: str = Field(min_length=3, max_length=32)
+    name: str = Field(min_length=1, max_length=128)
+    category: str = Field(pattern="^(MEDICAL|ACCIDENT)$")
+    summary: str = Field(min_length=1, max_length=500)
+    version_no: str = Field(default="1.0.0", min_length=1, max_length=32)
+    effective_from: datetime
+    rule_version: str = Field(default="eligibility-v1", min_length=1, max_length=32)
+    rate_version: str = Field(default="rate-v1", min_length=1, max_length=32)
+    terms_summary: str = Field(default="", max_length=5000)
+    plans: list[ProductPlanRequest] = Field(min_length=1, max_length=12)
+
+
+class ProductVersionUpdateRequest(BaseModel):
+    effective_from: datetime
+    effective_to: datetime | None = None
+    rule_version: str = Field(min_length=1, max_length=32)
+    rate_version: str = Field(min_length=1, max_length=32)
+    terms_summary: str = Field(default="", max_length=5000)
+    plans: list[ProductPlanRequest] = Field(min_length=1, max_length=12)
+
+
+def create_admin_business_router(
+    service: EnrollmentService,
+    auth_service: AdminAuthService,
+    catalog_service: ProductCatalogService,
+    attribution_service: AttributionService,
+) -> APIRouter:
+    router = APIRouter(tags=["admin-business"])
+
+    def current_admin(
+        credentials: Annotated[
+            HTTPAuthorizationCredentials | None,
+            Depends(admin_business_bearer),
+        ],
+    ) -> AdminUser:
+        if credentials is None:
+            raise AppError("AUTH_REQUIRED", "请先登录后台", 401)
+        return auth_service.authenticate_access(credentials.credentials)
+
+    def require_read(user: AdminUser, permission: str) -> None:
+        if "*" not in user.permissions and permission not in user.permissions:
+            raise AppError("PERMISSION_DENIED", "当前角色无权访问该数据", 403)
+
+    def scoped_orders(user: AdminUser) -> dict[str, Any]:
+        result = service.list_orders()
+        if user.data_scope == "SELF":
+            allowed = attribution_service.order_ids(user.id)
+            items = [item for item in result["items"] if item["order_id"] in allowed]
+            result = {"items": items, "total": len(items)}
+        if user.data_scope != "ALL":
+            result = {
+                "items": [_mask_order(item) for item in result["items"]],
+                "total": result["total"],
+            }
+        return result
+
+    def scoped_policies(user: AdminUser) -> dict[str, Any]:
+        result = service.list_all_policies()
+        if user.data_scope == "SELF":
+            allowed = attribution_service.order_ids(user.id)
+            items = [item for item in result["items"] if item["order_id"] in allowed]
+            result = {"items": items, "total": len(items)}
+        if user.data_scope != "ALL":
+            result = {
+                "items": [_mask_policy(item) for item in result["items"]],
+                "total": result["total"],
+            }
+        return result
+
+    @router.get("/admin/dashboard")
+    def dashboard(
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_read(user, "dashboard:read")
+        orders = scoped_orders(user)
+        policies = scoped_policies(user)
+        active_policies = [item for item in policies["items"] if item["status"] == "ACTIVE"]
+        return success_response(
+            request,
+            {
+                "order_count": orders["total"],
+                "policy_count": len(active_policies),
+                "premium_cents": sum(int(item["premium_cents"]) for item in active_policies),
+            },
+        )
+
+    @router.get("/admin/orders")
+    def orders(
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_read(user, "order:read")
+        return success_response(request, scoped_orders(user))
+
+    @router.get("/admin/orders/{order_id}")
+    def order_detail(
+        order_id: str,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_read(user, "order:read")
+        item = next(
+            (order for order in scoped_orders(user)["items"] if order["order_id"] == order_id),
+            None,
+        )
+        if item is None:
+            raise AppError("ORDER_NOT_FOUND", "未找到订单或无权查看", 404)
+        return success_response(request, item)
+
+    @router.get("/admin/policies")
+    def policies(
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_read(user, "policy:read")
+        return success_response(request, scoped_policies(user))
+
+    @router.get("/admin/policies/{policy_id}")
+    def policy_detail(
+        policy_id: str,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_read(user, "policy:read")
+        item = next(
+            (
+                policy
+                for policy in scoped_policies(user)["items"]
+                if policy["policy_id"] == policy_id
+            ),
+            None,
+        )
+        if item is None:
+            raise AppError("POLICY_NOT_FOUND", "未找到保单或无权查看", 404)
+        return success_response(request, item)
+
+    @router.get("/admin/products")
+    def products(
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_read(user, "product:read")
+        return success_response(request, catalog_service.list_managed())
+
+    @router.post("/admin/products", status_code=201)
+    def create_product(
+        payload: ProductCreateRequest,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_read(user, "product:write")
+        return success_response(
+            request,
+            catalog_service.create_product(
+                product_code=payload.product_code,
+                name=payload.name,
+                category=payload.category,
+                summary=payload.summary,
+                version_no=payload.version_no,
+                effective_from=payload.effective_from,
+                rule_version=payload.rule_version,
+                rate_version=payload.rate_version,
+                terms_summary=payload.terms_summary,
+                plans=[plan.model_dump() for plan in payload.plans],
+                actor_id=user.id,
+                actor_name=user.display_name,
+            ),
+        )
+
+    @router.put("/admin/products/{product_id}/status")
+    def update_product_status(
+        product_id: str,
+        payload: ProductStatusRequest,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_read(user, "product:write")
+        return success_response(
+            request,
+            catalog_service.update_product_status(
+                product_id,
+                payload.status,
+                actor_id=user.id,
+                actor_name=user.display_name,
+            ),
+        )
+
+    @router.put("/admin/products/{product_id}")
+    def update_product(
+        product_id: str,
+        payload: ProductUpdateRequest,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_read(user, "product:write")
+        return success_response(
+            request,
+            catalog_service.update_product(
+                product_id,
+                name=payload.name,
+                category=payload.category,
+                summary=payload.summary,
+                actor_id=user.id,
+                actor_name=user.display_name,
+            ),
+        )
+
+    @router.post("/admin/products/{product_id}/versions", status_code=201)
+    def create_product_version(
+        product_id: str,
+        payload: ProductVersionCreateRequest,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_read(user, "product:write")
+        return success_response(
+            request,
+            catalog_service.create_draft_version(
+                product_id,
+                version_no=payload.version_no,
+                effective_from=payload.effective_from,
+                source_version_id=payload.source_version_id,
+                actor_id=user.id,
+                actor_name=user.display_name,
+            ),
+        )
+
+    @router.put("/admin/products/{product_id}/versions/{version_id}")
+    def update_product_version(
+        product_id: str,
+        version_id: str,
+        payload: ProductVersionUpdateRequest,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_read(user, "product:write")
+        return success_response(
+            request,
+            catalog_service.update_draft_version(
+                product_id,
+                version_id,
+                effective_from=payload.effective_from,
+                effective_to=payload.effective_to,
+                rule_version=payload.rule_version,
+                rate_version=payload.rate_version,
+                terms_summary=payload.terms_summary,
+                plans=[plan.model_dump() for plan in payload.plans],
+                actor_id=user.id,
+                actor_name=user.display_name,
+            ),
+        )
+
+    @router.post("/admin/products/{product_id}/versions/{version_id}/publish")
+    def publish_product_version(
+        product_id: str,
+        version_id: str,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_read(user, "product:write")
+        return success_response(
+            request,
+            catalog_service.publish_version(
+                product_id,
+                version_id,
+                actor_id=user.id,
+                actor_name=user.display_name,
+            ),
+        )
+
+    @router.post("/admin/products/{product_id}/versions/{version_id}/retire")
+    def retire_product_version(
+        product_id: str,
+        version_id: str,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_read(user, "product:write")
+        return success_response(
+            request,
+            catalog_service.retire_version(
+                product_id,
+                version_id,
+                actor_id=user.id,
+                actor_name=user.display_name,
+            ),
+        )
+
+    @router.delete("/admin/products/{product_id}/versions/{version_id}")
+    def delete_product_version(
+        product_id: str,
+        version_id: str,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_read(user, "product:write")
+        return success_response(
+            request,
+            catalog_service.delete_draft_version(
+                product_id,
+                version_id,
+                actor_id=user.id,
+                actor_name=user.display_name,
+            ),
+        )
+
+    @router.get("/admin/products/{product_id}/logs")
+    def product_change_logs(
+        product_id: str,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_read(user, "product:read")
+        return success_response(
+            request,
+            catalog_service.list_change_logs(product_id),
+        )
+
+    @router.get("/admin/attribution/overview")
+    def attribution_overview(
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_read(user, "attribution:read")
+        owner_id = user.id if user.data_scope == "SELF" else None
+        return success_response(
+            request,
+            attribution_service.performance(owner_id),
+        )
+
+    @router.get("/admin/attribution/codes")
+    def attribution_codes(
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_read(user, "attribution:read")
+        owner_id = user.id if user.data_scope == "SELF" else None
+        return success_response(
+            request,
+            attribution_service.list_codes(owner_id),
+        )
+
+    @router.get("/admin/roles")
+    def roles(
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_read(user, "role:read")
+        return success_response(request, auth_service.list_roles())
+
+    @router.put("/admin/roles/{role_code}")
+    def update_role(
+        role_code: str,
+        payload: RoleUpdateRequest,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_read(user, "role:write")
+        return success_response(
+            request,
+            auth_service.update_role(
+                role_code,
+                permissions=payload.permissions,
+                data_scope=payload.data_scope,
+            ),
+        )
+
+    @router.get("/admin/users")
+    def managed_users(
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_read(user, "user:read")
+        return success_response(request, auth_service.list_managed_users())
+
+    @router.post("/admin/users/admins", status_code=201)
+    def create_admin_user(
+        payload: AdminUserCreateRequest,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_read(user, "user:write")
+        return success_response(
+            request,
+            auth_service.create_admin_user(
+                username=payload.username,
+                password=payload.password,
+                display_name=payload.display_name,
+                role_code=payload.role_code,
+            ),
+        )
+
+    @router.put("/admin/users/admins/{user_id}")
+    def update_admin_user(
+        user_id: str,
+        payload: AdminUserUpdateRequest,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_read(user, "user:write")
+        return success_response(
+            request,
+            auth_service.update_admin_user(
+                user,
+                user_id,
+                display_name=payload.display_name,
+                status=payload.status,
+                role_code=payload.role_code,
+            ),
+        )
+
+    @router.post("/admin/users/admins/{user_id}/password")
+    def reset_admin_password(
+        user_id: str,
+        payload: PasswordResetRequest,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_read(user, "user:write")
+        return success_response(
+            request,
+            auth_service.reset_admin_password(
+                user_id,
+                new_password=payload.new_password,
+            ),
+        )
+
+    @router.put("/admin/users/customers/{user_id}/status")
+    def update_customer_status(
+        user_id: str,
+        payload: UserStatusRequest,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_read(user, "user:write")
+        return success_response(
+            request,
+            auth_service.update_customer_status(
+                user_id,
+                status=payload.status,
+            ),
+        )
+
+    return router
+
+
+def _mask_secret(value: str | None) -> str:
+    if not value:
+        return ""
+    if len(value) == 11:
+        return f"{value[:3]}****{value[-4:]}"
+    if len(value) >= 10:
+        return f"{value[:6]}********{value[-4:]}"
+    return value
+
+
+def _mask_person(person: dict[str, Any]) -> dict[str, Any]:
+    masked = dict(person)
+    if "id_no" in masked:
+        masked["id_no"] = _mask_secret(str(masked["id_no"]))
+    return masked
+
+
+def _mask_order(item: dict[str, Any]) -> dict[str, Any]:
+    masked = dict(item)
+    masked["applicant"] = _mask_person(dict(item.get("applicant", {})))
+    masked["insured"] = _mask_person(dict(item.get("insured", {})))
+    contact = dict(item.get("contact", {}))
+    if "mobile" in contact:
+        contact["mobile"] = _mask_secret(str(contact["mobile"]))
+    masked["contact"] = contact
+    return masked
+
+
+def _mask_policy(item: dict[str, Any]) -> dict[str, Any]:
+    masked = dict(item)
+    masked["applicant"] = _mask_person(dict(item.get("applicant", {})))
+    masked["insured"] = _mask_person(dict(item.get("insured", {})))
+    return masked

+ 209 - 0
backend/app/api/v1/agent.py

@@ -0,0 +1,209 @@
+from typing import Annotated, Any
+
+from fastapi import APIRouter, Depends, Request, status
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from pydantic import BaseModel, Field
+from starlette.responses import StreamingResponse
+
+from app.api.responses import success_response
+from app.core.errors import AppError
+from app.domains.agent.service import AgentThreadService
+from app.domains.identity.models import AdminUser, H5User
+from app.domains.identity.service import AdminAuthService, H5AuthService
+
+agent_bearer_scheme = HTTPBearer(auto_error=False)
+
+
+class CreateThreadRequest(BaseModel):
+    title: str | None = Field(default=None, max_length=128)
+
+
+class TextContent(BaseModel):
+    type: str = Field(pattern="^text$")
+    text: str = Field(min_length=1, max_length=4000)
+
+
+class SendMessageRequest(BaseModel):
+    content: TextContent
+    page_context: dict[str, Any] | None = None
+    client_message_id: str = Field(min_length=1, max_length=64)
+
+
+def create_agent_router(
+    thread_service: AgentThreadService,
+    h5_auth_service: H5AuthService,
+    admin_auth_service: AdminAuthService,
+) -> APIRouter:
+    router = APIRouter(tags=["agent"])
+
+    def current_h5_user(
+        credentials: Annotated[
+            HTTPAuthorizationCredentials | None,
+            Depends(agent_bearer_scheme),
+        ],
+    ) -> H5User:
+        if credentials is None:
+            raise AppError("AUTH_REQUIRED", "请先登录", 401)
+        return h5_auth_service.authenticate_access(credentials.credentials)
+
+    def current_admin(
+        credentials: Annotated[
+            HTTPAuthorizationCredentials | None,
+            Depends(agent_bearer_scheme),
+        ],
+    ) -> AdminUser:
+        if credentials is None:
+            raise AppError("AUTH_REQUIRED", "请先登录后台", 401)
+        return admin_auth_service.authenticate_access(credentials.credentials)
+
+    @router.post("/agent/threads", status_code=status.HTTP_201_CREATED)
+    def create_customer_thread(
+        payload: CreateThreadRequest,
+        request: Request,
+        user: Annotated[H5User, Depends(current_h5_user)],
+    ) -> dict[str, Any]:
+        return success_response(
+            request,
+            thread_service.create_customer_thread(user, title=payload.title),
+        )
+
+    @router.post(
+        "/agent/threads/{thread_id}/messages",
+        status_code=status.HTTP_202_ACCEPTED,
+    )
+    def send_customer_message(
+        thread_id: str,
+        payload: SendMessageRequest,
+        request: Request,
+        user: Annotated[H5User, Depends(current_h5_user)],
+    ) -> dict[str, Any]:
+        return success_response(
+            request,
+            thread_service.send_customer_message(
+                user,
+                thread_id=thread_id,
+                text=payload.content.text,
+            ),
+        )
+
+    @router.post("/agent/threads/{thread_id}/messages/stream")
+    def stream_customer_message(
+        thread_id: str,
+        payload: SendMessageRequest,
+        user: Annotated[H5User, Depends(current_h5_user)],
+    ) -> StreamingResponse:
+        events = thread_service.stream_customer_message(
+            user,
+            thread_id=thread_id,
+            text=payload.content.text,
+        )
+        return StreamingResponse(
+            (event.to_sse() for event in events),
+            media_type="text/event-stream",
+            headers={
+                "Cache-Control": "no-cache",
+                "X-Accel-Buffering": "no",
+            },
+        )
+
+    @router.get("/agent/threads/{thread_id}/messages")
+    def list_customer_messages(
+        thread_id: str,
+        request: Request,
+        user: Annotated[H5User, Depends(current_h5_user)],
+    ) -> dict[str, Any]:
+        return success_response(
+            request,
+            thread_service.list_customer_messages(user, thread_id=thread_id),
+        )
+
+    @router.get("/agent/runs/{run_id}")
+    def get_customer_run(
+        run_id: str,
+        request: Request,
+        user: Annotated[H5User, Depends(current_h5_user)],
+    ) -> dict[str, Any]:
+        return success_response(
+            request,
+            thread_service.get_customer_run(user, run_id=run_id),
+        )
+
+    @router.get("/agent/runs/{run_id}/events")
+    def replay_customer_run_events(
+        run_id: str,
+        user: Annotated[H5User, Depends(current_h5_user)],
+    ) -> StreamingResponse:
+        events = thread_service.list_customer_run_events(user, run_id=run_id)
+        return StreamingResponse(
+            (event.to_sse() for event in events),
+            media_type="text/event-stream",
+            headers={"Cache-Control": "no-cache"},
+        )
+
+    @router.post(
+        "/admin/agent/threads",
+        status_code=status.HTTP_201_CREATED,
+    )
+    def create_operation_thread(
+        payload: CreateThreadRequest,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        return success_response(
+            request,
+            thread_service.create_operation_thread(user, title=payload.title),
+        )
+
+    @router.post(
+        "/admin/agent/threads/{thread_id}/messages",
+        status_code=status.HTTP_202_ACCEPTED,
+    )
+    def send_operation_message(
+        thread_id: str,
+        payload: SendMessageRequest,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        return success_response(
+            request,
+            thread_service.send_operation_message(
+                user,
+                thread_id=thread_id,
+                text=payload.content.text,
+            ),
+        )
+
+    @router.get("/admin/agent/threads/{thread_id}/messages")
+    def list_operation_messages(
+        thread_id: str,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        return success_response(
+            request,
+            thread_service.list_operation_messages(user, thread_id=thread_id),
+        )
+
+    @router.get("/admin/agent/runs/{run_id}")
+    def get_operation_run(
+        run_id: str,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        return success_response(
+            request,
+            thread_service.get_operation_run(user, run_id=run_id),
+        )
+
+    @router.get("/admin/agent/runs")
+    def list_operation_runs(
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+        limit: int = 20,
+    ) -> dict[str, Any]:
+        return success_response(
+            request,
+            thread_service.list_operation_runs(user, limit=min(max(limit, 1), 100)),
+        )
+
+    return router

+ 207 - 0
backend/app/api/v1/enrollment.py

@@ -0,0 +1,207 @@
+from typing import Annotated, Any
+
+from fastapi import APIRouter, Depends, Header, Request, Response, status
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from pydantic import BaseModel, Field
+
+from app.api.responses import success_response
+from app.core.config import Settings
+from app.core.errors import AppError
+from app.domains.enrollment.service import EnrollmentService
+from app.domains.identity.models import H5User
+from app.domains.identity.service import H5AuthService
+
+enrollment_bearer_scheme = HTTPBearer(auto_error=False)
+
+
+class InsuredInput(BaseModel):
+    age: int = Field(ge=0, le=120)
+    region_code: str = Field(min_length=6, max_length=12)
+    occupation_code: str = Field(min_length=1, max_length=32)
+
+
+class CreateQuoteRequest(BaseModel):
+    product_id: str = Field(min_length=26, max_length=26)
+    plan_id: str = Field(min_length=26, max_length=26)
+    insured: InsuredInput
+    relationship: str = Field(pattern="^(SELF|PARENT|SPOUSE|CHILD)$")
+
+
+class PersonInput(BaseModel):
+    name: str = Field(min_length=1, max_length=64)
+    id_no: str = Field(min_length=6, max_length=32)
+
+
+class ContactInput(BaseModel):
+    mobile: str = Field(min_length=11, max_length=11)
+
+
+class CreateDraftRequest(BaseModel):
+    quote_id: str = Field(min_length=26, max_length=26)
+    applicant: PersonInput
+    insured: PersonInput
+    contact: ContactInput
+
+
+class CreateOrderRequest(BaseModel):
+    draft_id: str = Field(min_length=26, max_length=26)
+    confirmation_token: str = Field(min_length=32, max_length=128)
+
+
+class MockPaymentCallbackRequest(BaseModel):
+    callback_no: str = Field(min_length=1, max_length=64)
+    provider_transaction_no: str = Field(min_length=1, max_length=64)
+    payment_no: str = Field(min_length=1, max_length=40)
+    status: str = Field(pattern="^(SUCCEEDED|FAILED)$")
+    amount_cents: int = Field(gt=0)
+    occurred_at: str = Field(min_length=10, max_length=64)
+
+
+def create_enrollment_router(
+    service: EnrollmentService,
+    h5_auth_service: H5AuthService,
+    settings: Settings,
+) -> APIRouter:
+    router = APIRouter(tags=["enrollment"])
+
+    def current_h5_user(
+        credentials: Annotated[
+            HTTPAuthorizationCredentials | None,
+            Depends(enrollment_bearer_scheme),
+        ],
+    ) -> H5User:
+        if credentials is None:
+            raise AppError("AUTH_REQUIRED", "请先登录", 401)
+        return h5_auth_service.authenticate_access(credentials.credentials)
+
+    @router.post("/h5/quotes", status_code=status.HTTP_201_CREATED)
+    def create_quote(
+        payload: CreateQuoteRequest,
+        request: Request,
+        user: Annotated[H5User, Depends(current_h5_user)],
+    ) -> dict[str, Any]:
+        return success_response(
+            request,
+            service.create_quote(
+                user,
+                product_id=payload.product_id,
+                plan_id=payload.plan_id,
+                age=payload.insured.age,
+                region_code=payload.insured.region_code,
+                occupation_code=payload.insured.occupation_code,
+                relationship=payload.relationship,
+            ),
+        )
+
+    @router.post("/h5/enrollment-drafts", status_code=status.HTTP_201_CREATED)
+    def create_draft(
+        payload: CreateDraftRequest,
+        request: Request,
+        user: Annotated[H5User, Depends(current_h5_user)],
+    ) -> dict[str, Any]:
+        return success_response(
+            request,
+            service.create_draft(
+                user,
+                quote_id=payload.quote_id,
+                applicant=payload.applicant.model_dump(),
+                insured=payload.insured.model_dump(),
+                contact=payload.contact.model_dump(),
+            ),
+        )
+
+    @router.post(
+        "/h5/enrollment-drafts/{draft_id}/confirmation",
+        status_code=status.HTTP_201_CREATED,
+    )
+    def confirm_draft(
+        draft_id: str,
+        request: Request,
+        user: Annotated[H5User, Depends(current_h5_user)],
+    ) -> dict[str, Any]:
+        return success_response(request, service.confirm_draft(user, draft_id))
+
+    @router.post("/h5/orders", status_code=status.HTTP_201_CREATED)
+    def create_order(
+        payload: CreateOrderRequest,
+        request: Request,
+        response: Response,
+        user: Annotated[H5User, Depends(current_h5_user)],
+        idempotency_key: Annotated[
+            str,
+            Header(alias="Idempotency-Key", min_length=1, max_length=64),
+        ],
+    ) -> dict[str, Any]:
+        data, created = service.create_order(
+            user,
+            draft_id=payload.draft_id,
+            confirmation_token=payload.confirmation_token,
+            idempotency_key=idempotency_key,
+        )
+        response.status_code = status.HTTP_201_CREATED if created else status.HTTP_200_OK
+        return success_response(request, data)
+
+    @router.post(
+        "/h5/orders/{order_id}/payments",
+        status_code=status.HTTP_201_CREATED,
+    )
+    def create_payment(
+        order_id: str,
+        request: Request,
+        response: Response,
+        user: Annotated[H5User, Depends(current_h5_user)],
+        idempotency_key: Annotated[
+            str,
+            Header(alias="Idempotency-Key", min_length=1, max_length=64),
+        ],
+    ) -> dict[str, Any]:
+        data, created = service.create_payment(
+            user,
+            order_id=order_id,
+            idempotency_key=idempotency_key,
+        )
+        response.status_code = status.HTTP_201_CREATED if created else status.HTTP_200_OK
+        return success_response(request, data)
+
+    @router.post("/dev/mock-payments/{payment_id}/complete")
+    def complete_mock_payment(
+        payment_id: str,
+        request: Request,
+        user: Annotated[H5User, Depends(current_h5_user)],
+    ) -> dict[str, Any]:
+        if not settings.dev_tools_enabled or settings.app_env == "production":
+            raise AppError("DEV_TOOL_DISABLED", "本地模拟支付未启用", 404)
+        return success_response(
+            request,
+            service.complete_mock_payment(user, payment_id=payment_id),
+        )
+
+    @router.post("/callbacks/mock-payment")
+    def mock_payment_callback(
+        payload: MockPaymentCallbackRequest,
+        request: Request,
+        signature: Annotated[
+            str,
+            Header(alias="X-Mock-Pay-Signature", min_length=1, max_length=128),
+        ],
+    ) -> dict[str, Any]:
+        return success_response(
+            request,
+            service.handle_mock_callback(payload.model_dump(), signature),
+        )
+
+    @router.get("/h5/policies")
+    def list_policies(
+        request: Request,
+        user: Annotated[H5User, Depends(current_h5_user)],
+    ) -> dict[str, Any]:
+        return success_response(request, service.list_policies(user))
+
+    @router.get("/h5/orders")
+    def list_orders(
+        request: Request,
+        user: Annotated[H5User, Depends(current_h5_user)],
+    ) -> dict[str, Any]:
+        return success_response(request, service.list_orders(user.id))
+
+    return router

+ 140 - 0
backend/app/api/v1/h5_auth.py

@@ -0,0 +1,140 @@
+"""H5 登录相关 HTTP 接口。
+
+Pydantic 模型负责校验请求体,FastAPI 的 Depends 负责在接口执行前完成身份验证。
+"""
+
+from typing import Annotated, Any
+
+from fastapi import APIRouter, Depends, Request, Response
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from pydantic import BaseModel, Field
+
+from app.api.responses import success_response
+from app.core.config import Settings
+from app.core.errors import AppError
+from app.domains.attribution.service import AttributionService
+from app.domains.identity.models import H5User
+from app.domains.identity.service import H5AuthService
+
+bearer_scheme = HTTPBearer(auto_error=False)
+H5_REFRESH_COOKIE = "zbt_h5_refresh"
+H5_REFRESH_COOKIE_PATH = "/api/v1/h5/auth"
+
+
+class MobileRequest(BaseModel):
+    """请求验证码时的 JSON 请求体。"""
+
+    mobile: str = Field(min_length=11, max_length=11)
+
+
+class H5LoginRequest(MobileRequest):
+    code: str = Field(min_length=6, max_length=6)
+    referral_code: str | None = Field(default=None, max_length=32)
+
+
+class RefreshRequest(BaseModel):
+    refresh_token: str = Field(min_length=32)
+
+
+def create_h5_router(
+    auth_service: H5AuthService,
+    settings: Settings,
+    attribution_service: AttributionService | None = None,
+) -> APIRouter:
+    """创建 H5 认证路由,并把认证服务放入各接口的闭包中。"""
+    router = APIRouter(tags=["h5-auth"])
+
+    def bearer_token(
+        credentials: Annotated[
+            HTTPAuthorizationCredentials | None,
+            Depends(bearer_scheme),
+        ],
+    ) -> str:
+        # Depends 会从 Authorization: Bearer <token> 请求头中解析凭证。
+        if credentials is None:
+            raise AppError("AUTH_REQUIRED", "请先登录", 401)
+        return credentials.credentials
+
+    def current_user(
+        token: Annotated[str, Depends(bearer_token)],
+    ) -> H5User:
+        # 依赖可以继续依赖另一个依赖:先取 token,再查询当前用户。
+        return auth_service.authenticate_access(token)
+
+    @router.post("/h5/auth/request-code")
+    def request_code(payload: MobileRequest, request: Request) -> dict[str, Any]:
+        return success_response(request, auth_service.request_code(payload.mobile))
+
+    def set_refresh_cookie(response: Response, refresh_token: str) -> None:
+        response.set_cookie(
+            key=H5_REFRESH_COOKIE,
+            value=refresh_token,
+            max_age=settings.refresh_token_days * 24 * 60 * 60,
+            httponly=True,
+            secure=settings.app_env == "production",
+            samesite="lax",
+            path=H5_REFRESH_COOKIE_PATH,
+        )
+
+    @router.post("/h5/auth/login")
+    def login(
+        payload: H5LoginRequest,
+        request: Request,
+        response: Response,
+    ) -> dict[str, Any]:
+        # payload 已由 FastAPI 转成 H5LoginRequest,无需手动解析 JSON。
+        result = auth_service.login(payload.mobile, payload.code)
+        if payload.referral_code and attribution_service is not None:
+            user = auth_service.authenticate_access(str(result["tokens"]["access_token"]))
+            result["attribution"] = attribution_service.capture_visit(
+                user,
+                payload.referral_code,
+            )
+        set_refresh_cookie(response, result["tokens"]["refresh_token"])
+        return success_response(request, result)
+
+    @router.post("/h5/auth/refresh")
+    def refresh(
+        request: Request,
+        response: Response,
+        payload: RefreshRequest | None = None,
+    ) -> dict[str, Any]:
+        refresh_token = (
+            payload.refresh_token if payload is not None else request.cookies.get(H5_REFRESH_COOKIE)
+        )
+        if refresh_token is None:
+            raise AppError("AUTH_REQUIRED", "缺少刷新凭证", 401)
+        result = auth_service.refresh(refresh_token)
+        set_refresh_cookie(response, result["tokens"]["refresh_token"])
+        return success_response(request, result)
+
+    @router.post("/h5/auth/logout")
+    def logout(
+        request: Request,
+        response: Response,
+        token: Annotated[str, Depends(bearer_token)],
+    ) -> dict[str, Any]:
+        auth_service.logout(token)
+        response.delete_cookie(
+            key=H5_REFRESH_COOKIE,
+            path=H5_REFRESH_COOKIE_PATH,
+            samesite="lax",
+        )
+        return success_response(request, {"logged_out": True})
+
+    @router.get("/h5/me")
+    def me(
+        request: Request,
+        user: Annotated[H5User, Depends(current_user)],
+    ) -> dict[str, Any]:
+        return success_response(
+            request,
+            {
+                "id": user.id,
+                "mobile_masked": user.mobile_masked,
+                "display_name": user.display_name,
+                "status": user.status,
+            },
+        )
+
+    return router

+ 41 - 0
backend/app/api/v1/h5_products.py

@@ -0,0 +1,41 @@
+"""H5 产品列表 HTTP 接口。"""
+
+from typing import Annotated, Any
+
+from fastapi import APIRouter, Depends, Query, Request
+from fastapi.security import HTTPAuthorizationCredentials
+
+from app.api.responses import success_response
+from app.api.v1.h5_auth import bearer_scheme
+from app.core.errors import AppError
+from app.domains.catalog.service import ProductCatalogService
+from app.domains.identity.service import H5AuthService
+
+
+def create_h5_product_router(
+    catalog_service: ProductCatalogService,
+    auth_service: H5AuthService,
+) -> APIRouter:
+    """创建需要 H5 登录身份才能访问的产品路由。"""
+    router = APIRouter(tags=["h5-products"])
+
+    def authenticated(
+        credentials: Annotated[
+            HTTPAuthorizationCredentials | None,
+            Depends(bearer_scheme),
+        ],
+    ) -> None:
+        if credentials is None:
+            raise AppError("AUTH_REQUIRED", "请先登录", 401)
+        auth_service.authenticate_access(credentials.credentials)
+
+    @router.get("/h5/products", dependencies=[Depends(authenticated)])
+    def list_products(
+        request: Request,
+        category: Annotated[str | None, Query(max_length=32)] = None,
+    ) -> dict[str, Any]:
+        # Query 声明 category 来自 URL 查询参数,例如 ?category=health。
+        items = catalog_service.list_available(category=category)
+        return success_response(request, {"items": items, "total": len(items)})
+
+    return router

+ 17 - 0
backend/app/api/v1/system.py

@@ -0,0 +1,17 @@
+from fastapi import APIRouter, Request
+
+from app.api.responses import success_response
+
+router = APIRouter(prefix="/system", tags=["system"])
+
+
+@router.get("/health/live")
+def live_health(request: Request) -> dict[str, object]:
+    return success_response(
+        request,
+        {
+            "status": "alive",
+            "project": "智保通",
+            "stage": "s1",
+        },
+    )

+ 1 - 0
backend/app/commands/__init__.py

@@ -0,0 +1 @@
+"""Local maintenance commands."""

+ 57 - 0
backend/app/commands/build_analytics.py

@@ -0,0 +1,57 @@
+from datetime import UTC, date, datetime
+
+from sqlalchemy.dialects.mysql import insert
+
+from app.core.config import Settings
+from app.infrastructure.mysql.analytics_models import (
+    AnalyticsSyncOffsetRecord,
+    DimDateRecord,
+    MetricDefinitionRecord,
+)
+from app.infrastructure.mysql.sessions import (
+    DatabaseName,
+    create_mysql_engine,
+    create_session_factory,
+)
+
+
+def build_analytics_foundation(settings: Settings | None = None) -> None:
+    resolved_settings = settings or Settings()
+    engine = create_mysql_engine(resolved_settings, DatabaseName.ANALYTICS)
+    factory = create_session_factory(engine)
+    today = date.today()
+    date_key = int(today.strftime("%Y%m%d"))
+    with factory.begin() as session:
+        session.execute(
+            insert(DimDateRecord)
+            .values(
+                date_key=date_key,
+                full_date=today,
+                year=today.year,
+                month=today.month,
+                day=today.day,
+            )
+            .on_duplicate_key_update(full_date=today)
+        )
+        session.execute(
+            insert(MetricDefinitionRecord)
+            .values(
+                metric_code="ORDER_COUNT",
+                name="订单量",
+                description="按筛选条件统计的订单数量",
+                unit="笔",
+                expression="COUNT(order_id)",
+            )
+            .on_duplicate_key_update(name="订单量")
+        )
+        session.execute(
+            insert(AnalyticsSyncOffsetRecord)
+            .values(source_name="core_outbox", updated_at=datetime.now(UTC))
+            .on_duplicate_key_update(updated_at=datetime.now(UTC))
+        )
+    engine.dispose()
+
+
+if __name__ == "__main__":
+    build_analytics_foundation()
+    print("Analytics 基础维度、指标定义和同步游标已构建。")

+ 175 - 0
backend/app/commands/evaluate_agents.py

@@ -0,0 +1,175 @@
+"""使用真实 HTTP、真实大模型和 LangSmith Trace 评测两种 Agent 人格。"""
+
+import argparse
+import json
+import sys
+import time
+from contextlib import suppress
+from pathlib import Path
+from typing import Any
+from uuid import NAMESPACE_URL, UUID, uuid5
+
+import httpx
+from langsmith import Client
+
+from app.core.config import Settings
+
+BACKEND_ROOT = Path(__file__).resolve().parents[2]
+
+
+def load_cases(suite: str) -> list[dict[str, Any]]:
+    suffix = "smoke" if suite == "smoke" else "full"
+    cases: list[dict[str, Any]] = []
+    for persona in ("customer", "operation"):
+        path = BACKEND_ROOT / "evals" / f"{persona}-{suffix}.jsonl"
+        cases.extend(
+            json.loads(line)
+            for line in path.read_text(encoding="utf-8").splitlines()
+            if line.strip()
+        )
+    return cases
+
+
+def login(client: httpx.Client, persona: str, admin_password: str) -> str:
+    if persona == "customer":
+        response = client.post(
+            "/h5/auth/login",
+            json={"mobile": "18800000001", "code": "147258"},
+        )
+    else:
+        response = client.post(
+            "/admin/auth/login",
+            json={"username": "admin", "password": admin_password},
+        )
+    response.raise_for_status()
+    return str(response.json()["data"]["tokens"]["access_token"])
+
+
+def run_case(
+    client: httpx.Client,
+    case: dict[str, Any],
+    admin_password: str,
+) -> dict[str, Any]:
+    persona = str(case["persona"])
+    token = login(client, persona, admin_password)
+    prefix = "" if persona == "customer" else "/admin"
+    headers = {"Authorization": f"Bearer {token}"}
+    thread_response = client.post(
+        f"{prefix}/agent/threads",
+        headers=headers,
+        json={"title": f"评测-{case['id']}"},
+    )
+    thread_response.raise_for_status()
+    thread_id = thread_response.json()["data"]["id"]
+    response = client.post(
+        f"{prefix}/agent/threads/{thread_id}/messages",
+        headers=headers,
+        json={
+            "content": {"type": "text", "text": case["input"]},
+            "client_message_id": f"eval-{case['id']}",
+        },
+        timeout=120,
+    )
+    response.raise_for_status()
+    data = response.json()["data"]
+    run_id = str(data["run_id"])
+    for _ in range(5):
+        if data.get("trace_url"):
+            break
+        time.sleep(1)
+        detail = client.get(
+            f"{prefix}/agent/runs/{run_id}",
+            headers=headers,
+        )
+        detail.raise_for_status()
+        data["trace_url"] = detail.json()["data"].get("trace_url")
+
+    text = str(data["assistant_message"]["text"])
+    tools = set(data.get("invoked_tools", []))
+    expected_tools = set(case["expected_tools"])
+    expected_terms = [str(term) for term in case["expected_terms"]]
+    scores = {
+        "response_nonempty": bool(text.strip()),
+        "tool_coverage": expected_tools.issubset(tools),
+        "term_coverage": all(term in text for term in expected_terms),
+        "trace_linked": bool(data.get("trace_id")),
+    }
+    return {
+        "id": case["id"],
+        "persona": persona,
+        "run_id": run_id,
+        "trace_id": data.get("trace_id"),
+        "trace_url": data.get("trace_url"),
+        "invoked_tools": sorted(tools),
+        "scores": scores,
+        "passed": all(scores.values()),
+        "answer": text,
+    }
+
+
+def sync_langsmith(
+    settings: Settings,
+    suite: str,
+    cases: list[dict[str, Any]],
+    results: list[dict[str, Any]],
+) -> None:
+    if not settings.langsmith_api_key:
+        return
+    client = Client(
+        api_url=settings.langsmith_endpoint,
+        api_key=settings.langsmith_api_key,
+    )
+    dataset_name = f"智保通-S1-{suite}"
+    if not client.has_dataset(dataset_name=dataset_name):
+        client.create_dataset(
+            dataset_name,
+            description="智保通第一阶段客户与运营 Agent 真实模型评测集",
+            metadata={"project": "智保通", "stage": 1, "suite": suite},
+        )
+    result_by_id = {result["id"]: result for result in results}
+    for case in cases:
+        example_id = uuid5(NAMESPACE_URL, f"{dataset_name}:{case['id']}")
+        # 示例 ID 固定;重复执行时保留第一次创建的基准样本。
+        with suppress(Exception):
+            client.create_example(
+                example_id=example_id,
+                dataset_name=dataset_name,
+                inputs={"persona": case["persona"], "text": case["input"]},
+                outputs={
+                    "expected_tools": case["expected_tools"],
+                    "expected_terms": case["expected_terms"],
+                },
+                metadata={"suite": suite, "case_id": case["id"]},
+            )
+        result = result_by_id[str(case["id"])]
+        trace_id = result.get("trace_id")
+        if not trace_id:
+            continue
+        for key, score in result["scores"].items():
+            client.create_feedback(
+                run_id=UUID(str(trace_id)),
+                key=f"eval.{key}",
+                score=bool(score),
+                comment=f"智保通 S1 {suite} 自动评测",
+            )
+
+
+def main() -> int:
+    if hasattr(sys.stdout, "reconfigure"):
+        sys.stdout.reconfigure(encoding="utf-8")
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--suite", choices=("smoke", "full"), default="smoke")
+    parser.add_argument("--base-url", default="http://127.0.0.1:8000/api/v1")
+    parser.add_argument("--admin-password", default="zaq1XSW@")
+    arguments = parser.parse_args()
+    settings = Settings()
+    cases = load_cases(arguments.suite)
+    with httpx.Client(base_url=arguments.base_url, timeout=120) as client:
+        results = [run_case(client, case, arguments.admin_password) for case in cases]
+    sync_langsmith(settings, arguments.suite, cases, results)
+    print(json.dumps({"suite": arguments.suite, "results": results}, ensure_ascii=False, indent=2))
+    return 0 if all(result["passed"] for result in results) else 1
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 59 - 0
backend/app/commands/init_databases.py

@@ -0,0 +1,59 @@
+import argparse
+import getpass
+import re
+
+import pymysql
+
+from app.core.config import Settings
+
+SAFE_ACCOUNT = re.compile(r"^[A-Za-z0-9_]+$")
+
+
+def initialize_databases(root_user: str, root_password: str, settings: Settings) -> None:
+    if not SAFE_ACCOUNT.fullmatch(settings.mysql_user):
+        raise ValueError("MYSQL_USER 只能包含字母、数字和下划线")
+    if not settings.mysql_password:
+        raise ValueError("请先在 .env 中设置 MYSQL_PASSWORD")
+
+    connection = pymysql.connect(
+        host=settings.mysql_host,
+        port=settings.mysql_port,
+        user=root_user,
+        password=root_password,
+        autocommit=True,
+    )
+    databases = (
+        settings.mysql_core_database,
+        settings.mysql_agent_database,
+        settings.mysql_analytics_database,
+    )
+    account = f"'{settings.mysql_user}'@'localhost'"
+    escaped_password = connection.escape(settings.mysql_password)
+    with connection, connection.cursor() as cursor:
+        for database in databases:
+            cursor.execute(
+                f"CREATE DATABASE IF NOT EXISTS `{database}` "
+                "CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"
+            )
+        cursor.execute(f"CREATE USER IF NOT EXISTS {account} IDENTIFIED BY {escaped_password}")
+        for database in databases:
+            cursor.execute(f"GRANT ALL PRIVILEGES ON `{database}`.* TO {account}")
+        cursor.execute("FLUSH PRIVILEGES")
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="初始化智保通第一阶段 MySQL 三库")
+    parser.add_argument("--root-user", default="root")
+    args = parser.parse_args()
+    password = getpass.getpass(f"MySQL {args.root_user} 密码:")
+    settings = Settings()
+    initialize_databases(args.root_user, password, settings)
+    print(
+        "已初始化数据库:"
+        f"{settings.mysql_core_database}、{settings.mysql_agent_database}、"
+        f"{settings.mysql_analytics_database}"
+    )
+
+
+if __name__ == "__main__":
+    main()

+ 32 - 0
backend/app/commands/reset_cache.py

@@ -0,0 +1,32 @@
+from typing import Any, cast
+
+from redis import Redis
+
+from app.core.config import Settings
+
+
+def clear_project_cache(settings: Settings | None = None) -> int:
+    resolved_settings = settings or Settings()
+    client = Redis.from_url(resolved_settings.redis_url, decode_responses=True)
+    deleted = 0
+    cursor = 0
+    while True:
+        cursor, keys = cast(
+            tuple[int, list[str]],
+            client.scan(
+                cursor=cursor,
+                match=f"{resolved_settings.redis_prefix}*",
+                count=500,
+            ),
+        )
+        if keys:
+            deleted += cast(Any, client.delete(*keys))
+        if cursor == 0:
+            break
+    client.close()
+    return deleted
+
+
+if __name__ == "__main__":
+    count = clear_project_cache()
+    print(f"已删除 {count} 个智保通第一阶段 Redis Key。")

+ 509 - 0
backend/app/commands/seed.py

@@ -0,0 +1,509 @@
+from dataclasses import dataclass
+from datetime import UTC, datetime, timedelta
+from hashlib import sha256
+
+from app.core.config import Settings
+from app.core.passwords import PasswordService
+from app.domains.attribution.models import PromotionCode, Salesperson
+from app.domains.catalog.models import Plan, Product, ProductChangeLog, ProductVersion
+from app.domains.identity.models import AdminUser
+from app.infrastructure.mysql.attribution_repositories import (
+    SqlAlchemyAttributionRepository,
+)
+from app.infrastructure.mysql.repositories import (
+    SqlAlchemyCatalogRepository,
+    SqlAlchemyIdentityRepository,
+)
+from app.infrastructure.mysql.sessions import (
+    DatabaseName,
+    create_mysql_engine,
+    create_session_factory,
+)
+
+INITIAL_ADMIN_PASSWORD = "zaq1XSW@"
+
+
+@dataclass(frozen=True, slots=True)
+class SeedManifest:
+    admin_users: tuple[AdminUser, ...]
+    products: tuple[Product, ...]
+    versions: tuple[ProductVersion, ...]
+    salespersons: tuple[Salesperson, ...]
+    promotion_codes: tuple[PromotionCode, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class PlanConfig:
+    summary: str
+    premium_cents: int
+    coverage_amount_cents: int
+    min_age: int
+    max_age: int
+
+
+def stable_id(value: str) -> str:
+    return sha256(value.encode("utf-8")).hexdigest()[:26].upper()
+
+
+def build_seed_manifest(password_hash: str = "HASHED_DURING_DATABASE_SEED") -> SeedManifest:
+    products = (
+        _product("MED-BASIC", "蓉惠基础医疗险", "MEDICAL"),
+        _product("MED-UPGRADE", "蓉惠升级医疗险", "MEDICAL"),
+        _product("MED-SENIOR", "银龄守护医疗险", "MEDICAL"),
+        _product("ACC-FAMILY", "家庭意外保障险", "ACCIDENT"),
+    )
+    product_ids = {product.product_code: product.id for product in products}
+    versions = (
+        _version(product_ids, "MED-BASIC", "1.0.0", "EXPIRED", 2023, 2024, ("BASIC",)),
+        _version(product_ids, "MED-BASIC", "2.0.0", "PUBLISHED", 2025, 2029, ("BASIC",)),
+        _version(product_ids, "MED-BASIC", "3.0.0", "DRAFT", 2030, None, ("BASIC",)),
+        _version(
+            product_ids,
+            "MED-UPGRADE",
+            "1.0.0",
+            "PUBLISHED",
+            2025,
+            2029,
+            ("STANDARD", "ENHANCED"),
+        ),
+        _version(
+            product_ids,
+            "MED-UPGRADE",
+            "1.1.0",
+            "DRAFT",
+            2030,
+            None,
+            ("STANDARD", "ENHANCED"),
+        ),
+        _version(
+            product_ids,
+            "MED-SENIOR",
+            "1.0.0",
+            "PUBLISHED",
+            2025,
+            2029,
+            ("SENIOR_STANDARD",),
+        ),
+        _version(
+            product_ids,
+            "ACC-FAMILY",
+            "1.0.0",
+            "PUBLISHED",
+            2025,
+            2029,
+            ("INDIVIDUAL", "FAMILY"),
+        ),
+    )
+    admin_users = (
+        _admin("admin", "超级管理员", "SUPER_ADMIN", "ALL", ("*",), password_hash),
+        _admin(
+            "operator01",
+            "运营人员",
+            "OPERATOR",
+            "MASKED_ALL",
+            ("dashboard:read", "order:read", "policy:read", "user:read"),
+            password_hash,
+        ),
+        _admin(
+            "reviewer01",
+            "审核人员",
+            "REVIEWER",
+            "READ_ONLY",
+            ("order:read", "policy:read"),
+            password_hash,
+        ),
+        _admin(
+            "sales_a",
+            "张伟",
+            "SALESPERSON",
+            "SELF",
+            (
+                "dashboard:read",
+                "order:read",
+                "policy:read",
+                "product:read",
+                "attribution:read",
+            ),
+            password_hash,
+        ),
+        _admin(
+            "sales_b",
+            "李娜",
+            "SALESPERSON",
+            "SELF",
+            (
+                "dashboard:read",
+                "order:read",
+                "policy:read",
+                "product:read",
+                "attribution:read",
+            ),
+            password_hash,
+        ),
+        _admin(
+            "sales_c",
+            "王磊",
+            "SALESPERSON",
+            "SELF",
+            (
+                "dashboard:read",
+                "order:read",
+                "policy:read",
+                "product:read",
+                "attribution:read",
+            ),
+            password_hash,
+        ),
+        _admin(
+            "sales_d",
+            "陈晨",
+            "SALESPERSON",
+            "SELF",
+            (
+                "dashboard:read",
+                "order:read",
+                "policy:read",
+                "product:read",
+                "attribution:read",
+            ),
+            password_hash,
+        ),
+        _admin(
+            "sales_e",
+            "刘洋",
+            "SALESPERSON",
+            "SELF",
+            (
+                "dashboard:read",
+                "order:read",
+                "policy:read",
+                "product:read",
+                "attribution:read",
+            ),
+            password_hash,
+        ),
+        _admin(
+            "sales_f",
+            "赵敏",
+            "SALESPERSON",
+            "SELF",
+            (
+                "dashboard:read",
+                "order:read",
+                "policy:read",
+                "product:read",
+                "attribution:read",
+            ),
+            password_hash,
+        ),
+    )
+    created_at = datetime(2026, 1, 1, tzinfo=UTC)
+    salespersons = (
+        Salesperson(
+            id=stable_id("salesperson:sales_a"),
+            admin_user_id=stable_id("admin:sales_a"),
+            code="SALES-A",
+            name="张伟",
+            status="ACTIVE",
+            created_at=created_at,
+        ),
+        Salesperson(
+            id=stable_id("salesperson:sales_b"),
+            admin_user_id=stable_id("admin:sales_b"),
+            code="SALES-B",
+            name="李娜",
+            status="ACTIVE",
+            created_at=created_at,
+        ),
+        Salesperson(
+            id=stable_id("salesperson:sales_c"),
+            admin_user_id=stable_id("admin:sales_c"),
+            code="SALES-C",
+            name="王磊",
+            status="ACTIVE",
+            created_at=created_at,
+        ),
+        Salesperson(
+            id=stable_id("salesperson:sales_d"),
+            admin_user_id=stable_id("admin:sales_d"),
+            code="SALES-D",
+            name="陈晨",
+            status="ACTIVE",
+            created_at=created_at,
+        ),
+        Salesperson(
+            id=stable_id("salesperson:sales_e"),
+            admin_user_id=stable_id("admin:sales_e"),
+            code="SALES-E",
+            name="刘洋",
+            status="ACTIVE",
+            created_at=created_at,
+        ),
+        Salesperson(
+            id=stable_id("salesperson:sales_f"),
+            admin_user_id=stable_id("admin:sales_f"),
+            code="SALES-F",
+            name="赵敏",
+            status="ACTIVE",
+            created_at=created_at,
+        ),
+    )
+    promotion_codes = (
+        PromotionCode(
+            id=stable_id("promotion:ZBT-SALES-A"),
+            code="ZBT-SALES-A",
+            salesperson_id=salespersons[0].id,
+            channel="GROUND_PROMOTION",
+            status="ACTIVE",
+            created_at=created_at,
+        ),
+        PromotionCode(
+            id=stable_id("promotion:ZBT-SALES-B"),
+            code="ZBT-SALES-B",
+            salesperson_id=salespersons[1].id,
+            channel="COMMUNITY",
+            status="ACTIVE",
+            created_at=created_at,
+        ),
+        PromotionCode(
+            id=stable_id("promotion:ZBT-SALES-C"),
+            code="ZBT-SALES-C",
+            salesperson_id=salespersons[2].id,
+            channel="ENTERPRISE",
+            status="ACTIVE",
+            created_at=created_at,
+        ),
+        PromotionCode(
+            id=stable_id("promotion:ZBT-SALES-D"),
+            code="ZBT-SALES-D",
+            salesperson_id=salespersons[3].id,
+            channel="PARTNER",
+            status="ACTIVE",
+            created_at=created_at,
+        ),
+        PromotionCode(
+            id=stable_id("promotion:ZBT-SALES-E"),
+            code="ZBT-SALES-E",
+            salesperson_id=salespersons[4].id,
+            channel="ONLINE_REFERRAL",
+            status="ACTIVE",
+            created_at=created_at,
+        ),
+        PromotionCode(
+            id=stable_id("promotion:ZBT-SALES-F"),
+            code="ZBT-SALES-F",
+            salesperson_id=salespersons[5].id,
+            channel="STORE",
+            status="ACTIVE",
+            created_at=created_at,
+        ),
+    )
+    return SeedManifest(
+        admin_users=admin_users,
+        products=products,
+        versions=versions,
+        salespersons=salespersons,
+        promotion_codes=promotion_codes,
+    )
+
+
+def run_seed(settings: Settings | None = None) -> SeedManifest:
+    resolved_settings = settings or Settings()
+    password_hash = PasswordService().hash(INITIAL_ADMIN_PASSWORD)
+    manifest = build_seed_manifest(password_hash)
+    engine = create_mysql_engine(resolved_settings, DatabaseName.CORE)
+    factory = create_session_factory(engine)
+    identities = SqlAlchemyIdentityRepository(factory)
+    catalog = SqlAlchemyCatalogRepository(factory)
+    attribution = SqlAlchemyAttributionRepository(factory)
+    for admin_user in manifest.admin_users:
+        identities.save_admin_user(admin_user)
+    for product in manifest.products:
+        catalog.save_product(product)
+    for version in manifest.versions:
+        catalog.save_version(version)
+    audit_anchor = datetime(2026, 7, 1, 9, 0, tzinfo=UTC)
+    for index, product in enumerate(manifest.products):
+        catalog.save_change_log(
+            ProductChangeLog(
+                id=stable_id(f"product-log:created:{product.id}"),
+                product_id=product.id,
+                version_id=None,
+                action="PRODUCT_CREATED",
+                actor_id="SYSTEM",
+                actor_name="系统初始化",
+                detail={"product_code": product.product_code},
+                created_at=audit_anchor - timedelta(days=120 - index),
+            )
+        )
+    for index, version in enumerate(manifest.versions):
+        catalog.save_change_log(
+            ProductChangeLog(
+                id=stable_id(f"product-log:version:{version.id}"),
+                product_id=version.product_id,
+                version_id=version.id,
+                action=(
+                    "VERSION_DRAFT_CREATED"
+                    if version.status == "DRAFT"
+                    else "VERSION_IMPORTED"
+                ),
+                actor_id="SYSTEM",
+                actor_name="系统初始化",
+                detail={"version_no": version.version_no, "status": version.status},
+                created_at=audit_anchor - timedelta(days=40 - index * 3),
+            )
+        )
+    for salesperson in manifest.salespersons:
+        attribution.save_salesperson(salesperson)
+    for promotion_code in manifest.promotion_codes:
+        attribution.save_promotion_code(promotion_code)
+    engine.dispose()
+    return manifest
+
+
+def _product(code: str, name: str, category: str) -> Product:
+    summaries = {
+        "MED-BASIC": "覆盖常见住院医疗费用,为年轻家庭提供简洁实用的基础保障。",
+        "MED-UPGRADE": "提高医疗费用保障额度,并提供标准与升级两档方案灵活选择。",
+        "MED-SENIOR": "面向中老年人群的住院医疗保障,覆盖50至75周岁的投保需求。",
+        "ACC-FAMILY": "覆盖日常意外风险,可按个人或家庭方案为家人统一配置保障。",
+    }
+    return Product(
+        id=stable_id(f"product:{code}"),
+        product_code=code,
+        name=name,
+        category=category,
+        summary=summaries[code],
+        status="ACTIVE",
+    )
+
+
+def _version(
+    product_ids: dict[str, str],
+    product_code: str,
+    version_no: str,
+    status: str,
+    start_year: int,
+    end_year: int | None,
+    plan_codes: tuple[str, ...],
+) -> ProductVersion:
+    version_key = f"{product_code}:{version_no}"
+    return ProductVersion(
+        id=stable_id(f"version:{version_key}"),
+        product_id=product_ids[product_code],
+        version_no=version_no,
+        status=status,
+        effective_from=datetime(start_year, 1, 1, tzinfo=UTC),
+        effective_to=(
+            datetime(end_year, 12, 31, 23, 59, 59, tzinfo=UTC) if end_year is not None else None
+        ),
+        terms_summary=(
+            "本产品为一年期保障,等待期、责任免除及赔付比例以电子保险条款为准;"
+            "投保前请如实完成健康告知并确认被保人信息。"
+        ),
+        plans=tuple(
+            _plan(version_key, code)
+            for code in plan_codes
+        ),
+    )
+
+
+def _plan_name(code: str) -> str:
+    return {
+        "BASIC": "基础计划",
+        "STANDARD": "标准计划",
+        "ENHANCED": "升级计划",
+        "SENIOR_STANDARD": "银龄标准计划",
+        "INDIVIDUAL": "个人计划",
+        "FAMILY": "家庭计划",
+    }[code]
+
+
+def _plan(version_key: str, code: str) -> Plan:
+    config = _plan_config(code)
+    return Plan(
+        id=stable_id(f"plan:{version_key}:{code}"),
+        code=code,
+        name=_plan_name(code),
+        summary=config.summary,
+        premium_cents=config.premium_cents,
+        coverage_amount_cents=config.coverage_amount_cents,
+        min_age=config.min_age,
+        max_age=config.max_age,
+    )
+
+
+def _plan_config(code: str) -> PlanConfig:
+    return {
+        "BASIC": PlanConfig(
+            "覆盖住院医疗及约定门诊费用,适合首次配置医疗保障的人群。",
+            23900,
+            200_000_000,
+            0,
+            65,
+        ),
+        "STANDARD": PlanConfig(
+            "提升医疗保障额度,兼顾常见疾病住院和重大疾病医疗支出。",
+            39900,
+            300_000_000,
+            0,
+            65,
+        ),
+        "ENHANCED": PlanConfig(
+            "提供更高医疗额度和扩展责任,适合重视全面保障的家庭。",
+            59900,
+            600_000_000,
+            0,
+            65,
+        ),
+        "SENIOR_STANDARD": PlanConfig(
+            "面向中老年人群的住院医疗计划,覆盖高发疾病住院费用。",
+            19900,
+            100_000_000,
+            50,
+            75,
+        ),
+        "INDIVIDUAL": PlanConfig(
+            "覆盖个人日常意外身故、伤残及意外医疗责任。",
+            9900,
+            50_000_000,
+            0,
+            70,
+        ),
+        "FAMILY": PlanConfig(
+            "一份计划覆盖家庭成员的常见意外风险,适合家庭统一配置。",
+            15900,
+            100_000_000,
+            0,
+            70,
+        ),
+    }[code]
+
+
+def _admin(
+    username: str,
+    display_name: str,
+    role: str,
+    data_scope: str,
+    permissions: tuple[str, ...],
+    password_hash: str,
+) -> AdminUser:
+    return AdminUser(
+        id=stable_id(f"admin:{username}"),
+        username=username,
+        password_hash=password_hash,
+        display_name=display_name,
+        status="ACTIVE",
+        roles=(role,),
+        permissions=permissions,
+        data_scope=data_scope,
+    )
+
+
+if __name__ == "__main__":
+    seeded = run_seed()
+    print(
+        f"已写入 {len(seeded.admin_users)} 个后台账号、"
+        f"{len(seeded.products)} 个产品、{len(seeded.versions)} 个产品版本、"
+        f"{len(seeded.promotion_codes)} 个推广码。"
+    )

+ 583 - 0
backend/app/commands/seed_business_data.py

@@ -0,0 +1,583 @@
+from dataclasses import dataclass
+from datetime import UTC, datetime, timedelta
+from hashlib import sha256
+
+from app.commands.seed import build_seed_manifest, stable_id
+from app.core.config import Settings
+from app.domains.attribution.models import LeadAttribution, OrderAttribution
+from app.domains.enrollment.models import (
+    EnrollmentDraft,
+    EnrollmentOrder,
+    PaymentTransaction,
+    Policy,
+    Quote,
+    UserConfirmation,
+)
+from app.domains.identity.models import H5User
+from app.infrastructure.mysql.attribution_repositories import (
+    SqlAlchemyAttributionRepository,
+)
+from app.infrastructure.mysql.enrollment_repositories import (
+    SqlAlchemyEnrollmentRepository,
+)
+from app.infrastructure.mysql.repositories import SqlAlchemyIdentityRepository
+from app.infrastructure.mysql.sessions import (
+    DatabaseName,
+    create_mysql_engine,
+    create_session_factory,
+)
+
+
+@dataclass(frozen=True, slots=True)
+class BusinessCase:
+    applicant: str
+    insured: str
+    relationship: str
+    product_code: str
+    plan_code: str
+    status: str
+    days_ago: int
+    policy_status: str | None = None
+    payment_status: str | None = None
+
+
+BASE_CASES: tuple[BusinessCase, ...] = (
+    BusinessCase(
+        "周明远", "周明远", "SELF", "MED-BASIC", "BASIC", "ISSUED", 0, "ACTIVE", "SUCCEEDED"
+    ),
+    BusinessCase(
+        "林晓雨",
+        "林建国",
+        "PARENT",
+        "MED-SENIOR",
+        "SENIOR_STANDARD",
+        "ISSUED",
+        1,
+        "ACTIVE",
+        "SUCCEEDED",
+    ),
+    BusinessCase(
+        "陈子昂", "王思雅", "SPOUSE", "MED-UPGRADE", "STANDARD", "ISSUED", 2, "ACTIVE", "SUCCEEDED"
+    ),
+    BusinessCase(
+        "赵清和", "赵安安", "CHILD", "ACC-FAMILY", "FAMILY", "ISSUED", 3, "ACTIVE", "SUCCEEDED"
+    ),
+    BusinessCase(
+        "许文博", "许文博", "SELF", "ACC-FAMILY", "INDIVIDUAL", "ISSUED", 4, "ACTIVE", "SUCCEEDED"
+    ),
+    BusinessCase(
+        "郑嘉宁",
+        "郑国强",
+        "PARENT",
+        "MED-SENIOR",
+        "SENIOR_STANDARD",
+        "ISSUED",
+        5,
+        "ACTIVE",
+        "SUCCEEDED",
+    ),
+    BusinessCase(
+        "孙语桐", "方俊杰", "SPOUSE", "MED-UPGRADE", "ENHANCED", "ISSUED", 7, "ACTIVE", "SUCCEEDED"
+    ),
+    BusinessCase(
+        "何志诚", "何沐阳", "CHILD", "ACC-FAMILY", "FAMILY", "ISSUED", 9, "ACTIVE", "SUCCEEDED"
+    ),
+    BusinessCase(
+        "蒋若溪", "蒋若溪", "SELF", "MED-UPGRADE", "STANDARD", "ISSUED", 12, "ACTIVE", "SUCCEEDED"
+    ),
+    BusinessCase(
+        "唐景行",
+        "唐德华",
+        "PARENT",
+        "MED-SENIOR",
+        "SENIOR_STANDARD",
+        "ISSUED",
+        15,
+        "ACTIVE",
+        "SUCCEEDED",
+    ),
+    BusinessCase(
+        "邓舒涵", "宋嘉树", "SPOUSE", "MED-BASIC", "BASIC", "ISSUED", 20, "ACTIVE", "SUCCEEDED"
+    ),
+    BusinessCase(
+        "梁书航", "梁小满", "CHILD", "ACC-FAMILY", "FAMILY", "ISSUED", 25, "ACTIVE", "SUCCEEDED"
+    ),
+    BusinessCase(
+        "顾念安", "顾念安", "SELF", "MED-BASIC", "BASIC", "ISSUED", 30, "CANCELLED", "SUCCEEDED"
+    ),
+    BusinessCase(
+        "马致远",
+        "马国庆",
+        "PARENT",
+        "MED-SENIOR",
+        "SENIOR_STANDARD",
+        "ISSUED",
+        430,
+        "EXPIRED",
+        "SUCCEEDED",
+    ),
+    BusinessCase(
+        "罗婉清", "罗婉清", "SELF", "MED-UPGRADE", "ENHANCED", "PAID", 1, None, "SUCCEEDED"
+    ),
+    BusinessCase("谢云舟", "沈知夏", "SPOUSE", "MED-BASIC", "BASIC", "PAID", 3, None, "SUCCEEDED"),
+    BusinessCase(
+        "苏曼青",
+        "苏曼青",
+        "SELF",
+        "ACC-FAMILY",
+        "INDIVIDUAL",
+        "PENDING_PAYMENT",
+        0,
+        None,
+        "CREATED",
+    ),
+    BusinessCase(
+        "白敬亭",
+        "白国良",
+        "PARENT",
+        "MED-SENIOR",
+        "SENIOR_STANDARD",
+        "PENDING_PAYMENT",
+        2,
+        None,
+        None,
+    ),
+    BusinessCase(
+        "陆星河", "陆小禾", "CHILD", "ACC-FAMILY", "FAMILY", "PENDING_PAYMENT", 5, None, "CREATED"
+    ),
+    BusinessCase(
+        "秦知意", "秦知意", "SELF", "MED-UPGRADE", "STANDARD", "PENDING_PAYMENT", 8, None, None
+    ),
+    BusinessCase(
+        "夏予川", "顾安然", "SPOUSE", "MED-BASIC", "BASIC", "PENDING_PAYMENT", 11, None, "CREATED"
+    ),
+    BusinessCase("叶听澜", "叶听澜", "SELF", "MED-BASIC", "BASIC", "CANCELLED", 4, None, "FAILED"),
+    BusinessCase(
+        "程砚秋",
+        "程建军",
+        "PARENT",
+        "MED-SENIOR",
+        "SENIOR_STANDARD",
+        "CANCELLED",
+        13,
+        None,
+        "FAILED",
+    ),
+    BusinessCase("江望舒", "江小鱼", "CHILD", "ACC-FAMILY", "FAMILY", "CANCELLED", 21, None, None),
+)
+
+GENERATED_NAMES = (
+    "沈亦辰",
+    "温书瑶",
+    "乔景川",
+    "孟知微",
+    "裴言澈",
+    "楚星晚",
+    "傅时安",
+    "宋予宁",
+    "贺云深",
+    "姜晚晴",
+    "顾承泽",
+    "许清欢",
+    "陆闻舟",
+    "苏念初",
+    "程屿森",
+    "叶舒然",
+    "周砚北",
+    "林语棠",
+    "陈遇白",
+    "赵南乔",
+    "唐慕川",
+    "梁初夏",
+    "郑怀瑾",
+    "孙云舒",
+    "何景明",
+    "蒋依澜",
+    "邓星野",
+    "罗芷晴",
+    "马明谦",
+    "谢知夏",
+    "秦远洲",
+    "白若宁",
+    "夏云川",
+    "顾安禾",
+    "叶庭深",
+    "程书妍",
+    "江叙白",
+    "周晚意",
+    "林嘉树",
+    "陈思源",
+    "赵安澜",
+    "唐清越",
+    "梁知行",
+    "郑初晴",
+    "孙景和",
+    "何语心",
+    "蒋承安",
+    "邓若溪",
+)
+
+
+def _generated_business_cases() -> tuple[BusinessCase, ...]:
+    relationships = ("SELF", "PARENT", "SPOUSE", "CHILD")
+    product_plans = (
+        ("MED-BASIC", "BASIC"),
+        ("MED-BASIC", "BASIC"),
+        ("MED-UPGRADE", "STANDARD"),
+        ("MED-UPGRADE", "ENHANCED"),
+        ("MED-SENIOR", "SENIOR_STANDARD"),
+        ("ACC-FAMILY", "INDIVIDUAL"),
+        ("ACC-FAMILY", "FAMILY"),
+    )
+    generated: list[BusinessCase] = []
+    for offset, applicant in enumerate(GENERATED_NAMES, start=1):
+        index = len(BASE_CASES) + offset
+        relationship = relationships[index % len(relationships)]
+        product_code, plan_code = product_plans[index % len(product_plans)]
+        if product_code == "MED-SENIOR":
+            relationship = "PARENT"
+        selector = index % 20
+        if selector in {0, 7, 14, 18}:
+            status = "PENDING_PAYMENT"
+            policy_status = None
+            payment_status = "CREATED" if index % 2 else None
+        elif selector in {9, 19}:
+            status = "CANCELLED"
+            policy_status = None
+            payment_status = "FAILED" if index % 2 else None
+        elif selector == 11:
+            status = "PAID"
+            policy_status = None
+            payment_status = "SUCCEEDED"
+        else:
+            status = "ISSUED"
+            policy_status = "ACTIVE"
+            payment_status = "SUCCEEDED"
+        insured = (
+            applicant
+            if relationship == "SELF"
+            else GENERATED_NAMES[(offset + 11) % len(GENERATED_NAMES)]
+        )
+        generated.append(
+            BusinessCase(
+                applicant=applicant,
+                insured=insured,
+                relationship=relationship,
+                product_code=product_code,
+                plan_code=plan_code,
+                status=status,
+                days_ago=(index * 7 + (index // 3) * 5) % 120,
+                policy_status=policy_status,
+                payment_status=payment_status,
+            )
+        )
+    return tuple(generated)
+
+
+CASES = BASE_CASES + _generated_business_cases()
+
+ATTRIBUTION_PATTERN: tuple[int | None, ...] = (
+    0,
+    0,
+    0,
+    1,
+    0,
+    1,
+    2,
+    0,
+    1,
+    2,
+    3,
+    0,
+    1,
+    2,
+    3,
+    4,
+    0,
+    1,
+    2,
+    3,
+    4,
+    5,
+    None,
+    None,
+)
+
+LEAD_ONLY_COUNT = 42
+
+PREMIUMS = {
+    ("MED-BASIC", "BASIC"): 23900,
+    ("MED-UPGRADE", "STANDARD"): 39900,
+    ("MED-UPGRADE", "ENHANCED"): 59900,
+    ("MED-SENIOR", "SENIOR_STANDARD"): 19900,
+    ("ACC-FAMILY", "INDIVIDUAL"): 9900,
+    ("ACC-FAMILY", "FAMILY"): 15900,
+}
+
+
+def run_seed(settings: Settings | None = None) -> dict[str, int]:
+    resolved_settings = settings or Settings()
+    engine = create_mysql_engine(resolved_settings, DatabaseName.CORE)
+    factory = create_session_factory(engine)
+    identities = SqlAlchemyIdentityRepository(factory)
+    enrollment = SqlAlchemyEnrollmentRepository(factory)
+    attribution = SqlAlchemyAttributionRepository(factory)
+    manifest = build_seed_manifest()
+    for salesperson in manifest.salespersons:
+        attribution.save_salesperson(salesperson)
+    for seed_code in manifest.promotion_codes:
+        attribution.save_promotion_code(seed_code)
+    products = {product.product_code: product for product in manifest.products}
+    published_versions = {
+        product.product_code: next(
+            version
+            for version in manifest.versions
+            if version.product_id == product.id and version.status == "PUBLISHED"
+        )
+        for product in manifest.products
+    }
+    anchor = datetime(2026, 7, 26, 16, 30, tzinfo=UTC)
+    customer_ids: dict[str, str] = {}
+
+    for index, case in enumerate(CASES, start=1):
+        mobile = f"1886001{index:04d}"
+        user_id = stable_id(f"business-user:{mobile}")
+        customer_ids[mobile] = user_id
+        identities.save_h5_user(
+            H5User(
+                id=user_id,
+                mobile=mobile,
+                mobile_masked=f"{mobile[:3]}****{mobile[-4:]}",
+                display_name=case.applicant,
+                status="ACTIVE",
+                created_at=anchor - timedelta(days=case.days_ago + 8),
+            )
+        )
+        promotion_index = ATTRIBUTION_PATTERN[(index - 1) % len(ATTRIBUTION_PATTERN)]
+        attributed_code = (
+            manifest.promotion_codes[promotion_index] if promotion_index is not None else None
+        )
+        if attributed_code is not None:
+            attribution.save_lead(
+                LeadAttribution(
+                    id=stable_id(f"business-lead:{index}"),
+                    h5_user_id=user_id,
+                    salesperson_id=attributed_code.salesperson_id,
+                    promotion_code_id=attributed_code.id,
+                    source_code=attributed_code.code,
+                    visit_count=1 + index % 4,
+                    first_touch_at=anchor - timedelta(days=case.days_ago + 8),
+                    last_touch_at=anchor - timedelta(days=case.days_ago + 1),
+                )
+            )
+
+        product = products[case.product_code]
+        version = published_versions[case.product_code]
+        plan = next(item for item in version.plans if item.code == case.plan_code)
+        created_at = anchor - timedelta(
+            days=case.days_ago,
+            hours=(index * 3) % 12,
+            minutes=(index * 7) % 60,
+        )
+        premium_cents = PREMIUMS[(case.product_code, case.plan_code)]
+        quote_id = stable_id(f"business-quote:{index}")
+        draft_id = stable_id(f"business-draft:{index}")
+        confirmation_id = stable_id(f"business-confirmation:{index}")
+        order_id = stable_id(f"business-order:{index}")
+        id_no_suffix = f"{index:04d}"
+        applicant_snapshot = {
+            "name": case.applicant,
+            "id_no": f"51010419880101{id_no_suffix}",
+        }
+        insured_snapshot = {
+            "name": case.insured,
+            "id_no": f"51010419920102{id_no_suffix}",
+        }
+
+        enrollment.save_quote(
+            Quote(
+                id=quote_id,
+                user_id=user_id,
+                product_id=product.id,
+                product_version_id=version.id,
+                plan_id=plan.id,
+                insured_age=65 if case.relationship == "PARENT" else 35,
+                insured_region_code="510100",
+                occupation_code="GENERAL",
+                relationship=case.relationship,
+                premium_cents=premium_cents,
+                currency="CNY",
+                rule_version="eligibility-v1",
+                rate_version="rate-v1",
+                status="USED",
+                expires_at=created_at + timedelta(minutes=30),
+                created_at=created_at - timedelta(minutes=12),
+            )
+        )
+        enrollment.save_draft(
+            EnrollmentDraft(
+                id=draft_id,
+                user_id=user_id,
+                quote_id=quote_id,
+                applicant=applicant_snapshot,
+                insured=insured_snapshot,
+                contact={"mobile": mobile},
+                status="CONFIRMED",
+                expires_at=created_at + timedelta(hours=24),
+                created_at=created_at - timedelta(minutes=8),
+            )
+        )
+        enrollment.save_confirmation(
+            UserConfirmation(
+                id=confirmation_id,
+                user_id=user_id,
+                draft_id=draft_id,
+                token_hash=sha256(f"business-confirmation-token:{index}".encode()).hexdigest(),
+                status="USED",
+                expires_at=created_at + timedelta(minutes=10),
+                created_at=created_at - timedelta(minutes=3),
+            )
+        )
+        order = EnrollmentOrder(
+            id=order_id,
+            order_no=f"ORD-{created_at:%Y%m%d}-B{index:07d}",
+            user_id=user_id,
+            quote_id=quote_id,
+            draft_id=draft_id,
+            confirmation_id=confirmation_id,
+            idempotency_key=f"business-order-{index:04d}",
+            product_id=product.id,
+            product_version_id=version.id,
+            plan_id=plan.id,
+            applicant_snapshot=applicant_snapshot,
+            insured_snapshot=insured_snapshot,
+            amount_cents=premium_cents,
+            currency="CNY",
+            status=case.status,
+            created_at=created_at,
+        )
+        enrollment.save_order(order)
+        if attributed_code is not None:
+            attribution.save_order_attribution(
+                OrderAttribution(
+                    id=stable_id(f"business-order-attribution:{index}"),
+                    order_id=order.id,
+                    h5_user_id=user_id,
+                    salesperson_id=attributed_code.salesperson_id,
+                    promotion_code_id=attributed_code.id,
+                    source_code=attributed_code.code,
+                    amount_cents=premium_cents,
+                    attributed_at=created_at,
+                )
+            )
+
+        if case.payment_status is not None:
+            payment_id = stable_id(f"business-payment:{index}")
+            succeeded_at = (
+                created_at + timedelta(minutes=2) if case.payment_status == "SUCCEEDED" else None
+            )
+            enrollment.save_payment(
+                PaymentTransaction(
+                    id=payment_id,
+                    payment_no=f"PAY-{created_at:%Y%m%d}-B{index:07d}",
+                    order_id=order_id,
+                    user_id=user_id,
+                    transaction_type="PAYMENT",
+                    provider="LOCAL_MOCK_PAY",
+                    idempotency_key=f"business-payment-{index:04d}",
+                    amount_cents=premium_cents,
+                    currency="CNY",
+                    status=case.payment_status,
+                    provider_transaction_no=(
+                        f"MP-{created_at:%Y%m%d}-B{index:07d}"
+                        if case.payment_status == "SUCCEEDED"
+                        else None
+                    ),
+                    succeeded_at=succeeded_at,
+                    created_at=created_at + timedelta(minutes=1),
+                )
+            )
+
+        if case.policy_status is not None:
+            coverage_start = (created_at + timedelta(days=1)).date()
+            coverage_end = coverage_start + timedelta(days=365)
+            enrollment.save_policy(
+                Policy(
+                    id=stable_id(f"business-policy:{index}"),
+                    policy_no=f"POL-{created_at:%Y%m%d}-B{index:07d}",
+                    order_id=order_id,
+                    user_id=user_id,
+                    product_id=product.id,
+                    product_version_id=version.id,
+                    plan_id=plan.id,
+                    premium_cents=premium_cents,
+                    currency="CNY",
+                    coverage_start=coverage_start,
+                    coverage_end=coverage_end,
+                    status=case.policy_status,
+                    issued_at=created_at + timedelta(minutes=4),
+                )
+            )
+
+    for index in range(1, LEAD_ONLY_COUNT + 1):
+        mobile = f"1887002{index:04d}"
+        user_id = stable_id(f"business-lead-only-user:{mobile}")
+        promotion_index = ATTRIBUTION_PATTERN[
+            (index * 5 + index // 4) % (len(ATTRIBUTION_PATTERN) - 2)
+        ]
+        if promotion_index is None:
+            continue
+        promotion_code = manifest.promotion_codes[promotion_index]
+        created_days_ago = (index * 11 + 3) % 150
+        identities.save_h5_user(
+            H5User(
+                id=user_id,
+                mobile=mobile,
+                mobile_masked=f"{mobile[:3]}****{mobile[-4:]}",
+                display_name=f"咨询客户{index:02d}",
+                status="ACTIVE",
+                created_at=anchor - timedelta(days=created_days_ago),
+            )
+        )
+        attribution.save_lead(
+            LeadAttribution(
+                id=stable_id(f"business-lead-only:{index}"),
+                h5_user_id=user_id,
+                salesperson_id=promotion_code.salesperson_id,
+                promotion_code_id=promotion_code.id,
+                source_code=promotion_code.code,
+                visit_count=1 + (index * 3) % 7,
+                first_touch_at=anchor - timedelta(days=created_days_ago),
+                last_touch_at=anchor - timedelta(days=max(0, created_days_ago - (index % 12))),
+            )
+        )
+
+    engine.dispose()
+    return {
+        "customers": len(customer_ids) + LEAD_ONLY_COUNT,
+        "orders": len(CASES),
+        "policies": sum(case.policy_status is not None for case in CASES),
+        "payments": sum(case.payment_status is not None for case in CASES),
+        "leads": sum(
+            ATTRIBUTION_PATTERN[(index - 1) % len(ATTRIBUTION_PATTERN)] is not None
+            for index in range(1, len(CASES) + 1)
+        )
+        + LEAD_ONLY_COUNT,
+        "attributed_orders": sum(
+            ATTRIBUTION_PATTERN[(index - 1) % len(ATTRIBUTION_PATTERN)] is not None
+            for index in range(1, len(CASES) + 1)
+        ),
+    }
+
+
+if __name__ == "__main__":
+    result = run_seed()
+    print(
+        "业务数据写入完成:"
+        f"{result['customers']} 位投保用户,"
+        f"{result['orders']} 笔订单,"
+        f"{result['payments']} 条支付流水,"
+        f"{result['policies']} 张保单,"
+        f"{result['leads']} 条推广线索,"
+        f"{result['attributed_orders']} 笔推广归因订单。"
+    )

+ 56 - 0
backend/app/commands/verify_seed.py

@@ -0,0 +1,56 @@
+from sqlalchemy import func, select
+
+from app.core.config import Settings
+from app.infrastructure.mysql.core_models import (
+    AdminUserRecord,
+    PlanRecord,
+    ProductRecord,
+    ProductVersionRecord,
+)
+from app.infrastructure.mysql.sessions import (
+    DatabaseName,
+    create_mysql_engine,
+    create_session_factory,
+)
+
+EXPECTED_COUNTS = {
+    "admin_users": 9,
+    "products": 4,
+    "product_versions": 7,
+    "plans": 10,
+}
+
+
+def read_seed_counts(settings: Settings | None = None) -> dict[str, int]:
+    resolved_settings = settings or Settings()
+    engine = create_mysql_engine(resolved_settings, DatabaseName.CORE)
+    factory = create_session_factory(engine)
+    models = {
+        "admin_users": AdminUserRecord,
+        "products": ProductRecord,
+        "product_versions": ProductVersionRecord,
+        "plans": PlanRecord,
+    }
+    with factory() as session:
+        counts = {
+            name: session.scalar(select(func.count()).select_from(model)) or 0
+            for name, model in models.items()
+        }
+    engine.dispose()
+    return counts
+
+
+def main() -> None:
+    counts = read_seed_counts()
+    failures = {
+        name: (counts[name], expected)
+        for name, expected in EXPECTED_COUNTS.items()
+        if counts[name] != expected
+    }
+    if failures:
+        raise SystemExit(f"种子数据数量不符合固定契约:{failures}")
+    print(f"种子数据验证通过:{counts}")
+
+
+if __name__ == "__main__":
+    main()

+ 202 - 0
backend/app/commands/verify_stage_one.py

@@ -0,0 +1,202 @@
+"""对已启动的本地服务执行第一阶段非 AI 业务端到端验收。"""
+
+import argparse
+import json
+import sys
+import time
+
+import httpx
+from sqlalchemy import select
+
+from app.core.config import Settings
+from app.infrastructure.mysql.core_models import AsyncTaskRecord
+from app.infrastructure.mysql.sessions import (
+    DatabaseName,
+    create_mysql_engine,
+    create_session_factory,
+)
+
+
+def main() -> int:
+    if hasattr(sys.stdout, "reconfigure"):
+        sys.stdout.reconfigure(encoding="utf-8")
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--base-url", default="http://127.0.0.1:8000/api/v1")
+    parser.add_argument("--admin-password", default="zaq1XSW@")
+    arguments = parser.parse_args()
+    mobile = f"189{int(time.time()) % 100_000_000:08d}"
+    with httpx.Client(base_url=arguments.base_url, timeout=30) as client:
+        h5_login = client.post(
+            "/h5/auth/login",
+            json={
+                "mobile": mobile,
+                "code": "147258",
+                "referral_code": "ZBT-SALES-A",
+            },
+        )
+        h5_login.raise_for_status()
+        h5_data = h5_login.json()["data"]
+        assert h5_data["attribution"]["attributed"] is True
+        headers = {
+            "Authorization": f"Bearer {h5_data['tokens']['access_token']}",
+        }
+        products = client.get("/h5/products", headers=headers)
+        products.raise_for_status()
+        product = next(
+            item
+            for item in products.json()["data"]["items"]
+            if item["product_code"] == "MED-SENIOR"
+        )
+        quote = client.post(
+            "/h5/quotes",
+            headers=headers,
+            json={
+                "product_id": product["product_id"],
+                "plan_id": product["plans"][0]["id"],
+                "insured": {
+                    "age": 65,
+                    "region_code": "510100",
+                    "occupation_code": "GENERAL",
+                },
+                "relationship": "PARENT",
+            },
+        )
+        quote.raise_for_status()
+        draft = client.post(
+            "/h5/enrollment-drafts",
+            headers=headers,
+            json={
+                "quote_id": quote.json()["data"]["quote_id"],
+                "applicant": {
+                    "name": "端到端验收用户",
+                    "id_no": "510104199001011234",
+                },
+                "insured": {
+                    "name": "端到端验收父亲",
+                    "id_no": "510104196101011234",
+                },
+                "contact": {"mobile": mobile},
+            },
+        )
+        draft.raise_for_status()
+        draft_id = draft.json()["data"]["draft_id"]
+        confirmation = client.post(
+            f"/h5/enrollment-drafts/{draft_id}/confirmation",
+            headers=headers,
+        )
+        confirmation.raise_for_status()
+        order = client.post(
+            "/h5/orders",
+            headers={**headers, "Idempotency-Key": f"e2e-order-{mobile}"},
+            json={
+                "draft_id": draft_id,
+                "confirmation_token": confirmation.json()["data"]["confirmation_token"],
+            },
+        )
+        order.raise_for_status()
+        order_data = order.json()["data"]
+        payment = client.post(
+            f"/h5/orders/{order_data['order_id']}/payments",
+            headers={**headers, "Idempotency-Key": f"e2e-payment-{mobile}"},
+        )
+        payment.raise_for_status()
+        completed = client.post(
+            f"/dev/mock-payments/{payment.json()['data']['payment_id']}/complete",
+            headers=headers,
+        )
+        completed.raise_for_status()
+        completion = completed.json()["data"]
+        policies = client.get("/h5/policies", headers=headers)
+        policies.raise_for_status()
+
+        admin_login = client.post(
+            "/admin/auth/login",
+            json={"username": "admin", "password": arguments.admin_password},
+        )
+        admin_login.raise_for_status()
+        admin_headers = {
+            "Authorization": (f"Bearer {admin_login.json()['data']['tokens']['access_token']}")
+        }
+        admin_order = client.get(
+            f"/admin/orders/{order_data['order_id']}",
+            headers=admin_headers,
+        )
+        attribution = client.get(
+            "/admin/attribution/overview",
+            headers=admin_headers,
+        )
+        managed_products = client.get("/admin/products", headers=admin_headers)
+        roles = client.get("/admin/roles", headers=admin_headers)
+        users = client.get("/admin/users", headers=admin_headers)
+        for response in (admin_order, attribution, managed_products, roles, users):
+            response.raise_for_status()
+        managed_product = managed_products.json()["data"]["items"][0]
+        original_status = managed_product["status"]
+        changed_status = "INACTIVE" if original_status == "ACTIVE" else "ACTIVE"
+        changed_product = client.put(
+            f"/admin/products/{managed_product['product_id']}/status",
+            headers=admin_headers,
+            json={"status": changed_status},
+        )
+        changed_product.raise_for_status()
+        restored_product = client.put(
+            f"/admin/products/{managed_product['product_id']}/status",
+            headers=admin_headers,
+            json={"status": original_status},
+        )
+        restored_product.raise_for_status()
+
+        salesperson_login = client.post(
+            "/admin/auth/login",
+            json={"username": "sales_a", "password": arguments.admin_password},
+        )
+        salesperson_login.raise_for_status()
+        salesperson_headers = {
+            "Authorization": (
+                f"Bearer {salesperson_login.json()['data']['tokens']['access_token']}"
+            )
+        }
+        scoped_orders = client.get("/admin/orders", headers=salesperson_headers)
+        scoped_orders.raise_for_status()
+        scoped_items = scoped_orders.json()["data"]["items"]
+        scoped_order = next(
+            item for item in scoped_items if item["order_id"] == order_data["order_id"]
+        )
+        assert "*" in scoped_order["applicant"]["id_no"]
+
+    settings = Settings()
+    engine = create_mysql_engine(settings, DatabaseName.CORE)
+    session_factory = create_session_factory(engine)
+    with session_factory() as session:
+        task = session.scalar(
+            select(AsyncTaskRecord).where(
+                AsyncTaskRecord.idempotency_key == f"policy.issue:{order_data['order_id']}"
+            )
+        )
+    engine.dispose()
+    assert task is not None
+    assert task.status == "SUCCEEDED"
+    assert task.attempt_count == 1
+    assert completion["order_status"] == "ISSUED"
+    assert policies.json()["data"]["total"] == 1
+    summary = {
+        "mobile": mobile,
+        "order_no": order_data["order_no"],
+        "policy_no": completion["policy"]["policy_no"],
+        "task_status": task.status,
+        "task_attempt_count": task.attempt_count,
+        "attribution_orders": attribution.json()["data"]["order_count"],
+        "managed_products": managed_products.json()["data"]["total"],
+        "admin_order_visible": (admin_order.json()["data"]["order_id"] == order_data["order_id"]),
+        "product_status_restored": (restored_product.json()["data"]["status"] == original_status),
+        "role_count": len(roles.json()["data"]["items"]),
+        "admin_user_count": len(users.json()["data"]["admin_users"]),
+        "salesperson_scoped_orders": len(scoped_items),
+        "salesperson_data_masked": "*" in scoped_order["applicant"]["id_no"],
+    }
+    print(json.dumps(summary, ensure_ascii=False, indent=2))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 1 - 0
backend/app/core/__init__.py

@@ -0,0 +1 @@
+"""通用基础能力。"""

+ 107 - 0
backend/app/core/config.py

@@ -0,0 +1,107 @@
+"""集中读取和校验项目配置。
+
+配置优先从后端目录的 ``.env`` 读取;若某项未配置,则使用字段右侧的默认值。
+Pydantic 会自动把环境变量中的字符串转换成声明的 Python 类型。
+"""
+
+from functools import lru_cache
+from pathlib import Path
+from typing import Literal
+
+from pydantic import Field, field_validator
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+# 当前文件位于 backend/app/core,向上两级就是后端项目根目录。
+BACKEND_ROOT = Path(__file__).resolve().parents[2]
+
+
+class Settings(BaseSettings):
+    """应用运行时需要的全部配置及其默认值。"""
+
+    model_config = SettingsConfigDict(
+        env_file=BACKEND_ROOT / ".env",
+        env_file_encoding="utf-8",
+        case_sensitive=False,
+        extra="ignore",
+    )
+
+    # Literal 限制只能使用列出的三种环境名称,拼写错误会在启动时直接报错。
+    app_env: Literal["development", "test", "production"] = "development"
+    app_host: str = "127.0.0.1"
+    app_port: int = 8000
+    app_timezone: str = "Asia/Shanghai"
+    app_cors_origins: list[str] = [
+        "http://127.0.0.1:5173",
+        "http://127.0.0.1:5174",
+    ]
+
+    mysql_host: str = "127.0.0.1"
+    mysql_port: int = 3306
+    mysql_user: str = "zbt_s1"
+    mysql_password: str = ""
+    mysql_core_database: str = "insurance_s1_core"
+    mysql_agent_database: str = "insurance_s1_agent"
+    mysql_analytics_database: str = "insurance_s1_analytics"
+
+    redis_url: str = "redis://127.0.0.1:6379/0"
+    redis_prefix: str = "ins:s1:"
+    agent_rate_limit: int = Field(default=20, ge=1, le=1000)
+    agent_rate_window_seconds: int = Field(default=60, ge=1, le=3600)
+    agent_hot_state_ttl_seconds: int = Field(default=3600, ge=60, le=86400)
+    agent_event_ttl_seconds: int = Field(default=1800, ge=60, le=86400)
+
+    deepseek_api_key: str = ""
+    deepseek_base_url: str = "https://api.deepseek.com"
+    deepseek_model: str = "deepseek-chat"
+    langsmith_api_key: str = ""
+    langsmith_endpoint: str = "https://api.smith.langchain.com"
+    langsmith_project: str = "智保通-第一阶段-智能投保"
+    langsmith_tracing: bool = True
+
+    jwt_access_secret: str = Field(default="development-access-secret-change-me", min_length=32)
+    jwt_refresh_secret: str = Field(default="development-refresh-secret-change-me", min_length=32)
+    field_encryption_key: str = Field(default="development-field-key-change-me-now", min_length=32)
+    dev_fixed_otp: str = Field(default="147258", pattern=r"^\d{6}$")
+    dev_tools_enabled: bool = True
+    mock_payment_callback_secret: str = Field(
+        default="development-mock-payment-secret-change-me",
+        min_length=32,
+    )
+
+    access_token_minutes: int = 15
+    refresh_token_days: int = 7
+
+    # 以下验证器限制程序只能访问本项目约定的数据库,避免误连或误操作其他库。
+    @field_validator("mysql_core_database")
+    @classmethod
+    def validate_core_database(cls, value: str) -> str:
+        if value not in {"insurance_s1_core", "insurance_s1_test_core"}:
+            raise ValueError("第一阶段Core数据库名称不在白名单")
+        return value
+
+    @field_validator("mysql_agent_database")
+    @classmethod
+    def validate_agent_database(cls, value: str) -> str:
+        if value not in {"insurance_s1_agent", "insurance_s1_test_agent"}:
+            raise ValueError("第一阶段Agent数据库名称不在白名单")
+        return value
+
+    @field_validator("mysql_analytics_database")
+    @classmethod
+    def validate_analytics_database(cls, value: str) -> str:
+        if value not in {"insurance_s1_analytics", "insurance_s1_test_analytics"}:
+            raise ValueError("第一阶段Analytics数据库名称不在白名单")
+        return value
+
+    @field_validator("redis_prefix")
+    @classmethod
+    def validate_redis_prefix(cls, value: str) -> str:
+        if value != "ins:s1:" and not value.startswith("ins:s1:test:"):
+            raise ValueError("第一阶段Redis前缀不在白名单")
+        return value
+
+
+@lru_cache
+def get_settings() -> Settings:
+    """创建并缓存配置对象,后续调用复用同一个实例。"""
+    return Settings()

+ 11 - 0
backend/app/core/errors.py

@@ -0,0 +1,11 @@
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass(slots=True)
+class AppError(Exception):
+    code: str
+    message: str
+    status_code: int
+    details: dict[str, Any] | None = None
+    retryable: bool = False

+ 9 - 0
backend/app/core/identifiers.py

@@ -0,0 +1,9 @@
+import ulid
+
+
+def new_ulid() -> str:
+    return str(ulid.new())
+
+
+def new_request_id() -> str:
+    return f"req_{new_ulid()}"

+ 12 - 0
backend/app/core/passwords.py

@@ -0,0 +1,12 @@
+from pwdlib import PasswordHash
+
+
+class PasswordService:
+    def __init__(self) -> None:
+        self._password_hash = PasswordHash.recommended()
+
+    def hash(self, password: str) -> str:
+        return self._password_hash.hash(password)
+
+    def verify(self, password: str, password_hash: str) -> bool:
+        return self._password_hash.verify(password, password_hash)

+ 129 - 0
backend/app/core/security.py

@@ -0,0 +1,129 @@
+"""JWT(JSON Web Token)的签发与校验。
+
+access token 有效期较短,用于访问接口;refresh token 有效期较长,只用于换新令牌。
+"""
+
+import hashlib
+from dataclasses import dataclass
+from datetime import UTC, datetime, timedelta
+from typing import Any
+
+import jwt
+
+from app.core.config import Settings
+from app.core.errors import AppError
+from app.core.identifiers import new_ulid
+
+
+@dataclass(frozen=True, slots=True)
+class TokenPair:
+    """一次登录签发的一对令牌及其有效期。"""
+
+    access_token: str
+    refresh_token: str
+    access_expires_in: int
+    refresh_expires_in: int
+    refresh_jti: str
+
+
+class JwtService:
+    """封装 PyJWT,统一项目中的令牌字段、密钥和校验规则。"""
+
+    def __init__(self, settings: Settings) -> None:
+        self._settings = settings
+
+    def issue_pair(
+        self,
+        *,
+        subject_id: str,
+        subject_type: str,
+        audience: str,
+        session_id: str,
+        now: datetime | None = None,
+    ) -> TokenPair:
+        """为一个用户会话签发 access token 和 refresh token。"""
+        issued_at = now or datetime.now(UTC)
+        access_seconds = self._settings.access_token_minutes * 60
+        refresh_seconds = self._settings.refresh_token_days * 24 * 60 * 60
+        refresh_jti = new_ulid()
+        # 两种令牌共有的标准/自定义声明;sub 是用户,sid 是服务端会话。
+        common = {
+            "sub": subject_id,
+            "subject_type": subject_type,
+            "aud": audience,
+            "iss": "zhibaotong-s1",
+            "sid": session_id,
+            "iat": issued_at,
+        }
+        access = jwt.encode(
+            {
+                # ** 会把 common 字典的键值展开并合并到新字典。
+                **common,
+                "typ": "access",
+                "exp": issued_at + timedelta(seconds=access_seconds),
+            },
+            self._settings.jwt_access_secret,
+            algorithm="HS256",
+        )
+        refresh = jwt.encode(
+            {
+                **common,
+                "typ": "refresh",
+                "jti": refresh_jti,
+                "exp": issued_at + timedelta(seconds=refresh_seconds),
+            },
+            self._settings.jwt_refresh_secret,
+            algorithm="HS256",
+        )
+        return TokenPair(
+            access_token=access,
+            refresh_token=refresh,
+            access_expires_in=access_seconds,
+            refresh_expires_in=refresh_seconds,
+            refresh_jti=refresh_jti,
+        )
+
+    def decode_access(self, token: str, *, audience: str) -> dict[str, Any]:
+        """校验并解码 access token。"""
+        return self._decode(
+            token,
+            secret=self._settings.jwt_access_secret,
+            audience=audience,
+            token_type="access",
+        )
+
+    def decode_refresh(self, token: str, *, audience: str) -> dict[str, Any]:
+        """校验并解码 refresh token。"""
+        return self._decode(
+            token,
+            secret=self._settings.jwt_refresh_secret,
+            audience=audience,
+            token_type="refresh",
+        )
+
+    @staticmethod
+    def hash_token_identifier(value: str) -> str:
+        return hashlib.sha256(value.encode("utf-8")).hexdigest()
+
+    @staticmethod
+    def _decode(
+        token: str,
+        *,
+        secret: str,
+        audience: str,
+        token_type: str,
+    ) -> dict[str, Any]:
+        """执行公共解码逻辑,并把第三方库异常转换成本项目的业务异常。"""
+        try:
+            payload = jwt.decode(
+                token,
+                secret,
+                algorithms=["HS256"],
+                audience=audience,
+                issuer="zhibaotong-s1",
+            )
+        except jwt.PyJWTError as exc:
+            raise AppError("AUTH_REQUIRED", "登录凭证无效或已过期", 401) from exc
+        if payload.get("typ") != token_type:
+            raise AppError("AUTH_REQUIRED", "登录凭证类型无效", 401)
+        return payload

+ 1 - 0
backend/app/domains/__init__.py

@@ -0,0 +1 @@
+"""领域模块。"""

+ 1 - 0
backend/app/domains/agent/__init__.py

@@ -0,0 +1 @@
+"""智能体会话领域。"""

+ 40 - 0
backend/app/domains/agent/models.py

@@ -0,0 +1,40 @@
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Any
+
+
+@dataclass(frozen=True)
+class AgentThread:
+    id: str
+    owner_type: str
+    owner_id: str
+    persona: str
+    title: str
+    status: str
+    created_at: datetime
+    updated_at: datetime
+
+
+@dataclass(frozen=True)
+class AgentMessage:
+    id: str
+    thread_id: str
+    role: str
+    content: str
+    metadata: dict[str, Any]
+    created_at: datetime
+
+
+@dataclass(frozen=True)
+class AgentRun:
+    id: str
+    request_id: str
+    thread_id: str
+    trace_id: str | None
+    persona: str
+    status: str
+    input: dict[str, Any]
+    output: dict[str, Any] | None
+    error_code: str | None
+    created_at: datetime
+    completed_at: datetime | None

+ 58 - 0
backend/app/domains/agent/repository.py

@@ -0,0 +1,58 @@
+from typing import Protocol
+
+from app.domains.agent.models import AgentMessage, AgentRun, AgentThread
+
+
+class AgentRepository(Protocol):
+    def save_thread(self, thread: AgentThread) -> None: ...
+
+    def get_thread(self, thread_id: str) -> AgentThread | None: ...
+
+    def list_threads(self, owner_type: str, owner_id: str) -> list[AgentThread]: ...
+
+    def save_message(self, message: AgentMessage) -> None: ...
+
+    def list_messages(self, thread_id: str) -> list[AgentMessage]: ...
+
+    def save_run(self, run: AgentRun) -> None: ...
+
+    def get_run(self, run_id: str) -> AgentRun | None: ...
+
+    def list_runs(self, thread_ids: tuple[str, ...], limit: int = 20) -> list[AgentRun]: ...
+
+
+class InMemoryAgentRepository:
+    def __init__(self) -> None:
+        self._threads: dict[str, AgentThread] = {}
+        self._messages: dict[str, AgentMessage] = {}
+        self._runs: dict[str, AgentRun] = {}
+
+    def save_thread(self, thread: AgentThread) -> None:
+        self._threads[thread.id] = thread
+
+    def get_thread(self, thread_id: str) -> AgentThread | None:
+        return self._threads.get(thread_id)
+
+    def list_threads(self, owner_type: str, owner_id: str) -> list[AgentThread]:
+        return [
+            thread
+            for thread in self._threads.values()
+            if thread.owner_type == owner_type and thread.owner_id == owner_id
+        ]
+
+    def save_message(self, message: AgentMessage) -> None:
+        self._messages[message.id] = message
+
+    def list_messages(self, thread_id: str) -> list[AgentMessage]:
+        return [message for message in self._messages.values() if message.thread_id == thread_id]
+
+    def save_run(self, run: AgentRun) -> None:
+        self._runs[run.id] = run
+
+    def get_run(self, run_id: str) -> AgentRun | None:
+        return self._runs.get(run_id)
+
+    def list_runs(self, thread_ids: tuple[str, ...], limit: int = 20) -> list[AgentRun]:
+        allowed = set(thread_ids)
+        runs = [run for run in self._runs.values() if run.thread_id in allowed]
+        return sorted(runs, key=lambda run: run.created_at, reverse=True)[:limit]

+ 32 - 0
backend/app/domains/agent/runtime.py

@@ -0,0 +1,32 @@
+"""Agent 运行时端口。
+
+实际 LangChain/DeepSeek 实现在 ``app.harness.kernel``,领域服务只依赖此协议。
+"""
+
+from typing import Protocol
+
+from app.core.errors import AppError
+from app.harness.kernel import AgentInvocation
+from app.harness.schemas import AgentReply
+
+__all__ = [
+    "AgentInvocation",
+    "AgentReply",
+    "AgentRuntime",
+    "UnavailableAgentRuntime",
+]
+
+
+class AgentRuntime(Protocol):
+    def reply(self, invocation: AgentInvocation) -> AgentReply: ...
+
+
+class UnavailableAgentRuntime:
+    def reply(self, invocation: AgentInvocation) -> AgentReply:
+        del invocation
+        raise AppError(
+            "AGENT_NOT_CONFIGURED",
+            "DeepSeek尚未配置,无法启动智能体",
+            503,
+            retryable=False,
+        )

+ 541 - 0
backend/app/domains/agent/service.py

@@ -0,0 +1,541 @@
+from collections.abc import Callable, Iterator
+from dataclasses import dataclass
+from datetime import datetime
+from secrets import randbelow
+from typing import Any
+
+from app.core.errors import AppError
+from app.core.identifiers import new_ulid
+from app.domains.agent.models import AgentMessage, AgentRun, AgentThread
+from app.domains.agent.repository import AgentRepository
+from app.domains.agent.runtime import AgentRuntime
+from app.domains.identity.models import AdminUser, H5User
+from app.harness.events import RuntimeEvent
+from app.harness.kernel import AgentInvocation, ConversationTurn
+from app.harness.schemas import AgentReply
+from app.infrastructure.redis.agent_state import (
+    AgentStateStore,
+    InMemoryAgentStateStore,
+)
+
+
+@dataclass(frozen=True)
+class PreparedMessage:
+    thread: AgentThread
+    text: str
+    run_id: str
+    service_no: str
+    started_at: datetime
+    history: tuple[ConversationTurn, ...]
+    h5_user: H5User | None
+    admin_user: AdminUser | None
+
+
+class AgentThreadService:
+    """客户与运营人格共用的会话、消息、运行事件和限流服务。"""
+
+    def __init__(
+        self,
+        repository: AgentRepository,
+        clock: Callable[[], datetime],
+        runtime: AgentRuntime,
+        state_store: AgentStateStore | None = None,
+    ) -> None:
+        self._repository = repository
+        self._clock = clock
+        self._runtime = runtime
+        self._state_store = state_store or InMemoryAgentStateStore()
+
+    def create_customer_thread(
+        self,
+        user: H5User,
+        *,
+        title: str | None,
+    ) -> dict[str, Any]:
+        return self._create_thread(
+            owner_type="H5_USER",
+            owner_id=user.id,
+            persona="customer",
+            title=title or "新的保障咨询",
+        )
+
+    def create_operation_thread(
+        self,
+        user: AdminUser,
+        *,
+        title: str | None,
+    ) -> dict[str, Any]:
+        return self._create_thread(
+            owner_type="ADMIN_USER",
+            owner_id=user.id,
+            persona="operation",
+            title=title or "新的运营分析",
+        )
+
+    def send_customer_message(
+        self,
+        user: H5User,
+        *,
+        thread_id: str,
+        text: str,
+    ) -> dict[str, Any]:
+        prepared = self._prepare_message(
+            self._owned_thread("H5_USER", user.id, thread_id),
+            text,
+            h5_user=user,
+        )
+        return self._execute(prepared)
+
+    def stream_customer_message(
+        self,
+        user: H5User,
+        *,
+        thread_id: str,
+        text: str,
+    ) -> Iterator[RuntimeEvent]:
+        prepared = self._prepare_message(
+            self._owned_thread("H5_USER", user.id, thread_id),
+            text,
+            h5_user=user,
+        )
+        yield self._emit(
+            prepared,
+            "run.started",
+            1,
+            {"persona": prepared.thread.persona},
+        )
+        try:
+            reply = self._invoke(prepared)
+            result = self._complete(prepared, reply)
+        except AppError as error:
+            self._save_failed_run(prepared, error)
+            yield self._emit(
+                prepared,
+                "run.failed",
+                2,
+                {
+                    "code": error.code,
+                    "message": error.message,
+                    "retryable": error.retryable,
+                },
+            )
+            return
+        sequence = 2
+        for tool_name in reply.invoked_tools:
+            yield self._emit(
+                prepared,
+                "tool.completed",
+                sequence,
+                {"tool_name": tool_name},
+            )
+            sequence += 1
+        if reply.cards or reply.actions:
+            yield self._emit(
+                prepared,
+                "ui.ready",
+                sequence,
+                {
+                    "cards": reply.cards,
+                    "actions": reply.actions,
+                },
+            )
+            sequence += 1
+        yield self._emit(
+            prepared,
+            "run.completed",
+            sequence,
+            {"result": result},
+        )
+
+    def send_operation_message(
+        self,
+        user: AdminUser,
+        *,
+        thread_id: str,
+        text: str,
+    ) -> dict[str, Any]:
+        prepared = self._prepare_message(
+            self._owned_thread("ADMIN_USER", user.id, thread_id),
+            text,
+            admin_user=user,
+        )
+        return self._execute(prepared)
+
+    def list_customer_messages(
+        self,
+        user: H5User,
+        *,
+        thread_id: str,
+    ) -> dict[str, Any]:
+        self._owned_thread("H5_USER", user.id, thread_id)
+        return self._message_data(thread_id)
+
+    def list_operation_messages(
+        self,
+        user: AdminUser,
+        *,
+        thread_id: str,
+    ) -> dict[str, Any]:
+        self._owned_thread("ADMIN_USER", user.id, thread_id)
+        return self._message_data(thread_id)
+
+    def get_customer_run(self, user: H5User, *, run_id: str) -> dict[str, Any]:
+        return self._get_run("H5_USER", user.id, run_id)
+
+    def get_operation_run(
+        self,
+        user: AdminUser,
+        *,
+        run_id: str,
+    ) -> dict[str, Any]:
+        return self._get_run("ADMIN_USER", user.id, run_id)
+
+    def list_operation_runs(
+        self,
+        user: AdminUser,
+        *,
+        limit: int = 20,
+    ) -> dict[str, Any]:
+        threads = self._repository.list_threads("ADMIN_USER", user.id)
+        runs = self._repository.list_runs(
+            tuple(thread.id for thread in threads),
+            limit,
+        )
+        return {
+            "items": [
+                {
+                    "id": run.id,
+                    "thread_id": run.thread_id,
+                    "persona": run.persona,
+                    "status": run.status,
+                    "trace_id": run.trace_id,
+                    "trace_url": self._resolve_trace_url(run),
+                    "input": run.input,
+                    "error_code": run.error_code,
+                    "created_at": run.created_at,
+                    "completed_at": run.completed_at,
+                }
+                for run in runs
+            ],
+            "total": len(runs),
+        }
+
+    def list_customer_run_events(
+        self,
+        user: H5User,
+        *,
+        run_id: str,
+    ) -> list[RuntimeEvent]:
+        self._get_run("H5_USER", user.id, run_id)
+        return self._state_store.list_events(run_id)
+
+    def _create_thread(
+        self,
+        *,
+        owner_type: str,
+        owner_id: str,
+        persona: str,
+        title: str,
+    ) -> dict[str, Any]:
+        now = self._clock()
+        thread = AgentThread(
+            id=new_ulid(),
+            owner_type=owner_type,
+            owner_id=owner_id,
+            persona=persona,
+            title=title.strip(),
+            status="ACTIVE",
+            created_at=now,
+            updated_at=now,
+        )
+        self._repository.save_thread(thread)
+        self._state_store.touch_thread(
+            thread.id,
+            owner_type=thread.owner_type,
+            owner_id=thread.owner_id,
+            persona=thread.persona,
+        )
+        return {
+            "id": thread.id,
+            "persona": thread.persona,
+            "title": thread.title,
+            "status": thread.status,
+        }
+
+    def _prepare_message(
+        self,
+        thread: AgentThread,
+        text: str,
+        *,
+        h5_user: H5User | None = None,
+        admin_user: AdminUser | None = None,
+    ) -> PreparedMessage:
+        principal = h5_user or admin_user
+        if principal is None:
+            raise AppError("AGENT_PRINCIPAL_REQUIRED", "Agent缺少登录身份", 401)
+        if not self._state_store.allow_request(principal.id):
+            raise AppError(
+                "AGENT_RATE_LIMITED",
+                "请求过于频繁,请稍后再试",
+                429,
+                retryable=True,
+            )
+        history = tuple(
+            ConversationTurn(
+                role="assistant" if message.role == "ASSISTANT" else "user",
+                content=message.content,
+            )
+            for message in self._repository.list_messages(thread.id)[-12:]
+        )
+        now = self._clock()
+        run_id = new_ulid()
+        service_no = f"ZBT-{now:%Y%m%d}-{randbelow(10_000):04d}"
+        self._repository.save_message(
+            AgentMessage(
+                id=new_ulid(),
+                thread_id=thread.id,
+                role="USER",
+                content=text,
+                metadata={"run_id": run_id, "service_no": service_no},
+                created_at=now,
+            )
+        )
+        self._state_store.touch_thread(
+            thread.id,
+            owner_type=thread.owner_type,
+            owner_id=thread.owner_id,
+            persona=thread.persona,
+        )
+        return PreparedMessage(
+            thread=thread,
+            text=text,
+            run_id=run_id,
+            service_no=service_no,
+            started_at=now,
+            history=history,
+            h5_user=h5_user,
+            admin_user=admin_user,
+        )
+
+    def _execute(self, prepared: PreparedMessage) -> dict[str, Any]:
+        self._emit(
+            prepared,
+            "run.started",
+            1,
+            {"persona": prepared.thread.persona},
+        )
+        try:
+            reply = self._invoke(prepared)
+            result = self._complete(prepared, reply)
+        except AppError as error:
+            self._save_failed_run(prepared, error)
+            self._emit(
+                prepared,
+                "run.failed",
+                2,
+                {
+                    "code": error.code,
+                    "message": error.message,
+                    "retryable": error.retryable,
+                },
+            )
+            raise
+        sequence = 2
+        for tool_name in reply.invoked_tools:
+            self._emit(
+                prepared,
+                "tool.completed",
+                sequence,
+                {"tool_name": tool_name},
+            )
+            sequence += 1
+        if reply.cards or reply.actions:
+            self._emit(
+                prepared,
+                "ui.ready",
+                sequence,
+                {"cards": reply.cards, "actions": reply.actions},
+            )
+            sequence += 1
+        self._emit(
+            prepared,
+            "run.completed",
+            sequence,
+            {"result": result},
+        )
+        return result
+
+    def _invoke(self, prepared: PreparedMessage) -> AgentReply:
+        try:
+            return self._runtime.reply(
+                AgentInvocation(
+                    persona=prepared.thread.persona,
+                    message=prepared.text,
+                    h5_user=prepared.h5_user,
+                    admin_user=prepared.admin_user,
+                    history=prepared.history,
+                )
+            )
+        except AppError:
+            raise
+        except Exception as error:
+            raise AppError(
+                "AGENT_RUNTIME_FAILED",
+                "智能体服务暂不可用,请稍后重试",
+                502,
+                retryable=True,
+            ) from error
+
+    def _complete(
+        self,
+        prepared: PreparedMessage,
+        reply: AgentReply,
+    ) -> dict[str, Any]:
+        assistant_message = {
+            "type": "text",
+            "text": reply.text,
+            "cards": reply.cards,
+            "actions": reply.actions,
+            "service_no": prepared.service_no,
+        }
+        completed_at = self._clock()
+        self._repository.save_message(
+            AgentMessage(
+                id=new_ulid(),
+                thread_id=prepared.thread.id,
+                role="ASSISTANT",
+                content=reply.text,
+                metadata={
+                    "run_id": prepared.run_id,
+                    "service_no": prepared.service_no,
+                    "cards": reply.cards,
+                    "actions": reply.actions,
+                    "invoked_tools": list(reply.invoked_tools),
+                    "trace_url": reply.trace_url,
+                },
+                created_at=completed_at,
+            )
+        )
+        self._repository.save_run(
+            AgentRun(
+                id=prepared.run_id,
+                request_id=f"agent-{new_ulid()}",
+                thread_id=prepared.thread.id,
+                trace_id=reply.trace_id,
+                persona=prepared.thread.persona,
+                status="COMPLETED",
+                input={"type": "text", "text": prepared.text},
+                output={**assistant_message, "trace_url": reply.trace_url},
+                error_code=None,
+                created_at=prepared.started_at,
+                completed_at=completed_at,
+            )
+        )
+        return {
+            "run_id": prepared.run_id,
+            "thread_id": prepared.thread.id,
+            "status": "COMPLETED",
+            "assistant_message": assistant_message,
+            "service_no": prepared.service_no,
+            "trace_id": reply.trace_id,
+            "trace_url": reply.trace_url,
+            "invoked_tools": list(reply.invoked_tools),
+        }
+
+    def _save_failed_run(
+        self,
+        prepared: PreparedMessage,
+        error: AppError,
+    ) -> None:
+        self._repository.save_run(
+            AgentRun(
+                id=prepared.run_id,
+                request_id=f"agent-{new_ulid()}",
+                thread_id=prepared.thread.id,
+                trace_id=None,
+                persona=prepared.thread.persona,
+                status="FAILED",
+                input={"type": "text", "text": prepared.text},
+                output=None,
+                error_code=error.code,
+                created_at=prepared.started_at,
+                completed_at=self._clock(),
+            )
+        )
+
+    def _emit(
+        self,
+        prepared: PreparedMessage,
+        event: str,
+        sequence: int,
+        data: dict[str, Any],
+    ) -> RuntimeEvent:
+        runtime_event = RuntimeEvent.model_validate(
+            {
+                "event": event,
+                "run_id": prepared.run_id,
+                "sequence": sequence,
+                "occurred_at": self._clock(),
+                "data": data,
+            }
+        )
+        self._state_store.append_event(runtime_event)
+        return runtime_event
+
+    def _message_data(self, thread_id: str) -> dict[str, Any]:
+        messages = self._repository.list_messages(thread_id)
+        return {
+            "items": [
+                {
+                    "id": message.id,
+                    "role": message.role,
+                    "content": message.content,
+                    "metadata": message.metadata,
+                    "created_at": message.created_at,
+                }
+                for message in messages
+            ]
+        }
+
+    def _get_run(
+        self,
+        owner_type: str,
+        owner_id: str,
+        run_id: str,
+    ) -> dict[str, Any]:
+        run = self._repository.get_run(run_id)
+        if run is None:
+            raise AppError("AGENT_RUN_NOT_FOUND", "未找到智能体运行", 404)
+        self._owned_thread(owner_type, owner_id, run.thread_id)
+        trace_url = self._resolve_trace_url(run)
+        return {
+            "id": run.id,
+            "thread_id": run.thread_id,
+            "persona": run.persona,
+            "status": run.status,
+            "trace_id": run.trace_id,
+            "trace_url": trace_url,
+            "output": run.output,
+            "error_code": run.error_code,
+            "completed_at": run.completed_at,
+        }
+
+    def _resolve_trace_url(self, run: AgentRun) -> str | None:
+        trace_url = run.output.get("trace_url") if run.output is not None else None
+        if not trace_url and run.trace_id:
+            resolver = getattr(self._runtime, "trace_url", None)
+            if callable(resolver):
+                resolved = resolver(run.trace_id)
+                trace_url = str(resolved) if resolved else None
+        return str(trace_url) if trace_url else None
+
+    def _owned_thread(
+        self,
+        owner_type: str,
+        owner_id: str,
+        thread_id: str,
+    ) -> AgentThread:
+        thread = self._repository.get_thread(thread_id)
+        if thread is None or thread.owner_type != owner_type or thread.owner_id != owner_id:
+            raise AppError("AGENT_THREAD_NOT_FOUND", "未找到会话", 404)
+        return thread

+ 526 - 0
backend/app/domains/agent/tools.py

@@ -0,0 +1,526 @@
+"""智保通第一阶段 Agent 可调用的确定性业务工具。"""
+
+from typing import Any, Literal, Protocol
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from app.core.errors import AppError
+from app.domains.catalog.service import ProductCatalogService
+from app.domains.enrollment.service import EnrollmentService
+from app.harness.policy import ToolPolicy
+from app.harness.schemas import (
+    AgentAction,
+    BusinessListBlock,
+    HarnessToolResult,
+    MetricItem,
+    MetricsBlock,
+    QuoteBlock,
+    QuoteView,
+)
+from app.harness.tooling import ToolDefinition, ToolExecutionContext, ToolRegistry
+
+
+class AttributionMetricsProvider(Protocol):
+    def performance(self, admin_user_id: str | None = None) -> dict[str, Any]: ...
+
+    def order_ids(self, admin_user_id: str | None = None) -> set[str]: ...
+
+
+class EmptyAttributionMetrics:
+    def performance(self, admin_user_id: str | None = None) -> dict[str, Any]:
+        del admin_user_id
+        return {
+            "salesperson_count": 0,
+            "visit_count": 0,
+            "lead_count": 0,
+            "order_count": 0,
+            "premium_cents": 0,
+            "items": [],
+        }
+
+    def order_ids(self, admin_user_id: str | None = None) -> set[str]:
+        del admin_user_id
+        return set()
+
+
+class ToolArguments(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+
+
+class ListProductsArgs(ToolArguments):
+    category: Literal["MEDICAL", "ACCIDENT"] | None = None
+
+
+class CalculateQuoteArgs(ToolArguments):
+    product_code: str = Field(min_length=2, max_length=32)
+    plan_code: str = Field(min_length=2, max_length=32)
+    age: int = Field(ge=0, le=120)
+    region_code: str = Field(default="510100", pattern=r"^\d{6}$")
+    occupation_code: str = Field(default="GENERAL", min_length=2, max_length=32)
+    relationship: Literal["SELF", "PARENT", "SPOUSE", "CHILD"]
+
+
+class PrepareEnrollmentArgs(ToolArguments):
+    product_code: str = Field(min_length=2, max_length=32)
+    plan_code: str = Field(min_length=2, max_length=32)
+    age: int = Field(ge=0, le=120)
+    relationship: Literal["SELF", "PARENT", "SPOUSE", "CHILD"]
+
+
+class EmptyArgs(ToolArguments):
+    pass
+
+
+class RecentItemsArgs(ToolArguments):
+    limit: int = Field(default=5, ge=1, le=20)
+
+
+def build_agent_tool_registry(
+    catalog: ProductCatalogService,
+    enrollment: EnrollmentService,
+    attribution: AttributionMetricsProvider | None = None,
+) -> ToolRegistry:
+    registry = ToolRegistry()
+    metrics = attribution or EmptyAttributionMetrics()
+
+    def list_products(
+        context: ToolExecutionContext,
+        arguments: BaseModel,
+    ) -> HarnessToolResult:
+        del context
+        args = _arguments(arguments, ListProductsArgs)
+        products = catalog.list_available(category=args.category)
+        return HarnessToolResult(
+            summary=f"当前共有{len(products)}款符合条件的在售产品",
+            data={"items": products, "total": len(products)},
+        )
+
+    def calculate_quote(
+        context: ToolExecutionContext,
+        arguments: BaseModel,
+    ) -> HarnessToolResult:
+        args = _arguments(arguments, CalculateQuoteArgs)
+        user = context.h5_user
+        if user is None:
+            raise AppError("AGENT_CUSTOMER_REQUIRED", "保费测算需要H5用户身份", 401)
+        product, plan = _resolve_product_plan(
+            catalog,
+            args.product_code,
+            args.plan_code,
+        )
+        try:
+            quote = enrollment.create_quote(
+                user,
+                product_id=str(product["product_id"]),
+                plan_id=str(plan["id"]),
+                age=args.age,
+                region_code=args.region_code,
+                occupation_code=args.occupation_code,
+                relationship=args.relationship,
+            )
+        except AppError as error:
+            if error.code != "ELIGIBILITY_REJECTED":
+                raise
+            block = QuoteBlock(
+                quote=QuoteView(
+                    eligible=False,
+                    product_name=str(product["name"]),
+                    plan_name=str(plan["name"]),
+                    reason=error.message,
+                )
+            )
+            return HarnessToolResult(
+                summary=f"资格校验未通过:{error.message}",
+                data={
+                    "eligible": False,
+                    "reason_code": (error.details or {}).get("reason_code"),
+                },
+                blocks=[block],
+            )
+        block = QuoteBlock(
+            quote=QuoteView(
+                eligible=True,
+                quote_id=str(quote["quote_id"]),
+                product_id=str(quote["product_id"]),
+                product_name=str(product["name"]),
+                plan_id=str(quote["plan_id"]),
+                plan_name=str(plan["name"]),
+                premium_cents=int(quote["premium_cents"]),
+                currency=str(quote["currency"]),
+                expires_at=str(quote["expires_at"]),
+            )
+        )
+        return HarnessToolResult(
+            summary=(
+                f"{product['name']}{plan['name']}测算成功,"
+                f"年保费{int(quote['premium_cents']) / 100:.2f}元"
+            ),
+            data=quote,
+            blocks=[block],
+            actions=[
+                AgentAction(
+                    type="open_enrollment",
+                    label="填写投保信息",
+                    payload={
+                        "product_code": args.product_code,
+                        "plan_code": args.plan_code,
+                        "quote_id": quote["quote_id"],
+                        "age": args.age,
+                        "relationship": args.relationship,
+                    },
+                )
+            ],
+        )
+
+    def prepare_enrollment(
+        context: ToolExecutionContext,
+        arguments: BaseModel,
+    ) -> HarnessToolResult:
+        if context.h5_user is None:
+            raise AppError("AGENT_CUSTOMER_REQUIRED", "投保引导需要H5用户身份", 401)
+        args = _arguments(arguments, PrepareEnrollmentArgs)
+        product, plan = _resolve_product_plan(
+            catalog,
+            args.product_code,
+            args.plan_code,
+        )
+        action = AgentAction(
+            type="open_enrollment",
+            label="立即投保",
+            payload={
+                "product_id": product["product_id"],
+                "product_code": args.product_code,
+                "plan_id": plan["id"],
+                "plan_code": args.plan_code,
+                "age": args.age,
+                "relationship": args.relationship,
+            },
+        )
+        return HarnessToolResult(
+            summary="已准备安全投保表单入口",
+            data={"product": product, "plan": plan},
+            actions=[action],
+        )
+
+    def list_my_orders(
+        context: ToolExecutionContext,
+        arguments: BaseModel,
+    ) -> HarnessToolResult:
+        del arguments
+        user = context.h5_user
+        if user is None:
+            raise AppError("AGENT_CUSTOMER_REQUIRED", "订单查询需要H5用户身份", 401)
+        result = enrollment.list_orders(user.id)
+        items = [_safe_order_item(item) for item in result["items"]]
+        return HarnessToolResult(
+            summary=f"查到{len(items)}笔本人投保订单",
+            data={"items": items, "total": len(items)},
+            blocks=[
+                BusinessListBlock(
+                    title="我的投保订单",
+                    entity="order",
+                    items=items[:5],
+                    total=len(items),
+                )
+            ],
+            actions=[AgentAction(type="open_orders", label="查看我的订单")],
+        )
+
+    def list_my_policies(
+        context: ToolExecutionContext,
+        arguments: BaseModel,
+    ) -> HarnessToolResult:
+        del arguments
+        user = context.h5_user
+        if user is None:
+            raise AppError("AGENT_CUSTOMER_REQUIRED", "保单查询需要H5用户身份", 401)
+        result = enrollment.list_policies(user)
+        items = [_safe_policy_item(item) for item in result["items"]]
+        return HarnessToolResult(
+            summary=f"查到{len(items)}张本人保单",
+            data={"items": items, "total": len(items)},
+            blocks=[
+                BusinessListBlock(
+                    title="我的保障",
+                    entity="policy",
+                    items=items[:5],
+                    total=len(items),
+                )
+            ],
+            actions=[AgentAction(type="open_policies", label="查看我的保障")],
+        )
+
+    def overview(
+        context: ToolExecutionContext,
+        arguments: BaseModel,
+    ) -> HarnessToolResult:
+        del arguments
+        orders = enrollment.list_orders()
+        policies = enrollment.list_all_policies()
+        if context.admin_user and context.admin_user.data_scope == "SELF":
+            allowed = metrics.order_ids(context.admin_user.id)
+            orders = _filter_business_result(orders, allowed, "order_id")
+            policies = _filter_business_result(policies, allowed, "order_id")
+        active = [item for item in policies["items"] if item["status"] == "ACTIVE"]
+        premium_cents = sum(int(item["premium_cents"]) for item in active)
+        items = [
+            MetricItem(label="订单总量", value=int(orders["total"]), unit="笔"),
+            MetricItem(label="有效保单", value=len(active), unit="张"),
+            MetricItem(label="累计保费", value=premium_cents / 100, unit="元"),
+        ]
+        return HarnessToolResult(
+            summary=(
+                f"当前订单{orders['total']}笔、有效保单{len(active)}张、"
+                f"累计保费{premium_cents / 100:.2f}元"
+            ),
+            data={
+                "order_count": orders["total"],
+                "active_policy_count": len(active),
+                "premium_cents": premium_cents,
+            },
+            blocks=[MetricsBlock(title="经营总览", items=items)],
+        )
+
+    def recent_orders(
+        context: ToolExecutionContext,
+        arguments: BaseModel,
+    ) -> HarnessToolResult:
+        args = _arguments(arguments, RecentItemsArgs)
+        result = enrollment.list_orders()
+        if context.admin_user and context.admin_user.data_scope == "SELF":
+            result = _filter_business_result(
+                result,
+                metrics.order_ids(context.admin_user.id),
+                "order_id",
+            )
+        items = [_safe_order_item(item) for item in result["items"][: args.limit]]
+        return HarnessToolResult(
+            summary=f"返回最近{len(items)}笔订单",
+            data={"items": items, "total": result["total"]},
+            blocks=[
+                BusinessListBlock(
+                    title="近期订单",
+                    entity="order",
+                    items=items,
+                    total=int(result["total"]),
+                )
+            ],
+            actions=[AgentAction(type="open_admin_orders", label="进入订单中心")],
+        )
+
+    def recent_policies(
+        context: ToolExecutionContext,
+        arguments: BaseModel,
+    ) -> HarnessToolResult:
+        args = _arguments(arguments, RecentItemsArgs)
+        result = enrollment.list_all_policies()
+        if context.admin_user and context.admin_user.data_scope == "SELF":
+            result = _filter_business_result(
+                result,
+                metrics.order_ids(context.admin_user.id),
+                "order_id",
+            )
+        items = [_safe_policy_item(item) for item in result["items"][: args.limit]]
+        return HarnessToolResult(
+            summary=f"返回最近{len(items)}张保单",
+            data={"items": items, "total": result["total"]},
+            blocks=[
+                BusinessListBlock(
+                    title="近期保单",
+                    entity="policy",
+                    items=items,
+                    total=int(result["total"]),
+                )
+            ],
+            actions=[AgentAction(type="open_admin_policies", label="进入保单中心")],
+        )
+
+    def attribution_performance(
+        context: ToolExecutionContext,
+        arguments: BaseModel,
+    ) -> HarnessToolResult:
+        del arguments
+        admin = context.admin_user
+        if admin is None:
+            raise AppError("AGENT_ADMIN_REQUIRED", "推广分析需要后台身份", 401)
+        owner_id = admin.id if admin.data_scope == "SELF" else None
+        result = metrics.performance(owner_id)
+        block = MetricsBlock(
+            title="推广业绩",
+            items=[
+                MetricItem(
+                    label="推广员",
+                    value=int(result["salesperson_count"]),
+                    unit="人",
+                ),
+                MetricItem(label="访问", value=int(result["visit_count"]), unit="次"),
+                MetricItem(label="线索", value=int(result["lead_count"]), unit="人"),
+                MetricItem(label="归因订单", value=int(result["order_count"]), unit="笔"),
+                MetricItem(
+                    label="归因保费",
+                    value=int(result["premium_cents"]) / 100,
+                    unit="元",
+                ),
+            ],
+        )
+        return HarnessToolResult(
+            summary=(
+                f"推广访问{result['visit_count']}次、线索{result['lead_count']}人、"
+                f"归因订单{result['order_count']}笔"
+            ),
+            data=result,
+            blocks=[block],
+            actions=[AgentAction(type="open_attribution", label="查看推广明细")],
+        )
+
+    definitions = (
+        ToolDefinition(
+            name="list_available_products",
+            description="查询当前真实在售保险产品和计划;推荐产品前必须调用。",
+            arguments=ListProductsArgs,
+            policy=ToolPolicy(personas=("customer", "operation"), effect="read"),
+            handler=list_products,
+        ),
+        ToolDefinition(
+            name="calculate_insurance_quote",
+            description="按真实产品、年龄、地区、职业和关系执行确定性资格校验与保费测算。",
+            arguments=CalculateQuoteArgs,
+            policy=ToolPolicy(personas=("customer",), effect="draft"),
+            handler=calculate_quote,
+        ),
+        ToolDefinition(
+            name="prepare_enrollment",
+            description="为已明确的产品和计划生成安全投保表单入口,不采集身份证号。",
+            arguments=PrepareEnrollmentArgs,
+            policy=ToolPolicy(personas=("customer",), effect="draft"),
+            handler=prepare_enrollment,
+        ),
+        ToolDefinition(
+            name="list_my_orders",
+            description="查询当前登录客户自己的投保订单。",
+            arguments=EmptyArgs,
+            policy=ToolPolicy(personas=("customer",), effect="read"),
+            handler=list_my_orders,
+        ),
+        ToolDefinition(
+            name="list_my_policies",
+            description="查询当前登录客户自己的电子保单。",
+            arguments=EmptyArgs,
+            policy=ToolPolicy(personas=("customer",), effect="read"),
+            handler=list_my_policies,
+        ),
+        ToolDefinition(
+            name="get_operation_overview",
+            description="查询订单、有效保单和累计保费经营指标。",
+            arguments=EmptyArgs,
+            policy=ToolPolicy(
+                personas=("operation",),
+                effect="read",
+                required_permissions=("dashboard:read",),
+            ),
+            handler=overview,
+        ),
+        ToolDefinition(
+            name="list_recent_orders",
+            description="按当前管理员数据范围查询近期订单,只返回脱敏字段。",
+            arguments=RecentItemsArgs,
+            policy=ToolPolicy(
+                personas=("operation",),
+                effect="read",
+                required_permissions=("order:read",),
+            ),
+            handler=recent_orders,
+        ),
+        ToolDefinition(
+            name="list_recent_policies",
+            description="按当前管理员数据范围查询近期保单,只返回脱敏字段。",
+            arguments=RecentItemsArgs,
+            policy=ToolPolicy(
+                personas=("operation",),
+                effect="read",
+                required_permissions=("policy:read",),
+            ),
+            handler=recent_policies,
+        ),
+        ToolDefinition(
+            name="get_attribution_performance",
+            description="查询当前管理员数据范围内的推广访问、线索、订单和保费。",
+            arguments=EmptyArgs,
+            policy=ToolPolicy(
+                personas=("operation",),
+                effect="read",
+                required_permissions=("attribution:read",),
+            ),
+            handler=attribution_performance,
+        ),
+    )
+    for definition in definitions:
+        registry.register(definition)
+    return registry
+
+
+def _arguments(value: BaseModel, expected: type[ToolArguments]) -> Any:
+    if not isinstance(value, expected):
+        raise AppError("AGENT_TOOL_ARGUMENTS_INVALID", "工具参数类型错误", 500)
+    return value
+
+
+def _resolve_product_plan(
+    catalog: ProductCatalogService,
+    product_code: str,
+    plan_code: str,
+) -> tuple[dict[str, Any], dict[str, Any]]:
+    product = next(
+        (item for item in catalog.list_available() if item["product_code"] == product_code),
+        None,
+    )
+    if product is None:
+        raise AppError("PRODUCT_NOT_AVAILABLE", "产品当前不可投保", 404)
+    plan = next(
+        (item for item in product["plans"] if item["code"] == plan_code),
+        None,
+    )
+    if plan is None:
+        raise AppError("PLAN_NOT_AVAILABLE", "保障计划当前不可投保", 404)
+    return product, plan
+
+
+def _safe_order_item(item: dict[str, Any]) -> dict[str, Any]:
+    return {
+        "order_id": item["order_id"],
+        "order_no": item["order_no"],
+        "product_name": item["product_name"],
+        "plan_name": item["plan_name"],
+        "relationship": item["relationship"],
+        "status": item["status"],
+        "amount_cents": item["amount_cents"],
+        "currency": item["currency"],
+        "policy_no": item["policy_no"],
+        "created_at": str(item["created_at"]),
+    }
+
+
+def _safe_policy_item(item: dict[str, Any]) -> dict[str, Any]:
+    return {
+        "policy_id": item["policy_id"],
+        "policy_no": item["policy_no"],
+        "order_no": item["order_no"],
+        "product_name": item["product_name"],
+        "plan_name": item["plan_name"],
+        "relationship": item["relationship"],
+        "status": item["status"],
+        "premium_cents": item["premium_cents"],
+        "currency": item["currency"],
+        "coverage_start": str(item["coverage_start"]),
+        "coverage_end": str(item["coverage_end"]),
+        "issued_at": str(item["issued_at"]),
+    }
+
+
+def _filter_business_result(
+    result: dict[str, Any],
+    allowed_ids: set[str],
+    id_field: str,
+) -> dict[str, Any]:
+    items = [item for item in result["items"] if str(item.get(id_field, "")) in allowed_ids]
+    return {"items": items, "total": len(items)}

+ 1 - 0
backend/app/domains/attribution/__init__.py

@@ -0,0 +1 @@
+"""推广归因领域。"""

+ 46 - 0
backend/app/domains/attribution/models.py

@@ -0,0 +1,46 @@
+from dataclasses import dataclass
+from datetime import datetime
+
+
+@dataclass(frozen=True, slots=True)
+class Salesperson:
+    id: str
+    admin_user_id: str
+    code: str
+    name: str
+    status: str
+    created_at: datetime
+
+
+@dataclass(frozen=True, slots=True)
+class PromotionCode:
+    id: str
+    code: str
+    salesperson_id: str
+    channel: str
+    status: str
+    created_at: datetime
+
+
+@dataclass(frozen=True, slots=True)
+class LeadAttribution:
+    id: str
+    h5_user_id: str
+    salesperson_id: str
+    promotion_code_id: str
+    source_code: str
+    visit_count: int
+    first_touch_at: datetime
+    last_touch_at: datetime
+
+
+@dataclass(frozen=True, slots=True)
+class OrderAttribution:
+    id: str
+    order_id: str
+    h5_user_id: str
+    salesperson_id: str
+    promotion_code_id: str
+    source_code: str
+    amount_cents: int
+    attributed_at: datetime

+ 73 - 0
backend/app/domains/attribution/repository.py

@@ -0,0 +1,73 @@
+from typing import Protocol
+
+from app.domains.attribution.models import (
+    LeadAttribution,
+    OrderAttribution,
+    PromotionCode,
+    Salesperson,
+)
+
+
+class AttributionRepository(Protocol):
+    def save_salesperson(self, salesperson: Salesperson) -> None: ...
+
+    def list_salespersons(self) -> list[Salesperson]: ...
+
+    def save_promotion_code(self, promotion_code: PromotionCode) -> None: ...
+
+    def get_promotion_code(self, code: str) -> PromotionCode | None: ...
+
+    def list_promotion_codes(self) -> list[PromotionCode]: ...
+
+    def save_lead(self, lead: LeadAttribution) -> None: ...
+
+    def get_lead_by_user(self, h5_user_id: str) -> LeadAttribution | None: ...
+
+    def list_leads(self) -> list[LeadAttribution]: ...
+
+    def save_order_attribution(self, attribution: OrderAttribution) -> None: ...
+
+    def get_order_attribution(self, order_id: str) -> OrderAttribution | None: ...
+
+    def list_order_attributions(self) -> list[OrderAttribution]: ...
+
+
+class InMemoryAttributionRepository:
+    def __init__(self) -> None:
+        self._salespersons: dict[str, Salesperson] = {}
+        self._promotion_codes: dict[str, PromotionCode] = {}
+        self._leads: dict[str, LeadAttribution] = {}
+        self._orders: dict[str, OrderAttribution] = {}
+
+    def save_salesperson(self, salesperson: Salesperson) -> None:
+        self._salespersons[salesperson.id] = salesperson
+
+    def list_salespersons(self) -> list[Salesperson]:
+        return sorted(self._salespersons.values(), key=lambda item: item.code)
+
+    def save_promotion_code(self, promotion_code: PromotionCode) -> None:
+        self._promotion_codes[promotion_code.code.upper()] = promotion_code
+
+    def get_promotion_code(self, code: str) -> PromotionCode | None:
+        return self._promotion_codes.get(code.upper())
+
+    def list_promotion_codes(self) -> list[PromotionCode]:
+        return sorted(self._promotion_codes.values(), key=lambda item: item.code)
+
+    def save_lead(self, lead: LeadAttribution) -> None:
+        self._leads[lead.h5_user_id] = lead
+
+    def get_lead_by_user(self, h5_user_id: str) -> LeadAttribution | None:
+        return self._leads.get(h5_user_id)
+
+    def list_leads(self) -> list[LeadAttribution]:
+        return list(self._leads.values())
+
+    def save_order_attribution(self, attribution: OrderAttribution) -> None:
+        self._orders[attribution.order_id] = attribution
+
+    def get_order_attribution(self, order_id: str) -> OrderAttribution | None:
+        return self._orders.get(order_id)
+
+    def list_order_attributions(self) -> list[OrderAttribution]:
+        return list(self._orders.values())

+ 174 - 0
backend/app/domains/attribution/service.py

@@ -0,0 +1,174 @@
+from collections.abc import Callable
+from dataclasses import replace
+from datetime import datetime
+from typing import Any
+
+from app.core.errors import AppError
+from app.core.identifiers import new_ulid
+from app.domains.attribution.models import LeadAttribution, OrderAttribution
+from app.domains.attribution.repository import AttributionRepository
+from app.domains.enrollment.models import EnrollmentOrder
+from app.domains.identity.models import H5User
+
+
+class AttributionService:
+    """第一阶段使用首次有效触点归因,订单创建时固化归因快照。"""
+
+    def __init__(
+        self,
+        repository: AttributionRepository,
+        clock: Callable[[], datetime],
+    ) -> None:
+        self._repository = repository
+        self._clock = clock
+
+    def capture_visit(
+        self,
+        user: H5User,
+        referral_code: str,
+    ) -> dict[str, Any]:
+        code = self._repository.get_promotion_code(referral_code.strip().upper())
+        if code is None or code.status != "ACTIVE":
+            raise AppError("REFERRAL_CODE_INVALID", "推广码无效或已停用", 422)
+        now = self._clock()
+        existing = self._repository.get_lead_by_user(user.id)
+        if existing is None:
+            lead = LeadAttribution(
+                id=new_ulid(),
+                h5_user_id=user.id,
+                salesperson_id=code.salesperson_id,
+                promotion_code_id=code.id,
+                source_code=code.code,
+                visit_count=1,
+                first_touch_at=now,
+                last_touch_at=now,
+            )
+        else:
+            lead = replace(
+                existing,
+                visit_count=existing.visit_count + 1,
+                last_touch_at=now,
+            )
+        self._repository.save_lead(lead)
+        return {
+            "source_code": lead.source_code,
+            "attributed": True,
+            "first_touch": existing is None,
+        }
+
+    def record_order(self, order: EnrollmentOrder) -> None:
+        if self._repository.get_order_attribution(order.id) is not None:
+            return
+        lead = self._repository.get_lead_by_user(order.user_id)
+        if lead is None:
+            return
+        self._repository.save_order_attribution(
+            OrderAttribution(
+                id=new_ulid(),
+                order_id=order.id,
+                h5_user_id=order.user_id,
+                salesperson_id=lead.salesperson_id,
+                promotion_code_id=lead.promotion_code_id,
+                source_code=lead.source_code,
+                amount_cents=order.amount_cents,
+                attributed_at=self._clock(),
+            )
+        )
+
+    def performance(self, admin_user_id: str | None = None) -> dict[str, Any]:
+        salespersons = [
+            item
+            for item in self._repository.list_salespersons()
+            if item.status == "ACTIVE"
+            and (admin_user_id is None or item.admin_user_id == admin_user_id)
+        ]
+        salesperson_ids = {item.id for item in salespersons}
+        leads = [
+            item for item in self._repository.list_leads() if item.salesperson_id in salesperson_ids
+        ]
+        orders = [
+            item
+            for item in self._repository.list_order_attributions()
+            if item.salesperson_id in salesperson_ids
+        ]
+        items = []
+        for salesperson in salespersons:
+            owned_leads = [item for item in leads if item.salesperson_id == salesperson.id]
+            owned_orders = [item for item in orders if item.salesperson_id == salesperson.id]
+            visit_count = sum(item.visit_count for item in owned_leads)
+            lead_count = len(owned_leads)
+            order_count = len(owned_orders)
+            premium_cents = sum(item.amount_cents for item in owned_orders)
+            items.append(
+                {
+                    "salesperson_id": salesperson.id,
+                    "admin_user_id": salesperson.admin_user_id,
+                    "salesperson_code": salesperson.code,
+                    "salesperson_name": salesperson.name,
+                    "visit_count": visit_count,
+                    "lead_count": lead_count,
+                    "order_count": order_count,
+                    "premium_cents": premium_cents,
+                    "visit_conversion_rate": round(lead_count / visit_count * 100, 1)
+                    if visit_count
+                    else 0.0,
+                    "lead_conversion_rate": round(order_count / lead_count * 100, 1)
+                    if lead_count
+                    else 0.0,
+                    "average_premium_cents": premium_cents // order_count if order_count else 0,
+                }
+            )
+        items.sort(
+            key=lambda item: (item["premium_cents"], item["order_count"]),
+            reverse=True,
+        )
+        visit_count = sum(item.visit_count for item in leads)
+        lead_count = len(leads)
+        order_count = len(orders)
+        premium_cents = sum(item.amount_cents for item in orders)
+        return {
+            "salesperson_count": len(salespersons),
+            "visit_count": visit_count,
+            "lead_count": lead_count,
+            "order_count": order_count,
+            "premium_cents": premium_cents,
+            "visit_conversion_rate": round(lead_count / visit_count * 100, 1)
+            if visit_count
+            else 0.0,
+            "lead_conversion_rate": round(order_count / lead_count * 100, 1) if lead_count else 0.0,
+            "average_premium_cents": premium_cents // order_count if order_count else 0,
+            "items": items,
+        }
+
+    def order_ids(self, admin_user_id: str | None = None) -> set[str]:
+        salesperson_ids = {
+            item.id
+            for item in self._repository.list_salespersons()
+            if admin_user_id is None or item.admin_user_id == admin_user_id
+        }
+        return {
+            item.order_id
+            for item in self._repository.list_order_attributions()
+            if item.salesperson_id in salesperson_ids
+        }
+
+    def list_codes(self, admin_user_id: str | None = None) -> dict[str, Any]:
+        salespersons = {
+            item.id: item
+            for item in self._repository.list_salespersons()
+            if admin_user_id is None or item.admin_user_id == admin_user_id
+        }
+        items = [
+            {
+                "code": item.code,
+                "channel": item.channel,
+                "status": item.status,
+                "salesperson_id": salesperson.id,
+                "salesperson_code": salesperson.code,
+                "salesperson_name": salesperson.name,
+                "created_at": item.created_at,
+            }
+            for item in self._repository.list_promotion_codes()
+            if (salesperson := salespersons.get(item.salesperson_id)) is not None
+        ]
+        return {"items": items, "total": len(items)}

+ 1 - 0
backend/app/domains/catalog/__init__.py

@@ -0,0 +1 @@
+"""产品目录领域。"""

+ 59 - 0
backend/app/domains/catalog/models.py

@@ -0,0 +1,59 @@
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Any
+
+
+@dataclass(frozen=True, slots=True)
+class Plan:
+    id: str
+    code: str
+    name: str
+    summary: str = ""
+    status: str = "ACTIVE"
+    premium_cents: int = 0
+    coverage_amount_cents: int = 0
+    min_age: int = 0
+    max_age: int = 100
+
+
+@dataclass(frozen=True, slots=True)
+class Product:
+    id: str
+    product_code: str
+    name: str
+    category: str
+    summary: str
+    status: str
+
+
+@dataclass(frozen=True, slots=True)
+class ProductVersion:
+    id: str
+    product_id: str
+    version_no: str
+    status: str
+    effective_from: datetime
+    effective_to: datetime | None
+    plans: tuple[Plan, ...]
+    rule_version: str = "eligibility-v1"
+    rate_version: str = "rate-v1"
+    terms_summary: str = ""
+
+    def is_available_at(self, when: datetime) -> bool:
+        return (
+            self.status == "PUBLISHED"
+            and self.effective_from <= when
+            and (self.effective_to is None or when <= self.effective_to)
+        )
+
+
+@dataclass(frozen=True, slots=True)
+class ProductChangeLog:
+    id: str
+    product_id: str
+    version_id: str | None
+    action: str
+    actor_id: str
+    actor_name: str
+    detail: dict[str, Any]
+    created_at: datetime

+ 56 - 0
backend/app/domains/catalog/repository.py

@@ -0,0 +1,56 @@
+from typing import Protocol
+
+from app.domains.catalog.models import Product, ProductChangeLog, ProductVersion
+
+
+class CatalogRepository(Protocol):
+    def save_product(self, product: Product) -> None: ...
+
+    def list_products(self) -> list[Product]: ...
+
+    def list_versions(self, product_id: str) -> list[ProductVersion]: ...
+
+    def save_version(self, version: ProductVersion) -> None: ...
+
+    def delete_version(self, version_id: str) -> None: ...
+
+    def save_change_log(self, log: ProductChangeLog) -> None: ...
+
+    def list_change_logs(self, product_id: str) -> list[ProductChangeLog]: ...
+
+
+class InMemoryCatalogRepository:
+    def __init__(self) -> None:
+        self._products: dict[str, Product] = {}
+        self._versions: dict[str, list[ProductVersion]] = {}
+        self._change_logs: list[ProductChangeLog] = []
+
+    def save_product(self, product: Product) -> None:
+        self._products[product.id] = product
+
+    def save_version(self, version: ProductVersion) -> None:
+        versions = self._versions.setdefault(version.product_id, [])
+        for index, existing in enumerate(versions):
+            if existing.id == version.id:
+                versions[index] = version
+                break
+        else:
+            versions.append(version)
+
+    def list_products(self) -> list[Product]:
+        return list(self._products.values())
+
+    def list_versions(self, product_id: str) -> list[ProductVersion]:
+        return list(self._versions.get(product_id, []))
+
+    def delete_version(self, version_id: str) -> None:
+        for product_id, versions in self._versions.items():
+            self._versions[product_id] = [
+                version for version in versions if version.id != version_id
+            ]
+
+    def save_change_log(self, log: ProductChangeLog) -> None:
+        self._change_logs.append(log)
+
+    def list_change_logs(self, product_id: str) -> list[ProductChangeLog]:
+        return [log for log in self._change_logs if log.product_id == product_id]

+ 608 - 0
backend/app/domains/catalog/service.py

@@ -0,0 +1,608 @@
+"""产品目录业务逻辑。
+
+这一层不关心数据来自 MySQL 还是测试内存,只通过 CatalogRepository 接口取数据。
+"""
+
+import re
+from collections.abc import Callable
+from dataclasses import replace
+from datetime import datetime, timedelta
+from typing import Any
+
+from app.core.errors import AppError
+from app.core.identifiers import new_ulid
+from app.domains.catalog.models import Plan, Product, ProductChangeLog, ProductVersion
+from app.domains.catalog.repository import CatalogRepository
+
+
+class ProductCatalogService:
+    def __init__(
+        self,
+        repository: CatalogRepository,
+        clock: Callable[[], datetime],
+    ) -> None:
+        self._repository = repository
+        self._clock = clock
+
+    def list_available(self, *, category: str | None = None) -> list[dict[str, Any]]:
+        """返回当前时间有效的在售产品,可选按产品类别过滤。"""
+        now = self._clock()
+        items: list[dict[str, Any]] = []
+        for product in self._repository.list_products():
+            # continue 会跳过本轮循环,继续检查下一个产品。
+            if product.status != "ACTIVE" or (category and product.category != category):
+                continue
+
+            # 列表推导式:从产品的所有版本中筛出当前生效的版本。
+            versions = [
+                version
+                for version in self._repository.list_versions(product.id)
+                if version.is_available_at(now)
+            ]
+            if not versions:
+                continue
+            # 若多个版本同时有效,选择生效时间最晚的版本作为当前版本。
+            current = max(versions, key=lambda version: version.effective_from)
+            items.append(
+                {
+                    "product_id": product.id,
+                    "product_code": product.product_code,
+                    "name": product.name,
+                    "category": product.category,
+                    "summary": product.summary,
+                    "product_version_id": current.id,
+                    "version_no": current.version_no,
+                    "plans": [
+                        {
+                            "id": plan.id,
+                            "code": plan.code,
+                            "name": plan.name,
+                            "summary": plan.summary,
+                            "premium_cents": plan.premium_cents,
+                            "coverage_amount_cents": plan.coverage_amount_cents,
+                            "min_age": plan.min_age,
+                            "max_age": plan.max_age,
+                        }
+                        for plan in current.plans
+                        if plan.status == "ACTIVE"
+                    ],
+                }
+            )
+        # lambda 是一个简短的匿名函数,这里告诉 sorted 按产品编码排序。
+        return sorted(items, key=lambda item: str(item["product_code"]))
+
+    def list_managed(self) -> dict[str, Any]:
+        items = []
+        for product in self._repository.list_products():
+            versions = self._repository.list_versions(product.id)
+            items.append(
+                {
+                    "product_id": product.id,
+                    "product_code": product.product_code,
+                    "name": product.name,
+                    "category": product.category,
+                    "summary": product.summary,
+                    "status": product.status,
+                    "versions": [
+                        {
+                            "id": version.id,
+                            "version_no": version.version_no,
+                            "status": version.status,
+                            "effective_from": version.effective_from,
+                            "effective_to": version.effective_to,
+                            "rule_version": version.rule_version,
+                            "rate_version": version.rate_version,
+                            "terms_summary": version.terms_summary,
+                            "plan_count": len(version.plans),
+                            "plans": [
+                                {
+                                    "id": plan.id,
+                                    "code": plan.code,
+                                    "name": plan.name,
+                                    "summary": plan.summary,
+                                    "status": plan.status,
+                                    "premium_cents": plan.premium_cents,
+                                    "coverage_amount_cents": plan.coverage_amount_cents,
+                                    "min_age": plan.min_age,
+                                    "max_age": plan.max_age,
+                                }
+                                for plan in version.plans
+                            ],
+                        }
+                        for version in sorted(
+                            versions,
+                            key=lambda item: item.effective_from,
+                            reverse=True,
+                        )
+                    ],
+                    "recent_logs": [
+                        self._serialize_log(log)
+                        for log in self._repository.list_change_logs(product.id)[:8]
+                    ],
+                }
+            )
+        sorted_items = sorted(items, key=lambda item: str(item["product_code"]))
+        return {"items": sorted_items, "total": len(sorted_items)}
+
+    def update_product_status(
+        self,
+        product_id: str,
+        status: str,
+        *,
+        actor_id: str = "SYSTEM",
+        actor_name: str = "系统",
+    ) -> dict[str, Any]:
+        if status not in {"ACTIVE", "INACTIVE"}:
+            raise AppError("PRODUCT_STATUS_INVALID", "产品状态无效", 422)
+        product = next(
+            (item for item in self._repository.list_products() if item.id == product_id),
+            None,
+        )
+        if product is None:
+            raise AppError("PRODUCT_NOT_FOUND", "未找到产品", 404)
+        updated = replace(product, status=status)
+        self._repository.save_product(updated)
+        self._write_log(
+            product_id,
+            None,
+            "PRODUCT_STATUS_CHANGED",
+            actor_id,
+            actor_name,
+            {"from": product.status, "to": status},
+        )
+        return {
+            "product_id": updated.id,
+            "product_code": updated.product_code,
+            "name": updated.name,
+            "status": updated.status,
+        }
+
+    def update_product(
+        self,
+        product_id: str,
+        *,
+        name: str,
+        category: str,
+        summary: str,
+        actor_id: str,
+        actor_name: str,
+    ) -> dict[str, Any]:
+        product = self._get_product(product_id)
+        normalized_name = name.strip()
+        normalized_summary = summary.strip()
+        if not normalized_name or not normalized_summary:
+            raise AppError("PRODUCT_CONTENT_REQUIRED", "产品名称和产品简介不能为空", 422)
+        if category not in {"MEDICAL", "ACCIDENT"}:
+            raise AppError("PRODUCT_CATEGORY_INVALID", "产品分类无效", 422)
+        updated = replace(
+            product,
+            name=normalized_name,
+            category=category,
+            summary=normalized_summary,
+        )
+        self._repository.save_product(updated)
+        self._write_log(
+            product_id,
+            None,
+            "PRODUCT_UPDATED",
+            actor_id,
+            actor_name,
+            {"name": updated.name, "category": updated.category},
+        )
+        return self._serialize_product(updated)
+
+    def create_product(
+        self,
+        *,
+        product_code: str,
+        name: str,
+        category: str,
+        summary: str,
+        version_no: str,
+        effective_from: datetime,
+        rule_version: str,
+        rate_version: str,
+        terms_summary: str,
+        plans: list[dict[str, Any]],
+        actor_id: str,
+        actor_name: str,
+    ) -> dict[str, Any]:
+        normalized_code = product_code.strip().upper()
+        normalized_name = name.strip()
+        normalized_summary = summary.strip()
+        normalized_version = version_no.strip()
+        if not re.fullmatch(r"[A-Z][A-Z0-9-]{2,31}", normalized_code):
+            raise AppError(
+                "PRODUCT_CODE_INVALID",
+                "产品编码须以字母开头,仅包含大写字母、数字和连字符",
+                422,
+            )
+        if any(
+            product.product_code.upper() == normalized_code
+            for product in self._repository.list_products()
+        ):
+            raise AppError("PRODUCT_CODE_DUPLICATED", "产品编码已存在", 409)
+        if not normalized_name or not normalized_summary:
+            raise AppError("PRODUCT_CONTENT_REQUIRED", "产品名称和产品简介不能为空", 422)
+        if category not in {"MEDICAL", "ACCIDENT"}:
+            raise AppError("PRODUCT_CATEGORY_INVALID", "产品分类无效", 422)
+        if not normalized_version:
+            raise AppError("VERSION_NO_REQUIRED", "首个版本号不能为空", 422)
+        product = Product(
+            id=new_ulid(),
+            product_code=normalized_code,
+            name=normalized_name,
+            category=category,
+            summary=normalized_summary,
+            status="INACTIVE",
+        )
+        initial_version = ProductVersion(
+            id=new_ulid(),
+            product_id=product.id,
+            version_no=normalized_version,
+            status="DRAFT",
+            effective_from=self._require_aware(effective_from),
+            effective_to=None,
+            plans=self._build_plans(plans, ()),
+            rule_version=rule_version.strip() or "eligibility-v1",
+            rate_version=rate_version.strip() or "rate-v1",
+            terms_summary=terms_summary.strip(),
+        )
+        self._repository.save_product(product)
+        self._repository.save_version(initial_version)
+        self._write_log(
+            product.id,
+            initial_version.id,
+            "PRODUCT_CREATED",
+            actor_id,
+            actor_name,
+            {
+                "product_code": product.product_code,
+                "version_no": initial_version.version_no,
+            },
+        )
+        return {
+            "product": self._serialize_product(product),
+            "initial_version": self._serialize_version(initial_version),
+        }
+
+    def create_draft_version(
+        self,
+        product_id: str,
+        *,
+        version_no: str,
+        effective_from: datetime,
+        source_version_id: str | None,
+        actor_id: str,
+        actor_name: str,
+    ) -> dict[str, Any]:
+        self._get_product(product_id)
+        normalized_version = version_no.strip()
+        versions = self._repository.list_versions(product_id)
+        if not normalized_version:
+            raise AppError("VERSION_NO_REQUIRED", "版本号不能为空", 422)
+        if any(version.version_no == normalized_version for version in versions):
+            raise AppError("VERSION_NO_DUPLICATED", "该产品版本号已存在", 409)
+        source = None
+        if source_version_id:
+            source = next(
+                (version for version in versions if version.id == source_version_id),
+                None,
+            )
+            if source is None:
+                raise AppError("SOURCE_VERSION_NOT_FOUND", "未找到要复制的产品版本", 404)
+        elif versions:
+            source = max(versions, key=lambda version: version.effective_from)
+        plans = tuple(
+            replace(plan, id=new_ulid()) for plan in source.plans
+        ) if source else ()
+        draft = ProductVersion(
+            id=new_ulid(),
+            product_id=product_id,
+            version_no=normalized_version,
+            status="DRAFT",
+            effective_from=self._require_aware(effective_from),
+            effective_to=None,
+            plans=plans,
+            rule_version=source.rule_version if source else "eligibility-v1",
+            rate_version=source.rate_version if source else "rate-v1",
+            terms_summary=source.terms_summary if source else "",
+        )
+        self._repository.save_version(draft)
+        self._write_log(
+            product_id,
+            draft.id,
+            "VERSION_DRAFT_CREATED",
+            actor_id,
+            actor_name,
+            {"version_no": draft.version_no, "copied_from": source_version_id},
+        )
+        return self._serialize_version(draft)
+
+    def update_draft_version(
+        self,
+        product_id: str,
+        version_id: str,
+        *,
+        effective_from: datetime,
+        effective_to: datetime | None,
+        rule_version: str,
+        rate_version: str,
+        terms_summary: str,
+        plans: list[dict[str, Any]],
+        actor_id: str,
+        actor_name: str,
+    ) -> dict[str, Any]:
+        version = self._get_version(product_id, version_id)
+        if version.status != "DRAFT":
+            raise AppError("VERSION_IMMUTABLE", "只有草稿版本可以编辑", 409)
+        start = self._require_aware(effective_from)
+        end = self._require_aware(effective_to) if effective_to else None
+        if end is not None and end <= start:
+            raise AppError("VERSION_PERIOD_INVALID", "销售截止时间必须晚于生效时间", 422)
+        updated = replace(
+            version,
+            effective_from=start,
+            effective_to=end,
+            rule_version=rule_version.strip() or "eligibility-v1",
+            rate_version=rate_version.strip() or "rate-v1",
+            terms_summary=terms_summary.strip(),
+            plans=self._build_plans(plans, version.plans),
+        )
+        self._repository.save_version(updated)
+        self._write_log(
+            product_id,
+            version_id,
+            "VERSION_DRAFT_UPDATED",
+            actor_id,
+            actor_name,
+            {"version_no": updated.version_no, "plan_count": len(updated.plans)},
+        )
+        return self._serialize_version(updated)
+
+    def publish_version(
+        self,
+        product_id: str,
+        version_id: str,
+        *,
+        actor_id: str,
+        actor_name: str,
+    ) -> dict[str, Any]:
+        version = self._get_version(product_id, version_id)
+        if version.status != "DRAFT":
+            raise AppError("VERSION_NOT_DRAFT", "只有草稿版本可以发布", 409)
+        if not version.terms_summary:
+            raise AppError("VERSION_TERMS_REQUIRED", "发布前请填写投保须知与条款摘要", 422)
+        active_plans = [plan for plan in version.plans if plan.status == "ACTIVE"]
+        if not active_plans:
+            raise AppError("VERSION_PLAN_REQUIRED", "发布前至少需要一个有效保障计划", 422)
+        if any(plan.premium_cents <= 0 or plan.coverage_amount_cents <= 0 for plan in active_plans):
+            raise AppError("VERSION_PLAN_INVALID", "有效计划的保费和保额必须大于零", 422)
+        for existing in self._repository.list_versions(product_id):
+            if (
+                existing.status == "PUBLISHED"
+                and existing.effective_from < version.effective_from
+                and (
+                    existing.effective_to is None
+                    or existing.effective_to >= version.effective_from
+                )
+            ):
+                self._repository.save_version(
+                    replace(
+                        existing,
+                        effective_to=version.effective_from - timedelta(microseconds=1),
+                    )
+                )
+        published = replace(version, status="PUBLISHED")
+        self._repository.save_version(published)
+        self._write_log(
+            product_id,
+            version_id,
+            "VERSION_PUBLISHED",
+            actor_id,
+            actor_name,
+            {"version_no": published.version_no},
+        )
+        return self._serialize_version(published)
+
+    def retire_version(
+        self,
+        product_id: str,
+        version_id: str,
+        *,
+        actor_id: str,
+        actor_name: str,
+    ) -> dict[str, Any]:
+        version = self._get_version(product_id, version_id)
+        if version.status != "PUBLISHED":
+            raise AppError("VERSION_NOT_PUBLISHED", "只有已发布版本可以终止", 409)
+        retired = replace(version, status="RETIRED", effective_to=self._clock())
+        self._repository.save_version(retired)
+        self._write_log(
+            product_id,
+            version_id,
+            "VERSION_RETIRED",
+            actor_id,
+            actor_name,
+            {"version_no": retired.version_no},
+        )
+        return self._serialize_version(retired)
+
+    def delete_draft_version(
+        self,
+        product_id: str,
+        version_id: str,
+        *,
+        actor_id: str,
+        actor_name: str,
+    ) -> dict[str, Any]:
+        version = self._get_version(product_id, version_id)
+        if version.status != "DRAFT":
+            raise AppError("VERSION_DELETE_DENIED", "只有草稿版本可以删除", 409)
+        self._repository.delete_version(version_id)
+        self._write_log(
+            product_id,
+            version_id,
+            "VERSION_DRAFT_DELETED",
+            actor_id,
+            actor_name,
+            {"version_no": version.version_no},
+        )
+        return {"version_id": version_id, "deleted": True}
+
+    def list_change_logs(self, product_id: str) -> dict[str, Any]:
+        self._get_product(product_id)
+        items = [
+            self._serialize_log(log)
+            for log in self._repository.list_change_logs(product_id)
+        ]
+        return {"items": items, "total": len(items)}
+
+    def _get_product(self, product_id: str) -> Product:
+        product = next(
+            (item for item in self._repository.list_products() if item.id == product_id),
+            None,
+        )
+        if product is None:
+            raise AppError("PRODUCT_NOT_FOUND", "未找到产品", 404)
+        return product
+
+    def _get_version(self, product_id: str, version_id: str) -> ProductVersion:
+        self._get_product(product_id)
+        version = next(
+            (
+                item
+                for item in self._repository.list_versions(product_id)
+                if item.id == version_id
+            ),
+            None,
+        )
+        if version is None:
+            raise AppError("VERSION_NOT_FOUND", "未找到产品版本", 404)
+        return version
+
+    @staticmethod
+    def _require_aware(value: datetime) -> datetime:
+        if value.tzinfo is None or value.utcoffset() is None:
+            raise AppError("DATETIME_TIMEZONE_REQUIRED", "时间必须包含时区", 422)
+        return value
+
+    @staticmethod
+    def _build_plans(
+        items: list[dict[str, Any]],
+        existing_plans: tuple[Plan, ...],
+    ) -> tuple[Plan, ...]:
+        if not items:
+            raise AppError("VERSION_PLAN_REQUIRED", "至少需要一个保障计划", 422)
+        existing_by_id = {plan.id: plan for plan in existing_plans}
+        codes: set[str] = set()
+        plans: list[Plan] = []
+        for item in items:
+            code = str(item.get("code", "")).strip().upper()
+            name = str(item.get("name", "")).strip()
+            if not code or not name:
+                raise AppError("PLAN_CONTENT_REQUIRED", "计划编码和名称不能为空", 422)
+            if code in codes:
+                raise AppError("PLAN_CODE_DUPLICATED", "同一版本的计划编码不能重复", 422)
+            codes.add(code)
+            min_age = int(item.get("min_age", 0))
+            max_age = int(item.get("max_age", 100))
+            premium_cents = int(item.get("premium_cents", 0))
+            coverage_amount_cents = int(item.get("coverage_amount_cents", 0))
+            if min_age < 0 or max_age > 120 or min_age > max_age:
+                raise AppError("PLAN_AGE_INVALID", "计划年龄范围无效", 422)
+            if premium_cents < 0 or coverage_amount_cents < 0:
+                raise AppError("PLAN_AMOUNT_INVALID", "计划保费和保额不能小于零", 422)
+            requested_id = str(item.get("id", ""))
+            plan_id = requested_id if requested_id in existing_by_id else new_ulid()
+            plans.append(
+                Plan(
+                    id=plan_id,
+                    code=code,
+                    name=name,
+                    summary=str(item.get("summary", "")).strip(),
+                    status=(
+                        str(item.get("status", "ACTIVE"))
+                        if str(item.get("status", "ACTIVE")) in {"ACTIVE", "INACTIVE"}
+                        else "ACTIVE"
+                    ),
+                    premium_cents=premium_cents,
+                    coverage_amount_cents=coverage_amount_cents,
+                    min_age=min_age,
+                    max_age=max_age,
+                )
+            )
+        return tuple(plans)
+
+    def _write_log(
+        self,
+        product_id: str,
+        version_id: str | None,
+        action: str,
+        actor_id: str,
+        actor_name: str,
+        detail: dict[str, Any],
+    ) -> None:
+        self._repository.save_change_log(
+            ProductChangeLog(
+                id=new_ulid(),
+                product_id=product_id,
+                version_id=version_id,
+                action=action,
+                actor_id=actor_id,
+                actor_name=actor_name,
+                detail=detail,
+                created_at=self._clock(),
+            )
+        )
+
+    @staticmethod
+    def _serialize_product(product: Any) -> dict[str, Any]:
+        return {
+            "product_id": product.id,
+            "product_code": product.product_code,
+            "name": product.name,
+            "category": product.category,
+            "summary": product.summary,
+            "status": product.status,
+        }
+
+    @staticmethod
+    def _serialize_version(version: ProductVersion) -> dict[str, Any]:
+        return {
+            "id": version.id,
+            "version_no": version.version_no,
+            "status": version.status,
+            "effective_from": version.effective_from,
+            "effective_to": version.effective_to,
+            "rule_version": version.rule_version,
+            "rate_version": version.rate_version,
+            "terms_summary": version.terms_summary,
+            "plan_count": len(version.plans),
+            "plans": [
+                {
+                    "id": plan.id,
+                    "code": plan.code,
+                    "name": plan.name,
+                    "summary": plan.summary,
+                    "status": plan.status,
+                    "premium_cents": plan.premium_cents,
+                    "coverage_amount_cents": plan.coverage_amount_cents,
+                    "min_age": plan.min_age,
+                    "max_age": plan.max_age,
+                }
+                for plan in version.plans
+            ],
+        }
+
+    @staticmethod
+    def _serialize_log(log: ProductChangeLog) -> dict[str, Any]:
+        return {
+            "id": log.id,
+            "version_id": log.version_id,
+            "action": log.action,
+            "actor_id": log.actor_id,
+            "actor_name": log.actor_name,
+            "detail": log.detail,
+            "created_at": log.created_at,
+        }

+ 1 - 0
backend/app/domains/enrollment/__init__.py

@@ -0,0 +1 @@
+"""智能投保领域。"""

+ 124 - 0
backend/app/domains/enrollment/models.py

@@ -0,0 +1,124 @@
+from dataclasses import dataclass
+from datetime import date, datetime
+from typing import Any
+
+
+@dataclass(frozen=True)
+class Quote:
+    id: str
+    user_id: str
+    product_id: str
+    product_version_id: str
+    plan_id: str
+    insured_age: int
+    insured_region_code: str
+    occupation_code: str
+    relationship: str
+    premium_cents: int
+    currency: str
+    rule_version: str
+    rate_version: str
+    status: str
+    expires_at: datetime
+    created_at: datetime
+
+
+@dataclass(frozen=True)
+class EnrollmentDraft:
+    id: str
+    user_id: str
+    quote_id: str
+    applicant: dict[str, Any]
+    insured: dict[str, Any]
+    contact: dict[str, Any]
+    status: str
+    expires_at: datetime
+    created_at: datetime
+
+
+@dataclass(frozen=True)
+class UserConfirmation:
+    id: str
+    user_id: str
+    draft_id: str
+    token_hash: str
+    status: str
+    expires_at: datetime
+    created_at: datetime
+
+
+@dataclass(frozen=True)
+class EnrollmentOrder:
+    id: str
+    order_no: str
+    user_id: str
+    quote_id: str
+    draft_id: str
+    confirmation_id: str
+    idempotency_key: str
+    product_id: str
+    product_version_id: str
+    plan_id: str
+    applicant_snapshot: dict[str, Any]
+    insured_snapshot: dict[str, Any]
+    amount_cents: int
+    currency: str
+    status: str
+    created_at: datetime
+
+
+@dataclass(frozen=True)
+class PaymentTransaction:
+    id: str
+    payment_no: str
+    order_id: str
+    user_id: str
+    transaction_type: str
+    provider: str
+    idempotency_key: str
+    amount_cents: int
+    currency: str
+    status: str
+    provider_transaction_no: str | None
+    succeeded_at: datetime | None
+    created_at: datetime
+
+
+@dataclass(frozen=True)
+class Policy:
+    id: str
+    policy_no: str
+    order_id: str
+    user_id: str
+    product_id: str
+    product_version_id: str
+    plan_id: str
+    premium_cents: int
+    currency: str
+    coverage_start: date
+    coverage_end: date
+    status: str
+    issued_at: datetime
+
+
+@dataclass(frozen=True)
+class OutboxEvent:
+    id: str
+    aggregate_type: str
+    aggregate_id: str
+    event_type: str
+    payload: dict[str, Any]
+    status: str
+    created_at: datetime
+
+
+@dataclass(frozen=True)
+class AsyncTask:
+    id: str
+    task_type: str
+    business_key: str
+    idempotency_key: str
+    payload: dict[str, Any]
+    status: str
+    attempt_count: int
+    created_at: datetime

+ 178 - 0
backend/app/domains/enrollment/repository.py

@@ -0,0 +1,178 @@
+from typing import Protocol
+
+from app.domains.enrollment.models import (
+    AsyncTask,
+    EnrollmentDraft,
+    EnrollmentOrder,
+    OutboxEvent,
+    PaymentTransaction,
+    Policy,
+    Quote,
+    UserConfirmation,
+)
+
+
+class EnrollmentRepository(Protocol):
+    def save_quote(self, quote: Quote) -> None: ...
+
+    def get_quote(self, quote_id: str) -> Quote | None: ...
+
+    def save_draft(self, draft: EnrollmentDraft) -> None: ...
+
+    def get_draft(self, draft_id: str) -> EnrollmentDraft | None: ...
+
+    def save_confirmation(self, confirmation: UserConfirmation) -> None: ...
+
+    def get_confirmation_by_token_hash(self, token_hash: str) -> UserConfirmation | None: ...
+
+    def save_order(self, order: EnrollmentOrder) -> None: ...
+
+    def get_order(self, order_id: str) -> EnrollmentOrder | None: ...
+
+    def find_order_by_idempotency(
+        self, user_id: str, idempotency_key: str
+    ) -> EnrollmentOrder | None: ...
+
+    def list_orders(self, user_id: str | None = None) -> list[EnrollmentOrder]: ...
+
+    def save_payment(self, payment: PaymentTransaction) -> None: ...
+
+    def get_payment(self, payment_id: str) -> PaymentTransaction | None: ...
+
+    def get_payment_by_no(self, payment_no: str) -> PaymentTransaction | None: ...
+
+    def get_payment_by_order(self, order_id: str) -> PaymentTransaction | None: ...
+
+    def find_payment_by_idempotency(
+        self, order_id: str, idempotency_key: str
+    ) -> PaymentTransaction | None: ...
+
+    def save_policy(self, policy: Policy) -> None: ...
+
+    def get_policy_by_order(self, order_id: str) -> Policy | None: ...
+
+    def list_policies(self, user_id: str | None = None) -> list[Policy]: ...
+
+    def save_outbox_event(self, event: OutboxEvent) -> None: ...
+
+    def save_task(self, task: AsyncTask) -> None: ...
+
+    def get_task_by_idempotency(self, idempotency_key: str) -> AsyncTask | None: ...
+
+
+class InMemoryEnrollmentRepository:
+    def __init__(self) -> None:
+        self._quotes: dict[str, Quote] = {}
+        self._drafts: dict[str, EnrollmentDraft] = {}
+        self._confirmations: dict[str, UserConfirmation] = {}
+        self._orders: dict[str, EnrollmentOrder] = {}
+        self._payments: dict[str, PaymentTransaction] = {}
+        self._policies: dict[str, Policy] = {}
+        self._outbox_events: dict[str, OutboxEvent] = {}
+        self._tasks: dict[str, AsyncTask] = {}
+
+    def save_quote(self, quote: Quote) -> None:
+        self._quotes[quote.id] = quote
+
+    def get_quote(self, quote_id: str) -> Quote | None:
+        return self._quotes.get(quote_id)
+
+    def save_draft(self, draft: EnrollmentDraft) -> None:
+        self._drafts[draft.id] = draft
+
+    def get_draft(self, draft_id: str) -> EnrollmentDraft | None:
+        return self._drafts.get(draft_id)
+
+    def save_confirmation(self, confirmation: UserConfirmation) -> None:
+        self._confirmations[confirmation.id] = confirmation
+
+    def get_confirmation_by_token_hash(self, token_hash: str) -> UserConfirmation | None:
+        return next(
+            (
+                confirmation
+                for confirmation in self._confirmations.values()
+                if confirmation.token_hash == token_hash
+            ),
+            None,
+        )
+
+    def save_order(self, order: EnrollmentOrder) -> None:
+        self._orders[order.id] = order
+
+    def get_order(self, order_id: str) -> EnrollmentOrder | None:
+        return self._orders.get(order_id)
+
+    def find_order_by_idempotency(
+        self, user_id: str, idempotency_key: str
+    ) -> EnrollmentOrder | None:
+        return next(
+            (
+                order
+                for order in self._orders.values()
+                if order.user_id == user_id and order.idempotency_key == idempotency_key
+            ),
+            None,
+        )
+
+    def list_orders(self, user_id: str | None = None) -> list[EnrollmentOrder]:
+        return [
+            order for order in self._orders.values() if user_id is None or order.user_id == user_id
+        ]
+
+    def save_payment(self, payment: PaymentTransaction) -> None:
+        self._payments[payment.id] = payment
+
+    def get_payment(self, payment_id: str) -> PaymentTransaction | None:
+        return self._payments.get(payment_id)
+
+    def get_payment_by_no(self, payment_no: str) -> PaymentTransaction | None:
+        return next(
+            (payment for payment in self._payments.values() if payment.payment_no == payment_no),
+            None,
+        )
+
+    def get_payment_by_order(self, order_id: str) -> PaymentTransaction | None:
+        return next(
+            (payment for payment in self._payments.values() if payment.order_id == order_id),
+            None,
+        )
+
+    def find_payment_by_idempotency(
+        self, order_id: str, idempotency_key: str
+    ) -> PaymentTransaction | None:
+        return next(
+            (
+                payment
+                for payment in self._payments.values()
+                if payment.order_id == order_id and payment.idempotency_key == idempotency_key
+            ),
+            None,
+        )
+
+    def save_policy(self, policy: Policy) -> None:
+        self._policies[policy.id] = policy
+
+    def get_policy_by_order(self, order_id: str) -> Policy | None:
+        return next(
+            (policy for policy in self._policies.values() if policy.order_id == order_id),
+            None,
+        )
+
+    def list_policies(self, user_id: str | None = None) -> list[Policy]:
+        return [
+            policy
+            for policy in self._policies.values()
+            if user_id is None or policy.user_id == user_id
+        ]
+
+    def save_outbox_event(self, event: OutboxEvent) -> None:
+        self._outbox_events[event.id] = event
+
+    def save_task(self, task: AsyncTask) -> None:
+        self._tasks[task.id] = task
+
+    def get_task_by_idempotency(self, idempotency_key: str) -> AsyncTask | None:
+        return next(
+            (task for task in self._tasks.values() if task.idempotency_key == idempotency_key),
+            None,
+        )

+ 557 - 0
backend/app/domains/enrollment/service.py

@@ -0,0 +1,557 @@
+import hmac
+import json
+from collections.abc import Callable
+from dataclasses import replace
+from datetime import datetime, timedelta
+from hashlib import sha256
+from secrets import token_urlsafe
+from typing import Any, Protocol
+
+from app.core.errors import AppError
+from app.core.identifiers import new_ulid
+from app.domains.catalog.service import ProductCatalogService
+from app.domains.enrollment.models import (
+    AsyncTask,
+    EnrollmentDraft,
+    EnrollmentOrder,
+    OutboxEvent,
+    PaymentTransaction,
+    Policy,
+    Quote,
+    UserConfirmation,
+)
+from app.domains.enrollment.repository import EnrollmentRepository
+from app.domains.enrollment.tasks import InlinePolicyTaskDispatcher, TaskDispatcher
+from app.domains.identity.models import H5User
+
+
+class OrderAttributionRecorder(Protocol):
+    def record_order(self, order: EnrollmentOrder) -> None: ...
+
+
+class EnrollmentService:
+    def __init__(
+        self,
+        repository: EnrollmentRepository,
+        catalog_service: ProductCatalogService,
+        clock: Callable[[], datetime],
+        callback_secret: str,
+        attribution_recorder: OrderAttributionRecorder | None = None,
+        task_dispatcher: TaskDispatcher | None = None,
+    ) -> None:
+        self._repository = repository
+        self._catalog = catalog_service
+        self._clock = clock
+        self._callback_secret = callback_secret.encode("utf-8")
+        self._attribution_recorder = attribution_recorder
+        self._task_dispatcher = task_dispatcher or InlinePolicyTaskDispatcher(
+            repository,
+            clock,
+        )
+
+    def create_quote(
+        self,
+        user: H5User,
+        *,
+        product_id: str,
+        plan_id: str,
+        age: int,
+        region_code: str,
+        occupation_code: str,
+        relationship: str,
+    ) -> dict[str, Any]:
+        product = next(
+            (item for item in self._catalog.list_available() if item["product_id"] == product_id),
+            None,
+        )
+        if product is None:
+            raise AppError("PRODUCT_NOT_AVAILABLE", "产品当前不可投保", 404)
+        plan = next(
+            (item for item in product["plans"] if item["id"] == plan_id),
+            None,
+        )
+        if plan is None:
+            raise AppError("PLAN_NOT_AVAILABLE", "保障计划当前不可投保", 404)
+
+        premium = self._deterministic_premium(
+            str(product["product_code"]),
+            str(plan["code"]),
+            age,
+            region_code,
+            occupation_code,
+            configured_premium=int(plan.get("premium_cents", 0)),
+            configured_min_age=int(plan.get("min_age", 0)),
+            configured_max_age=int(plan.get("max_age", 100)),
+        )
+        now = self._clock()
+        quote = Quote(
+            id=new_ulid(),
+            user_id=user.id,
+            product_id=product_id,
+            product_version_id=str(product["product_version_id"]),
+            plan_id=plan_id,
+            insured_age=age,
+            insured_region_code=region_code,
+            occupation_code=occupation_code,
+            relationship=relationship,
+            premium_cents=premium,
+            currency="CNY",
+            rule_version="eligibility-v1",
+            rate_version="rate-v1",
+            status="VALID",
+            expires_at=now + timedelta(minutes=30),
+            created_at=now,
+        )
+        self._repository.save_quote(quote)
+        return {
+            "eligible": True,
+            "eligibility_reasons": [],
+            "quote_id": quote.id,
+            "product_id": quote.product_id,
+            "product_version_id": quote.product_version_id,
+            "plan_id": quote.plan_id,
+            "premium_cents": quote.premium_cents,
+            "currency": quote.currency,
+            "rule_version": quote.rule_version,
+            "rate_version": quote.rate_version,
+            "expires_at": quote.expires_at,
+        }
+
+    def create_draft(
+        self,
+        user: H5User,
+        *,
+        quote_id: str,
+        applicant: dict[str, Any],
+        insured: dict[str, Any],
+        contact: dict[str, Any],
+    ) -> dict[str, Any]:
+        quote = self._repository.get_quote(quote_id)
+        now = self._clock()
+        if (
+            quote is None
+            or quote.user_id != user.id
+            or quote.status != "VALID"
+            or quote.expires_at <= now
+        ):
+            raise AppError("QUOTE_NOT_AVAILABLE", "报价不存在或已失效", 410)
+        draft = EnrollmentDraft(
+            id=new_ulid(),
+            user_id=user.id,
+            quote_id=quote.id,
+            applicant=applicant,
+            insured=insured,
+            contact=contact,
+            status="EDITABLE",
+            expires_at=now + timedelta(hours=24),
+            created_at=now,
+        )
+        self._repository.save_draft(draft)
+        return {
+            "draft_id": draft.id,
+            "quote_id": draft.quote_id,
+            "status": draft.status,
+            "expires_at": draft.expires_at,
+        }
+
+    def confirm_draft(self, user: H5User, draft_id: str) -> dict[str, Any]:
+        draft = self._repository.get_draft(draft_id)
+        now = self._clock()
+        if (
+            draft is None
+            or draft.user_id != user.id
+            or draft.status != "EDITABLE"
+            or draft.expires_at <= now
+        ):
+            raise AppError("DRAFT_NOT_AVAILABLE", "投保草稿不存在或不可确认", 409)
+        raw_token = token_urlsafe(32)
+        confirmation = UserConfirmation(
+            id=new_ulid(),
+            user_id=user.id,
+            draft_id=draft.id,
+            token_hash=self._hash_token(raw_token),
+            status="PENDING",
+            expires_at=now + timedelta(minutes=10),
+            created_at=now,
+        )
+        self._repository.save_confirmation(confirmation)
+        return {
+            "confirmation_id": confirmation.id,
+            "confirmation_token": raw_token,
+            "expires_at": confirmation.expires_at,
+        }
+
+    def create_order(
+        self,
+        user: H5User,
+        *,
+        draft_id: str,
+        confirmation_token: str,
+        idempotency_key: str,
+    ) -> tuple[dict[str, Any], bool]:
+        existing = self._repository.find_order_by_idempotency(user.id, idempotency_key)
+        if existing is not None:
+            if existing.draft_id != draft_id:
+                raise AppError(
+                    "IDEMPOTENCY_CONFLICT",
+                    "相同幂等键已用于其他投保请求",
+                    409,
+                )
+            return self._order_data(existing), False
+
+        now = self._clock()
+        draft = self._repository.get_draft(draft_id)
+        confirmation = self._repository.get_confirmation_by_token_hash(
+            self._hash_token(confirmation_token)
+        )
+        if draft is None or draft.user_id != user.id:
+            raise AppError("DRAFT_NOT_AVAILABLE", "投保草稿不存在", 404)
+        if (
+            confirmation is None
+            or confirmation.user_id != user.id
+            or confirmation.draft_id != draft.id
+            or confirmation.status != "PENDING"
+            or confirmation.expires_at <= now
+        ):
+            raise AppError(
+                "CONFIRMATION_REQUIRED",
+                "创建订单前需要有效的用户确认",
+                409,
+            )
+        quote = self._repository.get_quote(draft.quote_id)
+        if quote is None or quote.expires_at <= now:
+            raise AppError("QUOTE_EXPIRED", "报价已过期", 410)
+        order = EnrollmentOrder(
+            id=new_ulid(),
+            order_no=f"ORD-{now:%Y%m%d}-{new_ulid()[-8:]}",
+            user_id=user.id,
+            quote_id=quote.id,
+            draft_id=draft.id,
+            confirmation_id=confirmation.id,
+            idempotency_key=idempotency_key,
+            product_id=quote.product_id,
+            product_version_id=quote.product_version_id,
+            plan_id=quote.plan_id,
+            applicant_snapshot=draft.applicant,
+            insured_snapshot=draft.insured,
+            amount_cents=quote.premium_cents,
+            currency=quote.currency,
+            status="PENDING_PAYMENT",
+            created_at=now,
+        )
+        self._repository.save_order(order)
+        if self._attribution_recorder is not None:
+            self._attribution_recorder.record_order(order)
+        return self._order_data(order), True
+
+    def create_payment(
+        self,
+        user: H5User,
+        *,
+        order_id: str,
+        idempotency_key: str,
+    ) -> tuple[dict[str, Any], bool]:
+        order = self._repository.get_order(order_id)
+        if order is None or order.user_id != user.id:
+            raise AppError("ORDER_NOT_FOUND", "未找到订单", 404)
+        if order.status != "PENDING_PAYMENT":
+            raise AppError("ORDER_NOT_PAYABLE", "订单当前不可支付", 409)
+        existing = self._repository.find_payment_by_idempotency(order.id, idempotency_key)
+        if existing is not None:
+            return self._payment_data(existing), False
+        now = self._clock()
+        payment = PaymentTransaction(
+            id=new_ulid(),
+            payment_no=f"PAY-{now:%Y%m%d}-{new_ulid()[-8:]}",
+            order_id=order.id,
+            user_id=user.id,
+            transaction_type="PAYMENT",
+            provider="LOCAL_MOCK_PAY",
+            idempotency_key=idempotency_key,
+            amount_cents=order.amount_cents,
+            currency=order.currency,
+            status="CREATED",
+            provider_transaction_no=None,
+            succeeded_at=None,
+            created_at=now,
+        )
+        self._repository.save_payment(payment)
+        return self._payment_data(payment), True
+
+    def complete_mock_payment(
+        self,
+        user: H5User,
+        *,
+        payment_id: str,
+    ) -> dict[str, Any]:
+        payment = self._repository.get_payment(payment_id)
+        if payment is None or payment.user_id != user.id:
+            raise AppError("PAYMENT_NOT_FOUND", "未找到支付流水", 404)
+        now = self._clock()
+        payload = {
+            "callback_no": f"CB-{now:%Y%m%d}-{payment.id[-8:]}",
+            "provider_transaction_no": f"MP-{now:%Y%m%d}-{payment.id[-8:]}",
+            "payment_no": payment.payment_no,
+            "status": "SUCCEEDED",
+            "amount_cents": payment.amount_cents,
+            "occurred_at": now.isoformat(),
+        }
+        signature = self.sign_mock_callback(payload)
+        return self.handle_mock_callback(payload, signature)
+
+    def handle_mock_callback(
+        self,
+        payload: dict[str, Any],
+        signature: str,
+    ) -> dict[str, Any]:
+        expected_signature = self.sign_mock_callback(payload)
+        if not hmac.compare_digest(signature, expected_signature):
+            raise AppError("INVALID_CALLBACK_SIGNATURE", "支付回调签名无效", 401)
+        payment = self._repository.get_payment_by_no(str(payload["payment_no"]))
+        if payment is None:
+            raise AppError("PAYMENT_NOT_FOUND", "未找到支付流水", 404)
+        if payload["status"] != "SUCCEEDED":
+            raise AppError("PAYMENT_CALLBACK_REJECTED", "支付结果不是成功状态", 409)
+        if int(payload["amount_cents"]) != payment.amount_cents:
+            raise AppError("PAYMENT_AMOUNT_MISMATCH", "支付回调金额不一致", 409)
+        order = self._repository.get_order(payment.order_id)
+        if order is None:
+            raise AppError("ORDER_NOT_FOUND", "未找到支付对应订单", 404)
+        existing_policy = self._repository.get_policy_by_order(order.id)
+        if payment.status == "SUCCEEDED" and existing_policy is not None:
+            return self._payment_completion_data(payment, order, existing_policy)
+
+        now = self._clock()
+        succeeded_payment = replace(
+            payment,
+            status="SUCCEEDED",
+            provider_transaction_no=str(payload["provider_transaction_no"]),
+            succeeded_at=now,
+        )
+        paid_order = replace(order, status="PAID")
+        self._repository.save_payment(succeeded_payment)
+        self._repository.save_order(paid_order)
+
+        event = OutboxEvent(
+            id=new_ulid(),
+            aggregate_type="PAYMENT",
+            aggregate_id=payment.id,
+            event_type="payment.succeeded",
+            payload={"payment_id": payment.id, "order_id": order.id},
+            status="PENDING",
+            created_at=now,
+        )
+        task_key = f"policy.issue:{order.id}"
+        task = self._repository.get_task_by_idempotency(task_key) or AsyncTask(
+            id=new_ulid(),
+            task_type="policy.issue",
+            business_key=order.id,
+            idempotency_key=task_key,
+            payload={"order_id": order.id},
+            status="PENDING",
+            attempt_count=0,
+            created_at=now,
+        )
+        self._repository.save_outbox_event(event)
+        self._repository.save_task(task)
+        policy = existing_policy or self._task_dispatcher.dispatch(task)
+        self._repository.save_outbox_event(replace(event, status="DISPATCHED"))
+        issued_order = self._repository.get_order(order.id)
+        if issued_order is None:
+            raise AppError("ORDER_NOT_FOUND", "保单签发后未找到订单", 500)
+        return self._payment_completion_data(succeeded_payment, issued_order, policy)
+
+    def sign_mock_callback(self, payload: dict[str, Any]) -> str:
+        canonical = json.dumps(
+            payload,
+            ensure_ascii=False,
+            sort_keys=True,
+            separators=(",", ":"),
+        ).encode("utf-8")
+        return hmac.new(
+            self._callback_secret,
+            canonical,
+            digestmod=sha256,
+        ).hexdigest()
+
+    def list_policies(self, user: H5User) -> dict[str, Any]:
+        policies = self._repository.list_policies(user.id)
+        items = [self._policy_data(policy) for policy in policies]
+        return {"items": items, "total": len(items)}
+
+    def list_all_policies(self) -> dict[str, Any]:
+        policies = self._repository.list_policies()
+        items = [self._policy_data(policy) for policy in policies]
+        return {"items": items, "total": len(items)}
+
+    def list_orders(self, user_id: str | None = None) -> dict[str, Any]:
+        orders = self._repository.list_orders(user_id)
+        items = [self._order_data(order) for order in orders]
+        return {"items": items, "total": len(items)}
+
+    @staticmethod
+    def _hash_token(token: str) -> str:
+        return sha256(token.encode("utf-8")).hexdigest()
+
+    def _order_data(self, order: EnrollmentOrder) -> dict[str, Any]:
+        draft = self._repository.get_draft(order.draft_id)
+        quote = self._repository.get_quote(order.quote_id)
+        payment = self._repository.get_payment_by_order(order.id)
+        policy = self._repository.get_policy_by_order(order.id)
+        product = next(
+            (
+                item
+                for item in self._catalog.list_available()
+                if item["product_id"] == order.product_id
+            ),
+            None,
+        )
+        plan = (
+            next(
+                (item for item in product["plans"] if item["id"] == order.plan_id),
+                None,
+            )
+            if product is not None
+            else None
+        )
+        return {
+            "order_id": order.id,
+            "order_no": order.order_no,
+            "user_id": order.user_id,
+            "product_name": product["name"] if product else "智保通保障计划",
+            "plan_name": plan["name"] if plan else "保障计划",
+            "relationship": quote.relationship if quote else "SELF",
+            "applicant": order.applicant_snapshot,
+            "insured": order.insured_snapshot,
+            "contact": draft.contact if draft else {},
+            "status": order.status,
+            "amount_cents": order.amount_cents,
+            "currency": order.currency,
+            "payment": (
+                {
+                    "payment_id": payment.id,
+                    "payment_no": payment.payment_no,
+                    "provider": payment.provider,
+                    "status": payment.status,
+                    "provider_transaction_no": payment.provider_transaction_no,
+                    "succeeded_at": payment.succeeded_at,
+                }
+                if payment
+                else None
+            ),
+            "policy_no": policy.policy_no if policy else None,
+            "created_at": order.created_at,
+        }
+
+    @staticmethod
+    def _payment_data(payment: PaymentTransaction) -> dict[str, Any]:
+        return {
+            "payment_id": payment.id,
+            "payment_no": payment.payment_no,
+            "order_id": payment.order_id,
+            "transaction_type": payment.transaction_type,
+            "status": payment.status,
+            "amount_cents": payment.amount_cents,
+            "currency": payment.currency,
+        }
+
+    def _policy_data(self, policy: Policy) -> dict[str, Any]:
+        order = self._repository.get_order(policy.order_id)
+        quote = self._repository.get_quote(order.quote_id) if order else None
+        product = next(
+            (
+                item
+                for item in self._catalog.list_available()
+                if item["product_id"] == policy.product_id
+            ),
+            None,
+        )
+        plan = (
+            next(
+                (item for item in product["plans"] if item["id"] == policy.plan_id),
+                None,
+            )
+            if product is not None
+            else None
+        )
+        return {
+            "policy_id": policy.id,
+            "policy_no": policy.policy_no,
+            "order_id": policy.order_id,
+            "order_no": order.order_no if order else "",
+            "product_name": product["name"] if product else "智保通保障计划",
+            "plan_name": plan["name"] if plan else "保障计划",
+            "relationship": quote.relationship if quote else "SELF",
+            "applicant": order.applicant_snapshot if order else {},
+            "insured": order.insured_snapshot if order else {},
+            "status": policy.status,
+            "premium_cents": policy.premium_cents,
+            "currency": policy.currency,
+            "coverage_start": policy.coverage_start,
+            "coverage_end": policy.coverage_end,
+            "issued_at": policy.issued_at,
+        }
+
+    def _payment_completion_data(
+        self,
+        payment: PaymentTransaction,
+        order: EnrollmentOrder,
+        policy: Policy,
+    ) -> dict[str, Any]:
+        return {
+            "payment_id": payment.id,
+            "payment_status": payment.status,
+            "order_id": order.id,
+            "order_status": order.status,
+            "policy": self._policy_data(policy),
+        }
+
+    @staticmethod
+    def _deterministic_premium(
+        product_code: str,
+        plan_code: str,
+        age: int,
+        region_code: str,
+        occupation_code: str,
+        *,
+        configured_premium: int = 0,
+        configured_min_age: int = 0,
+        configured_max_age: int = 100,
+    ) -> int:
+        if region_code != "510100":
+            raise AppError(
+                "ELIGIBILITY_REJECTED",
+                "当前产品仅支持成都地区",
+                422,
+                details={"reason_code": "REGION_NOT_SUPPORTED"},
+            )
+        if occupation_code != "GENERAL":
+            raise AppError(
+                "ELIGIBILITY_REJECTED",
+                "当前职业类别不在可投保范围",
+                422,
+                details={"reason_code": "OCCUPATION_NOT_SUPPORTED"},
+            )
+        rates: dict[tuple[str, str], tuple[int, int, int]] = {
+            ("MED-SENIOR", "SENIOR_STANDARD"): (50, 75, 19900),
+            ("MED-BASIC", "BASIC"): (0, 65, 23900),
+            ("MED-UPGRADE", "STANDARD"): (0, 65, 39900),
+            ("MED-UPGRADE", "ENHANCED"): (0, 65, 59900),
+            ("ACC-FAMILY", "INDIVIDUAL"): (0, 70, 9900),
+            ("ACC-FAMILY", "FAMILY"): (0, 70, 15900),
+        }
+        rule = rates.get((product_code, plan_code))
+        min_age = configured_min_age if configured_premium > 0 else (rule[0] if rule else 0)
+        max_age = configured_max_age if configured_premium > 0 else (rule[1] if rule else -1)
+        if (configured_premium <= 0 and rule is None) or not min_age <= age <= max_age:
+            raise AppError(
+                "ELIGIBILITY_REJECTED",
+                "被保人年龄不在该计划可投保范围",
+                422,
+                details={"reason_code": "AGE_OUT_OF_RANGE"},
+            )
+        if configured_premium > 0:
+            return configured_premium
+        if rule is None:
+            raise AppError("RATE_NOT_FOUND", "未找到该保障计划的费率", 422)
+        return rule[2]

+ 83 - 0
backend/app/domains/enrollment/tasks.py

@@ -0,0 +1,83 @@
+"""投保领域异步任务边界。
+
+第一阶段使用进程内调度器保证本地一键运行;接口可以在后续替换为 RabbitMQ
+发布器和独立 Worker,支付回调与保单签发规则无需改写。
+"""
+
+from collections.abc import Callable
+from dataclasses import replace
+from datetime import datetime, timedelta
+from typing import Protocol
+
+from app.core.errors import AppError
+from app.core.identifiers import new_ulid
+from app.domains.enrollment.models import AsyncTask, Policy
+from app.domains.enrollment.repository import EnrollmentRepository
+
+
+class TaskDispatcher(Protocol):
+    def dispatch(self, task: AsyncTask) -> Policy: ...
+
+
+class InlinePolicyTaskDispatcher:
+    """本地运行适配器:真实记录任务生命周期,然后在当前进程执行。"""
+
+    def __init__(
+        self,
+        repository: EnrollmentRepository,
+        clock: Callable[[], datetime],
+    ) -> None:
+        self._repository = repository
+        self._clock = clock
+
+    def dispatch(self, task: AsyncTask) -> Policy:
+        existing = self._repository.get_policy_by_order(task.business_key)
+        if existing is not None:
+            self._repository.save_task(
+                replace(task, status="SUCCEEDED", attempt_count=task.attempt_count + 1)
+            )
+            return existing
+
+        running = replace(
+            task,
+            status="RUNNING",
+            attempt_count=task.attempt_count + 1,
+        )
+        self._repository.save_task(running)
+        try:
+            policy = self._issue_policy(task.business_key)
+        except Exception:
+            self._repository.save_task(replace(running, status="FAILED"))
+            raise
+        self._repository.save_task(replace(running, status="SUCCEEDED"))
+        return policy
+
+    def _issue_policy(self, order_id: str) -> Policy:
+        order = self._repository.get_order(order_id)
+        if order is None:
+            raise AppError("ORDER_NOT_FOUND", "未找到待签发保单的订单", 404)
+        if order.status not in {"PAID", "ISSUED"}:
+            raise AppError("ORDER_NOT_PAID", "订单尚未支付,不能签发保单", 409)
+        existing = self._repository.get_policy_by_order(order.id)
+        if existing is not None:
+            return existing
+
+        now = self._clock()
+        policy = Policy(
+            id=new_ulid(),
+            policy_no=f"POL-{now:%Y%m%d}-{new_ulid()[-8:]}",
+            order_id=order.id,
+            user_id=order.user_id,
+            product_id=order.product_id,
+            product_version_id=order.product_version_id,
+            plan_id=order.plan_id,
+            premium_cents=order.amount_cents,
+            currency=order.currency,
+            coverage_start=now.date() + timedelta(days=1),
+            coverage_end=now.date() + timedelta(days=365),
+            status="ACTIVE",
+            issued_at=now,
+        )
+        self._repository.save_policy(policy)
+        self._repository.save_order(replace(order, status="ISSUED"))
+        return policy

+ 1 - 0
backend/app/domains/identity/__init__.py

@@ -0,0 +1 @@
+"""身份与权限领域。"""

+ 49 - 0
backend/app/domains/identity/models.py

@@ -0,0 +1,49 @@
+from dataclasses import dataclass
+from datetime import datetime
+
+
+@dataclass(frozen=True, slots=True)
+class H5User:
+    id: str
+    mobile: str
+    mobile_masked: str
+    display_name: str
+    status: str
+    created_at: datetime
+
+
+@dataclass(frozen=True, slots=True)
+class AdminUser:
+    id: str
+    username: str
+    password_hash: str
+    display_name: str
+    status: str
+    roles: tuple[str, ...]
+    permissions: tuple[str, ...]
+    data_scope: str
+
+
+@dataclass(frozen=True, slots=True)
+class AdminRole:
+    code: str
+    name: str
+    data_scope: str
+    status: str
+    permissions: tuple[str, ...]
+
+
+@dataclass(slots=True)
+class AuthSession:
+    id: str
+    subject_type: str
+    subject_id: str
+    refresh_jti_hash: str
+    expires_at: datetime
+    revoked_at: datetime | None = None
+
+    @property
+    def active(self) -> bool:
+        from datetime import UTC, datetime
+
+        return self.revoked_at is None and self.expires_at > datetime.now(UTC)

+ 115 - 0
backend/app/domains/identity/repository.py

@@ -0,0 +1,115 @@
+from dataclasses import replace
+from typing import Protocol
+
+from app.domains.identity.models import AdminRole, AdminUser, AuthSession, H5User
+
+
+class IdentityRepository(Protocol):
+    def get_h5_user_by_mobile(self, mobile: str) -> H5User | None: ...
+
+    def get_h5_user(self, user_id: str) -> H5User | None: ...
+
+    def list_h5_users(self) -> list[H5User]: ...
+
+    def save_h5_user(self, user: H5User) -> None: ...
+
+    def get_admin_user_by_username(self, username: str) -> AdminUser | None: ...
+
+    def get_admin_user(self, user_id: str) -> AdminUser | None: ...
+
+    def list_admin_users(self) -> list[AdminUser]: ...
+
+    def save_admin_user(self, user: AdminUser) -> None: ...
+
+    def list_roles(self) -> list[AdminRole]: ...
+
+    def save_role(self, role: AdminRole) -> None: ...
+
+    def get_session(self, session_id: str) -> AuthSession | None: ...
+
+    def save_session(self, session: AuthSession) -> None: ...
+
+
+class InMemoryIdentityRepository:
+    def __init__(self) -> None:
+        self._h5_users: dict[str, H5User] = {}
+        self._h5_user_ids_by_mobile: dict[str, str] = {}
+        self._admin_users: dict[str, AdminUser] = {}
+        self._admin_user_ids_by_username: dict[str, str] = {}
+        self._roles: dict[str, AdminRole] = {}
+        self._sessions: dict[str, AuthSession] = {}
+
+    def get_h5_user_by_mobile(self, mobile: str) -> H5User | None:
+        user_id = self._h5_user_ids_by_mobile.get(mobile)
+        return self._h5_users.get(user_id) if user_id else None
+
+    def get_h5_user(self, user_id: str) -> H5User | None:
+        return self._h5_users.get(user_id)
+
+    def list_h5_users(self) -> list[H5User]:
+        return sorted(
+            self._h5_users.values(),
+            key=lambda user: user.created_at,
+            reverse=True,
+        )
+
+    def save_h5_user(self, user: H5User) -> None:
+        self._h5_users[user.id] = user
+        self._h5_user_ids_by_mobile[user.mobile] = user.id
+
+    def get_admin_user_by_username(self, username: str) -> AdminUser | None:
+        user_id = self._admin_user_ids_by_username.get(username)
+        return self._admin_users.get(user_id) if user_id else None
+
+    def get_admin_user(self, user_id: str) -> AdminUser | None:
+        return self._admin_users.get(user_id)
+
+    def list_admin_users(self) -> list[AdminUser]:
+        return sorted(self._admin_users.values(), key=lambda user: user.username)
+
+    def save_admin_user(self, user: AdminUser) -> None:
+        self._admin_users[user.id] = user
+        self._admin_user_ids_by_username[user.username] = user.id
+        for role_code in user.roles:
+            self._roles.setdefault(
+                role_code,
+                AdminRole(
+                    code=role_code,
+                    name=role_code,
+                    data_scope=user.data_scope,
+                    status="ACTIVE",
+                    permissions=user.permissions,
+                ),
+            )
+
+    def list_roles(self) -> list[AdminRole]:
+        return sorted(self._roles.values(), key=lambda role: role.code)
+
+    def save_role(self, role: AdminRole) -> None:
+        if role.code not in self._roles:
+            raise KeyError(role.code)
+        self._roles[role.code] = role
+        for user_id, user in tuple(self._admin_users.items()):
+            if role.code in user.roles:
+                assigned_roles = [self._roles[code] for code in user.roles]
+                permissions = tuple(
+                    sorted(
+                        {
+                            permission
+                            for assigned_role in assigned_roles
+                            for permission in assigned_role.permissions
+                        }
+                    )
+                )
+                scopes = {assigned_role.data_scope for assigned_role in assigned_roles}
+                self._admin_users[user_id] = replace(
+                    user,
+                    permissions=permissions,
+                    data_scope="ALL" if "ALL" in scopes else sorted(scopes)[0],
+                )
+
+    def get_session(self, session_id: str) -> AuthSession | None:
+        return self._sessions.get(session_id)
+
+    def save_session(self, session: AuthSession) -> None:
+        self._sessions[session.id] = session

+ 495 - 0
backend/app/domains/identity/service.py

@@ -0,0 +1,495 @@
+"""H5 用户和后台管理员的认证业务逻辑。
+
+路由层负责收发 HTTP 数据,本文件负责验证码、登录、令牌刷新和退出等业务规则。
+"""
+
+import re
+from dataclasses import replace
+from datetime import UTC, datetime, timedelta
+from typing import Any
+
+from app.core.config import Settings
+from app.core.errors import AppError
+from app.core.identifiers import new_ulid
+from app.core.passwords import PasswordService
+from app.core.security import JwtService
+from app.domains.identity.models import AdminRole, AdminUser, AuthSession, H5User
+from app.domains.identity.repository import IdentityRepository
+
+MOBILE_PATTERN = re.compile(r"^1\d{10}$")
+ADMIN_USERNAME_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{2,31}$")
+ROLE_PERMISSION_CATALOG: tuple[dict[str, str], ...] = (
+    {"code": "dashboard:read", "name": "查看运营总览", "group": "运营分析"},
+    {"code": "order:read", "name": "查看全部订单", "group": "保单业务"},
+    {"code": "policy:read", "name": "查看全部保单", "group": "保单业务"},
+    {"code": "user:read", "name": "查看用户信息", "group": "客户管理"},
+    {"code": "user:write", "name": "维护用户状态", "group": "客户管理"},
+    {"code": "product:read", "name": "查看产品配置", "group": "产品管理"},
+    {"code": "product:write", "name": "维护产品与版本", "group": "产品管理"},
+    {"code": "attribution:read", "name": "查看推广归因", "group": "渠道推广"},
+    {"code": "role:read", "name": "查看角色权限", "group": "系统治理"},
+    {"code": "role:write", "name": "编辑角色权限", "group": "系统治理"},
+)
+ROLE_NAMES = {
+    "SUPER_ADMIN": "超级管理员",
+    "OPERATOR": "运营人员",
+    "REVIEWER": "审核人员",
+    "SALESPERSON": "业务员",
+}
+ALLOWED_DATA_SCOPES = {"ALL", "MASKED_ALL", "READ_ONLY", "SELF"}
+
+
+class H5AuthService:
+    """处理 H5 用户认证流程。"""
+
+    def __init__(
+        self,
+        settings: Settings,
+        repository: IdentityRepository,
+        jwt_service: JwtService,
+    ) -> None:
+        self._settings = settings
+        self._repository = repository
+        self._jwt = jwt_service
+
+    def request_code(self, mobile: str) -> dict[str, int]:
+        """校验手机号并请求验证码;当前开发环境使用固定验证码。"""
+        self._validate_mobile(mobile)
+        if self._settings.app_env == "production" or not self._settings.dev_tools_enabled:
+            raise AppError("DEPENDENCY_UNAVAILABLE", "当前环境未配置验证码服务", 503)
+        return {"expires_in": 300}
+
+    def login(self, mobile: str, code: str) -> dict[str, Any]:
+        """验证手机号和验证码,必要时创建用户,并签发一对 JWT。"""
+        self._validate_mobile(mobile)
+        if code != self._settings.dev_fixed_otp:
+            raise AppError("INVALID_OTP", "验证码错误或已过期", 400)
+        user = self._repository.get_h5_user_by_mobile(mobile)
+        if user is None:
+            # 首次登录即注册:数据库中没有该手机号时创建一条用户记录。
+            user = H5User(
+                id=new_ulid(),
+                mobile=mobile,
+                mobile_masked=self._mask_mobile(mobile),
+                display_name=f"用户{mobile[-4:]}",
+                status="ACTIVE",
+                created_at=datetime.now(UTC),
+            )
+            self._repository.save_h5_user(user)
+        if user.status != "ACTIVE":
+            raise AppError("PERMISSION_DENIED", "用户已被禁用", 403)
+        # session_id 把 access token、refresh token 与服务端会话记录关联起来。
+        session_id = new_ulid()
+        tokens = self._jwt.issue_pair(
+            subject_id=user.id,
+            subject_type="H5_USER",
+            audience="zbt-h5",
+            session_id=session_id,
+        )
+        self._repository.save_session(
+            AuthSession(
+                id=session_id,
+                subject_type="H5_USER",
+                subject_id=user.id,
+                refresh_jti_hash=self._jwt.hash_token_identifier(tokens.refresh_jti),
+                expires_at=datetime.now(UTC) + timedelta(days=self._settings.refresh_token_days),
+            )
+        )
+        return {
+            "user": self._user_data(user),
+            "tokens": {
+                "access_token": tokens.access_token,
+                "refresh_token": tokens.refresh_token,
+                "token_type": "bearer",
+                "access_expires_in": tokens.access_expires_in,
+                "refresh_expires_in": tokens.refresh_expires_in,
+            },
+        }
+
+    def authenticate_access(self, token: str) -> H5User:
+        """验证 access token 和服务端会话,返回当前登录用户。"""
+        payload = self._jwt.decode_access(token, audience="zbt-h5")
+        session = self._repository.get_session(str(payload["sid"]))
+        if session is None or not session.active:
+            raise AppError("AUTH_REQUIRED", "登录会话已失效", 401)
+        user = self._repository.get_h5_user(str(payload["sub"]))
+        if user is None or user.status != "ACTIVE":
+            raise AppError("AUTH_REQUIRED", "用户不存在或已禁用", 401)
+        return user
+
+    def refresh(self, refresh_token: str) -> dict[str, Any]:
+        """用 refresh token 换取新令牌,并使旧 refresh token 失效。"""
+        payload = self._jwt.decode_refresh(refresh_token, audience="zbt-h5")
+        session = self._repository.get_session(str(payload["sid"]))
+        # 数据库只保存 jti 的哈希,数据库泄漏时不会直接暴露令牌标识。
+        presented_jti_hash = self._jwt.hash_token_identifier(str(payload["jti"]))
+        if session is None or not session.active or session.refresh_jti_hash != presented_jti_hash:
+            raise AppError("AUTH_REQUIRED", "刷新凭证已失效", 401)
+        user = self._repository.get_h5_user(str(payload["sub"]))
+        if user is None or user.status != "ACTIVE":
+            raise AppError("AUTH_REQUIRED", "用户不存在或已禁用", 401)
+        tokens = self._jwt.issue_pair(
+            subject_id=user.id,
+            subject_type="H5_USER",
+            audience="zbt-h5",
+            session_id=session.id,
+        )
+        session.refresh_jti_hash = self._jwt.hash_token_identifier(tokens.refresh_jti)
+        session.expires_at = datetime.now(UTC) + timedelta(days=self._settings.refresh_token_days)
+        self._repository.save_session(session)
+        return {
+            "user": self._user_data(user),
+            "tokens": {
+                "access_token": tokens.access_token,
+                "refresh_token": tokens.refresh_token,
+                "token_type": "bearer",
+                "access_expires_in": tokens.access_expires_in,
+                "refresh_expires_in": tokens.refresh_expires_in,
+            },
+        }
+
+    def logout(self, access_token: str) -> None:
+        """撤销服务端会话;即使 JWT 尚未过期,之后也无法继续使用。"""
+        payload = self._jwt.decode_access(access_token, audience="zbt-h5")
+        session = self._repository.get_session(str(payload["sid"]))
+        if session is not None and session.revoked_at is None:
+            session.revoked_at = datetime.now(UTC)
+            self._repository.save_session(session)
+
+    @staticmethod
+    def _validate_mobile(mobile: str) -> None:
+        if not MOBILE_PATTERN.fullmatch(mobile):
+            raise AppError("VALIDATION_ERROR", "手机号格式无效", 400)
+
+    @staticmethod
+    def _mask_mobile(mobile: str) -> str:
+        return f"{mobile[:3]}****{mobile[-4:]}"
+
+    @staticmethod
+    def _user_data(user: H5User) -> dict[str, str]:
+        return {
+            "id": user.id,
+            "mobile": user.mobile,
+            "mobile_masked": user.mobile_masked,
+            "display_name": user.display_name,
+            "status": user.status,
+        }
+
+
+class AdminAuthService:
+    """处理后台管理员认证,与 H5 用户使用不同的 audience。"""
+
+    def __init__(
+        self,
+        settings: Settings,
+        repository: IdentityRepository,
+        jwt_service: JwtService,
+        password_service: PasswordService,
+    ) -> None:
+        self._settings = settings
+        self._repository = repository
+        self._jwt = jwt_service
+        self._passwords = password_service
+
+    def login(self, username: str, password: str) -> dict[str, Any]:
+        user = self._repository.get_admin_user_by_username(username)
+        if (
+            user is None
+            or user.status != "ACTIVE"
+            or not self._passwords.verify(password, user.password_hash)
+        ):
+            raise AppError("AUTH_REQUIRED", "用户名或密码错误", 401)
+        session_id = new_ulid()
+        tokens = self._jwt.issue_pair(
+            subject_id=user.id,
+            subject_type="ADMIN_USER",
+            audience="zbt-admin",
+            session_id=session_id,
+        )
+        self._repository.save_session(
+            AuthSession(
+                id=session_id,
+                subject_type="ADMIN_USER",
+                subject_id=user.id,
+                refresh_jti_hash=self._jwt.hash_token_identifier(tokens.refresh_jti),
+                expires_at=datetime.now(UTC) + timedelta(days=self._settings.refresh_token_days),
+            )
+        )
+        return {
+            "user": self._user_data(user),
+            "tokens": {
+                "access_token": tokens.access_token,
+                "refresh_token": tokens.refresh_token,
+                "token_type": "bearer",
+                "access_expires_in": tokens.access_expires_in,
+                "refresh_expires_in": tokens.refresh_expires_in,
+            },
+        }
+
+    def authenticate_access(self, token: str) -> AdminUser:
+        payload = self._jwt.decode_access(token, audience="zbt-admin")
+        session = self._repository.get_session(str(payload["sid"]))
+        if session is None or not session.active:
+            raise AppError("AUTH_REQUIRED", "登录会话已失效", 401)
+        user = self._repository.get_admin_user(str(payload["sub"]))
+        if user is None or user.status != "ACTIVE":
+            raise AppError("AUTH_REQUIRED", "后台账号不存在或已禁用", 401)
+        return user
+
+    def refresh(self, refresh_token: str) -> dict[str, Any]:
+        """轮换后台刷新令牌,并维持同一个服务端会话。"""
+        payload = self._jwt.decode_refresh(refresh_token, audience="zbt-admin")
+        session = self._repository.get_session(str(payload["sid"]))
+        presented_jti_hash = self._jwt.hash_token_identifier(str(payload["jti"]))
+        if session is None or not session.active or session.refresh_jti_hash != presented_jti_hash:
+            raise AppError("AUTH_REQUIRED", "刷新凭证已失效", 401)
+        user = self._repository.get_admin_user(str(payload["sub"]))
+        if user is None or user.status != "ACTIVE":
+            raise AppError("AUTH_REQUIRED", "后台账号不存在或已禁用", 401)
+        tokens = self._jwt.issue_pair(
+            subject_id=user.id,
+            subject_type="ADMIN_USER",
+            audience="zbt-admin",
+            session_id=session.id,
+        )
+        session.refresh_jti_hash = self._jwt.hash_token_identifier(tokens.refresh_jti)
+        session.expires_at = datetime.now(UTC) + timedelta(days=self._settings.refresh_token_days)
+        self._repository.save_session(session)
+        return {
+            "user": self._user_data(user),
+            "tokens": {
+                "access_token": tokens.access_token,
+                "refresh_token": tokens.refresh_token,
+                "token_type": "bearer",
+                "access_expires_in": tokens.access_expires_in,
+                "refresh_expires_in": tokens.refresh_expires_in,
+            },
+        }
+
+    def logout(self, access_token: str) -> None:
+        """撤销后台服务端会话。"""
+        payload = self._jwt.decode_access(access_token, audience="zbt-admin")
+        session = self._repository.get_session(str(payload["sid"]))
+        if session is not None and session.revoked_at is None:
+            session.revoked_at = datetime.now(UTC)
+            self._repository.save_session(session)
+
+    def list_roles(self) -> dict[str, Any]:
+        roles = self._repository.list_roles()
+        return {
+            "items": [self._role_data(role) for role in roles],
+            "permission_catalog": list(ROLE_PERMISSION_CATALOG),
+            "data_scopes": [
+                {"code": "ALL", "name": "全部数据"},
+                {"code": "MASKED_ALL", "name": "全部脱敏数据"},
+                {"code": "READ_ONLY", "name": "只读数据"},
+                {"code": "SELF", "name": "仅本人数据"},
+            ],
+        }
+
+    def update_role(
+        self,
+        role_code: str,
+        *,
+        permissions: list[str],
+        data_scope: str,
+    ) -> dict[str, Any]:
+        if role_code == "SUPER_ADMIN":
+            raise AppError(
+                "PROTECTED_ROLE",
+                "超级管理员为系统保护角色,不允许修改",
+                409,
+            )
+        if data_scope not in ALLOWED_DATA_SCOPES:
+            raise AppError("VALIDATION_ERROR", "数据范围无效", 400)
+        allowed_permissions = {item["code"] for item in ROLE_PERMISSION_CATALOG}
+        requested_permissions = set(permissions)
+        if not requested_permissions.issubset(allowed_permissions):
+            raise AppError("VALIDATION_ERROR", "包含未定义的权限项", 400)
+        role = next(
+            (item for item in self._repository.list_roles() if item.code == role_code),
+            None,
+        )
+        if role is None:
+            raise AppError("ROLE_NOT_FOUND", "角色不存在", 404)
+        updated = replace(
+            role,
+            name=ROLE_NAMES.get(role.code, role.name),
+            data_scope=data_scope,
+            permissions=tuple(sorted(requested_permissions)),
+        )
+        self._repository.save_role(updated)
+        return self._role_data(updated)
+
+    def list_managed_users(self) -> dict[str, Any]:
+        return {
+            "admin_users": [self._user_data(user) for user in self._repository.list_admin_users()],
+            "customers": [self._customer_data(user) for user in self._repository.list_h5_users()],
+        }
+
+    def create_admin_user(
+        self,
+        *,
+        username: str,
+        password: str,
+        display_name: str,
+        role_code: str,
+    ) -> dict[str, Any]:
+        normalized_username = username.strip()
+        normalized_name = display_name.strip()
+        self._validate_admin_account_input(
+            normalized_username,
+            password,
+            normalized_name,
+        )
+        if self._repository.get_admin_user_by_username(normalized_username) is not None:
+            raise AppError("USERNAME_EXISTS", "后台账号已存在", 409)
+        role = self._find_role(role_code)
+        user = AdminUser(
+            id=new_ulid(),
+            username=normalized_username,
+            password_hash=self._passwords.hash(password),
+            display_name=normalized_name,
+            status="ACTIVE",
+            roles=(role.code,),
+            permissions=role.permissions,
+            data_scope=role.data_scope,
+        )
+        self._repository.save_admin_user(user)
+        return self._user_data(user)
+
+    def update_admin_user(
+        self,
+        actor: AdminUser,
+        user_id: str,
+        *,
+        display_name: str,
+        status: str,
+        role_code: str,
+    ) -> dict[str, Any]:
+        target = self._repository.get_admin_user(user_id)
+        if target is None:
+            raise AppError("ADMIN_USER_NOT_FOUND", "后台账号不存在", 404)
+        normalized_name = display_name.strip()
+        if not normalized_name:
+            raise AppError("VALIDATION_ERROR", "账号姓名不能为空", 400)
+        if status not in {"ACTIVE", "DISABLED"}:
+            raise AppError("VALIDATION_ERROR", "账号状态无效", 400)
+        if target.id == actor.id and status != "ACTIVE":
+            raise AppError("SELF_DISABLE_FORBIDDEN", "不能停用当前登录账号", 409)
+        role = self._find_role(role_code)
+        if "SUPER_ADMIN" in target.roles and (status != "ACTIVE" or role.code != "SUPER_ADMIN"):
+            active_super_admins = [
+                item
+                for item in self._repository.list_admin_users()
+                if item.status == "ACTIVE" and "SUPER_ADMIN" in item.roles
+            ]
+            if len(active_super_admins) <= 1:
+                raise AppError(
+                    "LAST_SUPER_ADMIN",
+                    "系统必须保留至少一个启用的超级管理员",
+                    409,
+                )
+        updated = replace(
+            target,
+            display_name=normalized_name,
+            status=status,
+            roles=(role.code,),
+            permissions=role.permissions,
+            data_scope=role.data_scope,
+        )
+        self._repository.save_admin_user(updated)
+        return self._user_data(updated)
+
+    def reset_admin_password(
+        self,
+        user_id: str,
+        *,
+        new_password: str,
+    ) -> dict[str, Any]:
+        target = self._repository.get_admin_user(user_id)
+        if target is None:
+            raise AppError("ADMIN_USER_NOT_FOUND", "后台账号不存在", 404)
+        if len(new_password) < 8:
+            raise AppError("VALIDATION_ERROR", "新密码至少需要8位", 400)
+        updated = replace(
+            target,
+            password_hash=self._passwords.hash(new_password),
+        )
+        self._repository.save_admin_user(updated)
+        return {"user_id": updated.id, "password_reset": True}
+
+    def update_customer_status(
+        self,
+        user_id: str,
+        *,
+        status: str,
+    ) -> dict[str, Any]:
+        user = self._repository.get_h5_user(user_id)
+        if user is None:
+            raise AppError("H5_USER_NOT_FOUND", "投保用户不存在", 404)
+        if status not in {"ACTIVE", "DISABLED"}:
+            raise AppError("VALIDATION_ERROR", "用户状态无效", 400)
+        updated = replace(user, status=status)
+        self._repository.save_h5_user(updated)
+        return self._customer_data(updated)
+
+    def _find_role(self, role_code: str) -> AdminRole:
+        role = next(
+            (item for item in self._repository.list_roles() if item.code == role_code),
+            None,
+        )
+        if role is None:
+            raise AppError("ROLE_NOT_FOUND", "角色不存在", 404)
+        return role
+
+    @staticmethod
+    def _validate_admin_account_input(
+        username: str,
+        password: str,
+        display_name: str,
+    ) -> None:
+        if not ADMIN_USERNAME_PATTERN.fullmatch(username):
+            raise AppError(
+                "VALIDATION_ERROR",
+                "账号需以字母开头,可包含字母、数字、点、下划线或短横线",
+                400,
+            )
+        if len(password) < 8:
+            raise AppError("VALIDATION_ERROR", "密码至少需要8位", 400)
+        if not display_name:
+            raise AppError("VALIDATION_ERROR", "账号姓名不能为空", 400)
+
+    @staticmethod
+    def _user_data(user: AdminUser) -> dict[str, Any]:
+        return {
+            "id": user.id,
+            "username": user.username,
+            "display_name": user.display_name,
+            "status": user.status,
+            "roles": sorted(user.roles),
+            "permissions": sorted(user.permissions),
+            "data_scope": user.data_scope,
+        }
+
+    @staticmethod
+    def _role_data(role: AdminRole) -> dict[str, Any]:
+        return {
+            "code": role.code,
+            "name": ROLE_NAMES.get(role.code, role.name),
+            "data_scope": role.data_scope,
+            "status": role.status,
+            "permissions": sorted(role.permissions),
+            "protected": role.code == "SUPER_ADMIN",
+        }
+
+    @staticmethod
+    def _customer_data(user: H5User) -> dict[str, Any]:
+        return {
+            "id": user.id,
+            "mobile": user.mobile,
+            "mobile_masked": user.mobile_masked,
+            "display_name": user.display_name,
+            "status": user.status,
+            "created_at": user.created_at,
+        }

+ 6 - 0
backend/app/harness/__init__.py

@@ -0,0 +1,6 @@
+"""智保通第一阶段 Agent Harness 公共内核。"""
+
+from app.harness.kernel import AgentInvocation, AgentKernel
+from app.harness.schemas import AgentReply
+
+__all__ = ["AgentInvocation", "AgentKernel", "AgentReply"]

+ 13 - 0
backend/app/harness/assets/prompts/customer.md

@@ -0,0 +1,13 @@
+你是“智保通”的客户保障顾问。你的任务是理解保障需求,并通过已授权工具给出真实、可核验的投保建议。
+
+必须遵守:
+
+- 产品、投保资格、保费、订单和保单信息只能来自工具,绝不能编造。
+- 推荐前必须查询当前在售产品;最多推荐两款,优先只推荐最匹配的一款。
+- 信息不足时,优先追问年龄、常住地区、职业类别和投保关系。
+- 不在对话中索取完整身份证号;需要个人信息时,引导用户打开安全投保表单。
+- 不承诺一定承保或理赔,不把报价当作最终承保结论。
+- 只在用户明确表达投保意图时建议“立即投保”动作。
+- 使用简洁中文,适合手机端阅读,不输出表格、JSON、技术接口名或内部实现。
+
+最终回答使用“需求判断、推荐建议、下一步”三个短段落。必须明确写出所推荐产品的真实名称,Harness 会据此生成对应产品卡片。

+ 28 - 0
backend/app/harness/assets/prompts/operation.md

@@ -0,0 +1,28 @@
+你是“智保通”的运营数据助手,帮助后台人员理解订单、保单、产品和推广归因数据。
+
+必须遵守:
+
+- 只读分析,不创建、修改或删除任何业务数据。
+- 指标、订单、保单和推广业绩只能来自工具,不能编造数值。
+- 严格服从当前管理员的权限和数据范围,不尝试扩大查询范围。
+- 数据不足时明确说明,不用常识补齐业务数据。
+- 不展示完整身份证号、手机号、令牌、密码等敏感信息。
+- 不提供退保审批、退款执行或其他越权操作入口。
+- 使用简洁、专业的中文,不输出 JSON、内部实现或数据库细节。
+- 不重复罗列结构化数据卡片中已经完整展示的明细。
+
+最终回答固定使用以下结构:
+
+## 结论
+
+用一至两句话给出最重要的经营判断,关键数字使用 Markdown 粗体。
+
+## 关键发现
+
+使用编号列表,最多三项。每项先写业务含义,再写对应数据依据。
+
+## 建议关注
+
+使用无序列表,最多两项。只给出分析和排查建议,不执行写操作。
+
+回答中不要重复写总标题,不使用 Markdown 表格。结构化指标和明细由 Harness 根据 Tool 结果生成。

+ 10 - 0
backend/app/harness/assets/skills/enrollment-guidance/SKILL.md

@@ -0,0 +1,10 @@
+---
+id: enrollment-guidance
+name: 安全投保引导
+personas: customer
+tools: prepare_enrollment, list_my_orders, list_my_policies
+---
+
+# 安全投保引导
+
+只有用户明确表示继续投保,且产品、计划、年龄与投保关系已经明确时,才生成打开投保表单的动作。个人姓名、证件号和联系电话由 H5 安全表单采集,不在对话中索取。订单与保单查询必须限定为当前登录用户。

+ 10 - 0
backend/app/harness/assets/skills/insurance-consultation/SKILL.md

@@ -0,0 +1,10 @@
+---
+id: insurance-consultation
+name: 保险需求分析
+personas: customer
+tools: list_available_products, calculate_insurance_quote
+---
+
+# 保险需求分析
+
+先识别投保对象、年龄、地区、职业和保障目标。调用在售产品查询后,只保留符合需求的候选产品;当年龄、地区、职业、关系和计划都明确时,才调用确定性保费测算。产品工具与规则工具冲突时,以规则工具结果为准。

+ 10 - 0
backend/app/harness/assets/skills/operation-insights/SKILL.md

@@ -0,0 +1,10 @@
+---
+id: operation-insights
+name: 运营数据洞察
+personas: operation
+tools: get_operation_overview, list_recent_orders, list_recent_policies, get_attribution_performance, list_available_products
+---
+
+# 运营数据洞察
+
+先确定问题属于经营总览、订单、保单、产品还是推广归因,再调用最小必要的只读工具。回答必须引用工具返回的统计口径,不把样例数据当实时数据。管理员数据范围为 SELF 时只分析本人推广归因数据;MASKED_ALL 时不得展示敏感明细。

+ 31 - 0
backend/app/harness/events.py

@@ -0,0 +1,31 @@
+"""Agent Runtime Event 与 SSE 传输协议。"""
+
+import json
+from datetime import datetime
+from typing import Any, Literal
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class RuntimeEvent(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+
+    event: Literal[
+        "run.started",
+        "tool.completed",
+        "ui.ready",
+        "run.completed",
+        "run.failed",
+    ]
+    run_id: str
+    sequence: int = Field(ge=1)
+    occurred_at: datetime
+    data: dict[str, Any] = Field(default_factory=dict)
+
+    def to_sse(self) -> str:
+        payload = self.model_dump(mode="json")
+        return (
+            f"id: {self.run_id}:{self.sequence}\n"
+            f"event: {self.event}\n"
+            f"data: {json.dumps(payload, ensure_ascii=False, separators=(',', ':'))}\n\n"
+        )

+ 245 - 0
backend/app/harness/kernel.py

@@ -0,0 +1,245 @@
+"""统一 Agent 内核:同一编排能力承载客户与运营两种人格。"""
+
+from dataclasses import dataclass
+from typing import Any, cast
+from uuid import UUID, uuid4
+
+from langchain.agents import create_agent
+from langchain_core.messages import AIMessage
+from langchain_core.runnables import RunnableConfig
+from langsmith import Client, tracing_context
+
+from app.core.config import Settings
+from app.domains.identity.models import AdminUser, H5User
+from app.harness.model_gateway import ModelGateway
+from app.harness.policy import HarnessPolicyEngine
+from app.harness.registries import PersonaRegistry, PromptRegistry, SkillRegistry
+from app.harness.schemas import (
+    AgentAction,
+    AgentReply,
+    AgentStructuredOutput,
+    ProductRecommendationsBlock,
+    ProductView,
+)
+from app.harness.tooling import ToolExecutionContext, ToolRegistry
+
+
+@dataclass(frozen=True)
+class ConversationTurn:
+    role: str
+    content: str
+
+
+@dataclass(frozen=True)
+class AgentInvocation:
+    persona: str
+    message: str
+    h5_user: H5User | None = None
+    admin_user: AdminUser | None = None
+    history: tuple[ConversationTurn, ...] = ()
+
+
+class AgentKernel:
+    def __init__(
+        self,
+        *,
+        settings: Settings,
+        model_gateway: ModelGateway,
+        tools: ToolRegistry,
+        prompts: PromptRegistry | None = None,
+        skills: SkillRegistry | None = None,
+        personas: PersonaRegistry | None = None,
+        policy: HarnessPolicyEngine | None = None,
+    ) -> None:
+        self._settings = settings
+        self._model_gateway = model_gateway
+        self._tools = tools
+        self._prompts = prompts or PromptRegistry()
+        self._skills = skills or SkillRegistry()
+        self._personas = personas or PersonaRegistry()
+        self._policy = policy or HarnessPolicyEngine()
+        self._langsmith_client = (
+            Client(
+                api_url=settings.langsmith_endpoint,
+                api_key=settings.langsmith_api_key,
+            )
+            if settings.langsmith_tracing and settings.langsmith_api_key
+            else None
+        )
+
+    def reply(self, invocation: AgentInvocation) -> AgentReply:
+        persona = self._personas.get(invocation.persona)
+        system_prompt = self._compose_prompt(persona.prompt_id, persona.skill_ids)
+        context = ToolExecutionContext(
+            persona=persona.id,
+            h5_user=invocation.h5_user,
+            admin_user=invocation.admin_user,
+        )
+        tools = self._tools.build(
+            names=persona.tool_names,
+            context=context,
+            policy_engine=self._policy,
+        )
+        agent = create_agent(
+            model=self._model_gateway.chat_model(persona.id),
+            tools=tools,
+            system_prompt=system_prompt,
+            name=f"zhibaotong-{persona.id}",
+        )
+        trace_id = uuid4()
+        messages = [
+            {"role": turn.role, "content": turn.content} for turn in invocation.history[-12:]
+        ]
+        messages.append({"role": "user", "content": invocation.message})
+        with tracing_context(
+            project_name=self._settings.langsmith_project,
+            enabled=self._langsmith_client is not None,
+            client=self._langsmith_client,
+            tags=["zhibaotong", "s1", persona.id],
+            metadata={
+                "persona": persona.id,
+                "channel": ("customer_h5" if persona.id == "customer" else "operation_admin"),
+                "principal_id": context.principal_id,
+            },
+        ):
+            run_config: RunnableConfig = {"run_id": trace_id}
+            result = agent.invoke(
+                cast(Any, {"messages": messages}),
+                config=run_config,
+            )
+        structured = self._structured_output(
+            cast(dict[str, Any], result),
+            context,
+        )
+        blocks = [block for execution in context.executions for block in execution.result.blocks]
+        recommendation = self._recommendation_block(
+            context,
+            structured.selected_product_codes,
+        )
+        if recommendation is not None:
+            blocks.insert(0, recommendation)
+        actions = [
+            action for execution in context.executions for action in execution.result.actions
+        ]
+        if structured.suggested_action is not None:
+            actions.append(structured.suggested_action)
+        unique_actions = _deduplicate_actions(actions)
+        return AgentReply(
+            text=structured.message,
+            cards=[block.model_dump(mode="json") for block in blocks],
+            trace_id=str(trace_id),
+            trace_url=self._trace_url(trace_id),
+            actions=[action.model_dump(mode="json") for action in unique_actions],
+            invoked_tools=tuple(execution.name for execution in context.executions),
+        )
+
+    def _compose_prompt(
+        self,
+        prompt_id: str,
+        skill_ids: tuple[str, ...],
+    ) -> str:
+        prompt = self._prompts.get(prompt_id)
+        skill_sections = []
+        for skill_id in skill_ids:
+            skill = self._skills.get(skill_id)
+            skill_sections.append(f"## Skill:{skill.name}\n\n{skill.instructions}")
+        return f"{prompt}\n\n# 已加载技能\n\n" + "\n\n".join(skill_sections)
+
+    @staticmethod
+    def _structured_output(
+        result: dict[str, Any],
+        context: ToolExecutionContext,
+    ) -> AgentStructuredOutput:
+        messages = result.get("messages", [])
+        final = messages[-1] if messages else None
+        text = (
+            str(final.content)
+            if isinstance(final, AIMessage)
+            else "暂时无法生成有效回答,请稍后重试。"
+        )
+        selected_codes: list[str] = []
+        for execution in context.executions:
+            if execution.name != "list_available_products":
+                continue
+            raw_items = execution.result.data.get("items", [])
+            if not isinstance(raw_items, list):
+                continue
+            for item in raw_items:
+                if not isinstance(item, dict):
+                    continue
+                code = str(item.get("product_code", ""))
+                name = str(item.get("name", ""))
+                if code and (code in text or name in text):
+                    selected_codes.append(code)
+        return AgentStructuredOutput(
+            message=text,
+            selected_product_codes=list(dict.fromkeys(selected_codes))[:2],
+        )
+
+    @staticmethod
+    def _recommendation_block(
+        context: ToolExecutionContext,
+        selected_codes: list[str],
+    ) -> ProductRecommendationsBlock | None:
+        if not selected_codes:
+            return None
+        product_items: list[dict[str, Any]] = []
+        for execution in context.executions:
+            if execution.name != "list_available_products":
+                continue
+            raw_items = execution.result.data.get("items", [])
+            if isinstance(raw_items, list):
+                product_items.extend(item for item in raw_items if isinstance(item, dict))
+        selected = [
+            item
+            for code in selected_codes
+            for item in product_items
+            if item.get("product_code") == code
+        ][:2]
+        if not selected:
+            return None
+        views = [
+            ProductView.model_validate(
+                {
+                    "product_id": item["product_id"],
+                    "product_code": item["product_code"],
+                    "name": item["name"],
+                    "category": item["category"],
+                    "summary": item["summary"],
+                    "product_version_id": item["product_version_id"],
+                    "plans": item["plans"],
+                }
+            )
+            for item in selected
+        ]
+        return ProductRecommendationsBlock(items=views)
+
+    def _trace_url(self, trace_id: UUID) -> str | None:
+        if self._langsmith_client is None:
+            return None
+        try:
+            run = self._langsmith_client.read_run(trace_id)
+            return self._langsmith_client.get_run_url(
+                run=run,
+                project_name=self._settings.langsmith_project,
+            )
+        except Exception:
+            # Trace 上传可能有短暂延迟,运行详情接口会再次尝试解析。
+            return None
+
+    def trace_url(self, trace_id: str) -> str | None:
+        try:
+            return self._trace_url(UUID(trace_id))
+        except ValueError:
+            return None
+
+
+def _deduplicate_actions(actions: list[AgentAction]) -> list[AgentAction]:
+    unique: list[AgentAction] = []
+    seen: set[tuple[str, str]] = set()
+    for action in actions:
+        key = (action.type, str(sorted(action.payload.items())))
+        if key not in seen:
+            seen.add(key)
+            unique.append(action)
+    return unique

+ 31 - 0
backend/app/harness/model_gateway.py

@@ -0,0 +1,31 @@
+"""模型供应商适配层,Agent Kernel 不直接依赖 DeepSeek SDK 配置。"""
+
+from typing import Protocol
+
+from langchain_core.language_models.chat_models import BaseChatModel
+from langchain_openai import ChatOpenAI
+from pydantic import SecretStr
+
+from app.core.config import Settings
+
+
+class ModelGateway(Protocol):
+    def chat_model(self, persona: str) -> BaseChatModel: ...
+
+
+class DeepSeekModelGateway:
+    """通过 OpenAI 兼容协议连接真实 DeepSeek。"""
+
+    def __init__(self, settings: Settings) -> None:
+        self._settings = settings
+
+    def chat_model(self, persona: str) -> BaseChatModel:
+        del persona
+        return ChatOpenAI(
+            model=self._settings.deepseek_model,
+            api_key=SecretStr(self._settings.deepseek_api_key),
+            base_url=self._settings.deepseek_base_url,
+            temperature=0,
+            max_retries=2,
+            timeout=60,
+        )

+ 66 - 0
backend/app/harness/policy.py

@@ -0,0 +1,66 @@
+"""Tool 调用策略:人格白名单、权限和副作用边界。"""
+
+from dataclasses import dataclass
+from typing import Literal
+
+from app.core.errors import AppError
+
+
+@dataclass(frozen=True)
+class ToolPolicy:
+    personas: tuple[str, ...]
+    effect: Literal["read", "draft"]
+    required_permissions: tuple[str, ...] = ()
+
+
+class HarnessPolicyEngine:
+    def can_use(
+        self,
+        *,
+        persona: str,
+        allowed_by_persona: tuple[str, ...],
+        tool_name: str,
+        policy: ToolPolicy,
+        permissions: tuple[str, ...],
+    ) -> bool:
+        if tool_name not in allowed_by_persona or persona not in policy.personas:
+            return False
+        if persona == "operation" and policy.effect != "read":
+            return False
+        return not (
+            policy.required_permissions
+            and "*" not in permissions
+            and not set(policy.required_permissions).intersection(permissions)
+        )
+
+    def authorize(
+        self,
+        *,
+        persona: str,
+        allowed_by_persona: tuple[str, ...],
+        tool_name: str,
+        policy: ToolPolicy,
+        permissions: tuple[str, ...],
+    ) -> None:
+        if tool_name not in allowed_by_persona or persona not in policy.personas:
+            raise AppError(
+                "AGENT_TOOL_NOT_ALLOWED",
+                f"当前Agent人格不能使用工具:{tool_name}",
+                403,
+            )
+        if persona == "operation" and policy.effect != "read":
+            raise AppError(
+                "AGENT_OPERATION_READ_ONLY",
+                "运营Agent只允许执行只读工具",
+                403,
+            )
+        if (
+            policy.required_permissions
+            and "*" not in permissions
+            and not set(policy.required_permissions).intersection(permissions)
+        ):
+            raise AppError(
+                "PERMISSION_DENIED",
+                f"缺少工具权限:{tool_name}",
+                403,
+            )

+ 155 - 0
backend/app/harness/registries.py

@@ -0,0 +1,155 @@
+"""Prompt、Skill 与 Persona 注册中心。"""
+
+from dataclasses import dataclass
+from pathlib import Path
+
+from app.core.errors import AppError
+
+ASSET_ROOT = Path(__file__).resolve().parent / "assets"
+
+
+@dataclass(frozen=True)
+class SkillDefinition:
+    id: str
+    name: str
+    personas: tuple[str, ...]
+    tools: tuple[str, ...]
+    instructions: str
+    source: Path
+
+
+@dataclass(frozen=True)
+class PersonaDefinition:
+    id: str
+    name: str
+    prompt_id: str
+    skill_ids: tuple[str, ...]
+    tool_names: tuple[str, ...]
+
+
+class PromptRegistry:
+    def __init__(self, root: Path | None = None) -> None:
+        self._root = root or ASSET_ROOT / "prompts"
+        self._items = {
+            path.stem: path.read_text(encoding="utf-8").strip() for path in self._root.glob("*.md")
+        }
+
+    def get(self, prompt_id: str) -> str:
+        prompt = self._items.get(prompt_id)
+        if prompt is None:
+            raise AppError(
+                "HARNESS_PROMPT_NOT_FOUND",
+                f"未找到Prompt:{prompt_id}",
+                500,
+            )
+        return prompt
+
+    def ids(self) -> tuple[str, ...]:
+        return tuple(sorted(self._items))
+
+
+class SkillRegistry:
+    """发现标准 ``SKILL.md``,并解析本项目使用的最小前置元数据。"""
+
+    def __init__(self, root: Path | None = None) -> None:
+        self._root = root or ASSET_ROOT / "skills"
+        self._items: dict[str, SkillDefinition] = {}
+        for path in self._root.glob("*/SKILL.md"):
+            skill = self._parse(path)
+            if skill.id in self._items:
+                raise AppError(
+                    "HARNESS_SKILL_DUPLICATED",
+                    f"Skill标识重复:{skill.id}",
+                    500,
+                )
+            self._items[skill.id] = skill
+
+    def get(self, skill_id: str) -> SkillDefinition:
+        skill = self._items.get(skill_id)
+        if skill is None:
+            raise AppError(
+                "HARNESS_SKILL_NOT_FOUND",
+                f"未找到Skill:{skill_id}",
+                500,
+            )
+        return skill
+
+    def ids(self) -> tuple[str, ...]:
+        return tuple(sorted(self._items))
+
+    @staticmethod
+    def _parse(path: Path) -> SkillDefinition:
+        raw = path.read_text(encoding="utf-8").strip()
+        if not raw.startswith("---\n"):
+            raise AppError(
+                "HARNESS_SKILL_INVALID",
+                f"Skill缺少元数据:{path.parent.name}",
+                500,
+            )
+        _, metadata_text, instructions = raw.split("---", maxsplit=2)
+        metadata: dict[str, str] = {}
+        for line in metadata_text.strip().splitlines():
+            key, separator, value = line.partition(":")
+            if separator:
+                metadata[key.strip()] = value.strip()
+        required = {"id", "name", "personas", "tools"}
+        if not required.issubset(metadata):
+            raise AppError(
+                "HARNESS_SKILL_INVALID",
+                f"Skill元数据不完整:{path.parent.name}",
+                500,
+            )
+        return SkillDefinition(
+            id=metadata["id"],
+            name=metadata["name"],
+            personas=tuple(_csv(metadata["personas"])),
+            tools=tuple(_csv(metadata["tools"])),
+            instructions=instructions.strip(),
+            source=path,
+        )
+
+
+class PersonaRegistry:
+    def __init__(self) -> None:
+        self._items = {
+            "customer": PersonaDefinition(
+                id="customer",
+                name="客户保障顾问",
+                prompt_id="customer",
+                skill_ids=("insurance-consultation", "enrollment-guidance"),
+                tool_names=(
+                    "list_available_products",
+                    "calculate_insurance_quote",
+                    "prepare_enrollment",
+                    "list_my_orders",
+                    "list_my_policies",
+                ),
+            ),
+            "operation": PersonaDefinition(
+                id="operation",
+                name="运营数据助手",
+                prompt_id="operation",
+                skill_ids=("operation-insights",),
+                tool_names=(
+                    "get_operation_overview",
+                    "list_recent_orders",
+                    "list_recent_policies",
+                    "get_attribution_performance",
+                    "list_available_products",
+                ),
+            ),
+        }
+
+    def get(self, persona_id: str) -> PersonaDefinition:
+        persona = self._items.get(persona_id)
+        if persona is None:
+            raise AppError(
+                "HARNESS_PERSONA_NOT_FOUND",
+                f"未找到Agent人格:{persona_id}",
+                500,
+            )
+        return persona
+
+
+def _csv(value: str) -> list[str]:
+    return [item.strip() for item in value.split(",") if item.strip()]

+ 138 - 0
backend/app/harness/schemas.py

@@ -0,0 +1,138 @@
+"""Agent 与双端前端之间的稳定结构化协议。"""
+
+from dataclasses import dataclass, field
+from typing import Annotated, Any, Literal
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class StrictModel(BaseModel):
+    """拒绝未声明字段,防止模型生成内容悄悄穿透协议。"""
+
+    model_config = ConfigDict(extra="forbid")
+
+
+class ProductPlanView(StrictModel):
+    id: str
+    code: str
+    name: str
+    summary: str = ""
+    premium_cents: int = Field(ge=0)
+    coverage_amount_cents: int = Field(ge=0)
+    min_age: int = Field(ge=0, le=120)
+    max_age: int = Field(ge=0, le=120)
+
+
+class ProductView(StrictModel):
+    product_id: str
+    product_code: str
+    name: str
+    category: str
+    summary: str
+    product_version_id: str
+    plans: list[ProductPlanView]
+
+
+class ProductRecommendationsBlock(StrictModel):
+    type: Literal["product_recommendations"] = "product_recommendations"
+    version: Literal["1.0"] = "1.0"
+    title: str = "为你匹配的保障方案"
+    items: list[ProductView] = Field(max_length=2)
+
+
+class QuoteView(StrictModel):
+    eligible: bool
+    quote_id: str | None = None
+    product_id: str | None = None
+    product_name: str
+    plan_id: str | None = None
+    plan_name: str
+    premium_cents: int | None = None
+    currency: str = "CNY"
+    expires_at: str | None = None
+    reason: str | None = None
+
+
+class QuoteBlock(StrictModel):
+    type: Literal["quote"] = "quote"
+    version: Literal["1.0"] = "1.0"
+    title: str = "保费测算结果"
+    quote: QuoteView
+
+
+class BusinessListBlock(StrictModel):
+    type: Literal["business_list"] = "business_list"
+    version: Literal["1.0"] = "1.0"
+    title: str
+    entity: Literal["order", "policy", "product", "attribution"]
+    items: list[dict[str, Any]]
+    total: int = Field(ge=0)
+
+
+class MetricItem(StrictModel):
+    label: str
+    value: int | float | str
+    unit: str = ""
+    trend: str | None = None
+
+
+class MetricsBlock(StrictModel):
+    type: Literal["metrics"] = "metrics"
+    version: Literal["1.0"] = "1.0"
+    title: str
+    items: list[MetricItem]
+
+
+UiBlock = Annotated[
+    ProductRecommendationsBlock | QuoteBlock | BusinessListBlock | MetricsBlock,
+    Field(discriminator="type"),
+]
+
+
+class AgentAction(StrictModel):
+    """前端可执行动作;类型和负载都由 Harness 控制。"""
+
+    type: Literal[
+        "open_enrollment",
+        "open_orders",
+        "open_policies",
+        "open_admin_orders",
+        "open_admin_policies",
+        "open_attribution",
+        "none",
+    ]
+    label: str
+    payload: dict[str, Any] = Field(default_factory=dict)
+    confirmation_required: bool = False
+
+
+class AgentStructuredOutput(StrictModel):
+    """模型最终输出;业务数据必须来自 Tool,不允许模型自行填充。"""
+
+    message: str = Field(min_length=1, max_length=1200)
+    selected_product_codes: list[str] = Field(default_factory=list, max_length=2)
+    suggested_action: AgentAction | None = None
+
+
+class HarnessToolResult(StrictModel):
+    """单个 Tool 的统一结果包络。"""
+
+    summary: str
+    data: dict[str, Any] = Field(default_factory=dict)
+    blocks: list[UiBlock] = Field(default_factory=list)
+    actions: list[AgentAction] = Field(default_factory=list)
+
+
+@dataclass(frozen=True)
+class AgentReply:
+    """Agent Kernel 对应用层返回的结果。
+
+    ``cards`` 沿用第一版 H5 字段名,内容已经升级为受校验的 UI Block。
+    """
+
+    text: str
+    cards: list[dict[str, Any]]
+    trace_id: str | None
+    trace_url: str | None = None
+    actions: list[dict[str, Any]] = field(default_factory=list)
+    invoked_tools: tuple[str, ...] = ()

+ 127 - 0
backend/app/harness/tooling.py

@@ -0,0 +1,127 @@
+"""Tool 注册、上下文注入与执行审计。"""
+
+from collections.abc import Callable
+from dataclasses import dataclass, field
+from typing import Any
+
+from langchain_core.tools import BaseTool, StructuredTool
+from pydantic import BaseModel
+
+from app.core.errors import AppError
+from app.domains.identity.models import AdminUser, H5User
+from app.harness.policy import HarnessPolicyEngine, ToolPolicy
+from app.harness.schemas import HarnessToolResult
+
+ToolHandler = Callable[["ToolExecutionContext", BaseModel], HarnessToolResult]
+
+
+@dataclass(frozen=True)
+class ToolDefinition:
+    name: str
+    description: str
+    arguments: type[BaseModel]
+    policy: ToolPolicy
+    handler: ToolHandler
+
+
+@dataclass(frozen=True)
+class ToolExecution:
+    name: str
+    result: HarnessToolResult
+
+
+@dataclass
+class ToolExecutionContext:
+    persona: str
+    h5_user: H5User | None = None
+    admin_user: AdminUser | None = None
+    executions: list[ToolExecution] = field(default_factory=list)
+
+    @property
+    def principal_id(self) -> str:
+        principal = self.h5_user or self.admin_user
+        if principal is None:
+            raise AppError("AGENT_PRINCIPAL_REQUIRED", "Agent缺少登录身份", 401)
+        return principal.id
+
+    @property
+    def permissions(self) -> tuple[str, ...]:
+        return self.admin_user.permissions if self.admin_user else ()
+
+
+class ToolRegistry:
+    def __init__(self) -> None:
+        self._items: dict[str, ToolDefinition] = {}
+
+    def register(self, definition: ToolDefinition) -> None:
+        if definition.name in self._items:
+            raise AppError(
+                "HARNESS_TOOL_DUPLICATED",
+                f"Tool标识重复:{definition.name}",
+                500,
+            )
+        self._items[definition.name] = definition
+
+    def names(self) -> tuple[str, ...]:
+        return tuple(sorted(self._items))
+
+    def build(
+        self,
+        *,
+        names: tuple[str, ...],
+        context: ToolExecutionContext,
+        policy_engine: HarnessPolicyEngine,
+    ) -> list[BaseTool]:
+        tools: list[BaseTool] = []
+        for name in names:
+            definition = self._items.get(name)
+            if definition is None:
+                raise AppError(
+                    "HARNESS_TOOL_NOT_FOUND",
+                    f"未找到Tool:{name}",
+                    500,
+                )
+            if not policy_engine.can_use(
+                persona=context.persona,
+                allowed_by_persona=names,
+                tool_name=name,
+                policy=definition.policy,
+                permissions=context.permissions,
+            ):
+                continue
+            tools.append(
+                self._to_langchain_tool(
+                    definition,
+                    names,
+                    context,
+                    policy_engine,
+                )
+            )
+        return tools
+
+    @staticmethod
+    def _to_langchain_tool(
+        definition: ToolDefinition,
+        allowed_names: tuple[str, ...],
+        context: ToolExecutionContext,
+        policy_engine: HarnessPolicyEngine,
+    ) -> BaseTool:
+        def invoke(**kwargs: Any) -> str:
+            policy_engine.authorize(
+                persona=context.persona,
+                allowed_by_persona=allowed_names,
+                tool_name=definition.name,
+                policy=definition.policy,
+                permissions=context.permissions,
+            )
+            arguments = definition.arguments.model_validate(kwargs)
+            result = definition.handler(context, arguments)
+            context.executions.append(ToolExecution(name=definition.name, result=result))
+            return result.model_dump_json(exclude_none=True)
+
+        return StructuredTool.from_function(
+            func=invoke,
+            name=definition.name,
+            description=definition.description,
+            args_schema=definition.arguments,
+        )

+ 1 - 0
backend/app/infrastructure/__init__.py

@@ -0,0 +1 @@
+"""基础设施适配器。"""

+ 1 - 0
backend/app/infrastructure/mysql/__init__.py

@@ -0,0 +1 @@
+"""MySQL与SQLAlchemy适配器。"""

+ 68 - 0
backend/app/infrastructure/mysql/agent_models.py

@@ -0,0 +1,68 @@
+from datetime import UTC, datetime
+
+from sqlalchemy import JSON, ForeignKey, Index, String, Text
+from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
+
+from app.infrastructure.mysql.types import UTCDateTime
+
+
+def utc_now() -> datetime:
+    return datetime.now(UTC)
+
+
+class AgentBase(DeclarativeBase):
+    pass
+
+
+class AgentThreadRecord(AgentBase):
+    __tablename__ = "agent_threads"
+    __table_args__ = (
+        Index("ix_agent_threads_owner_updated", "owner_type", "owner_id", "updated_at"),
+    )
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    owner_type: Mapped[str] = mapped_column(String(16), nullable=False)
+    owner_id: Mapped[str] = mapped_column(String(26), nullable=False)
+    persona: Mapped[str] = mapped_column(String(32), nullable=False)
+    title: Mapped[str] = mapped_column(String(128), nullable=False, default="")
+    status: Mapped[str] = mapped_column(String(32), nullable=False, default="ACTIVE")
+    created_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False, default=utc_now)
+    updated_at: Mapped[datetime] = mapped_column(
+        UTCDateTime(), nullable=False, default=utc_now, onupdate=utc_now
+    )
+
+
+class AgentMessageRecord(AgentBase):
+    __tablename__ = "agent_messages"
+    __table_args__ = (Index("ix_agent_messages_thread_created", "thread_id", "created_at"),)
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    thread_id: Mapped[str] = mapped_column(
+        String(26), ForeignKey("agent_threads.id"), nullable=False
+    )
+    role: Mapped[str] = mapped_column(String(16), nullable=False)
+    content: Mapped[str] = mapped_column(Text, nullable=False)
+    metadata_json: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False)
+    created_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False, default=utc_now)
+
+
+class AgentRunRecord(AgentBase):
+    __tablename__ = "agent_runs"
+    __table_args__ = (
+        Index("ix_agent_runs_thread_created", "thread_id", "created_at"),
+        Index("ix_agent_runs_trace", "trace_id"),
+    )
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    request_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
+    thread_id: Mapped[str] = mapped_column(
+        String(26), ForeignKey("agent_threads.id"), nullable=False
+    )
+    trace_id: Mapped[str | None] = mapped_column(String(64))
+    persona: Mapped[str] = mapped_column(String(32), nullable=False)
+    status: Mapped[str] = mapped_column(String(32), nullable=False)
+    input_json: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False)
+    output_json: Mapped[dict[str, object] | None] = mapped_column(JSON)
+    error_code: Mapped[str | None] = mapped_column(String(64))
+    created_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False, default=utc_now)
+    completed_at: Mapped[datetime | None] = mapped_column(UTCDateTime())

+ 157 - 0
backend/app/infrastructure/mysql/agent_repositories.py

@@ -0,0 +1,157 @@
+from sqlalchemy import select
+
+from app.domains.agent.models import AgentMessage, AgentRun, AgentThread
+from app.domains.agent.repository import AgentRepository
+from app.infrastructure.mysql.agent_models import (
+    AgentMessageRecord,
+    AgentRunRecord,
+    AgentThreadRecord,
+)
+from app.infrastructure.mysql.repositories import SessionFactory
+
+
+class SqlAlchemyAgentRepository(AgentRepository):
+    def __init__(self, session_factory: SessionFactory) -> None:
+        self._session_factory = session_factory
+
+    def save_thread(self, thread: AgentThread) -> None:
+        with self._session_factory() as session:
+            session.merge(
+                AgentThreadRecord(
+                    id=thread.id,
+                    owner_type=thread.owner_type,
+                    owner_id=thread.owner_id,
+                    persona=thread.persona,
+                    title=thread.title,
+                    status=thread.status,
+                    created_at=thread.created_at,
+                    updated_at=thread.updated_at,
+                )
+            )
+            session.commit()
+
+    def get_thread(self, thread_id: str) -> AgentThread | None:
+        with self._session_factory() as session:
+            record = session.get(AgentThreadRecord, thread_id)
+            return self._to_domain(record) if record else None
+
+    def list_threads(self, owner_type: str, owner_id: str) -> list[AgentThread]:
+        with self._session_factory() as session:
+            records = session.scalars(
+                select(AgentThreadRecord).where(
+                    AgentThreadRecord.owner_type == owner_type,
+                    AgentThreadRecord.owner_id == owner_id,
+                )
+            ).all()
+            return [self._to_domain(record) for record in records]
+
+    def save_message(self, message: AgentMessage) -> None:
+        with self._session_factory() as session:
+            session.merge(
+                AgentMessageRecord(
+                    id=message.id,
+                    thread_id=message.thread_id,
+                    role=message.role,
+                    content=message.content,
+                    metadata_json=message.metadata,
+                    created_at=message.created_at,
+                )
+            )
+            session.commit()
+
+    def list_messages(self, thread_id: str) -> list[AgentMessage]:
+        with self._session_factory() as session:
+            records = session.scalars(
+                select(AgentMessageRecord)
+                .where(AgentMessageRecord.thread_id == thread_id)
+                .order_by(AgentMessageRecord.created_at)
+            ).all()
+            return [
+                AgentMessage(
+                    id=record.id,
+                    thread_id=record.thread_id,
+                    role=record.role,
+                    content=record.content,
+                    metadata=dict(record.metadata_json),
+                    created_at=record.created_at,
+                )
+                for record in records
+            ]
+
+    def save_run(self, run: AgentRun) -> None:
+        with self._session_factory() as session:
+            session.merge(
+                AgentRunRecord(
+                    id=run.id,
+                    request_id=run.request_id,
+                    thread_id=run.thread_id,
+                    trace_id=run.trace_id,
+                    persona=run.persona,
+                    status=run.status,
+                    input_json=run.input,
+                    output_json=run.output,
+                    error_code=run.error_code,
+                    created_at=run.created_at,
+                    completed_at=run.completed_at,
+                )
+            )
+            session.commit()
+
+    def get_run(self, run_id: str) -> AgentRun | None:
+        with self._session_factory() as session:
+            record = session.get(AgentRunRecord, run_id)
+            if record is None:
+                return None
+            return AgentRun(
+                id=record.id,
+                request_id=record.request_id,
+                thread_id=record.thread_id,
+                trace_id=record.trace_id,
+                persona=record.persona,
+                status=record.status,
+                input=dict(record.input_json),
+                output=dict(record.output_json) if record.output_json else None,
+                error_code=record.error_code,
+                created_at=record.created_at,
+                completed_at=record.completed_at,
+            )
+
+    def list_runs(self, thread_ids: tuple[str, ...], limit: int = 20) -> list[AgentRun]:
+        if not thread_ids:
+            return []
+        with self._session_factory() as session:
+            records = session.scalars(
+                select(AgentRunRecord)
+                .where(AgentRunRecord.thread_id.in_(thread_ids))
+                .order_by(AgentRunRecord.created_at.desc())
+                .limit(limit)
+            ).all()
+            return [
+                AgentRun(
+                    id=record.id,
+                    request_id=record.request_id,
+                    thread_id=record.thread_id,
+                    trace_id=record.trace_id,
+                    persona=record.persona,
+                    status=record.status,
+                    input=dict(record.input_json),
+                    output=dict(record.output_json) if record.output_json else None,
+                    error_code=record.error_code,
+                    created_at=record.created_at,
+                    completed_at=record.completed_at,
+                )
+                for record in records
+            ]
+
+    @staticmethod
+    def _to_domain(record: AgentThreadRecord) -> AgentThread:
+        return AgentThread(
+            id=record.id,
+            owner_type=record.owner_type,
+            owner_id=record.owner_id,
+            persona=record.persona,
+            title=record.title,
+            status=record.status,
+            created_at=record.created_at,
+            updated_at=record.updated_at,
+        )

+ 85 - 0
backend/app/infrastructure/mysql/analytics_models.py

@@ -0,0 +1,85 @@
+from datetime import date, datetime
+
+from sqlalchemy import BigInteger, Date, Index, Numeric, String
+from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
+
+from app.infrastructure.mysql.types import UTCDateTime
+
+
+class AnalyticsBase(DeclarativeBase):
+    pass
+
+
+class DimDateRecord(AnalyticsBase):
+    __tablename__ = "dim_date"
+
+    date_key: Mapped[int] = mapped_column(primary_key=True)
+    full_date: Mapped[date] = mapped_column(Date, unique=True, nullable=False)
+    year: Mapped[int] = mapped_column(nullable=False)
+    month: Mapped[int] = mapped_column(nullable=False)
+    day: Mapped[int] = mapped_column(nullable=False)
+
+
+class DimProductRecord(AnalyticsBase):
+    __tablename__ = "dim_product"
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    product_id: Mapped[str] = mapped_column(String(26), nullable=False)
+    product_version_id: Mapped[str] = mapped_column(String(26), unique=True, nullable=False)
+    product_code: Mapped[str] = mapped_column(String(32), nullable=False)
+    product_name: Mapped[str] = mapped_column(String(128), nullable=False)
+    version_no: Mapped[str] = mapped_column(String(32), nullable=False)
+
+
+class DimSalespersonRecord(AnalyticsBase):
+    __tablename__ = "dim_salesperson"
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    salesperson_id: Mapped[str] = mapped_column(String(26), unique=True, nullable=False)
+    salesperson_code: Mapped[str] = mapped_column(String(32), nullable=False)
+    display_name: Mapped[str] = mapped_column(String(64), nullable=False)
+    organization_code: Mapped[str] = mapped_column(String(64), nullable=False)
+
+
+class FactOrderRecord(AnalyticsBase):
+    __tablename__ = "fact_orders"
+    __table_args__ = (Index("ix_fact_orders_date_product", "date_key", "product_key"),)
+
+    order_id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    date_key: Mapped[int] = mapped_column(nullable=False)
+    product_key: Mapped[int] = mapped_column(BigInteger, nullable=False)
+    salesperson_key: Mapped[int | None] = mapped_column(BigInteger)
+    status: Mapped[str] = mapped_column(String(32), nullable=False)
+    premium_cents: Mapped[int] = mapped_column(BigInteger, nullable=False)
+    created_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
+
+
+class FactPolicyRecord(AnalyticsBase):
+    __tablename__ = "fact_policies"
+
+    policy_id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    order_id: Mapped[str] = mapped_column(String(26), unique=True, nullable=False)
+    date_key: Mapped[int] = mapped_column(nullable=False)
+    product_key: Mapped[int] = mapped_column(BigInteger, nullable=False)
+    status: Mapped[str] = mapped_column(String(32), nullable=False)
+    premium_cents: Mapped[int] = mapped_column(BigInteger, nullable=False)
+
+
+class MetricDefinitionRecord(AnalyticsBase):
+    __tablename__ = "metric_definitions"
+
+    metric_code: Mapped[str] = mapped_column(String(64), primary_key=True)
+    name: Mapped[str] = mapped_column(String(128), nullable=False)
+    description: Mapped[str] = mapped_column(String(500), nullable=False)
+    unit: Mapped[str] = mapped_column(String(16), nullable=False)
+    expression: Mapped[str] = mapped_column(String(1000), nullable=False)
+    warning_threshold: Mapped[float | None] = mapped_column(Numeric(18, 4))
+
+
+class AnalyticsSyncOffsetRecord(AnalyticsBase):
+    __tablename__ = "analytics_sync_offsets"
+
+    source_name: Mapped[str] = mapped_column(String(64), primary_key=True)
+    last_event_id: Mapped[str | None] = mapped_column(String(26))
+    last_occurred_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
+    updated_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)

+ 173 - 0
backend/app/infrastructure/mysql/attribution_repositories.py

@@ -0,0 +1,173 @@
+from sqlalchemy import select
+
+from app.domains.attribution.models import (
+    LeadAttribution,
+    OrderAttribution,
+    PromotionCode,
+    Salesperson,
+)
+from app.domains.attribution.repository import AttributionRepository
+from app.infrastructure.mysql.core_models import (
+    LeadAttributionRecord,
+    OrderAttributionRecord,
+    PromotionCodeRecord,
+    SalespersonRecord,
+)
+from app.infrastructure.mysql.repositories import SessionFactory
+
+
+class SqlAlchemyAttributionRepository(AttributionRepository):
+    def __init__(self, session_factory: SessionFactory) -> None:
+        self._session_factory = session_factory
+
+    def save_salesperson(self, salesperson: Salesperson) -> None:
+        with self._session_factory() as session:
+            session.merge(
+                SalespersonRecord(
+                    id=salesperson.id,
+                    admin_user_id=salesperson.admin_user_id,
+                    salesperson_code=salesperson.code,
+                    name=salesperson.name,
+                    status=salesperson.status,
+                    created_at=salesperson.created_at,
+                )
+            )
+            session.commit()
+
+    def list_salespersons(self) -> list[Salesperson]:
+        with self._session_factory() as session:
+            records = session.scalars(
+                select(SalespersonRecord).order_by(SalespersonRecord.salesperson_code)
+            ).all()
+            return [
+                Salesperson(
+                    id=item.id,
+                    admin_user_id=item.admin_user_id,
+                    code=item.salesperson_code,
+                    name=item.name,
+                    status=item.status,
+                    created_at=item.created_at,
+                )
+                for item in records
+            ]
+
+    def save_promotion_code(self, promotion_code: PromotionCode) -> None:
+        with self._session_factory() as session:
+            session.merge(
+                PromotionCodeRecord(
+                    id=promotion_code.id,
+                    code=promotion_code.code,
+                    salesperson_id=promotion_code.salesperson_id,
+                    channel=promotion_code.channel,
+                    status=promotion_code.status,
+                    created_at=promotion_code.created_at,
+                )
+            )
+            session.commit()
+
+    def get_promotion_code(self, code: str) -> PromotionCode | None:
+        with self._session_factory() as session:
+            record = session.scalar(
+                select(PromotionCodeRecord).where(PromotionCodeRecord.code == code.upper())
+            )
+            return self._code(record) if record else None
+
+    def list_promotion_codes(self) -> list[PromotionCode]:
+        with self._session_factory() as session:
+            records = session.scalars(
+                select(PromotionCodeRecord).order_by(PromotionCodeRecord.code)
+            ).all()
+            return [self._code(item) for item in records]
+
+    def save_lead(self, lead: LeadAttribution) -> None:
+        with self._session_factory() as session:
+            session.merge(
+                LeadAttributionRecord(
+                    id=lead.id,
+                    h5_user_id=lead.h5_user_id,
+                    salesperson_id=lead.salesperson_id,
+                    promotion_code_id=lead.promotion_code_id,
+                    source_code=lead.source_code,
+                    visit_count=lead.visit_count,
+                    first_touch_at=lead.first_touch_at,
+                    last_touch_at=lead.last_touch_at,
+                )
+            )
+            session.commit()
+
+    def get_lead_by_user(self, h5_user_id: str) -> LeadAttribution | None:
+        with self._session_factory() as session:
+            record = session.scalar(
+                select(LeadAttributionRecord).where(LeadAttributionRecord.h5_user_id == h5_user_id)
+            )
+            return self._lead(record) if record else None
+
+    def list_leads(self) -> list[LeadAttribution]:
+        with self._session_factory() as session:
+            records = session.scalars(select(LeadAttributionRecord)).all()
+            return [self._lead(item) for item in records]
+
+    def save_order_attribution(self, attribution: OrderAttribution) -> None:
+        with self._session_factory() as session:
+            session.merge(
+                OrderAttributionRecord(
+                    id=attribution.id,
+                    order_id=attribution.order_id,
+                    h5_user_id=attribution.h5_user_id,
+                    salesperson_id=attribution.salesperson_id,
+                    promotion_code_id=attribution.promotion_code_id,
+                    source_code=attribution.source_code,
+                    amount_cents=attribution.amount_cents,
+                    attributed_at=attribution.attributed_at,
+                )
+            )
+            session.commit()
+
+    def get_order_attribution(self, order_id: str) -> OrderAttribution | None:
+        with self._session_factory() as session:
+            record = session.scalar(
+                select(OrderAttributionRecord).where(OrderAttributionRecord.order_id == order_id)
+            )
+            return self._order(record) if record else None
+
+    def list_order_attributions(self) -> list[OrderAttribution]:
+        with self._session_factory() as session:
+            records = session.scalars(select(OrderAttributionRecord)).all()
+            return [self._order(item) for item in records]
+
+    @staticmethod
+    def _code(record: PromotionCodeRecord) -> PromotionCode:
+        return PromotionCode(
+            id=record.id,
+            code=record.code,
+            salesperson_id=record.salesperson_id,
+            channel=record.channel,
+            status=record.status,
+            created_at=record.created_at,
+        )
+
+    @staticmethod
+    def _lead(record: LeadAttributionRecord) -> LeadAttribution:
+        return LeadAttribution(
+            id=record.id,
+            h5_user_id=record.h5_user_id,
+            salesperson_id=record.salesperson_id,
+            promotion_code_id=record.promotion_code_id,
+            source_code=record.source_code,
+            visit_count=record.visit_count,
+            first_touch_at=record.first_touch_at,
+            last_touch_at=record.last_touch_at,
+        )
+
+    @staticmethod
+    def _order(record: OrderAttributionRecord) -> OrderAttribution:
+        return OrderAttribution(
+            id=record.id,
+            order_id=record.order_id,
+            h5_user_id=record.h5_user_id,
+            salesperson_id=record.salesperson_id,
+            promotion_code_id=record.promotion_code_id,
+            source_code=record.source_code,
+            amount_cents=record.amount_cents,
+            attributed_at=record.attributed_at,
+        )

+ 519 - 0
backend/app/infrastructure/mysql/core_models.py

@@ -0,0 +1,519 @@
+from datetime import UTC, date, datetime
+
+from sqlalchemy import (
+    JSON,
+    BigInteger,
+    Column,
+    Date,
+    ForeignKey,
+    Index,
+    String,
+    Table,
+    Text,
+    UniqueConstraint,
+)
+from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
+
+from app.infrastructure.mysql.types import UTCDateTime
+
+
+def utc_now() -> datetime:
+    return datetime.now(UTC)
+
+
+class CoreBase(DeclarativeBase):
+    pass
+
+
+admin_user_roles = Table(
+    "admin_user_roles",
+    CoreBase.metadata,
+    Column("admin_user_id", String(26), ForeignKey("admin_users.id"), primary_key=True),
+    Column("role_id", String(26), ForeignKey("roles.id"), primary_key=True),
+)
+
+
+role_permissions = Table(
+    "role_permissions",
+    CoreBase.metadata,
+    Column("role_id", String(26), ForeignKey("roles.id"), primary_key=True),
+    Column(
+        "permission_id",
+        String(26),
+        ForeignKey("permissions.id"),
+        primary_key=True,
+    ),
+)
+
+
+class H5UserRecord(CoreBase):
+    __tablename__ = "h5_users"
+    __table_args__ = (
+        Index("ix_h5_users_status_created", "status", "created_at"),
+        Index("ix_h5_users_source", "source_code"),
+    )
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    mobile: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
+    mobile_masked: Mapped[str] = mapped_column(String(32), nullable=False)
+    display_name: Mapped[str] = mapped_column(String(64), nullable=False)
+    region_code: Mapped[str | None] = mapped_column(String(12))
+    status: Mapped[str] = mapped_column(String(32), nullable=False, default="ACTIVE")
+    source_code: Mapped[str] = mapped_column(String(64), nullable=False, default="DIRECT")
+    last_login_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
+    created_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False, default=utc_now)
+    updated_at: Mapped[datetime] = mapped_column(
+        UTCDateTime(), nullable=False, default=utc_now, onupdate=utc_now
+    )
+    version: Mapped[int] = mapped_column(nullable=False, default=1)
+
+
+class PermissionRecord(CoreBase):
+    __tablename__ = "permissions"
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    code: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
+    name: Mapped[str] = mapped_column(String(64), nullable=False)
+    resource: Mapped[str] = mapped_column(String(64), nullable=False)
+    action: Mapped[str] = mapped_column(String(32), nullable=False)
+
+
+class RoleRecord(CoreBase):
+    __tablename__ = "roles"
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    code: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
+    name: Mapped[str] = mapped_column(String(64), nullable=False)
+    data_scope: Mapped[str] = mapped_column(String(32), nullable=False)
+    status: Mapped[str] = mapped_column(String(32), nullable=False, default="ACTIVE")
+    permissions: Mapped[list[PermissionRecord]] = relationship(
+        secondary=role_permissions,
+        lazy="selectin",
+    )
+
+
+class AdminUserRecord(CoreBase):
+    __tablename__ = "admin_users"
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
+    password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
+    display_name: Mapped[str] = mapped_column(String(64), nullable=False)
+    status: Mapped[str] = mapped_column(String(32), nullable=False, default="ACTIVE")
+    roles: Mapped[list[RoleRecord]] = relationship(
+        secondary=admin_user_roles,
+        lazy="selectin",
+    )
+
+
+class AuthSessionRecord(CoreBase):
+    __tablename__ = "auth_sessions"
+    __table_args__ = (
+        Index(
+            "ix_auth_sessions_subject",
+            "subject_type",
+            "subject_id",
+            "revoked_at",
+        ),
+    )
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    subject_type: Mapped[str] = mapped_column(String(16), nullable=False)
+    subject_id: Mapped[str] = mapped_column(String(26), nullable=False)
+    refresh_jti_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
+    expires_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
+    revoked_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
+    created_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False, default=utc_now)
+
+
+class SalespersonRecord(CoreBase):
+    __tablename__ = "salespersons"
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    admin_user_id: Mapped[str] = mapped_column(
+        String(26),
+        ForeignKey("admin_users.id"),
+        unique=True,
+        nullable=False,
+    )
+    salesperson_code: Mapped[str] = mapped_column(
+        String(32),
+        unique=True,
+        nullable=False,
+    )
+    name: Mapped[str] = mapped_column(String(64), nullable=False)
+    status: Mapped[str] = mapped_column(String(32), nullable=False)
+    created_at: Mapped[datetime] = mapped_column(
+        UTCDateTime(),
+        nullable=False,
+        default=utc_now,
+    )
+
+
+class PromotionCodeRecord(CoreBase):
+    __tablename__ = "promotion_codes"
+    __table_args__ = (Index("ix_promotion_codes_salesperson", "salesperson_id", "status"),)
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    code: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
+    salesperson_id: Mapped[str] = mapped_column(
+        String(26),
+        ForeignKey("salespersons.id"),
+        nullable=False,
+    )
+    channel: Mapped[str] = mapped_column(String(32), nullable=False)
+    status: Mapped[str] = mapped_column(String(32), nullable=False)
+    created_at: Mapped[datetime] = mapped_column(
+        UTCDateTime(),
+        nullable=False,
+        default=utc_now,
+    )
+
+
+class LeadAttributionRecord(CoreBase):
+    __tablename__ = "lead_attributions"
+    __table_args__ = (Index("ix_lead_attributions_salesperson", "salesperson_id", "last_touch_at"),)
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    h5_user_id: Mapped[str] = mapped_column(
+        String(26),
+        ForeignKey("h5_users.id"),
+        unique=True,
+        nullable=False,
+    )
+    salesperson_id: Mapped[str] = mapped_column(
+        String(26),
+        ForeignKey("salespersons.id"),
+        nullable=False,
+    )
+    promotion_code_id: Mapped[str] = mapped_column(
+        String(26),
+        ForeignKey("promotion_codes.id"),
+        nullable=False,
+    )
+    source_code: Mapped[str] = mapped_column(String(32), nullable=False)
+    visit_count: Mapped[int] = mapped_column(nullable=False, default=1)
+    first_touch_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
+    last_touch_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
+
+
+class OrderAttributionRecord(CoreBase):
+    __tablename__ = "order_attributions"
+    __table_args__ = (
+        Index("ix_order_attributions_salesperson", "salesperson_id", "attributed_at"),
+    )
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    order_id: Mapped[str] = mapped_column(
+        String(26),
+        ForeignKey("enrollment_orders.id"),
+        unique=True,
+        nullable=False,
+    )
+    h5_user_id: Mapped[str] = mapped_column(
+        String(26),
+        ForeignKey("h5_users.id"),
+        nullable=False,
+    )
+    salesperson_id: Mapped[str] = mapped_column(
+        String(26),
+        ForeignKey("salespersons.id"),
+        nullable=False,
+    )
+    promotion_code_id: Mapped[str] = mapped_column(
+        String(26),
+        ForeignKey("promotion_codes.id"),
+        nullable=False,
+    )
+    source_code: Mapped[str] = mapped_column(String(32), nullable=False)
+    amount_cents: Mapped[int] = mapped_column(BigInteger, nullable=False)
+    attributed_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
+
+
+class ProductRecord(CoreBase):
+    __tablename__ = "products"
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    product_code: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
+    name: Mapped[str] = mapped_column(String(128), nullable=False)
+    category: Mapped[str] = mapped_column(String(32), nullable=False)
+    summary: Mapped[str] = mapped_column(String(500), nullable=False)
+    status: Mapped[str] = mapped_column(String(32), nullable=False)
+
+
+class ProductVersionRecord(CoreBase):
+    __tablename__ = "product_versions"
+    __table_args__ = (
+        UniqueConstraint("product_id", "version_no", name="uq_product_version"),
+        Index("ix_product_versions_availability", "status", "effective_from", "effective_to"),
+    )
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    product_id: Mapped[str] = mapped_column(String(26), ForeignKey("products.id"), nullable=False)
+    version_no: Mapped[str] = mapped_column(String(32), nullable=False)
+    status: Mapped[str] = mapped_column(String(32), nullable=False)
+    effective_from: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
+    effective_to: Mapped[datetime | None] = mapped_column(UTCDateTime())
+    rule_version: Mapped[str] = mapped_column(String(32), nullable=False, default="eligibility-v1")
+    rate_version: Mapped[str] = mapped_column(String(32), nullable=False, default="rate-v1")
+    terms_summary: Mapped[str] = mapped_column(Text, nullable=False, default="")
+    plans: Mapped[list["PlanRecord"]] = relationship(
+        back_populates="product_version",
+        cascade="all, delete-orphan",
+        lazy="selectin",
+    )
+
+
+class PlanRecord(CoreBase):
+    __tablename__ = "plans"
+    __table_args__ = (
+        UniqueConstraint("product_version_id", "plan_code", name="uq_plan_version_code"),
+    )
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    product_version_id: Mapped[str] = mapped_column(
+        String(26), ForeignKey("product_versions.id"), nullable=False
+    )
+    plan_code: Mapped[str] = mapped_column(String(32), nullable=False)
+    name: Mapped[str] = mapped_column(String(128), nullable=False)
+    summary: Mapped[str] = mapped_column(String(500), nullable=False, default="")
+    status: Mapped[str] = mapped_column(String(32), nullable=False, default="ACTIVE")
+    premium_cents: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
+    coverage_amount_cents: Mapped[int] = mapped_column(
+        BigInteger, nullable=False, default=0
+    )
+    min_age: Mapped[int] = mapped_column(nullable=False, default=0)
+    max_age: Mapped[int] = mapped_column(nullable=False, default=100)
+    product_version: Mapped[ProductVersionRecord] = relationship(back_populates="plans")
+
+
+class ProductChangeLogRecord(CoreBase):
+    __tablename__ = "product_change_logs"
+    __table_args__ = (
+        Index("ix_product_change_logs_product_created", "product_id", "created_at"),
+    )
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    product_id: Mapped[str] = mapped_column(
+        String(26), ForeignKey("products.id"), nullable=False
+    )
+    version_id: Mapped[str | None] = mapped_column(String(26))
+    action: Mapped[str] = mapped_column(String(32), nullable=False)
+    actor_id: Mapped[str] = mapped_column(String(26), nullable=False)
+    actor_name: Mapped[str] = mapped_column(String(64), nullable=False)
+    detail_json: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False)
+    created_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
+
+
+class EligibilityRuleRecord(CoreBase):
+    __tablename__ = "eligibility_rules"
+    __table_args__ = (
+        UniqueConstraint("product_version_id", "rule_code", name="uq_eligibility_rule"),
+    )
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    product_version_id: Mapped[str] = mapped_column(
+        String(26), ForeignKey("product_versions.id"), nullable=False
+    )
+    rule_code: Mapped[str] = mapped_column(String(64), nullable=False)
+    rule_type: Mapped[str] = mapped_column(String(32), nullable=False)
+    operator: Mapped[str] = mapped_column(String(16), nullable=False)
+    parameters_json: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False)
+    failure_code: Mapped[str] = mapped_column(String(64), nullable=False)
+    failure_message: Mapped[str] = mapped_column(String(255), nullable=False)
+
+
+class RateTableRecord(CoreBase):
+    __tablename__ = "rate_tables"
+    __table_args__ = (
+        UniqueConstraint("product_version_id", "rate_code", name="uq_rate_code"),
+        Index(
+            "ix_rate_lookup",
+            "product_version_id",
+            "plan_id",
+            "region_group",
+            "min_age",
+            "max_age",
+            "effective_from",
+        ),
+    )
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    product_version_id: Mapped[str] = mapped_column(
+        String(26), ForeignKey("product_versions.id"), nullable=False
+    )
+    plan_id: Mapped[str] = mapped_column(String(26), ForeignKey("plans.id"), nullable=False)
+    rate_code: Mapped[str] = mapped_column(String(64), nullable=False)
+    min_age: Mapped[int] = mapped_column(nullable=False)
+    max_age: Mapped[int] = mapped_column(nullable=False)
+    region_group: Mapped[str] = mapped_column(String(32), nullable=False)
+    occupation_group: Mapped[str] = mapped_column(String(32), nullable=False)
+    premium_cents: Mapped[int] = mapped_column(BigInteger, nullable=False)
+    currency: Mapped[str] = mapped_column(String(3), nullable=False, default="CNY")
+    effective_from: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
+    effective_to: Mapped[datetime | None] = mapped_column(UTCDateTime())
+
+
+class QuoteRecord(CoreBase):
+    __tablename__ = "quotes"
+    __table_args__ = (Index("ix_quotes_user_status", "h5_user_id", "status", "expires_at"),)
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    h5_user_id: Mapped[str] = mapped_column(String(26), ForeignKey("h5_users.id"), nullable=False)
+    product_id: Mapped[str] = mapped_column(String(26), ForeignKey("products.id"), nullable=False)
+    product_version_id: Mapped[str] = mapped_column(
+        String(26), ForeignKey("product_versions.id"), nullable=False
+    )
+    plan_id: Mapped[str] = mapped_column(String(26), ForeignKey("plans.id"), nullable=False)
+    insured_age: Mapped[int] = mapped_column(nullable=False)
+    insured_region_code: Mapped[str] = mapped_column(String(12), nullable=False)
+    occupation_code: Mapped[str] = mapped_column(String(32), nullable=False)
+    relationship: Mapped[str] = mapped_column(String(32), nullable=False)
+    premium_cents: Mapped[int] = mapped_column(BigInteger, nullable=False)
+    currency: Mapped[str] = mapped_column(String(3), nullable=False)
+    rule_version: Mapped[str] = mapped_column(String(32), nullable=False)
+    rate_version: Mapped[str] = mapped_column(String(32), nullable=False)
+    status: Mapped[str] = mapped_column(String(32), nullable=False)
+    expires_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
+    created_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False, default=utc_now)
+
+
+class EnrollmentDraftRecord(CoreBase):
+    __tablename__ = "enrollment_drafts"
+    __table_args__ = (Index("ix_drafts_user_status", "h5_user_id", "status", "created_at"),)
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    h5_user_id: Mapped[str] = mapped_column(String(26), ForeignKey("h5_users.id"), nullable=False)
+    quote_id: Mapped[str] = mapped_column(String(26), ForeignKey("quotes.id"), nullable=False)
+    applicant_json: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False)
+    insured_json: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False)
+    contact_json: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False)
+    status: Mapped[str] = mapped_column(String(32), nullable=False)
+    expires_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
+    created_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False, default=utc_now)
+
+
+class UserConfirmationRecord(CoreBase):
+    __tablename__ = "user_confirmations"
+    __table_args__ = (Index("ix_confirmations_subject", "draft_id", "status"),)
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    h5_user_id: Mapped[str] = mapped_column(String(26), ForeignKey("h5_users.id"), nullable=False)
+    draft_id: Mapped[str] = mapped_column(
+        String(26), ForeignKey("enrollment_drafts.id"), nullable=False
+    )
+    token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
+    status: Mapped[str] = mapped_column(String(32), nullable=False)
+    expires_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
+    created_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False, default=utc_now)
+
+
+class EnrollmentOrderRecord(CoreBase):
+    __tablename__ = "enrollment_orders"
+    __table_args__ = (
+        UniqueConstraint(
+            "h5_user_id",
+            "idempotency_key",
+            name="uq_orders_user_idempotency",
+        ),
+        Index("ix_orders_user_status", "h5_user_id", "status", "created_at"),
+    )
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    order_no: Mapped[str] = mapped_column(String(40), unique=True, nullable=False)
+    h5_user_id: Mapped[str] = mapped_column(String(26), ForeignKey("h5_users.id"), nullable=False)
+    quote_id: Mapped[str] = mapped_column(
+        String(26), ForeignKey("quotes.id"), unique=True, nullable=False
+    )
+    draft_id: Mapped[str] = mapped_column(
+        String(26), ForeignKey("enrollment_drafts.id"), nullable=False
+    )
+    confirmation_id: Mapped[str] = mapped_column(
+        String(26), ForeignKey("user_confirmations.id"), unique=True, nullable=False
+    )
+    idempotency_key: Mapped[str] = mapped_column(String(64), nullable=False)
+    product_id: Mapped[str] = mapped_column(String(26), nullable=False)
+    product_version_id: Mapped[str] = mapped_column(String(26), nullable=False)
+    plan_id: Mapped[str] = mapped_column(String(26), nullable=False)
+    applicant_snapshot_json: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False)
+    insured_snapshot_json: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False)
+    amount_cents: Mapped[int] = mapped_column(BigInteger, nullable=False)
+    currency: Mapped[str] = mapped_column(String(3), nullable=False)
+    status: Mapped[str] = mapped_column(String(32), nullable=False)
+    created_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False, default=utc_now)
+
+
+class PaymentTransactionRecord(CoreBase):
+    __tablename__ = "payment_transactions"
+    __table_args__ = (
+        UniqueConstraint(
+            "order_id",
+            "idempotency_key",
+            name="uq_payments_order_idempotency",
+        ),
+        Index("ix_payments_order_status", "order_id", "status"),
+    )
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    payment_no: Mapped[str] = mapped_column(String(40), unique=True, nullable=False)
+    order_id: Mapped[str] = mapped_column(
+        String(26), ForeignKey("enrollment_orders.id"), nullable=False
+    )
+    h5_user_id: Mapped[str] = mapped_column(String(26), nullable=False)
+    transaction_type: Mapped[str] = mapped_column(String(16), nullable=False)
+    provider: Mapped[str] = mapped_column(String(32), nullable=False)
+    idempotency_key: Mapped[str] = mapped_column(String(64), nullable=False)
+    amount_cents: Mapped[int] = mapped_column(BigInteger, nullable=False)
+    currency: Mapped[str] = mapped_column(String(3), nullable=False)
+    status: Mapped[str] = mapped_column(String(32), nullable=False)
+    provider_transaction_no: Mapped[str | None] = mapped_column(String(64), unique=True)
+    succeeded_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
+    created_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False, default=utc_now)
+
+
+class PolicyRecord(CoreBase):
+    __tablename__ = "policies"
+    __table_args__ = (Index("ix_policies_user_status", "h5_user_id", "status", "coverage_end"),)
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    policy_no: Mapped[str] = mapped_column(String(40), unique=True, nullable=False)
+    order_id: Mapped[str] = mapped_column(
+        String(26), ForeignKey("enrollment_orders.id"), unique=True, nullable=False
+    )
+    h5_user_id: Mapped[str] = mapped_column(String(26), nullable=False)
+    product_id: Mapped[str] = mapped_column(String(26), nullable=False)
+    product_version_id: Mapped[str] = mapped_column(String(26), nullable=False)
+    plan_id: Mapped[str] = mapped_column(String(26), nullable=False)
+    premium_cents: Mapped[int] = mapped_column(BigInteger, nullable=False)
+    currency: Mapped[str] = mapped_column(String(3), nullable=False)
+    coverage_start: Mapped[date] = mapped_column(Date, nullable=False)
+    coverage_end: Mapped[date] = mapped_column(Date, nullable=False)
+    status: Mapped[str] = mapped_column(String(32), nullable=False)
+    issued_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
+
+
+class OutboxEventRecord(CoreBase):
+    __tablename__ = "outbox_events"
+    __table_args__ = (Index("ix_outbox_dispatch", "status", "created_at"),)
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    aggregate_type: Mapped[str] = mapped_column(String(32), nullable=False)
+    aggregate_id: Mapped[str] = mapped_column(String(26), nullable=False)
+    event_type: Mapped[str] = mapped_column(String(128), nullable=False)
+    payload_json: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False)
+    status: Mapped[str] = mapped_column(String(32), nullable=False)
+    created_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
+
+
+class AsyncTaskRecord(CoreBase):
+    __tablename__ = "async_tasks"
+    __table_args__ = (Index("ix_async_tasks_claim", "status", "created_at"),)
+
+    id: Mapped[str] = mapped_column(String(26), primary_key=True)
+    task_type: Mapped[str] = mapped_column(String(128), nullable=False)
+    business_key: Mapped[str] = mapped_column(String(128), nullable=False)
+    idempotency_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
+    payload_json: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False)
+    status: Mapped[str] = mapped_column(String(32), nullable=False)
+    attempt_count: Mapped[int] = mapped_column(nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)

+ 396 - 0
backend/app/infrastructure/mysql/enrollment_repositories.py

@@ -0,0 +1,396 @@
+from sqlalchemy import select
+
+from app.domains.enrollment.models import (
+    AsyncTask,
+    EnrollmentDraft,
+    EnrollmentOrder,
+    OutboxEvent,
+    PaymentTransaction,
+    Policy,
+    Quote,
+    UserConfirmation,
+)
+from app.domains.enrollment.repository import EnrollmentRepository
+from app.infrastructure.mysql.core_models import (
+    AsyncTaskRecord,
+    EnrollmentDraftRecord,
+    EnrollmentOrderRecord,
+    OutboxEventRecord,
+    PaymentTransactionRecord,
+    PolicyRecord,
+    QuoteRecord,
+    UserConfirmationRecord,
+)
+from app.infrastructure.mysql.repositories import SessionFactory
+
+
+class SqlAlchemyEnrollmentRepository(EnrollmentRepository):
+    def __init__(self, session_factory: SessionFactory) -> None:
+        self._session_factory = session_factory
+
+    def save_quote(self, quote: Quote) -> None:
+        with self._session_factory() as session:
+            session.merge(
+                QuoteRecord(
+                    id=quote.id,
+                    h5_user_id=quote.user_id,
+                    product_id=quote.product_id,
+                    product_version_id=quote.product_version_id,
+                    plan_id=quote.plan_id,
+                    insured_age=quote.insured_age,
+                    insured_region_code=quote.insured_region_code,
+                    occupation_code=quote.occupation_code,
+                    relationship=quote.relationship,
+                    premium_cents=quote.premium_cents,
+                    currency=quote.currency,
+                    rule_version=quote.rule_version,
+                    rate_version=quote.rate_version,
+                    status=quote.status,
+                    expires_at=quote.expires_at,
+                    created_at=quote.created_at,
+                )
+            )
+            session.commit()
+
+    def get_quote(self, quote_id: str) -> Quote | None:
+        with self._session_factory() as session:
+            record = session.get(QuoteRecord, quote_id)
+            return self._quote(record) if record else None
+
+    def save_draft(self, draft: EnrollmentDraft) -> None:
+        with self._session_factory() as session:
+            session.merge(
+                EnrollmentDraftRecord(
+                    id=draft.id,
+                    h5_user_id=draft.user_id,
+                    quote_id=draft.quote_id,
+                    applicant_json=draft.applicant,
+                    insured_json=draft.insured,
+                    contact_json=draft.contact,
+                    status=draft.status,
+                    expires_at=draft.expires_at,
+                    created_at=draft.created_at,
+                )
+            )
+            session.commit()
+
+    def get_draft(self, draft_id: str) -> EnrollmentDraft | None:
+        with self._session_factory() as session:
+            record = session.get(EnrollmentDraftRecord, draft_id)
+            return self._draft(record) if record else None
+
+    def save_confirmation(self, confirmation: UserConfirmation) -> None:
+        with self._session_factory() as session:
+            session.merge(
+                UserConfirmationRecord(
+                    id=confirmation.id,
+                    h5_user_id=confirmation.user_id,
+                    draft_id=confirmation.draft_id,
+                    token_hash=confirmation.token_hash,
+                    status=confirmation.status,
+                    expires_at=confirmation.expires_at,
+                    created_at=confirmation.created_at,
+                )
+            )
+            session.commit()
+
+    def get_confirmation_by_token_hash(self, token_hash: str) -> UserConfirmation | None:
+        with self._session_factory() as session:
+            record = session.scalar(
+                select(UserConfirmationRecord).where(
+                    UserConfirmationRecord.token_hash == token_hash
+                )
+            )
+            return self._confirmation(record) if record else None
+
+    def save_order(self, order: EnrollmentOrder) -> None:
+        with self._session_factory() as session:
+            session.merge(
+                EnrollmentOrderRecord(
+                    id=order.id,
+                    order_no=order.order_no,
+                    h5_user_id=order.user_id,
+                    quote_id=order.quote_id,
+                    draft_id=order.draft_id,
+                    confirmation_id=order.confirmation_id,
+                    idempotency_key=order.idempotency_key,
+                    product_id=order.product_id,
+                    product_version_id=order.product_version_id,
+                    plan_id=order.plan_id,
+                    applicant_snapshot_json=order.applicant_snapshot,
+                    insured_snapshot_json=order.insured_snapshot,
+                    amount_cents=order.amount_cents,
+                    currency=order.currency,
+                    status=order.status,
+                    created_at=order.created_at,
+                )
+            )
+            session.commit()
+
+    def get_order(self, order_id: str) -> EnrollmentOrder | None:
+        with self._session_factory() as session:
+            record = session.get(EnrollmentOrderRecord, order_id)
+            return self._order(record) if record else None
+
+    def find_order_by_idempotency(
+        self, user_id: str, idempotency_key: str
+    ) -> EnrollmentOrder | None:
+        with self._session_factory() as session:
+            record = session.scalar(
+                select(EnrollmentOrderRecord).where(
+                    EnrollmentOrderRecord.h5_user_id == user_id,
+                    EnrollmentOrderRecord.idempotency_key == idempotency_key,
+                )
+            )
+            return self._order(record) if record else None
+
+    def list_orders(self, user_id: str | None = None) -> list[EnrollmentOrder]:
+        with self._session_factory() as session:
+            statement = select(EnrollmentOrderRecord).order_by(
+                EnrollmentOrderRecord.created_at.desc()
+            )
+            if user_id is not None:
+                statement = statement.where(EnrollmentOrderRecord.h5_user_id == user_id)
+            return [self._order(record) for record in session.scalars(statement)]
+
+    def save_payment(self, payment: PaymentTransaction) -> None:
+        with self._session_factory() as session:
+            session.merge(
+                PaymentTransactionRecord(
+                    id=payment.id,
+                    payment_no=payment.payment_no,
+                    order_id=payment.order_id,
+                    h5_user_id=payment.user_id,
+                    transaction_type=payment.transaction_type,
+                    provider=payment.provider,
+                    idempotency_key=payment.idempotency_key,
+                    amount_cents=payment.amount_cents,
+                    currency=payment.currency,
+                    status=payment.status,
+                    provider_transaction_no=payment.provider_transaction_no,
+                    succeeded_at=payment.succeeded_at,
+                    created_at=payment.created_at,
+                )
+            )
+            session.commit()
+
+    def get_payment(self, payment_id: str) -> PaymentTransaction | None:
+        with self._session_factory() as session:
+            record = session.get(PaymentTransactionRecord, payment_id)
+            return self._payment(record) if record else None
+
+    def get_payment_by_no(self, payment_no: str) -> PaymentTransaction | None:
+        with self._session_factory() as session:
+            record = session.scalar(
+                select(PaymentTransactionRecord).where(
+                    PaymentTransactionRecord.payment_no == payment_no
+                )
+            )
+            return self._payment(record) if record else None
+
+    def get_payment_by_order(self, order_id: str) -> PaymentTransaction | None:
+        with self._session_factory() as session:
+            record = session.scalar(
+                select(PaymentTransactionRecord).where(
+                    PaymentTransactionRecord.order_id == order_id
+                )
+            )
+            return self._payment(record) if record else None
+
+    def find_payment_by_idempotency(
+        self, order_id: str, idempotency_key: str
+    ) -> PaymentTransaction | None:
+        with self._session_factory() as session:
+            record = session.scalar(
+                select(PaymentTransactionRecord).where(
+                    PaymentTransactionRecord.order_id == order_id,
+                    PaymentTransactionRecord.idempotency_key == idempotency_key,
+                )
+            )
+            return self._payment(record) if record else None
+
+    def save_policy(self, policy: Policy) -> None:
+        with self._session_factory() as session:
+            session.merge(
+                PolicyRecord(
+                    id=policy.id,
+                    policy_no=policy.policy_no,
+                    order_id=policy.order_id,
+                    h5_user_id=policy.user_id,
+                    product_id=policy.product_id,
+                    product_version_id=policy.product_version_id,
+                    plan_id=policy.plan_id,
+                    premium_cents=policy.premium_cents,
+                    currency=policy.currency,
+                    coverage_start=policy.coverage_start,
+                    coverage_end=policy.coverage_end,
+                    status=policy.status,
+                    issued_at=policy.issued_at,
+                )
+            )
+            session.commit()
+
+    def get_policy_by_order(self, order_id: str) -> Policy | None:
+        with self._session_factory() as session:
+            record = session.scalar(select(PolicyRecord).where(PolicyRecord.order_id == order_id))
+            return self._policy(record) if record else None
+
+    def list_policies(self, user_id: str | None = None) -> list[Policy]:
+        with self._session_factory() as session:
+            statement = select(PolicyRecord).order_by(PolicyRecord.issued_at.desc())
+            if user_id is not None:
+                statement = statement.where(PolicyRecord.h5_user_id == user_id)
+            return [self._policy(record) for record in session.scalars(statement)]
+
+    def save_outbox_event(self, event: OutboxEvent) -> None:
+        with self._session_factory() as session:
+            session.merge(
+                OutboxEventRecord(
+                    id=event.id,
+                    aggregate_type=event.aggregate_type,
+                    aggregate_id=event.aggregate_id,
+                    event_type=event.event_type,
+                    payload_json=event.payload,
+                    status=event.status,
+                    created_at=event.created_at,
+                )
+            )
+            session.commit()
+
+    def save_task(self, task: AsyncTask) -> None:
+        with self._session_factory() as session:
+            session.merge(
+                AsyncTaskRecord(
+                    id=task.id,
+                    task_type=task.task_type,
+                    business_key=task.business_key,
+                    idempotency_key=task.idempotency_key,
+                    payload_json=task.payload,
+                    status=task.status,
+                    attempt_count=task.attempt_count,
+                    created_at=task.created_at,
+                )
+            )
+            session.commit()
+
+    def get_task_by_idempotency(self, idempotency_key: str) -> AsyncTask | None:
+        with self._session_factory() as session:
+            record = session.scalar(
+                select(AsyncTaskRecord).where(AsyncTaskRecord.idempotency_key == idempotency_key)
+            )
+            if record is None:
+                return None
+            return AsyncTask(
+                id=record.id,
+                task_type=record.task_type,
+                business_key=record.business_key,
+                idempotency_key=record.idempotency_key,
+                payload=dict(record.payload_json),
+                status=record.status,
+                attempt_count=record.attempt_count,
+                created_at=record.created_at,
+            )
+
+    @staticmethod
+    def _quote(record: QuoteRecord) -> Quote:
+        return Quote(
+            id=record.id,
+            user_id=record.h5_user_id,
+            product_id=record.product_id,
+            product_version_id=record.product_version_id,
+            plan_id=record.plan_id,
+            insured_age=record.insured_age,
+            insured_region_code=record.insured_region_code,
+            occupation_code=record.occupation_code,
+            relationship=record.relationship,
+            premium_cents=record.premium_cents,
+            currency=record.currency,
+            rule_version=record.rule_version,
+            rate_version=record.rate_version,
+            status=record.status,
+            expires_at=record.expires_at,
+            created_at=record.created_at,
+        )
+
+    @staticmethod
+    def _draft(record: EnrollmentDraftRecord) -> EnrollmentDraft:
+        return EnrollmentDraft(
+            id=record.id,
+            user_id=record.h5_user_id,
+            quote_id=record.quote_id,
+            applicant=dict(record.applicant_json),
+            insured=dict(record.insured_json),
+            contact=dict(record.contact_json),
+            status=record.status,
+            expires_at=record.expires_at,
+            created_at=record.created_at,
+        )
+
+    @staticmethod
+    def _confirmation(record: UserConfirmationRecord) -> UserConfirmation:
+        return UserConfirmation(
+            id=record.id,
+            user_id=record.h5_user_id,
+            draft_id=record.draft_id,
+            token_hash=record.token_hash,
+            status=record.status,
+            expires_at=record.expires_at,
+            created_at=record.created_at,
+        )
+
+    @staticmethod
+    def _order(record: EnrollmentOrderRecord) -> EnrollmentOrder:
+        return EnrollmentOrder(
+            id=record.id,
+            order_no=record.order_no,
+            user_id=record.h5_user_id,
+            quote_id=record.quote_id,
+            draft_id=record.draft_id,
+            confirmation_id=record.confirmation_id,
+            idempotency_key=record.idempotency_key,
+            product_id=record.product_id,
+            product_version_id=record.product_version_id,
+            plan_id=record.plan_id,
+            applicant_snapshot=dict(record.applicant_snapshot_json),
+            insured_snapshot=dict(record.insured_snapshot_json),
+            amount_cents=record.amount_cents,
+            currency=record.currency,
+            status=record.status,
+            created_at=record.created_at,
+        )
+
+    @staticmethod
+    def _payment(record: PaymentTransactionRecord) -> PaymentTransaction:
+        return PaymentTransaction(
+            id=record.id,
+            payment_no=record.payment_no,
+            order_id=record.order_id,
+            user_id=record.h5_user_id,
+            transaction_type=record.transaction_type,
+            provider=record.provider,
+            idempotency_key=record.idempotency_key,
+            amount_cents=record.amount_cents,
+            currency=record.currency,
+            status=record.status,
+            provider_transaction_no=record.provider_transaction_no,
+            succeeded_at=record.succeeded_at,
+            created_at=record.created_at,
+        )
+
+    @staticmethod
+    def _policy(record: PolicyRecord) -> Policy:
+        return Policy(
+            id=record.id,
+            policy_no=record.policy_no,
+            order_id=record.order_id,
+            user_id=record.h5_user_id,
+            product_id=record.product_id,
+            product_version_id=record.product_version_id,
+            plan_id=record.plan_id,
+            premium_cents=record.premium_cents,
+            currency=record.currency,
+            coverage_start=record.coverage_start,
+            coverage_end=record.coverage_end,
+            status=record.status,
+            issued_at=record.issued_at,
+        )

+ 367 - 0
backend/app/infrastructure/mysql/repositories.py

@@ -0,0 +1,367 @@
+from sqlalchemy import select
+from sqlalchemy.orm import Session, sessionmaker
+
+from app.core.identifiers import new_ulid
+from app.domains.catalog.models import Plan, Product, ProductChangeLog, ProductVersion
+from app.domains.catalog.repository import CatalogRepository
+from app.domains.identity.models import AdminRole, AdminUser, AuthSession, H5User
+from app.domains.identity.repository import IdentityRepository
+from app.infrastructure.mysql.core_models import (
+    AdminUserRecord,
+    AuthSessionRecord,
+    H5UserRecord,
+    PermissionRecord,
+    PlanRecord,
+    ProductChangeLogRecord,
+    ProductRecord,
+    ProductVersionRecord,
+    RoleRecord,
+)
+
+SessionFactory = sessionmaker[Session]
+
+
+class SqlAlchemyIdentityRepository(IdentityRepository):
+    def __init__(self, session_factory: SessionFactory) -> None:
+        self._session_factory = session_factory
+
+    def get_h5_user_by_mobile(self, mobile: str) -> H5User | None:
+        with self._session_factory() as session:
+            record = session.scalar(select(H5UserRecord).where(H5UserRecord.mobile == mobile))
+            return self._to_h5_user(record) if record else None
+
+    def get_h5_user(self, user_id: str) -> H5User | None:
+        with self._session_factory() as session:
+            record = session.get(H5UserRecord, user_id)
+            return self._to_h5_user(record) if record else None
+
+    def list_h5_users(self) -> list[H5User]:
+        with self._session_factory() as session:
+            records = session.scalars(
+                select(H5UserRecord).order_by(H5UserRecord.created_at.desc())
+            ).all()
+            return [self._to_h5_user(record) for record in records]
+
+    def save_h5_user(self, user: H5User) -> None:
+        with self._session_factory() as session:
+            session.merge(
+                H5UserRecord(
+                    id=user.id,
+                    mobile=user.mobile,
+                    mobile_masked=user.mobile_masked,
+                    display_name=user.display_name,
+                    status=user.status,
+                    created_at=user.created_at,
+                )
+            )
+            session.commit()
+
+    def get_admin_user_by_username(self, username: str) -> AdminUser | None:
+        with self._session_factory() as session:
+            record = session.scalar(
+                select(AdminUserRecord).where(AdminUserRecord.username == username)
+            )
+            return self._to_admin_user(record) if record else None
+
+    def get_admin_user(self, user_id: str) -> AdminUser | None:
+        with self._session_factory() as session:
+            record = session.get(AdminUserRecord, user_id)
+            return self._to_admin_user(record) if record else None
+
+    def list_admin_users(self) -> list[AdminUser]:
+        with self._session_factory() as session:
+            records = session.scalars(
+                select(AdminUserRecord).order_by(AdminUserRecord.username)
+            ).all()
+            return [self._to_admin_user(record) for record in records]
+
+    def save_admin_user(self, user: AdminUser) -> None:
+        with self._session_factory() as session:
+            roles: list[RoleRecord] = []
+            for role_code in user.roles:
+                role = session.scalar(select(RoleRecord).where(RoleRecord.code == role_code))
+                if role is None:
+                    role = RoleRecord(
+                        id=new_ulid(),
+                        code=role_code,
+                        name=role_code,
+                        data_scope=user.data_scope,
+                        status="ACTIVE",
+                    )
+                    session.add(role)
+                permissions: list[PermissionRecord] = []
+                for permission_code in user.permissions:
+                    permission = session.scalar(
+                        select(PermissionRecord).where(PermissionRecord.code == permission_code)
+                    )
+                    if permission is None:
+                        resource, _, action = permission_code.partition(":")
+                        permission = PermissionRecord(
+                            id=new_ulid(),
+                            code=permission_code,
+                            name=permission_code,
+                            resource=resource,
+                            action=action or "read",
+                        )
+                        session.add(permission)
+                    permissions.append(permission)
+                role.permissions = permissions
+                roles.append(role)
+            record = session.get(AdminUserRecord, user.id)
+            if record is None:
+                record = AdminUserRecord(
+                    id=user.id,
+                    username=user.username,
+                    password_hash=user.password_hash,
+                    display_name=user.display_name,
+                    status=user.status,
+                )
+                session.add(record)
+            else:
+                record.username = user.username
+                record.password_hash = user.password_hash
+                record.display_name = user.display_name
+                record.status = user.status
+            record.roles = roles
+            session.commit()
+
+    def list_roles(self) -> list[AdminRole]:
+        with self._session_factory() as session:
+            records = session.scalars(select(RoleRecord).order_by(RoleRecord.code)).all()
+            return [
+                AdminRole(
+                    code=record.code,
+                    name=record.name,
+                    data_scope=record.data_scope,
+                    status=record.status,
+                    permissions=tuple(sorted(permission.code for permission in record.permissions)),
+                )
+                for record in records
+            ]
+
+    def save_role(self, role: AdminRole) -> None:
+        with self._session_factory() as session:
+            record = session.scalar(select(RoleRecord).where(RoleRecord.code == role.code))
+            if record is None:
+                raise KeyError(role.code)
+            permissions: list[PermissionRecord] = []
+            for permission_code in role.permissions:
+                permission = session.scalar(
+                    select(PermissionRecord).where(PermissionRecord.code == permission_code)
+                )
+                if permission is None:
+                    resource, _, action = permission_code.partition(":")
+                    permission = PermissionRecord(
+                        id=new_ulid(),
+                        code=permission_code,
+                        name=permission_code,
+                        resource=resource,
+                        action=action or "read",
+                    )
+                    session.add(permission)
+                permissions.append(permission)
+            record.name = role.name
+            record.data_scope = role.data_scope
+            record.status = role.status
+            record.permissions = permissions
+            session.commit()
+
+    def get_session(self, session_id: str) -> AuthSession | None:
+        with self._session_factory() as session:
+            record = session.get(AuthSessionRecord, session_id)
+            if record is None:
+                return None
+            return AuthSession(
+                id=record.id,
+                subject_type=record.subject_type,
+                subject_id=record.subject_id,
+                refresh_jti_hash=record.refresh_jti_hash,
+                expires_at=record.expires_at,
+                revoked_at=record.revoked_at,
+            )
+
+    def save_session(self, auth_session: AuthSession) -> None:
+        with self._session_factory() as session:
+            session.merge(
+                AuthSessionRecord(
+                    id=auth_session.id,
+                    subject_type=auth_session.subject_type,
+                    subject_id=auth_session.subject_id,
+                    refresh_jti_hash=auth_session.refresh_jti_hash,
+                    expires_at=auth_session.expires_at,
+                    revoked_at=auth_session.revoked_at,
+                )
+            )
+            session.commit()
+
+    @staticmethod
+    def _to_h5_user(record: H5UserRecord) -> H5User:
+        return H5User(
+            id=record.id,
+            mobile=record.mobile,
+            mobile_masked=record.mobile_masked,
+            display_name=record.display_name,
+            status=record.status,
+            created_at=record.created_at,
+        )
+
+    @staticmethod
+    def _to_admin_user(record: AdminUserRecord) -> AdminUser:
+        permissions = {permission.code for role in record.roles for permission in role.permissions}
+        scopes = {role.data_scope for role in record.roles}
+        data_scope = "ALL" if "ALL" in scopes else sorted(scopes)[0]
+        return AdminUser(
+            id=record.id,
+            username=record.username,
+            password_hash=record.password_hash,
+            display_name=record.display_name,
+            status=record.status,
+            roles=tuple(sorted(role.code for role in record.roles)),
+            permissions=tuple(sorted(permissions)),
+            data_scope=data_scope,
+        )
+
+
+class SqlAlchemyCatalogRepository(CatalogRepository):
+    def __init__(self, session_factory: SessionFactory) -> None:
+        self._session_factory = session_factory
+
+    def save_product(self, product: Product) -> None:
+        with self._session_factory() as session:
+            session.merge(
+                ProductRecord(
+                    id=product.id,
+                    product_code=product.product_code,
+                    name=product.name,
+                    category=product.category,
+                    summary=product.summary,
+                    status=product.status,
+                )
+            )
+            session.commit()
+
+    def save_version(self, version: ProductVersion) -> None:
+        with self._session_factory() as session:
+            record = session.get(ProductVersionRecord, version.id)
+            if record is None:
+                record = ProductVersionRecord(
+                    id=version.id,
+                    product_id=version.product_id,
+                    version_no=version.version_no,
+                )
+                session.add(record)
+            record.version_no = version.version_no
+            record.status = version.status
+            record.effective_from = version.effective_from
+            record.effective_to = version.effective_to
+            record.rule_version = version.rule_version
+            record.rate_version = version.rate_version
+            record.terms_summary = version.terms_summary
+            record.plans = [
+                PlanRecord(
+                    id=plan.id,
+                    plan_code=plan.code,
+                    name=plan.name,
+                    summary=plan.summary,
+                    status=plan.status,
+                    premium_cents=plan.premium_cents,
+                    coverage_amount_cents=plan.coverage_amount_cents,
+                    min_age=plan.min_age,
+                    max_age=plan.max_age,
+                )
+                for plan in version.plans
+            ]
+            session.commit()
+
+    def delete_version(self, version_id: str) -> None:
+        with self._session_factory() as session:
+            record = session.get(ProductVersionRecord, version_id)
+            if record is not None:
+                session.delete(record)
+                session.commit()
+
+    def save_change_log(self, log: ProductChangeLog) -> None:
+        with self._session_factory() as session:
+            session.merge(
+                ProductChangeLogRecord(
+                    id=log.id,
+                    product_id=log.product_id,
+                    version_id=log.version_id,
+                    action=log.action,
+                    actor_id=log.actor_id,
+                    actor_name=log.actor_name,
+                    detail_json=log.detail,
+                    created_at=log.created_at,
+                )
+            )
+            session.commit()
+
+    def list_change_logs(self, product_id: str) -> list[ProductChangeLog]:
+        with self._session_factory() as session:
+            records = session.scalars(
+                select(ProductChangeLogRecord)
+                .where(ProductChangeLogRecord.product_id == product_id)
+                .order_by(ProductChangeLogRecord.created_at.desc())
+            ).all()
+            return [
+                ProductChangeLog(
+                    id=record.id,
+                    product_id=record.product_id,
+                    version_id=record.version_id,
+                    action=record.action,
+                    actor_id=record.actor_id,
+                    actor_name=record.actor_name,
+                    detail=dict(record.detail_json),
+                    created_at=record.created_at,
+                )
+                for record in records
+            ]
+
+    def list_products(self) -> list[Product]:
+        with self._session_factory() as session:
+            records = session.scalars(select(ProductRecord)).all()
+            return [
+                Product(
+                    id=record.id,
+                    product_code=record.product_code,
+                    name=record.name,
+                    category=record.category,
+                    summary=record.summary,
+                    status=record.status,
+                )
+                for record in records
+            ]
+
+    def list_versions(self, product_id: str) -> list[ProductVersion]:
+        with self._session_factory() as session:
+            records = session.scalars(
+                select(ProductVersionRecord).where(ProductVersionRecord.product_id == product_id)
+            ).all()
+            return [
+                ProductVersion(
+                    id=record.id,
+                    product_id=record.product_id,
+                    version_no=record.version_no,
+                    status=record.status,
+                    effective_from=record.effective_from,
+                    effective_to=record.effective_to,
+                    rule_version=record.rule_version,
+                    rate_version=record.rate_version,
+                    terms_summary=record.terms_summary,
+                    plans=tuple(
+                        Plan(
+                            id=plan.id,
+                            code=plan.plan_code,
+                            name=plan.name,
+                            summary=plan.summary,
+                            status=plan.status,
+                            premium_cents=plan.premium_cents,
+                            coverage_amount_cents=plan.coverage_amount_cents,
+                            min_age=plan.min_age,
+                            max_age=plan.max_age,
+                        )
+                        for plan in record.plans
+                    ),
+                )
+                for record in records
+            ]

+ 60 - 0
backend/app/infrastructure/mysql/sessions.py

@@ -0,0 +1,60 @@
+"""SQLAlchemy 数据库连接与会话工具。
+
+Engine 管理数据库连接池;Session 表示一次数据库工作单元,负责查询和提交事务。
+"""
+
+from collections.abc import Iterator
+from enum import StrEnum
+
+from sqlalchemy import URL, Engine, create_engine
+from sqlalchemy.orm import Session, sessionmaker
+
+from app.core.config import Settings
+
+
+class DatabaseName(StrEnum):
+    """三个业务数据库的逻辑名称,使用枚举可避免到处手写字符串。"""
+
+    CORE = "core"
+    AGENT = "agent"
+    ANALYTICS = "analytics"
+
+
+def build_mysql_url(settings: Settings, database: DatabaseName) -> URL:
+    """根据配置和目标数据库生成 SQLAlchemy 连接地址。"""
+    database_names = {
+        DatabaseName.CORE: settings.mysql_core_database,
+        DatabaseName.AGENT: settings.mysql_agent_database,
+        DatabaseName.ANALYTICS: settings.mysql_analytics_database,
+    }
+    return URL.create(
+        drivername="mysql+pymysql",
+        username=settings.mysql_user,
+        password=settings.mysql_password,
+        host=settings.mysql_host,
+        port=settings.mysql_port,
+        database=database_names[database],
+        query={"charset": "utf8mb4"},
+    )
+
+
+def create_mysql_engine(settings: Settings, database: DatabaseName) -> Engine:
+    """创建连接池;此时通常还不会立刻执行 SQL。"""
+    return create_engine(
+        build_mysql_url(settings, database),
+        # 借出连接前先检查连接是否有效,避免拿到已被 MySQL 关闭的旧连接。
+        pool_pre_ping=True,
+        # 定期回收连接,降低 MySQL 服务端超时导致断连的概率。
+        pool_recycle=1800,
+    )
+
+
+def create_session_factory(engine: Engine) -> sessionmaker[Session]:
+    """创建 Session 工厂;调用这个工厂才能得到具体会话。"""
+    return sessionmaker(bind=engine, expire_on_commit=False)
+
+
+def session_scope(factory: sessionmaker[Session]) -> Iterator[Session]:
+    """提供自动提交/回滚的会话上下文,yield 把会话交给调用方使用。"""
+    with factory.begin() as session:
+        yield session

+ 40 - 0
backend/app/infrastructure/mysql/types.py

@@ -0,0 +1,40 @@
+from datetime import UTC, datetime
+
+from sqlalchemy import DateTime
+from sqlalchemy.engine.interfaces import Dialect
+from sqlalchemy.types import TypeDecorator
+
+
+class UTCDateTime(TypeDecorator[datetime]):
+    """在数据库中保存UTC无时区时间,读取时恢复为UTC感知时间。"""
+
+    impl = DateTime
+    cache_ok = True
+
+    def process_bind_param(
+        self,
+        value: datetime | None,
+        dialect: Dialect,
+    ) -> datetime | None:
+        del dialect
+        if value is None:
+            return None
+        if value.tzinfo is None:
+            raise ValueError("写入数据库的时间必须包含时区")
+        return value.astimezone(UTC).replace(tzinfo=None)
+
+    def process_result_value(
+        self,
+        value: datetime | None,
+        dialect: Dialect,
+    ) -> datetime | None:
+        del dialect
+        if value is None:
+            return None
+        if value.tzinfo is None:
+            return value.replace(tzinfo=UTC)
+        return value.astimezone(UTC)
+
+    @property
+    def python_type(self) -> type[datetime]:
+        return datetime

+ 1 - 0
backend/app/infrastructure/redis/__init__.py

@@ -0,0 +1 @@
+"""Redis infrastructure adapters."""

+ 137 - 0
backend/app/infrastructure/redis/agent_state.py

@@ -0,0 +1,137 @@
+"""Agent 热状态、频率控制和运行事件的 Redis 适配器。"""
+
+import json
+from typing import Protocol, cast
+
+from redis import Redis
+
+from app.core.config import Settings
+from app.harness.events import RuntimeEvent
+from app.infrastructure.redis.keys import RedisKeyBuilder
+
+
+class AgentStateStore(Protocol):
+    def allow_request(self, principal_id: str) -> bool: ...
+
+    def touch_thread(
+        self,
+        thread_id: str,
+        *,
+        owner_type: str,
+        owner_id: str,
+        persona: str,
+    ) -> None: ...
+
+    def append_event(self, event: RuntimeEvent) -> None: ...
+
+    def list_events(self, run_id: str) -> list[RuntimeEvent]: ...
+
+    def close(self) -> None: ...
+
+
+class InMemoryAgentStateStore:
+    def __init__(self, rate_limit: int = 20) -> None:
+        self._rate_limit = rate_limit
+        self._request_counts: dict[str, int] = {}
+        self._threads: dict[str, dict[str, str]] = {}
+        self._events: dict[str, list[RuntimeEvent]] = {}
+
+    def allow_request(self, principal_id: str) -> bool:
+        count = self._request_counts.get(principal_id, 0) + 1
+        self._request_counts[principal_id] = count
+        return count <= self._rate_limit
+
+    def touch_thread(
+        self,
+        thread_id: str,
+        *,
+        owner_type: str,
+        owner_id: str,
+        persona: str,
+    ) -> None:
+        self._threads[thread_id] = {
+            "owner_type": owner_type,
+            "owner_id": owner_id,
+            "persona": persona,
+        }
+
+    def append_event(self, event: RuntimeEvent) -> None:
+        self._events.setdefault(event.run_id, []).append(event)
+
+    def list_events(self, run_id: str) -> list[RuntimeEvent]:
+        return list(self._events.get(run_id, []))
+
+    def close(self) -> None:
+        return None
+
+
+class RedisAgentStateStore:
+    def __init__(self, settings: Settings) -> None:
+        self._client: Redis = Redis.from_url(
+            settings.redis_url,
+            decode_responses=True,
+        )
+        self._keys = RedisKeyBuilder(settings.redis_prefix)
+        self._rate_limit = settings.agent_rate_limit
+        self._rate_window_seconds = settings.agent_rate_window_seconds
+        self._hot_ttl_seconds = settings.agent_hot_state_ttl_seconds
+        self._event_ttl_seconds = settings.agent_event_ttl_seconds
+
+    def allow_request(self, principal_id: str) -> bool:
+        key = self._keys.agent_rate(principal_id)
+        count = cast(
+            int,
+            self._client.eval(
+                """
+                local current = redis.call('INCR', KEYS[1])
+                if current == 1 then
+                    redis.call('EXPIRE', KEYS[1], ARGV[1])
+                end
+                return current
+                """,
+                1,
+                key,
+                self._rate_window_seconds,
+            ),
+        )
+        return count <= self._rate_limit
+
+    def touch_thread(
+        self,
+        thread_id: str,
+        *,
+        owner_type: str,
+        owner_id: str,
+        persona: str,
+    ) -> None:
+        payload = json.dumps(
+            {
+                "owner_type": owner_type,
+                "owner_id": owner_id,
+                "persona": persona,
+            },
+            ensure_ascii=False,
+            separators=(",", ":"),
+        )
+        self._client.set(
+            self._keys.agent_thread_hot(thread_id),
+            payload,
+            ex=self._hot_ttl_seconds,
+        )
+
+    def append_event(self, event: RuntimeEvent) -> None:
+        key = self._keys.agent_run_events(event.run_id)
+        pipe = self._client.pipeline()
+        pipe.rpush(key, event.model_dump_json())
+        pipe.expire(key, self._event_ttl_seconds)
+        pipe.execute()
+
+    def list_events(self, run_id: str) -> list[RuntimeEvent]:
+        raw_items = cast(
+            list[str],
+            self._client.lrange(self._keys.agent_run_events(run_id), 0, -1),
+        )
+        return [RuntimeEvent.model_validate_json(item) for item in raw_items]
+
+    def close(self) -> None:
+        self._client.close()

+ 50 - 0
backend/app/infrastructure/redis/keys.py

@@ -0,0 +1,50 @@
+class RedisKeyBuilder:
+    def __init__(self, prefix: str) -> None:
+        if not prefix.endswith(":"):
+            raise ValueError("Redis Key 前缀必须以冒号结尾")
+        self._prefix = prefix
+
+    def auth_otp(self, mobile_hash: str) -> str:
+        return self._key("auth", "otp", mobile_hash)
+
+    def auth_session(self, session_id: str) -> str:
+        return self._key("auth", "session", session_id)
+
+    def auth_refresh(self, jti_hash: str) -> str:
+        return self._key("auth", "refresh", jti_hash)
+
+    def login_rate(self, subject_hash: str) -> str:
+        return self._key("rate", "login", subject_hash)
+
+    def agent_rate(self, subject_id: str) -> str:
+        return self._key("rate", "agent", subject_id)
+
+    def agent_thread_hot(self, thread_id: str) -> str:
+        return self._key("agent", "thread", thread_id, "hot")
+
+    def agent_run_events(self, run_id: str) -> str:
+        return self._key("agent", "run", run_id, "events")
+
+    def agent_run_cancel(self, run_id: str) -> str:
+        return self._key("agent", "run", run_id, "cancel")
+
+    def payment_lock(self, payment_id: str) -> str:
+        return self._key("lock", "payment", payment_id)
+
+    def issue_lock(self, order_id: str) -> str:
+        return self._key("lock", "issue", order_id)
+
+    def product_cache(self, scope_hash: str) -> str:
+        return self._key("cache", "product", "available", scope_hash)
+
+    def metrics_cache(self, metric_hash: str) -> str:
+        return self._key("cache", "metrics", metric_hash)
+
+    def worker_wakeup(self, queue: str) -> str:
+        return self._key("worker", "wakeup", queue)
+
+    def _key(self, *segments: str) -> str:
+        for segment in segments:
+            if not segment or ":" in segment:
+                raise ValueError("Redis Key 片段不能为空或包含冒号")
+        return self._prefix + ":".join(segments)

+ 292 - 0
backend/app/main.py

@@ -0,0 +1,292 @@
+"""FastAPI 应用入口。
+
+启动命令 ``uv run uvicorn app.main:app --reload`` 中:
+- ``app.main`` 表示本文件(app/main.py);
+- 最后的 ``app`` 表示文件末尾创建的 FastAPI 实例。
+"""
+
+from collections.abc import Awaitable, Callable
+from datetime import UTC, datetime
+
+from fastapi import FastAPI, Request
+from fastapi.exceptions import RequestValidationError
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
+from starlette.responses import Response
+
+from app.api.v1.admin_auth import create_admin_router
+from app.api.v1.admin_business import create_admin_business_router
+from app.api.v1.agent import create_agent_router
+from app.api.v1.enrollment import create_enrollment_router
+from app.api.v1.h5_auth import create_h5_router
+from app.api.v1.h5_products import create_h5_product_router
+from app.api.v1.system import router as system_router
+from app.core.config import Settings, get_settings
+from app.core.errors import AppError
+from app.core.identifiers import new_request_id
+from app.core.passwords import PasswordService
+from app.core.security import JwtService
+from app.domains.agent.repository import AgentRepository
+from app.domains.agent.runtime import (
+    AgentRuntime,
+    UnavailableAgentRuntime,
+)
+from app.domains.agent.service import AgentThreadService
+from app.domains.agent.tools import build_agent_tool_registry
+from app.domains.attribution.repository import (
+    AttributionRepository,
+    InMemoryAttributionRepository,
+)
+from app.domains.attribution.service import AttributionService
+from app.domains.catalog.repository import CatalogRepository
+from app.domains.catalog.service import ProductCatalogService
+from app.domains.enrollment.repository import EnrollmentRepository
+from app.domains.enrollment.service import EnrollmentService
+from app.domains.identity.repository import (
+    IdentityRepository,
+)
+from app.domains.identity.service import AdminAuthService, H5AuthService
+from app.harness.kernel import AgentKernel
+from app.harness.model_gateway import DeepSeekModelGateway
+from app.infrastructure.mysql.agent_repositories import SqlAlchemyAgentRepository
+from app.infrastructure.mysql.attribution_repositories import (
+    SqlAlchemyAttributionRepository,
+)
+from app.infrastructure.mysql.enrollment_repositories import (
+    SqlAlchemyEnrollmentRepository,
+)
+from app.infrastructure.mysql.repositories import (
+    SqlAlchemyCatalogRepository,
+    SqlAlchemyIdentityRepository,
+)
+from app.infrastructure.mysql.sessions import (
+    DatabaseName,
+    create_mysql_engine,
+    create_session_factory,
+)
+from app.infrastructure.redis.agent_state import (
+    AgentStateStore,
+    InMemoryAgentStateStore,
+    RedisAgentStateStore,
+)
+
+
+def create_app(
+    *,
+    settings: Settings | None = None,
+    identity_repository: IdentityRepository | None = None,
+    catalog_repository: CatalogRepository | None = None,
+    agent_repository: AgentRepository | None = None,
+    agent_runtime: AgentRuntime | None = None,
+    enrollment_repository: EnrollmentRepository | None = None,
+    attribution_repository: AttributionRepository | None = None,
+    agent_state_store: AgentStateStore | None = None,
+    clock: Callable[[], datetime] | None = None,
+) -> FastAPI:
+    """创建并组装一个 FastAPI 应用。
+
+    参数后的 ``| None`` 表示该参数可以不传。正常启动时使用真实配置和
+    MySQL 仓储;测试时可以传入假的仓储和时钟,因此测试不必连接真实数据库。
+    开头的 ``*`` 表示后面的参数必须写出参数名,可避免调用时传错位置。
+    """
+    app_settings = settings or get_settings()
+
+    # 只有未注入测试仓储时才创建真实数据库连接。
+    core_engine = None
+    agent_engine = None
+    if identity_repository is None or catalog_repository is None or enrollment_repository is None:
+        core_engine = create_mysql_engine(app_settings, DatabaseName.CORE)
+        session_factory = create_session_factory(core_engine)
+
+    # ``a or b``:优先使用调用方传入的对象,否则创建生产环境的默认对象。
+    repository = identity_repository or SqlAlchemyIdentityRepository(session_factory)
+    product_repository = catalog_repository or SqlAlchemyCatalogRepository(session_factory)
+    if agent_repository is None:
+        agent_engine = create_mysql_engine(app_settings, DatabaseName.AGENT)
+        agent_session_factory = create_session_factory(agent_engine)
+        runtime_repository: AgentRepository = SqlAlchemyAgentRepository(agent_session_factory)
+    else:
+        runtime_repository = agent_repository
+
+    # 用可替换的 clock 获取时间,可以让与时间有关的测试保持稳定。
+    current_time = clock or (lambda: datetime.now(UTC))
+
+    # Service 负责业务规则,Repository 负责数据读写,Router 负责接收 HTTP 请求。
+    jwt_service = JwtService(app_settings)
+    h5_auth_service = H5AuthService(app_settings, repository, jwt_service)
+    catalog_service = ProductCatalogService(product_repository, current_time)
+    attribution_repository_adapter = attribution_repository or (
+        InMemoryAttributionRepository()
+        if app_settings.app_env == "test"
+        else SqlAlchemyAttributionRepository(session_factory)
+    )
+    attribution_service = AttributionService(
+        attribution_repository_adapter,
+        current_time,
+    )
+    transactional_repository = enrollment_repository or SqlAlchemyEnrollmentRepository(
+        session_factory
+    )
+    enrollment_service = EnrollmentService(
+        transactional_repository,
+        catalog_service,
+        current_time,
+        app_settings.mock_payment_callback_secret,
+        attribution_service,
+    )
+    admin_auth_service = AdminAuthService(
+        app_settings,
+        repository,
+        jwt_service,
+        PasswordService(),
+    )
+    configured_agent_runtime = agent_runtime
+    if configured_agent_runtime is None and app_settings.deepseek_api_key:
+        configured_agent_runtime = AgentKernel(
+            settings=app_settings,
+            model_gateway=DeepSeekModelGateway(app_settings),
+            tools=build_agent_tool_registry(
+                catalog_service,
+                enrollment_service,
+                attribution_service,
+            ),
+        )
+    runtime_state_store = agent_state_store or (
+        InMemoryAgentStateStore(app_settings.agent_rate_limit)
+        if app_settings.app_env == "test"
+        else RedisAgentStateStore(app_settings)
+    )
+    thread_service = AgentThreadService(
+        runtime_repository,
+        current_time,
+        configured_agent_runtime or UnavailableAgentRuntime(),
+        runtime_state_store,
+    )
+    application = FastAPI(
+        title="智保通第一阶段API",
+        version="0.1.0",
+    )
+    application.state.identity_repository = repository
+    application.state.catalog_repository = product_repository
+    application.state.core_engine = core_engine
+    application.state.agent_engine = agent_engine
+    application.state.agent_state_store = runtime_state_store
+    application.state.attribution_repository = attribution_repository_adapter
+    application.router.add_event_handler("shutdown", runtime_state_store.close)
+
+    # CORS 决定哪些前端地址可以在浏览器中调用本后端。
+    application.add_middleware(
+        CORSMiddleware,
+        allow_origins=app_settings.app_cors_origins,
+        allow_credentials=True,
+        allow_methods=["*"],
+        allow_headers=["*"],
+    )
+
+    @application.middleware("http")
+    async def request_context(
+        request: Request,
+        call_next: Callable[[Request], Awaitable[Response]],
+    ) -> Response:
+        # 每个请求都有 request_id,方便把前端报错与后端日志对应起来。
+        request.state.request_id = request.headers.get("X-Request-ID") or new_request_id()
+        response = await call_next(request)
+        response.headers["X-Request-ID"] = request.state.request_id
+        return response
+
+    @application.exception_handler(AppError)
+    async def app_error_handler(request: Request, error: AppError) -> JSONResponse:
+        """把业务层抛出的 AppError 统一转换成 JSON 错误响应。"""
+        return JSONResponse(
+            status_code=error.status_code,
+            content={
+                "success": False,
+                "error": {
+                    "code": error.code,
+                    "message": error.message,
+                    "details": error.details or {},
+                    "retryable": error.retryable,
+                },
+                "meta": {
+                    "request_id": getattr(request.state, "request_id", new_request_id()),
+                },
+            },
+        )
+
+    @application.exception_handler(RequestValidationError)
+    async def validation_error_handler(
+        request: Request,
+        error: RequestValidationError,
+    ) -> JSONResponse:
+        """把框架参数校验错误转换为稳定包络,不回传原始敏感输入。"""
+        fields = [
+            {
+                "field": ".".join(str(part) for part in item["loc"]),
+                "message": item["msg"],
+            }
+            for item in error.errors()
+        ]
+        return JSONResponse(
+            status_code=400,
+            content={
+                "success": False,
+                "error": {
+                    "code": "VALIDATION_ERROR",
+                    "message": "请求参数无效",
+                    "details": {"fields": fields},
+                    "retryable": False,
+                },
+                "meta": {
+                    "request_id": getattr(request.state, "request_id", new_request_id()),
+                },
+            },
+        )
+
+    # 注册路由;统一前缀使所有第一版接口都从 /api/v1 开始。
+    application.include_router(system_router, prefix="/api/v1")
+    application.include_router(
+        create_h5_router(
+            h5_auth_service,
+            app_settings,
+            attribution_service,
+        ),
+        prefix="/api/v1",
+    )
+    application.include_router(
+        create_h5_product_router(catalog_service, h5_auth_service),
+        prefix="/api/v1",
+    )
+    application.include_router(
+        create_admin_router(admin_auth_service, app_settings),
+        prefix="/api/v1",
+    )
+    application.include_router(
+        create_agent_router(
+            thread_service,
+            h5_auth_service,
+            admin_auth_service,
+        ),
+        prefix="/api/v1",
+    )
+    application.include_router(
+        create_enrollment_router(
+            enrollment_service,
+            h5_auth_service,
+            app_settings,
+        ),
+        prefix="/api/v1",
+    )
+    application.include_router(
+        create_admin_business_router(
+            enrollment_service,
+            admin_auth_service,
+            catalog_service,
+            attribution_service,
+        ),
+        prefix="/api/v1",
+    )
+    return application
+
+
+# Uvicorn 导入 ``app.main:app`` 时,会执行这里并得到可运行的 ASGI 应用。
+app = create_app()

+ 3 - 0
backend/evals/customer-full.jsonl

@@ -0,0 +1,3 @@
+{"id":"customer-recommend-parent","persona":"customer","input":"我想给65岁的父亲买一份成都地区的医疗保险,请推荐合适方案并测算标准计划保费。","expected_tools":["list_available_products","calculate_insurance_quote"],"expected_terms":["父亲","保费"]}
+{"id":"customer-list-orders","persona":"customer","input":"请查询我的投保订单,并告诉我每笔订单当前状态。","expected_tools":["list_my_orders"],"expected_terms":["订单"]}
+{"id":"customer-list-policies","persona":"customer","input":"请查询我已经生效的保单。","expected_tools":["list_my_policies"],"expected_terms":["保单"]}

+ 1 - 0
backend/evals/customer-smoke.jsonl

@@ -0,0 +1 @@
+{"id":"customer-recommend-parent","persona":"customer","input":"我想给65岁的父亲买一份成都地区的医疗保险,请推荐合适方案并测算标准计划保费。","expected_tools":["list_available_products","calculate_insurance_quote"],"expected_terms":["父亲","保费"]}

+ 3 - 0
backend/evals/operation-full.jsonl

@@ -0,0 +1,3 @@
+{"id":"operation-overview","persona":"operation","input":"请分析当前订单、有效保单和累计保费情况,并说明数据依据。","expected_tools":["get_operation_overview"],"expected_terms":["订单","保单"]}
+{"id":"operation-recent-orders","persona":"operation","input":"查询最近5笔订单并总结状态分布。","expected_tools":["list_recent_orders"],"expected_terms":["订单"]}
+{"id":"operation-attribution","persona":"operation","input":"分析推广人员的访问、线索、归因订单和保费情况。","expected_tools":["get_attribution_performance"],"expected_terms":["推广","订单"]}

+ 1 - 0
backend/evals/operation-smoke.jsonl

@@ -0,0 +1 @@
+{"id":"operation-overview","persona":"operation","input":"请分析当前订单、有效保单和累计保费情况,并说明数据依据。","expected_tools":["get_operation_overview"],"expected_terms":["订单","保单"]}

+ 12 - 0
backend/migrations/README.md

@@ -0,0 +1,12 @@
+# 三库迁移
+
+三套 Alembic 历史彼此独立,分别写入对应数据库的 `alembic_version` 表:
+
+```powershell
+uv run alembic -c alembic.ini --name alembic_core upgrade head
+uv run alembic -c alembic.ini --name alembic_agent upgrade head
+uv run alembic -c alembic.ini --name alembic_analytics upgrade head
+```
+
+初始迁移固定创建该版本声明的表集合。后续字段和表变化必须新增 revision,
+不得修改已经分发的 revision。

+ 40 - 0
backend/migrations/agent/env.py

@@ -0,0 +1,40 @@
+from alembic import context
+
+from app.core.config import Settings
+from app.infrastructure.mysql.agent_models import AgentBase
+from app.infrastructure.mysql.sessions import DatabaseName, build_mysql_url
+
+config = context.config
+target_metadata = AgentBase.metadata
+
+
+def run_migrations_offline() -> None:
+    context.configure(
+        url=build_mysql_url(Settings(), DatabaseName.AGENT),
+        target_metadata=target_metadata,
+        literal_binds=True,
+        dialect_opts={"paramstyle": "named"},
+        compare_type=True,
+    )
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def run_migrations_online() -> None:
+    from sqlalchemy import create_engine
+
+    engine = create_engine(build_mysql_url(Settings(), DatabaseName.AGENT))
+    with engine.connect() as connection:
+        context.configure(
+            connection=connection,
+            target_metadata=target_metadata,
+            compare_type=True,
+        )
+        with context.begin_transaction():
+            context.run_migrations()
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    run_migrations_online()

+ 20 - 0
backend/migrations/agent/script.py.mako

@@ -0,0 +1,20 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+"""
+from alembic import op
+import sqlalchemy as sa
+
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+    ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+    ${downgrades if downgrades else "pass"}

+ 26 - 0
backend/migrations/agent/versions/agent_0001_initial.py

@@ -0,0 +1,26 @@
+"""创建 Agent 库基础表。"""
+
+from alembic import op
+
+from app.infrastructure.mysql.agent_models import AgentBase
+
+revision = "agent_0001"
+down_revision = None
+branch_labels = None
+depends_on = None
+
+TABLE_NAMES = ("agent_threads", "agent_messages", "agent_runs")
+
+
+def upgrade() -> None:
+    bind = op.get_bind()
+    for table in AgentBase.metadata.sorted_tables:
+        if table.name in TABLE_NAMES:
+            table.create(bind=bind)
+
+
+def downgrade() -> None:
+    bind = op.get_bind()
+    for table in reversed(AgentBase.metadata.sorted_tables):
+        if table.name in TABLE_NAMES:
+            table.drop(bind=bind)

+ 40 - 0
backend/migrations/analytics/env.py

@@ -0,0 +1,40 @@
+from alembic import context
+
+from app.core.config import Settings
+from app.infrastructure.mysql.analytics_models import AnalyticsBase
+from app.infrastructure.mysql.sessions import DatabaseName, build_mysql_url
+
+config = context.config
+target_metadata = AnalyticsBase.metadata
+
+
+def run_migrations_offline() -> None:
+    context.configure(
+        url=build_mysql_url(Settings(), DatabaseName.ANALYTICS),
+        target_metadata=target_metadata,
+        literal_binds=True,
+        dialect_opts={"paramstyle": "named"},
+        compare_type=True,
+    )
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def run_migrations_online() -> None:
+    from sqlalchemy import create_engine
+
+    engine = create_engine(build_mysql_url(Settings(), DatabaseName.ANALYTICS))
+    with engine.connect() as connection:
+        context.configure(
+            connection=connection,
+            target_metadata=target_metadata,
+            compare_type=True,
+        )
+        with context.begin_transaction():
+            context.run_migrations()
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    run_migrations_online()

+ 20 - 0
backend/migrations/analytics/script.py.mako

@@ -0,0 +1,20 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+"""
+from alembic import op
+import sqlalchemy as sa
+
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+    ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+    ${downgrades if downgrades else "pass"}

+ 34 - 0
backend/migrations/analytics/versions/analytics_0001_initial.py

@@ -0,0 +1,34 @@
+"""创建 Analytics 库基础表。"""
+
+from alembic import op
+
+from app.infrastructure.mysql.analytics_models import AnalyticsBase
+
+revision = "analytics_0001"
+down_revision = None
+branch_labels = None
+depends_on = None
+
+TABLE_NAMES = (
+    "dim_date",
+    "dim_product",
+    "dim_salesperson",
+    "fact_orders",
+    "fact_policies",
+    "metric_definitions",
+    "analytics_sync_offsets",
+)
+
+
+def upgrade() -> None:
+    bind = op.get_bind()
+    for table in AnalyticsBase.metadata.sorted_tables:
+        if table.name in TABLE_NAMES:
+            table.create(bind=bind)
+
+
+def downgrade() -> None:
+    bind = op.get_bind()
+    for table in reversed(AnalyticsBase.metadata.sorted_tables):
+        if table.name in TABLE_NAMES:
+            table.drop(bind=bind)

+ 41 - 0
backend/migrations/core/env.py

@@ -0,0 +1,41 @@
+from alembic import context
+
+from app.core.config import Settings
+from app.infrastructure.mysql.core_models import CoreBase
+from app.infrastructure.mysql.sessions import DatabaseName, build_mysql_url
+
+config = context.config
+target_metadata = CoreBase.metadata
+
+
+def run_migrations_offline() -> None:
+    url = build_mysql_url(Settings(), DatabaseName.CORE)
+    context.configure(
+        url=url,
+        target_metadata=target_metadata,
+        literal_binds=True,
+        dialect_opts={"paramstyle": "named"},
+        compare_type=True,
+    )
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def run_migrations_online() -> None:
+    from sqlalchemy import create_engine
+
+    engine = create_engine(build_mysql_url(Settings(), DatabaseName.CORE))
+    with engine.connect() as connection:
+        context.configure(
+            connection=connection,
+            target_metadata=target_metadata,
+            compare_type=True,
+        )
+        with context.begin_transaction():
+            context.run_migrations()
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    run_migrations_online()

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů