voucher — 代金券模块(pipeline-app 宿主)
独立代金券管理模块:可配置规则引擎、一次性使用、不找零、并发安全。
- 宿主:pipeline-app(库名走
get_module_dbname('voucher'),pipeline 宿主下=pipeline) - 版本:2.0.0(2026-09-12 sqlor 风格全异步重写,替代 1.x sage 时代废代码)
核心架构
模板 voucher_template(面值/发行量/有效期/状态,org_id 隔离)
└─ 规则集 voucher_rule(可配置,增删不改代码;按 sort_order 依次校验)
└─ 实例 voucher_instance(券码/状态 unused→used|expired|void,一次性)
└─ 流水 voucher_usage_log(订单/抵扣金额/产品,只读审计)
关键机制
| 机制 | 实现 |
|---|---|
| 一次性使用 | 核销用条件 UPDATE(status='unused' 守卫)+ affected_rows 判定,并发双花只有一个成功 |
| 发行量控制 | issued_count<total_count 原子占额;total_count=0 不限量;未使用券删除回吐名额 |
| 规则可配置 | @register_rule 注册 validator 纯函数,模板下挂 voucher_rule 行即生效 |
| 作废 | invalidate_voucher_api:unused→void(条件 UPDATE),已用/过期/已作废拒绝,原因追加 remark 审计留痕 |
| 过期清扫 | expire_vouchers:查可用券/我的券前自动把过期 unused 置 expired |
| 规则损坏 | rule_config 非法 JSON → 拒绝使用(不静默放行,防绕过限制) |
| 注册送券 | bind pipeline:organization:c:after 事件:新客户机构注册自动发 WELCOME400 欢迎券;幂等(同机构不重发);「前 N 名」由 total_count 原子占额控制;异常只记日志不阻断注册 |
内置规则类型(短名,appcodes_kv 组合键 ≤32 字符约束)
| rule_type | rule_config 示例 | 说明 |
|---|---|---|
min_amount |
{"min_value": 100} |
最低消费门槛 |
max_amount |
{"max_value": 1000} |
最高消费上限 |
product_type |
{"product_types": ["pipeline"]} |
限定产品类型 |
product |
{"products": ["PP_X"]} |
限定特定产品 |
exclude_product |
{"products": ["PP_X"]} |
排除特定产品 |
max_usage_count |
{"max_count": 1} |
最大使用次数 |
user_level |
{"min_level": 2} |
用户等级下限 |
新增规则:voucher/rules/validators.py 加一个 @register_rule('xxx') 纯函数 + data.json 字典补一行,无需改引擎。
页面与 API
管理侧(logined;菜单「代金券管理」,工单三角色可见)
| 页面 | 路径 | 说明 |
|---|---|---|
| 入口 | /voucher/index.ui |
卡片导航:模板管理/券实例管理/使用流水 |
| 模板管理 | /voucher/voucher_template_list/index.ui |
CRUD + 工具栏「发券」弹窗 + 子表「规则配置」 |
| 券实例 | /voucher/voucher_instance_list/index.ui |
新增=发券引擎(禁裸插入)+ 工具栏「作废」弹窗 + 子表「使用记录」 |
| 使用流水 | /voucher/voucher_usage_log_list/index.ui |
只读(noedit) |
管理 API(wwwroot/api/,全部薄封装 → init.py ServerEnv 函数):
- template/:create/update/delete/options(options 返回纯
[{value,text}]数组) - rule/:create/update/delete;
rule_types.dspy返回已注册类型清单 - instance/:
voucher_issue.dspy(发券)、update(仅 unused 可改客户/备注)、delete(used 禁删)、voucher_invalidate.dspy(作废) apply_voucher.dspy(核销)、get_available.dspy(内部查可用)
客户侧(logined;菜单「我的代金券」)
| 页面/端点 | 路径 | 说明 |
|---|---|---|
| 我的代金券 | /voucher/my/index.ui |
本机构全部券(含已用/过期/作废),状态过滤 |
| 自助查询 | /voucher/api/v1/available.dspy |
可用券(登录态按机构隔离,支持 product_type/product_name/request_amount 过滤) |
| 列表 API | /voucher/api/my_vouchers.dspy |
Tabular 契约 {total, rows} |
其他模块调用(ServerEnv)
# 结算联动(product_management/accounting 等):
result = await env.voucher_batch_apply(sor, customer_id, order_id, voucher_ids, context)
# → {'total_deducted': 150.0, 'remaining': 50.0, 'details': [...]}
# remaining > 0 时从余额扣
# context: {request_amount(必填), product_type, product_name, user_level, used_by}
注册函数全集(load_voucher()):get_available_vouchers_api / apply_voucher_api / validate_voucher_api / my_vouchers_api / 模板规则实例管理函数 / issue_voucher_api / invalidate_voucher_api / get_voucher_template_options / get_registered_rule_types / 引擎级 voucher_issue / voucher_apply / voucher_batch_apply / voucher_get_available。
目录结构
voucher/
├── voucher/ # Python 包
│ ├── init.py # ServerEnv 注册 + 注册送券挂钩 + load_voucher()
│ └── rules/ # registry(@register_rule) + validators(7种) + engine(全异步)
├── init/data.json # 字典种子(模板状态/实例状态/规则类型/来源/启用标志)
├── models/ # 4 张表定义(建表唯一事实源,json2ddl 渲染)
├── json/ # 4 个 CRUD 定义(xls2ui 生成 wwwroot/voucher_*_list/,生成物不入库)
├── wwwroot/
│ ├── index.ui # 管理入口(卡片导航)
│ ├── my/index.ui # 客户「我的代金券」
│ ├── issue_form.ui # 发券弹窗表单
│ ├── invalidate_form.ui # 作废弹窗表单
│ └── api/ # 薄封装 dspy(零 import,委托 init.py 注册函数)
├── scripts/
│ ├── load_path.py # RBAC 注册(logined;CRUD 目录只注册 index.ui+get_* 只读,
│ │ # 生成的 add/update/delete 写端点不注册——裸 sor.C/U/D 绕过
│ │ # 全部业务校验,未注册即 403,写操作只走 api/ 有校验端点)
│ └── seed_welcome_voucher.py # WELCOME400 种子(幂等)
├── i18n/{zh,en,jp,ko}/msg.txt # key=value 格式(merge_i18n 消费)
└── pyproject.toml # packages = ["voucher", "voucher.rules"]
部署清单(pipeline-app 宿主,六处登记)
build.sh:clone/pip install/xls2ui/软链四个清单加voucher(已登记)app/pipeline_app.py:import +load_voucher()(已登记,ticket 之后)scripts/import_init.py:INIT_MODULES 加 voucher 行(已登记)- 建表:
create_tables.py动态扫描 pkgs/*/models 自动覆盖(无需改) wwwroot/index.ui:客户「我的代金券」+ 管理「代金券管理」菜单(已登记,is_ticket_staff 门禁)- RBAC:
./load_path.sh(自动扫 pkgs/*/scripts/load_path.py)→redis-cli -n 0 FLUSHDB→ 重启
部署后一次性操作:
cd <APP_ROOT>
./py3/bin/python pkgs/voucher/scripts/seed_welcome_voucher.py # WELCOME400 种子
Pitfalls
- 实例「新增」必须走发券 API(voucher_issue.dspy → issue_voucher 引擎):裸插入会绕过原子占额/券码生成/有效期计算。CRUD json 的
editable.new_data_url已指向它。 - 状态字段禁止界面直改:使用走核销 API、作废走 invalidate API(都有条件 UPDATE 原子守卫);update_voucher_instance 只允许改 customer_id/remark 且仅 unused 券。
- 一次性使用:不追踪 remaining_value/partial 状态,抵扣 = min(面值, 消费金额),不找零。
- rule_config 数组字段用 JSON array(validators 兼容逗号分隔串,但写入必须是 JSON)。
- rule_type 用短名(product_type 而非 applicable_product_type)——appcodes_kv.id =
{parentid}_{k}≤32 字符。 - 模板删除:已发放(issued_count>0)拒绝,引导用「停用」;删除级联删规则,已发出的券保留但 validate 拒绝(模板非 active)。
- dspy 薄封装返回 dict(框架自动序列化),禁 json.dumps 双重序列化(Pitfall 38)。
- datetime 字段回前端必须 str 化(
_jsonable_instance),框架 json.dumps 无 default=str。 - 弹窗表单 submited 事件的 params 是 Response 对象,要
await resp.json()后再 widgetBuild(form.js dispatch 原始 resp)。 - 【安全铁律】CRUD 生成的 add/update/delete dspy 不注册权限:它们是裸 sor.C/U/D,绕过发券引擎原子占额/流水审计只读/org 隔离/状态守卫。只注册 index.ui + get_* 只读;写操作只走 wwwroot/api/ 有校验端点。2026-09-12 实测抓洞:统一注册四件套时 update/delete_voucher_usage_log.dspy 登录态 200 可篡改审计流水(已修,commit 0fbd0ca)。
仓库
- 远端:git@git.opencomputing.cn:yumoqing/voucher.git(main)
- 宿主接入提交:pipeline-app(build.sh/pipeline_app.py/import_init.py/index.ui)
Description
Languages
Python
99.7%
Shell
0.3%