From daf73a28656a19baca846f3b997943a0bba35b94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Mon, 14 Sep 2026 01:19:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(portal):=20=E7=85=A7=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E9=80=90=E6=9D=A1=E6=A0=B8=E5=AF=B9=E5=89=8D=E7=AB=AF=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E6=8E=89=205=20=E5=A4=84=E3=80=8C=E7=85=A7=E7=9D=80?= =?UTF-8?q?=E8=A1=A8=E7=BB=93=E6=9E=84=E5=86=99=E3=80=81=E7=9C=8B=E7=9D=80?= =?UTF-8?q?=E5=83=8F=E5=AF=B9=E3=80=8D=E7=9A=84=E7=BC=BA=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 做法:把前端 `api-client.js` 注册的端点**按前端完全相同的方式**(同样的路径、参数、 身份)逐个调用,再拿真实返回去核对前端 render 用到的字段。 **这类问题纯读代码看不出来** —— 只有把真实返回和期望字段摆在一起才会暴露。 ## 1. 知识库功能实际是坏的(K002 / K003 是裸信封) `K002` 的成功体是 `{knowledge_ids, filename, chunk_count}`、`K003` 是 `{items, count}`, **都没有 `data` 信封**。而 `request()` 默认取 `payload.data`(undefined),于是: - 上传后前端显示「已入库 **0** 块」,而库里其实切了 23 块; - 列表永远显示「知识库为空」。 端点表里标 `raw: true` 后(与 `V001` 同一做法)两者都正常。 实测:上传 4805 字符的产品手册 → 切 23 块并出现在列表里。 ## 2. 委托/成交详情页有 5 行永远显示「--」 前端按 `fin_sim_order` / `fin_transaction` 的**建表字段**写了 `quote_source`、 `nav`、`fee_rate_snapshot`、`confirmed_at`、`auto_confirmed` —— 但这些字段**接口的返回视图没有带**(表里有、返回里没有)。已按实际返回重写字段表, 并在注释里写明"以接口返回为准,不要照表写"。 ## 3. 配置项与路由规则的**编辑功能不可能成功**(接口缺口) `PUT` 硬性要求 `If-Match`,校验的是该行内容的 digest;而这两个资源是 `detail=False` —— **没有任何端点能返回这个 digest**(列表的 `meta` 只有 trace_id)。 乐观并发在"读不到版本"的前提下等于死锁:**首次编辑必然 409**。 (配置发布能用,是因为它有详情端点 `A003`。) - 新增详情端点 `A048` / `A049`(`detail=True`),已登记 `docs/05` §19; - 前端编辑前先 GET 详情取 etag,再带 `If-Match` 提交。 - 实测:编辑配置项与路由规则均 200;**不带 `If-Match` 仍返回 409**, 说明乐观并发没有被削弱。 ## 4. 路由规则表单**必然提交失败** 前端固定写 `max_attempts: 2` 且 `fallbacks: []`,而后端要求 `max_attempts ≤ 端点总数`(主 + 兜底)→ 422「重试次数超过端点数量」。 改为 `1` 并注明约束。 ## 5. 主端点手填 ID 会 422 后端对不存在/未激活的 `primary_endpoint_id` 直接 422「模型端点不存在或未激活」。 把输入框改成**下拉**,只列 `status='active'` 的端点(数据复用已有的端点列表)。 ## 顺带 - `apiClient` 增加 `del()` / `put()`:发出的方法一直由端点表决定,所以 `post('K004')` 也能发 DELETE —— 语义太绕,现在意图与行为一致。 - 清理了测试期间上传的 31 条知识残留(客服会检索到它们),库内恢复到 23 条产品手册。 ## 关于"逐条核对"的方法论 前两轮跑出来的 9 个和 5 个"失败"里,**多数是我测试脚本自己的假设错了**,不是前端问题: `T001` 是 `{account, summary}` 嵌套、`RK002` 的字段叫 `risk_level`、 `RK002/RK004/RK005` 的 limit 上限是 5/10/10(前端传的正是 5/10/10)、 `AD011/A002/A047` 的 data 是裸 list。每一处都回到前端源码确认后才下结论 —— **先把"我以为"改成"代码里写的"**,否则报告出去的就是假 bug。 验证:unit+contract **1397 passed**;integration **110 passed**;ruff 通过; mypy 251 文件 0 错;§19 现 93 个端点无重复;e2e 冒烟 **40/40**。 前端等价测试:只读 31 项全绿、写操作(含 ETag 链路)9 项 8 绿 1 项因测试数据过短。 --- app/api/controllers/admin.py | 10 +++- app/static/portal/common/api-client.js | 13 +++- app/static/portal/customer/orders/orders.js | 12 +++- .../customer/transactions/transactions.js | 16 ++--- .../employee-console/workspace/workspace.js | 60 +++++++++++++++---- docs/05-接口文档.md | 13 +++- 6 files changed, 98 insertions(+), 26 deletions(-) diff --git a/app/api/controllers/admin.py b/app/api/controllers/admin.py index 59e8076..d67c062 100644 --- a/app/api/controllers/admin.py +++ b/app/api/controllers/admin.py @@ -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) diff --git a/app/static/portal/common/api-client.js b/app/static/portal/common/api-client.js index 52ad556..61905df 100644 --- a/app/static/portal/common/api-client.js +++ b/app/static/portal/common/api-client.js @@ -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_MAILBOX: { method: 'GET', path: '/api/v1/offsite-fund/mailbox-status' }, diff --git a/app/static/portal/customer/orders/orders.js b/app/static/portal/customer/orders/orders.js index 601d9aa..5778282 100644 --- a/app/static/portal/customer/orders/orders.js +++ b/app/static/portal/customer/orders/orders.js @@ -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; diff --git a/app/static/portal/customer/transactions/transactions.js b/app/static/portal/customer/transactions/transactions.js index aef1c83..2ea1482 100644 --- a/app/static/portal/customer/transactions/transactions.js +++ b/app/static/portal/customer/transactions/transactions.js @@ -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; diff --git a/app/static/portal/employee-console/workspace/workspace.js b/app/static/portal/employee-console/workspace/workspace.js index a215b71..b51afb1 100644 --- a/app/static/portal/employee-console/workspace/workspace.js +++ b/app/static/portal/employee-console/workspace/workspace.js @@ -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 ''; + } + return active + .map((item) => ``) + .join(''); + } + function renderReleaseContent() { const content = state.releaseContent; if (!content?.releaseId) { targets.releaseContent.innerHTML = ''; return; } @@ -392,8 +410,8 @@ if (requireAdmin()) { (rule) => `
`, ); targets.releaseContent.innerHTML = ` -

版本 ${escapeHtml(String(releaseId))} · 配置项

${items.length} 条
${itemRows}
-

版本 ${escapeHtml(String(releaseId))} · 模型路由规则

${rules.length} 条
${ruleRows}
`; +

版本 ${escapeHtml(String(releaseId))} · 配置项

${items.length} 条
${itemRows}
+

版本 ${escapeHtml(String(releaseId))} · 模型路由规则

${rules.length} 条
${ruleRows}
`; 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(); diff --git a/docs/05-接口文档.md b/docs/05-接口文档.md index 5b727cd..32b0813 100644 --- a/docs/05-接口文档.md +++ b/docs/05-接口文档.md @@ -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/**` **不符**,