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