From 2c56aa904a7542626a93964a90ee16af04c1b34a Mon Sep 17 00:00:00 2001 From: yumoqing Date: Fri, 29 May 2026 00:24:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BB=A3=E9=87=91=E5=88=B8=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E5=88=9D=E5=A7=8B=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 4张表: voucher_template, voucher_rule, voucher_instance, voucher_usage_log - 可配置规则引擎: registry + validators + engine - 8种内置规则: min_amount, max_amount, applicable_product_type, applicable_product, exclude_product, max_usage_count, valid_period, user_level - CRUD定义 + API接口 + 前端页面 - SQL建表脚本 + RBAC权限配置 - 一次性使用,不找零 --- README.md | 233 +++++++++++++++++- conf/config.json | 12 + json/voucher_instance_list.json | 49 ++++ json/voucher_rule_list.json | 51 ++++ json/voucher_template_list.json | 44 ++++ json/voucher_usage_log_list.json | 32 +++ models/voucher_instance.json | 50 ++++ models/voucher_rule.json | 47 ++++ models/voucher_template.json | 38 +++ models/voucher_usage_log.json | 35 +++ pyproject.toml | 17 ++ rules/__init__.py | 1 + rules/engine.py | 198 +++++++++++++++ rules/registry.py | 21 ++ rules/validators.py | 100 ++++++++ scripts/load_path.py | 75 ++++++ sql/tables.sql | 107 ++++++++ voucher/__init__.py | 1 + voucher/init.py | 231 +++++++++++++++++ wwwroot/api/apply_voucher.dspy | 17 ++ wwwroot/api/get_available.dspy | 13 + .../api/instance/voucher_instance_create.dspy | 10 + .../api/instance/voucher_instance_delete.dspy | 5 + .../instance/voucher_instance_options.dspy | 4 + .../api/instance/voucher_instance_update.dspy | 11 + wwwroot/api/rule/voucher_rule_create.dspy | 10 + wwwroot/api/rule/voucher_rule_delete.dspy | 5 + wwwroot/api/rule/voucher_rule_update.dspy | 11 + wwwroot/api/rule_types.dspy | 4 + .../api/template/voucher_template_create.dspy | 11 + .../api/template/voucher_template_delete.dspy | 5 + .../template/voucher_template_options.dspy | 4 + .../api/template/voucher_template_update.dspy | 11 + .../api/usage/voucher_usage_log_create.dspy | 10 + .../api/usage/voucher_usage_log_delete.dspy | 5 + .../api/usage/voucher_usage_log_update.dspy | 11 + wwwroot/index.ui | 48 ++++ wwwroot/menu.json | 25 ++ 38 files changed, 1561 insertions(+), 1 deletion(-) create mode 100644 conf/config.json create mode 100644 json/voucher_instance_list.json create mode 100644 json/voucher_rule_list.json create mode 100644 json/voucher_template_list.json create mode 100644 json/voucher_usage_log_list.json create mode 100644 models/voucher_instance.json create mode 100644 models/voucher_rule.json create mode 100644 models/voucher_template.json create mode 100644 models/voucher_usage_log.json create mode 100644 pyproject.toml create mode 100644 rules/__init__.py create mode 100644 rules/engine.py create mode 100644 rules/registry.py create mode 100644 rules/validators.py create mode 100644 scripts/load_path.py create mode 100644 sql/tables.sql create mode 100644 voucher/__init__.py create mode 100644 voucher/init.py create mode 100644 wwwroot/api/apply_voucher.dspy create mode 100644 wwwroot/api/get_available.dspy create mode 100644 wwwroot/api/instance/voucher_instance_create.dspy create mode 100644 wwwroot/api/instance/voucher_instance_delete.dspy create mode 100644 wwwroot/api/instance/voucher_instance_options.dspy create mode 100644 wwwroot/api/instance/voucher_instance_update.dspy create mode 100644 wwwroot/api/rule/voucher_rule_create.dspy create mode 100644 wwwroot/api/rule/voucher_rule_delete.dspy create mode 100644 wwwroot/api/rule/voucher_rule_update.dspy create mode 100644 wwwroot/api/rule_types.dspy create mode 100644 wwwroot/api/template/voucher_template_create.dspy create mode 100644 wwwroot/api/template/voucher_template_delete.dspy create mode 100644 wwwroot/api/template/voucher_template_options.dspy create mode 100644 wwwroot/api/template/voucher_template_update.dspy create mode 100644 wwwroot/api/usage/voucher_usage_log_create.dspy create mode 100644 wwwroot/api/usage/voucher_usage_log_delete.dspy create mode 100644 wwwroot/api/usage/voucher_usage_log_update.dspy create mode 100644 wwwroot/index.ui create mode 100644 wwwroot/menu.json diff --git a/README.md b/README.md index d8d3304..7bb699f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,233 @@ -# voucher +# voucher — 代金券模块 +独立代金券管理模块,支持**可配置规则引擎**,一次性使用,不找零。 + +## 核心架构 + +``` +模板(类别) → 定义规则集 +实例(代金券)→ 属于某个模板,继承规则 +使用流水 → 记录每次消费抵扣 +``` + +### 表结构 +| 表 | 说明 | +|---|---| +| voucher_template | 代金券模板(面值、有效期、发行量、状态) | +| voucher_rule | 规则定义(模板关联,可增删启用禁用) | +| voucher_instance | 券实例(一次性使用,状态:unused/used/expired) | +| voucher_usage_log | 使用流水(订单、抵扣金额、产品信息) | + +--- + +## 可配置规则引擎 + +规则通过 `@register_rule` 装饰器注册到 `RULE_REGISTRY`,新增规则只需写 validator 函数,无需修改引擎代码。 + +### 内置规则类型 + +| rule_type | 说明 | rule_config 示例 | +|-----------|------|------------------| +| `min_amount` | 最低消费门槛 | `{"min_value": 100}` | +| `max_amount` | 最高消费限制 | `{"max_value": 1000}` | +| `applicable_product_type` | 限定产品类型(llm/image/video/audio) | `{"product_types": ["llm", "image"]}` | +| `applicable_product` | 限定特定产品(具体模型名) | `{"products": ["gpt-4", "claude-3"]}` | +| `exclude_product` | 排除特定产品 | `{"products": ["gpt-4"]}` | +| `max_usage_count` | 最大使用次数 | `{"max_count": 1}` | +| `valid_period` | 有效期检查 | `{}` (由实例 valid_from/valid_to 处理) | +| `user_level` | 用户等级限制 | `{"min_level": 2}` | + +### 规则执行流程 +1. 查询券实例状态(unused + 未过期) +2. 加载模板关联的所有启用规则(按 sort_order 排序) +3. 依次执行 validator,任一失败即拒绝 +4. 全部通过 → 计算抵扣金额 = min(面值, 消费金额) +5. 记录流水 + 标记券为已使用(一次性作废) + +### 新增规则步骤 +```python +# rules/validators.py +@register_rule('new_rule_type') +def check_new_rule(config, context): + # config: 从 rule_config JSON 解析 + # context: 包含 request_amount, product_type, product_name, user_level 等 + if not some_condition: + return False, "不满足条件" + return True, None +``` +然后在模板管理界面添加 `rule_type: "new_rule_type"` 的规则记录即可。 + +--- + +## 与其他模块交互 + +### llmage 模块(消费时使用代金券) + +```python +# llmage 的计费逻辑中调用 voucher 引擎 +from voucher.rules.engine import apply_voucher, get_available_vouchers + +# 查询客户可用代金券 +vouchers = get_available_vouchers(sor, customer_id, context={ + 'product_type': 'llm', # 从 llm.catelog 获取 + 'product_name': 'gpt-4', # 具体模型名 + 'request_amount': amount, + 'user_level': customer.level +}) + +# 使用代金券抵扣 +ok, deducted, err = apply_voucher(sor, instance_id, customer_id, order_id, context={ + 'request_amount': amount, + 'product_type': 'llm', + 'product_name': 'gpt-4' +}) + +# 批量使用多张券 +from voucher.rules.engine import batch_apply_vouchers +result = batch_apply_vouchers(sor, customer_id, order_id, voucher_ids, context) +# result: {total_deducted: 150.0, remaining: 50.0, details: [...]} +# remaining > 0 时从余额扣减 +``` + +### accounting 模块(余额扣减联动) + +```python +# accounting 消费流程中先尝试代金券,剩余从余额扣 +total_amount = calculate_consumption(customer_id, period) + +# Step 1: 尝试代金券抵扣 +result = batch_apply_vouchers(sor, customer_id, order_id, voucher_ids, context) + +# Step 2: 剩余金额从余额扣减 +if result['remaining'] > 0: + accounting.deduct_balance(customer_id, result['remaining'], order_id) + +# Step 3: 记录完整账单 +accounting.record_order(order_id, total_amount, + voucher_deducted=result['total_deducted'], + balance_deducted=result['remaining']) +``` + +### dapi 模块(客户自助查询) + +```python +# 客户通过 API 查询自己的可用代金券 +# dapi 端点调用 voucher 引擎 +available = get_available_vouchers(sor, customer_id) +``` + +### 集成点总结 + +| 调用方 | 场景 | 调用函数 | +|--------|------|----------| +| llmage | 推理/生成时抵扣 | `apply_voucher()` | +| accounting | 月度账单结算 | `batch_apply_vouchers()` | +| dapi | 客户查询可用券 | `get_available_vouchers()` | +| 任意模块 | 新增规则类型 | `@register_rule` + 模板管理界面 | + +--- + +## 目录结构 + +``` +voucher/ +├── voucher/ # Python 包 +│ ├── __init__.py +│ └── init.py # 模块初始化 + ServerEnv 注册 +├── rules/ # 规则引擎(纯 Python) +│ ├── registry.py # @register_rule 装饰器 +│ ├── validators.py # 内置规则校验器 +│ └── engine.py # 校验/使用/批量使用 +├── wwwroot/ # 前端 +│ ├── index.ui # 入口页(卡片导航) +│ ├── menu.json # 菜单定义 +│ └── api/ # API 端点 +│ ├── template/ # 模板 CRUD +│ ├── rule/ # 规则 CRUD +│ ├── instance/ # 实例 CRUD +│ ├── usage/ # 流水 CRUD +│ ├── apply_voucher.dspy # 使用代金券 +│ ├── get_available.dspy # 查询可用券 +│ └── rule_types.dspy # 获取规则类型列表 +├── models/ # 表定义 JSON +├── json/ # CRUD 定义 JSON +├── sql/ +│ └── tables.sql # 建表 + 编码初始化 +├── scripts/ +│ └── load_path.py # RBAC 权限注册 +├── pyproject.toml +└── README.md +``` + +--- + +## 部署步骤 + +### 1. 代码部署 +```bash +cd ~/repos/voucher && git pull +cd ~/repos/sage/pkgs && ln -sf ~/repos/voucher voucher +cd voucher && ~/repos/sage/py3/bin/pip install -e . +``` + +### 2. 数据库建表 +```bash +mysql -u root -p sage < ~/repos/voucher/sql/tables.sql +``` + +### 3. Sage 集成 +```python +# sage/app/sage.py +from voucher.init import load_voucher +# ... in init(): +load_voucher() +``` + +```bash +# sage/build.sh 安装循环 +for m in ... voucher +``` + +### 4. 菜单入口 +```json +// sage/wwwroot/global_menu.ui items 数组 +,{ + "name": "voucher", + "label": "代金券", + "icon": "fa fa-ticket", + "url": "{{entire_url('/voucher/index.ui')}}", + "target": "app.sage_main_content" +} +``` + +### 5. RBAC 权限 +```bash +cd ~/repos/sage && ./py3/bin/python ~/repos/voucher/scripts/load_path.py +``` + +### 6. 重启 +```bash +cd ~/repos/sage && ./stop.sh && ./start.sh +``` + +--- + +## 使用示例 + +### 创建模板 + 配置规则 +1. 模板管理 → 新增 → 名称"新用户满减券",面值 50,有效期 30 天 +2. 点击模板 → 规则配置 → 添加规则: + - 规则类型: `min_amount`,配置: `{"min_value": 100}` + - 规则类型: `applicable_product_type`,配置: `{"product_types": ["llm"]}` + +### 发放代金券 +1. 券实例管理 → 新增 → 选择模板,填写客户 ID +2. 系统自动生成券码,设置有效期 + +### 消费时抵扣 +```python +# llmage 推理完成后 +context = {'request_amount': 120.0, 'product_type': 'llm', 'product_name': 'gpt-4'} +result = batch_apply_vouchers(sor, customer_id, order_id, [vid1, vid2], context) +# → 抵扣 50(满100可用),剩余 70 从余额扣 +``` diff --git a/conf/config.json b/conf/config.json new file mode 100644 index 0000000..9e8b61a --- /dev/null +++ b/conf/config.json @@ -0,0 +1,12 @@ +{ + "module": "voucher", + "databases": { + "sage": { + "host": "${DB_HOST}", + "port": "${DB_PORT}", + "user": "${DB_USER}", + "passwd": "${DB_PASSWD}", + "db": "sage" + } + } +} diff --git a/json/voucher_instance_list.json b/json/voucher_instance_list.json new file mode 100644 index 0000000..a6a4749 --- /dev/null +++ b/json/voucher_instance_list.json @@ -0,0 +1,49 @@ +{ + "tblname": "voucher_instance", + "alias": "voucher_instance_list", + "title": "代金券实例管理", + "params": { + "sortby": ["created_at desc"], + "data_filter": { + "AND": [ + {"field": "customer_id", "op": "=", "var": "customer_id"}, + {"field": "status", "op": "=", "var": "status"}, + {"field": "template_id", "op": "=", "var": "template_id"} + ] + }, + "browserfields": { + "exclouded": [], + "alters": { + "template_id": { + "uitype": "code", + "dataurl": "{{entire_url('../api/template/voucher_template_options.dspy')}}", + "data_field": "options", + "textField": "text", + "valueField": "value" + }, + "status": { + "uitype": "code", + "data": [ + {"value": "unused", "text": "未使用"}, + {"value": "used", "text": "已使用"}, + {"value": "expired", "text": "已过期"} + ] + } + } + }, + "editexclouded": ["used_at", "order_id", "actual_deducted"], + "editable": { + "new_data_url": "{{entire_url('../api/instance/voucher_instance_create.dspy')}}", + "update_data_url": "{{entire_url('../api/instance/voucher_instance_update.dspy')}}", + "delete_data_url": "{{entire_url('../api/instance/voucher_instance_delete.dspy')}}" + }, + "subtables": [ + { + "field": "id", + "title": "使用记录", + "url": "{{entire_url('../voucher_usage_log_list/')}}", + "subtable": "voucher_usage_log" + } + ] + } +} diff --git a/json/voucher_rule_list.json b/json/voucher_rule_list.json new file mode 100644 index 0000000..484fdf8 --- /dev/null +++ b/json/voucher_rule_list.json @@ -0,0 +1,51 @@ +{ + "tblname": "voucher_rule", + "alias": "voucher_rule_list", + "title": "代金券规则管理", + "params": { + "sortby": ["sort_order"], + "data_filter": { + "AND": [ + {"field": "template_id", "op": "=", "var": "template_id"}, + {"field": "rule_type", "op": "=", "var": "rule_type"} + ] + }, + "browserfields": { + "exclouded": [], + "alters": { + "template_id": { + "uitype": "code", + "dataurl": "{{entire_url('../api/template/voucher_template_options.dspy')}}", + "data_field": "options", + "textField": "text", + "valueField": "value" + }, + "rule_type": { + "uitype": "code", + "data": [ + {"value": "min_amount", "text": "最低消费"}, + {"value": "max_amount", "text": "最高消费"}, + {"value": "applicable_product_type", "text": "限定产品类型"}, + {"value": "applicable_product", "text": "限定特定产品"}, + {"value": "exclude_product", "text": "排除特定产品"}, + {"value": "max_usage_count", "text": "最大使用次数"}, + {"value": "valid_period", "text": "有效期"}, + {"value": "user_level", "text": "用户等级"} + ] + }, + "enabled": { + "uitype": "code", + "data": [ + {"value": "1", "text": "启用"}, + {"value": "0", "text": "停用"} + ] + } + } + }, + "editable": { + "new_data_url": "{{entire_url('../api/rule/voucher_rule_create.dspy')}}", + "update_data_url": "{{entire_url('../api/rule/voucher_rule_update.dspy')}}", + "delete_data_url": "{{entire_url('../api/rule/voucher_rule_delete.dspy')}}" + } + } +} diff --git a/json/voucher_template_list.json b/json/voucher_template_list.json new file mode 100644 index 0000000..ec43c88 --- /dev/null +++ b/json/voucher_template_list.json @@ -0,0 +1,44 @@ +{ + "tblname": "voucher_template", + "alias": "voucher_template_list", + "title": "代金券模板管理", + "params": { + "sortby": ["created_at desc"], + "browserfields": { + "exclouded": ["creator", "updater"], + "alters": { + "status": { + "uitype": "code", + "data": [ + {"value": "draft", "text": "草稿"}, + {"value": "active", "text": "启用"}, + {"value": "inactive", "text": "停用"} + ] + }, + "face_value": { + "uitype": "code", + "data": [] + } + } + }, + "data_filter": { + "AND": [ + {"field": "name", "op": "LIKE", "var": "name"}, + {"field": "status", "op": "=", "var": "status"} + ] + }, + "editable": { + "new_data_url": "{{entire_url('../api/template/voucher_template_create.dspy')}}", + "update_data_url": "{{entire_url('../api/template/voucher_template_update.dspy')}}", + "delete_data_url": "{{entire_url('../api/template/voucher_template_delete.dspy')}}" + }, + "subtables": [ + { + "field": "id", + "title": "规则配置", + "url": "{{entire_url('../voucher_rule_list/')}}", + "subtable": "voucher_rule" + } + ] + } +} diff --git a/json/voucher_usage_log_list.json b/json/voucher_usage_log_list.json new file mode 100644 index 0000000..650ffab --- /dev/null +++ b/json/voucher_usage_log_list.json @@ -0,0 +1,32 @@ +{ + "tblname": "voucher_usage_log", + "alias": "voucher_usage_log_list", + "title": "代金券使用流水", + "params": { + "sortby": ["used_at desc"], + "data_filter": { + "AND": [ + {"field": "instance_id", "op": "=", "var": "instance_id"}, + {"field": "customer_id", "op": "=", "var": "customer_id"}, + {"field": "order_id", "op": "=", "var": "order_id"} + ] + }, + "browserfields": { + "exclouded": [], + "alters": { + "instance_id": { + "uitype": "code", + "dataurl": "{{entire_url('../api/instance/voucher_instance_options.dspy')}}", + "data_field": "options", + "textField": "text", + "valueField": "value" + } + } + }, + "editable": { + "new_data_url": "{{entire_url('../api/usage/voucher_usage_log_create.dspy')}}", + "update_data_url": "{{entire_url('../api/usage/voucher_usage_log_update.dspy')}}", + "delete_data_url": "{{entire_url('../api/usage/voucher_usage_log_delete.dspy')}}" + } + } +} diff --git a/models/voucher_instance.json b/models/voucher_instance.json new file mode 100644 index 0000000..b4a2f7d --- /dev/null +++ b/models/voucher_instance.json @@ -0,0 +1,50 @@ +{ + "summary": [ + { + "name": "voucher_instance", + "title": "代金券实例", + "primary": ["id"], + "catelog": "entity" + } + ], + "fields": [ + {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "template_id", "title": "模板ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "customer_id", "title": "客户ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "code", "title": "唯一券码", "type": "str", "length": 64, "nullable": "no"}, + {"name": "status", "title": "状态", "type": "str", "length": 16, "nullable": "no", "default": "unused"}, + {"name": "face_value", "title": "面值", "type": "double", "length": 10, "dec": 2, "nullable": "no"}, + {"name": "actual_deducted", "title": "实际抵扣金额", "type": "double", "length": 10, "dec": 2, "nullable": "yes"}, + {"name": "valid_from", "title": "生效时间", "type": "datetime", "nullable": "no"}, + {"name": "valid_to", "title": "过期时间", "type": "datetime", "nullable": "no"}, + {"name": "issued_at", "title": "发放时间", "type": "datetime", "nullable": "no"}, + {"name": "used_at", "title": "使用时间", "type": "datetime", "nullable": "yes"}, + {"name": "order_id", "title": "使用的订单ID", "type": "str", "length": 64, "nullable": "yes"}, + {"name": "source", "title": "来源", "type": "str", "length": 32, "nullable": "yes"}, + {"name": "remark", "title": "备注", "type": "str", "length": 255, "nullable": "yes"}, + {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, + {"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"} + ], + "indexes": [ + {"name": "idx_instance_code", "idxtype": "unique", "idxfields": ["code"]}, + {"name": "idx_instance_customer", "idxtype": "index", "idxfields": ["customer_id"]}, + {"name": "idx_instance_template", "idxtype": "index", "idxfields": ["template_id"]}, + {"name": "idx_instance_status", "idxtype": "index", "idxfields": ["status"]}, + {"name": "idx_instance_valid_to", "idxtype": "index", "idxfields": ["valid_to"]} + ], + "codes": [ + { + "field": "template_id", + "table": "voucher_template", + "valuefield": "id", + "textfield": "name" + }, + { + "field": "status", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='voucher_instance_status'" + } + ] +} diff --git a/models/voucher_rule.json b/models/voucher_rule.json new file mode 100644 index 0000000..f2adb43 --- /dev/null +++ b/models/voucher_rule.json @@ -0,0 +1,47 @@ +{ + "summary": [ + { + "name": "voucher_rule", + "title": "代金券规则定义", + "primary": ["id"], + "catelog": "entity" + } + ], + "fields": [ + {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "template_id", "title": "模板ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "rule_type", "title": "规则类型", "type": "str", "length": 32, "nullable": "no"}, + {"name": "rule_config", "title": "规则配置(JSON)", "type": "text", "nullable": "no"}, + {"name": "enabled", "title": "是否启用", "type": "short", "nullable": "no", "default": "1"}, + {"name": "sort_order", "title": "执行顺序", "type": "int", "nullable": "no", "default": "0"}, + {"name": "remark", "title": "备注", "type": "str", "length": 255, "nullable": "yes"}, + {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, + {"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"} + ], + "indexes": [ + {"name": "idx_rule_template", "idxtype": "index", "idxfields": ["template_id"]}, + {"name": "idx_rule_enabled", "idxtype": "index", "idxfields": ["enabled"]} + ], + "codes": [ + { + "field": "template_id", + "table": "voucher_template", + "valuefield": "id", + "textfield": "name" + }, + { + "field": "rule_type", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='voucher_rule_type'" + }, + { + "field": "enabled", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='yes_no'" + } + ] +} diff --git a/models/voucher_template.json b/models/voucher_template.json new file mode 100644 index 0000000..4598dd5 --- /dev/null +++ b/models/voucher_template.json @@ -0,0 +1,38 @@ +{ + "summary": [ + { + "name": "voucher_template", + "title": "代金券模板", + "primary": ["id"], + "catelog": "entity" + } + ], + "fields": [ + {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "name", "title": "模板名称", "type": "str", "length": 64, "nullable": "no"}, + {"name": "code", "title": "模板编码", "type": "str", "length": 32, "nullable": "no"}, + {"name": "face_value", "title": "面值", "type": "double", "length": 10, "dec": 2, "nullable": "no"}, + {"name": "total_count", "title": "总发行量", "type": "int", "nullable": "no", "default": "0"}, + {"name": "issued_count", "title": "已发放量", "type": "int", "nullable": "no", "default": "0"}, + {"name": "valid_days", "title": "有效期天数", "type": "int", "nullable": "no", "default": "30"}, + {"name": "status", "title": "状态", "type": "str", "length": 16, "nullable": "no", "default": "draft"}, + {"name": "remark", "title": "备注", "type": "str", "length": 255, "nullable": "yes"}, + {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, + {"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"}, + {"name": "creator", "title": "创建人", "type": "str", "length": 32, "nullable": "yes"}, + {"name": "updater", "title": "更新人", "type": "str", "length": 32, "nullable": "yes"} + ], + "indexes": [ + {"name": "idx_template_code", "idxtype": "unique", "idxfields": ["code"]}, + {"name": "idx_template_status", "idxtype": "index", "idxfields": ["status"]} + ], + "codes": [ + { + "field": "status", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='voucher_template_status'" + } + ] +} diff --git a/models/voucher_usage_log.json b/models/voucher_usage_log.json new file mode 100644 index 0000000..333a26f --- /dev/null +++ b/models/voucher_usage_log.json @@ -0,0 +1,35 @@ +{ + "summary": [ + { + "name": "voucher_usage_log", + "title": "代金券使用流水", + "primary": ["id"], + "catelog": "entity" + } + ], + "fields": [ + {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "instance_id", "title": "券实例ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "customer_id", "title": "客户ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "order_id", "title": "订单/消费ID", "type": "str", "length": 64, "nullable": "no"}, + {"name": "face_value", "title": "券面值", "type": "double", "length": 10, "dec": 2, "nullable": "no"}, + {"name": "deducted_amount", "title": "实际抵扣金额", "type": "double", "length": 10, "dec": 2, "nullable": "no"}, + {"name": "used_at", "title": "使用时间", "type": "datetime", "nullable": "no"}, + {"name": "used_by", "title": "操作人", "type": "str", "length": 32, "nullable": "yes"}, + {"name": "product_type", "title": "产品类型", "type": "str", "length": 32, "nullable": "yes"}, + {"name": "product_name", "title": "产品名称", "type": "str", "length": 64, "nullable": "yes"} + ], + "indexes": [ + {"name": "idx_log_instance", "idxtype": "index", "idxfields": ["instance_id"]}, + {"name": "idx_log_customer", "idxtype": "index", "idxfields": ["customer_id"]}, + {"name": "idx_log_order", "idxtype": "index", "idxfields": ["order_id"]} + ], + "codes": [ + { + "field": "instance_id", + "table": "voucher_instance", + "valuefield": "id", + "textfield": "code" + } + ] +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..42d1381 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "voucher" +version = "1.0.0" +description = "代金券模块:模板管理、可配置规则引擎、一次性使用代金券" +requires-python = ">=3.8" +dependencies = [ + "sqlor", + "bricks_for_python", +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["voucher*"] diff --git a/rules/__init__.py b/rules/__init__.py new file mode 100644 index 0000000..1b9aaca --- /dev/null +++ b/rules/__init__.py @@ -0,0 +1 @@ +# voucher rules engine diff --git a/rules/engine.py b/rules/engine.py new file mode 100644 index 0000000..2ca72e6 --- /dev/null +++ b/rules/engine.py @@ -0,0 +1,198 @@ +"""代金券规则引擎:动态加载规则、校验、使用""" + +import json +from decimal import Decimal +from datetime import datetime + +from appPublic.uniqueID import getID +from .registry import RULE_REGISTRY + + +def validate_voucher(sor, instance_id, context): + """ + 校验代金券是否可用 + + Args: + sor: SQLor 上下文 + instance_id: 券实例ID + context: dict, 包含 request_amount, product_type, product_name, user_level 等 + + Returns: + (bool, Decimal, str): (可用, 抵扣金额, 错误信息) + """ + # 1. 查询券实例 + instance = sor.R('voucher_instance', {'id': instance_id}).first() + if not instance: + return False, Decimal('0'), "代金券不存在" + + # 2. 状态检查(一次性使用) + if instance.status != 'unused': + return False, Decimal('0'), "代金券已使用或失效" + + # 3. 有效期检查 + now = datetime.now() + if instance.valid_from and now < instance.valid_from: + return False, Decimal('0'), "代金券尚未生效" + if instance.valid_to and now > instance.valid_to: + return False, Decimal('0'), "代金券已过期" + + # 4. 加载模板规则(按 sort_order 排序) + rules = sor.R('voucher_rule', { + 'template_id': instance.template_id, + 'enabled': 1 + }, {'sort': 'sort_order'}) + + # 5. 构建执行上下文 + ctx = { + 'instance': instance, + 'used_count': instance.get('used_count', 0) if isinstance(instance, dict) else 0, + 'valid_from': instance.valid_from, + 'valid_to': instance.valid_to, + **context + } + + # 6. 依次执行规则 + for rule in rules: + rule_type = rule.rule_type if hasattr(rule, 'rule_type') else rule['rule_type'] + rule_config_str = rule.rule_config if hasattr(rule, 'rule_config') else rule['rule_config'] + + if rule_type not in RULE_REGISTRY: + continue # 未知规则类型跳过 + + try: + config = json.loads(rule_config_str) if isinstance(rule_config_str, str) else rule_config_str + except (json.JSONDecodeError, TypeError): + continue + + validator = RULE_REGISTRY[rule_type] + ok, err_msg = validator(config, ctx) + if not ok: + return False, Decimal('0'), err_msg + + # 7. 计算抵扣金额(一次性,不找零) + face_value = Decimal(str(instance.face_value if hasattr(instance, 'face_value') else instance['face_value'])) + request_amount = Decimal(str(context.get('request_amount', 0))) + deductible = min(face_value, request_amount) + + return True, deductible, None + + +def apply_voucher(sor, instance_id, customer_id, order_id, context): + """ + 使用代金券(一次性,不找零) + + Args: + sor: SQLor 上下文 + instance_id: 券实例ID + customer_id: 客户ID + order_id: 订单ID + context: dict, 包含 request_amount, product_type, product_name 等 + + Returns: + (bool, Decimal, str): (成功, 抵扣金额, 错误信息) + """ + # 1. 校验 + ok, deductible, err = validate_voucher(sor, instance_id, context) + if not ok: + return False, Decimal('0'), err + + # 2. 获取券实例信息 + instance = sor.R('voucher_instance', {'id': instance_id}).first() + face_value = Decimal(str(instance.face_value if hasattr(instance, 'face_value') else instance['face_value'])) + + now = datetime.now() + + # 3. 记录使用流水 + sor.I('voucher_usage_log', { + 'id': getID(), + 'instance_id': instance_id, + 'customer_id': customer_id, + 'order_id': order_id, + 'face_value': face_value, + 'deducted_amount': deductible, + 'used_at': now, + 'used_by': sor.env.get_user() if hasattr(sor, 'env') else None, + 'product_type': context.get('product_type', ''), + 'product_name': context.get('product_name', '') + }) + + # 4. 标记券为已使用(一次性,直接作废) + sor.U('voucher_instance', {'id': instance_id}, { + 'status': 'used', + 'actual_deducted': deductible, + 'used_at': now, + 'order_id': order_id + }) + + return True, deductible, None + + +def batch_apply_vouchers(sor, customer_id, order_id, voucher_ids, context): + """ + 批量使用代金券(依次尝试,直到消费金额抵扣完) + + Args: + sor: SQLor 上下文 + customer_id: 客户ID + order_id: 订单ID + voucher_ids: 券实例ID列表 + context: dict, 包含 request_amount, product_type, product_name 等 + + Returns: + dict: {total_deducted, remaining, details: [{voucher_id, deducted, error}]} + """ + total_deducted = Decimal('0') + remaining = Decimal(str(context.get('request_amount', 0))) + details = [] + + for vid in voucher_ids: + if remaining <= 0: + break + + ctx = {**context, 'request_amount': remaining} + ok, deducted, err = apply_voucher(sor, vid, customer_id, order_id, ctx) + + if ok: + total_deducted += deducted + remaining -= deducted + details.append({'voucher_id': vid, 'deducted': float(deducted), 'error': None}) + else: + details.append({'voucher_id': vid, 'deducted': 0, 'error': err}) + + return { + 'total_deducted': float(total_deducted), + 'remaining': float(remaining), + 'details': details + } + + +def get_available_vouchers(sor, customer_id, context=None): + """ + 查询客户可用代金券 + + Args: + sor: SQLor 上下文 + customer_id: 客户ID + context: 可选,用于预校验 + + Returns: + list: 可用券实例列表 + """ + now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + vouchers = sor.R('voucher_instance', { + 'customer_id': customer_id, + 'status': 'unused' + }, {'sort': 'valid_to'}) + + if not context: + return list(vouchers) + + # 带 context 时预校验过滤 + available = [] + for v in vouchers: + vid = v.id if hasattr(v, 'id') else v['id'] + ok, _, _ = validate_voucher(sor, vid, context) + if ok: + available.append(v) + + return available diff --git a/rules/registry.py b/rules/registry.py new file mode 100644 index 0000000..55d93dd --- /dev/null +++ b/rules/registry.py @@ -0,0 +1,21 @@ +"""规则注册机制:新增规则只需写一个 validator 函数 + @register_rule 装饰器""" + +RULE_REGISTRY = {} + + +def register_rule(rule_type): + """装饰器:注册规则类型""" + def decorator(func): + RULE_REGISTRY[rule_type] = func + return func + return decorator + + +def get_all_rule_types(): + """返回所有已注册的规则类型""" + return list(RULE_REGISTRY.keys()) + + +def get_rule_validator(rule_type): + """根据类型获取 validator 函数""" + return RULE_REGISTRY.get(rule_type) diff --git a/rules/validators.py b/rules/validators.py new file mode 100644 index 0000000..423e371 --- /dev/null +++ b/rules/validators.py @@ -0,0 +1,100 @@ +"""内置规则校验器,每种规则一个函数,返回 (bool, error_msg)""" + +from decimal import Decimal +from datetime import datetime +from .registry import register_rule + + +@register_rule('min_amount') +def check_min_amount(config, context): + """最低消费门槛""" + min_val = Decimal(str(config.get('min_value', 0))) + request_amount = Decimal(str(context.get('request_amount', 0))) + if request_amount < min_val: + return False, f"未达最低消费 {min_val} 元" + return True, None + + +@register_rule('max_amount') +def check_max_amount(config, context): + """最高消费限制""" + max_val = Decimal(str(config.get('max_value', 0))) + if max_val <= 0: + return True, None + request_amount = Decimal(str(context.get('request_amount', 0))) + if request_amount > max_val: + return False, f"超过最高消费 {max_val} 元" + return True, None + + +@register_rule('applicable_product_type') +def check_product_type(config, context): + """限定产品类型(大类:llm/image/video/audio)""" + allowed_types = config.get('product_types', []) + if not allowed_types: + return True, None + current_type = context.get('product_type', '') + if current_type and current_type not in allowed_types: + return False, f"仅限 {', '.join(allowed_types)} 类型使用" + return True, None + + +@register_rule('applicable_product') +def check_specific_product(config, context): + """限定特定产品(具体模型名)""" + allowed_products = config.get('products', []) + if not allowed_products: + return True, None + current_product = context.get('product_name', '') + if current_product and current_product not in allowed_products: + return False, f"仅限 {', '.join(allowed_products)} 使用" + return True, None + + +@register_rule('exclude_product') +def check_exclude_product(config, context): + """排除特定产品""" + excluded = config.get('products', []) + if not excluded: + return True, None + current_product = context.get('product_name', '') + if current_product and current_product in excluded: + return False, f"{current_product} 不可使用此代金券" + return True, None + + +@register_rule('max_usage_count') +def check_max_usage_count(config, context): + """最大使用次数""" + max_count = int(config.get('max_count', 0)) + if max_count == 0: + return True, None + used_count = int(context.get('used_count', 0)) + if used_count >= max_count: + return False, f"已达最大使用次数 {max_count} 次" + return True, None + + +@register_rule('valid_period') +def check_valid_period(config, context): + """有效期检查(通常由 instance 的 valid_from/valid_to 自动处理,此规则用于额外限制)""" + now = datetime.now() + valid_from = context.get('valid_from') + valid_to = context.get('valid_to') + if valid_from and now < valid_from: + return False, "代金券尚未生效" + if valid_to and now > valid_to: + return False, "代金券已过期" + return True, None + + +@register_rule('user_level') +def check_user_level(config, context): + """用户等级限制""" + required_level = int(config.get('min_level', 0)) + if required_level == 0: + return True, None + user_level = int(context.get('user_level', 0)) + if user_level < required_level: + return False, f"需 {required_level} 级以上用户" + return True, None diff --git a/scripts/load_path.py b/scripts/load_path.py new file mode 100644 index 0000000..a2ec443 --- /dev/null +++ b/scripts/load_path.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""voucher 模块 RBAC 权限注册""" +import os +import sys +import subprocess + +def find_sage_root(): + for candidate in [ + os.path.expanduser("~/repos/sage"), + os.path.expanduser("~/sage"), + ]: + if os.path.isdir(os.path.join(candidate, "wwwroot")): + return candidate + return None + +def set_perm(sage_root, role, path): + script = os.path.join(sage_root, "set_role_perm.py") + cmd = [os.path.join(sage_root, "py3/bin/python"), script, role, path] + print(f" {role} {path}") + subprocess.run(cmd, cwd=sage_root, capture_output=True) + +def main(): + sage_root = find_sage_root() + if not sage_root: + print("ERROR: Sage root not found") + sys.exit(1) + + print("Registering voucher RBAC permissions...") + + # any (公开) + any_paths = [ + "/voucher/menu.ui", + ] + + # logined (登录后) + logined_paths = [ + "/voucher", + "/voucher/index.ui", + "/voucher/voucher_template_list", + "/voucher/voucher_template_list/index.ui", + "/voucher/voucher_rule_list", + "/voucher/voucher_rule_list/index.ui", + "/voucher/voucher_instance_list", + "/voucher/voucher_instance_list/index.ui", + "/voucher/voucher_usage_log_list", + "/voucher/voucher_usage_log_list/index.ui", + "/voucher/api/template/voucher_template_create.dspy", + "/voucher/api/template/voucher_template_update.dspy", + "/voucher/api/template/voucher_template_delete.dspy", + "/voucher/api/template/voucher_template_options.dspy", + "/voucher/api/rule/voucher_rule_create.dspy", + "/voucher/api/rule/voucher_rule_update.dspy", + "/voucher/api/rule/voucher_rule_delete.dspy", + "/voucher/api/instance/voucher_instance_create.dspy", + "/voucher/api/instance/voucher_instance_update.dspy", + "/voucher/api/instance/voucher_instance_delete.dspy", + "/voucher/api/instance/voucher_instance_options.dspy", + "/voucher/api/usage/voucher_usage_log_create.dspy", + "/voucher/api/usage/voucher_usage_log_update.dspy", + "/voucher/api/usage/voucher_usage_log_delete.dspy", + "/voucher/api/apply_voucher.dspy", + "/voucher/api/get_available.dspy", + "/voucher/api/rule_types.dspy", + ] + + for p in any_paths: + set_perm(sage_root, "any", p) + + for p in logined_paths: + set_perm(sage_root, "logined", p) + + print("Done.") + +if __name__ == "__main__": + main() diff --git a/sql/tables.sql b/sql/tables.sql new file mode 100644 index 0000000..a559fed --- /dev/null +++ b/sql/tables.sql @@ -0,0 +1,107 @@ +-- ============================================ +-- 代金券模块建表脚本 +-- ============================================ + +-- 代金券模板表 +CREATE TABLE IF NOT EXISTS voucher_template ( + id VARCHAR(32) NOT NULL COMMENT '主键ID', + name VARCHAR(64) NOT NULL COMMENT '模板名称', + code VARCHAR(32) NOT NULL COMMENT '模板编码', + face_value DECIMAL(10,2) NOT NULL COMMENT '面值', + total_count INT NOT NULL DEFAULT 0 COMMENT '总发行量', + issued_count INT NOT NULL DEFAULT 0 COMMENT '已发放量', + valid_days INT NOT NULL DEFAULT 30 COMMENT '有效期天数', + status VARCHAR(16) NOT NULL DEFAULT 'draft' COMMENT '状态: draft/active/inactive', + remark VARCHAR(255) DEFAULT NULL COMMENT '备注', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + creator VARCHAR(32) DEFAULT NULL COMMENT '创建人', + updater VARCHAR(32) DEFAULT NULL COMMENT '更新人', + PRIMARY KEY (id), + UNIQUE KEY idx_template_code (code), + KEY idx_template_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='代金券模板'; + +-- 代金券规则表 +CREATE TABLE IF NOT EXISTS voucher_rule ( + id VARCHAR(32) NOT NULL COMMENT '主键ID', + template_id VARCHAR(32) NOT NULL COMMENT '模板ID', + rule_type VARCHAR(32) NOT NULL COMMENT '规则类型', + rule_config TEXT NOT NULL COMMENT '规则配置(JSON)', + enabled SMALLINT NOT NULL DEFAULT 1 COMMENT '是否启用', + sort_order INT NOT NULL DEFAULT 0 COMMENT '执行顺序', + remark VARCHAR(255) DEFAULT NULL COMMENT '备注', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (id), + KEY idx_rule_template (template_id), + KEY idx_rule_enabled (enabled) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='代金券规则定义'; + +-- 代金券实例表 +CREATE TABLE IF NOT EXISTS voucher_instance ( + id VARCHAR(32) NOT NULL COMMENT '主键ID', + template_id VARCHAR(32) NOT NULL COMMENT '模板ID', + customer_id VARCHAR(32) NOT NULL COMMENT '客户ID', + code VARCHAR(64) NOT NULL COMMENT '唯一券码', + status VARCHAR(16) NOT NULL DEFAULT 'unused' COMMENT '状态: unused/used/expired', + face_value DECIMAL(10,2) NOT NULL COMMENT '面值', + actual_deducted DECIMAL(10,2) DEFAULT NULL COMMENT '实际抵扣金额', + valid_from DATETIME NOT NULL COMMENT '生效时间', + valid_to DATETIME NOT NULL COMMENT '过期时间', + issued_at DATETIME NOT NULL COMMENT '发放时间', + used_at DATETIME DEFAULT NULL COMMENT '使用时间', + order_id VARCHAR(64) DEFAULT NULL COMMENT '使用的订单ID', + source VARCHAR(32) DEFAULT NULL COMMENT '来源', + remark VARCHAR(255) DEFAULT NULL COMMENT '备注', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY idx_instance_code (code), + KEY idx_instance_customer (customer_id), + KEY idx_instance_template (template_id), + KEY idx_instance_status (status), + KEY idx_instance_valid_to (valid_to) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='代金券实例(一次性使用)'; + +-- 代金券使用流水表 +CREATE TABLE IF NOT EXISTS voucher_usage_log ( + id VARCHAR(32) NOT NULL COMMENT '主键ID', + instance_id VARCHAR(32) NOT NULL COMMENT '券实例ID', + customer_id VARCHAR(32) NOT NULL COMMENT '客户ID', + order_id VARCHAR(64) NOT NULL COMMENT '订单/消费ID', + face_value DECIMAL(10,2) NOT NULL COMMENT '券面值', + deducted_amount DECIMAL(10,2) NOT NULL COMMENT '实际抵扣金额', + used_at DATETIME NOT NULL COMMENT '使用时间', + used_by VARCHAR(32) DEFAULT NULL COMMENT '操作人', + product_type VARCHAR(32) DEFAULT NULL COMMENT '产品类型', + product_name VARCHAR(64) DEFAULT NULL COMMENT '产品名称', + PRIMARY KEY (id), + KEY idx_log_instance (instance_id), + KEY idx_log_customer (customer_id), + KEY idx_log_order (order_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='代金券使用流水'; + +-- 初始化编码数据 +INSERT INTO appcodes (id, name, hierarchy_flg) VALUES +('voucher_template_status', '代金券模板状态', '0'), +('voucher_instance_status', '代金券实例状态', '0'), +('voucher_rule_type', '代金券规则类型', '0') +ON DUPLICATE KEY UPDATE name=VALUES(name); + +INSERT INTO appcodes_kv (id, parentid, k, v) VALUES +('vts_draft', 'voucher_template_status', 'draft', '草稿'), +('vts_active', 'voucher_template_status', 'active', '启用'), +('vts_inactive', 'voucher_template_status', 'inactive', '停用'), +('vis_unused', 'voucher_instance_status', 'unused', '未使用'), +('vis_used', 'voucher_instance_status', 'used', '已使用'), +('vis_expired', 'voucher_instance_status', 'expired', '已过期'), +('vrt_min', 'voucher_rule_type', 'min_amount', '最低消费'), +('vrt_max', 'voucher_rule_type', 'max_amount', '最高消费'), +('vrt_ptype', 'voucher_rule_type', 'applicable_product_type', '限定产品类型'), +('vrt_prod', 'voucher_rule_type', 'applicable_product', '限定特定产品'), +('vrt_exprod', 'voucher_rule_type', 'exclude_product', '排除特定产品'), +('vrt_usage', 'voucher_rule_type', 'max_usage_count', '最大使用次数'), +('vrt_period', 'voucher_rule_type', 'valid_period', '有效期'), +('vrt_level', 'voucher_rule_type', 'user_level', '用户等级') +ON DUPLICATE KEY UPDATE v=VALUES(v); diff --git a/voucher/__init__.py b/voucher/__init__.py new file mode 100644 index 0000000..1369328 --- /dev/null +++ b/voucher/__init__.py @@ -0,0 +1 @@ +# voucher module diff --git a/voucher/init.py b/voucher/init.py new file mode 100644 index 0000000..d342ece --- /dev/null +++ b/voucher/init.py @@ -0,0 +1,231 @@ +"""voucher 模块初始化""" + +from ahserver.serverenv import ServerEnv +from appPublic.uniqueID import getID + +MODULE_NAME = "voucher" +MODULE_VERSION = "1.0.0" +DBNAME = "sage" + + +def _get_dbname(): + return DBNAME + + +# ========== Template CRUD ========== + +async def create_voucher_template(data): + sor = DBPools().sqlorContext(_get_dbname()) + data['id'] = getID() + data['issued_count'] = 0 + if 'status' not in data: + data['status'] = 'draft' + sor.I('voucher_template', data) + return {'status': 'success', 'data': {'id': data['id']}} + + +async def update_voucher_template(conditions, data): + sor = DBPools().sqlorContext(_get_dbname()) + sor.U('voucher_template', conditions, data) + return {'status': 'success'} + + +async def delete_voucher_template(conditions): + sor = DBPools().sqlorContext(_get_dbname()) + # 级联删除关联规则 + sor.D('voucher_rule', {'template_id': conditions.get('id', '')}) + sor.D('voucher_template', conditions) + return {'status': 'success'} + + +# ========== Rule CRUD ========== + +async def create_voucher_rule(data): + sor = DBPools().sqlorContext(_get_dbname()) + data['id'] = getID() + if 'enabled' not in data: + data['enabled'] = 1 + if 'sort_order' not in data: + data['sort_order'] = 0 + sor.I('voucher_rule', data) + return {'status': 'success', 'data': {'id': data['id']}} + + +async def update_voucher_rule(conditions, data): + sor = DBPools().sqlorContext(_get_dbname()) + sor.U('voucher_rule', conditions, data) + return {'status': 'success'} + + +async def delete_voucher_rule(conditions): + sor = DBPools().sqlorContext(_get_dbname()) + sor.D('voucher_rule', conditions) + return {'status': 'success'} + + +# ========== Instance CRUD ========== + +async def create_voucher_instance(data): + from datetime import datetime, timedelta + sor = DBPools().sqlorContext(_get_dbname()) + + data['id'] = getID() + data['status'] = 'unused' + + # 从模板获取面值 + template = sor.R('voucher_template', {'id': data.get('template_id', '')}).first() + if template: + data['face_value'] = template.face_value if hasattr(template, 'face_value') else template['face_value'] + valid_days = template.valid_days if hasattr(template, 'valid_days') else template.get('valid_days', 30) + else: + valid_days = data.get('valid_days', 30) + + # 设置有效期 + now = datetime.now() + data['valid_from'] = data.get('valid_from', now.strftime('%Y-%m-%d %H:%M:%S')) + data['valid_to'] = data.get('valid_to', (now + timedelta(days=valid_days)).strftime('%Y-%m-%d %H:%M:%S')) + data['issued_at'] = now.strftime('%Y-%m-%d %H:%M:%S') + + # 生成唯一券码 + if 'code' not in data or not data['code']: + import hashlib + raw = f"{data['id']}-{data.get('customer_id', '')}-{now.timestamp()}" + data['code'] = 'VCH-' + hashlib.md5(raw.encode()).hexdigest()[:12].upper() + + sor.I('voucher_instance', data) + + # 更新模板已发放量 + if template: + tid = data.get('template_id', '') + sor.db_execute(f"UPDATE voucher_template SET issued_count = issued_count + 1 WHERE id = '{tid}'") + + return {'status': 'success', 'data': {'id': data['id'], 'code': data['code']}} + + +async def update_voucher_instance(conditions, data): + sor = DBPools().sqlorContext(_get_dbname()) + sor.U('voucher_instance', conditions, data) + return {'status': 'success'} + + +async def delete_voucher_instance(conditions): + sor = DBPools().sqlorContext(_get_dbname()) + sor.D('voucher_usage_log', {'instance_id': conditions.get('id', '')}) + sor.D('voucher_instance', conditions) + return {'status': 'success'} + + +# ========== Usage Log CRUD ========== + +async def create_voucher_usage_log(data): + sor = DBPools().sqlorContext(_get_dbname()) + data['id'] = getID() + sor.I('voucher_usage_log', data) + return {'status': 'success', 'data': {'id': data['id']}} + + +async def update_voucher_usage_log(conditions, data): + sor = DBPools().sqlorContext(_get_dbname()) + sor.U('voucher_usage_log', conditions, data) + return {'status': 'success'} + + +async def delete_voucher_usage_log(conditions): + sor = DBPools().sqlorContext(_get_dbname()) + sor.D('voucher_usage_log', conditions) + return {'status': 'success'} + + +# ========== Options API (dropdown) ========== + +async def get_voucher_template_options(): + sor = DBPools().sqlorContext(_get_dbname()) + rows = sor.R('voucher_template', {}, {'sort': 'name'}) + options = [] + for r in rows: + tid = r.id if hasattr(r, 'id') else r['id'] + name = r.name if hasattr(r, 'name') else r['name'] + options.append({'value': tid, 'text': name}) + return {'status': 'success', 'data': {'options': options}} + + +async def get_voucher_instance_options(): + sor = DBPools().sqlorContext(_get_dbname()) + rows = sor.R('voucher_instance', {}, {'sort': 'created_at desc'}) + options = [] + for r in rows: + vid = r.id if hasattr(r, 'id') else r['id'] + code = r.code if hasattr(r, 'code') else r['code'] + options.append({'value': vid, 'text': code}) + return {'status': 'success', 'data': {'options': options}} + + +# ========== Voucher Engine API ========== + +async def apply_voucher_api(customer_id, order_id, voucher_ids, context): + """外部调用:使用代金券""" + from voucher.rules.engine import batch_apply_vouchers + sor = DBPools().sqlorContext(_get_dbname()) + result = batch_apply_vouchers(sor, customer_id, order_id, voucher_ids, context) + return {'status': 'success', 'data': result} + + +async def get_available_vouchers_api(customer_id, context=None): + """外部调用:查询可用代金券""" + from voucher.rules.engine import get_available_vouchers + sor = DBPools().sqlorContext(_get_dbname()) + vouchers = get_available_vouchers(sor, customer_id, context) + items = [] + for v in vouchers: + if hasattr(v, '__dict__'): + items.append(v.__dict__) + else: + items.append(v) + return {'status': 'success', 'data': items} + + +async def get_registered_rule_types(): + """返回已注册的规则类型列表""" + from voucher.rules.registry import get_all_rule_types + return {'status': 'success', 'data': get_all_rule_types()} + + +# ========== Module Load ========== + +def load_voucher(): + """注册所有函数到 ServerEnv""" + env = ServerEnv() + + # Template CRUD + env.create_voucher_template = create_voucher_template + env.update_voucher_template = update_voucher_template + env.delete_voucher_template = delete_voucher_template + + # Rule CRUD + env.create_voucher_rule = create_voucher_rule + env.update_voucher_rule = update_voucher_rule + env.delete_voucher_rule = delete_voucher_rule + + # Instance CRUD + env.create_voucher_instance = create_voucher_instance + env.update_voucher_instance = update_voucher_instance + env.delete_voucher_instance = delete_voucher_instance + + # Usage Log CRUD + env.create_voucher_usage_log = create_voucher_usage_log + env.update_voucher_usage_log = update_voucher_usage_log + env.delete_voucher_usage_log = delete_voucher_usage_log + + # Options + env.get_voucher_template_options = get_voucher_template_options + env.get_voucher_instance_options = get_voucher_instance_options + + # Engine API + env.apply_voucher_api = apply_voucher_api + env.get_available_vouchers_api = get_available_vouchers_api + env.get_registered_rule_types = get_registered_rule_types + + # Import validators to trigger @register_rule decorators + import voucher.rules.validators # noqa: F401 + + return True diff --git a/wwwroot/api/apply_voucher.dspy b/wwwroot/api/apply_voucher.dspy new file mode 100644 index 0000000..206ff4e --- /dev/null +++ b/wwwroot/api/apply_voucher.dspy @@ -0,0 +1,17 @@ +import json + +customer_id = params_kw.get('customer_id', '') +order_id = params_kw.get('order_id', '') +voucher_ids = params_kw.get('voucher_ids', []) +if isinstance(voucher_ids, str): + voucher_ids = json.loads(voucher_ids) + +context = { + 'request_amount': params_kw.get('request_amount', 0), + 'product_type': params_kw.get('product_type', ''), + 'product_name': params_kw.get('product_name', ''), + 'user_level': params_kw.get('user_level', 0), +} + +result = await apply_voucher_api(customer_id, order_id, voucher_ids, context) +return json.dumps(result) diff --git a/wwwroot/api/get_available.dspy b/wwwroot/api/get_available.dspy new file mode 100644 index 0000000..be520a2 --- /dev/null +++ b/wwwroot/api/get_available.dspy @@ -0,0 +1,13 @@ +import json + +customer_id = params_kw.get('customer_id', '') +context = {} +if params_kw.get('product_type'): + context['product_type'] = params_kw.get('product_type') +if params_kw.get('product_name'): + context['product_name'] = params_kw.get('product_name') +if params_kw.get('request_amount'): + context['request_amount'] = params_kw.get('request_amount') + +result = await get_available_vouchers_api(customer_id, context if context else None) +return json.dumps(result) diff --git a/wwwroot/api/instance/voucher_instance_create.dspy b/wwwroot/api/instance/voucher_instance_create.dspy new file mode 100644 index 0000000..7e91ebd --- /dev/null +++ b/wwwroot/api/instance/voucher_instance_create.dspy @@ -0,0 +1,10 @@ +import json + +data = {} +for key in ['template_id', 'customer_id', 'code', 'source', 'remark', 'valid_from', 'valid_to']: + val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None + if val is not None: + data[key] = val + +result = await create_voucher_instance(data) +return json.dumps(result) diff --git a/wwwroot/api/instance/voucher_instance_delete.dspy b/wwwroot/api/instance/voucher_instance_delete.dspy new file mode 100644 index 0000000..b127f9b --- /dev/null +++ b/wwwroot/api/instance/voucher_instance_delete.dspy @@ -0,0 +1,5 @@ +import json + +conditions = {'id': params_kw.get('id', '')} +result = await delete_voucher_instance(conditions) +return json.dumps(result) diff --git a/wwwroot/api/instance/voucher_instance_options.dspy b/wwwroot/api/instance/voucher_instance_options.dspy new file mode 100644 index 0000000..c198214 --- /dev/null +++ b/wwwroot/api/instance/voucher_instance_options.dspy @@ -0,0 +1,4 @@ +import json + +result = await get_voucher_instance_options() +return json.dumps(result) diff --git a/wwwroot/api/instance/voucher_instance_update.dspy b/wwwroot/api/instance/voucher_instance_update.dspy new file mode 100644 index 0000000..e59c21c --- /dev/null +++ b/wwwroot/api/instance/voucher_instance_update.dspy @@ -0,0 +1,11 @@ +import json + +conditions = {'id': params_kw.get('id', '')} +data = {} +for key in ['template_id', 'customer_id', 'code', 'status', 'source', 'remark']: + val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None + if val is not None: + data[key] = val + +result = await update_voucher_instance(conditions, data) +return json.dumps(result) diff --git a/wwwroot/api/rule/voucher_rule_create.dspy b/wwwroot/api/rule/voucher_rule_create.dspy new file mode 100644 index 0000000..58ded38 --- /dev/null +++ b/wwwroot/api/rule/voucher_rule_create.dspy @@ -0,0 +1,10 @@ +import json + +data = {} +for key in ['template_id', 'rule_type', 'rule_config', 'enabled', 'sort_order', 'remark']: + val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None + if val is not None: + data[key] = val + +result = await create_voucher_rule(data) +return json.dumps(result) diff --git a/wwwroot/api/rule/voucher_rule_delete.dspy b/wwwroot/api/rule/voucher_rule_delete.dspy new file mode 100644 index 0000000..3a8d848 --- /dev/null +++ b/wwwroot/api/rule/voucher_rule_delete.dspy @@ -0,0 +1,5 @@ +import json + +conditions = {'id': params_kw.get('id', '')} +result = await delete_voucher_rule(conditions) +return json.dumps(result) diff --git a/wwwroot/api/rule/voucher_rule_update.dspy b/wwwroot/api/rule/voucher_rule_update.dspy new file mode 100644 index 0000000..f2473fa --- /dev/null +++ b/wwwroot/api/rule/voucher_rule_update.dspy @@ -0,0 +1,11 @@ +import json + +conditions = {'id': params_kw.get('id', '')} +data = {} +for key in ['template_id', 'rule_type', 'rule_config', 'enabled', 'sort_order', 'remark']: + val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None + if val is not None: + data[key] = val + +result = await update_voucher_rule(conditions, data) +return json.dumps(result) diff --git a/wwwroot/api/rule_types.dspy b/wwwroot/api/rule_types.dspy new file mode 100644 index 0000000..45688bb --- /dev/null +++ b/wwwroot/api/rule_types.dspy @@ -0,0 +1,4 @@ +import json + +result = await get_registered_rule_types() +return json.dumps(result) diff --git a/wwwroot/api/template/voucher_template_create.dspy b/wwwroot/api/template/voucher_template_create.dspy new file mode 100644 index 0000000..8101fbe --- /dev/null +++ b/wwwroot/api/template/voucher_template_create.dspy @@ -0,0 +1,11 @@ +import json + +params = params_kw if hasattr(params_kw, '__getitem__') else {} +data = {} +for key in ['name', 'code', 'face_value', 'total_count', 'valid_days', 'status', 'remark']: + val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None + if val is not None: + data[key] = val + +result = await create_voucher_template(data) +return json.dumps(result) diff --git a/wwwroot/api/template/voucher_template_delete.dspy b/wwwroot/api/template/voucher_template_delete.dspy new file mode 100644 index 0000000..74168fa --- /dev/null +++ b/wwwroot/api/template/voucher_template_delete.dspy @@ -0,0 +1,5 @@ +import json + +conditions = {'id': params_kw.get('id', '')} +result = await delete_voucher_template(conditions) +return json.dumps(result) diff --git a/wwwroot/api/template/voucher_template_options.dspy b/wwwroot/api/template/voucher_template_options.dspy new file mode 100644 index 0000000..f5b2292 --- /dev/null +++ b/wwwroot/api/template/voucher_template_options.dspy @@ -0,0 +1,4 @@ +import json + +result = await get_voucher_template_options() +return json.dumps(result) diff --git a/wwwroot/api/template/voucher_template_update.dspy b/wwwroot/api/template/voucher_template_update.dspy new file mode 100644 index 0000000..96c70e7 --- /dev/null +++ b/wwwroot/api/template/voucher_template_update.dspy @@ -0,0 +1,11 @@ +import json + +conditions = {'id': params_kw.get('id', '')} +data = {} +for key in ['name', 'code', 'face_value', 'total_count', 'valid_days', 'status', 'remark', 'updater']: + val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None + if val is not None: + data[key] = val + +result = await update_voucher_template(conditions, data) +return json.dumps(result) diff --git a/wwwroot/api/usage/voucher_usage_log_create.dspy b/wwwroot/api/usage/voucher_usage_log_create.dspy new file mode 100644 index 0000000..267f92f --- /dev/null +++ b/wwwroot/api/usage/voucher_usage_log_create.dspy @@ -0,0 +1,10 @@ +import json + +data = {} +for key in ['instance_id', 'customer_id', 'order_id', 'face_value', 'deducted_amount', 'used_at', 'used_by', 'product_type', 'product_name']: + val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None + if val is not None: + data[key] = val + +result = await create_voucher_usage_log(data) +return json.dumps(result) diff --git a/wwwroot/api/usage/voucher_usage_log_delete.dspy b/wwwroot/api/usage/voucher_usage_log_delete.dspy new file mode 100644 index 0000000..25cd76d --- /dev/null +++ b/wwwroot/api/usage/voucher_usage_log_delete.dspy @@ -0,0 +1,5 @@ +import json + +conditions = {'id': params_kw.get('id', '')} +result = await delete_voucher_usage_log(conditions) +return json.dumps(result) diff --git a/wwwroot/api/usage/voucher_usage_log_update.dspy b/wwwroot/api/usage/voucher_usage_log_update.dspy new file mode 100644 index 0000000..fe689ec --- /dev/null +++ b/wwwroot/api/usage/voucher_usage_log_update.dspy @@ -0,0 +1,11 @@ +import json + +conditions = {'id': params_kw.get('id', '')} +data = {} +for key in ['instance_id', 'customer_id', 'order_id', 'face_value', 'deducted_amount', 'used_by', 'product_type', 'product_name']: + val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None + if val is not None: + data[key] = val + +result = await update_voucher_usage_log(conditions, data) +return json.dumps(result) diff --git a/wwwroot/index.ui b/wwwroot/index.ui new file mode 100644 index 0000000..eb0783b --- /dev/null +++ b/wwwroot/index.ui @@ -0,0 +1,48 @@ +{ + "widgettype": "VBox", + "options": {"width": "100%", "height": "100%", "padding": "20px"}, + "subwidgets": [ + {"widgettype": "Title3", "options": {"text": "代金券管理"}}, + {"widgettype": "ResponsableBox", "options": {"gap": "16px", "minWidth": "250px"}, + "subwidgets": [ + {"widgettype": "VBox", + "options": {"bgcolor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "8px", "cwidth": "20"}, + "binds": [{ + "wid": "self", "event": "click", "actiontype": "urlwidget", + "target": "app.voucher_content", + "options": {"url": "{{entire_url('/voucher/voucher_template_list/index.ui')}}"}, + "mode": "replace" + }], + "subwidgets": [ + {"widgettype": "Text", "options": {"text": "模板管理", "fontSize": "18px", "bold": true}}, + {"widgettype": "Text", "options": {"text": "管理代金券模板和规则配置", "fontSize": "14px", "color": "#666"}} + ]}, + {"widgettype": "VBox", + "options": {"bgcolor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "8px", "cwidth": "20"}, + "binds": [{ + "wid": "self", "event": "click", "actiontype": "urlwidget", + "target": "app.voucher_content", + "options": {"url": "{{entire_url('/voucher/voucher_instance_list/index.ui')}}"}, + "mode": "replace" + }], + "subwidgets": [ + {"widgettype": "Text", "options": {"text": "券实例管理", "fontSize": "18px", "bold": true}}, + {"widgettype": "Text", "options": {"text": "查看和发放代金券", "fontSize": "14px", "color": "#666"}} + ]}, + {"widgettype": "VBox", + "options": {"bgcolor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "8px", "cwidth": "20"}, + "binds": [{ + "wid": "self", "event": "click", "actiontype": "urlwidget", + "target": "app.voucher_content", + "options": {"url": "{{entire_url('/voucher/voucher_usage_log_list/index.ui')}}"}, + "mode": "replace" + }], + "subwidgets": [ + {"widgettype": "Text", "options": {"text": "使用流水", "fontSize": "18px", "bold": true}}, + {"widgettype": "Text", "options": {"text": "查看代金券使用记录", "fontSize": "14px", "color": "#666"}} + ]} + ]}, + {"widgettype": "VBox", "id": "voucher_content", + "options": {"width": "100%", "flex": "1", "marginTop": "20px"}} + ] +} diff --git a/wwwroot/menu.json b/wwwroot/menu.json new file mode 100644 index 0000000..e29b702 --- /dev/null +++ b/wwwroot/menu.json @@ -0,0 +1,25 @@ +{ + "items": [ + { + "name": "voucher_template_list", + "label": "模板管理", + "icon": "fa fa-ticket", + "url": "{{entire_url('/voucher/voucher_template_list/index.ui')}}", + "target": "app.sage_main_content" + }, + { + "name": "voucher_instance_list", + "label": "券实例", + "icon": "fa fa-list", + "url": "{{entire_url('/voucher/voucher_instance_list/index.ui')}}", + "target": "app.sage_main_content" + }, + { + "name": "voucher_usage_log_list", + "label": "使用流水", + "icon": "fa fa-history", + "url": "{{entire_url('/voucher/voucher_usage_log_list/index.ui')}}", + "target": "app.sage_main_content" + } + ] +}