fix: 修好访客客服浮窗、投顾工作台数据口径,恢复访客页来源声明

组员这次提交的两套新页面方向都对,但各有一处"接不上"的地方,这里补齐。

1) 访客客服浮窗(此前一问即失败)
   客服 Agent 让访客走 query_knowledge(访客令牌的角色是 visitor、权限只有
   agent:run + knowledge:query),但发布配置里三个知识意图只发了 search_knowledge,
   于是 ToolExecutor 直接抛 ForbiddenAgentError,而客服代码对白名单失败是
   「必须冒泡」的 —— 访客拿不到任何回答,登录客户侧却完全正常。
   发布脚本的知识类意图改为同时发 search_knowledge 与 query_knowledge:
   访客走前者、客户走后者,缺任一条对应人群就失败关闭。
   (suitability_check 不发 query_knowledge:访客意图白名单不含它,访客到不了。)

2) 发布脚本会静默丢提示词
   旧写法只查 platform_config_item 就当作"继承",而 config_release 是整版本替换
   语义,新版本没带上的行等于被删除 —— 实际把 customer_service_chitchat 提示词
   漏在了旧版本里(admin 端只在激活时打一句 stderr 警告)。
   改为走 ConfigReleaseService.effective_snapshot() 读全三张受管表,补上提示词
   搬运(version 重分配、带上 input_schema/output_schema),并在激活后硬校验
   配置项与提示词条数,条数不符即失败退出。
   (model_routing_rule 本环境为空;不为空则直接中止,不假装支持。)
   丢失的那条提示词已按原文恢复,active 版本现为 9 条配置项 + 1 条提示词。

3) 投顾工作台永远为空
   published() 取的是 customer_id == 自己 user_id,而投顾是员工账号、不可能是
   客户;且只认 advisor_recommendation_plan + approved,而投顾交付的主产物是
   investment_goal_book,发布后状态是 published。三重不匹配下页面永远显示空态。
   改为按「本人 + sys_customer_assignment 里名下归属客户」过滤(不用 data_scope:
   投顾因持有 all 级权限会把整个身份的 scope 抬到 all,那会放开到全部客户),
   并覆盖两类 content_type 与两种已发布取值。
   实测:投顾可见归属客户 9001 的方案书,客户仍只见自己的,风控仍 403。

4) 访客页把"演示数据"声明删了但假数据还在
   mock-data.js 的 MOCK_SOURCE_NOTICE 与两个页面的 data-source-notice 区块被删除,
   而 MOCK_PRODUCTS/MOCK_RANKING_CHANGE 仍在渲染(详情页含历史净值曲线)。
   恢复声明常量、页面区块与样式,并给 products/product-detail 的 link 与 script
   加上版本参数 —— 此前没有版本号,浏览器会命中旧缓存,改动看不见。

其他:投顾页显示交付物类型与客户编号(后端新返回的字段),README 补上投顾页
数据口径、访客/客户两条检索工具的差别,以及"渲染 mock 必须带来源声明"的约定。
This commit is contained in:
2026-09-13 18:54:09 +08:00
parent b9aafce120
commit d4a895c643
11 changed files with 221 additions and 59 deletions
+29 -3
View File
@@ -29,6 +29,14 @@ from app.service.suitability_service import SuitabilityService
class ProductRecommendationService:
CONTENT_TYPE = "advisor_recommendation_plan"
#: 面向客户展示的投顾内容类型。**方案书(goal book)是本项目投顾交付的主产物**,
#: 由 `InvestmentGoalService` 写入同一张 `client_facing_content` 表,靠 `content_type`
#: 区分。投顾工作台只认 recommendation 时永远为空 —— 因为投顾给客户交付的是方案书。
CLIENT_CONTENT_TYPES: tuple[str, ...] = ("advisor_recommendation_plan", "investment_goal_book")
#: 两类内容的"已发布"在库里取值不同:recommendation 审核通过后置 `approved`
#: (`product_recommendation_service.review`),方案书发布后置 `published`
#: (`investment_goal_service.publish_book`)。只判 `approved` 会把方案书整类漏掉。
PUBLISHED_STATES: tuple[str, ...] = ("approved", "published")
def __init__(
self,
@@ -315,16 +323,32 @@ class ProductRecommendationService:
operation,
)
@staticmethod
def _visible_customer_ids(context: RequestContext) -> tuple[int, ...]:
"""可查看的客户 id:本人 + 名下归属客户。
为什么不用 `data_scope`:投顾/运营因为持有 `promotion:*` 这类 all 级权限,
`IdentityService` 会把**整个身份**的 scope 抬到 `all`(`identity_repository` 取各授权
scope 的最大值)。按 scope 判定会让他们看到全部客户的方案,属过度开放。
归属关系来自 `sys_customer_assignment`(逐条授权,且带 assigned_at/unassigned_at
时间窗校验),比 scope 更窄,也更贴合"投顾只看自己服务的客户"这个业务口径。
"""
ids = {str(context.user_id), *(str(item) for item in context.customer_ids)}
return tuple(sorted({int(item) for item in ids if item.strip().isdigit()}))
async def published(self, context: RequestContext) -> dict[str, object]:
await AuthorizationService.require(context, "product-recommendation:read:self")
customer_ids = self._visible_customer_ids(context)
if not customer_ids:
return {"data": [], "meta": {"trace_id": context.trace_id}}
async with self.session_factory() as session:
rows = list(
await session.scalars(
select(ClientFacingContent)
.where(
ClientFacingContent.customer_id == int(context.user_id),
ClientFacingContent.content_type == self.CONTENT_TYPE,
ClientFacingContent.review_status == "approved",
ClientFacingContent.customer_id.in_(customer_ids),
ClientFacingContent.content_type.in_(self.CLIENT_CONTENT_TYPES),
ClientFacingContent.review_status.in_(self.PUBLISHED_STATES),
ClientFacingContent.published_at.is_not(None),
)
.order_by(ClientFacingContent.published_at.desc())
@@ -335,6 +359,8 @@ class ProductRecommendationService:
"data": [
{
"content_id": str(row.id),
"customer_id": str(row.customer_id),
"content_type": row.content_type,
"plan": row.draft_content,
"published_at": row.published_at.isoformat() if row.published_at else None,
}
+15 -4
View File
@@ -18,10 +18,21 @@
| `/portal/employee-console/login/` | 未登录员工 / 管理员 | A034 |
| `/portal/employee-console/workspace/` | admin / super_admin | A002-A006、A012、A033、A035-A040、客服转人工管理接口 |
| `/portal/employee-risk/dashboard/` | risk_operator / admin / super_admin | `/api/v1/risk/**`、R001-R003 |
| `/portal/employee-advisor/dashboard/` | advisor / admin / super_admin | `/api/v1/advisor/recommendations/published`、目标与组合分析 |
| `/portal/employee-advisor/dashboard/` | advisor / admin / super_admin | `/api/v1/advisor/recommendations/published`(本人 + 名下归属客户的**已发布**交付物) |
| `/portal/employee-operations/dashboard/` | operator / admin / super_admin | `/api/v1/offsite-fund/mails`、`/api/v1/offsite-fund/mailbox-status` |
客服浮窗由公开首页、基金产品页和客户工作台统一挂载。访客使用 `/api/v1/visitor-tokens`
获取短期令牌,仅允许公开知识问答;客户沿用登录令牌,可在授权范围内查询账户服务。
> 投顾页的数据口径:接口按「本人 + `sys_customer_assignment` 里名下归属客户」过滤,且同时覆盖
> `investment_goal_book`(方案书,发布后 `review_status='published'`)与
> `advisor_recommendation_plan`(推荐方案,审核后 `review_status='approved'`)两类内容。
> 方案书的**审核与发布都要求管理员**(`investment-goal:review` / `publish` 都带 `admin=True`),
> 投顾自己发不出来 —— 这是有意设计的复核环节,不是缺陷。
公开产品 HTTP 接口尚未实现,因此相关页面使用 `common/mock-data.js`,不得与登录后的真实账户数据混用。客户页面均由 `common/auth.js` 执行入口守卫,接口路径只在 `common/api-client.js` 的端点表登记。
客服浮窗由公开首页、基金产品页和客户工作台统一挂载。访客使用 `/api/v1/visitor-tokens`
获取短期令牌(角色 `visitor`,权限只有 `agent:run` + `knowledge:query`),因此**必须**走
`query_knowledge` 这个工具名;登录客户走 `search_knowledge`。两者都要出现在发布配置的
`agent_tools/customer_service:<intent>` 白名单里,缺哪一条,对应人群就一问即失败。
公开产品 HTTP 接口尚未实现,因此相关页面使用 `common/mock-data.js`,不得与登录后的真实账户数据混用。
**凡渲染这些 mock 数据的页面都必须挂 `data-source-notice` 并写入 `MOCK_SOURCE_NOTICE`**
(`guest/products/`、`guest/product-detail/`)—— 删掉声明不会让数据变真,只会让客户误以为看到的是真实净值。
客户页面均由 `common/auth.js` 执行入口守卫,接口路径只在 `common/api-client.js` 的端点表登记。
+5
View File
@@ -58,3 +58,8 @@ export const MOCK_RANKING_CHANGE = Object.freeze({
'159645': -1.12,
'159925': 1.54,
});
//: 公开产品 HTTP 接口尚未实现,访客页面的产品、净值与涨跌**来自本文件而不是数据库**。
//: 这是接口缺位时的显式降级,不是真实行情 —— 凡展示这些数据的页面都必须原样呈现这句话。
//: 删掉它并不会让数据变真,只会让客户以为看到的是真实净值。
export const MOCK_SOURCE_NOTICE = '公开产品接口尚未提供,本页使用与 fin_product、fin_nav_history 模型同字段的演示数据,非真实行情。';
@@ -4,6 +4,13 @@ import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js'
import { mountShell } from '/static/portal/common/layout/app-shell.js';
import { renderEmpty, renderError, renderLoading } from '/static/portal/common/state-view.js';
//: 后端 `published` 返回的是"我可见的已发布交付物",用 `content_type` 区分两类。
//: 没有标签的话投顾只看到"方案 1",不知道是哪个客户、哪一类内容。
const CONTENT_TYPE_LABELS = {
investment_goal_book: '投资目标方案书',
advisor_recommendation_plan: '产品推荐方案',
};
if (requireAdvisor()) {
mountShell({ active: 'advisor-dashboard', mode: 'advisor' });
const context = getAuthContext();
@@ -18,7 +25,7 @@ if (requireAdvisor()) {
const rows = Array.isArray(response.data) ? response.data : [];
metrics.innerHTML = [['已发布方案', rows.length, '服务端返回'], ['协作客户', '按归属', '数据范围内'], ['审核状态', '留痕', '发布前需复核']].map(([label, value, meta]) => `<article class="metric-card"><p class="metric-card__label">${label}</p><p class="metric-card__value">${value}</p><p class="metric-card__meta">${meta}</p></article>`).join('');
if (!rows.length) { renderEmpty(list, '暂未发布方案', '当前账号暂无已审核发布的客户方案。'); return; }
list.innerHTML = rows.map((row) => `<article class="advisor-card"><h3 class="advisor-card__title">方案 ${escapeHtml(row.content_id || '--')}</h3><p class="advisor-card__meta">发布时间:${escapeHtml(formatDateTime(row.published_at))}</p><div class="advisor-card__content">${escapeHtml(typeof row.plan === 'string' ? row.plan : JSON.stringify(row.plan || {}))}</div></article>`).join('');
list.innerHTML = rows.map((row) => `<article class="advisor-card"><h3 class="advisor-card__title">${escapeHtml(CONTENT_TYPE_LABELS[row.content_type] || '方案')} · 客户 ${escapeHtml(row.customer_id || '--')}</h3><p class="advisor-card__meta">发布时间:${escapeHtml(formatDateTime(row.published_at))}</p><div class="advisor-card__content">${escapeHtml(typeof row.plan === 'string' ? row.plan : JSON.stringify(row.plan || {}))}</div></article>`).join('');
} catch (error) { apiClient.reportError(error); renderError(list, error, load); }
}
document.querySelector('[data-refresh]').addEventListener('click', load);
@@ -5,17 +5,18 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>基金详情 · 南方财富</title>
<link rel="stylesheet" href="/static/portal/common/base.css">
<link rel="stylesheet" href="/static/portal/guest/product-detail/product-detail.css">
<link rel="stylesheet" href="/static/portal/guest/product-detail/product-detail.css?v=20260913-4">
</head>
<body>
<main id="main-content" class="page-shell" data-detail-root>
<div class="page-heading"><div><a class="detail-back" href="/portal/guest/products/">返回基金列表</a><h1 class="page-heading__title" data-product-name>基金详情</h1><p class="page-heading__description" data-product-meta></p></div><div class="page-heading__actions"><a class="button" href="/portal/customer/login/">登录后交易</a></div></div>
<div class="source-notice" role="note" data-source-notice></div>
<section class="detail-grid">
<article class="panel detail-chart"><div class="panel__header"><h2 class="panel__title">历史净值</h2><span class="section-heading__meta">近十二期</span></div><div class="panel__body"><div class="detail-chart__quote"><strong data-current-nav>--</strong><span data-change>--</span></div><svg class="detail-chart__svg" viewBox="0 0 760 280" role="img" aria-label="基金历史净值走势" data-chart></svg><div class="detail-chart__axis"><span data-start-date></span><span data-end-date></span></div></div></article>
<aside class="panel"><div class="panel__header"><h2 class="panel__title">产品要素</h2></div><div class="panel__body detail-facts" data-product-facts></div></aside>
</section>
<section class="panel risk-disclosure"><div class="panel__header"><h2 class="panel__title">风险与交易说明</h2></div><div class="panel__body"><div class="risk-disclosure__grid"><div><strong>风险等级</strong><span data-risk-level></span></div><div><strong>最小金额</strong><span data-min-amount></span></div><div><strong>交易方式</strong><span>场内市价模拟成交</span></div></div><p>基金净值会随市场变化。历史数据不代表未来表现,交易前应结合自身风险承受能力判断。</p></div></section>
</main>
<script type="module" src="/static/portal/guest/product-detail/product-detail.js"></script>
<script type="module" src="/static/portal/guest/product-detail/product-detail.js?v=20260913-4"></script>
</body>
</html>
@@ -1,4 +1,5 @@
.detail-back { display: inline-block; margin-bottom: var(--space-3); color: var(--brand-dark); font-size: var(--fs-small); }
.source-notice { margin-bottom: var(--space-4); padding: var(--space-3) var(--space-4); color: var(--ink-soft); background: var(--accent-soft); border-left: 3px solid var(--accent); font-size: var(--fs-small); line-height: 1.6; }
.detail-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); gap: var(--space-4); }
.detail-chart__quote { display: flex; align-items: baseline; gap: var(--space-3); }
.detail-chart__quote strong { font-size: var(--fs-metric); }
@@ -1,5 +1,5 @@
import { escapeHtml, formatCurrency, formatPercent } from '/static/portal/common/formatters.js';
import { getMockNavHistory, MOCK_PRODUCTS, MOCK_RANKING_CHANGE } from '/static/portal/common/mock-data.js';
import { getMockNavHistory, MOCK_PRODUCTS, MOCK_RANKING_CHANGE, MOCK_SOURCE_NOTICE } from '/static/portal/common/mock-data.js';
import { mountShell } from '/static/portal/common/layout/app-shell.js';
mountShell({ active: 'products' });
@@ -11,6 +11,8 @@ const change = MOCK_RANKING_CHANGE[product.product_code];
document.title = `${product.product_name} · 南方财富`;
document.querySelector('[data-product-name]').textContent = product.product_name;
document.querySelector('[data-product-meta]').textContent = `${product.product_code} · ${product.exchange_code} · ${product.product_category}`;
// 详情页含历史净值曲线,全部来自 common/mock-data.js(公开产品 HTTP 接口尚未实现)。
document.querySelector('[data-source-notice]').textContent = MOCK_SOURCE_NOTICE;
document.querySelector('[data-current-nav]').textContent = product.current_nav;
const changeNode = document.querySelector('[data-change]');
changeNode.textContent = formatPercent(change);
+3 -2
View File
@@ -5,11 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>基金产品 · 南方财富</title>
<link rel="stylesheet" href="/static/portal/common/base.css">
<link rel="stylesheet" href="/static/portal/guest/products/products.css">
<link rel="stylesheet" href="/static/portal/guest/products/products.css?v=20260913-4">
</head>
<body>
<main id="main-content" class="page-shell">
<div class="page-heading"><div><h1 class="page-heading__title" data-page-title>基金产品</h1><p class="page-heading__description" data-page-description>按代码、名称、类型与风险等级查找场内基金。涨跌数据仅用于界面演示。</p></div><a class="button" href="/portal/customer/login/">登录查看资产</a></div>
<div class="source-notice" role="note" data-source-notice></div>
<section class="product-filter" aria-label="基金筛选">
<div class="form-field product-filter__search"><label class="form-field__label" for="search">基金名称或代码</label><input id="search" class="form-field__input" type="search" placeholder="例如 510300" autocomplete="off"></div>
<div class="form-field"><label class="form-field__label" for="category">产品类型</label><select id="category" class="form-field__input"><option value="">全部类型</option></select></div>
@@ -21,6 +22,6 @@
<div class="panel__body" data-product-list></div>
</section>
</main>
<script type="module" src="/static/portal/guest/products/products.js"></script>
<script type="module" src="/static/portal/guest/products/products.js?v=20260913-4"></script>
</body>
</html>
@@ -1,3 +1,4 @@
.source-notice { margin-bottom: var(--space-4); padding: var(--space-3) var(--space-4); color: var(--ink-soft); background: var(--accent-soft); border-left: 3px solid var(--accent); font-size: var(--fs-small); line-height: 1.6; }
.product-filter { margin-bottom: var(--space-5); padding: var(--space-4); display: grid; grid-template-columns: minmax(260px, 2fr) repeat(2, minmax(150px, 1fr)) auto; align-items: end; gap: var(--space-3); background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-md); }
.product-rank { width: 28px; height: 28px; margin-right: var(--space-2); display: inline-grid; place-items: center; color: var(--surface); background: var(--brand); border-radius: var(--radius-sm); font-size: var(--fs-small); }
@media (max-width: 900px) { .product-filter { grid-template-columns: repeat(2, minmax(0, 1fr)); } .product-filter__search { grid-column: 1 / -1; } }
+4 -1
View File
@@ -1,5 +1,5 @@
import { escapeHtml, formatPercent } from '/static/portal/common/formatters.js';
import { MOCK_PRODUCTS, MOCK_RANKING_CHANGE } from '/static/portal/common/mock-data.js';
import { MOCK_PRODUCTS, MOCK_RANKING_CHANGE, MOCK_SOURCE_NOTICE } from '/static/portal/common/mock-data.js';
import { mountShell } from '/static/portal/common/layout/app-shell.js';
import { renderEmpty } from '/static/portal/common/state-view.js';
@@ -17,6 +17,9 @@ if (rankingView) {
document.querySelector('[data-page-description]').textContent = '按阶段涨跌查看场内基金表现,作为公开研究参考。';
document.querySelector('[data-list-title]').textContent = '排行列表';
}
// 本页的产品、净值与涨跌全部来自 common/mock-data.js(公开产品 HTTP 接口尚未实现)。
// 这句话必须留在页面上:排行视图的文案读起来像真实研究数据,去掉声明就越界了。
document.querySelector('[data-source-notice]').textContent = MOCK_SOURCE_NOTICE;
[...new Set(MOCK_PRODUCTS.map((item) => item.product_category))].forEach((value) => {
category.insertAdjacentHTML('beforeend', `<option value="${escapeHtml(value)}">${escapeHtml(value)}</option>`);
});
+149 -45
View File
@@ -23,16 +23,30 @@ import uuid
from pathlib import Path
from typing import Any
import asyncmy
import httpx
import jwt
from sqlalchemy import func, select
from app.core.config import get_settings
from app.infrastructure.db import SessionFactory
from app.main import create_app
from app.model.configuration import PromptTemplateVersion
from app.service.config_release_service import ConfigReleaseService
ADMIN = "9003"
AGENT_TYPE = "customer_service"
TOOL_NAME = "search_knowledge"
# 访客检索工具。与 `TOOL_NAME` 是**同一个只读检索处理器**的别名(见 `bootstrap.py`),
# 但权限码与角色集不同:
# search_knowledge -> `knowledge:reference:read`,角色 customer/advisor/operator/admin
# query_knowledge -> `knowledge:query`,角色 visitor/customer
# 访客令牌(`VisitorTokenIssuer`)只带 `agent:run` + `knowledge:query`,角色是 `visitor`,
# 所以公开浮窗**只能**走 `query_knowledge` —— 客服 Agent 也确实是这么分支的
# (`_answer_from_knowledge` 里按 `"visitor" in context.roles` 选工具名)。
# 漏发它的表现:访客在公开页面一问就抛 `ForbiddenAgentError`(工具不在意图白名单),
# 而登录客户侧完全正常 —— 因为客户走的是 `search_knowledge`。
# 所以知识类意图必须**同时**发两个:访客走前者、登录客户走后者。
VISITOR_TOOL = "query_knowledge"
SUITABILITY_TOOL = "check_suitability"
# 画像只读工具:客服的"出口零"(本人风险等级/投资偏好/测评是否过期)走它取权威字段。
# 那个出口复用的是 `faq` 意图 key(见 `customer_service.PROFILE_WHITELIST_INTENT`),
@@ -42,10 +56,14 @@ PROFILE_TOOL = "query_customer_profile"
# 只有会调用工具的意图才需要白名单;chitchat(模型生成)与 transfer_human(引导人工)
# 都不查知识库。给它们配空白名单反而会掩盖"配置漏配",因此不发布这两条。
INTENT_TOOLS: dict[str, tuple[str, ...]] = {
"faq": (TOOL_NAME, PROFILE_TOOL),
"product_inquiry": (TOOL_NAME,),
"policy_explain": (TOOL_NAME,),
# 适当性裁决要两步:先从知识库拿到产品的风险等级,再由底座按档案里的客户等级裁决
# 三个知识意图都要**同时**发两个检索工具,原因见 VISITOR_TOOL 的注释:
# 访客与登录客户落在同一个意图里,却走不同的工具名。
"faq": (TOOL_NAME, VISITOR_TOOL, PROFILE_TOOL),
"product_inquiry": (TOOL_NAME, VISITOR_TOOL),
"policy_explain": (TOOL_NAME, VISITOR_TOOL),
# 适当性裁决要两步:先从知识库拿到产品的风险等级,再由底座按档案里的客户等级裁决。
# 这里**不发** VISITOR_TOOL:访客意图白名单(`VISITOR_INTENTS`)不含 suitability_check,
# 访客根本到不了这条出口,发了只是噪音。
"suitability_check": (TOOL_NAME, SUITABILITY_TOOL),
}
@@ -65,41 +83,87 @@ def token(subject: str) -> str:
)
async def active_config_items() -> list[dict[str, Any]]:
"""读取当前生效版本的全部配置项,用于在新版本里原样继承。"""
settings = get_settings()
# MYSQL_DSN 形如 mysql+asyncmy://user:pass@host:port/db
dsn = settings.mysql_dsn.split("://", 1)[1]
credentials, location = dsn.split("@", 1)
user, password = credentials.split(":", 1)
host_port, database = location.split("/", 1)
host, _, port = host_port.partition(":")
connection = await asyncmy.connect(
host=host, port=int(port or 3306), user=user, password=password, db=database
)
try:
cursor = connection.cursor()
await cursor.execute(
"""
SELECT i.namespace, i.config_key, i.value_json, i.schema_version
FROM platform_config_item i
JOIN config_release r ON r.id = i.release_id
WHERE r.status = 'active'
"""
)
rows = await cursor.fetchall()
finally:
connection.close()
items: list[dict[str, Any]] = []
for namespace, config_key, value_json, schema_version in rows:
value = json.loads(value_json) if isinstance(value_json, str) else value_json
items.append({
"namespace": namespace,
"item_key": config_key,
"value_json": value,
"schema_version": schema_version,
})
return items
#: `PromptPayload` 接受的字段(`app/api/schemas/admin.py`)。表里还有
#: `checksum` / `created_by` / `created_at`,那三个由服务端生成,不能搬。
PROMPT_API_FIELDS = (
"prompt_code", "task_type", "agent_type",
"system_prompt", "user_prompt_template", "input_schema", "output_schema",
)
def as_json(value: Any) -> Any:
"""JSON 列从驱动读出来可能是字符串、也可能已解析,统一归一化。
不归一化的后果很隐蔽:`value_json` 保持字符串时搬过去会被判成"不是对象",
而 `input_schema` / `output_schema` 是 `dict | None` 字段,传字符串直接 422。
"""
return json.loads(value) if isinstance(value, str) else value
async def active_snapshot() -> dict[str, list[dict[str, Any]]]:
"""当前生效版本在**全部三张受管表**里的内容。
为什么不能只查 `platform_config_item`(旧写法就是这么写的,代价见下):
`config_release` 是**整版本替换**语义,新版本没带上的行**等于被删除**。
本项目为此丢过两次配置,两次都是"功能看着正常、零告警":
* release 174 → 181:提示词被漏搬,`load_active_prompt` 读不到,Agent 静默回落
到代码里的默认话术;
* 本次(254 → 303):本脚本只搬配置项,把 `customer_service_chitchat` 提示词漏在
了旧版本里 —— admin 端只在激活时打一句 stderr 警告,很容易被刷过去。
`ConfigReleaseService.effective_snapshot()` 一次读全三张表,是唯一正确的来源。
"""
async with SessionFactory() as session:
return await ConfigReleaseService(session).effective_snapshot()
def snapshot_items(snapshot: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]:
"""把快照里的配置项转成 API 载荷形状。
库是 `config_key`、API 是 `item_key` —— 字段名不同,快照行不能直接 POST。
"""
return [
{
"namespace": row["namespace"],
"item_key": row["config_key"],
"value_json": as_json(row["value_json"]),
"schema_version": row["schema_version"],
}
for row in snapshot["platform_config_item"]
]
async def next_prompt_versions(rows: list[dict[str, Any]]) -> list[int]:
"""给每条待搬运的提示词分配新 version。
`prompt_template_version` 的唯一键含 `version`,照搬旧行会主键冲突;
逐个 `prompt_code` 取现有最大值 +1。
"""
assigned: list[int] = []
async with SessionFactory() as session:
for row in rows:
latest = await session.scalar(
select(func.max(PromptTemplateVersion.version)).where(
PromptTemplateVersion.prompt_code == row["prompt_code"]
)
)
assigned.append(int(latest or 0) + 1)
return assigned
def prompt_payloads(
rows: list[dict[str, Any]], versions: list[int], release_id: int
) -> list[dict[str, Any]]:
"""提示词的 API 载荷(`input_schema` / `output_schema` **必须带上**,漏了即静默丢失)。"""
payloads: list[dict[str, Any]] = []
for row, version in zip(rows, versions, strict=True):
payload: dict[str, Any] = {"release_id": release_id, "version": version}
for field in PROMPT_API_FIELDS:
value = row.get(field)
payload[field] = as_json(value) if field.endswith("_schema") else value
payloads.append(payload)
return payloads
async def post(
@@ -198,10 +262,23 @@ async def main() -> int:
if await ensure_suitability_intent(client, auth) != 0:
return 1
inherited = await active_config_items()
print(f"当前生效版本的配置项:{len(inherited)} 条(将原样继承)")
snapshot = await active_snapshot()
inherited = snapshot_items(snapshot)
prompts = list(snapshot["prompt_template_version"])
rules = list(snapshot["model_routing_rule"])
print(
f"当前生效版本:配置项 {len(inherited)} 条、提示词 {len(prompts)} 条、"
f"路由规则 {len(rules)} 条(将原样继承)"
)
for item in inherited:
print(f" · {item['namespace']} / {item['item_key']}")
for prompt in prompts:
print(f" · 提示词 {prompt['prompt_code']}(v{prompt['version']})")
if rules:
# 本环境当前为空;不为空就必须先支持搬运,否则激活即静默清空。
print(f"\n[失败] 生效版本里有 {len(rules)} 条 model_routing_rule,本脚本尚未支持搬运。")
print(" 直接发布会把它们清空 —— 先补上搬运逻辑再跑。")
return 1
new_items = [
{
@@ -239,7 +316,10 @@ async def main() -> int:
created = await post(client, "/api/v1/admin/config-releases", auth=auth, payload={
"release_no": f"cs-tools-{uuid.uuid4().hex[:12]}",
"title": "客服 Agent 意图工具白名单",
"change_summary": "新增 faq/product_inquiry/policy_explain 的知识检索白名单,并继承既有配置项",
"change_summary": (
"知识类意图补发访客检索工具 query_knowledge(访客令牌只有 knowledge:query),"
"保留 search_knowledge 供登录客户使用,并继承既有配置项"
),
})
if created.status_code != 201:
print(f"创建发布版本失败:{created.status_code} {created.text[:200]}")
@@ -256,6 +336,18 @@ async def main() -> int:
print(f" 失败:{response.text[:200]}")
return 1
for payload in prompt_payloads(prompts, await next_prompt_versions(prompts), release_id):
response = await post(
client, "/api/v1/admin/prompt-templates", auth=auth, payload=payload
)
print(
f" [继承] 提示词 {payload['prompt_code']} → v{payload['version']}"
f" → {response.status_code}"
)
if response.status_code not in (200, 201):
print(f" 失败:{response.text[:200]}")
return 1
release_base = f"/api/v1/admin/config-releases/{release_id}"
submitted = await post(
client, f"{release_base}/validations", auth=auth, payload={},
@@ -278,10 +370,22 @@ async def main() -> int:
return 1
print(f"最终状态:{activated.json()['data']['status']}")
remaining = await active_config_items()
print(f"\n激活后生效版本配置项:{len(remaining)} 条")
after = await active_snapshot()
remaining = snapshot_items(after)
print(
f"\n激活后生效版本:配置项 {len(remaining)} 条、"
f"提示词 {len(after['prompt_template_version'])} 条"
)
for item in remaining:
print(f" · {item['namespace']} / {item['item_key']} = {item['value_json']}")
# 条数对不上就是静默丢失 —— 这正是本脚本上一次踩的坑,必须硬校验。
if len(remaining) != len(inherited_only) + len(pending):
print("[失败] 配置项条数与预期不符,可能发生静默丢失,请人工核查")
return 1
if len(after["prompt_template_version"]) != len(prompts):
print("[失败] 提示词条数与继承前不一致,可能发生静默丢失,请人工核查")
return 1
print("[OK] 完成")
return 0