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:
+10
-2
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 非法或已过期")
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user