64 KiB
Raw Blame History

name version description trigger_conditions
supplychain-pitfalls 1.3.0 supplychain/Sage 模块开发的反复踩坑教训,涉及 xls2ui、RBAC、bricks 限制、部署流程、dspy 编码规范。
修改 supplychain/Sage 模块的 CRUD 配置、index.ui、load_path.py
supplychain 模块报 403、undefined.length、按钮无反应
涉及 bricks 工具栏 bind 配置
开发 ensure dspy、创建用户、product_org_auth 授权
二级分销商管理(sub_distributors):添加管理员、重置密码、分销协议授权
供销协议/分销协议折扣明细(get_contract_discount_items / get_agreement_discount_items)
supplychain init.py 中的 Jinja2 注册函数涉及产品过滤或 orgid 查询
product.providerid 字段、产品导入增量模式、providerid 回填
JSON subtables 已定义但 index.ui 缺少对应 toolbar/bind → 需重跑 xls2ui
合同附件管理(供销合同/分销协议)
unipay 支付模块开发(支付渠道、provider、转账充值、load_path)
Bricks VBox/HBox 子控件白屏、subwidgets 规范
CRUD 自动生成文件 git 冲突
__pycache__ 或 *.pyc 被误提交到 git 仓库
wwwroot 下 .ui/.dspy 报 500 需要归因(先判断是生成文件还是 git 跟踪源文件)

⚠️ 工作流铁律(最高优先级)

  1. 四阶段门禁强制:dev → review → test → audit。每个阶段留证据,未测试禁提交。本地修改→commit→push→服务器pull + pip install + 重启→curl/浏览器冒烟。部署完成才算阶段结束,不允许口头声称通过。

  2. 开发在本地,不在服务器:本地编码→git commit→git push→服务器git pull+pip install。严禁SSH直改服务器源文件(git pull会覆盖)。

  3. 必须浏览器实测:curl 200 ≠ 功能正常。完整链路:页面加载 → 数据加载 → 点击按钮 → 弹窗内容正确渲染 → console 无错误。

  4. 修改 → commit → push → 服务器 git pull → xls2ui(如改 JSON 或有 CRUD 目录缺失)→ pip install(如改 .py)→ 重启 → 浏览器测。不允许跳过任何一步。

  5. 服务器有本地修改时先 git checkout -- . 再 pull。绝不 scp/sed 手动改服务器文件。

  6. 测试失败要说清楚失败在哪,不要沉默或装作通过。

  7. login 表单对程序化操作不响应时,换 real browser 登录。 不循环重试同一种方法。

  8. 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)

现象:供销协议折扣明细空表,或因用错过滤字段显示了其他供应商的产品。

根因:三次试错:

  1. ❌ p.org_id — 产品归属机构,大部分是'0',不等于供应商 org
  2. ❌ product_supplier_mapping.supplier_org_id — 供销路由表,非产品→供应商直连
  3. ✅ 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):

  1. import_categories_and_products 中 updated_prods → skipped_prods:已有产品直接跳过(skipped_prods += 1; continue),不再更新
  2. 创建产品时写入 providerid = prod.get('providerid', '')
  3. product.json 模型添加 providerid varchar(32) 字段
  4. 各资源模块的 load_product_category_product 需在返回数据中包含 providerid(如 llmage 从 llm.providerid 取)
  5. 服务器需执行 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_items
  • smssend/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 表(L351 await sor.C("sub_distributors", rec))
  • 但 get_distribution_chain 查的是 sub_resellers 表
  • 还写了不存在的 orgid 字段

Bug 2:字段名错误

  • 旧模型用 sub_reseller_code(编号字符串),不适合存储 org_id
  • get_distribution_chain SQL 查 orgid(不存在)

修复:

  1. 模型 sub_resellers.json:sub_reseller_code → sub_resellerid(VARCHAR 32,存 org_id)
  2. 唯一索引:idx_sr_code → idx_sr_sub_resellerid (resellerid, sub_resellerid)
  3. codes 关联:sub_resellerid → organization.orgname
  4. create_sub_reseller:写入 sub_resellers 表(非 sub_distributors),字段 sub_resellerid
  5. get_distribution_chain SQL:SELECT sub_resellerid FROM sub_resellers
  6. JSON/UI/dspy 字段引用同步更新
  7. 删除未使用的 _generate_sub_reseller_code 函数
  8. 重新生成 DDL

字段重命名完整流程(见 references/field-rename-workflow.md):

  1. 模型 JSON:字段名 + 索引 + codes 关联
  2. init.py:所有 SQL + create/update 函数
  3. JSON 配置:editexclouded、data_filter 等
  4. UI 文件:字段定义(name/title/length)
  5. dspy 文件:字段定义
  6. 删除孤儿函数(grep 确认无调用)
  7. 重新生成 DDL
  8. 验证脚本:grep 全仓库确认无残留
  9. 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):

  1. 建 init_data.py — 批量导入函数 + 聚合重算
  2. 建 wwwroot/cron/init_data.dspy — 触发端点(PATHS_ANY + IP 白名单)
  3. init.py 注册函数 + load_path.py 注册 RBAC
  4. 通过 curl http://127.0.0.1/<module>/cron/init_data.dspy 一次性执行

关键设计:

  • 按 luid 去重(SELECT existing → skip if found)
  • 批量分页(LIMIT/OFFSET,每批 1000)
  • 先导入历史表再导入当前表
  • 导入完成后按所有 distinct dates 重算聚合表

现象:~/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 跟踪源文件。

真相(两条独立故障,勿混为一谈):

  1. 坑 64 的 CRUD 目录缺失 → 影响的是 supply_contracts_list/get_supply_contracts.dspy 等生成端点(invalid path → 500)
  2. 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。