first commit

This commit is contained in:
yumoqing 2025-12-05 18:03:14 +08:00
commit ebd1b00ddd
19 changed files with 594 additions and 0 deletions

21
README.md Normal file
View File

@ -0,0 +1,21 @@
# mymodule-product
产品模块(产品分类 / 产品管理 / 产品-能力绑定 / 定价策略)
## 功能
- 树形产品分类管理CRUD
- 产品 CRUD包含默认定价、meta、view_schema
- 产品与能力绑定N:N
- 产品定价策略(产品层覆盖能力定价)
- 产品上下线draft/online/offline
## 使用
1. 将 `models/*.json` 转换为数据库表(可用迁移脚本)。
2. 导入 `init/data.json`
3. 在 `ahserver` 启动时调用 `mymodule.init.setup_app(app)` 注册服务。
4. 路由示例见 `mymodule/init.py` 注释。
## 注意
- 示例代码使用 `sqlor` 的接口sor.C/U/R/D/sqlExe
- 前端 UI 为伪代码(`wwwroot/*.ui`),可用 bricks 渲染。

62
init/data.json Normal file
View File

@ -0,0 +1,62 @@
{
"product_category": [
{"id":"cat_llm","parent_id": null, "name":"大模型产品"},
{"id":"cat_compute","parent_id": null, "name":"算力资源"},
{"id":"cat_service","parent_id": null, "name":"技术服务"}
],
"capability": [
{
"id": "cap_llm_qwen72b",
"name": "Qwen-72B",
"kind": "llm",
"base_pricing": "{\"input_token\":0.00001, \"output_token\":0.00002}",
"meta": "{\"endpoint\":\"http://llm-svc.local/v1/chat\",\"model\":\"qwen-72b\"}"
},
{
"id": "cap_gpu_a100",
"name": "A100-80G 池",
"kind": "compute",
"base_pricing": "{\"hourly\":18.5}",
"meta": "{\"pool\":\"gpu_a100_pool\"}"
},
{
"id": "cap_rag_milvus",
"name": "Milvus RAG",
"kind": "rag",
"base_pricing": "{\"per_query\":0.002}",
"meta": "{\"endpoint\":\"http://rag.local/query\"}"
}
],
"product": [
{
"id":"prod_qwen_public",
"category_id":"cat_llm",
"name":"Qwen 公有云对话",
"code":"qwen_public",
"type":"LLM_CHAT",
"summary":"Qwen-72B 在线对话服务",
"status":"online",
"default_pricing":"{\"input_token\":0.00001, \"output_token\":0.00002}",
"meta":"{\"view_schema\":\"chat_view\",\"executor\":{\"type\":\"LLMExecutor\",\"capability_id\":\"cap_llm_qwen72b\"}}"
},
{
"id":"prod_a100_hour",
"category_id":"cat_compute",
"name":"A100 小时租用",
"code":"gpu_a100_hour",
"type":"COMPUTE_RESOURCE",
"summary":"按小时租用 A100 资源",
"status":"online",
"meta":"{\"view_schema\":\"compute_detail\",\"executor\":{\"type\":\"ComputeExecutor\",\"capability_id\":\"cap_gpu_a100\"}}"
}
],
"product_capability": [
{"id":"bind_qwen_prod", "product_id":"prod_qwen_public", "capability_id":"cap_llm_qwen72b", "config":"{}"},
{"id":"bind_a100_prod", "product_id":"prod_a100_hour", "capability_id":"cap_gpu_a100", "config":"{}"}
],
"pricing_plan": [
{"id":"plan_qwen_payg","product_id":"prod_qwen_public","name":"按量计费","type":"payg","plan_detail":"{\"input_token\":0.00001,\"output_token\":0.00002}","is_default":1},
{"id":"plan_a100_hour","product_id":"prod_a100_hour","name":"按小时计费","type":"payg","plan_detail":"{\"hourly\":18.5}","is_default":1}
]
}

18
init/script.py Normal file
View File

@ -0,0 +1,18 @@
# init/script.py
import json
from sqlor.dbpools import DBPools
from ahserver.serverenv import ServerEnv
async def init_db():
db = DBPools()
env = ServerEnv()
dbname = env.get_module_dbname()
async with db.sqlorContext(dbname) as sor:
# 这里假设表已由迁移工具建立;这个脚本负责导入初始数据
data = json.load(open('init/data.json','r',encoding='utf8'))
for table, rows in data.items():
for r in rows:
# 注意字段类型转换
await sor.C(table, r)
return True

24
json/capability.crud.json Normal file
View File

@ -0,0 +1,24 @@
{
"tblname": "capability",
"alias": "capability",
"title": "能力管理",
"params": {
"sortby": ["name"],
"browserfields": {
"exclouded": ["meta", "orgid", "base_pricing"]
},
"editexclouded": ["created_at", "orgid", "updated_at"],
"subtables": [
{
"field": "capability_id",
"title": "绑定的产品",
"subtable": "product_capability",
"url": "{{entire_url('product_capability')}}"
}
]
}
}

View File

@ -0,0 +1,19 @@
{
"tblname": "pricing_plan",
"alias": "pricing_plan",
"title": "定价策略",
"params": {
"sortby": ["created_at desc"],
"browserfields": {
"exclouded": ["orgid", "plan_detail"]
},
"editexclouded": ["orgid", "created_at", "updated_at"],
"editor": {
"binds": []
}
}
}

40
json/product.crud.json Normal file
View File

@ -0,0 +1,40 @@
{
"tblname": "product",
"alias": "product",
"title": "产品管理",
"params": {
"sortby": ["updated_at desc"],
"browserfields": {
"exclouded": ["orgid", "detail", "meta"],
"alters": {
"status": {
"uitype": "code",
"data": [
{"value": "draft", "text": "草稿"},
{"value": "online", "text": "上架"},
{"value": "offline", "text": "下架"}
]
}
}
},
"editexclouded": ["orgid", "created_at", "updated_at"],
"subtables": [
{
"field": "product_id",
"title": "能力绑定",
"subtable": "product_capability",
"url": "{{entire_url('product_capability')}}"
},
{
"field": "product_id",
"title": "定价计划",
"subtable": "pricing_plan",
"url": "{{entire_url('pricing_plan')}}"
}
]
}
}

View File

@ -0,0 +1,19 @@
{
"tblname": "product_capability",
"alias": "product_capability",
"title": "产品能力绑定",
"params": {
"sortby": ["created_at desc"],
"browserfields": {
"exclouded": ["orgid", "config"]
},
"editexclouded": ["orgid", "created_at"],
"editor": {
"binds": []
}
}
}

View File

@ -0,0 +1,27 @@
{
"tblname": "product_category",
"alias": "product_category",
"uitype": "tree",
"title": "产品分类",
"params": {
"idField": "id",
"textField": "name",
"parentField": "parent_id",
"editable": true,
"sortby": ["order_idx", "name"],
"browserfields":{
"exclouded": [ "orgid" ],
"alters":{}
},
"edit_exclouded_fields": ["orgid", "created_at", "updated_at"],
"subtables": [
{
"field": "category_id",
"title": "所属产品",
"subtable": "product",
"url": "{{entire_url('product')}}"
}
]
}
}

27
models/capability.json Normal file
View File

@ -0,0 +1,27 @@
{
"summary": [
{
"name": "capability",
"title": "能力",
"primary": "id",
"catelog": "entity"
}
],
"fields": [
{"name": "id", "title": "能力ID", "type": "str", "length": 32, "nullable": "no"},
{ "name": "orgid", "title": "租户ID", "type": "str", "length": 32, "nullable": "no", "default": "", "comments": "所属组织(多租户隔离)" },
{"name": "name", "title": "名称", "type": "str", "length": 128, "nullable": "no"},
{"name": "kind", "title": "能力类型", "type": "str", "length": 64, "nullable": "yes"},
{"name": "base_pricing", "title": "基础定价(JSON)", "type": "text", "nullable": "yes"},
{"name": "meta", "title": "元数据", "type": "text", "nullable": "yes"},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "yes"},
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "yes"}
],
"indexes": [
{"name": "idx_kind", "idxtype": "index", "idxfields": ["kind"]}
]
}

37
models/pricing_plan.json Normal file
View File

@ -0,0 +1,37 @@
{
"summary": [
{
"name": "pricing_plan",
"title": "定价策略",
"primary": "id",
"catelog": "entity"
}
],
"fields": [
{"name": "id", "title": "定价ID", "type": "str", "length": 32, "nullable": "no"},
{ "name": "orgid", "title": "租户ID", "type": "str", "length": 32, "nullable": "no", "default": "", "comments": "所属组织(多租户隔离)" },
{"name": "product_id", "title": "产品ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "name", "title": "方案名称", "type": "str", "length": 128, "nullable": "no"},
{"name": "type", "title": "方案类型", "type": "str", "length": 32, "nullable": "no", "default": "payg"},
{"name": "plan_detail", "title": "方案内容 JSON", "type": "text", "nullable": "yes"},
{"name": "is_default", "title": "是否默认方案", "type": "short", "length": 0, "nullable": "yes", "default": "0"},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "yes"},
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "yes"}
],
"indexes": [
{"name": "idx_product", "idxtype": "index", "idxfields": ["product_id"]}
],
"codes": [
{
"field": "product_id",
"table": "product",
"valuefield": "id",
"textfield": "name",
"cond": ""
}
]
}

43
models/product.json Normal file
View File

@ -0,0 +1,43 @@
{
"summary": [
{
"name": "product",
"title": "产品",
"primary": "id",
"catelog": "entity"
}
],
"fields": [
{"name": "id", "title": "产品ID", "type": "str", "length": 32, "nullable": "no"},
{ "name": "orgid", "title": "租户ID", "type": "str", "length": 32, "nullable": "no", "default": "", "comments": "所属组织(多租户隔离)" },
{"name": "category_id", "title": "分类ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "name", "title": "产品名称", "type": "str", "length": 128, "nullable": "no"},
{"name": "code", "title": "产品编码", "type": "str", "length": 64, "nullable": "yes"},
{"name": "type", "title": "产品类型", "type": "str", "length": 64, "nullable": "yes"},
{"name": "summary", "title": "概要简介", "type": "str", "length": 512, "nullable": "yes"},
{"name": "detail", "title": "详情", "type": "text", "nullable": "yes"},
{"name": "status", "title": "状态", "type": "str", "length": 16, "nullable": "no", "default": "draft"},
{"name": "default_pricing", "title": "默认定价(JSON)", "type": "text", "nullable": "yes"},
{"name": "meta", "title": "元数据", "type": "text", "nullable": "yes"},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "yes"},
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "yes"}
],
"indexes": [
{"name": "idx_category", "idxtype": "index", "idxfields": ["category_id"]},
{"name": "uk_code", "idxtype": "unique", "idxfields": ["code"]}
],
"codes": [
{
"field": "category_id",
"table": "product_category",
"valuefield": "id",
"textfield": "name",
"cond": ""
}
]
}

View File

@ -0,0 +1,44 @@
{
"summary": [
{
"name": "product_capability",
"title": "产品能力绑定",
"primary": "id",
"catelog": "relation"
}
],
"fields": [
{"name": "id", "title": "ID", "type": "str", "length": 32, "nullable": "no"},
{ "name": "orgid", "title": "租户ID", "type": "str", "length": 32, "nullable": "no", "default": "", "comments": "所属组织(多租户隔离)" },
{"name": "product_id", "title": "产品ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "capability_id", "title": "能力ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "config", "title": "绑定配置(JSON)", "type": "text", "nullable": "yes"},
{"name": "created_at", "title": "绑定时间", "type": "timestamp", "nullable": "yes"}
],
"indexes": [
{
"name": "uk_product_cap",
"idxtype": "unique",
"idxfields": ["product_id", "capability_id"]
}
],
"codes": [
{
"field": "product_id",
"table": "product",
"valuefield": "id",
"textfield": "name",
"cond": ""
},
{
"field": "capability_id",
"table": "capability",
"valuefield": "id",
"textfield": "name",
"cond": ""
}
]
}

View File

@ -0,0 +1,27 @@
{
"summary": [
{
"name": "product_category",
"title": "产品分类",
"primary": "id",
"catelog": "entity"
}
],
"fields": [
{"name": "id", "title": "ID", "type": "str", "length": 32, "nullable": "no"},
{ "name": "orgid", "title": "租户ID", "type": "str", "length": 32, "nullable": "no", "default": "", "comments": "所属组织(多租户隔离)" },
{"name": "parent_id", "title": "父分类ID", "type": "str", "length": 32, "nullable": "yes"},
{"name": "name", "title": "分类名称", "type": "str", "length": 128, "nullable": "no"},
{"name": "slug", "title": "分类短名", "type": "str", "length": 128, "nullable": "yes"},
{"name": "order_idx", "title": "排序号", "type": "long", "length": 0, "nullable": "yes", "default": "0"},
{"name": "meta", "title": "扩展元数据", "type": "text", "nullable": "yes"},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "yes"},
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "yes"}
],
"indexes": [
{"name": "idx_parent", "idxtype": "index", "idxfields": ["parent_id"]}
]
}

View File

@ -0,0 +1,20 @@
# mymodule/capability_service.py
import json
import uuid
class CapabilityService:
def __init__(self, db):
self.db = db
async def list(self, sor):
return await sor.sqlExe("select * from capability order by name", {})
async def get(self, sor, cid):
rows = await sor.sqlExe("select * from capability where id=${cid}$ limit 1", {'cid':cid})
return rows[0] if rows else None
async def create(self, sor, data):
cid = data.get('id') or str(uuid.uuid4())
await sor.C('capability', {'id':cid, 'name': data['name'], 'kind': data.get('kind'), 'base_pricing': json.dumps(data.get('base_pricing', {})), 'meta': json.dumps(data.get('meta', {}))})
return {'id': cid}

View File

@ -0,0 +1,34 @@
# mymodule/category_service.py
import json
import uuid
from sqlor.dbpools import DBPools
class CategoryService:
def __init__(self, db: DBPools):
self.db = db
async def list(self, sor, params=None):
# 返回树形数据,前端按 parent_id 重建树
rows = await sor.sqlExe("select * from product_category order by order_idx, created_at", {})
return rows
async def create(self, sor, data):
cid = data.get("id") or str(uuid.uuid4())
await sor.C('product_category', {'id':cid, 'parent_id': data.get('parent_id'), 'name': data['name'], 'slug': data.get('slug'), 'order_idx': data.get('order_idx',0), 'meta': json.dumps(data.get('meta', {}))})
return {'id': cid}
async def update(self, sor, cid, data):
await sor.U('product_category', {'id': cid, 'name': data.get('name'), 'slug': data.get('slug'), 'order_idx': data.get('order_idx',0), 'meta': json.dumps(data.get('meta', {}))})
return {'id': cid}
async def delete(self, sor, cid):
# 检查是否有子分类/产品
child = await sor.sqlExe("select 1 from product_category where parent_id=${pid}$ limit 1", {'pid':cid})
if child:
raise Exception("存在子分类,请先删除或移动子分类")
prod = await sor.sqlExe("select 1 from product where category_id=${cid}$ limit 1", {'cid':cid})
if prod:
raise Exception("分类下存在产品,请先删除或移动产品")
await sor.D('product_category', {'id':cid})
return {'id': cid}

View File

@ -0,0 +1,29 @@
# mymodule/pricing_service.py
import json
import uuid
class PricingService:
def __init__(self, db):
self.db = db
async def create_plan(self, sor, product_id, data):
pid = str(uuid.uuid4())
await sor.C('pricing_plan', {
'id': pid,
'product_id': product_id,
'name': data['name'],
'type': data.get('type','payg'),
'plan_detail': json.dumps(data.get('plan_detail', {})),
'is_default': 1 if data.get('is_default') else 0
})
return {'id': pid}
async def update_plan(self, sor, plan_id, data):
await sor.U('pricing_plan', {
'id': plan_id,
'name': data.get('name'),
'plan_detail': json.dumps(data.get('plan_detail', {})),
'is_default': 1 if data.get('is_default') else 0
})
return {'id': plan_id}

View File

@ -0,0 +1,87 @@
# mymodule/product_service.py
import json
import uuid
from sqlor.dbpools import DBPools
class ProductService:
def __init__(self, db: DBPools):
self.db = db
async def get_tree(self, sor):
# 返回合并的分类与产品(前端直接渲染树)
cats = await sor.sqlExe("select * from product_category order by order_idx", {})
prods = await sor.sqlExe("select * from product where status != 'deleted' order by name", {})
# 简单合并:前端以 category_id 关联
return {'categories': cats, 'products': prods}
async def create(self, sor, data):
pid = data.get('id') or str(uuid.uuid4())
await sor.C('product', {
'id': pid,
'category_id': data['category_id'],
'name': data['name'],
'code': data.get('code'),
'type': data.get('type','generic'),
'summary': data.get('summary'),
'detail': data.get('detail'),
'status': data.get('status','draft'),
'default_pricing': json.dumps(data.get('default_pricing', {})),
'meta': json.dumps(data.get('meta', {}))
})
return {'id': pid}
async def update(self, sor, pid, data):
await sor.U('product', {
'id': pid,
'category_id': data.get('category_id'),
'name': data.get('name'),
'code': data.get('code'),
'type': data.get('type'),
'summary': data.get('summary'),
'detail': data.get('detail'),
'status': data.get('status'),
'default_pricing': json.dumps(data.get('default_pricing', {})),
'meta': json.dumps(data.get('meta', {}))
})
return {'id': pid}
async def delete(self, sor, pid):
# 软删除为上线安全考虑
await sor.U('product', {'id': pid, 'status': 'deleted'})
return {'id': pid}
async def bind_capability(self, sor, pid, cap_id, config=None):
bind_id = str(uuid.uuid4())
if not config:
config = {}
# check exists
exists = await sor.sqlExe("select 1 from product_capability where product_id=${pid}$ and capability_id=${cid}$ limit 1", {'pid':pid, 'cid':cap_id})
if exists:
raise Exception("已存在绑定")
await sor.C('product_capability', {'id': bind_id, 'product_id': pid, 'capability_id': cap_id, 'config': json.dumps(config or {})})
return {'id': bind_id}
async def unbind_capability(self, sor, bind_id):
await sor.D('product_capability', {'id': bind_id})
return {'id': bind_id}
async def change_status(self, sor, pid, status):
# status in ['draft','online','offline']
if status not in ('draft','online','offline'):
raise Exception("状态不合法")
await sor.U('product', {'id': pid, 'status': status})
return {'id': pid, 'status': status}
async def get(self, sor, pid):
rows = await sor.sqlExe("select * from product where id=${pid}$ limit 1", {'pid':pid})
if not rows:
return None
product = rows[0]
# load bindings
binds = await sor.sqlExe("select * from product_capability where product_id=${pid}$", {'pid':pid})
product['bindings'] = binds
# load pricing plans
plans = await sor.sqlExe("select * from pricing_plan where product_id=${pid}$ order by is_default desc", {'pid':pid})
product['pricing'] = plans
return product

16
pyproject.toml Normal file
View File

@ -0,0 +1,16 @@
[tool.poetry]
name = "product"
version = "0.1.0"
description = "Product module for platform - product/category/capability/pricing bindings"
authors = ["yumoqing <yumoqing@gmail.com>"]
[tool.poetry.dependencies]
python = "^3.10"
aiohttp = "*"
sqlor = {version="*", optional=true}
ahserver = {version="*", optional=true}
cryptography = "*"
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"

0
wwwroot/README.md Normal file
View File