64 KiB
| name | version | description | trigger_conditions | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| supplychain-pitfalls | 1.3.0 | supplychain/Sage 模块开发的反复踩坑教训,涉及 xls2ui、RBAC、bricks 限制、部署流程、dspy 编码规范。 |
|
⚠️ 工作流铁律(最高优先级)
-
四阶段门禁强制:dev → review → test → audit。每个阶段留证据,未测试禁提交。本地修改→commit→push→服务器pull + pip install + 重启→curl/浏览器冒烟。部署完成才算阶段结束,不允许口头声称通过。
-
开发在本地,不在服务器:本地编码→git commit→git push→服务器git pull+pip install。严禁SSH直改服务器源文件(git pull会覆盖)。
-
必须浏览器实测:curl 200 ≠ 功能正常。完整链路:页面加载 → 数据加载 → 点击按钮 → 弹窗内容正确渲染 → console 无错误。
-
修改 → commit → push → 服务器 git pull → xls2ui(如改 JSON 或有 CRUD 目录缺失)→ pip install(如改 .py)→ 重启 → 浏览器测。不允许跳过任何一步。
-
服务器有本地修改时先
git checkout -- .再 pull。绝不 scp/sed 手动改服务器文件。 -
测试失败要说清楚失败在哪,不要沉默或装作通过。
-
login 表单对程序化操作不响应时,换 real browser 登录。 不循环重试同一种方法。
-
CRUD 目录部署后检查:
git pull后检查 xls2ui 生成的目录是否存在(如wwwroot/supply_contracts_list/index.ui),若缺失则运行xls2ui -m models -o wwwroot <modulename> json/*.json(见坑 64)。
坑 1-36 略(已稳定,内容不变)
内容与上一版本一致,此处省略以节省空间。
坑 37:产品→供应商的正确链路(product_resource_supplier)
现象:供销协议折扣明细空表,或因用错过滤字段显示了其他供应商的产品。
根因:三次试错:
- ❌
p.org_id— 产品归属机构,大部分是'0',不等于供应商 org - ❌
product_supplier_mapping.supplier_org_id— 供销路由表,非产品→供应商直连 - ✅
product.resource_ref_id → product_resource_supplier.supplier_org_id— 产品通过关联的资源找到资源供应方
正确 JOIN(get_contract_discount_items):
# Step 1: supplychain → 供应商 orgid
async with get_sor_context(env, 'supplychain') as sor:
rows = await sor.sqlExe(
"SELECT s.orgid FROM suppliers s "
"JOIN supply_contracts sc ON sc.supplier_id = s.id "
"WHERE sc.id = ${cid}$", {'cid': contract_id})
supplier_orgid = rows[0].orgid if rows else None
# Step 2: sage → product JOIN product_resource_supplier
async with get_sor_context(env, 'sage') as sor:
rows = await sor.sqlExe(
"SELECT DISTINCT p.id as productid, p.product_name, p.product_code, "
"p.price as sale_price, pc.id as category_id, pc.name as category_name "
"FROM product p "
"JOIN product_resource_supplier prs ON p.resource_ref_id = prs.product_resource_id "
"LEFT JOIN product_category pc ON p.category_id = pc.id "
"WHERE p.status = '1' AND prs.supplier_org_id = ${oid}$ AND prs.status = '1' "
"ORDER BY pc.sort_order, pc.name, p.sort_order, p.product_name",
{'oid': supplier_orgid})
数据模型链:
product.resource_ref_id → product_resource.id → product_resource_supplier(product_resource_id, supplier_org_id)
教训:涉及产品-供应商关联时,不要猜字段名。先看模型 JSON 确认真实链路。
坑 39:供销协议折扣明细 → 直接用 product.providerid 过滤(推荐)
现象:坑 37 的 product_resource_supplier JOIN 过于复杂,且依赖资源路由表的及时更新。
最终方案:product 表已有 providerid 字段(从资源导入时自动填入 llm.providerid),直接用它匹配合同的 supplier_id。
async def get_contract_discount_items(request):
env = request._run_ns
# 1. 合同 → supplier_id
async with get_sor_context(env, 'supplychain') as sor:
rows = await sor.sqlExe(
"SELECT supplier_id FROM supply_contracts WHERE id = ${cid}$",
{'cid': contract_id})
supplier_id = rows[0].supplier_id if rows else None
if not supplier_id:
return []
# 2. 产品过滤:p.providerid = supplier_id(简单直接)
async with get_sor_context(env, 'sage') as sor:
rows = await sor.sqlExe(
"SELECT p.id as productid, p.product_name, p.product_code, "
"p.price as sale_price, pc.id as category_id, pc.name as category_name "
"FROM product p "
"LEFT JOIN product_category pc ON p.category_id = pc.id "
"WHERE p.status = '1' AND p.providerid = ${sid}$ "
"ORDER BY pc.sort_order, pc.name, p.sort_order, p.product_name",
{'sid': supplier_id})
providerid 来源链路:llm.providerid → load_product_category_product() → import_categories_and_products() → product.providerid
注意:如果产品是从旧导入(没有 providerid),先用 MySQL 回填:
UPDATE product p JOIN llm l ON p.resource_ref_id = l.id
SET p.providerid = l.providerid
WHERE p.providerid IS NULL AND l.status = 'published';
坑 40:ServerEnv() 单例 ≠ request._run_ns → get_user() 返回空
现象:dspy/init.py 中 env = ServerEnv(); user_id = await env.get_user() 返回 None/AttributeError,导致 created_by、sale_id 等字段为 NULL。数据库验证:SELECT created_by FROM discount_marketing 全部 NULL。
根因:self.y_env = DictObject() (processorResource.py:104) 是普通 DictObject。request._run_ns = self.run_ns (baseProcessor.py:87) 包含了 y_env。get_user() / get_userid() / get_userorgid() 注册在 self.y_env (processorResource.py:347-349),最终在 request._run_ns。但 ServerEnv() 是独立单例 DictObject (serverenv.py),和 request._run_ns 不是同一对象。
修复:env = ServerEnv() → env = request._run_ns:
# ❌ WRONG — get_user() 在 ServerEnv 上不存在
async def create_marketing(request, params_kw):
env = ServerEnv()
user_id = await env.get_user() # None!
# ✅ CORRECT — 参考 generate_promo_code 的正确写法
async def create_marketing(request, params_kw):
env = request._run_ns
user_id = await env.get_user()
注意:get_module_dbname 在 ServerEnv() 上有 (ext.py:72 注册),get_user 只在 request._run_ns 上。但 request._run_ns 也通过 RF 机制拥有 get_module_dbname,所以统一用 request._run_ns 即可。
排查:检查所有使用 ServerEnv() + get_user/get_userorgid 的函数:
grep -n 'env = ServerEnv()' init.py | while read line; do
lineno=$(echo "$line" | cut -d: -f1)
sed -n "$((lineno+1)),$((lineno+6))p" init.py | grep -q 'get_user' && echo "LINE $lineno: NEEDS FIX"
done
坑 41:CRUD JSON 的 editable 嵌套被 xls2ddl 忽略 → new_data_url 不生效
现象:JSON 配置 params.editable.new_data_url,但浏览器实际 POST 到默认 add_xxx.dspy,自定义端点从未调用。日志中无任何自定义端点访问记录。
根因:xls2ddl 模板 (tmpls.py:31) 读的是 params.new_data_url,不认 params.editable.new_data_url。嵌套 editable 被静默忽略,fallback 到默认值。
修复:将三个 URL 提升到 params 顶层,删除 editable 包装:
// ❌ WRONG — editable 嵌套被忽略
"params": {
"editable": {
"new_data_url": "{{entire_url('../api/marketing_create.dspy')}}"
}
}
// ✅ CORRECT
"params": {
"new_data_url": "{{entire_url('/discount/api/marketing_create.dspy')}}",
"update_data_url": "{{entire_url('/discount/api/marketing_update.dspy')}}",
"delete_data_url": "{{entire_url('/discount/api/marketing_delete.dspy')}}"
}
验证:改 JSON 后必须重跑 xls2ui 再 grep index.ui 确认:
xls2ui -m models -o wwwroot/discount discount json/discount_marketing_list.json
grep 'new_data_url' wwwroot/discount/discount_marketing_list/index.ui
# 应输出: "new_data_url": "{{entire_url('/discount/api/marketing_create.dspy')}}"
坑 42:logined_userid 双重作用 — 查询过滤 + 插入注入
现象:加 "logined_userid": "sale_id" 后列表只显示自己的记录——这正是模板的设计行为,不是 bug。
模板逻辑 (tmpls.py):
- get (line 180-181):追加
filterjson['AND'].append({'field': field, 'op': '=', ...})→ 强制WHERE sale_id = <userid> - add (line 226-239):
ns['field'] = userid→ 插入时自动填值
适用场景:只有「列表归属于当前用户」的场景才用 logined_userid(如促销码)。不要用于只想插入时填值的场景(如营销方案的 created_by)——用自定义 new_data_url + request._run_ns 代替。
坑 43:sor.R 字段名不匹配模型 → 返回全表+role_recs[0]取错记录
现象:add_sub_distributor_admin.dspy 给管理员分配角色后,实际得到的是 owner.* 而非 reseller.admin。
根因:sor.R('role', {'role': 'reseller.admin'}) — 模型 role 表的字段是 id, orgtypeid, name,没有 role 字段。sor.R 忽略未知字段 → 等效于 {} → 返回全部 27 条 role → role_recs[0] 每次都取第一条(owner.*)。
正确写法:
# ❌ WRONG — 'role' 不在模型字段中
role_recs = await sor.R('role', {'role': 'reseller.admin'})
# ✅ CORRECT — 用模型实际字段 orgtypeid + name
role_recs = await sor.R('role', {'orgtypeid': 'reseller', 'name': 'admin'})
教训:sor.R() 的过滤字段必须匹配模型 JSON 中的 fields[].name。写之前用模型 JSON 验证字段名:
python3 -c "import json; [print(f['name']) for f in json.load(open('models/role.json'))['fields']]"
坑 44:不要给用户手动分配 logined/any 角色
get_userroles() 自动为所有登录用户追加 ['any', 'logined']。手动在 userrole 表中插入 logined 角色是多余的,且浪费一次查询。
# ❌ WRONG
for role_name in ['logined', 'reseller.admin', 'reseller.operator']:
...
# ✅ CORRECT
for orgtypeid, role_name in [('reseller', 'admin'), ('reseller', 'operator')]:
role_recs = await sor.R('role', {'orgtypeid': orgtypeid, 'name': role_name})
坑 45:logined_userorgid + default_filterjson 抵消自定义 OR 子查询
现象:自定义 get_product.dspy 已写 WHERE (org_id = ${userorgid}$ OR id IN (SELECT ... FROM product_org_auth)),但日志显示实际 SQL 末尾多了 AND org_id = 'xxx',导致 OR 子查询被抵消,返回 0 行。
日志证据:
default_filterjson():c=[{'field': 'org_id', 'op': '=', 'var': 'org_id'}]
sql="... where (org_id = ${userorgid}$ OR id IN (...)) and org_id = ${org_id}$"
根因:JSON 中 logined_userorgid: "org_id" → 模板注入 ns['org_id'] = userorgid。随后 default_filterjson 遍历 ns 中匹配模型字段的 key,发现 org_id → 追加 AND org_id = ${org_id}$。这个额外 AND 把前面 OR 子查询完全抵消。
修复:在自定义 get dspy 中,ns['org_id'] = userorgid 之后立即 del ns['org_id']:
ns['org_id'] = userorgid
ns['userorgid'] = userorgid
del ns['org_id'] # 防止 default_filterjson 生成 AND org_id=...
SQL 仍可用 ${userorgid}$(保留在 ns 中),但 default_filterjson 不会再捡到 org_id。
验证:查看 sage 日志确认不再出现 default_filterjson():c=[...'org_id'...]
坑 38:产品资源导入的增量模式 + providerid
(内容不变,略)
坑 46:自定义 get dspy 中 dbname 来源 — 跨库查询必须分别建 sqlorContext
现象:自定义 get dspy 中直接写 dbname = get_module_dbname('product_management') → 但 product 表在 sage 库。
正确做法:get dspy 中 dbname 变量由模板注入,指向模块数据库。如果 SQL JOIN 了其他库的表(如 product_org_auth),直接在 SQL 中跨库引用即可(同库)。如果确实需要两个数据库连接,用 get_sor_context。
坑 47:模块初始化字典数据 — init/data.json 的 appcodes 结构
模块的字典初始化数据(如下拉选项)应放在 init/data.json,Sage 框架会自动加载:
{
"appcodes": [
{
"parentid": "supplier_settle_cycle",
"parentname": "结算周期",
"items": [
{"k": "monthly", "v": "月结"},
{"k": "quarterly", "v": "季结"}
]
}
]
}
parentid → appcodes_kv.parentid,k/v → appcodes_kv.k/v。注意 appcodes_kv 表无 sort_order 字段(只有 id/parentid/k/v),排序在 SELECT 时处理。
如果数据已存在于 data.json 但数据库缺数据,可能是模块初次安装时未触发初始化。用脚本手动加载(见 references/init-data-loader.md)。
坑 48:data_filter 在 JSON params 中 vs 运行时 params_kw — 容易混淆
JSON 中的 data_filter 是模板生成时的过滤器定义。运行时 params_kw.get('data_filter') 来自 HTTP 请求参数,不是同一个东西。
当浏览器不传 data_filter 参数时,filterjson = None,触发 default_filterjson(fields, ns) 回退逻辑。default_filterjson 遍历 ns 中所有匹配模型字段名的 key,自动生成 AND field = value 条件。这就是坑 45 中 org_id 被自动加入过滤的原因。
教训:自定义 get dspy 中使用 default_filterjson 时,确保 ns 中没有不该参与过滤的字段。必要时在调用前 del ns['unwanted_field']。
坑 49:Bricks i18n — 客户端 tip 硬编码英文 + i18n.json 需合并到全局
详见 references/bricks-i18n.md。Bricks 菜单父项点击报错修复见 references/bricks-menu-fix.md。Organization orgname 为空兜底见 references/orgname-null-fallback.md。
坑 50:users 表 username/nick_name 为 NULL → UiCode 下拉显示空
现象:自由客户分配页面中,UiCode 销售下拉数据显示 {"value": "<uuid>", "text": null},大部分销售员名字为空。
根因:users 表中 username 和 nick_name 字段可能为 NULL(早期用户或自动创建的用户未设这些字段)。查询 u.nick_name as sale_name 时直接返回 NULL。
修复:使用 COALESCE 回退链始终返回可显示值:
# ❌ 可能返回 NULL
SELECT u.id, u.nick_name as sale_name FROM users u ...
# ✅ COALESCE 回退:username → nick_name → id
SELECT u.id, COALESCE(u.username, u.nick_name, u.id) as sale_name FROM users u ...
适用场景:任何查询 users 表用于前端显示的 SQL(下拉选项、表格列等)。同样模式可用于 organization 表或任何可能 NULL 的显示字段。
注意:get_free_customers 中 JOIN 目标也需纠正:
customerid存users.id(从init_free_customers.dspy用users.id初始化)- ❌
JOIN organization o ON o.id = dcb.customerid— 用户 ID 不等于机构 ID - ✅
LEFT JOIN users u ON u.id = dcb.customerid,显示COALESCE(u.username, u.nick_name, u.id) as customer_name
坑 51:get_reseller_sales 销售列表过滤 — 不要为 org='0' 开全量
现象:get_reseller_sales 中 if userorgid == '0' 分支查全量销售员,导致 org='0' 的管理员看到其他机构的销售。用户反馈:topc1_admin 不应出现在 sword(org 0)的销售清单中。
修复:去掉 userorgid == '0' 特殊分支,统一按 u.orgid = ${oid}$ 过滤。
坑 52:customerid 字段语义 — 存机构ID非用户ID
详见 references/customer-bind-org-pattern.md。核心:discount_customer_bind.customerid 是 organization.id,不是 users.id。初始化时按机构去重,JOIN organization 显示 orgname。
坑 38:产品资源导入的增量模式 + providerid
现象:重复执行资源导入会覆盖手动修改的产品数据,且新导入产品缺 providerid。
修复(product_management/core.py 和 product_management/models/product.json):
import_categories_and_products中updated_prods→skipped_prods:已有产品直接跳过(skipped_prods += 1; continue),不再更新- 创建产品时写入
providerid = prod.get('providerid', '') product.json模型添加providerid varchar(32)字段- 各资源模块的
load_product_category_product需在返回数据中包含providerid(如 llmage 从llm.providerid取) - 服务器需执行
ALTER TABLE product ADD COLUMN providerid varchar(32) DEFAULT NULL AFTER org_id
坑 53:JSON subtables 已定义但 index.ui 缺少 toolbar/bind → 重跑 xls2ui
现象:JSON 配置中已正确定义 subtables(如合同附件),但浏览器查看列表页时 toolbar 没有对应按钮,点开行展开也没有附件 tab。比如供销合同/分销协议的附件管理 subtable 在 JSON 中有定义但页面上不显示。
根因:xls2ui 生成 index.ui 时根据 JSON 中的 subtables 生成 toolbar tools 和 binds。如果 JSON 后来添加了 subtable 但没重跑 xls2ui,或者之前的 xls2ui 版本不支持该 subtable 模式,生成的 index.ui 就缺少对应内容。
诊断:对比 JSON 的 subtables 和生成的 index.ui:
# JSON 中定义的 subtables
python3 -c "import json; d=json.load(open('json/supply_contracts_list.json')); [print(s['title']) for s in d['params'].get('subtables',[])]"
# index.ui 中的 toolbar tools
grep -A2 '"name"' wwwroot/supply_contracts_list/index.ui | grep -v '^--$'
# index.ui 中的 binds
grep '"event"' wwwroot/supply_contracts_list/index.ui
修复:重跑 xls2ui 重新生成 index.ui:
cd /path/to/module
xls2ui -m models -o wwwroot <modulename> json/<list_name>.json
# 示例:
xls2ui -m models -o wwwroot supplychain json/supply_contracts_list.json json/distribution_agreements_list.json
验证:grep 生成的 index.ui 确认 toolbar 和 bind 都存在:
grep 'contract_attach' wwwroot/supply_contracts_list/index.ui
# 应输出:toolbar name + bind event + bind url 三处引用
注意:
xls2ui也会重新生成 add/get/delete dspy 文件,这些通常不需要特殊处理- toolbar 按钮对应的图标放在
<module>/wwwroot/imgs/<name>.svg,由 Sage 框架通过/imgs/<name>.svg路径自动发现和 serve - wwwroot 的
imgs/目录和列表目录下的文件如果被.gitignore忽略,用git add -f强制添加
部署:纯 wwwroot 变更(index.ui、dspy、imgs,未改 .py 文件)只需服务器 git pull,无需 pip install 或重启 Sage。
坑 54:bricks Tabular render({}) 参数去重 — 上传/删除后列表不刷新
现象:合同附件上传后后台返回成功({"widgettype":"Message",...}),但附件列表不刷新,必须重新打开弹窗才能看到新文件。删除同理——确认删除后列表无变化。
根因:bricks DataViewer.render(params) 有参数去重逻辑(dataviewer.js:46-50):
async render(params) {
params = this.merge_search_params(params || {});
if (params == this.old_params){ // ← 对象引用比较!
return; // ← 相同则跳过!
}
this.old_params = params;
// ... 实际加载数据
}
上传后脚本调用 tbl.render({}) → merge_search_params({}) 返回和上次相同的参数对象 → == 为 true → 直接 return,不加载数据。
修复:调用 render() 前将 old_params 置 null 破环:
// ❌ WRONG — 相同参数被跳过
tbl.render({});
// ✅ CORRECT — 破环强制刷新
tbl.old_params = null;
tbl.render({});
或者直接调用无参的 tbl.render()(不传 {}),因为 add_record_finish 中已验证 this.render() 可正常刷新。
完整上传脚本模板(含消息显示):
fetch(upload_url, {method:'POST', body:formData})
.then(function(r){return r.json()})
.then(function(msg){
var tbl = bricks.getWidgetById('attach_tbl');
if (tbl) { tbl.old_params = null; tbl.render({}); }
self.value = null;
if (msg.widgettype) bricks.widgetBuild(msg).then(function(w){if(w)w.open()});
})
.catch(function(e){ console.error('upload failed', e); });
完整删除脚本模板:
fetch(delete_url + '?id=' + encodeURIComponent(record.id) + '&fiid=' + fiid, {method:'POST'})
.then(function(r){return r.json()})
.then(function(r){
if (r.widgettype === 'Message') {
self.old_params = null;
self.render({});
} else if (r.widgettype === 'Error') {
alert(r.options.message);
}
});
适用场景:任何在 bricks Tabular/DataViewer 上通过自定义脚本调用 render({}) 刷新的场景(上传后刷新、删除后刷新、手动触发重载等)。filter_form_submited(dataviewer.js:372-382)中已有正确示范:先 this.old_params = null; this.loader.pages = []; 再 this.render({})。
坑 55:DBPools() 无参构造 → 独立进程中 sqlorFactory 报 NoneType
现象:backend_accounting.py 启动后报错:AttributeError: 'NoneType' object has no attribute 'get',调用栈追溯到 sqlorFactory → dbdesc.get('driver', dbdesc) 中 dbdesc 为 None。
修复(优先 monkey-patch 方案,无需改第三方模块):
# backend_accounting.py 中,用正确配置初始化后替换 get_business_date
p = ProgramPath()
config = getConfig(NS={'workdir': os.getcwd(), 'ProgramPath': p})
db = DBPools(config.databases)
import appbase.businessdate as _bd
async def _patched_get_business_date(sor=None):
async def _f(sor):
sql = "select * from params where params_name = 'business_date'"
recs = await sor.sqlExe(sql, {})
if len(recs) > 0:
return recs[0]['params_value']
raise Exception('BusinessDateParamsError')
if sor:
return await _f(sor)
async with db.sqlorContext(get_module_dbname('appbase')) as sor:
return await _f(sor)
_bd.get_business_date = _patched_get_business_date
注意:DBPools 不是单例模式,每个 DBPools() 调用都创建独立实例。独立进程(backend_accounting.py、cron job 等)中所有被调用的模块都必须使用已配置的实例。
修复范围:不仅 businessdate.py,任何在独立进程中被间接调用的代码都不能 new DBPools()。应该通过参数传入 sor,或使用 getConfig().databases 初始化。
部署:修改后需 pip install . + 重启对应进程(Sage 主进程和 backend_accounting 都要重启)。
坑 56:product_accounting 中 resellerid=providerid → 供应商被当成分销商记账
现象:生产记账报错 AccountIdNone(orgid='6fadgewjraOyvxC_EkHou', subjectname='商户采购成本'),org 是供应商(阿里云)但缺少"商户采购成本"科目账户。
根因:product_accounting() 第1459行 PAY* 腿:
action='PAY*', customerid=ownerid, resellerid=providerid, providerid=providerid
resellerid 被设为 providerid(供应商 org)。记账系统 get_orgid_by_trans_role 把 role='reseller' 映射到 self.resellerid → 供应商被当成分销商来查科目账户 → 供应商没有 reseller 账户 → AccountIdNone。
正确逻辑:供应商应通过 role='provider' 查账户,不应出现在 resellerid 位置。PAY* 腿的 resellerid 应为上游分销商(owner),providerid 才是供应商:
# 当前(错误):resellerid=providerid → 供应商被当成分销商
action='PAY*', customerid=ownerid, resellerid=providerid, providerid=providerid
# 应为:resellerid=ownerid → 平台作为分销商,provider 是供应商
action='PAY*', customerid=ownerid, resellerid=ownerid, providerid=providerid
影响范围:所有 ownerid=0 且 llm 有 providerid 的产品记账(如阿里云 qwen 系列模型)。
坑 57:product 表列名是 product_name 不是 name → 500
现象:get_reseller_reconcile.dspy 生产环境 500,SQL 报 Unknown column 'p.name'。
根因:product 表的列名是 product_name,代码中错写成 p.name。DESC product 可确认。
修复:p.name → p.product_name(SELECT、GROUP BY、ORDER BY 共 3 处)。同时移除 JOIN ON 中无用的 COLLATE utf8mb4_unicode_ci。
教训:JOIN product 表时列名是 product_name 不是 name。
坑 58:DSPY 端点公开给 Cron — PATHS_LOGINED → PATHS_ANY
现象:cron curl dspy → 401,因注册为 logined 需 Session。
修复:load_path.py 中将端点从 PATHS_LOGINED 移到 PATHS_ANY,重跑 load_path.py + 重启 Sage。
坑 59:自定义 new_data_url dspy 不享受 logined_userorgid 自动注入
现象:JSON 配置了 "logined_userorgid": "ownerid",但新增 LLM 后 ownerid 为空,导致列表按 org 过滤时查不到记录。创建成功的消息弹出了,数据也入库了,但 ownerid 字段是 NULL/空字符串。
根因:logined_userorgid 的自动注入(ns['ownerid'] = userorgid)只在 xls2ui 生成的标准 add_xxx.dspy 中生效。当 JSON 通过 new_data_url 指向自定义 dspy 端点时(如 ../api/llm_create.dspy),模板引擎不会注入——自定义 dspy 必须自己处理。
诊断:
# 查看 JSON 是否用了自定义端点
grep 'new_data_url' json/llm.json
# → "new_data_url": "{{entire_url('../api/llm_create.dspy')}}"
# 查看 dspy 是否有手动注入
grep 'ownerid\|get_userorgid' wwwroot/api/llm_create.dspy
# 没有 → 需要补!
修复:在自定义 create dspy 中手动获取并注入:
env = request._run_ns
data = params_kw.copy()
data['id'] = getID()
data['ownerid'] = data.get('ownerid') or await env.get_userorgid()
await sor.C('llm', data)
教训:logined_userid/logined_userorgid 只对模板生成的默认 CRUD dspy 有效。一旦用了自定义 new_data_url,必须在自定义端点中手动获取当前用户/机构信息。
坑 60:create 函数只做拉链逻辑忘了 sor.C() 插入
现象:add_pricing_program_timing 调用成功返回 {"status": "success"},但数据库无新记录,界面无变化。
根因:函数做了拉链逻辑(关旧记录:sor.U() 更新 expired_date),但漏了 sor.C() 插入新记录。函数逻辑链不完整。
修复:拉链完成后补插入:
async def add_pricing_program_timing(env, sor, ns):
ppid = ns.get('ppid')
enabled_date = ns.get('enabled_date')
ns['expired_date'] = '9999-12-31'
# 1. 拉链:关旧记录
if ppid and enabled_date:
prev = await sor.R('pricing_program_timing', {'ppid': ppid, 'expired_date': '9999-12-31'})
if prev:
await sor.U('pricing_program_timing', {'id': prev[0].id, 'expired_date': enabled_date})
# 2. 插入新记录 ← 之前漏了这一步!
ns['id'] = ns.get('id') or getID()
await sor.C('pricing_program_timing', ns)
诊断技巧:create 返回 success 但 DB 无数据 → 搜函数中是否有 sor.C()。没有就是忘了。另外注意函数的 import——如果 getID 等未 import,需补上。
坑 62:sqlorContext 中硬编码 dbname → NoneType 崩溃
现象:db.sqlorContext('llmage') 或 DBPools().sqlorContext('sage') 导致 'NoneType' object has no attribute 'get'。
根因:dbname 硬编码字符串,配置文件中的数据库别名不一定匹配。sqlorFactory 中 dbdesc = self.databases.get(dbname, None) 找不到就返回 None → dbdesc.get('driver') 崩溃。
修复:用 ServerEnv().get_module_dbname('模块名') 动态获取:
# ❌ 硬编码 — 配置中可能无此别名
async with db.sqlorContext('llmage') as sor:
async with DBPools().sqlorContext('sage') as sor:
# ✅ 动态获取
lmage_dbname = ServerEnv().get_module_dbname('llmage')
async with db.sqlorContext(lmage_dbname) as sor:
注意:
ServerEnv().get_module_dbname()需要模块名(如'llmage','sage','discount'),不是数据库连接字符串- 如果在 dspy/init.py 中已有
env = request._run_ns,也可用env.get_module_dbname('sage') DBPools()无参构造是空对象,必须通过getConfig().databases或_get_sor()配置后才能用- 脚本中
DBPools(config.databases)初始化后,后续DBPools()可能仍为空——取决于 DBPools 实现是否单例(当前版本非单例)
排查:搜所有硬编码 dbname:
grep -rn "sqlorContext(['\"]" --include="*.py" .
坑 61:rolepermission 表是多对多桥梁,permission 表 path 唯一
现象:为 tenant 路径补充 owner.* 权限时,尝试插入新 permission 行报 Duplicate entry for key 'permission_idx1'。
根因:permission 表唯一索引在 path 字段上(不是 (path, permtype))。一个 path 只能有一条 permission 记录。多角色授权通过在 rolepermission 表中为同一 permid 添加多个 roleid 实现。
正确模式:
# 1. path → permission(一个 path 只一条)
perm_id = await ensure_permission(path)
# 2. 多角色授权 → rolepermission(多行,同 perm_id)
for role_id in [logined_id, reseller_admin_id, owner_super_id]:
existing = await sor.sqlExe(
'SELECT id FROM rolepermission WHERE roleid=${rid}$ AND permid=${pid}$',
{'rid': role_id, 'pid': perm_id})
if not existing:
await sor.sqlExe(
'INSERT INTO rolepermission (id, roleid, permid) VALUES (${id}$, ${rid}$, ${pid}$)',
{'id': getID(), 'rid': role_id, 'pid': perm_id})
诊断 SQL:
-- 查某 path 被哪些角色授权
SELECT r.orgtypeid, r.name, p.path
FROM rolepermission rp
JOIN permission p ON p.id = rp.permid
JOIN role r ON r.id = rp.roleid
WHERE p.path LIKE '/tenant%';
-- 查有 permission 但无 rolepermission 的无效路径
SELECT p.path FROM permission p
LEFT JOIN rolepermission rp ON rp.permid = p.id
WHERE p.path LIKE '/tenant%' AND rp.permid IS NULL;
教训:load_path.py 用 DB 直连模式时(非 set_role_perm.py),不要重复插入同一个 path 到 permission 表。先查是否存在,存在则只补充 rolepermission 关联。
坑 63:Bricks VBox/HBox 子控件用 subwidgets 不是 options.children
现象:VBox 容器页面显示空白,子控件没有渲染出来。
根因:子控件放在 options.children 中,但 bricks 规范要求放在顶层 subwidgets 数组里。
// ❌ WRONG — children 在 options 内,被忽略
{"widgettype":"VBox","options":{"children":[{"widgettype":"Title",...}]}}
// ✅ CORRECT — 顶层 subwidgets
{"widgettype":"VBox","options":{"width":"100%"},"subwidgets":[{"widgettype":"Title",...}]}
坑 64:CRUD 自动生成目录禁止提交 git
现象:git pull 时报大量 untracked CRUD 文件冲突(add_/get_/update_/delete_.dspy + index.ui)。
根因:xls2ui -m models -o wwwroot 生成的是 CRUD 中间文件,不应纳入版本控制。一旦有人提交到仓库,其他人 pull 时本地同名 untracked 文件就会冲突。
预防:
- 模块
.gitignore中列出所有 CRUD 生成目录:wwwroot/organization/ wwwroot/role/ wwwroot/users/ ...(每个表名目录一行) - 误提交后修复:
git rm -r <目录> && git commit,然后补.gitignore
生产服务器遇到冲突的快速修复:
git reset --hard HEAD && git pull # 丢弃本地改动直接拉远程
⚠️ 致命后果:服务器上 CRUD 目录完全缺失 → 500 "invalid path"
git pull 只拉取 git 跟踪的文件。xls2ui 生成的 CRUD 目录(wwwroot/<alias>/)在 .gitignore 中,永远不会通过 git pull 到达服务器。如果服务器重建、wwwroot 被清理、或首次部署未运行 xls2ui,所有 CRUD 页面(列表、新增、删除、更新)都会返回 500:
Exception: str(request.url)='.../get_supply_contracts.dspy' invalid path
诊断:检查生成目录是否存在:
for d in supply_contracts_list suppliers_list sub_resellers_list ...; do
ls <pkgs>/wwwroot/$d/index.ui 2>/dev/null || echo "MISSING: $d"
done
修复:在服务器上运行 xls2ui 重新生成:
cd /d/apitest/sage/pkgs/supplychain/json
/d/apitest/sage/py3/bin/xls2ui -m ../models -o ../wwwroot supplychain *.json
# 验证
ls ../wwwroot/supply_contracts_list/index.ui
教训:部署后必须检查 xls2ui 生成目录是否齐全,或在 build.sh 中包含此步骤。不可假设 git pull 会拉取这些 gitignore 文件。
⚠️ 区分(坑 86):不要把 wwwroot 下任何 .ui 都当成 CRUD 中间文件。contract_discount_setting.ui、agreement_discount_setting.ui、api/*.dspy 等是 git 跟踪的手写源文件。归因 500 前先 git ls-files | grep <文件名> 确认,见坑 86。
坑 66:product_management 多级分销记账(替代 llm_charging)
背景:产品记账已从 llmage/accounting.py 迁移到 product_management/core.py。llm_charging 仅用于获取原始价格,不应承担货币转换或折扣逻辑。
核心流程:
backend_accounting.py (独立进程,nohup运行)
→ ProductManager.backend_accounting()
→ get_accounting_llmusages() (llmage)
→ product_accounting() (product_management fallback)
→ llm_charging() (只取原价)
→ 查 distribution_agreements + agreementdetail 折扣
→ 汇率转换(env.get_exchange_rate)
→ 多级 ConsumeBiz → consume_accounting()
tenantid 模式(dspy 中):
# ❌ 不要用 ${oid}$ 占位符 — dspy 的 sor.sqlExe 跨上下文可能失败
# ❌ tenantid = userorgid — 应该是 parentid
# ✅ 用 repr() 直接拼 SQL,避免占位符
async with get_sor_context(env, 'sage') as sor3:
org_recs = await sor3.sqlExe(
'SELECT parentid FROM organization WHERE id=' + repr(userorgid), {})
if org_recs and org_recs[0].parentid:
params_kw.tenantid = org_recs[0].parentid
多级折扣链(在 product_accounting fallback 中):
CUST_DISCOUNT = 0.9 # 客户统一折扣
PROVIDER_DISCOUNT = 0.7 # 供应商 → 一级分销商
# 从 distribution_agreements + agreementdetail 查二级分销商折扣
sub_discount = 1.0
if resellerid != '0':
agree_recs = await sor.sqlExe(
"SELECT d.discount, a.resellerid FROM distribution_agreements a, agreementdetail d "
"WHERE a.id=d.agreeid AND a.sub_reseller_id=" + repr(resellerid)
+ " AND a.status='1' LIMIT 1", {})
# 汇率转换:pricing_program → env.get_exchange_rate
pricing_currency = pp.currency # e.g. 'USD'
fx_rate = await env.get_exchange_rate(pricing_currency, 'CNY', 'buy_rate')
raw_price = raw_usd * fx_rate
多级 ConsumeBiz 分录:
# 1) 客户 → 二级分销商 (PAY, 0.9折扣)
ais.append(DictObject(action='PAY', customerid=customerid, resellerid=resellerid, ...))
# 2) 二级 → 一级 (PAY*, sub_discount)
ais.append(DictObject(action='PAY*', customerid=resellerid, resellerid=parent_reseller, ...))
# 3) 一级 → 供应商 (PAY*, 0.7折扣)
ais.append(DictObject(action='PAY*', customerid=parent_reseller, resellerid=providerid, ...))
await consume_accounting(sor, orderid, ais)
必需的开户(accounting/openaccount.py):
# 平台自身
await openOwnerAccounts(sor, "0")
# 分销商
await openResellerAccounts(sor, "0", dist_id)
await openRetailRelationshipAccounts(sor, "0", "0", dist_id)
# 供应商
await openProviderAccounts(sor, "0", provider_id)
await openResellerAccounts(sor, "0", provider_id) # 供销关系中供应商也是 reseller
# 供应商自引用账户(PAY* 腿需要 orgid=provider, org1id=provider)
await openAccount(sor, "0", provider_id, cfg, org1id=provider_id)
backend_accounting.py 启动注意事项:
- 移除
sys.path中的pkgs/路径 DBPools(config.databases)必须先初始化load_pricing()必须调用才能注册buffered_charging- 进程用
nohup+disown启动
关联修复的模块级 bug:
supplychain/init.py:1043—get_contract_discount_items→get_agreement_discount_itemssmssend/init.py—_sms_engine_instance = None禁用短信(测试环境)accounting_config表 —amt_pattern中${交易金额}$ - ${交易手续费}$当手续费为0时报 SyntaxError,应改为${交易金额}$
详见 references/multi-tier-accounting.md。
坑 65:DSPY f-string → SyntaxError '{' was never closed
现象:dspy 文件运行时爆 SyntaxError: '{' was never closed,但在正常 Python 中语法完全正确。
根因:dspy 通过 exec() 运行,执行引擎对 {} 有特殊处理(模板/Jinja2 语法冲突)。f-string 中的 {变量} 被模板引擎误解析,导致括号计数错乱。
错误示例:
# ❌ dspy 中爆 SyntaxError
return {"message": f"错误: {e}"}
exception(f'error: {e}')
# ✅ 修复:用 + 拼接或 .format()
return {"message": "错误: " + str(e)}
exception('error: ' + str(e))
教训:dspy 文件禁止 f-string。所有字符串拼接用 + 或 .format()。同时 dspy 也禁止 import 语句。
供销合同/分销协议的附件管理采用统一模式,详见 references/contract-attach-pattern.md。
核心组件:
contract_attach.ui— 附件弹窗 UI(UiFile 上传 + Tabular 列表)contract_attach_list.dspy— 查询file表列出附件contract_upload.dspy— 通过ContractAttachMgr上传contract_attach_delete.dspy— 通过ContractAttachMgr删除ContractAttachMgr(FileMgr)— init.py 中的附件管理器ensure_contract_folder(request, contract_id, fiid)— 确保 filemgr folder 存在
坑 67:Jinja2 .ui 模板调用 Python 函数 — 签名只接受 (request)
现象:.ui 文件中 {% set items = get_agreement_discount_items(request) %} → 模板渲染 500,日志显示 TypeError: get_agreement_discount_items() missing 1 required positional argument: 'params_kw'。
根因:Jinja2 模板调用 ServerEnv 注册的函数时,只传一个参数 (request)。Python 函数签名写 (request, params_kw) 的话,params_kw 没有传入源。params_kw 需要通过 request._run_ns 获取:
# ❌ 签名错误 — Jinja2 只传 request
async def get_items(request, params_kw):
...
# ✅ 正确
async def get_items(request):
params_kw = getattr(request._run_ns, 'params_kw', {}) or {}
注意:dspy 文件(async def main() 模式)中 params_kw 是全局变量。模块 Python(init.py)中没有这个全局变量,必须从 request._run_ns 中取。
坑 67b:bricks PopupWindow 无法渲染 dspy 返回的 DataViewer/UrlWidget
现象:ensure dspy 返回 {"widgettype":"DataViewer","options":{"url":"..."}} → PopupWindow 弹窗空白。返回 HTML meta refresh → 也不跳转。返回 302 redirect → 也不生效。
根因:PopupWindow 的 urlwidget actiontype 处理 POST 响应时,只支持有限的 widget 类型(Message、Error、VBox 等),不支持 DataViewer/UrlWidget 这种负载型 widget。HTML meta refresh 和 HTTP redirect 也不被 PopupWindow 执行。
修复:去掉 ensure 中间层,工具栏直接 GET 打开 CRUD 页面:
{
"actiontype": "urlwidget",
"target": "PopupWindow",
"options": {
"method": "GET",
"url": "{{entire_url('/supplychain/supply_contracts_list')}}?supplier_id={{params_kw.get('id','')}}"
}
}
教训:bricks 工具栏 bind 只有 5 种 actiontype 可用(urlwidget 是最通用的)。PopupWindow 不能渲染嵌套的 DataViewer。直接 GET 目标页面是最简单的方案。
坑 77:折扣设置 UI 的"已设置"状态列无法实时更新
现象:分销协议/供销协议的折扣设置页(agreement_discount_setting.ui)中,修改折扣 blur 保存后,状态列仍显示"-未设置"。
根因:状态列是 Jinja2 模板在服务端渲染时确定的,基于 p.item_id 初始值。每次修改折扣保存后,状态 Tool 不会自动重新渲染。bricks 不支持 actiontype: "script" 做客户端 DOM 更新。
当前方案:save dspy 返回 Message widget("保存成功" toast),用户关闭再打开弹窗即可看到更新后的状态。
坑 78:Pipeline bricks 版本不兼容 — DataSelector widget 未注册
现象:pipeline editor 页面加载后控制台报 widgetBuild(): DataSelector not registered,下拉选择器不显示。
根因:pipeline-app 使用的 bricks.js 版本(pipeline 目录下)不支持 DataSelector widget。pipeline 的 pipeline_editor/index.ui 使用了此 widget 但 bricks 中没有注册。
修复:用标准 CRUD 模式(Tabular + toolbar)替代 DataSelector-based UI。
坑 79:pipeline 运行时 asyncio 事件循环冲突(预存环境 BUG)
现象:pipeline 重启后所有请求返回 500,日志:RuntimeError: Task got Future attached to a different loop。
根因:DBPools 单例被多个 pipeline 进程/事件循环共享。ahserver.webapp 在 fork 子进程后重用父进程的 DBPools 实例,db.databases 未刷新。
诊断:python app/pipeline_app.py 前台运行看启动日志。正常时输出 module loaded + Running on http://0.0.0.0:9090。
临时修复:pkill -9 -f pipeline_app,然后重新 nohup python app/pipeline_app.py -p 9090 -w /d/apitest/pipeline &。
坑 68:模块 Python 代码中获取数据库上下文 — 必须用 get_sor_context
现象:init.py 中 get_agreement_discount_items(request) 用 DBPools().sqlorContext('sage') → NameError: name 'get_module_dbname' is not defined,或 db.sqlorContext(dbname) → NoneType。
根因:模块 Python 代码(非 dspy 文件)获取数据库连接的正确方式是 get_sor_context(env, 'module_name'):
from sqlor.dbpools import DBPools, get_sor_context
async def my_func(request):
env = request._run_ns # ← 不是 ServerEnv()!
async with get_sor_context(env, 'sage') as sor: # sage 产品表
rows = await sor.sqlExe('SELECT * FROM product', {})
async with get_sor_context(env, 'supplychain') as sor: # 合同明细表
rows = await sor.sqlExe('SELECT * FROM supply_contract_items', {})
错误模式对比:
# ❌ DBPools() 硬编码 dbname — get_module_dbname 在 py 代码中不存在
async with DBPools().sqlorContext('sage') as sor:
# ❌ _get_sor() 自定义 helper — 配置可能不完整
db, dbname = _get_sor()
async with db.sqlorContext(dbname) as sor:
# ✅ 标准模式
env = request._run_ns
async with get_sor_context(env, 'sage') as sor:
注意:
get_sor_context在 dspy 上下文中是全局函数- 在
init.py中需from sqlor.dbpools import get_sor_context env = request._run_ns,不是ServerEnv()(坑 40)- 跨库查询要分别建 sqlorContext(坑 69)
坑 69:跨库 JOIN 报 1142 权限拒绝 → 分别查询各 DB 后 Python 合并
现象:get_agreement_discount_items 中用 LEFT JOIN supplychain.distribution_agreement_items → SELECT command denied to user 'test'@'localhost' for table supplychain.distribution_agreement_items。
根因:测试用户 test 只有 sage 库的权限,不能通过 database.table 跨库引用。
修复:分别连 sage 库查 products,连 supplychain 库查 items,Python 合并:
# 1. sage → products
async with get_sor_context(env, 'sage') as sor:
rows = await sor.sqlExe('SELECT id, product_name FROM product WHERE status=1', {})
# 2. supplychain → items
async with get_sor_context(env, 'supplychain') as sor:
items = await sor.sqlExe('SELECT id, productid, discount FROM distribution_agreement_items WHERE agreement_id=${aid}$', {'aid': aid})
# 3. merge in Python
items_map = {r.productid: {'id': r.id, 'discount': r.discount} for r in items}
坑 70:save dspy 重复 INSERT 报 Duplicate entry → 先查后插
现象:save_agreement_discount.dspy 每次 blur 都 sor.C('product_org_auth'),改两次折扣就 IntegrityError: Duplicate entry。
修复:插入前查存在:
existing = await sqlExe("SELECT id FROM product_org_auth WHERE product_id=${pid}$ AND org_id=${oid}$", {'pid': pid, 'oid': oid})
if not existing:
await sor.C('product_org_auth', {...})
坑 73:get_search_*.dspy 查询错误的 DB 和表名 → UiCode 下拉为空
现象:分销协议产品折扣明细添加表单中,产品分类和产品 ID 下拉框为空(bricks.UiCode.build_options: undefined.length)。
根因:get_search_prodtypeid.dspy 查询 product_types 表在 supplychain 库 → 表不存在。get_search_productid.dspy 查询 products 表 → 实际表名是 product。两者都在 sage 库。
修复:
# ❌ supplychain.product_types — 不存在
async with DBPools().sqlorContext('supplychain') as sor:
rows = await sor.sqlExe("select id as prodtypeid, type_name as prodtypeid_text from product_types", {})
# ✅ sage.product_category
async with DBPools().sqlorContext('sage') as sor:
rows = await sor.sqlExe(
"select id as prodtypeid, name as prodtypeid_text from product_category where status='1' order by sort_order, name", {})
教训:get_search_* dspy 是 UiCode 下拉控件的唯一数据源。写之前必确认:(1) 正确的数据库名 (2) 正确的表名 (3) 正确的列名。
坑 74:xls2ui 生成 index.ui 时自动移除 alter 中的字段 → editexclouded 对不上 → undefined.length
现象:供应合同和分销合同添加表单弹窗报 bricks.UiCode.build_options: Cannot read properties of undefined (reading 'length')。
根因:supplier_id 和 sub_reseller_id 在 CRUD JSON 的 browserfields.alters 中配置了 code 类型 + dataurl。xls2ui 生成 index.ui 时,自动把这两个字段从 editexclouded 中移除了(因为它们在 alters 中有 dataurl)。结果添加表单渲染这个字段时找不到 options 数据 → undefined.length。
修复:手动把被移除的字段加回 editexclouded,并确认 index.ui 中 editexclouded 数组包含所有不应显示在添加表单中的字段。
教训:xls2ui 生成后必须逐行检查 editexclouded 是否完整。alters 中的 dataurl 字段会被自动移除,但它们仍应在 editexclouded 中以隐藏方式传递。对比 JSON 源和生成的 index.ui:
# JSON 的 editexclouded
python3 -c "import json; print(json.load(open('json/suppliers_list.json'))['params']['editexclouded'])"
# index.ui 的 editexclouded
grep -A10 editexclouded wwwroot/suppliers_list/index.ui
坑 75:Pipeline 模块放在应用根目录,不在 pkgs/ 下
现象:Pipeline-app 的 pipeline_core、pipeline_ops 等模块位于 ~/pipeline/ 根目录,不是 ~/pipeline/pkgs/。试图移动到 pkgs/ 后 pip install -e 导致 import 路径断裂。
根因:pipeline-app 的 sys.path.insert(0, root_dir) 将 ~/pipeline/ 加入 Python path,from pipeline_core.init import ... 直接解析。移入 pkgs/ 后 Python 找不到。
教训:不要随意重组已有应用的目录结构。先 grep 确认 sys.path 和 import 模式。
坑 76:Pipeline 权限注册用 bin/init_perms.py,不是 Sage 的 load_path.py
现象:pipeline 模块 403,但 Sage load_path.py 已有。
根因:pipeline-app 独立端口 9090,有自己的 /bin/init_perms.py。每个模块路径必须在此文件中注册:
PERMS = [
('/pipeline_core/pipelines/*', '产线定义API', 'pipeline_core', 'logined'),
]
部署后执行 python bin/init_perms.py 生效。
坑 69:跨库 JOIN 报 1142 权限拒绝 → 分别查询各 DB 后 Python 合并
现象:get_agreement_discount_items 中用 LEFT JOIN supplychain.distribution_agreement_items → SELECT command denied to user 'test'@'localhost' for table supplychain.distribution_agreement_items。
根因:测试用户 test 只有 sage 库的权限,不能通过 database.table 跨库引用。同一 MySQL 实例不同 database 之间 JOIN 需要额外权限。
修复:分别连 sage 库查 products,连 supplychain 库查 items,然后在 Python 中合并:
# Step 1: sage → products
async with get_sor_context(env, 'sage') as sor:
rows = await sor.sqlExe('SELECT id as productid, product_name FROM product WHERE status=1', {})
# Step 2: supplychain → discount items
async with get_sor_context(env, 'supplychain') as sor:
items = await sor.sqlExe('SELECT id, productid, discount FROM distribution_agreement_items WHERE agreement_id=${aid}$', {'aid': aid})
# Step 3: merge in Python
items_map = {r.productid: {'id': r.id, 'discount': r.discount} for r in items}
教训:不要跨库 JOIN。总是分开查然后 Python 合并。
坑 70:DSPY 重复执行 → 不在 save dspy 中插入 product_org_auth 之前先检查
现象:save_agreement_discount.dspy 每次 blur 都尝试 sor.C('product_org_auth', ...) → IntegrityError: Duplicate entry for key 'idx_poa_unique'。同一产品改两次折扣就崩。
修复:插入前先查是否存在:
existing = await s3.sqlExe(
"SELECT id FROM product_org_auth WHERE product_id=${pid}$ AND org_id=${oid}$",
{'pid': productid, 'oid': sub_reseller_id})
if not existing:
await s3.C('product_org_auth', {...})
教训:任何可能重复执行的 INSERT 都要先 SELECT 检查。
坑 71:Pipeline 独立应用权限注册 — /bin/init_perms.py 不是 load_path.py
现象:pipeline 模块访问报 403 invalid path,但 Sage 的 load_path.py 已配置了路径。
根因:pipeline-app 是独立应用(端口 9090),有自己的权限注册脚本 /bin/init_perms.py,不共享 Sage 的 load_path.py。每个模块路径必须同时在此文件中注册。
Pipeline 权限注册模式:
PERMS = [
('/pipeline_core/', '产线管理访问', 'pipeline_core', 'logined'),
('/pipeline_core/pipelines/*', '产线定义API', 'pipeline_core', 'logined'),
('/pipeline_ops/', '运营管理', 'pipeline_ops', 'logined'),
('/', '应用首页', 'app', 'guest'),
]
部署流:修改 init_perms.py → 服务器 python bin/init_perms.py 执行。
教训:有独立端口的应用 = 独立权限系统。先 grep 项目的 init_perms / load_path 确认权限注册在哪里。
坑 72:Sage/Pipeline 同机多端口 → Rsync 非共享代码
现象:pipeline 代码在 /d/ymq/pipeline/pipeline-app/ 但实际运行在 /d/apitest/pipeline/(不是同一目录)。
根因:开发环境和测试环境的代码目录不共享。Sage 的 pkgs 模式(pip install pkgs/xxx)和 Pipeline 的拷贝模式(scp / 手动 sync)不同。
Pipeline 部署:新文件直接 scp 到服务器 /d/apitest/pipeline/。不要尝试 git pull 因为服务器 pipeline 目录不是 git repo。
教训:每个应用确认其部署方式后再动——不要假设所有模块都是 Sage pkgs。
现象:init.py 中 get_agreement_discount_items(request) 用 DBPools().sqlorContext('sage') → NameError: name 'get_module_dbname' is not defined,或 db.sqlorContext(dbname) → NoneType。
根因:模块 Python 代码(非 dspy 文件)获取数据库连接的正确方式是 get_sor_context(env, 'module_name'):
from sqlor.dbpools import DBPools, get_sor_context
async def my_func(request):
env = request._run_ns # ← 不是 ServerEnv()!
async with get_sor_context(env, 'sage') as sor: # sage 产品表
rows = await sor.sqlExe('SELECT * FROM product', {})
async with get_sor_context(env, 'supplychain') as sor: # 合同明细表
rows = await sor.sqlExe('SELECT * FROM supply_contract_items', {})
错误模式对比:
# ❌ DBPools() 硬编码 dbname — get_module_dbname 在 py 代码中不存在
async with DBPools().sqlorContext('sage') as sor:
# ❌ _get_sor() 自定义 helper — 配置可能不完整
db, dbname = _get_sor()
async with db.sqlorContext(dbname) as sor:
# ✅ 标准模式
env = request._run_ns
async with get_sor_context(env, 'sage') as sor:
注意:
get_sor_context在 dspy 上下文中是全局函数- 在
init.py中需from sqlor.dbpools import get_sor_context env = request._run_ns,不是ServerEnv()(坑 40)- 跨库查询要分别建 sqlorContext(坑 46)
坑 80:json2ddl datetime DEFAULT 'NOW()' → MySQL ERROR 1067 Invalid default value
现象:json2ddl mysql . | sagedb 建表时报 ERROR 1067 (42000): Invalid default value for 'created_at'。生成的 DDL 为:
`created_at` DATETIME DEFAULT 'NOW()' comment '创建时间'
根因:MySQL 不允许 datetime 类型使用 DEFAULT 'NOW()'(字符串字面量)。DATETIME 只接受 DEFAULT CURRENT_TIMESTAMP(无引号)或固定时间戳。而 json2ddl 对 type: "datetime" + default: "NOW()" 的组合会原样输出带引号的字符串。
修复:模型 JSON 中把 type 改为 timestamp,不写 default:
// ❌ WRONG — 生成 DATETIME DEFAULT 'NOW()' → 1067 错误
{"name": "created_at", "title": "创建时间", "type": "datetime", "default": "NOW()"}
// ❌ WRONG — default: "CURRENT_TIMESTAMP" 也会重复
{"name": "created_at", "title": "创建时间", "type": "timestamp", "default": "CURRENT_TIMESTAMP"}
// ✅ CORRECT — timestamp 类型无 default,json2ddl 自动生成 DEFAULT CURRENT_TIMESTAMP
{"name": "created_at", "title": "创建时间", "type": "timestamp"}
教训:模型 JSON 中时间戳字段永远用 "type": "timestamp" 不写 default。json2ddl 对 timestamp 类型会自动添加 DEFAULT CURRENT_TIMESTAMP。
坑 82:多层分销链遍历 — sub_resellerid 字段语义 + create_sub_reseller 写错表
现象:多层分销记账时,product_accounting() 构建的分销链始终截断,中间层级的分销商 PAY* 分录从未生成。
根因(双重 bug):
Bug 1:create_sub_reseller 写错表
- 函数实际写入
sub_distributors表(L351await sor.C("sub_distributors", rec)) - 但
get_distribution_chain查的是sub_resellers表 - 还写了不存在的
orgid字段
Bug 2:字段名错误
- 旧模型用
sub_reseller_code(编号字符串),不适合存储 org_id get_distribution_chainSQL 查orgid(不存在)
修复:
- 模型
sub_resellers.json:sub_reseller_code→sub_resellerid(VARCHAR 32,存 org_id) - 唯一索引:
idx_sr_code→idx_sr_sub_resellerid(resellerid, sub_resellerid) - codes 关联:
sub_resellerid→organization.orgname create_sub_reseller:写入sub_resellers表(非sub_distributors),字段sub_reselleridget_distribution_chainSQL:SELECT sub_resellerid FROM sub_resellers- JSON/UI/dspy 字段引用同步更新
- 删除未使用的
_generate_sub_reseller_code函数 - 重新生成 DDL
字段重命名完整流程(见 references/field-rename-workflow.md):
- 模型 JSON:字段名 + 索引 + codes 关联
- init.py:所有 SQL + create/update 函数
- JSON 配置:editexclouded、data_filter 等
- UI 文件:字段定义(name/title/length)
- dspy 文件:字段定义
- 删除孤儿函数(grep 确认无调用)
- 重新生成 DDL
- 验证脚本:grep 全仓库确认无残留
- commit + push
验证脚本模板:
#!/usr/bin/env python3
"""验证字段重命名残留"""
from pathlib import Path
REPO = Path("/d/ymq/repos/<module>")
old_field = "sub_reseller_code"
hits = []
for p in REPO.rglob("*"):
if ".git" in p.parts or "__pycache__" in p.parts or ".pyc" == p.suffix:
continue
if p.is_file() and p.suffix in (".py", ".dspy", ".json", ".ui", ".sql"):
txt = p.read_text(errors="ignore")
if old_field in txt:
hits.append(str(p.relative_to(REPO)))
if hits:
print(f"❌ 残留 {old_field}:", hits)
exit(1)
print("✅ 无残留")
教训:重命名模型字段时,必须逐层检查(模型 → DDL → init.py → JSON → UI → dspy),最后用 grep 全仓库确认无残留。
坑 83:__pycache__/ 被误提交 git — 反复发生
现象:git log 发现 supplychain/__pycache__/init.cpython-310.pyc 被提交到远端仓库。用户反馈"又提交了"。
根因:.gitignore 已有 __pycache__/ 规则,但在 gitignore 生效之前提交的文件不会被自动移除。后续 commit 时如果 git add -A 或 IDE 自动暂存,__pycache__/ 可能再次混入。
修复流程:
# 1. 检查哪些 __pycache__ 被 git 跟踪
git ls-files -- "*__pycache__*"
# 2. 从 git 跟踪中移除(保留本地文件)
git rm --cached supplychain/__pycache__/init.cpython-310.pyc
# 3. 确认 .gitignore 已有规则
grep '__pycache__' .gitignore
# 应有:__pycache__/ 和 *.pyc
# 4. 提交并推送
git commit -m "fix: 移除误提交的__pycache__中间文件(.gitignore已有规则)"
git push
预防:
- 每次提交前执行
git status确认无__pycache__或scripts/__pycache__在 staged 中 - 提交前加载
pre-commit-crud-check技能执行完整检查 .gitignore必须包含__pycache__/和*.pyc
教训:.gitignore 只能阻止未跟踪文件的添加。已被跟踪的文件需 git rm --cached 手动移除。每个模块仓库都应检查是否有历史遗留的被跟踪构建产物(git ls-files | grep __pycache__)。
坑 81:数据集市初始化 — 历史数据批量导入模式
场景:新建数据集市模块(如 sage_datamart),需要从已有表(如 llmusage、llmusage_history)导入全量历史数据到集市表。
模式(见 references/datamart-init-pattern.md):
- 建
init_data.py— 批量导入函数 + 聚合重算 - 建
wwwroot/cron/init_data.dspy— 触发端点(PATHS_ANY + IP 白名单) init.py注册函数 + load_path.py 注册 RBAC- 通过
curl http://127.0.0.1/<module>/cron/init_data.dspy一次性执行
关键设计:
- 按 luid 去重(SELECT existing → skip if found)
- 批量分页(LIMIT/OFFSET,每批 1000)
- 先导入历史表再导入当前表
- 导入完成后按所有 distinct dates 重算聚合表
坑 84:Sage 源码 wwwroot 不得存放模块 symlink 和测试目录
现象:~/repos/sage/wwwroot/ 下出现大量模块 wwwroot symlink(如 supplychain → /home/hermesai/repos/supplychain/wwwroot),以及测试目录 tenant/、tmp/、get_domain_info.dspy 等。
根因:各模块 build.sh 执行 ln -sf "$SCRIPT_DIR/wwwroot" "$SAGE_ROOT/wwwroot/<module>" 将模块 wwwroot 链接到 Sage 目录用于部署。但这些 symlink 不是 Sage 源码,不应留在 ~/repos/sage/wwwroot/ 中。
清理:
cd ~/repos/sage/wwwroot
# 列出所有模块 wwwroot symlink
find . -maxdepth 1 -type l -exec ls -la {} \;
# 删除(保留 bricks、shell_theme 等框架文件)
rm -f msp rag cpcc dapi rbac uapi charge llmage unipay appbase \
filemgr discount accounting dashboard_for_sage product_management \
supplychain bugfix pricing
预防:
build.sh的 symlink 是部署环节产物,应在服务器wwwroot/下,不在开发机~/repos/sage/wwwroot/- 不要在
~/repos/sage/wwwroot下创建测试目录或随意放置.dspy文件 - 测试在
~/repos/<module>/各自仓库内进行
教训:~/repos/sage/wwwroot 是 Sage 框架源码目录,只含核心 .ui/.dspy + 内置目录。模块 symlink 属于部署环境,不属于源码。
坑 85:FileMgr filetype 溢出 → 500 → bricks "Cannot read properties of undefined (reading 'dom_element')"
现象:供销合同/分销协议点击附件上传,弹窗提示"上传失败: Cannot read properties of undefined (reading 'dom_element')"。后台返回 500,日志显示 DataError: (1406, "Data too long for column 'filetype' at row 1")。
根因:filemgr/filemgr.py:113 用 params_kw.upfile.split('.')[-1] 提取扩展名。文件无后缀时(如 README、hostname),split('.') 返回整个路径字符串,远超 file 表的 filetype varchar(10) 列长 → MySQL DataError → 500 → bricks 无法解析 HTML 错误响应 → dom_element。
修复:用 os.path.splitext 正确提取扩展名 + [:10] 截断兜底:
# ❌ 无后缀文件 → 返回全路径 → varchar(10) 溢出
"filetype": params_kw.upfile.split('.')[-1].lower(),
# ✅ 无后缀返回空字符串,超长截断
"filetype": os.path.splitext(params_kw.upfile)[1].lstrip('.').lower()[:10],
验证:os.path.splitext('/tmp/hostname') → ('', '') (ext 为空);os.path.splitext('/tmp/a.' + 'x'*20) → 截断为 10 字符。
教训:split('.')[-1] 不是安全的后缀提取方式。Python 标准库 os.path.splitext 正确处理无后缀、隐藏文件、多级路径等边界情况。涉及 file 表写入的 FileMgr 子类(ContractAttachMgr 等)都受此影响。
部署:filemgr 是独立 repo,修改后需 pip install --upgrade . + 重启 Sage。
坑 86:别把手写 .ui 源文件误判为 xls2ui CRUD 中间文件(用户纠正过的归因错误)
事故:contract_discount_setting.ui 报 500,我错误归因为"xls2ui 生成的 CRUD 目录缺失"(坑 64),用户纠正:该文件不是 CRUD 中间文件,是供应链模块 wwwroot 下的 git 跟踪源文件。
真相(两条独立故障,勿混为一谈):
- 坑 64 的 CRUD 目录缺失 → 影响的是
supply_contracts_list/get_supply_contracts.dspy等生成端点(invalid path → 500) contract_discount_setting.ui的 500 → 历史根因是旧版get_contract_discount_items()查了不存在的表sage.product_supplier_mapping(日志 Jul 13:except=(1146, "Table 'sage.product_supplier_mapping' doesn't exist"),Traceback 指向 .ui 第 1 行{% set items = get_contract_discount_items(request) %})。后续提交已改用product.providerid过滤(见坑 39),修复后 200。
判别规则(500 归因前必做):
# 是 git 跟踪源文件还是生成文件?
git ls-files | grep '<文件名>' # 有输出 = 源文件,不是 CRUD 中间产物
grep '<目录>' .gitignore # 被 ignore 的目录 = xls2ui 生成,git pull 不携带
ls -la <服务器>/wwwroot/<文件名> # 文件是否真实存在、mtime 是否陈旧
CRUD 生成目录有固定形态:wwwroot/<表名>_list/ 下 index.ui + add_/get_/update_/delete_<表名>.dspy 五件套。散落在 wwwroot 根或 api/ 下的 .ui/.dspy 都是手写源文件。
排查 .ui 模板 500 的正确动作:grep -B2 -A10 '<文件名>\|get_contract_discount' logs/sage.log——Jinja2 模板 500 的 Traceback 会同时出现 .ui 文件行号和 init.py 中被调函数的行号,能直接定位到是模板语法还是被调函数(缺表/缺列/权限)出问题。"后台没日志"通常是没 grep 对关键词。
Discount 模块 load_path 权限清单
新增页面后必须更新 scripts/load_path.py 并执行注册。完整覆盖表和 403 排查步骤见 references/discount-load-path.md。