Forráskód Böngészése

init:项目初始化

yangxiaolong 1 hónapja
commit
4004cef25b
100 módosított fájl, 10655 hozzáadás és 0 törlés
  1. 16 0
      .gitignore
  2. 1 0
      .python-version
  3. 5 0
      backend/.idea/.gitignore
  4. 19 0
      backend/.idea/backend.iml
  5. 6 0
      backend/.idea/inspectionProfiles/profiles_settings.xml
  6. 6 0
      backend/.idea/misc.xml
  7. 8 0
      backend/.idea/modules.xml
  8. 150 0
      backend/README.md
  9. 11 0
      backend/alembic.ini
  10. 5 0
      backend/evals/customer-full.jsonl
  11. 2 0
      backend/evals/customer-smoke.jsonl
  12. 5 0
      backend/evals/operation-full.jsonl
  13. 2 0
      backend/evals/operation-smoke.jsonl
  14. 12 0
      backend/migrations/README.md
  15. 40 0
      backend/migrations/agent/env.py
  16. 20 0
      backend/migrations/agent/script.py.mako
  17. 26 0
      backend/migrations/agent/versions/agent_0001_initial.py
  18. 20 0
      backend/migrations/agent/versions/agent_0002_customer_memories.py
  19. 40 0
      backend/migrations/analytics/env.py
  20. 20 0
      backend/migrations/analytics/script.py.mako
  21. 34 0
      backend/migrations/analytics/versions/analytics_0001_initial.py
  22. 41 0
      backend/migrations/core/env.py
  23. 20 0
      backend/migrations/core/script.py.mako
  24. 41 0
      backend/migrations/core/versions/core_0001_initial.py
  25. 37 0
      backend/migrations/core/versions/core_0002_enrollment_flow.py
  26. 33 0
      backend/migrations/core/versions/core_0003_attribution.py
  27. 48 0
      backend/migrations/core/versions/core_0004_product_management.py
  28. 19 0
      backend/migrations/core/versions/core_0005_knowledge_documents.py
  29. 22 0
      backend/migrations/core/versions/core_0006_service_requests.py
  30. 20 0
      backend/migrations/core/versions/core_0007_knowledge_search_tests.py
  31. 69 0
      backend/pyproject.toml
  32. 1 0
      backend/src/zbt/__init__.py
  33. 1 0
      backend/src/zbt/api/__init__.py
  34. 23 0
      backend/src/zbt/api/responses.py
  35. 1 0
      backend/src/zbt/api/v1/__init__.py
  36. 118 0
      backend/src/zbt/api/v1/admin_auth.py
  37. 553 0
      backend/src/zbt/api/v1/admin_business.py
  38. 174 0
      backend/src/zbt/api/v1/admin_knowledge.py
  39. 237 0
      backend/src/zbt/api/v1/agent.py
  40. 207 0
      backend/src/zbt/api/v1/enrollment.py
  41. 140 0
      backend/src/zbt/api/v1/h5_auth.py
  42. 41 0
      backend/src/zbt/api/v1/h5_products.py
  43. 155 0
      backend/src/zbt/api/v1/service_requests.py
  44. 17 0
      backend/src/zbt/api/v1/system.py
  45. 1 0
      backend/src/zbt/commands/__init__.py
  46. 35 0
      backend/src/zbt/commands/build_analytics.py
  47. 203 0
      backend/src/zbt/commands/evaluate_agents.py
  48. 339 0
      backend/src/zbt/commands/inherit_stage_one.py
  49. 59 0
      backend/src/zbt/commands/init_databases.py
  50. 32 0
      backend/src/zbt/commands/reset_cache.py
  51. 525 0
      backend/src/zbt/commands/seed.py
  52. 583 0
      backend/src/zbt/commands/seed_business_data.py
  53. 117 0
      backend/src/zbt/commands/seed_knowledge.py
  54. 56 0
      backend/src/zbt/commands/verify_seed.py
  55. 202 0
      backend/src/zbt/commands/verify_stage_one.py
  56. 1 0
      backend/src/zbt/core/__init__.py
  57. 155 0
      backend/src/zbt/core/config.py
  58. 11 0
      backend/src/zbt/core/errors.py
  59. 9 0
      backend/src/zbt/core/identifiers.py
  60. 12 0
      backend/src/zbt/core/passwords.py
  61. 129 0
      backend/src/zbt/core/security.py
  62. 1 0
      backend/src/zbt/domains/__init__.py
  63. 1 0
      backend/src/zbt/domains/agent/__init__.py
  64. 234 0
      backend/src/zbt/domains/agent/memory.py
  65. 40 0
      backend/src/zbt/domains/agent/models.py
  66. 63 0
      backend/src/zbt/domains/agent/repository.py
  67. 32 0
      backend/src/zbt/domains/agent/runtime.py
  68. 605 0
      backend/src/zbt/domains/agent/service.py
  69. 949 0
      backend/src/zbt/domains/agent/tools.py
  70. 1 0
      backend/src/zbt/domains/attribution/__init__.py
  71. 46 0
      backend/src/zbt/domains/attribution/models.py
  72. 73 0
      backend/src/zbt/domains/attribution/repository.py
  73. 174 0
      backend/src/zbt/domains/attribution/service.py
  74. 1 0
      backend/src/zbt/domains/catalog/__init__.py
  75. 59 0
      backend/src/zbt/domains/catalog/models.py
  76. 56 0
      backend/src/zbt/domains/catalog/repository.py
  77. 608 0
      backend/src/zbt/domains/catalog/service.py
  78. 1 0
      backend/src/zbt/domains/enrollment/__init__.py
  79. 124 0
      backend/src/zbt/domains/enrollment/models.py
  80. 178 0
      backend/src/zbt/domains/enrollment/repository.py
  81. 557 0
      backend/src/zbt/domains/enrollment/service.py
  82. 83 0
      backend/src/zbt/domains/enrollment/tasks.py
  83. 1 0
      backend/src/zbt/domains/identity/__init__.py
  84. 49 0
      backend/src/zbt/domains/identity/models.py
  85. 115 0
      backend/src/zbt/domains/identity/repository.py
  86. 500 0
      backend/src/zbt/domains/identity/service.py
  87. 2 0
      backend/src/zbt/domains/knowledge/__init__.py
  88. 32 0
      backend/src/zbt/domains/knowledge/chunking.py
  89. 62 0
      backend/src/zbt/domains/knowledge/index.py
  90. 59 0
      backend/src/zbt/domains/knowledge/models.py
  91. 40 0
      backend/src/zbt/domains/knowledge/repository.py
  92. 149 0
      backend/src/zbt/domains/knowledge/retrieval.py
  93. 395 0
      backend/src/zbt/domains/knowledge/service.py
  94. 1 0
      backend/src/zbt/domains/service_request/__init__.py
  95. 34 0
      backend/src/zbt/domains/service_request/models.py
  96. 19 0
      backend/src/zbt/domains/service_request/preprocessing.py
  97. 40 0
      backend/src/zbt/domains/service_request/repository.py
  98. 282 0
      backend/src/zbt/domains/service_request/service.py
  99. 35 0
      backend/src/zbt/domains/service_request/storage.py
  100. 19 0
      backend/src/zbt/domains/service_request/vision.py

+ 16 - 0
.gitignore

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

+ 1 - 0
.python-version

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

+ 5 - 0
backend/.idea/.gitignore

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

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

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<module type="PYTHON_MODULE" version="4">
+  <component name="NewModuleRootManager">
+    <content url="file://$MODULE_DIR$">
+      <sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
+      <sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
+      <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>

+ 150 - 0
backend/README.md

@@ -0,0 +1,150 @@
+# 智保通后端
+
+本目录是智保通的统一后端,使用 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/
+├── src/
+│   └── zbt/               # 可安装的智保通 Python 包
+│       ├── api/           # HTTP 路由、请求参数和响应格式
+│       ├── commands/      # 初始化数据等命令行工具
+│       ├── core/          # 配置、安全、密码和公共错误
+│       ├── domains/       # 业务模型、仓储接口和业务服务
+│       ├── harness/       # Agent 内核、策略和资源
+│       ├── infrastructure/# MySQL、Redis、Milvus 等基础设施实现
+│       └── main.py        # FastAPI 应用入口
+├── migrations/            # 三个数据库的 Alembic 迁移
+├── tests/                 # 自动化测试
+├── alembic.ini
+├── pyproject.toml
+└── uv.lock
+```
+
+项目采用 `src` 布局,`uv sync` 会以可编辑模式安装 `zbt` 包;执行
+`uv build` 可以生成 wheel 和源码包。
+
+代码主要调用方向:
+
+```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 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\inherit-stage-one.ps1
+.\scripts\seed.ps1
+.\scripts\build-analytics.ps1
+.\scripts\verify-seed.ps1
+```
+
+这些脚本依次完成环境检查、创建数据库、建表、继承第一阶段持久化数据、
+写入第二阶段初始数据、构建分析数据和验证初始化结果。
+
+## 单独启动后端
+
+```powershell
+Set-Location backend
+uv run uvicorn zbt.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 src/zbt
+```
+
+也可以在项目根目录执行 `.\scripts\test.ps1`,同时检查前端和后端。
+
+## 数据库迁移
+
+项目使用三个相互独立的数据库:
+
+- `insurance_s2_core`
+- `insurance_s2_agent`
+- `insurance_s2_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。

+ 11 - 0
backend/alembic.ini

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

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

@@ -0,0 +1,5 @@
+{"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":["保单"]}
+{"id":"customer-agentic-rag-waiting-period","persona":"customer","input":"安心医疗险疾病医疗等待期是多少天?请给出条款依据。","expected_tools":["search_insurance_knowledge"],"expected_terms":["30天"],"expected_card_types":["knowledge_sources"]}
+{"id":"customer-agentic-rag-unknown","persona":"customer","input":"安心医疗险是否报销火星医院的手术?如果知识库没有明确规定,请不要推测。","expected_tools":["search_insurance_knowledge"],"expected_terms":["无法确定"]}

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

@@ -0,0 +1,2 @@
+{"id":"customer-recommend-parent","persona":"customer","input":"我想给65岁的父亲买一份成都地区的医疗保险,请推荐合适方案并测算标准计划保费。","expected_tools":["list_available_products","calculate_insurance_quote"],"expected_terms":["父亲","保费"]}
+{"id":"customer-agentic-rag-waiting-period","persona":"customer","input":"安心医疗险疾病医疗等待期是多少天?请给出条款依据。","expected_tools":["search_insurance_knowledge"],"expected_terms":["30天"],"expected_card_types":["knowledge_sources"]}

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

@@ -0,0 +1,5 @@
+{"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":["推广","订单"]}
+{"id":"operation-analytics-trend","persona":"operation","input":"请分析最近30天订单量按天变化趋势,并说明查询口径。","expected_tools":["query_operation_analytics"],"expected_terms":["订单"],"expected_card_types":["chart"]}
+{"id":"operation-analytics-product","persona":"operation","input":"对比最近90天各产品的有效保单保费,并指出最高的产品。","expected_tools":["query_operation_analytics"],"expected_terms":["保单","产品"],"expected_card_types":["chart"]}

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

@@ -0,0 +1,2 @@
+{"id":"operation-overview","persona":"operation","input":"请分析当前订单、有效保单和累计保费情况,并说明数据依据。","expected_tools":["get_operation_overview"],"expected_terms":["订单","保单"]}
+{"id":"operation-analytics-trend","persona":"operation","input":"请分析最近30天订单量按天变化趋势,并说明查询口径。","expected_tools":["query_operation_analytics"],"expected_terms":["订单"],"expected_card_types":["chart"]}

+ 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 zbt.core.config import Settings
+from zbt.infrastructure.mysql.agent_models import AgentBase
+from zbt.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 zbt.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)

+ 20 - 0
backend/migrations/agent/versions/agent_0002_customer_memories.py

@@ -0,0 +1,20 @@
+"""创建客户长期记忆表。"""
+
+from alembic import op
+
+from zbt.infrastructure.mysql.agent_models import AgentBase
+
+revision = "agent_0002"
+down_revision = "agent_0001"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    bind = op.get_bind()
+    AgentBase.metadata.tables["customer_memories"].create(bind=bind)
+
+
+def downgrade() -> None:
+    bind = op.get_bind()
+    AgentBase.metadata.tables["customer_memories"].drop(bind=bind)

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

@@ -0,0 +1,40 @@
+from alembic import context
+
+from zbt.core.config import Settings
+from zbt.infrastructure.mysql.analytics_models import AnalyticsBase
+from zbt.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 zbt.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 zbt.core.config import Settings
+from zbt.infrastructure.mysql.core_models import CoreBase
+from zbt.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()

+ 20 - 0
backend/migrations/core/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"}

+ 41 - 0
backend/migrations/core/versions/core_0001_initial.py

@@ -0,0 +1,41 @@
+"""创建 Core 库基础表。"""
+
+from alembic import op
+
+from zbt.infrastructure.mysql.core_models import CoreBase
+
+revision = "core_0001"
+down_revision = None
+branch_labels = None
+depends_on = None
+
+TABLE_NAMES = (
+    "h5_users",
+    "permissions",
+    "roles",
+    "admin_users",
+    "auth_sessions",
+    "products",
+    "product_versions",
+    "plans",
+    "eligibility_rules",
+    "rate_tables",
+    "admin_user_roles",
+    "role_permissions",
+)
+
+
+def upgrade() -> None:
+    bind = op.get_bind()
+    tables = [CoreBase.metadata.tables[name] for name in TABLE_NAMES]
+    for table in CoreBase.metadata.sorted_tables:
+        if table in tables:
+            table.create(bind=bind)
+
+
+def downgrade() -> None:
+    bind = op.get_bind()
+    tables = [CoreBase.metadata.tables[name] for name in TABLE_NAMES]
+    for table in reversed(CoreBase.metadata.sorted_tables):
+        if table in tables:
+            table.drop(bind=bind)

+ 37 - 0
backend/migrations/core/versions/core_0002_enrollment_flow.py

@@ -0,0 +1,37 @@
+"""创建智能投保交易主链表。"""
+
+from alembic import op
+
+from zbt.infrastructure.mysql.core_models import CoreBase
+
+revision = "core_0002"
+down_revision = "core_0001"
+branch_labels = None
+depends_on = None
+
+TABLE_NAMES = (
+    "quotes",
+    "enrollment_drafts",
+    "user_confirmations",
+    "enrollment_orders",
+    "payment_transactions",
+    "policies",
+    "outbox_events",
+    "async_tasks",
+)
+
+
+def upgrade() -> None:
+    bind = op.get_bind()
+    tables = [CoreBase.metadata.tables[name] for name in TABLE_NAMES]
+    for table in CoreBase.metadata.sorted_tables:
+        if table in tables:
+            table.create(bind=bind)
+
+
+def downgrade() -> None:
+    bind = op.get_bind()
+    tables = [CoreBase.metadata.tables[name] for name in TABLE_NAMES]
+    for table in reversed(CoreBase.metadata.sorted_tables):
+        if table in tables:
+            table.drop(bind=bind)

+ 33 - 0
backend/migrations/core/versions/core_0003_attribution.py

@@ -0,0 +1,33 @@
+"""创建推广员、推广码、线索与订单归因表。"""
+
+from alembic import op
+
+from zbt.infrastructure.mysql.core_models import CoreBase
+
+revision = "core_0003"
+down_revision = "core_0002"
+branch_labels = None
+depends_on = None
+
+TABLE_NAMES = (
+    "salespersons",
+    "promotion_codes",
+    "lead_attributions",
+    "order_attributions",
+)
+
+
+def upgrade() -> None:
+    bind = op.get_bind()
+    tables = [CoreBase.metadata.tables[name] for name in TABLE_NAMES]
+    for table in CoreBase.metadata.sorted_tables:
+        if table in tables:
+            table.create(bind=bind)
+
+
+def downgrade() -> None:
+    bind = op.get_bind()
+    tables = [CoreBase.metadata.tables[name] for name in TABLE_NAMES]
+    for table in reversed(CoreBase.metadata.sorted_tables):
+        if table in tables:
+            table.drop(bind=bind)

+ 48 - 0
backend/migrations/core/versions/core_0004_product_management.py

@@ -0,0 +1,48 @@
+"""补齐产品计划配置与产品操作日志。"""
+
+import sqlalchemy as sa
+from alembic import op
+
+from zbt.infrastructure.mysql.core_models import CoreBase
+
+revision = "core_0004"
+down_revision = "core_0003"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    bind = op.get_bind()
+    inspector = sa.inspect(bind)
+    existing_columns = {
+        str(column["name"]) for column in inspector.get_columns("plans")
+    }
+    columns = (
+        sa.Column(
+            "premium_cents",
+            sa.BigInteger(),
+            nullable=False,
+            server_default="0",
+        ),
+        sa.Column(
+            "coverage_amount_cents",
+            sa.BigInteger(),
+            nullable=False,
+            server_default="0",
+        ),
+        sa.Column("min_age", sa.Integer(), nullable=False, server_default="0"),
+        sa.Column("max_age", sa.Integer(), nullable=False, server_default="100"),
+    )
+    for column in columns:
+        if column.name not in existing_columns:
+            op.add_column("plans", column)
+    if not inspector.has_table("product_change_logs"):
+        CoreBase.metadata.tables["product_change_logs"].create(bind=bind)
+
+
+def downgrade() -> None:
+    CoreBase.metadata.tables["product_change_logs"].drop(bind=op.get_bind())
+    op.drop_column("plans", "max_age")
+    op.drop_column("plans", "min_age")
+    op.drop_column("plans", "coverage_amount_cents")
+    op.drop_column("plans", "premium_cents")

+ 19 - 0
backend/migrations/core/versions/core_0005_knowledge_documents.py

@@ -0,0 +1,19 @@
+"""创建第二阶段知识文档表。"""
+
+from alembic import op
+
+from zbt.infrastructure.mysql.core_models import CoreBase
+
+revision = "core_0005"
+down_revision = "core_0004"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    CoreBase.metadata.tables["knowledge_documents"].create(bind=op.get_bind())
+
+
+def downgrade() -> None:
+    CoreBase.metadata.tables["knowledge_documents"].drop(bind=op.get_bind())
+

+ 22 - 0
backend/migrations/core/versions/core_0006_service_requests.py

@@ -0,0 +1,22 @@
+"""创建第二阶段材料识别与服务申请表。"""
+
+from alembic import op
+
+from zbt.infrastructure.mysql.core_models import CoreBase
+
+revision = "core_0006"
+down_revision = "core_0005"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    bind = op.get_bind()
+    CoreBase.metadata.tables["material_recognitions"].create(bind=bind)
+    CoreBase.metadata.tables["service_requests"].create(bind=bind)
+
+
+def downgrade() -> None:
+    bind = op.get_bind()
+    CoreBase.metadata.tables["service_requests"].drop(bind=bind)
+    CoreBase.metadata.tables["material_recognitions"].drop(bind=bind)

+ 20 - 0
backend/migrations/core/versions/core_0007_knowledge_search_tests.py

@@ -0,0 +1,20 @@
+"""创建知识检索测试记录表。"""
+
+from alembic import op
+
+from zbt.infrastructure.mysql.core_models import CoreBase
+
+revision = "core_0007"
+down_revision = "core_0006"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    bind = op.get_bind()
+    CoreBase.metadata.tables["knowledge_search_tests"].create(bind=bind)
+
+
+def downgrade() -> None:
+    bind = op.get_bind()
+    CoreBase.metadata.tables["knowledge_search_tests"].drop(bind=bind)

+ 69 - 0
backend/pyproject.toml

@@ -0,0 +1,69 @@
+[project]
+name = "zhibaotong-s2-backend"
+version = "0.2.0"
+description = "智保通第二阶段知识服务与数据分析统一后端"
+requires-python = ">=3.12,<3.13"
+dependencies = [
+  "alembic>=1.16,<2",
+  "asyncmy>=0.2,<1",
+  "fastapi>=0.116,<1",
+  "httpx>=0.28,<1",
+  "pydantic>=2.11,<3",
+  "pydantic-settings>=2.10,<3",
+  "pwdlib[argon2]>=0.2,<1",
+  "pyjwt[crypto]>=2.10,<3",
+  "redis>=6,<8",
+  "sqlalchemy>=2.0,<3",
+  "ulid-py>=1.1,<2",
+  "uvicorn[standard]>=0.35,<1",
+  "pymysql>=1.1,<2",
+  "langchain>=1.0,<2",
+  "langchain-openai>=1.0,<2",
+  "langsmith>=0.4,<1",
+  "pymilvus>=2.6,<3",
+  "flagembedding>=1.3,<2",
+  "transformers>=4.44.2,<5",
+  "python-multipart>=0.0.20,<1",
+  "pypdf>=6,<7",
+  "pillow>=11,<13",
+]
+
+[build-system]
+requires = ["hatchling>=1.27,<2"]
+build-backend = "hatchling.build"
+
+[dependency-groups]
+dev = [
+  "coverage[toml]>=7.9,<8",
+  "mypy>=1.16,<2",
+  "pytest>=8.4,<10",
+  "pytest-asyncio>=1.0,<2",
+  "ruff>=0.12,<1",
+  "types-pymysql>=1.1,<2",
+]
+
+[tool.uv]
+package = true
+
+[[tool.uv.index]]
+url = "https://mirrors.aliyun.com/pypi/simple/"
+default = true
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/zbt"]
+
+[tool.pytest.ini_options]
+addopts = "-q"
+testpaths = ["tests"]
+
+[tool.ruff]
+line-length = 100
+target-version = "py312"
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "UP", "B", "SIM"]
+
+[tool.mypy]
+python_version = "3.12"
+strict = true
+plugins = ["pydantic.mypy"]

+ 1 - 0
backend/src/zbt/__init__.py

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

+ 1 - 0
backend/src/zbt/api/__init__.py

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

+ 23 - 0
backend/src/zbt/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/src/zbt/api/v1/__init__.py

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

+ 118 - 0
backend/src/zbt/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 zbt.api.responses import success_response
+from zbt.api.v1.h5_auth import RefreshRequest
+from zbt.core.config import Settings
+from zbt.core.errors import AppError
+from zbt.domains.identity.models import AdminUser
+from zbt.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/src/zbt/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 zbt.api.responses import success_response
+from zbt.core.errors import AppError
+from zbt.domains.attribution.service import AttributionService
+from zbt.domains.catalog.service import ProductCatalogService
+from zbt.domains.enrollment.service import EnrollmentService
+from zbt.domains.identity.models import AdminUser
+from zbt.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

+ 174 - 0
backend/src/zbt/api/v1/admin_knowledge.py

@@ -0,0 +1,174 @@
+"""管理端知识中心接口。
+
+接口顺序对应管理页面的发布流程:创建/上传、建立索引、检索测试、正式发布。
+权限在每个接口再次校验,不能只依赖前端隐藏按钮。
+"""
+
+from typing import Annotated, Any, Literal
+
+from fastapi import APIRouter, Depends, File, Form, Request, UploadFile
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from pydantic import BaseModel, Field
+
+from zbt.api.responses import success_response
+from zbt.core.errors import AppError
+from zbt.domains.identity.models import AdminUser
+from zbt.domains.identity.service import AdminAuthService
+from zbt.domains.knowledge.service import KnowledgeService
+from zbt.infrastructure.documents.parser import extract_document_text
+
+admin_knowledge_bearer = HTTPBearer(auto_error=False)
+
+
+class KnowledgeDocumentCreateRequest(BaseModel):
+    title: str = Field(min_length=2, max_length=200)
+    document_type: Literal["INSURANCE_TERMS", "PRODUCT_GUIDE", "SERVICE_RULES", "FAQ"]
+    source_name: str = Field(min_length=1, max_length=255)
+    product_code: str | None = Field(default=None, max_length=64)
+    content: str = Field(min_length=10, max_length=2_000_000)
+
+
+class KnowledgeSearchTestRequest(BaseModel):
+    query: str = Field(min_length=2, max_length=500)
+    limit: int = Field(default=5, ge=1, le=10)
+
+
+def create_admin_knowledge_router(
+    service: KnowledgeService,
+    auth_service: AdminAuthService,
+) -> APIRouter:
+    """创建知识中心路由,并注入知识服务与后台认证服务。"""
+
+    router = APIRouter(tags=["admin-knowledge"])
+
+    def current_admin(
+        credentials: Annotated[
+            HTTPAuthorizationCredentials | None,
+            Depends(admin_knowledge_bearer),
+        ],
+    ) -> AdminUser:
+        if credentials is None:
+            raise AppError("AUTH_REQUIRED", "请先登录后台", 401)
+        return auth_service.authenticate_access(credentials.credentials)
+
+    def require_permission(user: AdminUser, permission: str) -> None:
+        if "*" not in user.permissions and permission not in user.permissions:
+            raise AppError("PERMISSION_DENIED", "当前角色无权管理知识库", 403)
+
+    @router.get("/admin/knowledge/documents")
+    def list_documents(
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_permission(user, "knowledge:read")
+        return success_response(request, service.list_documents())
+
+    @router.post("/admin/knowledge/documents", status_code=201)
+    def create_document(
+        payload: KnowledgeDocumentCreateRequest,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_permission(user, "knowledge:write")
+        return success_response(
+            request,
+            service.create_document(
+                title=payload.title,
+                document_type=payload.document_type,
+                source_name=payload.source_name,
+                product_code=payload.product_code,
+                content=payload.content,
+                created_by=user.id,
+            ),
+        )
+
+    @router.post("/admin/knowledge/documents/upload", status_code=201)
+    async def upload_document(
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+        file: Annotated[UploadFile, File()],
+        title: Annotated[str, Form(min_length=2, max_length=200)],
+        document_type: Annotated[
+            Literal["INSURANCE_TERMS", "PRODUCT_GUIDE", "SERVICE_RULES", "FAQ"],
+            Form(),
+        ],
+        product_code: Annotated[str | None, Form(max_length=64)] = None,
+    ) -> dict[str, Any]:
+        """读取上传文件正文并创建草稿,上传动作本身不会自动发布知识。"""
+
+        require_permission(user, "knowledge:write")
+        source_name = file.filename or "uploaded-document"
+        content = extract_document_text(source_name, await file.read())
+        return success_response(
+            request,
+            service.create_document(
+                title=title,
+                document_type=document_type,
+                source_name=source_name,
+                product_code=product_code,
+                content=content,
+                created_by=user.id,
+            ),
+        )
+
+    @router.post("/admin/knowledge/documents/{document_id}/index")
+    def index_document(
+        document_id: str,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        """触发切片、向量化和 Milvus 索引替换。"""
+
+        require_permission(user, "knowledge:write")
+        return success_response(request, service.index_document(document_id))
+
+    @router.post("/admin/knowledge/documents/{document_id}/publish")
+    def publish_document(
+        document_id: str,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        """通过发布门禁后,将知识开放给客户 Agent 检索。"""
+
+        require_permission(user, "knowledge:publish")
+        return success_response(request, service.publish_document(document_id))
+
+    @router.post("/admin/knowledge/documents/{document_id}/search-test")
+    def test_document_search(
+        document_id: str,
+        payload: KnowledgeSearchTestRequest,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        """执行并保存检索测试;有效记录是发布的必要条件。"""
+
+        require_permission(user, "knowledge:write")
+        return success_response(
+            request,
+            service.test_search(
+                document_id,
+                payload.query,
+                limit=payload.limit,
+                tested_by=user.id,
+            ),
+        )
+
+    @router.get("/admin/knowledge/documents/{document_id}/search-tests")
+    def list_document_search_tests(
+        document_id: str,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_permission(user, "knowledge:read")
+        return success_response(request, service.list_search_tests(document_id))
+
+    @router.post("/admin/knowledge/documents/{document_id}/disable")
+    def disable_document(
+        document_id: str,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_permission(user, "knowledge:publish")
+        return success_response(request, service.disable_document(document_id))
+
+    return router

+ 237 - 0
backend/src/zbt/api/v1/agent.py

@@ -0,0 +1,237 @@
+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 zbt.api.responses import success_response
+from zbt.core.errors import AppError
+from zbt.domains.agent.service import AgentThreadService
+from zbt.domains.identity.models import AdminUser, H5User
+from zbt.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.get("/agent/threads")
+    def list_customer_threads(
+        request: Request,
+        user: Annotated[H5User, Depends(current_h5_user)],
+        limit: int = 30,
+    ) -> dict[str, Any]:
+        return success_response(
+            request,
+            thread_service.list_customer_threads(
+                user,
+                limit=min(max(limit, 1), 100),
+            ),
+        )
+
+    @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.get("/admin/agent/threads")
+    def list_operation_threads(
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+        limit: int = 30,
+    ) -> dict[str, Any]:
+        return success_response(
+            request,
+            thread_service.list_operation_threads(
+                user,
+                limit=min(max(limit, 1), 100),
+            ),
+        )
+
+    @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/src/zbt/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 zbt.api.responses import success_response
+from zbt.core.config import Settings
+from zbt.core.errors import AppError
+from zbt.domains.enrollment.service import EnrollmentService
+from zbt.domains.identity.models import H5User
+from zbt.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/src/zbt/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 zbt.api.responses import success_response
+from zbt.core.config import Settings
+from zbt.core.errors import AppError
+from zbt.domains.attribution.service import AttributionService
+from zbt.domains.identity.models import H5User
+from zbt.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/src/zbt/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 zbt.api.responses import success_response
+from zbt.api.v1.h5_auth import bearer_scheme
+from zbt.core.errors import AppError
+from zbt.domains.catalog.service import ProductCatalogService
+from zbt.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

+ 155 - 0
backend/src/zbt/api/v1/service_requests.py

@@ -0,0 +1,155 @@
+from typing import Annotated, Any, Literal
+from urllib.parse import quote
+
+from fastapi import APIRouter, Depends, File, Form, Request, UploadFile, status
+from fastapi.responses import Response
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from pydantic import BaseModel, Field
+
+from zbt.api.responses import success_response
+from zbt.core.errors import AppError
+from zbt.domains.identity.models import AdminUser, H5User
+from zbt.domains.identity.service import AdminAuthService, H5AuthService
+from zbt.domains.service_request.service import ServiceRequestService
+
+service_bearer = HTTPBearer(auto_error=False)
+
+
+class SubmitServiceRequest(BaseModel):
+    policy_id: str = Field(min_length=26, max_length=26)
+    request_type: Literal["MEDICAL_REIMBURSEMENT_ASSISTANCE"]
+    recognition_id: str = Field(min_length=26, max_length=26)
+    confirmed_fields: dict[str, str]
+
+
+class ReviewServiceRequest(BaseModel):
+    decision: Literal["ACCEPTED", "REJECTED"]
+
+
+def create_service_request_router(
+    service: ServiceRequestService,
+    h5_auth_service: H5AuthService,
+    admin_auth_service: AdminAuthService,
+) -> APIRouter:
+    router = APIRouter(tags=["service-requests"])
+
+    def current_h5_user(
+        credentials: Annotated[
+            HTTPAuthorizationCredentials | None,
+            Depends(service_bearer),
+        ],
+    ) -> 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(service_bearer),
+        ],
+    ) -> AdminUser:
+        if credentials is None:
+            raise AppError("AUTH_REQUIRED", "请先登录后台", 401)
+        return admin_auth_service.authenticate_access(credentials.credentials)
+
+    def require_permission(user: AdminUser, permission: str) -> None:
+        if "*" not in user.permissions and permission not in user.permissions:
+            raise AppError("PERMISSION_DENIED", "当前角色无权处理服务申请", 403)
+
+    @router.post(
+        "/h5/service-materials/recognize",
+        status_code=status.HTTP_201_CREATED,
+    )
+    async def recognize_material(
+        request: Request,
+        user: Annotated[H5User, Depends(current_h5_user)],
+        file: Annotated[UploadFile, File()],
+        policy_id: Annotated[str, Form(min_length=26, max_length=26)],
+        material_type: Annotated[
+            Literal["MEDICAL_INVOICE", "EXPENSE_LIST", "DIAGNOSIS_CERTIFICATE"],
+            Form(),
+        ],
+    ) -> dict[str, Any]:
+        content = await file.read()
+        if len(content) > 10 * 1024 * 1024:
+            raise AppError("MATERIAL_TOO_LARGE", "单张材料不能超过10MB", 413)
+        return success_response(
+            request,
+            service.recognize_material(
+                user,
+                policy_id=policy_id,
+                material_type=material_type,
+                source_name=file.filename or "service-material",
+                media_type=file.content_type or "application/octet-stream",
+                content=content,
+            ),
+        )
+
+    @router.post("/h5/service-requests", status_code=status.HTTP_201_CREATED)
+    def submit_request(
+        payload: SubmitServiceRequest,
+        request: Request,
+        user: Annotated[H5User, Depends(current_h5_user)],
+    ) -> dict[str, Any]:
+        return success_response(
+            request,
+            service.submit_request(
+                user,
+                policy_id=payload.policy_id,
+                request_type=payload.request_type,
+                recognition_id=payload.recognition_id,
+                confirmed_fields=payload.confirmed_fields,
+            ),
+        )
+
+    @router.get("/h5/service-requests")
+    def list_user_requests(
+        request: Request,
+        user: Annotated[H5User, Depends(current_h5_user)],
+    ) -> dict[str, Any]:
+        return success_response(request, service.list_user_requests(user))
+
+    @router.get("/admin/service-requests")
+    def list_admin_requests(
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_permission(user, "service:read")
+        return success_response(request, service.list_all_requests())
+
+    @router.get("/admin/service-requests/{request_id}/material")
+    def get_admin_request_material(
+        request_id: str,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> Response:
+        require_permission(user, "service:read")
+        material, media_type, source_name = service.get_request_material(request_id)
+        encoded_name = quote(source_name, safe="")
+        return Response(
+            content=material.content,
+            media_type=media_type,
+            headers={
+                "Content-Disposition": f"inline; filename*=UTF-8''{encoded_name}",
+                "Cache-Control": "private, max-age=300",
+            },
+        )
+
+    @router.post("/admin/service-requests/{request_id}/review")
+    def review_request(
+        request_id: str,
+        payload: ReviewServiceRequest,
+        request: Request,
+        user: Annotated[AdminUser, Depends(current_admin)],
+    ) -> dict[str, Any]:
+        require_permission(user, "service:review")
+        return success_response(
+            request,
+            service.review_request(
+                request_id,
+                decision=payload.decision,
+                reviewer_id=user.id,
+            ),
+        )
+
+    return router

+ 17 - 0
backend/src/zbt/api/v1/system.py

@@ -0,0 +1,17 @@
+from fastapi import APIRouter, Request
+
+from zbt.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": "s2",
+        },
+    )

+ 1 - 0
backend/src/zbt/commands/__init__.py

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

+ 35 - 0
backend/src/zbt/commands/build_analytics.py

@@ -0,0 +1,35 @@
+from datetime import UTC, datetime
+
+from zbt.core.config import Settings
+from zbt.infrastructure.mysql.analytics_queries import SqlAlchemyOperationalAnalytics
+from zbt.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()
+    core_engine = create_mysql_engine(resolved_settings, DatabaseName.CORE)
+    analytics_engine = create_mysql_engine(
+        resolved_settings,
+        DatabaseName.ANALYTICS,
+    )
+    service = SqlAlchemyOperationalAnalytics(
+        create_session_factory(core_engine),
+        create_session_factory(analytics_engine),
+        lambda: datetime.now(UTC),
+    )
+    result = service.refresh()
+    core_engine.dispose()
+    analytics_engine.dispose()
+    print(
+        "Analytics 已同步:"
+        f"产品{result['products']}、业务员{result['salespeople']}、"
+        f"订单{result['orders']}、保单{result['policies']}。"
+    )
+
+
+if __name__ == "__main__":
+    build_analytics_foundation()

+ 203 - 0
backend/src/zbt/commands/evaluate_agents.py

@@ -0,0 +1,203 @@
+"""使用真实 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 zbt.core.config import Settings
+
+BACKEND_ROOT = Path.cwd()
+DEFAULT_EVALUATION_H5_MOBILE = "18899999999"
+
+
+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,
+    h5_mobile: str,
+) -> str:
+    if persona == "customer":
+        response = client.post(
+            "/h5/auth/login",
+            json={"mobile": h5_mobile, "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,
+    h5_mobile: str,
+) -> dict[str, Any]:
+    persona = str(case["persona"])
+    token = login(client, persona, admin_password, h5_mobile)
+    prefix = "" if persona == "customer" else "/admin"
+    headers = {"Authorization": f"Bearer {token}"}
+    thread_response = client.post(
+        f"{prefix}/agent/threads",
+        headers=headers,
+        json={"title": f"__EVAL__:{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"])
+    cards = list(data["assistant_message"].get("cards", []))
+    tools = set(data.get("invoked_tools", []))
+    expected_tools = set(case["expected_tools"])
+    expected_terms = [str(term) for term in case["expected_terms"]]
+    expected_card_types = set(case.get("expected_card_types", []))
+    actual_card_types = {
+        str(card.get("type"))
+        for card in cards
+        if isinstance(card, dict) and card.get("type")
+    }
+    scores = {
+        "response_nonempty": bool(text.strip()),
+        "tool_coverage": expected_tools.issubset(tools),
+        "term_coverage": all(term in text for term in expected_terms),
+        "card_coverage": expected_card_types.issubset(actual_card_types),
+        "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"智保通-S2-{suite}"
+    if not client.has_dataset(dataset_name=dataset_name):
+        client.create_dataset(
+            dataset_name,
+            description="智保通第二阶段Agentic RAG、运营分析和业务工具真实模型评测集",
+            metadata={"project": "智保通", "stage": 2, "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"智保通 S2 {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@")
+    parser.add_argument(
+        "--h5-mobile",
+        default=DEFAULT_EVALUATION_H5_MOBILE,
+        help="客户人格评测专用手机号,必须与普通体验账号隔离",
+    )
+    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,
+                arguments.h5_mobile,
+            )
+            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())

+ 339 - 0
backend/src/zbt/commands/inherit_stage_one.py

@@ -0,0 +1,339 @@
+"""将第一阶段持久化数据幂等继承到第二阶段数据库。"""
+
+from dataclasses import dataclass
+
+import pymysql
+from pymysql.cursors import Cursor
+
+from zbt.commands.build_analytics import build_analytics_foundation
+from zbt.core.config import Settings
+
+DATABASE_PAIRS = (
+    ("insurance_s1_core", "insurance_s2_core"),
+    ("insurance_s1_agent", "insurance_s2_agent"),
+    ("insurance_s1_analytics", "insurance_s2_analytics"),
+)
+
+EXCLUDED_TABLES = {
+    "alembic_version",
+    "async_tasks",
+    "auth_sessions",
+    "outbox_events",
+    "analytics_sync_offsets",
+}
+
+SPECIAL_RELATION_TABLES = {
+    "admin_user_roles",
+    "role_permissions",
+}
+
+
+@dataclass(frozen=True, slots=True)
+class TableCopyResult:
+    source_database: str
+    target_database: str
+    table_name: str
+    source_rows: int
+    target_rows_before: int
+    target_rows_after: int
+
+
+def quote_identifier(identifier: str) -> str:
+    if not identifier.replace("_", "").isalnum():
+        raise ValueError(f"不安全的数据库标识符:{identifier}")
+    return f"`{identifier}`"
+
+
+def build_upsert_sql(
+    source_database: str,
+    target_database: str,
+    table_name: str,
+    columns: list[str],
+    primary_keys: list[str],
+) -> str:
+    if not columns:
+        raise ValueError(f"表 {table_name} 没有可复制字段。")
+    if not primary_keys:
+        raise ValueError(f"表 {table_name} 没有主键,无法执行幂等继承。")
+
+    quoted_columns = ", ".join(quote_identifier(column) for column in columns)
+    update_columns = [column for column in columns if column not in primary_keys]
+    if update_columns:
+        update_clause = ", ".join(
+            f"{quote_identifier(column)} = VALUES({quote_identifier(column)})"
+            for column in update_columns
+        )
+    else:
+        first_key = quote_identifier(primary_keys[0])
+        update_clause = f"{first_key} = VALUES({first_key})"
+
+    return (
+        f"INSERT INTO {quote_identifier(target_database)}.{quote_identifier(table_name)} "
+        f"({quoted_columns}) "
+        f"SELECT {quoted_columns} "
+        f"FROM {quote_identifier(source_database)}.{quote_identifier(table_name)} "
+        f"ON DUPLICATE KEY UPDATE {update_clause}"
+    )
+
+
+def _table_names(cursor: Cursor, database: str) -> set[str]:
+    cursor.execute(
+        """
+        SELECT table_name
+        FROM information_schema.tables
+        WHERE table_schema = %s
+          AND table_type = 'BASE TABLE'
+        """,
+        (database,),
+    )
+    return {str(row[0]) for row in cursor.fetchall()}
+
+
+def _column_names(cursor: Cursor, database: str, table_name: str) -> list[str]:
+    cursor.execute(
+        """
+        SELECT column_name
+        FROM information_schema.columns
+        WHERE table_schema = %s
+          AND table_name = %s
+        ORDER BY ordinal_position
+        """,
+        (database, table_name),
+    )
+    return [str(row[0]) for row in cursor.fetchall()]
+
+
+def _primary_keys(cursor: Cursor, database: str, table_name: str) -> list[str]:
+    cursor.execute(
+        """
+        SELECT column_name
+        FROM information_schema.key_column_usage
+        WHERE table_schema = %s
+          AND table_name = %s
+          AND constraint_name = 'PRIMARY'
+        ORDER BY ordinal_position
+        """,
+        (database, table_name),
+    )
+    return [str(row[0]) for row in cursor.fetchall()]
+
+
+def _row_count(cursor: Cursor, database: str, table_name: str) -> int:
+    cursor.execute(
+        f"SELECT COUNT(*) FROM {quote_identifier(database)}.{quote_identifier(table_name)}"
+    )
+    row = cursor.fetchone()
+    return int(row[0]) if row is not None else 0
+
+
+def _copy_identity_relations(cursor: Cursor) -> list[TableCopyResult]:
+    results: list[TableCopyResult] = []
+
+    admin_roles_before = _row_count(
+        cursor,
+        "insurance_s2_core",
+        "admin_user_roles",
+    )
+    cursor.execute(
+        """
+        DELETE relation
+        FROM insurance_s2_core.admin_user_roles AS relation
+        LEFT JOIN insurance_s2_core.admin_users AS admin_user
+          ON admin_user.id = relation.admin_user_id
+        LEFT JOIN insurance_s2_core.roles AS role
+          ON role.id = relation.role_id
+        WHERE admin_user.id IS NULL OR role.id IS NULL
+        """
+    )
+    cursor.execute(
+        """
+        INSERT INTO insurance_s2_core.admin_user_roles (admin_user_id, role_id)
+        SELECT target_user.id, target_role.id
+        FROM insurance_s1_core.admin_user_roles AS source_relation
+        JOIN insurance_s1_core.admin_users AS source_user
+          ON source_user.id = source_relation.admin_user_id
+        JOIN insurance_s2_core.admin_users AS target_user
+          ON target_user.username = source_user.username
+        JOIN insurance_s1_core.roles AS source_role
+          ON source_role.id = source_relation.role_id
+        JOIN insurance_s2_core.roles AS target_role
+          ON target_role.code = source_role.code
+        ON DUPLICATE KEY UPDATE
+          admin_user_id = VALUES(admin_user_id)
+        """
+    )
+    admin_roles_after = _row_count(
+        cursor,
+        "insurance_s2_core",
+        "admin_user_roles",
+    )
+    results.append(
+        TableCopyResult(
+            source_database="insurance_s1_core",
+            target_database="insurance_s2_core",
+            table_name="admin_user_roles",
+            source_rows=_row_count(
+                cursor,
+                "insurance_s1_core",
+                "admin_user_roles",
+            ),
+            target_rows_before=admin_roles_before,
+            target_rows_after=admin_roles_after,
+        )
+    )
+
+    role_permissions_before = _row_count(
+        cursor,
+        "insurance_s2_core",
+        "role_permissions",
+    )
+    cursor.execute(
+        """
+        DELETE relation
+        FROM insurance_s2_core.role_permissions AS relation
+        LEFT JOIN insurance_s2_core.roles AS role
+          ON role.id = relation.role_id
+        LEFT JOIN insurance_s2_core.permissions AS permission
+          ON permission.id = relation.permission_id
+        WHERE role.id IS NULL OR permission.id IS NULL
+        """
+    )
+    cursor.execute(
+        """
+        INSERT INTO insurance_s2_core.role_permissions (role_id, permission_id)
+        SELECT target_role.id, target_permission.id
+        FROM insurance_s1_core.role_permissions AS source_relation
+        JOIN insurance_s1_core.roles AS source_role
+          ON source_role.id = source_relation.role_id
+        JOIN insurance_s2_core.roles AS target_role
+          ON target_role.code = source_role.code
+        JOIN insurance_s1_core.permissions AS source_permission
+          ON source_permission.id = source_relation.permission_id
+        JOIN insurance_s2_core.permissions AS target_permission
+          ON target_permission.code = source_permission.code
+        ON DUPLICATE KEY UPDATE
+          role_id = VALUES(role_id)
+        """
+    )
+    role_permissions_after = _row_count(
+        cursor,
+        "insurance_s2_core",
+        "role_permissions",
+    )
+    results.append(
+        TableCopyResult(
+            source_database="insurance_s1_core",
+            target_database="insurance_s2_core",
+            table_name="role_permissions",
+            source_rows=_row_count(
+                cursor,
+                "insurance_s1_core",
+                "role_permissions",
+            ),
+            target_rows_before=role_permissions_before,
+            target_rows_after=role_permissions_after,
+        )
+    )
+    return results
+
+
+def inherit_stage_one_data(settings: Settings | None = None) -> list[TableCopyResult]:
+    resolved = settings or Settings()
+    connection = pymysql.connect(
+        host=resolved.mysql_host,
+        port=resolved.mysql_port,
+        user=resolved.mysql_user,
+        password=resolved.mysql_password,
+        database=resolved.mysql_core_database,
+        charset="utf8mb4",
+        autocommit=False,
+    )
+    results: list[TableCopyResult] = []
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute("SET FOREIGN_KEY_CHECKS = 0")
+            for source_database, target_database in DATABASE_PAIRS:
+                source_tables = _table_names(cursor, source_database)
+                target_tables = _table_names(cursor, target_database)
+                copy_tables = sorted(
+                    (source_tables & target_tables)
+                    - EXCLUDED_TABLES
+                    - SPECIAL_RELATION_TABLES
+                )
+                for table_name in copy_tables:
+                    source_columns = _column_names(
+                        cursor,
+                        source_database,
+                        table_name,
+                    )
+                    target_columns = set(
+                        _column_names(cursor, target_database, table_name)
+                    )
+                    common_columns = [
+                        column for column in source_columns if column in target_columns
+                    ]
+                    primary_keys = _primary_keys(
+                        cursor,
+                        target_database,
+                        table_name,
+                    )
+                    source_rows = _row_count(cursor, source_database, table_name)
+                    target_rows_before = _row_count(
+                        cursor,
+                        target_database,
+                        table_name,
+                    )
+                    cursor.execute(
+                        build_upsert_sql(
+                            source_database,
+                            target_database,
+                            table_name,
+                            common_columns,
+                            primary_keys,
+                        )
+                    )
+                    target_rows_after = _row_count(
+                        cursor,
+                        target_database,
+                        table_name,
+                    )
+                    results.append(
+                        TableCopyResult(
+                            source_database=source_database,
+                            target_database=target_database,
+                            table_name=table_name,
+                            source_rows=source_rows,
+                            target_rows_before=target_rows_before,
+                            target_rows_after=target_rows_after,
+                        )
+                    )
+            results.extend(_copy_identity_relations(cursor))
+        connection.commit()
+    except Exception:
+        connection.rollback()
+        raise
+    finally:
+        try:
+            with connection.cursor() as cursor:
+                cursor.execute("SET FOREIGN_KEY_CHECKS = 1")
+            connection.commit()
+        finally:
+            connection.close()
+
+    build_analytics_foundation(resolved)
+    return results
+
+
+def main() -> None:
+    results = inherit_stage_one_data()
+    print("第一阶段数据已继承到第二阶段:")
+    for result in results:
+        print(
+            f"- {result.target_database}.{result.table_name}: "
+            f"{result.target_rows_before} -> {result.target_rows_after} "
+            f"(第一阶段 {result.source_rows})"
+        )
+
+
+if __name__ == "__main__":
+    main()

+ 59 - 0
backend/src/zbt/commands/init_databases.py

@@ -0,0 +1,59 @@
+import argparse
+import getpass
+import re
+
+import pymysql
+
+from zbt.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/src/zbt/commands/reset_cache.py

@@ -0,0 +1,32 @@
+from typing import Any, cast
+
+from redis import Redis
+
+from zbt.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。")

+ 525 - 0
backend/src/zbt/commands/seed.py

@@ -0,0 +1,525 @@
+from dataclasses import dataclass
+from datetime import UTC, datetime, timedelta
+from hashlib import sha256
+
+from zbt.core.config import Settings
+from zbt.core.passwords import PasswordService
+from zbt.domains.attribution.models import PromotionCode, Salesperson
+from zbt.domains.catalog.models import Plan, Product, ProductChangeLog, ProductVersion
+from zbt.domains.identity.models import AdminUser
+from zbt.infrastructure.mysql.attribution_repositories import (
+    SqlAlchemyAttributionRepository,
+)
+from zbt.infrastructure.mysql.repositories import (
+    SqlAlchemyCatalogRepository,
+    SqlAlchemyIdentityRepository,
+)
+from zbt.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",
+                "knowledge:read",
+                "knowledge:write",
+                "knowledge:publish",
+                "service:read",
+                "service:review",
+            ),
+            password_hash,
+        ),
+        _admin(
+            "reviewer01",
+            "审核人员",
+            "REVIEWER",
+            "READ_ONLY",
+            (
+                "order:read",
+                "policy:read",
+                "knowledge:read",
+                "service:read",
+                "service:review",
+            ),
+            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/src/zbt/commands/seed_business_data.py

@@ -0,0 +1,583 @@
+from dataclasses import dataclass
+from datetime import UTC, datetime, timedelta
+from hashlib import sha256
+
+from zbt.commands.seed import build_seed_manifest, stable_id
+from zbt.core.config import Settings
+from zbt.domains.attribution.models import LeadAttribution, OrderAttribution
+from zbt.domains.enrollment.models import (
+    EnrollmentDraft,
+    EnrollmentOrder,
+    PaymentTransaction,
+    Policy,
+    Quote,
+    UserConfirmation,
+)
+from zbt.domains.identity.models import H5User
+from zbt.infrastructure.mysql.attribution_repositories import (
+    SqlAlchemyAttributionRepository,
+)
+from zbt.infrastructure.mysql.enrollment_repositories import (
+    SqlAlchemyEnrollmentRepository,
+)
+from zbt.infrastructure.mysql.repositories import SqlAlchemyIdentityRepository
+from zbt.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']} 笔推广归因订单。"
+    )

+ 117 - 0
backend/src/zbt/commands/seed_knowledge.py

@@ -0,0 +1,117 @@
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import TypedDict
+
+from zbt.core.config import get_settings
+from zbt.domains.knowledge.repository import KnowledgeRepository
+from zbt.domains.knowledge.service import KnowledgeService
+from zbt.infrastructure.milvus.knowledge_index import MilvusKnowledgeIndex
+from zbt.infrastructure.mysql.knowledge_repositories import SqlAlchemyKnowledgeRepository
+from zbt.infrastructure.mysql.sessions import (
+    DatabaseName,
+    create_mysql_engine,
+    create_session_factory,
+)
+
+
+def resolve_seed_root() -> Path:
+    """从项目根目录或 backend 工作目录定位知识库种子文件。"""
+
+    candidates = (
+        Path.cwd() / "knowledge" / "seed",
+        Path.cwd().parent / "knowledge" / "seed",
+    )
+    for candidate in candidates:
+        if candidate.is_dir():
+            return candidate
+    raise FileNotFoundError("未找到 knowledge/seed,请从项目根目录或 backend 目录执行命令。")
+
+
+class SeedDocumentConfig(TypedDict):
+    file_name: str
+    title: str
+    document_type: str
+    product_code: str | None
+
+
+SEED_ROOT = resolve_seed_root()
+SEED_DOCUMENTS: tuple[SeedDocumentConfig, ...] = (
+    {
+        "file_name": "安心医疗险保险条款.md",
+        "title": "安心医疗险保险条款",
+        "document_type": "INSURANCE_TERMS",
+        "product_code": "MED-BASIC",
+    },
+    {
+        "file_name": "医疗费用报销协助服务规则.md",
+        "title": "医疗费用报销协助服务规则",
+        "document_type": "SERVICE_RULES",
+        "product_code": None,
+    },
+    {
+        "file_name": "家庭意外保障常见问题.md",
+        "title": "家庭意外保障常见问题",
+        "document_type": "FAQ",
+        "product_code": "ACC-FAMILY",
+    },
+)
+
+
+def seed_documents(
+    service: KnowledgeService,
+    repository: KnowledgeRepository,
+    seed_root: Path,
+    documents: tuple[SeedDocumentConfig, ...] = SEED_DOCUMENTS,
+) -> list[str]:
+    """重建种子知识索引,兼容从 SQL 恢复出的已发布文档。"""
+    existing_by_source = {
+        document.source_name: document for document in repository.list_documents()
+    }
+    ready_sources: list[str] = []
+    for config in documents:
+        source_name = config["file_name"]
+        document = existing_by_source.get(source_name)
+        if document is None:
+            created = service.create_document(
+                title=config["title"],
+                document_type=config["document_type"],
+                source_name=source_name,
+                product_code=(
+                    config["product_code"] if config["product_code"] is not None else None
+                ),
+                content=(seed_root / source_name).read_text(encoding="utf-8"),
+                created_by="SYSTEM_SEED",
+            )
+            document_id = str(created["document_id"])
+        else:
+            document_id = document.id
+        service.index_document(document_id)
+        service.test_search(
+            document_id,
+            config["title"],
+            tested_by="SYSTEM_SEED",
+        )
+        service.publish_document(document_id)
+        ready_sources.append(source_name)
+    return ready_sources
+
+
+def main() -> None:
+    settings = get_settings()
+    engine = create_mysql_engine(settings, DatabaseName.CORE)
+    repository = SqlAlchemyKnowledgeRepository(create_session_factory(engine))
+    service = KnowledgeService(
+        repository,
+        lambda: datetime.now(UTC),
+        MilvusKnowledgeIndex(settings),
+    )
+    for source_name in seed_documents(
+        service,
+        repository,
+        SEED_ROOT,
+    ):
+        print(f"knowledge ready: {source_name}")
+
+
+if __name__ == "__main__":
+    main()

+ 56 - 0
backend/src/zbt/commands/verify_seed.py

@@ -0,0 +1,56 @@
+from sqlalchemy import func, select
+
+from zbt.core.config import Settings
+from zbt.infrastructure.mysql.core_models import (
+    AdminUserRecord,
+    PlanRecord,
+    ProductRecord,
+    ProductVersionRecord,
+)
+from zbt.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/src/zbt/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 zbt.core.config import Settings
+from zbt.infrastructure.mysql.core_models import AsyncTaskRecord
+from zbt.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/src/zbt/core/__init__.py

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

+ 155 - 0
backend/src/zbt/core/config.py

@@ -0,0 +1,155 @@
+"""集中读取和校验项目配置。
+
+配置优先从后端目录的 ``.env`` 读取;若某项未配置,则使用字段右侧的默认值。
+Pydantic 会自动把环境变量中的字符串转换成声明的 Python 类型。
+"""
+
+from functools import lru_cache
+from pathlib import Path
+from typing import Literal
+
+from pydantic import AliasChoices, Field, field_validator
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+def resolve_env_file(start_directory: Path | None = None) -> Path:
+    """兼容从 backend、项目根目录和可编辑安装环境启动。"""
+
+    start = (start_directory or Path.cwd()).resolve()
+    editable_backend = Path(__file__).resolve().parents[3]
+    candidates = (
+        start / ".env",
+        start / "backend" / ".env",
+        editable_backend / ".env",
+    )
+    for candidate in candidates:
+        if candidate.is_file():
+            return candidate
+    return start / ".env"
+
+
+ENV_FILE = resolve_env_file()
+BACKEND_ROOT = ENV_FILE.parent
+
+
+class Settings(BaseSettings):
+    """应用运行时需要的全部配置及其默认值。"""
+
+    model_config = SettingsConfigDict(
+        env_file=ENV_FILE,
+        env_file_encoding="utf-8",
+        case_sensitive=False,
+        extra="ignore",
+        populate_by_name=True,
+    )
+
+    # 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_s2"
+    mysql_password: str = ""
+    mysql_core_database: str = "insurance_s2_core"
+    mysql_agent_database: str = "insurance_s2_agent"
+    mysql_analytics_database: str = "insurance_s2_analytics"
+
+    redis_url: str = "redis://127.0.0.1:6379/0"
+    redis_prefix: str = "ins:s2:"
+    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)
+
+    milvus_uri: str = "http://127.0.0.1:19530"
+    milvus_token: str = ""
+    milvus_collection_prefix: str = "ins_s2_"
+    bge_m3_model_path: str = "BAAI/bge-m3"
+    bge_m3_device: str = "cpu"
+    bge_reranker_model_path: str = "BAAI/bge-reranker-v2-m3"
+    bge_reranker_device: str = "cpu"
+    embedding_dimension: int = Field(default=1024, ge=128, le=4096)
+    knowledge_candidate_multiplier: int = Field(default=4, ge=2, le=20)
+
+    deepseek_api_key: str = ""
+    deepseek_base_url: str = "https://api.deepseek.com"
+    deepseek_model: str = "deepseek-chat"
+    dashscope_api_key: str = Field(
+        default="",
+        validation_alias=AliasChoices("DASHSCOPE_API_KEY", "QWEN_API_KEY"),
+    )
+    dashscope_base_url: str = Field(
+        default="https://dashscope.aliyuncs.com/compatible-mode/v1",
+        validation_alias=AliasChoices("DASHSCOPE_BASE_URL", "QWEN_BASE_URL"),
+    )
+    dashscope_vision_model: str = Field(
+        default="qwen3.5-plus",
+        validation_alias=AliasChoices("DASHSCOPE_VISION_MODEL", "QWEN_VISION_MODEL"),
+    )
+    service_material_storage_path: Path = BACKEND_ROOT / "var" / "service-materials"
+    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_s2_core", "insurance_s2_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_s2_agent", "insurance_s2_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_s2_analytics", "insurance_s2_test_analytics"}:
+            raise ValueError("第二阶段Analytics数据库名称不在白名单")
+        return value
+
+    @field_validator("redis_prefix")
+    @classmethod
+    def validate_redis_prefix(cls, value: str) -> str:
+        if value != "ins:s2:" and not value.startswith("ins:s2:test:"):
+            raise ValueError("第二阶段Redis前缀不在白名单")
+        return value
+
+    @field_validator("milvus_collection_prefix")
+    @classmethod
+    def validate_milvus_prefix(cls, value: str) -> str:
+        if not value.startswith("ins_s2_"):
+            raise ValueError("第二阶段Milvus集合前缀不在白名单")
+        return value
+
+
+@lru_cache
+def get_settings() -> Settings:
+    """创建并缓存配置对象,后续调用复用同一个实例。"""
+    return Settings()

+ 11 - 0
backend/src/zbt/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/src/zbt/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/src/zbt/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/src/zbt/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 zbt.core.config import Settings
+from zbt.core.errors import AppError
+from zbt.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-s2",
+            "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-s2",
+            )
+        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/src/zbt/domains/__init__.py

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

+ 1 - 0
backend/src/zbt/domains/agent/__init__.py

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

+ 234 - 0
backend/src/zbt/domains/agent/memory.py

@@ -0,0 +1,234 @@
+"""客户长期记忆。
+
+长期记忆与普通聊天记录不同:聊天记录属于某次会话,长期记忆可以在新会话中
+被 Agent 召回。只有用户明确要求“请记住”时才会写入,并在写入前阻止手机号、
+身份证号和银行卡号等敏感标识进入记忆库。
+"""
+
+import re
+from collections.abc import Callable
+from dataclasses import dataclass, replace
+from datetime import datetime
+from typing import Protocol
+
+from zbt.core.errors import AppError
+from zbt.core.identifiers import new_ulid
+
+
+@dataclass(frozen=True, slots=True)
+class CustomerMemory:
+    """一条归属于特定 H5 用户的长期记忆。"""
+
+    id: str
+    owner_id: str
+    category: str
+    content: str
+    status: str
+    created_at: datetime
+    updated_at: datetime
+
+
+@dataclass(frozen=True, slots=True)
+class CustomerMemoryHit:
+    memory_id: str
+    score: float
+
+
+class CustomerMemoryRepository(Protocol):
+    """长期记忆正文的持久化接口,正式环境由 MySQL 实现。"""
+
+    def save(self, memory: CustomerMemory) -> None: ...
+
+    def get(self, memory_id: str) -> CustomerMemory | None: ...
+
+    def list_active(self, owner_id: str) -> list[CustomerMemory]: ...
+
+
+class CustomerMemoryIndex(Protocol):
+    """长期记忆的语义索引接口,正式环境由 Milvus 实现。"""
+
+    def upsert(self, memory: CustomerMemory) -> None: ...
+
+    def search(
+        self,
+        *,
+        owner_id: str,
+        query: str,
+        limit: int,
+    ) -> list[CustomerMemoryHit]: ...
+
+    def delete(self, memory_id: str) -> None: ...
+
+
+class InMemoryCustomerMemoryRepository:
+    """供单元测试使用的内存仓储,不用于跨进程保存数据。"""
+
+    def __init__(self) -> None:
+        self._items: dict[str, CustomerMemory] = {}
+
+    def save(self, memory: CustomerMemory) -> None:
+        self._items[memory.id] = memory
+
+    def get(self, memory_id: str) -> CustomerMemory | None:
+        return self._items.get(memory_id)
+
+    def list_active(self, owner_id: str) -> list[CustomerMemory]:
+        return [
+            memory
+            for memory in self._items.values()
+            if memory.owner_id == owner_id and memory.status == "ACTIVE"
+        ]
+
+
+class InMemoryCustomerMemoryIndex:
+    def __init__(self) -> None:
+        self._items: dict[str, CustomerMemory] = {}
+
+    def upsert(self, memory: CustomerMemory) -> None:
+        self._items[memory.id] = memory
+
+    def search(
+        self,
+        *,
+        owner_id: str,
+        query: str,
+        limit: int,
+    ) -> list[CustomerMemoryHit]:
+        query_terms = _terms(query)
+        candidates = []
+        for memory in self._items.values():
+            if memory.owner_id != owner_id or memory.status != "ACTIVE":
+                continue
+            content_terms = _terms(memory.content)
+            union = query_terms | content_terms
+            score = (
+                len(query_terms & content_terms) / len(union)
+                if union
+                else 0.0
+            )
+            if score > 0:
+                candidates.append(CustomerMemoryHit(memory.id, score))
+        return sorted(
+            candidates,
+            key=lambda item: (-item.score, item.memory_id),
+        )[:limit]
+
+    def delete(self, memory_id: str) -> None:
+        self._items.pop(memory_id, None)
+
+
+class CustomerMemoryService:
+    """执行长期记忆的保存、按用户召回和逻辑删除。"""
+
+    def __init__(
+        self,
+        repository: CustomerMemoryRepository,
+        index: CustomerMemoryIndex,
+        clock: Callable[[], datetime],
+    ) -> None:
+        self._repository = repository
+        self._index = index
+        self._clock = clock
+
+    def save(
+        self,
+        *,
+        owner_id: str,
+        category: str,
+        content: str,
+    ) -> dict[str, object]:
+        """校验并保存一条用户主动授权的长期记忆。"""
+
+        normalized = " ".join(content.split())
+        if category not in {"PREFERENCE", "FAMILY_CONTEXT", "CONSTRAINT"}:
+            raise AppError("CUSTOMER_MEMORY_CATEGORY_INVALID", "记忆类别无效", 422)
+        if len(normalized) < 2 or len(normalized) > 300:
+            raise AppError("CUSTOMER_MEMORY_CONTENT_INVALID", "记忆内容长度无效", 422)
+        if _contains_sensitive_identifier(normalized):
+            raise AppError(
+                "CUSTOMER_MEMORY_SENSITIVE",
+                "长期记忆不能保存手机号、身份证号或银行卡号",
+                422,
+            )
+        now = self._clock()
+        memory = CustomerMemory(
+            id=new_ulid(),
+            owner_id=owner_id,
+            category=category,
+            content=normalized,
+            status="ACTIVE",
+            created_at=now,
+            updated_at=now,
+        )
+        # 正文写入 MySQL,向量写入 Milvus;二者职责不同但使用同一个记忆编号。
+        self._repository.save(memory)
+        self._index.upsert(memory)
+        return self._serialize(memory)
+
+    def search(
+        self,
+        *,
+        owner_id: str,
+        query: str,
+        limit: int = 3,
+    ) -> dict[str, object]:
+        """只在当前登录用户自己的记忆范围内进行相似度检索。"""
+
+        memories = {
+            memory.id: memory for memory in self._repository.list_active(owner_id)
+        }
+        hits = self._index.search(
+            owner_id=owner_id,
+            query=query,
+            limit=min(max(limit, 1), 5),
+        )
+        items = [
+            {
+                **self._serialize(memories[hit.memory_id]),
+                "score": hit.score,
+            }
+            for hit in hits
+            if hit.memory_id in memories
+        ]
+        return {"items": items, "total": len(items)}
+
+    def delete(self, *, owner_id: str, memory_id: str) -> dict[str, object]:
+        """逻辑删除正文并同步移除向量,后续对话不再召回该内容。"""
+
+        memory = self._repository.get(memory_id)
+        if memory is None or memory.owner_id != owner_id or memory.status != "ACTIVE":
+            raise AppError("CUSTOMER_MEMORY_NOT_FOUND", "未找到可删除的长期记忆", 404)
+        disabled = replace(memory, status="DELETED", updated_at=self._clock())
+        self._repository.save(disabled)
+        self._index.delete(memory_id)
+        return self._serialize(disabled)
+
+    @staticmethod
+    def _serialize(memory: CustomerMemory) -> dict[str, object]:
+        return {
+            "memory_id": memory.id,
+            "category": memory.category,
+            "content": memory.content,
+            "status": memory.status,
+            "created_at": memory.created_at,
+            "updated_at": memory.updated_at,
+        }
+
+
+def _contains_sensitive_identifier(content: str) -> bool:
+    """用确定性规则拦截常见敏感号码,不把安全判断交给大模型。"""
+
+    patterns = (
+        r"(?<!\d)1[3-9]\d{9}(?!\d)",
+        r"(?<!\d)\d{17}[\dXx](?!\d)",
+        r"(?<!\d)\d{16,19}(?!\d)",
+    )
+    return any(re.search(pattern, content) for pattern in patterns)
+
+
+def _terms(content: str) -> set[str]:
+    normalized = re.sub(r"\s+", "", content.casefold())
+    return {
+        normalized[index : index + 2]
+        for index in range(max(len(normalized) - 1, 0))
+    } or {normalized}

+ 40 - 0
backend/src/zbt/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

+ 63 - 0
backend/src/zbt/domains/agent/repository.py

@@ -0,0 +1,63 @@
+from typing import Protocol
+
+from zbt.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]:
+        messages = [
+            message
+            for message in self._messages.values()
+            if message.thread_id == thread_id
+        ]
+        return sorted(messages, key=lambda message: message.created_at)
+
+    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/src/zbt/domains/agent/runtime.py

@@ -0,0 +1,32 @@
+"""Agent 运行时端口。
+
+实际 LangChain/DeepSeek 实现在 ``app.harness.kernel``,领域服务只依赖此协议。
+"""
+
+from typing import Protocol
+
+from zbt.core.errors import AppError
+from zbt.harness.kernel import AgentInvocation
+from zbt.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,
+        )

+ 605 - 0
backend/src/zbt/domains/agent/service.py

@@ -0,0 +1,605 @@
+from collections.abc import Callable, Iterator
+from dataclasses import dataclass, replace
+from datetime import datetime
+from secrets import randbelow
+from typing import Any
+
+from zbt.core.errors import AppError
+from zbt.core.identifiers import new_ulid
+from zbt.domains.agent.models import AgentMessage, AgentRun, AgentThread
+from zbt.domains.agent.repository import AgentRepository
+from zbt.domains.agent.runtime import AgentRuntime
+from zbt.domains.identity.models import AdminUser, H5User
+from zbt.harness.events import RuntimeEvent
+from zbt.harness.kernel import AgentInvocation, ConversationTurn
+from zbt.harness.schemas import AgentReply
+from zbt.infrastructure.redis.agent_state import (
+    AgentStateStore,
+    InMemoryAgentStateStore,
+)
+
+EVALUATION_THREAD_PREFIXES = ("评测-", "__EVAL__:")
+
+
+@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_customer_threads(
+        self,
+        user: H5User,
+        *,
+        limit: int = 30,
+    ) -> dict[str, Any]:
+        return self._list_thread_data("H5_USER", user.id, limit)
+
+    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 list_operation_threads(
+        self,
+        user: AdminUser,
+        *,
+        limit: int = 30,
+    ) -> dict[str, Any]:
+        return self._list_thread_data("ADMIN_USER", user.id, limit)
+
+    def _list_thread_data(
+        self,
+        owner_type: str,
+        owner_id: str,
+        limit: int,
+    ) -> dict[str, Any]:
+        threads = sorted(
+            (
+                thread
+                for thread in self._repository.list_threads(owner_type, owner_id)
+                if not thread.title.startswith(EVALUATION_THREAD_PREFIXES)
+            ),
+            key=lambda item: item.updated_at,
+            reverse=True,
+        )[:limit]
+        items: list[dict[str, Any]] = []
+        for thread in threads:
+            messages = self._repository.list_messages(thread.id)
+            last_message = messages[-1] if messages else None
+            items.append(
+                {
+                    "id": thread.id,
+                    "persona": thread.persona,
+                    "title": thread.title,
+                    "status": thread.status,
+                    "message_count": len(messages),
+                    "last_message": (
+                        {
+                            "role": last_message.role,
+                            "content": last_message.content,
+                            "created_at": last_message.created_at,
+                        }
+                        if last_message
+                        else None
+                    ),
+                    "created_at": thread.created_at,
+                    "updated_at": thread.updated_at,
+                }
+            )
+        return {"items": items, "total": len(items)}
+
+    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}"
+        updated_thread = replace(thread, updated_at=now)
+        self._repository.save_thread(updated_thread)
+        self._state_store.touch_thread(
+            updated_thread.id,
+            owner_type=updated_thread.owner_type,
+            owner_id=updated_thread.owner_id,
+            persona=updated_thread.persona,
+        )
+        return PreparedMessage(
+            thread=updated_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="USER",
+                content=prepared.text,
+                metadata={
+                    "run_id": prepared.run_id,
+                    "service_no": prepared.service_no,
+                },
+                created_at=prepared.started_at,
+            )
+        )
+        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

+ 949 - 0
backend/src/zbt/domains/agent/tools.py

@@ -0,0 +1,949 @@
+"""智保通 Agent 可以调用的受控业务工具。
+
+第二阶段在第一阶段业务工具之上新增四类能力:
+
+- Agentic RAG:允许模型拆分、改写问题并进行最多三轮知识检索;
+- 长期记忆:按当前 H5 用户保存、召回和删除保障偏好;
+- 运营分析:把模型选择的指标转换成后端白名单查询;
+- 证据约束:回答只能引用工具实际返回的知识片段。
+
+大模型负责决定“何时调用哪个工具”,真实查询、权限和数据范围仍由后端执行。
+"""
+
+from typing import Any, Literal, Protocol
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from zbt.core.errors import AppError
+from zbt.domains.catalog.service import ProductCatalogService
+from zbt.domains.enrollment.service import EnrollmentService
+from zbt.harness.policy import ToolPolicy
+from zbt.harness.schemas import (
+    AgentAction,
+    BusinessListBlock,
+    ChartBlock,
+    HarnessToolResult,
+    KnowledgeSourcesBlock,
+    MetricItem,
+    MetricsBlock,
+    QuoteBlock,
+    QuoteView,
+)
+from zbt.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 KnowledgeSearchProvider(Protocol):
+    """面向 Agent 的知识检索接口,实际实现是 ``KnowledgeService``。"""
+
+    def search(
+        self,
+        query: str,
+        *,
+        limit: int = 5,
+        product_code: str | None = None,
+        document_types: tuple[str, ...] = (),
+    ) -> dict[str, Any]: ...
+
+
+class OperationalAnalyticsProvider(Protocol):
+    """受控运营指标查询接口,不接受模型生成的原始 SQL。"""
+
+    def query(
+        self,
+        *,
+        metric: str,
+        group_by: str,
+        days: int,
+        admin_user_id: str | None = None,
+    ) -> dict[str, Any]: ...
+
+
+class CustomerMemoryProvider(Protocol):
+    """长期记忆接口;所有操作都必须显式携带当前用户编号。"""
+
+    def save(
+        self,
+        *,
+        owner_id: str,
+        category: str,
+        content: str,
+    ) -> dict[str, object]: ...
+
+    def search(
+        self,
+        *,
+        owner_id: str,
+        query: str,
+        limit: int = 3,
+    ) -> dict[str, object]: ...
+
+    def delete(self, *, owner_id: str, memory_id: str) -> dict[str, object]: ...
+
+
+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)
+
+
+class SearchKnowledgeArgs(ToolArguments):
+    """Agentic RAG 的单轮检索计划。
+
+    模型可给出原始问题和最多两个改写问题,后端会分别检索后再合并结果。
+    """
+
+    query: str = Field(min_length=2, max_length=500)
+    alternate_queries: list[str] = Field(default_factory=list, max_length=2)
+    product_code: str | None = Field(default=None, min_length=2, max_length=64)
+    document_types: list[
+        Literal["INSURANCE_TERMS", "PRODUCT_GUIDE", "SERVICE_RULES", "FAQ"]
+    ] = Field(default_factory=list, max_length=4)
+    limit: int = Field(default=5, ge=1, le=8)
+
+
+class OperationAnalyticsArgs(ToolArguments):
+    metric: Literal[
+        "ORDER_COUNT",
+        "ORDER_PREMIUM_SUM",
+        "ACTIVE_POLICY_COUNT",
+        "ACTIVE_POLICY_PREMIUM_SUM",
+    ]
+    group_by: Literal["DAY", "PRODUCT"] = "DAY"
+    days: Literal[7, 30, 90] = 30
+
+
+class RememberCustomerArgs(ToolArguments):
+    category: Literal["PREFERENCE", "FAMILY_CONTEXT", "CONSTRAINT"]
+    content: str = Field(min_length=2, max_length=300)
+
+
+class RecallCustomerMemoryArgs(ToolArguments):
+    query: str = Field(min_length=2, max_length=300)
+    limit: int = Field(default=3, ge=1, le=5)
+
+
+class ForgetCustomerMemoryArgs(ToolArguments):
+    memory_id: str = Field(min_length=16, max_length=64)
+
+
+def build_agent_tool_registry(
+    catalog: ProductCatalogService,
+    enrollment: EnrollmentService,
+    attribution: AttributionMetricsProvider | None = None,
+    knowledge: KnowledgeSearchProvider | None = None,
+    analytics: OperationalAnalyticsProvider | None = None,
+    memory: CustomerMemoryProvider | 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="查看推广明细")],
+        )
+
+    def search_insurance_knowledge(
+        context: ToolExecutionContext,
+        arguments: BaseModel,
+    ) -> HarnessToolResult:
+        """执行一轮 Agentic RAG 检索并返回可追溯证据。
+
+        同一次 Agent 运行最多调用三轮。模型可以根据上一轮证据不足的部分继续
+        缩小问题,但到达上限后必须使用已有依据回答或明确拒绝推测。
+        """
+
+        args = _arguments(arguments, SearchKnowledgeArgs)
+        queries = _normalize_knowledge_queries(args.query, args.alternate_queries)
+        attempt = (
+            sum(
+                execution.name == "search_insurance_knowledge"
+                for execution in context.executions
+            )
+            + 1
+        )
+        if attempt > 3:
+            raise AppError(
+                "AGENTIC_RETRIEVAL_LIMIT_REACHED",
+                "知识检索最多执行3轮,请基于现有证据回答或明确说明无法确定",
+                409,
+            )
+        retrieval_plan = {
+            "original_query": args.query.strip(),
+            "executed_queries": queries,
+            "filters": {
+                "product_code": args.product_code,
+                "document_types": list(args.document_types),
+            },
+            "attempt": attempt,
+        }
+        if knowledge is None:
+            return HarnessToolResult(
+                summary="没有检索到已发布的可靠知识依据",
+                data={
+                    "items": [],
+                    "total": 0,
+                    "agentic_retrieval": {
+                        **retrieval_plan,
+                        "next_step": "refine_query",
+                    },
+                },
+            )
+        # 每个改写问题独立检索,再按片段编号融合,减少同义问题漏召回。
+        rankings = [
+            knowledge.search(
+                query,
+                limit=args.limit,
+                product_code=args.product_code,
+                document_types=tuple(args.document_types),
+            )["items"]
+            for query in queries
+        ]
+        items = _fuse_knowledge_rankings(rankings, limit=args.limit)
+        result = {"items": items, "total": len(items)}
+        if not items:
+            return HarnessToolResult(
+                summary="没有检索到已发布的可靠知识依据",
+                data={
+                    **result,
+                    "agentic_retrieval": {
+                        **retrieval_plan,
+                        "next_step": "refine_query",
+                    },
+                },
+            )
+        best = items[0]
+        excerpt = str(best["content"])[:180]
+        # 这些约束会随工具结果返回给模型,明确禁止把相似内容扩展成确定结论。
+        evidence_data = {
+            **result,
+            "agentic_retrieval": {
+                **retrieval_plan,
+                "next_step": "evaluate_evidence",
+            },
+            "answer_constraints": {
+                "direct_evidence_only": True,
+                "no_scenario_expansion": True,
+                "no_numeric_inference": True,
+                "unsupported_answer": "当前知识库未明确,无法确定",
+            },
+        }
+        return HarnessToolResult(
+            summary=(
+                f"第{attempt}轮共执行{len(queries)}个检索问题,"
+                f"合并得到{result['total']}条已发布依据。最相关内容:{excerpt}"
+                f"(来源:{best['source_name']},版本v{best['document_version']})。"
+                "请先判断证据是否覆盖问题各子项;不足时缩小范围再次检索。"
+                "回答只能使用返回摘录中的直接事实;不得扩展具体场景或推断未出现的数字。"
+            ),
+            data=evidence_data,
+            blocks=[
+                KnowledgeSourcesBlock(
+                    items=[
+                        {
+                            "document_id": str(item["document_id"]),
+                            "document_version": int(item["document_version"]),
+                            "title": str(item["title"]),
+                            "source_name": str(item["source_name"]),
+                            "excerpt": str(item["content"])[:180],
+                            "score": float(item["score"]),
+                        }
+                        for item in items[:5]
+                    ]
+                )
+            ],
+        )
+
+    def query_operation_analytics(
+        context: ToolExecutionContext,
+        arguments: BaseModel,
+    ) -> HarnessToolResult:
+        """查询运营趋势;模型只能选择已登记的指标、维度和时间窗口。"""
+
+        args = _arguments(arguments, OperationAnalyticsArgs)
+        admin = context.admin_user
+        if admin is None:
+            raise AppError("AGENT_ADMIN_REQUIRED", "运营分析需要后台身份", 401)
+        if analytics is None:
+            raise AppError(
+                "ANALYTICS_UNAVAILABLE",
+                "运营分析服务尚未就绪",
+                503,
+                retryable=True,
+            )
+        result = analytics.query(
+            metric=args.metric,
+            group_by=args.group_by,
+            days=args.days,
+            admin_user_id=admin.id if admin.data_scope == "SELF" else None,
+        )
+        rows = list(result["rows"])
+        return HarnessToolResult(
+            summary=(
+                f"已按{args.days}天、{result['group_by']}维度查询"
+                f"{result['metric_name']},合计{result['total']}{result['unit']}。"
+                "查询由后端白名单语义层编译,只读取分析事实表。"
+            ),
+            data=result,
+            blocks=[
+                ChartBlock(
+                    title=f"{args.days}天{result['metric_name']}趋势",
+                    chart_type="line" if args.group_by == "DAY" else "bar",
+                    unit=str(result["unit"]),
+                    points=[
+                        {
+                            "label": str(item["label"]),
+                            "value": float(item["value"]),
+                        }
+                        for item in rows
+                    ],
+                )
+            ],
+        )
+
+    def remember_customer_preference(
+        context: ToolExecutionContext,
+        arguments: BaseModel,
+    ) -> HarnessToolResult:
+        """仅在用户明确要求记住时,由 Agent 调用该工具保存长期偏好。"""
+
+        args = _arguments(arguments, RememberCustomerArgs)
+        user = context.h5_user
+        if user is None:
+            raise AppError("AGENT_CUSTOMER_REQUIRED", "长期记忆需要H5用户身份", 401)
+        if memory is None:
+            raise AppError("CUSTOMER_MEMORY_UNAVAILABLE", "长期记忆服务尚未就绪", 503)
+        result = memory.save(
+            owner_id=user.id,
+            category=args.category,
+            content=args.content,
+        )
+        return HarnessToolResult(
+            summary=(
+                f"已按用户明确要求保存长期记忆:{result['content']}。"
+                f"记忆编号:{result['memory_id']}"
+            ),
+            data=dict(result),
+        )
+
+    def recall_customer_memories(
+        context: ToolExecutionContext,
+        arguments: BaseModel,
+    ) -> HarnessToolResult:
+        """召回当前用户相关记忆,不能跨用户读取,也不能替代保险条款。"""
+
+        args = _arguments(arguments, RecallCustomerMemoryArgs)
+        user = context.h5_user
+        if user is None:
+            raise AppError("AGENT_CUSTOMER_REQUIRED", "长期记忆需要H5用户身份", 401)
+        if memory is None:
+            raise AppError("CUSTOMER_MEMORY_UNAVAILABLE", "长期记忆服务尚未就绪", 503)
+        result = memory.search(
+            owner_id=user.id,
+            query=args.query,
+            limit=args.limit,
+        )
+        return HarnessToolResult(
+            summary=(
+                f"检索到{result['total']}条当前用户的长期记忆。"
+                "记忆只用于个性化沟通,不能替代保险条款和业务数据。"
+            ),
+            data=dict(result),
+        )
+
+    def forget_customer_memory(
+        context: ToolExecutionContext,
+        arguments: BaseModel,
+    ) -> HarnessToolResult:
+        """响应用户删除记忆的要求,并让该内容立即停止参与后续对话。"""
+
+        args = _arguments(arguments, ForgetCustomerMemoryArgs)
+        user = context.h5_user
+        if user is None:
+            raise AppError("AGENT_CUSTOMER_REQUIRED", "长期记忆需要H5用户身份", 401)
+        if memory is None:
+            raise AppError("CUSTOMER_MEMORY_UNAVAILABLE", "长期记忆服务尚未就绪", 503)
+        result = memory.delete(owner_id=user.id, memory_id=args.memory_id)
+        return HarnessToolResult(
+            summary="已删除指定长期记忆,后续对话不再使用该内容。",
+            data=dict(result),
+        )
+
+    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,
+        ),
+        ToolDefinition(
+            name="search_insurance_knowledge",
+            description=(
+                "执行受控Agentic RAG:可提交原问题、最多2个改写问题及产品、文档类型过滤,"
+                "对已发布的保险条款、保障责任、免责范围和服务规则进行多路检索并合并证据。"
+                "调用后需判断证据充分性;不足时可缩小问题再次调用,"
+                "最终只引用直接支持结论的来源和版本。"
+            ),
+            arguments=SearchKnowledgeArgs,
+            policy=ToolPolicy(personas=("customer", "operation"), effect="read"),
+            handler=search_insurance_knowledge,
+        ),
+        ToolDefinition(
+            name="query_operation_analytics",
+            description=(
+                "按白名单指标、时间窗口和维度查询分析库,返回确定性聚合结果和图表。"
+                "需要趋势、分产品对比或一段时间内的经营分析时调用;"
+                "不得生成或提交原始SQL。"
+            ),
+            arguments=OperationAnalyticsArgs,
+            policy=ToolPolicy(
+                personas=("operation",),
+                effect="read",
+                required_permissions=("orders:read", "policies:read"),
+            ),
+            handler=query_operation_analytics,
+        ),
+        ToolDefinition(
+            name="remember_customer_preference",
+            description=(
+                "仅当客户明确说“记住、保存、以后按此偏好”时,"
+                "保存不含手机号、身份证号和银行卡号的偏好或家庭保障上下文。"
+            ),
+            arguments=RememberCustomerArgs,
+            policy=ToolPolicy(personas=("customer",), effect="draft"),
+            handler=remember_customer_preference,
+        ),
+        ToolDefinition(
+            name="recall_customer_memories",
+            description=(
+                "在需要跨会话个性化推荐时,语义检索当前登录客户自己的长期记忆。"
+                "不得把记忆当作条款或业务事实。"
+            ),
+            arguments=RecallCustomerMemoryArgs,
+            policy=ToolPolicy(personas=("customer",), effect="read"),
+            handler=recall_customer_memories,
+        ),
+        ToolDefinition(
+            name="forget_customer_memory",
+            description="按客户明确要求删除当前账号自己的指定长期记忆。",
+            arguments=ForgetCustomerMemoryArgs,
+            policy=ToolPolicy(personas=("customer",), effect="draft"),
+            handler=forget_customer_memory,
+        ),
+    )
+    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 _normalize_knowledge_queries(
+    query: str,
+    alternate_queries: list[str],
+) -> list[str]:
+    queries: list[str] = []
+    for candidate in [query, *alternate_queries]:
+        normalized = candidate.strip()
+        if len(normalized) >= 2 and normalized not in queries:
+            queries.append(normalized)
+    return queries[:3]
+
+
+def _fuse_knowledge_rankings(
+    rankings: list[list[dict[str, Any]]],
+    *,
+    limit: int,
+) -> list[dict[str, Any]]:
+    fused_scores: dict[str, float] = {}
+    items_by_chunk: dict[str, dict[str, Any]] = {}
+    for ranking in rankings:
+        for rank, item in enumerate(ranking, start=1):
+            chunk_id = str(item["chunk_id"])
+            items_by_chunk.setdefault(chunk_id, dict(item))
+            fused_scores[chunk_id] = fused_scores.get(chunk_id, 0.0) + (
+                1.0 / (60 + rank)
+            )
+    ordered_chunk_ids = sorted(
+        fused_scores,
+        key=lambda chunk_id: (-fused_scores[chunk_id], chunk_id),
+    )
+    results: list[dict[str, Any]] = []
+    for chunk_id in ordered_chunk_ids[:limit]:
+        item = dict(items_by_chunk[chunk_id])
+        item["score"] = fused_scores[chunk_id]
+        results.append(item)
+    return results
+
+
+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/src/zbt/domains/attribution/__init__.py

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

+ 46 - 0
backend/src/zbt/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/src/zbt/domains/attribution/repository.py

@@ -0,0 +1,73 @@
+from typing import Protocol
+
+from zbt.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/src/zbt/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 zbt.core.errors import AppError
+from zbt.core.identifiers import new_ulid
+from zbt.domains.attribution.models import LeadAttribution, OrderAttribution
+from zbt.domains.attribution.repository import AttributionRepository
+from zbt.domains.enrollment.models import EnrollmentOrder
+from zbt.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/src/zbt/domains/catalog/__init__.py

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

+ 59 - 0
backend/src/zbt/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/src/zbt/domains/catalog/repository.py

@@ -0,0 +1,56 @@
+from typing import Protocol
+
+from zbt.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/src/zbt/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 zbt.core.errors import AppError
+from zbt.core.identifiers import new_ulid
+from zbt.domains.catalog.models import Plan, Product, ProductChangeLog, ProductVersion
+from zbt.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/src/zbt/domains/enrollment/__init__.py

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

+ 124 - 0
backend/src/zbt/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/src/zbt/domains/enrollment/repository.py

@@ -0,0 +1,178 @@
+from typing import Protocol
+
+from zbt.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/src/zbt/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 zbt.core.errors import AppError
+from zbt.core.identifiers import new_ulid
+from zbt.domains.catalog.service import ProductCatalogService
+from zbt.domains.enrollment.models import (
+    AsyncTask,
+    EnrollmentDraft,
+    EnrollmentOrder,
+    OutboxEvent,
+    PaymentTransaction,
+    Policy,
+    Quote,
+    UserConfirmation,
+)
+from zbt.domains.enrollment.repository import EnrollmentRepository
+from zbt.domains.enrollment.tasks import InlinePolicyTaskDispatcher, TaskDispatcher
+from zbt.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/src/zbt/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 zbt.core.errors import AppError
+from zbt.core.identifiers import new_ulid
+from zbt.domains.enrollment.models import AsyncTask, Policy
+from zbt.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/src/zbt/domains/identity/__init__.py

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

+ 49 - 0
backend/src/zbt/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/src/zbt/domains/identity/repository.py

@@ -0,0 +1,115 @@
+from dataclasses import replace
+from typing import Protocol
+
+from zbt.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

+ 500 - 0
backend/src/zbt/domains/identity/service.py

@@ -0,0 +1,500 @@
+"""H5 用户和后台管理员的认证业务逻辑。
+
+路由层负责收发 HTTP 数据,本文件负责验证码、登录、令牌刷新和退出等业务规则。
+"""
+
+import re
+from dataclasses import replace
+from datetime import UTC, datetime, timedelta
+from typing import Any
+
+from zbt.core.config import Settings
+from zbt.core.errors import AppError
+from zbt.core.identifiers import new_ulid
+from zbt.core.passwords import PasswordService
+from zbt.core.security import JwtService
+from zbt.domains.identity.models import AdminRole, AdminUser, AuthSession, H5User
+from zbt.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": "knowledge:read", "name": "查看知识资产", "group": "知识中心"},
+    {"code": "knowledge:write", "name": "上传与索引知识", "group": "知识中心"},
+    {"code": "knowledge:publish", "name": "发布与停用知识", "group": "知识中心"},
+    {"code": "service:read", "name": "查看服务申请", "group": "服务中心"},
+    {"code": "service:review", "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,
+        }

+ 2 - 0
backend/src/zbt/domains/knowledge/__init__.py

@@ -0,0 +1,2 @@
+"""保险知识库领域。"""
+

+ 32 - 0
backend/src/zbt/domains/knowledge/chunking.py

@@ -0,0 +1,32 @@
+class DocumentChunker:
+    """按段落优先切分,超长段落使用字符重叠保留上下文。"""
+
+    def __init__(self, *, max_chars: int = 700, overlap_chars: int = 100) -> None:
+        if max_chars <= 0:
+            raise ValueError("max_chars必须大于0")
+        if overlap_chars < 0 or overlap_chars >= max_chars:
+            raise ValueError("overlap_chars必须大于等于0且小于max_chars")
+        self._max_chars = max_chars
+        self._overlap_chars = overlap_chars
+
+    def split(self, content: str) -> list[str]:
+        paragraphs = [
+            paragraph.strip()
+            for paragraph in content.replace("\r\n", "\n").replace("\r", "\n").split("\n\n")
+            if paragraph.strip()
+        ]
+        chunks: list[str] = []
+        for paragraph in paragraphs:
+            if len(paragraph) <= self._max_chars:
+                chunks.append(paragraph)
+                continue
+            chunks.extend(self._split_long_paragraph(paragraph))
+        return chunks
+
+    def _split_long_paragraph(self, paragraph: str) -> list[str]:
+        step = self._max_chars - self._overlap_chars
+        return [
+            paragraph[start : start + self._max_chars]
+            for start in range(0, len(paragraph), step)
+            if paragraph[start : start + self._max_chars]
+        ]

+ 62 - 0
backend/src/zbt/domains/knowledge/index.py

@@ -0,0 +1,62 @@
+from typing import Protocol
+
+from zbt.domains.knowledge.models import KnowledgeChunk, KnowledgeSearchHit
+
+
+class KnowledgeIndex(Protocol):
+    def replace_document(
+        self,
+        document_id: str,
+        chunks: list[KnowledgeChunk],
+    ) -> None: ...
+
+    def search(
+        self,
+        query: str,
+        *,
+        document_ids: set[str],
+        limit: int,
+    ) -> list[KnowledgeSearchHit]: ...
+
+
+class InMemoryKnowledgeIndex:
+    def __init__(self) -> None:
+        self._chunks_by_document: dict[str, list[KnowledgeChunk]] = {}
+
+    def replace_document(
+        self,
+        document_id: str,
+        chunks: list[KnowledgeChunk],
+    ) -> None:
+        self._chunks_by_document[document_id] = list(chunks)
+
+    def search(
+        self,
+        query: str,
+        *,
+        document_ids: set[str],
+        limit: int,
+    ) -> list[KnowledgeSearchHit]:
+        query_terms = {character for character in query if character.strip()}
+        hits: list[KnowledgeSearchHit] = []
+        for document_id in document_ids:
+            for chunk in self._chunks_by_document.get(document_id, []):
+                content_terms = set(chunk.content)
+                overlap = len(query_terms & content_terms)
+                if overlap == 0:
+                    continue
+                hits.append(
+                    KnowledgeSearchHit(
+                        chunk_id=chunk.id,
+                        document_id=chunk.document_id,
+                        document_version=chunk.document_version,
+                        ordinal=chunk.ordinal,
+                        title=chunk.title,
+                        content=chunk.content,
+                        document_type=chunk.document_type,
+                        source_name=chunk.source_name,
+                        product_code=chunk.product_code,
+                        score=overlap / max(len(query_terms), 1),
+                    )
+                )
+        return sorted(hits, key=lambda item: item.score, reverse=True)[:limit]

+ 59 - 0
backend/src/zbt/domains/knowledge/models.py

@@ -0,0 +1,59 @@
+from dataclasses import dataclass
+from datetime import datetime
+
+
+@dataclass(frozen=True, slots=True)
+class KnowledgeDocument:
+    id: str
+    title: str
+    document_type: str
+    source_name: str
+    product_code: str | None
+    content: str
+    status: str
+    version_no: int
+    chunk_count: int
+    created_by: str
+    created_at: datetime
+    updated_at: datetime
+    indexed_at: datetime | None = None
+    published_at: datetime | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class KnowledgeChunk:
+    id: str
+    document_id: str
+    document_version: int
+    ordinal: int
+    title: str
+    content: str
+    document_type: str
+    source_name: str
+    product_code: str | None
+
+
+@dataclass(frozen=True, slots=True)
+class KnowledgeSearchHit:
+    chunk_id: str
+    document_id: str
+    document_version: int
+    ordinal: int
+    title: str
+    content: str
+    document_type: str
+    source_name: str
+    product_code: str | None
+    score: float
+
+
+@dataclass(frozen=True, slots=True)
+class KnowledgeSearchTest:
+    id: str
+    document_id: str
+    query: str
+    hit_count: int
+    top_score: float | None
+    passed: bool
+    tested_by: str
+    created_at: datetime

+ 40 - 0
backend/src/zbt/domains/knowledge/repository.py

@@ -0,0 +1,40 @@
+from typing import Protocol
+
+from zbt.domains.knowledge.models import KnowledgeDocument, KnowledgeSearchTest
+
+
+class KnowledgeRepository(Protocol):
+    def save_document(self, document: KnowledgeDocument) -> None: ...
+
+    def get_document(self, document_id: str) -> KnowledgeDocument | None: ...
+
+    def list_documents(self) -> list[KnowledgeDocument]: ...
+
+    def save_search_test(self, search_test: KnowledgeSearchTest) -> None: ...
+
+    def list_search_tests(self, document_id: str) -> list[KnowledgeSearchTest]: ...
+
+
+class InMemoryKnowledgeRepository:
+    def __init__(self) -> None:
+        self._documents: dict[str, KnowledgeDocument] = {}
+        self._search_tests: dict[str, KnowledgeSearchTest] = {}
+
+    def save_document(self, document: KnowledgeDocument) -> None:
+        self._documents[document.id] = document
+
+    def get_document(self, document_id: str) -> KnowledgeDocument | None:
+        return self._documents.get(document_id)
+
+    def list_documents(self) -> list[KnowledgeDocument]:
+        return list(self._documents.values())
+
+    def save_search_test(self, search_test: KnowledgeSearchTest) -> None:
+        self._search_tests[search_test.id] = search_test
+
+    def list_search_tests(self, document_id: str) -> list[KnowledgeSearchTest]:
+        return [
+            search_test
+            for search_test in self._search_tests.values()
+            if search_test.document_id == document_id
+        ]

+ 149 - 0
backend/src/zbt/domains/knowledge/retrieval.py

@@ -0,0 +1,149 @@
+"""知识检索中的关键词召回与多路结果融合。
+
+第二阶段同时使用两类检索:
+
+1. Milvus 负责语义检索,适合找到“说法不同但含义相近”的内容;
+2. 本模块的 BM25 负责关键词检索,适合保险名称、责任条款等精确文字;
+3. 两路结果通过 RRF 融合,避免只依赖单一检索方式。
+
+本模块只负责“找候选内容和排序”,不负责让大模型生成答案。
+"""
+
+import math
+import re
+from collections import Counter
+from collections.abc import Iterable
+from dataclasses import replace
+from typing import Protocol
+
+from zbt.domains.knowledge.models import KnowledgeChunk, KnowledgeSearchHit
+
+ASCII_TERM_PATTERN = re.compile(r"[a-z0-9]+")
+CHINESE_RUN_PATTERN = re.compile(r"[\u4e00-\u9fff]+")
+
+
+class KnowledgeReranker(Protocol):
+    """语义重排器的最小接口。
+
+    具体实现可以使用本地 BGE Reranker,也可以替换成其他重排服务。
+    """
+
+    def rerank(
+        self,
+        query: str,
+        hits: list[KnowledgeSearchHit],
+        *,
+        limit: int,
+    ) -> list[KnowledgeSearchHit]: ...
+
+
+def rank_bm25(
+    query: str,
+    chunks: Iterable[KnowledgeChunk],
+    *,
+    limit: int,
+    k1: float = 1.5,
+    b: float = 0.75,
+) -> list[KnowledgeSearchHit]:
+    """按照关键词相关度对知识分块排序。
+
+    ``chunks`` 是已经切分好的知识片段;返回值统一转换成
+    ``KnowledgeSearchHit``,方便后续与 Milvus 的语义结果合并。
+    """
+
+    candidates = list(chunks)
+    if not candidates or not query.strip():
+        return []
+    tokenized = [_tokenize(chunk.content) for chunk in candidates]
+    query_terms = set(_tokenize(query))
+    if not query_terms:
+        return []
+    document_frequency = Counter(
+        term
+        for terms in tokenized
+        for term in query_terms.intersection(terms)
+    )
+    average_length = sum(len(terms) for terms in tokenized) / len(tokenized)
+    ranked: list[KnowledgeSearchHit] = []
+    for chunk, terms in zip(candidates, tokenized, strict=True):
+        frequencies = Counter(terms)
+        score = 0.0
+        for term in query_terms:
+            frequency = frequencies[term]
+            if frequency == 0:
+                continue
+            idf = math.log(
+                1
+                + (
+                    len(candidates)
+                    - document_frequency[term]
+                    + 0.5
+                )
+                / (document_frequency[term] + 0.5)
+            )
+            length_normalizer = k1 * (
+                1 - b + b * len(terms) / max(average_length, 1)
+            )
+            score += idf * frequency * (k1 + 1) / (frequency + length_normalizer)
+        if score > 0:
+            ranked.append(_chunk_to_hit(chunk, score))
+    return sorted(ranked, key=lambda hit: hit.score, reverse=True)[:limit]
+
+
+def reciprocal_rank_fusion(
+    rankings: Iterable[list[KnowledgeSearchHit]],
+    *,
+    limit: int,
+    rank_constant: int = 60,
+) -> list[KnowledgeSearchHit]:
+    """使用 RRF 合并多份排行榜。
+
+    RRF 关注同一片段在各排行榜中的名次,不直接比较不同检索器的原始分数,
+    因而适合融合 BM25 分数和向量相似度这两种量纲不同的结果。
+    """
+
+    hits_by_id: dict[str, KnowledgeSearchHit] = {}
+    scores: dict[str, float] = {}
+    for ranking in rankings:
+        for rank, hit in enumerate(ranking, start=1):
+            hits_by_id.setdefault(hit.chunk_id, hit)
+            scores[hit.chunk_id] = scores.get(hit.chunk_id, 0.0) + 1 / (
+                rank_constant + rank
+            )
+    ordered_ids = sorted(scores, key=scores.__getitem__, reverse=True)[:limit]
+    return [
+        replace(hits_by_id[chunk_id], score=scores[chunk_id])
+        for chunk_id in ordered_ids
+    ]
+
+
+def _tokenize(text: str) -> list[str]:
+    """将中英文混合内容转换成可供 BM25 使用的词项。
+
+    英文和数字按连续字符串提取;中文使用相邻两个字组成的二元词,
+    这样不依赖额外中文分词服务也能完成本地检索。
+    """
+
+    lowered = text.lower()
+    terms = ASCII_TERM_PATTERN.findall(lowered)
+    for run in CHINESE_RUN_PATTERN.findall(lowered):
+        if len(run) == 1:
+            terms.append(run)
+            continue
+        terms.extend(run[index : index + 2] for index in range(len(run) - 1))
+    return terms
+
+
+def _chunk_to_hit(chunk: KnowledgeChunk, score: float) -> KnowledgeSearchHit:
+    return KnowledgeSearchHit(
+        chunk_id=chunk.id,
+        document_id=chunk.document_id,
+        document_version=chunk.document_version,
+        ordinal=chunk.ordinal,
+        title=chunk.title,
+        content=chunk.content,
+        document_type=chunk.document_type,
+        source_name=chunk.source_name,
+        product_code=chunk.product_code,
+        score=score,
+    )

+ 395 - 0
backend/src/zbt/domains/knowledge/service.py

@@ -0,0 +1,395 @@
+"""知识库应用服务。
+
+它把管理端的“上传 → 索引 → 检索测试 → 发布”串成完整流程,并把
+Milvus 语义检索、BM25 关键词检索和 BGE 重排封装在一个稳定入口中。
+Agent 只能检索已经发布的知识,草稿和仅完成索引的文档不会进入正式回答。
+"""
+
+import logging
+from collections.abc import Callable
+from dataclasses import replace
+from datetime import datetime
+from typing import Any
+
+from zbt.core.errors import AppError
+from zbt.core.identifiers import new_ulid
+from zbt.domains.knowledge.chunking import DocumentChunker
+from zbt.domains.knowledge.index import KnowledgeIndex
+from zbt.domains.knowledge.models import (
+    KnowledgeChunk,
+    KnowledgeDocument,
+    KnowledgeSearchTest,
+)
+from zbt.domains.knowledge.repository import KnowledgeRepository
+from zbt.domains.knowledge.retrieval import (
+    KnowledgeReranker,
+    rank_bm25,
+    reciprocal_rank_fusion,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class KnowledgeService:
+    """协调知识文档状态、索引和检索的应用服务。"""
+
+    def __init__(
+        self,
+        repository: KnowledgeRepository,
+        clock: Callable[[], datetime],
+        index: KnowledgeIndex | None = None,
+        chunker: DocumentChunker | None = None,
+        reranker: KnowledgeReranker | None = None,
+        candidate_multiplier: int = 4,
+    ) -> None:
+        self._repository = repository
+        self._clock = clock
+        self._index = index
+        self._chunker = chunker or DocumentChunker()
+        self._reranker = reranker
+        self._candidate_multiplier = max(candidate_multiplier, 1)
+
+    def create_document(
+        self,
+        *,
+        title: str,
+        document_type: str,
+        source_name: str,
+        product_code: str | None,
+        content: str,
+        created_by: str,
+    ) -> dict[str, Any]:
+        """创建知识草稿。
+
+        这里只把原始文档保存到 MySQL;文档尚未切分,也不会被 Agent 检索到。
+        """
+
+        now = self._clock()
+        document = KnowledgeDocument(
+            id=new_ulid(),
+            title=title.strip(),
+            document_type=document_type,
+            source_name=source_name.strip(),
+            product_code=product_code.strip() if product_code else None,
+            content=content.strip(),
+            status="DRAFT",
+            version_no=1,
+            chunk_count=0,
+            created_by=created_by,
+            created_at=now,
+            updated_at=now,
+        )
+        self._repository.save_document(document)
+        return self._serialize_document(document)
+
+    def list_documents(self) -> dict[str, Any]:
+        documents = sorted(
+            self._repository.list_documents(),
+            key=lambda item: item.created_at,
+            reverse=True,
+        )
+        items = [self._serialize_document(document) for document in documents]
+        return {"items": items, "total": len(items)}
+
+    def index_document(self, document_id: str) -> dict[str, Any]:
+        """切分文档并替换其在 Milvus 中的全部向量片段。
+
+        使用“整篇替换”可以避免文档重新索引后残留旧版本片段。
+        """
+
+        document = self._repository.get_document(document_id)
+        if document is None:
+            raise AppError("KNOWLEDGE_DOCUMENT_NOT_FOUND", "未找到知识文档", 404)
+        if self._index is None:
+            raise AppError(
+                "KNOWLEDGE_INDEX_UNAVAILABLE",
+                "知识索引服务尚未就绪",
+                503,
+                retryable=True,
+            )
+        chunks = self._split_document(document)
+        self._index.replace_document(document.id, chunks)
+        now = self._clock()
+        indexed = replace(
+            document,
+            status="INDEXED",
+            chunk_count=len(chunks),
+            indexed_at=now,
+            published_at=None,
+            updated_at=now,
+        )
+        self._repository.save_document(indexed)
+        return self._serialize_document(indexed)
+
+    def publish_document(self, document_id: str) -> dict[str, Any]:
+        """发布已经验证过的知识。
+
+        发布前必须存在本次索引之后产生的有效检索测试记录,防止“写入成功但
+        实际搜不到”的知识直接被 Agent 使用。
+        """
+
+        document = self._repository.get_document(document_id)
+        if document is None:
+            raise AppError("KNOWLEDGE_DOCUMENT_NOT_FOUND", "未找到知识文档", 404)
+        if document.status != "INDEXED":
+            raise AppError(
+                "KNOWLEDGE_DOCUMENT_NOT_INDEXED",
+                "知识文档完成索引和检索测试后才能发布",
+                409,
+            )
+        effective_tests = [
+            search_test
+            for search_test in self._repository.list_search_tests(document_id)
+            if search_test.passed
+            and (
+                document.indexed_at is None
+                or search_test.created_at >= document.indexed_at
+            )
+        ]
+        if not effective_tests:
+            raise AppError(
+                "KNOWLEDGE_SEARCH_TEST_REQUIRED",
+                "知识文档至少完成一次有结果的检索测试后才能发布",
+                409,
+            )
+        now = self._clock()
+        published = replace(
+            document,
+            status="PUBLISHED",
+            published_at=now,
+            updated_at=now,
+        )
+        self._repository.save_document(published)
+        return self._serialize_document(published)
+
+    def disable_document(self, document_id: str) -> dict[str, Any]:
+        document = self._repository.get_document(document_id)
+        if document is None:
+            raise AppError("KNOWLEDGE_DOCUMENT_NOT_FOUND", "未找到知识文档", 404)
+        if document.status == "INACTIVE":
+            return self._serialize_document(document)
+        if document.status != "PUBLISHED":
+            raise AppError(
+                "KNOWLEDGE_DOCUMENT_NOT_PUBLISHED",
+                "只有已发布知识才能停用",
+                409,
+            )
+        now = self._clock()
+        disabled = replace(document, status="INACTIVE", updated_at=now)
+        self._repository.save_document(disabled)
+        return self._serialize_document(disabled)
+
+    def search(
+        self,
+        query: str,
+        *,
+        limit: int = 5,
+        product_code: str | None = None,
+        document_types: tuple[str, ...] = (),
+    ) -> dict[str, Any]:
+        """检索所有符合过滤条件的已发布文档。
+
+        这是 Agent 工具实际调用的入口,状态过滤在服务端完成,模型无法绕过。
+        """
+
+        if self._index is None:
+            raise AppError(
+                "KNOWLEDGE_INDEX_UNAVAILABLE",
+                "知识索引服务尚未就绪",
+                503,
+                retryable=True,
+            )
+        published_documents = [
+            document
+            for document in self._repository.list_documents()
+            if document.status == "PUBLISHED"
+            and (product_code is None or document.product_code == product_code)
+            and (
+                not document_types
+                or document.document_type in set(document_types)
+            )
+        ]
+        return self._search_documents(query, published_documents, limit=limit)
+
+    def test_search(
+        self,
+        document_id: str,
+        query: str,
+        *,
+        limit: int = 5,
+        tested_by: str = "SYSTEM",
+    ) -> dict[str, Any]:
+        """只针对指定文档执行检索,并持久化本次测试结果。
+
+        该记录既供管理端查看,也是 ``publish_document`` 的发布门禁依据。
+        """
+
+        document = self._repository.get_document(document_id)
+        if document is None:
+            raise AppError("KNOWLEDGE_DOCUMENT_NOT_FOUND", "未找到知识文档", 404)
+        if document.status not in {"INDEXED", "PUBLISHED"}:
+            raise AppError(
+                "KNOWLEDGE_DOCUMENT_NOT_INDEXED",
+                "知识文档完成索引后才能测试检索",
+                409,
+            )
+        result = self._search_documents(query, [document], limit=limit)
+        now = self._clock()
+        search_test = KnowledgeSearchTest(
+            id=new_ulid(),
+            document_id=document_id,
+            query=query.strip(),
+            hit_count=int(result["total"]),
+            top_score=(
+                float(result["items"][0]["score"]) if result["items"] else None
+            ),
+            passed=bool(result["items"]),
+            tested_by=tested_by,
+            created_at=now,
+        )
+        self._repository.save_search_test(search_test)
+        return {
+            "document_id": document_id,
+            "test_record": self._serialize_search_test(search_test),
+            **result,
+        }
+
+    def list_search_tests(self, document_id: str) -> dict[str, Any]:
+        if self._repository.get_document(document_id) is None:
+            raise AppError("KNOWLEDGE_DOCUMENT_NOT_FOUND", "未找到知识文档", 404)
+        search_tests = sorted(
+            self._repository.list_search_tests(document_id),
+            key=lambda item: item.created_at,
+            reverse=True,
+        )
+        items = [self._serialize_search_test(item) for item in search_tests]
+        return {"document_id": document_id, "items": items, "total": len(items)}
+
+    def _search_documents(
+        self,
+        query: str,
+        documents: list[KnowledgeDocument],
+        *,
+        limit: int,
+    ) -> dict[str, Any]:
+        """执行混合检索主链路:向量召回 → BM25 召回 → RRF → 可选重排。"""
+
+        if self._index is None:
+            raise AppError(
+                "KNOWLEDGE_INDEX_UNAVAILABLE",
+                "知识索引服务尚未就绪",
+                503,
+                retryable=True,
+            )
+        document_ids = {document.id for document in documents}
+        candidate_limit = limit * self._candidate_multiplier
+        # 候选数量先放大,给融合和重排阶段留下足够的选择空间。
+        dense_hits = self._index.search(
+            query.strip(),
+            document_ids=document_ids,
+            limit=candidate_limit,
+        )
+        lexical_hits = rank_bm25(
+            query,
+            (
+                chunk
+                for document in documents
+                for chunk in self._split_document(document)
+            ),
+            limit=candidate_limit,
+        )
+        candidates = reciprocal_rank_fusion(
+            [dense_hits, lexical_hits],
+            limit=candidate_limit,
+        )
+        hits = candidates[:limit]
+        if self._reranker is not None:
+            try:
+                hits = self._reranker.rerank(query, candidates, limit=limit)
+            except Exception:
+                # 语义重排只用于提升排序效果,不应成为知识问答的单点故障。
+                # 失败时继续使用 Milvus + BM25 的融合结果,并保留完整异常日志。
+                logger.exception(
+                    "Knowledge reranker failed; falling back to fused retrieval results"
+                )
+        items = [
+            {
+                "chunk_id": hit.chunk_id,
+                "document_id": hit.document_id,
+                "document_version": hit.document_version,
+                "ordinal": hit.ordinal,
+                "title": hit.title,
+                "content": hit.content,
+                "document_type": hit.document_type,
+                "source_name": hit.source_name,
+                "product_code": hit.product_code,
+                "score": hit.score,
+            }
+            for hit in hits
+        ]
+        return {"items": items, "total": len(items)}
+
+    def _split_document(self, document: KnowledgeDocument) -> list[KnowledgeChunk]:
+        """把文档切成带来源、版本和顺序号的可追溯知识片段。"""
+
+        paragraphs = self._chunker.split(document.content)
+        if not paragraphs:
+            raise AppError("KNOWLEDGE_DOCUMENT_EMPTY", "知识文档没有可索引内容", 422)
+        return [
+            KnowledgeChunk(
+                id=f"{document.id}:{document.version_no}:{ordinal}",
+                document_id=document.id,
+                document_version=document.version_no,
+                ordinal=ordinal,
+                title=document.title,
+                content=content,
+                document_type=document.document_type,
+                source_name=document.source_name,
+                product_code=document.product_code,
+            )
+            for ordinal, content in enumerate(paragraphs, start=1)
+        ]
+
+    def _serialize_document(self, document: KnowledgeDocument) -> dict[str, Any]:
+        search_tests = sorted(
+            self._repository.list_search_tests(document.id),
+            key=lambda item: item.created_at,
+            reverse=True,
+        )
+        latest_test = search_tests[0] if search_tests else None
+        return {
+            "document_id": document.id,
+            "title": document.title,
+            "document_type": document.document_type,
+            "source_name": document.source_name,
+            "product_code": document.product_code,
+            "status": document.status,
+            "version_no": document.version_no,
+            "chunk_count": document.chunk_count,
+            "created_by": document.created_by,
+            "created_at": document.created_at,
+            "updated_at": document.updated_at,
+            "indexed_at": document.indexed_at,
+            "published_at": document.published_at,
+            "search_test_count": len(search_tests),
+            "last_search_test_at": (
+                latest_test.created_at if latest_test is not None else None
+            ),
+            "last_search_test_passed": (
+                latest_test.passed if latest_test is not None else None
+            ),
+            "excerpt": document.content[:160],
+        }
+
+    @staticmethod
+    def _serialize_search_test(search_test: KnowledgeSearchTest) -> dict[str, Any]:
+        return {
+            "test_id": search_test.id,
+            "document_id": search_test.document_id,
+            "query": search_test.query,
+            "hit_count": search_test.hit_count,
+            "top_score": search_test.top_score,
+            "passed": search_test.passed,
+            "tested_by": search_test.tested_by,
+            "created_at": search_test.created_at,
+        }

+ 1 - 0
backend/src/zbt/domains/service_request/__init__.py

@@ -0,0 +1 @@
+"""保险服务申请领域。"""

+ 34 - 0
backend/src/zbt/domains/service_request/models.py

@@ -0,0 +1,34 @@
+from dataclasses import dataclass
+from datetime import datetime
+
+
+@dataclass(frozen=True, slots=True)
+class MaterialRecognition:
+    id: str
+    user_id: str
+    policy_id: str
+    material_type: str
+    source_name: str
+    media_type: str
+    fields: dict[str, str]
+    confidence: float
+    warnings: tuple[str, ...]
+    status: str
+    created_at: datetime
+    confirmed_at: datetime | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class ServiceRequest:
+    id: str
+    request_no: str
+    user_id: str
+    policy_id: str
+    request_type: str
+    recognition_id: str
+    confirmed_fields: dict[str, str]
+    status: str
+    submitted_at: datetime
+    reviewed_by: str | None = None
+    review_note: str | None = None
+    reviewed_at: datetime | None = None

+ 19 - 0
backend/src/zbt/domains/service_request/preprocessing.py

@@ -0,0 +1,19 @@
+from dataclasses import dataclass
+from typing import Protocol
+
+
+@dataclass(frozen=True, slots=True)
+class ProcessedMaterial:
+    content: bytes
+    media_type: str
+    width: int | None = None
+    height: int | None = None
+
+
+class MaterialPreprocessor(Protocol):
+    def process(self, *, content: bytes, media_type: str) -> ProcessedMaterial: ...
+
+
+class PassthroughMaterialPreprocessor:
+    def process(self, *, content: bytes, media_type: str) -> ProcessedMaterial:
+        return ProcessedMaterial(content=content, media_type=media_type)

+ 40 - 0
backend/src/zbt/domains/service_request/repository.py

@@ -0,0 +1,40 @@
+from typing import Protocol
+
+from zbt.domains.service_request.models import MaterialRecognition, ServiceRequest
+
+
+class ServiceRequestRepository(Protocol):
+    def save_recognition(self, recognition: MaterialRecognition) -> None: ...
+
+    def get_recognition(self, recognition_id: str) -> MaterialRecognition | None: ...
+
+    def save_request(self, request: ServiceRequest) -> None: ...
+
+    def get_request(self, request_id: str) -> ServiceRequest | None: ...
+
+    def list_requests(self, user_id: str | None = None) -> list[ServiceRequest]: ...
+
+
+class InMemoryServiceRequestRepository:
+    def __init__(self) -> None:
+        self._recognitions: dict[str, MaterialRecognition] = {}
+        self._requests: dict[str, ServiceRequest] = {}
+
+    def save_recognition(self, recognition: MaterialRecognition) -> None:
+        self._recognitions[recognition.id] = recognition
+
+    def get_recognition(self, recognition_id: str) -> MaterialRecognition | None:
+        return self._recognitions.get(recognition_id)
+
+    def save_request(self, request: ServiceRequest) -> None:
+        self._requests[request.id] = request
+
+    def get_request(self, request_id: str) -> ServiceRequest | None:
+        return self._requests.get(request_id)
+
+    def list_requests(self, user_id: str | None = None) -> list[ServiceRequest]:
+        return [
+            request
+            for request in self._requests.values()
+            if user_id is None or request.user_id == user_id
+        ]

+ 282 - 0
backend/src/zbt/domains/service_request/service.py

@@ -0,0 +1,282 @@
+"""保单服务申请与多模态材料识别。
+
+用户从自己的有效保单发起服务,上传的图片先经过格式校验和标准化,再交给
+百炼多模态模型提取字段。模型结果必须由用户确认后才能生成正式服务申请;
+识别可信度和原始图片只在管理端审核视图中展示。
+"""
+
+from collections.abc import Callable
+from dataclasses import replace
+from datetime import datetime
+from typing import Any
+
+from zbt.core.errors import AppError
+from zbt.core.identifiers import new_ulid
+from zbt.domains.enrollment.repository import EnrollmentRepository
+from zbt.domains.identity.models import H5User
+from zbt.domains.service_request.models import MaterialRecognition, ServiceRequest
+from zbt.domains.service_request.preprocessing import (
+    MaterialPreprocessor,
+    PassthroughMaterialPreprocessor,
+)
+from zbt.domains.service_request.repository import ServiceRequestRepository
+from zbt.domains.service_request.storage import (
+    InMemoryMaterialStorage,
+    MaterialStorage,
+    StoredMaterial,
+)
+from zbt.domains.service_request.vision import MaterialVisionGateway
+
+
+class ServiceRequestService:
+    """协调保单校验、图片处理、模型识别、用户确认和运营审核。"""
+
+    def __init__(
+        self,
+        repository: ServiceRequestRepository,
+        enrollment_repository: EnrollmentRepository,
+        vision_gateway: MaterialVisionGateway,
+        clock: Callable[[], datetime],
+        material_storage: MaterialStorage | None = None,
+        material_preprocessor: MaterialPreprocessor | None = None,
+    ) -> None:
+        self._repository = repository
+        self._enrollments = enrollment_repository
+        self._vision = vision_gateway
+        self._clock = clock
+        self._materials = material_storage or InMemoryMaterialStorage()
+        self._preprocessor = (
+            material_preprocessor or PassthroughMaterialPreprocessor()
+        )
+
+    def recognize_material(
+        self,
+        user: H5User,
+        *,
+        policy_id: str,
+        material_type: str,
+        source_name: str,
+        media_type: str,
+        content: bytes,
+    ) -> dict[str, Any]:
+        """识别一张属于当前用户有效保单的服务材料。
+
+        返回的是“待确认结果”,并不直接创建服务申请,避免模型识别错误未经
+        用户核对就进入后台业务流程。
+        """
+
+        self._require_user_policy(user.id, policy_id)
+        if media_type not in {"image/jpeg", "image/png", "image/webp"}:
+            raise AppError("MATERIAL_TYPE_UNSUPPORTED", "仅支持JPG、PNG和WebP图片", 422)
+        if not content:
+            raise AppError("MATERIAL_EMPTY", "上传材料不能为空", 422)
+        # 预处理器会验证图片真实性、尺寸和格式,并输出适合模型读取的标准图片。
+        processed = self._preprocessor.process(
+            content=content,
+            media_type=media_type,
+        )
+        extraction = self._vision.recognize(
+            content=processed.content,
+            media_type=processed.media_type,
+            material_type=material_type,
+        )
+        recognition_id = new_ulid()
+        recognition = MaterialRecognition(
+            id=recognition_id,
+            user_id=user.id,
+            policy_id=policy_id,
+            material_type=material_type,
+            source_name=source_name,
+            media_type=processed.media_type,
+            fields=dict(extraction.fields),
+            confidence=extraction.confidence,
+            warnings=extraction.warnings,
+            status="PENDING_CONFIRMATION",
+            created_at=self._clock(),
+        )
+        # 原图/标准图保存在材料存储中,结构化识别结果保存在业务数据库中。
+        self._materials.save(recognition_id, processed.content)
+        try:
+            self._repository.save_recognition(recognition)
+        except Exception:
+            self._materials.delete(recognition_id)
+            raise
+        return self._recognition_data(recognition)
+
+    def submit_request(
+        self,
+        user: H5User,
+        *,
+        policy_id: str,
+        request_type: str,
+        recognition_id: str,
+        confirmed_fields: dict[str, str],
+    ) -> dict[str, Any]:
+        """使用用户核对后的字段创建正式服务申请。"""
+
+        self._require_user_policy(user.id, policy_id)
+        recognition = self._repository.get_recognition(recognition_id)
+        if (
+            recognition is None
+            or recognition.user_id != user.id
+            or recognition.policy_id != policy_id
+        ):
+            raise AppError("MATERIAL_RECOGNITION_NOT_FOUND", "未找到材料识别结果", 404)
+        if recognition.status != "PENDING_CONFIRMATION":
+            raise AppError("MATERIAL_ALREADY_CONFIRMED", "材料识别结果已确认", 409)
+        required = {"invoice_no", "hospital_name", "amount_yuan", "contact_mobile"}
+        missing = sorted(
+            field for field in required if not confirmed_fields.get(field, "").strip()
+        )
+        if missing:
+            raise AppError(
+                "SERVICE_REQUEST_FIELDS_INCOMPLETE",
+                "服务申请信息不完整",
+                422,
+                details={"missing_fields": missing},
+            )
+        now = self._clock()
+        self._repository.save_recognition(
+            replace(recognition, status="CONFIRMED", confirmed_at=now)
+        )
+        request = ServiceRequest(
+            id=new_ulid(),
+            request_no=f"ZBT-SVC-{now:%Y%m%d}-{new_ulid()[-4:]}",
+            user_id=user.id,
+            policy_id=policy_id,
+            request_type=request_type,
+            recognition_id=recognition_id,
+            confirmed_fields={
+                key: str(value).strip() for key, value in confirmed_fields.items()
+            },
+            status="SUBMITTED",
+            submitted_at=now,
+        )
+        self._repository.save_request(request)
+        return self._request_data(request)
+
+    def list_user_requests(self, user: H5User) -> dict[str, Any]:
+        return self._list_requests(user.id)
+
+    def list_all_requests(self) -> dict[str, Any]:
+        return self._list_requests(include_audit=True)
+
+    def get_request_material(self, request_id: str) -> tuple[StoredMaterial, str, str]:
+        """读取申请对应的材料,供有权限的管理端审核人员查看。"""
+
+        request = self._repository.get_request(request_id)
+        if request is None:
+            raise AppError("SERVICE_REQUEST_NOT_FOUND", "未找到服务申请", 404)
+        recognition = self._repository.get_recognition(request.recognition_id)
+        if recognition is None:
+            raise AppError("MATERIAL_RECOGNITION_NOT_FOUND", "未找到材料识别记录", 404)
+        material = self._materials.load(recognition.id)
+        if material is None:
+            raise AppError("SERVICE_MATERIAL_NOT_FOUND", "该申请未留存原始材料", 404)
+        return material, recognition.media_type, recognition.source_name
+
+    def review_request(
+        self,
+        request_id: str,
+        *,
+        decision: str,
+        reviewer_id: str,
+    ) -> dict[str, Any]:
+        """把待处理申请更新为受理或不受理;已处理申请不能重复审核。"""
+
+        request = self._repository.get_request(request_id)
+        if request is None:
+            raise AppError("SERVICE_REQUEST_NOT_FOUND", "未找到服务申请", 404)
+        if request.status != "SUBMITTED":
+            raise AppError("SERVICE_REQUEST_ALREADY_REVIEWED", "服务申请已完成审核", 409)
+        if decision not in {"ACCEPTED", "REJECTED"}:
+            raise AppError("SERVICE_REQUEST_DECISION_INVALID", "审核结论无效", 422)
+        reviewed = replace(
+            request,
+            status=decision,
+            reviewed_by=reviewer_id,
+            review_note=None if decision == "ACCEPTED" else "不满足赔付条件",
+            reviewed_at=self._clock(),
+        )
+        self._repository.save_request(reviewed)
+        return self._request_data(reviewed, include_audit=True)
+
+    def _list_requests(
+        self,
+        user_id: str | None = None,
+        *,
+        include_audit: bool = False,
+    ) -> dict[str, Any]:
+        requests = sorted(
+            self._repository.list_requests(user_id),
+            key=lambda item: item.submitted_at,
+            reverse=True,
+        )
+        return {
+            "items": [
+                self._request_data(request, include_audit=include_audit)
+                for request in requests
+            ],
+            "total": len(requests),
+        }
+
+    def _require_user_policy(self, user_id: str, policy_id: str) -> None:
+        policy = next(
+            (
+                item
+                for item in self._enrollments.list_policies(user_id)
+                if item.id == policy_id and item.status == "ACTIVE"
+            ),
+            None,
+        )
+        if policy is None:
+            raise AppError("POLICY_NOT_FOUND", "未找到可申请服务的有效保单", 404)
+
+    @staticmethod
+    def _recognition_data(recognition: MaterialRecognition) -> dict[str, Any]:
+        return {
+            "recognition_id": recognition.id,
+            "policy_id": recognition.policy_id,
+            "material_type": recognition.material_type,
+            "source_name": recognition.source_name,
+            "fields": recognition.fields,
+            "warnings": list(recognition.warnings),
+            "status": recognition.status,
+            "created_at": recognition.created_at,
+            "confirmed_at": recognition.confirmed_at,
+        }
+
+    def _request_data(
+        self,
+        request: ServiceRequest,
+        *,
+        include_audit: bool = False,
+    ) -> dict[str, Any]:
+        data: dict[str, Any] = {
+            "request_id": request.id,
+            "request_no": request.request_no,
+            "user_id": request.user_id,
+            "policy_id": request.policy_id,
+            "request_type": request.request_type,
+            "recognition_id": request.recognition_id,
+            "confirmed_fields": request.confirmed_fields,
+            "status": request.status,
+            "submitted_at": request.submitted_at,
+            "reviewed_by": request.reviewed_by,
+            "review_note": request.review_note,
+            "reviewed_at": request.reviewed_at,
+        }
+        if include_audit:
+            recognition = self._repository.get_recognition(request.recognition_id)
+            data["recognition"] = (
+                {
+                    "source_name": recognition.source_name,
+                    "media_type": recognition.media_type,
+                    "confidence": recognition.confidence,
+                    "warnings": list(recognition.warnings),
+                    "material_available": self._materials.exists(recognition.id),
+                }
+                if recognition is not None
+                else None
+            )
+        return data

+ 35 - 0
backend/src/zbt/domains/service_request/storage.py

@@ -0,0 +1,35 @@
+from dataclasses import dataclass
+from typing import Protocol
+
+
+@dataclass(frozen=True, slots=True)
+class StoredMaterial:
+    content: bytes
+
+
+class MaterialStorage(Protocol):
+    def save(self, recognition_id: str, content: bytes) -> None: ...
+
+    def load(self, recognition_id: str) -> StoredMaterial | None: ...
+
+    def exists(self, recognition_id: str) -> bool: ...
+
+    def delete(self, recognition_id: str) -> None: ...
+
+
+class InMemoryMaterialStorage:
+    def __init__(self) -> None:
+        self._items: dict[str, bytes] = {}
+
+    def save(self, recognition_id: str, content: bytes) -> None:
+        self._items[recognition_id] = bytes(content)
+
+    def load(self, recognition_id: str) -> StoredMaterial | None:
+        content = self._items.get(recognition_id)
+        return StoredMaterial(content) if content is not None else None
+
+    def exists(self, recognition_id: str) -> bool:
+        return recognition_id in self._items
+
+    def delete(self, recognition_id: str) -> None:
+        self._items.pop(recognition_id, None)

+ 19 - 0
backend/src/zbt/domains/service_request/vision.py

@@ -0,0 +1,19 @@
+from dataclasses import dataclass
+from typing import Protocol
+
+
+@dataclass(frozen=True, slots=True)
+class MaterialExtraction:
+    fields: dict[str, str]
+    confidence: float
+    warnings: tuple[str, ...]
+
+
+class MaterialVisionGateway(Protocol):
+    def recognize(
+        self,
+        *,
+        content: bytes,
+        media_type: str,
+        material_type: str,
+    ) -> MaterialExtraction: ...

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott