From 8698f1f53ac7614816d00e85c7e33ea01fc50a34 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=AC=A7=E9=98=B3=E6=B4=8B?= <2443479321@qq.com>
Date: Tue, 8 Sep 2026 19:17:35 +0800
Subject: [PATCH] =?UTF-8?q?feature:=E5=9F=BA=E6=9C=AC=E6=A1=86=E6=9E=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env | 68 +++
.env.example | 68 +++
.idea/.gitignore | 10 +
.idea/.name | 1 +
.idea/MarsCodeWorkspaceAppSettings.xml | 7 +
.idea/Mutual_Fund.iml | 10 +
.idea/claudeCodeTabState.xml | 20 +
.../inspectionProfiles/profiles_settings.xml | 6 +
.idea/misc.xml | 7 +
.idea/modules.xml | 8 +
.idea/vcs.xml | 6 +
README.md | 67 +++
api/__init__.py | 0
api/deps.py | 27 ++
api/router.py | 10 +
api/routers/__init__.py | 0
api/routers/auth.py | 41 ++
api/routers/health.py | 17 +
config/__init__.py | 0
config/database/__init__.py | 73 +++
config/database/milvus.py | 39 ++
config/database/mysql.py | 51 ++
config/database/neo4j.py | 40 ++
config/database/redis.py | 43 ++
config/deps.py | 26 ++
config/settings.py | 137 ++++++
docker-compose.yml | 60 +++
logs/error.log | 405 ++++++++++++++++
logs/info.log | 414 ++++++++++++++++
main.py | 31 ++
model/__init__.py | 1 +
model/base.py | 6 +
model/sys_config.py | 22 +
model/sys_user.py | 31 ++
repositories/__init__.py | 1 +
repositories/base.py | 48 ++
repositories/sys_config.py | 28 ++
repositories/sys_user.py | 14 +
requirements.txt | 12 +
schemas/__init__.py | 0
schemas/auth.py | 7 +
service/__init__.py | 1 +
service/auth.py | 46 ++
sql/schema.sql | 440 ++++++++++++++++++
test_auth.py | 67 +++
test_llm.py | 118 +++++
test_main.http | 11 +
test_repo.py | 44 ++
tool/__init__.py | 1 +
tool/llm.py | 135 ++++++
utils/__init__.py | 1 +
utils/exceptions.py | 89 ++++
utils/logger.py | 65 +++
utils/request_id.py | 32 ++
utils/response.py | 37 ++
开发计划.md | 371 +++++++++++++++
56 files changed, 3320 insertions(+)
create mode 100644 .env
create mode 100644 .env.example
create mode 100644 .idea/.gitignore
create mode 100644 .idea/.name
create mode 100644 .idea/MarsCodeWorkspaceAppSettings.xml
create mode 100644 .idea/Mutual_Fund.iml
create mode 100644 .idea/claudeCodeTabState.xml
create mode 100644 .idea/inspectionProfiles/profiles_settings.xml
create mode 100644 .idea/misc.xml
create mode 100644 .idea/modules.xml
create mode 100644 .idea/vcs.xml
create mode 100644 README.md
create mode 100644 api/__init__.py
create mode 100644 api/deps.py
create mode 100644 api/router.py
create mode 100644 api/routers/__init__.py
create mode 100644 api/routers/auth.py
create mode 100644 api/routers/health.py
create mode 100644 config/__init__.py
create mode 100644 config/database/__init__.py
create mode 100644 config/database/milvus.py
create mode 100644 config/database/mysql.py
create mode 100644 config/database/neo4j.py
create mode 100644 config/database/redis.py
create mode 100644 config/deps.py
create mode 100644 config/settings.py
create mode 100644 docker-compose.yml
create mode 100644 logs/error.log
create mode 100644 logs/info.log
create mode 100644 main.py
create mode 100644 model/__init__.py
create mode 100644 model/base.py
create mode 100644 model/sys_config.py
create mode 100644 model/sys_user.py
create mode 100644 repositories/__init__.py
create mode 100644 repositories/base.py
create mode 100644 repositories/sys_config.py
create mode 100644 repositories/sys_user.py
create mode 100644 requirements.txt
create mode 100644 schemas/__init__.py
create mode 100644 schemas/auth.py
create mode 100644 service/__init__.py
create mode 100644 service/auth.py
create mode 100644 sql/schema.sql
create mode 100644 test_auth.py
create mode 100644 test_llm.py
create mode 100644 test_main.http
create mode 100644 test_repo.py
create mode 100644 tool/__init__.py
create mode 100644 tool/llm.py
create mode 100644 utils/__init__.py
create mode 100644 utils/exceptions.py
create mode 100644 utils/logger.py
create mode 100644 utils/request_id.py
create mode 100644 utils/response.py
create mode 100644 开发计划.md
diff --git a/.env b/.env
new file mode 100644
index 0000000..edbfa85
--- /dev/null
+++ b/.env
@@ -0,0 +1,68 @@
+# 应用级 Settings 完整配置源(缺失任一键启动即报错)
+APP_ENV=dev
+
+# 数据库启动策略(config/database/__init__.py)
+DB_STRICT_STARTUP=false # true=任一核心库启动失败即阻止应用启动(生产建议开启)
+DB_CONN_RETRIES=3
+DB_RETRY_BACKOFF_SEC=1
+
+# JWT 鉴权(service/auth.py)
+JWT_SECRET=dev-secret-please-change-this-to-a-random-64chars-string!! # 生产必须换成强随机值(≥32字节)
+JWT_ALGORITHM=HS256
+JWT_EXPIRE_SECONDS=86400
+
+# MySQL
+MYSQL_HOST=localhost
+MYSQL_PORT=3306
+MYSQL_USER=root
+MYSQL_PASSWORD=root
+MYSQL_DATABASE=sheep
+MYSQL_POOL_SIZE=5
+MYSQL_MAX_OVERFLOW=10
+MYSQL_POOL_RECYCLE=3600
+MYSQL_CONNECT_TIMEOUT=5
+MYSQL_POOL_TIMEOUT=30
+
+# Redis
+REDIS_HOST=localhost
+REDIS_PORT=6379
+REDIS_DB=0
+REDIS_PASSWORD=
+REDIS_SOCKET_CONNECT_TIMEOUT=5
+REDIS_SOCKET_TIMEOUT=5
+REDIS_HEALTH_CHECK_INTERVAL=30
+REDIS_MAX_CONNECTIONS=100
+
+# Neo4j
+NEO4J_URI=bolt://localhost:7687
+NEO4J_USER=neo4j
+NEO4J_PASSWORD=ouyang200202211.
+NEO4J_MAX_CONNECTION_POOL_SIZE=50
+NEO4J_CONNECTION_ACQUISITION_TIMEOUT=60
+NEO4J_CONNECTION_TIMEOUT=15
+NEO4J_MAX_TRANSACTION_RETRY_TIME=30
+
+# Milvus
+MILVUS_URI=http://localhost:19530
+MILVUS_TOKEN=
+MILVUS_USER=
+MILVUS_PASSWORD=
+MILVUS_DB=default
+MILVUS_CONNECT_TIMEOUT=5
+MILVUS_TIMEOUT=30
+
+# LLM(tool/llm.py 统一客户端)
+LLM_MODE=ollama
+LLM_OLLAMA_BASE=http://localhost:11434
+LLM_OLLAMA_CHAT_MODEL=qwen2.5:7b
+LLM_OLLAMA_EMBED_MODEL=bge-m3
+LLM_API_BASE=https://api.openai.com/v1
+LLM_API_KEY=
+LLM_API_CHAT_MODEL=gpt-4o-mini
+LLM_API_EMBED_MODEL=text-embedding-3-small
+LLM_TEMPERATURE=0.3
+LLM_MAX_TOKENS=1024
+LLM_TIMEOUT=30
+LLM_MAX_RETRIES=3
+LLM_RETRY_BACKOFF_SEC=1
+LLM_FALLBACK_CHAT_MODEL=
\ No newline at end of file
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..8d99260
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,68 @@
+# 复制为 .env 填写真实值;勿提交 .env。settings.py 无硬编码,缺失任一键启动即报错。
+APP_ENV=dev
+
+# 数据库启动策略(config/database/__init__.py)
+DB_STRICT_STARTUP=false # true=任一核心库启动失败即阻止应用启动(生产建议开启)
+DB_CONN_RETRIES=3
+DB_RETRY_BACKOFF_SEC=1
+
+# JWT 鉴权(service/auth.py)
+JWT_SECRET=dev-secret-please-change-this-to-a-random-64chars-string!! # 生产必须换成强随机值(≥32字节)
+JWT_ALGORITHM=HS256
+JWT_EXPIRE_SECONDS=86400
+
+# MySQL
+MYSQL_HOST=localhost
+MYSQL_PORT=3306
+MYSQL_USER=root
+MYSQL_PASSWORD=
+MYSQL_DATABASE=memory
+MYSQL_POOL_SIZE=5
+MYSQL_MAX_OVERFLOW=10
+MYSQL_POOL_RECYCLE=3600
+MYSQL_CONNECT_TIMEOUT=5
+MYSQL_POOL_TIMEOUT=30
+
+# Redis
+REDIS_HOST=localhost
+REDIS_PORT=6379
+REDIS_DB=0
+REDIS_PASSWORD=
+REDIS_SOCKET_CONNECT_TIMEOUT=5
+REDIS_SOCKET_TIMEOUT=5
+REDIS_HEALTH_CHECK_INTERVAL=30
+REDIS_MAX_CONNECTIONS=100
+
+# Neo4j
+NEO4J_URI=bolt://localhost:7687
+NEO4J_USER=neo4j
+NEO4J_PASSWORD=
+NEO4J_MAX_CONNECTION_POOL_SIZE=50
+NEO4J_CONNECTION_ACQUISITION_TIMEOUT=60
+NEO4J_CONNECTION_TIMEOUT=15
+NEO4J_MAX_TRANSACTION_RETRY_TIME=30
+
+# Milvus
+MILVUS_URI=http://localhost:19530
+MILVUS_TOKEN=
+MILVUS_USER=
+MILVUS_PASSWORD=
+MILVUS_DB=
+MILVUS_CONNECT_TIMEOUT=5
+MILVUS_TIMEOUT=30
+
+# LLM(tool/llm.py 统一客户端)
+LLM_MODE=ollama # ollama(本地)/ api(API Key)
+LLM_OLLAMA_BASE=http://localhost:11434
+LLM_OLLAMA_CHAT_MODEL=qwen2.5:7b
+LLM_OLLAMA_EMBED_MODEL=bge-m3
+LLM_API_BASE=https://api.openai.com/v1
+LLM_API_KEY=
+LLM_API_CHAT_MODEL=gpt-4o-mini
+LLM_API_EMBED_MODEL=text-embedding-3-small
+LLM_TEMPERATURE=0.3
+LLM_MAX_TOKENS=1024
+LLM_TIMEOUT=30
+LLM_MAX_RETRIES=3
+LLM_RETRY_BACKOFF_SEC=1
+LLM_FALLBACK_CHAT_MODEL= # 备用模型:主模型失败自动切换(留空则不启用)
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..b6b1ecf
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,10 @@
+# 默认忽略的文件
+/shelf/
+/workspace.xml
+# 已忽略包含查询文件的默认文件夹
+/queries/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
+# 基于编辑器的 HTTP 客户端请求
+/httpRequests/
diff --git a/.idea/.name b/.idea/.name
new file mode 100644
index 0000000..540b297
--- /dev/null
+++ b/.idea/.name
@@ -0,0 +1 @@
+Mutual_Fund
\ No newline at end of file
diff --git a/.idea/MarsCodeWorkspaceAppSettings.xml b/.idea/MarsCodeWorkspaceAppSettings.xml
new file mode 100644
index 0000000..b26fdc6
--- /dev/null
+++ b/.idea/MarsCodeWorkspaceAppSettings.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/Mutual_Fund.iml b/.idea/Mutual_Fund.iml
new file mode 100644
index 0000000..3fbabf5
--- /dev/null
+++ b/.idea/Mutual_Fund.iml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/claudeCodeTabState.xml b/.idea/claudeCodeTabState.xml
new file mode 100644
index 0000000..08b9482
--- /dev/null
+++ b/.idea/claudeCodeTabState.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
new file mode 100644
index 0000000..105ce2d
--- /dev/null
+++ b/.idea/inspectionProfiles/profiles_settings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..7440d61
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..0fc3293
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b31a8ed
--- /dev/null
+++ b/README.md
@@ -0,0 +1,67 @@
+# 智能公募基金系统(开发计划 Phase 0 骨架)
+
+基于《开发计划.md》搭建的分层架构。当前为 **Phase 0 基础设施**:四库懒连接底座 + 统一响应/异常/日志/链路 + 24 张表 DDL + docker-compose。业务 Agent 与前端按计划后续 Phase 落地。
+
+## 目录结构
+
+```
+Memory_demo/
+├── main.py # 应用入口:四库生命周期 + 中间件 + 异常 + 路由
+├── api/ # 路由层(health 已就绪,其余模块随 Phase 加入)
+│ ├── router.py # 路由汇总
+│ └── routers/health.py
+├── service/ # 业务逻辑层(rag/profile/trade/risk/advisor/memory…)
+├── repositories/ # 数据访问层:BaseRepository(CRUD)+ SysConfigRepo(模板),AsyncSession 注入
+├── tool/ # 工具层(文档解析/Embedding/检索/规则/置信度/记忆)
+├── model/ # 数据模型层(Base + ORM:sys_config 起步)
+├── config/ # 配置文件+四库单例(现有)
+│ ├── settings.py # pydantic-settings(env 前缀:MYSQL_/REDIS_/NEO4J_/MILVUS_)
+│ ├── deps.py # FastAPI 依赖注入工厂
+│ └── database/ # mysql / redis / neo4j / milvus 惰性单例
+├── utils/ # 基础设施
+│ ├── response.py # 统一响应 {code,message,data,trace_id} + 错误码(§6.1)
+│ ├── exceptions.py # ApiError 体系 + 全局异常处理器
+│ ├── logger.py # info/error 分文件日志 + 手机号/身份证脱敏
+│ └── request_id.py # trace_id 中间件
+├── sql/schema.sql # 24 张表建表脚本(注释含中文,供 NL2SQL)
+├── docker-compose.yml # 四库一键启动
+├── requirements.txt
+└── .env / .env.example # 连接配置
+```
+
+## 快速启动
+
+```bash
+# 1. 中间件(Milvus 启动较慢,首次约 1-2 分钟)
+docker compose up -d
+
+# 2. 建表
+mysql -uroot -p -h 127.0.0.1 memory_demo < sql/schema.sql
+
+# 3. 配置 .env(复制 .env.example 填写密码,与 docker-compose 保持一致)
+
+# 4. 启动服务
+uvicorn main:app --reload --port 8000
+# Swagger: http://localhost:8000/docs
+# 健康检查: http://localhost:8000/api/health/ready (四库全 ok 返回 200,否则 503 + 明细)
+```
+
+## 约定
+
+- **配置**:`config/settings.py` 无任何硬编码,全部从 `.env` 读取(各子配置各自加载 env_file,进程环境变量优先级高于 `.env`);任一生效键缺失启动即报错(fail-fast),新增键记得同步 `.env` 与 `.env.example`。
+- **数据库连接(懒创建)**:应用启动不连接任何库,四库会话首次访问才建(`redis.client()`/`neo4j.client()`/`milvus.client()`/`mysql.get_engine()`);需要时可手动预热 `python -c "import asyncio,config.database as d; asyncio.run(d.init_db())"`。手动预热会逐库重试 `DB_CONN_RETRIES` 次(指数退避),`DB_STRICT_STARTUP=true` 时重试耗尽抛错。`/api/health/ready` 按需探测各库 `{status, ms}`。Redis 开启 `retry_on_timeout`/`socket_keepalive`,Milvus 操作超时走 `MILVUS_TIMEOUT`。
+- **统一响应**:`{"code":200,"message":"success","data":...,"trace_id":"..."}`;业务错误码见 `utils/response.py` Code(1001 LLM 失败 / 1002 知识库无结果 / 1004 风控拦截 / 1005 适当性不匹配)。
+- **异常路由**:业务抛 `ApiError` 子类,全局处理器转统一响应(400/401/403/404 保留 HTTP 状态码,1001+ 业务码返回 HTTP 200)。
+- **日志**:`logs/info.log`、`logs/error.log` 按天滚动,自动带 trace_id,身份证/手机号脱敏。
+- **四库职责**(开发计划 §1):MySQL 业务 / Milvus 向量 / Neo4j 图谱 / Redis 缓存·会话·事件·锁。
+- **LLM 统一客户端**(`tool/llm.py`):`LLM_MODE=ollama | api` 双模式,底层均走 OpenAI 兼容 `/v1` 端点,业务只调 `chat(messages)` / `embed(texts)`。内置:指数退避重试(1s/2s/4s)、主模型失败自动切 `LLM_FALLBACK_CHAT_MODEL`、再失败抛 `LLMFailError`(§1001)由调用方兜底话术。自检:`python test_llm.py`。
+- **数据访问**:service 不直接写 SQL,统一走 `repositories/`(会话经 `config.deps.get_db` 注入);新表照 `repositories/sys_config.py` 复制一个仓储 + 一个 ORM 模型。
+- **JWT 鉴权**:`POST /api/auth/login`(username/password)签发 token(仅存 user_id+exp,HS256);任意路由加 `Depends(get_current_user)`(`api/deps.py`)即自动校验 Bearer token → 按 user_id 回查 `sys_user` → 注入用户对象(401 无效/过期/不存在,403 禁用)。密码哈希用 stdlib PBKDF2(`service/auth.py`),SEED 测试用户可看 `test_auth.py`。生产务必替换 `JWT_SECRET`(≥32 字节)。
+
+## 下一步(按开发计划)
+
+1. Phase 1:官网 + 注册登录 + 知识库(RAG)+ 客服Agent —— 新增 `api/auth.py`、`api/knowledge.py`、`tool/embedding.py`、`service/rag_service.py`
+2. Phase 2:客户Web端 + 风评问卷 + 画像/记忆(含 memory_unit)
+3. Phase 3:Neo4j 图谱 + 投顾Agent(调仓/深度分析/对比)
+4. Phase 4:风控Agent(规则引擎 + 阻断闭环)
+5. Phase 5:运营后台 + 审计/脱敏/定时任务 + 集成联调
\ No newline at end of file
diff --git a/api/__init__.py b/api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/api/deps.py b/api/deps.py
new file mode 100644
index 0000000..7d75ee1
--- /dev/null
+++ b/api/deps.py
@@ -0,0 +1,27 @@
+"""路由级鉴权依赖:Bearer JWT → 校验 token → 按 user_id 回查 sys_user → 注入用户。"""
+from fastapi import Depends, Request
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from config.deps import get_db
+from model.sys_user import SysUser
+from repositories.sys_user import SysUserRepo
+from service.auth import decode_token
+from utils.exceptions import AuthError, ForbiddenError
+
+
+async def get_current_user(
+ request: Request, db: AsyncSession = Depends(get_db)
+) -> SysUser:
+ header = request.headers.get("Authorization", "")
+ if not header.startswith("Bearer "):
+ raise AuthError()
+ try:
+ user_id = int(decode_token(header.removeprefix("Bearer "))["user_id"])
+ except Exception:
+ raise AuthError("Token 无效或已过期")
+ user = await SysUserRepo(db).get(user_id)
+ if user is None:
+ raise AuthError("用户不存在")
+ if user.status != "正常":
+ raise ForbiddenError("账号已被禁用或冻结")
+ return user
\ No newline at end of file
diff --git a/api/router.py b/api/router.py
new file mode 100644
index 0000000..dd4ab2a
--- /dev/null
+++ b/api/router.py
@@ -0,0 +1,10 @@
+"""路由汇总。各业务模块在对应 Phase 落地后按模块加入:
+- auth / product / trade / profile / memory / knowledge / risk / advisor / workorder / admin
+"""
+from fastapi import APIRouter
+
+from api.routers import auth, health
+
+api_router = APIRouter()
+api_router.include_router(health.router, prefix="/api", tags=["系统"])
+api_router.include_router(auth.router, prefix="/api", tags=["认证"])
\ No newline at end of file
diff --git a/api/routers/__init__.py b/api/routers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/api/routers/auth.py b/api/routers/auth.py
new file mode 100644
index 0000000..358ae82
--- /dev/null
+++ b/api/routers/auth.py
@@ -0,0 +1,41 @@
+"""认证路由:登录签发 JWT + 当前用户信息(get_current_user 依赖注入示例)。"""
+from fastapi import APIRouter, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from api.deps import get_current_user
+from config.deps import get_db
+from schemas.auth import LoginReq
+from model.sys_user import SysUser
+from repositories.sys_user import SysUserRepo
+from service.auth import create_token, verify_password
+from utils.exceptions import ForbiddenError, ParamError
+from utils.response import success
+
+router = APIRouter()
+
+
+def user_payload(u: SysUser) -> dict:
+ return {
+ "id": u.id,
+ "username": u.username,
+ "user_type": u.user_type,
+ "employee_role": u.employee_role,
+ "customer_level": u.customer_level,
+ "status": u.status,
+ }
+
+
+@router.post("/auth/login")
+async def login(body: LoginReq, db: AsyncSession = Depends(get_db)):
+ user = await SysUserRepo(db).get_by_username(body.username)
+ if user is None or not verify_password(body.password, user.password_hash):
+ raise ParamError("用户名或密码错误")
+ if user.status != "正常":
+ raise ForbiddenError("账号状态异常,无法登录")
+ return success({"token": create_token(user.id), "user": user_payload(user)})
+
+
+@router.get("/auth/me")
+async def me(user: SysUser = Depends(get_current_user)):
+ """受保护示例路由:任意接口加 Depends(get_current_user) 即获得当前用户。"""
+ return success({"user": user_payload(user)})
\ No newline at end of file
diff --git a/api/routers/health.py b/api/routers/health.py
new file mode 100644
index 0000000..526fa25
--- /dev/null
+++ b/api/routers/health.py
@@ -0,0 +1,17 @@
+"""系统健康检查:四库就绪状态 + 各库探测耗时(任一失败返回 503 + 明细)。"""
+from fastapi import APIRouter
+from fastapi.responses import JSONResponse
+
+from config.database import check_ready_detail
+
+router = APIRouter()
+
+
+@router.get("/health/ready")
+async def health_ready():
+ dbs = await check_ready_detail()
+ all_ok = all(v["status"] == "ok" for v in dbs.values())
+ return JSONResponse(
+ status_code=200 if all_ok else 503,
+ content={"status": "ready" if all_ok else "degraded", "dbs": dbs},
+ )
\ No newline at end of file
diff --git a/config/__init__.py b/config/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/config/database/__init__.py b/config/database/__init__.py
new file mode 100644
index 0000000..4e0deaf
--- /dev/null
+++ b/config/database/__init__.py
@@ -0,0 +1,73 @@
+"""四库统一注册表:异步生命周期编排。
+
+启动:逐库 init,失败按指数退避重试;DB_STRICT_STARTUP=true 时重试耗尽直接抛错阻止启动。
+关闭:逆序 dispose。健康:check_ready_detail 返回带耗时明细,供 /health 与监控面板使用。
+"""
+import asyncio
+import logging
+import time
+
+from . import milvus, mysql, neo4j, redis
+from config.settings import settings
+
+logger = logging.getLogger("config.database")
+
+# 关闭顺序与设计一致:mysql → redis → neo4j → milvus
+_DB_MODULES = (mysql, redis, neo4j, milvus)
+
+
+def _name(m) -> str:
+ return m.__name__.rsplit(".", 1)[-1]
+
+
+async def _init_with_retry(m, strict: bool) -> bool:
+ name = _name(m)
+ retries = settings.db.conn_retries
+ for attempt in range(retries):
+ try:
+ await m.init_db()
+ return True
+ except Exception as e:
+ logger.warning(
+ "[%s] init failed (attempt %d/%d): %s: %s",
+ name, attempt + 1, retries, type(e).__name__, e,
+ )
+ if attempt < retries - 1:
+ await asyncio.sleep(settings.db.retry_backoff_sec * (2**attempt))
+ if strict:
+ raise RuntimeError(f"[{name}] 初始化失败({retries} 次尝试后)")
+ return False
+
+
+async def init_db() -> None:
+ """手动预热(应用启动不自动调用,会话懒创建):asyncio.run(database.init_db())"""
+ for m in _DB_MODULES:
+ await _init_with_retry(m, settings.db.strict_startup)
+
+
+async def dispose() -> None:
+ for m in _DB_MODULES:
+ try:
+ await m.dispose()
+ except Exception as e:
+ logger.warning("[%s] dispose failed: %s: %s", _name(m), type(e).__name__, e)
+
+
+async def check_ready_detail() -> dict[str, dict]:
+ """含耗时明细:{db: {status, ms}}。任一失败不影响其它库探测。"""
+ out: dict[str, dict] = {}
+ for m in _DB_MODULES:
+ name = _name(m)
+ t0 = time.perf_counter()
+ try:
+ await m.check_health()
+ status = "ok"
+ except Exception as e:
+ status = f"down: {type(e).__name__}"
+ out[name] = {"status": status, "ms": round((time.perf_counter() - t0) * 1000, 1)}
+ return out
+
+
+async def check_ready() -> dict[str, str]:
+ """兼容旧契约:{db: status_str}。"""
+ return {k: v["status"] for k, v in (await check_ready_detail()).items()}
\ No newline at end of file
diff --git a/config/database/milvus.py b/config/database/milvus.py
new file mode 100644
index 0000000..8004731
--- /dev/null
+++ b/config/database/milvus.py
@@ -0,0 +1,39 @@
+"""Milvus 会话:全局单例 AsyncMilvusClient。
+
+timeout 入参是数据操作默认超时(用 MILVUS_TIMEOUT);
+构造为急切连接、由注册表 init_db 做指数退避重试 + 严格模式兜底。
+"""
+from pymilvus import AsyncMilvusClient
+
+from config.settings import settings
+
+_client: AsyncMilvusClient | None = None
+
+
+def client() -> AsyncMilvusClient:
+ global _client
+ if _client is None:
+ _client = AsyncMilvusClient(
+ uri=settings.milvus.uri,
+ token=settings.milvus.token,
+ user=settings.milvus.user,
+ password=settings.milvus.password,
+ db_name=settings.milvus.db_name,
+ timeout=settings.milvus.timeout,
+ )
+ return _client
+
+
+async def init_db() -> None:
+ client() # 急切建连;失败由注册表重试(config/database/__init__.py)
+
+
+async def dispose() -> None:
+ global _client
+ if _client is not None:
+ await _client.close()
+ _client = None
+
+
+async def check_health() -> None:
+ await client().get_server_version()
\ No newline at end of file
diff --git a/config/database/mysql.py b/config/database/mysql.py
new file mode 100644
index 0000000..92182de
--- /dev/null
+++ b/config/database/mysql.py
@@ -0,0 +1,51 @@
+"""MySQL 会话(懒):首次访问才创建 AsyncEngine + 连接池,不随应用启动初始化。
+
+引擎/会话工厂都是函数式访问,第一次调用时建出单例;dispose 清理已建对象。
+"""
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+from config.settings import settings
+
+_engine = None
+_sessionmaker = None
+
+
+def get_engine():
+ global _engine
+ if _engine is None:
+ _engine = create_async_engine(
+ settings.mysql.url,
+ pool_size=settings.mysql.pool_size,
+ max_overflow=settings.mysql.max_overflow,
+ pool_recycle=settings.mysql.pool_recycle,
+ pool_pre_ping=True, # 取/还连接时探测,自动剔除死连接
+ pool_timeout=settings.mysql.pool_timeout,
+ connect_args={"connect_timeout": settings.mysql.connect_timeout},
+ )
+ return _engine
+
+
+def get_session_factory():
+ global _sessionmaker
+ if _sessionmaker is None:
+ _sessionmaker = async_sessionmaker(bind=get_engine(), expire_on_commit=False)
+ return _sessionmaker
+
+
+async def init_db() -> None:
+ """手动预热入口(registry.init_db 逐库调用;不随应用启动自动执行)。"""
+ get_engine()
+
+
+async def dispose() -> None:
+ global _engine, _sessionmaker
+ if _engine is not None:
+ await _engine.dispose()
+ _engine = None
+ _sessionmaker = None
+
+
+async def check_health() -> None:
+ async with get_engine().connect() as conn:
+ await conn.execute(text("SELECT 1"))
\ No newline at end of file
diff --git a/config/database/neo4j.py b/config/database/neo4j.py
new file mode 100644
index 0000000..bfd3e9e
--- /dev/null
+++ b/config/database/neo4j.py
@@ -0,0 +1,40 @@
+"""Neo4j 会话:全局单例 AsyncDriver(内部自带连接池与事务重试)。"""
+from neo4j import AsyncGraphDatabase
+from config.settings import settings
+
+_driver = None
+
+
+def client():
+ global _driver
+ if _driver is None:
+ _driver = AsyncGraphDatabase.driver(
+ settings.neo4j.uri,
+ auth=(settings.neo4j.user, settings.neo4j.password),
+ max_connection_pool_size=settings.neo4j.max_connection_pool_size,
+ connection_acquisition_timeout=settings.neo4j.connection_acquisition_timeout,
+ connection_timeout=settings.neo4j.connection_timeout,
+ max_transaction_retry_time=settings.neo4j.max_transaction_retry_time,
+ )
+ return _driver
+
+
+async def init_db() -> None:
+ client() # 构造为懒连接,仅建出 driver 单例
+
+
+async def get_session():
+ """每次操作取一个轻量 pool 内 session;execute_read/write 自带事务重试。"""
+ async with client().session() as s:
+ yield s
+
+
+async def dispose() -> None:
+ global _driver
+ if _driver is not None:
+ await _driver.close()
+ _driver = None
+
+
+async def check_health() -> None:
+ await client().verify_connectivity()
\ No newline at end of file
diff --git a/config/database/redis.py b/config/database/redis.py
new file mode 100644
index 0000000..3b799b9
--- /dev/null
+++ b/config/database/redis.py
@@ -0,0 +1,43 @@
+"""Redis 会话:内置连接池的 asyncio 客户端单例。"""
+import redis.asyncio as aioredis
+from redis.backoff import FullJitterBackoff
+from redis.retry import Retry
+
+from config.settings import settings
+
+_client: aioredis.Redis | None = None
+
+
+def client() -> aioredis.Redis:
+ global _client
+ if _client is None:
+ _client = aioredis.Redis(
+ host=settings.redis.host,
+ port=settings.redis.port,
+ db=settings.redis.db,
+ password=settings.redis.password,
+ socket_connect_timeout=settings.redis.socket_connect_timeout,
+ socket_timeout=settings.redis.socket_timeout,
+ socket_keepalive=True, # 长连接保活,防代理/防火墙断连
+ retry_on_timeout=True, # 读超时自动重试,吸收瞬时抖动
+ health_check_interval=settings.redis.health_check_interval,
+ retry=Retry(FullJitterBackoff(base=1, cap=10), retries=3),
+ max_connections=settings.redis.max_connections,
+ decode_responses=True,
+ )
+ return _client
+
+
+async def init_db() -> None:
+ await client() # 构造是同步懒加载,启动时只需确保单例被建出
+
+
+async def dispose() -> None:
+ global _client
+ if _client is not None:
+ await _client.aclose()
+ _client = None
+
+
+async def check_health() -> None:
+ assert await client().ping()
\ No newline at end of file
diff --git a/config/deps.py b/config/deps.py
new file mode 100644
index 0000000..c9f9091
--- /dev/null
+++ b/config/deps.py
@@ -0,0 +1,26 @@
+"""FastAPI Depends 工厂:把池化会话按请求注入,用后即还。"""
+from .database import milvus, mysql, neo4j, redis
+
+
+async def get_db():
+ """仓储/服务层用 AsyncSession(UoW):注入 BaseRepository。"""
+ async with mysql.get_session_factory()() as session:
+ yield session
+
+
+async def get_mysql_conn():
+ async with mysql.get_session_factory()() as conn:
+ yield conn
+
+
+def get_redis():
+ yield redis.client()
+
+
+async def get_neo4j_session():
+ async with neo4j.get_session() as s:
+ yield s
+
+
+def get_milvus():
+ yield milvus.client()
\ No newline at end of file
diff --git a/config/settings.py b/config/settings.py
new file mode 100644
index 0000000..171da69
--- /dev/null
+++ b/config/settings.py
@@ -0,0 +1,137 @@
+"""应用配置:全部从 .env 读取,代码不硬编码任何连接/模型值。
+
+.env 路径由本文件位置推导(绝对路径),与启动工作目录无关;每个子配置各自声明
+env_file,保证嵌套配置也读文件(否则默认实例会吞掉环境变量);任一必需键缺失,
+启动即报错(fail-fast),避免带着错误配置静默运行。
+"""
+from pathlib import Path
+from typing import Optional
+
+from pydantic_settings import BaseSettings, SettingsConfigDict
+from sqlalchemy.engine import URL
+
+# 项目根 = settings.py 的上两级;无论从哪个 cwd 启动都能定位 .env
+_ENV_FILE: Path = Path(__file__).resolve().parent.parent / ".env"
+
+
+class MysqlCfg(BaseSettings):
+ host: str
+ port: int
+ user: str
+ password: str
+ database: str
+ pool_size: int
+ max_overflow: int
+ pool_recycle: int
+ connect_timeout: int
+ pool_timeout: int
+
+ model_config = SettingsConfigDict(env_prefix="MYSQL_", env_file=_ENV_FILE, extra="ignore")
+
+ @property
+ def url(self) -> URL:
+ return URL.create(
+ "mysql+aiomysql",
+ username=self.user,
+ password=self.password,
+ host=self.host,
+ port=self.port,
+ database=self.database,
+ query={"charset": "utf8mb4"},
+ )
+
+
+class RedisCfg(BaseSettings):
+ host: str
+ port: int
+ db: int
+ password: Optional[str] = None # 可缺省(空串/未设置表示无密码)
+ socket_connect_timeout: int
+ socket_timeout: int
+ health_check_interval: int
+ max_connections: int
+
+ model_config = SettingsConfigDict(env_prefix="REDIS_", env_file=_ENV_FILE, extra="ignore")
+
+
+class Neo4jCfg(BaseSettings):
+ uri: str
+ user: str
+ password: str
+ max_connection_pool_size: int
+ connection_acquisition_timeout: int
+ connection_timeout: int
+ max_transaction_retry_time: int
+
+ model_config = SettingsConfigDict(env_prefix="NEO4J_", env_file=_ENV_FILE, extra="ignore")
+
+
+class MilvusCfg(BaseSettings):
+ uri: str
+ token: Optional[str] = None # 可缺省(无需鉴权时留空)
+ user: Optional[str] = None
+ password: Optional[str] = None
+ db_name: Optional[str] = None
+ connect_timeout: int # 建连/通道就绪超时(构造是急切连接,必须短)
+ timeout: int # 数据操作超时,调用处可覆盖
+
+ model_config = SettingsConfigDict(env_prefix="MILVUS_", env_file=_ENV_FILE, extra="ignore")
+
+
+class LLMCfg(BaseSettings):
+ """大模型配置:本地 Ollama / OpenAI 兼容 API 双模式见 tool/llm.py。"""
+
+ mode: str
+ # —— 本地 Ollama ——
+ ollama_base: str
+ ollama_chat_model: str
+ ollama_embed_model: str
+ # —— OpenAI 兼容 API(base_url 以 /v1 结尾)——
+ api_base: str
+ api_key: str
+ api_chat_model: str
+ api_embed_model: str
+ # —— 通用超参 ——
+ temperature: float
+ max_tokens: int
+ timeout: int
+ max_retries: int
+ retry_backoff_sec: float # 指数退避基数
+ fallback_chat_model: str # 备用模型:主模型失败后自动切换(空则不启用)
+
+ model_config = SettingsConfigDict(env_prefix="LLM_", env_file=_ENV_FILE, extra="ignore")
+
+
+class JwtCfg(BaseSettings):
+ """JWT 鉴权配置(service/auth.py)。"""
+ secret: str # 签名密钥,生产必须换强随机值
+ algorithm: str # 签名算法,如 HS256
+ expire_seconds: int # Token 有效期(秒)
+
+ model_config = SettingsConfigDict(env_prefix="JWT_", env_file=_ENV_FILE, extra="ignore")
+
+
+class DBMaintenanceCfg(BaseSettings):
+ """数据库启动策略:连接重试与严格模式(config/database/__init__.py)。"""
+ strict_startup: bool = False # true=任一核心库启动失败即阻止应用启动(生产建议开启)
+ conn_retries: int = 3 # 各库 init 最大尝试次数
+ retry_backoff_sec: float = 1.0 # 指数退避基数:1s/2s/4s
+
+ model_config = SettingsConfigDict(env_prefix="DB_", env_file=_ENV_FILE, extra="ignore")
+
+
+class Settings(BaseSettings):
+ app_env: str
+ db: DBMaintenanceCfg = DBMaintenanceCfg()
+ jwt: JwtCfg = JwtCfg()
+
+ mysql: MysqlCfg = MysqlCfg()
+ redis: RedisCfg = RedisCfg()
+ neo4j: Neo4jCfg = Neo4jCfg()
+ milvus: MilvusCfg = MilvusCfg()
+ llm: LLMCfg = LLMCfg()
+
+ model_config = SettingsConfigDict(env_file=_ENV_FILE, extra="ignore")
+
+
+settings = Settings()
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..3c38911
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,60 @@
+# 四个数据库一键启动:MySQL · Redis · Neo4j · Milvus(standalone 内置 etcd/minio)
+# 启动:docker compose up -d 停止:docker compose down
+services:
+ mysql:
+ image: mysql:8.0
+ container_name: fund-mysql
+ ports:
+ - "3306:3306"
+ environment:
+ MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root123456}
+ MYSQL_DATABASE: ${MYSQL_DATABASE:-memory_demo}
+ TZ: Asia/Shanghai
+ command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
+ volumes:
+ - mysql-data:/var/lib/mysql
+ healthcheck:
+ test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p$$MYSQL_ROOT_PASSWORD"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ redis:
+ image: redis:7
+ container_name: fund-redis
+ ports:
+ - "6379:6379"
+ volumes:
+ - redis-data:/data
+
+ neo4j:
+ image: neo4j:5
+ container_name: fund-neo4j
+ ports:
+ - "7474:7474" # 浏览器控制台
+ - "7687:7687" # bolt 驱动
+ environment:
+ NEO4J_AUTH: ${NEO4J_AUTH:-neo4j/neo4j1234}
+ volumes:
+ - neo4j-data:/data
+
+ milvus:
+ image: milvusdb/milvus:v2.4-latest
+ container_name: fund-milvus
+ ports:
+ - "19530:19530" # gRPC/HTTP 接口
+ - "9091:9091" # 监控端口
+ volumes:
+ - milvus-data:/var/lib/milvus
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 60s
+
+volumes:
+ mysql-data:
+ redis-data:
+ neo4j-data:
+ milvus-data:
\ No newline at end of file
diff --git a/logs/error.log b/logs/error.log
new file mode 100644
index 0000000..ddccd54
--- /dev/null
+++ b/logs/error.log
@@ -0,0 +1,405 @@
+2026-09-08 17:26:52,160 ERROR [-] api: unhandled error on /api/auth/login
+Traceback (most recent call last):
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\errors.py", line 164, in __call__
+ await self.app(scope, receive, _send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\base.py", line 193, in __call__
+ response = await self.dispatch_func(request, call_next)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "D:\Projects\py\Memory_demo\utils\request_id.py", line 28, in dispatch
+ response: Response = await call_next(request)
+ ^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\base.py", line 168, in call_next
+ raise app_exc from app_exc.__cause__ or app_exc.__context__
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\base.py", line 144, in coro
+ await self.app(scope, receive_or_disconnect, send_no_error)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\exceptions.py", line 63, in __call__
+ await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
+ raise exc
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
+ await app(scope, receive, sender)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\middleware\asyncexitstack.py", line 18, in __call__
+ await self.app(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\routing.py", line 670, in __call__
+ await self.middleware_stack(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 2734, in app
+ await route.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1780, in handle
+ await self.original_router.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 2789, in handle
+ await included_router._handle_selected(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1791, in _handle_selected
+ await route.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1780, in handle
+ await self.original_router.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 2789, in handle
+ await included_router._handle_selected(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1800, in _handle_selected
+ await original_route.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1279, in handle
+ await app(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 158, in app
+ await wrap_app_handling_exceptions(app, request)(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
+ raise exc
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
+ await app(scope, receive, sender)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 144, in app
+ response = await f(request)
+ ^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 481, in app
+ solved_result = await solve_dependencies(
+ ^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\dependencies\utils.py", line 668, in solve_dependencies
+ solved = await _solve_generator(
+ ^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\dependencies\utils.py", line 574, in _solve_generator
+ return await stack.enter_async_context(cm)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\contextlib.py", line 647, in enter_async_context
+ result = await _enter(cm)
+ ^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\contextlib.py", line 204, in __aenter__
+ return await anext(self.gen)
+ ^^^^^^^^^^^^^^^^^^^^^
+ File "D:\Projects\py\Memory_demo\config\deps.py", line 7, in get_db
+ async with mysql.get_session_factory() as session:
+TypeError: 'async_sessionmaker' object does not support the asynchronous context manager protocol
+2026-09-08 17:26:52,230 ERROR [-] sqlalchemy.pool.impl.AsyncAdaptedQueuePool: Exception closing connection >
+Traceback (most recent call last):
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\pool\base.py", line 375, in _close_connection
+ self._dialect.do_close(connection)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\engine\default.py", line 721, in do_close
+ dbapi_connection.close()
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\dialects\mysql\aiomysql.py", line 114, in close
+ self.await_(self._connection.ensure_closed())
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 132, in await_only
+ return current.parent.switch(awaitable) # type: ignore[no-any-return,attr-defined] # noqa: E501
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 196, in greenlet_spawn
+ value = await result
+ ^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\aiomysql\connection.py", line 349, in ensure_closed
+ self._writer.write(send_data)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\streams.py", line 331, in write
+ self._transport.write(data)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\proactor_events.py", line 366, in write
+ self._loop_writing(data=bytes(data))
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\proactor_events.py", line 402, in _loop_writing
+ self._write_fut = self._loop._proactor.send(self._sock, data)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^
+AttributeError: 'NoneType' object has no attribute 'send'
+2026-09-08 17:27:10,975 ERROR [7920a7ecec5e4f8b] sqlalchemy.pool.impl.AsyncAdaptedQueuePool: Exception terminating connection >
+Traceback (most recent call last):
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\pool\base.py", line 373, in _close_connection
+ self._dialect.do_terminate(connection)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\dialects\mysql\aiomysql.py", line 242, in do_terminate
+ dbapi_connection.terminate()
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\connectors\asyncio.py", line 402, in terminate
+ self.await_(asyncio.shield(self._terminate_graceful_close())) # type: ignore[attr-defined] # noqa: E501
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 132, in await_only
+ return current.parent.switch(awaitable) # type: ignore[no-any-return,attr-defined] # noqa: E501
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 196, in greenlet_spawn
+ value = await result
+ ^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\dialects\mysql\aiomysql.py", line 117, in _terminate_graceful_close
+ await self._connection.ensure_closed()
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\aiomysql\connection.py", line 349, in ensure_closed
+ self._writer.write(send_data)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\streams.py", line 331, in write
+ self._transport.write(data)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\proactor_events.py", line 366, in write
+ self._loop_writing(data=bytes(data))
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\proactor_events.py", line 402, in _loop_writing
+ self._write_fut = self._loop._proactor.send(self._sock, data)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^
+AttributeError: 'NoneType' object has no attribute 'send'
+2026-09-08 17:27:11,025 ERROR [-] api: unhandled error on /api/auth/login
+Traceback (most recent call last):
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\errors.py", line 164, in __call__
+ await self.app(scope, receive, _send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\base.py", line 193, in __call__
+ response = await self.dispatch_func(request, call_next)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "D:\Projects\py\Memory_demo\utils\request_id.py", line 28, in dispatch
+ response: Response = await call_next(request)
+ ^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\base.py", line 168, in call_next
+ raise app_exc from app_exc.__cause__ or app_exc.__context__
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\base.py", line 144, in coro
+ await self.app(scope, receive_or_disconnect, send_no_error)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\exceptions.py", line 63, in __call__
+ await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
+ raise exc
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
+ await app(scope, receive, sender)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\middleware\asyncexitstack.py", line 18, in __call__
+ await self.app(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\routing.py", line 670, in __call__
+ await self.middleware_stack(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 2734, in app
+ await route.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1780, in handle
+ await self.original_router.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 2789, in handle
+ await included_router._handle_selected(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1791, in _handle_selected
+ await route.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1780, in handle
+ await self.original_router.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 2789, in handle
+ await included_router._handle_selected(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1800, in _handle_selected
+ await original_route.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1279, in handle
+ await app(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 158, in app
+ await wrap_app_handling_exceptions(app, request)(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
+ raise exc
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
+ await app(scope, receive, sender)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 144, in app
+ response = await f(request)
+ ^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 706, in app
+ raw_response = await run_endpoint_function(
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 352, in run_endpoint_function
+ return await dependant.call(**values)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "D:\Projects\py\Memory_demo\api\routers\auth.py", line 35, in login
+ user = await SysUserRepo(db).get_by_username(body.username)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "D:\Projects\py\Memory_demo\repositories\sys_user.py", line 14, in get_by_username
+ return await self.db.scalar(select(SysUser).where(SysUser.username == username))
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\ext\asyncio\session.py", line 504, in scalar
+ return await greenlet_spawn(
+ ^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 201, in greenlet_spawn
+ result = context.throw(*sys.exc_info())
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\orm\session.py", line 2421, in scalar
+ return self._execute_internal(
+ ^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\orm\session.py", line 2261, in _execute_internal
+ conn = self._connection_for_bind(bind)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\orm\session.py", line 2113, in _connection_for_bind
+ return trans._connection_for_bind(engine, execution_options)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "", line 2, in _connection_for_bind
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\orm\state_changes.py", line 137, in _go
+ ret_value = fn(self, *arg, **kw)
+ ^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\orm\session.py", line 1191, in _connection_for_bind
+ conn = bind.connect()
+ ^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\engine\base.py", line 3295, in connect
+ return self._connection_cls(self)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\engine\base.py", line 144, in __init__
+ self._dbapi_connection = engine.raw_connection()
+ ^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\engine\base.py", line 3319, in raw_connection
+ return self.pool.connect()
+ ^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\pool\base.py", line 448, in connect
+ return _ConnectionFairy._checkout(self)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\pool\base.py", line 1371, in _checkout
+ with util.safe_reraise():
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\langhelpers.py", line 122, in __exit__
+ raise exc_value.with_traceback(exc_tb)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\pool\base.py", line 1309, in _checkout
+ result = pool._dialect._do_ping_w_event(
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\engine\default.py", line 729, in _do_ping_w_event
+ return self.do_ping(dbapi_connection)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\dialects\mysql\pymysql.py", line 144, in do_ping
+ dbapi_connection.ping()
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\dialects\mysql\aiomysql.py", line 100, in ping
+ self.await_(self._connection.ping(reconnect=False))
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 132, in await_only
+ return current.parent.switch(awaitable) # type: ignore[no-any-return,attr-defined] # noqa: E501
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 196, in greenlet_spawn
+ value = await result
+ ^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\aiomysql\connection.py", line 493, in ping
+ await self._execute_command(COMMAND.COM_PING, "")
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\aiomysql\connection.py", line 724, in _execute_command
+ self._write_bytes(prelude + sql[:chunk_size - 1])
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\aiomysql\connection.py", line 669, in _write_bytes
+ return self._writer.write(data)
+ ^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\streams.py", line 331, in write
+ self._transport.write(data)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\proactor_events.py", line 366, in write
+ self._loop_writing(data=bytes(data))
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\proactor_events.py", line 402, in _loop_writing
+ self._write_fut = self._loop._proactor.send(self._sock, data)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^
+AttributeError: 'NoneType' object has no attribute 'send'
+2026-09-08 17:27:39,262 ERROR [c744544f6e564c1a] sqlalchemy.pool.impl.AsyncAdaptedQueuePool: Exception terminating connection >
+Traceback (most recent call last):
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\pool\base.py", line 373, in _close_connection
+ self._dialect.do_terminate(connection)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\dialects\mysql\aiomysql.py", line 242, in do_terminate
+ dbapi_connection.terminate()
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\connectors\asyncio.py", line 402, in terminate
+ self.await_(asyncio.shield(self._terminate_graceful_close())) # type: ignore[attr-defined] # noqa: E501
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 132, in await_only
+ return current.parent.switch(awaitable) # type: ignore[no-any-return,attr-defined] # noqa: E501
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 196, in greenlet_spawn
+ value = await result
+ ^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\dialects\mysql\aiomysql.py", line 117, in _terminate_graceful_close
+ await self._connection.ensure_closed()
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\aiomysql\connection.py", line 349, in ensure_closed
+ self._writer.write(send_data)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\streams.py", line 331, in write
+ self._transport.write(data)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\proactor_events.py", line 366, in write
+ self._loop_writing(data=bytes(data))
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\proactor_events.py", line 402, in _loop_writing
+ self._write_fut = self._loop._proactor.send(self._sock, data)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^
+AttributeError: 'NoneType' object has no attribute 'send'
+2026-09-08 17:27:39,266 ERROR [-] api: unhandled error on /api/auth/login
+Traceback (most recent call last):
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\errors.py", line 164, in __call__
+ await self.app(scope, receive, _send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\base.py", line 193, in __call__
+ response = await self.dispatch_func(request, call_next)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "D:\Projects\py\Memory_demo\utils\request_id.py", line 28, in dispatch
+ response: Response = await call_next(request)
+ ^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\base.py", line 168, in call_next
+ raise app_exc from app_exc.__cause__ or app_exc.__context__
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\base.py", line 144, in coro
+ await self.app(scope, receive_or_disconnect, send_no_error)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\exceptions.py", line 63, in __call__
+ await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
+ raise exc
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
+ await app(scope, receive, sender)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\middleware\asyncexitstack.py", line 18, in __call__
+ await self.app(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\routing.py", line 670, in __call__
+ await self.middleware_stack(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 2734, in app
+ await route.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1780, in handle
+ await self.original_router.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 2789, in handle
+ await included_router._handle_selected(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1791, in _handle_selected
+ await route.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1780, in handle
+ await self.original_router.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 2789, in handle
+ await included_router._handle_selected(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1800, in _handle_selected
+ await original_route.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1279, in handle
+ await app(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 158, in app
+ await wrap_app_handling_exceptions(app, request)(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
+ raise exc
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
+ await app(scope, receive, sender)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 144, in app
+ response = await f(request)
+ ^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 706, in app
+ raw_response = await run_endpoint_function(
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 352, in run_endpoint_function
+ return await dependant.call(**values)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "D:\Projects\py\Memory_demo\api\routers\auth.py", line 35, in login
+ user = await SysUserRepo(db).get_by_username(body.username)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "D:\Projects\py\Memory_demo\repositories\sys_user.py", line 14, in get_by_username
+ return await self.db.scalar(select(SysUser).where(SysUser.username == username))
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\ext\asyncio\session.py", line 504, in scalar
+ return await greenlet_spawn(
+ ^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 201, in greenlet_spawn
+ result = context.throw(*sys.exc_info())
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\orm\session.py", line 2421, in scalar
+ return self._execute_internal(
+ ^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\orm\session.py", line 2261, in _execute_internal
+ conn = self._connection_for_bind(bind)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\orm\session.py", line 2113, in _connection_for_bind
+ return trans._connection_for_bind(engine, execution_options)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "", line 2, in _connection_for_bind
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\orm\state_changes.py", line 137, in _go
+ ret_value = fn(self, *arg, **kw)
+ ^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\orm\session.py", line 1191, in _connection_for_bind
+ conn = bind.connect()
+ ^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\engine\base.py", line 3295, in connect
+ return self._connection_cls(self)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\engine\base.py", line 144, in __init__
+ self._dbapi_connection = engine.raw_connection()
+ ^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\engine\base.py", line 3319, in raw_connection
+ return self.pool.connect()
+ ^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\pool\base.py", line 448, in connect
+ return _ConnectionFairy._checkout(self)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\pool\base.py", line 1371, in _checkout
+ with util.safe_reraise():
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\langhelpers.py", line 122, in __exit__
+ raise exc_value.with_traceback(exc_tb)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\pool\base.py", line 1309, in _checkout
+ result = pool._dialect._do_ping_w_event(
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\engine\default.py", line 729, in _do_ping_w_event
+ return self.do_ping(dbapi_connection)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\dialects\mysql\pymysql.py", line 144, in do_ping
+ dbapi_connection.ping()
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\dialects\mysql\aiomysql.py", line 100, in ping
+ self.await_(self._connection.ping(reconnect=False))
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 132, in await_only
+ return current.parent.switch(awaitable) # type: ignore[no-any-return,attr-defined] # noqa: E501
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 196, in greenlet_spawn
+ value = await result
+ ^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\aiomysql\connection.py", line 493, in ping
+ await self._execute_command(COMMAND.COM_PING, "")
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\aiomysql\connection.py", line 724, in _execute_command
+ self._write_bytes(prelude + sql[:chunk_size - 1])
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\aiomysql\connection.py", line 669, in _write_bytes
+ return self._writer.write(data)
+ ^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\streams.py", line 331, in write
+ self._transport.write(data)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\proactor_events.py", line 366, in write
+ self._loop_writing(data=bytes(data))
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\proactor_events.py", line 402, in _loop_writing
+ self._write_fut = self._loop._proactor.send(self._sock, data)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^
+AttributeError: 'NoneType' object has no attribute 'send'
diff --git a/logs/info.log b/logs/info.log
new file mode 100644
index 0000000..e25fec3
--- /dev/null
+++ b/logs/info.log
@@ -0,0 +1,414 @@
+2026-09-08 15:14:35,174 INFO [-] httpx2: HTTP Request: GET http://testserver/ "HTTP/1.1 200 OK"
+2026-09-08 15:14:45,647 INFO [-] httpx2: HTTP Request: GET http://testserver/api/health/ready "HTTP/1.1 503 Service Unavailable"
+2026-09-08 15:14:45,648 INFO [-] httpx2: HTTP Request: GET http://testserver/openapi.json "HTTP/1.1 200 OK"
+2026-09-08 15:15:03,333 INFO [-] httpx2: HTTP Request: GET http://testserver/t/ok "HTTP/1.1 200 OK"
+2026-09-08 15:15:03,334 INFO [-] httpx2: HTTP Request: GET http://testserver/t/notfound "HTTP/1.1 404 Not Found"
+2026-09-08 15:15:03,335 INFO [-] httpx2: HTTP Request: GET http://testserver/t/param?q=abc "HTTP/1.1 422 Unprocessable Content"
+2026-09-08 15:27:03,604 INFO [-] httpx2: HTTP Request: GET http://testserver/api/health/ready "HTTP/1.1 503 Service Unavailable"
+2026-09-08 15:59:18,441 INFO [-] httpx2: HTTP Request: GET http://testserver/api/health/ready "HTTP/1.1 503 Service Unavailable"
+2026-09-08 16:42:12,463 INFO [-] httpx2: HTTP Request: GET http://testserver/api/health/ready "HTTP/1.1 200 OK"
+2026-09-08 17:26:52,160 ERROR [-] api: unhandled error on /api/auth/login
+Traceback (most recent call last):
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\errors.py", line 164, in __call__
+ await self.app(scope, receive, _send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\base.py", line 193, in __call__
+ response = await self.dispatch_func(request, call_next)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "D:\Projects\py\Memory_demo\utils\request_id.py", line 28, in dispatch
+ response: Response = await call_next(request)
+ ^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\base.py", line 168, in call_next
+ raise app_exc from app_exc.__cause__ or app_exc.__context__
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\base.py", line 144, in coro
+ await self.app(scope, receive_or_disconnect, send_no_error)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\exceptions.py", line 63, in __call__
+ await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
+ raise exc
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
+ await app(scope, receive, sender)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\middleware\asyncexitstack.py", line 18, in __call__
+ await self.app(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\routing.py", line 670, in __call__
+ await self.middleware_stack(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 2734, in app
+ await route.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1780, in handle
+ await self.original_router.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 2789, in handle
+ await included_router._handle_selected(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1791, in _handle_selected
+ await route.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1780, in handle
+ await self.original_router.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 2789, in handle
+ await included_router._handle_selected(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1800, in _handle_selected
+ await original_route.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1279, in handle
+ await app(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 158, in app
+ await wrap_app_handling_exceptions(app, request)(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
+ raise exc
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
+ await app(scope, receive, sender)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 144, in app
+ response = await f(request)
+ ^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 481, in app
+ solved_result = await solve_dependencies(
+ ^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\dependencies\utils.py", line 668, in solve_dependencies
+ solved = await _solve_generator(
+ ^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\dependencies\utils.py", line 574, in _solve_generator
+ return await stack.enter_async_context(cm)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\contextlib.py", line 647, in enter_async_context
+ result = await _enter(cm)
+ ^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\contextlib.py", line 204, in __aenter__
+ return await anext(self.gen)
+ ^^^^^^^^^^^^^^^^^^^^^
+ File "D:\Projects\py\Memory_demo\config\deps.py", line 7, in get_db
+ async with mysql.get_session_factory() as session:
+TypeError: 'async_sessionmaker' object does not support the asynchronous context manager protocol
+2026-09-08 17:26:52,230 ERROR [-] sqlalchemy.pool.impl.AsyncAdaptedQueuePool: Exception closing connection >
+Traceback (most recent call last):
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\pool\base.py", line 375, in _close_connection
+ self._dialect.do_close(connection)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\engine\default.py", line 721, in do_close
+ dbapi_connection.close()
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\dialects\mysql\aiomysql.py", line 114, in close
+ self.await_(self._connection.ensure_closed())
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 132, in await_only
+ return current.parent.switch(awaitable) # type: ignore[no-any-return,attr-defined] # noqa: E501
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 196, in greenlet_spawn
+ value = await result
+ ^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\aiomysql\connection.py", line 349, in ensure_closed
+ self._writer.write(send_data)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\streams.py", line 331, in write
+ self._transport.write(data)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\proactor_events.py", line 366, in write
+ self._loop_writing(data=bytes(data))
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\proactor_events.py", line 402, in _loop_writing
+ self._write_fut = self._loop._proactor.send(self._sock, data)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^
+AttributeError: 'NoneType' object has no attribute 'send'
+2026-09-08 17:27:10,975 ERROR [7920a7ecec5e4f8b] sqlalchemy.pool.impl.AsyncAdaptedQueuePool: Exception terminating connection >
+Traceback (most recent call last):
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\pool\base.py", line 373, in _close_connection
+ self._dialect.do_terminate(connection)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\dialects\mysql\aiomysql.py", line 242, in do_terminate
+ dbapi_connection.terminate()
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\connectors\asyncio.py", line 402, in terminate
+ self.await_(asyncio.shield(self._terminate_graceful_close())) # type: ignore[attr-defined] # noqa: E501
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 132, in await_only
+ return current.parent.switch(awaitable) # type: ignore[no-any-return,attr-defined] # noqa: E501
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 196, in greenlet_spawn
+ value = await result
+ ^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\dialects\mysql\aiomysql.py", line 117, in _terminate_graceful_close
+ await self._connection.ensure_closed()
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\aiomysql\connection.py", line 349, in ensure_closed
+ self._writer.write(send_data)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\streams.py", line 331, in write
+ self._transport.write(data)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\proactor_events.py", line 366, in write
+ self._loop_writing(data=bytes(data))
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\proactor_events.py", line 402, in _loop_writing
+ self._write_fut = self._loop._proactor.send(self._sock, data)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^
+AttributeError: 'NoneType' object has no attribute 'send'
+2026-09-08 17:27:11,025 ERROR [-] api: unhandled error on /api/auth/login
+Traceback (most recent call last):
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\errors.py", line 164, in __call__
+ await self.app(scope, receive, _send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\base.py", line 193, in __call__
+ response = await self.dispatch_func(request, call_next)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "D:\Projects\py\Memory_demo\utils\request_id.py", line 28, in dispatch
+ response: Response = await call_next(request)
+ ^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\base.py", line 168, in call_next
+ raise app_exc from app_exc.__cause__ or app_exc.__context__
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\base.py", line 144, in coro
+ await self.app(scope, receive_or_disconnect, send_no_error)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\exceptions.py", line 63, in __call__
+ await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
+ raise exc
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
+ await app(scope, receive, sender)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\middleware\asyncexitstack.py", line 18, in __call__
+ await self.app(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\routing.py", line 670, in __call__
+ await self.middleware_stack(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 2734, in app
+ await route.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1780, in handle
+ await self.original_router.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 2789, in handle
+ await included_router._handle_selected(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1791, in _handle_selected
+ await route.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1780, in handle
+ await self.original_router.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 2789, in handle
+ await included_router._handle_selected(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1800, in _handle_selected
+ await original_route.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1279, in handle
+ await app(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 158, in app
+ await wrap_app_handling_exceptions(app, request)(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
+ raise exc
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
+ await app(scope, receive, sender)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 144, in app
+ response = await f(request)
+ ^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 706, in app
+ raw_response = await run_endpoint_function(
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 352, in run_endpoint_function
+ return await dependant.call(**values)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "D:\Projects\py\Memory_demo\api\routers\auth.py", line 35, in login
+ user = await SysUserRepo(db).get_by_username(body.username)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "D:\Projects\py\Memory_demo\repositories\sys_user.py", line 14, in get_by_username
+ return await self.db.scalar(select(SysUser).where(SysUser.username == username))
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\ext\asyncio\session.py", line 504, in scalar
+ return await greenlet_spawn(
+ ^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 201, in greenlet_spawn
+ result = context.throw(*sys.exc_info())
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\orm\session.py", line 2421, in scalar
+ return self._execute_internal(
+ ^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\orm\session.py", line 2261, in _execute_internal
+ conn = self._connection_for_bind(bind)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\orm\session.py", line 2113, in _connection_for_bind
+ return trans._connection_for_bind(engine, execution_options)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "", line 2, in _connection_for_bind
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\orm\state_changes.py", line 137, in _go
+ ret_value = fn(self, *arg, **kw)
+ ^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\orm\session.py", line 1191, in _connection_for_bind
+ conn = bind.connect()
+ ^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\engine\base.py", line 3295, in connect
+ return self._connection_cls(self)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\engine\base.py", line 144, in __init__
+ self._dbapi_connection = engine.raw_connection()
+ ^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\engine\base.py", line 3319, in raw_connection
+ return self.pool.connect()
+ ^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\pool\base.py", line 448, in connect
+ return _ConnectionFairy._checkout(self)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\pool\base.py", line 1371, in _checkout
+ with util.safe_reraise():
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\langhelpers.py", line 122, in __exit__
+ raise exc_value.with_traceback(exc_tb)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\pool\base.py", line 1309, in _checkout
+ result = pool._dialect._do_ping_w_event(
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\engine\default.py", line 729, in _do_ping_w_event
+ return self.do_ping(dbapi_connection)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\dialects\mysql\pymysql.py", line 144, in do_ping
+ dbapi_connection.ping()
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\dialects\mysql\aiomysql.py", line 100, in ping
+ self.await_(self._connection.ping(reconnect=False))
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 132, in await_only
+ return current.parent.switch(awaitable) # type: ignore[no-any-return,attr-defined] # noqa: E501
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 196, in greenlet_spawn
+ value = await result
+ ^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\aiomysql\connection.py", line 493, in ping
+ await self._execute_command(COMMAND.COM_PING, "")
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\aiomysql\connection.py", line 724, in _execute_command
+ self._write_bytes(prelude + sql[:chunk_size - 1])
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\aiomysql\connection.py", line 669, in _write_bytes
+ return self._writer.write(data)
+ ^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\streams.py", line 331, in write
+ self._transport.write(data)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\proactor_events.py", line 366, in write
+ self._loop_writing(data=bytes(data))
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\proactor_events.py", line 402, in _loop_writing
+ self._write_fut = self._loop._proactor.send(self._sock, data)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^
+AttributeError: 'NoneType' object has no attribute 'send'
+2026-09-08 17:27:39,262 ERROR [c744544f6e564c1a] sqlalchemy.pool.impl.AsyncAdaptedQueuePool: Exception terminating connection >
+Traceback (most recent call last):
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\pool\base.py", line 373, in _close_connection
+ self._dialect.do_terminate(connection)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\dialects\mysql\aiomysql.py", line 242, in do_terminate
+ dbapi_connection.terminate()
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\connectors\asyncio.py", line 402, in terminate
+ self.await_(asyncio.shield(self._terminate_graceful_close())) # type: ignore[attr-defined] # noqa: E501
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 132, in await_only
+ return current.parent.switch(awaitable) # type: ignore[no-any-return,attr-defined] # noqa: E501
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 196, in greenlet_spawn
+ value = await result
+ ^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\dialects\mysql\aiomysql.py", line 117, in _terminate_graceful_close
+ await self._connection.ensure_closed()
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\aiomysql\connection.py", line 349, in ensure_closed
+ self._writer.write(send_data)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\streams.py", line 331, in write
+ self._transport.write(data)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\proactor_events.py", line 366, in write
+ self._loop_writing(data=bytes(data))
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\proactor_events.py", line 402, in _loop_writing
+ self._write_fut = self._loop._proactor.send(self._sock, data)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^
+AttributeError: 'NoneType' object has no attribute 'send'
+2026-09-08 17:27:39,266 ERROR [-] api: unhandled error on /api/auth/login
+Traceback (most recent call last):
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\errors.py", line 164, in __call__
+ await self.app(scope, receive, _send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\base.py", line 193, in __call__
+ response = await self.dispatch_func(request, call_next)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "D:\Projects\py\Memory_demo\utils\request_id.py", line 28, in dispatch
+ response: Response = await call_next(request)
+ ^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\base.py", line 168, in call_next
+ raise app_exc from app_exc.__cause__ or app_exc.__context__
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\base.py", line 144, in coro
+ await self.app(scope, receive_or_disconnect, send_no_error)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\middleware\exceptions.py", line 63, in __call__
+ await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
+ raise exc
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
+ await app(scope, receive, sender)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\middleware\asyncexitstack.py", line 18, in __call__
+ await self.app(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\routing.py", line 670, in __call__
+ await self.middleware_stack(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 2734, in app
+ await route.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1780, in handle
+ await self.original_router.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 2789, in handle
+ await included_router._handle_selected(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1791, in _handle_selected
+ await route.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1780, in handle
+ await self.original_router.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 2789, in handle
+ await included_router._handle_selected(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1800, in _handle_selected
+ await original_route.handle(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 1279, in handle
+ await app(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 158, in app
+ await wrap_app_handling_exceptions(app, request)(scope, receive, send)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
+ raise exc
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
+ await app(scope, receive, sender)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 144, in app
+ response = await f(request)
+ ^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 706, in app
+ raw_response = await run_endpoint_function(
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py", line 352, in run_endpoint_function
+ return await dependant.call(**values)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "D:\Projects\py\Memory_demo\api\routers\auth.py", line 35, in login
+ user = await SysUserRepo(db).get_by_username(body.username)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "D:\Projects\py\Memory_demo\repositories\sys_user.py", line 14, in get_by_username
+ return await self.db.scalar(select(SysUser).where(SysUser.username == username))
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\ext\asyncio\session.py", line 504, in scalar
+ return await greenlet_spawn(
+ ^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 201, in greenlet_spawn
+ result = context.throw(*sys.exc_info())
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\orm\session.py", line 2421, in scalar
+ return self._execute_internal(
+ ^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\orm\session.py", line 2261, in _execute_internal
+ conn = self._connection_for_bind(bind)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\orm\session.py", line 2113, in _connection_for_bind
+ return trans._connection_for_bind(engine, execution_options)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "", line 2, in _connection_for_bind
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\orm\state_changes.py", line 137, in _go
+ ret_value = fn(self, *arg, **kw)
+ ^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\orm\session.py", line 1191, in _connection_for_bind
+ conn = bind.connect()
+ ^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\engine\base.py", line 3295, in connect
+ return self._connection_cls(self)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\engine\base.py", line 144, in __init__
+ self._dbapi_connection = engine.raw_connection()
+ ^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\engine\base.py", line 3319, in raw_connection
+ return self.pool.connect()
+ ^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\pool\base.py", line 448, in connect
+ return _ConnectionFairy._checkout(self)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\pool\base.py", line 1371, in _checkout
+ with util.safe_reraise():
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\langhelpers.py", line 122, in __exit__
+ raise exc_value.with_traceback(exc_tb)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\pool\base.py", line 1309, in _checkout
+ result = pool._dialect._do_ping_w_event(
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\engine\default.py", line 729, in _do_ping_w_event
+ return self.do_ping(dbapi_connection)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\dialects\mysql\pymysql.py", line 144, in do_ping
+ dbapi_connection.ping()
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\dialects\mysql\aiomysql.py", line 100, in ping
+ self.await_(self._connection.ping(reconnect=False))
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 132, in await_only
+ return current.parent.switch(awaitable) # type: ignore[no-any-return,attr-defined] # noqa: E501
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 196, in greenlet_spawn
+ value = await result
+ ^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\aiomysql\connection.py", line 493, in ping
+ await self._execute_command(COMMAND.COM_PING, "")
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\aiomysql\connection.py", line 724, in _execute_command
+ self._write_bytes(prelude + sql[:chunk_size - 1])
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\site-packages\aiomysql\connection.py", line 669, in _write_bytes
+ return self._writer.write(data)
+ ^^^^^^^^^^^^^^^^^^^^^^^^
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\streams.py", line 331, in write
+ self._transport.write(data)
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\proactor_events.py", line 366, in write
+ self._loop_writing(data=bytes(data))
+ File "C:\Users\24434\AppData\Local\Programs\Python\Python312\Lib\asyncio\proactor_events.py", line 402, in _loop_writing
+ self._write_fut = self._loop._proactor.send(self._sock, data)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^
+AttributeError: 'NoneType' object has no attribute 'send'
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..b7e6be0
--- /dev/null
+++ b/main.py
@@ -0,0 +1,31 @@
+"""应用入口:四库异步生命周期 + 中间件 + 全局异常 + 路由装配。"""
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI
+
+from api.router import api_router
+from config import database
+from utils.exceptions import register_exception_handlers
+from utils.logger import setup_logging
+from utils.request_id import RequestIdMiddleware
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ setup_logging() # 幂等:分级日志 + trace_id + 脱敏
+ # 四库会话懒创建:启动不连接任何库,首次访问才建,可手动预热:
+ # asyncio.run(database.init_db())
+ yield
+ await database.dispose() # 清理已创建的单例(未创建则空操作)
+
+
+app = FastAPI(title="智能公募基金系统", version="0.1.0", lifespan=lifespan)
+
+app.add_middleware(RequestIdMiddleware)
+register_exception_handlers(app)
+app.include_router(api_router)
+
+
+@app.get("/")
+async def root():
+ return {"message": "智能公募基金系统 API", "docs": "/docs"}
\ No newline at end of file
diff --git a/model/__init__.py b/model/__init__.py
new file mode 100644
index 0000000..de3ce4d
--- /dev/null
+++ b/model/__init__.py
@@ -0,0 +1 @@
+"""数据模型层:Pydantic DTOs(随各 API 落地)+ SQLAlchemy ORM(随各模块落地)。建表 DDL 见 sql/schema.sql。"""
\ No newline at end of file
diff --git a/model/base.py b/model/base.py
new file mode 100644
index 0000000..69026cf
--- /dev/null
+++ b/model/base.py
@@ -0,0 +1,6 @@
+"""SQLAlchemy 2.0 声明式基类,所有 ORM 模型继承。"""
+from sqlalchemy.orm import DeclarativeBase
+
+
+class Base(DeclarativeBase):
+ pass
\ No newline at end of file
diff --git a/model/sys_config.py b/model/sys_config.py
new file mode 100644
index 0000000..b28f309
--- /dev/null
+++ b/model/sys_config.py
@@ -0,0 +1,22 @@
+"""sys_config 运营参数表 ORM 模型(高净值门槛/偏离阈值等,免发版调整)。"""
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import BigInteger, DateTime, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from model.base import Base
+
+
+class SysConfig(Base):
+ __tablename__ = "sys_config"
+ __table_args__ = {"comment": "运营参数 KV 表"}
+
+ id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+ config_key: Mapped[str] = mapped_column(String(64), unique=True)
+ config_value: Mapped[str] = mapped_column(String(255))
+ description: Mapped[str | None] = mapped_column(String(255))
+ update_time: Mapped[datetime] = mapped_column(
+ DateTime, server_default=func.now(), onupdate=func.now()
+ )
\ No newline at end of file
diff --git a/model/sys_user.py b/model/sys_user.py
new file mode 100644
index 0000000..0a9a13c
--- /dev/null
+++ b/model/sys_user.py
@@ -0,0 +1,31 @@
+"""sys_user 统一用户表 ORM 模型(客户与员工共用账号体系)。"""
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import BigInteger, DateTime, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from model.base import Base
+
+
+class SysUser(Base):
+ __tablename__ = "sys_user"
+ __table_args__ = {"comment": "统一用户表(客户与员工共用账号体系)"}
+
+ id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+ username: Mapped[str] = mapped_column(String(64), unique=True)
+ password_hash: Mapped[str] = mapped_column(String(255))
+ salt: Mapped[str | None] = mapped_column(String(32))
+ real_name: Mapped[str | None] = mapped_column(String(32))
+ phone: Mapped[str] = mapped_column(String(20))
+ user_type: Mapped[str] = mapped_column(String(16), server_default="CUSTOMER")
+ employee_role: Mapped[str | None] = mapped_column(String(32))
+ customer_level: Mapped[str | None] = mapped_column(String(16))
+ status: Mapped[str] = mapped_column(String(16), server_default="正常")
+ create_time: Mapped[datetime] = mapped_column(
+ DateTime, server_default=func.now()
+ )
+ update_time: Mapped[datetime] = mapped_column(
+ DateTime, server_default=func.now(), onupdate=func.now()
+ )
\ No newline at end of file
diff --git a/repositories/__init__.py b/repositories/__init__.py
new file mode 100644
index 0000000..a846e91
--- /dev/null
+++ b/repositories/__init__.py
@@ -0,0 +1 @@
+"""数据访问层:Service 不直接碰 session,统一走这里;后续每表复制 SysConfigRepo 加一个仓储。"""
\ No newline at end of file
diff --git a/repositories/base.py b/repositories/base.py
new file mode 100644
index 0000000..9763d6c
--- /dev/null
+++ b/repositories/base.py
@@ -0,0 +1,48 @@
+"""MySQL 通用仓储基类:封装 CRUD(AsyncSession 会话注入,函数内自提交)。"""
+from __future__ import annotations
+
+from typing import Any, Sequence
+
+from sqlalchemy import delete as sa_delete
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+
+class BaseRepository:
+ """子类只需设 model;主键列固定名为 id。"""
+
+ model: type | None = None
+
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def get(self, pk: int) -> Any | None:
+ return await self.db.get(self.model, pk)
+
+ async def list(self, *, where: Sequence | None = None,
+ order_by: Any | None = None, limit: int = 100,
+ offset: int = 0) -> list:
+ stmt = select(self.model)
+ if where:
+ stmt = stmt.where(*where)
+ if order_by is not None:
+ stmt = stmt.order_by(order_by)
+ return list((await self.db.scalars(stmt.limit(limit).offset(offset))).all())
+
+ async def add(self, obj: Any) -> Any:
+ """新增并入参刷新出 id 等 DB 生成字段。"""
+ self.db.add(obj)
+ await self.db.commit()
+ await self.db.refresh(obj)
+ return obj
+
+ async def delete(self, pk: int) -> bool:
+ result = await self.db.execute(sa_delete(self.model).where(self.model.id == pk))
+ await self.db.commit()
+ return result.rowcount > 0
+
+ async def count(self, *, where: Sequence | None = None) -> int:
+ stmt = select(func.count()).select_from(self.model)
+ if where:
+ stmt = stmt.where(*where)
+ return (await self.db.scalar(stmt)) or 0
\ No newline at end of file
diff --git a/repositories/sys_config.py b/repositories/sys_config.py
new file mode 100644
index 0000000..6be6b1b
--- /dev/null
+++ b/repositories/sys_config.py
@@ -0,0 +1,28 @@
+"""sys_config 仓储:运营参数读写(示例+可复用模板)。"""
+from __future__ import annotations
+
+from sqlalchemy import select
+
+from model.sys_config import SysConfig
+from repositories.base import BaseRepository
+
+
+class SysConfigRepo(BaseRepository):
+ model = SysConfig
+
+ async def get_value(self, key: str, default: str | None = None) -> str | None:
+ row = await self.db.scalar(select(SysConfig).where(SysConfig.config_key == key))
+ return row.config_value if row else default
+
+ async def set_value(self, key: str, value: str, description: str | None = None) -> SysConfig:
+ row = await self.db.scalar(select(SysConfig).where(SysConfig.config_key == key))
+ if row is None:
+ row = SysConfig(config_key=key, config_value=value, description=description)
+ self.db.add(row)
+ else:
+ row.config_value = value
+ if description:
+ row.description = description
+ await self.db.commit()
+ await self.db.refresh(row)
+ return row
\ No newline at end of file
diff --git a/repositories/sys_user.py b/repositories/sys_user.py
new file mode 100644
index 0000000..d8dc4a4
--- /dev/null
+++ b/repositories/sys_user.py
@@ -0,0 +1,14 @@
+"""sys_user 仓储:认证用按用户名/主键取用户。"""
+from __future__ import annotations
+
+from sqlalchemy import select
+
+from model.sys_user import SysUser
+from repositories.base import BaseRepository
+
+
+class SysUserRepo(BaseRepository):
+ model = SysUser
+
+ async def get_by_username(self, username: str) -> SysUser | None:
+ return await self.db.scalar(select(SysUser).where(SysUser.username == username))
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..55494c7
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,12 @@
+fastapi
+uvicorn
+httpx
+pyjwt
+pydantic
+pydantic-settings
+sqlalchemy
+aiomysql
+redis
+pymilvus
+neo4j
+python-dotenv
\ No newline at end of file
diff --git a/schemas/__init__.py b/schemas/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/schemas/auth.py b/schemas/auth.py
new file mode 100644
index 0000000..0084476
--- /dev/null
+++ b/schemas/auth.py
@@ -0,0 +1,7 @@
+"""认证相关 DTO。"""
+from pydantic import BaseModel, Field
+
+
+class LoginReq(BaseModel):
+ username: str = Field(min_length=1, max_length=64)
+ password: str = Field(min_length=1, max_length=128)
\ No newline at end of file
diff --git a/service/__init__.py b/service/__init__.py
new file mode 100644
index 0000000..10e9135
--- /dev/null
+++ b/service/__init__.py
@@ -0,0 +1 @@
+"""业务逻辑层:每个 Agent/模块一个 service(rag / profile / trade / risk / advisor / memory…),随 Phase 落地。"""
\ No newline at end of file
diff --git a/service/auth.py b/service/auth.py
new file mode 100644
index 0000000..9e2060d
--- /dev/null
+++ b/service/auth.py
@@ -0,0 +1,46 @@
+"""认证服务:JWT 签发/校验(HS256)+ 密码哈希/校验。
+
+密码用 stdlib PBKDF2(免 bcrypt 依赖),存储格式自描述:
+ pbkdf2_sha256$600000$$
+"""
+import hashlib
+import hmac
+import secrets
+import time
+
+import jwt
+
+from config.settings import settings
+
+_ITERATIONS = 600_000
+
+
+# ---- JWT ----
+def create_token(user_id: int, *, extra: dict | None = None) -> str:
+ """签发 token:仅存 user_id + iat/exp,用户信息校验时按 id 回查 sys_user。"""
+ now = int(time.time())
+ payload = {"user_id": user_id, "iat": now, "exp": now + settings.jwt.expire_seconds}
+ if extra:
+ payload.update(extra)
+ return jwt.encode(payload, settings.jwt.secret, algorithm=settings.jwt.algorithm)
+
+
+def decode_token(token: str) -> dict:
+ """校验签名 + 过期,返回 claims;任何失败抛异常(调用方转 401)。"""
+ return jwt.decode(token, settings.jwt.secret, algorithms=[settings.jwt.algorithm])
+
+
+# ---- 密码 ----
+def hash_password(password: str) -> str:
+ salt = secrets.token_hex(16)
+ digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), _ITERATIONS)
+ return f"pbkdf2_sha256${_ITERATIONS}${salt}${digest.hex()}"
+
+
+def verify_password(password: str, stored: str) -> bool:
+ try:
+ _, iters, salt, digest = stored.split("$")
+ except ValueError:
+ return False
+ calc = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), int(iters))
+ return hmac.compare_digest(calc.hex(), digest)
\ No newline at end of file
diff --git a/sql/schema.sql b/sql/schema.sql
new file mode 100644
index 0000000..f2d3f68
--- /dev/null
+++ b/sql/schema.sql
@@ -0,0 +1,440 @@
+-- =====================================================================
+-- 智能公募基金系统 · MySQL 建表脚本(24 张,见开发计划 §3.1)
+-- 执行:mysql -u -p < sql/schema.sql
+-- 约定:InnoDB / utf8mb4;采用"逻辑外键"(不建物理 FK,便于 Mock 数据灌入),
+-- 注释即文档,NL2SQL 以 COMMENT 注入 Schema。
+-- =====================================================================
+
+-- ---------------------------------------------------------------------
+-- 01 统一用户表(客户 + 员工共用)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS sys_user (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ username VARCHAR(64) NOT NULL COMMENT '登录账号,唯一',
+ password_hash VARCHAR(255) NOT NULL COMMENT 'bcrypt 哈希,不存明文',
+ salt VARCHAR(32) NULL COMMENT '口令盐',
+ real_name VARCHAR(32) NULL COMMENT '真实姓名',
+ phone VARCHAR(20) NOT NULL COMMENT '手机号(脱敏展示用)',
+ user_type VARCHAR(16) NOT NULL DEFAULT 'CUSTOMER' COMMENT 'CUSTOMER=客户 / EMPLOYEE=员工',
+ employee_role VARCHAR(32) NULL COMMENT '员工角色:理财顾问/风控专员/客户经理/管理员',
+ customer_level VARCHAR(16) NULL COMMENT '客户等级:普通/金卡/白金/钻石/私行',
+ status VARCHAR(16) NOT NULL DEFAULT '正常' COMMENT '账号状态:正常/禁用/冻结',
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ UNIQUE KEY uk_username (username),
+ KEY idx_phone (phone),
+ KEY idx_user_type (user_type)
+) COMMENT='统一用户表(客户与员工共用账号体系)';
+
+-- ---------------------------------------------------------------------
+-- 02 客户画像主表
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS fin_customer_profile (
+ customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID,一对一关联 sys_user.id',
+ risk_level VARCHAR(16) NULL COMMENT '风险等级:保守/稳健/平衡/进取/激进',
+ risk_score INT NULL COMMENT '风险评分 0-100,越高越激进',
+ investment_experience VARCHAR(16) NULL COMMENT '投资经验:0-1/1-3/3-5/5-10/10年以上',
+ annual_income_range VARCHAR(32) NULL COMMENT '年收入区间',
+ total_assets DECIMAL(18,2) NULL COMMENT '总资产(元)',
+ asset_allocation JSON NULL COMMENT '资产配置偏好 {股票:30,债券:40,...}',
+ product_preference JSON NULL COMMENT '产品偏好 {基金:[混合型,债券型]}',
+ customer_level VARCHAR(16) NULL COMMENT '客户等级(运营可调整)',
+ confidence_score DECIMAL(5,2) NOT NULL DEFAULT 0.50 COMMENT '画像置信度 0-1,用于推荐权重',
+ profile_version INT NOT NULL DEFAULT 1 COMMENT '画像版本号(重建时递增)',
+ update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (customer_id),
+ KEY idx_risk_level (risk_level),
+ KEY idx_total_assets (total_assets)
+) COMMENT='客户画像主表(RAG/GraphRAG 个性化推荐核心上下文)';
+
+-- ---------------------------------------------------------------------
+-- 03 公募基金产品表
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS fin_product (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ product_code VARCHAR(32) NOT NULL COMMENT '基金代码,唯一,如 F000001',
+ product_name VARCHAR(128) NOT NULL COMMENT '基金名称',
+ product_type VARCHAR(32) NOT NULL COMMENT '类型:货币/债券/混合/股票基金',
+ risk_level VARCHAR(8) NOT NULL COMMENT '风险等级 R1-R5',
+ expected_return DECIMAL(7,4) NULL COMMENT '预期年化收益率(%)',
+ nav DECIMAL(12,6) NULL COMMENT '最新单位净值',
+ nav_date DATE NULL COMMENT '净值日期',
+ min_amount DECIMAL(16,2) NOT NULL DEFAULT 10.00 COMMENT '起投金额(元)',
+ fee_rate DECIMAL(6,4) NOT NULL DEFAULT 0.0000 COMMENT '申购费率(%)',
+ term_days INT NOT NULL DEFAULT 0 COMMENT '产品期限天数,0=活期(开放申赎)',
+ fund_manager VARCHAR(64) NULL COMMENT '基金经理',
+ status VARCHAR(16) NOT NULL DEFAULT '在售' COMMENT '在售/暂停/到期/清盘',
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ UNIQUE KEY uk_product_code (product_code),
+ KEY idx_type_status (product_type, status),
+ KEY idx_risk (risk_level)
+) COMMENT='公募基金产品表';
+
+-- ---------------------------------------------------------------------
+-- 04 基金净值历史
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS fund_nav_history (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ product_id BIGINT UNSIGNED NOT NULL COMMENT '产品ID',
+ nav_date DATE NOT NULL COMMENT '净值日期',
+ unit_nav DECIMAL(12,6) NOT NULL COMMENT '单位净值',
+ accumulated_nav DECIMAL(12,6) NULL COMMENT '累计净值',
+ daily_growth DECIMAL(8,4) NULL COMMENT '日增长率(%)',
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE KEY uk_product_date (product_id, nav_date)
+) COMMENT='基金净值历史(业绩指标/走势图数据源)';
+
+-- ---------------------------------------------------------------------
+-- 05 交易申请单(申购/赎回主表:风控挂起→处置→回写的状态机载体)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS trade_order (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ order_no VARCHAR(32) NOT NULL COMMENT '申请单号,全局唯一',
+ customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID',
+ product_id BIGINT UNSIGNED NOT NULL COMMENT '产品ID',
+ advisor_id BIGINT UNSIGNED NULL COMMENT '经办投顾(代下/调仓)',
+ order_type VARCHAR(16) NOT NULL COMMENT '申购/赎回',
+ amount DECIMAL(18,2) NULL COMMENT '申请金额(元)',
+ shares DECIMAL(18,4) NULL COMMENT '申请/成交份额',
+ nav DECIMAL(12,6) NULL COMMENT '确认净值(确认时回填)',
+ fee DECIMAL(12,2) NULL COMMENT '手续费(确认时回填)',
+ status VARCHAR(16) NOT NULL DEFAULT '待确认' COMMENT '待确认/风控挂起/已确认/已撤单/失败',
+ risk_alert_id BIGINT UNSIGNED NULL COMMENT '命中风控预警ID(挂起时关联)',
+ cancel_reason VARCHAR(255) NULL COMMENT '撤单/失败原因',
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ confirm_time DATETIME NULL COMMENT '确认时间',
+ KEY idx_customer (customer_id, status),
+ KEY idx_status (status),
+ UNIQUE KEY uk_order_no (order_no)
+) COMMENT='交易申请单(风控阻断闭环主表,状态机单一来源)';
+
+-- ---------------------------------------------------------------------
+-- 06 成交流水
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS fin_transaction (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ transaction_no VARCHAR(32) NOT NULL COMMENT '流水号,全局唯一',
+ order_id BIGINT UNSIGNED NULL COMMENT '关联申请单 ID',
+ customer_id BIGINT UNSIGNED NOT NULL,
+ product_id BIGINT UNSIGNED NOT NULL,
+ operator_id BIGINT UNSIGNED NULL COMMENT '经办理财顾问',
+ transaction_type VARCHAR(16) NOT NULL COMMENT '申购/赎回/分红/定投',
+ amount DECIMAL(18,2) NOT NULL COMMENT '交易金额(元)',
+ shares DECIMAL(18,4) NULL COMMENT '成交份额',
+ nav DECIMAL(12,6) NULL COMMENT '成交净值',
+ fee DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '手续费(元)',
+ status VARCHAR(16) NOT NULL DEFAULT '已确认' COMMENT '待确认/已确认/已撤单/失败',
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE KEY uk_transaction_no (transaction_no),
+ KEY idx_customer_time (customer_id, create_time),
+ KEY idx_product (product_id)
+) COMMENT='成交流水表(成交后落库)';
+
+-- ---------------------------------------------------------------------
+-- 07 持仓表
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS fin_holdings (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ customer_id BIGINT UNSIGNED NOT NULL COMMENT '持仓客户',
+ product_id BIGINT UNSIGNED NOT NULL COMMENT '持有产品',
+ shares DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '持有份额',
+ cost_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '成本金额(元)',
+ current_value DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '当前市值(元,持仓重估任务更新)',
+ profit_loss DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '盈亏金额(元)',
+ profit_ratio DECIMAL(8,4) NOT NULL DEFAULT 0 COMMENT '盈亏比例(%)',
+ status VARCHAR(16) NOT NULL DEFAULT '持有中' COMMENT '持有中/已清仓',
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ UNIQUE KEY uk_customer_product (customer_id, product_id),
+ KEY idx_status (status)
+) COMMENT='持仓表(当前/历史持仓快照)';
+
+-- ---------------------------------------------------------------------
+-- 08 风险评估记录
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS fin_risk_assessment (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ customer_id BIGINT UNSIGNED NOT NULL COMMENT '被评估客户',
+ assessment_date DATE NOT NULL COMMENT '评估日期',
+ question_version VARCHAR(16) NULL COMMENT '问卷版本(题目变更后按版本追溯)',
+ total_score INT NOT NULL COMMENT '总分 0-100',
+ risk_level VARCHAR(16) NOT NULL COMMENT '评定:保守/稳健/平衡/进取/激进',
+ answers JSON NULL COMMENT '答题详情 [{q:1,a:B,score:10}]',
+ assessor_type VARCHAR(16) NOT NULL DEFAULT 'AI评估' COMMENT 'AI评估/人工评估',
+ valid_until DATE NOT NULL COMMENT '有效期至(默认 +1年,监管一年一评)',
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ KEY idx_customer_date (customer_id, assessment_date)
+) COMMENT='风险评估记录表(购买前问卷确认客户等级)';
+
+-- ---------------------------------------------------------------------
+-- 09 问卷模板
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS ops_questionnaire (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(64) NOT NULL COMMENT '问卷名称,如 风险测评问卷',
+ q_type VARCHAR(16) NOT NULL COMMENT '风险测评/投资者分类',
+ version VARCHAR(16) NOT NULL DEFAULT 'v1' COMMENT '版本号',
+ status VARCHAR(16) NOT NULL DEFAULT '启用' COMMENT '启用/停用',
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+) COMMENT='问卷模板表';
+
+-- ---------------------------------------------------------------------
+-- 10 题目表
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS ops_question (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ questionnaire_id BIGINT UNSIGNED NOT NULL COMMENT '所属问卷',
+ question_no INT NOT NULL COMMENT '题号',
+ content VARCHAR(512) NOT NULL COMMENT '题干',
+ option_json JSON NULL COMMENT '选项配置 [{label:A,text:...}]',
+ score_json JSON NULL COMMENT '选项分值 {A:10,B:8,...}',
+ sort INT NOT NULL DEFAULT 0,
+ UNIQUE KEY uk_q_no (questionnaire_id, question_no)
+) COMMENT='问卷题目表(题目/选项/分值可运营配置)';
+
+-- ---------------------------------------------------------------------
+-- 11 风控预警表
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS fin_risk_alert (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ customer_id BIGINT UNSIGNED NOT NULL COMMENT '被预警客户',
+ order_id BIGINT UNSIGNED NULL COMMENT '关联交易申请单(挂起时必填)',
+ alert_type VARCHAR(32) NOT NULL COMMENT '大额交易/频繁交易/快进快出/疑洗钱/适当性异常等',
+ alert_level VARCHAR(8) NOT NULL COMMENT '低(蓝)/中(黄)/高(红)',
+ trigger_detail TEXT NULL COMMENT '触发详情(命中规则、AI 推理过程)',
+ transaction_ids JSON NULL COMMENT '关联交易 ID 列表',
+ confidence DECIMAL(5,2) NOT NULL DEFAULT 0.50 COMMENT '预警置信度',
+ status VARCHAR(16) NOT NULL DEFAULT '未处理' COMMENT '未处理/处理中/已确认/已排除',
+ handler_id BIGINT UNSIGNED NULL COMMENT '处理人(风控专员)',
+ handle_result TEXT NULL COMMENT '处置结论留痕',
+ handle_time DATETIME NULL,
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ KEY idx_status_level (status, alert_level),
+ KEY idx_customer (customer_id),
+ KEY idx_order (order_id)
+) COMMENT='风控预警表(阻断后由风控专员在控制台处置)';
+
+-- ---------------------------------------------------------------------
+-- 12 风控规则表(规则引擎表驱动)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS risk_rule (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ rule_id VARCHAR(16) NOT NULL COMMENT '规则编号,如 R001',
+ rule_name VARCHAR(64) NOT NULL COMMENT '规则名称',
+ trigger_condition VARCHAR(512) NULL COMMENT '触发条件描述',
+ threshold JSON NULL COMMENT '阈值配置 {amount:50000,...}',
+ risk_level VARCHAR(8) NOT NULL DEFAULT '低' COMMENT '低/中/高',
+ weight DECIMAL(4,2) NOT NULL DEFAULT 1.00 COMMENT '规则权重(预警分级用)',
+ status VARCHAR(8) NOT NULL DEFAULT '启用' COMMENT '启用/停用(运营可调)',
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ UNIQUE KEY uk_rule_id (rule_id)
+) COMMENT='风控规则表(20 条反洗钱/异常交易规则,驱动风控Agent)';
+
+-- ---------------------------------------------------------------------
+-- 13 通用业务工单表
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS biz_work_order (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ work_order_no VARCHAR(32) NOT NULL COMMENT '工单编号,如 WO20260908xxxx',
+ order_type VARCHAR(32) NOT NULL COMMENT '产品申购/产品赎回/投顾签约/信息变更/投诉建议/风评重做/可疑交易上报',
+ sub_type VARCHAR(32) NULL COMMENT '子类型',
+ customer_id BIGINT UNSIGNED NULL COMMENT '关联客户',
+ submitter_id BIGINT UNSIGNED NULL COMMENT '提交人(客户或员工代办)',
+ handler_id BIGINT UNSIGNED NULL COMMENT '当前处理人(投顾/风控专员)',
+ current_node VARCHAR(32) NOT NULL DEFAULT '初审' COMMENT '初审/复核/完成',
+ priority VARCHAR(8) NOT NULL DEFAULT '普通' COMMENT '普通/紧急/特急',
+ status VARCHAR(16) NOT NULL DEFAULT '待处理' COMMENT '待处理/处理中/待审核/已完成/已驳回',
+ biz_content JSON NULL COMMENT '业务内容(按 order_type 结构各异)',
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ UNIQUE KEY uk_work_order_no (work_order_no),
+ KEY idx_handler_status (handler_id, status),
+ KEY idx_customer (customer_id)
+) COMMENT='通用业务工单表(投标顾签约、可疑交易上报等流程载体)';
+
+-- ---------------------------------------------------------------------
+-- 14 会话归档表
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS conversation_archive (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ session_id VARCHAR(64) NOT NULL COMMENT '会话ID,同轮共用',
+ user_id BIGINT UNSIGNED NOT NULL COMMENT '对话用户',
+ agent_type VARCHAR(32) NOT NULL COMMENT 'customer/advisor/risk/analyst',
+ role VARCHAR(16) NOT NULL COMMENT 'user/assistant/system',
+ content MEDIUMTEXT NULL COMMENT '对话内容',
+ tool_calls JSON NULL COMMENT '工具调用记录 [{"tool":"nl2sql",...}]',
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ KEY idx_session (session_id),
+ KEY idx_user_time (user_id, create_time),
+ KEY idx_agent (agent_type)
+) COMMENT='会话归档表(审计回溯 + Agent 持续学习素材,归档前脱敏)';
+
+-- ---------------------------------------------------------------------
+-- 15 知识元数据表(原文存 MySQL,替代 MinIO)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS fin_knowledge_meta (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ knowledge_type VARCHAR(32) NOT NULL COMMENT '产品说明/政策法规/FAQ/操作指南/市场研报',
+ title VARCHAR(255) NOT NULL COMMENT '知识条目标题',
+ source_file VARCHAR(255) NULL COMMENT '来源文件名',
+ raw_content LONGTEXT NULL COMMENT '文档原文/切片(MinIO 替代方案)',
+ file_path VARCHAR(512) NULL COMMENT '本地上传原文件路径(若保留)',
+ milvus_collection VARCHAR(128) NULL COMMENT 'Milvus 向量集合名',
+ version VARCHAR(32) NOT NULL DEFAULT 'v1',
+ status VARCHAR(16) NOT NULL DEFAULT '有效' COMMENT '有效/过期/草稿',
+ expire_at DATETIME NULL COMMENT '过期时间(政策 1 年/产品说明半年)',
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ KEY idx_type (knowledge_type),
+ KEY idx_status (status)
+) COMMENT='金融知识元数据表(RAG 知识库目录 + 正文载体,Milvus source_id 关联本表)';
+
+-- ---------------------------------------------------------------------
+-- 16 客户-投顾关系表(签约状态驱动客服引导与投顾服务范围)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS customer_relation (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID',
+ advisor_id BIGINT UNSIGNED NOT NULL COMMENT '投顾ID',
+ assign_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '分配时间(注册自动分配)',
+ signed_time DATETIME NULL COMMENT '签约时间',
+ end_time DATETIME NULL COMMENT '关系结束时间',
+ status VARCHAR(16) NOT NULL DEFAULT '已分配' COMMENT '已分配/已签约/已结束',
+ reason VARCHAR(128) NULL COMMENT '结束/变更原因',
+ KEY idx_advisor (advisor_id, status),
+ KEY idx_customer (customer_id)
+) COMMENT='客户-投顾关系表(状态驱动:签约后投顾Agent方案正式触达客户)';
+
+-- ---------------------------------------------------------------------
+-- 17 画像变更日志(冲突策略审计)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS customer_profile_change_log (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ customer_id BIGINT UNSIGNED NOT NULL,
+ tag VARCHAR(64) NULL COMMENT '标签名',
+ old_value VARCHAR(255) NULL COMMENT '旧值',
+ new_value VARCHAR(255) NULL COMMENT '新值',
+ source VARCHAR(32) NULL COMMENT '来源:风评问卷/行为推断/AI对话提取/用户自述/人工',
+ confidence DECIMAL(5,2) NULL COMMENT '新值置信度',
+ reason VARCHAR(255) NULL COMMENT '变更原因(冲突策略/人工修正)',
+ operator_id BIGINT UNSIGNED NULL COMMENT '操作人(人工修正时)',
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ KEY idx_customer (customer_id)
+) COMMENT='画像变更审计(保留冲突记录与人工修正痕迹)';
+
+-- ---------------------------------------------------------------------
+-- 18 操作审计日志(企业级:谁在何时做了什么)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS audit_log (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ user_id BIGINT UNSIGNED NULL COMMENT '操作人',
+ username VARCHAR(64) NULL,
+ module VARCHAR(32) NOT NULL COMMENT 'auth/product/trade/risk/...',
+ action VARCHAR(64) NOT NULL COMMENT '操作动作',
+ target VARCHAR(128) NULL COMMENT '操作对象(单号/ID)',
+ detail TEXT NULL COMMENT '详情(JSON 字符串)',
+ ip VARCHAR(64) NULL,
+ trace_id VARCHAR(32) NULL COMMENT '链路号',
+ status VARCHAR(8) NOT NULL DEFAULT '成功' COMMENT '成功/失败',
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ KEY idx_user (user_id),
+ KEY idx_module_time (module, create_time)
+) COMMENT='操作审计日志表(企业级合规留痕)';
+
+-- ---------------------------------------------------------------------
+-- 19 官网公告
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS sys_announcement (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ title VARCHAR(128) NOT NULL,
+ content TEXT NULL,
+ status VARCHAR(16) NOT NULL DEFAULT '草稿' COMMENT '草稿/已发布',
+ publish_time DATETIME NULL,
+ expire_at DATETIME NULL,
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ KEY idx_status (status, publish_time)
+) COMMENT='官网公告表';
+
+-- ---------------------------------------------------------------------
+-- 20 站内信/消息中心
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS sys_message (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ user_id BIGINT UNSIGNED NOT NULL COMMENT '收件人',
+ msg_type VARCHAR(32) NOT NULL COMMENT '推荐/调仓/风控/签约/系统',
+ title VARCHAR(128) NOT NULL,
+ content VARCHAR(512) NULL,
+ biz_id VARCHAR(64) NULL COMMENT '关联业务单号(订单/工单/预警)',
+ is_read TINYINT NOT NULL DEFAULT 0,
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ KEY idx_user_read (user_id, is_read)
+) COMMENT='站内信/消息中心表(投顾触达客户通道)';
+
+-- ---------------------------------------------------------------------
+-- 21 运营参数表(免发版调整)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS sys_config (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ config_key VARCHAR(64) NOT NULL COMMENT '键,如 high_net_worth_threshold',
+ config_value VARCHAR(255) NOT NULL COMMENT '值',
+ description VARCHAR(255) NULL COMMENT '说明',
+ update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ UNIQUE KEY uk_config_key (config_key)
+) COMMENT='运营参数 KV 表(高净值门槛/偏离度阈值/风控开关等)';
+
+-- ---------------------------------------------------------------------
+-- 22 记忆单元表(记忆架构主体,见开发计划 §4.2)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS memory_unit (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ customer_id BIGINT UNSIGNED NOT NULL COMMENT '归属客户',
+ tag VARCHAR(64) NOT NULL COMMENT '标签名,如 risk_preference',
+ content VARCHAR(512) NOT NULL COMMENT '内容,如 偏好稳健型理财',
+ info_type VARCHAR(8) NOT NULL COMMENT 'FACT 客观事实 / OPINION 主观观点',
+ source VARCHAR(32) NOT NULL COMMENT '风评问卷/行为推断/AI对话提取/用户自述/系统默认',
+ source_confidence DECIMAL(5,2) NOT NULL DEFAULT 0.20 COMMENT '来源初始置信度',
+ confidence DECIMAL(5,2) NOT NULL DEFAULT 0.20 COMMENT '当前置信度(动态)',
+ evidence_count INT NOT NULL DEFAULT 0 COMMENT '证据数',
+ conflict_count INT NOT NULL DEFAULT 0 COMMENT '冲突数',
+ recall_count INT NOT NULL DEFAULT 0 COMMENT '召回数',
+ update_time DATETIME NULL,
+ last_recall_time DATETIME NULL,
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT 'active/demoted/archived/deleted',
+ valid_until DATE NULL COMMENT '有效期',
+ KEY idx_customer_status (customer_id, status),
+ KEY idx_tag (tag)
+) COMMENT='记忆单元表(三层记忆中期主体,向量镜像在 Milvus customer_memory)';
+
+-- ---------------------------------------------------------------------
+-- 23 基金业绩指标缓存(定时任务从净值计算)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS fund_performance (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ product_id BIGINT UNSIGNED NOT NULL,
+ period VARCHAR(16) NOT NULL COMMENT '近1月/近3月/近6月/近1年/成立以来',
+ return_rate DECIMAL(10,4) NULL COMMENT '区间收益率(%)',
+ annual_volatility DECIMAL(10,4) NULL COMMENT '年化波动率(%)',
+ max_drawdown DECIMAL(10,4) NULL COMMENT '最大回撤(%)',
+ sharpe DECIMAL(10,4) NULL COMMENT '夏普比率',
+ calc_date DATE NOT NULL COMMENT '计算日期',
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE KEY uk_product_period (product_id, period)
+) COMMENT='基金业绩指标缓存(深度分析/多维对比数据源)';
+
+-- ---------------------------------------------------------------------
+-- 24 组合基准配置(再平衡参照系)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS portfolio_benchmark (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ risk_level VARCHAR(16) NOT NULL COMMENT '客户风险等级:保守/稳健/平衡/进取/激进',
+ target_allocation JSON NOT NULL COMMENT '目标配置 {货币:50,债券:40,股票:10}',
+ drift_threshold DECIMAL(5,2) NOT NULL DEFAULT 5.00 COMMENT '偏离阈值(%),超阈值触发调仓建议',
+ status VARCHAR(8) NOT NULL DEFAULT '启用' COMMENT '启用/停用',
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ UNIQUE KEY uk_risk_level (risk_level)
+) COMMENT='组合基准配置表(投顾Agent 再平衡参照,运营可调整)';
\ No newline at end of file
diff --git a/test_auth.py b/test_auth.py
new file mode 100644
index 0000000..7cc58d2
--- /dev/null
+++ b/test_auth.py
@@ -0,0 +1,67 @@
+"""认证 E2E:登录取 token → /auth/me 依赖注入回查 sys_user → 反例(错密码/坏 token/已删用户)。
+
+全程跑在同一个事件循环(httpx.ASGITransport),避免单例 async engine 跨循环。
+"""
+import asyncio
+
+import httpx
+
+import main as appmod
+from config.database.mysql import get_engine, get_session_factory
+from model.sys_user import SysUser
+from repositories.sys_user import SysUserRepo
+from service.auth import hash_password
+
+USER = "auth_test_u"
+PWD = "Test@1234"
+
+
+async def seed() -> int:
+ async with get_session_factory()() as s:
+ repo = SysUserRepo(s)
+ existing = await repo.get_by_username(USER)
+ if existing:
+ return existing.id
+ u = SysUser(username=USER, password_hash=hash_password(PWD), phone="13800001111",
+ user_type="EMPLOYEE", employee_role="理财顾问", status="正常")
+ return (await repo.add(u)).id
+
+
+async def cleanup(uid: int) -> None:
+ async with get_session_factory()() as s:
+ await SysUserRepo(s).delete(uid)
+
+
+async def run():
+ uid = await seed()
+ try:
+ transport = httpx.ASGITransport(app=appmod.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
+ # 错密码 → 400
+ r = await c.post("/api/auth/login", json={"username": USER, "password": "wrong"})
+ assert r.status_code == 400 and r.json()["code"] == 400, r.text
+ # 正常登录 → token
+ r = await c.post("/api/auth/login", json={"username": USER, "password": PWD})
+ assert r.status_code == 200, r.text
+ token = r.json()["data"]["token"]
+ assert token
+ # /auth/me:依赖注入按 id 回查 sys_user
+ r = await c.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"})
+ me = r.json()["data"]["user"]
+ assert r.status_code == 200 and me["id"] == uid and me["employee_role"] == "理财顾问", r.text
+ # 缺 token / 坏 token → 401
+ assert (await c.get("/api/auth/me")).status_code == 401
+ assert (await c.get("/api/auth/me", headers={"Authorization": "Bearer abc.def.ghi"})).status_code == 401
+ # 删除用户后 token 回查失败 → 401
+ await cleanup(uid)
+ assert (await c.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"})).status_code == 401
+ # 不存在用户登录 → 400
+ r = await c.post("/api/auth/login", json={"username": "no_such_user", "password": PWD})
+ assert r.status_code == 400
+ print("AUTH TESTS PASSED")
+ finally:
+ await get_engine().dispose()
+
+
+if __name__ == "__main__":
+ asyncio.run(run())
\ No newline at end of file
diff --git a/test_llm.py b/test_llm.py
new file mode 100644
index 0000000..7b13813
--- /dev/null
+++ b/test_llm.py
@@ -0,0 +1,118 @@
+"""tool.llm 自检:本地 Mock OpenAI 兼容服务,验证双模式 URL、重试、备用模型降级、embedding。
+
+运行:python test_llm.py(纯 assert,无框架;可通过 exitcode 判断)
+"""
+import asyncio
+import http.server
+import json
+import threading
+
+from config.settings import settings
+from tool.llm import LLMFailError, LLMClient
+
+INDEX = json.dumps({"index": 0}).encode()
+
+
+class MockLLMServer(http.server.BaseHTTPRequestHandler):
+ """OpenAI 兼容 Mock:chat/embeddings/tags。model=boom:1b 返回 500 用于测降级。"""
+
+ def log_message(self, *args):
+ pass
+
+ def _reply(self, code: int, obj: dict):
+ body = json.dumps(obj).encode()
+ self.send_response(code)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self): # ollama /api/tags 健康探测
+ self._reply(200, {"models": [{"name": "qwen2.5:7b"}]})
+
+ def do_POST(self):
+ n = int(self.headers.get("Content-Length", 0))
+ req = json.loads(self.rfile.read(n))
+ if self.path == "/v1/chat/completions":
+ model = req["model"]
+ if model.startswith("boom"):
+ self._reply(500, {"error": "model overloaded"})
+ return
+ self._reply(200, {"choices": [{"message": {"content": f"reply:{model}"}}]})
+ elif self.path == "/v1/embeddings":
+ texts = req["input"]
+ self._reply(200, {"data": [{"embedding": [float(i + 1)] * 4} for i in range(len(texts))]})
+ else:
+ self._reply(404, {"error": "not found"})
+
+
+def start_server() -> tuple[threading.Thread, int]:
+ srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), MockLLMServer)
+ port = srv.server_address[1]
+ t = threading.Thread(target=srv.serve_forever, daemon=True)
+ t.start()
+ return t, port
+
+
+def cfg(base_url: str, **kw):
+ """从 .env 加载的 settings.llm 派生测试配置(只覆盖用例所需字段)。"""
+ kw.setdefault("mode", "ollama")
+ kw.setdefault("ollama_base", base_url)
+ kw.setdefault("max_retries", 2)
+ kw.setdefault("retry_backoff_sec", 0)
+ return settings.llm.model_copy(update=kw)
+
+
+async def achat(c: LLMClient, text: str = "hi") -> str:
+ return await c.chat([{"role": "user", "content": text}])
+
+
+def main():
+ _, port = start_server()
+ base = f"http://127.0.0.1:{port}"
+
+ # 1. ollama 模式 URL + 正常 chat
+ c = LLMClient(cfg(base, ollama_chat_model="ok:1b"))
+ assert c.is_ollama and c.base_url == f"{base}/v1", c.base_url
+ out = asyncio.run(achat(c))
+ assert out == "reply:ok:1b", out
+
+ # 2. 备用模型降级:主模型 500 → 自动切 fallback_chat_model
+ c2 = LLMClient(cfg(base, ollama_chat_model="boom:1b", ollama_embed_model="bge-m3",
+ fallback_chat_model="ok:1b"))
+ out = asyncio.run(achat(c2))
+ assert out == "reply:ok:1b", out
+
+ # 3. 全部失败 → LLMFailError(重试耗尽)
+ c3 = LLMClient(cfg(base, ollama_chat_model="boom:1b", max_retries=1))
+ try:
+ asyncio.run(achat(c3))
+ raise AssertionError("should have raised LLMFailError")
+ except LLMFailError:
+ pass
+
+ # 4. API 模式:端点/鉴权头/模型选择
+ c4 = LLMClient(settings.llm.model_copy(update={
+ "mode": "api", "api_base": base + "/v1", "api_key": "sk-test",
+ "api_chat_model": "gpt-x", "max_retries": 1,
+ }))
+ assert not c4.is_ollama and c4.base_url == f"{base}/v1"
+ assert c4._headers()["Authorization"] == "Bearer sk-test"
+ assert c4.chat_model == "gpt-x"
+ out = asyncio.run(achat(c4))
+ assert out == "reply:gpt-x", out
+
+ # 5. embedding 批量
+ c5 = LLMClient(cfg(base))
+ vecs = asyncio.run(c5.embed(["基金", "债券"]))
+ assert len(vecs) == 2 and len(vecs[0]) == 4, vecs
+ assert asyncio.run(c5.embed_one("货币")) == [1.0] * 4
+
+ # 6. 健康探测(ollama /api/tags)
+ asyncio.run(c5.check_health())
+
+ print("ALL LLM TESTS PASSED")
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/test_main.http b/test_main.http
new file mode 100644
index 0000000..a2d81a9
--- /dev/null
+++ b/test_main.http
@@ -0,0 +1,11 @@
+# Test your FastAPI endpoints
+
+GET http://127.0.0.1:8000/
+Accept: application/json
+
+###
+
+GET http://127.0.0.1:8000/hello/User
+Accept: application/json
+
+###
diff --git a/test_repo.py b/test_repo.py
new file mode 100644
index 0000000..9c17af6
--- /dev/null
+++ b/test_repo.py
@@ -0,0 +1,44 @@
+"""数据访问层自检:MySQL 可达且表已建则跑真实 CRUD;否则 SKIP(exit 0)。"""
+import asyncio
+
+from sqlalchemy import text
+
+from config.database.mysql import get_engine, get_session_factory
+from repositories.sys_config import SysConfigRepo
+
+
+async def _db_ok() -> bool:
+ """MySQL 可达且 sys_config 表已建(schema.sql 已导入)才算就绪。"""
+ try:
+ async with get_engine().connect() as conn:
+ await conn.execute(text("SELECT 1"))
+ async with get_session_factory()() as session:
+ await session.execute(text("SELECT 1 FROM sys_config LIMIT 1"))
+ return True
+ except Exception:
+ return False
+
+
+async def main():
+ try:
+ if not await _db_ok():
+ print("SKIP: MySQL 不可达或 sys_config 未建表,CRUD 检查延后(先启动 MySQL 并导入 sql/schema.sql)")
+ return
+ async with get_session_factory()() as session:
+ repo = SysConfigRepo(session)
+ row = await repo.set_value("test.hnw", "3000000", "自检")
+ assert row.config_key == "test.hnw"
+ assert await repo.get_value("test.hnw") == "3000000"
+ assert await repo.count() > 0
+ rows = await repo.list(where=(repo.model.config_key == "test.hnw",))
+ assert len(rows) == 1
+ assert await repo.delete(rows[0].id)
+ assert await repo.get_value("test.hnw") is None # 幂等清理
+ print("REPO TESTS PASSED")
+ finally:
+ import config.database as _db
+ await _db.dispose() # 干净收尾,避免连接在事件循环关闭后被 GC
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
\ No newline at end of file
diff --git a/tool/__init__.py b/tool/__init__.py
new file mode 100644
index 0000000..6a2c7bc
--- /dev/null
+++ b/tool/__init__.py
@@ -0,0 +1 @@
+"""工具层:文档解析 / Embedding / 向量检索 / 图谱查询 / 规则匹配 / 置信度 / 记忆操作等 Tool,随 Phase 落地。"""
\ No newline at end of file
diff --git a/tool/llm.py b/tool/llm.py
new file mode 100644
index 0000000..fb5f469
--- /dev/null
+++ b/tool/llm.py
@@ -0,0 +1,135 @@
+"""统一 LLM 客户端:本地 Ollama / OpenAI 兼容 API(api_key)双模式。
+
+设计:两个模式的底层都是 OpenAI 兼容 /v1 端点,切换只改配置(LLM_MODE),
+业务代码一律调用 `chat()` / `embed()`,不感知底层。失败自动指数退避重试,
+主模型失败切换备用模型(fallback_chat_model),再失败抛 LLMFailError(§1001),
+调用方兜底话术见 FALLBACK_REPLY(降级链与原需求文档一致)。
+"""
+from __future__ import annotations
+
+import asyncio
+import logging
+
+import httpx
+
+from config.settings import LLMCfg, settings
+from utils.exceptions import LLMFailError
+
+logger = logging.getLogger("tool.llm")
+
+# 兜底话术:主模型 + 备用模型均失败时返回
+FALLBACK_REPLY = "抱歉,服务暂时不可用,请稍后再试,或拨打客服热线 400-XXX-XXXX。"
+
+
+class LLMClient:
+ def __init__(self, cfg: LLMCfg | None = None):
+ self.cfg = cfg or settings.llm
+
+ # ---- 模式解析:端点与模型由 LLM_MODE 决定 ---------------------------
+ @property
+ def is_ollama(self) -> bool:
+ return self.cfg.mode.lower() == "ollama"
+
+ @property
+ def base_url(self) -> str:
+ return (self.cfg.ollama_base + "/v1") if self.is_ollama else self.cfg.api_base
+
+ @property
+ def chat_model(self) -> str:
+ return self.cfg.ollama_chat_model if self.is_ollama else self.cfg.api_chat_model
+
+ @property
+ def embed_model(self) -> str:
+ return self.cfg.ollama_embed_model if self.is_ollama else self.cfg.api_embed_model
+
+ def _headers(self) -> dict:
+ h = {"Content-Type": "application/json"}
+ if not self.is_ollama:
+ h["Authorization"] = f"Bearer {self.cfg.api_key}"
+ return h
+
+ def describe(self) -> str:
+ """健康检查/日志用:当前模式 + 端点 + 模型。"""
+ return f"{self.cfg.mode} [{self.base_url}] chat={self.chat_model} embed={self.embed_model}"
+
+ # ---- 对话 -----------------------------------------------------------
+ async def chat(
+ self,
+ messages: list[dict],
+ *,
+ temperature: float | None = None,
+ max_tokens: int | None = None,
+ model: str | None = None,
+ ) -> str:
+ """chat/completions,返回 assistant 文本。
+
+ 模型降级链:model > chat_model > fallback_chat_model;每模型内部退避重试。
+ """
+ models = [model or self.chat_model]
+ if self.cfg.fallback_chat_model and self.cfg.fallback_chat_model not in models:
+ models.append(self.cfg.fallback_chat_model)
+
+ last_err: Exception | None = None
+ for m in models:
+ try:
+ return await self._chat_once(m, messages, temperature, max_tokens)
+ except Exception as e: # noqa: BLE001 重试/切换模型,异常向上收敛
+ logger.warning("chat model=%r failed: %s: %s", m, type(e).__name__, e)
+ last_err = e
+ raise LLMFailError() from last_err
+
+ async def _chat_once(
+ self,
+ model: str,
+ messages: list[dict],
+ temperature: float | None,
+ max_tokens: int | None,
+ ) -> str:
+ payload = {
+ "model": model,
+ "messages": messages,
+ "temperature": self.cfg.temperature if temperature is None else temperature,
+ "max_tokens": self.cfg.max_tokens if max_tokens is None else max_tokens,
+ "stream": False,
+ }
+ url = f"{self.base_url}/chat/completions"
+ # 指数退避:retry_backoff_sec → 翻倍 → …,最多 max_retries 次
+ for attempt in range(self.cfg.max_retries):
+ try:
+ async with httpx.AsyncClient(timeout=self.cfg.timeout) as client:
+ r = await client.post(url, headers=self._headers(), json=payload)
+ r.raise_for_status()
+ return r.json()["choices"][0]["message"]["content"]
+ except Exception:
+ if attempt == self.cfg.max_retries - 1:
+ raise
+ await asyncio.sleep(self.cfg.retry_backoff_sec * (2**attempt))
+ raise LLMFailError() # 理论不可达,防御
+
+ # ---- Embedding(RAG 入口的统一向量化) -------------------------------
+ async def embed(self, texts: list[str]) -> list[list[float]]:
+ """批量向量化,返回与 input 一一对应的向量列表(维度由模型决定)。"""
+ url = f"{self.base_url}/embeddings"
+ payload = {"model": self.embed_model, "input": texts}
+ async with httpx.AsyncClient(timeout=self.cfg.timeout) as client:
+ r = await client.post(url, headers=self._headers(), json=payload)
+ r.raise_for_status()
+ data = r.json()
+ return [item["embedding"] for item in data["data"]]
+
+ async def embed_one(self, text: str) -> list[float]:
+ return (await self.embed([text]))[0]
+
+ # ---- 就绪探测(启动/健康检查可选接入) --------------------------------
+ async def check_health(self) -> None:
+ """验证当前模式端点可达。ollama 走 /api/tags,API 走 /models(需鉴权,401 也算可达)。"""
+ base = self.cfg.ollama_base if self.is_ollama else self.cfg.api_base
+ path = "/api/tags" if self.is_ollama else "/models"
+ async with httpx.AsyncClient(timeout=min(self.cfg.timeout, 10)) as client:
+ r = await client.get(f"{base}{path}", headers=self._headers())
+ if r.status_code >= 500:
+ r.raise_for_status()
+
+
+# 全局单例:Agent 统一引入
+llm = LLMClient()
\ No newline at end of file
diff --git a/utils/__init__.py b/utils/__init__.py
new file mode 100644
index 0000000..b5d72a0
--- /dev/null
+++ b/utils/__init__.py
@@ -0,0 +1 @@
+"""基础设施工具层:统一响应、全局异常、分级日志、请求链路 trace_id。"""
\ No newline at end of file
diff --git a/utils/exceptions.py b/utils/exceptions.py
new file mode 100644
index 0000000..948ce38
--- /dev/null
+++ b/utils/exceptions.py
@@ -0,0 +1,89 @@
+"""业务异常与全局异常处理器:统一转成 {code, message, data, trace_id}。"""
+from fastapi import FastAPI, Request
+from fastapi.exceptions import RequestValidationError
+from fastapi.responses import JSONResponse
+from starlette.exceptions import HTTPException as StarletteHTTPException
+
+from utils.logger import get_logger
+from utils.response import Code, fail
+
+logger = get_logger("api")
+
+
+class ApiError(Exception):
+ """业务异常基类。code 见 utils.response.Code。"""
+
+ def __init__(self, code: int, message: str, data=None):
+ self.code = code
+ self.message = message
+ self.data = data
+ super().__init__(message)
+
+
+class ParamError(ApiError):
+ def __init__(self, message: str = "参数错误"):
+ super().__init__(Code.PARAM_ERROR, message)
+
+
+class AuthError(ApiError):
+ def __init__(self, message: str = "未登录或登录已过期"):
+ super().__init__(Code.UNAUTHORIZED, message)
+
+
+class ForbiddenError(ApiError):
+ def __init__(self, message: str = "无权限执行该操作"):
+ super().__init__(Code.FORBIDDEN, message)
+
+
+class NotFoundError(ApiError):
+ def __init__(self, message: str = "资源不存在"):
+ super().__init__(Code.NOT_FOUND, message)
+
+
+class LLMFailError(ApiError):
+ """LLM 调用失败(重试耗尽,§1001)。"""
+
+ def __init__(self, message: str = "LLM 调用失败,请稍后再试"):
+ super().__init__(Code.LLM_FAIL, message)
+
+
+class RiskTriggeredError(ApiError):
+ """交易被风控拦截(§1004)。"""
+
+ def __init__(self, message: str = "交易被风控拦截,请在订单列表查看处理进度", data=None):
+ super().__init__(Code.RISK_TRIGGERED, message, data)
+
+
+class NotSuitableError(ApiError):
+ """适当性不匹配(§1005)。"""
+
+ def __init__(self, message: str = "客户风险等级与产品风险等级不匹配", data=None):
+ super().__init__(Code.NOT_SUITABLE, message, data)
+
+
+def _http_status(code: int) -> int:
+ """业务码若为标准 HTTP 状态码则原样返回,自定义业务码(1001+)统一返回 200。"""
+ return code if 400 <= code < 600 else 200
+
+
+def register_exception_handlers(app: FastAPI) -> None:
+ @app.exception_handler(StarletteHTTPException)
+ async def http_handler(request: Request, exc: StarletteHTTPException):
+ return JSONResponse(status_code=exc.status_code, content=fail(exc.status_code, str(exc.detail)).model_dump())
+
+ @app.exception_handler(RequestValidationError)
+ async def validation_handler(request: Request, exc: RequestValidationError):
+ return JSONResponse(
+ status_code=422,
+ content=fail(Code.PARAM_ERROR, "参数校验失败", exc.errors()).model_dump(),
+ )
+
+ @app.exception_handler(ApiError)
+ async def api_error_handler(request: Request, exc: ApiError):
+ status = _http_status(exc.code)
+ return JSONResponse(status_code=status, content=fail(exc.code, exc.message, exc.data).model_dump())
+
+ @app.exception_handler(Exception)
+ async def unhandled_handler(request: Request, exc: Exception):
+ logger.exception("unhandled error on %s", request.url.path)
+ return JSONResponse(status_code=500, content=fail(Code.SERVER_ERROR, "服务内部错误").model_dump())
\ No newline at end of file
diff --git a/utils/logger.py b/utils/logger.py
new file mode 100644
index 0000000..3342a2e
--- /dev/null
+++ b/utils/logger.py
@@ -0,0 +1,65 @@
+"""分级日志(info.log / error.log + console),自动携带 trace_id,金融敏感字段脱敏。"""
+import logging
+import re
+import sys
+from logging.handlers import TimedRotatingFileHandler
+from pathlib import Path
+
+from utils.request_id import get_request_id
+
+_TRACE_FMT = "%(asctime)s %(levelname)-7s [%(trace_id)s] %(name)s: %(message)s"
+
+# 手机号(11位)/ 身份证(18位,末位可 X)→ 日志脱敏
+_PHONE = re.compile(r"(? bool:
+ record.trace_id = get_request_id() or "-"
+ return True
+
+
+class SensitiveMaskFilter(logging.Filter):
+ """身份证保留前4后4、手机号保留前3后4,其余用 * 掩码。"""
+
+ def filter(self, record: logging.LogRecord) -> bool:
+ msg = record.getMessage()
+ msg = _IDCARD.sub(lambda m: m.group()[:4] + "**********" + m.group()[-4:], msg)
+ msg = _PHONE.sub(lambda m: m.group()[:3] + "****" + m.group()[-4:], msg)
+ record.msg = msg
+ record.args = ()
+ return True
+
+
+def get_logger(name: str = "app") -> logging.Logger:
+ return logging.getLogger(name)
+
+
+def setup_logging(log_dir: str = "./logs") -> None:
+ """幂等初始化:console + 按天滚动的 info.log / error.log。"""
+ global _logging_configured
+ if _logging_configured:
+ return
+ _logging_configured = True
+
+ root = logging.getLogger()
+ root.setLevel(logging.INFO)
+ formatter = logging.Formatter(_TRACE_FMT)
+ Path(log_dir).mkdir(parents=True, exist_ok=True)
+
+ handlers = [
+ logging.StreamHandler(sys.stderr),
+ TimedRotatingFileHandler(str(Path(log_dir) / "info.log"), when="midnight", backupCount=30, encoding="utf-8"),
+ TimedRotatingFileHandler(str(Path(log_dir) / "error.log"), when="midnight", backupCount=30, encoding="utf-8"),
+ ]
+ handlers[0].setLevel(logging.INFO)
+ handlers[1].setLevel(logging.INFO)
+ handlers[2].setLevel(logging.ERROR)
+ for h in handlers:
+ h.setFormatter(formatter)
+ h.addFilter(TraceIdFilter())
+ h.addFilter(SensitiveMaskFilter())
+ root.addHandler(h)
\ No newline at end of file
diff --git a/utils/request_id.py b/utils/request_id.py
new file mode 100644
index 0000000..cb95e88
--- /dev/null
+++ b/utils/request_id.py
@@ -0,0 +1,32 @@
+"""每请求 trace_id:contextvar 存链路号,中间件生成 / 透传并回写响应头。"""
+import contextvars
+import uuid
+
+from starlette.middleware.base import BaseHTTPMiddleware
+from starlette.requests import Request
+from starlette.responses import Response
+
+_request_id: contextvars.ContextVar[str | None] = contextvars.ContextVar("request_id", default=None)
+
+
+def get_request_id() -> str | None:
+ """获取当前请求的 trace_id(请求上下文外返回 None)。"""
+ return _request_id.get()
+
+
+def new_request_id() -> str:
+ return uuid.uuid4().hex[:16]
+
+
+class RequestIdMiddleware(BaseHTTPMiddleware):
+ """透传客户端 X-Trace-Id(无则生成),供日志/响应头/审计链路关联。"""
+
+ async def dispatch(self, request: Request, call_next):
+ rid = request.headers.get("X-Trace-Id") or new_request_id()
+ token = _request_id.set(rid)
+ try:
+ response: Response = await call_next(request)
+ response.headers["X-Trace-Id"] = rid
+ return response
+ finally:
+ _request_id.reset(token)
\ No newline at end of file
diff --git a/utils/response.py b/utils/response.py
new file mode 100644
index 0000000..f61ef0b
--- /dev/null
+++ b/utils/response.py
@@ -0,0 +1,37 @@
+"""统一响应格式:{code, message, data, trace_id},错误码与需求文档 §6.1 对齐。"""
+from typing import Any
+
+from pydantic import BaseModel
+
+from utils.request_id import get_request_id
+
+
+class Code:
+ OK = 200
+ PARAM_ERROR = 400
+ UNAUTHORIZED = 401
+ FORBIDDEN = 403
+ NOT_FOUND = 404
+ SERVER_ERROR = 500
+ LLM_FAIL = 1001 # LLM 调用失败
+ KB_NO_RESULT = 1002 # 知识库检索无结果
+ SQL_GEN_FAIL = 1003 # NL2SQL 生成失败
+ RISK_TRIGGERED = 1004 # 风控规则触发 / 交易被拦截
+ NOT_SUITABLE = 1005 # 适当性不匹配
+
+
+class ApiResponse(BaseModel):
+ code: int = Code.OK
+ message: str = "success"
+ data: Any = None
+ trace_id: str | None = None
+
+
+def success(data: Any = None, message: str = "success") -> ApiResponse:
+ """成功响应。"""
+ return ApiResponse(code=Code.OK, message=message, data=data, trace_id=get_request_id())
+
+
+def fail(code: int, message: str, data: Any = None) -> ApiResponse:
+ """失败响应(业务错误码见 Code)。"""
+ return ApiResponse(code=code, message=message, data=data, trace_id=get_request_id())
\ No newline at end of file
diff --git a/开发计划.md b/开发计划.md
new file mode 100644
index 0000000..3d8eea9
--- /dev/null
+++ b/开发计划.md
@@ -0,0 +1,371 @@
+# 智能公募基金系统 · 开发计划
+
+> 依据:《需求文档-修改版.html》(v4.53)
+> 版本:v1.0 | 日期:2026-09-07 | 状态:待评审
+
+---
+
+## 0. 约束与差异说明
+
+本项目在需求文档基础上做三处**硬性调整**,其余遵循原文档:
+
+| 项 | 需求文档原方案 | 本项目约定 | 说明 |
+|---|---|---|---|
+| 业务范围 | 全品类理财(基金/银行理财/保险/信托) | **仅公募基金** | 产品表、知识库、推荐逻辑均围绕公募基金 |
+| 数据库 | MySQL + Milvus + Neo4j + Redis + **MinIO** | **仅 MySQL、Milvus、Neo4j、Redis** | MinIO 移除:原始文档正文改存 MySQL `raw_content`;对象文件(原始上传文件)存本地磁盘 `./data/files/`(磁盘不属于数据库,允许),仅记录路径。LLM/Embedding 为外部服务,不受四库约束 |
+| 前端形态 | Streamlit/Gradio 演示 | **企业级 Web**(Vue3 + Element Plus + Pinia + Vite),四个入口:官网 / 客户Web端 / 投顾工作台 / 风控控制台 | 前后端分离,`/api` 统一网关 |
+
+企业级要求:RBAC 权限、操作审计、数据脱敏、幂等下单、限流、链路追踪、SSE 流式、定时任务、消息中心、多级降级。
+
+> 现状基础:工作目录 `Memory_demo` 已完成 FastAPI 骨架 + 四库懒连接单例(`config/database.py`)+ 一个跨四库的 memory 读写 demo,**可复用为 Phase 0 底座**,无需重写。
+
+---
+
+## 1. 总体架构
+
+```
+Vue3 前端
+├─ 官网首页(可匿名) ├─ 客户Web端(登录) ├─ 投顾工作台(登录) ├─ 风控控制台(登录)
+ └────────────────────── FastAPI /api ──────────────────────┘
+ │ Mock JWT · RBAC · 审计 · 限流
+Agent 编排层(统一执行骨架: 记忆召回→意图路由→核心逻辑→结果输出→数据沉淀→事件广播)
+ ├─ 客服Agent(匿名+客户) ├─ 投顾Agent ├─ 风控Agent └─ 数据分析Agent(NL2SQL,加分项)
+能力组件: RAG(Milvus) · GraphRAG(Neo4j) · NL2SQL(MySQL) · 规则引擎 · 画像研判 · 置信度 · 三层记忆
+ 工具层: 文档解析 · Embedding · 订单提交 · 净值计算 · 脱敏 · 事件发布
+ 数据层: MySQL(业务) Milvus(向量) Neo4j(图谱) Redis(缓存/会话/事件/锁)
+```
+
+**四库职责分工(铁律:模块归属不清时按此表归位)**
+
+| 数据库 | 存什么 | 谁读写 |
+|---|---|---|
+| **MySQL** | 用户/投顾关系/画像/基金产品/净值/订单/成交/持仓/风评问卷/预警/规则/工单/会话归档/画像变更日志/知识元数据+原文/审计/公告/站内信 | 业务服务、各 Agent 的持久化沉淀 |
+| **Milvus** | ① FAQ 向量 ② 基金产品说明/政策法规 文档向量 ③ **客户长期记忆向量**(对话摘要+画像标签,按 `customer_id` 过滤) | RAG 检索、客户记忆召回/写入 |
+| **Neo4j** | 客户-产品-行业-市场-基金经理-风险等级图谱 + 规则-业务实体关联 | GraphRAG、投顾推荐多跳查询、行业集中度分析 |
+| **Redis** | 会话短期记忆、画像/产品热缓存、待处理预警 Set、Pub/Sub 事件总线、幂等锁、限流、Token 黑名单、验证码 | 所有 Agent 的短期记忆与实时事件 |
+
+---
+
+## 2. 业务需求
+
+### 2.1 官网首页(首页 = 公司官网 + 客服Agent)
+- 公司介绍、资质荣誉、团队、联系合作(静态区块,数据驱动)
+- **基金产品展示**:列表 + 筛选(类型/风险等级/收益率/起投额)+ 产品详情(净值走势、基金经理、费率、投资策略)
+- 公司公告栏
+- **客服Agent 浮窗**:免登录可用,RAG 回答公募基金问题(FAQ / 产品说明 / 政策解读),**只做普通金融知识解答与公司产品介绍,绝不产生推荐清单**,来源引用 + 兜底转人工话术;识别到购买/开户意图时引导注册;**提出产品推荐诉求 → 引导注册并签约投资顾问**(签约后由投顾Agent 提供专业推荐)
+- 注册/登录入口
+
+### 2.2 注册、登录与账户
+- 注册:手机号 + 短信验证码(Mock)+ 密码 → 创建客户账号 → 预置首次登录引导
+- 登录:username/password → Mock JWT(HS256,24h,含 user_type/employee_role/customer_level)→ 按角色路由到对应工作台
+- 密码安全:哈希存储(bcrypt),不落明文;Token 鉴权中间件;禁用账号校验
+- RBAC:客户 / 投顾 / 风控专员 / 运营(管理员)四类角色,接口级权限依赖注入
+
+### 2.3 客户Web端
+| 模块 | 说明 |
+|---|---|
+| 产品浏览 | 列表/搜索/详情,净值走势图(折线图,数据来自净值表) |
+| **风险评估问卷** | 购买前置:未完成问卷或问卷过期(1年)不可购买任何产品。问卷作答 → 计算总分 → C1-C5 风险等级 → 回写画像;**客户等级**(普通/金卡/白金/钻石/私行)由资产规模+风评自动评定,下载产品范围由风险等级控制 |
+| **购买流程** | 选产品 → 适当性校验(C↔R 匹配,C1 仅 R1,逐级放宽,不匹配拦截 40105 错误)→ 是否已填问卷/在有效期 → 生成申购申请单 → 提交风控 Agent 实时检查 → 通过则按净值确认扣款并更新持仓;**命中规则则订单挂起等待风控处置**(见 2.5) |
+| 持仓与交易 | 当前持仓(成本/市值/盈亏)、成交流水、在途申请单(可撤单未确认单) |
+| 申诉与其他 | 撤单、风险等级复核(可重做问卷,一年一次) |
+| **我的客服 Agent** | 登录态基金问答:RAG + 短期记忆(Redis);**长期记忆**(Milvus `customer_memory`);每次对话结束执行**主客观分类/观点提炼/行为推断**(§4)→ 更新记忆单元与用户画像(置信度+变更日志)。**职责边界:只答普通金融知识与介绍公司产品,不做任何推荐**;持仓/交易类问题引导至对应页面而非代答;**推荐诉求或高净值用户(钻石/私行 或 total_assets≥门槛,sys_config)→ 主动引导签约投资顾问(未签约)或转接专属投顾(已签约)** |
+| 我的投顾 | 展示分配投顾信息;**未签约可发起「签约投顾」申请**(开通正式专业推荐服务);已签约展示专属投顾与收到的推荐/调仓方案(可确认/拒绝)、通知 |
+| 个人中心 | 资料、改密、脱敏展示身份证/手机号、消息中心(站内信)、我的公告 |
+
+### 2.4 投顾工作台
+| 模块 | 说明 |
+|---|---|
+| 客户管理 | 名下客户列表(搜索/按等级/风险分类)、客户详情(画像、风评历史、持仓、交易、聊天记录、规则命中等)、客户分配/转介 |
+| 画像与研判 | 查看四维研判结果、画像标签置信度、手工修正画像(触发审计) |
+| **签约与服务** | 接收客户「投顾签约」工单(客服Agent 引导而来或客户主动发起)→ 查看画像确认服务资格 → 认领/签约 → 客户关系状态置「已签约」并通知客户;**签约前投顾Agent 仅在内部使用,签约后推荐的推荐/调仓方案正式触达客户** |
+| **投顾Agent** | 输入「给客户X推荐3只稳健基金」→ 读取画像 → 适当性过滤 → **GraphRAG 多跳增强**(行业集中度/同风格产品/经理关联)→ 综合排序 → 生成推荐理由与资产配置建议 → 产出推荐报告(**必须附免责声明**),投顾审核后通过站内信触达客户。AI **不直接代客下单** |
+| **调仓与再平衡** | 画像风险等级 → 目标配置基准(portfolio_benchmark)→ 当前持仓市值归因 → 逐组合计算**偏离度**,超阈值(±X% 可配)自动生成**调仓建议**(超配→赎回清单 / 低配→申购清单 + 金额 + 适当性校验 + 图谱约束行业不过度集中)→ 投顾审核 → **客户 Web 端确认授权** → 生成交易申请单走风控Agent(§2.5)。**每日监测**:持仓变动/风险等级变化/市场异动后自动重算并提示。执行前 AI 不代客下单 |
+| **基金深度分析** | 单基金深度分析:区间收益/最大回撤/夏普/波动率/年化(基于 fund_nav_history,定时任务缓存至 `fund_performance`)+ 基金经理/持仓风格/行业暴露/费率,GraphRAG 补充关联 |
+| **多维对比与可视化** | 多基金对比(收益/风险/回撤/夏普/费率的雷达图+表格)、净值走势对比、组合配置饼图、持仓行业穿透(ECharts 可视化,数据后端计算) |
+| **拦截触达客户** | 名下客户购买被风控拦截 → 工作台待办 + 站内信通知 → 查看订单/命中规则/预警详情 → **一键沟通**:站内信模板回复客户 或 标记「已电话联系」(回写沟通记录与工单),供风控处置留证 |
+| 辅助工具 | 单一客户话术提示、待办(新增客户、**签约请求**、风评临期、**拦截待沟通**、风控事件涉及客户) |
+
+### 2.5 风控控制台
+| 模块 | 说明 |
+|---|---|
+| 实时监控 | 每笔申购申请单提交即进入风控 Agent;规则引擎逐条匹配(20 条规则:大额交易 / 频繁交易 / 快进快出 / 短期集中 / 洗钱特征等) |
+| **阻断与处置闭环** | 命中规则 → 订单状态置「**风控挂起**」→ 生成预警记录(分级 蓝/黄/红 + 置信度)→ **控制台页面出现待处理卡片**(交易摘要+命中规则+关联信息)→ 风控专员处置:**放行**(订单继续确认)/ **拦截**(订单作废,通知客户)/ **冻结客户**(禁止交易,转工单复核)→ 处置结果回写订单与预警表 |
+| 预警工作台 | 预警列表/详情/筛选(级别、状态、类型)、处置留痕、大屏统计(今日预警数、命中率、按规则分布) |
+| 规则管理 | 规则启停、阈值配置(规则表驱动,可运营调整,改前留审计) |
+| 事件联动 | **申购/赎回被拦截 → 广播 `event:purchase_blocked` → 该客户名下的投顾收到站内信 + 工作台提醒(红点/未读),主动沟通客户**;高等级预警同步提示投顾工作台与客服端「注意」;预警生成可疑交易上报工单。全部双写落 MySQL,Pub/Sub 只作实时通知 |
+
+### 2.6 运营管理端(企业级新增)
+- 产品管理:基金上架/下架、费率/风险等级/起投额维护、**净值维护(日更)**
+- 公告管理、问卷与题目管理(题目/选项/分值可配,作答按版本记录)
+- 知识库管理:文档上传(txt/md/docx)→ 解析分块(512 token/64 overlap)→ Embedding → Milvus 入库,元数据登记(含原文存 MySQL),FAQ 维护,删除/过期策略
+- 员工与角色管理、规则管理(同上)、系统监控(接口/预警趋势/四库健康)
+
+### 2.7 企业级公共能力
+- 统一响应 `{code,message,data,trace_id}` + 全局异常 + 分级日志(info/error,日志脱敏)
+- 操作审计(audit_log:谁在什么时间做了什么)
+- 数据脱敏过滤器(身份证/手机/姓名/卡号,员工可见全量、客户前端脱敏)
+- 幂等下单(Redis 分布式锁 + order_no 唯一键,防重复申购)
+- Redis 限流(登录/客服接口)
+- SSE 流式输出(客服/投顾回答打字机)
+- 定时任务:净值更新、持仓市值重估、风评过期检测、置信度周期校准、知识过期清理(APScheduler)
+- 多级降级:LLM 指数退避重试→备用模型→兜底话术;Milvus 超时→MySQL LIKE;Neo4j 超时→仅 RAG;Redis 挂→直连 MySQL
+
+---
+
+## 3. 四个数据库设计
+
+### 3.1 MySQL 表(约 24 张,在原 10 张基础上扩展)
+
+**沿用并调整:**
+
+| 表 | 用途 | 关键调整 |
+|---|---|---|
+| `sys_user` | 统一用户(客户/员工) | password_hash 用 bcrypt;加 phone/nickname/status |
+| `fin_customer_profile` | 客户画像 | 加 `profile_version`、`customer_level` |
+| `fin_product` | 公募基金产品 | 字段改基金特征:fund_code、unit_nav、nav_date、fee_rate |
+| `fin_transaction` | 成交流水 | 加 `order_id` 关联申请单 |
+| `fin_holdings` | 持仓 | 不变 |
+| `fin_risk_assessment` | 风评记录 | 加 `question_version` |
+| `fin_risk_alert` | 风控预警 | 加 `order_id`、`confidence`、`handle_time` |
+| `biz_work_order` | 通用工单 | 状态机沿用原文档 |
+| `conversation_archive` | 会话归档 | 加索引 (user_id, agent_type, create_time) |
+| `fin_knowledge_meta` | 知识元数据 | **加 `raw_content` LONGTEXT 替代 MinIO**;加 `file_path` 指向本地上传原文件 |
+
+**新增:**
+
+| 表 | 用途 |
+|---|---|
+| `customer_relation` | 客户-投顾分配(customer_id, advisor_id, assign_time, end_time, status=已分配/已签约/已结束)——**签约状态**驱动客服引导与投顾服务范围,见 §2.3/2.4 |
+| `fund_nav_history` | 净值历史(product_id, nav_date, unit_nav, accumulated_nav, daily_growth) |
+| `trade_order` | **交易申请单**(order_no 唯一, order_type 申购/赎回, amount, status=待确认/风控挂起/已确认/已撤单/失败, risk_alert_id)——「挂起→处置→回写」闭环的主表 |
+| `ops_questionnaire` | 问卷模板(类型:风险测评/投资者分类,version) |
+| `ops_question` | 题目(问卷、题号、题干、选项JSON、分值JSON) |
+| `risk_rule` | 风控规则(rule_id, 条件, 阈值, 风险级别, 权重, 状态)——规则引擎表驱动 |
+| `customer_profile_change_log` | 画像变更审计(字段、旧值、新值、来源、置信度、原因) |
+| `audit_log` | 操作审计(user, action, module, target, detail, ip, trace_id) |
+| `sys_announcement` | 官网公告 |
+| `sys_message` | 站内信/消息中心(user_id, type, is_read) |
+| `memory_unit` | **记忆单元**(tag, content, info_type=FACT/OPINION, source, confidence, evidence/conflict/recall_count, status, valid_until)——记忆架构主体,见 §4 |
+| `fund_performance` | 基金业绩指标缓存(近1月/3月/6月/1年/成立以来收益、年化波动率、最大回撤、夏普比率、计算日期)——定时任务从净值计算 |
+| `portfolio_benchmark` | **组合基准配置**(risk_level → 目标资产类别权重,如 保守=货币50/债券40/股票10,可运营调整)——再平衡的参照系 |
+| `sys_config` | 运营参数 KV(**高净值门槛**、偏离度阈值、风控开关等,免发版调整) |
+
+### 3.2 Milvus 集合(3 个业务集 + 1 个记忆集)
+
+| 集合 | 内容 | 维度 | 索引/度量 | TopK/阈值 |
+|---|---|---|---|---|
+| `fin_faq` | FAQ 问答对(40 组 + 扩展) | 1024(bge-large-zh) | HNSW / COSINE | 3 / 0.75 |
+| `fin_fund_doc` | 基金产品说明、公司业务、政策法规 | 1024 | IVF_FLAT / COSINE | 5 / 0.7 |
+| `fin_policy` | 政策法规 | 1024 | IVF_FLAT / COSINE | 5 / 0.7 |
+| `customer_memory` | **客户长期记忆**:对话摘要/画像标签向量,metadata 含 `customer_id`,检索时 `expr: customer_id == X` 过滤 | 1024 | HNSW / COSINE | 5 / 0.6 |
+
+### 3.3 Redis Key 设计
+
+| Key | 类型 | TTL | 用途 |
+|---|---|---|---|
+| `session:{session_id}:messages` | List | 30min(最长24h) | 短期会话记忆,Token≤4096 截断 |
+| `profile:{customer_id}` | Hash | 7d(访问续期) | 画像热缓存(Cache-Aside) |
+| `product:list:{biz}` / `product:detail:{id}` | String/Hash | 1h | 产品缓存 |
+| `order:lock:{order_no}` | String | 60s | 下单幂等锁 |
+| `risk:alert:pending` | Set | 永久(伴随消费) | 待处理预警快速统计/去重 |
+| `rate:limit:{user_id}:{action}` | String | 滑动窗口 | 登录/对话限流 |
+| `token:blacklist:{jti}` | String | 剩余有效期 | 登出/封禁 |
+| `verify:code:{phone}` | String | 5min | 短信验证码 Mock |
+| **频道** | | | `event:risk_alert` / `event:profile_update` / `event:work_order_change` / `event:knowledge_update`(Pub/Sub 双写:事件同时落 MySQL 表保证不丢) |
+
+### 3.4 Neo4j 图谱模型
+
+- **节点**:`Customer`、`Product`、`FundManager`、`Industry`、`Market`、`RiskLevel`
+- **关系**:`HAS_RISK_LEVEL`、`INVESTS_IN`、`BELONGS_TO(产品→行业)`、`MANAGED_BY(产品→经理)`、`SUITABLE_FOR(产品→风险等级)`、`LOCATED_IN(行业→市场)`
+- 数据导入脚本:MySQL → 全量产品/客户/持仓/风评 → Cypher MERGE 导入;Mock 行业/市场/经理
+- 封装 Cypher Tool:`get_customer_products` / `get_suitable_products` / `get_industry_distribution` / `get_manager_products` / `get_peer_products`
+
+---
+
+## 4. 记忆架构设计(依据《记忆架构设计.html》v2.3)
+
+记忆的本质是**提取 → 存储 → 衰减 → 召回**闭环。核心约束/目标:客户 Agent 长期记忆、读聊天记录更新画像、画像不为一句情绪话而失真。
+
+### 4.1 三层记忆与四库映射
+
+| 层 | 存储 | 内容 | 生命周期 |
+|---|---|---|---|
+| 短期 | Redis `session:{id}:messages` | 会话上下文 | 30min TTL(最长24h),Token≤4096 截断 |
+| 中期 | MySQL `memory_unit` + Redis 热缓存 | **记忆单元**(事实 FACT / 观点 OPINION 分离) | 缓存 Cache-Aside 7d 续期;单元常驻可审计 |
+| 长期 | Milvus `customer_memory`(语义经验)+ Neo4j 图谱(关系记忆) | 经验向量、客户-产品关系 | 遗忘周期管理(§4.5) |
+
+一句话:不同价值的信息用不同存储策略——购物小票(会话)和房产证(长期记忆)不放进同一个抽屉。
+
+### 4.2 记忆单元数据模型(`memory_unit` 表,核心)
+
+| 字段 | 说明 |
+|---|---|
+| id / customer_id | UUID / 归属客户 |
+| tag / content | 标签名(如 `risk_preference`)/ 内容(如"偏好稳健型") |
+| info_type | `FACT` 客观事实 / `OPINION` 主观观点 |
+| source | 风评问卷 / 行为推断 / AI对话提取 / 用户自述 / 系统默认 |
+| source_confidence | 来源初始置信度 |
+| confidence | 当前置信度(动态计算) |
+| evidence_count / conflict_count / recall_count | 证据数 / 冲突数 / 召回次数 |
+| create/update/last_recall_time | 创建 / 更新 / 最后召回时间 |
+| status | active / demoted / archived / deleted |
+| valid_until | 有效期(过期需重新验证) |
+
+### 4.3 信息提取层(写入侧)
+
+- **主客观分类器**:LLM 判定 FACT/OPINION。**红线:主观观点绝不冒充客观事实入库**——"我觉得基金都不好"只存为低置信 OPINION,可衰减、可修正,不直接改变客户风险等级或可购产品范围;可被系统验证的(年龄/交易/风评)才入 FACT。
+- **观点提炼**:对话 → 结构化标签 `{tag, type, confidence, evidence}`;输出错误走六维校验(枚举值/数值范围/时间逻辑/来源/标签/格式)。
+- **行为推断**(初始置信 0.8,**行为比语言真实**):频繁查看某基金净值→"关注该基金";连续赎回同类产品→"对该类失去信心";风评得分逐年上升→"风险承受能力提升";多次咨询养老产品→"有养老规划需求";多次询问产品推荐/投顾服务→"有专业投顾服务意愿"(触发签约引导,§2.3)。
+- **去重合并**:向量相似度判同一观点 → `evidence_count+1` → 重算置信度;新观点则新建单元。
+- **矛盾检测**:opinion 两两 LLM 判定或向量相似度+情感极性 → 双方 `conflict_count+1`、置信下降(如"喜欢高风险" vs "高风险太吓人",两条都真、带时间戳并存,不覆盖、不无限累积)。
+
+### 4.4 置信度引擎
+
+- 来源初始值:风评问卷 0.90 / 行为推断 0.80 / AI 对话提取 0.60 / 用户自述 0.40 / 系统默认 0.20
+- 公式:`score = (base + min(evidence×0.05, 0.30) − conflict×0.10) × max(0, 1 − age_days/365×0.20)`,钳制 [0,1]
+- 时效性分(重排用):`exp(−age_days/180)`(半年半衰期,指数衰减更符合记忆衰退直觉)
+- 全部参数配置化,随业务校准,不硬编码
+
+### 4.5 遗忘机制(每周维护任务)
+
+| 当前置信度 | 年龄 | 命运 |
+|---|---|---|
+| ≥ 0.7 | — | active,保留并进入长期 |
+| 0.3 ~ 0.7 | — | demoted,降权、减少召回 |
+| < 0.3 | > 180 天 | archived 归档(可回溯) |
+| < 0.3 | > 270 天 | deleted 删除 |
+
+与原有"置信度周期校准"调度合并为同一每周任务:**重算置信度 → 矛盾检测 → 遗忘决策 → 更新存储**,周期复检(CHECK_INTERVAL=7d)。
+
+### 4.6 记忆编排器(MemoryOrchestrator 服务组件)
+
+- `process_dialogue`:主客观分类 → 事实/观点提炼 + 行为推断 → 矛盾检测 → 置信度计算 → 六维校验 → 写 MySQL `memory_unit` + Milvus `customer_memory` + 失效 Redis 缓存 → 同步短期记忆 → Pub/Sub `event:profile_update`
+- `recall` 召回:Milvus 向量 + `memory_unit` 精确查询 → `recall_count+1` → **综合重排**(置信分 × 场景权重 × 时效性,场景权重沿用原需求文档 F4.3 FinalConfidenceRank:产品推荐/风险研判/客户画像/知识检索)→ TopK
+- 写入触发源:客服/投顾对话结束(异步)、交易申购/赎回(行为)、风评答卷(结构化)
+- 召回场景:客服 Agent 回复前、投顾 Agent 推荐前
+
+### 4.7 合规边界(企业级新增)
+
+- **主客观分离防误导**:一句情绪话不改变客户等级与适当性范围;可用产品由风评/行为/交易事实决定
+- **被遗忘权**:用户可申请遗忘 → 删除/降权主观观点;客观交易/风评数据按法定保存期保留并脱敏,操作留审计
+- **元认知**:置信度低或矛盾多时,Agent 主动确认("我可能记得不太清楚,您能确认一下吗?",置信度阈值可配置)
+
+---
+
+## 5. Agent 设计
+
+**统一执行骨架**(四 Agent 共用):输入接收 → 记忆召回(短/中/长)→ 意图路由 → 核心逻辑(RAG/GraphRAG/NL2SQL/规则)→ 结果输出(SSE)→ 数据沉淀(会话归档)→ 事件广播。
+
+| Agent | 服务对象 | 核心逻辑 | 记忆 | 关键合规 |
+|---|---|---|---|---|
+| 客服Agent | 匿名访客、登录客户 | 意图识别 + RAG + 多轮;**仅普通金融知识 + 公司产品介绍,不推荐** | 短:Redis;长:customer_memory;对话→画像标签抽取 | 推荐诉求 / 高净值用户 → **引导签约投顾(§2.3/2.4)**;持仓账户类问题引导至对应页面;无法回答→转人工话术 |
+| 投顾Agent | 投顾(内部) | 画像 + 适当性过滤 + GraphRAG 排序 → 推荐报告;**组合偏离度计算与调仓方案生成(§2.4)+ 每日组合监控** | 中:画像缓存;长:图谱+memory_unit | 调仓方案须投顾审核 + 客户确认才生成交易申请单;报告附免责声明;C1 绝不推 R4+;**不代客下单** |
+| 风控Agent | 风控专员 | 20 条规则引擎 + 置信度分级 → 阻断/预警 | 短:事件缓存;长期:预警记录 | 阻断即挂起订单,人工处置才放行 |
+| 数据分析Agent(加分) | 运营/投顾 | NL2SQL:动态 Schema 注入 + 安全校验(仅SELECT) + 结果解读 | 中:MySQL+Redis | 只读;结果含自然语言解读;面向客户报告附免责 |
+
+**画像更新链路**(客户Agent 核心需求,详见 §4):对话 → **主客观分类** → 事实/观点提炼 + 行为推断 → 矛盾检测 → 置信度计算(§4.4 公式)→ 写 `memory_unit` + `customer_memory` 向量 + 失效 Redis 缓存 → 变更日志 + Pub/Sub `event:profile_update` → 投顾端感知。
+
+**事件协作**:风控→投顾(**申购被拦截→通知投顾联系客户**、高预警提示)、客服→风控(高风险意图上报)、画像变更→投顾。全部双写保证不丢。
+
+---
+
+## 6. 分阶段开发计划(约 6 周)
+
+### Phase 0|基础设施(第 1 周 · 前 3 天)
+- **复用现有 `Memory_demo` 骨架**:四库懒连接单例(已验通)
+- docker-compose 一键起 MySQL/Milvus/Neo4j/Redis(含本地 Embedding 模型可选)
+- 全量建表(3.1 节 ~24 张)+ Mock 数据生成(5 位测试客户、40 只基金、20 条规则、净值 90 天序列、FAQ);Mock 数据需符合原文档 1.4 节约束(收益率区间/交易时间线/金额合理)
+- 统一响应/全局异常/日志/路由前缀规范
+- **验收**:四库 `/health/ready` 全 ok;20 张表建齐;Mock 数据可查询
+
+### Phase 1|官网 + 注册登录 + 知识库 + 客服Agent(第 1 周后半 ~ 第 2 周)
+- 前端工程初始化(Vue3+Vite+Element Plus),四入口路由雏形
+- 官网首页:公司介绍/产品展示(列表/详情/净值图)/公告/客服浮窗
+- 注册(验证码 Mock)→ **自动分配投顾**;登录 → JWT + RBAC → 角色路由
+- 知识库搭建:文档解析(txt/md/docx)→ 分块 → Embedding → Milvus 入库,`fin_knowledge_meta` 原文 + 元数据登记,上传/列表/删除/检索接口
+- 客服Agent:意图识别 + RAG 检索 + Redis 短期记忆 + 兜底策略 + SSE;**职责边界内置:产品推荐诉求仅引导注册+签约投顾,不直接推荐**
+- **验收**:官网可匿名浏览并能与客服Agent 对话(≥5 类基金问题准确率≥80%);注册后自动分配到投顾;登录按角色跳到对应端
+
+### Phase 2|客户Web端(第 3 周 ~ 第 4 周前半)
+- 风险测评问卷(16 题,题目/评分可配)→ C1-C5 → 客户等级评定;购买前置拦截(未问卷/过期不可购买)
+- 产品浏览 + 适当性校验 + **申购申请单**(幂等锁)→ 净值确认 → 更新持仓;赎回/撤单/持仓/交易/在途查询
+- 画像系统:四维研判引擎 + 标签置信度 + 冲突策略 + 变更日志
+- **客户Agent 增强**:长期记忆(customer_memory 写入/读取)+ **主客观分类/观点提炼/行为推断(§4.3)** → 记忆单元与画像更新链路
+- **客服边界与签约引导**:客服不产生推荐;识别**推荐诉求**或**高净值客户**(customer_level=钻石/私行 或 total_assets≥sys_config 门槛)→ 未签约引导发起「签约投顾」申请(生成工单),已签约引导转接专属投顾
+- 个人中心(脱敏、改密、消息中心、我的投顾)
+- **验收**:新客户首次购买被问卷拦截;填完问卷 C1 客户不可购 R3+;申购→(直接放行路径)持仓更新成功;Agent 多轮对话后画像出现新标签(查看变更日志);**主客观分类正确**——"我觉得债券稳"存为可衰减 OPINION、"我35岁/买了10万"存为 FACT,情绪话不改变风险等级(§4);**客服边界**:请求推荐时客服只引导签约不列清单;高净值(钻石/私行或资产≥门槛)未签约客户收到签约引导提示
+
+### Phase 3|投顾工作台(第 4 周后半 ~ 第 5 周前半)
+- Neo4j 图谱构建 + MySQL 导入脚本 + 图谱统计/可视化接口
+- 客户管理:名下客户列表/详情(画像/持仓/风评/交易/聊天记录)、分配与转介
+- **签约请求处理**:客户发起(或客服Agent 引导发起)的「投顾签约」工单 → 查看画像确认资格 → 认领/签约 → `customer_relation.status=已签约` → 通知客户;**签约后投顾Agent 的推荐/调仓方案正式触达客户,未签约仅供内部参考**
+- **投顾Agent**:画像读取 → 适当性过滤 → GraphRAG 增强 → 推荐排序 → 推荐理由 + 资产配置建议 + 免责声明 → 生成报告 → 站内信触达;审核后才算完成
+- **组合诊断与调仓(再平衡)**:portfolio_benchmark 目标配置 → 当前持仓市值归因 → 偏离度计算 → 超阈值自动生成调仓建议(超配赎回/低配申购清单 + 金额 + 适当性校验)→ 投顾审核 → **客户 Web 端确认** → 生成交易申请单(走风控 Agent);图谱辅助约束行业集中度、推荐同风格替代基金
+- **基金深度分析 / 多维对比 / 可视化**:净值 → 业绩指标(区间收益/最大回撤/夏普/波动率,定时任务缓存 fund_performance)+ GraphRAG 补充 → 单基金深度分析、多基金对比、ECharts 可视化(净值走势 / 收益-风险散点 / 配置雷达 / 行业穿透)
+- **验收**:图谱节点>300/关系>2000;投顾Agent 推荐不越适当性红线(对 5 位测试客户逐一验证);推荐理由引用画像;报告附免责声明;风控事件涉及客户时工作台出现提示(含拦截待沟通待办);**构造偏离样例(如债券超配/股基低配)→ 自动生成调仓清单、金额正确、不越适当性红线,客户确认后生成交易申请单并进入风控监控**;**签约闭环:客户提交签约申请 → 投顾认领后客户侧显示专属投顾、客服不再引导签约(customer_relation.status 正确流转)**
+
+### Phase 4|风控控制台(第 5 周后半 ~ 第 6 周前半)
+- 20 条规则引擎(risk_rule 表驱动)+ 三级预警分级 + 置信度(参考 F4.3 公式)
+- 交易实时接入:申购/赎回提交即进风控 Agent
+- **阻断与处置闭环**:命中 → 订单「风控挂起」→ 预警卡片 → 页面待处理列表 → 专员 放行/拦截/冻结 → 回写订单与客户状态
+- 预警工作台 + 趋势统计 + 规则管理页(启停/阈值)
+- Redis Pub/Sub 事件联动 + 可疑交易上报工单
+- **验收**:用 10 组 Mock 异常交易验证 20 条规则;命中后订单在页面出现且客户侧显示「审核中」;放行后订单继续确认、拦截后作废并通知;高等级预警触发投顾/客服端提示;**拦截子场景**:客户申购被拦截 → 该客户名下投顾工作台出现待办+站内信 → 投顾查看预警详情并标记「已联系客户」 → 回写沟通记录至工单(全链路可查)
+
+### Phase 5|运营后台 + 企业级加固 + 集成联调(第 6 周后半 ~ 第 7 周)
+- 运营后台:产品/净值/公告/问卷/知识库/员工与角色/系统监控页面
+- 公共能力落地:审计日志、脱敏、幂等、限流、SSE、事件双写补全
+- 定时任务:净值更新、持仓重估、**组合每日监控(偏离度盘点 → 超阈值自动生成调仓建议并通知投顾,§2.4)**、风评过期检测、**记忆每周维护(置信度重算 + 矛盾检测 + 遗忘归档淘汰,§4.5)**、知识过期清理
+- 数据分析Agent(NL2SQL,正式实现,作为加分项)+ 系统监控大屏
+- **端到端联调**:官网注册→问卷→购买→风控命中→处置→投顾推荐 全链路演示;5 位测试客户 + 边界 case(无画像、超长对话、SQL 注入、并发、适当性违规)
+- 性能达标:RAG<2s、Agent 回答<5s(SSE);降级路径逐一验证
+- 交付:README(目录/启动/配置)、API 文档、数据库文档、演示脚本、答辩 PPT、会议纪要
+
+---
+
+## 7. 里程碑与交付物
+
+| 里程碑 | 时间 | 交付物 |
+|---|---|---|
+| M0 基建就绪 | 第 1 周 | 四库 docker-compose、建表脚本、Mock 数据、骨架可启动 |
+| M1 官网可注册 + 客服Agent可用 | 第 2 周末 | 官网/注册登录/知识库/客服对话 |
+| M2 客户可购买 | 第 4 周前 | 问卷+适当性+申购闭环 |
+| M3 投顾可推荐 | 第 5 周前 | Neo4j 图谱、投顾Agent 报告 |
+| M4 风控可拦截处置 | 第 6 周前 | 规则引擎、阻断闭环、预警工作台 |
+| M5 全链路可演示 | 第 7 周 | 运营后台、审计/监控、端到端演示、答辩材料 |
+
+## 8. 团队分工(可按实际人数合并)
+
+| 角色 | 负责 |
+|---|---|
+| 后端/架构 | FastAPI、四库接入、订单/交易/画像核心链路、审计/脱敏/限流 |
+| AI/Agent | RAG、NL2SQL、客服Agent、画像抽取、置信度 |
+| 图谱/数据 | Neo4j 构建、GraphRAG、Mock 数据、定时任务 |
+| 业务/风控 | 风评问卷、画像研判、20 条规则引擎、阻断闭环 |
+| 前端 | Vue3 四端页面、SSE 流式、图表 |
+| 集成 | 事件总线、端到端联调、演示、文档 |
+
+## 9. 风险与对策
+
+| 风险 | 对策 |
+|---|---|
+| 四库集群资源占用大(Milvus/Neo4j 内存) | docker-compose 资源限制;小数据量集合用存量数据起步;本地 bge-large-zh 避免 API 费用 |
+| LLM 延迟/不稳 | 指数退避重试 + 备用模型 + 兜底话术;检索/图谱超时降级 |
+| 适当性红线被绕过 | 购买下单服务内强制校验(不依赖前端)+ 单测覆盖 C1→R3+ 用例 |
+| 订单与风控状态一致性 | `trade_order` 单一状态机 + 处置接口幂等 + 审计留痕 |
+| 时间不足功能裁剪 | 硬优先级:M1-M4 主链路优先;运营后台、NL2SQL、监控大屏为后置项 |
+
+## 附录 A:需求 → 四库映射
+
+| 需求 | MySQL | Milvus | Neo4j | Redis |
+|---|---|---|---|---|
+| 官网产品展示 | 产品表/净值表 | — | — | 产品缓存 |
+| 客服Agent FAQ/政策回答 | 会话归档 | fin_faq/fin_policy | — | 短记忆/限流 |
+| 客户长期记忆/画像更新 | memory_unit/画像/变更日志 | customer_memory | — | 画像缓存/事件 |
+| 投顾推荐 | 画像+持仓 | 文档向量 | 图谱多跳 | 画像缓存/事件 |
+| 购买适当性校验 | 风评/产品 | — | SUITABLE_FOR | 画像缓存 |
+| 风控拦截+投顾触达 | trade_order/预警/工单/站内信 | — | 规则-实体 | 事件/pending Set/通知 |
+| 调仓与组合监控 | 持仓/基准/业绩指标/订单 | — | 行业集中度/替代基金 | 每日监控锁/待办/通知 |
+| 客服引导签约投顾 | customer_relation/工单 | — | — | 画像/记忆缓存 |
+| 审计/脱敏/公告/消息 | 对应表 | — | — | 缓存 |
\ No newline at end of file