From ebd1b00dddd6dc6d4b1c8db386f0acd95f0ad8f8 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Fri, 5 Dec 2025 18:03:14 +0800 Subject: [PATCH] first commit --- README.md | 21 ++++++++ init/data.json | 62 ++++++++++++++++++++++ init/script.py | 18 +++++++ json/capability.crud.json | 24 +++++++++ json/pricing_plan.crud.json | 19 +++++++ json/product.crud.json | 40 ++++++++++++++ json/product_capability.crud.json | 19 +++++++ json/product_category.crud.json | 27 ++++++++++ models/capability.json | 27 ++++++++++ models/pricing_plan.json | 37 +++++++++++++ models/product.json | 43 +++++++++++++++ models/product_catability.json | 44 ++++++++++++++++ models/product_category.json | 27 ++++++++++ product/capability_service.py | 20 +++++++ product/category_service.py | 34 ++++++++++++ product/pricing_service.py | 29 +++++++++++ product/product_service.py | 87 +++++++++++++++++++++++++++++++ pyproject.toml | 16 ++++++ wwwroot/README.md | 0 19 files changed, 594 insertions(+) create mode 100644 README.md create mode 100644 init/data.json create mode 100644 init/script.py create mode 100644 json/capability.crud.json create mode 100644 json/pricing_plan.crud.json create mode 100644 json/product.crud.json create mode 100644 json/product_capability.crud.json create mode 100644 json/product_category.crud.json create mode 100644 models/capability.json create mode 100644 models/pricing_plan.json create mode 100644 models/product.json create mode 100644 models/product_catability.json create mode 100644 models/product_category.json create mode 100644 product/capability_service.py create mode 100644 product/category_service.py create mode 100644 product/pricing_service.py create mode 100644 product/product_service.py create mode 100644 pyproject.toml create mode 100644 wwwroot/README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..6176151 --- /dev/null +++ b/README.md @@ -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 渲染。 + diff --git a/init/data.json b/init/data.json new file mode 100644 index 0000000..7454dd1 --- /dev/null +++ b/init/data.json @@ -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} + ] +} + diff --git a/init/script.py b/init/script.py new file mode 100644 index 0000000..a97a5a7 --- /dev/null +++ b/init/script.py @@ -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 + diff --git a/json/capability.crud.json b/json/capability.crud.json new file mode 100644 index 0000000..7da80ee --- /dev/null +++ b/json/capability.crud.json @@ -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')}}" + } + ] + } +} + diff --git a/json/pricing_plan.crud.json b/json/pricing_plan.crud.json new file mode 100644 index 0000000..fc0a8e8 --- /dev/null +++ b/json/pricing_plan.crud.json @@ -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": [] + } + } +} + diff --git a/json/product.crud.json b/json/product.crud.json new file mode 100644 index 0000000..03974cb --- /dev/null +++ b/json/product.crud.json @@ -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')}}" + } + ] + } +} + diff --git a/json/product_capability.crud.json b/json/product_capability.crud.json new file mode 100644 index 0000000..2bb8ec5 --- /dev/null +++ b/json/product_capability.crud.json @@ -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": [] + } + } +} + diff --git a/json/product_category.crud.json b/json/product_category.crud.json new file mode 100644 index 0000000..a3ca71d --- /dev/null +++ b/json/product_category.crud.json @@ -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')}}" + } + ] + } +} + diff --git a/models/capability.json b/models/capability.json new file mode 100644 index 0000000..3286562 --- /dev/null +++ b/models/capability.json @@ -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"]} + ] +} + diff --git a/models/pricing_plan.json b/models/pricing_plan.json new file mode 100644 index 0000000..d052ef5 --- /dev/null +++ b/models/pricing_plan.json @@ -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": "" + } + ] +} + diff --git a/models/product.json b/models/product.json new file mode 100644 index 0000000..e155a07 --- /dev/null +++ b/models/product.json @@ -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": "" + } + ] +} + diff --git a/models/product_catability.json b/models/product_catability.json new file mode 100644 index 0000000..e5c1748 --- /dev/null +++ b/models/product_catability.json @@ -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": "" + } + ] +} + diff --git a/models/product_category.json b/models/product_category.json new file mode 100644 index 0000000..06a8843 --- /dev/null +++ b/models/product_category.json @@ -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"]} + ] +} + diff --git a/product/capability_service.py b/product/capability_service.py new file mode 100644 index 0000000..b96e564 --- /dev/null +++ b/product/capability_service.py @@ -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} + diff --git a/product/category_service.py b/product/category_service.py new file mode 100644 index 0000000..5e8c323 --- /dev/null +++ b/product/category_service.py @@ -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} + diff --git a/product/pricing_service.py b/product/pricing_service.py new file mode 100644 index 0000000..a1e630c --- /dev/null +++ b/product/pricing_service.py @@ -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} + diff --git a/product/product_service.py b/product/product_service.py new file mode 100644 index 0000000..7557403 --- /dev/null +++ b/product/product_service.py @@ -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 + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..294e956 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[tool.poetry] +name = "product" +version = "0.1.0" +description = "Product module for platform - product/category/capability/pricing bindings" +authors = ["yumoqing "] + +[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" diff --git a/wwwroot/README.md b/wwwroot/README.md new file mode 100644 index 0000000..e69de29