docs(pricing): spec 增补业务模块枚举与 per_period 周期计费;新增配置页原型

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-28 16:19:10 +08:00
co-authored by Claude
parent 2b800544bc
commit c3564df658
2 changed files with 508 additions and 17 deletions
@@ -1,7 +1,7 @@
# 定价模块计价对象枚举化与模型计价设计
> 版本:v0.3(本期)—— 在 v0.2(无预扣)基础上,将计价对象固定枚举化 + 接入模型计价。
> 范围:shop-user-trade **pricing 计价模块**。交付「枚举 + 配置管理 + per_1M 计算器」层;模型结算接入另期。
> 版本:v0.3(本期)—— 在 v0.2(无预扣)基础上,将计价对象固定枚举化 + 接入模型计价与业务模块周期计价
> 范围:shop-user-trade **pricing 计价模块**。交付「枚举 + 配置管理 + per_1M/per_period 计算器」层;模型/业务模块结算接入另期。
> 约定:金额一律**元**float64DB `NUMERIC(15,2)`2 位小数),不足 1 分向上取整(`ceilFen`)。
---
@@ -11,9 +11,10 @@
现状(v0.2):`pricing_config` 是「一 `biz_key` → 单一 `charge_mode` + 单模式 `rules`」,只能给任意业务标识配一种计费方式。
诉求:
1. 计价对象**固定枚举**`工作流` + `模型列表`model-gateway 系统模型)。后端提供枚举接口,管理端为这些项配置价格标准。
1. 计价对象**固定枚举**`工作流` + `模型列表`model-gateway 系统模型)+ `业务模块`(如 AI客服)。后端提供枚举接口,管理端为这些项配置价格标准。
2. 工作流可**同时配置三套价格**per_item / per_second / per_token),创建/使用工作流时按本次执行**选择一种**扣费方式。
3. 模型计价采用 model-gateway 已有的 OpenAI 风格 `PriceConfig`per_1M,含 match 条件、audio 变体、缓存、折扣),不同模型类型配置内容不同。
4. 业务模块按**周期**订阅计价(按年),通用 `per_period` 建模,后续可扩月/季。
目标:模型价格**单一来源**(只配一份,工作流与直接调模型共用);计价对象枚举 = 表结构;计算器注册表统一派发。
@@ -27,13 +28,15 @@
|---|---|---|
| `workflow` | `workflow` | 固定单主体,规则为三模式容器 |
| `model` | model-gateway 模型 **ID** | 每个系统模型一个主体,规则为其 PriceConfig |
| `business` | 业务模块标识(如 `ai_customer_service`) | 固定写死的一组业务模块,规则为周期订阅价格 |
枚举接口 `GET /pricing/subjects` 返回全部可配置主体:
- `workflow`(静态,恒有,附 `chargeModes: [per_item, per_second, per_token]`
- 各系统模型(**实时**调 model-gateway `/listModelManage``system_model=true` 过滤,返回 modelId/modelName/modelType
- 各业务模块(静态写死,如 AI客服,附 `chargeModes: [per_period]`
- 每项附 `hasConfig` + `enabled`,管理端据此区分未配价主体。
模型主体以 **modelId** 标识(稳定不变),枚举同时返回 modelName 供展示。
模型主体以 **modelId** 标识(稳定不变),枚举同时返回 modelName 供展示。业务模块列表固定写死在 `consts/pricing`(新增业务模块改常量,暂不做管理端增删)。
---
@@ -49,8 +52,8 @@ CREATE TABLE IF NOT EXISTS pricing_config (
creator VARCHAR(64) NOT NULL DEFAULT '', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) NOT NULL DEFAULT '', updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at timestamp(6),
subject_type VARCHAR(16) NOT NULL, -- workflow | model
subject_id VARCHAR(64) NOT NULL, -- workflow | model-gateway 模型ID
subject_type VARCHAR(16) NOT NULL, -- workflow | model | business
subject_id VARCHAR(64) NOT NULL, -- workflow | model-gateway 模型ID | 业务模块标识
rules TEXT NOT NULL, -- 见 §4
min_balance NUMERIC(15,2) NOT NULL DEFAULT 0, -- 门禁(元),0=不校验
currency VARCHAR(16) NOT NULL DEFAULT 'CNY',
@@ -69,8 +72,8 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_pricing_config_subject ON pricing_config(su
-- 唯一幂等键:uk_charge_order_subject (subject_type, subject_id, biz_order_no)
subject_type VARCHAR(16) NOT NULL,
subject_id VARCHAR(64) NOT NULL,
charge_mode VARCHAR(32) NOT NULL, -- 建单时选中/派发键:per_item/per_second/per_token/per_1K/per_1M/per_1
rule_snapshot TEXT NOT NULL, -- workflow: 选中模式的规则片段;model: 整个 PriceConfig
charge_mode VARCHAR(32) NOT NULL, -- 建单时选中/派发键:per_item/per_second/per_token/per_1K/per_1M/per_1/per_period
rule_snapshot TEXT NOT NULL, -- workflow: 选中模式的规则片段;model: 整个 PriceConfigbusiness: 周期价格规则
-- 其余字段(user_id/status/actual_amount/usage/settle_time 等)不变
```
@@ -113,6 +116,16 @@ rule_snapshot TEXT NOT NULL, -- workflow: 选中模式的规则片段
- 字段与 model-gateway `entity.PriceConfig / PriceRule / PriceMatch / PriceDiscount` 完全一致。
- Save 校验:`unit ∈ {per_1K, per_1M, per_1}`、rules 数组结构、match 字段类型(audio/video/reasoning 不同模型类型内容不同,但结构统一)。
### 4.3 business —— 周期订阅(per_period
```json
{ "period": "year", "price": 1999 }
```
- `period`:周期单位,本期 `year`,后续可扩 `month/quarter` 等(扩值即支持,计算器按允许集合校验)。
- `price`:每周期价格(元),一次性收取。
- Save 校验:`period ∈ 允许集合`(本期 `{year}`)、`price > 0`
---
## 5. 计算器注册表(charge_calc.go
@@ -120,7 +133,7 @@ rule_snapshot TEXT NOT NULL, -- workflow: 选中模式的规则片段
### 5.1 统一 charge_mode 枚举
```
per_item | per_second | per_token | per_1K | per_1M | per_1
per_item | per_second | per_token | per_1K | per_1M | per_1 | per_period
```
### 5.2 新增 per_1K/per_1M/per_1 计算器(一个实现注册三个键)
@@ -152,6 +165,11 @@ type ChargeUsage struct {
> ⚠️ **设计决策**per_token **费率不冻结在 rule_snapshot**(建单时未知会用哪些模型),结算时按模型 subject **实时价格**算。这是「模型价格单一来源」的代价,与 v0.2「改价不影响在途单」的 rule_snapshot 保证有冲突,仅 per_token 模式例外,其余模式仍走快照。
### 5.5 新增 per_period 计算器(周期订阅)
- `validate``period ∈ 允许集合`(本期 `{year}`+ `price > 0`
- `charge`:返回 `ceilFen(price)`(每次购买按整周期一次性收取,与用量无关;`usage.ItemCount` 预留多条数叠加)。
---
## 6. 接口设计
@@ -160,7 +178,7 @@ type ChargeUsage struct {
| 路由 | 方法 | 说明 |
|---|---|---|
| `/pricing/subjects` | GET | 枚举:workflow + 实时 model-gateway 系统模型,每项附 hasConfig/enabled |
| `/pricing/subjects` | GET | 枚举:workflow + 实时 model-gateway 系统模型 + 业务模块,每项附 hasConfig/enabled |
| `/pricing/config/save` | POST | `{subjectType, subjectId, rules, minBalance, currency, enabled}`,按 subject_type 校验 rules |
| `/pricing/config/get` | GET | 按 subjectType+subjectId |
| `/pricing/config/list` | GET | 分页,可按 subjectType/enabled 过滤 |
@@ -169,19 +187,20 @@ type ChargeUsage struct {
| 路由 | 方法 | 说明 |
|---|---|---|
| `/pricing/open_order` | POST | `{userId, subjectType, subjectId, chargeMode?, bizOrderNo}`workflow 的 chargeMode 必填且须已配置;model 的 chargeMode 可空(默认取 unit |
| `/pricing/open_order` | POST | `{userId, subjectType, subjectId, chargeMode?, bizOrderNo}`workflow 的 chargeMode 必填且须已配置;model 的 chargeMode 可空(默认取 unitbusiness 的 chargeMode 可空(默认 per_period |
| `/pricing/settle` `/cancel` `/fail` `/order` | — | 契约字段同步换 subjectType/subjectId |
**OpenOrder 校验**
- workflow`chargeMode ∈ rules 的 key``rule_snapshot = rules[chargeMode]`
- model`charge_mode = config.unit``rule_snapshot = 整个 PriceConfig`
- business`charge_mode = per_period``rule_snapshot = 周期规则 JSON`
- 门禁 `min_balance` 照旧;建单前校验规则 JSON;幂等返回既有单。
---
## 7. 枚举实现(subject_service.go,新)
- `GET /pricing/subjects`consul 解析 model-gateway 服务地址 → 调 `GET /listModelManage``system_model=true`)→ 合并固定 workflow 项。
- `GET /pricing/subjects`consul 解析 model-gateway 服务地址 → 调 `GET /listModelManage``system_model=true`)→ 合并固定 workflow 项 + 固定业务模块组(`consts/pricing`
- 每项查 `pricing_config` 是否已存在(hasConfig / enabled)。
- model-gateway 服务地址与鉴权放 `config.yml`(复用 `common/http` + consul 解析,同 market_service 调订单服务的模式)。
- model-gateway 不可用时:枚举接口**直接报错**(管理端低频接口,失败暴露更清晰,可重试);workflow 项仍为固定返回。
@@ -199,12 +218,15 @@ type ChargeUsage struct {
| pricing_config 去 charge_mode 列 | rules 自描述,workflow 三模式都在 rules 里 |
| 未配价模型按 0 计 | 工作流可含未单独配价的模型,不阻塞结算 |
| min_balance 门禁照旧 | 非冻结门禁语义不变 |
| 业务模块固定写死(AI客服等) | 与「固定枚举」一致;新增业务模块改常量,暂不做管理端增删 |
| 周期计费用 per_period 通用建模 | 扩月/季只扩 period 允许集合,不改代码结构 |
---
## 9. 遗留(另期)
- 模型 subject 结算接入:ai-agent 上报模型用量(PromptTokens/CompletionTokens/CachedTokens/MediaType)→ open_order/settle。
- 业务模块订阅购买流程接入(按周期下单/扣费/续费)。
- model-gateway 停扣(切换时机沿用 v0.2 §8.2)。
- 工作流侧「选择本次扣费方式」的 UI/配置项(ai-agent 侧,本期不动)。
@@ -212,16 +234,16 @@ type ChargeUsage struct {
## 10. 改动清单
1. `consts/pricing/charge_mode.go`:加 `per_1K/per_1M/per_1`
2. `consts/pricing/subject.go`(新):subject_type 常量 workflow/model
1. `consts/pricing/charge_mode.go`:加 `per_1K/per_1M/per_1/per_period`(含 period 允许集合 `{year}`
2. `consts/pricing/subject.go`(新):subject_type 常量 workflow/model/business + 业务模块固定列表(AI客服 → `ai_customer_service`
3. `model/entity/pricing/pricing_config.go`biz_key/charge_mode → subject_type/subject_id
4. `model/dto/pricing/config_dto.go`Save/Get/List/Info 换 subject 字段
5. `model/dto/pricing/charge_dto.go`OpenOrderReq 加 subjectType/subjectId/chargeModeChargeOrderInfo 的 bizKey → subjectType/subjectIdDAO 入参同步
6. `dao/pricing/pricing_config_dao.go`Get/Save/List 按 (subject_type, subject_id)
7. `dao/pricing/charge_order_dao.go`:幂等键改 (subject_type, subject_id, biz_order_no)
8. `service/pricing/charge_calc.go`per_unit 计算器 + ChargeUsage 扩展 + per_token 语义改模型价
9. `service/pricing/pricing_service.go`OpenOrder 校验 chargeMode/unitSave 按类型校验 rulessettleOrder 派发
10. `service/pricing/subject_service.go`(新):枚举接口
8. `service/pricing/charge_calc.go`per_unit 计算器 + per_period 计算器 + ChargeUsage 扩展 + per_token 语义改模型价
9. `service/pricing/pricing_service.go`OpenOrder 校验 chargeMode/unit/periodSave 按类型校验 rulessettleOrder 派发
10. `service/pricing/subject_service.go`(新):枚举接口workflow + 实时模型 + 固定业务模块)
11. `controller/pricing/pricing_controller.go`:加 Subjects
12. `init.sql`pricing_config 改结构、charge_order 改键、迁移 SQL
13. `config.yml`:加 model-gateway 服务地址
@@ -0,0 +1,469 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>计价配置管理 · 原型预览</title>
<style>
:root{
--primary:#409eff; --primary-light:#ecf5ff; --danger:#f56c6c; --success:#67c23a;
--text:#303133; --text2:#606266; --text3:#909399; --border:#dcdfe6; --bg:#f5f7fa; --card:#fff;
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:"PingFang SC","Microsoft YaHei",system-ui,sans-serif;background:var(--bg);color:var(--text);font-size:14px}
.topbar{background:#fff;border-bottom:1px solid var(--border);padding:14px 24px;display:flex;align-items:center;justify-content:space-between}
.topbar h1{font-size:18px;font-weight:600}
.topbar .tag{font-size:12px;color:var(--text3);margin-left:12px}
.topbar .badge-proto{background:var(--primary-light);color:var(--primary);border-radius:4px;padding:3px 8px;font-size:12px}
.layout{display:flex;gap:16px;padding:16px 24px;align-items:flex-start}
.panel{background:var(--card);border:1px solid var(--border);border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.04)}
/* 左侧枚举列表 */
.sidebar{width:300px;flex-shrink:0;overflow:hidden}
.sidebar .p-head{padding:12px 16px;border-bottom:1px solid var(--border);font-weight:600;display:flex;justify-content:space-between;align-items:center}
.sidebar .p-head .hint{font-size:12px;color:var(--text3);font-weight:400}
.grp-title{padding:10px 16px 4px;font-size:12px;color:var(--text3)}
.subj{display:flex;align-items:center;gap:8px;padding:10px 16px;cursor:pointer;border-left:3px solid transparent;transition:.15s}
.subj:hover{background:var(--primary-light)}
.subj.active{background:var(--primary-light);border-left-color:var(--primary)}
.subj .name{flex:1;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.subj .type-badge{font-size:11px;border-radius:3px;padding:1px 6px;color:#fff;flex-shrink:0}
.subj .conf-badge{font-size:11px;border-radius:3px;padding:1px 6px;flex-shrink:0}
.conf-yes{background:#f0f9eb;color:var(--success)} .conf-no{background:#fef0f0;color:var(--danger)}
.t-reason{background:#e6a23c}.t-chat{background:#409eff}.t-audio{background:#67c23a}.t-video{background:#909399}.t-workflow{background:#8e44ad}.t-business{background:#e74c3c}
.mode-chip{font-size:11px;background:#f4f4f5;color:var(--text2);border-radius:3px;padding:1px 5px}
/* 右侧编辑区 */
.editor{flex:1;min-width:0;overflow:hidden}
.e-head{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
.e-head .st{color:var(--text3);font-size:12px}
.e-head .modes{margin-top:4px}
.e-body{padding:20px;display:flex;gap:20px;flex-wrap:wrap}
.col{flex:1;min-width:380px}
.card{border:1px solid var(--border);border-radius:6px;margin-bottom:16px;overflow:hidden}
.card .c-head{background:#fafafa;padding:10px 14px;font-weight:600;display:flex;align-items:center;justify-content:space-between;font-size:14px}
.card .c-head .st{font-size:12px;color:var(--text3);font-weight:400}
.card .c-body{padding:14px}
.f-row{display:flex;gap:12px;margin-bottom:10px;align-items:center;flex-wrap:wrap}
.f{display:flex;flex-direction:column;gap:4px}
.f label{font-size:12px;color:var(--text2)}
.f input,.f select{height:30px;border:1px solid var(--border);border-radius:4px;padding:0 8px;font-size:13px;outline:none;background:#fff}
.f input:focus,.f select:focus{border-color:var(--primary)}
.f input.wide{width:220px}
.f input.num{width:110px}
.tbl{width:100%;border-collapse:collapse}
.tbl th,.tbl td{border:1px solid var(--border);padding:5px 8px;font-size:13px;text-align:left}
.tbl th{background:#fafafa;font-weight:500;color:var(--text2)}
.tbl input{width:100%;border:none;outline:none;font-size:13px;padding:2px}
.tbl input:focus{background:var(--primary-light)}
.btn{height:30px;padding:0 14px;border-radius:4px;border:1px solid var(--border);background:#fff;color:var(--text);cursor:pointer;font-size:13px}
.btn:hover{color:var(--primary);border-color:var(--primary)}
.btn.primary{background:var(--primary);border-color:var(--primary);color:#fff}
.btn.primary:hover{background:#66b1ff}
.btn.mini{height:24px;padding:0 8px;font-size:12px}
.btn.danger:hover{color:var(--danger);border-color:var(--danger)}
.hint{font-size:12px;color:var(--text3)}
.switch{position:relative;display:inline-block;width:40px;height:20px}
.switch input{opacity:0;width:0;height:0}
.slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:#ccc;transition:.2s;border-radius:10px}
.slider:before{position:absolute;content:"";height:16px;width:16px;left:2px;bottom:2px;background:#fff;transition:.2s;border-radius:50%}
input:checked+.slider{background:var(--primary)}
input:checked+.slider:before{transform:translateX(20px)}
.json{width:100%;min-height:320px;font-family:Consolas,Menlo,monospace;font-size:12.5px;border:1px solid var(--border);border-radius:6px;padding:12px;background:#1e1e1e;color:#d4d4d4;white-space:pre;overflow:auto;line-height:1.55}
.json .p-key{color:#9cdcfe}.json .p-str{color:#ce9178}.json .p-num{color:#b5cea8}.json .p-bool{color:#569cd6}.json .p-null{color:#6a9955}
.note{border:1px solid #e6a23c;background:#fdf6ec;color:#b88230;border-radius:6px;padding:10px 14px;font-size:12.5px;line-height:1.7;margin-bottom:16px}
.toast{position:fixed;top:20px;left:50%;transform:translateX(-50%);background:var(--success);color:#fff;padding:10px 20px;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,.15);opacity:0;transition:.3s;z-index:99;font-size:14px}
.toast.show{opacity:1}
.section-title{font-size:13px;color:var(--text2);margin:18px 0 10px;display:flex;align-items:center;gap:6px}
.section-title::before{content:"";width:3px;height:14px;background:var(--primary);border-radius:2px}
.api-box{margin-top:16px}
.rule-card{border:1px solid var(--border);border-radius:6px;margin-bottom:10px;padding:12px}
.rule-card .rc-head{display:flex;align-items:center;gap:8px;margin-bottom:10px}
.rule-card .rc-head input{flex:1}
.rc-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
.money-note{font-size:12px;color:var(--text3);margin-bottom:8px}
</style>
</head>
<body>
<div class="topbar">
<h1>计价配置管理 <span class="tag">/pricing · 原型预览</span></h1>
<span class="badge-proto">原型预览 · 静态无后端</span>
</div>
<div class="layout">
<!-- ============ 左侧:计价对象枚举 ============ -->
<div class="panel sidebar">
<div class="p-head">计价对象 <span class="hint">GET /pricing/subjects</span></div>
<div class="grp-title">工作流</div>
<div id="subj-workflow"></div>
<div class="grp-title">模型(实时拉取 model-gateway 系统模型)</div>
<div id="subj-models"></div>
<div class="grp-title">业务模块</div>
<div id="subj-business"></div>
</div>
<!-- ============ 右侧:配置编辑 ============ -->
<div class="panel editor">
<div class="e-head">
<div>
<div style="font-size:15px;font-weight:600" id="ed-title">工作流</div>
<div class="st" id="ed-sub">subjectType=workflow · subjectId=workflow</div>
</div>
<div>
<button class="btn" onclick="loadApiExample()">枚举响应示例</button>
<button class="btn primary" onclick="saveMock()">保存配置</button>
</div>
</div>
<!-- 工作流编辑 -->
<div class="e-body" id="ed-workflow" style="display:none">
<div class="col">
<div class="note">工作流可同时配置 <b>按条 / 按秒 / 按token</b> 三套价格。创建/使用工作流时,从这些配置中<b>选择本次执行的扣费方式</b>。只配置的模式才会出现在 rules 里。</div>
<!-- per_item -->
<div class="card">
<div class="c-head">按条计费 per_item
<span class="st"><label class="switch"><input type="checkbox" id="w-item-on" onchange="renderWorkflow()"><span class="slider"></span></label></span>
</div>
<div class="c-body">
<div class="hint">档位开放列表,上不封顶。命中首个 <code>maxSec ≥ 时长</code> 的档;超出最大档按 overflowUnitPrice 线性叠加。</div>
<table class="tbl" style="margin:10px 0">
<tr><th>档位最大秒数 maxSec</th><th>价格(元)</th><th></th></tr>
<tbody id="w-item-tiers"></tbody>
</table>
<button class="btn mini" onclick="addTier()">+ 加档</button>
<div class="f-row" style="margin-top:10px">
<div class="f"><label>超出最大档单价(元/秒)</label><input class="num" id="w-item-overflow" type="number" step="0.01" value="0.9" oninput="renderWorkflow()"></div>
</div>
</div>
</div>
<!-- per_second -->
<div class="card">
<div class="c-head">按秒计费 per_second
<span class="st"><label class="switch"><input type="checkbox" id="w-sec-on" onchange="renderWorkflow()"><span class="slider"></span></label></span>
</div>
<div class="c-body">
<div class="hint">按秒向上取整(不满 1 秒按 1 秒),再乘单价。</div>
<div class="f-row" style="margin-top:10px">
<div class="f"><label>单价(元/秒)</label><input class="num" id="w-sec-unit" type="number" step="0.01" value="0.9" oninput="renderWorkflow()"></div>
</div>
</div>
</div>
<!-- per_token -->
<div class="card">
<div class="c-head">按token计费 per_token
<span class="st"><label class="switch"><input type="checkbox" id="w-tok-on" onchange="renderWorkflow()"><span class="slider"></span></label></span>
</div>
<div class="c-body">
<div class="note" style="margin-bottom:0">价格<b>取自各模型自己的计价配置</b>(模型列表里配的那份),不在此重复配置。未配价的模型按 0 计。⚠️ per_token 费率不冻结在快照,结算时按模型实时价。</div>
</div>
</div>
</div>
<div class="col">
<div class="section-title">公共配置</div>
<div class="card"><div class="c-body">
<div class="f-row">
<div class="f"><label>门禁 min_balance(元)</label><input class="num" id="w-minbalance" type="number" step="0.01" value="0" oninput="renderWorkflow()"></div>
<div class="f"><label>币种</label><select id="w-currency" onchange="renderWorkflow()"><option value="CNY" selected>CNY</option><option value="USD">USD</option></select></div>
<div class="f"><label>启用</label><label class="switch"><input type="checkbox" id="w-enabled" checked onchange="renderWorkflow()"><span class="slider"></span></label></div>
</div>
</div></div>
<div class="section-title">rules JSON(实时预览)</div>
<textarea class="json" id="wf-json" readonly spellcheck="false"></textarea>
</div>
</div>
<!-- 模型编辑 -->
<div class="e-body" id="ed-model" style="display:none">
<div class="col">
<div class="card">
<div class="c-head">PriceConfig 配置
<span class="st"><select id="m-example" onchange="loadExample()">
<option value="">选择示例填充</option>
<option value="audio">音频模型示例</option>
<option value="video">视频模型示例</option>
<option value="reason">推理模型示例</option>
</select></span>
</div>
<div class="c-body">
<div class="f-row">
<div class="f"><label>计价基准 unit</label><select id="m-unit" onchange="renderModel()"><option value="per_1M" selected>per_1M(百万token</option><option value="per_1K">per_1K(千token</option><option value="per_1">per_1(单条)</option></select></div>
<div class="f"><label>币种</label><select id="m-currency" onchange="renderModel()"><option value="CNY" selected>CNY</option><option value="USD">USD</option></select></div>
<div class="f"><label>启用</label><label class="switch"><input type="checkbox" id="m-enabled" checked onchange="renderModel()"><span class="slider"></span></label></div>
</div>
<div class="f-row"><div class="f"><label>模型级折扣 rate(如 0.4=4折,空=无)</label><input class="num" id="m-discount" type="number" step="0.01" oninput="renderModel()"></div></div>
<div class="money-note">价格单位:元 / 基准单位(per_1M 即每百万 token)。不同模型类型配置内容不同——音频/视频/推理见示例。</div>
<div id="m-rules"></div>
<button class="btn mini" onclick="addModelRule()">+ 加规则</button>
</div>
</div>
</div>
<div class="col">
<div class="section-title">公共配置</div>
<div class="card"><div class="c-body">
<div class="f-row">
<div class="f"><label>门禁 min_balance(元)</label><input class="num" id="m-minbalance" type="number" step="0.01" value="0" oninput="renderModel()"></div>
</div>
</div></div>
<div class="section-title">rules JSON(实时预览)</div>
<textarea class="json" id="m-json" readonly spellcheck="false"></textarea>
<div class="note" style="margin-top:12px">结算入参(ChargeUsage 扩展,本期备用):<code>{promptTokens, completionTokens, cachedTokens, mediaType}</code>。mediaType = audio / no_video / has_video 由请求体参考媒体字段推导。</div>
</div>
</div>
<!-- 业务模块编辑 -->
<div class="e-body" id="ed-business" style="display:none">
<div class="col">
<div class="card">
<div class="c-head">周期订阅 per_period
<span class="st">通用周期建模,后续可扩 month/quarter</span>
</div>
<div class="c-body">
<div class="hint" style="margin-bottom:10px">按周期一次性收取,与用量无关。本期支持按年(period=year)。</div>
<div class="f-row">
<div class="f"><label>周期 period</label>
<select id="b-period" onchange="renderBusiness()"><option value="year" selected>year(按年)</option></select>
</div>
<div class="f"><label>每周期价格(元)</label>
<input class="num" id="b-price" type="number" step="0.01" value="1999" oninput="renderBusiness()">
</div>
</div>
</div>
</div>
</div>
<div class="col">
<div class="section-title">公共配置</div>
<div class="card"><div class="c-body">
<div class="f-row">
<div class="f"><label>门禁 min_balance(元)</label><input class="num" id="b-minbalance" type="number" step="0.01" value="0" oninput="renderBusiness()"></div>
<div class="f"><label>币种</label><select id="b-currency" onchange="renderBusiness()"><option value="CNY" selected>CNY</option><option value="USD">USD</option></select></div>
<div class="f"><label>启用</label><label class="switch"><input type="checkbox" id="b-enabled" checked onchange="renderBusiness()"><span class="slider"></span></label></div>
</div>
</div></div>
<div class="section-title">rules JSON(实时预览)</div>
<textarea class="json" id="b-json" readonly spellcheck="false"></textarea>
<div class="hint" style="margin-top:8px">订阅购买/续费流程接入另期;本期提供价格标准配置。</div>
</div>
</div>
<!-- 枚举响应示例 -->
<div class="e-body" id="ed-api" style="display:none">
<div class="col" style="flex:1;min-width:0">
<div class="section-title">GET /pricing/subjects · 枚举响应</div>
<textarea class="json" id="api-json" readonly spellcheck="false"></textarea>
<div class="hint" style="margin-top:8px">模型部分实时调 model-gateway <code>/listModelManage</code>system_model=true);model-gateway 不可用时接口报错。</div>
</div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
/* ================= 主体数据(模拟枚举) ================= */
const SUBJECTS = [
{type:'workflow', id:'workflow', name:'工作流', modes:['per_item','per_second','per_token'], hasConfig:true, enabled:1},
{type:'model', id:'101', name:'deepseek-v3', modelType:'reason', typeLabel:'推理', conf:true},
{type:'model', id:'102', name:'gpt-4o', modelType:'chat', typeLabel:'对话', conf:true},
{type:'model', id:'103', name:'语音合成·统一', modelType:'audio', typeLabel:'音频', conf:false},
{type:'model', id:'104', name:'视频理解·统一', modelType:'video', typeLabel:'视频', conf:false},
{type:'business', id:'ai_customer_service', name:'AI客服', typeLabel:'业务', conf:false},
];
/* ================= 模型示例(对应 design §4.2 ================= */
const EXAMPLES = {
audio:{unit:'per_1M',currency:'CNY',enabled:1,minBalance:0,
rules:[{name:'音频-统一价格(万字符5元)', input:500, output:500, cacheHit:0, inputAudio:500, cacheHitAudio:0, cacheStorageHour:0, match:''}]},
video:{unit:'per_1M',currency:'CNY',enabled:1,minBalance:0,
rules:[
{name:'输入不含视频', input:23, output:23, cacheHit:0, inputAudio:0, cacheHitAudio:0, cacheStorageHour:0, match:'{"mediaType":"no_video"}'},
{name:'输入包含视频', input:14, output:14, cacheHit:0, inputAudio:0, cacheHitAudio:0, cacheStorageHour:0, match:'{"mediaType":"has_video"}'},
]},
reason:{unit:'per_1M',currency:'CNY',enabled:1,minBalance:0,
rules:[
{name:'输入[0,32000]', input:0.6, output:3.6, cacheHit:0.12, inputAudio:9, cacheHitAudio:1.8, cacheStorageHour:0.017, match:'{"inputLengthMax":32000}'},
{name:'输入(32000,128000]', input:0.9, output:5.4, cacheHit:0.18, inputAudio:13.5, cacheHitAudio:2.7, cacheStorageHour:0.017, match:'{"inputLengthMin":32001,"inputLengthMax":128000}'},
{name:'输入(128000,256000]', input:1.8, output:10.8, cacheHit:0.36, inputAudio:27, cacheHitAudio:5.4, cacheStorageHour:0.017, match:'{"inputLengthMin":128001,"inputLengthMax":256000}'},
]},
};
/* ================= 状态 ================= */
let current = 'workflow';
let modelRules = [];
/* ================= 渲染左侧枚举 ================= */
function renderSubjects(){
const wf = SUBJECTS.find(s=>s.type==='workflow');
document.getElementById('subj-workflow').innerHTML = subjectHTML(wf);
const models = SUBJECTS.filter(s=>s.type==='model');
document.getElementById('subj-models').innerHTML = models.map(subjectHTML).join('');
const biz = SUBJECTS.filter(s=>s.type==='business');
document.getElementById('subj-business').innerHTML = biz.map(subjectHTML).join('');
}
function subjectHTML(s){
const conf = s.type==='workflow' ? s.hasConfig : s.conf;
const typeLabel = s.type==='workflow' ? '工作流' : (s.type==='business' ? '业务' : s.typeLabel);
const tclass = s.type==='workflow' ? 't-workflow' : (s.type==='business' ? 't-business' : 't-'+s.modelType);
let extra='';
if(s.type==='workflow'){ extra = s.modes.map(m=>`<span class="mode-chip">${m}</span>`).join(' '); }
if(s.type==='business'){ extra = `<span class="mode-chip">per_period</span>`; }
return `<div class="subj ${current===s.type+':'+s.id?'active':''}" onclick="selectSubject('${s.type}','${s.id}')">
<span class="type-badge ${tclass}">${typeLabel}</span>
<span class="name">${s.name}</span>
${extra}
<span class="conf-badge ${conf?'conf-yes':'conf-no'}">${conf?'已配置':'未配置'}</span>
</div>`;
}
/* ================= 切换主体 ================= */
function selectSubject(type, id){
current = type+':'+id;
renderSubjects();
const panels = ['ed-workflow','ed-model','ed-business','ed-api'];
panels.forEach(p=>document.getElementById(p).style.display='none');
if(type==='workflow'){
document.getElementById('ed-workflow').style.display='flex';
document.getElementById('ed-title').textContent='工作流';
document.getElementById('ed-sub').textContent='subjectType=workflow · subjectId=workflow';
loadWorkflowState();
} else if(type==='model'){
document.getElementById('ed-model').style.display='flex';
const s = SUBJECTS.find(x=>x.type==='model'&&x.id===id);
document.getElementById('ed-title').textContent=s.name;
document.getElementById('ed-sub').textContent=`subjectType=model · subjectId=${s.id}${s.typeLabel}`;
const ex = EXAMPLES[s.modelType];
if(ex && !document.getElementById('m-example').value){ loadState(ex); }
else if(!modelRules.length){ modelRules=[emptyRule()]; renderModel(); }
} else if(type==='business'){
document.getElementById('ed-business').style.display='flex';
const s = SUBJECTS.find(x=>x.type==='business'&&x.id===id);
document.getElementById('ed-title').textContent=s.name;
document.getElementById('ed-sub').textContent=`subjectType=business · subjectId=${s.id}`;
renderBusiness();
}
}
function renderBusiness(){
const cfg = { subjectType:'business', subjectId:current.split(':')[1],
rules:{ period:document.getElementById('b-period').value, price:+document.getElementById('b-price').value },
minBalance:+document.getElementById('b-minbalance').value,
currency:document.getElementById('b-currency').value,
enabled:document.getElementById('b-enabled').checked?1:0 };
document.getElementById('b-json').value = JSON.stringify(cfg,null,2);
}
function selectSubjectRaw(){}
function loadApiExample(){
['ed-workflow','ed-model','ed-api'].forEach(p=>document.getElementById(p).style.display='none');
document.getElementById('ed-api').style.display='flex';
document.getElementById('ed-title').textContent='枚举响应示例';
document.getElementById('ed-sub').textContent='GET /pricing/subjects';
const wf = SUBJECTS.find(s=>s.type==='workflow');
const api = {
subjects:[
{subjectType:'workflow', subjectId:'workflow', name:'工作流', chargeModes:['per_item','per_second','per_token'], hasConfig:true, enabled:1},
...SUBJECTS.filter(s=>s.type==='model').map(s=>({subjectType:'model', subjectId:s.id, name:s.name, modelType:s.modelType, hasConfig:s.conf, enabled:1})),
...SUBJECTS.filter(s=>s.type==='business').map(s=>({subjectType:'business', subjectId:s.id, name:s.name, chargeModes:['per_period'], hasConfig:s.conf, enabled:1}))
]
};
document.getElementById('api-json').value = JSON.stringify(api,null,2);
}
/* ================= 工作流编辑 ================= */
let tiers = [{maxSec:15,price:29},{maxSec:30,price:49},{maxSec:60,price:89}];
function renderWorkflowTiers(){
const tb=document.getElementById('w-item-tiers');
tb.innerHTML = tiers.map((t,i)=>`<tr>
<td><input type="number" value="${t.maxSec}" oninput="tiers[${i}].maxSec=+this.value;renderWorkflow()"></td>
<td><input type="number" step="0.01" value="${t.price}" oninput="tiers[${i}].price=+this.value;renderWorkflow()"></td>
<td><button class="btn mini danger" onclick="tiers.splice(${i},1);renderWorkflowTiers();renderWorkflow()">删</button></td></tr>`).join('');
}
function addTier(){ tiers.push({maxSec:60,price:89}); renderWorkflowTiers(); renderWorkflow(); }
function loadWorkflowState(){ renderWorkflowTiers(); renderWorkflow(); }
function renderWorkflow(){
const rules = {};
if(document.getElementById('w-item-on').checked){
rules.per_item = {tiers:tiers.filter(t=>t&&t.maxSec>0), overflowUnitPrice:+document.getElementById('w-item-overflow').value};
}
if(document.getElementById('w-sec-on').checked){
rules.per_second = {unitPrice:+document.getElementById('w-sec-unit').value};
}
if(document.getElementById('w-tok-on').checked){
rules.per_token = {};
}
const cfg = { subjectType:'workflow', subjectId:'workflow', rules,
minBalance:+document.getElementById('w-minbalance').value,
currency:document.getElementById('w-currency').value,
enabled:document.getElementById('w-enabled').checked?1:0 };
document.getElementById('wf-json').value = JSON.stringify(cfg,null,2);
}
/* ================= 模型编辑 ================= */
function emptyRule(){ return {name:'',input:0,output:0,cacheHit:0,inputAudio:0,cacheHitAudio:0,cacheStorageHour:0,match:''}; }
function addModelRule(){ modelRules.push(emptyRule()); renderModel(); }
function loadExample(){
const v=document.getElementById('m-example').value;
if(!v) return;
loadState(JSON.parse(JSON.stringify(EXAMPLES[v])));
}
function loadState(ex){
document.getElementById('m-unit').value=ex.unit||'per_1M';
document.getElementById('m-currency').value=ex.currency||'CNY';
document.getElementById('m-enabled').checked = ex.enabled!==0;
document.getElementById('m-minbalance').value = ex.minBalance||0;
document.getElementById('m-discount').value = ex.discount ? ex.discount.rate : '';
modelRules = ex.rules && ex.rules.length ? JSON.parse(JSON.stringify(ex.rules)) : [emptyRule()];
renderModel();
}
function renderModel(){
const box=document.getElementById('m-rules');
box.innerHTML = modelRules.map((r,i)=>`<div class="rule-card">
<div class="rc-head">
<span style="color:var(--text3);font-size:12px">规则 #${i+1}</span>
<input value="${esc(r.name)}" placeholder="规则名(如:输入[0,32000]" oninput="modelRules[${i}].name=this.value;renderModel()">
<button class="btn mini danger" onclick="modelRules.splice(${i},1);renderModel()">删</button>
</div>
<div class="rc-grid">
${num('input','输入单价 input',i,'元/基准')}
${num('output','输出单价 output',i,'元/基准')}
${num('cacheHit','缓存命中 cacheHit',i,'元/基准')}
${num('inputAudio','音频输入 inputAudio',i,'元/基准')}
${num('cacheHitAudio','音频缓存 cacheHitAudio',i,'元/基准')}
${num('cacheStorageHour','缓存存储 cacheStorageHour',i,'元/小时')}
</div>
<div class="f" style="margin-top:8px"><label>命中条件 matchJSON,空=无条件命中)</label>
<input value="${esc(r.match)}" placeholder='{"inputLengthMax":32000} 或 {"mediaType":"has_video"}' oninput="modelRules[${i}].match=this.value;renderModel()"></div>
</div>`).join('');
const rules = modelRules.filter(r=>r.name||r.input||r.output||r.cacheHit||r.inputAudio||r.cacheHitAudio||r.cacheStorageHour||r.match)
.map(r=>({name:r.name, input:+r.input, output:+r.output, cacheHit:+r.cacheHit,
inputAudio:+r.inputAudio, cacheHitAudio:+r.cacheHitAudio, cacheStorageHour:+r.cacheStorageHour,
...(r.match?{match:JSON.parse(r.match||'{}')}:{})}));
const unit = document.getElementById('m-unit').value;
const dis = document.getElementById('m-discount').value;
const cfg = { unit, rules, currency:document.getElementById('m-currency').value,
...(dis!==''?{discount:{rate:+dis}}:{discount:null}) };
document.getElementById('m-json').value = JSON.stringify({subjectType:'model', subjectId:current.split(':')[1], rules:cfg, minBalance:+document.getElementById('m-minbalance').value, enabled:document.getElementById('m-enabled').checked?1:0},null,2);
}
function num(key,label,i,unit){
return `<div class="f"><label>${label}</label><input type="number" step="0.01" value="${modelRules[i][key]}" oninput="modelRules[${i}].${key}=+this.value;renderModel()"></div>`;
}
function esc(s){ return (s||'').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
/* ================= 保存(mock ================= */
function saveMock(){
const t=document.getElementById('toast');
t.textContent='已保存(模拟)· rules JSON 已在上方预览';
t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1800);
}
/* ================= 初始化 ================= */
renderSubjects();
selectSubject('workflow','workflow');
document.getElementById('w-item-on').checked=true;
document.getElementById('w-sec-on').checked=true;
document.getElementById('w-tok-on').checked=true;
renderWorkflow();
</script>
</body>
</html>