Merge remote-tracking branch 'origin/qyqy_develop' into qyqy_develop

This commit is contained in:
2026-09-14 01:23:32 +08:00
6 changed files with 98 additions and 26 deletions
+8 -2
View File
@@ -181,9 +181,15 @@ def register_transition(resource: str, id_name: str, action: str) -> None:
register_resource("config-releases", ReleasePayload, "release_id", update=False)
register_resource("platform-config-items", ItemPayload, "item_id", scoped=True, detail=False)
# ⚠️ `platform-config-items` 与 `model-routing-rules` 必须是 `detail=True`:
# 它们的更新端点(PUT)**硬性要求 `If-Match`**,而校验用的是**该行内容的 digest**
# (`admin_service.mutate`:`if_match is None or if_match != digest(existing) → 409`)。
# 此前 `detail=False` 意味着**没有任何端点能返回这个 digest** —— 列表的 `meta` 只有
# trace_id、也没有详情端点,于是**首次编辑必然 409**:乐观并发成了死锁,
# 编辑功能实际不可用(2026-09-13 前端等价测试发现)。
register_resource("platform-config-items", ItemPayload, "item_id", scoped=True, detail=True)
register_resource("model-endpoints", EndpointPayload, "endpoint_id")
register_resource("model-routing-rules", RoutingPayload, "rule_id", scoped=True, detail=False)
register_resource("model-routing-rules", RoutingPayload, "rule_id", scoped=True, detail=True)
register_resource("prompt-templates", PromptPayload, "prompt_id", update=False)
register_resource("agent-intent-configs", IntentPayload, "config_id")
register_resource("reply-templates", ReplyPayload, "template_id", detail=False)
+11 -2
View File
@@ -20,9 +20,13 @@ const ENDPOINTS = Object.freeze({
A008: { method: 'POST', path: '/api/v1/admin/config-releases/{releaseId}/platform-config-items', idempotent: true },
A009: { method: 'GET', path: '/api/v1/admin/config-releases/{releaseId}/platform-config-items' },
A010: { method: 'PUT', path: '/api/v1/admin/config-releases/{releaseId}/platform-config-items/{itemId}', idempotent: true },
// 详情端点:**更新必须先拿到这一行的 etag**(PUT 要求 If-Match),
// 而列表的 meta 里没有它 —— 见 `docs/05` §19 的 A048/A049 说明。
A048: { method: 'GET', path: '/api/v1/admin/config-releases/{releaseId}/platform-config-items/{itemId}' },
A018: { method: 'POST', path: '/api/v1/admin/config-releases/{releaseId}/model-routing-rules', idempotent: true },
A019: { method: 'GET', path: '/api/v1/admin/config-releases/{releaseId}/model-routing-rules' },
A020: { method: 'PUT', path: '/api/v1/admin/config-releases/{releaseId}/model-routing-rules/{ruleId}', idempotent: true },
A049: { method: 'GET', path: '/api/v1/admin/config-releases/{releaseId}/model-routing-rules/{ruleId}' },
A003: { method: 'GET', path: '/api/v1/admin/config-releases/{releaseId}' },
A004: { method: 'POST', path: '/api/v1/admin/config-releases/{releaseId}/validations', idempotent: true },
A005: { method: 'POST', path: '/api/v1/admin/config-releases/{releaseId}/reviews', idempotent: true },
@@ -80,8 +84,13 @@ const ENDPOINTS = Object.freeze({
ADVISOR_GOAL_BOOK: { method: 'GET', path: '/api/v1/advisor/investment-goals/{goalNo}/goal-book' },
ADVISOR_REVIEW_BOOK: { method: 'POST', path: '/api/v1/advisor/investment-goals/{goalNo}/goal-book/reviews', idempotent: true },
ADVISOR_PUBLISH_BOOK: { method: 'POST', path: '/api/v1/advisor/investment-goals/{goalNo}/goal-book/publications', idempotent: true },
K002: { method: 'POST', path: '/api/v1/knowledge/upload' },
K003: { method: 'GET', path: '/api/v1/knowledge/list' },
// ⚠️ K002 / K003 必须标 `raw: true`:它们的成功体是**裸的**(没有 `data` 信封)——
// K002 直接返回 `{knowledge_ids, filename, chunk_count}`,K003 返回 `{items, count}`。
// 不标的话 `request()` 会去取 `payload.data`(undefined),调用方拿到空值:
// 上传显示"已入库 0 块"、列表显示"知识库为空",而库里其实有数据。
// 与 `V001`(访客令牌)同一个道理。
K002: { method: 'POST', path: '/api/v1/knowledge/upload', raw: true },
K003: { method: 'GET', path: '/api/v1/knowledge/list', raw: true },
K004: { method: 'DELETE', path: '/api/v1/knowledge/{knowledgeId}', idempotent: true },
OFFSITE_MAILS: { method: 'GET', path: '/api/v1/offsite-fund/mails' },
OFFSITE_MAIL: { method: 'GET', path: '/api/v1/offsite-fund/mails/{mailId}' },
+9 -3
View File
@@ -5,8 +5,15 @@ import { renderEmpty } from '/static/portal/common/state-view.js';
// 委托详情(T004):点行内「详情」展开,不跳页。
//
// 为什么补它:后端 T004 一直存在,但本页只接了 T003(列表),客户**点不进任何一条委托**
// —— 看不到成交价、费用与行情来源。成交明细页(T008)是同一个缺口。
// 为什么补它:后端 T004 一直存在,但本页只接了 T003(列表),客户**点不进任何一条委托**。
// 成交明细页(T008)是同一个缺口。
//
// ⚠️ 这里的字段**必须与 T004 的实际返回一致**。接口当前返回 16 个字段
// (order_no / product_id / product_code / product_name / order_side / price_type /
// quantity / limit_price / quote_price / quote_at / filled_quantity /
// average_executed_price / status / submitted_at / cancelled_at / reject_reason)。
// **不要**照 `fin_sim_order` 的建表字段写:表里还有 `quote_source`,
// 但接口的返回视图没带它 —— 照表写会让那一行永远显示 "--"。
const DETAIL_ROWS = [
['委托编号', (item) => item.order_no],
['基金', (item) => `${item.product_name || '--'}(${item.product_code || '--'})`],
@@ -19,7 +26,6 @@ const DETAIL_ROWS = [
['状态', (item) => item.status],
['提交时间', (item) => formatDateTime(item.submitted_at)],
['行情时间', (item) => formatDateTime(item.quote_at)],
['行情来源', (item) => item.quote_source || '--'],
];
const COLUMN_COUNT = 8;
@@ -4,24 +4,26 @@ import { escapeHtml, formatCurrency, formatDateTime, formatNumber } from '/stati
import { renderEmpty } from '/static/portal/common/state-view.js';
// 成交详情(T008):点行内「详情」展开,不跳页。与委托页(T004)同一个缺口、同一套做法。
//
// ⚠️ 这里的字段**必须与 T008 的实际返回一致**。接口当前返回 13 个字段
// (transaction_no / order_no / product_id / product_code / product_name / order_side /
// executed_price / executed_quantity / gross_amount / fee_amount / net_amount /
// quote_at / executed_at)。**不要**照 `fin_transaction` 的建表字段写:表里还有
// `nav` / `fee_rate_snapshot` / `confirmed_at` / `auto_confirmed`,
// 但接口的返回视图没带它们 —— 照表写会让那四行永远显示 "--"。
// (这四个字段对客户其实有价值,已作为接口建议另行记录。)
const DETAIL_ROWS = [
['成交编号', (item) => item.transaction_no],
['关联委托', (item) => item.order_no],
['基金', (item) => `${item.product_name || '--'}(${item.product_code || '--'})`],
['方向', (item) => (item.order_side === 'buy' ? '买入' : '卖出')],
['业务类型', (item) => item.transaction_type],
['成交份额', (item) => formatNumber(item.executed_quantity, 4)],
['成交价', (item) => formatCurrency(item.executed_price)],
['单位净值', (item) => formatCurrency(item.nav)],
['成交金额', (item) => formatCurrency(item.gross_amount)],
['费率快照', (item) => (item.fee_rate_snapshot === null || item.fee_rate_snapshot === undefined
? '--' : `${(Number(item.fee_rate_snapshot) * 100).toFixed(4)}%`)],
['费用', (item) => formatCurrency(item.fee_amount)],
['净额', (item) => formatCurrency(item.net_amount)],
['行情时间', (item) => formatDateTime(item.quote_at)],
['成交时间', (item) => formatDateTime(item.executed_at)],
['确认时间', (item) => formatDateTime(item.confirmed_at)],
['是否自动确认', (item) => (item.auto_confirmed ? '是' : '否')],
['行情来源', (item) => item.quote_source || '--'],
];
const COLUMN_COUNT = 10;
@@ -245,9 +245,8 @@ if (requireAdmin()) {
renderLoading(targets.knowledge, 3);
try {
const response = await apiClient.get('K003', { query: { limit: 50 } });
// ⚠️ K003 的成功体是**裸的** `{items, count}`,没有 `data` 信封 ——
// `request()` 仍会去取 `payload.data`,那里是 undefined。所以两面都兜一下,
// 否则列表永远显示"知识库为空",而库里其实有数据。
// K003 的成功体是**裸的** `{items, count}`,端点表里标了 `raw: true`,
// 所以 `response.data` 就是那个裸体本身(不是 `payload.data` 那种再包一层)。
const payload = response.data ?? response;
state.knowledge = Array.isArray(payload?.items) ? payload.items : [];
if (!state.knowledge.length) {
@@ -352,6 +351,9 @@ if (requireAdmin()) {
async function loadReleaseContent(releaseId) {
renderLoading(targets.releaseContent, 2);
try {
// 路由规则的主端点要从**已激活**的端点里选,所以先把端点列表备好
// (`initialize()` 里几个 load 是并发的,不保证此刻已经加载完)。
if (!state.endpoints.length) await loadEndpoints();
const [itemsResponse, rulesResponse] = await Promise.all([
apiClient.get('A009', { pathParams: { releaseId } }),
apiClient.get('A019', { pathParams: { releaseId } }),
@@ -377,6 +379,22 @@ if (requireAdmin()) {
});
}
/**
* 模型端点的下拉选项。
*
* ⚠️ **只列已激活的**:后端对不存在或未激活的 `primary_endpoint_id` 直接返回
* 422「模型端点不存在或未激活」。让用户手填数字 ID 等于把这条约束丢给运气。
*/
function endpointOptions(selected) {
const active = (state.endpoints || []).filter((item) => item.status === 'active');
if (!active.length) {
return '<option value="">(没有已激活的模型端点,请先在下方「模型端点」里激活一个)</option>';
}
return active
.map((item) => `<option value="${item.id}"${String(item.id) === String(selected) ? ' selected' : ''}>${escapeHtml(`${item.endpoint_code} · ${item.model_name}`)}</option>`)
.join('');
}
function renderReleaseContent() {
const content = state.releaseContent;
if (!content?.releaseId) { targets.releaseContent.innerHTML = ''; return; }
@@ -392,8 +410,8 @@ if (requireAdmin()) {
(rule) => `<div class="admin-release-actions"><button class="button table-action" type="button" data-edit-rule="${rule.id}">编辑</button></div>`,
);
targets.releaseContent.innerHTML = `
<section class="panel"><div class="panel__header"><h2 class="panel__title">版本 ${escapeHtml(String(releaseId))} · 配置项</h2><span class="section-heading__meta">${items.length} 条</span></div>${itemRows}<form class="admin-inline-form" data-item-form><label class="form-field"><span class="form-field__label">命名空间</span><select class="form-field__input" name="namespace">${CONFIG_NAMESPACES.map((name) => `<option value="${name}">${name}</option>`).join('')}</select></label><label class="form-field"><span class="form-field__label">键名</span><input class="form-field__input" name="item_key" required maxlength="128" placeholder="customer_service:faq"></label><label class="form-field admin-inline-form__wide"><span class="form-field__label">值(JSON)</span><input class="form-field__input" name="value_json" required placeholder='{"allowed_tools":["search_knowledge"]}'></label><button class="button button--primary" type="submit" data-item-submit>新增配置项</button><span class="admin-inline-form__status" data-item-status></span></form></section>
<section class="panel"><div class="panel__header"><h2 class="panel__title">版本 ${escapeHtml(String(releaseId))} · 模型路由规则</h2><span class="section-heading__meta">${rules.length} 条</span></div>${ruleRows}<form class="admin-inline-form" data-rule-form><label class="form-field"><span class="form-field__label">规则码</span><input class="form-field__input" name="rule_code" required maxlength="64"></label><label class="form-field"><span class="form-field__label">Agent</span><input class="form-field__input" name="agent_type" required maxlength="32" placeholder="customer_service"></label><label class="form-field"><span class="form-field__label">任务类型</span><input class="form-field__input" name="task_type" required maxlength="48" placeholder="intent_classify"></label><label class="form-field"><span class="form-field__label">模型策略</span><input class="form-field__input" name="model_policy" required maxlength="32" placeholder="primary_only"></label><label class="form-field"><span class="form-field__label">主端点 ID</span><input class="form-field__input" name="primary_endpoint_id" type="number" min="1" required></label><button class="button button--primary" type="submit" data-rule-submit>新增路由规则</button><span class="admin-inline-form__status" data-rule-status></span></form></section>`;
<section class="panel"><div class="panel__header"><h2 class="panel__title">版本 ${escapeHtml(String(releaseId))} · 配置项</h2><span class="section-heading__meta">${items.length} 条</span></div>${itemRows}<form class="admin-inline-form" data-item-form><label class="form-field"><span class="form-field__label">命名空间</span><select class="form-field__input" name="namespace">${CONFIG_NAMESPACES.map((name) => `<option value="${name}">${name}</option>`).join('')}</select></label><label class="form-field"><span class="form-field__label">键名</span><input class="form-field__input" name="item_key" required maxlength="128" placeholder="customer_service:faq(agent_tools 的键必须是 已注册agent:意图 )"></label><label class="form-field admin-inline-form__wide"><span class="form-field__label">值(JSON)</span><input class="form-field__input" name="value_json" required placeholder='{"allowed_tools":["search_knowledge"]}'></label><button class="button button--primary" type="submit" data-item-submit>新增配置项</button><span class="admin-inline-form__status" data-item-status></span></form></section>
<section class="panel"><div class="panel__header"><h2 class="panel__title">版本 ${escapeHtml(String(releaseId))} · 模型路由规则</h2><span class="section-heading__meta">${rules.length} 条</span></div>${ruleRows}<form class="admin-inline-form" data-rule-form><label class="form-field"><span class="form-field__label">规则码</span><input class="form-field__input" name="rule_code" required maxlength="64"></label><label class="form-field"><span class="form-field__label">Agent</span><input class="form-field__input" name="agent_type" required maxlength="32" placeholder="customer_service"></label><label class="form-field"><span class="form-field__label">任务类型</span><input class="form-field__input" name="task_type" required maxlength="48" placeholder="intent_classify"></label><label class="form-field"><span class="form-field__label">模型策略</span><input class="form-field__input" name="model_policy" required maxlength="32" placeholder="primary_only"></label><label class="form-field"><span class="form-field__label">主端点</span><select class="form-field__input" name="primary_endpoint_id" required>${endpointOptions()}</select></label><button class="button button--primary" type="submit" data-rule-submit>新增路由规则</button><span class="admin-inline-form__status" data-rule-status></span></form></section>`;
targets.releaseContent.querySelector('[data-item-form]').addEventListener('submit', (event) => submitItem(event, releaseId));
targets.releaseContent.querySelector('[data-rule-form]').addEventListener('submit', (event) => submitRule(event, releaseId));
targets.releaseContent.querySelectorAll('[data-edit-item]').forEach((button) => button.addEventListener('click', () => editItem(button.dataset.editItem)));
@@ -457,8 +475,18 @@ if (requireAdmin()) {
submit.disabled = true;
status.textContent = '正在提交…';
try {
if (editingId) await apiClient.put('A010', body, { pathParams: { releaseId, itemId: editingId } });
else await apiClient.post('A008', body, { pathParams: { releaseId } });
if (editingId) {
// ⚠️ 更新要带 `If-Match`:后端按该行内容的 digest 校验(admin_service.mutate),
// 不带或不匹配一律 409。而列表的 meta 里**没有** etag,所以必须先取详情。
// 详情端点(A048)是 2026-09-13 为此新开的 —— 在此之前这条路径根本走不通。
const detail = await apiClient.get('A048', { pathParams: { releaseId, itemId: editingId } });
await apiClient.put('A010', body, {
pathParams: { releaseId, itemId: editingId },
headers: { 'If-Match': `"${detail.meta.etag}"` },
});
} else {
await apiClient.post('A008', body, { pathParams: { releaseId } });
}
showToast(editingId ? '配置项已更新' : '配置项已新增');
await loadReleaseContent(releaseId);
await loadAudits();
@@ -479,9 +507,11 @@ if (requireAdmin()) {
task_type: form.elements.task_type.value.trim(),
model_policy: form.elements.model_policy.value.trim(),
primary_endpoint_id: Number(form.elements.primary_endpoint_id.value),
// fallbacks 留空:界面上暂不做多级兜底编辑,需要时用接口补。
fallbacks: [],
max_attempts: 2,
// ⚠️ `max_attempts` **不能超过"端点总数"**(主端点 + fallbacks)。
// 界面暂不编辑 fallbacks(提交空数组),所以这里只能是 **1** ——
// 写成 2 会被后端以 422「重试次数超过端点数量」拒绝,**必然提交失败**。
max_attempts: 1,
latency_budget_ms: 15000,
priority: 100,
};
@@ -489,8 +519,16 @@ if (requireAdmin()) {
submit.disabled = true;
status.textContent = '正在提交…';
try {
if (editingId) await apiClient.put('A020', body, { pathParams: { releaseId, ruleId: editingId } });
else await apiClient.post('A018', body, { pathParams: { releaseId } });
if (editingId) {
// 同上:更新路由规则也要 `If-Match`,etag 从详情端点(A049)取。
const detail = await apiClient.get('A049', { pathParams: { releaseId, ruleId: editingId } });
await apiClient.put('A020', body, {
pathParams: { releaseId, ruleId: editingId },
headers: { 'If-Match': `"${detail.meta.etag}"` },
});
} else {
await apiClient.post('A018', body, { pathParams: { releaseId } });
}
showToast(editingId ? '路由规则已更新' : '路由规则已新增');
await loadReleaseContent(releaseId);
await loadAudits();
+12 -1
View File
@@ -1115,6 +1115,8 @@ GET /internal/metrics
| A045 | `POST /api/v1/admin/advisor/recommendations/{content_id}/reviews` | `product-recommendation:review`(+`admin`) | 必须 | `200` | 推荐方案审核 |
| A046 | `POST /api/v1/admin/advisor/recommendations/{content_id}/publications` | `product-recommendation:publish`(+`admin`) | 必须 | `200` | 推荐方案发布 |
| A047 | `GET /api/v1/admin/advisor/pending-contents` | `product-recommendation:review`(+`admin`) | 否 | `200` | 否 |
| A048 | `GET /api/v1/admin/config-releases/{release_id}/platform-config-items/{item_id}` | `config:read`(+`admin`) | 否 | `200` | 否 |
| A049 | `GET /api/v1/admin/config-releases/{release_id}/model-routing-rules/{rule_id}` | `config:read`(+`admin`) | 否 | `200` | 否 |
| AD001 | `POST /api/v1/advisor/investment-goals` | `investment-goal:write:self` / `:customer` | 必须 | `201` | 投资目标创建 |
| AD002 | `GET /api/v1/advisor/investment-goals/current` | `investment-goal:read:self` | 否 | `200` | 否 |
| AD003 | `GET /api/v1/advisor/customers/{customer_id}/investment-goals/current` | `investment-goal:read:self` / `:customer` | 否 | `200` | 否 |
@@ -1210,7 +1212,16 @@ GET /internal/metrics
业务域接口 `/customer-service/handover-tickets/**`、`/advisory-plans/**`、`/sim-orders/**`、`/risk-scans/**` 和 `/risk-alerts/**` 的具体方法、请求体、领域状态机和错误码分别由对应业务文档登记;它们仍必须遵守本文第 3-5、11 和 12 节。
> **AD 段(投顾自用)与 A041–A046(投顾治理)的六点说明**:
> **A048 / A049 为什么必须存在**:`platform-config-items` 与 `model-routing-rules`
> 的**更新端点要求 `If-Match`**,校验的是该行内容的 digest;而这两个资源此前**没有详情端点**,
> 列表的 `meta` 也不带 etag —— 客户端**无从取得当前 digest**,首次编辑必然
> `409 RESOURCE_VERSION_CONFLICT`。乐观并发在"读不到版本"的前提下等于死锁。
> 补上详情端点后,客户端 GET 详情(响应头 `ETag` + `meta.etag`)再 PUT 即可。
>
> ⚠️ 判据:**凡接受 `If-Match` 的资源,必须同时提供能返回该 etag 的读取路径** ——
> 这是本次由前端等价测试(照接口逐个调用核对)才暴露出来的,纯看代码不容易发现。
> **AD 段(投顾自用)与 A041–A047(投顾治理)的六点说明**:
>
> 这批端点原先**只存在于代码中**,`§19` 一条都没登记(2026-09-13 补登)。当时 §12 写的
> 入口是 `/api/v1/advisory-plans/**`,与实际路径 `/api/v1/advisor/**` **不符**,