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 cc4c8e5..505a705 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_MAIL: { method: 'GET', path: '/api/v1/offsite-fund/mails/{mailId}' }, 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 = ` -