merge: 同步 origin/qyqy_develop(风控扫描、知识检索、客服 Agent、协商等)

冲突处理:均为双方各自新增,按并集保留——
- .gitignore:本地 data/logs 忽略项 + 远端 .dsh-drop/
- app/core/config.py:offsite/promotion 与 risk_scan 配置项并存
- app/service/agent/bootstrap.py:场外/推介/风控/NL2SQL/知识检索 工具与 Agent 全部注册
- app/worker/__main__.py:场外邮件 Worker 接线 + runtime 关系服务/投影清理注入并存

收尾:新增 20260911_merge_risk_heads 收敛迁移双 head;按 docs/21 生成 config/jwt/dev 开发密钥。
This commit is contained in:
2026-09-11 17:32:47 +08:00
153 changed files with 16846 additions and 267 deletions
+10 -2
View File
@@ -21,8 +21,10 @@ class Settings(BaseSettings):
jwt_issuer: str
jwt_audience: str
jwt_algorithm: str = "RS256"
jwt_private_key_path: str = "config/jwt/jwt-private.pem"
jwt_public_key_path: str = "config/jwt/jwt-public.pem"
# 默认指向**开发专用**密钥(tools/generate_jwt_keys.py 生成,config/jwt/ 不进版本库)。
# 生产环境必须用环境变量覆盖为生产机上单独生成的那一套,不要复用开发密钥。
jwt_private_key_path: str = "config/jwt/dev/jwt-private.pem"
jwt_public_key_path: str = "config/jwt/dev/jwt-public.pem"
jwt_clock_skew_seconds: int = Field(default=30, ge=0)
mysql_dsn: str
mysql_pool_size: int = Field(default=5, ge=1)
@@ -106,6 +108,12 @@ class Settings(BaseSettings):
promotion_max_photo_size_bytes: int = Field(default=10 * 1024 * 1024, gt=0)
promotion_max_performance_file_size_bytes: int = Field(default=20 * 1024 * 1024, gt=0)
risk_scan_schedule_enabled: bool = False
risk_scan_interval_minutes: int = Field(default=5, ge=1, le=1440)
risk_scan_run_immediately: bool = False
risk_scan_retry_limit: int = Field(default=2, ge=0, le=5)
risk_scan_poll_seconds: float = Field(default=30, gt=0)
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
+17
View File
@@ -13,6 +13,20 @@ class AgentRequestMetadata(BaseModel):
ui_entry: str | None = None
class ConversationTurn(BaseModel):
"""会话中的一轮对话(短期记忆)。
只保留角色与正文:模型用它解析指代("那它风险高吗"里的"它"指哪只基金),
不需要意图、置信度这类内部字段——把内部字段一并喂给模型既增加噪声,
也扩大了"模型看到不该看的东西"的面。
"""
model_config = ConfigDict(frozen=True)
role: Literal["user", "assistant"]
content: str
class AgentRequest(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
@@ -21,6 +35,9 @@ class AgentRequest(BaseModel):
session_id: str
idempotency_key: str
metadata: AgentRequestMetadata = Field(default_factory=AgentRequestMetadata)
# 本会话中**本次之前**的对话,按时间正序(旧 → 新)。
# 默认空元组:既有构造点(API 受理路径、单测、验收脚本)无需改动即可继续工作。
history: tuple[ConversationTurn, ...] = ()
@field_validator("message")
@classmethod
+20
View File
@@ -0,0 +1,20 @@
"""知识检索工具的入参契约(与 `fund_contracts.py` 同一模式)。
放在 `app/core` 而不是 service 里:工具的 `input_model` 会被 ToolExecutor 用于参数校验,
属于跨层契约;放在 service 模块会让 API 层与工具注册处都反向依赖 service 实现。
"""
from pydantic import BaseModel, ConfigDict, Field
class KnowledgeSearchInput(BaseModel):
"""知识库检索入参。
`collection` 留空表示三个集合全查(客服默认行为);指定单个集合用于意图明确时收窄范围。
"""
model_config = ConfigDict(extra="forbid")
query: str = Field(min_length=1, max_length=500)
collection: str = Field(default="", max_length=64)
top_k: int = Field(default=5, ge=1, le=10)
+54 -6
View File
@@ -1,22 +1,66 @@
"""风控列表游标;对外是不透明字符串,内部保存页偏移。"""
"""风控列表游标;对外是不透明字符串,内部保存页偏移与**绑定指纹**。
`docs/05` §3.8 要求「游标是不透明字符串,绑定用户、查询条件、排序字段和方向,
客户端不得解析或修改」;§3.6 的错误表把「**与过滤条件不符**」明确列为 `INVALID_CURSOR`
的触发条件之一(§16 同样写「分页游标绑定过滤条件」)。
原先这里只存 `{"offset": n}`,什么都没绑定,于是:
- 任何拿到游标的人都能拿它去翻**别人的**结果集(越权);
- 改了筛选条件还能继续用旧游标,而 offset 分页在结果集变化时本来就会跳行/重复,
两者叠加会**静默返回错位的数据**。
现在游标里额外存一个**绑定指纹**:由「用户 + 查询条件 + 排序」压成。服务端解码时按当前
请求重算指纹并比对,不一致就报 `INVALID_CURSOR`。
指纹用 SHA-256(无需密钥):它要防的是"无意复用",不是恶意伪造。
真正的防篡改需要 HMAC 与密钥管理,属于后续加固项 —— 这里不假装做到了。
"""
import base64
import hashlib
import json
from collections.abc import Mapping
from typing import Any
from app.core.errors import InvalidCursorError
__all__ = ["decode_offset_cursor", "encode_offset_cursor"]
__all__ = ["cursor_binding", "decode_offset_cursor", "encode_offset_cursor"]
_BINDING_KEY = "b"
def encode_offset_cursor(offset: int) -> str:
def cursor_binding(*, user_id: str, filters: Mapping[str, Any], order_by: str = "") -> str:
"""把「谁、按什么条件、按什么顺序」压成一个稳定指纹。
`sort_keys=True` 让字典顺序不影响结果;`default=str` 兜住日期与 Decimal 之类不可直接
序列化的筛选值(它们同样应当参与比对)。
"""
material = json.dumps(
{
"user_id": str(user_id),
"filters": {str(key): value for key, value in filters.items()},
"order_by": order_by,
},
sort_keys=True,
ensure_ascii=False,
default=str,
separators=(",", ":"),
)
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:32]
def encode_offset_cursor(offset: int, *, binding: str) -> str:
if offset < 0:
raise ValueError("offset must be non-negative")
payload = json.dumps({"offset": offset}, separators=(",", ":")).encode("utf-8")
payload = json.dumps(
{"offset": offset, _BINDING_KEY: binding}, separators=(",", ":")
).encode("utf-8")
return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
def decode_offset_cursor(raw: str | None) -> int:
def decode_offset_cursor(raw: str | None, *, binding: str) -> int:
"""解码游标并校验绑定指纹;任何不一致都按非法游标处理(400 INVALID_CURSOR)。"""
if raw is None or not raw.strip():
return 0
value = raw.strip()
@@ -26,8 +70,12 @@ def decode_offset_cursor(raw: str | None) -> int:
decoded: Any = json.loads(payload.decode("utf-8"))
except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as error:
raise InvalidCursorError("cursor 非法或已过期") from error
if not isinstance(decoded, dict) or set(decoded) != {"offset"}:
if not isinstance(decoded, dict) or set(decoded) != {"offset", _BINDING_KEY}:
raise InvalidCursorError("cursor 非法或已过期")
if decoded[_BINDING_KEY] != binding:
# 换了用户、改了筛选条件或排序:旧游标对新查询没有意义。继续用会静默返回错位的
# 数据 —— 这正是 docs/05 把"与过滤条件不符"列为 INVALID_CURSOR 的原因。
raise InvalidCursorError("cursor 与当前查询条件不符")
offset = decoded["offset"]
if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0:
raise InvalidCursorError("cursor 非法或已过期")
+83
View File
@@ -0,0 +1,83 @@
"""时区换算:库内存 UTC naive,边界判断与展示按配置时区(默认北京时间)。
**为什么需要它**:风控的定时规则曾直接取 `confirmed_at.hour` 判断"凌晨",而库里存的是
UTC —— `[0,6)` UTC 实际是北京时间 **08:00–14:00**,于是「凌晨时段小额操作」这条规则把
整个上午的交易都判成了凌晨(`docs/25` P1 #6)。日报也按 UTC 日切,却按北京时间展示,
两处对同一天的理解差 8 小时。
**约定**(与 `app/infrastructure/db.py:15-21` 的说明一致):
- 库内 DATETIME 为 **UTC naive**,不带时区;
- 任何"这是本地几点 / 哪一天"的判断,都必须先经过本模块换算;
- 展示同样走这里,保证与 `get_settings().timezone` 一致。
"""
from datetime import UTC, date, datetime, time, timedelta
from zoneinfo import ZoneInfo
from app.core.config import get_settings
def local_zone() -> ZoneInfo:
"""业务判断与展示统一使用的本地时区(默认 `Asia/Shanghai`)。"""
return ZoneInfo(get_settings().timezone)
def to_local(value: datetime) -> datetime:
"""把库内的 UTC naive 时间换算成本地时区。
已经带时区的值按其原时区处理——那说明它来自库外(例如请求参数),
按它自己声明的时区解释才是对的。
"""
aware = value if value.tzinfo is not None else value.replace(tzinfo=UTC)
return aware.astimezone(local_zone())
def to_utc_naive(value: datetime) -> datetime:
"""把任意时区的时间换算回**库内格式**(UTC naive),用于构造查询条件。
查询参数必须经过这一步:拿本地时间直接去比库内的 UTC 列,会整体差 8 小时。
"""
aware = value if value.tzinfo is not None else value.replace(tzinfo=UTC)
return aware.astimezone(UTC).replace(tzinfo=None)
def from_local(value: datetime) -> datetime:
"""把**客户端传来的时间参数**换算成库内格式(UTC naive)。
REST 的时间参数是裸 `datetime`(`api/schemas/risk.py:32-33`),不带时区信息。
对面向中国客户的业务系统,裸值按**本地(北京)时间**解释才符合填表人的预期:
当成 UTC 会让前端填的"今天 00:00"实际查到昨天 08:00 起的数据。
带时区的值仍按它自己声明的时区处理。
与 `to_utc_naive` 的区别就在裸值上:这个把裸值当**本地**,那个当 **UTC**。
取库里的值用后者,接客户端输入用这个。
"""
aware = value if value.tzinfo is not None else value.replace(tzinfo=local_zone())
return aware.astimezone(UTC).replace(tzinfo=None)
def local_hour(value: datetime) -> int:
"""库内时间对应的**本地**小时(0-23)。
用于"是否凌晨"这类按时段判断的规则——绝不能用 `value.hour`,
那是 UTC 小时。
"""
return to_local(value).hour
def local_date(value: datetime) -> date:
"""库内时间对应的**本地**日期。"""
return to_local(value).date()
def local_day_bounds(value: datetime) -> tuple[datetime, datetime]:
"""库内时间所在的**本地自然日**,换算回库内格式的起止时刻 `[start, end)`。
返回的是 UTC naive:它要拿去查库内的 UTC 列。若返回本地时间,
区间会与库内值整体错开 8 小时——这正是原先日报日界出错的成因。
"""
local = to_local(value)
start_local = datetime.combine(local.date(), time.min, tzinfo=local.tzinfo)
start = to_utc_naive(start_local)
return start, start + timedelta(days=1)