Prechádzať zdrojové kódy

fix:调整项目目录

yangxiaolong 1 mesiac pred
rodič
commit
cd9621d1cc
100 zmenil súbory, kde vykonal 88 pridanie a 10370 odobranie
  1. 1 0
      backend/.idea/backend.iml
  2. 13 8
      backend/README.md
  3. 0 3
      backend/alembic.ini
  4. 0 1
      backend/app/__init__.py
  5. 0 1
      backend/app/api/__init__.py
  6. 0 23
      backend/app/api/responses.py
  7. 0 1
      backend/app/api/v1/__init__.py
  8. 0 118
      backend/app/api/v1/admin_auth.py
  9. 0 553
      backend/app/api/v1/admin_business.py
  10. 0 209
      backend/app/api/v1/agent.py
  11. 0 207
      backend/app/api/v1/enrollment.py
  12. 0 140
      backend/app/api/v1/h5_auth.py
  13. 0 41
      backend/app/api/v1/h5_products.py
  14. 0 17
      backend/app/api/v1/system.py
  15. 0 1
      backend/app/commands/__init__.py
  16. 0 57
      backend/app/commands/build_analytics.py
  17. 0 175
      backend/app/commands/evaluate_agents.py
  18. 0 59
      backend/app/commands/init_databases.py
  19. 0 32
      backend/app/commands/reset_cache.py
  20. 0 509
      backend/app/commands/seed.py
  21. 0 583
      backend/app/commands/seed_business_data.py
  22. 0 56
      backend/app/commands/verify_seed.py
  23. 0 202
      backend/app/commands/verify_stage_one.py
  24. 0 1
      backend/app/core/__init__.py
  25. 0 107
      backend/app/core/config.py
  26. 0 11
      backend/app/core/errors.py
  27. 0 9
      backend/app/core/identifiers.py
  28. 0 12
      backend/app/core/passwords.py
  29. 0 129
      backend/app/core/security.py
  30. 0 1
      backend/app/domains/__init__.py
  31. 0 1
      backend/app/domains/agent/__init__.py
  32. 0 40
      backend/app/domains/agent/models.py
  33. 0 58
      backend/app/domains/agent/repository.py
  34. 0 32
      backend/app/domains/agent/runtime.py
  35. 0 541
      backend/app/domains/agent/service.py
  36. 0 526
      backend/app/domains/agent/tools.py
  37. 0 1
      backend/app/domains/attribution/__init__.py
  38. 0 46
      backend/app/domains/attribution/models.py
  39. 0 73
      backend/app/domains/attribution/repository.py
  40. 0 174
      backend/app/domains/attribution/service.py
  41. 0 1
      backend/app/domains/catalog/__init__.py
  42. 0 59
      backend/app/domains/catalog/models.py
  43. 0 56
      backend/app/domains/catalog/repository.py
  44. 0 608
      backend/app/domains/catalog/service.py
  45. 0 1
      backend/app/domains/enrollment/__init__.py
  46. 0 124
      backend/app/domains/enrollment/models.py
  47. 0 178
      backend/app/domains/enrollment/repository.py
  48. 0 557
      backend/app/domains/enrollment/service.py
  49. 0 83
      backend/app/domains/enrollment/tasks.py
  50. 0 1
      backend/app/domains/identity/__init__.py
  51. 0 49
      backend/app/domains/identity/models.py
  52. 0 115
      backend/app/domains/identity/repository.py
  53. 0 495
      backend/app/domains/identity/service.py
  54. 0 6
      backend/app/harness/__init__.py
  55. 0 13
      backend/app/harness/assets/prompts/customer.md
  56. 0 28
      backend/app/harness/assets/prompts/operation.md
  57. 0 10
      backend/app/harness/assets/skills/enrollment-guidance/SKILL.md
  58. 0 10
      backend/app/harness/assets/skills/insurance-consultation/SKILL.md
  59. 0 10
      backend/app/harness/assets/skills/operation-insights/SKILL.md
  60. 0 31
      backend/app/harness/events.py
  61. 0 245
      backend/app/harness/kernel.py
  62. 0 31
      backend/app/harness/model_gateway.py
  63. 0 66
      backend/app/harness/policy.py
  64. 0 155
      backend/app/harness/registries.py
  65. 0 138
      backend/app/harness/schemas.py
  66. 0 127
      backend/app/harness/tooling.py
  67. 0 1
      backend/app/infrastructure/__init__.py
  68. 0 1
      backend/app/infrastructure/mysql/__init__.py
  69. 0 68
      backend/app/infrastructure/mysql/agent_models.py
  70. 0 157
      backend/app/infrastructure/mysql/agent_repositories.py
  71. 0 85
      backend/app/infrastructure/mysql/analytics_models.py
  72. 0 173
      backend/app/infrastructure/mysql/attribution_repositories.py
  73. 0 519
      backend/app/infrastructure/mysql/core_models.py
  74. 0 396
      backend/app/infrastructure/mysql/enrollment_repositories.py
  75. 0 367
      backend/app/infrastructure/mysql/repositories.py
  76. 0 60
      backend/app/infrastructure/mysql/sessions.py
  77. 0 40
      backend/app/infrastructure/mysql/types.py
  78. 0 1
      backend/app/infrastructure/redis/__init__.py
  79. 0 137
      backend/app/infrastructure/redis/agent_state.py
  80. 0 50
      backend/app/infrastructure/redis/keys.py
  81. 0 292
      backend/app/main.py
  82. 3 3
      backend/migrations/agent/env.py
  83. 1 1
      backend/migrations/agent/versions/agent_0001_initial.py
  84. 3 3
      backend/migrations/analytics/env.py
  85. 1 1
      backend/migrations/analytics/versions/analytics_0001_initial.py
  86. 3 3
      backend/migrations/core/env.py
  87. 1 1
      backend/migrations/core/versions/core_0001_initial.py
  88. 1 1
      backend/migrations/core/versions/core_0002_enrollment_flow.py
  89. 1 1
      backend/migrations/core/versions/core_0003_attribution.py
  90. 1 1
      backend/migrations/core/versions/core_0004_product_management.py
  91. 9 3
      backend/pyproject.toml
  92. 2 2
      backend/tests/integration/test_real_mysql_api.py
  93. 5 5
      backend/tests/integration/test_real_redis.py
  94. 8 8
      backend/tests/integration/test_sqlalchemy_repositories.py
  95. 5 5
      backend/tests/test_admin_auth.py
  96. 11 11
      backend/tests/test_agent_api.py
  97. 1 1
      backend/tests/test_agent_evaluations.py
  98. 3 3
      backend/tests/test_api_contract.py
  99. 2 2
      backend/tests/test_application_wiring.py
  100. 13 13
      backend/tests/test_attribution.py

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

@@ -2,6 +2,7 @@
 <module type="PYTHON_MODULE" version="4">
   <component name="NewModuleRootManager">
     <content url="file://$MODULE_DIR$">
+      <sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
       <excludeFolder url="file://$MODULE_DIR$/.venv" />
     </content>
     <orderEntry type="jdk" jdkName="uv (backend)" jdkType="Python SDK" />

+ 13 - 8
backend/README.md

@@ -20,13 +20,15 @@
 
 ```text
 backend/
-├── app/
-│   ├── api/               # HTTP 路由、请求参数和响应格式
-│   ├── commands/          # 初始化数据等命令行工具
-│   ├── core/              # 配置、安全、密码和公共错误
-│   ├── domains/           # 业务模型、仓储接口和业务服务
-│   ├── infrastructure/    # MySQL、Redis 等基础设施实现
-│   └── main.py            # FastAPI 应用入口
+├── src/
+│   └── zbt/               # 可安装的智保通 Python 包
+│       ├── api/           # HTTP 路由、请求参数和响应格式
+│       ├── commands/      # 初始化数据等命令行工具
+│       ├── core/          # 配置、安全、密码和公共错误
+│       ├── domains/       # 业务模型、仓储接口和业务服务
+│       ├── harness/       # Agent 内核、策略和资源
+│       ├── infrastructure/# MySQL、Redis 等基础设施实现
+│       └── main.py        # FastAPI 应用入口
 ├── migrations/            # 三个数据库的 Alembic 迁移
 ├── tests/                 # 自动化测试
 ├── alembic.ini
@@ -34,6 +36,9 @@ backend/
 └── uv.lock
 ```
 
+项目采用 `src` 布局,`uv sync` 会以可编辑模式安装 `zbt` 包;执行
+`uv build` 可以生成 wheel 和源码包。
+
 代码主要调用方向:
 
 ```text
@@ -102,7 +107,7 @@ uv sync --locked
 
 ```powershell
 Set-Location C:\Users\yxl\Desktop\new_harness\01-zhibaotong-smart-enrollment\backend
-uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
+uv run uvicorn zbt.main:app --reload --host 127.0.0.1 --port 8000
 ```
 
 启动后访问:

+ 0 - 3
backend/alembic.ini

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

+ 0 - 1
backend/app/__init__.py

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

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

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

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

@@ -1,23 +0,0 @@
-"""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(),
-        },
-    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -1,11 +0,0 @@
-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

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

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

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

@@ -1,12 +0,0 @@
-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)

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

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

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

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

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

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

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

@@ -1,40 +0,0 @@
-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

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

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

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

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

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

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

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

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

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

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

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

@@ -1,46 +0,0 @@
-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

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

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

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

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

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

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

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

@@ -1,59 +0,0 @@
-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

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

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

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

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

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

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

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

@@ -1,124 +0,0 @@
-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

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

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

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

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

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

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

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

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

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

@@ -1,49 +0,0 @@
-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)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 0 - 292
backend/app/main.py

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

+ 3 - 3
backend/migrations/agent/env.py

@@ -1,8 +1,8 @@
 from alembic import context
 
-from app.core.config import Settings
-from app.infrastructure.mysql.agent_models import AgentBase
-from app.infrastructure.mysql.sessions import DatabaseName, build_mysql_url
+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

+ 1 - 1
backend/migrations/agent/versions/agent_0001_initial.py

@@ -2,7 +2,7 @@
 
 from alembic import op
 
-from app.infrastructure.mysql.agent_models import AgentBase
+from zbt.infrastructure.mysql.agent_models import AgentBase
 
 revision = "agent_0001"
 down_revision = None

+ 3 - 3
backend/migrations/analytics/env.py

@@ -1,8 +1,8 @@
 from alembic import context
 
-from app.core.config import Settings
-from app.infrastructure.mysql.analytics_models import AnalyticsBase
-from app.infrastructure.mysql.sessions import DatabaseName, build_mysql_url
+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

+ 1 - 1
backend/migrations/analytics/versions/analytics_0001_initial.py

@@ -2,7 +2,7 @@
 
 from alembic import op
 
-from app.infrastructure.mysql.analytics_models import AnalyticsBase
+from zbt.infrastructure.mysql.analytics_models import AnalyticsBase
 
 revision = "analytics_0001"
 down_revision = None

+ 3 - 3
backend/migrations/core/env.py

@@ -1,8 +1,8 @@
 from alembic import context
 
-from app.core.config import Settings
-from app.infrastructure.mysql.core_models import CoreBase
-from app.infrastructure.mysql.sessions import DatabaseName, build_mysql_url
+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

+ 1 - 1
backend/migrations/core/versions/core_0001_initial.py

@@ -2,7 +2,7 @@
 
 from alembic import op
 
-from app.infrastructure.mysql.core_models import CoreBase
+from zbt.infrastructure.mysql.core_models import CoreBase
 
 revision = "core_0001"
 down_revision = None

+ 1 - 1
backend/migrations/core/versions/core_0002_enrollment_flow.py

@@ -2,7 +2,7 @@
 
 from alembic import op
 
-from app.infrastructure.mysql.core_models import CoreBase
+from zbt.infrastructure.mysql.core_models import CoreBase
 
 revision = "core_0002"
 down_revision = "core_0001"

+ 1 - 1
backend/migrations/core/versions/core_0003_attribution.py

@@ -2,7 +2,7 @@
 
 from alembic import op
 
-from app.infrastructure.mysql.core_models import CoreBase
+from zbt.infrastructure.mysql.core_models import CoreBase
 
 revision = "core_0003"
 down_revision = "core_0002"

+ 1 - 1
backend/migrations/core/versions/core_0004_product_management.py

@@ -3,7 +3,7 @@
 import sqlalchemy as sa
 from alembic import op
 
-from app.infrastructure.mysql.core_models import CoreBase
+from zbt.infrastructure.mysql.core_models import CoreBase
 
 revision = "core_0004"
 down_revision = "core_0003"

+ 9 - 3
backend/pyproject.toml

@@ -22,6 +22,10 @@ dependencies = [
   "langsmith>=0.4,<1",
 ]
 
+[build-system]
+requires = ["hatchling>=1.27,<2"]
+build-backend = "hatchling.build"
+
 [dependency-groups]
 dev = [
   "coverage[toml]>=7.9,<8",
@@ -33,15 +37,17 @@ dev = [
 ]
 
 [tool.uv]
-package = false
+package = true
 
 [[tool.uv.index]]
-url = "https://pypi.tuna.tsinghua.edu.cn/simple"
+url = "https://mirrors.aliyun.com/pypi/simple/"
 default = true
 
+[tool.hatch.build.targets.wheel]
+packages = ["src/zbt"]
+
 [tool.pytest.ini_options]
 addopts = "-q"
-pythonpath = ["."]
 testpaths = ["tests"]
 
 [tool.ruff]

+ 2 - 2
backend/tests/integration/test_real_mysql_api.py

@@ -4,8 +4,8 @@ from datetime import UTC, datetime
 import pytest
 from fastapi.testclient import TestClient
 
-from app.core.config import Settings
-from app.main import create_app
+from zbt.core.config import Settings
+from zbt.main import create_app
 
 settings = Settings()
 TEST_DATABASES = {

+ 5 - 5
backend/tests/integration/test_real_redis.py

@@ -3,11 +3,11 @@ from datetime import UTC, datetime
 import pytest
 from redis import Redis
 
-from app.commands.reset_cache import clear_project_cache
-from app.core.config import Settings
-from app.harness.events import RuntimeEvent
-from app.infrastructure.redis.agent_state import RedisAgentStateStore
-from app.infrastructure.redis.keys import RedisKeyBuilder
+from zbt.commands.reset_cache import clear_project_cache
+from zbt.core.config import Settings
+from zbt.harness.events import RuntimeEvent
+from zbt.infrastructure.redis.agent_state import RedisAgentStateStore
+from zbt.infrastructure.redis.keys import RedisKeyBuilder
 
 settings = Settings()
 pytestmark = pytest.mark.skipif(

+ 8 - 8
backend/tests/integration/test_sqlalchemy_repositories.py

@@ -6,17 +6,17 @@ from sqlalchemy import create_engine, func, select
 from sqlalchemy.orm import Session, sessionmaker
 from sqlalchemy.pool import StaticPool
 
-from app.core.config import Settings
-from app.core.passwords import PasswordService
-from app.domains.catalog.models import Plan, Product, ProductVersion
-from app.domains.catalog.repository import InMemoryCatalogRepository
-from app.domains.identity.models import AdminUser, H5User
-from app.infrastructure.mysql.core_models import CoreBase, PlanRecord
-from app.infrastructure.mysql.repositories import (
+from zbt.core.config import Settings
+from zbt.core.passwords import PasswordService
+from zbt.domains.catalog.models import Plan, Product, ProductVersion
+from zbt.domains.catalog.repository import InMemoryCatalogRepository
+from zbt.domains.identity.models import AdminUser, H5User
+from zbt.infrastructure.mysql.core_models import CoreBase, PlanRecord
+from zbt.infrastructure.mysql.repositories import (
     SqlAlchemyCatalogRepository,
     SqlAlchemyIdentityRepository,
 )
-from app.main import create_app
+from zbt.main import create_app
 
 
 def test_sqlalchemy_repositories_round_trip_identity_and_catalog() -> None:

+ 5 - 5
backend/tests/test_admin_auth.py

@@ -1,10 +1,10 @@
 from fastapi.testclient import TestClient
 
-from app.core.config import Settings
-from app.core.passwords import PasswordService
-from app.domains.identity.models import AdminUser
-from app.domains.identity.repository import InMemoryIdentityRepository
-from app.main import create_app
+from zbt.core.config import Settings
+from zbt.core.passwords import PasswordService
+from zbt.domains.identity.models import AdminUser
+from zbt.domains.identity.repository import InMemoryIdentityRepository
+from zbt.main import create_app
 
 
 def test_admin_login_returns_roles_permissions_and_data_scope() -> None:

+ 11 - 11
backend/tests/test_agent_api.py

@@ -3,17 +3,17 @@ from datetime import UTC, datetime
 
 from fastapi.testclient import TestClient
 
-from app.core.config import Settings
-from app.core.passwords import PasswordService
-from app.domains.agent.repository import InMemoryAgentRepository
-from app.domains.agent.runtime import AgentReply
-from app.domains.catalog.repository import InMemoryCatalogRepository
-from app.domains.enrollment.repository import InMemoryEnrollmentRepository
-from app.domains.identity.models import AdminUser
-from app.domains.identity.repository import InMemoryIdentityRepository
-from app.harness.kernel import AgentInvocation
-from app.infrastructure.redis.agent_state import InMemoryAgentStateStore
-from app.main import create_app
+from zbt.core.config import Settings
+from zbt.core.passwords import PasswordService
+from zbt.domains.agent.repository import InMemoryAgentRepository
+from zbt.domains.agent.runtime import AgentReply
+from zbt.domains.catalog.repository import InMemoryCatalogRepository
+from zbt.domains.enrollment.repository import InMemoryEnrollmentRepository
+from zbt.domains.identity.models import AdminUser
+from zbt.domains.identity.repository import InMemoryIdentityRepository
+from zbt.harness.kernel import AgentInvocation
+from zbt.infrastructure.redis.agent_state import InMemoryAgentStateStore
+from zbt.main import create_app
 
 
 class FixedAgentRuntime:

+ 1 - 1
backend/tests/test_agent_evaluations.py

@@ -1,4 +1,4 @@
-from app.commands.evaluate_agents import load_cases
+from zbt.commands.evaluate_agents import load_cases
 
 
 def test_smoke_and_full_agent_evaluation_datasets_cover_both_personas() -> None:

+ 3 - 3
backend/tests/test_api_contract.py

@@ -2,9 +2,9 @@ from datetime import UTC, datetime
 
 from fastapi.testclient import TestClient
 
-from app.domains.catalog.repository import InMemoryCatalogRepository
-from app.domains.identity.repository import InMemoryIdentityRepository
-from app.main import create_app
+from zbt.domains.catalog.repository import InMemoryCatalogRepository
+from zbt.domains.identity.repository import InMemoryIdentityRepository
+from zbt.main import create_app
 
 
 def build_client() -> TestClient:

+ 2 - 2
backend/tests/test_application_wiring.py

@@ -1,8 +1,8 @@
-from app.infrastructure.mysql.repositories import (
+from zbt.infrastructure.mysql.repositories import (
     SqlAlchemyCatalogRepository,
     SqlAlchemyIdentityRepository,
 )
-from app.main import create_app
+from zbt.main import create_app
 
 
 def test_default_application_uses_real_mysql_repository_adapters() -> None:

+ 13 - 13
backend/tests/test_attribution.py

@@ -2,23 +2,23 @@ from datetime import UTC, datetime
 
 from fastapi.testclient import TestClient
 
-from app.commands.seed import build_seed_manifest
-from app.core.config import Settings
-from app.core.passwords import PasswordService
-from app.domains.agent.repository import InMemoryAgentRepository
-from app.domains.attribution.models import (
+from zbt.commands.seed import build_seed_manifest
+from zbt.core.config import Settings
+from zbt.core.passwords import PasswordService
+from zbt.domains.agent.repository import InMemoryAgentRepository
+from zbt.domains.attribution.models import (
     OrderAttribution,
     PromotionCode,
     Salesperson,
 )
-from app.domains.attribution.repository import InMemoryAttributionRepository
-from app.domains.attribution.service import AttributionService
-from app.domains.catalog.repository import InMemoryCatalogRepository
-from app.domains.enrollment.models import EnrollmentOrder
-from app.domains.enrollment.repository import InMemoryEnrollmentRepository
-from app.domains.identity.models import AdminUser, H5User
-from app.domains.identity.repository import InMemoryIdentityRepository
-from app.main import create_app
+from zbt.domains.attribution.repository import InMemoryAttributionRepository
+from zbt.domains.attribution.service import AttributionService
+from zbt.domains.catalog.repository import InMemoryCatalogRepository
+from zbt.domains.enrollment.models import EnrollmentOrder
+from zbt.domains.enrollment.repository import InMemoryEnrollmentRepository
+from zbt.domains.identity.models import AdminUser, H5User
+from zbt.domains.identity.repository import InMemoryIdentityRepository
+from zbt.main import create_app
 
 NOW = datetime(2026, 7, 26, 9, tzinfo=UTC)
 

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov