docs(pricing): spec 增补业务模块枚举与 per_period 周期计费;新增配置页原型
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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 计算器」层;模型/业务模块结算接入另期。
|
||||
> 约定:金额一律**元**(float64,DB `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: 整个 PriceConfig;business: 周期价格规则
|
||||
-- 其余字段(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 可空(默认取 unit);business 的 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/chargeMode;ChargeOrderInfo 的 bizKey → subjectType/subjectId;DAO 入参同步
|
||||
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/unit;Save 按类型校验 rules;settleOrder 派发
|
||||
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/period;Save 按类型校验 rules;settleOrder 派发
|
||||
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>命中条件 match(JSON,空=无条件命中)</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,'"').replace(/</g,'<'); }
|
||||
|
||||
/* ================= 保存(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>
|
||||
Reference in New Issue
Block a user