Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
AUG26-008。
- 迁移 000214–000217:tb_shop 全局唯一且不可修改的随机分销码(含存量回填)、
tb_agent_distribution_registration 待审批注册记录、tb_withdrawal_qualification 资料版本、
tb_commission_withdrawal_request_attempt 审批尝试记录,以及提现申请的 latest_*/异常标记列;
不修改既有迁移,down 在存在本 Change 业务事实或新类型场景行时拒绝破坏性回滚。
- 公开接口 POST /api/c/v1/agent-distribution-registrations:无认证,复用既有短信验证码校验、
消费与限流;无效分销码、停用上级、验证码无效或已消费统一返回「分销码不可用」且不落库,
审批通过前不创建店铺、账号或钱包。
- 审批通过才在同一事务内建启用店铺、代理主账号、钱包、上级层级与业务员快照,驳回不建实体,
重复回调不重复建实体,提交后清理上级下级缓存。
- 提现资料资格按不可变版本保存,替换合同或法人身份证即新增版本并同事务失效旧有效版本;
超管作废原因必填;代理停用与店铺删除联动失效。
- 提现每次提交或重提新增不可变审批尝试记录并冻结金额;企业微信通过仅一次从冻结扣减、
保持状态 2 并写 paid_at(不使用状态 4),驳回/cancelled/deleted 仅一次释放,
通过后撤销不回滚、不重新冻结、只写正交异常标记;加锁顺序统一为申请→尝试→钱包。
- 本地人工终审对已关联审批实例的申请返回状态冲突,approval_instance_id 为空的存量申请保持既有行为,
不新增任何配置开关。
- 补齐审批业务类型注册点全集:业务类型与场景字段常量、场景 DTO 两处枚举与中文描述、
场景字段白名单/合法类型/中文名、数据库 CHECK、Worker 决策消费者与装配、审批审计资源映射,
以及三个新审计资源与 13 个审计动作;失败/拒绝审计改为必达。
- 新增后台路由与 OpenAPI:资格提交/查询/作废、提现申请/重提/详情、店铺详情返回只读分销码。
- 归档本 Change:主 Spec 新增 agent-distribution-withdrawal 能力(5 个 Requirement)。
验证(junhong_cmp_test + Redis DB 6,显式 DB_*,未重置整库):
- 迁移 up → version 217 且 dirty=false → down 3 → up 回 217,fixture 复核残留为 0。
- 受控状态机脚手架 227 项通过 / 0 项失败,覆盖 18 组场景(幂等与乱序回调、资金冻结/释放/重提、
退款回扣 × 在途提现并发、负向场景拒绝审计与 14 个动作码审计真实落库)。
- gofmt 空、go build/go vet 通过、gendocs 与工作区逐字节一致、context-health 通过、
openspec validate --strict 通过、doctor healthy;自动化测试按项目决策为 N/A。
运行期前置(未完成,非代码交付物):由超管经 PUT /api/admin/wecom/scenes/{business_type} 为
agent_distribution_approval、withdrawal_qualification_approval、commission_withdrawal_approval
配置启用场景与模板控件映射;未配置时相应提交失败关闭。
188 lines
12 KiB
Python
188 lines
12 KiB
Python
"""店铺批量导入 SQL 构造。"""
|
||
from __future__ import annotations
|
||
|
||
import secrets
|
||
from datetime import datetime
|
||
from hashlib import sha256
|
||
|
||
from .shop_import import ShopImportPlan, ShopImportRow, initial_password_hash
|
||
|
||
|
||
def build_shop_sql(plan: ShopImportPlan) -> str:
|
||
"""生成包含店铺初始事实的单事务 SQL。"""
|
||
lines = [
|
||
"-- 店铺批量导入 SQL;执行前必须审核结果清单与摘要。",
|
||
f"-- 批次: {plan.config.batch_id}",
|
||
f"-- 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||
"-- 建议执行: psql -1 -v ON_ERROR_STOP=1 -f <此文件>",
|
||
"",
|
||
"-- psql -1 开启的事务内先执行以下守卫。",
|
||
"",
|
||
*_preflight_sql(plan),
|
||
]
|
||
for row in plan.rows:
|
||
lines.extend(_row_sql(row, plan))
|
||
lines.append("")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _preflight_sql(plan: ShopImportPlan) -> list[str]:
|
||
config = plan.config
|
||
mapped_salespeople = []
|
||
seen_salespeople: set[str] = set()
|
||
for row in plan.rows:
|
||
if row.salesperson not in seen_salespeople:
|
||
seen_salespeople.add(row.salesperson)
|
||
mapped_salespeople.append((row.line_no, row.salesperson, config.salesperson_account_ids[row.salesperson]))
|
||
target_rows = ",\n ".join(
|
||
f"({row.line_no}, {_sql_str(row.shop_code)}, {_sql_str(row.username)}, {_sql_str(row.phone)})"
|
||
for row in plan.rows
|
||
)
|
||
salesperson_rows = ",\n ".join(
|
||
f"({line_no}, {_sql_str(name)}, {account_id})" for line_no, name, account_id in mapped_salespeople
|
||
)
|
||
return [
|
||
"-- 所有目标库守卫必须在业务写入前完成。",
|
||
"DO $shop_import_guard$",
|
||
"DECLARE",
|
||
" v_row record;",
|
||
"BEGIN",
|
||
" IF NOT EXISTS (SELECT 1 FROM tb_role WHERE id = " + str(config.default_role_id) + " AND role_type = 2 AND status = 1 AND deleted_at IS NULL) THEN",
|
||
f" RAISE EXCEPTION '导入配置错误: default_role_id={config.default_role_id} 不是启用的客户角色';",
|
||
" END IF;",
|
||
" IF NOT EXISTS (SELECT 1 FROM tb_account WHERE id = " + str(config.operator_id) + " AND user_type = 1 AND status = 1 AND deleted_at IS NULL) THEN",
|
||
f" RAISE EXCEPTION '导入配置错误: operator_id={config.operator_id} 不是启用的超级管理员';",
|
||
" END IF;",
|
||
" FOR v_row IN",
|
||
" VALUES",
|
||
f" {salesperson_rows}",
|
||
" LOOP",
|
||
" IF NOT EXISTS (SELECT 1 FROM tb_account WHERE id = v_row.column3 AND user_type = 2 AND status = 1 AND deleted_at IS NULL) THEN",
|
||
" RAISE EXCEPTION 'CSV 第 % 行业务员映射无效: % (账号 ID=%)', v_row.column1, v_row.column2, v_row.column3;",
|
||
" END IF;",
|
||
" END LOOP;",
|
||
" FOR v_row IN",
|
||
" VALUES",
|
||
f" {target_rows}",
|
||
" LOOP",
|
||
" IF EXISTS (SELECT 1 FROM tb_shop WHERE shop_code = v_row.column2 AND deleted_at IS NULL) THEN",
|
||
" RAISE EXCEPTION 'CSV 第 % 行店铺编号已存在: %', v_row.column1, v_row.column2;",
|
||
" END IF;",
|
||
" IF EXISTS (SELECT 1 FROM tb_account WHERE username = v_row.column3 AND deleted_at IS NULL) THEN",
|
||
" RAISE EXCEPTION 'CSV 第 % 行用户名已存在: %', v_row.column1, v_row.column3;",
|
||
" END IF;",
|
||
" IF EXISTS (SELECT 1 FROM tb_account WHERE phone = v_row.column4 AND deleted_at IS NULL) THEN",
|
||
" RAISE EXCEPTION 'CSV 第 % 行手机号已存在: %', v_row.column1, v_row.column4;",
|
||
" END IF;",
|
||
" END LOOP;",
|
||
"END;",
|
||
"$shop_import_guard$;",
|
||
"",
|
||
]
|
||
|
||
|
||
def _row_sql(row: ShopImportRow, plan: ShopImportPlan) -> list[str]:
|
||
config = plan.config
|
||
parent_expr = "NULL" if not row.parent_shop_code else f"(SELECT id FROM tb_shop WHERE shop_code = {_sql_str(row.parent_shop_code)} AND deleted_at IS NULL)"
|
||
password_hash = initial_password_hash(row.phone)
|
||
distribution_code = secrets.token_hex(16)
|
||
return [
|
||
f"-- CSV 第 {row.line_no} 行: {_sql_str(row.shop_name)}",
|
||
"DO $shop_import$",
|
||
"DECLARE",
|
||
" v_shop_id bigint;",
|
||
" v_account_id bigint;",
|
||
" v_event_id bigint;",
|
||
" v_parent_id bigint;",
|
||
" v_operator_name varchar;",
|
||
"BEGIN",
|
||
f" SELECT username INTO v_operator_name FROM tb_account WHERE id = {config.operator_id};",
|
||
" INSERT INTO tb_shop (creator, updater, shop_name, shop_code, distribution_code, parent_id, business_owner_account_id, level, contact_name, contact_phone, province, city, district, address, status, client_login_disabled, created_at, updated_at)",
|
||
" VALUES ("
|
||
f"{config.operator_id}, {config.operator_id}, {_sql_str(row.shop_name)}, {_sql_str(row.shop_code)}, {_sql_str(distribution_code)}, {parent_expr}, "
|
||
f"{config.salesperson_account_ids[row.salesperson]}, {row.level}, {_sql_str(row.agent_name)}, {_sql_str(row.phone)}, '', '', '', '', 1, FALSE, NOW(), NOW())",
|
||
" RETURNING id, parent_id INTO v_shop_id, v_parent_id;",
|
||
"",
|
||
" INSERT INTO tb_account (creator, updater, username, phone, password, user_type, shop_id, is_primary, status, created_at, updated_at)",
|
||
" VALUES ("
|
||
f"{config.operator_id}, {config.operator_id}, {_sql_str(row.username)}, {_sql_str(row.phone)}, {_sql_str(password_hash)}, 3, v_shop_id, TRUE, 1, NOW(), NOW())",
|
||
" RETURNING id INTO v_account_id;",
|
||
"",
|
||
" INSERT INTO tb_account_role (account_id, role_id, status, creator, updater, created_at, updated_at)",
|
||
f" VALUES (v_account_id, {config.default_role_id}, 1, {config.operator_id}, {config.operator_id}, NOW(), NOW());",
|
||
"",
|
||
" INSERT INTO tb_shop_role (shop_id, role_id, status, creator, updater, created_at, updated_at)",
|
||
f" VALUES (v_shop_id, {config.default_role_id}, 1, {config.operator_id}, {config.operator_id}, NOW(), NOW());",
|
||
"",
|
||
" INSERT INTO tb_agent_wallet (shop_id, wallet_type, balance, frozen_balance, credit_enabled, credit_limit, currency, status, version, shop_id_tag)",
|
||
" SELECT v_shop_id, 'main', 0, 0, default_credit_enabled, default_credit_limit, 'CNY', 1, 0, v_shop_id",
|
||
f" FROM tb_role WHERE id = {config.default_role_id} AND deleted_at IS NULL;",
|
||
"",
|
||
" INSERT INTO tb_agent_wallet (shop_id, wallet_type, balance, frozen_balance, credit_enabled, credit_limit, currency, status, version, shop_id_tag)",
|
||
" VALUES (v_shop_id, 'commission', 0, 0, FALSE, 0, 'CNY', 1, 0, v_shop_id);",
|
||
*_audit_sql(row, plan),
|
||
"END;",
|
||
"$shop_import$;",
|
||
"",
|
||
]
|
||
|
||
|
||
def _audit_sql(row: ShopImportRow, plan: ShopImportPlan) -> list[str]:
|
||
config = plan.config
|
||
owner_id = config.salesperson_account_ids[row.salesperson]
|
||
created_event_id, created_hash = _audit_ids(config.batch_id, row.line_no, "shop.create")
|
||
owner_event_id, owner_hash = _audit_ids(config.batch_id, row.line_no, "shop.update_business_owner")
|
||
identity = (
|
||
"jsonb_build_object('id', v_shop_id, 'shop_code', " + _sql_str(row.shop_code) + ", 'shop_name', " + _sql_str(row.shop_name) + ", "
|
||
"'parent_id', v_parent_id, 'level', " + str(row.level) + ")"
|
||
)
|
||
after = (
|
||
"jsonb_build_object('shop_name', " + _sql_str(row.shop_name) + ", 'contact_name', " + _sql_str(row.agent_name) + ", "
|
||
"'contact_phone', " + _sql_str(row.phone) + ", 'province', '', 'city', '', 'district', '', 'address', '', "
|
||
"'shop_code', " + _sql_str(row.shop_code) + ", 'parent_id', v_parent_id, 'level', " + str(row.level) + ")"
|
||
)
|
||
parent_resource = [
|
||
" IF v_parent_id IS NOT NULL THEN",
|
||
" INSERT INTO tb_audit_event_resource (audit_event_id, resource_type, resource_id, resource_key, display_name, relation, role, identity_snapshot, before_data, after_data, subject_visibility, subject_summary, subject_data, sort_order)",
|
||
" SELECT v_event_id, 'shop', id::text, id::text, shop_name, 'reference', 'shop_parent',",
|
||
" jsonb_build_object('id', id, 'shop_code', shop_code, 'shop_name', shop_name, 'parent_id', parent_id, 'level', level),",
|
||
" '{}'::jsonb, '{}'::jsonb, 'internal_only', '', '{}'::jsonb, 0",
|
||
" FROM tb_shop WHERE id = v_parent_id;",
|
||
" END IF;",
|
||
]
|
||
return [
|
||
"",
|
||
" INSERT INTO tb_audit_event (event_id, occurred_at, category, action_code, action_name, summary, actor_kind, actor_id, actor_name, source, scope_type, result, risk_level, metadata, content_hash)",
|
||
" VALUES ("
|
||
f"{_sql_str(created_event_id)}, NOW(), 'business', 'shop.create', '创建店铺', '创建店铺', 'account', '{config.operator_id}', v_operator_name, "
|
||
f"'admin_api', 'platform', 'success', 'normal', jsonb_build_object('batch_id', {_sql_str(config.batch_id)}, 'csv_line_no', {row.line_no}), {_sql_str(created_hash)})",
|
||
" RETURNING id INTO v_event_id;",
|
||
" INSERT INTO tb_audit_event_resource (audit_event_id, resource_type, resource_id, resource_key, display_name, relation, role, identity_snapshot, before_data, after_data, subject_visibility, subject_summary, subject_data, sort_order)",
|
||
" VALUES (v_event_id, 'shop', v_shop_id::text, v_shop_id::text, " + _sql_str(row.shop_name) + ", 'primary', 'shop_target', " + identity + ", '{}'::jsonb, " + after + ", 'internal_only', '', '{}'::jsonb, 0);",
|
||
*parent_resource,
|
||
"",
|
||
" INSERT INTO tb_audit_event (event_id, occurred_at, category, action_code, action_name, summary, actor_kind, actor_id, actor_name, source, scope_type, result, risk_level, metadata, content_hash)",
|
||
" VALUES ("
|
||
f"{_sql_str(owner_event_id)}, NOW(), 'business', 'shop.update_business_owner', '更新店铺业务员归属', '设置店铺业务员归属', 'account', '{config.operator_id}', v_operator_name, "
|
||
f"'admin_api', 'platform', 'success', 'normal', jsonb_build_object('batch_id', {_sql_str(config.batch_id)}, 'csv_line_no', {row.line_no}), {_sql_str(owner_hash)})",
|
||
" RETURNING id INTO v_event_id;",
|
||
" INSERT INTO tb_audit_event_resource (audit_event_id, resource_type, resource_id, resource_key, display_name, relation, role, identity_snapshot, before_data, after_data, subject_visibility, subject_summary, subject_data, sort_order)",
|
||
" VALUES (v_event_id, 'shop', v_shop_id::text, v_shop_id::text, " + _sql_str(row.shop_name) + ", 'primary', 'shop_target', " + identity + ", '{}'::jsonb, " + f"jsonb_build_object('business_owner_account_id', {owner_id})" + ", 'subject_result', '店铺业务员归属已设置', '{}'::jsonb, 0);",
|
||
*parent_resource,
|
||
" INSERT INTO tb_audit_event_resource (audit_event_id, resource_type, resource_id, resource_key, display_name, relation, role, identity_snapshot, before_data, after_data, subject_visibility, subject_summary, subject_data, sort_order)",
|
||
" SELECT v_event_id, 'account', id::text, id::text, username, 'reference', 'shop_business_owner',",
|
||
" jsonb_build_object('id', id, 'username', username, 'phone', phone, 'user_type', user_type, 'shop_id', shop_id, 'enterprise_id', enterprise_id, 'wecom_userid', wecom_userid, 'wecom_name', wecom_name),",
|
||
" jsonb_build_object('assigned', FALSE), jsonb_build_object('assigned', TRUE), 'internal_only', '', '{}'::jsonb, 1",
|
||
f" FROM tb_account WHERE id = {owner_id};",
|
||
]
|
||
|
||
|
||
def _audit_ids(batch_id: str, line_no: int, action: str) -> tuple[str, str]:
|
||
seed = f"{batch_id}:{line_no}:{action}"
|
||
digest = sha256(seed.encode()).hexdigest()
|
||
return f"evt_{digest[:32]}", digest
|
||
|
||
|
||
def _sql_str(value: str) -> str:
|
||
return "'" + value.replace("'", "''") + "'"
|