feat: 产品模块完整功能实现 — 资源绑定/多供应商路由/包月订购/消耗引擎/成本计算

新增模型: product_resource, product_resource_supplier, product_subscription, product_usage_log
新增API: 15个.dspy端点(资源绑定/供应商管理/订购/超额/消耗/统计)
新增UI: 4个管理界面(资源绑定/供应商关联/订购管理/消费记录)
核心逻辑: ProductManager新增 bind/unbind/subscribe/product_use/check_quota 等完整业务方法
设计文档: DESIGN.md 完整架构规范
This commit is contained in:
Hermes Agent 2026-06-20 12:10:32 +08:00
parent 79e8a5fa69
commit e8860401bc
32 changed files with 2085 additions and 1 deletions

478
DESIGN.md Normal file
View File

@ -0,0 +1,478 @@
# 产品模块 + 供应链模块 功能设计规范
> 版本: 1.0 | 日期: 2026-06-19
> 模块: product_management + supplychain
---
## 一、总体架构
```
平台方(owner)
├── product_management ← 产品目录层(定义+展示+消费)
│ ├── 产品分类树 (product_category)
│ ├── 产品注册 (product)
│ ├── 产品资源绑定 (product_resource + product_resource_supplier)
│ ├── 客户订购 (product_subscription)
│ └── 消费记录 (product_usage_log)
└── supplychain ← 供应链层(供应+定价+结算)
├── 供应商 (suppliers)
├── 供销关系 (platform_supply_relations)
├── 供销产品 (platform_supply_products)
├── 供应商资源定价 (supplier_resource_price) ← NEW
├── 分销协议 (distribution_agreements + items)
├── 销售记账 (sales_ledger)
└── 对账结算 (provider_reconcile / reseller_reconcile)
```
**职责边界:**
- product_management: 产品是什么、挂哪里、卖多少、客户买了什么、用了多少、成本多少
- supplychain: 谁来供货、供货价多少、分销折扣、对账结算
**数据流向:**
```
供应商资源定价(supplychain) → 产品资源绑定(product_mgmt) → 消费成本计算(product_mgmt) → 销售记账(supplychain) → 对账结算(supplychain)
```
---
## 二、新增数据模型
### 2.1 product_management 新增表
#### product_resource (产品资源绑定表)
产品绑定的底层资源,一个产品可绑定多个资源。
| 字段 | 类型 | 说明 |
|------|------|------|
| id | VARCHAR(32) PK | 主键 |
| product_id | VARCHAR(32) | 产品ID → product.id |
| resource_type | VARCHAR(32) | 资源类型: llm_model/llm_monthly/compute |
| resource_ref_id | VARCHAR(32) | 资源引用ID (如 llm.id) |
| resource_ref_name | VARCHAR(255) | 资源显示名(冗余,展示用) |
| quota | DOUBLE(15,4) | 配额量(包月类用), 0=不限 |
| quota_unit | VARCHAR(32) | 配额单位: tokens/requests/gpu_hours |
| priority | INT | 多供应商时的优先级(1=最高) |
| overflow_product_id | VARCHAR(32) | 超额后转用的按量产品ID |
| status | CHAR(1) | 1=启用 0=禁用 |
| created_at | DATETIME | |
| updated_at | DATETIME | |
索引: idx_pr_product(product_id), idx_pr_resource(resource_type,resource_ref_id)
#### product_resource_supplier (产品资源-供应商关联表)
同一产品资源可关联多个供应商,按优先级路由消耗。
| 字段 | 类型 | 说明 |
|------|------|------|
| id | VARCHAR(32) PK | 主键 |
| product_resource_id | VARCHAR(32) | → product_resource.id |
| supplier_org_id | VARCHAR(32) | 供应商机构ID → organization.id |
| priority | INT | 优先级(1=最高,消耗时优先用高优先级供应商) |
| weight | INT | 权重(priority相同时按权重分配) |
| status | CHAR(1) | 1=启用 0=禁用 |
| created_at | DATETIME | |
索引: idx_prs_resource(product_resource_id), UNIQUE idx_prs_unique(product_resource_id,supplier_org_id)
#### product_subscription (客户订购表)
客户购买包月/包量产品后的订购记录。
| 字段 | 类型 | 说明 |
|------|------|------|
| id | VARCHAR(32) PK | 主键 |
| product_id | VARCHAR(32) | → product.id |
| user_id | VARCHAR(32) | 客户用户ID |
| user_org_id | VARCHAR(32) | 客户机构ID |
| subscription_type | CHAR(1) | 1=包月 2=包量 3=一次性 |
| status | CHAR(1) | 1=活跃 2=已过期 3=已取消 4=已超额 |
| start_date | DATE | 生效日期 |
| end_date | DATE | 到期日期 |
| quota_total | DOUBLE(15,4) | 总配额 |
| quota_used | DOUBLE(15,4) | 已使用量 |
| quota_unit | VARCHAR(32) | 配额单位 |
| overflow_mode | CHAR(1) | 超额模式: 1=转按量 2=停服 |
| overflow_rate | DOUBLE(15,6) | 超额后单价(转按量时) |
| purchase_price | DOUBLE(15,2) | 购买价格 |
| purchase_currency | CHAR(8) | 货币 |
| created_at | DATETIME | |
| updated_at | DATETIME | |
索引: idx_ps_product(product_id), idx_ps_user(user_id,user_org_id), idx_ps_status(status), idx_ps_dates(start_date,end_date)
#### product_usage_log (产品消费记录表)
每次资源消耗的详细记录,含成本精确计算。
| 字段 | 类型 | 说明 |
|------|------|------|
| id | VARCHAR(32) PK | 主键 |
| product_id | VARCHAR(32) | → product.id |
| subscription_id | VARCHAR(32) | → product_subscription.id (可空,按量产品无订购) |
| user_id | VARCHAR(32) | 消费者用户ID |
| user_org_id | VARCHAR(32) | 消费者机构ID |
| product_resource_id | VARCHAR(32) | → product_resource.id |
| supplier_org_id | VARCHAR(32) | 实际供应商机构ID |
| resource_type | VARCHAR(32) | 资源类型 |
| resource_ref_id | VARCHAR(32) | 资源引用ID |
| used_amount | DOUBLE(15,4) | 消耗量 |
| used_unit | VARCHAR(32) | 消耗单位 |
| unit_cost | DOUBLE(15,8) | 单位成本(来自supplier_resource_price) |
| total_cost | DOUBLE(15,6) | 总成本 = used_amount × unit_cost |
| sell_price | DOUBLE(15,6) | 客户侧售价 |
| billing_mode | CHAR(1) | 1=配额内 2=超额按量 |
| source_ref_table | VARCHAR(64) | 来源表(如llmusage) |
| source_ref_id | VARCHAR(32) | 来源记录ID |
| use_time | DATETIME | 消费时间 |
| created_at | DATETIME | |
索引: idx_pul_product(product_id), idx_pul_subscription(subscription_id), idx_pul_user(user_id,user_org_id), idx_pul_supplier(supplier_org_id), idx_pul_time(use_time)
### 2.2 supplychain 新增表
#### supplier_resource_price (供应商资源定价表)
供应商对各资源的基准定价,是成本计算的源头。
| 字段 | 类型 | 说明 |
|------|------|------|
| id | VARCHAR(32) PK | 主键 |
| supplier_org_id | VARCHAR(32) | 供应商机构ID |
| resource_type | VARCHAR(32) | 资源类型 |
| resource_ref_id | VARCHAR(32) | 资源引用ID (如 llm.id) |
| resource_ref_name | VARCHAR(255) | 资源名称(冗余) |
| unit_price | DOUBLE(15,8) | 单位价格 |
| price_unit | VARCHAR(32) | 价格单位: per_1k_tokens/per_request/per_gpu_hour |
| input_price | DOUBLE(15,8) | 输入价格(LLM专用,可空) |
| output_price | DOUBLE(15,8) | 输出价格(LLM专用,可空) |
| currency | CHAR(8) | 货币 CNY/USD |
| effective_date | DATE | 生效日期 |
| expiry_date | DATE | 失效日期(可空=长期有效) |
| status | CHAR(1) | 1=有效 0=无效 |
| created_at | DATETIME | |
| updated_at | DATETIME | |
索引: idx_srp_supplier(supplier_org_id), idx_srp_resource(resource_type,resource_ref_id), idx_srp_dates(effective_date,expiry_date), UNIQUE idx_srp_unique(supplier_org_id,resource_type,resource_ref_id,effective_date)
---
## 三、appcodes 初始化数据
```
resource_type:
llm_model = 大模型按量
llm_monthly = 大模型包月
compute = 算力
quota_unit / price_unit:
tokens = tokens
requests = 次
gpu_hours = GPU时
per_1k_tokens = 每千tokens
per_request = 每次
per_gpu_hour = 每GPU时
subscription_status:
1 = 活跃
2 = 已过期
3 = 已取消
4 = 已超额(转按量)
overflow_mode:
1 = 转按量
2 = 停服
billing_mode:
1 = 配额内
2 = 超额按量
```
---
## 四、功能清单与实现逻辑
### 模块A: product_management (产品目录层)
#### A1. 产品资源绑定管理 (管理端)
| 功能 | API | 逻辑 |
|------|-----|------|
| 查看产品的资源绑定列表 | GET /api/product_resources?product_id=X | 查 product_resource WHERE product_id=X, JOIN product_resource_supplier 获取供应商列表 |
| 为产品绑定资源 | POST /api/product_resource_bind | 插入 product_resource + 校验 product 存在且属于当前 org |
| 解绑资源 | DELETE /api/product_resource_unbind?id=X | 删除 product_resource + 级联删除 product_resource_supplier |
| 添加供应商到资源绑定 | POST /api/resource_supplier_add | 插入 product_resource_supplier, 校验 supplier_org_id 在 supplychain.suppliers 中存在 |
| 移除供应商 | DELETE /api/resource_supplier_remove?id=X | 删除 product_resource_supplier |
| 调整供应商优先级 | PUT /api/resource_supplier_priority | 更新 priority/weight |
| 设置超额产品 | PUT /api/resource_overflow | 更新 product_resource.overflow_product_id |
#### A2. 客户订购 (管理端创建 + 客户端查看)
| 功能 | API | 逻辑 |
|------|-----|------|
| 创建订购(客户购买包月产品) | POST /api/subscribe | 1.校验产品存在+状态+日期范围 2.读取product_resource获取quota 3.插入product_subscription(status=1) 4.设置overflow_rate(从超额产品的price取) |
| 查看订购列表(管理端) | GET /api/subscriptions | 查 product_subscription, 可按user_id/product_id/status筛选 |
| 查看我的订购(客户端) | GET /api/my_subscriptions | 查 product_subscription WHERE user_id=当前用户 |
| 订购详情 | GET /api/subscription_detail?id=X | 返回订购信息 + 配额使用百分比 + 关联产品信息 |
| 取消订购 | PUT /api/subscription_cancel?id=X | status改为3, 停止配额使用 |
#### A3. 产品消费 (核心引擎)
| 功能 | API | 逻辑 |
|------|-----|------|
| 消费资源 | POST /api/product_use | **核心流程(见下方详细逻辑)** |
| 查看消费记录 | GET /api/usage_logs | 查 product_usage_log, 支持按product_id/user_id/日期/供应商筛选 |
| 消费统计 | GET /api/usage_stats | 聚合查询: 按产品/供应商/日期汇总消耗量和成本 |
| 配额检查 | GET /api/quota_check?subscription_id=X | 返回 quota_total/quota_used/remaining/percentage |
**product_use 核心流程:**
```
1. 接收参数: product_id, user_id, user_org_id, used_amount, used_unit,
source_ref_table, source_ref_id, resource_ref_id
2. 查找产品: product WHERE id=product_id AND status='1'
3. 判断产品类型:
IF product_type 是包月类(llm_monthly):
a. 查找活跃订购: product_subscription WHERE product_id AND user_id
AND status='1' AND start_date<=today AND end_date>=today
b. 如果有活跃订购:
- 计算剩余配额 = quota_total - quota_used
- IF used_amount <= 剩余:
billing_mode = '1'(配额内)
unit_cost = 0 (配额内不计单次成本)
更新 quota_used += used_amount
- ELSE (超额):
先用完配额: quota_used = quota_total
超额部分 = used_amount - 剩余
billing_mode = '2'(超额按量)
查找 overflow_product 获取 overflow_rate
sell_price = 超额部分 × overflow_rate
subscription.status = '4'(已超额)
c. 如果无活跃订购(已过期/未订购):
billing_mode = '2'
按产品自身 price 计算
ELSE (按量产品):
billing_mode = '2'
直接按量计费
4. 路由供应商(确定成本来源):
查找 product_resource WHERE product_id AND resource_ref_id
查找 product_resource_supplier WHERE product_resource_id AND status='1'
ORDER BY priority ASC, weight DESC
选择第一个可用供应商 → supplier_org_id
5. 计算成本:
查找 supplier_resource_price WHERE supplier_org_id AND resource_ref_id
AND effective_date<=today AND (expiry_date IS NULL OR expiry_date>=today)
unit_cost = supplier_resource_price.unit_price (或 input_price/output_price)
total_cost = used_amount × unit_cost
6. 计算售价(给客户的计费):
按量产品: sell_price = used_amount × product.price
包月超额: sell_price = 超额部分 × subscription.overflow_rate
7. 写入 product_usage_log
8. 返回: {success, billing_mode, used_amount, total_cost, sell_price,
supplier_org_id, remaining_quota}
```
#### A4. 产品目录展示 (面向客户端)
| 功能 | API | 逻辑 |
|------|-----|------|
| 分类树(客户端) | GET /api/public_categories | 现有 get_category_tree, 只返回 status='1' AND has_product='1' 的叶子节点 |
| 分类下产品列表 | GET /api/public_products?category_id=X | 现有 get_products_by_category, 返回产品摘要+价格+是否有资源绑定 |
| 产品详情 | GET /api/public_product_detail?id=X | 现有 get_product_detail, 增加资源绑定信息(绑定了几种资源,各多少配额) |
### 模块B: supplychain (供应链层)
#### B1. 供应商资源定价管理
| 功能 | API | 逻辑 |
|------|-----|------|
| 查看供应商资源定价列表 | GET /api/supplier_resource_prices | 查 supplier_resource_price, 可按supplier_org_id/resource_type筛选 |
| 创建/更新定价 | POST /api/supplier_resource_price_save | UPSERT supplier_resource_price, 校验supplier存在 |
| 删除定价(置为无效) | DELETE /api/supplier_resource_price_disable?id=X | status改为'0' |
| 批量导入定价 | POST /api/supplier_resource_price_import | 接收JSON数组,批量插入 |
| 查询指定资源的供应商价格 | GET /api/resource_supplier_cost?resource_type=X&resource_ref_id=Y | 返回所有供应商对该资源的定价,用于成本比较 |
#### B2. 消费成本对账 (连接 product_management 和 supplychain)
| 功能 | API | 逻辑 |
|------|-----|------|
| 按供应商汇总成本 | GET /api/cost_by_supplier | 跨库JOIN: product_usage_log GROUP BY supplier_org_id, SUM(total_cost) |
| 按产品汇总成本 | GET /api/cost_by_product | product_usage_log GROUP BY product_id |
| 生成销售记账条目 | POST /api/generate_sales_ledger | 从 product_usage_log 汇总后写入 sales_ledger (周期任务或手动触发) |
| 成本趋势 | GET /api/cost_trend | 按日期汇总成本, 最近30天 |
---
## 五、业务流程推演
### 场景1: 大模型按量产品
```
1. 供应商(OpenAI代理商)在 supplychain 注册,
在 supplier_resource_price 设置 GPT-4 价格:
input_price=0.03/1Ktokens, output_price=0.06/1Ktokens
2. 平台在 product_management 创建产品:
product: "GPT-4智能对话" product_type=llm_model price=0.05/1Ktokens
3. 产品绑定资源:
product_resource: resource_type=llm_model, resource_ref_id=llm.id(GPT-4)
product_resource_supplier: supplier_org_id=OpenAI代理商, priority=1
4. 客户调用GPT-4, 消耗2000 tokens:
product_use → 路由到OpenAI代理商
unit_cost = 0.03/1K × 2 = 0.06 (成本)
sell_price = 0.05/1K × 2 = 0.10 (售价)
写入 product_usage_log
5. 月末对账:
supplychain 从 product_usage_log 汇总:
供应商: OpenAI代理商, 总成本 = SUM(total_cost)
→ 写入 sales_ledger → 供应商结算
```
### 场景2: 大模型包月产品 + 超额转按量
```
1. 平台创建包月产品:
product: "AI助手月度套餐" product_type=llm_monthly price=99.00/月
2. 产品绑定资源:
product_resource: resource_type=llm_monthly, resource_ref_id=llm.id,
quota=1000000(100万tokens), quota_unit=tokens,
overflow_product_id=指向"GPT-4按量产品"
3. 绑定两个供应商(同资源多供应商):
product_resource_supplier:
- supplier_A: priority=1, weight=70
- supplier_B: priority=1, weight=30
4. 客户购买:
product_subscription: quota_total=1000000, start=2026-06-01, end=2026-06-30
5. 客户使用中(配额内):
每次调用 → billing_mode='1', 更新 quota_used
按 weight 比例分配供应商: 70%走A, 30%走B
成本分别按各供应商的 supplier_resource_price 计算
6. 配额用完(第25天用完100万tokens):
subscription.status → '4'(已超额)
后续调用 → billing_mode='2'
sell_price = 超额量 × overflow_rate(从overflow_product的price取)
total_cost = 超额量 × supplier_unit_price(实时成本)
7. 月末: 订购到期 → 定时任务将 status='1' 且 end_date<today 的改为 '2'(已过期)
客户可选择续费(创建新subscription)
```
### 场景3: 算力产品
```
1. 供应商设置GPU定价:
supplier_resource_price: resource_type=compute, resource_ref_id=gpu_a100,
unit_price=8.00/gpu_hour
2. 平台创建算力产品:
product: "A100 GPU租用" product_type=compute, price=12.00/gpu_hour
3. 客户购买后使用 10 GPU时:
total_cost = 10 × 8.00 = 80.00
sell_price = 10 × 12.00 = 120.00
profit = 40.00
```
### 场景4: 分销商定价
```
1. 平台(owner)创建产品和资源绑定
2. 分销商通过 supplychain.distribution_agreement_items 设置自己的折扣:
discount=0.85 → 客户按 85折 购买
sale_price = product.price × 0.85
3. 客户通过分销商购买:
售价 = product.price × distribution_discount
分销商成本 = product.price × supply_discount (从supplychain取)
利润 = 售价 - 成本
4. 对账时:
sales_ledger 记录 supply_amount(进货成本) + distribution_amount(分销收入) + profit_amount
```
---
## 六、UI 页面规划
### product_management (管理端)
| 页面 | 功能 |
|------|------|
| product_resource_list/index.ui | 选择产品 → 显示/管理该产品的资源绑定 |
| product_resource_list/bind_resource.ui | 绑定资源弹窗(选资源类型+引用+配额) |
| product_resource_list/supplier_bind.ui | 为资源绑定添加/管理供应商 |
| subscription_list/index.ui | 订购列表(筛选产品/用户/状态) |
| subscription_detail.ui | 订购详情(配额进度条+消费记录) |
### product_management (客户端/展示端)
| 页面 | 功能 |
|------|------|
| (复用现有) index.ui → category树 → product列表 | 展示产品目录 |
| product_detail.ui | 产品详情(含资源信息+价格+购买按钮) |
| my_subscriptions.ui | 我的订购(配额进度+消费记录) |
### supplychain (管理端)
| 页面 | 功能 |
|------|------|
| supplier_resource_price_list/index.ui | 供应商资源定价CRUD |
| cost_dashboard.ui | 成本分析看板(按供应商/产品/日期) |
---
## 七、跨模块数据访问
| 场景 | 读 | 写 |
|------|-----|-----|
| product_use计算成本 | supplychain.supplier_resource_price | product_management.product_usage_log, product_subscription |
| 供应商定价查询 | supplychain.supplier_resource_price | - |
| 生成sales_ledger | product_management.product_usage_log | supplychain.sales_ledger |
| 供应商列表(绑定供应商时) | supplychain.suppliers | - |
| LLM模型列表(绑定资源时) | llmage.llm | - |
---
## 八、定时任务
| 任务 | 周期 | 逻辑 |
|------|------|------|
| 订购过期检查 | 每日00:05 | product_subscription WHERE status='1' AND end_date<today status='2' |
| 成本汇总入账 | 每月1日 | 上月 product_usage_log 汇总 → 写入 sales_ledger |
---
## 九、检查清单
- [ ] product_resource model.json + DDL
- [ ] product_resource_supplier model.json + DDL
- [ ] product_subscription model.json + DDL
- [ ] product_usage_log model.json + DDL
- [ ] supplier_resource_price model.json + DDL (supplychain)
- [ ] appcodes init data 更新
- [ ] 资源绑定 CRUD APIs (6个)
- [ ] 订购 APIs (5个)
- [ ] 消费引擎 product_use (核心)
- [ ] 消费记录查询/统计 APIs (3个)
- [ ] 供应商资源定价 CRUD APIs (5个)
- [ ] 成本对账 APIs (4个)
- [ ] 管理端 UI (5个页面)
- [ ] 客户端 UI (2个页面)
- [ ] 供应商定价 UI (2个页面)
- [ ] 定时任务 (2个)
- [ ] 集成测试: 按量产品完整流程
- [ ] 集成测试: 包月产品完整流程(含超额转按量)
- [ ] 集成测试: 多供应商路由
- [ ] 集成测试: 分销商折扣

View File

@ -29,6 +29,41 @@
"id": "enabled_flg",
"name": "是否启用",
"hierarchy_flg": "0"
},
{
"id": "resource_type",
"name": "资源类型",
"hierarchy_flg": "0"
},
{
"id": "quota_unit",
"name": "配额单位",
"hierarchy_flg": "0"
},
{
"id": "price_unit",
"name": "价格单位",
"hierarchy_flg": "0"
},
{
"id": "subscription_type",
"name": "订购类型",
"hierarchy_flg": "0"
},
{
"id": "subscription_status",
"name": "订购状态",
"hierarchy_flg": "0"
},
{
"id": "overflow_mode",
"name": "超额模式",
"hierarchy_flg": "0"
},
{
"id": "billing_mode",
"name": "计费模式",
"hierarchy_flg": "0"
}
],
"appcodes_kv": [
@ -50,9 +85,39 @@
{"id": "product_type_data", "parentid": "product_type", "k": "data", "v": "数据服务"},
{"id": "product_type_api", "parentid": "product_type", "k": "api", "v": "API服务"},
{"id": "product_type_custom", "parentid": "product_type", "k": "custom", "v": "自定义"},
{"id": "product_type_llm_model", "parentid": "product_type", "k": "llm_model", "v": "大模型按量"},
{"id": "product_type_llm_monthly", "parentid": "product_type", "k": "llm_monthly", "v": "大模型包月"},
{"id": "product_type_compute", "parentid": "product_type", "k": "compute", "v": "算力"},
{"id": "enabled_flg_1", "parentid": "enabled_flg", "k": "1", "v": "启用"},
{"id": "enabled_flg_0", "parentid": "enabled_flg", "k": "0", "v": "禁用"}
{"id": "enabled_flg_0", "parentid": "enabled_flg", "k": "0", "v": "禁用"},
{"id": "resource_type_llm_model", "parentid": "resource_type", "k": "llm_model", "v": "大模型按量"},
{"id": "resource_type_llm_monthly", "parentid": "resource_type", "k": "llm_monthly", "v": "大模型包月"},
{"id": "resource_type_compute", "parentid": "resource_type", "k": "compute", "v": "算力"},
{"id": "quota_unit_tokens", "parentid": "quota_unit", "k": "tokens", "v": "tokens"},
{"id": "quota_unit_requests", "parentid": "quota_unit", "k": "requests", "v": "次"},
{"id": "quota_unit_gpu_hours", "parentid": "quota_unit", "k": "gpu_hours", "v": "GPU时"},
{"id": "price_unit_per_1k_tokens", "parentid": "price_unit", "k": "per_1k_tokens", "v": "每千tokens"},
{"id": "price_unit_per_request", "parentid": "price_unit", "k": "per_request", "v": "每次"},
{"id": "price_unit_per_gpu_hour", "parentid": "price_unit", "k": "per_gpu_hour", "v": "每GPU时"},
{"id": "subscription_type_1", "parentid": "subscription_type", "k": "1", "v": "包月"},
{"id": "subscription_type_2", "parentid": "subscription_type", "k": "2", "v": "包量"},
{"id": "subscription_type_3", "parentid": "subscription_type", "k": "3", "v": "一次性"},
{"id": "subscription_status_1", "parentid": "subscription_status", "k": "1", "v": "活跃"},
{"id": "subscription_status_2", "parentid": "subscription_status", "k": "2", "v": "已过期"},
{"id": "subscription_status_3", "parentid": "subscription_status", "k": "3", "v": "已取消"},
{"id": "subscription_status_4", "parentid": "subscription_status", "k": "4", "v": "已超额"},
{"id": "overflow_mode_1", "parentid": "overflow_mode", "k": "1", "v": "转按量"},
{"id": "overflow_mode_2", "parentid": "overflow_mode", "k": "2", "v": "停服"},
{"id": "billing_mode_1", "parentid": "billing_mode", "k": "1", "v": "配额内"},
{"id": "billing_mode_2", "parentid": "billing_mode", "k": "2", "v": "超额按量"}
],
"_note_product_category": "产品类别树由每个 reseller (org_id) 自行管理,不在 init/data.json 中预设全局数据。新机构注册时自动创建根类别。"
}

View File

@ -0,0 +1,7 @@
{
"product_resource": {
"params": {
"product_id": {"type": "str"}
}
}
}

View File

@ -0,0 +1,7 @@
{
"product_resource_supplier": {
"params": {
"product_resource_id": {"type": "str"}
}
}
}

View File

@ -0,0 +1,10 @@
{
"product_subscription": {
"params": {
"product_id": {"type": "str"},
"user_id": {"type": "str"},
"user_org_id": {"type": "str"},
"status": {"type": "str"}
}
}
}

View File

@ -0,0 +1,13 @@
{
"product_usage_log": {
"params": {
"product_id": {"type": "str"},
"subscription_id": {"type": "str"},
"user_id": {"type": "str"},
"supplier_org_id": {"type": "str"},
"billing_mode": {"type": "str"},
"start_date": {"type": "str"},
"end_date": {"type": "str"}
}
}
}

View File

@ -0,0 +1,58 @@
{
"summary": [
{
"name": "product_resource",
"title": "产品资源绑定表",
"primary": ["id"],
"catelog": "relation"
}
],
"fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "product_id", "title": "产品ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "resource_type", "title": "资源类型", "type": "str", "length": 32, "nullable": "no"},
{"name": "resource_ref_id", "title": "资源引用ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "resource_ref_name", "title": "资源显示名", "type": "str", "length": 255},
{"name": "quota", "title": "配额量", "type": "double", "length": 15, "dec": 4, "default": "0"},
{"name": "quota_unit", "title": "配额单位", "type": "str", "length": 32},
{"name": "priority", "title": "优先级", "type": "int", "default": "1"},
{"name": "overflow_product_id", "title": "超额后转用产品ID", "type": "str", "length": 32},
{"name": "status", "title": "状态", "type": "char", "length": 1, "default": "1"},
{"name": "created_at", "title": "创建时间", "type": "datetime", "nullable": "no"},
{"name": "updated_at", "title": "更新时间", "type": "datetime"}
],
"indexes": [
{"name": "idx_pr_product", "idxtype": "index", "idxfields": ["product_id"]},
{"name": "idx_pr_resource", "idxtype": "index", "idxfields": ["resource_type", "resource_ref_id"]},
{"name": "idx_pr_status", "idxtype": "index", "idxfields": ["status"]}
],
"codes": [
{
"field": "product_id",
"table": "product",
"valuefield": "id",
"textfield": "product_name"
},
{
"field": "resource_type",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='resource_type'"
},
{
"field": "quota_unit",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='quota_unit'"
},
{
"field": "status",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='product_status'"
}
]
}

View File

@ -0,0 +1,45 @@
{
"summary": [
{
"name": "product_resource_supplier",
"title": "产品资源供应商关联表",
"primary": ["id"],
"catelog": "relation"
}
],
"fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "product_resource_id", "title": "产品资源绑定ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "supplier_org_id", "title": "供应商机构ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "priority", "title": "优先级", "type": "int", "default": "1"},
{"name": "weight", "title": "权重", "type": "int", "default": "100"},
{"name": "status", "title": "状态", "type": "char", "length": 1, "default": "1"},
{"name": "created_at", "title": "创建时间", "type": "datetime", "nullable": "no"}
],
"indexes": [
{"name": "idx_prs_resource", "idxtype": "index", "idxfields": ["product_resource_id"]},
{"name": "idx_prs_supplier", "idxtype": "index", "idxfields": ["supplier_org_id"]},
{"name": "idx_prs_unique", "idxtype": "unique", "idxfields": ["product_resource_id", "supplier_org_id"]}
],
"codes": [
{
"field": "product_resource_id",
"table": "product_resource",
"valuefield": "id",
"textfield": "resource_ref_name"
},
{
"field": "supplier_org_id",
"table": "supplychain.suppliers",
"valuefield": "org_id",
"textfield": "supplier_name"
},
{
"field": "status",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='product_status'"
}
]
}

View File

@ -0,0 +1,83 @@
{
"summary": [
{
"name": "product_subscription",
"title": "客户订购表",
"primary": ["id"],
"catelog": "relation"
}
],
"fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "product_id", "title": "产品ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "user_id", "title": "客户用户ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "user_org_id", "title": "客户机构ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "subscription_type", "title": "订购类型", "type": "char", "length": 1, "nullable": "no"},
{"name": "status", "title": "状态", "type": "char", "length": 1, "nullable": "no", "default": "1"},
{"name": "start_date", "title": "生效日期", "type": "date", "nullable": "no"},
{"name": "end_date", "title": "到期日期", "type": "date", "nullable": "no"},
{"name": "quota_total", "title": "总配额", "type": "double", "length": 15, "dec": 4, "default": "0"},
{"name": "quota_used", "title": "已使用量", "type": "double", "length": 15, "dec": 4, "default": "0"},
{"name": "quota_unit", "title": "配额单位", "type": "str", "length": 32},
{"name": "overflow_mode", "title": "超额模式", "type": "char", "length": 1, "default": "1"},
{"name": "overflow_rate", "title": "超额单价", "type": "double", "length": 15, "dec": 6, "default": "0"},
{"name": "purchase_price", "title": "购买价格", "type": "double", "length": 15, "dec": 2, "default": "0"},
{"name": "purchase_currency", "title": "货币", "type": "char", "length": 8, "default": "CNY"},
{"name": "created_at", "title": "创建时间", "type": "datetime", "nullable": "no"},
{"name": "updated_at", "title": "更新时间", "type": "datetime"}
],
"indexes": [
{"name": "idx_ps_product", "idxtype": "index", "idxfields": ["product_id"]},
{"name": "idx_ps_user", "idxtype": "index", "idxfields": ["user_id", "user_org_id"]},
{"name": "idx_ps_status", "idxtype": "index", "idxfields": ["status"]},
{"name": "idx_ps_dates", "idxtype": "index", "idxfields": ["start_date", "end_date"]}
],
"codes": [
{
"field": "product_id",
"table": "product",
"valuefield": "id",
"textfield": "product_name"
},
{
"field": "user_id",
"table": "sage.users",
"valuefield": "id",
"textfield": "username"
},
{
"field": "user_org_id",
"table": "sage.organization",
"valuefield": "id",
"textfield": "orgname"
},
{
"field": "subscription_type",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='subscription_type'"
},
{
"field": "status",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='subscription_status'"
},
{
"field": "overflow_mode",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='overflow_mode'"
},
{
"field": "quota_unit",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='quota_unit'"
}
]
}

View File

@ -0,0 +1,67 @@
{
"summary": [
{
"name": "product_usage_log",
"title": "产品消费记录表",
"primary": ["id"],
"catelog": "relation"
}
],
"fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "product_id", "title": "产品ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "subscription_id", "title": "订购ID", "type": "str", "length": 32},
{"name": "user_id", "title": "消费者用户ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "user_org_id", "title": "消费者机构ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "product_resource_id", "title": "产品资源绑定ID", "type": "str", "length": 32},
{"name": "supplier_org_id", "title": "供应商机构ID", "type": "str", "length": 32},
{"name": "resource_type", "title": "资源类型", "type": "str", "length": 32},
{"name": "resource_ref_id", "title": "资源引用ID", "type": "str", "length": 32},
{"name": "used_amount", "title": "消耗量", "type": "double", "length": 15, "dec": 4, "nullable": "no"},
{"name": "used_unit", "title": "消耗单位", "type": "str", "length": 32},
{"name": "unit_cost", "title": "单位成本", "type": "double", "length": 15, "dec": 8, "default": "0"},
{"name": "total_cost", "title": "总成本", "type": "double", "length": 15, "dec": 6, "default": "0"},
{"name": "sell_price", "title": "客户售价", "type": "double", "length": 15, "dec": 6, "default": "0"},
{"name": "billing_mode", "title": "计费模式", "type": "char", "length": 1, "nullable": "no"},
{"name": "source_ref_table", "title": "来源表", "type": "str", "length": 64},
{"name": "source_ref_id", "title": "来源记录ID", "type": "str", "length": 32},
{"name": "use_time", "title": "消费时间", "type": "datetime", "nullable": "no"},
{"name": "created_at", "title": "创建时间", "type": "datetime", "nullable": "no"}
],
"indexes": [
{"name": "idx_pul_product", "idxtype": "index", "idxfields": ["product_id"]},
{"name": "idx_pul_subscription", "idxtype": "index", "idxfields": ["subscription_id"]},
{"name": "idx_pul_user", "idxtype": "index", "idxfields": ["user_id", "user_org_id"]},
{"name": "idx_pul_supplier", "idxtype": "index", "idxfields": ["supplier_org_id"]},
{"name": "idx_pul_time", "idxtype": "index", "idxfields": ["use_time"]},
{"name": "idx_pul_source", "idxtype": "index", "idxfields": ["source_ref_table", "source_ref_id"]}
],
"codes": [
{
"field": "product_id",
"table": "product",
"valuefield": "id",
"textfield": "product_name"
},
{
"field": "supplier_org_id",
"table": "supplychain.suppliers",
"valuefield": "org_id",
"textfield": "supplier_name"
},
{
"field": "resource_type",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='resource_type'"
},
{
"field": "billing_mode",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='billing_mode'"
}
]
}

View File

@ -128,3 +128,130 @@ CREATE INDEX product_type_config_idx_ptc_org_opr ON product_type_config(org_id,
CREATE UNIQUE INDEX product_type_config_idx_ptc_org_cat ON product_type_config(org_id,operator_id,category_id,config_name);
CREATE INDEX product_type_config_idx_ptc_enabled ON product_type_config(enabled_flg);
-- ./product_resource.json
drop table if exists product_resource;
CREATE TABLE product_resource
(
`id` VARCHAR(32) NOT NULL comment '主键ID',
`product_id` VARCHAR(32) NOT NULL comment '产品ID',
`resource_type` VARCHAR(32) NOT NULL comment '资源类型',
`resource_ref_id` VARCHAR(32) NOT NULL comment '资源引用ID',
`resource_ref_name` VARCHAR(255) comment '资源显示名',
`quota` double(15,4) DEFAULT '0' comment '配额量',
`quota_unit` VARCHAR(32) comment '配额单位',
`priority` int DEFAULT '1' comment '优先级',
`overflow_product_id` VARCHAR(32) comment '超额后转用产品ID',
`status` CHAR(1) DEFAULT '1' comment '状态',
`created_at` datetime NOT NULL comment '创建时间',
`updated_at` datetime comment '更新时间'
,primary key(id)
)
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci
engine=innodb
comment '产品资源绑定表'
;
CREATE INDEX product_resource_idx_pr_product ON product_resource(product_id);
CREATE INDEX product_resource_idx_pr_resource ON product_resource(resource_type,resource_ref_id);
CREATE INDEX product_resource_idx_pr_status ON product_resource(status);
-- ./product_resource_supplier.json
drop table if exists product_resource_supplier;
CREATE TABLE product_resource_supplier
(
`id` VARCHAR(32) NOT NULL comment '主键ID',
`product_resource_id` VARCHAR(32) NOT NULL comment '产品资源绑定ID',
`supplier_org_id` VARCHAR(32) NOT NULL comment '供应商机构ID',
`priority` int DEFAULT '1' comment '优先级',
`weight` int DEFAULT '100' comment '权重',
`status` CHAR(1) DEFAULT '1' comment '状态',
`created_at` datetime NOT NULL comment '创建时间'
,primary key(id)
)
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci
engine=innodb
comment '产品资源供应商关联表'
;
CREATE INDEX product_resource_supplier_idx_prs_resource ON product_resource_supplier(product_resource_id);
CREATE INDEX product_resource_supplier_idx_prs_supplier ON product_resource_supplier(supplier_org_id);
CREATE UNIQUE INDEX product_resource_supplier_idx_prs_unique ON product_resource_supplier(product_resource_id,supplier_org_id);
-- ./product_subscription.json
drop table if exists product_subscription;
CREATE TABLE product_subscription
(
`id` VARCHAR(32) NOT NULL comment '主键ID',
`product_id` VARCHAR(32) NOT NULL comment '产品ID',
`user_id` VARCHAR(32) NOT NULL comment '客户用户ID',
`user_org_id` VARCHAR(32) NOT NULL comment '客户机构ID',
`subscription_type` CHAR(1) NOT NULL comment '订购类型',
`status` CHAR(1) NOT NULL DEFAULT '1' comment '状态',
`start_date` date NOT NULL comment '生效日期',
`end_date` date NOT NULL comment '到期日期',
`quota_total` double(15,4) DEFAULT '0' comment '总配额',
`quota_used` double(15,4) DEFAULT '0' comment '已使用量',
`quota_unit` VARCHAR(32) comment '配额单位',
`overflow_mode` CHAR(1) DEFAULT '1' comment '超额模式',
`overflow_rate` double(15,6) DEFAULT '0' comment '超额单价',
`purchase_price` double(15,2) DEFAULT '0' comment '购买价格',
`purchase_currency` CHAR(8) DEFAULT 'CNY' comment '货币',
`created_at` datetime NOT NULL comment '创建时间',
`updated_at` datetime comment '更新时间'
,primary key(id)
)
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci
engine=innodb
comment '客户订购表'
;
CREATE INDEX product_subscription_idx_ps_product ON product_subscription(product_id);
CREATE INDEX product_subscription_idx_ps_user ON product_subscription(user_id,user_org_id);
CREATE INDEX product_subscription_idx_ps_status ON product_subscription(status);
CREATE INDEX product_subscription_idx_ps_dates ON product_subscription(start_date,end_date);
-- ./product_usage_log.json
drop table if exists product_usage_log;
CREATE TABLE product_usage_log
(
`id` VARCHAR(32) NOT NULL comment '主键ID',
`product_id` VARCHAR(32) NOT NULL comment '产品ID',
`subscription_id` VARCHAR(32) comment '订购ID',
`user_id` VARCHAR(32) NOT NULL comment '消费者用户ID',
`user_org_id` VARCHAR(32) NOT NULL comment '消费者机构ID',
`product_resource_id` VARCHAR(32) comment '产品资源绑定ID',
`supplier_org_id` VARCHAR(32) comment '供应商机构ID',
`resource_type` VARCHAR(32) comment '资源类型',
`resource_ref_id` VARCHAR(32) comment '资源引用ID',
`used_amount` double(15,4) NOT NULL comment '消耗量',
`used_unit` VARCHAR(32) comment '消耗单位',
`unit_cost` double(15,8) DEFAULT '0' comment '单位成本',
`total_cost` double(15,6) DEFAULT '0' comment '总成本',
`sell_price` double(15,6) DEFAULT '0' comment '客户售价',
`billing_mode` CHAR(1) NOT NULL comment '计费模式',
`source_ref_table` VARCHAR(64) comment '来源表',
`source_ref_id` VARCHAR(32) comment '来源记录ID',
`use_time` datetime NOT NULL comment '消费时间',
`created_at` datetime NOT NULL comment '创建时间'
,primary key(id)
)
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci
engine=innodb
comment '产品消费记录表'
;
CREATE INDEX product_usage_log_idx_pul_product ON product_usage_log(product_id);
CREATE INDEX product_usage_log_idx_pul_subscription ON product_usage_log(subscription_id);
CREATE INDEX product_usage_log_idx_pul_user ON product_usage_log(user_id,user_org_id);
CREATE INDEX product_usage_log_idx_pul_supplier ON product_usage_log(supplier_org_id);
CREATE INDEX product_usage_log_idx_pul_time ON product_usage_log(use_time);
CREATE INDEX product_usage_log_idx_pul_source ON product_usage_log(source_ref_table,source_ref_id);

View File

@ -490,3 +490,609 @@ class ProductManager:
'updated_at': now
})
return {'success': True, 'id': config_id, 'message': 'Config created'}
# ─── Resource Binding ───
async def bind_resource(self, product_id, resource_type, resource_ref_id,
resource_ref_name='', quota=0, quota_unit='',
overflow_product_id=None, org_id=None):
"""Bind a resource to a product."""
if not org_id:
org_id = self._get_current_org_id()
dbname = self._get_dbname()
now = time.strftime('%Y-%m-%d %H:%M:%S')
async with DBPools().sqlorContext(dbname) as sor:
# Verify product exists and belongs to org
prod = await sor.sqlExe(
"SELECT id FROM product WHERE id=${pid}$ AND org_id=${oid}$",
{'pid': product_id, 'oid': org_id})
if not prod:
return {'success': False, 'message': 'Product not found'}
rid = getID()
await sor.I('product_resource', {
'id': rid,
'product_id': product_id,
'resource_type': resource_type,
'resource_ref_id': resource_ref_id,
'resource_ref_name': resource_ref_name,
'quota': float(quota) if quota else 0,
'quota_unit': quota_unit,
'priority': 1,
'overflow_product_id': overflow_product_id or '',
'status': '1',
'created_at': now,
'updated_at': now
})
return {'success': True, 'id': rid}
async def unbind_resource(self, product_resource_id, org_id=None):
"""Unbind a resource from product (cascade delete suppliers)."""
if not org_id:
org_id = self._get_current_org_id()
dbname = self._get_dbname()
async with DBPools().sqlorContext(dbname) as sor:
# Verify ownership via product
check = await sor.sqlExe(
"""SELECT pr.id FROM product_resource pr
JOIN product p ON pr.product_id=p.id
WHERE pr.id=${id}$ AND p.org_id=${oid}$""",
{'id': product_resource_id, 'oid': org_id})
if not check:
return {'success': False, 'message': 'Resource binding not found'}
# Cascade delete suppliers
await sor.sqlExe(
"DELETE FROM product_resource_supplier WHERE product_resource_id=${id}$",
{'id': product_resource_id})
await sor.D('product_resource', {'id': product_resource_id})
return {'success': True}
async def get_product_resources(self, product_id, org_id=None):
"""Get all resource bindings for a product, with suppliers."""
if not org_id:
org_id = self._get_current_org_id()
dbname = self._get_dbname()
async with DBPools().sqlorContext(dbname) as sor:
resources = await sor.sqlExe(
"""SELECT pr.* FROM product_resource pr
JOIN product p ON pr.product_id=p.id
WHERE pr.product_id=${pid}$ AND p.org_id=${oid}$
ORDER BY pr.priority ASC""",
{'pid': product_id, 'oid': org_id})
resources = [dict(r) for r in (resources or [])]
# Attach suppliers for each resource
for res in resources:
suppliers = await sor.sqlExe(
"""SELECT prs.*, s.supplier_name
FROM product_resource_supplier prs
LEFT JOIN supplychain.suppliers s ON prs.supplier_org_id=s.org_id
WHERE prs.product_resource_id=${rid}$
ORDER BY prs.priority ASC, prs.weight DESC""",
{'rid': res['id']})
res['suppliers'] = [dict(s) for s in (suppliers or [])]
return {'success': True, 'resources': resources}
async def add_supplier_to_resource(self, product_resource_id, supplier_org_id,
priority=1, weight=100, org_id=None):
"""Add a supplier to a product resource binding."""
if not org_id:
org_id = self._get_current_org_id()
dbname = self._get_dbname()
now = time.strftime('%Y-%m-%d %H:%M:%S')
async with DBPools().sqlorContext(dbname) as sor:
# Verify resource belongs to org's product
check = await sor.sqlExe(
"""SELECT pr.id FROM product_resource pr
JOIN product p ON pr.product_id=p.id
WHERE pr.id=${rid}$ AND p.org_id=${oid}$""",
{'rid': product_resource_id, 'oid': org_id})
if not check:
return {'success': False, 'message': 'Resource binding not found'}
sid = getID()
try:
await sor.I('product_resource_supplier', {
'id': sid,
'product_resource_id': product_resource_id,
'supplier_org_id': supplier_org_id,
'priority': int(priority),
'weight': int(weight),
'status': '1',
'created_at': now
})
except Exception as e:
return {'success': False, 'message': f'Duplicate supplier: {e}'}
return {'success': True, 'id': sid}
async def remove_supplier_from_resource(self, prs_id, org_id=None):
"""Remove a supplier from a resource binding."""
if not org_id:
org_id = self._get_current_org_id()
dbname = self._get_dbname()
async with DBPools().sqlorContext(dbname) as sor:
check = await sor.sqlExe(
"""SELECT prs.id FROM product_resource_supplier prs
JOIN product_resource pr ON prs.product_resource_id=pr.id
JOIN product p ON pr.product_id=p.id
WHERE prs.id=${id}$ AND p.org_id=${oid}$""",
{'id': prs_id, 'oid': org_id})
if not check:
return {'success': False, 'message': 'Not found'}
await sor.D('product_resource_supplier', {'id': prs_id})
return {'success': True}
async def update_supplier_priority(self, prs_id, priority=None, weight=None, org_id=None):
"""Update supplier priority/weight."""
if not org_id:
org_id = self._get_current_org_id()
dbname = self._get_dbname()
async with DBPools().sqlorContext(dbname) as sor:
data = {}
if priority is not None:
data['priority'] = int(priority)
if weight is not None:
data['weight'] = int(weight)
if not data:
return {'success': False, 'message': 'No fields to update'}
await sor.U('product_resource_supplier', data, {'id': prs_id})
return {'success': True}
async def set_overflow_product(self, product_resource_id, overflow_product_id, org_id=None):
"""Set overflow product for a resource binding."""
if not org_id:
org_id = self._get_current_org_id()
dbname = self._get_dbname()
now = time.strftime('%Y-%m-%d %H:%M:%S')
async with DBPools().sqlorContext(dbname) as sor:
await sor.U('product_resource', {
'overflow_product_id': overflow_product_id or '',
'updated_at': now
}, {'id': product_resource_id})
return {'success': True}
# ─── Subscriptions ───
async def subscribe_product(self, product_id, user_id, user_org_id,
start_date, end_date, org_id=None):
"""Create a subscription for a monthly/quantity product."""
if not org_id:
org_id = self._get_current_org_id()
dbname = self._get_dbname()
now = time.strftime('%Y-%m-%d %H:%M:%S')
async with DBPools().sqlorContext(dbname) as sor:
# Get product
prod = await sor.sqlExe(
"SELECT * FROM product WHERE id=${pid}$ AND status='1'",
{'pid': product_id})
if not prod:
return {'success': False, 'message': 'Product not found'}
product = dict(prod[0])
# Get resource binding for quota
resources = await sor.sqlExe(
"SELECT * FROM product_resource WHERE product_id=${pid}$ AND status='1' LIMIT 1",
{'pid': product_id})
quota_total = 0
quota_unit = ''
overflow_product_id = ''
if resources:
res = dict(resources[0])
quota_total = float(res.get('quota', 0))
quota_unit = res.get('quota_unit', '')
overflow_product_id = res.get('overflow_product_id', '')
# Get overflow rate from overflow product's price
overflow_rate = 0
if overflow_product_id:
op = await sor.sqlExe(
"SELECT price FROM product WHERE id=${oid}$",
{'oid': overflow_product_id})
if op:
overflow_rate = float(op[0].get('price', 0))
sub_id = getID()
sub_type = '1' # monthly by default
if product.get('product_type') == 'llm_monthly':
sub_type = '1'
elif product.get('product_type') in ('llm_model', 'compute'):
sub_type = '2'
await sor.I('product_subscription', {
'id': sub_id,
'product_id': product_id,
'user_id': user_id,
'user_org_id': user_org_id,
'subscription_type': sub_type,
'status': '1',
'start_date': start_date,
'end_date': end_date,
'quota_total': quota_total,
'quota_used': 0,
'quota_unit': quota_unit,
'overflow_mode': '1',
'overflow_rate': overflow_rate,
'purchase_price': float(product.get('price', 0)),
'purchase_currency': product.get('currency', 'CNY'),
'created_at': now,
'updated_at': now
})
return {'success': True, 'id': sub_id, 'quota_total': quota_total,
'overflow_rate': overflow_rate}
async def get_subscriptions(self, filters=None, org_id=None):
"""List subscriptions with optional filters."""
if not org_id:
org_id = self._get_current_org_id()
dbname = self._get_dbname()
filters = filters or {}
conditions = []
ns = {}
if filters.get('product_id'):
conditions.append("ps.product_id=${product_id}$")
ns['product_id'] = filters['product_id']
if filters.get('user_id'):
conditions.append("ps.user_id=${user_id}$")
ns['user_id'] = filters['user_id']
if filters.get('user_org_id'):
conditions.append("ps.user_org_id=${uoid}$")
ns['uoid'] = filters['user_org_id']
if filters.get('status'):
conditions.append("ps.status=${status}$")
ns['status'] = filters['status']
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
async with DBPools().sqlorContext(dbname) as sor:
sql = f"""SELECT ps.*, p.product_name, p.product_type
FROM product_subscription ps
LEFT JOIN product p ON ps.product_id=p.id
{where}
ORDER BY ps.created_at DESC"""
rows = await sor.sqlExe(sql, ns)
return {'success': True, 'rows': [dict(r) for r in (rows or [])]}
async def get_subscription_detail(self, subscription_id):
"""Get subscription detail with quota usage."""
dbname = self._get_dbname()
async with DBPools().sqlorContext(dbname) as sor:
rows = await sor.sqlExe(
"""SELECT ps.*, p.product_name, p.product_type, p.brief_intro
FROM product_subscription ps
LEFT JOIN product p ON ps.product_id=p.id
WHERE ps.id=${sid}$""",
{'sid': subscription_id})
if not rows:
return {'success': False, 'message': 'Not found'}
sub = dict(rows[0])
total = float(sub.get('quota_total', 0))
used = float(sub.get('quota_used', 0))
remaining = max(0, total - used)
pct = round((used / total * 100), 2) if total > 0 else 0
sub['quota_remaining'] = remaining
sub['quota_percentage'] = pct
return {'success': True, 'data': sub}
async def cancel_subscription(self, subscription_id, org_id=None):
"""Cancel a subscription."""
dbname = self._get_dbname()
now = time.strftime('%Y-%m-%d %H:%M:%S')
async with DBPools().sqlorContext(dbname) as sor:
await sor.U('product_subscription', {
'status': '3', 'updated_at': now
}, {'id': subscription_id})
return {'success': True}
async def expire_subscriptions(self):
"""Batch expire: set status='2' for subscriptions past end_date."""
dbname = self._get_dbname()
today = datetime.date.today().isoformat()
now = time.strftime('%Y-%m-%d %H:%M:%S')
async with DBPools().sqlorContext(dbname) as sor:
result = await sor.sqlExe(
"""UPDATE product_subscription
SET status='2', updated_at=${now}$
WHERE status='1' AND end_date < ${today}$""",
{'today': today, 'now': now})
return {'success': True, 'expired_count': result}
# ─── Product Use Engine (CORE) ───
async def product_use(self, product_id, user_id, user_org_id,
used_amount, used_unit, resource_ref_id=None,
source_ref_table=None, source_ref_id=None):
"""Core consumption engine: route supplier, calc cost, log usage.
Flow:
1. Validate product
2. Check subscription (for monthly products)
3. Route to supplier (by priority/weight)
4. Calculate cost from supplier_resource_price
5. Calculate sell price
6. Write product_usage_log
7. Update subscription quota
"""
dbname = self._get_dbname()
now = time.strftime('%Y-%m-%d %H:%M:%S')
today = datetime.date.today().isoformat()
used_amount = float(used_amount)
async with DBPools().sqlorContext(dbname) as sor:
# Step 1: Get product
prod = await sor.sqlExe(
"SELECT * FROM product WHERE id=${pid}$ AND status='1'",
{'pid': product_id})
if not prod:
return {'success': False, 'message': 'Product not found or inactive'}
product = dict(prod[0])
product_type = product.get('product_type', '')
# Step 2: Check subscription for monthly products
subscription = None
billing_mode = '2' # default pay-per-use
subscription_id = None
sell_price = 0
remaining_quota = None
is_monthly = product_type in ('llm_monthly',)
if is_monthly:
subs = await sor.sqlExe(
"""SELECT * FROM product_subscription
WHERE product_id=${pid}$ AND user_id=${uid}$
AND user_org_id=${uoid}$ AND status='1'
AND start_date <= ${today}$ AND end_date >= ${today}$
ORDER BY created_at ASC LIMIT 1""",
{'pid': product_id, 'uid': user_id,
'uoid': user_org_id, 'today': today})
if subs:
subscription = dict(subs[0])
subscription_id = subscription['id']
quota_total = float(subscription.get('quota_total', 0))
quota_used = float(subscription.get('quota_used', 0))
remaining = quota_total - quota_used
if remaining >= used_amount:
# Within quota
billing_mode = '1'
remaining_quota = remaining - used_amount
# Update quota
await sor.U('product_subscription', {
'quota_used': quota_used + used_amount,
'updated_at': now
}, {'id': subscription_id})
else:
# Overflow: use up remaining, rest is overage
if remaining > 0:
await sor.U('product_subscription', {
'quota_used': quota_total,
'status': '4',
'updated_at': now
}, {'id': subscription_id})
billing_mode = '2'
overage = used_amount - remaining
else:
billing_mode = '2'
overage = used_amount
overflow_rate = float(subscription.get('overflow_rate', 0))
if overflow_rate <= 0:
overflow_rate = float(product.get('price', 0))
sell_price = overage * overflow_rate
remaining_quota = 0
if not is_monthly or billing_mode == '2':
# Pay-per-use: sell_price = amount × product price
if sell_price == 0:
sell_price = used_amount * float(product.get('price', 0))
# Step 3: Route supplier
supplier_org_id = None
product_resource_id = None
resource_type = ''
res_cond = "product_id=${pid}$ AND status='1'"
res_ns = {'pid': product_id}
if resource_ref_id:
res_cond += " AND resource_ref_id=${rrid}$"
res_ns['rrid'] = resource_ref_id
resources = await sor.sqlExe(
f"SELECT * FROM product_resource WHERE {res_cond} ORDER BY priority ASC",
res_ns)
if resources:
res = dict(resources[0])
product_resource_id = res['id']
resource_type = res.get('resource_type', '')
if not resource_ref_id:
resource_ref_id = res.get('resource_ref_id', '')
# Get suppliers ordered by priority, weight
suppliers = await sor.sqlExe(
"""SELECT * FROM product_resource_supplier
WHERE product_resource_id=${rid}$ AND status='1'
ORDER BY priority ASC, weight DESC""",
{'rid': product_resource_id})
if suppliers:
supplier_org_id = dict(suppliers[0]).get('supplier_org_id')
# Step 4: Calculate cost from supplier_resource_price
unit_cost = 0
total_cost = 0
if supplier_org_id and resource_ref_id:
sc_dbname = 'supplychain'
price_sql = (
"SELECT * FROM " + sc_dbname + ".supplier_resource_price"
" WHERE supplier_org_id=${soid}$"
" AND resource_ref_id=${rrid}$"
" AND status='1'"
" AND effective_date <= ${today}$"
" AND (expiry_date IS NULL OR expiry_date >= ${today}$)"
" ORDER BY effective_date DESC LIMIT 1"
)
price_rows = await sor.sqlExe(price_sql,
{'soid': supplier_org_id, 'rrid': resource_ref_id, 'today': today})
if price_rows:
pricing = dict(price_rows[0])
# For LLM: use input_price if available, else unit_price
if resource_type.startswith('llm'):
ip = pricing.get('input_price')
unit_cost = float(ip) if ip else float(pricing.get('unit_price', 0))
else:
unit_cost = float(pricing.get('unit_price', 0))
total_cost = used_amount * unit_cost
# Step 5: Write usage log
log_id = getID()
await sor.I('product_usage_log', {
'id': log_id,
'product_id': product_id,
'subscription_id': subscription_id or '',
'user_id': user_id,
'user_org_id': user_org_id,
'product_resource_id': product_resource_id or '',
'supplier_org_id': supplier_org_id or '',
'resource_type': resource_type,
'resource_ref_id': resource_ref_id or '',
'used_amount': used_amount,
'used_unit': used_unit,
'unit_cost': unit_cost,
'total_cost': total_cost,
'sell_price': sell_price,
'billing_mode': billing_mode,
'source_ref_table': source_ref_table or '',
'source_ref_id': source_ref_id or '',
'use_time': now,
'created_at': now
})
return {
'success': True,
'log_id': log_id,
'billing_mode': billing_mode,
'used_amount': used_amount,
'total_cost': round(total_cost, 6),
'sell_price': round(sell_price, 6),
'supplier_org_id': supplier_org_id,
'remaining_quota': remaining_quota
}
# ─── Usage Logs & Stats ───
async def get_usage_logs(self, filters=None, page=1, page_size=50):
"""Query usage logs with filters."""
dbname = self._get_dbname()
filters = filters or {}
page = int(page) or 1
page_size = int(page_size) or 50
conditions = []
ns = {}
for key in ('product_id', 'subscription_id', 'user_id', 'user_org_id',
'supplier_org_id', 'billing_mode', 'resource_type'):
if filters.get(key):
conditions.append(f"pul.{key}=${key}$")
ns[key] = filters[key]
if filters.get('start_date'):
conditions.append("pul.use_time >= ${start_date}$")
ns['start_date'] = filters['start_date']
if filters.get('end_date'):
conditions.append("pul.use_time <= ${end_date}$")
ns['end_date'] = filters['end_date']
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
offset = (page - 1) * page_size
async with DBPools().sqlorContext(dbname) as sor:
count_sql = f"SELECT COUNT(*) as cnt FROM product_usage_log pul {where}"
cnt_rows = await sor.sqlExe(count_sql, ns)
total = cnt_rows[0]['cnt'] if cnt_rows else 0
sql = f"""SELECT pul.*, p.product_name, s.supplier_name
FROM product_usage_log pul
LEFT JOIN product p ON pul.product_id=p.id
LEFT JOIN supplychain.suppliers s ON pul.supplier_org_id=s.org_id
{where}
ORDER BY pul.use_time DESC
LIMIT {page_size} OFFSET {offset}"""
rows = await sor.sqlExe(sql, ns)
return {'success': True, 'rows': [dict(r) for r in (rows or [])],
'total': total, 'page': page}
async def get_usage_stats(self, filters=None):
"""Aggregate usage stats by product/supplier/date."""
dbname = self._get_dbname()
filters = filters or {}
group_by = filters.get('group_by', 'product_id')
conditions = []
ns = {}
if filters.get('start_date'):
conditions.append("use_time >= ${start_date}$")
ns['start_date'] = filters['start_date']
if filters.get('end_date'):
conditions.append("use_time <= ${end_date}$")
ns['end_date'] = filters['end_date']
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
async with DBPools().sqlorContext(dbname) as sor:
sql = f"""SELECT {group_by},
COUNT(*) as usage_count,
SUM(used_amount) as total_used,
SUM(total_cost) as total_cost,
SUM(sell_price) as total_revenue,
SUM(sell_price) - SUM(total_cost) as total_profit
FROM product_usage_log
{where}
GROUP BY {group_by}
ORDER BY total_cost DESC"""
rows = await sor.sqlExe(sql, ns)
return {'success': True, 'stats': [dict(r) for r in (rows or [])]}
async def check_quota(self, subscription_id):
"""Check quota status for a subscription."""
dbname = self._get_dbname()
async with DBPools().sqlorContext(dbname) as sor:
rows = await sor.sqlExe(
"SELECT * FROM product_subscription WHERE id=${sid}$",
{'sid': subscription_id})
if not rows:
return {'success': False, 'message': 'Not found'}
sub = dict(rows[0])
total = float(sub.get('quota_total', 0))
used = float(sub.get('quota_used', 0))
remaining = max(0, total - used)
pct = round((used / total * 100), 2) if total > 0 else 0
return {
'success': True,
'quota_total': total,
'quota_used': used,
'quota_remaining': remaining,
'quota_percentage': pct,
'status': sub.get('status'),
'overflow_mode': sub.get('overflow_mode'),
'overflow_rate': float(sub.get('overflow_rate', 0))
}

View File

@ -91,6 +91,48 @@ PATHS_LOGINED = [
f"/{MOD}/product_type_config_list/add_product_type_config.dspy",
f"/{MOD}/product_type_config_list/update_product_type_config.dspy",
f"/{MOD}/product_type_config_list/delete_product_type_config.dspy",
# Resource binding
f"/{MOD}/product_resource_list",
f"/{MOD}/product_resource_list/index.ui",
f"/{MOD}/product_resource_list/get_product_resource.dspy",
f"/{MOD}/product_resource_list/add_product_resource.dspy",
f"/{MOD}/product_resource_list/update_product_resource.dspy",
f"/{MOD}/product_resource_list/delete_product_resource.dspy",
f"/{MOD}/product_resource_supplier_list",
f"/{MOD}/product_resource_supplier_list/index.ui",
f"/{MOD}/product_resource_supplier_list/get_product_resource_supplier.dspy",
f"/{MOD}/product_resource_supplier_list/add_product_resource_supplier.dspy",
f"/{MOD}/product_resource_supplier_list/update_product_resource_supplier.dspy",
f"/{MOD}/product_resource_supplier_list/delete_product_resource_supplier.dspy",
f"/{MOD}/api/product_resource_bind.dspy",
f"/{MOD}/api/product_resource_unbind.dspy",
f"/{MOD}/api/product_resources_list.dspy",
f"/{MOD}/api/resource_supplier_add.dspy",
f"/{MOD}/api/resource_supplier_remove.dspy",
f"/{MOD}/api/resource_supplier_priority.dspy",
f"/{MOD}/api/resource_overflow_set.dspy",
# Subscriptions
f"/{MOD}/product_subscription_list",
f"/{MOD}/product_subscription_list/index.ui",
f"/{MOD}/product_subscription_list/get_product_subscription.dspy",
f"/{MOD}/product_subscription_list/add_product_subscription.dspy",
f"/{MOD}/product_subscription_list/update_product_subscription.dspy",
f"/{MOD}/product_subscription_list/delete_product_subscription.dspy",
f"/{MOD}/api/subscribe_product.dspy",
f"/{MOD}/api/subscriptions_list.dspy",
f"/{MOD}/api/subscription_detail.dspy",
f"/{MOD}/api/subscription_cancel.dspy",
f"/{MOD}/api/quota_check.dspy",
# Usage logs
f"/{MOD}/product_usage_log_list",
f"/{MOD}/product_usage_log_list/index.ui",
f"/{MOD}/product_usage_log_list/get_product_usage_log.dspy",
f"/{MOD}/api/product_use_api.dspy",
f"/{MOD}/api/usage_logs.dspy",
f"/{MOD}/api/usage_stats.dspy",
]
# ============================================================

View File

@ -0,0 +1,17 @@
result = {'success': False, 'message': ''}
try:
mgr = ProductManager()
r = await mgr.bind_resource(
product_id=params_kw.get('product_id', ''),
resource_type=params_kw.get('resource_type', ''),
resource_ref_id=params_kw.get('resource_ref_id', ''),
resource_ref_name=params_kw.get('resource_ref_name', ''),
quota=params_kw.get('quota', 0),
quota_unit=params_kw.get('quota_unit', ''),
overflow_product_id=params_kw.get('overflow_product_id', '')
)
result = r
except Exception as e:
result['message'] = str(e)
debug(format_exc())
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,11 @@
result = {'success': False, 'message': ''}
try:
mgr = ProductManager()
r = await mgr.unbind_resource(
product_resource_id=params_kw.get('product_resource_id', '')
)
result = r
except Exception as e:
result['message'] = str(e)
debug(format_exc())
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,11 @@
result = {'success': False, 'resources': []}
try:
mgr = ProductManager()
r = await mgr.get_product_resources(
product_id=params_kw.get('product_id', '')
)
result = r
except Exception as e:
result['error'] = str(e)
debug(format_exc())
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,18 @@
result = {'success': False, 'message': ''}
try:
mgr = ProductManager()
r = await mgr.product_use(
product_id=params_kw.get('product_id', ''),
user_id=params_kw.get('user_id', ''),
user_org_id=params_kw.get('user_org_id', ''),
used_amount=params_kw.get('used_amount', 0),
used_unit=params_kw.get('used_unit', ''),
resource_ref_id=params_kw.get('resource_ref_id', ''),
source_ref_table=params_kw.get('source_ref_table', ''),
source_ref_id=params_kw.get('source_ref_id', '')
)
result = r
except Exception as e:
result['message'] = str(e)
debug(format_exc())
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,11 @@
result = {'success': False}
try:
mgr = ProductManager()
r = await mgr.check_quota(
subscription_id=params_kw.get('subscription_id', '')
)
result = r
except Exception as e:
result['error'] = str(e)
debug(format_exc())
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,12 @@
result = {'success': False, 'message': ''}
try:
mgr = ProductManager()
r = await mgr.set_overflow_product(
product_resource_id=params_kw.get('product_resource_id', ''),
overflow_product_id=params_kw.get('overflow_product_id', '')
)
result = r
except Exception as e:
result['message'] = str(e)
debug(format_exc())
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,14 @@
result = {'success': False, 'message': ''}
try:
mgr = ProductManager()
r = await mgr.add_supplier_to_resource(
product_resource_id=params_kw.get('product_resource_id', ''),
supplier_org_id=params_kw.get('supplier_org_id', ''),
priority=params_kw.get('priority', 1),
weight=params_kw.get('weight', 100)
)
result = r
except Exception as e:
result['message'] = str(e)
debug(format_exc())
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,13 @@
result = {'success': False, 'message': ''}
try:
mgr = ProductManager()
r = await mgr.update_supplier_priority(
prs_id=params_kw.get('id', ''),
priority=params_kw.get('priority'),
weight=params_kw.get('weight')
)
result = r
except Exception as e:
result['message'] = str(e)
debug(format_exc())
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,11 @@
result = {'success': False, 'message': ''}
try:
mgr = ProductManager()
r = await mgr.remove_supplier_from_resource(
prs_id=params_kw.get('id', '')
)
result = r
except Exception as e:
result['message'] = str(e)
debug(format_exc())
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,15 @@
result = {'success': False, 'message': ''}
try:
mgr = ProductManager()
r = await mgr.subscribe_product(
product_id=params_kw.get('product_id', ''),
user_id=params_kw.get('user_id', ''),
user_org_id=params_kw.get('user_org_id', ''),
start_date=params_kw.get('start_date', ''),
end_date=params_kw.get('end_date', '')
)
result = r
except Exception as e:
result['message'] = str(e)
debug(format_exc())
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,11 @@
result = {'success': False, 'message': ''}
try:
mgr = ProductManager()
r = await mgr.cancel_subscription(
subscription_id=params_kw.get('id', '')
)
result = r
except Exception as e:
result['message'] = str(e)
debug(format_exc())
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,11 @@
result = {'success': False, 'data': {}}
try:
mgr = ProductManager()
r = await mgr.get_subscription_detail(
subscription_id=params_kw.get('id', '')
)
result = r
except Exception as e:
result['error'] = str(e)
debug(format_exc())
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,14 @@
result = {'success': False, 'rows': []}
try:
mgr = ProductManager()
r = await mgr.get_subscriptions(filters={
'product_id': params_kw.get('product_id', ''),
'user_id': params_kw.get('user_id', ''),
'user_org_id': params_kw.get('user_org_id', ''),
'status': params_kw.get('status', '')
})
result = r
except Exception as e:
result['error'] = str(e)
debug(format_exc())
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,21 @@
result = {'success': False, 'rows': [], 'total': 0}
try:
mgr = ProductManager()
r = await mgr.get_usage_logs(
filters={
'product_id': params_kw.get('product_id', ''),
'subscription_id': params_kw.get('subscription_id', ''),
'user_id': params_kw.get('user_id', ''),
'supplier_org_id': params_kw.get('supplier_org_id', ''),
'billing_mode': params_kw.get('billing_mode', ''),
'start_date': params_kw.get('start_date', ''),
'end_date': params_kw.get('end_date', '')
},
page=params_kw.get('page', 1),
page_size=params_kw.get('page_size', 50)
)
result = r
except Exception as e:
result['error'] = str(e)
debug(format_exc())
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,13 @@
result = {'success': False, 'stats': []}
try:
mgr = ProductManager()
r = await mgr.get_usage_stats(filters={
'group_by': params_kw.get('group_by', 'product_id'),
'start_date': params_kw.get('start_date', ''),
'end_date': params_kw.get('end_date', '')
})
result = r
except Exception as e:
result['error'] = str(e)
debug(format_exc())
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,82 @@
{
"widgettype": "VBox",
"options": {"width": "100%", "padding": "8px", "gap": "8px"},
"subwidgets": [
{
"widgettype": "InlineForm",
"id": "filter_form",
"options": {
"css": "card", "padding": "8px",
"submit_label": "查询",
"fields": [
{"name": "product_id", "label": "产品ID", "uitype": "str", "cwidth": 16}
]
},
"binds": [{
"wid": "self", "event": "submit",
"actiontype": "script", "target": "res_table",
"script": "var tbl = bricks.getWidgetById('res_table', bricks.app.root); if(tbl) await tbl.render(params);"
}]
},
{
"widgettype": "Tabular",
"id": "res_table",
"options": {
"width": "100%", "css": "card",
"data_url": "{{entire_url('/product_management/product_resource_list/get_product_resource.dspy')}}",
"data_method": "GET", "page_rows": 20,
"toolbar": {
"tools": [
{"name": "add_resource", "label": "绑定资源"},
{"name": "manage_suppliers", "label": "管理供应商", "selected_row": true},
{"name": "set_overflow", "label": "设置超额产品", "selected_row": true}
]
},
"row_options": {
"browserfields": {
"exclouded": ["id", "created_at", "updated_at"],
"alters": {
"product_id": {
"uitype": "code", "valueField": "product_id", "textField": "product_id_text",
"params": {"dbname": "product_management", "table": "product", "tblvalue": "id", "tbltext": "product_name", "valueField": "product_id", "textField": "product_id_text"},
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
},
"resource_type": {"uitype": "code", "data": [{"value": "llm_model", "text": "大模型按量"}, {"value": "llm_monthly", "text": "大模型包月"}, {"value": "compute", "text": "算力"}]},
"quota_unit": {"uitype": "code", "data": [{"value": "tokens", "text": "tokens"}, {"value": "requests", "text": "次"}, {"value": "gpu_hours", "text": "GPU时"}]},
"status": {"uitype": "code", "data": [{"value": "1", "text": "启用"}, {"value": "0", "text": "禁用"}]}
}
},
"fields": [
{"name": "product_id", "title": "产品", "type": "str", "length": 32, "cwidth": 14},
{"name": "resource_type", "title": "资源类型", "type": "str", "length": 32, "cwidth": 10},
{"name": "resource_ref_id", "title": "资源引用ID", "type": "str", "length": 32, "cwidth": 14},
{"name": "resource_ref_name", "title": "资源名称", "type": "str", "length": 255, "cwidth": 14},
{"name": "quota", "title": "配额", "type": "double", "length": 15, "dec": 4, "cwidth": 10},
{"name": "quota_unit", "title": "单位", "type": "str", "length": 32, "cwidth": 8},
{"name": "overflow_product_id", "title": "超额产品", "type": "str", "length": 32, "cwidth": 14},
{"name": "status", "title": "状态", "type": "char", "length": 1, "cwidth": 6}
]
}
},
"binds": [
{
"wid": "self", "event": "add_resource",
"actiontype": "urlwidget", "target": "PopupWindow",
"popup_options": {"title": "绑定资源", "cwidth": 40, "cheight": 25},
"options": {"url": "{{entire_url('/product_management/product_resource_list/add_product_resource.dspy')}}"}
},
{
"wid": "self", "event": "manage_suppliers",
"actiontype": "urlwidget", "target": "app.sage_main_content",
"options": {"url": "{{entire_url('/product_management/product_resource_supplier_list/index.ui')}}?product_resource_id=${id}$"},
"mode": "replace"
},
{
"wid": "self", "event": "set_overflow",
"actiontype": "script", "target": "self",
"script": "var dv = bricks.getWidgetById('res_table', bricks.app.root); if(!dv || !dv.select_row) { alert('请先选中一条记录'); return; } var row = dv.select_row.user_data; var pid = prompt('输入超额产品ID:'); if(!pid) return; var resp = await fetch('{{entire_url('/product_management/api/resource_overflow_set.dspy')}}?product_resource_id=' + row.id + '&overflow_product_id=' + pid); var d = await resp.json(); alert(d.success ? '设置成功' : '失败: ' + d.message); if(d.success) await dv.render({});"
}
]
}
]
}

View File

@ -0,0 +1,46 @@
{
"widgettype": "VBox",
"options": {"width": "100%", "padding": "8px", "gap": "8px"},
"subwidgets": [
{
"widgettype": "Tabular",
"id": "supplier_table",
"options": {
"width": "100%", "css": "card",
"data_url": "{{entire_url('/product_management/product_resource_supplier_list/get_product_resource_supplier.dspy')}}",
"data_method": "GET", "page_rows": 20,
"toolbar": {
"tools": [
{"name": "add_supplier", "label": "添加供应商"}
]
},
"row_options": {
"browserfields": {
"exclouded": ["id", "created_at"],
"alters": {
"supplier_org_id": {
"uitype": "code", "valueField": "supplier_org_id", "textField": "supplier_org_id_text",
"params": {"dbname": "supplychain", "table": "suppliers", "tblvalue": "org_id", "tbltext": "supplier_name", "valueField": "supplier_org_id", "textField": "supplier_org_id_text"},
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
},
"status": {"uitype": "code", "data": [{"value": "1", "text": "启用"}, {"value": "0", "text": "禁用"}]}
}
},
"fields": [
{"name": "product_resource_id", "title": "资源绑定ID", "type": "str", "length": 32, "cwidth": 16},
{"name": "supplier_org_id", "title": "供应商", "type": "str", "length": 32, "cwidth": 16},
{"name": "priority", "title": "优先级", "type": "int", "cwidth": 8},
{"name": "weight", "title": "权重", "type": "int", "cwidth": 8},
{"name": "status", "title": "状态", "type": "char", "length": 1, "cwidth": 6}
]
}
},
"binds": [{
"wid": "self", "event": "add_supplier",
"actiontype": "urlwidget", "target": "PopupWindow",
"popup_options": {"title": "添加供应商", "cwidth": 40, "cheight": 20},
"options": {"url": "{{entire_url('/product_management/product_resource_supplier_list/add_product_resource_supplier.dspy')}}"}
}]
}
]
}

View File

@ -0,0 +1,78 @@
{
"widgettype": "VBox",
"options": {"width": "100%", "padding": "8px", "gap": "8px"},
"subwidgets": [
{
"widgettype": "InlineForm",
"id": "sub_filter",
"options": {
"css": "card", "padding": "8px", "submit_label": "查询",
"fields": [
{"name": "user_id", "label": "用户ID", "uitype": "str", "cwidth": 12},
{"name": "status", "label": "状态", "uitype": "code", "cwidth": 8,
"data": [{"value": "", "text": "全部"}, {"value": "1", "text": "活跃"}, {"value": "2", "text": "已过期"}, {"value": "3", "text": "已取消"}, {"value": "4", "text": "已超额"}]}
]
},
"binds": [{
"wid": "self", "event": "submit",
"actiontype": "script", "target": "sub_table",
"script": "var tbl = bricks.getWidgetById('sub_table', bricks.app.root); if(tbl) await tbl.render(params);"
}]
},
{
"widgettype": "Tabular",
"id": "sub_table",
"options": {
"width": "100%", "css": "card",
"data_url": "{{entire_url('/product_management/product_subscription_list/get_product_subscription.dspy')}}",
"data_method": "GET", "page_rows": 20,
"toolbar": {
"tools": [
{"name": "view_detail", "label": "查看详情", "selected_row": true},
{"name": "cancel_sub", "label": "取消订购", "selected_row": true}
]
},
"row_options": {
"browserfields": {
"exclouded": ["id", "created_at", "updated_at"],
"alters": {
"product_id": {
"uitype": "code", "valueField": "product_id", "textField": "product_id_text",
"params": {"dbname": "product_management", "table": "product", "tblvalue": "id", "tbltext": "product_name", "valueField": "product_id", "textField": "product_id_text"},
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
},
"subscription_type": {"uitype": "code", "data": [{"value": "1", "text": "包月"}, {"value": "2", "text": "包量"}, {"value": "3", "text": "一次性"}]},
"status": {"uitype": "code", "data": [{"value": "1", "text": "活跃"}, {"value": "2", "text": "已过期"}, {"value": "3", "text": "已取消"}, {"value": "4", "text": "已超额"}]},
"overflow_mode": {"uitype": "code", "data": [{"value": "1", "text": "转按量"}, {"value": "2", "text": "停服"}]}
}
},
"fields": [
{"name": "product_id", "title": "产品", "type": "str", "length": 32, "cwidth": 14},
{"name": "user_id", "title": "用户ID", "type": "str", "length": 32, "cwidth": 12},
{"name": "subscription_type", "title": "类型", "type": "char", "length": 1, "cwidth": 6},
{"name": "status", "title": "状态", "type": "char", "length": 1, "cwidth": 6},
{"name": "start_date", "title": "开始", "type": "date", "cwidth": 10},
{"name": "end_date", "title": "结束", "type": "date", "cwidth": 10},
{"name": "quota_total", "title": "总配额", "type": "double", "length": 15, "dec": 4, "cwidth": 10},
{"name": "quota_used", "title": "已用", "type": "double", "length": 15, "dec": 4, "cwidth": 10},
{"name": "overflow_mode", "title": "超额", "type": "char", "length": 1, "cwidth": 6},
{"name": "purchase_price", "title": "购买价", "type": "double", "length": 15, "dec": 2, "cwidth": 8}
]
}
},
"binds": [
{
"wid": "self", "event": "view_detail",
"actiontype": "urlwidget", "target": "PopupWindow",
"popup_options": {"title": "订购详情", "cwidth": 40, "cheight": 20},
"options": {"url": "{{entire_url('/product_management/api/subscription_detail.dspy')}}?id=${id}$"}
},
{
"wid": "self", "event": "cancel_sub",
"actiontype": "script", "target": "self",
"script": "var dv = bricks.getWidgetById('sub_table', bricks.app.root); if(!dv || !dv.select_row) { alert('请先选中一条记录'); return; } var row = dv.select_row.user_data; if(!confirm('确认取消此订购?')) return; var resp = await fetch('{{entire_url('/product_management/api/subscription_cancel.dspy')}}?id=' + row.id); var d = await resp.json(); alert(d.success ? '已取消' : '失败: ' + d.message); if(d.success) await dv.render({});"
}
]
}
]
}

View File

@ -0,0 +1,67 @@
{
"widgettype": "VBox",
"options": {"width": "100%", "padding": "8px", "gap": "8px"},
"subwidgets": [
{
"widgettype": "InlineForm",
"id": "log_filter",
"options": {
"css": "card", "padding": "8px", "submit_label": "查询",
"fields": [
{"name": "product_id", "label": "产品ID", "uitype": "str", "cwidth": 10},
{"name": "supplier_org_id", "label": "供应商ID", "uitype": "str", "cwidth": 10},
{"name": "billing_mode", "label": "计费模式", "uitype": "code", "cwidth": 8,
"data": [{"value": "", "text": "全部"}, {"value": "1", "text": "配额内"}, {"value": "2", "text": "超额按量"}]},
{"name": "start_date", "label": "开始日期", "uitype": "date", "cwidth": 10},
{"name": "end_date", "label": "结束日期", "uitype": "date", "cwidth": 10}
]
},
"binds": [{
"wid": "self", "event": "submit",
"actiontype": "script", "target": "log_table",
"script": "var tbl = bricks.getWidgetById('log_table', bricks.app.root); if(tbl) await tbl.render(params);"
}]
},
{
"widgettype": "Tabular",
"id": "log_table",
"options": {
"width": "100%", "css": "card",
"data_url": "{{entire_url('/product_management/product_usage_log_list/get_product_usage_log.dspy')}}",
"data_method": "GET", "page_rows": 20,
"row_options": {
"browserfields": {
"exclouded": ["id", "created_at", "source_ref_table", "source_ref_id", "product_resource_id"],
"alters": {
"product_id": {
"uitype": "code", "valueField": "product_id", "textField": "product_id_text",
"params": {"dbname": "product_management", "table": "product", "tblvalue": "id", "tbltext": "product_name", "valueField": "product_id", "textField": "product_id_text"},
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
},
"supplier_org_id": {
"uitype": "code", "valueField": "supplier_org_id", "textField": "supplier_org_id_text",
"params": {"dbname": "supplychain", "table": "suppliers", "tblvalue": "org_id", "tbltext": "supplier_name", "valueField": "supplier_org_id", "textField": "supplier_org_id_text"},
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
},
"resource_type": {"uitype": "code", "data": [{"value": "llm_model", "text": "大模型按量"}, {"value": "llm_monthly", "text": "大模型包月"}, {"value": "compute", "text": "算力"}]},
"billing_mode": {"uitype": "code", "data": [{"value": "1", "text": "配额内"}, {"value": "2", "text": "超额按量"}]}
}
},
"fields": [
{"name": "product_id", "title": "产品", "type": "str", "length": 32, "cwidth": 12},
{"name": "user_id", "title": "用户", "type": "str", "length": 32, "cwidth": 10},
{"name": "supplier_org_id", "title": "供应商", "type": "str", "length": 32, "cwidth": 12},
{"name": "resource_type", "title": "资源类型", "type": "str", "length": 32, "cwidth": 8},
{"name": "used_amount", "title": "消耗量", "type": "double", "length": 15, "dec": 4, "cwidth": 8},
{"name": "used_unit", "title": "单位", "type": "str", "length": 32, "cwidth": 6},
{"name": "unit_cost", "title": "单位成本", "type": "double", "length": 15, "dec": 8, "cwidth": 10},
{"name": "total_cost", "title": "总成本", "type": "double", "length": 15, "dec": 6, "cwidth": 8},
{"name": "sell_price", "title": "售价", "type": "double", "length": 15, "dec": 6, "cwidth": 8},
{"name": "billing_mode", "title": "计费", "type": "char", "length": 1, "cwidth": 6},
{"name": "use_time", "title": "消费时间", "type": "datetime", "cwidth": 14}
]
}
}
}
]
}