122 Commits

Author SHA1 Message Date
XingQue
79c86ec373 fix: 全支付接口入口拦截小汐支付宝,微信支付路径不动
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 16:29:50 +08:00
XingQue
49e07c2673 fix: 仅小汐(xx)禁止发起支付宝支付,其它俱乐部与回调不受影响
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 16:08:03 +08:00
XingQue
231e5162b0 feat: 俱乐部订单单向互通抢单(集团配置 + 接单池/抢单校验)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 03:08:10 +08:00
XingQue
1234cff035 fix: 会员/图标换图生成新路径并删除旧OSS,避免CDN缓存导致不更新
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 02:52:52 +08:00
XingQue
7cb41bf4d7 fix: 后台用户菜单对俱乐部超级管理000001可见(page_id兜底)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 02:21:18 +08:00
XingQue
6443336bd9 fix: 俱乐部超级管理000001可添加/管理本店后台用户,无需集团权限
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 02:14:33 +08:00
XingQue
98b4526efa feat: 新增小汐电竞种子组长/管事命令 seed_xx_invite_data
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 02:01:02 +08:00
XingQue
c870e7e6e9 fix: 单俱乐部视图下角色/用户/会员严格隔离,禁止泄露其他俱乐部
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 01:48:51 +08:00
XingQue
30c2dff040 fix: 小溪目录复制脚本兼容俱乐部名「小汐电竞」
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 01:26:24 +08:00
XingQue
280d8742c9 feat: 提现审核按俱乐部隔离并加固打款商户号,新增小溪审核目录复制脚本
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 01:20:42 +08:00
XingQue
332c42c85d feat: 新增打手「我的」VIP条可配置背景与图标
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 22:49:11 +08:00
XingQue
ccd0149897 fix: 接单池/充值背景按俱乐部下发更稳健,并返回 club_id 便于排查
配置型大图在已上传或路径非默认时均下发;payload 带 club_id,避免后台与小程序俱乐部不一致导致空白。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 21:08:05 +08:00
XingQue
0f7e5c503c fix: icon_key 无效时回传具体 key,并提示需更新接单池背景相关版本
便于定位是空 key 还是服务器未 pull/未重启导致 grab_card_* 未注册。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 20:56:00 +08:00
XingQue
3c017e2ad4 fix: 未上传的配置型大背景后台列表不再返回假默认图
避免图标管理预览 404,并明确 is_uploaded 状态,方便前端展示上传按钮。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 20:52:09 +08:00
XingQue
fb5a1707db feat: 接单池三类订单卡背景支持图标管理配置
新增平台/优质商家/普通商家卡背景 icon key,未上传不下发,按俱乐部独立配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 20:41:24 +08:00
XingQue
ff3d1e2ed3 fix: 审核商品脚本去掉须知中的代练等敏感词
自检误把「禁止代练」写进文案导致脚本自拦截;改为正向表述并不再触发敏感词。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 19:21:44 +08:00
XingQue
78850a5be8 feat: 龙先生审核目录重置为电竞技巧/策略指导商品
对齐小程序简介口径,避开代练代打与线下陪玩语义,仅写入 lxs 审核专用专区与商品。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 19:19:11 +08:00
XingQue
da4514eeda feat: 龙先生审核目录重置为娱乐陪伴正能量商品
三审核类型改名;每类型2专区×10商品;避开代练代打;dry-run双确认。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 15:52:00 +08:00
XingQue
59336424e3 feat: 龙先生审核目录清空后从星阙复制
仅 lxs 审核专区/商品;先删后从 xq 镜像;dry-run + --i-confirm=lxs-from-xq。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 15:24:32 +08:00
XingQue
47939042e6 feat: 龙先生审核「娱乐护航」专区商品复制到其它审核类型
仅 lxs;只增不减;默认 dry-run,--apply 需 --i-confirm-club=lxs。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 11:52:32 +08:00
XingQue
12d01614e9 feat: 龙先生审核版游戏护航商品种子脚本
仅 lxs;shenhezhuangtai=2;正向陪伴文案;默认头像图;dry-run + 双确认。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 17:47:25 +08:00
XingQue
d771add6e8 feat: 新增删除 UFO 公共商品与专区(含 OSS)管理命令
仅 club_id=ufo 且 dianpu 为空;默认 dry-run,--apply 需 --i-confirm-club=ufo。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 16:40:46 +08:00
XingQue
2a2ed25f0c fix: copy_ufo_sanjiaozhou_catalog 的 --list-clubs 可不传 --to-club
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 00:57:33 +08:00
XingQue
e8e98e2970 feat: 新增 UFO 三角洲商品目录复制到龙先生的管理命令
默认 dry-run,--apply 需双确认 club_id;只增不减,OSS 图物理复制。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 00:50:45 +08:00
XingQue
4b5130c824 feat: 会员/履约金页文案按俱乐部可配置
新增 club_recharge_page_copy 表与小程序/后台读写接口,kefu 菜单挂「充值页文案」。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 00:04:23 +08:00
XingQue
dcb191ac3a fix: 邀请码俱乐部不符时提示两侧 club_id;新增 diagnose_lsx_invite 对齐种子
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 22:30:23 +08:00
XingQue
d2d8e37080 fix: seed_lsx_invite_data 找不到 lsx 时列出全部俱乐部并支持 --club-id
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 22:25:43 +08:00
XingQue
0d9139d5cc fix: 删除命令强制仅 lsx,预检清单,禁止误删其他俱乐部
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 22:04:13 +08:00
XingQue
34344fa5b2 feat: 按商户号彻底删除俱乐部及绑定配置 delete_club_by_mch_id
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 21:59:17 +08:00
XingQue
b41f2b3095 fix: 清理脚本按路径是否以 .pem 结尾判定保留/删除俱乐部
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 21:46:31 +08:00
XingQue
de88e55378 fix: 不再自动创建 lsx 空俱乐部;新增按无 key 路径清理重复配置
cleanup_lsx_empty_club 只删无证书路径的龙先生相关俱乐部,保留已上传 key 的配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 21:43:12 +08:00
XingQue
cddd231dc2 feat: 后台添加商家 + 龙先生 lsx 俱乐部/邀请链种子
支持客服按用户UID开通商家(分俱乐部校验);新增 seed_club_lsx、seed_lsx_invite_data。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 21:29:22 +08:00
XingQue
46d64fbf92 fix: 商品分成字段名兼容,修复后台默认/自定义分成无法回显与保存
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 19:41:49 +08:00
XingQue
6cb3dff949 fix: 老板订单详情查询打手表现字段错误导致接单后整页500
dingdanxiangqing2 误用 OrderID 过滤 DashouBiaoxian(应为 dingdan_id),有人接单即服务器内部错误,打手信息无法返回。同步修正结算写入 fanche_cishu 与评分整数累加。不影响其它俱乐部业务分支。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 04:06:05 +08:00
XingQue
c25ad40630 chore: 公告轮播接口响应附带 club_id
仅多返回字段,不改查询/鉴权/写入逻辑,便于核对俱乐部隔离。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 03:52:47 +08:00
XingQue
3313da5ce2 feat: 待付款续付 jixuzhifu,复用原单号且不误改支付状态
仅 Status=9 可续付;续付前渠道查单,已付走现有 fucha 履约;本接口不把未付标成已付。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 03:21:23 +08:00
XingQue
f2a9c1abc5 feat: 点单商品列表/详情多返回介绍与对外销量
hqsp 增加 jieshao,保持 xiaoliang=duiwai_xiaoliang;详情补充 jieshao/duiwai_xiaoliang 别名。仅多字段,不影响其它俱乐部逻辑。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 03:13:24 +08:00
XingQue
44f13e3310 revert: 撤回登录相关鉴权改动,恢复星阙原登录逻辑
UFO 登录问题应只在前端处理(登录请求不带旧 Token),不应改共享后端登录接口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 01:15:03 +08:00
XingQue
d7f40409d6 fix: 客服登录接口同样彻底跳过鉴权
避免 kefu-login 因坏 Token / 空鉴权头被 DRF 改写成 403 权限不足。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 01:11:49 +08:00
XingQue
97e9093aa5 fix: 登录接口彻底跳过鉴权,消除 403 权限不足
ClubWechatLoginView / WechatMiniProgramLoginView 覆盖 initial,不做 JWT/权限/限流;动态配置接口也不再解析坏 Token。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 01:11:18 +08:00
XingQue
c37d66745b fix: 微信登录接口跳过 JWT 鉴权,避免旧 Token 导致 403
ClubWechatLoginView 设置 authentication_classes=[],登录请求不再校验 Authorization。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 00:55:18 +08:00
XingQue
65db2772d8 feat: 点单端搜索可指定打手接口 sousuodashou
仅返回未封禁且当前未在服务中的打手,按俱乐部隔离,不影响其他业务。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 00:31:47 +08:00
XingQue
4826a3ca23 feat: 增加 import_ufo_banners,仅向 ufo 灌入轮播与我的背景
写入 Lunbo:accept_order / accept_order_banner2 / boss_center;不动其它俱乐部。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 23:41:13 +08:00
XingQue
bc29118a6e fix: UFO 导入图片路径对齐后台目录,失败不写外站 URL
商品/专区/规则/轮播分别进 shangpin/shangpintupian、zhuanqu、guize、a_long/lunbo;库只存相对路径。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 23:34:09 +08:00
XingQue
e722c7016d fix: UFO 导入默认精确匹配类型名「三角洲」并写俱乐部上架表
不模糊匹配;ClubShangpinLeixingConfig(ufo) 上架 + 商品/专区挂同一 leixing_id。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 23:28:40 +08:00
XingQue
93580a6d63 fix: UFO 导入必须显式 --leixing-id,避免三角洲类型挂错
新增 --list-leixing;名称不含「三角洲」直接拦截;禁止模糊匹配。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 23:26:05 +08:00
XingQue
9d49c96e64 feat: 增加 import_ufo_catalog,仅向 club=ufo 灌入商品专区
默认 dry-run;--apply 写库并上传 OSS;可选 --with-banners。内置 ufo_catalog.json。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 23:20:31 +08:00
XingQue
a483236d45 chore: 轮播选项文案标明第二横幅与个人中心可多图
便于后台区分 UFO 点单主轮播 / 第二横幅 / 我的背景。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 23:12:41 +08:00
XingQue
ce86b488d9 fix: 微信登录失败时返回明确错误,附属信息异常不阻断登录
避免 staff/订单统计异常导致整次 wechat-login 500;缺俱乐部配置时提示更清晰。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 21:00:09 +08:00
XingQue
dc4c0ad091 feat: 转盘按商品重设计(库存、控奖、后台免填商品编号)
转盘即商品自动建绑;奖池支持库存;控奖指定用户必中;xq/xzj 普通点单路径不变。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 20:39:59 +08:00
XingQue
43ad221c67 feat: 点单横幅/个人中心背景 page_key,并 seed 转盘菜单
新增 accept_order_banner2、boss_center;菜单定义写入 product.turntable,不影响现有俱乐部数据隔离。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 20:21:16 +08:00
XingQue
6a107e873d feat: UFO 转盘购票与专区图标(不影响现有俱乐部点单)
新增转盘活动/奖品/机会表与 Czjilu leixing=7 履约;专区 tupian_url;hqsp/详情下发 shi_zhuanpan。普通点单与抢单主链路保持不变。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 18:59:26 +08:00
XingQue
dacd55bb1e fix: 统一付呗/微信直连官方查单确认,轮询幂等入账
复查与充值轮询按通道查官方单:付呗单查付呗、直连查微信;履约仍幂等防多加,未完成返回业务码便于继续轮询。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 20:57:36 +08:00
XingQue
b19430213a fix: 付呗支付复查/轮询改查付呗单,避免一直显示支付中
下单已走付呗但复查仍查微信直连,导致付成功不入账;现按流水查付呗并履约,同时加强回调日志。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 20:50:14 +08:00
XingQue
e03d8728ae fix: 支付通道管理权限导入改为 admin_context
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 20:04:27 +08:00
XingQue
5762f00d40 feat: 分俱乐部付呗收款通道,默认仍走微信直连
小程序前端仍传 wechat;后台可切换 wechat/fubei,支持配置多套付呗并退款按原通道路由。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 20:02:24 +08:00
XingQue
f36db77f6b fix: 新订单服务号模板改为两字段并换新模板ID
对齐「新订单待受理通知」:short_thing4 游戏类型、thing15 下单项目;不改动广播触发点。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 16:27:12 +08:00
XingQue
ab3b9f21b5 feat: 投诉记录独立图标;邀请点单文案与推荐人默认图修正
新增 fighter_complaint_record,与我的处罚分开;点单邀请文案改为邀请点单,推荐人默认改用 icon-sub。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 04:15:49 +08:00
XingQue
ef49fe3855 feat: 点单客服/邀请与打手关注快手图标可后台配置
新增 cuser_mine_kefu、cuser_mine_invite、cuser_mine_invitees、fighter_follow_kuaishou,后台「图标与频道」可分别上传。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 04:02:46 +08:00
XingQue
24cb0b88f7 fix: 审核配置缺 club_id 时回落旧表,避免 hqsp 500
服务器未 migrate shop.0004 时不再因 Unknown column 打挂点单商品接口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 03:56:11 +08:00
XingQue
8e82a8457d feat: 收款与提现商户拆分,支持后台上传证书
收款仍用 mch_id/mch_key;提现优先 withdraw_mch_id(空则回落);证书分 pay/withdraw 目录落盘。退款逻辑未改。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 03:52:55 +08:00
XingQue
98b58f8058 fix: 商品基础数据容错避免500;类型专区按俱乐部上架并管专区
具体俱乐部只返回已上架类型;上架/下架允许 7007a 或 8080a。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 03:33:49 +08:00
XingQue
f1db185d03 fix: 专区管理允许在具体俱乐部操作,类型仍限集团汇总
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 03:24:41 +08:00
XingQue
6515568ec0 feat: 只看审核按俱乐部隔离,商品管理支持俱乐部视图
未绑店出货开关按 club_id 配置;后台商品列表可在具体小程序下管理并切换审核展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 03:20:03 +08:00
XingQue
ed627fa6f7 fix: 修复点单商品在多店铺间跳动
扫码绑店参数只消费一次,避免 Tab 返回反复换绑;一人多条脏绑定取最新并在换绑时清理,hqsp 稳定取当前店。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 02:53:34 +08:00
XingQue
435422e716 fix: 点单端推荐官按UID绑定,二维码进首页且去掉邀请码
扫码参数改为 yaoqingren=UserUID 落地点单首页;海报默认不再复用管事背景;不影响打手端与支付抢单分红。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 02:48:16 +08:00
XingQue
60d8b52693 feat: 点单端邀请绑定与只看审核商品开关
复用 ClubMiniappIcon 增加点单端海报背景;新增 C 端邀请码/换绑店铺接口;hqsp 支持 zhi_kan_shenhe,不影响支付抢单分红。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 02:37:31 +08:00
XingQue
d5b8ff5b8a feat: 商品/专区/店铺按俱乐部分离,工单统计与点单手机号认证
商品与专区、店铺增加 club_id(历史默认 xq);列表按俱乐部过滤;工单列表返回待办统计;新增 hqshouji/rzwlsjh 手机号查询与认证接口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 01:55:14 +08:00
XingQue
7c6e14db01 fix: 客服菜单增加投诉工单,列表按俱乐部过滤
seed_kefu_menu 后侧栏才能出现「投诉工单」;admin list 使用 club 上下文过滤。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 00:47:24 +08:00
XingQue
27862fa22b fix: 工单图片/升级表冗余字段改名,避免与外键 gongdan_id 冲突
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 00:37:21 +08:00
XingQue
a432a0b9c4 feat: 投诉工单后端(模型/接口/迁移/权限)
支持用户提交投诉、证据图上传、升级补充;客服列表处理;充值记录列表供投诉选ID。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 00:30:36 +08:00
XingQue
eadf9e2503 fix: 支付宝充值回调地址写死到 settings,沿用原有密钥配置
不再要求改 app_secrets;NOTIFY_URL_CZJILU 缺省或误配成订单回调时自动用 /shangpin/alipay-czjilu-notify/。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 23:06:13 +08:00
XingQue
1ab59efddb fix: 加固支付宝充值回调幂等履约,避免付成功不到账与误删单
禁止 Czjilu 回调地址回落到订单 notify;回调失败回 fail 重试;取消支付改关单不删;客服补单走同一履约防多加。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 22:59:59 +08:00
XingQue
48d2e13037 fix: 商家生成链接时锁定打手与管事分成
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 15:50:24 +08:00
XingQue
de74865278 fix: 打手提现/收款认赠送会员为有效会员资格
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 21:34:57 +08:00
XingQue
e7b59feacd fix: 罚款微信支付 openid 与 AppID 使用同一俱乐部
交罚款时不再用罚单申请人俱乐部取 openid、打手俱乐部取商户配置;统一按当前小程序俱乐部下单,避免部分人 appid/openid 不一致导致下单失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 18:45:57 +08:00
XingQue
b6044cd44a fix: 收款申请仅拦截进行中订单
打手佣金/押金收款时,订单状态只拦 Status=2;退款中、结算中等不再拦截;其余资格校验不变。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 17:01:33 +08:00
XingQue
cb89720260 feat: 打手赠送会员(免费接单)独立表与抢单资格
新增 dashou_zengsong_huiyuan,与购买会员分离;抢单/抢单池认赠送有效会员;后台送/改期/移除接口,送时积分非10则改为10并写操作日志。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 02:14:07 +08:00
XingQue
a1a49261a5 Revert "fix: 抢单押金校验计入提现冻结中押金"
This reverts commit 52009a54a9.
2026-07-17 01:52:45 +08:00
XingQue
52009a54a9 fix: 抢单押金校验计入提现冻结中押金
押金提现申请后余额已扣,仅抢单接口把审核中/待收款冻结额加回可用;提现与换人等其它场景仍只认实际余额。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 01:50:45 +08:00
XingQue
60fdf8c427 fix: 加固称号增删改接口参数校验
兼容空 leixing/中文类型名,规范化板块与称号 ID,避免误报无效类型;修正 update_feiyong 复用逻辑。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 15:53:31 +08:00
XingQue
118260a710 fix: 修复考核官添加板块校验与板块改名接口
板块 ID 统一按整数校验,避免字符串与数据库整型比较导致误报不存在;板块接口补充 scope 与 bankuai_id 校验。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 15:33:54 +08:00
XingQue
dac6193d5b fix(merchant): 操作统计按订单去重并返回职位备注
同一行为对同一订单只计1单金额取Max;补充 total_order_count/action_count;人员候选展示职位与商家备注。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 00:00:07 +08:00
XingQue
477129de46 fix(merchant): 操作人统计时间筛选兼容 USE_TZ=False
MySQL 在关闭 USE_TZ 时不接受 timezone-aware datetime,解析为 naive 再查询。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 23:53:52 +08:00
XingQue
9c029288ba feat(merchant): 订单操作流水表与按真实操作人统计
新增 merchant_order_action_log,在结算/撤单/退款/罚单/派单/换打手成功路径写入真实操作人;提供 action-stats / action-operators 接口供经营数据页筛选统计。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 23:42:39 +08:00
XingQue
e6e8e718d3 fix: 子客服(总管)申请罚款证据上传权限,支持商家客服代商家操作
COS 上传接口 fakuan_evidence / fakuan_evidence_draft 现允许具 penalty_apply 或 penalty_manage 且能查看该订单的子客服。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 16:00:06 +08:00
XingQue
ce8bda52d3 feat: 打手抢单大厅默认关闭假单展示,保留开关可恢复
通过 FAKE_GRAB_ORDER_POOL_ENABLED 控制,False 时仅返回真实订单,后台假单管理功能不变。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 15:29:27 +08:00
XingQue
80cca803f8 fix: 总榜按邀请总人数排行,组长去重统计;总会员子类型解析增强
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 02:00:31 +08:00
XingQue
5556bbdb61 fix: 总榜按无效人数排行;总会员ID匹配与子类型展开修复
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 01:56:39 +08:00
XingQue
fda4ffb628 fix: 提现申请仅拦截进行中订单,移除打手状态误拦
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 00:15:13 +08:00
XingQue
b02b2dffed fix: 提现申请放行退款审核中订单,管理员拒绝退款不再500
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 00:06:42 +08:00
XingQue
dc6ddee0d7 fix: 打手提现申请仅拦截进行中与退款审核中订单
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 23:49:01 +08:00
XingQue
6689b7af00 fix: 批量退款处理人ID默认138377,限制chuliid最长11位
batch_20260710 超长导致762单全部失败;与客服手机号字段一致。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 23:21:23 +08:00
XingQue
73f0ce36f2 feat: 星阙商家退款审核批量同意脚本
新增 batch_approve_xq_merchant_refunds 管理命令,复用 kefusjtk 同意退款逻辑;
默认要求待审>=762单且 dry-run,--execute 才真正处理。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 23:18:56 +08:00
XingQue
48eca8e5d0 fix: 星之界(xzj)与星阙共用商户证书路径及V3兜底配置
xzj 提现/查单与 xq 同读 settings 私钥目录;AppID 仍各用 club 表小程序。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 22:57:21 +08:00
XingQue
6ac32674a5 fix: 证书扫描含pub_key.pem;诊断明确缺少apiclient_cert.pem
服务器仅有私钥无商户证书时V3签名必失败,诊断输出操作指引。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 22:33:24 +08:00
XingQue
b5021ee07d fix: V3签名从私钥配对证书自动取序列号,xq忽略club错误私钥路径
SIGN_ERROR根因是私钥与序列号不是同一套证书;扫描匹配 apiclient_cert.pem。
新增 manage.py wx_v3_pay_diag 探针验签。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 22:31:14 +08:00
XingQue
2e419b5b6e fix: 星阙提现改用club库商户号1747846572,禁止回落旧1746545364
优先读 club 表;为空时用正确 mch/api_v3_key;并从 apiclient_cert.pem 自动读序列号。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 22:29:20 +08:00
XingQue
a28dafa6b7 fix: 补回 seed_club_xq 缺失的 CLUB_ID_DEFAULT 导入
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 22:23:57 +08:00
XingQue
ca67dfa863 fix: 星阙V3提现强制用全局证书配置,修复序列号与私钥不匹配
club表私钥路径/序列号错填会导致微信报证书序列号错误。
xq 提现改读 settings+写死序列号;build_authorization 双重兜底。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 22:23:31 +08:00
XingQue
65a40b5f09 fix: 临时写死微信V3商户证书序列号
覆盖 club 表与 settings 中的 cert_serial_no,统一使用 apiclient_key 对应序列号。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 22:17:47 +08:00
XingQue
d6237ccc7b fix: 收款不再预查微信,恢复早期「有package就调起」逻辑
接入多俱乐部后每次收款先查微信,V3查单失败会永久返回code12。
改为仅读本地:有package直接确认,无package立刻作废重试;转账失败返回明确错误。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 22:07:09 +08:00
XingQue
8bc0afb7f4 fix: 简化提现收款对账,避免永远卡在收款处理中
- 同一审核单只保留最新一条进行中打款,多次点击不再堆叠阻塞
- 有 package 时查单失败也直接调起微信确认
- 无 package 超过20秒即作废允许重试;转账/网络失败返回明确错误而非 code12

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 21:59:28 +08:00
XingQue
921941a36d fix: 无 package 的打款单不再长时间提示收款处理中
微信查单 not_found/失败且本地无 package 时立即允许重新发起收款。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 21:53:59 +08:00
XingQue
e1a3c07cef fix: 自动提现收款不再因查单失败卡死,失败可重试且防双笔
- 查单失败时优先用本地 package 直接调起微信确认收款
- 单次打款失败不关审核单(保持待收款6),允许同一审核单再次收款
- 跳过已失败打款记录;缩短 grace 至 2 分钟;去掉「核对状态」类死锁提示

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 21:48:00 +08:00
XingQue
49ad57bbb2 feat: club 表独立 mch_key 字段,后台明确区分 APIv2 收款与 APIv3 提现
- 新增 club.mch_key 列并迁移 config_json 历史数据,api_v3_key 保持独立
- club-manage 读写 mch_key;支付读 club.mch_key 优先,兼容 config_json 回落
- 提现查单返回商户号改读俱乐部 V3 配置;不影响其他业务逻辑

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 21:34:32 +08:00
XingQue
017d176790 fix: 星之界/星阙支付按俱乐部分别取 openid 与 AppID,启动静默刷新绑定
- resolve_club_payment_openid: xzj 回落 UserName 并 lazy 写绑定;xq 回落 OpenID;禁止跨俱乐部混用
- 统一 get_club_miniapp_appid;非 xq 禁止回落全局 AppID
- 新增 /jituan/auth/refresh-wx-bind;小程序有 token 时静默刷新 openid

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 20:57:30 +08:00
XingQue
5d3e4e32a2 fix: 小程序支付 AppID 与登录一致,登录覆盖过期 openid 绑定
- JSAPI 支付优先 club.wx_appid(与 jscode2session 一致,不再优先 pay_app_id)
- 登录时同一用户+俱乐部只保留最新 openid,清除旧绑定
- 支付仅读 user_wx_openid,彻底禁用 legacy OpenID/UserName 回落

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 20:52:56 +08:00
XingQue
f601541eb0 fix: 支付 openid 按俱乐部隔离,禁止跨小程序回落 legacy 字段
appid 已正确读 club 表,但 openid 在无 xq 绑定时会错误回落 UserName/OpenID(可能是星之界 openid),导致 appid 与 openid 不匹配。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 20:47:35 +08:00
XingQue
df416b8284 fix: 俱乐部配置接口区分 V2 小程序密钥与 APIv3 密钥
返回 pay_config 说明实际支付生效字段,避免后台仅展示 api_v3_key 造成误导。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 20:17:51 +08:00
XingQue
3660fa4cc7 fix: 会员支付打印微信完整错误并支持配置 V2 商户密钥
- 统一下单 XML 安全解析,日志输出 raw_response 与 err_code_des
- 下单前打印 club/appid/mch_id/key_source 诊断
- 后台 club-manage 可保存 config_json.mch_key(V2 MD5 密钥,非 api_v3_key)
- 未配 mch_key 时告警:回落全局密钥易导致签名错误

回退: git reset --hard backup-before-wx-v2-diag-20260710

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 20:05:00 +08:00
XingQue
e0fa112da6 Merge branch 'main' of https://git.gvsds.com/XingQue/Django
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	orders/views/payment.py
2026-07-10 19:40:07 +08:00
XingQue
42963a9ace fix: 星阙(xq)微信支付改读后台 club 表配置
移除 xq 强制回落 app_secrets 的逻辑,与星之界一致优先读 club 表;
字段为空时仍按项回落 settings,避免未配置时中断现网。
订单 fucha 查单同步使用 club 对应商户号。

回退: git reset --hard backup-before-xq-club-pay-20260710

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 19:39:15 +08:00
182f18da91 修复了主动点单能力 2026-07-10 15:50:43 +08:00
XingQue
4bda89d7c3 Merge branch 'main' of https://git.gvsds.com/XingQue/Django 2026-07-10 15:42:00 +08:00
XingQue
5e758a8a46 Merge branch 'main' of https://git.gvsds.com/XingQue/Django
Co-authored-by: Cursor <cursoragent@cursor.com>
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-07-10 15:39:37 +08:00
dcdcb7559e 修复了履约查单问题 2026-07-10 15:37:49 +08:00
5eebbfbff1 修复了罚单履约与回退机制 2026-07-10 15:18:45 +08:00
XingQue
4d1677a660 Merge branch 'main' of https://git.gvsds.com/XingQue/Django 2026-07-10 15:11:55 +08:00
XingQue
a3cf7803d8 chore: 拉取远程前保留本地改动
含 kefu_base 修改及 club_payment_channel 相关文件。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 15:11:53 +08:00
e123510185 Merge branch 'main' of https://git.gvsds.com/XingQue/Django 2026-07-10 14:04:32 +08:00
4652f3394a 修复了订单状态(所有支付接口)更新的问题 2026-07-10 14:03:42 +08:00
162 changed files with 20982 additions and 2571 deletions

View File

@@ -42,6 +42,7 @@ INSTALLED_APPS = [
'rank',
'merchant_ops',
'jituan',
'gongdan',
]
MIDDLEWARE = [
@@ -290,6 +291,12 @@ SHANGJIA_CZ_MIN_AMOUNT = 1
SHANGJIA_CZ_MAX_AMOUNT = 10000
FAKUAN_PAY_DESCRIPTION = '罚款缴纳'
FAKUAN_PAY_NOTIFY_URL = 'https://www.abas.asia/hqhd/yonghu/fk_huitiao'
ZHUANPAN_PAY_DESCRIPTION = '转盘购票'
ZHUANPAN_PAY_NOTIFY_URL = 'https://www.abas.asia/hqhd/shangpin/zhuanpanhuitiao'
# 支付宝 Czjilu 充值统一回调(会员/押金/积分/商家/考核/罚款)
# 与订单 alipay-notify 分离;密钥仍用 app_secrets.ALIPAY_PROD此处只写死回调地址
ALIPAY_CZJILU_NOTIFY_URL = 'https://www.abas.asia/hqhd/shangpin/alipay-czjilu-notify/'
# ==================== 订单ID前缀 ====================
ORDER_PREFIX = {
@@ -363,6 +370,8 @@ CELERY_TASK_QUEUES = {
# ==================== 定时任务时间配置 ====================
# 订单超时自动结算Celery process_expired_order / check_order_expire_task总开关False=彻底关闭
ORDER_AUTO_SETTLEMENT_ENABLED = False
# 打手抢单大厅假单展示False=仅返回真实订单(后台假单管理仍可用;改 True 可恢复假单池)
FAKE_GRAB_ORDER_POOL_ENABLED = False
ORDER_EXPIRE_SECONDS = 48 * 60 * 60 # 48小时 = 172800秒开关关闭时不生效
DAILY_TASK_HOUR = 0
DAILY_TASK_MINUTE = 30
@@ -375,11 +384,11 @@ ORDER_CHECK_INTERVAL = 5
# ==================== 微信支付 V3 ====================
WECHAT_PAY_V3_CONFIG = {
'MCHID': app_secrets.WECHAT_PAY_V3_MCHID,
'APPID': app_secrets.WECHAT_PAY_V3_APPID,
'API_V3_KEY': app_secrets.WECHAT_PAY_V3_API_V3_KEY,
'MCHID': '1747846572',
'APPID': 'wx0e4be86faac4a8d1',
'API_V3_KEY': 'dujie4637rufjd98ijuyrgcvbvbn4789',
'PRIVATE_KEY_PATH': app_secrets.WECHAT_PAY_V3_PRIVATE_KEY_PATH,
'CERT_SERIAL_NO': app_secrets.WECHAT_PAY_V3_CERT_SERIAL_NO,
'CERT_SERIAL_NO': '481784CF56E872D9D8122BAC64E6BC7390803582',
'PLATFORM_CERT_DIR': app_secrets.WECHAT_PAY_V3_PLATFORM_CERT_DIR,
'NOTIFY_URL': 'https://www.abas.asia/hqhd/yonghu/tixiancallback',
'TRANSFER_SCENE_ID': '1005',
@@ -390,3 +399,6 @@ WECHAT_PAY_V3_CONFIG = {
ALIPAY_SANDBOX = app_secrets.ALIPAY_SANDBOX
ALIPAY_PROD = app_secrets.ALIPAY_PROD
ALIPAY_SANDBOX_CFG = app_secrets.ALIPAY_SANDBOX_CFG
# 仅这些 club_id 禁止「发起」支付宝支付;回调/查单仍可用(提现收款不受影响)。
# 小溪/小汐 = xx。恢复改为 frozenset(),再重启 gunicorn。
ALIPAY_DISABLED_CLUB_IDS = frozenset({'xx'})

View File

@@ -25,6 +25,7 @@ urlpatterns = [
path('shangdian/', include('shop.urls')),
path('dengji/', include('rank.urls')),
path('jituan/', include('jituan.urls')),
path('gongdan/', include('gongdan.urls')),
]
# 重要说明:

View File

@@ -4,8 +4,8 @@ from django.views.decorators.csrf import csrf_exempt
from .view import (GetRolePermissionView,
ModifyRolePermissionView, AddRoleView, GetAdminRolesView, GetAdminUserListView, AddAdminUserView,
ModifyAdminUserView, KefuGetDashouListView, KefuGetDashouDetailView, KefuUpdateDashouView, KefuGetShangjiaListView,
KefuGetShangjiaDetailView, KefuUpdateShangjiaView, GetProductBaseDataView, GetProductListView, SaveProductOrderView,
AddProductView, DeleteProductView, GetProductDetailView, UpdateProductView, UpdateMemberView, GetMemberListView,
KefuGetShangjiaDetailView, KefuUpdateShangjiaView, KefuAddShangjiaView, GetProductBaseDataView, GetProductListView, SaveProductOrderView,
AddProductView, DeleteProductView, GetProductDetailView, UpdateProductView, UpdateZhiKanShenheView, UpdateMemberView, GetMemberListView,
AddMemberView, GetGuanliListView, GetGuanliDetailView, PromoteGuanshiToZuzhangView, ChangeInviterView, UpdateGuanliView, GetWithdrawSettingsView,
UpdateWithdrawSettingsView, GetZuzhangListView, GetZuzhangDetailView, UpdateZuzhangView, GetOperationLogListView, GetProductTypeZoneView,
ModifyProductTypeZoneView, GetRateView, ModifyRateView, PopupNoticeListAPIView, PopupNoticeModifyAPIView,ShopListView,
@@ -14,7 +14,8 @@ from .view import (GetRolePermissionView,
FineApplyView, PunishDashouView,
FaKuanTongJiView, FaKuanLieBiaoView, FaKuanChuLiView, FaKuanChuangJianView, FaKuanPingTaiShenHeView,
HqbkxxView,BkxgView,CaiwuView,CwhybkhqView, HybkjtsjView, SzxxView, CwddhqlxView, CwhqjtddsjView,
CwqtczhqView, KhpzhqView, ChzsgcView,KhgglView, ShgxgsjView, KhjlglView, KhjlczView, ZxkfghdsView)
CwqtczhqView, KhpzhqView, ChzsgcView,KhgglView, ShgxgsjView, KhjlglView, KhjlczView, ZxkfghdsView,
DashouGiftHuiyuanSaveView, DashouGiftHuiyuanRemoveView)
from config.views import MiniappScriptHoutaiListView, MiniappScriptHoutaiModifyView
from rank.reward_houtai_views import RankRewardHoutaiListView, RankRewardHoutaiSaveView, RankRewardHoutaiClaimsView
@@ -30,9 +31,12 @@ urlpatterns = [
path('kefuhqdslb', KefuGetDashouListView.as_view(), name='客服获取打手数据'),
path('kefuhqdsxq', KefuGetDashouDetailView.as_view(), name='客服获取打手具体信息'),
path('kefuxgds', KefuUpdateDashouView.as_view(), name='客服修改打手数据'),
path('dszsonghy', DashouGiftHuiyuanSaveView.as_view(), name='打手赠送会员'),
path('dsycsonghy', DashouGiftHuiyuanRemoveView.as_view(), name='移除打手赠送会员'),
path('hqsjgllb', KefuGetShangjiaListView.as_view(), name='后台获取商家列表'),
path('hthqsjxq', KefuGetShangjiaDetailView.as_view(), name='后台获取商家详情'),
path('xgsjxx', KefuUpdateShangjiaView.as_view(), name='后台修改商家数据'),
path('tjsj', KefuAddShangjiaView.as_view(), name='后台添加商家'),
path('htsphqjcsx', GetProductBaseDataView.as_view(), name='后台获取商品类型专区'),
path('hthqsplb', GetProductListView.as_view(), name='后台获取商品数据'),
@@ -41,6 +45,7 @@ urlpatterns = [
path('htscsp', DeleteProductView.as_view(), name='后台删除商品'),
path('htsphqxq', GetProductDetailView.as_view(), name='后台获取商品详情'),
path('bcspxg', UpdateProductView.as_view(), name='后台更新商品数据'),
path('htxgzhikan', UpdateZhiKanShenheView.as_view(), name='后台更新只看审核开关'),
path('hthqhylb', GetMemberListView.as_view(), name='后台获取会员列表'),
path('htxghyxx', UpdateMemberView.as_view(), name='后台更新会员信息'),

View File

@@ -35,11 +35,14 @@ from .views.roles import (
from .views.kefu import (
KefuGetDashouListView, KefuGetDashouDetailView, KefuUpdateDashouView,
KefuGetShangjiaListView, KefuGetShangjiaDetailView, KefuUpdateShangjiaView,
KefuAddShangjiaView,
KefuChatPermissionsView,
DashouGiftHuiyuanSaveView, DashouGiftHuiyuanRemoveView,
)
from .views.products import (
GetProductBaseDataView, GetProductListView, SaveProductOrderView,
AddProductView, DeleteProductView, GetProductDetailView, UpdateProductView,
UpdateZhiKanShenheView,
)
from .views.members import (
GetMemberListView, UpdateMemberView, AddMemberView,
@@ -96,11 +99,14 @@ __all__ = [
'GetAdminRolesView', 'GetAdminUserListView', 'ModifyAdminUserView', 'AddAdminUserView',
# kefu
'KefuGetDashouListView', 'KefuGetDashouDetailView', 'KefuUpdateDashouView',
'DashouGiftHuiyuanSaveView', 'DashouGiftHuiyuanRemoveView',
'KefuGetShangjiaListView', 'KefuGetShangjiaDetailView', 'KefuUpdateShangjiaView',
'KefuAddShangjiaView',
'KefuChatPermissionsView',
# products
'GetProductBaseDataView', 'GetProductListView', 'SaveProductOrderView',
'AddProductView', 'DeleteProductView', 'GetProductDetailView', 'UpdateProductView',
'UpdateZhiKanShenheView',
# members
'GetMemberListView', 'UpdateMemberView', 'AddMemberView',
'ClubMemberCatalogView', 'ClubMemberEnableView', 'ClubMemberDisableView',

View File

@@ -304,6 +304,7 @@ class KefuGetDashouDetailView(APIView):
'data': {
'user_info': user_info,
'member_list': member_list,
'gift_member_list': [],
'all_members': all_members,
'permissions': permissions,
}
@@ -369,11 +370,15 @@ class KefuGetDashouDetailView(APIView):
# 7. 查询所有会员类型(用于前端添加会员)
all_members = list(Huiyuan.query.all().values('huiyuan_id', 'jieshao'))
from jituan.services.gift_huiyuan import list_gift_huiyuan_for_admin
gift_member_list = list_gift_huiyuan_for_admin(uid)
return Response({
'code': 0,
'data': {
'user_info': user_info,
'member_list': member_list,
'gift_member_list': gift_member_list,
'all_members': all_members,
'permissions': permissions, # 返回当前客服的权限列表
}
@@ -1105,9 +1110,115 @@ class KefuUpdateShangjiaView(APIView):
return Response({'code': 400, 'msg': '未知操作'})
class KefuAddShangjiaView(APIView):
"""
后台按用户ID开通商家分俱乐部禁止前端自助注册场景使用
POST /houtai/tjsj 或 /jituan/houtai/tjsj
参数username, yonghuid用户业务ID, nicheng可选
权限1100a
"""
permission_classes = []
parser_classes = [JSONParser]
def post(self, request):
username = request.data.get('username', '').strip()
yonghuid = str(request.data.get('yonghuid', '')).strip()
nicheng_in = (request.data.get('nicheng') or '').strip()
if not username:
return Response({'code': 401, 'msg': '缺少username'})
if not yonghuid:
return Response({'code': 400, 'msg': '请填写用户ID'})
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return permissions
if '1100a' not in permissions:
return Response({'code': 403, 'msg': '无权限添加商家需要1100a'})
try:
user = User.query.get(UserUID=yonghuid)
except User.DoesNotExist:
return Response({'code': 404, 'msg': '用户不存在请确认用户ID'})
denied = forbid_if_user_out_of_scope(request, user)
if denied:
return denied
if not getattr(user, 'IsActive', True):
return Response({'code': 400, 'msg': '该账号已被封禁,无法开通商家'})
try:
dashou = user.DashouProfile
if int(getattr(dashou, 'zhanghaozhuangtai', 1) or 0) != 1:
return Response({'code': 400, 'msg': '该账号已被封禁,无法开通商家'})
except UserDashou.DoesNotExist:
pass
except Exception:
pass
if UserShangjia.query.filter(user=user).exists():
return Response({'code': 400, 'msg': '该用户已是商家,无需重复添加'})
from merchant_ops.services.authz import assert_no_active_staff_when_merchant_register, StaffAuthError
try:
assert_no_active_staff_when_merchant_register(user)
except StaffAuthError as e:
return Response({'code': 403, 'msg': getattr(e, 'msg', None) or str(e)})
except Exception:
pass
boss_nickname = '普通商家'
try:
boss = user.BossProfile
if boss.nickname and boss.nickname.strip():
boss_nickname = boss.nickname.strip()
except UserBoss.DoesNotExist:
pass
shangjia_nicheng = nicheng_in or boss_nickname
if UserShangjia.query.filter(nicheng=shangjia_nicheng).exists():
shangjia_nicheng = f"{shangjia_nicheng}{random.randint(1000, 9999)}"
try:
with transaction.atomic():
shop = UserShangjia.query.create(
user=user,
nicheng=shangjia_nicheng,
zhuangtai=1,
yue=0.00,
fabu=0,
tuikuan=0,
chengjiao=0,
jinridingdan=0,
jinriliushui=0.00,
jinyuedingdan=0,
jinyueliushui=0.00,
dianhua='',
wechat='',
)
write_xiugai_log(
yonghuid=yonghuid,
xiugaiid=kefu.user.Phone,
leixing=XIUGAI_LEIXING_SHANGJIA,
qitashuoming=(
f'【后台】添加商家用户ID={yonghuid},昵称={shop.nicheng}'
f'操作人={kefu.user.Phone}'
),
)
except Exception as e:
logger.exception('后台添加商家失败')
return Response({'code': 500, 'msg': f'添加失败:{e}'})
return Response({
'code': 0,
'msg': '商家添加成功',
'data': {
'yonghuid': yonghuid,
'nicheng': shop.nicheng,
'zhuangtai': shop.zhuangtai,
},
})
class KefuChatPermissionsView(APIView):
@@ -1150,6 +1261,223 @@ class KefuChatPermissionsView(APIView):
})
class DashouGiftHuiyuanSaveView(APIView):
"""
赠送会员 / 改期(免费接单资格,写入 dashou_zengsong_huiyuan
POST /houtai/dszsonghy
Body: {
phone, dashou_id, huiyuan_id,
daoqi_time: 'YYYY-MM-DD''YYYY-MM-DD HH:MM:SS',
id?: 已有记录ID改期时建议传
}
"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
from jituan.services.gift_huiyuan import (
has_gift_huiyuan_permission,
parse_gift_daoqi_time,
upsert_gift_huiyuan,
update_gift_huiyuan_daoqi,
)
from products.models import Huiyuan as HuiyuanModel
phone = (request.data.get('phone') or request.data.get('username') or '').strip()
dashou_id = (request.data.get('dashou_id') or request.data.get('uid') or '').strip()
huiyuan_id = (request.data.get('huiyuan_id') or '').strip()
record_id = request.data.get('id')
raw_daoqi = request.data.get('daoqi_time')
kefu, permissions = verify_kefu_permission(request, phone or None)
if kefu is None:
return permissions
if not has_gift_huiyuan_permission(permissions):
return Response({'code': 403, 'msg': '无赠送会员权限(需打手基础管理或加积分权限)'})
if not dashou_id:
return Response({'code': 400, 'msg': '缺少打手ID'})
daoqi_time, err = parse_gift_daoqi_time(raw_daoqi)
if err:
return Response({'code': 400, 'msg': err})
try:
dashou_user = User.query.get(UserUID=dashou_id)
except User.DoesNotExist:
return Response({'code': 404, 'msg': '打手不存在'})
denied = forbid_if_user_out_of_scope(request, dashou_user)
if denied:
return denied
try:
dashou_user.DashouProfile
except ObjectDoesNotExist:
return Response({'code': 404, 'msg': '该用户不是打手'})
song_ren = getattr(kefu.user, 'Phone', None) or phone
operator = song_ren
# 有记录ID只改期
if record_id not in (None, ''):
try:
rid = int(record_id)
except (TypeError, ValueError):
return Response({'code': 400, 'msg': '无效的记录ID'})
rec, err = update_gift_huiyuan_daoqi(
record_id=rid,
yonghu_id=dashou_id,
daoqi_time=daoqi_time,
song_ren=song_ren,
)
if err:
return Response({'code': 400, 'msg': err})
try:
hy_name = HuiyuanModel.query.get(huiyuan_id=rec.huiyuan_id).jieshao
except HuiyuanModel.DoesNotExist:
hy_name = rec.huiyuan_id
write_xiugai_log(
yonghuid=dashou_id,
xiugaiid=operator,
leixing=XIUGAI_LEIXING_DASHOU,
huiyuan_id=rec.huiyuan_id,
qitashuoming=(
f'【后台】修改赠送会员到期时间会员ID={rec.huiyuan_id}{hy_name}'
f'新到期={rec.daoqi_time.strftime("%Y-%m-%d %H:%M:%S")}'
f'打手ID={dashou_id},操作人={operator}'
),
)
return Response({
'code': 0,
'msg': '改期成功',
'data': {
'id': rec.id,
'huiyuan_id': rec.huiyuan_id,
'daoqi_time': rec.daoqi_time.strftime('%Y-%m-%d %H:%M:%S'),
},
})
if not huiyuan_id:
return Response({'code': 400, 'msg': '缺少会员ID'})
rec, created, jifen_info, err = upsert_gift_huiyuan(
yonghu_id=dashou_id,
huiyuan_id=huiyuan_id,
daoqi_time=daoqi_time,
song_ren=song_ren,
)
if err:
return Response({'code': 400, 'msg': err})
try:
hy_name = HuiyuanModel.query.get(huiyuan_id=huiyuan_id).jieshao
except HuiyuanModel.DoesNotExist:
hy_name = huiyuan_id
if jifen_info:
write_xiugai_log(
yonghuid=dashou_id,
xiugaiid=operator,
leixing=XIUGAI_LEIXING_DASHOU,
jifen=jifen_info['after'],
yjifen=jifen_info['before'],
qitashuoming=(
f'【后台】送会员时整改积分:{jifen_info["before"]}{jifen_info["after"]}'
f'原积分={jifen_info["before"]},整改后={jifen_info["after"]}'
f'打手ID={dashou_id},操作人={operator}(送会员免费接单)'
),
)
action_label = '赠送' if created else '更新到期时间'
write_xiugai_log(
yonghuid=dashou_id,
xiugaiid=operator,
leixing=XIUGAI_LEIXING_DASHOU,
huiyuan_id=huiyuan_id,
qitashuoming=(
f'【后台】{action_label}赠送会员免费接单会员ID={huiyuan_id}{hy_name}'
f'到期={rec.daoqi_time.strftime("%Y-%m-%d %H:%M:%S")}'
f'打手ID={dashou_id},操作人={operator}(写入赠送会员表,非购买会员)'
),
)
return Response({
'code': 0,
'msg': '赠送成功' if created else '到期时间已更新',
'data': {
'id': rec.id,
'huiyuan_id': rec.huiyuan_id,
'daoqi_time': rec.daoqi_time.strftime('%Y-%m-%d %H:%M:%S'),
'created': created,
'jifen_adjusted': bool(jifen_info),
'jifen': jifen_info,
},
})
class DashouGiftHuiyuanRemoveView(APIView):
"""
移除赠送会员
POST /houtai/dsycsonghy
Body: { phone, dashou_id, id? , huiyuan_id? }
"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
from jituan.services.gift_huiyuan import (
has_gift_huiyuan_permission,
remove_gift_huiyuan,
)
phone = (request.data.get('phone') or request.data.get('username') or '').strip()
dashou_id = (request.data.get('dashou_id') or request.data.get('uid') or '').strip()
record_id = request.data.get('id')
huiyuan_id = (request.data.get('huiyuan_id') or '').strip()
kefu, permissions = verify_kefu_permission(request, phone or None)
if kefu is None:
return permissions
if not has_gift_huiyuan_permission(permissions):
return Response({'code': 403, 'msg': '无移除赠送会员权限'})
if not dashou_id:
return Response({'code': 400, 'msg': '缺少打手ID'})
try:
dashou_user = User.query.get(UserUID=dashou_id)
except User.DoesNotExist:
return Response({'code': 404, 'msg': '打手不存在'})
denied = forbid_if_user_out_of_scope(request, dashou_user)
if denied:
return denied
rid = None
if record_id not in (None, ''):
try:
rid = int(record_id)
except (TypeError, ValueError):
return Response({'code': 400, 'msg': '无效的记录ID'})
ok, err = remove_gift_huiyuan(
record_id=rid,
yonghu_id=dashou_id if not rid else None,
huiyuan_id=huiyuan_id if not rid else None,
)
if err:
return Response({'code': 400, 'msg': err})
operator = getattr(kefu.user, 'Phone', None) or phone
write_xiugai_log(
yonghuid=dashou_id,
xiugaiid=operator,
leixing=XIUGAI_LEIXING_DASHOU,
huiyuan_id=huiyuan_id or '',
qitashuoming=(
f'【后台】移除赠送会员免费接单记录ID={rid or "-"}'
f'会员ID={huiyuan_id or "-"}打手ID={dashou_id},操作人={operator}'
),
)
return Response({'code': 0, 'msg': '移除成功'})

View File

@@ -141,18 +141,26 @@ class GetProductBaseDataView(APIView):
return permissions
if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'})
scope_err = block_non_group_catalog_scope(request)
if scope_err:
return Response({'code': 400, 'msg': scope_err})
# 获取所有商品类型(按 paixu 升序)
# 获取所有商品类型(按 paixu 升序);商品数按当前俱乐部统计
from jituan.services.club_context import filter_club_char_field, resolve_club_scope
from jituan.constants import DATA_SCOPE_ALL
from jituan.services.club_write import resolve_club_id_for_write
try:
type_qs = ShangpinLeixing.query.all().order_by('paixu')
# 批量聚合商品数量,避免循环内 N+1 count
# 批量聚合商品数量,避免循环内 N+1 count(按俱乐部)
_public_sp = filter_club_char_field(
Shangpin.query.filter(
dianpu__isnull=True, dianpu_leixing__isnull=True, leixing_id__isnull=False
),
request,
field='club_id',
)
# 用 Django QS 聚合,避免 Fluent values 链在部分环境下异常
_type_counts = {
item['leixing_id']: item['cnt']
for item in Shangpin.query.filter(
dianpu__isnull=True, dianpu_leixing__isnull=True, leixing_id__isnull=False
).values('leixing_id').annotate(cnt=Count('id'))
for item in _public_sp.values('leixing_id').annotate(cnt=Count('id'))
}
type_list = []
for t in type_qs:
@@ -166,12 +174,18 @@ class GetProductBaseDataView(APIView):
'product_count': product_count,
})
# 获取所有专区(按 paixu 升序
zone_qs = ShangpinZhuanqu.query.all().order_by('paixu')
# 获取专区(按俱乐部隔离;集团汇总看全部
zone_qs = filter_club_char_field(
ShangpinZhuanqu.query.all().order_by('paixu'), request, field='club_id'
)
_zone_counts = {
item['zhuanqu_id']: item['cnt']
for item in Shangpin.query.filter(
for item in filter_club_char_field(
Shangpin.query.filter(
dianpu__isnull=True, dianpu_leixing__isnull=True, zhuanqu_id__isnull=False
),
request,
field='club_id',
).values('zhuanqu_id').annotate(cnt=Count('id'))
}
zone_list = []
@@ -184,10 +198,38 @@ class GetProductBaseDataView(APIView):
'paixu': z.paixu,
'shenhezhuangtai': z.shenhezhuangtai,
'product_count': product_count,
'club_id': getattr(z, 'club_id', '') or '',
'tupian_url': getattr(z, 'tupian_url', '') or '',
})
# 获取所有会员(只需 ID、介绍、价格
member_list = list(Huiyuan.query.all().values('huiyuan_id', 'jieshao', 'jiage'))
member_list = []
for m in Huiyuan.query.all().values('huiyuan_id', 'jieshao', 'jiage'):
member_list.append({
'huiyuan_id': m['huiyuan_id'],
'jieshao': m['jieshao'] or '',
'jiage': float(m['jiage'] or 0),
})
# 本俱乐部「只看审核」开关(集团汇总不下发可写状态)
club_scoped = resolve_club_scope(request) != DATA_SCOPE_ALL
zhi_kan_shenhe = True
club_id = ''
if club_scoped:
club_id = resolve_club_id_for_write(request) or ''
try:
from shop.services.shenhe_config import (
get_or_create_shenhe_config,
is_zhi_kan_shenhe_enabled,
)
get_or_create_shenhe_config(club_id)
zhi_kan_shenhe = is_zhi_kan_shenhe_enabled(club_id)
except Exception:
logging.getLogger(__name__).exception(
'读取只看审核配置失败 club_id=%s(请确认已 migrate shop 0004',
club_id,
)
zhi_kan_shenhe = True
return Response({
'code': 0,
@@ -195,8 +237,14 @@ class GetProductBaseDataView(APIView):
'type_list': type_list,
'zone_list': zone_list,
'member_list': member_list,
'zhi_kan_shenhe': zhi_kan_shenhe,
'club_scoped': club_scoped,
'club_id': club_id,
}
})
except Exception:
logging.getLogger(__name__).exception('GetProductBaseDataView 失败')
return Response({'code': 500, 'msg': '获取商品基础数据失败,请稍后重试'})
@@ -235,9 +283,6 @@ class GetProductListView(APIView):
return permissions
if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'})
scope_err = block_non_group_catalog_scope(request)
if scope_err:
return Response({'code': 400, 'msg': scope_err})
# 获取参数
leixing_id = request.data.get('leixing_id')
@@ -257,11 +302,13 @@ class GetProductListView(APIView):
except:
return Response({'code': 400, 'msg': '分页参数错误'})
# 🔥 修改:构建查询集,增加公共商品筛选(店铺为空且店铺商品类型为空
# 🔥 公共商品 + 按俱乐部隔离(集团汇总看全部
from jituan.services.club_context import filter_club_char_field
qs = Shangpin.query.filter(
dianpu__isnull=True, # 所属店铺为空
dianpu_leixing__isnull=True # 店铺商品类型为空
)
qs = filter_club_char_field(qs, request, field='club_id')
if leixing_id:
try:
@@ -323,8 +370,10 @@ class GetProductListView(APIView):
'jiage': str(p.jiage),
'kucun': p.kucun,
'zhenshi_xiaoliang': p.zhenshi_xiaoliang,
'duiwai_xiaoliang': p.duiwai_xiaoliang,
'tupian_url': p.tupian_url or '',
'jieshao': p.jieshao or '',
'club_id': getattr(p, 'club_id', '') or '',
})
return Response({
@@ -365,9 +414,6 @@ class GetProductListView(APIView):
return permissions
if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'})
scope_err = block_non_group_catalog_scope(request)
if scope_err:
return Response({'code': 400, 'msg': scope_err})
# 获取所有商品类型(按 paixu 升序)
type_qs = ShangpinLeixing.query.all().order_by('paixu')
@@ -404,6 +450,7 @@ class GetProductListView(APIView):
'paixu': z.paixu,
'shenhezhuangtai': z.shenhezhuangtai,
'product_count': product_count,
'tupian_url': getattr(z, 'tupian_url', '') or '',
})
# 获取所有会员(只需 ID、介绍、价格
@@ -454,9 +501,6 @@ class GetProductListView(APIView):
return permissions
if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'})
scope_err = block_non_group_catalog_scope(request)
if scope_err:
return Response({'code': 400, 'msg': scope_err})
# 获取参数
leixing_id = request.data.get('leixing_id')
@@ -578,9 +622,6 @@ class SaveProductOrderView(APIView):
return permissions
if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'})
scope_err = block_non_group_catalog_scope(request)
if scope_err:
return Response({'code': 400, 'msg': scope_err})
sort_type = request.data.get('type', '').strip().lower()
if sort_type not in ('leixing', 'zhuanqu'):
@@ -643,9 +684,6 @@ class AddProductView(APIView):
return permissions
if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'})
scope_err = block_non_group_catalog_scope(request)
if scope_err:
return Response({'code': 400, 'msg': scope_err})
# 获取表单字段
biaoqian = request.data.get('biaoqian', '').strip()
@@ -658,6 +696,9 @@ class AddProductView(APIView):
huiyuan_id = request.data.get('huiyuan_id', '').strip()
yongjin_str = request.data.get('yongjin', '0')
fencheng_type = request.data.get('fencheng_type', 'default')
# 兼容列表页 ewai_dashou_fencheng 与旧字段 ewai_PlayerCommission
ewai_PlayerCommission_str = request.data.get('ewai_dashou_fencheng')
if ewai_PlayerCommission_str is None or ewai_PlayerCommission_str == '':
ewai_PlayerCommission_str = request.data.get('ewai_PlayerCommission', '0')
jieshao = request.data.get('jieshao', '').strip()
xiadan_xuzhi = request.data.get('xiadan_xuzhi', '').strip()
@@ -707,6 +748,14 @@ class AddProductView(APIView):
zhuanqu_obj = ShangpinZhuanqu.query.get(id=zhuanqu_id)
if zhuanqu_obj.leixing_id != leixing_id:
return Response({'code': 400, 'msg': '专区不属于该商品类型'})
from jituan.constants import DATA_SCOPE_ALL
from jituan.services.club_context import resolve_club_scope
from jituan.services.club_write import resolve_club_id_for_write
if resolve_club_scope(request) != DATA_SCOPE_ALL:
write_club = resolve_club_id_for_write(request)
zone_club = getattr(zhuanqu_obj, 'club_id', None) or 'xq'
if zone_club != write_club:
return Response({'code': 400, 'msg': '专区不属于当前俱乐部,请先切换俱乐部或选择本俱乐部专区'})
except ShangpinZhuanqu.DoesNotExist:
return Response({'code': 400, 'msg': '商品专区不存在'})
@@ -774,6 +823,8 @@ class AddProductView(APIView):
# 创建商品
try:
from jituan.services.club_write import resolve_club_id_for_write
club_id = resolve_club_id_for_write(request)
with transaction.atomic():
product = Shangpin.query.create(
biaoqian=biaoqian,
@@ -793,6 +844,7 @@ class AddProductView(APIView):
ewai_dashou_fencheng=ewai_fencheng,
paixu=0, # 新商品默认排序最后
shenhezhuangtai=1, # 正常状态
club_id=club_id,
)
except Exception as e:
logger.error(f"创建商品失败: {str(e)}", exc_info=True)
@@ -843,9 +895,6 @@ class DeleteProductView(APIView):
return permissions
if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'})
scope_err = block_non_group_catalog_scope(request)
if scope_err:
return Response({'code': 400, 'msg': scope_err})
# 查询商品是否存在
try:
@@ -908,9 +957,6 @@ class GetProductDetailView(APIView):
return permissions
if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'})
scope_err = block_non_group_catalog_scope(request)
if scope_err:
return Response({'code': 400, 'msg': scope_err})
# 查询商品
try:
@@ -946,7 +992,10 @@ class GetProductDetailView(APIView):
'huiyuan_id': product.huiyuan_id,
'yongjin': str(product.yongjin) if product.yongjin is not None else None,
'tupian_url': product.tupian_url,
'kaioi_ewai_PlayerCommission': product.kaioi_ewai_dashou_fencheng,
# 兼容两套字段名(客服详情页用 kaioi_ewai_dashou_fencheng
'kaioi_ewai_dashou_fencheng': bool(product.kaioi_ewai_dashou_fencheng),
'ewai_dashou_fencheng': str(product.ewai_dashou_fencheng) if product.ewai_dashou_fencheng is not None else '0',
'kaioi_ewai_PlayerCommission': bool(product.kaioi_ewai_dashou_fencheng),
'ewai_PlayerCommission': str(product.ewai_dashou_fencheng) if product.ewai_dashou_fencheng is not None else '0',
'CreateTime': product.CreateTime.isoformat() if product.CreateTime else None,
'shenhezhuangtai': product.shenhezhuangtai,
@@ -992,9 +1041,6 @@ class UpdateProductView(APIView):
return permissions
if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'})
scope_err = block_non_group_catalog_scope(request)
if scope_err:
return Response({'code': 400, 'msg': scope_err})
# 使用事务,将 select_for_update 放在事务内
with transaction.atomic():
@@ -1070,16 +1116,26 @@ class UpdateProductView(APIView):
except:
yongjin = Decimal('0')
# ---------- 3. 打手分成 ----------
kaioi_ewai = request.data.get('kaioi_ewai_PlayerCommission') == 'true'
# ---------- 3. 打手分成(兼容详情页字段名与旧 PlayerCommission 别名) ----------
raw_kaioi = request.data.get('kaioi_ewai_dashou_fencheng')
if raw_kaioi is None:
raw_kaioi = request.data.get('kaioi_ewai_PlayerCommission')
kaioi_ewai = str(raw_kaioi).strip().lower() in ('1', 'true', 'yes', 'on')
ewai_fencheng = Decimal('0')
if kaioi_ewai:
raw_amt = request.data.get('ewai_dashou_fencheng')
if raw_amt is None or raw_amt == '':
raw_amt = request.data.get('ewai_PlayerCommission', 0)
try:
ewai_fencheng = Decimal(str(request.data.get('ewai_PlayerCommission', 0)))
ewai_fencheng = Decimal(str(raw_amt))
if ewai_fencheng < 0:
return Response({'code': 400, 'msg': '分成金额不能为负数'})
except:
except Exception:
return Response({'code': 400, 'msg': '分成金额格式错误'})
else:
# 默认分成:清空固定金额,下单按俱乐部利率算
ewai_fencheng = None
jieshao = request.data.get('jieshao', '').strip()
xiadan_xuzhi = request.data.get('xiadan_xuzhi', '').strip()
@@ -1146,6 +1202,52 @@ class UpdateProductView(APIView):
return Response({'code': 0, 'msg': '商品修改成功', 'data': {'id': product.id}})
class UpdateZhiKanShenheView(APIView):
"""
商品管理页:按俱乐部更新「只看审核商品」开关。
权限2200a
POST /houtai/htxgzhikan
"""
permission_classes = []
parser_classes = [JSONParser]
def post(self, request):
username = request.data.get('username', '').strip()
if not username:
return Response({'code': 401, 'msg': '缺少username'})
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return permissions
if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'})
val = request.data.get('zhi_kan_shenhe')
if val is None:
return Response({'code': 400, 'msg': '缺少参数 zhi_kan_shenhe'})
from jituan.constants import DATA_SCOPE_ALL
from jituan.services.club_context import resolve_club_scope
from jituan.services.club_write import resolve_club_id_for_write
from shop.services.shenhe_config import get_or_create_shenhe_config
if resolve_club_scope(request) == DATA_SCOPE_ALL:
return Response({'code': 400, 'msg': '请先切换到具体俱乐部再修改只看审核开关'})
try:
with transaction.atomic():
club_id = resolve_club_id_for_write(request)
config = get_or_create_shenhe_config(club_id)
config = DianpuShangpinShenheShezhi.objects.select_for_update().get(pk=config.pk)
config.zhi_kan_shenhe = bool(val)
config.save(update_fields=['zhi_kan_shenhe'])
return Response({
'code': 0,
'msg': '只看审核开关已更新',
'data': {'club_id': club_id, 'zhi_kan_shenhe': bool(val)},
})
except Exception:
logging.getLogger(__name__).exception('更新只看审核开关失败')
return Response({'code': 500, 'msg': '服务器错误'})

View File

@@ -223,9 +223,15 @@ class ModifyRolePermissionView(APIView):
if not role_code:
return Response({'code': 400, 'msg': '缺少角色编码'})
from jituan.services.admin_context import can_manage_admin_assignments, can_manage_roles_for_club
from jituan.services.club_rbac import resolve_role_by_name, resolve_role_club_id
explicit_club = (request.data.get('target_club_id') or request.data.get('club_id') or '').strip() or None
bind_club_id = resolve_role_club_id(request, explicit_club)
if bind_club_id:
if not can_manage_roles_for_club(request.user, permissions, bind_club_id):
return Response({'code': 403, 'msg': '您无权管理该俱乐部的角色'})
elif not can_manage_admin_assignments(request.user, permissions):
return Response({'code': 403, 'msg': '您无权管理全局角色'})
role = resolve_role_by_name(role_code, bind_club_id)
if not role:
return Response({'code': 404, 'msg': '角色不存在或与当前俱乐部不匹配'})
@@ -354,11 +360,17 @@ class AddRoleView(APIView):
if '000001' not in permissions:
return Response({'code': 403, 'msg': '您无权进行此操作'})
# 检查角色名称是否已存在(同俱乐部内唯一)
# 检查角色名称是否已存在(同俱乐部内唯一);单俱乐部视图强制写入当前顶栏俱乐部
from jituan.services.admin_context import can_manage_admin_assignments, can_manage_roles_for_club
from jituan.services.club_rbac import resolve_role_club_id, role_name_exists
target_club_id = resolve_role_club_id(
request, (request.data.get('target_club_id') or '').strip() or None,
)
if target_club_id:
if not can_manage_roles_for_club(request.user, permissions, target_club_id):
return Response({'code': 403, 'msg': '您无权在该俱乐部下创建角色'})
elif not can_manage_admin_assignments(request.user, permissions):
return Response({'code': 403, 'msg': '仅集团权限可创建全局角色'})
if role_name_exists(role_name, target_club_id):
return Response({'code': 400, 'msg': '该俱乐部下角色名称已存在'})
@@ -494,16 +506,45 @@ class GetAdminUserListView(APIView):
'is_primary': a.is_primary,
})
from jituan.constants import DATA_SCOPE_ALL
from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope
from jituan.services.club_rbac import filter_roles_for_request
scope = resolve_club_scope(request)
curr_club = (resolve_club_id_from_request(request) or '').strip()
# 单俱乐部视图:角色下拉/展示只允许「本俱乐部角色」;若本店无角色则仅全局
allowed_role_club_ids = None
if scope != DATA_SCOPE_ALL:
scoped_roles = list(filter_roles_for_request(request).values_list('ClubID', flat=True))
allowed_role_club_ids = {(c or '').strip() for c in scoped_roles}
def _roles_in_view(roles_data):
if scope == DATA_SCOPE_ALL or allowed_role_club_ids is None:
return roles_data
return [
r for r in roles_data
if (r.get('club_id') or '').strip() in allowed_role_club_ids
]
def _assignments_in_view(assignments):
if scope == DATA_SCOPE_ALL:
return assignments
# 本俱乐部任职 + 集团级club_id 空);绝不展示其他子公司任职
return [
a for a in assignments
if not a.get('club_id') or a.get('club_id') == curr_club
]
data_list = []
for user in _page_users:
roles_data = _roles_by_user.get(user.UserUUID, [])
roles_data = _roles_in_view(_roles_by_user.get(user.UserUUID, []))
data_list.append({
'phone': user.Phone or '',
'yonghuid': user.UserUID or '',
'nicheng': user.KefuProfile.nicheng,
'club_id': getattr(user, 'ClubID', '') or '',
'roles': roles_data,
'assignments': _assignments_by_user.get(user.UserUID, []),
'assignments': _assignments_in_view(_assignments_by_user.get(user.UserUID, [])),
'status': user.KefuProfile.zhuangtai,
'create_time': user.UserCreateTime.strftime('%Y-%m-%d %H:%M:%S') if getattr(user, 'UserCreateTime', None) else '',
'update_time': user.KefuProfile.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') if getattr(user.KefuProfile, 'UpdateTime', None) else '',
@@ -512,6 +553,15 @@ class GetAdminUserListView(APIView):
})
from jituan.services.admin_context import can_manage_admin_assignments, get_manageable_club_ids
manageable = sorted(get_manageable_club_ids(
request.user, permissions, current_club_id=curr_club,
))
if scope != DATA_SCOPE_ALL:
# 顶栏在具体俱乐部时,可管理集合收敛到当前店(含超级管理 000001
manageable = [c for c in manageable if c == curr_club]
# 兜底:本店超级管理一定能管本店用户(不依赖集团任职)
if not manageable and curr_club and permissions and '000001' in permissions:
manageable = [curr_club]
return Response({
'code': 0,
'data': {
@@ -520,7 +570,12 @@ class GetAdminUserListView(APIView):
'page': page,
'pageSize': page_size,
'can_manage_club_assignments': can_manage_admin_assignments(request.user, permissions),
'manageable_club_ids': sorted(get_manageable_club_ids(request.user, permissions)),
'can_manage_club_users': bool(manageable) or (
bool(permissions) and '000001' in permissions and scope != DATA_SCOPE_ALL
),
'manageable_club_ids': manageable,
'current_club_id': curr_club,
'scope': scope,
}
})
@@ -546,19 +601,23 @@ class GetAdminRolesView(APIView):
if not can_access_admin_user_management(request.user, permissions):
return Response({'code': 403, 'msg': '您无权访问此页面'})
from jituan.services.admin_context import can_manage_admin_assignments, get_manageable_club_ids
from jituan.constants import DATA_SCOPE_ALL
from jituan.services.admin_context import can_manage_admin_assignments
from jituan.services.club_context import resolve_club_scope
from jituan.services.club_rbac import filter_roles_for_request
scope = resolve_club_scope(request)
all_clubs = request.data.get('all_clubs') in (True, 1, '1', 'true', 'yes')
if all_clubs and can_manage_admin_assignments(request.user, permissions):
# 仅集团汇总视图 + 集团任职管理权 才可拉全量子公司角色;
# 顶栏在具体俱乐部时(含集团账号切到某店),绝不容许其他俱乐部角色泄露。
if (
scope == DATA_SCOPE_ALL
and all_clubs
and can_manage_admin_assignments(request.user, permissions)
):
roles = Role.objects.filter(RoleStatus=1).order_by('ClubID', 'RoleName').values('RoleName', 'ClubID')
else:
manageable = get_manageable_club_ids(request.user, permissions)
if manageable:
roles = Role.objects.filter(
RoleStatus=1, ClubID__in=manageable,
).order_by('ClubID', 'RoleName').values('RoleName', 'ClubID')
else:
roles = filter_roles_for_request(request).values('RoleName', 'ClubID')
roles = filter_roles_for_request(request).order_by('ClubID', 'RoleName').values('RoleName', 'ClubID')
return Response({
'code': 0,
'data': {
@@ -671,9 +730,11 @@ class ModifyAdminUserView(APIView):
club_assign_actions = {'add_club_assignment', 'remove_club_assignment'}
role_manage_actions = {'add_user_roles', 'remove_user_role'}
if action in club_assign_actions:
# 跨店任职仍需集团权限;本店超级管理只管功能角色与账号,不分配其他俱乐部任职
if not can_manage_admin_assignments(request.user, permissions):
return Response({'code': 403, 'msg': '无权分配俱乐部任职'})
return Response({'code': 403, 'msg': '无权分配俱乐部任职(需集团权限)'})
elif action in role_manage_actions:
# 本店超级管理 000001 可给本俱乐部用户增删功能角色
if not can_access_admin_user_management(request.user, permissions):
return Response({'code': 403, 'msg': '您无权管理角色分配'})
elif not can_manage_admin_assignments(request.user, permissions) and '000001' not in permissions:
@@ -721,12 +782,16 @@ class ModifyAdminUserView(APIView):
role_codes = request.data.get('role_codes', [])
if not role_codes:
return Response({'code': 400, 'msg': '请选择角色'})
from jituan.services.club_rbac import resolve_role_by_name
from jituan.services.club_context import resolve_club_id_from_request
bind_club_id = (request.data.get('target_club_id') or '').strip()
from jituan.services.club_rbac import resolve_role_by_name, resolve_role_club_id
bind_club_id = resolve_role_club_id(
request, (request.data.get('target_club_id') or '').strip() or None,
)
if not bind_club_id:
bind_club_id = resolve_club_id_from_request(request)
if not can_manage_roles_for_club(request.user, permissions, bind_club_id):
return Response({'code': 400, 'msg': '请先切换到具体俱乐部再分配角色'})
if not (
can_manage_roles_for_club(request.user, permissions, bind_club_id)
or ('000001' in permissions)
):
return Response({'code': 403, 'msg': '您无权管理该俱乐部的角色分配'})
added, missing = [], []
for rc in role_codes:
@@ -766,12 +831,16 @@ class ModifyAdminUserView(APIView):
role_code = request.data.get('role_code')
if not role_code:
return Response({'code': 400, 'msg': '缺少角色编码'})
from jituan.services.club_rbac import resolve_role_by_name
from jituan.services.club_context import resolve_club_id_from_request
bind_club_id = (request.data.get('target_club_id') or '').strip()
from jituan.services.club_rbac import resolve_role_by_name, resolve_role_club_id
bind_club_id = resolve_role_club_id(
request, (request.data.get('target_club_id') or '').strip() or None,
)
if not bind_club_id:
bind_club_id = resolve_club_id_from_request(request)
if not can_manage_roles_for_club(request.user, permissions, bind_club_id):
return Response({'code': 400, 'msg': '请先切换到具体俱乐部再操作角色'})
if not (
can_manage_roles_for_club(request.user, permissions, bind_club_id)
or ('000001' in permissions)
):
return Response({'code': 403, 'msg': '您无权管理该俱乐部的角色分配'})
role = resolve_role_by_name(role_code, bind_club_id)
if not role:
@@ -839,12 +908,17 @@ class AddAdminUserView(APIView):
can_manage_admin_assignments,
can_manage_roles_for_club,
)
from jituan.services.club_context import resolve_club_id_from_request
from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope
from jituan.constants import DATA_SCOPE_ALL
target_club_id = resolve_club_id_from_request(request)
if resolve_club_scope(request) == DATA_SCOPE_ALL or not target_club_id:
return Response({'code': 400, 'msg': '请先切换到具体俱乐部再添加用户'})
# 俱乐部超级管理 000001 即可添加本店后台用户,无需集团权限
if not (
can_manage_admin_assignments(request.user, permissions)
or can_manage_roles_for_club(request.user, permissions, target_club_id)
or ('000001' in permissions)
):
return Response({'code': 403, 'msg': '您无权在该俱乐部添加用户'})

View File

@@ -139,7 +139,7 @@ class HqbkxxView(APIView):
return Response({'code': 403, 'msg': '无板块管理权限'}, status=403)
scope_err = block_non_group_catalog_scope(request)
if scope_err:
return Response({'code': 400, 'msg': scope_err})
return Response({'code': 400, 'msg': scope_err}, status=400)
# 5. 查询所有板块
bankuai_list = []
@@ -241,7 +241,7 @@ class BkxgView(APIView):
return Response({'code': 403, 'msg': '无板块管理权限'}, status=403)
scope_err = block_non_group_catalog_scope(request)
if scope_err:
return Response({'code': 400, 'msg': scope_err})
return Response({'code': 400, 'msg': scope_err}, status=400)
# 5. 获取操作类型
action = request.data.get('action')
@@ -279,9 +279,13 @@ class BkxgView(APIView):
})
# ==================== 以下操作需要 bankuai_id ====================
bankuai_id = request.data.get('bankuai_id')
if not bankuai_id:
raw_bankuai_id = request.data.get('bankuai_id')
if raw_bankuai_id is None or str(raw_bankuai_id).strip() == '':
return Response({'code': 1, 'msg': '缺少 bankuai_id'}, status=400)
try:
bankuai_id = int(raw_bankuai_id)
except (TypeError, ValueError):
return Response({'code': 1, 'msg': '无效的 bankuai_id'}, status=400)
try:
bk = Bankuai.query.get(bankuai_id=bankuai_id)

View File

@@ -142,8 +142,10 @@ class ShopListView(APIView):
zhengqu_zonge_min = request.data.get('zhengqu_zonge_min')
zhengqu_zonge_max = request.data.get('zhengqu_zonge_max')
# 4. 构建查询集(先查店铺,预加载关联凭证)
# 4. 构建查询集(先查店铺,预加载关联凭证;按俱乐部隔离
from jituan.services.club_context import filter_club_char_field
queryset = Dianpu.query.select_related('pingzheng').all()
queryset = filter_club_char_field(queryset, request, field='club_id')
if dianpu_mingcheng:
queryset = queryset.filter(dianpu_mingcheng__icontains=dianpu_mingcheng)
@@ -189,6 +191,7 @@ class ShopListView(APIView):
'kaiqi_meiri_xiane': dianpu.kaiqi_meiri_xiane,
'pingzheng__yonghu': pingzheng.yonghu, # 用户ID
'pingzheng__zhanghao': pingzheng.zhanghao, # 登录账号
'club_id': getattr(dianpu, 'club_id', '') or '',
})
# 获取默认配置ID=1不存在则创建
@@ -327,6 +330,7 @@ class ShopModifyView(APIView):
mima=mima,
is_active=True
)
from jituan.services.club_write import resolve_club_id_for_write
dianpu = Dianpu.query.create(
pingzheng=pingzheng,
dianpu_mingcheng=dianpu_mingcheng,
@@ -336,6 +340,7 @@ class ShopModifyView(APIView):
shouyi_choucheng_feilv=request.data.get('shouyi_choucheng_feilv', 0),
kaiqi_meiri_xiane=request.data.get('kaiqi_meiri_xiane', False),
meiri_tixian_xiane=request.data.get('meiri_tixian_xiane', 0),
club_id=resolve_club_id_for_write(request),
)
return Response({'code': 0, 'msg': '店铺创建成功', 'dianpu_id': dianpu.id})
except IntegrityError as e:
@@ -416,15 +421,38 @@ class ShopPublicTypeAndAuditView(APIView):
logger.exception("查询公共商品类型失败")
return Response({'code': 500, 'msg': '服务器错误,请稍后重试'})
# ---------- 获取审核模式配置 ----------
audit_config = DianpuShangpinShenheShezhi.query.filter(id=1).first()
kaiqi_shenhe = audit_config.kaiqi_shenhe if audit_config else False
# ---------- 获取本俱乐部审核模式配置 ----------
from jituan.services.club_write import resolve_club_id_for_write
from shop.services.shenhe_config import get_or_create_shenhe_config
from jituan.constants import DATA_SCOPE_ALL
from jituan.services.club_context import resolve_club_scope
if resolve_club_scope(request) == DATA_SCOPE_ALL:
# 集团汇总:不自动创建,返回默认值提示前端切到具体俱乐部
return Response({
'code': 0,
'data': {
'public_types': type_list,
'kaiqi_shenhe': False,
'zhi_kan_shenhe': True,
'club_scoped': False,
'msg': '请切换到具体俱乐部查看/修改审核展示开关',
}
})
club_id = resolve_club_id_for_write(request)
audit_config = get_or_create_shenhe_config(club_id)
kaiqi_shenhe = audit_config.kaiqi_shenhe
zhi_kan_shenhe = bool(getattr(audit_config, 'zhi_kan_shenhe', True))
return Response({
'code': 0,
'data': {
'public_types': type_list,
'kaiqi_shenhe': kaiqi_shenhe
'kaiqi_shenhe': kaiqi_shenhe,
'zhi_kan_shenhe': zhi_kan_shenhe,
'club_id': club_id,
'club_scoped': True,
}
})
@@ -458,8 +486,10 @@ class ShopListForProductView(APIView):
# 店铺状态筛选1正常0封禁不传则全部
zhuangtai = request.data.get('zhuangtai')
# ---------- 构建查询集关联凭证表获取用户ID ----------
# ---------- 构建查询集关联凭证表获取用户ID;按俱乐部隔离 ----------
from jituan.services.club_context import filter_club_char_field
shops = Dianpu.query.select_related('pingzheng').all()
shops = filter_club_char_field(shops, request, field='club_id')
# 状态筛选:只接受 0 或 1
if zhuangtai is not None:
@@ -615,6 +645,8 @@ class ShopProductModifyView(APIView):
if action == 'update_audit_mode':
return self._update_audit_mode(request, user_perms)
elif action == 'update_zhi_kan_shenhe':
return self._update_zhi_kan_shenhe(request, user_perms)
elif action == 'update_mapping_status':
return self._update_mapping_status(request, user_perms)
else:
@@ -622,22 +654,61 @@ class ShopProductModifyView(APIView):
@transaction.atomic # ✅ 事务修复点
def _update_audit_mode(self, request, user_perms):
if not has_perm_code(permissions, '1199ab'):
if not has_perm_code(user_perms, '1199ab'):
return Response({'code': 403, 'msg': '无权限修改审核模式'})
kaiqi = request.data.get('kaiqi_shenhe')
if kaiqi is None:
return Response({'code': 400, 'msg': '缺少参数 kaiqi_shenhe'})
from jituan.constants import DATA_SCOPE_ALL
from jituan.services.club_context import resolve_club_scope
from jituan.services.club_write import resolve_club_id_for_write
from shop.services.shenhe_config import get_or_create_shenhe_config
if resolve_club_scope(request) == DATA_SCOPE_ALL:
return Response({'code': 400, 'msg': '请先切换到具体俱乐部再修改审核模式'})
try:
config, _ = DianpuShangpinShenheShezhi.objects.select_for_update().get_or_create(id=1)
club_id = resolve_club_id_for_write(request)
config = get_or_create_shenhe_config(club_id)
config = DianpuShangpinShenheShezhi.objects.select_for_update().get(pk=config.pk)
config.kaiqi_shenhe = bool(kaiqi)
config.save(update_fields=['kaiqi_shenhe'])
return Response({'code': 0, 'msg': '审核模式已更新'})
return Response({'code': 0, 'msg': '审核模式已更新', 'data': {'club_id': club_id}})
except Exception as e:
logger.exception("更新审核模式失败")
return Response({'code': 500, 'msg': '服务器错误'})
@transaction.atomic
def _update_zhi_kan_shenhe(self, request, user_perms):
"""点单端:未绑店用户是否按身份只看审核专用商品(按俱乐部)。"""
if not has_perm_code(user_perms, '1199ab'):
return Response({'code': 403, 'msg': '无权限修改只看审核开关'})
val = request.data.get('zhi_kan_shenhe')
if val is None:
return Response({'code': 400, 'msg': '缺少参数 zhi_kan_shenhe'})
from jituan.constants import DATA_SCOPE_ALL
from jituan.services.club_context import resolve_club_scope
from jituan.services.club_write import resolve_club_id_for_write
from shop.services.shenhe_config import get_or_create_shenhe_config
if resolve_club_scope(request) == DATA_SCOPE_ALL:
return Response({'code': 400, 'msg': '请先切换到具体俱乐部再修改只看审核开关'})
try:
club_id = resolve_club_id_for_write(request)
config = get_or_create_shenhe_config(club_id)
config = DianpuShangpinShenheShezhi.objects.select_for_update().get(pk=config.pk)
config.zhi_kan_shenhe = bool(val)
config.save(update_fields=['zhi_kan_shenhe'])
return Response({'code': 0, 'msg': '只看审核开关已更新', 'data': {'club_id': club_id}})
except Exception:
logger.exception("更新只看审核开关失败")
return Response({'code': 500, 'msg': '服务器错误'})
@transaction.atomic
def _update_mapping_status(self, request, user_perms):
mapping_id = request.data.get('mapping_id')

View File

@@ -236,21 +236,36 @@ class GetProductTypeZoneView(APIView):
return permissions
if '7007a' not in permissions:
return Response({'code': 403, 'msg': '您无权访问此页面'})
scope_err = block_non_group_catalog_scope(request)
if scope_err:
return Response({'code': 400, 'msg': scope_err})
# 查询所有商品类型(含专区数量)
# 一次性分组聚合所有类型的专区数量,避免循环内 N+1 count
# 注ShangpinZhuanqu.leixing_id 是 PositiveIntegerField非 FK无法用反向 annotate
zone_count_rows = ShangpinZhuanqu.query.values('leixing_id').annotate(cnt=Count('id'))
zone_count_map = {row['leixing_id']: row['cnt'] for row in zone_count_rows}
from jituan.constants import DATA_SCOPE_ALL
from jituan.services.club_context import resolve_club_scope, resolve_club_id_from_request
from jituan.services.club_shangpin_leixing import build_club_leixing_admin_list
types = ShangpinLeixing.query.all().order_by('-paixu') # paixu 越大越靠前
club_scoped = resolve_club_scope(request) != DATA_SCOPE_ALL
club_id = resolve_club_id_from_request(request)
try:
zone_qs_for_count = filter_club_char_field(
ShangpinZhuanqu.query.all(), request, field='club_id'
)
zone_count_map = {
row['leixing_id']: row['cnt']
for row in zone_qs_for_count.values('leixing_id').annotate(cnt=Count('id'))
}
if club_scoped:
# 具体俱乐部:仅本俱乐部已上架类型(与抢单端一致)
admin_payload = build_club_leixing_admin_list(request)
type_list = []
for t in types:
# 从预聚合的 map 中取专区数量
zone_count = zone_count_map.get(t.id, 0)
for item in (admin_payload.get('list') or []):
type_list.append({
**item,
'product_count': zone_count_map.get(item['id'], 0),
'club_enabled': True,
})
else:
type_list = []
for t in ShangpinLeixing.query.all().order_by('-paixu'):
type_list.append({
'id': t.id,
'jieshao': t.jieshao or '',
@@ -260,11 +275,12 @@ class GetProductTypeZoneView(APIView):
'yongjin': float(t.yongjin) if t.yongjin is not None else None,
'shenhezhuangtai': t.shenhezhuangtai,
'paixu': t.paixu,
'product_count': zone_count,
'product_count': zone_count_map.get(t.id, 0),
})
# 查询所有专区
zones = ShangpinZhuanqu.query.all().order_by('-paixu')
zones = filter_club_char_field(
ShangpinZhuanqu.query.all().order_by('-paixu'), request, field='club_id'
)
zone_list = [
{
'id': z.id,
@@ -272,19 +288,18 @@ class GetProductTypeZoneView(APIView):
'leixing_id': z.leixing_id,
'shenhezhuangtai': z.shenhezhuangtai,
'paixu': z.paixu,
'club_id': getattr(z, 'club_id', '') or '',
}
for z in zones
]
# 查询所有会员(只返回 id、介绍、价格
members = Huiyuan.query.all().values('huiyuan_id', 'jieshao', 'jiage')
member_list = [
{
'huiyuan_id': m['huiyuan_id'],
'jieshao': m['jieshao'],
'jiage': float(m['jiage']),
'jieshao': m['jieshao'] or '',
'jiage': float(m['jiage'] or 0),
}
for m in members
for m in Huiyuan.query.all().values('huiyuan_id', 'jieshao', 'jiage')
]
return Response({
@@ -293,8 +308,13 @@ class GetProductTypeZoneView(APIView):
'type_list': type_list,
'zone_list': zone_list,
'member_list': member_list,
'club_scoped': club_scoped,
'club_id': club_id if club_scoped else '',
}
})
except Exception:
logging.getLogger(__name__).exception('GetProductTypeZoneView 失败')
return Response({'code': 500, 'msg': '获取类型专区失败,请稍后重试'})
@@ -321,11 +341,12 @@ class ModifyProductTypeZoneView(APIView):
return permissions
if '7007a' not in permissions:
return Response({'code': 403, 'msg': '您无权进行此操作'})
# 类型仍须集团汇总;专区允许在具体俱乐部操作
if obj_type == 'leixing':
scope_err = block_non_group_catalog_scope(request)
if scope_err:
return Response({'code': 400, 'msg': scope_err})
if obj_type == 'leixing':
return self.handle_leixing(request, action)
elif obj_type == 'zhuanqu':
return self.handle_zhuanqu(request, action)
@@ -590,13 +611,17 @@ class ModifyProductTypeZoneView(APIView):
try:
with transaction.atomic():
from jituan.services.club_write import resolve_club_id_for_write
club_id = resolve_club_id_for_write(request)
zhuanqu = ShangpinZhuanqu.query.create(
mingzi=mingzi,
leixing_id=leixing_id,
shenhezhuangtai=shenhezhuangtai,
paixu=0
paixu=0,
club_id=club_id,
tupian_url=(request.data.get('tupian_url') or '').strip(),
)
return Response({'code': 0, 'msg': '添加成功', 'data': {'id': zhuanqu.id}})
return Response({'code': 0, 'msg': '添加成功', 'data': {'id': zhuanqu.id, 'club_id': club_id}})
except Exception as e:
logger.error(f"添加专区失败: {e}", exc_info=True)
return Response({'code': 500, 'msg': '数据库错误'})
@@ -610,6 +635,15 @@ class ModifyProductTypeZoneView(APIView):
except ShangpinZhuanqu.DoesNotExist:
return Response({'code': 404, 'msg': '专区不存在'})
from jituan.constants import DATA_SCOPE_ALL
from jituan.services.club_context import resolve_club_scope
from jituan.services.club_write import resolve_club_id_for_write
if resolve_club_scope(request) != DATA_SCOPE_ALL:
write_club = resolve_club_id_for_write(request)
zone_club = getattr(zhuanqu, 'club_id', None) or 'xq'
if zone_club != write_club:
return Response({'code': 403, 'msg': '专区不属于当前俱乐部'})
changes = {}
mingzi = request.data.get('mingzi')
if mingzi is not None and mingzi.strip() != zhuanqu.mingzi:
@@ -637,6 +671,12 @@ class ModifyProductTypeZoneView(APIView):
except (TypeError, ValueError):
return Response({'code': 400, 'msg': '审核状态无效必须为1或2'})
if 'tupian_url' in request.data:
new_url = (request.data.get('tupian_url') or '').strip()
old_url = getattr(zhuanqu, 'tupian_url', None) or ''
if new_url != old_url:
changes['tupian_url'] = new_url
if not changes:
return Response({'code': 400, 'msg': '未做任何修改'})
@@ -661,6 +701,15 @@ class ModifyProductTypeZoneView(APIView):
except ShangpinZhuanqu.DoesNotExist:
return Response({'code': 404, 'msg': '专区不存在'})
from jituan.constants import DATA_SCOPE_ALL
from jituan.services.club_context import resolve_club_scope
from jituan.services.club_write import resolve_club_id_for_write
if resolve_club_scope(request) != DATA_SCOPE_ALL:
write_club = resolve_club_id_for_write(request)
zone_club = getattr(zhuanqu, 'club_id', None) or 'xq'
if zone_club != write_club:
return Response({'code': 403, 'msg': '专区不属于当前俱乐部'})
with transaction.atomic():
if delete_products:
Shangpin.query.filter(zhuanqu_id=zone_id).delete()

View File

@@ -175,6 +175,58 @@ class KhpzhqView(APIView):
_CHENGHAO_LEIXING_OK = frozenset({'dashou', 'boss', 'shangjia', 'guanshi', 'zuzhang'})
_CHENGHAO_LEIXING_ALIAS = {
'打手': 'dashou',
'老板': 'boss',
'商家': 'shangjia',
'管事': 'guanshi',
'组长': 'zuzhang',
}
def _normalize_chenghao_leixing(raw, default='dashou'):
"""兼容 null / 中文显示名 / 首尾空格,统一为合法 leixing 代码。"""
if raw is None or raw == '':
return default
s = str(raw).strip()
if not s:
return default
if s in _CHENGHAO_LEIXING_OK:
return s
mapped = _CHENGHAO_LEIXING_ALIAS.get(s) or _CHENGHAO_LEIXING_ALIAS.get(s.lower())
if mapped:
return mapped
lower = s.lower()
if lower in _CHENGHAO_LEIXING_OK:
return lower
return None
def _parse_optional_bankuai_id(raw):
"""空值视为未绑定板块;非法/不存在返回 False。"""
if raw is None or raw == '':
return None
try:
bid = int(raw)
except (TypeError, ValueError):
return False
if bid <= 0:
return None
if not Bankuai.query.filter(bankuai_id=bid).exists():
return False
return bid
def _parse_chenghao_id(raw):
if raw is None or str(raw).strip() == '':
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
class ChzsgcView(APIView):
"""
称号(标签)增删改查统一接口
@@ -197,18 +249,21 @@ class ChzsgcView(APIView):
# ========== 创建称号 ==========
if action == 'create':
mingcheng = request.data.get('mingcheng', '').strip()
leixing = request.data.get('leixing', 'dashou')
bankuai_id = request.data.get('bankuai_id')
texiao_miaoshu = request.data.get('texiao_miaoshu', '')
kaohe_guize = request.data.get('kaohe_guize', '')
kaioi_jinpai = request.data.get('kaioi_jinpai', False)
mingcheng = (request.data.get('mingcheng') or '').strip()
leixing = _normalize_chenghao_leixing(request.data.get('leixing'), default='dashou')
bankuai_id = _parse_optional_bankuai_id(request.data.get('bankuai_id'))
texiao_miaoshu = request.data.get('texiao_miaoshu', '') or ''
kaohe_guize = request.data.get('kaohe_guize', '') or ''
kaioi_jinpai = bool(request.data.get('kaioi_jinpai', False))
feiyong_list = request.data.get('feiyong_list', [])
if not mingcheng:
return Response({'code': 1, 'msg': '称号名称不能为空'}, status=400)
if leixing not in ['dashou','boss','shangjia','guanshi','zuzhang']:
if leixing is None:
logger.warning('ChzsgcView create 无效 leixing=%r', request.data.get('leixing'))
return Response({'code': 1, 'msg': '无效的称号类型'}, status=400)
if bankuai_id is False:
return Response({'code': 1, 'msg': '板块不存在'}, status=400)
if Chenghao.query.filter(mingcheng=mingcheng).exists():
return Response({'code': 1, 'msg': '称号名称已存在'}, status=400)
@@ -238,7 +293,7 @@ class ChzsgcView(APIView):
# ========== 修改称号 ==========
elif action == 'update':
ch_id = request.data.get('chenghao_id')
ch_id = _parse_chenghao_id(request.data.get('chenghao_id'))
if not ch_id:
return Response({'code': 1, 'msg': '缺少称号ID'}, status=400)
try:
@@ -246,17 +301,25 @@ class ChzsgcView(APIView):
except Chenghao.DoesNotExist:
return Response({'code': 1, 'msg': '称号不存在'}, status=404)
mingcheng = request.data.get('mingcheng', ch.mingcheng).strip()
leixing = request.data.get('leixing', ch.leixing)
bankuai_id = request.data.get('bankuai_id', ch.bankuai_id)
mingcheng = (request.data.get('mingcheng') if request.data.get('mingcheng') is not None else ch.mingcheng) or ''
mingcheng = str(mingcheng).strip()
raw_leixing = request.data.get('leixing', ch.leixing)
leixing = _normalize_chenghao_leixing(raw_leixing, default=ch.leixing or 'dashou')
if 'bankuai_id' in request.data:
bankuai_id = _parse_optional_bankuai_id(request.data.get('bankuai_id'))
else:
bankuai_id = ch.bankuai_id
texiao_miaoshu = request.data.get('texiao_miaoshu', ch.texiao_miaoshu)
kaohe_guize = request.data.get('kaohe_guize', ch.kaohe_guize)
kaioi_jinpai = request.data.get('kaioi_jinpai', ch.kaioi_jinpai)
if not mingcheng:
return Response({'code': 1, 'msg': '称号名称不能为空'}, status=400)
if leixing not in ['dashou','boss','shangjia','guanshi','zuzhang']:
if leixing is None:
logger.warning('ChzsgcView update 无效 leixing=%r ch_id=%s', raw_leixing, ch_id)
return Response({'code': 1, 'msg': '无效的称号类型'}, status=400)
if bankuai_id is False:
return Response({'code': 1, 'msg': '板块不存在'}, status=400)
if Chenghao.query.filter(mingcheng=mingcheng).exclude(id=ch_id).exists():
return Response({'code': 1, 'msg': '称号名称已存在'}, status=400)
@@ -265,7 +328,7 @@ class ChzsgcView(APIView):
ch.bankuai_id = bankuai_id
ch.texiao_miaoshu = texiao_miaoshu
ch.kaohe_guize = kaohe_guize
ch.kaioi_jinpai = kaioi_jinpai
ch.kaioi_jinpai = bool(kaioi_jinpai)
ch.save()
# 如果前端传递了 feiyong_list则全量替换费用
@@ -284,7 +347,7 @@ class ChzsgcView(APIView):
# ========== 删除称号 ==========
elif action == 'delete':
ch_id = request.data.get('chenghao_id')
ch_id = _parse_chenghao_id(request.data.get('chenghao_id'))
if not ch_id:
return Response({'code': 1, 'msg': '缺少称号ID'}, status=400)
try:
@@ -295,11 +358,11 @@ class ChzsgcView(APIView):
return Response({'code': 0, 'msg': '删除成功'})
# ========== 添加/修改某次费用 ==========
elif action == 'add_feiyong':
ch_id = request.data.get('chenghao_id')
elif action in ('add_feiyong', 'update_feiyong'):
ch_id = _parse_chenghao_id(request.data.get('chenghao_id'))
cishu = request.data.get('cishu')
feiyong = request.data.get('feiyong', 0)
if not ch_id or not cishu:
if not ch_id or cishu is None or cishu == '':
return Response({'code': 1, 'msg': '缺少称号ID或次数'}, status=400)
try:
ch = Chenghao.query.get(id=ch_id)
@@ -308,16 +371,16 @@ class ChzsgcView(APIView):
if int(cishu) < 1:
return Response({'code': 1, 'msg': '次数必须>=1'}, status=400)
KaoheCishuFeiyong.query.update_or_create(
chenghao=ch, cishu=cishu,
chenghao=ch, cishu=int(cishu),
defaults={'feiyong': feiyong}
)
return Response({'code': 0, 'msg': '添加/修改费用成功'})
# ========== 删除某次费用 ==========
elif action == 'remove_feiyong':
ch_id = request.data.get('chenghao_id')
ch_id = _parse_chenghao_id(request.data.get('chenghao_id'))
cishu = request.data.get('cishu')
if not ch_id or not cishu:
if not ch_id or cishu is None or cishu == '':
return Response({'code': 1, 'msg': '缺少称号ID或次数'}, status=400)
if int(cishu) == 1:
return Response({'code': 1, 'msg': '不能删除第1次考核费用'}, status=400)
@@ -325,13 +388,9 @@ class ChzsgcView(APIView):
ch = Chenghao.query.get(id=ch_id)
except Chenghao.DoesNotExist:
return Response({'code': 1, 'msg': '称号不存在'}, status=404)
KaoheCishuFeiyong.query.filter(chenghao=ch, cishu=cishu).delete()
KaoheCishuFeiyong.query.filter(chenghao=ch, cishu=int(cishu)).delete()
return Response({'code': 0, 'msg': '删除成功'})
# ========== 修改某次费用等价于add ==========
elif action == 'update_feiyong':
return self.add_feiyong(request) # 复用逻辑
else:
return Response({'code': 1, 'msg': '未知操作'}, status=400)

View File

@@ -3,7 +3,7 @@ from django.urls import path
from .views import ShangpinGonggaoView, AdminUpdateConfigView, AdminUploadImageView, AdminConfigQueryView, \
DashouPaihangView,GetAdminZhanghaoView, GetXiugaiJiluView,ShangjiaTianjiaMobanView, ShangjiaMobanListView, \
ShangjiaGengxinMobanView, ShangjiaShanchuMobanView, ShangjiaGenerateLinkView,KehuGetDingdanLianjieView, \
KehuTianxieDingdanView, GuanZhuAListView, ZuzhangHaibaoView,GuanshiQRCodeView,\
KehuTianxieDingdanView, GuanZhuAListView, ZuzhangHaibaoView,GuanshiQRCodeView, CYaoqingQRCodeView,\
GetDynamicConfigView, HaibaoPeizhiView, PopupConfigView, GetWithdrawModeView,CheckPhoneAuthView,\
ShangjiaLianjieListView, HuashuHqView, HuashuMatchView
@@ -31,6 +31,7 @@ urlpatterns = [
path('gzal', GuanZhuAListView.as_view(), name='关注阿龙'),
path('guanshiewm', GuanshiQRCodeView.as_view(), name='管事生成二维码'),
path('zuzhanghb', ZuzhangHaibaoView.as_view(), name='组长二维码'),
path('cyaoqingewm', CYaoqingQRCodeView.as_view(), name='点单端邀请二维码'),
path('qdpzhq', GetDynamicConfigView.as_view(), name='小程序获取配置'),
path('haibaopz', HaibaoPeizhiView.as_view(), name='推广海报配置'),

View File

@@ -38,7 +38,7 @@ from .merchant_links import (
IPUtils, OrderLinkThrottle,
)
from .qrcode_poster import (
GuanZhuAListView, GuanshiQRCodeView, ZuzhangHaibaoView,
GuanZhuAListView, GuanshiQRCodeView, ZuzhangHaibaoView, CYaoqingQRCodeView,
)
from .scripts import (
HuashuHqView, HuashuMatchView,
@@ -65,7 +65,7 @@ __all__ = [
'KehuGetDingdanLianjieView', 'KehuTianxieDingdanView',
'IPUtils', 'OrderLinkThrottle',
# qrcode_poster
'GuanZhuAListView', 'GuanshiQRCodeView', 'ZuzhangHaibaoView',
'GuanZhuAListView', 'GuanshiQRCodeView', 'ZuzhangHaibaoView', 'CYaoqingQRCodeView',
# scripts
'HuashuHqView', 'HuashuMatchView',
'MiniappScriptHoutaiListView', 'MiniappScriptHoutaiModifyView',

View File

@@ -81,15 +81,23 @@ class ShangpinGonggaoView(APIView):
"""
try:
logger.info("收到商品公告和轮播图请求")
from jituan.services.display_config import get_gonggao_content, get_lunbo_urls, normalize_page_key
from jituan.services.display_config import (
get_gonggao_content,
get_lunbo_urls,
lunbo_image_type_for_page,
normalize_page_key,
)
page_key = normalize_page_key(request.data.get('page_key'), image_type=1)
image_type = lunbo_image_type_for_page(page_key)
gonggao_content = get_gonggao_content(request, notice_type=1, page_key=page_key)
lunbo_urls = get_lunbo_urls(request, page_key=page_key, image_type=1)
lunbo_urls = get_lunbo_urls(request, page_key=page_key, image_type=image_type)
from jituan.services.club_context import resolve_effective_club_id
response_data = {
"shangpingonggao": gonggao_content,
"shangpinlunbo": lunbo_urls,
"page_key": page_key,
"club_id": resolve_effective_club_id(request, None),
}
logger.info(f"返回数据:公告长度{len(gonggao_content)},轮播图数量{len(lunbo_urls)} page_key={page_key}")
# 4. 返回响应

View File

@@ -46,8 +46,10 @@ from users.models import (
)
from users.business_models import User
from orders.models import CommissionRate, Order, MerchantOrderExt
from orders.models import Order, MerchantOrderExt
from orders.notice_tasks import dingdan_guangbo
from orders.utils import calc_shangjia_order_fencheng
from jituan.services.club_context import resolve_club_id_from_request
from rank.models import Chenghao, DingdanBiaoqian
@@ -103,12 +105,9 @@ class ShangjiaGenerateLinkView(APIView):
if jiage > shangjia.yue:
return Response({'code': 400, 'msg': f'余额不足,当前余额{shangjia.yue}', 'data': {}}, status=400)
# 利率
try:
lilu = float(CommissionRate.query.get(Platform='3').Rate) or 1.0
except CommissionRate.DoesNotExist:
lilu = 1.0
dashou_fencheng = Decimal(str(round(float(jiage) * lilu, 2)))
# 打手+管事分成(与普通派单 sjpaifa 同一套算法,打手优先)
club_id = resolve_club_id_from_request(request)
dashou_fencheng, guanshi_fencheng = calc_shangjia_order_fencheng(jiage, club_id=club_id)
# 抢单要求:根据模板是否启用佣金
chenghao_id = moban.TitleID
@@ -154,13 +153,14 @@ class ShangjiaGenerateLinkView(APIView):
dingdan_id = generate_dingdan_id()
secure_token = generate_secure_token(dingdan_id, user.UserUID)
# 创建订单主表
# 创建订单主表(分红在生成链接时锁定,填表入池不再改)
dingdan = Order.query.create(
OrderID=dingdan_id,
Status=13,
Platform=2,
Amount=jiage,
PlayerCommission=dashou_fencheng,
ManagerCommission=guanshi_fencheng,
ProductTypeID=shangpin_type_id,
GrabRequirement=final_yaoqiuleixing,
MembershipID=final_huiyuan_id,
@@ -168,6 +168,7 @@ class ShangjiaGenerateLinkView(APIView):
Description=moban.TemplateDesc[:2000],
User1ID=f"Sj{user.UserUID}",
ImageURL=user.Avatar or '',
ClubID=club_id,
)
# 商家扩展表

View File

@@ -351,9 +351,137 @@ class GuanshiQRCodeView(APIView):
return None
class CYaoqingQRCodeView(APIView):
"""
点单端老板推荐官小程序码(落地点单首页,按 UserUID 绑定,无邀请码)。
POST /peizhi/cyaoqingewm
"""
permission_classes = [permissions.IsAuthenticated]
def post(self, request):
current_user = request.user
try:
from jituan.services.club_context import resolve_effective_club_id
from utils.weixin_token import get_club_mini_access_token, is_weixin_token_invalid
from users.services.c_invite import get_or_create_c_yaoqing
from users.models import YonghuCYaoqing
if not getattr(current_user, 'UserUID', None):
return Response(
{'code': 500, 'message': '用户身份异常', 'data': None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
club_id = resolve_effective_club_id(request, current_user)
profile = get_or_create_c_yaoqing(current_user)
inviter_uid = str(current_user.UserUID)
access_token = get_club_mini_access_token(club_id)
if not access_token:
return Response(
{'code': 500, 'message': '获取微信access_token失败', 'data': None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
# 必须进点单首页,禁止进打手/管事页
page_path = f'pages/index/index?yaoqingren={urllib.parse.quote(inviter_uid, safe="")}'
post_data = {
'path': page_path,
'width': 430,
'auto_color': False,
'line_color': {'r': 0, 'g': 0, 'b': 0},
}
def _request_qr(token):
try:
return requests.post(
f'https://api.weixin.qq.com/wxa/getwxacode?access_token={token}',
json=post_data,
timeout=10,
)
except Exception as e:
logger.error('请求微信接口异常: %s', e, exc_info=True)
return None
wx_resp = _request_qr(access_token)
if wx_resp is None:
return Response(
{'code': 500, 'message': '调用微信服务失败', 'data': None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
if wx_resp.headers.get('content-type', '').startswith('application/json'):
try:
err = wx_resp.json()
if is_weixin_token_invalid(err.get('errcode'), err.get('errmsg', '')):
access_token = get_club_mini_access_token(club_id, force_refresh=True)
if access_token:
wx_resp = _request_qr(access_token)
except Exception:
pass
if wx_resp.status_code != 200:
errmsg = '未知错误'
try:
errmsg = wx_resp.json().get('errmsg', errmsg)
except Exception:
pass
return Response(
{'code': 500, 'message': f'生成二维码失败: {errmsg}', 'data': None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
content_type = wx_resp.headers.get('content-type', '')
if 'image' not in content_type:
try:
errmsg = wx_resp.json().get('errmsg', '未知错误')
except Exception:
errmsg = '生成二维码失败'
return Response(
{'code': 500, 'message': f'生成二维码失败: {errmsg}', 'data': None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
timestamp = int(time.time())
filename = f'{current_user.UserUID}_{timestamp}.png'
oss_path = f'erweima/cuser/{club_id}/{filename}'
file_obj = io.BytesIO(wx_resp.content)
full_url = upload_to_oss(file_obj, oss_path)
if not full_url:
return Response(
{'code': 500, 'message': '图片上传失败', 'data': None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
cos_domain = getattr(settings, 'COS_DOMAIN', '')
if cos_domain and full_url.startswith(cos_domain):
relative_path = full_url[len(cos_domain):].lstrip('/')
else:
relative_path = oss_path
old_url = profile.invite_qrcode_url
if old_url:
try:
delete_from_oss(old_url)
except Exception as e:
logger.warning('删除旧点单端推荐二维码失败: %s', e)
with transaction.atomic():
locked = YonghuCYaoqing.objects.select_for_update().get(pk=profile.pk)
locked.invite_qrcode_url = relative_path
locked.save(update_fields=['invite_qrcode_url', 'UpdateTime'])
return Response({
'code': 0,
'message': 'success',
'data': {'url': relative_path, 'uid': inviter_uid},
})
except Exception as e:
logger.error('生成点单端推荐二维码失败: %s', e, exc_info=True)
return Response(
{'code': 500, 'message': '服务器内部错误', 'data': None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class ZuzhangHaibaoView(APIView):

0
gongdan/__init__.py Normal file
View File

8
gongdan/apps.py Normal file
View File

@@ -0,0 +1,8 @@
"""投诉工单应用。"""
from django.apps import AppConfig
class GongdanConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'gongdan'
verbose_name = '投诉工单'

42
gongdan/constants.py Normal file
View File

@@ -0,0 +1,42 @@
"""投诉工单常量。"""
# 投诉类型
TYPE_ORDER = 1 # 订单投诉(需订单 OrderID
TYPE_RECHARGE = 2 # 充值投诉(需 Czjilu.dingdan_id
TYPE_PENALTY = 3 # 罚款投诉(需罚单 Penalty.id
TYPE_GUANSHI = 4 # 管事投诉
TYPE_FRAUD = 5 # 被骗投诉
TYPE_CHOICES = (
(TYPE_ORDER, '订单投诉'),
(TYPE_RECHARGE, '充值投诉'),
(TYPE_PENALTY, '罚款投诉'),
(TYPE_GUANSHI, '管事投诉'),
(TYPE_FRAUD, '被骗投诉'),
)
TYPE_LABELS = dict(TYPE_CHOICES)
# 状态1处理中 2已处理 3不满意可升级4已关闭
STATUS_PROCESSING = 1
STATUS_DONE = 2
STATUS_DISSATISFIED = 3
STATUS_CLOSED = 4
STATUS_CHOICES = (
(STATUS_PROCESSING, '处理中'),
(STATUS_DONE, '已处理'),
(STATUS_DISSATISFIED, '不满意'),
(STATUS_CLOSED, '已关闭'),
)
STATUS_LABELS = dict(STATUS_CHOICES)
MAX_IMAGES = 9
MAX_SHUOMING_LEN = 500
MAX_SHENGJI_TIMES = 5
OSS_PREFIX = 'gongdan'
# 权限码
PERM_VIEW = 'gdck666' # 查看工单
PERM_HANDLE = 'gdcl666' # 处理工单

View File

@@ -0,0 +1,68 @@
# Generated manually for gongdan
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name='Gongdan',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('gongdan_id', models.CharField(db_index=True, max_length=32, unique=True, verbose_name='工单号')),
('leixing', models.IntegerField(choices=[(1, '订单投诉'), (2, '充值投诉'), (3, '罚款投诉'), (4, '管事投诉'), (5, '被骗投诉')], db_index=True, verbose_name='投诉类型')),
('order_id', models.CharField(blank=True, db_index=True, max_length=64, null=True, verbose_name='订单ID')),
('chongzhi_id', models.CharField(blank=True, db_index=True, max_length=64, null=True, verbose_name='充值单ID')),
('fadan_id', models.CharField(blank=True, db_index=True, max_length=32, null=True, verbose_name='罚单ID')),
('guanshi_id', models.CharField(blank=True, db_index=True, max_length=32, null=True, verbose_name='被投诉管事UID')),
('shuoming', models.CharField(max_length=500, verbose_name='投诉说明')),
('shenqingren_id', models.CharField(db_index=True, max_length=32, verbose_name='申请人UserUID')),
('club_id', models.CharField(blank=True, db_index=True, max_length=16, null=True, verbose_name='俱乐部')),
('zhuangtai', models.IntegerField(choices=[(1, '处理中'), (2, '已处理'), (3, '不满意'), (4, '已关闭')], db_index=True, default=1, verbose_name='状态')),
('chuli_jieguo', models.CharField(blank=True, max_length=500, null=True, verbose_name='处理结果说明')),
('chuliren_id', models.CharField(blank=True, db_index=True, max_length=32, null=True, verbose_name='处理人UID')),
('chuliren_name', models.CharField(blank=True, max_length=64, null=True, verbose_name='处理人展示名')),
('chuli_time', models.DateTimeField(blank=True, null=True, verbose_name='处理时间')),
('shengji_cishu', models.IntegerField(default=0, verbose_name='升级次数')),
('CreateTime', models.DateTimeField(auto_now_add=True, db_index=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={'db_table': 'gongdan', 'verbose_name': '投诉工单', 'ordering': ['-CreateTime']},
),
migrations.CreateModel(
name='GongdanImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('gongdan_hao', models.CharField(db_index=True, max_length=32, verbose_name='工单号冗余')),
('url', models.CharField(max_length=512, verbose_name='图片URL')),
('paixu', models.IntegerField(default=0, verbose_name='排序')),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('gongdan', models.ForeignKey(db_column='gongdan_pk', on_delete=django.db.models.deletion.CASCADE, related_name='images', to='gongdan.gongdan')),
],
options={'db_table': 'gongdan_image', 'verbose_name': '工单证据图', 'ordering': ['paixu', 'id']},
),
migrations.CreateModel(
name='GongdanShengji',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('gongdan_hao', models.CharField(db_index=True, max_length=32, verbose_name='工单号冗余')),
('cishu', models.IntegerField(verbose_name='第几次升级')),
('shuoming', models.CharField(max_length=500, verbose_name='补充说明')),
('shenqingren_id', models.CharField(db_index=True, max_length=32, verbose_name='申请人')),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('gongdan', models.ForeignKey(db_column='gongdan_pk', on_delete=django.db.models.deletion.CASCADE, related_name='shengji_list', to='gongdan.gongdan')),
],
options={
'db_table': 'gongdan_shengji',
'verbose_name': '工单升级记录',
'ordering': ['cishu', 'id'],
'unique_together': {('gongdan', 'cishu')},
},
),
]

View File

72
gongdan/models.py Normal file
View File

@@ -0,0 +1,72 @@
"""投诉工单模型:主单 + 证据图 + 升级补充说明。"""
from django.db import models
from gvsdsdk.model_base import QModel
from gongdan.constants import STATUS_PROCESSING, TYPE_CHOICES, STATUS_CHOICES
class Gongdan(QModel):
"""投诉工单主表。"""
gongdan_id = models.CharField(max_length=32, unique=True, db_index=True, verbose_name='工单号')
leixing = models.IntegerField(db_index=True, choices=TYPE_CHOICES, verbose_name='投诉类型')
# 按类型选填关联业务 ID字符串统一存便于展示与复制
order_id = models.CharField(max_length=64, null=True, blank=True, db_index=True, verbose_name='订单ID')
chongzhi_id = models.CharField(max_length=64, null=True, blank=True, db_index=True, verbose_name='充值单ID')
fadan_id = models.CharField(max_length=32, null=True, blank=True, db_index=True, verbose_name='罚单ID')
guanshi_id = models.CharField(max_length=32, null=True, blank=True, db_index=True, verbose_name='被投诉管事UID')
shuoming = models.CharField(max_length=500, verbose_name='投诉说明')
shenqingren_id = models.CharField(max_length=32, db_index=True, verbose_name='申请人UserUID')
club_id = models.CharField(max_length=16, null=True, blank=True, db_index=True, verbose_name='俱乐部')
zhuangtai = models.IntegerField(
default=STATUS_PROCESSING, db_index=True, choices=STATUS_CHOICES, verbose_name='状态',
)
chuli_jieguo = models.CharField(max_length=500, null=True, blank=True, verbose_name='处理结果说明')
chuliren_id = models.CharField(max_length=32, null=True, blank=True, db_index=True, verbose_name='处理人UID')
chuliren_name = models.CharField(max_length=64, null=True, blank=True, verbose_name='处理人展示名')
chuli_time = models.DateTimeField(null=True, blank=True, verbose_name='处理时间')
shengji_cishu = models.IntegerField(default=0, verbose_name='升级次数')
CreateTime = models.DateTimeField(auto_now_add=True, db_index=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'gongdan'
verbose_name = '投诉工单'
ordering = ['-CreateTime']
class GongdanImage(QModel):
"""工单证据图最多9张以主单当前版本为准升级时可整体替换"""
gongdan = models.ForeignKey(
Gongdan, on_delete=models.CASCADE, related_name='images', db_column='gongdan_pk',
)
# 冗余业务工单号(勿命名 gongdan_id会与 FK 访问器冲突)
gongdan_hao = models.CharField(max_length=32, db_index=True, verbose_name='工单号冗余')
url = models.CharField(max_length=512, verbose_name='图片URL')
paixu = models.IntegerField(default=0, verbose_name='排序')
CreateTime = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'gongdan_image'
verbose_name = '工单证据图'
ordering = ['paixu', 'id']
class GongdanShengji(QModel):
"""升级/继续投诉补充说明(只记文字与次数;图片改主表 images"""
gongdan = models.ForeignKey(
Gongdan, on_delete=models.CASCADE, related_name='shengji_list', db_column='gongdan_pk',
)
gongdan_hao = models.CharField(max_length=32, db_index=True, verbose_name='工单号冗余')
cishu = models.IntegerField(verbose_name='第几次升级')
shuoming = models.CharField(max_length=500, verbose_name='补充说明')
shenqingren_id = models.CharField(max_length=32, db_index=True, verbose_name='申请人')
CreateTime = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'gongdan_shengji'
verbose_name = '工单升级记录'
ordering = ['cishu', 'id']
unique_together = [['gongdan', 'cishu']]

292
gongdan/services.py Normal file
View File

@@ -0,0 +1,292 @@
"""工单业务:创建 / 升级 / 客服处理(幂等与校验)。"""
from __future__ import annotations
import logging
import random
import time
from decimal import Decimal
from django.db import transaction
from django.utils import timezone
from gongdan.constants import (
TYPE_ORDER, TYPE_RECHARGE, TYPE_PENALTY, TYPE_GUANSHI, TYPE_FRAUD,
TYPE_LABELS, STATUS_PROCESSING, STATUS_DONE, STATUS_DISSATISFIED, STATUS_CLOSED,
STATUS_LABELS, MAX_IMAGES, MAX_SHUOMING_LEN, MAX_SHENGJI_TIMES, OSS_PREFIX,
)
from gongdan.models import Gongdan, GongdanImage, GongdanShengji
logger = logging.getLogger(__name__)
class GongdanError(Exception):
def __init__(self, message, code='error'):
super().__init__(message)
self.code = code
def _gen_gongdan_id():
ts = int(time.time() * 1000)
ns = int(time.time_ns() // 1000) % 1000000
rnd = random.randint(0, 99)
return f'GD{ts:011d}{ns:06d}{rnd:02d}'
def _trim(text, max_len=MAX_SHUOMING_LEN):
return (text or '').strip()[:max_len]
def validate_type_refs(leixing, order_id=None, chongzhi_id=None, fadan_id=None, guanshi_id=None):
"""按类型校验必填关联 ID并尽量核对库内是否存在。"""
order_id = (order_id or '').strip() or None
chongzhi_id = (chongzhi_id or '').strip() or None
fadan_id = (fadan_id or '').strip() or None
guanshi_id = (guanshi_id or '').strip() or None
if leixing == TYPE_ORDER:
if not order_id:
raise GongdanError('订单投诉请填写订单ID', 'need_order_id')
from orders.models import Order
if not Order.query.filter(OrderID=order_id).exists():
raise GongdanError('订单不存在请核对订单ID', 'order_not_found')
return order_id, None, None, None
if leixing == TYPE_RECHARGE:
if not chongzhi_id:
raise GongdanError('充值投诉请填写充值记录ID', 'need_chongzhi_id')
from products.models import Czjilu
if not Czjilu.query.filter(dingdan_id=chongzhi_id).exists():
raise GongdanError('充值记录不存在请核对充值ID', 'chongzhi_not_found')
return None, chongzhi_id, None, None
if leixing == TYPE_PENALTY:
if not fadan_id:
raise GongdanError('罚款投诉请填写罚单ID', 'need_fadan_id')
from orders.models import Penalty
try:
fid = int(fadan_id)
except (TypeError, ValueError):
raise GongdanError('罚单ID格式不正确', 'bad_fadan_id')
if not Penalty.query.filter(id=fid).exists():
raise GongdanError('罚单不存在请核对罚单ID', 'fadan_not_found')
return None, None, str(fid), None
if leixing == TYPE_GUANSHI:
# 管事UID 选填,有则校验存在
if guanshi_id:
from users.models import UserGuanshi
if not UserGuanshi.objects.filter(user__UserUID=guanshi_id).exists():
raise GongdanError('管事不存在请核对管事ID', 'guanshi_not_found')
return None, None, None, guanshi_id
if leixing == TYPE_FRAUD:
return None, None, None, None
raise GongdanError('不支持的投诉类型', 'bad_type')
def serialize_gongdan(gd, include_images=True, include_shengji=False):
data = {
'gongdan_id': gd.gongdan_id,
'leixing': gd.leixing,
'leixing_label': TYPE_LABELS.get(gd.leixing, str(gd.leixing)),
'order_id': gd.order_id or '',
'chongzhi_id': gd.chongzhi_id or '',
'fadan_id': gd.fadan_id or '',
'guanshi_id': gd.guanshi_id or '',
'shuoming': gd.shuoming or '',
'shenqingren_id': gd.shenqingren_id,
'club_id': gd.club_id or '',
'zhuangtai': gd.zhuangtai,
'zhuangtai_label': STATUS_LABELS.get(gd.zhuangtai, str(gd.zhuangtai)),
'chuli_jieguo': gd.chuli_jieguo or '',
'chuliren_id': gd.chuliren_id or '',
'chuliren_name': gd.chuliren_name or '',
'chuli_time': gd.chuli_time.isoformat() if gd.chuli_time else '',
'shengji_cishu': gd.shengji_cishu or 0,
'CreateTime': gd.CreateTime.isoformat() if gd.CreateTime else '',
'UpdateTime': gd.UpdateTime.isoformat() if gd.UpdateTime else '',
}
if include_images:
data['images'] = [
{'url': img.url, 'paixu': img.paixu}
for img in gd.images.all().order_by('paixu', 'id')
]
if include_shengji:
data['shengji_list'] = [
{
'cishu': s.cishu,
'shuoming': s.shuoming,
'CreateTime': s.CreateTime.isoformat() if s.CreateTime else '',
}
for s in gd.shengji_list.all().order_by('cishu', 'id')
]
return data
def _replace_images(gd, image_urls):
urls = [u.strip() for u in (image_urls or []) if u and str(u).strip()]
if len(urls) > MAX_IMAGES:
raise GongdanError(f'最多上传{MAX_IMAGES}张图片', 'too_many_images')
GongdanImage.objects.filter(gongdan=gd).delete()
for i, url in enumerate(urls):
GongdanImage.objects.create(
gongdan=gd,
gongdan_hao=gd.gongdan_id,
url=url[:512],
paixu=i,
)
@transaction.atomic
def create_gongdan(
*,
yonghuid,
leixing,
shuoming,
club_id=None,
order_id=None,
chongzhi_id=None,
fadan_id=None,
guanshi_id=None,
image_urls=None,
):
try:
leixing = int(leixing)
except (TypeError, ValueError):
raise GongdanError('投诉类型无效', 'bad_type')
if leixing not in TYPE_LABELS:
raise GongdanError('投诉类型无效', 'bad_type')
text = _trim(shuoming)
if not text:
raise GongdanError('请填写投诉说明', 'need_shuoming')
order_id, chongzhi_id, fadan_id, guanshi_id = validate_type_refs(
leixing, order_id, chongzhi_id, fadan_id, guanshi_id,
)
gid = _gen_gongdan_id()
for _ in range(5):
if not Gongdan.query.filter(gongdan_id=gid).exists():
break
gid = _gen_gongdan_id()
gd = Gongdan.objects.create(
gongdan_id=gid,
leixing=leixing,
order_id=order_id,
chongzhi_id=chongzhi_id,
fadan_id=fadan_id,
guanshi_id=guanshi_id,
shuoming=text,
shenqingren_id=yonghuid,
club_id=club_id or None,
zhuangtai=STATUS_PROCESSING,
shengji_cishu=0,
)
_replace_images(gd, image_urls)
logger.info('创建工单 %s type=%s user=%s', gid, leixing, yonghuid)
return gd
@transaction.atomic
def escalate_gongdan(*, gongdan_id, yonghuid, shuoming, image_urls=None):
"""不满意后继续投诉:写升级记录;可选整体替换证据图。"""
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
if not gd:
raise GongdanError('工单不存在', 'not_found')
if gd.shenqingren_id != yonghuid:
raise GongdanError('无权操作此工单', 'forbidden')
if gd.zhuangtai not in (STATUS_DONE, STATUS_DISSATISFIED):
raise GongdanError('当前状态不可继续投诉,请等待客服处理', 'bad_status')
if (gd.shengji_cishu or 0) >= MAX_SHENGJI_TIMES:
raise GongdanError(f'最多升级{MAX_SHENGJI_TIMES}次,请联系客服', 'too_many_shengji')
text = _trim(shuoming)
if not text:
raise GongdanError('请填写补充说明', 'need_shuoming')
next_cishu = (gd.shengji_cishu or 0) + 1
GongdanShengji.objects.create(
gongdan=gd,
gongdan_hao=gd.gongdan_id,
cishu=next_cishu,
shuoming=text,
shenqingren_id=yonghuid,
)
gd.shengji_cishu = next_cishu
gd.zhuangtai = STATUS_PROCESSING
gd.chuli_jieguo = ''
gd.chuli_time = None
# 保留原处理人记录便于审计,但状态回到处理中
gd.save(update_fields=[
'shengji_cishu', 'zhuangtai', 'chuli_jieguo', 'chuli_time', 'UpdateTime',
])
if image_urls is not None:
_replace_images(gd, image_urls)
logger.info('工单升级 %s cishu=%s', gongdan_id, next_cishu)
return gd
@transaction.atomic
def handle_gongdan(
*,
gongdan_id,
handler_uid,
handler_name,
action,
chuli_jieguo='',
):
"""
客服处理:
action=done → 已处理
action=dissatisfied → 标记不满意(一般由用户侧触发,客服 sparingly
action=close → 关闭
action=processing → 重新标处理中
"""
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
if not gd:
raise GongdanError('工单不存在', 'not_found')
action = (action or '').strip().lower()
jieguo = _trim(chuli_jieguo)
if action in ('done', 'finish', 'resolve'):
if not jieguo:
raise GongdanError('请填写处理结果', 'need_jieguo')
gd.zhuangtai = STATUS_DONE
gd.chuli_jieguo = jieguo
gd.chuliren_id = handler_uid
gd.chuliren_name = (handler_name or '')[:64]
gd.chuli_time = timezone.now()
elif action in ('close',):
gd.zhuangtai = STATUS_CLOSED
gd.chuli_jieguo = jieguo or gd.chuli_jieguo or '已关闭'
gd.chuliren_id = handler_uid
gd.chuliren_name = (handler_name or '')[:64]
gd.chuli_time = timezone.now()
elif action in ('processing', 'reopen'):
gd.zhuangtai = STATUS_PROCESSING
elif action in ('dissatisfied',):
gd.zhuangtai = STATUS_DISSATISFIED
else:
raise GongdanError('未知处理动作', 'bad_action')
gd.save()
logger.info('工单处理 %s action=%s by=%s', gongdan_id, action, handler_uid)
return gd
@transaction.atomic
def user_mark_dissatisfied(*, gongdan_id, yonghuid):
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
if not gd:
raise GongdanError('工单不存在', 'not_found')
if gd.shenqingren_id != yonghuid:
raise GongdanError('无权操作此工单', 'forbidden')
if gd.zhuangtai != STATUS_DONE:
raise GongdanError('仅已处理的工单可标记不满意', 'bad_status')
gd.zhuangtai = STATUS_DISSATISFIED
gd.save(update_fields=['zhuangtai', 'UpdateTime'])
return gd

27
gongdan/urls.py Normal file
View File

@@ -0,0 +1,27 @@
from django.urls import path
from gongdan.views import (
GongdanUploadImageView,
GongdanCreateView,
GongdanMyListView,
GongdanDetailView,
GongdanEscalateView,
GongdanDissatisfiedView,
GongdanMetaView,
ChongzhiRecordListView,
GongdanAdminListView,
GongdanAdminHandleView,
)
urlpatterns = [
path('upload-image/', GongdanUploadImageView.as_view(), name='工单图片上传'),
path('create/', GongdanCreateView.as_view(), name='创建投诉工单'),
path('my-list/', GongdanMyListView.as_view(), name='我的投诉列表'),
path('detail/', GongdanDetailView.as_view(), name='工单详情'),
path('escalate/', GongdanEscalateView.as_view(), name='继续投诉升级'),
path('dissatisfied/', GongdanDissatisfiedView.as_view(), name='标记不满意'),
path('meta/', GongdanMetaView.as_view(), name='工单枚举'),
path('chongzhi-list/', ChongzhiRecordListView.as_view(), name='我的充值记录'),
path('admin/list/', GongdanAdminListView.as_view(), name='客服工单列表'),
path('admin/handle/', GongdanAdminHandleView.as_view(), name='客服处理工单'),
]

367
gongdan/views.py Normal file
View File

@@ -0,0 +1,367 @@
"""投诉工单 API用户提交/列表/升级 + 图片上传 + 客服处理。"""
import logging
import uuid
from django.utils import timezone
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework import status
from gongdan.constants import (
MAX_IMAGES, OSS_PREFIX, PERM_VIEW, PERM_HANDLE, TYPE_LABELS, STATUS_LABELS,
)
from gongdan.models import Gongdan
from gongdan.services import (
GongdanError,
create_gongdan,
escalate_gongdan,
handle_gongdan,
serialize_gongdan,
user_mark_dissatisfied,
)
from utils.oss_utils import upload_to_oss, validate_image
logger = logging.getLogger(__name__)
def _ok(data=None, message='ok', code=200):
body = {'code': code, 'message': message, 'msg': message}
if data is not None:
body['data'] = data
return Response(body, status=status.HTTP_200_OK)
def _err(message, http=400, code=None):
c = code if code is not None else http
return Response(
{'code': c, 'message': message, 'msg': message},
status=http,
)
def _resolve_club(request):
try:
from jituan.services.club_write import resolve_club_id_for_write
return resolve_club_id_for_write(request, request.user)
except Exception:
return getattr(request.user, 'club_id', None) or None
class GongdanUploadImageView(APIView):
"""上传证据图,返回 URL。路径POST /gongdan/upload-image/"""
permission_classes = [IsAuthenticated]
parser_classes = (MultiPartParser, FormParser)
def post(self, request):
f = request.FILES.get('file') or request.FILES.get('image') or request.FILES.get('tupian')
if not f:
return _err('请选择图片')
ok, msg = validate_image(f)
if not ok:
return _err(msg)
ext = (f.name.rsplit('.', 1)[-1] if '.' in f.name else 'jpg').lower()
if ext not in ('jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'):
ext = 'jpg'
uid = getattr(request.user, 'UserUID', 'anon')
day = timezone.now().strftime('%Y%m%d')
path = f'{OSS_PREFIX}/{day}/{uid}/{uuid.uuid4().hex}.{ext}'
url = upload_to_oss(f, path)
if not url:
return _err('上传失败,请稍后重试', http=500)
return _ok({'url': url}, '上传成功')
class GongdanCreateView(APIView):
"""创建投诉工单。POST /gongdan/create/"""
permission_classes = [IsAuthenticated]
parser_classes = (JSONParser, FormParser, MultiPartParser)
def post(self, request):
d = request.data
images = d.get('images') or d.get('image_urls') or []
if isinstance(images, str):
images = [x.strip() for x in images.split(',') if x.strip()]
try:
gd = create_gongdan(
yonghuid=request.user.UserUID,
leixing=d.get('leixing'),
shuoming=d.get('shuoming') or d.get('content') or '',
club_id=_resolve_club(request),
order_id=d.get('order_id') or d.get('OrderID'),
chongzhi_id=d.get('chongzhi_id') or d.get('dingdanid') or d.get('dingdan_id'),
fadan_id=d.get('fadan_id') or d.get('penalty_id'),
guanshi_id=d.get('guanshi_id'),
image_urls=images,
)
return _ok(serialize_gongdan(gd, include_images=True, include_shengji=True), '提交成功')
except GongdanError as e:
return _err(str(e))
except Exception as e:
logger.error('创建工单失败: %s', e, exc_info=True)
return _err('提交失败,请稍后重试', http=500)
class GongdanMyListView(APIView):
"""我的投诉列表。POST/GET /gongdan/my-list/"""
permission_classes = [IsAuthenticated]
def get(self, request):
return self._list(request)
def post(self, request):
return self._list(request)
def _list(self, request):
page = int(request.data.get('page') or request.query_params.get('page') or 1)
page_size = min(int(request.data.get('page_size') or request.query_params.get('page_size') or 20), 50)
qs = Gongdan.query.filter(shenqingren_id=request.user.UserUID).order_by('-CreateTime')
total = qs.count()
start = max(page - 1, 0) * page_size
rows = qs[start:start + page_size]
return _ok({
'list': [serialize_gongdan(g, include_images=False) for g in rows],
'total': total,
'page': page,
'page_size': page_size,
})
class GongdanDetailView(APIView):
"""工单详情。POST /gongdan/detail/ {gongdan_id}"""
permission_classes = [IsAuthenticated]
def post(self, request):
gid = (request.data.get('gongdan_id') or '').strip()
if not gid:
return _err('工单号不能为空')
gd = Gongdan.query.filter(gongdan_id=gid).first()
if not gd:
return _err('工单不存在', http=404)
# 申请人或有客服查看权限
if gd.shenqingren_id != request.user.UserUID:
from backend.utils import verify_kefu_permission
from gongdan.constants import PERM_VIEW, PERM_HANDLE
kefu, perms_or_resp = verify_kefu_permission(
request, request.data.get('username') or getattr(request.user, 'Phone', None),
)
if not kefu:
return perms_or_resp
perms = perms_or_resp if isinstance(perms_or_resp, list) else []
if PERM_VIEW not in perms and PERM_HANDLE not in perms and '000001' not in perms:
# 超管占位is admin via kefu object
is_admin = getattr(request.user, 'UserType', '') == 'admin' or getattr(request.user, 'IsSuperuser', False)
if not is_admin:
return _err('无权查看', http=403)
return _ok(serialize_gongdan(gd, include_images=True, include_shengji=True))
class GongdanEscalateView(APIView):
"""继续投诉/升级。POST /gongdan/escalate/"""
permission_classes = [IsAuthenticated]
def post(self, request):
d = request.data
images = d.get('images')
if images is not None and isinstance(images, str):
images = [x.strip() for x in images.split(',') if x.strip()]
try:
gd = escalate_gongdan(
gongdan_id=(d.get('gongdan_id') or '').strip(),
yonghuid=request.user.UserUID,
shuoming=d.get('shuoming') or '',
image_urls=images,
)
return _ok(serialize_gongdan(gd, include_images=True, include_shengji=True), '已提交补充投诉')
except GongdanError as e:
return _err(str(e))
except Exception as e:
logger.error('升级工单失败: %s', e, exc_info=True)
return _err('提交失败', http=500)
class GongdanDissatisfiedView(APIView):
"""标记不满意。POST /gongdan/dissatisfied/"""
permission_classes = [IsAuthenticated]
def post(self, request):
try:
gd = user_mark_dissatisfied(
gongdan_id=(request.data.get('gongdan_id') or '').strip(),
yonghuid=request.user.UserUID,
)
return _ok(serialize_gongdan(gd), '已标记不满意,可继续投诉')
except GongdanError as e:
return _err(str(e))
class GongdanMetaView(APIView):
"""类型/状态枚举供前端展示。GET /gongdan/meta/"""
permission_classes = [IsAuthenticated]
def get(self, request):
return _ok({
'types': [{'value': k, 'label': v} for k, v in TYPE_LABELS.items()],
'statuses': [{'value': k, 'label': v} for k, v in STATUS_LABELS.items()],
'max_images': MAX_IMAGES,
})
class ChongzhiRecordListView(APIView):
"""当前用户充值记录Czjilu供投诉选 ID / 充值记录页。POST /gongdan/chongzhi-list/"""
permission_classes = [IsAuthenticated]
def post(self, request):
from products.models import Czjilu
from products.czjilu_types import (
CZJILU_LEIXING_HUIYUAN, CZJILU_LEIXING_YAJIN, CZJILU_LEIXING_JIFEN,
CZJILU_LEIXING_SHANGJIA,
)
page = int(request.data.get('page') or 1)
page_size = min(int(request.data.get('page_size') or 20), 50)
qs = Czjilu.query.filter(yonghuid=request.user.UserUID).order_by('-CreateTime')
leixing = request.data.get('leixing')
if leixing not in (None, ''):
qs = qs.filter(leixing=int(leixing))
total = qs.count()
start = max(page - 1, 0) * page_size
type_map = {
CZJILU_LEIXING_HUIYUAN: '会员',
CZJILU_LEIXING_YAJIN: '押金',
CZJILU_LEIXING_JIFEN: '积分',
CZJILU_LEIXING_SHANGJIA: '商家充值',
5: '考核',
6: '罚款',
}
status_map = {9: '待支付', 3: '已支付', 2: '已取消', 8: '履约失败', 10: '业务失败'}
rows = []
for o in qs[start:start + page_size]:
rows.append({
'dingdan_id': o.dingdan_id,
'leixing': o.leixing,
'leixing_label': type_map.get(o.leixing, str(o.leixing)),
'jine': str(o.jine),
'zhuangtai': o.zhuangtai,
'zhuangtai_label': status_map.get(o.zhuangtai, str(o.zhuangtai)),
'shuoming': o.shuoming or '',
'CreateTime': o.CreateTime.isoformat() if getattr(o, 'CreateTime', None) else '',
})
return _ok({'list': rows, 'total': total, 'page': page, 'page_size': page_size})
# ---------- 客服后台 ----------
def _require_kefu_perm(request, *need_codes):
from backend.utils import verify_kefu_permission
username = request.data.get('username') or request.query_params.get('username') or getattr(request.user, 'Phone', None)
kefu, perms_or_resp = verify_kefu_permission(request, username)
if not kefu:
return None, None, perms_or_resp
perms = perms_or_resp if isinstance(perms_or_resp, list) else []
is_admin = (
getattr(request.user, 'UserType', '') == 'admin'
or getattr(request.user, 'IsSuperuser', False)
or '000001' in perms
)
if is_admin:
return kefu, perms, None
if not any(c in perms for c in need_codes):
return None, None, _err('无工单权限', http=403)
return kefu, perms, None
class GongdanAdminListView(APIView):
"""客服工单列表。POST /gongdan/admin/list/"""
permission_classes = [IsAuthenticated]
def post(self, request):
kefu, perms, err = _require_kefu_perm(request, PERM_VIEW, PERM_HANDLE)
if err:
return err
page = int(request.data.get('page') or 1)
page_size = min(int(request.data.get('page_size') or 20), 100)
from django.db.models import Q
qs = Gongdan.query.all().order_by('-CreateTime')
try:
from jituan.services.club_context import filter_club_char_field
qs = filter_club_char_field(qs, request, field='club_id')
except Exception:
pass
zhuangtai = request.data.get('zhuangtai')
leixing = request.data.get('leixing')
keyword = (request.data.get('keyword') or '').strip()
only_escalated = str(request.data.get('only_escalated') or '').lower() in ('1', 'true', 'yes')
need_handle = str(request.data.get('need_handle') or '').lower() in ('1', 'true', 'yes')
if need_handle:
qs = qs.filter(Q(zhuangtai=1) | Q(zhuangtai=3))
elif zhuangtai not in (None, ''):
qs = qs.filter(zhuangtai=int(zhuangtai))
if only_escalated:
qs = qs.filter(shengji_cishu__gt=0).filter(Q(zhuangtai=1) | Q(zhuangtai=3))
if leixing not in (None, ''):
qs = qs.filter(leixing=int(leixing))
if keyword:
qs = qs.filter(
Q(gongdan_id__icontains=keyword)
| Q(shenqingren_id__icontains=keyword)
| Q(order_id__icontains=keyword)
| Q(chongzhi_id__icontains=keyword)
| Q(fadan_id__icontains=keyword)
)
total = qs.count()
start = max(page - 1, 0) * page_size
rows = [serialize_gongdan(g, include_images=False) for g in qs[start:start + page_size]]
# 统计基于俱乐部范围全量(不受当前筛选影响),方便客服一眼看到待办量
stats_qs = Gongdan.query.all()
try:
from jituan.services.club_context import filter_club_char_field
stats_qs = filter_club_char_field(stats_qs, request, field='club_id')
except Exception:
pass
stats = {
'total': stats_qs.count(),
'pending': stats_qs.filter(zhuangtai=1).count(),
'done': stats_qs.filter(zhuangtai=2).count(),
'dissatisfied': stats_qs.filter(zhuangtai=3).count(),
'closed': stats_qs.filter(zhuangtai=4).count(),
'escalated': stats_qs.filter(shengji_cishu__gt=0).filter(
Q(zhuangtai=1) | Q(zhuangtai=3)
).count(),
'need_handle': stats_qs.filter(Q(zhuangtai=1) | Q(zhuangtai=3)).count(),
}
return _ok({
'list': rows,
'total': total,
'page': page,
'page_size': page_size,
'stats': stats,
})
class GongdanAdminHandleView(APIView):
"""客服处理工单。POST /gongdan/admin/handle/"""
permission_classes = [IsAuthenticated]
def post(self, request):
kefu, perms, err = _require_kefu_perm(request, PERM_HANDLE)
if err:
return err
d = request.data
handler_uid = getattr(request.user, 'UserUID', '') or getattr(request.user, 'Phone', '')
handler_name = getattr(request.user, 'NickName', None) or getattr(request.user, 'Phone', '') or handler_uid
try:
gd = handle_gongdan(
gongdan_id=(d.get('gongdan_id') or '').strip(),
handler_uid=handler_uid,
handler_name=handler_name,
action=d.get('action') or 'done',
chuli_jieguo=d.get('chuli_jieguo') or d.get('jieguo') or '',
)
return _ok(serialize_gongdan(gd, include_images=True, include_shengji=True), '处理成功')
except GongdanError as e:
return _err(str(e))
except Exception as e:
logger.error('处理工单失败: %s', e, exc_info=True)
return _err('处理失败', http=500)

View File

@@ -28,3 +28,37 @@ ADMIN_SCOPE_CLUB = 'CLUB'
# 集团最高权限账号(写死超级管理员,拥有全部功能权限与集团视图)
SUPER_ADMIN_PHONES = frozenset({'1383714110'})
# —— 支付通道 ——
PAY_CHANNEL_WECHAT_JSAPI = 'wechat_jsapi'
PAY_CHANNEL_WECHAT_V3 = 'wechat_v3'
PAY_CHANNEL_ALIPAY_WAP = 'alipay_wap'
PAY_CHANNEL_FUBEI_WX_MINI = 'fubei_wx_mini'
# 俱乐部「小程序收款」选用哪条通道(前端仍传 pay_method=wechat后端按此路由
MINI_PAY_CHANNEL_WECHAT = 'wechat'
MINI_PAY_CHANNEL_FUBEI = 'fubei'
MINI_PAY_CHANNEL_CHOICES = (MINI_PAY_CHANNEL_WECHAT, MINI_PAY_CHANNEL_FUBEI)
PAY_METHOD_WECHAT = 'wechat'
PAY_METHOD_ALIPAY = 'alipay'
PAY_METHOD_FUBEI = 'fubei'
PAY_METHOD_LABELS = {
PAY_METHOD_WECHAT: '微信支付',
PAY_METHOD_ALIPAY: '支付宝',
PAY_METHOD_FUBEI: '微信支付', # 前端不感知,展示仍为微信
}
PAY_METHOD_TO_CHANNEL = {
PAY_METHOD_WECHAT: PAY_CHANNEL_WECHAT_JSAPI,
PAY_METHOD_ALIPAY: PAY_CHANNEL_ALIPAY_WAP,
PAY_METHOD_FUBEI: PAY_CHANNEL_FUBEI_WX_MINI,
}
FUBEI_GATEWAY_PROD = 'https://shq-api.51fubei.com/gateway'
FUBEI_GATEWAY_SANDBOX = 'https://shq-api.51fubei.com/gateway' # 付呗文档未单独给沙箱网关时同正式
# 付呗下单 biz 标记(写入 attach / 流水表,回调与退款用)
FUBEI_BIZ_ORDER = 'order'
FUBEI_BIZ_CZJILU = 'czjilu'

1
jituan/data/README.md Normal file
View File

@@ -0,0 +1 @@
# 内置数据目录UFO 商品导入 catalog 等

4046
jituan/data/ufo_catalog.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,210 @@
"""清理龙先生重复/无 pem 证书俱乐部。
规则:
- 候选club_id=lsx / 名称含「龙先生」 / wx_appid=龙先生小程序
- 「有 pem」pay_key_path / pay_cert_path / private_key_path 任一路径以 .pem 结尾
(你后台上传的证书路径都是 *.pem脚本自动建的/空配置通常没有)
- 「可删」:上述三个字段都没有以 .pem 结尾的路径
- 默认 dry-run--apply 才删除
- 若候选里没有任何「有 pem」的对照默认不删加 --force-delete-empty-only 才删
用法:
python manage.py cleanup_lsx_empty_club
python manage.py cleanup_lsx_empty_club --apply
python manage.py cleanup_lsx_empty_club --apply --remove-seed-users
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Q
from jituan.models import (
Club,
ClubDashouExamConfig,
ClubDashouExamPass,
ClubDashouExamQuestion,
ClubDashouExamQuestionImage,
ClubFadanFenhongLilv,
ClubHuiyuanBundleInclude,
ClubHuiyuanPrice,
ClubKaoheChenghaoConfig,
ClubLilubiao,
ClubPaymentChannel,
ClubShangpinLeixingConfig,
ClubTixianQuota,
ClubWithdrawConfig,
)
from users.business_models import User
from users.models import UserBoss, UserGuanshi, UserZuzhang
LSX_APPID = 'wx7ff90e9d024fcdb8'
SEED_UIDS = ('9910001', '9910002')
RELATED_MODELS = (
ClubPaymentChannel,
ClubHuiyuanPrice,
ClubHuiyuanBundleInclude,
ClubLilubiao,
ClubWithdrawConfig,
ClubTixianQuota,
ClubShangpinLeixingConfig,
ClubKaoheChenghaoConfig,
ClubFadanFenhongLilv,
ClubDashouExamConfig,
ClubDashouExamQuestion,
ClubDashouExamQuestionImage,
ClubDashouExamPass,
)
# 证书「文件」字段(应以 .pem 结尾platform_cert_dir 是目录,不参与判定
_PEM_FILE_FIELDS = ('pay_key_path', 'pay_cert_path', 'private_key_path')
def _path_ends_with_pem(path):
return str(path or '').strip().lower().endswith('.pem')
def _has_pem_paths(club):
"""后台上传过证书:路径以 .pem 结尾。"""
return any(_path_ends_with_pem(getattr(club, f, '')) for f in _PEM_FILE_FIELDS)
def _pem_field_summary(club):
lines = []
for f in _PEM_FILE_FIELDS:
val = (getattr(club, f, '') or '').strip()
if not val:
tag = ''
elif _path_ends_with_pem(val):
tag = 'pem✓'
else:
tag = '非pem✗'
lines.append(f'{f}={val or "(空)"} [{tag}]')
plat = (getattr(club, 'platform_cert_dir', '') or '').strip()
lines.append(f'platform_cert_dir={plat or "(空)"} [目录仅展示]')
return lines
def _candidates():
return Club.query.filter(
Q(club_id__iexact='lsx')
| Q(club_id__icontains='lsx')
| Q(name__icontains='龙先生')
| Q(wx_appid=LSX_APPID)
).order_by('club_id')
def _purge_club_related(club_id, stdout, style):
for model in RELATED_MODELS:
try:
n = model.query.filter(club_id=club_id).count()
if n:
model.query.filter(club_id=club_id).delete()
stdout.write(f' - 已删 {model.__name__} x{n}')
except Exception as exc:
stdout.write(style.WARNING(f' - 跳过 {model.__name__}: {exc}'))
class Command(BaseCommand):
help = '列出并删除龙先生相关「证书路径不以 .pem 结尾」的重复俱乐部(默认 dry-run'
def add_arguments(self, parser):
parser.add_argument('--apply', action='store_true', help='真正写入删除')
parser.add_argument(
'--force-delete-empty-only',
action='store_true',
help='即使没有「有 .pem」的对照俱乐部也允许删除非 pem 候选',
)
parser.add_argument(
'--remove-seed-users',
action='store_true',
help='同时删除 seed_lsx_invite_data 种子用户 9910001/9910002',
)
parser.add_argument(
'--club-id',
type=str,
default='',
help='只处理指定 club_id精确匹配',
)
def handle(self, *args, **options):
apply = bool(options.get('apply'))
force = bool(options.get('force_delete_empty_only'))
only_id = (options.get('club_id') or '').strip()
qs = _candidates()
if only_id:
qs = Club.query.filter(club_id=only_id)
clubs = list(qs)
if not clubs:
self.stdout.write(self.style.WARNING('未找到龙先生/lsx 相关俱乐部'))
return
with_pem = []
without_pem = []
self.stdout.write(self.style.SUCCESS('=' * 60))
self.stdout.write('判定标准pay_key_path / pay_cert_path / private_key_path 是否以 .pem 结尾')
self.stdout.write('龙先生相关俱乐部扫描结果:')
for c in clubs:
keyed = _has_pem_paths(c)
(with_pem if keyed else without_pem).append(c)
self.stdout.write('-' * 40)
self.stdout.write(f'club_id={c.club_id} name={c.name} status={c.status}')
self.stdout.write(f' wx_appid={c.wx_appid or "(空)"}')
for line in _pem_field_summary(c):
self.stdout.write(f' {line}')
self.stdout.write(
f' >>> {"有 .pem 路径(保留)" if keyed else "无 .pem 路径(可删)"}'
)
self.stdout.write(self.style.SUCCESS('=' * 60))
self.stdout.write(f'有 .pem: {len(with_pem)} 条;无 .pem: {len(without_pem)}')
if not without_pem:
self.stdout.write(self.style.SUCCESS('没有「无 .pem 路径」的俱乐部,无需删除。'))
elif not with_pem and not force:
self.stdout.write(self.style.ERROR(
'全部候选都没有 .pem 路径。为安全起见不自动删。'
'若确认要删,加 --force-delete-empty-only --apply'
))
else:
for c in without_pem:
self.stdout.write(self.style.WARNING(
f'将删除无 .pem 俱乐部: {c.club_id} ({c.name})'
))
if not apply:
self.stdout.write(' (dry-run未真正删除加 --apply 执行)')
continue
with transaction.atomic():
_purge_club_related(c.club_id, self.stdout, self.style)
Club.query.filter(club_id=c.club_id).delete()
self.stdout.write(self.style.SUCCESS(f' 已删除俱乐部 {c.club_id}'))
if options.get('remove_seed_users'):
self.stdout.write('-' * 40)
self.stdout.write('处理种子用户 9910001 / 9910002 ...')
for uid in SEED_UIDS:
user = User.query.filter(UserUID=uid).first()
if not user:
self.stdout.write(f' {uid}: 不存在')
continue
self.stdout.write(
f' {uid}: ClubID={user.ClubID} Phone={user.Phone} OpenID={user.OpenID}'
)
if not apply:
self.stdout.write(' (dry-run)')
continue
with transaction.atomic():
UserZuzhang.query.filter(user=user).delete()
UserGuanshi.query.filter(user=user).delete()
UserBoss.query.filter(user=user).delete()
user.delete()
self.stdout.write(self.style.SUCCESS(f' 已删除用户 {uid}'))
if not apply:
self.stdout.write(self.style.WARNING(
'\n当前为预览。确认后执行:\n'
' python manage.py cleanup_lsx_empty_club --apply\n'
'如需顺带删种子账号:\n'
' python manage.py cleanup_lsx_empty_club --apply --remove-seed-users'
))

View File

@@ -0,0 +1,387 @@
# -*- coding: utf-8 -*-
"""
把龙先生(lxs)审核版商品目录复制到小溪(xx)。
范围(必须全部满足):
- 源俱乐部固定 lxsname 含「龙先生」)
- 目标俱乐部固定 xxname 含「小溪」「小西」或「小汐」)
- 仅全局 ShangpinLeixing.shenhezhuangtai=2 的类型
- 仅公共目录 dianpu IS NULL
- 专区/商品均要求 shenhezhuangtai=2
步骤:
1) (可选)清空 xx 下上述审核类型的专区+商品(只删 DB不删 OSS
2) 从 lxs 复制同类型专区+商品到 xx图片复用相对路径销量归零
3) 确保 xx 的 ClubShangpinLeixingConfig 启用这些审核类型(图标可复用源配置)
不碰:正常商品(shenhezhuangtai=1)、其它俱乐部、店铺商品、全局类型表、订单等。
用法:
python manage.py copy_lxs_shenhe_catalog_to_xx --list
python manage.py copy_lxs_shenhe_catalog_to_xx
python manage.py copy_lxs_shenhe_catalog_to_xx --apply --i-confirm=lxs-to-xx
python manage.py copy_lxs_shenhe_catalog_to_xx --apply --i-confirm=lxs-to-xx --clear-target
"""
from __future__ import annotations
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
SOURCE_CLUB = 'lxs'
SOURCE_NAME_HINT = '龙先生'
TARGET_CLUB = 'xx'
TARGET_NAME_HINTS = ('小溪', '小西', '小汐')
SHENHE = 2
CONFIRM_TOKEN = 'lxs-to-xx'
class Command(BaseCommand):
help = '复制龙先生审核专区/商品到小溪(xx)(默认 dry-run'
def add_arguments(self, parser):
parser.add_argument('--apply', action='store_true', help='真正写库')
parser.add_argument(
'--i-confirm',
type=str,
default='',
help=f'必须等于 {CONFIRM_TOKEN} 才允许 --apply',
)
parser.add_argument(
'--clear-target',
action='store_true',
help='写入前先清空 xx 下对应审核专区/商品(只删 DB',
)
parser.add_argument(
'--list',
action='store_true',
help='只列出审核类型及 lxs/xx 下专区商品数量',
)
def handle(self, *args, **options):
from jituan.models import Club, ClubShangpinLeixingConfig
from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu
apply = bool(options.get('apply'))
confirm = (options.get('i_confirm') or '').strip()
clear_target = bool(options.get('clear_target'))
src_club = Club.query.filter(club_id=SOURCE_CLUB).first()
if not src_club:
raise CommandError(f'源俱乐部 {SOURCE_CLUB} 不存在')
if SOURCE_NAME_HINT not in (src_club.name or ''):
raise CommandError(
f'安全拦截:{SOURCE_CLUB} 的 name={src_club.name!r} 不含「{SOURCE_NAME_HINT}'
)
dst_club = Club.query.filter(club_id=TARGET_CLUB).first()
if not dst_club:
raise CommandError(
f'目标俱乐部 {TARGET_CLUB} 不存在。请先在后台创建小溪俱乐部(club_id=xx)。'
)
if not any(h in (dst_club.name or '') for h in TARGET_NAME_HINTS):
raise CommandError(
f'安全拦截:{TARGET_CLUB} 的 name={dst_club.name!r} '
f'不含「{"/".join(TARGET_NAME_HINTS)}'
)
audit_types = list(
ShangpinLeixing.query.filter(shenhezhuangtai=SHENHE).order_by('id')
)
if not audit_types:
raise CommandError('没有任何审核专用商品类型(shenhezhuangtai=2)')
if len(audit_types) != 3:
self.stdout.write(self.style.WARNING(
f'注意:当前审核类型数量={len(audit_types)}(预期常为 3将按实际全部处理。'
))
leixing_ids = [lx.id for lx in audit_types]
def counts(club_id):
z = ShangpinZhuanqu.query.filter(
club_id=club_id,
leixing_id__in=leixing_ids,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).count()
p = Shangpin.query.filter(
club_id=club_id,
leixing_id__in=leixing_ids,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).count()
return z, p
def per_type(club_id):
rows = []
for lx in audit_types:
z = ShangpinZhuanqu.query.filter(
club_id=club_id,
leixing_id=lx.id,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).count()
p = Shangpin.query.filter(
club_id=club_id,
leixing_id=lx.id,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).count()
rows.append((lx, z, p))
return rows
if options.get('list'):
self.stdout.write(self.style.NOTICE('审核类型及两侧存量:'))
for lx in audit_types:
sz, sp = (
ShangpinZhuanqu.query.filter(
club_id=SOURCE_CLUB, leixing_id=lx.id,
shenhezhuangtai=SHENHE, dianpu__isnull=True,
).count(),
Shangpin.query.filter(
club_id=SOURCE_CLUB, leixing_id=lx.id,
shenhezhuangtai=SHENHE, dianpu__isnull=True,
).count(),
)
dz, dp = (
ShangpinZhuanqu.query.filter(
club_id=TARGET_CLUB, leixing_id=lx.id,
shenhezhuangtai=SHENHE, dianpu__isnull=True,
).count(),
Shangpin.query.filter(
club_id=TARGET_CLUB, leixing_id=lx.id,
shenhezhuangtai=SHENHE, dianpu__isnull=True,
).count(),
)
self.stdout.write(
f' id={lx.id}{lx.jieshao}'
f'lxs: zones={sz} products={sp} | '
f'xx: zones={dz} products={dp}'
)
return
if apply and confirm != CONFIRM_TOKEN:
raise CommandError(
f'安全拦截:--apply 必须带 --i-confirm={CONFIRM_TOKEN}\n'
f' python manage.py copy_lxs_shenhe_catalog_to_xx '
f'--apply --i-confirm={CONFIRM_TOKEN}'
)
src_z_total, src_p_total = counts(SOURCE_CLUB)
dst_z_total, dst_p_total = counts(TARGET_CLUB)
src_zones = list(
ShangpinZhuanqu.query.filter(
club_id=SOURCE_CLUB,
leixing_id__in=leixing_ids,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).order_by('leixing_id', 'paixu', 'id')
)
src_products = list(
Shangpin.query.filter(
club_id=SOURCE_CLUB,
leixing_id__in=leixing_ids,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).order_by('leixing_id', 'paixu', 'id')
)
src_zone_ids = {z.id for z in src_zones}
orphan = [p for p in src_products if p.zhuanqu_id not in src_zone_ids]
if orphan:
sample = ', '.join(f'{p.id}:{p.biaoqian}' for p in orphan[:5])
raise CommandError(
f'源商品有 {len(orphan)} 条专区不在本批审核专区内,已中止。样例: {sample}'
)
if src_z_total == 0 and src_p_total == 0:
raise CommandError('龙先生(lxs)下没有可复制的审核专区/商品')
mode = 'APPLY' if apply else 'DRY-RUN'
self.stdout.write(self.style.NOTICE(
f'\n===== {mode} 小溪审核目录 ← 龙先生 =====\n'
f'源: {SOURCE_CLUB} ({src_club.name}) '
f'专区={src_z_total} 商品={src_p_total}\n'
f'目标: {TARGET_CLUB} ({dst_club.name}) '
f'现有 专区={dst_z_total} 商品={dst_p_total}\n'
f'clear_target={clear_target}\n'
f'审核类型数={len(audit_types)} ids={leixing_ids}\n'
f'OSS: 不删文件;复制时复用龙先生相对路径(同一 COS 域名拼接)\n'
))
self.stdout.write('—— 分类型存量 ——')
for lx, sz, sp in per_type(SOURCE_CLUB):
self.stdout.write(f' [lxs] id={lx.id}{lx.jieshao}」 zones={sz} products={sp}')
for lx, dz, dp in per_type(TARGET_CLUB):
self.stdout.write(f' [xx] id={lx.id}{lx.jieshao}」 zones={dz} products={dp}')
self.stdout.write('—— 将从 lxs 复制的专区样例 ——')
for z in src_zones[:40]:
self.stdout.write(
f' zq#{z.id} leixing={z.leixing_id} {z.mingzi!r}'
)
if len(src_zones) > 40:
self.stdout.write(f' ... 其余 {len(src_zones) - 40} 个省略')
self.stdout.write('—— 将从 lxs 复制的商品样例 ——')
for p in src_products[:15]:
self.stdout.write(
f' sp#{p.id} leixing={p.leixing_id} {p.biaoqian!r} ¥{p.jiage}'
)
if len(src_products) > 15:
self.stdout.write(f' ... 其余 {len(src_products) - 15} 个省略')
if not apply:
self.stdout.write(self.style.WARNING(
'\n以上仅计划:未删库、未写库。确认无误后:\n'
f' python manage.py copy_lxs_shenhe_catalog_to_xx '
f'--apply --i-confirm={CONFIRM_TOKEN}\n'
f'若目标已有旧审核目录需先清空,再加 --clear-target\n'
))
return
stats = {
'deleted_products': 0,
'deleted_zones': 0,
'zones_created': 0,
'products_created': 0,
'cfg_created': 0,
'cfg_enabled': 0,
}
with transaction.atomic():
if clear_target:
n_prod, _ = Shangpin.query.filter(
club_id=TARGET_CLUB,
leixing_id__in=leixing_ids,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).delete()
n_zone, _ = ShangpinZhuanqu.query.filter(
club_id=TARGET_CLUB,
leixing_id__in=leixing_ids,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).delete()
stats['deleted_products'] = n_prod
stats['deleted_zones'] = n_zone
zone_id_map = {}
for z in src_zones:
if z.leixing_id not in leixing_ids:
raise CommandError(f'专区 {z.id} 类型 {z.leixing_id} 不在审核列表')
# 未 clear 时若同名专区已存在则复用,避免重复堆叠
exist = None
if not clear_target:
exist = ShangpinZhuanqu.query.filter(
club_id=TARGET_CLUB,
leixing_id=z.leixing_id,
mingzi=z.mingzi,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).first()
if exist:
zone_id_map[z.id] = exist.id
continue
row = ShangpinZhuanqu.query.create(
mingzi=z.mingzi,
leixing_id=z.leixing_id,
club_id=TARGET_CLUB,
tupian_url=z.tupian_url or '',
shenhezhuangtai=SHENHE,
paixu=int(z.paixu or 0),
shangjia_zhuangtai=bool(getattr(z, 'shangjia_zhuangtai', True)),
fengjin_zhuangtai=bool(getattr(z, 'fengjin_zhuangtai', True)),
dianpu=None,
dianpu_leixing=None,
)
zone_id_map[z.id] = row.id
stats['zones_created'] += 1
for p in src_products:
if p.leixing_id not in leixing_ids:
raise CommandError(f'商品 {p.id} 类型 {p.leixing_id} 不在审核列表')
dst_zq = zone_id_map.get(p.zhuanqu_id)
if not dst_zq:
raise CommandError(
f'商品「{p.biaoqian}」id={p.id} 无法映射 zhuanqu_id={p.zhuanqu_id}'
)
if not clear_target:
exist_p = Shangpin.query.filter(
club_id=TARGET_CLUB,
leixing_id=p.leixing_id,
zhuanqu_id=dst_zq,
biaoqian=p.biaoqian,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).first()
if exist_p:
continue
Shangpin.query.create(
biaoqian=p.biaoqian,
jiage=p.jiage,
kucun=p.kucun,
leixing_id=p.leixing_id,
zhuanqu_id=dst_zq,
zhenshi_xiaoliang=0,
duiwai_xiaoliang=0,
jieshao=p.jieshao,
xiadan_xuzhi=p.xiadan_xuzhi,
guize_tupian=p.guize_tupian or '',
tupian_url=p.tupian_url or '',
yaoqiuleixing=p.yaoqiuleixing,
huiyuan_id=p.huiyuan_id,
yongjin=p.yongjin,
kaioi_ewai_dashou_fencheng=bool(p.kaioi_ewai_dashou_fencheng),
ewai_dashou_fencheng=p.ewai_dashou_fencheng,
paixu=int(p.paixu or 0),
shenhezhuangtai=SHENHE,
shangjia_zhuangtai=bool(p.shangjia_zhuangtai),
fengjin_zhuangtai=bool(p.fengjin_zhuangtai),
shenhe_zhuangtai=bool(getattr(p, 'shenhe_zhuangtai', True)),
club_id=TARGET_CLUB,
shi_zhuanpan=False,
dianpu=None,
dianpu_leixing=None,
)
stats['products_created'] += 1
# 启用审核类型上架配置
for lx in audit_types:
src_cfg = ClubShangpinLeixingConfig.query.filter(
club_id=SOURCE_CLUB, leixing_id=lx.id,
).first()
dst_cfg = ClubShangpinLeixingConfig.query.filter(
club_id=TARGET_CLUB, leixing_id=lx.id,
).first()
icon = ''
paixu = int(getattr(lx, 'paixu', 0) or 0)
if src_cfg:
icon = src_cfg.tupian_url or ''
paixu = int(src_cfg.paixu or paixu)
if dst_cfg is None:
ClubShangpinLeixingConfig.query.create(
club_id=TARGET_CLUB,
leixing_id=lx.id,
is_enabled=True,
tupian_url=icon,
paixu=paixu,
)
stats['cfg_created'] += 1
else:
changed = False
if not dst_cfg.is_enabled:
dst_cfg.is_enabled = True
changed = True
if icon and not (dst_cfg.tupian_url or '').strip():
dst_cfg.tupian_url = icon
changed = True
if changed:
dst_cfg.save()
stats['cfg_enabled'] += 1
self.stdout.write(self.style.SUCCESS(
f'\n完成: {stats}\n'
f'龙先生(lxs)未改动;小溪(xx)已具备审核目录。\n'
f'请用小溪小程序(CLUB_ID=xx)重新编译验证。'
))

View File

@@ -0,0 +1,391 @@
# -*- coding: utf-8 -*-
"""
将龙先生(lxs)审核类型「娱乐护航」下的专区+商品,复制到其它审核专用商品类型。
背景:全局通常有 3 个 shenhezhuangtai=2 的商品类型;其中「娱乐护航」已有专区/商品,
另外两个往往为空。本命令只增不减,把源类型目录镜像到目标类型。
范围:
- 仅 club_id=lxsClub.name 须含「龙先生」)
- 仅 shenhezhuangtai=2审核专用
- 仅公共目录 dianpu IS NULL
- 源类型:名称精确「娱乐护航」(可用 --source-name 覆盖)
- 目标:除源以外的全部审核类型(或 --target-id 指定)
不碰:正常类型/商品、其它俱乐部、店铺商品、源数据、全局类型表本身。
图片:复用源相对路径(同俱乐部审核素材,不另拷 OSS
用法:
python manage.py copy_lxs_shenhe_huhang_to_other_types
python manage.py copy_lxs_shenhe_huhang_to_other_types --list-leixing
python manage.py copy_lxs_shenhe_huhang_to_other_types --apply --i-confirm-club=lxs
"""
from __future__ import annotations
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
ALLOWED_CLUB_ID = 'lxs'
NAME_HINT = '龙先生'
SHENHE = 2
DEFAULT_SOURCE_NAME = '娱乐护航'
class Command(BaseCommand):
help = '龙先生:把审核类型「娱乐护航」的专区/商品复制到其它审核类型(默认 dry-run'
def add_arguments(self, parser):
parser.add_argument('--apply', action='store_true', help='真正写库')
parser.add_argument(
'--i-confirm-club',
type=str,
default='',
help='必须等于 lxs 才允许 --apply',
)
parser.add_argument(
'--club-id',
type=str,
default=ALLOWED_CLUB_ID,
help='仅允许 lxs',
)
parser.add_argument(
'--source-name',
type=str,
default=DEFAULT_SOURCE_NAME,
help=f'源审核类型全名精确匹配,默认「{DEFAULT_SOURCE_NAME}',
)
parser.add_argument(
'--source-id',
type=int,
default=0,
help='可选:直接指定源 ShangpinLeixing.id须为审核专用且名称校验',
)
parser.add_argument(
'--target-id',
type=int,
action='append',
default=[],
help='可选:只复制到指定审核类型 id可多次传入默认=除源外全部审核类型',
)
parser.add_argument(
'--list-leixing',
action='store_true',
help='只列出审核类型及 lxs 下专区/商品数量',
)
def handle(self, *args, **options):
from jituan.models import Club
from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu
club_id = (options.get('club_id') or '').strip()
apply = bool(options.get('apply'))
confirm = (options.get('i_confirm_club') or '').strip()
source_name = (options.get('source_name') or DEFAULT_SOURCE_NAME).strip()
if club_id != ALLOWED_CLUB_ID:
raise CommandError(
f'安全拦截:只允许 club_id={ALLOWED_CLUB_ID!r},你传的是 {club_id!r}'
)
club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first()
if not club:
raise CommandError(f'俱乐部 {ALLOWED_CLUB_ID} 不存在')
if NAME_HINT not in (club.name or ''):
raise CommandError(
f'安全拦截:{ALLOWED_CLUB_ID} 的 name={club.name!r} 不含「{NAME_HINT}'
)
audit_types = list(
ShangpinLeixing.query.filter(shenhezhuangtai=SHENHE).order_by('id')
)
if not audit_types:
raise CommandError('库中没有任何审核专用商品类型(shenhezhuangtai=2)')
def count_for(leixing_id):
z = ShangpinZhuanqu.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id=leixing_id,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).count()
p = Shangpin.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id=leixing_id,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).count()
return z, p
if options.get('list_leixing'):
self.stdout.write(self.style.NOTICE(
f'审核类型清单club={ALLOWED_CLUB_ID} 下专区/商品数):'
))
for lx in audit_types:
zc, pc = count_for(lx.id)
mark = ' <<-- 源候选' if (lx.jieshao or '') == source_name else ''
self.stdout.write(
f' id={lx.id} name={lx.jieshao!r} zones={zc} products={pc}{mark}'
)
return
if apply and confirm != ALLOWED_CLUB_ID:
raise CommandError(
'安全拦截:--apply 必须带 --i-confirm-club=lxs\n'
' python manage.py copy_lxs_shenhe_huhang_to_other_types '
'--apply --i-confirm-club=lxs'
)
# 解析源类型
source_id = int(options.get('source_id') or 0)
if source_id:
source = ShangpinLeixing.query.filter(id=source_id).first()
if not source:
raise CommandError(f'找不到类型 id={source_id}')
if source.shenhezhuangtai != SHENHE:
raise CommandError(f'类型 id={source_id} 不是审核专用')
if (source.jieshao or '') != source_name:
raise CommandError(
f'安全拦截id={source_id} 名称是「{source.jieshao}」,'
f'与要求的「{source_name}」不一致'
)
else:
matches = [lx for lx in audit_types if (lx.jieshao or '') == source_name]
if not matches:
# 宽松:名称包含「娱乐护」且含「航」或等于常见别名
soft = [
lx for lx in audit_types
if '娱乐护' in (lx.jieshao or '')
]
hint = '\n'.join(
f' id={lx.id} name={lx.jieshao!r}' for lx in audit_types
)
raise CommandError(
f'找不到审核类型全名「{source_name}」。当前审核类型:\n{hint}\n'
f'可用 --source-id=... 或 --source-name=... 指定。'
+ (f'\n名称含「娱乐护」的有: {[s.jieshao for s in soft]}' if soft else '')
)
if len(matches) > 1:
raise CommandError(
f'{source_name}」匹配到多条 {[m.id for m in matches]},请 --source-id='
)
source = matches[0]
src_zones_n, src_prods_n = count_for(source.id)
if src_zones_n == 0 and src_prods_n == 0:
raise CommandError(
f'源类型 id={source.id}{source.jieshao}」在 {ALLOWED_CLUB_ID}'
f'没有任何审核专区/商品,无需复制'
)
# 目标类型
target_ids = [int(x) for x in (options.get('target_id') or [])]
if target_ids:
targets = []
for tid in target_ids:
lx = ShangpinLeixing.query.filter(id=tid).first()
if not lx:
raise CommandError(f'目标类型 id={tid} 不存在')
if lx.shenhezhuangtai != SHENHE:
raise CommandError(f'目标类型 id={tid} 不是审核专用,拒绝')
if lx.id == source.id:
raise CommandError('目标不能与源相同')
targets.append(lx)
else:
targets = [lx for lx in audit_types if lx.id != source.id]
if not targets:
raise CommandError('没有可复制的目标审核类型(除源外为空)')
src_zones = list(
ShangpinZhuanqu.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id=source.id,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).order_by('paixu', 'id')
)
src_products = list(
Shangpin.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id=source.id,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).order_by('paixu', 'id')
)
mode = 'APPLY' if apply else 'DRY-RUN'
self.stdout.write(self.style.NOTICE(
f'\n===== {mode} 复制审核目录 =====\n'
f'club={ALLOWED_CLUB_ID} ({club.name})\n'
f'源类型: id={source.id}{source.jieshao}'
f'专区={len(src_zones)} 商品={len(src_products)}\n'
f'目标类型({len(targets)}): '
+ ', '.join(f'id={t.id}{t.jieshao}' for t in targets)
+ '\n'
))
for t in targets:
zc, pc = count_for(t.id)
self.stdout.write(f' 目标当前存量 id={t.id}{t.jieshao}」 zones={zc} products={pc}')
self.stdout.write('—— 源专区 ——')
for z in src_zones:
self.stdout.write(f' id={z.id} {z.mingzi!r} paixu={z.paixu}')
self.stdout.write('—— 源商品样例 ——')
for p in src_products[:8]:
self.stdout.write(f' id={p.id} {p.biaoqian!r} ¥{p.jiage} zq={p.zhuanqu_id}')
if len(src_products) > 8:
self.stdout.write(f' ... 其余 {len(src_products) - 8} 个省略')
# 预计算每个目标的 create/skip
preview = []
for t in targets:
z_create = z_skip = p_create = p_skip = 0
for z in src_zones:
exist_z = ShangpinZhuanqu.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id=t.id,
mingzi=z.mingzi,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).first()
if exist_z:
z_skip += 1
else:
z_create += 1
# 商品去重按:目标类型下 同标题+同专区名(专区可能尚未建,用名推断)
for p in src_products:
src_z = next((x for x in src_zones if x.id == p.zhuanqu_id), None)
zname = src_z.mingzi if src_z else None
exist_zq = None
if zname:
exist_zq = ShangpinZhuanqu.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id=t.id,
mingzi=zname,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).first()
if exist_zq:
exist_p = Shangpin.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id=t.id,
zhuanqu_id=exist_zq.id,
biaoqian=p.biaoqian,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).first()
if exist_p:
p_skip += 1
else:
p_create += 1
else:
p_create += 1
preview.append((t, z_create, z_skip, p_create, p_skip))
self.stdout.write(
f' 计划 →「{t.jieshao}」: zones +{z_create}/skip{z_skip}, '
f'products +{p_create}/skip{p_skip}'
)
if not apply:
self.stdout.write(self.style.WARNING(
'\n以上仅计划,未写库。确认后:\n'
' python manage.py copy_lxs_shenhe_huhang_to_other_types '
'--apply --i-confirm-club=lxs\n'
))
return
stats = {
'targets': 0,
'zones_created': 0,
'zones_skipped': 0,
'products_created': 0,
'products_skipped': 0,
'products_skip_no_zone': 0,
}
with transaction.atomic():
for t in targets:
stats['targets'] += 1
zone_map = {} # src_zone_id -> dst_zone_id
for z in src_zones:
exist_z = ShangpinZhuanqu.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id=t.id,
mingzi=z.mingzi,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).first()
if exist_z:
zone_map[z.id] = exist_z.id
stats['zones_skipped'] += 1
continue
row = ShangpinZhuanqu.query.create(
mingzi=z.mingzi,
leixing_id=t.id,
club_id=ALLOWED_CLUB_ID,
tupian_url=z.tupian_url or '',
shenhezhuangtai=SHENHE,
paixu=int(z.paixu or 0),
shangjia_zhuangtai=bool(getattr(z, 'shangjia_zhuangtai', True)),
fengjin_zhuangtai=bool(getattr(z, 'fengjin_zhuangtai', True)),
dianpu=None,
dianpu_leixing=None,
)
zone_map[z.id] = row.id
stats['zones_created'] += 1
for p in src_products:
dst_zq = zone_map.get(p.zhuanqu_id)
if not dst_zq:
stats['products_skip_no_zone'] += 1
self.stderr.write(
f'跳过无专区映射商品: {p.biaoqian!r} src_zq={p.zhuanqu_id} →「{t.jieshao}'
)
continue
exist_p = Shangpin.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id=t.id,
zhuanqu_id=dst_zq,
biaoqian=p.biaoqian,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).first()
if exist_p:
stats['products_skipped'] += 1
continue
Shangpin.query.create(
biaoqian=p.biaoqian,
jiage=p.jiage,
kucun=p.kucun,
leixing_id=t.id,
zhuanqu_id=dst_zq,
zhenshi_xiaoliang=0,
duiwai_xiaoliang=0,
jieshao=p.jieshao,
xiadan_xuzhi=p.xiadan_xuzhi,
guize_tupian=p.guize_tupian or '',
tupian_url=p.tupian_url or '',
yaoqiuleixing=p.yaoqiuleixing,
huiyuan_id=p.huiyuan_id,
yongjin=p.yongjin,
kaioi_ewai_dashou_fencheng=bool(p.kaioi_ewai_dashou_fencheng),
ewai_dashou_fencheng=p.ewai_dashou_fencheng,
paixu=int(p.paixu or 0),
shenhezhuangtai=SHENHE,
shangjia_zhuangtai=bool(p.shangjia_zhuangtai),
fengjin_zhuangtai=bool(p.fengjin_zhuangtai),
shenhe_zhuangtai=bool(getattr(p, 'shenhe_zhuangtai', True)),
club_id=ALLOWED_CLUB_ID,
shi_zhuanpan=False,
dianpu=None,
dianpu_leixing=None,
)
stats['products_created'] += 1
self.stdout.write(self.style.SUCCESS(
f'\n完成: {stats}\n'
f'源「{source.jieshao}」未改动;目标均为审核专用且仅 lxs。'
))

View File

@@ -0,0 +1,502 @@
# -*- coding: utf-8 -*-
"""
将 UFO 俱乐部已上架的「三角洲」公共商品目录复制到龙先生俱乐部(只增不减)。
涉及表(只写目标俱乐部,不改源、不删任何行):
- club_shangpin_leixing_config ClubShangpinLeixingConfig 上架类型
- shangpin_zhuanqu ShangpinZhuanqu 公共专区
- shangpin Shangpin 公共商品
- OSS copy_in_oss 图物理复制到新相对路径
不碰:
- shangpin_leixing全局类型只复用不新建
- 店铺商品dianpu 非空)
- 订单/统计/轮播/转盘活动表
- UFO 及其它俱乐部任何行
用法(服务器):
cd /path/to/django
./venv/bin/python manage.py copy_ufo_sanjiaozhou_catalog --to-club=lxs
# 确认清单后:
./venv/bin/python manage.py copy_ufo_sanjiaozhou_catalog --to-club=lxs --apply --i-confirm-to-club=lxs
注意:龙先生 club_id 可能是 lxs 或 lsx脚本不猜测必须显式传 --to-club
且 Club.name 必须包含「龙先生」。
"""
from __future__ import annotations
import time
import uuid
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
DEFAULT_FROM_CLUB = 'ufo'
DEFAULT_LEIXING_NAME = '三角洲'
NAME_HINT = '龙先生'
class Command(BaseCommand):
help = (
'复制 UFO「三角洲」公共上架商品目录到龙先生默认 dry-run'
'只 insert / 启用类型,不删不改源)'
)
def add_arguments(self, parser):
parser.add_argument(
'--from-club',
type=str,
default=DEFAULT_FROM_CLUB,
help=f'源俱乐部,默认 {DEFAULT_FROM_CLUB}',
)
parser.add_argument(
'--to-club',
type=str,
default='',
help='目标俱乐部 club_id必须显式指定如 lxs--list-clubs 时可省略)',
)
parser.add_argument(
'--leixing-name',
type=str,
default=DEFAULT_LEIXING_NAME,
help=f'全局类型名全名精确匹配,默认「{DEFAULT_LEIXING_NAME}',
)
parser.add_argument(
'--leixing-id',
type=int,
default=0,
help='可选:直接指定全局 ShangpinLeixing.id仍校验名称',
)
parser.add_argument(
'--apply',
action='store_true',
help='真正写库 + OSS 复制(默认仅 dry-run',
)
parser.add_argument(
'--i-confirm-to-club',
type=str,
default='',
help='与 --to-club 完全一致时才允许 --apply',
)
parser.add_argument(
'--list-clubs',
action='store_true',
help='只列出 Club 表(含名称含「龙先生」的候选),不复制',
)
def handle(self, *args, **options):
from jituan.models import Club, ClubShangpinLeixingConfig
from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu
from utils.oss_utils import copy_in_oss
if options.get('list_clubs'):
self._list_clubs(Club)
return
from_club = (options.get('from_club') or DEFAULT_FROM_CLUB).strip()
to_club = (options.get('to_club') or '').strip()
leixing_name = (options.get('leixing_name') or DEFAULT_LEIXING_NAME).strip()
apply = bool(options.get('apply'))
confirm = (options.get('i_confirm_to_club') or '').strip()
if not to_club:
raise CommandError(
'必须传 --to-club=...(龙先生当前库为 lxs'
'仅查看俱乐部列表时用python manage.py copy_ufo_sanjiaozhou_catalog --list-clubs'
)
self.stdout.write(self.style.NOTICE('—— Club 表候选(名称含「龙先生」)——'))
self._list_clubs(Club, only_hint=True)
src_club = Club.query.filter(club_id=from_club).first()
if not src_club:
raise CommandError(f'源俱乐部不存在: club_id={from_club!r}')
dst_club = Club.query.filter(club_id=to_club).first()
if not dst_club:
raise CommandError(
f'目标俱乐部不存在: club_id={to_club!r}'
f'请先 --list-clubs 核对常见lxs 与 lsx 写反)。'
)
if NAME_HINT not in (dst_club.name or ''):
raise CommandError(
f'安全拦截:目标 club_id={to_club!r} 的 name={dst_club.name!r} '
f'不含「{NAME_HINT}」,拒绝执行。'
)
if from_club == to_club:
raise CommandError('源与目标 club_id 不能相同')
if apply:
if confirm != to_club:
raise CommandError(
'安全拦截:--apply 必须同时传 --i-confirm-to-club且与 --to-club 完全一致。\n'
f' 例:--to-club={to_club} --apply --i-confirm-to-club={to_club}'
)
leixing = self._resolve_leixing(
ShangpinLeixing,
leixing_id=int(options.get('leixing_id') or 0),
leixing_name=leixing_name,
)
if leixing.shenhezhuangtai == 2:
raise CommandError(
f'类型 id={leixing.id}{leixing.jieshao}」是审核专用,拒绝挂载'
)
# 源:公共专区 + 上架商品dianpu 为空 = 俱乐部公共目录)
src_zones = list(
ShangpinZhuanqu.query.filter(
club_id=from_club,
leixing_id=leixing.id,
shenhezhuangtai=1,
dianpu__isnull=True,
).order_by('paixu', 'id')
)
zone_ids = [z.id for z in src_zones]
src_products = []
if zone_ids:
src_products = list(
Shangpin.query.filter(
club_id=from_club,
leixing_id=leixing.id,
zhuanqu_id__in=zone_ids,
shenhezhuangtai=1,
shangjia_zhuangtai=True,
dianpu__isnull=True,
).order_by('paixu', 'id')
)
# 计划Config
cfg_existing = ClubShangpinLeixingConfig.query.filter(
club_id=to_club, leixing_id=leixing.id,
).first()
if cfg_existing is None:
cfg_action = 'CREATE_ENABLE'
elif not cfg_existing.is_enabled:
cfg_action = 'ENABLE_EXISTING'
else:
cfg_action = 'SKIP_ALREADY_ENABLED'
# 计划:专区
zone_plans = []
for z in src_zones:
exist = ShangpinZhuanqu.query.filter(
club_id=to_club,
leixing_id=leixing.id,
mingzi=z.mingzi,
dianpu__isnull=True,
).first()
if exist:
zone_plans.append({
'source': z,
'action': 'SKIP',
'target_id': exist.id,
'new_tupian_url': exist.tupian_url or '',
})
else:
zone_plans.append({
'source': z,
'action': 'CREATE',
'target_id': None,
'new_tupian_url': None,
})
zone_by_src_id = {p['source'].id: p for p in zone_plans}
product_plans = []
for p in src_products:
zp = zone_by_src_id.get(p.zhuanqu_id)
if not zp:
product_plans.append({
'source': p,
'action': 'SKIP_NO_ZONE',
'reason': f'zhuanqu_id={p.zhuanqu_id} 不在本批三角洲专区',
})
continue
planned_zq_id = zp.get('target_id')
if planned_zq_id:
exist_p = Shangpin.query.filter(
club_id=to_club,
zhuanqu_id=planned_zq_id,
biaoqian=p.biaoqian,
dianpu__isnull=True,
).first()
if exist_p:
product_plans.append({
'source': p,
'action': 'SKIP',
'zone_plan': zp,
'target_zhuanqu_id': planned_zq_id,
})
continue
product_plans.append({
'source': p,
'action': 'CREATE',
'zone_plan': zp,
'target_zhuanqu_id': planned_zq_id,
})
# —— 打印确认清单 ——
mode = 'APPLY' if apply else 'DRY-RUN'
self.stdout.write(self.style.NOTICE(
f'\n===== {mode} =====\n'
f'源: {from_club} ({src_club.name}) → 目标: {to_club} ({dst_club.name})\n'
f'类型: jieshao={leixing.jieshao!r} id={leixing.id}(全局复用,不新建)\n'
f'ClubShangpinLeixingConfig: {cfg_action}\n'
f'源专区={len(src_zones)} 源上架商品={len(src_products)}\n'
))
self.stdout.write('—— 专区计划 ——')
for i, zp in enumerate(zone_plans, 1):
z = zp['source']
self.stdout.write(
f' [{i}] {zp["action"]} src_id={z.id} mingzi={z.mingzi!r} '
f'paixu={z.paixu} tupian={z.tupian_url or "-"}'
+ (f' → existing_id={zp["target_id"]}' if zp['target_id'] else '')
)
self.stdout.write('—— 商品计划 ——')
for i, pp in enumerate(product_plans, 1):
p = pp['source']
zname = ''
zp = pp.get('zone_plan')
if zp:
zname = zp['source'].mingzi
self.stdout.write(
f' [{i}] {pp["action"]} src_id={p.id} {p.biaoqian!r} '
f'¥{p.jiage} zone={zname!r} '
f'tupian={p.tupian_url or "-"} guize={p.guize_tupian or "-"}'
+ (f' reason={pp.get("reason")}' if pp.get('reason') else '')
)
stats_preview = {
'cfg': cfg_action,
'zones_create': sum(1 for x in zone_plans if x['action'] == 'CREATE'),
'zones_skip': sum(1 for x in zone_plans if x['action'] == 'SKIP'),
'products_create': sum(1 for x in product_plans if x['action'] == 'CREATE'),
'products_skip': sum(1 for x in product_plans if x['action'] == 'SKIP'),
'products_skip_no_zone': sum(1 for x in product_plans if x['action'] == 'SKIP_NO_ZONE'),
}
self.stdout.write(self.style.NOTICE(f'\n统计预览: {stats_preview}'))
if not apply:
self.stdout.write(self.style.WARNING(
'\n以上仅计划,未写库、未拷 OSS。\n'
'确认无误后执行:\n'
f' python manage.py copy_ufo_sanjiaozhou_catalog '
f'--to-club={to_club} --apply --i-confirm-to-club={to_club}\n'
))
return
# —— APPLY先 OSS再 DB 事务 ——
def dest_key(category: str, src_url: str) -> str:
ext = 'png'
raw = (src_url or '').split('?', 1)[0]
if '.' in raw.rsplit('/', 1)[-1]:
ext = raw.rsplit('.', 1)[-1].lower()[:8] or 'png'
ts = int(time.time() * 1000)
rand = uuid.uuid4().hex[:8]
return f'club/{to_club}/catalog/{category}/{ts}_{rand}.{ext}'
def copy_img(src_url: str, category: str) -> str:
if not src_url:
return ''
# 已是相对路径或 URL物理复制到新 key
key = dest_key(category, src_url)
if not copy_in_oss(src_url, key):
raise CommandError(f'OSS 复制失败,已中止(未写库): {src_url}{key}')
return key
# 预复制专区/商品图(仅 CREATE
oss_ok = 0
for zp in zone_plans:
if zp['action'] != 'CREATE':
continue
z = zp['source']
if z.tupian_url:
zp['new_tupian_url'] = copy_img(z.tupian_url, 'zhuanqu')
oss_ok += 1
else:
zp['new_tupian_url'] = ''
for pp in product_plans:
if pp['action'] != 'CREATE':
continue
p = pp['source']
pp['new_tupian_url'] = copy_img(p.tupian_url, 'shangpin') if p.tupian_url else ''
if p.tupian_url:
oss_ok += 1
if p.guize_tupian:
pp['new_guize_tupian'] = copy_img(p.guize_tupian, 'guize')
oss_ok += 1
else:
pp['new_guize_tupian'] = ''
stats = {
'cfg_created': 0,
'cfg_enabled': 0,
'zones_created': 0,
'zones_skipped': 0,
'products_created': 0,
'products_skipped': 0,
'products_skipped_no_zone': 0,
'oss_copied': oss_ok,
}
with transaction.atomic():
cfg, created = ClubShangpinLeixingConfig.query.get_or_create(
club_id=to_club,
leixing_id=leixing.id,
defaults={
'is_enabled': True,
'paixu': int(leixing.paixu or 0),
'tupian_url': '',
},
)
if created:
stats['cfg_created'] = 1
elif not cfg.is_enabled:
cfg.is_enabled = True
cfg.save(update_fields=['is_enabled', 'UpdateTime'])
stats['cfg_enabled'] = 1
# 专区 insert + 映射
zone_id_map = {} # src_id -> dst_id
for zp in zone_plans:
z = zp['source']
if zp['action'] == 'SKIP':
zone_id_map[z.id] = zp['target_id']
stats['zones_skipped'] += 1
continue
# 事务内再查一次,防并发重复
exist = ShangpinZhuanqu.query.filter(
club_id=to_club,
leixing_id=leixing.id,
mingzi=z.mingzi,
dianpu__isnull=True,
).first()
if exist:
zone_id_map[z.id] = exist.id
stats['zones_skipped'] += 1
continue
row = ShangpinZhuanqu.query.create(
mingzi=z.mingzi,
leixing_id=leixing.id,
club_id=to_club,
tupian_url=zp.get('new_tupian_url') or '',
shenhezhuangtai=1,
paixu=int(z.paixu or 0),
shangjia_zhuangtai=bool(getattr(z, 'shangjia_zhuangtai', True)),
fengjin_zhuangtai=bool(getattr(z, 'fengjin_zhuangtai', True)),
dianpu=None,
dianpu_leixing=None,
)
zone_id_map[z.id] = row.id
zp['target_id'] = row.id
stats['zones_created'] += 1
for pp in product_plans:
p = pp['source']
if pp['action'] == 'SKIP_NO_ZONE':
stats['products_skipped_no_zone'] += 1
continue
if pp['action'] == 'SKIP':
stats['products_skipped'] += 1
continue
dst_zq = zone_id_map.get(p.zhuanqu_id)
if not dst_zq:
raise CommandError(
f'商品「{p.biaoqian}」src_id={p.id} 无法映射 zhuanqu_id={p.zhuanqu_id}'
)
exist_p = Shangpin.query.filter(
club_id=to_club,
zhuanqu_id=dst_zq,
biaoqian=p.biaoqian,
dianpu__isnull=True,
).first()
if exist_p:
stats['products_skipped'] += 1
continue
Shangpin.query.create(
biaoqian=p.biaoqian,
jiage=p.jiage,
kucun=p.kucun,
leixing_id=leixing.id,
zhuanqu_id=dst_zq,
zhenshi_xiaoliang=0,
duiwai_xiaoliang=0,
jieshao=p.jieshao,
xiadan_xuzhi=p.xiadan_xuzhi,
guize_tupian=pp.get('new_guize_tupian') or '',
yaoqiuleixing=p.yaoqiuleixing,
huiyuan_id=p.huiyuan_id,
yongjin=p.yongjin,
kaioi_ewai_dashou_fencheng=bool(p.kaioi_ewai_dashou_fencheng),
ewai_dashou_fencheng=p.ewai_dashou_fencheng,
tupian_url=pp.get('new_tupian_url') or '',
paixu=int(p.paixu or 0),
shenhezhuangtai=1,
shangjia_zhuangtai=True,
fengjin_zhuangtai=bool(p.fengjin_zhuangtai),
shenhe_zhuangtai=bool(getattr(p, 'shenhe_zhuangtai', False)),
club_id=to_club,
shi_zhuanpan=bool(getattr(p, 'shi_zhuanpan', False)),
dianpu=None,
dianpu_leixing=None,
)
stats['products_created'] += 1
self.stdout.write(self.style.SUCCESS(f'\n写入完成: {stats}'))
self.stdout.write(
'说明:未复制 Zhuanpan*;全局 ShangpinLeixing 未改;源 UFO 数据未动。'
)
def _list_clubs(self, Club, only_hint=False):
qs = Club.query.order_by('club_id')
for c in qs:
name = c.name or ''
if only_hint and NAME_HINT not in name:
continue
mark = ' <<-- 龙先生候选' if NAME_HINT in name else ''
self.stdout.write(
f' club_id={c.club_id!r} name={name!r} status={c.status}{mark}'
)
if only_hint:
hits = [c for c in Club.query.order_by('club_id') if NAME_HINT in (c.name or '')]
if not hits:
self.stdout.write(self.style.WARNING(' (无名称含「龙先生」的俱乐部)'))
def _resolve_leixing(self, ShangpinLeixing, leixing_id: int, leixing_name: str):
if leixing_id:
row = ShangpinLeixing.query.filter(id=leixing_id).first()
if not row:
raise CommandError(f'找不到 ShangpinLeixing id={leixing_id}')
if (row.jieshao or '') != leixing_name:
raise CommandError(
f'安全拦截:--leixing-id={leixing_id} 的名称是「{row.jieshao}」,'
f'与要求的精确名「{leixing_name}」不一致。'
)
return row
matches = list(ShangpinLeixing.query.filter(jieshao=leixing_name))
if not matches:
# 列出相近提示
self.stdout.write('全局商品类型清单:')
for lx in ShangpinLeixing.query.order_by('id'):
self.stdout.write(f' id={lx.id} name={lx.jieshao!r}')
raise CommandError(
f'未找到全局类型全名精确匹配「{leixing_name}」。'
f'请改后台类型名或 --leixing-id=... 指定。'
)
if len(matches) > 1:
ids = [m.id for m in matches]
raise CommandError(
f'全局类型「{leixing_name}」匹配到多条 id={ids},拒绝猜测。'
f'请用 --leixing-id=... 指定唯一一条。'
)
return matches[0]

View File

@@ -0,0 +1,427 @@
"""仅清理「龙先生电竞」(club_id=lsx) 的配置;绝对不碰其他俱乐部。
安全规则(硬约束):
1. 只允许操作 club_id=lsx或显式传入且必须等于 lsx
2. 若商户号命中了非 lsx 俱乐部:只打印警告,绝不删除
3. 默认 dry-run / --precheck只列出将删内容与条数不写库
4. --apply 必须再带 --i-understand-lsx-only 才真正删除
用法:
# 预检(推荐先跑这个,把输出发给你确认)
python manage.py delete_club_by_mch_id 17480975501 --precheck
# 确认无误后再删(双确认)
python manage.py delete_club_by_mch_id 17480975501 --apply --i-understand-lsx-only
"""
from __future__ import annotations
import os
import shutil
from django.core.management.base import BaseCommand, CommandError
from django.db import connection, transaction
from django.db.models import Q
from jituan.models import (
AdminAssignment,
Club,
ClubDashouExamConfig,
ClubDashouExamPass,
ClubDashouExamQuestion,
ClubDashouExamQuestionImage,
ClubFadanFenhongLilv,
ClubHuiyuanBundleInclude,
ClubHuiyuanPrice,
ClubKaoheChenghaoConfig,
ClubLilubiao,
ClubPaymentChannel,
ClubShangpinLeixingConfig,
ClubTixianQuota,
ClubWithdrawConfig,
IdentityTag,
IdentityTagBind,
PayChannelLedger,
UserWxOpenid,
)
from jituan.services.club_cert_upload import club_wx_cert_root
try:
from jituan.models import AdminAuditLog
except Exception:
AdminAuditLog = None
try:
from config.models import (
ClubMiniappIcon,
ClubPindao,
ClubPindaoImage,
Gonggao,
Lunbo,
MiniappScriptAutoReply,
MiniappScriptScene,
PopupPage,
Szjilu,
Tupianpeizhi,
)
except Exception:
Lunbo = Gonggao = Tupianpeizhi = PopupPage = Szjilu = None
ClubMiniappIcon = ClubPindao = ClubPindaoImage = None
MiniappScriptScene = MiniappScriptAutoReply = None
# 本命令唯一允许删除的俱乐部
ALLOWED_CLUB_ID = 'lsx'
ALLOWED_CLUB_NAME_HINT = '龙先生'
# ORM 配置模型(均按 club_id 过滤)
ORM_MODELS = [
('支付通道 ClubPaymentChannel', ClubPaymentChannel),
('会员价 ClubHuiyuanPrice', ClubHuiyuanPrice),
('会员套餐 ClubHuiyuanBundleInclude', ClubHuiyuanBundleInclude),
('利率 ClubLilubiao', ClubLilubiao),
('提现配置 ClubWithdrawConfig', ClubWithdrawConfig),
('提现配额 ClubTixianQuota', ClubTixianQuota),
('商品类型上架 ClubShangpinLeixingConfig', ClubShangpinLeixingConfig),
('考核称号 ClubKaoheChenghaoConfig', ClubKaoheChenghaoConfig),
('发单分红利率 ClubFadanFenhongLilv', ClubFadanFenhongLilv),
('接单考试配置 ClubDashouExamConfig', ClubDashouExamConfig),
('接单考试题图 ClubDashouExamQuestionImage', ClubDashouExamQuestionImage),
('接单考试题 ClubDashouExamQuestion', ClubDashouExamQuestion),
('接单考试通过 ClubDashouExamPass', ClubDashouExamPass),
('身份标签绑定 IdentityTagBind', IdentityTagBind),
('身份标签 IdentityTag', IdentityTag),
('支付通道流水 PayChannelLedger', PayChannelLedger),
('OpenID绑定 UserWxOpenid', UserWxOpenid),
('客服任职 AdminAssignment', AdminAssignment),
('审计日志 AdminAuditLog', AdminAuditLog),
('轮播 Lunbo', Lunbo),
('公告 Gonggao', Gonggao),
('图片配置 Tupianpeizhi', Tupianpeizhi),
('弹窗页 PopupPage', PopupPage),
('收支记录配置 Szjilu', Szjilu),
('小程序图标 ClubMiniappIcon', ClubMiniappIcon),
('频道图 ClubPindaoImage', ClubPindaoImage),
('频道 ClubPindao', ClubPindao),
('话术场景 MiniappScriptScene', MiniappScriptScene),
('话术自动回复 MiniappScriptAutoReply', MiniappScriptAutoReply),
]
# SQL 兜底表(仅 DELETE WHERE club_id=%s绝不会无 club_id 条件)
SQL_TABLES = [
'club_payment_channel',
'club_huiyuan_price',
'club_huiyuan_bundle_include',
'club_lilubiao',
'club_withdraw_config',
'club_tixian_quota',
'club_shangpin_leixing_config',
'club_kaohe_chenghao_config',
'club_fadan_fenhong_lilv',
'club_dashou_exam_config',
'club_dashou_exam_question',
'club_dashou_exam_question_image',
'club_dashou_exam_pass',
'identity_tag_bind',
'identity_tag',
'pay_channel_ledger',
'user_wx_openid',
'admin_assignment',
'admin_audit_log',
'lunbo',
'gonggao',
'tupianpeizhi',
'popup_page',
'szjilu',
'club_miniapp_icon',
'club_pindao',
'club_pindao_image',
'miniapp_script_scene',
'miniapp_script_auto_reply',
'daily_income_stat',
'daily_payout_stat',
'platform_income_log',
]
# 明确不删(业务数据)
NEVER_DELETE_NOTE = [
'User 用户主表 / 各身份扩展表(打手商家等)',
'订单 Order / 退款 / 处罚等业务流水',
'商品 Shangpin若 club_id=lsx 有商品也不会在本命令删;如需另开命令)',
'其他俱乐部 xq / xzj / ufo 等任何数据',
]
def _count_model(model, club_id):
if model is None:
return None
try:
return model.query.filter(club_id=club_id).count()
except Exception:
return None
def _assert_lsx_only(club_id: str):
cid = (club_id or '').strip()
if cid != ALLOWED_CLUB_ID:
raise CommandError(
f'安全拦截:本命令只允许操作 club_id={ALLOWED_CLUB_ID}(龙先生电竞),'
f'拒绝 club_id={cid!r}'
)
def _club_snapshot(club: Club):
return {
'club_id': club.club_id,
'name': club.name,
'status': club.status,
'wx_appid': club.wx_appid or '',
'official_appid': club.official_appid or '',
'official_secret_set': bool((club.official_secret or '').strip()),
'official_token_set': bool((club.official_token or '').strip()),
'encoding_aes_key_set': bool((club.encoding_aes_key or '').strip()),
'template_id': club.template_id or '',
'mch_id': club.mch_id or '',
'withdraw_mch_id': getattr(club, 'withdraw_mch_id', '') or '',
'pay_app_id': club.pay_app_id or '',
'pay_key_path': club.pay_key_path or '',
'pay_cert_path': club.pay_cert_path or '',
'private_key_path': club.private_key_path or '',
'platform_cert_dir': club.platform_cert_dir or '',
'goeasy_appkey_set': bool((club.goeasy_appkey or '').strip()),
'h5_domain': club.h5_domain or '',
'oss_prefix': club.oss_prefix or '',
'mini_pay_channel': getattr(club, 'mini_pay_channel', '') or '',
}
def _find_other_clubs_with_mch(mch_id: str, exclude_club_id: str):
"""商户号撞到其他俱乐部时列出,绝不删除它们。"""
others = list(
Club.query.filter(
Q(mch_id=mch_id) | Q(withdraw_mch_id=mch_id)
).exclude(club_id=exclude_club_id)
)
ch_club_ids = (
ClubPaymentChannel.query.filter(mch_id=mch_id)
.exclude(club_id=exclude_club_id)
.values_list('club_id', flat=True)
.distinct()
)
for cid in ch_club_ids:
if not any(c.club_id == cid for c in others):
c = Club.query.filter(club_id=cid).first()
if c:
others.append(c)
return others
def _precheck_report(stdout, style, club: Club, mch_id: str):
snap = _club_snapshot(club)
stdout.write(style.SUCCESS('=' * 64))
stdout.write(style.SUCCESS('【预检】仅针对龙先生电竞 club_id=lsx'))
stdout.write(style.SUCCESS('=' * 64))
stdout.write(f'请求商户号: {mch_id or "(未指定,仅按 lsx 清理)"}')
stdout.write('')
stdout.write('—— 俱乐部主表字段 ——')
for k, v in snap.items():
stdout.write(f' {k}: {v if v != "" else "(空)"}')
if mch_id:
club_mch = (club.mch_id or '').strip()
club_wm = (getattr(club, 'withdraw_mch_id', '') or '').strip()
ch_hit = ClubPaymentChannel.query.filter(club_id=ALLOWED_CLUB_ID, mch_id=mch_id).count()
matched = club_mch == mch_id or club_wm == mch_id or ch_hit > 0
if not matched:
stdout.write(style.WARNING(
f'\n注意lsx 当前 mch_id={club_mch or "(空)"} / withdraw={club_wm or "(空)"} / '
f'通道命中={ch_hit},与请求商户号 {mch_id} 不完全一致。'
f'本命令仍只清理 lsx不会动其他俱乐部。'
))
else:
stdout.write(style.SUCCESS(f'\n商户号匹配确认lsx 绑定了 {mch_id}'))
others = _find_other_clubs_with_mch(mch_id, ALLOWED_CLUB_ID)
if others:
stdout.write(style.ERROR(
f'\n!! 其他俱乐部也配置了商户号 {mch_id}(只警告,绝对不删):'
))
for o in others:
stdout.write(
f' - KEEP {o.club_id} ({o.name}) mch={o.mch_id} withdraw={getattr(o, "withdraw_mch_id", "")}'
)
else:
stdout.write('其他俱乐部未发现同商户号(安全)。')
stdout.write('\n—— 将删除的配置表(仅 club_id=lsx——')
total = 0
for label, model in ORM_MODELS:
n = _count_model(model, ALLOWED_CLUB_ID)
if n is None:
stdout.write(f' [跳过不可用] {label}')
continue
mark = '将删' if n else ''
stdout.write(f' [{mark}] {label}: {n}')
total += n or 0
stdout.write('\n—— SQL 兜底表计数(仅 WHERE club_id=lsx——')
with connection.cursor() as cursor:
for table in SQL_TABLES:
try:
cursor.execute(
f'SELECT COUNT(*) FROM `{table}` WHERE club_id=%s',
[ALLOWED_CLUB_ID],
)
n = cursor.fetchone()[0]
stdout.write(f' [{("将删" if n else "")}] {table}: {n}')
total += n
except Exception as exc:
stdout.write(f' [无此表/跳过] {table}: {exc}')
cert_dir = os.path.join(club_wx_cert_root(), ALLOWED_CLUB_ID)
stdout.write('\n—— 证书目录 ——')
if os.path.isdir(cert_dir):
file_n = 0
for _root, _dirs, files in os.walk(cert_dir):
file_n += len(files)
stdout.write(f' [将删目录] {cert_dir} (约 {file_n} 个文件)')
else:
stdout.write(f' [无目录] {cert_dir}')
stdout.write('\n—— 明确不删 ——')
for tip in NEVER_DELETE_NOTE:
stdout.write(f' · {tip}')
stdout.write('')
stdout.write(style.SUCCESS(f'预检合计(配置行粗略合计,含重复计数可能):约 {total}'))
stdout.write(style.WARNING(
'确认无误后再执行:\n'
f' python manage.py delete_club_by_mch_id {mch_id or "17480975501"} '
'--apply --i-understand-lsx-only'
))
stdout.write(style.SUCCESS('=' * 64))
def _delete_lsx(stdout, style, remove_cert_dir: bool):
_assert_lsx_only(ALLOWED_CLUB_ID)
club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first()
if not club:
raise CommandError(f'俱乐部 {ALLOWED_CLUB_ID} 不存在,无需删除')
# 名称再确认一层(非强制阻断,只警告)
if ALLOWED_CLUB_NAME_HINT not in (club.name or ''):
stdout.write(style.WARNING(
f'警告:{ALLOWED_CLUB_ID} 的 name={club.name!r} 不含「{ALLOWED_CLUB_NAME_HINT}」,'
f'但仍按 club_id=lsx 删除(主键约束)。'
))
with transaction.atomic():
for label, model in ORM_MODELS:
if model is None:
continue
n = model.query.filter(club_id=ALLOWED_CLUB_ID).count()
if n:
model.query.filter(club_id=ALLOWED_CLUB_ID).delete()
stdout.write(f' - 已删 {label}: {n}')
with connection.cursor() as cursor:
for table in SQL_TABLES:
try:
cursor.execute(
f'SELECT COUNT(*) FROM `{table}` WHERE club_id=%s',
[ALLOWED_CLUB_ID],
)
n = cursor.fetchone()[0]
if n:
cursor.execute(
f'DELETE FROM `{table}` WHERE club_id=%s',
[ALLOWED_CLUB_ID],
)
stdout.write(f' - SQL 已删 {table}: {n}')
except Exception:
pass
# 最后删主表(再次校验主键)
deleted, _ = Club.query.filter(club_id=ALLOWED_CLUB_ID).delete()
stdout.write(style.SUCCESS(f' - 已删 Club 主表 lsx (affected={deleted})'))
if remove_cert_dir:
cert_dir = os.path.join(club_wx_cert_root(), ALLOWED_CLUB_ID)
if os.path.isdir(cert_dir):
shutil.rmtree(cert_dir, ignore_errors=True)
stdout.write(style.SUCCESS(f' - 已删证书目录 {cert_dir}'))
class Command(BaseCommand):
help = '【仅 lsx/龙先生】按商户号预检/删除俱乐部配置;绝不碰其他俱乐部'
def add_arguments(self, parser):
parser.add_argument(
'mch_id',
nargs='?',
default='17480975501',
help='用于核对的商户号(默认 17480975501删除范围仍固定为 lsx',
)
parser.add_argument(
'--precheck',
action='store_true',
help='只预检列出将删内容(默认行为也是预检)',
)
parser.add_argument('--apply', action='store_true', help='真正删除(需配合确认开关)')
parser.add_argument(
'--i-understand-lsx-only',
action='store_true',
help='确认仅删除龙先生 lsx不涉及其他俱乐部',
)
parser.add_argument(
'--keep-cert-dir',
action='store_true',
help='保留磁盘 wx_certs/lsx 目录',
)
parser.add_argument(
'--club-id',
type=str,
default=ALLOWED_CLUB_ID,
help='必须为 lsx传其他值会直接拒绝',
)
def handle(self, *args, **options):
club_id = (options.get('club_id') or ALLOWED_CLUB_ID).strip()
_assert_lsx_only(club_id)
mch_id = str(options.get('mch_id') or '').strip()
apply = bool(options.get('apply'))
confirm = bool(options.get('i_understand_lsx_only'))
# 默认/显式 precheck 都走预检;只有 apply+确认才删除
do_delete = apply and confirm
club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first()
if not club:
raise CommandError(
f'未找到 club_id={ALLOWED_CLUB_ID}。不会删除任何其他俱乐部。'
)
_precheck_report(self.stdout, self.style, club, mch_id)
if not do_delete:
if apply and not confirm:
self.stdout.write(self.style.ERROR(
'\n已拒绝删除:缺少 --i-understand-lsx-only防止误删'
))
self.stdout.write(self.style.WARNING('\n当前仅预检,数据库未改动。'))
return
# 删除前再拦一道:其他俱乐部同商户号只警告
others = _find_other_clubs_with_mch(mch_id, ALLOWED_CLUB_ID) if mch_id else []
if others:
self.stdout.write(self.style.ERROR(
'其他俱乐部存在同商户号配置,本命令仍只删 lsx下列俱乐部保持不动'
))
for o in others:
self.stdout.write(f' KEEP {o.club_id}')
self.stdout.write(self.style.WARNING('\n开始删除(仅 lsx...'))
_delete_lsx(
self.stdout,
self.style,
remove_cert_dir=not bool(options.get('keep_cert_dir')),
)
self.stdout.write(self.style.SUCCESS('\n完成:仅龙先生 lsx 配置已清理。'))

View File

@@ -0,0 +1,178 @@
# -*- coding: utf-8 -*-
"""
删除 UFO 俱乐部「正常/公共」商品目录(专区 + 商品)及对应 OSS 图片。
范围(必须全部满足,缺一不删):
- club_id 固定为 ufo不可改目标
- dianpu IS NULL公共目录店铺商品一律不碰
- 表shangpin、shangpin_zhuanqu
- OSS商品 tupian_url / guize_tupian、专区 tupian_url
不碰:
- 其它俱乐部xq / lxs / xzj 等)
- 店铺商品dianpu 非空)
- 全局 ShangpinLeixing
- ClubShangpinLeixingConfig、订单、轮播、转盘、统计等
用法:
python manage.py delete_ufo_public_catalog
python manage.py delete_ufo_public_catalog --apply --i-confirm-club=ufo
"""
from __future__ import annotations
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
ALLOWED_CLUB_ID = 'ufo'
NAME_HINT = 'UFO' # Club.name 须含此字样(大小写不敏感)
class Command(BaseCommand):
help = '删除 UFO 公共商品+专区及 OSS 图(默认 dry-run仅 ufo'
def add_arguments(self, parser):
parser.add_argument(
'--apply',
action='store_true',
help='真正删库 + 删 OSS默认仅打印清单',
)
parser.add_argument(
'--i-confirm-club',
type=str,
default='',
help='必须等于 ufo才允许 --apply',
)
parser.add_argument(
'--club-id',
type=str,
default=ALLOWED_CLUB_ID,
help='仅允许 ufo传其它值会直接拒绝',
)
def handle(self, *args, **options):
from jituan.models import Club
from products.models import Shangpin, ShangpinZhuanqu
from utils.oss_utils import delete_from_oss
club_id = (options.get('club_id') or '').strip()
apply = bool(options.get('apply'))
confirm = (options.get('i_confirm_club') or '').strip()
if club_id != ALLOWED_CLUB_ID:
raise CommandError(
f'安全拦截:本命令只允许 club_id={ALLOWED_CLUB_ID!r},你传的是 {club_id!r}'
)
club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first()
if not club:
raise CommandError(f'俱乐部 {ALLOWED_CLUB_ID} 不存在,无需删除')
if NAME_HINT.lower() not in (club.name or '').lower():
raise CommandError(
f'安全拦截club_id={ALLOWED_CLUB_ID!r} 的 name={club.name!r} '
f'不含「{NAME_HINT}」,拒绝执行。'
)
if apply and confirm != ALLOWED_CLUB_ID:
raise CommandError(
'安全拦截:--apply 必须同时传 --i-confirm-club=ufo\n'
'python manage.py delete_ufo_public_catalog --apply --i-confirm-club=ufo'
)
zones = list(
ShangpinZhuanqu.query.filter(
club_id=ALLOWED_CLUB_ID,
dianpu__isnull=True,
).order_by('id')
)
products = list(
Shangpin.query.filter(
club_id=ALLOWED_CLUB_ID,
dianpu__isnull=True,
).order_by('id')
)
oss_urls = []
for z in zones:
if z.tupian_url:
oss_urls.append(('zone', z.id, z.tupian_url))
for p in products:
if p.tupian_url:
oss_urls.append(('product', p.id, p.tupian_url))
if p.guize_tupian:
oss_urls.append(('product_guize', p.id, p.guize_tupian))
mode = 'APPLY' if apply else 'DRY-RUN'
self.stdout.write(self.style.NOTICE(
f'\n===== {mode} 删除 UFO 公共商品目录 =====\n'
f'club_id={ALLOWED_CLUB_ID} name={club.name!r}\n'
f'公共专区={len(zones)} 公共商品={len(products)} '
f'待删 OSS 路径={len(oss_urls)}\n'
))
self.stdout.write('—— 专区 ——')
for z in zones[:50]:
self.stdout.write(
f' id={z.id} mingzi={z.mingzi!r} leixing_id={z.leixing_id} '
f'tupian={z.tupian_url or "-"}'
)
if len(zones) > 50:
self.stdout.write(f' ... 其余 {len(zones) - 50} 个专区省略')
self.stdout.write('—— 商品 ——')
for p in products[:80]:
self.stdout.write(
f' id={p.id} {p.biaoqian!r} ¥{p.jiage} '
f'zhuanqu_id={p.zhuanqu_id} tupian={p.tupian_url or "-"}'
)
if len(products) > 80:
self.stdout.write(f' ... 其余 {len(products) - 80} 个商品省略')
self.stdout.write(f'—— OSS 将删 {len(oss_urls)} 个对象(相对路径)——')
for kind, oid, url in oss_urls[:30]:
self.stdout.write(f' [{kind}#{oid}] {url}')
if len(oss_urls) > 30:
self.stdout.write(f' ... 其余 {len(oss_urls) - 30} 个省略')
if not apply:
self.stdout.write(self.style.WARNING(
'\n以上仅计划,未删库、未删 OSS。\n'
'确认无误后:\n'
' python manage.py delete_ufo_public_catalog --apply --i-confirm-club=ufo\n'
))
return
if not zones and not products:
self.stdout.write(self.style.SUCCESS('无公共专区/商品,无需删除。'))
return
with transaction.atomic():
# 再按条件删一遍,避免误伤其它 club / 店铺商品
n_prod, _ = Shangpin.query.filter(
club_id=ALLOWED_CLUB_ID,
dianpu__isnull=True,
).delete()
n_zone, _ = ShangpinZhuanqu.query.filter(
club_id=ALLOWED_CLUB_ID,
dianpu__isnull=True,
).delete()
oss_ok = 0
oss_fail = 0
seen = set()
for _, _, url in oss_urls:
key = (url or '').strip()
if not key or key in seen:
continue
seen.add(key)
if delete_from_oss(key):
oss_ok += 1
else:
oss_fail += 1
self.stderr.write(f'OSS 删除失败(已记日志,继续): {key}')
self.stdout.write(self.style.SUCCESS(
f'\n删除完成: products={n_prod} zones={n_zone} '
f'oss_ok={oss_ok} oss_fail={oss_fail}\n'
f'未动:其它俱乐部、店铺商品、全局类型、订单/转盘等。'
))

View File

@@ -0,0 +1,127 @@
"""诊断 / 对齐龙先生邀请种子与小程序 club_id。
用法:
python manage.py diagnose_lsx_invite
python manage.py diagnose_lsx_invite --align-to lsx
python manage.py diagnose_lsx_invite --align-to-appid
"""
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from jituan.models import Club
from users.business_models import User
from users.models import UserGuanshi, UserZuzhang
LSX_APPID = 'wx7ff90e9d024fcdb8'
SEED_UIDS = ('9910001', '9910002')
FRONTEND_EXPECT = 'lsx'
class Command(BaseCommand):
help = '诊断龙先生种子邀请人 ClubID 与小程序是否一致,并可对齐'
def add_arguments(self, parser):
parser.add_argument(
'--align-to',
type=str,
default='',
help='把种子用户 ClubID 改成指定值(如 lsx',
)
parser.add_argument(
'--align-to-appid',
action='store_true',
help='按 wx_appid=龙先生 找到俱乐部,把种子 ClubID 对齐过去',
)
def handle(self, *args, **options):
self.stdout.write(self.style.SUCCESS('=' * 60))
self.stdout.write('前端龙先生小程序默认 X-Club-Id / CLUB_ID = lsx')
self.stdout.write(f'龙先生 AppID = {LSX_APPID}')
self.stdout.write('')
clubs = list(Club.query.order_by('club_id'))
self.stdout.write(f'库里俱乐部 {len(clubs)} 个:')
target_by_app = None
for c in clubs:
mark = ''
if c.club_id == FRONTEND_EXPECT:
mark = ' ← 前端期望'
if (c.wx_appid or '') == LSX_APPID:
mark += ' ← AppID命中'
target_by_app = c
if '龙先生' in (c.name or ''):
mark += ' ← 名称命中'
self.stdout.write(
f' club_id={c.club_id!r} name={c.name!r} wx_appid={c.wx_appid or "(空)"}{mark}'
)
self.stdout.write('')
self.stdout.write('种子用户:')
for uid in SEED_UIDS:
user = User.query.filter(UserUID=uid).first()
if not user:
self.stdout.write(self.style.WARNING(f' {uid}: 不存在(还没跑 seed_lsx_invite_data'))
continue
role = []
try:
z = user.ZuzhangProfile
role.append(f'组长码={z.yaoqingma}')
except Exception:
pass
try:
g = user.GuanshiProfile
role.append(f'管事码={g.yaoqingma}')
except Exception:
pass
club = (user.ClubID or '').strip()
ok = club == FRONTEND_EXPECT
line = f' {uid}: ClubID={club or "(空)"} {" | ".join(role) or "无身份"}'
if ok:
self.stdout.write(self.style.SUCCESS(line + ' ✓ 与前端 lsx 一致'))
else:
self.stdout.write(self.style.ERROR(
line + f' ✗ 与前端 lsx 不一致 → 会报「邀请码不属于当前小程序俱乐部」'
))
self.stdout.write('')
self.stdout.write(self.style.WARNING(
'结论这是后端校验invite_guard不是前端邀请码输错。\n'
'小程序发 X-Club-Id=lsx邀请人 User.ClubID 必须也是 lsx。'
))
align_to = (options.get('align_to') or '').strip()
if options.get('align_to_appid'):
if not target_by_app:
raise CommandError(f'没有 wx_appid={LSX_APPID} 的俱乐部,无法对齐')
align_to = target_by_app.club_id
self.stdout.write(f'将按 AppID 对齐到 club_id={align_to!r}')
if not align_to:
self.stdout.write('')
self.stdout.write('若要修复,任选其一:')
self.stdout.write(' python manage.py diagnose_lsx_invite --align-to lsx')
self.stdout.write(' python manage.py diagnose_lsx_invite --align-to-appid')
self.stdout.write('然后前端 CLUB_ID 必须与种子 ClubID 相同。')
self.stdout.write(self.style.SUCCESS('=' * 60))
return
if not Club.query.filter(club_id=align_to).exists():
raise CommandError(f'目标俱乐部 {align_to!r} 不存在,请先在后台创建')
with transaction.atomic():
for uid in SEED_UIDS:
user = User.query.filter(UserUID=uid).first()
if not user:
self.stdout.write(self.style.WARNING(f'跳过不存在用户 {uid}'))
continue
old = user.ClubID
user.ClubID = align_to
user.save(update_fields=['ClubID'])
self.stdout.write(self.style.SUCCESS(
f'已更新 {uid}: ClubID {old!r}{align_to!r}'
))
self.stdout.write(self.style.SUCCESS(
f'\n对齐完成。请确认小程序 config/club-config.js 的 CLUB_ID = {align_to!r}'
))
self.stdout.write(self.style.SUCCESS('=' * 60))

View File

@@ -0,0 +1,177 @@
# -*- coding: utf-8 -*-
"""
仅导入 UFO 展示图到 Lunbo不碰商品。只写 club_id=ufo。
覆盖:
- accept_order 首页主轮播
- accept_order_banner2 首页第二横幅
- boss_center 「我的」顶区背景(原站 bg_img
默认 dry-run--apply 才写库。只删/写 ufo 对应 page_key不影响 xq/xzj。
cd /opt/1panel/apps/openresty/nginx/www/sites/43.142.166.152.443/django
git pull origin main
./venv/bin/python manage.py import_ufo_banners
./venv/bin/python manage.py import_ufo_banners --apply
"""
from __future__ import annotations
import json
import mimetypes
import os
import re
import ssl
import uuid
from datetime import datetime
from io import BytesIO
from pathlib import Path
from urllib.request import Request, urlopen
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
TARGET_CLUB = 'ufo'
DEFAULT_CATALOG = Path(__file__).resolve().parents[2] / 'data' / 'ufo_catalog.json'
# 原站「我的」顶区背景getUserCenterInfo.bg_imgcatalog 里没有时用这个兜底
BOSS_CENTER_FALLBACK = (
'https://ufo.qn.17v.tech/uploads/20251001/Fls0cQjtfsV7IUXl4cwqO6BFvPyh.jpg'
)
class Command(BaseCommand):
help = '导入 UFO 轮播 + 我的背景到 Lunbo仅 club=ufo'
def add_arguments(self, parser):
parser.add_argument('--apply', action='store_true')
parser.add_argument('--catalog', type=str, default='')
parser.add_argument('--skip-oss', action='store_true')
parser.add_argument('--club-id', type=str, default=TARGET_CLUB)
def handle(self, *args, **options):
club_id = (options.get('club_id') or '').strip()
if club_id != TARGET_CLUB:
raise CommandError(f'只允许 club_id={TARGET_CLUB}')
catalog_path = Path(options['catalog']).expanduser() if options['catalog'] else DEFAULT_CATALOG
if not catalog_path.is_file():
raise CommandError(f'找不到 {catalog_path}')
data = json.loads(catalog_path.read_text(encoding='utf-8'))
banners = list(data.get('banners') or [])
banners2 = list(data.get('banners2') or [])
boss = list(data.get('boss_center') or [])
if not boss:
# catalog 内嵌或 misc
misc = data.get('misc') or {}
bg = misc.get('bg_img') or BOSS_CENTER_FALLBACK
boss = [{'url': bg, 'kind': 'boss_center'}]
apply = bool(options['apply'])
skip_oss = bool(options['skip_oss'])
self.stdout.write(self.style.NOTICE(
f'club={TARGET_CLUB} accept_order={len(banners)} '
f'banner2={len(banners2)} boss_center={len(boss)} '
f'mode={"APPLY" if apply else "DRY-RUN"}'
))
for b in banners:
self.stdout.write(f' [主轮播] {b.get("url")}')
for b in banners2:
self.stdout.write(f' [第二横幅] {b.get("url")}')
for b in boss:
self.stdout.write(f' [我的背景] {b.get("url")}')
if not apply:
self.stdout.write(self.style.WARNING(
'dry-run。确认后\n ./venv/bin/python manage.py import_ufo_banners --apply'
))
return
from config.models import Lunbo
from jituan.models import Club
from jituan.services.display_config import (
LUNBO_PAGE_ACCEPT_BANNER2,
LUNBO_PAGE_ACCEPT_ORDER,
LUNBO_PAGE_BOSS_CENTER,
lunbo_image_type_for_page,
)
if not Club.query.filter(club_id=TARGET_CLUB).exists():
raise CommandError('俱乐部 ufo 不存在')
stats = {'ok': 0, 'fail': 0}
def to_oss(url: str) -> str:
if not url:
return ''
if skip_oss:
return url
if not str(url).startswith('http'):
return url
path = self._upload_remote(url)
if not path:
stats['fail'] += 1
raise CommandError(f'OSS 上传失败: {url}')
stats['ok'] += 1
return path
def replace_page(page_key, items):
image_type = lunbo_image_type_for_page(page_key)
# 只删本俱乐部本 page_key
deleted, _ = Lunbo.query.filter(
club_id=TARGET_CLUB,
page_key=page_key,
ImageType=image_type,
).delete()
self.stdout.write(f' clear {page_key} deleted={deleted}')
n = 0
for b in items:
url = to_oss(b.get('url') or b.get('path') or '')
if not url:
continue
Lunbo.query.create(
club_id=TARGET_CLUB,
page_key=page_key,
ImageURL=url,
ImageType=image_type,
)
n += 1
return n
with transaction.atomic():
n1 = replace_page(LUNBO_PAGE_ACCEPT_ORDER, banners)
n2 = replace_page(LUNBO_PAGE_ACCEPT_BANNER2, banners2)
n3 = replace_page(LUNBO_PAGE_BOSS_CENTER, boss)
self.stdout.write(self.style.SUCCESS(
f'完成仅 ufo主轮播={n1} 第二横幅={n2} 我的背景={n3} '
f'OSS成功={stats["ok"]} 失败={stats["fail"]}'
))
@staticmethod
def _upload_remote(url: str) -> str:
from utils.oss_utils import upload_to_oss
try:
ctx = ssl.create_default_context()
req = Request(url, headers={'User-Agent': 'UFO-Import/1.0'})
with urlopen(req, timeout=60, context=ctx) as resp:
raw = resp.read()
ctype = resp.headers.get('Content-Type') or ''
except Exception:
return ''
ext = mimetypes.guess_extension((ctype or '').split(';')[0].strip()) or ''
if not ext or ext == '.jpe':
m = re.search(r'\.(jpg|jpeg|png|gif|webp|bmp)(?:\?|$)', url, re.I)
ext = ('.' + m.group(1).lower()) if m else '.jpg'
if ext == '.jpeg':
ext = '.jpg'
ts = datetime.now().strftime('%Y%m%d%H%M%S')
rel = f"a_long/lunbo/{ts}_{uuid.uuid4().hex[:8]}{ext}"
bio = BytesIO(raw)
bio.name = os.path.basename(rel)
ok = upload_to_oss(bio, rel)
return rel if ok else ''

View File

@@ -0,0 +1,415 @@
# -*- coding: utf-8 -*-
"""
将原 UFO 站点商品目录灌入星阙,且只写 club_id=ufo。
默认 dry-run只打印计划不写库。真正写入必须加 --apply。
默认按全局类型名「三角洲」**全名精确匹配**找 id不含这三字的一律不要
找到唯一一条后:
1) ClubShangpinLeixingConfig 给 club=ufo 上架该类型
2) ShangpinZhuanqu / Shangpin 的 leixing_id 挂这个全局类型 id且 club_id=ufo
服务器上(复制整段):
cd /opt/1panel/apps/openresty/nginx/www/sites/43.142.166.152.443/django
git pull origin main
./venv/bin/python manage.py import_ufo_catalog
./venv/bin/python manage.py import_ufo_catalog --apply
./venv/bin/python manage.py import_ufo_catalog --apply --with-banners
"""
from __future__ import annotations
import json
import mimetypes
import os
import re
import ssl
import uuid
from decimal import Decimal, InvalidOperation
from io import BytesIO
from pathlib import Path
from urllib.request import Request, urlopen
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
TARGET_CLUB = 'ufo'
DEFAULT_LEIXING_NAME = '三角洲' # 全名精确匹配,不模糊
DEFAULT_CATALOG = Path(__file__).resolve().parents[2] / 'data' / 'ufo_catalog.json'
class Command(BaseCommand):
help = '导入 UFO 商品/专区到 club_id=ufo默认 dry-run类型名默认精确匹配「三角洲」'
def add_arguments(self, parser):
parser.add_argument('--apply', action='store_true', help='真正写库(仅 ufo')
parser.add_argument('--catalog', type=str, default='', help='catalog.json 路径')
parser.add_argument(
'--list-leixing',
action='store_true',
help='只列出全局商品类型 id/名称',
)
parser.add_argument(
'--leixing-id',
type=int,
default=0,
help='可选:直接指定全局类型主键;不传则按 --leixing-name 精确匹配',
)
parser.add_argument(
'--leixing-name',
type=str,
default=DEFAULT_LEIXING_NAME,
help='全局 ShangpinLeixing.jieshao 全名精确匹配,默认:三角洲',
)
parser.add_argument('--with-banners', action='store_true', help='同时导入点单主轮播+第二横幅')
parser.add_argument('--skip-oss', action='store_true', help='不上传 OSS保留原 URL应急')
parser.add_argument('--club-id', type=str, default=TARGET_CLUB, help='仅允许 ufo')
def handle(self, *args, **options):
from products.models import ShangpinLeixing
if options.get('list_leixing'):
self.stdout.write('全局商品类型清单ShangpinLeixing')
for lx in ShangpinLeixing.query.order_by('id'):
mark = ' <<-- 精确名=三角洲' if (lx.jieshao or '') == DEFAULT_LEIXING_NAME else ''
self.stdout.write(
f' id={lx.id} name={lx.jieshao!r} '
f'shenhezhuangtai={lx.shenhezhuangtai} paixu={lx.paixu}{mark}'
)
self.stdout.write(self.style.NOTICE(
'默认会精确匹配 name==「三角洲」;也可 --leixing-id=... 覆盖。\n'
'俱乐部上架表 ClubShangpinLeixingConfig 会在 --apply 时给 ufo 自动开启该类型。'
))
return
club_id = (options.get('club_id') or '').strip()
if club_id != TARGET_CLUB:
raise CommandError(
f'拒绝执行:只允许 club_id={TARGET_CLUB},你传的是 {club_id!r}'
)
catalog_path = Path(options['catalog']).expanduser() if options['catalog'] else DEFAULT_CATALOG
if not catalog_path.is_file():
raise CommandError(f'找不到目录文件: {catalog_path}')
data = json.loads(catalog_path.read_text(encoding='utf-8'))
products = data.get('products') or []
zones = data.get('zones') or []
banners = data.get('banners') or []
banners2 = data.get('banners2') or []
apply = bool(options['apply'])
with_banners = bool(options['with_banners'])
skip_oss = bool(options['skip_oss'])
from jituan.models import Club, ClubShangpinLeixingConfig
from products.models import Shangpin, ShangpinZhuanqu
if not Club.query.filter(club_id=TARGET_CLUB).exists():
raise CommandError(f'俱乐部 {TARGET_CLUB} 不存在,请先 create_club ufo')
leixing = self._resolve_leixing(
ShangpinLeixing,
leixing_id=int(options.get('leixing_id') or 0),
leixing_name=(options.get('leixing_name') or '').strip(),
)
self.stdout.write(self.style.NOTICE(
f'目标俱乐部={TARGET_CLUB} 类型={leixing.jieshao!r}(id={leixing.id}) '
f'shenhezhuangtai={leixing.shenhezhuangtai} '
f'专区={len(zones)} 商品={len(products)} banners={len(banners)}+{len(banners2)} '
f'mode={"APPLY" if apply else "DRY-RUN"}'
))
if leixing.shenhezhuangtai == 2:
raise CommandError(
f'类型 id={leixing.id}{leixing.jieshao}」是审核专用,拒绝挂载'
)
expect_name = (options.get('leixing_name') or DEFAULT_LEIXING_NAME).strip()
if (leixing.jieshao or '') != expect_name and not int(options.get('leixing_id') or 0):
raise CommandError(
f'安全拦截:解析到的类型名是「{leixing.jieshao}」,'
f'与要求的精确名「{expect_name}」不一致。'
)
self.stdout.write(
f'将写入ClubShangpinLeixingConfig(club=ufo, leixing_id={leixing.id}, is_enabled=True)'
f'专区/商品 leixing_id={leixing.id}, club_id=ufo'
)
for z in zones:
self.stdout.write(f' 专区: {z.get("name")} goods={len(z.get("goods_ids") or [])}')
for p in products[:5]:
self.stdout.write(
f' 商品样例: [{p.get("source_id")}] {p.get("title")} ¥{p.get("price")}{p.get("zone_name")}'
)
if len(products) > 5:
self.stdout.write(f' ... 其余 {len(products) - 5} 个商品省略')
if not apply:
self.stdout.write(self.style.WARNING(
'当前 dry-run未写库。确认无误后\n'
' ./venv/bin/python manage.py import_ufo_catalog --apply\n'
' ./venv/bin/python manage.py import_ufo_catalog --apply --with-banners'
))
return
stats = {
'leixing_enabled': 0,
'zones_created': 0,
'zones_updated': 0,
'products_created': 0,
'products_updated': 0,
'banners': 0,
'oss_ok': 0,
'oss_fail': 0,
}
def to_oss(url: str, prefix: str) -> str:
"""下载原图 → 上传到本站 OSS → 返回相对路径(与后台 AddProduct 一致)。"""
if not url:
return ''
if skip_oss:
# 应急:允许写完整 URL正式导入不要加 --skip-oss
return url
if not str(url).startswith('http'):
return url
path = self._upload_remote(url, prefix)
if not path:
stats['oss_fail'] += 1
raise CommandError(f'OSS 上传失败,已中止以免库里写入外站链接: {url}')
stats['oss_ok'] += 1
return path
with transaction.atomic():
cfg, created = ClubShangpinLeixingConfig.query.get_or_create(
club_id=TARGET_CLUB,
leixing_id=leixing.id,
defaults={
'is_enabled': True,
'paixu': int(leixing.paixu or 0),
'tupian_url': '',
},
)
if not cfg.is_enabled:
cfg.is_enabled = True
cfg.save(update_fields=['is_enabled', 'UpdateTime'])
stats['leixing_enabled'] += 1
elif created:
stats['leixing_enabled'] += 1
zone_id_map = {}
for idx, z in enumerate(zones):
name = (z.get('name') or '').strip()
if not name:
continue
src_id = int(z.get('source_id') or 0)
thumb = to_oss(z.get('thumb_url') or '', 'shangpin/zhuanqu')
row = ShangpinZhuanqu.query.filter(
club_id=TARGET_CLUB,
mingzi=name,
leixing_id=leixing.id,
dianpu__isnull=True,
).first()
if row:
row.tupian_url = thumb or row.tupian_url
row.shenhezhuangtai = 1
row.paixu = max(len(zones) - idx, 0)
row.save(update_fields=['tupian_url', 'shenhezhuangtai', 'paixu', 'UpdateTime'])
stats['zones_updated'] += 1
else:
row = ShangpinZhuanqu.query.create(
mingzi=name,
leixing_id=leixing.id,
club_id=TARGET_CLUB,
tupian_url=thumb or '',
shenhezhuangtai=1,
paixu=max(len(zones) - idx, 0),
dianpu=None,
dianpu_leixing=None,
)
stats['zones_created'] += 1
if src_id:
zone_id_map[src_id] = row.id
zone_id_map[name] = row.id
for p in products:
title = (p.get('title') or '').strip()
if not title:
continue
zone_name = (p.get('zone_name') or p.get('target_zhuanqu_name') or '').strip()
cat2 = int(p.get('cat2_id') or 0)
zhuanqu_id = zone_id_map.get(cat2) or zone_id_map.get(zone_name)
if not zhuanqu_id:
self.stderr.write(f'跳过无专区商品: {title} zone={zone_name}')
continue
price = self._decimal(p.get('price'))
intro = (p.get('intro') or '').strip()
xuzhi = (p.get('buy_tips_text') or '').strip()
if p.get('rise_reason'):
xuzhi = ((xuzhi + '\n\n') if xuzhi else '') + f"加价说明:{p.get('rise_reason')}"
pics = list(p.get('pics_url') or [])
thumb = p.get('thumb_url') or (pics[0] if pics else '')
tupian = to_oss(thumb, 'shangpin/shangpintupian')
rule_imgs = list(p.get('rule_images_url') or [])
guize = to_oss(rule_imgs[0], 'shangpin/guize') if rule_imgs else ''
weigh = int(p.get('weigh') or p.get('home_weigh') or 0)
on_shelf = int(p.get('status') or 1) == 1
is_raffle = int(p.get('raffle_id') or 0) > 0
row = Shangpin.query.filter(
club_id=TARGET_CLUB,
biaoqian=title,
zhuanqu_id=zhuanqu_id,
dianpu__isnull=True,
).first()
fields = dict(
biaoqian=title,
jiage=price,
kucun=99999,
leixing_id=leixing.id,
zhuanqu_id=zhuanqu_id,
jieshao=intro,
xiadan_xuzhi=xuzhi[:5000] if xuzhi else '',
guize_tupian=guize or '',
tupian_url=tupian or '',
yaoqiuleixing=leixing.yaoqiuleixing,
huiyuan_id=leixing.huiyuan_id,
yongjin=leixing.yongjin,
paixu=weigh,
shenhezhuangtai=1,
shangjia_zhuangtai=on_shelf,
club_id=TARGET_CLUB,
shi_zhuanpan=bool(is_raffle),
dianpu=None,
dianpu_leixing=None,
)
if row:
for k, v in fields.items():
setattr(row, k, v)
row.save()
stats['products_updated'] += 1
else:
Shangpin.query.create(
zhenshi_xiaoliang=0,
duiwai_xiaoliang=0,
**fields,
)
stats['products_created'] += 1
if with_banners:
from config.models import Lunbo
from jituan.services.display_config import (
LUNBO_PAGE_ACCEPT_BANNER2,
LUNBO_PAGE_ACCEPT_ORDER,
lunbo_image_type_for_page,
)
def upsert_lunbo(page_key, items):
image_type = lunbo_image_type_for_page(page_key)
Lunbo.query.filter(
club_id=TARGET_CLUB,
page_key=page_key,
ImageType=image_type,
).delete()
for b in items:
url = to_oss(b.get('url') or '', 'a_long/lunbo')
if not url:
continue
Lunbo.query.create(
club_id=TARGET_CLUB,
page_key=page_key,
ImageURL=url,
ImageType=image_type,
)
stats['banners'] += 1
upsert_lunbo(LUNBO_PAGE_ACCEPT_ORDER, banners)
upsert_lunbo(LUNBO_PAGE_ACCEPT_BANNER2, banners2)
self.stdout.write(self.style.SUCCESS(
'完成(仅 club=ufo: '
f"类型上架={stats['leixing_enabled']} "
f"专区+={stats['zones_created']}/改={stats['zones_updated']} "
f"商品+={stats['products_created']}/改={stats['products_updated']} "
f"轮播={stats['banners']} "
f"OSS成功={stats['oss_ok']} 失败={stats['oss_fail']}"
))
def _resolve_leixing(self, ShangpinLeixing, leixing_id: int, leixing_name: str):
"""优先 id否则 jieshao 全名精确匹配且必须唯一。不做 icontains。"""
if leixing_id:
try:
lx = ShangpinLeixing.query.get(id=leixing_id)
except ShangpinLeixing.DoesNotExist as exc:
raise CommandError(f'类型 id={leixing_id} 不存在') from exc
return lx
name = (leixing_name or DEFAULT_LEIXING_NAME).strip() or DEFAULT_LEIXING_NAME
rows = list(
ShangpinLeixing.query.filter(jieshao=name)
.exclude(shenhezhuangtai=2)
.order_by('id')
)
if not rows:
near = list(
ShangpinLeixing.query.filter(jieshao__icontains=name).order_by('id')[:20]
)
hint = '\n'.join(
f' id={x.id} name={x.jieshao!r} shenhezhuangtai={x.shenhezhuangtai}'
for x in near
) or ' (无近似)'
raise CommandError(
f'找不到 jieshao 精确等于「{name}」且非审核专用的类型。库中近似:\n{hint}\n'
'请先在后台把类型名改成正好「三角洲」,或 --leixing-id=... 指定。'
)
if len(rows) > 1:
detail = '\n'.join(
f' id={x.id} name={x.jieshao!r} shenhezhuangtai={x.shenhezhuangtai}'
for x in rows
)
raise CommandError(
f'{name}」精确匹配到多条,拒绝自动选,避免挂错:\n{detail}'
)
return rows[0]
@staticmethod
def _decimal(v) -> Decimal:
try:
return Decimal(str(v if v is not None else '0'))
except (InvalidOperation, TypeError, ValueError):
return Decimal('0')
@staticmethod
def _upload_remote(url: str, prefix: str) -> str:
"""返回 OSS 相对 key失败返回空串。与后台一致DB 只存相对路径。"""
from datetime import datetime
from utils.oss_utils import upload_to_oss
try:
ctx = ssl.create_default_context()
req = Request(url, headers={'User-Agent': 'UFO-Import/1.0'})
with urlopen(req, timeout=60, context=ctx) as resp:
raw = resp.read()
ctype = resp.headers.get('Content-Type') or ''
except Exception: # noqa: BLE001
return ''
ext = mimetypes.guess_extension((ctype or '').split(';')[0].strip()) or ''
if not ext or ext == '.jpe':
m = re.search(r'\.(jpg|jpeg|png|gif|webp|bmp)(?:\?|$)', url, re.I)
ext = ('.' + m.group(1).lower()) if m else '.jpg'
if ext == '.jpeg':
ext = '.jpg'
ts = datetime.now().strftime('%Y%m%d%H%M%S')
rel = f"{prefix.strip('/')}/{ts}_{uuid.uuid4().hex[:8]}{ext}"
bio = BytesIO(raw)
bio.name = os.path.basename(rel)
ok = upload_to_oss(bio, rel)
if not ok:
return ''
return rel # 关键:只返回相对路径,不返回完整 CDN URL

View File

@@ -0,0 +1,312 @@
# -*- coding: utf-8 -*-
"""
龙先生(lxs)审核版目录重置:清空本俱乐部三个审核类型下的专区/商品,再从星阙(xq)原样复制。
范围(必须全部满足):
- 目标俱乐部固定 lxsname 含「龙先生」)
- 源俱乐部固定 xqname 含「星阙」)
- 仅全局 ShangpinLeixing.shenhezhuangtai=2 的类型(通常 3 个)
- 仅公共目录 dianpu IS NULL
- 专区/商品均要求 shenhezhuangtai=2
步骤:
1) 删除 lxs 下上述类型的专区+商品(只删 DB不删 OSS避免误伤星阙共用图
2) 从 xq 复制同类型专区+商品到 lxs图片复用相对路径销量归零
不碰:正常商品(shenhezhuangtai=1)、其它俱乐部、店铺商品、全局类型表、订单等。
用法:
python manage.py reset_lxs_shenhe_catalog_from_xq
python manage.py reset_lxs_shenhe_catalog_from_xq --list
python manage.py reset_lxs_shenhe_catalog_from_xq --apply --i-confirm=lxs-from-xq
"""
from __future__ import annotations
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
TARGET_CLUB = 'lxs'
TARGET_NAME_HINT = '龙先生'
SOURCE_CLUB = 'xq'
SOURCE_NAME_HINT = '星阙'
SHENHE = 2
CONFIRM_TOKEN = 'lxs-from-xq'
class Command(BaseCommand):
help = '清空龙先生审核专区/商品,并从星阙复制三个审核类型目录(默认 dry-run'
def add_arguments(self, parser):
parser.add_argument('--apply', action='store_true', help='真正删+写')
parser.add_argument(
'--i-confirm',
type=str,
default='',
help=f'必须等于 {CONFIRM_TOKEN} 才允许 --apply',
)
parser.add_argument(
'--list',
action='store_true',
help='只列出审核类型及 xq/lxs 下专区商品数量',
)
def handle(self, *args, **options):
from jituan.models import Club
from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu
apply = bool(options.get('apply'))
confirm = (options.get('i_confirm') or '').strip()
src_club = Club.query.filter(club_id=SOURCE_CLUB).first()
if not src_club:
raise CommandError(f'源俱乐部 {SOURCE_CLUB} 不存在')
if SOURCE_NAME_HINT not in (src_club.name or ''):
raise CommandError(
f'安全拦截:{SOURCE_CLUB} 的 name={src_club.name!r} 不含「{SOURCE_NAME_HINT}'
)
dst_club = Club.query.filter(club_id=TARGET_CLUB).first()
if not dst_club:
raise CommandError(f'目标俱乐部 {TARGET_CLUB} 不存在')
if TARGET_NAME_HINT not in (dst_club.name or ''):
raise CommandError(
f'安全拦截:{TARGET_CLUB} 的 name={dst_club.name!r} 不含「{TARGET_NAME_HINT}'
)
audit_types = list(
ShangpinLeixing.query.filter(shenhezhuangtai=SHENHE).order_by('id')
)
if not audit_types:
raise CommandError('没有任何审核专用商品类型(shenhezhuangtai=2)')
if len(audit_types) != 3:
self.stdout.write(self.style.WARNING(
f'注意:当前审核类型数量={len(audit_types)}(预期常为 3将按实际全部处理。'
))
leixing_ids = [lx.id for lx in audit_types]
def counts(club_id):
z = ShangpinZhuanqu.query.filter(
club_id=club_id,
leixing_id__in=leixing_ids,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).count()
p = Shangpin.query.filter(
club_id=club_id,
leixing_id__in=leixing_ids,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).count()
return z, p
def per_type(club_id):
rows = []
for lx in audit_types:
z = ShangpinZhuanqu.query.filter(
club_id=club_id,
leixing_id=lx.id,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).count()
p = Shangpin.query.filter(
club_id=club_id,
leixing_id=lx.id,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).count()
rows.append((lx, z, p))
return rows
if options.get('list'):
self.stdout.write(self.style.NOTICE('审核类型及两侧存量:'))
for lx in audit_types:
sz, sp = (
ShangpinZhuanqu.query.filter(
club_id=SOURCE_CLUB, leixing_id=lx.id,
shenhezhuangtai=SHENHE, dianpu__isnull=True,
).count(),
Shangpin.query.filter(
club_id=SOURCE_CLUB, leixing_id=lx.id,
shenhezhuangtai=SHENHE, dianpu__isnull=True,
).count(),
)
dz, dp = (
ShangpinZhuanqu.query.filter(
club_id=TARGET_CLUB, leixing_id=lx.id,
shenhezhuangtai=SHENHE, dianpu__isnull=True,
).count(),
Shangpin.query.filter(
club_id=TARGET_CLUB, leixing_id=lx.id,
shenhezhuangtai=SHENHE, dianpu__isnull=True,
).count(),
)
self.stdout.write(
f' id={lx.id}{lx.jieshao}'
f'xq: zones={sz} products={sp} | '
f'lxs: zones={dz} products={dp}'
)
return
if apply and confirm != CONFIRM_TOKEN:
raise CommandError(
f'安全拦截:--apply 必须带 --i-confirm={CONFIRM_TOKEN}\n'
f' python manage.py reset_lxs_shenhe_catalog_from_xq '
f'--apply --i-confirm={CONFIRM_TOKEN}'
)
src_z_total, src_p_total = counts(SOURCE_CLUB)
dst_z_total, dst_p_total = counts(TARGET_CLUB)
src_zones = list(
ShangpinZhuanqu.query.filter(
club_id=SOURCE_CLUB,
leixing_id__in=leixing_ids,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).order_by('leixing_id', 'paixu', 'id')
)
src_products = list(
Shangpin.query.filter(
club_id=SOURCE_CLUB,
leixing_id__in=leixing_ids,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).order_by('leixing_id', 'paixu', 'id')
)
# 源商品的专区必须落在本批源专区内,否则拒绝(防串数据)
src_zone_ids = {z.id for z in src_zones}
orphan = [p for p in src_products if p.zhuanqu_id not in src_zone_ids]
if orphan:
sample = ', '.join(f'{p.id}:{p.biaoqian}' for p in orphan[:5])
raise CommandError(
f'源商品有 {len(orphan)} 条专区不在本批审核专区内,已中止。样例: {sample}'
)
mode = 'APPLY' if apply else 'DRY-RUN'
self.stdout.write(self.style.NOTICE(
f'\n===== {mode} 龙先生审核目录 ← 星阙 =====\n'
f'源: {SOURCE_CLUB} ({src_club.name}) '
f'专区={src_z_total} 商品={src_p_total}\n'
f'目标: {TARGET_CLUB} ({dst_club.name}) '
f'将先删除 专区={dst_z_total} 商品={dst_p_total},再复制源数据\n'
f'审核类型数={len(audit_types)} ids={leixing_ids}\n'
f'OSS: 不删文件;复制时复用星阙相对路径\n'
))
self.stdout.write('—— 分类型存量 ——')
for lx, sz, sp in per_type(SOURCE_CLUB):
self.stdout.write(f' [xq] id={lx.id}{lx.jieshao}」 zones={sz} products={sp}')
for lx, dz, dp in per_type(TARGET_CLUB):
self.stdout.write(f' [lxs] id={lx.id}{lx.jieshao}」 zones={dz} products={dp}')
self.stdout.write('—— 将从 xq 复制的专区 ——')
for z in src_zones[:40]:
self.stdout.write(
f' zq#{z.id} leixing={z.leixing_id} {z.mingzi!r}'
)
if len(src_zones) > 40:
self.stdout.write(f' ... 其余 {len(src_zones) - 40} 个省略')
self.stdout.write('—— 将从 xq 复制的商品样例 ——')
for p in src_products[:15]:
self.stdout.write(
f' sp#{p.id} leixing={p.leixing_id} {p.biaoqian!r} ¥{p.jiage}'
)
if len(src_products) > 15:
self.stdout.write(f' ... 其余 {len(src_products) - 15} 个省略')
if not apply:
self.stdout.write(self.style.WARNING(
'\n以上仅计划:未删库、未写库。确认无误后:\n'
f' python manage.py reset_lxs_shenhe_catalog_from_xq '
f'--apply --i-confirm={CONFIRM_TOKEN}\n'
))
return
stats = {
'deleted_products': 0,
'deleted_zones': 0,
'zones_created': 0,
'products_created': 0,
}
with transaction.atomic():
# 1) 只删 lxs + 审核类型 + 公共
n_prod, _ = Shangpin.query.filter(
club_id=TARGET_CLUB,
leixing_id__in=leixing_ids,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).delete()
n_zone, _ = ShangpinZhuanqu.query.filter(
club_id=TARGET_CLUB,
leixing_id__in=leixing_ids,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).delete()
stats['deleted_products'] = n_prod
stats['deleted_zones'] = n_zone
# 2) 按源专区插入,建立 id 映射
zone_id_map = {}
for z in src_zones:
if z.leixing_id not in leixing_ids:
raise CommandError(f'专区 {z.id} 类型 {z.leixing_id} 不在审核列表')
row = ShangpinZhuanqu.query.create(
mingzi=z.mingzi,
leixing_id=z.leixing_id,
club_id=TARGET_CLUB,
tupian_url=z.tupian_url or '',
shenhezhuangtai=SHENHE,
paixu=int(z.paixu or 0),
shangjia_zhuangtai=bool(getattr(z, 'shangjia_zhuangtai', True)),
fengjin_zhuangtai=bool(getattr(z, 'fengjin_zhuangtai', True)),
dianpu=None,
dianpu_leixing=None,
)
zone_id_map[z.id] = row.id
stats['zones_created'] += 1
for p in src_products:
if p.leixing_id not in leixing_ids:
raise CommandError(f'商品 {p.id} 类型 {p.leixing_id} 不在审核列表')
dst_zq = zone_id_map.get(p.zhuanqu_id)
if not dst_zq:
raise CommandError(
f'商品「{p.biaoqian}」id={p.id} 无法映射 zhuanqu_id={p.zhuanqu_id}'
)
Shangpin.query.create(
biaoqian=p.biaoqian,
jiage=p.jiage,
kucun=p.kucun,
leixing_id=p.leixing_id,
zhuanqu_id=dst_zq,
zhenshi_xiaoliang=0,
duiwai_xiaoliang=0,
jieshao=p.jieshao,
xiadan_xuzhi=p.xiadan_xuzhi,
guize_tupian=p.guize_tupian or '',
tupian_url=p.tupian_url or '',
yaoqiuleixing=p.yaoqiuleixing,
huiyuan_id=p.huiyuan_id,
yongjin=p.yongjin,
kaioi_ewai_dashou_fencheng=bool(p.kaioi_ewai_dashou_fencheng),
ewai_dashou_fencheng=p.ewai_dashou_fencheng,
paixu=int(p.paixu or 0),
shenhezhuangtai=SHENHE,
shangjia_zhuangtai=bool(p.shangjia_zhuangtai),
fengjin_zhuangtai=bool(p.fengjin_zhuangtai),
shenhe_zhuangtai=bool(getattr(p, 'shenhe_zhuangtai', True)),
club_id=TARGET_CLUB,
shi_zhuanpan=False,
dianpu=None,
dianpu_leixing=None,
)
stats['products_created'] += 1
self.stdout.write(self.style.SUCCESS(
f'\n完成: {stats}\n'
f'星阙(xq)未改动;龙先生(lxs)审核目录已与星阙对齐。'
))

View File

@@ -0,0 +1,342 @@
# -*- coding: utf-8 -*-
"""
龙先生(lxs)审核版目录重置:对齐小程序简介的「专业电竞护航 / 技巧支持 / 策略指导」。
目标(审核口径):
- 强调:线上电竞技巧讲解、对局策略交流、团队协作指导、正向技能提升
- 绝不出现:代练、代打、上分、线下陪玩、陪玩约玩等
- 仅改 club_id=lxs + 全局审核类型(shenhezhuangtai=2) 下的专区/商品
- 不碰正常商品(shenhezhuangtai=1)、其它俱乐部、店铺商品、订单
动作:
1) 将 3 个审核类型名改为:电竞技巧指导 / 对局策略交流 / 线上技能提升
2) 清空 lxs 下这些类型的审核专区+商品(只删 DB不删 OSS
3) 每个类型 2 个专区;每个专区 10 个商品(共 60
4) 图标统一 a_long/morentouxiang.jpg
5) 下单须知写明:仅线上指导交流,禁止代练代打与线下服务
用法:
python manage.py reset_lxs_shenhe_esports_coach
python manage.py reset_lxs_shenhe_esports_coach --list
python manage.py reset_lxs_shenhe_esports_coach --apply --i-confirm=lxs-esports-coach
"""
from __future__ import annotations
from decimal import Decimal
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
ALLOWED_CLUB_ID = 'lxs'
NAME_HINT = '龙先生'
SHENHE = 2
DEFAULT_AVATAR = 'a_long/morentouxiang.jpg'
CONFIRM_TOKEN = 'lxs-esports-coach'
EXPECTED_TYPE_COUNT = 3
TYPE_NAMES = ['电竞技巧指导', '对局策略交流', '线上技能提升']
ZONE_NAMES_BY_TYPE = [
['技巧讲解', '操作指导'],
['策略复盘', '思路交流'],
['协作训练', '正向成长'],
]
PRODUCTS_PER_ZONE = [
[
('基础操作技巧讲解', '线上讲解基础操作与键位思路,帮助建立正确习惯,重在理解与练习。', '19.90'),
('走位思路实时指导', '对局中讲解走位与观察习惯,提升意识,由学员本人操作完成。', '25.90'),
('技能连招要点拆解', '拆解技能释放节奏与连招要点,循序渐进,强调自学自练。', '22.90'),
('地图资源意识教学', '讲解地图信息与资源节奏,培养全局观,纯技巧交流。', '22.90'),
('瞄准与预判要点分享', '分享瞄准预判的练习方法,给出训练建议,学员自行练习。', '25.90'),
('开局节奏规划指导', '讲解开局规划与信息收集,帮助形成稳定开局习惯。', '19.90'),
('道具使用思路讲解', '讲解常用道具使用时机与配合思路,强调理解而非包办。', '19.90'),
('视野与信息读取教学', '教学如何读图、读信息,提升判断力,正向技能成长。', '22.90'),
('残局处理要点交流', '交流残局决策思路与优先级,鼓励独立思考与复盘。', '25.90'),
('个性化技巧答疑', '针对个人薄弱点线上答疑,给出可执行的练习清单。', '29.90'),
],
[
('对局策略思路交流', '线上交流阵容与战术思路,重在理解策略,学员自主决策执行。', '25.90'),
('团战站位策略讲解', '讲解团战站位与目标优先级,提升团队配合意识。', '25.90'),
('资源分配策略讨论', '讨论资源分配与节奏取舍,培养理性决策能力。', '22.90'),
('对手习惯观察复盘', '复盘如何观察对手习惯,给出分析框架,鼓励自我复盘。', '22.90'),
('中期转线策略交流', '交流中期转线与节奏切换思路,帮助建立阶段目标。', '25.90'),
('防守反击策略拆解', '拆解防守反击的触发条件与执行步骤,逻辑清晰可练。', '22.90'),
('进攻时机判断指导', '讲解进攻时机判断要素,强调信息与纪律,杜绝侥幸。', '25.90'),
('阵容克制关系科普', '科普常见克制关系与应对思路,开阔视野,正向学习。', '19.90'),
('赛后复盘方法教学', '教学赛后复盘步骤:录像、标注、总结,养成成长闭环。', '19.90'),
('策略问答一对一', '一对一策略问答,解答困惑,给出清晰可落地的改进方向。', '29.90'),
],
[
('团队沟通协作指导', '指导语音沟通话术与分工习惯,打造文明友好的协作环境。', '19.90'),
('良性竞争心态辅导', '交流输赢心态与压力管理,传递体育精神与正能量。', '15.90'),
('训练计划制定协助', '协助制定可坚持的训练计划,目标清晰、循序渐进。', '19.90'),
('团队默契练习指导', '指导信号、分工与节奏同步练习方法,提升协作效率。', '22.90'),
('文明竞技礼仪分享', '分享线上竞技礼仪与友好互动规范,拒绝不良言行。', '12.90'),
('专注力与节奏管理', '交流专注力与作息节奏管理小方法,支持长期进步。', '15.90'),
('新手友好入门指导', '面向新手的入门讲解与练习指导,学员全程自主操作。', '19.90'),
('进阶目标拆解辅导', '把长期目标拆成周计划,每周复盘一次,稳步提升。', '22.90'),
('录像点评成长课', '基于学员录像做要点点评,指出亮点与改进点,鼓励成长。', '29.90'),
('电竞护航技巧总复习', '汇总技巧与策略要点,形成个人笔记,巩固线上学习成果。', '25.90'),
],
]
BANNED_WORDS = (
'代练', '代打', '上分', '上段', '包星', '定位赛', '代肝', '代刷',
'代打手', '代练平台', '代练服务', '护航上分', '包赢',
'陪玩', '线下陪玩', '上门', '约玩', '陪睡觉', '色情',
'赌博', '出租号', '买号', '卖号',
)
ORDER_NOTICE = (
'本服务为龙先生电竞提供的线上电竞技巧支持、对局策略指导与正向技能交流。'
'全程由学员本人登录并亲自操作、自行决策;服务内容仅限讲解、答疑与思路交流。'
'请文明沟通,共建专业、健康的电竞学习环境。'
)
class Command(BaseCommand):
help = '龙先生审核版改为电竞技巧/策略指导正能量目录(默认 dry-run'
def add_arguments(self, parser):
parser.add_argument('--apply', action='store_true', help='真正写库')
parser.add_argument(
'--i-confirm',
type=str,
default='',
help=f'必须等于 {CONFIRM_TOKEN} 才允许 --apply',
)
parser.add_argument(
'--list',
action='store_true',
help='只列出当前审核类型及 lxs 存量',
)
parser.add_argument(
'--skip-rename-types',
action='store_true',
help='不修改全局审核类型名称,仅重建 lxs 专区/商品',
)
def handle(self, *args, **options):
from jituan.models import Club
from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu
apply = bool(options.get('apply'))
confirm = (options.get('i_confirm') or '').strip()
skip_rename = bool(options.get('skip_rename_types'))
club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first()
if not club:
raise CommandError(f'俱乐部 {ALLOWED_CLUB_ID} 不存在')
if NAME_HINT not in (club.name or ''):
raise CommandError(
f'安全拦截:{ALLOWED_CLUB_ID} name={club.name!r} 不含「{NAME_HINT}'
)
audit_types = list(
ShangpinLeixing.query.filter(shenhezhuangtai=SHENHE).order_by('id')
)
if len(audit_types) != EXPECTED_TYPE_COUNT:
raise CommandError(
f'安全拦截:审核类型数量应为 {EXPECTED_TYPE_COUNT}'
f'当前为 {len(audit_types)}'
+ ', '.join(f'id={t.id}{t.jieshao}' for t in audit_types)
)
leixing_ids = [t.id for t in audit_types]
def counts():
z = ShangpinZhuanqu.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id__in=leixing_ids,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).count()
p = Shangpin.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id__in=leixing_ids,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).count()
return z, p
if options.get('list'):
self.stdout.write(self.style.NOTICE('审核类型 → 计划改名lxs 当前专区/商品:'))
for i, t in enumerate(audit_types):
z = ShangpinZhuanqu.query.filter(
club_id=ALLOWED_CLUB_ID, leixing_id=t.id,
shenhezhuangtai=SHENHE, dianpu__isnull=True,
).count()
p = Shangpin.query.filter(
club_id=ALLOWED_CLUB_ID, leixing_id=t.id,
shenhezhuangtai=SHENHE, dianpu__isnull=True,
).count()
self.stdout.write(
f' id={t.id}{t.jieshao}」→「{TYPE_NAMES[i]}'
f'lxs zones={z} products={p}'
)
return
for zone_list in PRODUCTS_PER_ZONE:
if len(zone_list) != 10:
raise CommandError('内部错误:每个类型必须恰好 10 个商品模板')
for title, intro, _price in zone_list:
# 只扫标题/介绍;须知用正向表述,不出现敏感词本身
blob = title + intro
for w in BANNED_WORDS:
if w in blob:
raise CommandError(f'文案含禁止词「{w}」: {title}')
for w in BANNED_WORDS:
if w in ORDER_NOTICE:
raise CommandError(f'下单须知含禁止词「{w}」,请改成正向表述')
for zones in ZONE_NAMES_BY_TYPE:
if len(zones) != 2:
raise CommandError('内部错误:每个类型必须恰好 2 个专区')
for zn in zones:
for w in BANNED_WORDS:
if w in zn:
raise CommandError(f'专区名含禁止词「{w}」: {zn}')
for tn in TYPE_NAMES:
for w in BANNED_WORDS:
if w in tn:
raise CommandError(f'类型名含禁止词「{w}」: {tn}')
if apply and confirm != CONFIRM_TOKEN:
raise CommandError(
f'安全拦截:--apply 必须带 --i-confirm={CONFIRM_TOKEN}\n'
f' python manage.py reset_lxs_shenhe_esports_coach '
f'--apply --i-confirm={CONFIRM_TOKEN}'
)
old_z, old_p = counts()
mode = 'APPLY' if apply else 'DRY-RUN'
self.stdout.write(self.style.NOTICE(
f'\n===== {mode} 龙先生审核 → 电竞技巧/策略指导 =====\n'
f'club={ALLOWED_CLUB_ID} ({club.name})\n'
f'将清空 lxs 审核专区={old_z} 商品={old_p},再写入 '
f'{EXPECTED_TYPE_COUNT}类型×2专区×10商品=60\n'
f'图标: {DEFAULT_AVATAR}\n'
f'改名全局类型: {"" if skip_rename else "是(影响所有俱乐部审核展示名)"}\n'
f'定位: 线上技巧支持 / 策略指导 / 正向技能提升;无代练代打、无线下陪玩\n'
))
self.stdout.write('—— 类型改名计划 ——')
for i, t in enumerate(audit_types):
self.stdout.write(f' id={t.id}{t.jieshao}」→「{TYPE_NAMES[i]}')
self.stdout.write('—— 专区/商品结构 ——')
for i, tname in enumerate(TYPE_NAMES):
for zn in ZONE_NAMES_BY_TYPE[i]:
self.stdout.write(f'{tname}」/「{zn}」→ 10 个指导类商品')
self.stdout.write('—— 商品样例 ——')
for title, intro, price in PRODUCTS_PER_ZONE[0][:3]:
self.stdout.write(f' {title} ¥{price} | {intro}')
if not apply:
self.stdout.write(self.style.WARNING(
'\n以上仅计划。确认后执行:\n'
f' python manage.py reset_lxs_shenhe_esports_coach '
f'--apply --i-confirm={CONFIRM_TOKEN}\n'
))
return
stats = {
'types_renamed': 0,
'deleted_products': 0,
'deleted_zones': 0,
'zones_created': 0,
'products_created': 0,
}
with transaction.atomic():
if not skip_rename:
for i, t in enumerate(audit_types):
new_name = TYPE_NAMES[i]
if (t.jieshao or '') != new_name:
t.jieshao = new_name
t.tupian_url = t.tupian_url or DEFAULT_AVATAR
t.save()
stats['types_renamed'] += 1
n_prod, _ = Shangpin.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id__in=leixing_ids,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).delete()
n_zone, _ = ShangpinZhuanqu.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id__in=leixing_ids,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).delete()
stats['deleted_products'] = n_prod
stats['deleted_zones'] = n_zone
for i, lx in enumerate(audit_types):
lx = ShangpinLeixing.query.filter(id=lx.id).first()
zone_names = ZONE_NAMES_BY_TYPE[i]
base_products = PRODUCTS_PER_ZONE[i]
for zi, zname in enumerate(zone_names):
zrow = ShangpinZhuanqu.query.create(
mingzi=zname,
leixing_id=lx.id,
club_id=ALLOWED_CLUB_ID,
tupian_url=DEFAULT_AVATAR,
shenhezhuangtai=SHENHE,
paixu=20 - zi * 10,
shangjia_zhuangtai=True,
fengjin_zhuangtai=True,
dianpu=None,
dianpu_leixing=None,
)
stats['zones_created'] += 1
for pi, (title, intro, price) in enumerate(base_products):
full_title = f'{title}·{zname}'
for w in BANNED_WORDS:
if w in full_title or w in intro:
raise CommandError(f'禁止词命中: {full_title}')
Shangpin.query.create(
biaoqian=full_title[:45],
jiage=Decimal(price),
kucun=99999,
leixing_id=lx.id,
zhuanqu_id=zrow.id,
zhenshi_xiaoliang=0,
duiwai_xiaoliang=0,
jieshao=intro,
xiadan_xuzhi=ORDER_NOTICE,
guize_tupian='',
tupian_url=DEFAULT_AVATAR,
yaoqiuleixing=lx.yaoqiuleixing,
huiyuan_id=lx.huiyuan_id,
yongjin=lx.yongjin,
kaioi_ewai_dashou_fencheng=False,
ewai_dashou_fencheng=None,
paixu=100 - pi,
shenhezhuangtai=SHENHE,
shangjia_zhuangtai=True,
fengjin_zhuangtai=True,
shenhe_zhuangtai=True,
club_id=ALLOWED_CLUB_ID,
shi_zhuanpan=False,
dianpu=None,
dianpu_leixing=None,
)
stats['products_created'] += 1
if stats['zones_created'] != 6 or stats['products_created'] != 60:
raise CommandError(
f'数量异常: zones={stats["zones_created"]} '
f'products={stats["products_created"]},期望 6/60已回滚'
)
self.stdout.write(self.style.SUCCESS(
f'\n完成: {stats}\n'
f'均为审核专用(shenhezhuangtai=2);文案对齐「技巧支持/策略指导」,'
f'已避开代练代打与线下陪玩。'
))

View File

@@ -0,0 +1,332 @@
# -*- coding: utf-8 -*-
"""
龙先生(lxs)审核目录重置为「娱乐陪伴」正向内容(非代练代打、不过度游戏化)。
针对:全局 3 个 shenhezhuangtai=2 的商品类型 + 俱乐部 lxs 下专区/商品。
动作:
1) 将 3 个审核类型名改为:娱乐陪伴 / 休闲同行 / 友好互动改全局类型名dry-run 会提示)
2) 清空 lxs 下这些类型的审核专区+商品(只删 DB不删 OSS
3) 每个类型建 2 个专区;每个专区 10 个商品(共 60
4) 图标一律 a_long/morentouxiang.jpg
5) 文案纯正能量陪伴/交流,禁止代练代打上分等词
用法:
python manage.py reset_lxs_shenhe_yule_peiban
python manage.py reset_lxs_shenhe_yule_peiban --list
python manage.py reset_lxs_shenhe_yule_peiban --apply --i-confirm=lxs-yule-peiban
"""
from __future__ import annotations
from decimal import Decimal
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
ALLOWED_CLUB_ID = 'lxs'
NAME_HINT = '龙先生'
SHENHE = 2
DEFAULT_AVATAR = 'a_long/morentouxiang.jpg'
CONFIRM_TOKEN = 'lxs-yule-peiban'
EXPECTED_TYPE_COUNT = 3
# 三个审核类型目标名称(按现有审核类型 id 升序一一对应)
TYPE_NAMES = ['娱乐陪伴', '休闲同行', '友好互动']
# 每个类型 2 个专区名
ZONE_NAMES_BY_TYPE = [
['温暖陪伴', '轻松畅聊'],
['兴趣交流', '日常鼓励'],
['正能量同行', '休闲时光'],
]
# 每个专区 10 个商品:(标题, 介绍, 价格)
# 刻意弱化具体竞技/上分,强调陪伴、交流、正向娱乐
PRODUCTS_PER_ZONE = [
[
('暖心语音陪伴', '友好语音交流,倾听分享,营造轻松正能量氛围。', '19.90'),
('轻松心情疏导', '温和聊天陪伴,帮助放松心情,传递积极态度。', '19.90'),
('兴趣话题畅聊', '围绕健康兴趣爱好交流,互相启发,气氛友善。', '15.90'),
('正能量晚间陪伴', '晚间轻松聊天,分享日常点滴,友好互动。', '19.90'),
('学习打卡互勉', '互相提醒学习计划,鼓励坚持,共同进步。', '15.90'),
('运动打卡陪伴', '交流运动习惯与打卡心得,互相鼓励坚持。', '15.90'),
('阅读心得分享', '分享好书与阅读感受,文明交流,开阔视野。', '12.90'),
('音乐放松同行', '一起听歌聊天放松,分享喜爱曲目,气氛轻松。', '12.90'),
('生活小妙招交流', '交流生活经验与小技巧,互助友爱。', '12.90'),
('周末休闲畅聊', '周末轻松闲聊,分享愉快见闻,友好陪伴。', '15.90'),
],
[
('兴趣社群式交流', '按兴趣轻松交流,结识同好,氛围文明友好。', '15.90'),
('手工创作分享', '分享手工与创作过程,互相欣赏鼓励。', '15.90'),
('摄影见闻交流', '交流摄影与风景见闻,传递美好感受。', '12.90'),
('厨艺心得闲聊', '聊聊家常菜与烹饪心得,轻松愉快。', '12.90'),
('宠物日常分享', '分享萌宠日常,友好互动,温暖治愈。', '12.90'),
('旅行见闻讲述', '讲述旅行见闻与风景,开阔眼界,正能量满满。', '19.90'),
('电影观后闲谈', '交流观影感受与推荐,文明讨论,轻松愉快。', '12.90'),
('桌游规则解说', '友好讲解休闲桌游玩法,一起快乐体验。', '15.90'),
('棋类休闲对弈', '休闲棋类友好对弈,重在交流与乐趣。', '15.90'),
('午后茶歇闲聊', '午后轻松聊天陪伴,舒缓节奏,气氛温馨。', '12.90'),
],
[
('早安鼓励打卡', '清晨互相问候鼓励,开启积极一天。', '9.90'),
('习惯养成互勉', '一起制定小目标,互相督促,温柔坚持。', '15.90'),
('情绪友好倾听', '认真倾听烦恼,给予友善支持与正向建议。', '19.90'),
('时间管理小谈', '交流高效作息小方法,互相提醒,积极向上。', '15.90'),
('环保生活分享', '分享绿色生活小习惯,传递责任与温暖。', '9.90'),
('志愿服务见闻', '交流公益与志愿服务见闻,弘扬正能量。', '12.90'),
('亲子沟通小贴士', '交流亲子相处经验,友善互助(非医疗建议)。', '15.90'),
('职场礼仪闲谈', '聊聊职场沟通礼仪与礼貌习惯,积极文明。', '15.90'),
('节日祝福陪伴', '节日期间友好问候与陪伴聊天,传递祝福。', '12.90'),
('睡前放松畅聊', '睡前轻松聊天,帮助放松,友好结束一天。', '15.90'),
],
]
BANNED_WORDS = (
'代练', '代打', '上分', '上段', '包星', '定位赛', '代肝',
'代刷', '代打手', '代练平台', '代练服务', '护航上分',
)
class Command(BaseCommand):
help = '龙先生审核类型改为娱乐陪伴并向 lxs 写入正能量专区/商品(默认 dry-run'
def add_arguments(self, parser):
parser.add_argument('--apply', action='store_true', help='真正写库')
parser.add_argument(
'--i-confirm',
type=str,
default='',
help=f'必须等于 {CONFIRM_TOKEN} 才允许 --apply',
)
parser.add_argument(
'--list',
action='store_true',
help='只列出当前审核类型及 lxs 存量',
)
parser.add_argument(
'--skip-rename-types',
action='store_true',
help='不修改全局审核类型名称,仅重建 lxs 专区/商品',
)
def handle(self, *args, **options):
from jituan.models import Club
from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu
apply = bool(options.get('apply'))
confirm = (options.get('i_confirm') or '').strip()
skip_rename = bool(options.get('skip_rename_types'))
club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first()
if not club:
raise CommandError(f'俱乐部 {ALLOWED_CLUB_ID} 不存在')
if NAME_HINT not in (club.name or ''):
raise CommandError(
f'安全拦截:{ALLOWED_CLUB_ID} name={club.name!r} 不含「{NAME_HINT}'
)
audit_types = list(
ShangpinLeixing.query.filter(shenhezhuangtai=SHENHE).order_by('id')
)
if len(audit_types) != EXPECTED_TYPE_COUNT:
raise CommandError(
f'安全拦截:审核类型数量应为 {EXPECTED_TYPE_COUNT}'
f'当前为 {len(audit_types)}'
+ ', '.join(f'id={t.id}{t.jieshao}' for t in audit_types)
)
leixing_ids = [t.id for t in audit_types]
def counts():
z = ShangpinZhuanqu.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id__in=leixing_ids,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).count()
p = Shangpin.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id__in=leixing_ids,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).count()
return z, p
if options.get('list'):
self.stdout.write(self.style.NOTICE(
f'审核类型 → 计划改名lxs 当前专区/商品:'
))
for i, t in enumerate(audit_types):
z = ShangpinZhuanqu.query.filter(
club_id=ALLOWED_CLUB_ID, leixing_id=t.id,
shenhezhuangtai=SHENHE, dianpu__isnull=True,
).count()
p = Shangpin.query.filter(
club_id=ALLOWED_CLUB_ID, leixing_id=t.id,
shenhezhuangtai=SHENHE, dianpu__isnull=True,
).count()
self.stdout.write(
f' id={t.id}{t.jieshao}」→「{TYPE_NAMES[i]}'
f'lxs zones={z} products={p}'
)
return
# 文案自检
for zone_list in PRODUCTS_PER_ZONE:
if len(zone_list) != 10:
raise CommandError('内部错误:每个专区必须恰好 10 个商品')
for title, intro, _price in zone_list:
blob = title + intro
for w in BANNED_WORDS:
if w in blob:
raise CommandError(f'文案含禁止词「{w}」: {title}')
for zones in ZONE_NAMES_BY_TYPE:
if len(zones) != 2:
raise CommandError('内部错误:每个类型必须恰好 2 个专区')
if apply and confirm != CONFIRM_TOKEN:
raise CommandError(
f'安全拦截:--apply 必须带 --i-confirm={CONFIRM_TOKEN}\n'
f' python manage.py reset_lxs_shenhe_yule_peiban '
f'--apply --i-confirm={CONFIRM_TOKEN}'
)
old_z, old_p = counts()
mode = 'APPLY' if apply else 'DRY-RUN'
self.stdout.write(self.style.NOTICE(
f'\n===== {mode} 龙先生审核 → 娱乐陪伴 =====\n'
f'club={ALLOWED_CLUB_ID} ({club.name})\n'
f'将清空 lxs 审核专区={old_z} 商品={old_p},再写入 '
f'{EXPECTED_TYPE_COUNT}类型×2专区×10商品=60\n'
f'图标: {DEFAULT_AVATAR}\n'
f'改名全局类型: {"" if skip_rename else "是(影响所有俱乐部审核展示名)"}\n'
))
self.stdout.write('—— 类型改名计划 ——')
for i, t in enumerate(audit_types):
self.stdout.write(f' id={t.id}{t.jieshao}」→「{TYPE_NAMES[i]}')
self.stdout.write('—— 专区/商品结构 ——')
for i, tname in enumerate(TYPE_NAMES):
for zn in ZONE_NAMES_BY_TYPE[i]:
self.stdout.write(f'{tname}」/「{zn}」→ 10 个正能量商品')
self.stdout.write('—— 商品样例 ——')
for title, intro, price in PRODUCTS_PER_ZONE[0][:3]:
self.stdout.write(f' {title} ¥{price} | {intro}')
if not apply:
self.stdout.write(self.style.WARNING(
'\n以上仅计划。确认后:\n'
f' python manage.py reset_lxs_shenhe_yule_peiban '
f'--apply --i-confirm={CONFIRM_TOKEN}\n'
))
return
stats = {
'types_renamed': 0,
'deleted_products': 0,
'deleted_zones': 0,
'zones_created': 0,
'products_created': 0,
}
with transaction.atomic():
if not skip_rename:
for i, t in enumerate(audit_types):
new_name = TYPE_NAMES[i]
if (t.jieshao or '') != new_name:
t.jieshao = new_name
t.tupian_url = t.tupian_url or DEFAULT_AVATAR
t.save()
stats['types_renamed'] += 1
n_prod, _ = Shangpin.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id__in=leixing_ids,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).delete()
n_zone, _ = ShangpinZhuanqu.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id__in=leixing_ids,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).delete()
stats['deleted_products'] = n_prod
stats['deleted_zones'] = n_zone
# 按类型顺序重建
for i, lx in enumerate(audit_types):
# 刷新名称(若已 rename
lx = ShangpinLeixing.query.filter(id=lx.id).first()
zone_names = ZONE_NAMES_BY_TYPE[i]
# 两个专区共用同一组 10 商品模板?用户要每个专区 10 个 —
# 使用 PRODUCTS_PER_ZONE[i] 作为该类型两专区的商品池,
# 第一专区用全部 10 个;第二专区标题加专区后缀避免同类型下标题撞车,
# 或两专区用同一文案但标题带专区名区分。
base_products = PRODUCTS_PER_ZONE[i]
for zi, zname in enumerate(zone_names):
zrow = ShangpinZhuanqu.query.create(
mingzi=zname,
leixing_id=lx.id,
club_id=ALLOWED_CLUB_ID,
tupian_url=DEFAULT_AVATAR,
shenhezhuangtai=SHENHE,
paixu=20 - zi * 10,
shangjia_zhuangtai=True,
fengjin_zhuangtai=True,
dianpu=None,
dianpu_leixing=None,
)
stats['zones_created'] += 1
for pi, (title, intro, price) in enumerate(base_products):
# 同类型不同专区:标题加专区名,避免去重冲突且语义清晰
full_title = f'{title}·{zname}'
for w in BANNED_WORDS:
if w in full_title or w in intro:
raise CommandError(f'禁止词命中: {full_title}')
Shangpin.query.create(
biaoqian=full_title[:45],
jiage=Decimal(price),
kucun=99999,
leixing_id=lx.id,
zhuanqu_id=zrow.id,
zhenshi_xiaoliang=0,
duiwai_xiaoliang=0,
jieshao=intro,
xiadan_xuzhi=(
'本服务为正向休闲陪伴与交流,请文明沟通。'
'禁止任何代练、代打或不正当行为。'
),
guize_tupian='',
tupian_url=DEFAULT_AVATAR,
yaoqiuleixing=lx.yaoqiuleixing,
huiyuan_id=lx.huiyuan_id,
yongjin=lx.yongjin,
kaioi_ewai_dashou_fencheng=False,
ewai_dashou_fencheng=None,
paixu=100 - pi,
shenhezhuangtai=SHENHE,
shangjia_zhuangtai=True,
fengjin_zhuangtai=True,
shenhe_zhuangtai=True,
club_id=ALLOWED_CLUB_ID,
shi_zhuanpan=False,
dianpu=None,
dianpu_leixing=None,
)
stats['products_created'] += 1
if stats['zones_created'] != 6 or stats['products_created'] != 60:
raise CommandError(
f'数量异常: zones={stats["zones_created"]} products={stats["products_created"]}'
f'期望 6/60已回滚'
)
self.stdout.write(self.style.SUCCESS(
f'\n完成: {stats}\n'
f'均为审核专用(shenhezhuangtai=2),仅审核模式可见;文案已避开代练代打。'
))

View File

@@ -0,0 +1,80 @@
"""一键初始化龙先生电竞(lsx)俱乐部(默认不批量给所有客服加任职)。"""
from django.core.management import call_command
from django.core.management.base import BaseCommand
from jituan.models import Club
class Command(BaseCommand):
help = '创建龙先生电竞俱乐部(lsx);任职请单独 seed_admin_assignments --phone ...'
def add_arguments(self, parser):
parser.add_argument(
'--wx-appid', default='wx7ff90e9d024fcdb8', help='龙先生电竞小程序 appid',
)
parser.add_argument(
'--assignments',
action='store_true',
help='同时给 --phone 指定客服加 lsx 任职(需配合 --phone',
)
parser.add_argument(
'--phone', type=str, default='', help='与 --assignments 联用:客服手机号',
)
def handle(self, *args, **options):
wx_appid = (options.get('wx_appid') or '').strip()
club_id = 'lsx'
name = '龙先生电竞'
# 俱乐部应由后台「俱乐部配置」创建并上传证书;本命令不再自动 create_club
club = Club.query.filter(club_id=club_id).first()
if club is None:
self.stdout.write(self.style.ERROR(
f'俱乐部 {club_id} 不存在。请先在后台创建并上传支付 key'
f'不要用脚本自动建俱乐部(避免生成无证书空配置)。'
))
return
if wx_appid and (club.wx_appid or '') != wx_appid:
club.wx_appid = wx_appid
club.save(update_fields=['wx_appid'])
self.stdout.write(f'已更新 {club_id}.wx_appid = {wx_appid}')
else:
self.stdout.write(self.style.SUCCESS(
f'俱乐部 {club_id} 已存在name={club.name}),跳过创建。'
))
if options.get('assignments'):
phone = (options.get('phone') or '').strip()
if not phone:
self.stdout.write(self.style.WARNING(
'未指定 --phone跳过任职。示例'
'python manage.py seed_club_lsx --assignments --phone 你的手机号'
))
else:
call_command(
'seed_admin_assignments',
club_id=club_id,
phone=phone,
)
else:
self.stdout.write(
'未加任职。需要时在后台「数据范围」配置,或:'
'python manage.py seed_admin_assignments --phone 手机号 --club-id lsx'
)
keyed = any([
(club.pay_key_path or '').strip(),
(club.pay_cert_path or '').strip(),
(club.private_key_path or '').strip(),
(club.platform_cert_dir or '').strip(),
])
if not keyed:
self.stdout.write(self.style.WARNING(
f'{club_id} 当前无证书路径,请在后台上传 key'
f'若存在重复空俱乐部可用python manage.py cleanup_lsx_empty_club'
))
self.stdout.write(self.style.SUCCESS(
f'龙先生电竞 {club_id} 检查完成。'
))

View File

@@ -0,0 +1,153 @@
"""确保提现审核权限码 005bb并按俱乐部挂到指定角色可重复执行
权限码全局共用;隔离靠 Role.ClubID + 请求头俱乐部。
集团高管仍可跨俱乐部;俱乐部管理员仅本俱乐部。
用法示例:
python manage.py seed_club_withdraw_audit_perm
python manage.py seed_club_withdraw_audit_perm --club-id lxs --role-name 管理员
python manage.py seed_club_withdraw_audit_perm --all-clubs --role-name 管理员
python manage.py seed_club_withdraw_audit_perm --all-clubs --role-name 管理员 --dry-run
"""
import uuid
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.utils import timezone
from gvsdsdk.models import Permission, Role, RolePermission
from jituan.models import Club
WITHDRAW_AUDIT_PERM = '005bb'
WITHDRAW_AUDIT_NAME = '提现审核(按俱乐部)'
WITHDRAW_AUDIT_DESC = (
'审核本俱乐部提现申请(同意/拒绝/自动打款待收款)。'
'跨俱乐部仅集团高管可操作;须配合后台角色 ClubID 与数据范围任职。'
)
WITHDRAW_AUDIT_CATEGORY = '提现管理'
class Command(BaseCommand):
help = '写入 005bb 提现审核权限码,并按俱乐部挂到角色'
def add_arguments(self, parser):
parser.add_argument(
'--club-id', default='', help='仅处理该俱乐部角色(如 lxs / xq',
)
parser.add_argument(
'--all-clubs', action='store_true', help='处理全部启用中的俱乐部',
)
parser.add_argument(
'--role-name', default='',
help='角色名(如「管理员」);为空则只确保权限码存在,不挂角色',
)
parser.add_argument(
'--dry-run', action='store_true', help='只打印不写入',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
club_id = (options.get('club_id') or '').strip()
all_clubs = options.get('all_clubs')
role_name = (options.get('role_name') or '').strip()
sample = Permission.objects.filter(PermStatus=1).first()
tenant_uuid = sample.TenantUUID if sample else uuid.UUID(
'00000000-0000-0000-0000-000000000001'
).bytes
perm = Permission.objects.filter(PermCode=WITHDRAW_AUDIT_PERM).first()
if perm:
self.stdout.write(f' 权限码已存在: {WITHDRAW_AUDIT_PERM}')
if not dry_run and (perm.PermName != WITHDRAW_AUDIT_NAME or not perm.PermDesc):
perm.PermName = WITHDRAW_AUDIT_NAME
perm.PermDesc = WITHDRAW_AUDIT_DESC
perm.PermCategory = WITHDRAW_AUDIT_CATEGORY
perm.save(update_fields=['PermName', 'PermDesc', 'PermCategory'])
self.stdout.write(self.style.SUCCESS(' 已更新 005bb 名称/说明'))
else:
if dry_run:
self.stdout.write(f'would create perm {WITHDRAW_AUDIT_PERM}')
else:
perm = Permission.objects.create(
PermUUID=uuid.uuid4().bytes,
TenantUUID=tenant_uuid,
PermCode=WITHDRAW_AUDIT_PERM,
PermName=WITHDRAW_AUDIT_NAME,
PermDesc=WITHDRAW_AUDIT_DESC,
PermCategory=WITHDRAW_AUDIT_CATEGORY,
PermStatus=1,
CreateTime=timezone.now(),
)
self.stdout.write(self.style.SUCCESS(f' 新增权限: {WITHDRAW_AUDIT_PERM}'))
if not role_name:
self.stdout.write(
self.style.WARNING(
'未指定 --role-name仅确保权限码。'
'给俱乐部挂权请加:--club-id lxs --role-name 管理员'
)
)
return
if not club_id and not all_clubs:
raise CommandError('挂角色时请指定 --club-id 或 --all-clubs')
if all_clubs:
club_ids = list(
Club.query.filter(status=1).values_list('club_id', flat=True)
)
else:
if not Club.query.filter(club_id=club_id, status=1).exists():
raise CommandError(f'俱乐部不存在或未启用: {club_id}')
club_ids = [club_id]
if not perm and dry_run:
self.stdout.write('dry-run: skip role binding (perm not loaded)')
return
linked = 0
missing_roles = 0
with transaction.atomic():
for cid in club_ids:
role = Role.objects.filter(
RoleName=role_name, ClubID=cid, RoleStatus=1,
).first()
if not role:
missing_roles += 1
self.stdout.write(
self.style.WARNING(f' [{cid}] 无角色「{role_name}」,跳过')
)
continue
exists = RolePermission.objects.filter(
RoleUUID=role.RoleUUID, PermUUID=perm.PermUUID,
).exists()
if exists:
self.stdout.write(f' [{cid}] 角色「{role_name}」已有 {WITHDRAW_AUDIT_PERM}')
continue
if dry_run:
self.stdout.write(
f'would link {WITHDRAW_AUDIT_PERM} -> [{cid}] {role_name}'
)
linked += 1
continue
RolePermission.objects.create(
RolePermUUID=uuid.uuid4().bytes,
RoleUUID=role.RoleUUID,
PermUUID=perm.PermUUID,
CreateTime=timezone.now(),
)
linked += 1
self.stdout.write(
self.style.SUCCESS(
f' [{cid}] 已给角色「{role_name}」挂上 {WITHDRAW_AUDIT_PERM}'
)
)
self.stdout.write(
self.style.SUCCESS(
f'完成:挂权 {linked},缺角色 {missing_roles}'
f'另请确认该账号在「数据范围配置」有对应俱乐部 SINGLE_CLUB 任职。'
)
)

View File

@@ -7,6 +7,7 @@ from django.core.management.base import BaseCommand
from django.db import transaction
from jituan.constants import CLUB_ID_DEFAULT
from jituan.services.wechat_pay import XQ_WX_PAY, WX_V3_CERT_SERIAL_NO_OVERRIDE
from jituan.models import (
Club,
ClubHuiyuanPrice,
@@ -41,10 +42,11 @@ class Command(BaseCommand):
'status': 1,
'wx_appid': getattr(settings, 'WEIXIN_APPID', ''),
'wx_secret': getattr(settings, 'WEIXIN_SECRET', ''),
'mch_id': getattr(settings, 'WEIXIN_MCHID', '') or wx_v3.get('MCHID', ''),
'pay_app_id': wx_v3.get('APPID', '') or getattr(settings, 'WEIXIN_APPID', ''),
'api_v3_key': wx_v3.get('API_V3_KEY', ''),
'cert_serial_no': wx_v3.get('CERT_SERIAL_NO', ''),
'mch_id': XQ_WX_PAY['mch_id'],
'pay_app_id': XQ_WX_PAY['wx_appid'],
'mch_key': XQ_WX_PAY['mch_key'],
'api_v3_key': XQ_WX_PAY['api_v3_key'],
'cert_serial_no': WX_V3_CERT_SERIAL_NO_OVERRIDE,
'private_key_path': wx_v3.get('PRIVATE_KEY_PATH', ''),
'platform_cert_dir': wx_v3.get('PLATFORM_CERT_DIR', ''),
'config_json': {
@@ -75,7 +77,7 @@ class Command(BaseCommand):
if v and not getattr(club, k, None):
setattr(club, k, v)
# V3 字段允许用 settings 补全(证书路径等)
for field in ('api_v3_key', 'cert_serial_no', 'private_key_path', 'platform_cert_dir', 'pay_app_id'):
for field in ('mch_key', 'api_v3_key', 'cert_serial_no', 'private_key_path', 'platform_cert_dir', 'pay_app_id'):
val = defaults.get(field)
if val and not getattr(club, field, None):
setattr(club, field, val)

View File

@@ -26,12 +26,30 @@ EXTRA_PERMISSIONS = [
'配置客服欢迎语、发图确认弹窗、关键词自动回复(会话管理-客服聊天配置)',
'会话管理',
),
(
'gdck666',
'投诉工单查看',
'查看用户投诉工单列表与详情',
'投诉工单',
),
(
'gdcl666',
'投诉工单处理',
'处理/关闭用户投诉工单',
'投诉工单',
),
(
'sfbq666',
'身份装饰标签管理',
'管理小程序身份装饰标签(与用户管理-身份装饰标签菜单对应)',
'用户管理',
),
(
'005bb',
'提现审核(按俱乐部)',
'审核本俱乐部提现(同意/拒绝/自动打款)。跨俱乐部仅集团高管;须角色 ClubID + 数据范围任职。',
'提现管理',
),
# ---------- 店铺管理999 系列:列表/详情/复制/提现等) ----------
('999a', '店铺状态管理', '修改店铺封禁/正常状态', '店铺管理'),
('999b', '店铺余额与创建', '修改可提现余额、添加新店铺', '店铺管理'),

View File

@@ -0,0 +1,218 @@
"""龙先生电竞邀请链种子:插入组长+管事(含邀请码)。
默认 club_id=lsx若找不到会列出库里全部俱乐部便于核对真实 club_id。
可用:
python manage.py seed_lsx_invite_data
python manage.py seed_lsx_invite_data --club-id 你的真实id
python manage.py seed_lsx_invite_data --list-clubs
"""
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from jituan.models import Club, ClubHuiyuanPrice
from users.business_models import User
from users.models import UserBoss, UserGuanshi, UserZuzhang
from utils.invitationcode_utils import CreateInvitationCode
DEFAULT_CLUB_ID = 'lsx'
WX_APPID = 'wx7ff90e9d024fcdb8'
# 固定种子账号可重复执行幂等UID/手机避开 xzj 的 9900001/2
SEED_ZUZHANG = {
'UserUID': '9910001',
'OpenID': 'lsx_seed_openid_zuzhang_v1',
'Phone': '19910000001',
'UserName': 'lsx_seed_zuzhang',
'nickname': '龙先生种子组长',
}
SEED_GUANSHI = {
'UserUID': '9910002',
'OpenID': 'lsx_seed_openid_guanshi_v1',
'Phone': '19910000002',
'UserName': 'lsx_seed_guanshi',
'nickname': '龙先生种子管事',
}
def _list_clubs(stdout, style):
clubs = list(Club.query.order_by('sort_order', 'club_id'))
stdout.write(style.SUCCESS('=' * 60))
stdout.write(f'库里俱乐部共 {len(clubs)} 个:')
for c in clubs:
stdout.write('-' * 40)
stdout.write(f' club_id={c.club_id!r} name={c.name!r} status={c.status}')
stdout.write(f' wx_appid={c.wx_appid or "(空)"}')
stdout.write(f' mch_id={c.mch_id or "(空)"} withdraw_mch_id={getattr(c, "withdraw_mch_id", "") or "(空)"}')
stdout.write(
f' pay_key_path={c.pay_key_path or "(空)"}'
)
stdout.write(style.SUCCESS('=' * 60))
return clubs
def _resolve_club(club_id: str, stdout, style):
"""优先精确 club_id其次 wx_appid再名称含「龙先生」。"""
cid = (club_id or DEFAULT_CLUB_ID).strip()
club = Club.query.filter(club_id=cid).first()
if club:
return club
stdout.write(style.WARNING(f'精确 club_id={cid!r} 不存在,尝试按 AppID/名称匹配…'))
by_app = Club.query.filter(wx_appid=WX_APPID).first()
if by_app:
stdout.write(style.WARNING(
f'按 wx_appid={WX_APPID} 命中: club_id={by_app.club_id!r} name={by_app.name!r}'
))
return by_app
by_name = Club.query.filter(name__icontains='龙先生').order_by('club_id')
names = list(by_name)
if len(names) == 1:
c = names[0]
stdout.write(style.WARNING(
f'按名称「龙先生」命中唯一: club_id={c.club_id!r} name={c.name!r}'
))
return c
if len(names) > 1:
stdout.write(style.ERROR('名称含「龙先生」的俱乐部有多条,请用 --club-id 指定:'))
for c in names:
stdout.write(f' - {c.club_id!r} / {c.name!r}')
return None
return None
def _ensure_user(meta, club_id):
user = User.query.filter(UserUID=meta['UserUID']).first()
if not user:
user = User.query.filter(OpenID=meta['OpenID']).first()
if user:
if (user.ClubID or '') != club_id:
user.ClubID = club_id
user.save(update_fields=['ClubID'])
return user, False
user = User.query.create(
UserUID=meta['UserUID'],
UserName=meta['UserName'],
OpenID=meta['OpenID'],
Phone=meta['Phone'],
ClubID=club_id,
)
UserBoss.query.create(user=user, nickname=meta['nickname'])
return user, True
def _ensure_zuzhang(user):
try:
return user.ZuzhangProfile, False
except UserZuzhang.DoesNotExist:
pass
yaoqingma = CreateInvitationCode(str(user.UserUID))
z = UserZuzhang.query.create(
user=user,
yaoqingma=yaoqingma,
zhuangtai=1,
)
return z, True
def _ensure_guanshi(user, zuzhang_uid):
try:
return user.GuanshiProfile, False
except UserGuanshi.DoesNotExist:
pass
yaoqingma = CreateInvitationCode(str(user.UserUID))
g = UserGuanshi.query.create(
user=user,
yaoqingma=yaoqingma,
yaoqingren=zuzhang_uid,
zhuangtai=1,
)
return g, True
class Command(BaseCommand):
help = '插入龙先生电竞种子组长+管事(含邀请码);找不到俱乐部时会列出全部 club'
def add_arguments(self, parser):
parser.add_argument(
'--club-id',
type=str,
default=DEFAULT_CLUB_ID,
help='目标俱乐部 ID默认 lsx若后台建的不是 lsx 请改这里)',
)
parser.add_argument(
'--list-clubs',
action='store_true',
help='只列出库里所有俱乐部,不写入种子',
)
parser.add_argument(
'--force-new-codes',
action='store_true',
help='已存在时重新生成邀请码(慎用)',
)
def handle(self, *args, **options):
if options.get('list_clubs'):
_list_clubs(self.stdout, self.style)
return
want_id = (options.get('club_id') or DEFAULT_CLUB_ID).strip()
club = _resolve_club(want_id, self.stdout, self.style)
if club is None:
self.stdout.write(self.style.ERROR(
f'找不到可用俱乐部(请求 club_id={want_id!r})。'
))
self.stdout.write(self.style.WARNING(
'可能原因:\n'
' 1) 后台创建时 club_id 不是 lsx例如写成了别的拼音\n'
' 2) 之前执行过 delete_club_by_mch_id --apply把 lsx 主表删掉了\n'
'请先看下面列表,用真实 club_id 再跑:\n'
' python manage.py seed_lsx_invite_data --list-clubs\n'
' python manage.py seed_lsx_invite_data --club-id 真实id'
))
_list_clubs(self.stdout, self.style)
raise CommandError('俱乐部不存在,已中止(未写入任何种子)')
club_id = club.club_id
self.stdout.write(self.style.SUCCESS(
f'将写入种子到俱乐部: club_id={club_id!r} name={club.name!r} wx_appid={club.wx_appid or "(空)"}'
))
with transaction.atomic():
zz_user, zz_new = _ensure_user(SEED_ZUZHANG, club_id)
zz_profile, zz_profile_new = _ensure_zuzhang(zz_user)
if options.get('force_new_codes') and not zz_profile_new:
zz_profile.yaoqingma = CreateInvitationCode(str(zz_user.UserUID))
zz_profile.save(update_fields=['yaoqingma'])
gs_user, gs_new = _ensure_user(SEED_GUANSHI, club_id)
gs_profile, gs_profile_new = _ensure_guanshi(gs_user, zz_user.UserUID)
if options.get('force_new_codes') and not gs_profile_new:
gs_profile.yaoqingma = CreateInvitationCode(str(gs_user.UserUID))
gs_profile.save(update_fields=['yaoqingma'])
price_cnt = ClubHuiyuanPrice.query.filter(club_id=club_id).count()
self.stdout.write(self.style.SUCCESS('=' * 60))
self.stdout.write(self.style.SUCCESS(f'龙先生电竞邀请链种子已就绪club_id={club_id}'))
self.stdout.write(self.style.SUCCESS('=' * 60))
self.stdout.write(f'club_huiyuan_price 条数: {price_cnt}')
self.stdout.write('')
self.stdout.write('【组长】管事注册用这个邀请码guanshizhuce:')
self.stdout.write(f' UserUID={zz_user.UserUID} Phone={SEED_ZUZHANG["Phone"]}')
self.stdout.write(f' 组长邀请码 inviteCode = {zz_profile.yaoqingma}')
self.stdout.write('')
self.stdout.write('【管事】打手/接单员注册用这个邀请码dashouzhuce:')
self.stdout.write(f' UserUID={gs_user.UserUID} Phone={SEED_GUANSHI["Phone"]}')
self.stdout.write(f' 管事邀请码 inviteCode = {gs_profile.yaoqingma}')
self.stdout.write('')
self.stdout.write('商家:后台「商家管理 → 添加商家」按用户UID开通')
self.stdout.write('')
self.stdout.write('新建: 组长=%s 管事=%s' % (
'' if zz_new or zz_profile_new else '否(已存在)',
'' if gs_new or gs_profile_new else '否(已存在)',
))
self.stdout.write(self.style.SUCCESS('=' * 60))

View File

@@ -0,0 +1,311 @@
# -*- coding: utf-8 -*-
"""
为龙先生电竞(lxs)写入审核专用(shenhezhuangtai=2)「游戏护航/陪伴」公共商品目录。
范围:
- 仅 club_id=lxs名称须含「龙先生」
- 全局审核类型:取 ShangpinLeixing 中 shenhezhuangtai=2 的第一条;若无则创建「娱乐护航」
- 创建若干审核专区30 个商品主要挂在第一个专区
- 商品图统一用默认头像相对路径 a_long/morentouxiang.jpg
- 文案仅正向「护航/陪伴/开黑」,绝不出现代练、代打、上分等
不碰:正常商品(shenhezhuangtai=1)、其它俱乐部、店铺商品。
用法:
python manage.py seed_lxs_shenhe_huhang_catalog
python manage.py seed_lxs_shenhe_huhang_catalog --apply --i-confirm-club=lxs
"""
from __future__ import annotations
from decimal import Decimal
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
ALLOWED_CLUB_ID = 'lxs'
NAME_HINT = '龙先生'
DEFAULT_AVATAR = 'a_long/morentouxiang.jpg'
SHENHE = 2 # 审核专用
# 专区(审核版)
ZONE_DEFS = [
{'mingzi': '休闲开黑', 'paixu': 30},
{'mingzi': '语音陪伴', 'paixu': 20},
{'mingzi': '团队协作', 'paixu': 10},
]
# 30 个正向护航/陪伴向商品(标题 + 介绍 + 价格),禁止代练代打语义
PRODUCT_DEFS = [
('王者荣耀·休闲开黑陪伴', '友好组队开黑,轻松娱乐,语音交流,快乐优先。', '19.90'),
('和平精英·小队同行', '休闲模式组队畅玩,互相配合,体验团队乐趣。', '19.90'),
('原神·探索同行', '一起探索地图与活动,聊天解谜,轻松愉快。', '29.90'),
('星穹铁道·剧情陪伴', '陪你体验剧情与活动关卡,节奏友好,交流顺畅。', '29.90'),
('无畏契约·娱乐五排', '娱乐局友好开黑,沟通协作,享受对局过程。', '25.90'),
('英雄联盟·娱乐双排', '休闲娱乐局陪伴,友好沟通,重在开心。', '25.90'),
('永劫无间·双人协作', '双人娱乐局同行,互相支援,体验动作乐趣。', '22.90'),
('三角洲行动·小队协作', '小队任务友好配合,沟通顺畅,快乐完成目标。', '29.90'),
('蛋仔派对·欢乐同乐', '休闲派对模式一起玩,轻松互动,气氛友好。', '15.90'),
('逆水寒·社交同游', '一起游历风景与活动,聊天社交,轻松惬意。', '29.90'),
('鸣潮·旅途陪伴', '结伴体验开放世界内容,节奏舒适,友好交流。', '29.90'),
('绝区零·日常同行', '日常内容一起体验,互相提醒,轻松完成。', '22.90'),
('崩坏3·活动陪伴', '活动关卡友好同行,沟通配合,享受剧情。', '19.90'),
('命运冠位指定·活动同行', '活动副本休闲体验,节奏友好,互相鼓励。', '19.90'),
('明日方舟·关卡陪伴', '关卡推图友好协助,讲解思路,轻松通关体验。', '22.90'),
('第五人格·娱乐开黑', '娱乐模式组队,友好沟通,享受对抗乐趣。', '19.90'),
('CS2·休闲约战', '休闲模式友好对局,语音交流,重在体验。', '25.90'),
('Dota2·娱乐组队', '娱乐局结伴开黑,沟通协作,快乐优先。', '25.90'),
('炉石传说·休闲对局', '休闲模式一起玩,交流构筑心得,气氛轻松。', '15.90'),
('金铲铲之战·娱乐对局', '娱乐模式畅玩,聊天互动,轻松愉快。', '15.90'),
('穿越火线·休闲开黑', '休闲模式组队,友好沟通,享受射击乐趣。', '19.90'),
('CFHD·娱乐同行', '娱乐局结伴,语音交流,过程友好。', '19.90'),
('NBA2K·友谊赛陪伴', '友谊赛模式一起打,轻松互动,享受比赛。', '22.90'),
('FIFA·友谊赛同行', '友谊赛友好对战,交流心得,气氛轻松。', '22.90'),
('我的世界·建造同行', '一起建造与探索,创意交流,氛围友好。', '19.90'),
('幻兽帕鲁·合作体验', '合作模式结伴体验,互相帮助,轻松有趣。', '19.90'),
('双人成行·合作通关', '合作解谜一起玩,沟通配合,欢乐通关。', '29.90'),
('It Takes Two·双人协作', '双人关卡友好协作,互动解谜,享受过程。', '29.90'),
('斯普拉遁·休闲对战', '休闲对战结伴,轻松交流,享受涂色乐趣。', '15.90'),
('动物森友会·岛上同游', '一起逛岛、装饰与活动,聊天放松,氛围温馨。', '19.90'),
]
class Command(BaseCommand):
help = '龙先生(lxs)审核版护航商品种子(默认 dry-run'
def add_arguments(self, parser):
parser.add_argument('--apply', action='store_true', help='真正写库')
parser.add_argument(
'--i-confirm-club',
type=str,
default='',
help='必须等于 lxs 才允许 --apply',
)
parser.add_argument(
'--club-id',
type=str,
default=ALLOWED_CLUB_ID,
help='仅允许 lxs',
)
def handle(self, *args, **options):
from jituan.models import Club
from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu
club_id = (options.get('club_id') or '').strip()
apply = bool(options.get('apply'))
confirm = (options.get('i_confirm_club') or '').strip()
if club_id != ALLOWED_CLUB_ID:
raise CommandError(
f'安全拦截:只允许 club_id={ALLOWED_CLUB_ID!r},你传的是 {club_id!r}'
)
club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first()
if not club:
raise CommandError(f'俱乐部 {ALLOWED_CLUB_ID} 不存在')
if NAME_HINT not in (club.name or ''):
raise CommandError(
f'安全拦截:{ALLOWED_CLUB_ID} 的 name={club.name!r} 不含「{NAME_HINT}'
)
if apply and confirm != ALLOWED_CLUB_ID:
raise CommandError(
'安全拦截:--apply 必须带 --i-confirm-club=lxs\n'
' python manage.py seed_lxs_shenhe_huhang_catalog --apply --i-confirm-club=lxs'
)
# 审核类型:第一条;没有则计划创建
leixing = (
ShangpinLeixing.query
.filter(shenhezhuangtai=SHENHE)
.order_by('id')
.first()
)
leixing_action = 'REUSE'
if not leixing:
leixing_action = 'CREATE'
leixing = None
# 专区计划
zone_plans = []
for zdef in ZONE_DEFS:
exist = ShangpinZhuanqu.query.filter(
club_id=ALLOWED_CLUB_ID,
mingzi=zdef['mingzi'],
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).first()
if exist and leixing and exist.leixing_id == leixing.id:
zone_plans.append({'def': zdef, 'action': 'SKIP', 'row': exist})
elif exist and not leixing:
# 类型尚未创建名称撞车则复用该专区apply 时再绑类型)
zone_plans.append({'def': zdef, 'action': 'SKIP', 'row': exist})
else:
zone_plans.append({'def': zdef, 'action': 'CREATE', 'row': None})
# 商品计划:全部挂第一个专区
first_zone_name = ZONE_DEFS[0]['mingzi']
product_plans = []
for title, intro, price in PRODUCT_DEFS:
# 预检:同 club + 同标题 + 审核态是否已有
exist_p = Shangpin.query.filter(
club_id=ALLOWED_CLUB_ID,
biaoqian=title,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).first()
product_plans.append({
'title': title,
'intro': intro,
'price': price,
'action': 'SKIP' if exist_p else 'CREATE',
'exist_id': exist_p.id if exist_p else None,
})
mode = 'APPLY' if apply else 'DRY-RUN'
self.stdout.write(self.style.NOTICE(
f'\n===== {mode} 龙先生审核版护航商品 =====\n'
f'club={ALLOWED_CLUB_ID} ({club.name})\n'
f'审核类型: {leixing_action} '
f'{("id="+str(leixing.id)+" name="+repr(leixing.jieshao)) if leixing else "将创建「娱乐护航」"}\n'
f'默认图标: {DEFAULT_AVATAR}\n'
f'专区计划: create={sum(1 for z in zone_plans if z["action"]=="CREATE")} '
f'skip={sum(1 for z in zone_plans if z["action"]=="SKIP")}\n'
f'商品计划: create={sum(1 for p in product_plans if p["action"]=="CREATE")} '
f'skip={sum(1 for p in product_plans if p["action"]=="SKIP")} '
f'(共{len(product_plans)},挂专区「{first_zone_name}」)\n'
))
self.stdout.write('—— 专区 ——')
for z in zone_plans:
self.stdout.write(f' [{z["action"]}] {z["def"]["mingzi"]}')
self.stdout.write('—— 商品样例前8——')
for p in product_plans[:8]:
self.stdout.write(f' [{p["action"]}] {p["title"]} ¥{p["price"]}')
if len(product_plans) > 8:
self.stdout.write(f' ... 其余 {len(product_plans)-8} 个省略')
banned = ('代练', '代打', '上分', '上段', '包星', '定位赛代')
for p in product_plans:
blob = p['title'] + p['intro']
for b in banned:
if b in blob:
raise CommandError(f'文案含禁止词「{b}」: {p["title"]}')
if not apply:
self.stdout.write(self.style.WARNING(
'\n以上仅计划。确认后:\n'
' python manage.py seed_lxs_shenhe_huhang_catalog --apply --i-confirm-club=lxs\n'
))
return
stats = {
'leixing_created': 0,
'zones_created': 0,
'zones_skipped': 0,
'products_created': 0,
'products_skipped': 0,
}
with transaction.atomic():
if leixing is None:
leixing = ShangpinLeixing.query.create(
jieshao='娱乐护航',
tupian_url=DEFAULT_AVATAR,
paixu=0,
shenhezhuangtai=SHENHE,
yaoqiuleixing=None,
huiyuan_id=None,
yongjin=None,
)
stats['leixing_created'] = 1
elif leixing.shenhezhuangtai != SHENHE:
raise CommandError('解析到的类型不是审核专用,已中止')
zone_id_by_name = {}
for zp in zone_plans:
zdef = zp['def']
if zp['action'] == 'SKIP' and zp['row']:
# 若旧专区 leixing 不对,不改(只增不改);仅复用 id
zone_id_by_name[zdef['mingzi']] = zp['row'].id
stats['zones_skipped'] += 1
continue
exist = ShangpinZhuanqu.query.filter(
club_id=ALLOWED_CLUB_ID,
mingzi=zdef['mingzi'],
leixing_id=leixing.id,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).first()
if exist:
zone_id_by_name[zdef['mingzi']] = exist.id
stats['zones_skipped'] += 1
continue
row = ShangpinZhuanqu.query.create(
mingzi=zdef['mingzi'],
leixing_id=leixing.id,
club_id=ALLOWED_CLUB_ID,
tupian_url=DEFAULT_AVATAR,
shenhezhuangtai=SHENHE,
paixu=int(zdef['paixu']),
shangjia_zhuangtai=True,
fengjin_zhuangtai=True,
dianpu=None,
dianpu_leixing=None,
)
zone_id_by_name[zdef['mingzi']] = row.id
stats['zones_created'] += 1
first_zq_id = zone_id_by_name.get(first_zone_name)
if not first_zq_id:
raise CommandError(f'第一个专区「{first_zone_name}」未就绪')
for i, pp in enumerate(product_plans):
if pp['action'] == 'SKIP':
stats['products_skipped'] += 1
continue
exist_p = Shangpin.query.filter(
club_id=ALLOWED_CLUB_ID,
biaoqian=pp['title'],
zhuanqu_id=first_zq_id,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).first()
if exist_p:
stats['products_skipped'] += 1
continue
Shangpin.query.create(
biaoqian=pp['title'],
jiage=Decimal(pp['price']),
kucun=99999,
leixing_id=leixing.id,
zhuanqu_id=first_zq_id,
zhenshi_xiaoliang=0,
duiwai_xiaoliang=0,
jieshao=pp['intro'],
xiadan_xuzhi='请保持友好沟通,本服务为休闲娱乐陪伴,禁止不良言行。',
guize_tupian='',
tupian_url=DEFAULT_AVATAR,
yaoqiuleixing=leixing.yaoqiuleixing,
huiyuan_id=leixing.huiyuan_id,
yongjin=leixing.yongjin,
kaioi_ewai_dashou_fencheng=False,
ewai_dashou_fencheng=None,
paixu=1000 - i,
shenhezhuangtai=SHENHE,
shangjia_zhuangtai=True,
fengjin_zhuangtai=True,
shenhe_zhuangtai=True,
club_id=ALLOWED_CLUB_ID,
shi_zhuanpan=False,
dianpu=None,
dianpu_leixing=None,
)
stats['products_created'] += 1
self.stdout.write(self.style.SUCCESS(
f'\n完成: {stats}\n'
f'审核类型 id={leixing.id}{leixing.jieshao}」;'
f'商品均 shenhezhuangtai=2仅审核模式可见。'
))

View File

@@ -0,0 +1,223 @@
"""小汐电竞邀请链种子:插入组长+管事(含邀请码)。
默认 club_id=xx找不到会列出全部俱乐部。
可用:
python manage.py seed_xx_invite_data
python manage.py seed_xx_invite_data --club-id xx
python manage.py seed_xx_invite_data --list-clubs
"""
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from jituan.models import Club, ClubHuiyuanPrice
from users.business_models import User
from users.models import UserBoss, UserGuanshi, UserZuzhang
from utils.invitationcode_utils import CreateInvitationCode
DEFAULT_CLUB_ID = 'xx'
WX_APPID = 'wx0d445b76f93774fa'
NAME_HINTS = ('小汐', '小溪', '小西')
# 固定种子账号可重复执行幂等UID/手机避开 xzj 990000x、lsx 991000x
SEED_ZUZHANG = {
'UserUID': '9920001',
'OpenID': 'xx_seed_openid_zuzhang_v1',
'Phone': '19920000001',
'UserName': 'xx_seed_zuzhang',
'nickname': '小汐种子组长',
}
SEED_GUANSHI = {
'UserUID': '9920002',
'OpenID': 'xx_seed_openid_guanshi_v1',
'Phone': '19920000002',
'UserName': 'xx_seed_guanshi',
'nickname': '小汐种子管事',
}
def _list_clubs(stdout, style):
clubs = list(Club.query.order_by('sort_order', 'club_id'))
stdout.write(style.SUCCESS('=' * 60))
stdout.write(f'库里俱乐部共 {len(clubs)} 个:')
for c in clubs:
stdout.write('-' * 40)
stdout.write(f' club_id={c.club_id!r} name={c.name!r} status={c.status}')
stdout.write(f' wx_appid={c.wx_appid or "(空)"}')
stdout.write(style.SUCCESS('=' * 60))
return clubs
def _resolve_club(club_id: str, stdout, style):
"""优先精确 club_id其次 wx_appid再名称含小汐/小溪/小西。"""
cid = (club_id or DEFAULT_CLUB_ID).strip()
club = Club.query.filter(club_id=cid).first()
if club:
return club
stdout.write(style.WARNING(f'精确 club_id={cid!r} 不存在,尝试按 AppID/名称匹配…'))
by_app = Club.query.filter(wx_appid=WX_APPID).first()
if by_app:
stdout.write(style.WARNING(
f'按 wx_appid={WX_APPID} 命中: club_id={by_app.club_id!r} name={by_app.name!r}'
))
return by_app
hits = []
for hint in NAME_HINTS:
hits.extend(list(Club.query.filter(name__icontains=hint)))
# 去重
seen = set()
unique = []
for c in hits:
if c.club_id in seen:
continue
seen.add(c.club_id)
unique.append(c)
if len(unique) == 1:
c = unique[0]
stdout.write(style.WARNING(
f'按名称命中唯一: club_id={c.club_id!r} name={c.name!r}'
))
return c
if len(unique) > 1:
stdout.write(style.ERROR('名称匹配到多条,请用 --club-id 指定:'))
for c in unique:
stdout.write(f' - {c.club_id!r} / {c.name!r}')
return None
return None
def _ensure_user(meta, club_id):
user = User.query.filter(UserUID=meta['UserUID']).first()
if not user:
user = User.query.filter(OpenID=meta['OpenID']).first()
if user:
if (user.ClubID or '') != club_id:
user.ClubID = club_id
user.save(update_fields=['ClubID'])
return user, False
user = User.query.create(
UserUID=meta['UserUID'],
UserName=meta['UserName'],
OpenID=meta['OpenID'],
Phone=meta['Phone'],
ClubID=club_id,
)
UserBoss.query.create(user=user, nickname=meta['nickname'])
return user, True
def _ensure_zuzhang(user):
try:
return user.ZuzhangProfile, False
except UserZuzhang.DoesNotExist:
pass
yaoqingma = CreateInvitationCode(str(user.UserUID))
z = UserZuzhang.query.create(
user=user,
yaoqingma=yaoqingma,
zhuangtai=1,
)
return z, True
def _ensure_guanshi(user, zuzhang_uid):
try:
return user.GuanshiProfile, False
except UserGuanshi.DoesNotExist:
pass
yaoqingma = CreateInvitationCode(str(user.UserUID))
g = UserGuanshi.query.create(
user=user,
yaoqingma=yaoqingma,
yaoqingren=zuzhang_uid,
zhuangtai=1,
)
return g, True
class Command(BaseCommand):
help = '插入小汐电竞种子组长+管事(含邀请码);找不到俱乐部时会列出全部 club'
def add_arguments(self, parser):
parser.add_argument(
'--club-id',
type=str,
default=DEFAULT_CLUB_ID,
help='目标俱乐部 ID默认 xx',
)
parser.add_argument(
'--list-clubs',
action='store_true',
help='只列出库里所有俱乐部,不写入种子',
)
parser.add_argument(
'--force-new-codes',
action='store_true',
help='已存在时重新生成邀请码(慎用)',
)
def handle(self, *args, **options):
if options.get('list_clubs'):
_list_clubs(self.stdout, self.style)
return
want_id = (options.get('club_id') or DEFAULT_CLUB_ID).strip()
club = _resolve_club(want_id, self.stdout, self.style)
if club is None:
self.stdout.write(self.style.ERROR(
f'找不到可用俱乐部(请求 club_id={want_id!r})。'
))
self.stdout.write(self.style.WARNING(
'请先在后台创建小汐俱乐部(club_id=xx),或:\n'
' python manage.py seed_xx_invite_data --list-clubs\n'
' python manage.py seed_xx_invite_data --club-id 真实id'
))
_list_clubs(self.stdout, self.style)
raise CommandError('俱乐部不存在,已中止(未写入任何种子)')
club_id = club.club_id
self.stdout.write(self.style.SUCCESS(
f'将写入种子到俱乐部: club_id={club_id!r} name={club.name!r} '
f'wx_appid={club.wx_appid or "(空)"}'
))
with transaction.atomic():
zz_user, zz_new = _ensure_user(SEED_ZUZHANG, club_id)
zz_profile, zz_profile_new = _ensure_zuzhang(zz_user)
if options.get('force_new_codes') and not zz_profile_new:
zz_profile.yaoqingma = CreateInvitationCode(str(zz_user.UserUID))
zz_profile.save(update_fields=['yaoqingma'])
gs_user, gs_new = _ensure_user(SEED_GUANSHI, club_id)
gs_profile, gs_profile_new = _ensure_guanshi(gs_user, zz_user.UserUID)
if options.get('force_new_codes') and not gs_profile_new:
gs_profile.yaoqingma = CreateInvitationCode(str(gs_user.UserUID))
gs_profile.save(update_fields=['yaoqingma'])
price_cnt = ClubHuiyuanPrice.query.filter(club_id=club_id).count()
self.stdout.write(self.style.SUCCESS('=' * 60))
self.stdout.write(self.style.SUCCESS(f'小汐电竞邀请链种子已就绪club_id={club_id}'))
self.stdout.write(self.style.SUCCESS('=' * 60))
self.stdout.write(f'club_huiyuan_price 条数: {price_cnt}')
self.stdout.write('')
self.stdout.write('【组长】管事注册用这个邀请码guanshizhuce:')
self.stdout.write(f' UserUID={zz_user.UserUID} Phone={SEED_ZUZHANG["Phone"]}')
self.stdout.write(f' 组长邀请码 inviteCode = {zz_profile.yaoqingma}')
self.stdout.write('')
self.stdout.write('【管事】打手/接单员注册用这个邀请码dashouzhuce:')
self.stdout.write(f' UserUID={gs_user.UserUID} Phone={SEED_GUANSHI["Phone"]}')
self.stdout.write(f' 管事邀请码 inviteCode = {gs_profile.yaoqingma}')
self.stdout.write('')
self.stdout.write('商家:后台「商家管理 → 添加商家」按用户UID开通')
self.stdout.write('')
self.stdout.write('新建: 组长=%s 管事=%s' % (
'' if zz_new or zz_profile_new else '否(已存在)',
'' if gs_new or gs_profile_new else '否(已存在)',
))
self.stdout.write(self.style.SUCCESS('=' * 60))

View File

@@ -0,0 +1,114 @@
"""诊断微信 V3 商户证书/私钥/序列号是否匹配。用法: python manage.py wx_v3_pay_diag [--club xq]"""
import os
import requests
from django.core.management.base import BaseCommand
from jituan.constants import CLUB_ID_DEFAULT
from jituan.services.wechat_pay import (
XQ_WX_PAY,
get_wechat_v3_config,
resolve_merchant_cert_serial,
_load_pem_private_key,
_public_keys_match,
)
from utils.wechat_v3 import build_authorization
class Command(BaseCommand):
help = '诊断微信 V3 提现签名:私钥、证书序列号、商户号是否一致'
def add_arguments(self, parser):
parser.add_argument('--club', default=CLUB_ID_DEFAULT, help='俱乐部 IDxq 或 xzj')
def handle(self, *args, **options):
club_id = (options.get('club') or CLUB_ID_DEFAULT).strip()
cfg = get_wechat_v3_config(club_id)
self.stdout.write('=== 微信 V3 配置诊断 ===')
self.stdout.write(f"club_id : {cfg.get('club_id')}")
self.stdout.write(f"APPID : {cfg.get('APPID')}")
self.stdout.write(f"MCHID : {cfg.get('MCHID')} (期望 {XQ_WX_PAY['mch_id']})")
self.stdout.write(f"CERT_SERIAL : {cfg.get('CERT_SERIAL_NO')}")
self.stdout.write(f"API_V3_KEY : len={len(cfg.get('API_V3_KEY') or '')}")
key_path = cfg.get('PRIVATE_KEY_PATH') or ''
self.stdout.write(f"PRIVATE_KEY : {key_path}")
self.stdout.write(f"key_exists : {os.path.isfile(key_path)}")
cert_dir = os.path.dirname(os.path.abspath(key_path)) if key_path else ''
self.stdout.write(f"cert_dir : {cert_dir}")
if cert_dir and os.path.isdir(cert_dir):
self.stdout.write('cert_dir files:')
for name in sorted(os.listdir(cert_dir)):
if name.lower().endswith('.pem'):
self.stdout.write(f' - {name}')
serial_from_match = resolve_merchant_cert_serial(key_path, '')
self.stdout.write(f"matched_serial: {serial_from_match or '(未匹配到证书)'}")
if key_path and os.path.isfile(key_path):
try:
private_key = _load_pem_private_key(key_path)
self.stdout.write('private_key : 加载成功')
from cryptography import x509
from cryptography.hazmat.backends import default_backend
tested = []
for fname in sorted(os.listdir(cert_dir)):
if not fname.lower().endswith('.pem'):
continue
if fname.lower() in ('apiclient_key.pem',):
continue
cert_path = os.path.join(cert_dir, fname)
tested.append(fname)
try:
with open(cert_path, 'rb') as f:
data = f.read()
if b'PRIVATE KEY' in data:
self.stdout.write(f' {fname}: 是私钥,跳过')
continue
cert = x509.load_pem_x509_certificate(data, default_backend())
serial = format(cert.serial_number, 'X').upper()
matched = _public_keys_match(private_key, cert.public_key())
self.stdout.write(
f' {fname}: X509证书 serial={serial} 与私钥匹配={matched}'
)
except Exception as exc:
self.stdout.write(f' {fname}: 非X509商户证书 ({exc})')
if 'apiclient_cert.pem' not in tested:
self.stdout.write(self.style.ERROR(
'\n缺少 apiclient_cert.pem'
'\n请到微信商户平台(1747846572) → 账户中心 → API安全 → 申请API证书'
'\n下载证书包,将 apiclient_cert.pem 放到目录:'
f'\n {cert_dir}'
'\n当前目录只有 apiclient_key.pem没有商户证书所以签名必然失败。'
'\npub_key.pem 是微信支付平台公钥,不能代替 apiclient_cert.pem。'
))
except Exception as exc:
self.stdout.write(self.style.ERROR(f'私钥/证书检查失败: {exc}'))
# 试探请求查一个不存在的单号仅验证签名是否通过404=签名OK401 SIGN_ERROR=签名失败)
url = 'https://api.mch.weixin.qq.com/v3/fund-app/mch-transfer/transfer-bills/out-bill-no/WX_V3_DIAG_TEST'
auth = build_authorization('GET', url, '', club_id=club_id)
try:
resp = requests.get(
url,
headers={
'Authorization': auth,
'Accept': 'application/json',
'User-Agent': 'Mozilla/5.0',
},
timeout=10,
)
self.stdout.write(f"\n探针 HTTP {resp.status_code}: {resp.text[:300]}")
if resp.status_code == 401:
self.stdout.write(self.style.ERROR(
'签名仍失败:请确认服务器 apiclient_key.pem / apiclient_cert.pem '
'是商户号 1747846572 在微信商户平台下载的那一对'
))
elif resp.status_code in (404, 200):
self.stdout.write(self.style.SUCCESS('签名已通过404/200 表示微信接受了商户签名)'))
except requests.RequestException as exc:
self.stdout.write(self.style.ERROR(f'探针请求失败: {exc}'))

View File

@@ -0,0 +1,68 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('jituan', '0010_club_huiyuan_bundle_include'),
]
operations = [
migrations.CreateModel(
name='ClubPaymentChannel',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16, verbose_name='俱乐部ID')),
('channel', models.CharField(
choices=[
('wechat_jsapi', '微信直连-小程序JSAPI'),
('wechat_v3', '微信直连-V3转账/退款'),
('alipay_wap', '支付宝-手机网站'),
('fubei_wx_mini', '付呗-微信小程序'),
],
db_index=True,
max_length=32,
)),
('name', models.CharField(help_text='如:主付呗、备用微信', max_length=64, verbose_name='配置名称')),
('enabled', models.IntegerField(default=1, verbose_name='1启用0停用')),
('is_default', models.BooleanField(default=True, verbose_name='同通道默认配置')),
('sort_order', models.IntegerField(default=0, verbose_name='前端展示顺序')),
('sandbox', models.BooleanField(default=False, verbose_name='沙箱/联调')),
('app_id', models.CharField(blank=True, default='', max_length=64, verbose_name='AppID/付呗ID')),
('mch_id', models.CharField(blank=True, default='', max_length=64, verbose_name='商户号')),
('api_key_enc', models.TextField(blank=True, default='', verbose_name='API密钥/付呗Secret(加密)')),
('private_key_enc', models.TextField(blank=True, default='', verbose_name='应用私钥(加密)')),
('public_key_enc', models.TextField(blank=True, default='', verbose_name='平台公钥(加密或明文)')),
('cert_serial_no', models.CharField(blank=True, default='', max_length=64)),
('private_key_path', models.CharField(blank=True, default='', max_length=255)),
('platform_cert_dir', models.CharField(blank=True, default='', max_length=255)),
('notify_url', models.CharField(blank=True, default='', max_length=512, verbose_name='异步回调URL')),
('return_url', models.CharField(blank=True, default='', max_length=512, verbose_name='同步返回URL')),
('gateway_url', models.CharField(blank=True, default='', max_length=255, verbose_name='API网关')),
('mini_app_id', models.CharField(
blank=True,
default='',
help_text='付呗/微信JSAPI空则回落 club.wx_appid',
max_length=32,
verbose_name='支付用小程序AppID',
)),
('extra_json', models.JSONField(blank=True, default=dict, verbose_name='扩展配置')),
('remark', models.CharField(blank=True, default='', max_length=255)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '俱乐部支付通道',
'db_table': 'club_payment_channel',
'unique_together': {('club_id', 'channel', 'name')},
},
),
migrations.AddIndex(
model_name='clubpaymentchannel',
index=models.Index(fields=['club_id', 'channel', 'enabled'], name='idx_cpc_club_ch_en'),
),
migrations.AddIndex(
model_name='clubpaymentchannel',
index=models.Index(fields=['club_id', 'is_default'], name='idx_cpc_club_default'),
),
]

View File

@@ -0,0 +1,33 @@
"""club 表增加 mch_key 列APIv2 小程序支付密钥),从 config_json 迁移历史数据。"""
from django.db import migrations, models
def copy_mch_key_from_config_json(apps, schema_editor):
Club = apps.get_model('jituan', 'Club')
for club in Club.objects.all():
cfg = club.config_json or {}
legacy = (cfg.get('mch_key') or cfg.get('shanghumiyao') or '').strip()
if legacy and not (club.mch_key or '').strip():
club.mch_key = legacy
club.save(update_fields=['mch_key'])
class Migration(migrations.Migration):
dependencies = [
('jituan', '0011_club_payment_channel'),
]
operations = [
migrations.AddField(
model_name='club',
name='mch_key',
field=models.CharField(
blank=True,
default='',
max_length=128,
verbose_name='APIv2商户密钥',
),
),
migrations.RunPython(copy_mch_key_from_config_json, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,70 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('jituan', '0012_club_mch_key'),
]
operations = [
migrations.AddField(
model_name='club',
name='pay_cert_path',
field=models.CharField(
blank=True, default='', help_text='apiclient_cert.pem退款/V2 证书用',
max_length=512, verbose_name='收款商户证书路径',
),
),
migrations.AddField(
model_name='club',
name='pay_key_path',
field=models.CharField(
blank=True, default='', help_text='apiclient_key.pem退款/V2 证书用',
max_length=512, verbose_name='收款商户私钥路径',
),
),
migrations.AddField(
model_name='club',
name='withdraw_mch_id',
field=models.CharField(
blank=True, default='', help_text='空则回落收款商户号 mch_id',
max_length=32, verbose_name='提现商户号',
),
),
migrations.AlterField(
model_name='club',
name='mch_id',
field=models.CharField(blank=True, default='', max_length=32, verbose_name='收款商户号'),
),
migrations.AlterField(
model_name='club',
name='pay_app_id',
field=models.CharField(blank=True, default='', max_length=32, verbose_name='收款AppID'),
),
migrations.AlterField(
model_name='club',
name='mch_key',
field=models.CharField(blank=True, default='', max_length=128, verbose_name='收款APIv2密钥'),
),
migrations.AlterField(
model_name='club',
name='api_v3_key',
field=models.CharField(blank=True, default='', max_length=128, verbose_name='提现APIv3密钥'),
),
migrations.AlterField(
model_name='club',
name='cert_serial_no',
field=models.CharField(blank=True, default='', max_length=64, verbose_name='提现证书序列号'),
),
migrations.AlterField(
model_name='club',
name='private_key_path',
field=models.CharField(blank=True, default='', max_length=512, verbose_name='提现商户私钥路径'),
),
migrations.AlterField(
model_name='club',
name='platform_cert_dir',
field=models.CharField(blank=True, default='', max_length=512, verbose_name='提现平台证书目录'),
),
]

View File

@@ -0,0 +1,41 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('jituan', '0013_club_pay_withdraw_split'),
]
operations = [
migrations.AddField(
model_name='club',
name='mini_pay_channel',
field=models.CharField(
blank=True,
default='wechat',
help_text='wechat=微信直连fubei=付呗;默认 wechat不影响现网',
max_length=16,
verbose_name='小程序收款通道',
),
),
migrations.CreateModel(
name='PayChannelLedger',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('out_trade_no', models.CharField(db_index=True, max_length=64, unique=True, verbose_name='商户订单号')),
('club_id', models.CharField(db_index=True, default='', max_length=16)),
('channel', models.CharField(db_index=True, max_length=32, verbose_name='通道')),
('channel_config_id', models.IntegerField(blank=True, null=True)),
('biz_type', models.CharField(blank=True, default='', max_length=16, verbose_name='order/czjilu')),
('provider_order_sn', models.CharField(blank=True, default='', max_length=64, verbose_name='通道侧订单号')),
('amount_yuan', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '支付通道流水',
'db_table': 'pay_channel_ledger',
},
),
]

View File

@@ -0,0 +1,31 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('jituan', '0014_club_mini_pay_channel_and_ledger'),
]
operations = [
migrations.CreateModel(
name='ClubRechargePageCopy',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16, verbose_name='俱乐部ID')),
('page_key', models.CharField(db_index=True, max_length=16, verbose_name='huiyuan|yajin')),
('region_key', models.CharField(db_index=True, max_length=32, verbose_name='rules|benefits|advantages')),
('title', models.CharField(blank=True, default='', max_length=64, verbose_name='区域标题')),
('items_json', models.JSONField(blank=True, default=list, verbose_name='条目列表JSON')),
('enabled', models.BooleanField(default=True, verbose_name='是否启用该区域')),
('sort_order', models.IntegerField(default=0)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '充值页文案配置',
'db_table': 'club_recharge_page_copy',
'unique_together': {('club_id', 'page_key', 'region_key')},
},
),
]

View File

@@ -0,0 +1,35 @@
# Generated manually for ClubOrderGrabLink
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('jituan', '0015_club_recharge_page_copy'),
]
operations = [
migrations.CreateModel(
name='ClubOrderGrabLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('grabber_club_id', models.CharField(db_index=True, max_length=16, verbose_name='抢单方俱乐部')),
('order_club_id', models.CharField(db_index=True, max_length=16, verbose_name='被抢订单所属俱乐部')),
('CreateTime', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': '俱乐部订单互通抢单',
'db_table': 'club_order_grab_link',
'unique_together': {('grabber_club_id', 'order_club_id')},
},
),
migrations.AddIndex(
model_name='clubordergrablink',
index=models.Index(fields=['grabber_club_id'], name='idx_cogl_grabber'),
),
migrations.AddIndex(
model_name='clubordergrablink',
index=models.Index(fields=['order_club_id'], name='idx_cogl_order'),
),
]

View File

@@ -13,12 +13,34 @@ class Club(QModel):
status = models.IntegerField(default=1, verbose_name='1启用0停用')
wx_appid = models.CharField(max_length=32, blank=True, default='')
wx_secret = models.CharField(max_length=128, blank=True, default='')
mch_id = models.CharField(max_length=32, blank=True, default='')
pay_app_id = models.CharField(max_length=32, blank=True, default='')
api_v3_key = models.CharField(max_length=128, blank=True, default='')
cert_serial_no = models.CharField(max_length=64, blank=True, default='')
private_key_path = models.CharField(max_length=255, blank=True, default='')
platform_cert_dir = models.CharField(max_length=255, blank=True, default='')
# —— 点单/会员等收款商户V2 JSAPI退款也应对齐这套——
mch_id = models.CharField(max_length=32, blank=True, default='', verbose_name='收款商户号')
pay_app_id = models.CharField(max_length=32, blank=True, default='', verbose_name='收款AppID')
mch_key = models.CharField(max_length=128, blank=True, default='', verbose_name='收款APIv2密钥')
pay_cert_path = models.CharField(
max_length=512, blank=True, default='',
verbose_name='收款商户证书路径',
help_text='apiclient_cert.pem退款/V2 证书用',
)
pay_key_path = models.CharField(
max_length=512, blank=True, default='',
verbose_name='收款商户私钥路径',
help_text='apiclient_key.pem退款/V2 证书用',
)
# —— 提现/转账商户V3可与收款商户号不同——
withdraw_mch_id = models.CharField(
max_length=32, blank=True, default='',
verbose_name='提现商户号',
help_text='空则回落收款商户号 mch_id',
)
api_v3_key = models.CharField(max_length=128, blank=True, default='', verbose_name='提现APIv3密钥')
cert_serial_no = models.CharField(max_length=64, blank=True, default='', verbose_name='提现证书序列号')
private_key_path = models.CharField(
max_length=512, blank=True, default='', verbose_name='提现商户私钥路径',
)
platform_cert_dir = models.CharField(
max_length=512, blank=True, default='', verbose_name='提现平台证书目录',
)
official_appid = models.CharField(max_length=32, blank=True, default='')
official_secret = models.CharField(max_length=128, blank=True, default='')
official_token = models.CharField(max_length=64, blank=True, default='')
@@ -30,6 +52,14 @@ class Club(QModel):
goeasy_appkey = models.CharField(max_length=64, blank=True, default='')
goeasy_secret = models.CharField(max_length=128, blank=True, default='')
config_json = models.JSONField(default=dict, blank=True)
# 小程序「微信支付」入口实际走哪条通道wechat=直连商户号fubei=付呗(前端仍传 wechat
mini_pay_channel = models.CharField(
max_length=16,
blank=True,
default='wechat',
verbose_name='小程序收款通道',
help_text='wechat=微信直连fubei=付呗;默认 wechat不影响现网',
)
company_uuid = models.BinaryField(max_length=16, null=True, blank=True)
sort_order = models.IntegerField(default=0)
CreateTime = models.DateTimeField(auto_now_add=True)
@@ -40,6 +70,70 @@ class Club(QModel):
verbose_name = '俱乐部'
class ClubPaymentChannel(QModel):
"""俱乐部支付通道配置(可多条;同 channel 用 is_default 选主配置)。"""
CHANNEL_CHOICES = (
('wechat_jsapi', '微信直连-小程序JSAPI'),
('wechat_v3', '微信直连-V3转账/退款'),
('alipay_wap', '支付宝-手机网站'),
('fubei_wx_mini', '付呗-微信小程序'),
)
club_id = models.CharField(max_length=16, db_index=True, verbose_name='俱乐部ID')
channel = models.CharField(max_length=32, choices=CHANNEL_CHOICES, db_index=True)
name = models.CharField(max_length=64, verbose_name='配置名称', help_text='如:主付呗、备用微信')
enabled = models.IntegerField(default=1, verbose_name='1启用0停用')
is_default = models.BooleanField(default=True, verbose_name='同通道默认配置')
sort_order = models.IntegerField(default=0, verbose_name='前端展示顺序')
sandbox = models.BooleanField(default=False, verbose_name='沙箱/联调')
app_id = models.CharField(max_length=64, blank=True, default='', verbose_name='AppID/付呗ID')
mch_id = models.CharField(max_length=64, blank=True, default='', verbose_name='商户号')
api_key_enc = models.TextField(blank=True, default='', verbose_name='API密钥/付呗Secret(加密)')
private_key_enc = models.TextField(blank=True, default='', verbose_name='应用私钥(加密)')
public_key_enc = models.TextField(blank=True, default='', verbose_name='平台公钥(加密或明文)')
cert_serial_no = models.CharField(max_length=64, blank=True, default='')
private_key_path = models.CharField(max_length=255, blank=True, default='')
platform_cert_dir = models.CharField(max_length=255, blank=True, default='')
notify_url = models.CharField(max_length=512, blank=True, default='', verbose_name='异步回调URL')
return_url = models.CharField(max_length=512, blank=True, default='', verbose_name='同步返回URL')
gateway_url = models.CharField(max_length=255, blank=True, default='', verbose_name='API网关')
mini_app_id = models.CharField(
max_length=32, blank=True, default='',
verbose_name='支付用小程序AppID',
help_text='付呗/微信JSAPI空则回落 club.wx_appid',
)
extra_json = models.JSONField(blank=True, default=dict, verbose_name='扩展配置')
remark = models.CharField(max_length=255, blank=True, default='')
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'club_payment_channel'
verbose_name = '俱乐部支付通道'
unique_together = [['club_id', 'channel', 'name']]
indexes = [
models.Index(fields=['club_id', 'channel', 'enabled'], name='idx_cpc_club_ch_en'),
models.Index(fields=['club_id', 'is_default'], name='idx_cpc_club_default'),
]
class PayChannelLedger(QModel):
"""下单时记录实际支付通道,供回调路由与退款选用(前端无感知)。"""
out_trade_no = models.CharField(max_length=64, unique=True, db_index=True, verbose_name='商户订单号')
club_id = models.CharField(max_length=16, db_index=True, default='')
channel = models.CharField(max_length=32, db_index=True, verbose_name='通道')
channel_config_id = models.IntegerField(null=True, blank=True)
biz_type = models.CharField(max_length=16, blank=True, default='', verbose_name='order/czjilu')
provider_order_sn = models.CharField(max_length=64, blank=True, default='', verbose_name='通道侧订单号')
amount_yuan = models.DecimalField(max_digits=12, decimal_places=2, default=0)
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'pay_channel_ledger'
verbose_name = '支付通道流水'
class UserWxOpenid(QModel):
"""微信 openid 与俱乐部用户绑定club_id + openid 唯一)。"""
club_id = models.CharField(max_length=16, db_index=True)
@@ -316,6 +410,24 @@ class ClubDashouExamPass(QModel):
unique_together = [['club_id', 'yonghuid']]
class ClubRechargePageCopy(QModel):
"""会员充值页 / 履约金页按俱乐部可配置的文案与图标。"""
club_id = models.CharField(max_length=16, db_index=True, verbose_name='俱乐部ID')
page_key = models.CharField(max_length=16, db_index=True, verbose_name='huiyuan|yajin')
region_key = models.CharField(max_length=32, db_index=True, verbose_name='rules|benefits|advantages')
title = models.CharField(max_length=64, blank=True, default='', verbose_name='区域标题')
items_json = models.JSONField(default=list, blank=True, verbose_name='条目列表JSON')
enabled = models.BooleanField(default=True, verbose_name='是否启用该区域')
sort_order = models.IntegerField(default=0)
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'club_recharge_page_copy'
unique_together = [['club_id', 'page_key', 'region_key']]
verbose_name = '充值页文案配置'
class KefuMenuPage(QModel):
"""客服后台左侧菜单页(固定 page_id与前端路由对应"""
page_id = models.CharField(max_length=64, primary_key=True, verbose_name='页面ID')
@@ -332,3 +444,23 @@ class KefuMenuPage(QModel):
class Meta:
db_table = 'kefu_menu_page'
ordering = ['sort_order', 'page_id']
class ClubOrderGrabLink(QModel):
"""
俱乐部订单单向互通抢单。
grabber_club_id 的打手可抢 order_club_id 的待接单;反向需另建一条。
关停:删除记录即可(两字段,无开关列)。
"""
grabber_club_id = models.CharField(max_length=16, db_index=True, verbose_name='抢单方俱乐部')
order_club_id = models.CharField(max_length=16, db_index=True, verbose_name='被抢订单所属俱乐部')
CreateTime = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'club_order_grab_link'
verbose_name = '俱乐部订单互通抢单'
unique_together = [['grabber_club_id', 'order_club_id']]
indexes = [
models.Index(fields=['grabber_club_id'], name='idx_cogl_grabber'),
models.Index(fields=['order_club_id'], name='idx_cogl_order'),
]

View File

@@ -190,27 +190,36 @@ def get_switchable_club_ids(user):
def can_manage_roles_for_club(user, permissions, club_id):
"""
是否可为指定俱乐部分配/移除功能角色。
集团高层:全部俱乐部;否则须在该俱乐部拥有 000001 超级管理权限。
是否可为指定俱乐部分配/移除功能角色、添加本店后台用户
集团高层:全部俱乐部;否则须在该俱乐部拥有 000001 超级管理(不必有集团权限
"""
if can_manage_admin_assignments(user, permissions):
return True
cid = (club_id or '').strip()
if not cid:
return False
from jituan.services.club_rbac import resolve_permission_codes_for_club
codes = resolve_permission_codes_for_club(user, cid)
return '000001' in codes
from jituan.constants import DATA_SCOPE_SINGLE
from jituan.services.club_rbac import resolve_effective_permission_codes
# 与菜单/verify_kefu 同一套:含 legacy避免「页面能进但添加按钮出不来」
codes = resolve_effective_permission_codes(user, cid, DATA_SCOPE_SINGLE)
return '000001' in (codes or [])
def get_manageable_club_ids(user, permissions=None):
"""当前操作者可管理角色分配的俱乐部集合。"""
def get_manageable_club_ids(user, permissions=None, current_club_id=None):
"""
当前操作者可管理角色/后台用户的俱乐部集合。
current_club_id若请求上下文已带 000001务必把当前顶栏俱乐部算进去。
"""
if can_manage_admin_assignments(user, permissions):
return {c.club_id for c in Club.query.filter(status=1)}
return {
ids = {
c.club_id for c in Club.query.filter(status=1)
if can_manage_roles_for_club(user, permissions, c.club_id)
}
cid = (current_club_id or '').strip()
if cid and permissions and '000001' in permissions:
ids.add(cid)
return ids
def can_access_admin_user_management(user, permissions=None):

View File

@@ -18,6 +18,41 @@ from jituan.constants import CLUB_ID_DEFAULT
logger = logging.getLogger(__name__)
ALIPAY_DISABLED_MSG = '支付宝支付功能暂不可用,请使用微信支付'
def normalize_pay_club_id(club_id=None):
return (club_id or '').strip().lower() or CLUB_ID_DEFAULT
def is_alipay_enabled_for_club(club_id=None):
"""
是否允许该俱乐部「新发起」支付宝支付。
仅 settings.ALIPAY_DISABLED_CLUB_IDS 中的俱乐部为 False其余一律 True。
不影响异步回调验签与查单;不影响提现支付宝收款。
"""
cid = normalize_pay_club_id(club_id)
disabled = getattr(settings, 'ALIPAY_DISABLED_CLUB_IDS', None) or ()
try:
disabled_set = {str(x).strip().lower() for x in disabled if x is not None}
except TypeError:
disabled_set = set()
return cid not in disabled_set
def assert_alipay_allowed_for_club(club_id=None):
if not is_alipay_enabled_for_club(club_id):
raise Exception(ALIPAY_DISABLED_MSG)
def alipay_pay_method_block_reason(pay_method, club_id=None):
"""支付接口入口:仅 pay_method=alipay 且俱乐部禁用时返回文案;微信/其它一律 None。"""
if (pay_method or '').strip().lower() != 'alipay':
return None
if is_alipay_enabled_for_club(club_id):
return None
return ALIPAY_DISABLED_MSG
# ==================== 配置读取 ====================
@@ -33,22 +68,38 @@ def get_alipay_config(club_id=None):
else:
cfg = getattr(settings, 'ALIPAY_PROD', {})
# 充值回调优先读 secrets空则用 settings 默认地址(绝不回落到订单 NOTIFY_URL
czjilu_notify = (cfg.get('NOTIFY_URL_CZJILU') or '').strip()
order_notify = (cfg.get('NOTIFY_URL') or '').strip()
default_czjilu = (getattr(settings, 'ALIPAY_CZJILU_NOTIFY_URL', '') or '').strip()
if not czjilu_notify or (order_notify and czjilu_notify.rstrip('/') == order_notify.rstrip('/')):
czjilu_notify = default_czjilu
return {
'APP_ID': cfg.get('APP_ID', ''),
'PID': cfg.get('PID', ''),
'APP_PRIVATE_KEY': cfg.get('APP_PRIVATE_KEY', ''),
'ALIPAY_PUBLIC_KEY': cfg.get('ALIPAY_PUBLIC_KEY', ''),
'GATEWAY': cfg.get('GATEWAY', 'https://openapi.alipay.com/gateway.do'),
'NOTIFY_URL': cfg.get('NOTIFY_URL', ''),
'NOTIFY_URL_CZJILU': cfg.get('NOTIFY_URL_CZJILU', '') or cfg.get('NOTIFY_URL', ''),
'NOTIFY_URL': order_notify,
'NOTIFY_URL_CZJILU': czjilu_notify,
'RETURN_URL': cfg.get('RETURN_URL', ''),
'sandbox': sandbox,
}
def get_czjilu_notify_url(club_id=None):
"""获取 Czjilu 充值(押金/积分/会员/商家/考核/罚款)的支付宝异步回调地址。"""
return get_alipay_config(club_id)['NOTIFY_URL_CZJILU']
"""获取 Czjilu 充值回调地址secrets 可选覆盖,否则用 settings 写死的正确地址。
密钥/APP_ID 仍用原有 ALIPAY_PROD不必再配新密钥。
禁止回落到订单 NOTIFY_URL避免充值通知被订单回调吞掉。
"""
cfg = get_alipay_config(club_id)
notify_url = (cfg.get('NOTIFY_URL_CZJILU') or '').strip()
if not notify_url:
notify_url = (getattr(settings, 'ALIPAY_CZJILU_NOTIFY_URL', '') or '').strip()
if not notify_url:
raise Exception('缺少支付宝充值回调地址 ALIPAY_CZJILU_NOTIFY_URL')
return notify_url
def generate_alipay_czjilu_pay_params(dingdanid, jine, subject, club_id=None, expire_minutes=30):
@@ -57,6 +108,7 @@ def generate_alipay_czjilu_pay_params(dingdanid, jine, subject, club_id=None, ex
返回 {qr_code, qr_image, pay_method},并在 cache 标记该订单为支付宝订单
(轮询接口据此跳过微信查单,直接等异步回调更新本地状态)。
"""
assert_alipay_allowed_for_club(club_id)
from django.core.cache import cache
notify_url = get_czjilu_notify_url(club_id)
pay_url = build_wap_pay_url(
@@ -66,6 +118,7 @@ def generate_alipay_czjilu_pay_params(dingdanid, jine, subject, club_id=None, ex
club_id=club_id,
expire_minutes=expire_minutes,
notify_url=notify_url,
_skip_club_gate=True,
)
qr_image = qr_link_to_base64(pay_url)
cache.set(f'alipay_pay_url_{dingdanid}', pay_url, 60 * 30)
@@ -227,7 +280,10 @@ def verify_notify_params(params, club_id=None):
# ==================== wap.pay 跳转 URL ====================
def build_wap_pay_url(out_trade_no, amount, subject, body='', club_id=None, expire_minutes=30, notify_url=None):
def build_wap_pay_url(
out_trade_no, amount, subject, body='', club_id=None, expire_minutes=30,
notify_url=None, _skip_club_gate=False,
):
"""
生成 alipay.trade.wap.pay 跳转 URLGET 方式)。
@@ -236,13 +292,16 @@ def build_wap_pay_url(out_trade_no, amount, subject, body='', club_id=None, expi
amount: 订单金额Decimal 或字符串,单位元)
subject: 订单标题
body: 订单描述(可选)
club_id: 预留多 club暂未使用
club_id: 俱乐部;命中 ALIPAY_DISABLED_CLUB_IDS 时拒绝发起
expire_minutes: 超时关闭时间(分钟)
notify_url: 自定义异步回调地址(可选,默认用 cfg['NOTIFY_URL']
_skip_club_gate: 内部已校验过时跳过(避免双重校验)
返回:
完整的支付宝跳转 URL前端 web-view 直接加载即可。
"""
if not _skip_club_gate:
assert_alipay_allowed_for_club(club_id)
cfg = get_alipay_config(club_id)
biz_content = {
@@ -295,6 +354,7 @@ def build_precreate_qr(out_trade_no, amount, subject, body='', club_id=None, exp
"""
import requests
assert_alipay_allowed_for_club(club_id)
cfg = get_alipay_config(club_id)
biz_content = {
@@ -387,3 +447,61 @@ def club_id_from_czjilu(dingdan_id):
except Exception:
pass
return CLUB_ID_DEFAULT
# ==================== 主动查单alipay.trade.query====================
def query_alipay_trade(out_trade_no, club_id=None):
"""主动查询支付宝交易状态。
调用 alipay.trade.query 接口,返回 trade_status 字符串:
WAIT_BUYER_PAY / TRADE_CLOSED / TRADE_SUCCESS / TRADE_FINISHED
查询失败或异常返回空字符串。
用于轮询补偿:支付宝异步回调未到达时,通过主动查单确认支付状态。
"""
import requests
cfg = get_alipay_config(club_id)
biz_content = {
'out_trade_no': out_trade_no,
}
params = {
'app_id': cfg['APP_ID'],
'method': 'alipay.trade.query',
'charset': 'utf-8',
'sign_type': 'RSA2',
'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'),
'version': '1.0',
'biz_content': json.dumps(biz_content, separators=(',', ':'), ensure_ascii=False),
}
sign_content = _build_sign_content(params)
params['sign'] = sign_rsa2(sign_content, cfg['APP_PRIVATE_KEY'])
try:
resp = requests.post(cfg['GATEWAY'], data=params, timeout=10)
result = resp.json()
except Exception as exc:
logger.error('支付宝查单请求异常 out_trade_no=%s: %s', out_trade_no, exc, exc_info=True)
return ''
biz_resp = result.get('alipay_trade_query_response', {})
code = biz_resp.get('code', '')
trade_status = biz_resp.get('trade_status', '')
if code == '10000':
logger.info('支付宝查单成功 out_trade_no=%s trade_status=%s', out_trade_no, trade_status)
return trade_status
# 40004 = 交易不存在(订单未在支付宝侧创建或已关闭)
sub_code = biz_resp.get('sub_code', '')
sub_msg = biz_resp.get('sub_msg', '')
logger.warning(
'支付宝查单返回非成功 out_trade_no=%s code=%s msg=%s sub_code=%s sub_msg=%s',
out_trade_no, code, biz_resp.get('msg', ''), sub_code, sub_msg,
)
return trade_status or ''

View File

@@ -1,15 +1,15 @@
"""集团全局商品目录(商品/类型/专区)须在集团汇总视图操作"""
"""商品与专区按俱乐部隔离;类型仍可走集团汇总"""
from jituan.constants import DATA_SCOPE_ALL
from jituan.services.club_context import resolve_club_scope
GROUP_CATALOG_SCOPE_MSG = (
'商品类型与专区为集团全局数据,请在顶栏切换为「集团汇总(全部子公司)」后操作;'
'具体小程序仅可在「小程序配置」中做上架/图标等俱乐部侧配置'
'商品类型为集团全局数据,请在顶栏切换为「集团汇总(全部子公司)」后操作;'
'商品与专区已按俱乐部隔离,切换对应俱乐部即可管理'
)
def block_non_group_catalog_scope(request):
"""非集团汇总视图时返回错误文案,否则返回 None。"""
"""非集团汇总视图时返回错误文案,否则返回 None。用于类型等全局目录。"""
if resolve_club_scope(request) != DATA_SCOPE_ALL:
return GROUP_CATALOG_SCOPE_MSG
return None

View File

@@ -0,0 +1,120 @@
"""俱乐部微信商户证书落盘(收款 / 提现分目录)。"""
import logging
import os
import re
from django.conf import settings
logger = logging.getLogger(__name__)
# pay = 点单收款商户withdraw = 提现转账商户
CERT_KINDS = {
'pay_key': ('pay', 'apiclient_key.pem', 'pay_key_path'),
'pay_cert': ('pay', 'apiclient_cert.pem', 'pay_cert_path'),
'withdraw_key': ('withdraw', 'apiclient_key.pem', 'private_key_path'),
'withdraw_cert': ('withdraw', 'apiclient_cert.pem', None), # 与私钥同目录,不单独存字段
'withdraw_platform': ('withdraw', 'pub_key.pem', 'platform_cert_dir'), # 目录字段指向所在目录
}
_SAFE_CLUB = re.compile(r'^[a-zA-Z0-9_-]{1,16}$')
def club_wx_cert_root():
"""
证书根目录。优先 settings.CLUB_WX_CERT_ROOT
否则尝试沿用现网 secrets 父目录下的 wx_certs最后 BASE_DIR/wx_certs。
"""
configured = (getattr(settings, 'CLUB_WX_CERT_ROOT', None) or '').strip()
if configured:
return configured
# 兼容现网:.../sites/xxx/tuikuanzhengshu → .../sites/xxx/wx_certs
for attr in ('WEIXIN_CERT_PATH', 'WEIXIN_KEY_PATH'):
p = (getattr(settings, attr, None) or '').strip()
if p:
parent = os.path.dirname(os.path.dirname(os.path.abspath(p)))
if parent and parent not in ('.', os.sep):
return os.path.join(parent, 'wx_certs')
v3 = getattr(settings, 'WECHAT_PAY_V3_CONFIG', None) or {}
pk = (v3.get('PRIVATE_KEY_PATH') or '').strip()
if pk:
parent = os.path.dirname(os.path.dirname(os.path.abspath(pk)))
if parent and parent not in ('.', os.sep):
return os.path.join(parent, 'wx_certs')
return os.path.join(str(settings.BASE_DIR), 'wx_certs')
def club_cert_dir(club_id, bucket):
cid = (club_id or '').strip()
if not _SAFE_CLUB.match(cid):
raise ValueError('非法 club_id')
if bucket not in ('pay', 'withdraw'):
raise ValueError('非法证书分类')
path = os.path.join(club_wx_cert_root(), cid, bucket)
os.makedirs(path, mode=0o750, exist_ok=True)
return path
def _validate_pem(raw: bytes, expect_private: bool):
if not raw or len(raw) > 64 * 1024:
raise ValueError('文件过大或为空')
text = raw.decode('utf-8', errors='ignore')
if 'BEGIN' not in text or 'END' not in text:
raise ValueError('不是有效的 PEM 文件')
has_private = 'PRIVATE KEY' in text
if expect_private and not has_private:
raise ValueError('需要私钥 PEM含 PRIVATE KEY')
if not expect_private and has_private:
raise ValueError('请上传证书/公钥 PEM不要上传私钥')
return raw
def save_club_cert_file(club, kind, uploaded_file):
"""
保存上传的证书文件并回写 Club 字段。
kind: pay_key | pay_cert | withdraw_key | withdraw_cert | withdraw_platform
返回 {'path': ..., 'field': ..., 'kind': ...}
"""
if kind not in CERT_KINDS:
raise ValueError(f'不支持的文件类型: {kind}')
bucket, filename, field_name = CERT_KINDS[kind]
expect_private = kind in ('pay_key', 'withdraw_key')
raw = uploaded_file.read()
raw = _validate_pem(raw, expect_private=expect_private)
directory = club_cert_dir(club.club_id, bucket)
dest = os.path.join(directory, filename)
# 原子写入
tmp = dest + '.tmp'
with open(tmp, 'wb') as f:
f.write(raw)
os.chmod(tmp, 0o640)
os.replace(tmp, dest)
update_fields = []
if field_name == 'platform_cert_dir':
# 平台证书:字段存目录
setattr(club, field_name, directory)
update_fields.append(field_name)
saved_path = directory
elif field_name:
setattr(club, field_name, dest)
update_fields.append(field_name)
saved_path = dest
else:
# withdraw_cert只落盘序列号由私钥配对自动解析
saved_path = dest
if update_fields:
club.save(update_fields=update_fields)
logger.warning(
'[CLUB_CERT_UPLOAD] club=%s kind=%s path=%s field=%s',
club.club_id, kind, saved_path, field_name or '(none)',
)
return {
'kind': kind,
'path': saved_path,
'field': field_name,
'bucket': bucket,
'filename': filename,
}

View File

@@ -128,13 +128,17 @@ def build_member_list_payload(request):
for m in members:
member_list.append(_format_member(m))
else:
# 有本俱乐部专属价:只展示本店上架项;尚无专属价:回落集团默认会员定义。
# 绝不混入其他俱乐部的 ClubHuiyuanPrice。
has_club_prices = bool(price_map)
for m in members:
row = price_map.get(m.huiyuan_id)
if row is None and club_id != CLUB_ID_DEFAULT:
continue
if row is not None and not row.is_enabled:
if has_club_prices:
if row is None or not row.is_enabled:
continue
item = _format_member(m, row, club_id=club_id)
else:
item = _format_member(m, club_id=club_id)
item['goumai_cishu'] = _sales_map.get(m.huiyuan_id, 0)
member_list.append(item)
@@ -143,7 +147,7 @@ def build_member_list_payload(request):
'club_id': club_id,
'scope': scope,
'price_note': (
f'当前俱乐部 {club_id} 专属售价club_huiyuan_price'
f'当前俱乐部 {club_id} 专属售价;未配置专属价时展示集团默认会员'
if scope != DATA_SCOPE_ALL else
'集团汇总:展示全局默认价;切换具体俱乐部可配置售价与体验会员。'
),
@@ -272,9 +276,13 @@ MEMBER_CARD_FOLDER = 'beijing/huiyuan'
def upload_member_card_image(request, huiyuan_id, field, file_obj):
"""上传会员充值页卡片图,写入 club_huiyuan_price.card_image / card_bg。"""
"""上传会员充值页卡片图,写入 club_huiyuan_price.card_image / card_bg。
每次上传生成新相对路径并删除旧图,避免「同名覆盖但 CDN/浏览器仍显示旧图」。
"""
import os
from utils.oss_utils import validate_image, upload_to_oss
import uuid
from utils.oss_utils import validate_image, upload_to_oss, delete_from_oss
if club_write_blocked(request):
return None, CLUB_WRITE_SCOPE_MSG
@@ -285,18 +293,36 @@ def upload_member_card_image(request, huiyuan_id, field, file_obj):
return None, msg
club_id = club_id_for_write(request)
ext = os.path.splitext(getattr(file_obj, 'name', '') or '')[1].lower()
if ext not in ('.png', '.jpg', '.jpeg', '.webp', '.gif'):
ext = '.png'
rel_path = f'{MEMBER_CARD_FOLDER}/{club_id}/{huiyuan_id}_{field}{ext}'
if not upload_to_oss(file_obj, rel_path):
return None, '上传 OSS 失败'
row = ClubHuiyuanPrice.query.filter(club_id=club_id, huiyuan_id=huiyuan_id).first()
if not row:
return None, '请先在当前俱乐部上架该会员'
ext = os.path.splitext(getattr(file_obj, 'name', '') or '')[1].lower()
if ext not in ('.png', '.jpg', '.jpeg', '.webp', '.gif'):
ext = '.png'
# 带时间戳+uuid保证 URL 变化,前端/CDN 不会命中旧缓存
stamp = uuid.uuid4().hex[:12]
rel_path = f'{MEMBER_CARD_FOLDER}/{club_id}/{huiyuan_id}_{field}_{stamp}{ext}'
if hasattr(file_obj, 'seek'):
try:
file_obj.seek(0)
except Exception:
pass
if not upload_to_oss(file_obj, rel_path):
return None, '上传 OSS 失败'
old_path = (getattr(row, field, None) or '').strip()
setattr(row, field, rel_path)
row.save(update_fields=[field, 'UpdateTime'])
if old_path and old_path != rel_path:
try:
delete_from_oss(old_path)
except Exception as e:
logger.warning('删除旧会员卡片图失败 club=%s hy=%s field=%s path=%s err=%s',
club_id, huiyuan_id, field, old_path, e)
return {field: rel_path, 'huiyuan_id': huiyuan_id}, None

View File

@@ -0,0 +1,246 @@
"""俱乐部支付通道:读配置、加解密、付呗/微信/支付宝字段归一化。"""
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from django.conf import settings
from django.core.cache import cache
from gvsdsdk.payment.models import EncryptField, _decrypt_field
from jituan.constants import (
CLUB_ID_DEFAULT,
FUBEI_GATEWAY_PROD,
FUBEI_GATEWAY_SANDBOX,
PAY_CHANNEL_ALIPAY_WAP,
PAY_CHANNEL_FUBEI_WX_MINI,
PAY_CHANNEL_WECHAT_JSAPI,
PAY_METHOD_ALIPAY,
PAY_METHOD_FUBEI,
PAY_METHOD_LABELS,
PAY_METHOD_TO_CHANNEL,
PAY_METHOD_WECHAT,
)
from jituan.models import Club, ClubPaymentChannel
logger = logging.getLogger(__name__)
CACHE_TTL = 60
def encrypt_secret_field(plain: str) -> str:
if not plain:
return ''
return EncryptField(plain)
def decrypt_secret_field(encrypted: str) -> str:
if not encrypted:
return ''
return _decrypt_field(encrypted)
@dataclass
class PaymentChannelConfig:
"""归一化后的通道配置,供支付网关读取。"""
club_id: str
channel: str
config_id: int
name: str
enabled: bool
sandbox: bool
app_id: str = ''
mch_id: str = ''
api_key: str = ''
private_key: str = ''
public_key: str = ''
cert_serial_no: str = ''
private_key_path: str = ''
platform_cert_dir: str = ''
notify_url: str = ''
return_url: str = ''
gateway_url: str = ''
mini_app_id: str = ''
extra: Dict[str, Any] = field(default_factory=dict)
@property
def store_id(self):
return self.extra.get('store_id') or self.extra.get('fubei_store_id')
@property
def fubei_vendor_sn(self):
return self.extra.get('vendor_sn') or self.extra.get('fubei_vendor_sn') or self.app_id
def _cache_key(club_id: str, channel: str) -> str:
return f'club_pay_ch:{club_id}:{channel}'
def _default_notify_path(channel: str) -> str:
if channel == PAY_CHANNEL_FUBEI_WX_MINI:
return '/hqhd/dingdan/fubei-notify/'
if channel == PAY_CHANNEL_ALIPAY_WAP:
return '/hqhd/dingdan/alipay-notify/'
return '/hqhd/dingdan/pay-notify/'
def _build_base_url(club: Club) -> str:
domain = (club.h5_domain or '').strip().rstrip('/')
if domain:
return domain
return 'https://www.abas.asia'
def _resolve_notify_url(club: Club, row: ClubPaymentChannel) -> str:
if row.notify_url:
return row.notify_url.strip()
return f'{_build_base_url(club)}{_default_notify_path(row.channel)}'
def _row_to_config(club: Club, row: ClubPaymentChannel) -> PaymentChannelConfig:
extra = dict(row.extra_json or {})
gateway = (row.gateway_url or '').strip()
if not gateway and row.channel == PAY_CHANNEL_FUBEI_WX_MINI:
gateway = FUBEI_GATEWAY_SANDBOX if row.sandbox else FUBEI_GATEWAY_PROD
mini_app_id = (row.mini_app_id or club.pay_app_id or club.wx_appid or '').strip()
return PaymentChannelConfig(
club_id=row.club_id,
channel=row.channel,
config_id=row.id,
name=row.name,
enabled=row.enabled == 1,
sandbox=bool(row.sandbox),
app_id=(row.app_id or '').strip(),
mch_id=(row.mch_id or '').strip(),
api_key=decrypt_secret_field(row.api_key_enc),
private_key=decrypt_secret_field(row.private_key_enc),
public_key=decrypt_secret_field(row.public_key_enc) or (row.public_key_enc or '').strip(),
cert_serial_no=row.cert_serial_no or '',
private_key_path=row.private_key_path or '',
platform_cert_dir=row.platform_cert_dir or '',
notify_url=_resolve_notify_url(club, row),
return_url=(row.return_url or '').strip(),
gateway_url=gateway,
mini_app_id=mini_app_id,
extra=extra,
)
def get_club_payment_channel(
club_id: Optional[str],
channel: str,
*,
config_id: Optional[int] = None,
require_enabled: bool = True,
) -> Optional[PaymentChannelConfig]:
"""读取俱乐部某通道配置;优先 is_default可指定 config_id。"""
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
cache_k = _cache_key(cid, channel)
if config_id is None and require_enabled:
cached = cache.get(cache_k)
if cached is not None:
return cached if cached != '__none__' else None
club = Club.query.filter(club_id=cid).first()
if not club:
logger.warning('club %s 不存在,无法读取支付通道 %s', cid, channel)
return None
qs = ClubPaymentChannel.query.filter(club_id=cid, channel=channel)
if require_enabled:
qs = qs.filter(enabled=1)
if config_id is not None:
row = qs.filter(id=config_id).first()
else:
row = qs.filter(is_default=True).order_by('sort_order', 'id').first()
if not row:
row = qs.order_by('sort_order', 'id').first()
if not row:
if config_id is None and require_enabled:
cache.set(cache_k, '__none__', CACHE_TTL)
return None
cfg = _row_to_config(club, row)
if config_id is None and require_enabled:
cache.set(cache_k, cfg, CACHE_TTL)
return cfg
def invalidate_club_payment_cache(club_id: str, channel: Optional[str] = None):
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
if channel:
cache.delete(_cache_key(cid, channel))
return
for ch in (
PAY_CHANNEL_WECHAT_JSAPI,
PAY_CHANNEL_ALIPAY_WAP,
PAY_CHANNEL_FUBEI_WX_MINI,
):
cache.delete(_cache_key(cid, ch))
def get_fubei_config(club_id: Optional[str] = None) -> Optional[PaymentChannelConfig]:
"""付呗微信小程序通道配置。"""
return get_club_payment_channel(club_id, PAY_CHANNEL_FUBEI_WX_MINI)
def list_payment_methods_for_miniapp(club_id: Optional[str] = None) -> List[Dict[str, Any]]:
"""小程序下单页返回用户可见的支付方式enabled 且配置完整)。"""
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
methods = []
wechat_direct = get_club_payment_channel(cid, PAY_CHANNEL_WECHAT_JSAPI)
fubei = get_fubei_config(cid)
alipay = get_club_payment_channel(cid, PAY_CHANNEL_ALIPAY_WAP)
if fubei and fubei.app_id and fubei.api_key:
methods.append({
'code': PAY_METHOD_FUBEI,
'label': PAY_METHOD_LABELS[PAY_METHOD_FUBEI],
'channel': PAY_CHANNEL_FUBEI_WX_MINI,
'sort_order': _sort_for(cid, PAY_CHANNEL_FUBEI_WX_MINI),
})
elif wechat_direct and wechat_direct.mch_id and wechat_direct.api_key:
methods.append({
'code': PAY_METHOD_WECHAT,
'label': PAY_METHOD_LABELS[PAY_METHOD_WECHAT],
'channel': PAY_CHANNEL_WECHAT_JSAPI,
'sort_order': _sort_for(cid, PAY_CHANNEL_WECHAT_JSAPI),
})
if alipay and alipay.app_id and alipay.private_key and alipay.public_key:
from jituan.services.alipay_pay import is_alipay_enabled_for_club
if is_alipay_enabled_for_club(cid):
methods.append({
'code': PAY_METHOD_ALIPAY,
'label': PAY_METHOD_LABELS[PAY_METHOD_ALIPAY],
'channel': PAY_CHANNEL_ALIPAY_WAP,
'sort_order': _sort_for(cid, PAY_CHANNEL_ALIPAY_WAP),
})
methods.sort(key=lambda x: x.get('sort_order', 0))
return methods
def _sort_for(club_id: str, channel: str) -> int:
row = ClubPaymentChannel.query.filter(
club_id=club_id, channel=channel, enabled=1,
).order_by('-is_default', 'sort_order', 'id').first()
return row.sort_order if row else 0
def channel_for_pay_method(pay_method: str) -> Optional[str]:
return PAY_METHOD_TO_CHANNEL.get((pay_method or '').strip().lower())
# 付呗通道后台必填字段说明(文档/校验用)
FUBEI_REQUIRED_FIELDS = {
'app_id': '付呗开放平台 vendor_sn / 付呗ID',
'api_key_enc': '付呗 Secret入库前加密',
'mini_app_id': '微信小程序 AppID可与 club.wx_appid 相同;须在付呗后台关联)',
'notify_url': '异步回调,默认 /hqhd/dingdan/fubei-notify/',
'extra_json.store_id': '付呗门店 store_id服务商开户后提供统一下单必填',
}

View File

@@ -13,26 +13,44 @@ def _global_role_q():
def resolve_role_club_id(request, explicit=None):
"""创建/筛选角色时使用的俱乐部;集团视图可显式指定 target_club_id。"""
"""
创建/筛选角色时使用的俱乐部。
单俱乐部视图:强制当前顶栏俱乐部,忽略跨店 target_club_id。
集团汇总视图可显式指定未指定则为全局club_id 空)。
"""
scope = resolve_club_scope(request)
if scope != DATA_SCOPE_ALL:
return resolve_club_id_from_request(request)
if explicit:
return (explicit or '').strip()
scope = resolve_club_scope(request)
if scope == DATA_SCOPE_ALL:
return ''
return resolve_club_id_from_request(request)
def filter_roles_for_request(request):
"""
子公司视图:仅本俱乐部角色。
集团汇总视图:全部角色(含各俱乐部 + 全局 club_id 为空)
子公司视图:优先本俱乐部角色;本俱乐部尚无角色时仅回落集团全局角色。
绝不混入其他俱乐部角色
集团汇总视图:全部角色(含各俱乐部 + 全局)。
"""
qs = Role.objects.filter(RoleStatus=1)
scope = resolve_club_scope(request)
if scope == DATA_SCOPE_ALL:
return qs
club_id = resolve_club_id_from_request(request)
return qs.filter(Q(ClubID=club_id) | _global_role_q())
club_qs = qs.filter(ClubID=club_id)
if club_qs.exists():
return club_qs
return qs.filter(_global_role_q())
def role_visible_in_scope(role_club_id, request):
"""列表展示用:单俱乐部视图只允许本俱乐部或全局(无本俱乐部角色时由调用方决定)。"""
scope = resolve_club_scope(request)
if scope == DATA_SCOPE_ALL:
return True
curr = (resolve_club_id_from_request(request) or '').strip()
cid = (role_club_id or '').strip()
return cid == '' or cid == curr
def filter_kefu_users_by_admin_scope(qs, request):

View File

@@ -13,25 +13,45 @@ logger = logging.getLogger(__name__)
_REFERER_APPID_RE = re.compile(r'servicewechat\.com/(wx[0-9a-fA-F]+)/', re.I)
def get_club_miniapp_appid(club):
"""小程序 AppID登录 jscode2session 与 JSAPI 支付须一致。"""
if not club:
return ''
return (club.wx_appid or club.pay_app_id or '').strip()
def resolve_club_by_appid(app_id):
if not app_id:
return None
app_id = app_id.strip()
try:
return Club.query.filter(wx_appid=app_id, status=1).first()
club = Club.query.filter(wx_appid=app_id, status=1).first()
if club:
return club
return Club.query.filter(pay_app_id=app_id, status=1).first()
except Exception:
return Club.objects.filter(wx_appid=app_id, status=1).first()
club = Club.objects.filter(wx_appid=app_id, status=1).first()
if club:
return club
return Club.objects.filter(pay_app_id=app_id, status=1).first()
def get_club_wx_credentials(club_id):
"""优先读 club 表xq 缺省时回退 settings 全局配置(兼容现网)。"""
club_id = club_id or CLUB_ID_DEFAULT
club = None
try:
club = Club.query.get(club_id=club_id)
except Club.DoesNotExist:
club = None
except Exception:
logger.exception('读取俱乐部失败 club_id=%s', club_id)
club = None
if club and club.wx_appid and club.wx_secret:
return club.wx_appid, club.wx_secret, club
appid = get_club_miniapp_appid(club)
secret = (club.wx_secret or '').strip() if club else ''
if appid and secret:
return appid, secret, club
if club_id == CLUB_ID_DEFAULT:
return (
@@ -39,7 +59,10 @@ def get_club_wx_credentials(club_id):
getattr(settings, 'WEIXIN_SECRET', ''),
club,
)
return '', '', club
# 非默认俱乐部未配置时给出明确原因,避免后续空指针 500
if not club:
return '', '', None
return appid or '', secret or '', club
def _invite_code_from_request(request):

View File

@@ -1,9 +1,13 @@
"""用户所属俱乐部解析。"""
import logging
from django.db import IntegrityError, transaction
from jituan.constants import CLUB_ID_DEFAULT
from jituan.models import UserWxOpenid
logger = logging.getLogger(__name__)
def resolve_invite_party_club(user, request_club_id=None):
"""
@@ -106,18 +110,35 @@ def ensure_wx_openid_binding(club_id, openid, user, unionid=''):
openid = (openid or '').strip()
binding = UserWxOpenid.query.filter(club_id=club_id, openid=openid).first()
if binding:
if binding and binding.yonghuid == uid:
return binding
try:
# 独立 savepoint并发/重复绑定时不污染外层 transaction.atomic
with transaction.atomic():
return UserWxOpenid.query.create(
# 同一用户在同一俱乐部只保留当前 openidAppID 变更/重新登录须覆盖旧绑定)
stale = UserWxOpenid.query.filter(yonghuid=uid, club_id=club_id).exclude(openid=openid)
stale_count = stale.count()
if stale_count:
logger.info(
'[WX_OPENID_BIND] uid=%s club=%s 清除 %d 条过期 openid 绑定',
uid, club_id, stale_count,
)
stale.delete()
binding = UserWxOpenid.query.filter(club_id=club_id, openid=openid).first()
if binding:
return binding
row = UserWxOpenid.query.create(
club_id=club_id,
openid=openid,
yonghuid=uid,
unionid=unionid or getattr(user, 'UnionID', None) or '',
)
if club_id == CLUB_ID_DEFAULT and getattr(user, 'OpenID', None) != openid:
user.OpenID = openid
user.save(update_fields=['OpenID'])
return row
except IntegrityError:
return UserWxOpenid.query.filter(club_id=club_id, openid=openid).first()
@@ -200,32 +221,85 @@ def _looks_like_wx_openid(value):
return len(v) >= 20 and v.startswith('o')
def get_wx_openid_for_user(user, club_id=None):
def resolve_club_payment_openid(user, club_id=None):
"""
按俱乐部取微信支付 JSAPI 用 openid
多俱乐部用户 openid 在 user_wx_openidxq 老用户可回落 User.OpenID
星之界等俱乐部用户常 UserName=openid 且 OpenID 为空,再回落 UserName。
支付专用:按俱乐部取 openid与 club 表 miniapp AppID 对应
1) user_wx_openid(yonghuid, club_id)
2) 无本 club 绑定但有其他 club → 拒绝(须在本小程序重新登录)
3) 无任何绑定时 lazy backfillxq→User.OpenIDxzj 等→UserName(openid)
"""
if not user:
return ''
return '', 'none'
cid = (club_id or '').strip() or get_user_club_id(user)
uid = getattr(user, 'UserUID', None) or getattr(user, 'yonghuid', None)
if uid and cid:
binding = UserWxOpenid.query.filter(yonghuid=uid, club_id=cid).first()
if not uid or not cid:
return '', 'none'
binding = (
UserWxOpenid.query.filter(yonghuid=uid, club_id=cid)
.order_by('-id')
.first()
)
if binding and binding.openid:
return binding.openid
legacy = getattr(user, 'OpenID', None) or getattr(user, 'openid', None)
if legacy:
return legacy.strip()
return binding.openid.strip(), f'user_wx_openid#{binding.id}'
other_clubs = list(
UserWxOpenid.query.filter(yonghuid=uid)
.exclude(club_id=cid)
.values_list('club_id', flat=True)[:5]
)
if other_clubs:
logger.warning(
'[PAY_OPENID] uid=%s 无 club=%s 绑定,已有其他俱乐部 %s;须在本小程序重新登录',
uid, cid, other_clubs,
)
return '', 'other_club_only'
username = (getattr(user, 'UserName', None) or '').strip()
if _looks_like_wx_openid(username):
return username
return ''
legacy_oid = (getattr(user, 'OpenID', None) or getattr(user, 'openid', None) or '').strip()
candidate = ''
source = ''
if cid == CLUB_ID_DEFAULT:
if legacy_oid and _looks_like_wx_openid(legacy_oid):
candidate, source = legacy_oid, 'legacy_OpenID'
elif _looks_like_wx_openid(username):
candidate, source = username, 'legacy_UserName'
if candidate:
logger.info('[PAY_OPENID] uid=%s club=%s lazy backfill from %s', uid, cid, source)
ensure_wx_openid_binding(cid, candidate, user)
return candidate, source
logger.warning(
'[PAY_OPENID] uid=%s club=%s 无绑定 legacy_UserName=%s… legacy_OpenID=%s',
uid, cid, username[:10] if username else '', bool(legacy_oid),
)
return '', 'missing'
def get_wx_openid_for_user(user, club_id=None):
openid, _ = resolve_club_payment_openid(user, club_id)
return openid
def get_wx_openid_for_user_with_source(user, club_id=None):
return resolve_club_payment_openid(user, club_id)
def get_payment_openid(request, user=None, club_id=None):
"""下单/充值:按请求俱乐部解析 openid。"""
from jituan.services.club_write import resolve_club_id_for_write
user = user or getattr(request, 'user', None)
cid = club_id or resolve_club_id_for_write(request, user)
return get_wx_openid_for_user(user, cid), cid
cid = (club_id or '').strip() or resolve_club_id_for_write(request, user)
openid, source = get_wx_openid_for_user_with_source(user, cid)
if openid:
logger.info('[PAY_OPENID] uid=%s club=%s openid=%s… source=%s',
getattr(user, 'UserUID', ''), cid,
openid[:6] + '...' if len(openid) > 10 else openid, source)
elif user:
uid = getattr(user, 'UserUID', None) or getattr(user, 'yonghuid', None)
logger.warning(
'[PAY_OPENID] 无法解析支付 openid uid=%s club=%s source=%s user_club=%s',
uid, cid, source, get_user_club_id(user),
)
return openid, cid

View File

@@ -11,6 +11,7 @@ from jituan.constants import CLUB_ID_DEFAULT
from jituan.services.club_penalty import resolve_gsfenhong_club_id
from orders.models import CommissionRate
from products.models import Czjilu, Gsfenhong
from products.czjilu_types import is_czjilu_fulfillable_status
from users.models import UserDashou, UserGuanshi
logger = logging.getLogger(__name__)
@@ -26,15 +27,73 @@ class CzjiluFulfillError(Exception):
self.code = code
@transaction.atomic
def fulfill_zhuanpan(dingdan_id, paid_amount_yuan=None):
"""转盘购票履约Czjilu 标已付 + 抽奖机会改为待抽。绝不创建抢单池 Order。"""
from products.czjilu_types import CZJILU_LEIXING_ZHUANPAN, CZJILU_PAID_STATUS
from products.models import ZhuanpanJihui
try:
order = Czjilu.query.select_for_update().get(dingdan_id=dingdan_id)
except Czjilu.DoesNotExist:
raise CzjiluFulfillError('订单不存在', 'not_found')
if order.leixing != CZJILU_LEIXING_ZHUANPAN:
raise CzjiluFulfillError('订单类型不是转盘购票', 'type_error')
if order.zhuangtai == CZJILU_PAID_STATUS:
return {'already_done': True, 'dingdan_id': dingdan_id, 'zhuangtai': CZJILU_PAID_STATUS}
if not is_czjilu_fulfillable_status(order.zhuangtai):
return {'already_done': True, 'dingdan_id': dingdan_id, 'zhuangtai': order.zhuangtai}
amount = Decimal(str(order.jine))
if paid_amount_yuan is not None:
paid = Decimal(str(paid_amount_yuan))
if abs(paid - amount) > Decimal('0.01'):
raise CzjiluFulfillError(f'支付金额{paid}与订单{amount}不一致', 'amount_mismatch')
jihui = ZhuanpanJihui.query.select_for_update().filter(
czjilu_dingdan_id=dingdan_id
).first()
if not jihui:
raise CzjiluFulfillError('转盘机会单不存在', 'not_found')
order.zhuangtai = CZJILU_PAID_STATUS
order.save(update_fields=['zhuangtai'])
if int(jihui.zhuangtai) == 9:
jihui.zhuangtai = 1 # 已付待抽
jihui.save(update_fields=['zhuangtai', 'UpdateTime'])
try:
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
repair_wechat_income_if_missing(
amount,
getattr(order, 'club_id', None) or CLUB_ID_DEFAULT,
biz_ref=f'cz:{dingdan_id}',
)
except Exception as exc:
logger.error('转盘购票补记收支失败 dingdan=%s: %s', dingdan_id, exc, exc_info=True)
logger.info('转盘购票履约完成 dingdan=%s jihui=%s', dingdan_id, jihui.id)
return {
'already_done': False,
'dingdan_id': dingdan_id,
'zhuangtai': CZJILU_PAID_STATUS,
'jihui_id': jihui.id,
}
@transaction.atomic
def fulfill_yajin_recharge(dingdan_id, paid_amount_yuan=None):
"""押金充值幂等履约:仅 zhuangtai==9 时入账"""
"""押金充值幂等履约:仅待履约状态(9/用户关单2)时入账;已付(3)直接跳过防多加"""
order = Czjilu.query.filter(dingdan_id=dingdan_id).select_for_update().first()
if not order:
raise CzjiluFulfillError('订单不存在', 'not_found')
if order.leixing != 2:
raise CzjiluFulfillError('订单类型不是押金', 'type_error')
if order.zhuangtai != 9:
if not is_czjilu_fulfillable_status(order.zhuangtai):
try:
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
repair_wechat_income_if_missing(
@@ -118,13 +177,13 @@ def _apply_yajin_fenhong(order, dashou, amount):
@transaction.atomic
def fulfill_jifen_recharge(dingdan_id, paid_amount_yuan=None):
"""积分补充幂等履约:仅 zhuangtai==9 时入账。"""
"""积分补充幂等履约:仅待履约状态(9/2)时入账。"""
order = Czjilu.query.filter(dingdan_id=dingdan_id).select_for_update().first()
if not order:
raise CzjiluFulfillError('订单不存在', 'not_found')
if order.leixing != 3:
raise CzjiluFulfillError('订单类型不是积分', 'type_error')
if order.zhuangtai != 9:
if not is_czjilu_fulfillable_status(order.zhuangtai):
try:
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
repair_wechat_income_if_missing(
@@ -179,7 +238,7 @@ def fulfill_fakuan(dingdan_id, paid_amount_yuan=None):
raise CzjiluFulfillError('订单不存在', 'not_found')
if order.leixing != 6:
raise CzjiluFulfillError('订单类型不是罚款', 'type_error')
if order.zhuangtai != 9:
if not is_czjilu_fulfillable_status(order.zhuangtai):
try:
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
repair_wechat_income_if_missing(
@@ -205,17 +264,33 @@ def fulfill_fakuan(dingdan_id, paid_amount_yuan=None):
fadan = None
m = re.search(r'#fadan:(\d+)', order.shuoming or '')
if m:
fadan_id_str = m.group(1)
fadan = Penalty.query.filter(
id=int(m.group(1)),
id=int(fadan_id_str),
PenalizedUserID=order.yonghuid,
Status__in=[1, 3],
).first()
if not fadan:
logger.warning(
'罚款订单 %s shuoming 标记罚单 %s 未找到(被处罚者=%s, 状态需为1/3)',
dingdan_id, fadan_id_str, order.yonghuid,
)
else:
logger.warning(
'罚款订单 %s shuoming 无 #fadan:xxx 标记,回退金额匹配: %s',
dingdan_id, order.shuoming,
)
if not fadan:
fadan = Penalty.query.filter(
PenalizedUserID=order.yonghuid,
FineAmount=order.jine,
Status__in=[1, 3],
).order_by('-CreateTime').first()
if fadan:
logger.warning(
'罚款订单 %s 回退金额匹配到罚单 %s(金额=%s',
dingdan_id, fadan.id, order.jine,
)
if fadan:
fadan.Status = 2
fadan.save(update_fields=['Status'])
@@ -224,6 +299,11 @@ def fulfill_fakuan(dingdan_id, paid_amount_yuan=None):
logger.error('罚单 %s 分红处理失败: %s', fadan.id, msg)
else:
logger.info('罚单 %s 分红处理完成: %s', fadan.id, msg)
else:
logger.error(
'罚款订单 %s 未找到对应罚单,罚单状态未更新(被处罚者=%s, 金额=%s)',
dingdan_id, order.yonghuid, order.jine,
)
except Exception as exc:
logger.error('罚款罚单状态更新失败 dingdan=%s: %s', dingdan_id, exc, exc_info=True)
@@ -239,7 +319,7 @@ def fulfill_fakuan(dingdan_id, paid_amount_yuan=None):
@transaction.atomic
def fulfill_kaohe(dingdan_id, paid_amount_yuan=None):
"""考核支付幂等履约:仅 zhuangtai==9 时入账。"""
"""考核支付幂等履约:仅待履约状态(9/2)时入账。"""
from rank.models import KaohePayTemp
from rank.utils import create_shenhe_jilu_from_temp
from users.business_models import User
@@ -249,7 +329,7 @@ def fulfill_kaohe(dingdan_id, paid_amount_yuan=None):
raise CzjiluFulfillError('订单不存在', 'not_found')
if order.leixing != 5:
raise CzjiluFulfillError('订单类型不是考核', 'type_error')
if order.zhuangtai != 9:
if not is_czjilu_fulfillable_status(order.zhuangtai):
try:
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
repair_wechat_income_if_missing(
@@ -302,7 +382,7 @@ def fulfill_kaohe(dingdan_id, paid_amount_yuan=None):
@transaction.atomic
def fulfill_shangjia(dingdan_id, paid_amount_yuan=None):
"""商家充值幂等履约:仅 zhuangtai==9 时入账"""
"""商家充值幂等履约:仅待履约状态(9/2)时入账;已付(3)跳过防多加"""
from users.models import UserShangjia
order = Czjilu.query.filter(dingdan_id=dingdan_id).select_for_update().first()
@@ -310,7 +390,7 @@ def fulfill_shangjia(dingdan_id, paid_amount_yuan=None):
raise CzjiluFulfillError('订单不存在', 'not_found')
if order.leixing != 4:
raise CzjiluFulfillError('订单类型不是商家充值', 'type_error')
if order.zhuangtai != 9:
if not is_czjilu_fulfillable_status(order.zhuangtai):
try:
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
repair_wechat_income_if_missing(
@@ -350,9 +430,7 @@ def fulfill_shangjia(dingdan_id, paid_amount_yuan=None):
def confirm_czjilu_paid(dingdan_id, yonghuid, expected_leixing):
"""轮询确认:支付宝订单直接看本地状态,微信订单查微信 SUCCESS 后幂等履约"""
from django.core.cache import cache
"""轮询确认:付呗/微信/支付宝官方查单成功后幂等履约(防多加)"""
try:
order = Czjilu.query.get(dingdan_id=dingdan_id, yonghuid=yonghuid)
except Czjilu.DoesNotExist:
@@ -373,21 +451,22 @@ def confirm_czjilu_paid(dingdan_id, yonghuid, expected_leixing):
logger.error('充值单补记收支失败 dingdan=%s: %s', dingdan_id, exc, exc_info=True)
return {'already_done': True, 'zhuangtai': 3}
if order.zhuangtai != 9:
if not is_czjilu_fulfillable_status(order.zhuangtai):
raise CzjiluFulfillError(f'订单状态异常: {order.zhuangtai}', 'status_error')
# 支付宝订单:异步回调会更新状态,轮询时只看本地状态,未支付则提示稍后重试
is_alipay = cache.get(f'alipay_pay_url_{dingdan_id}') is not None
if is_alipay:
raise CzjiluFulfillError('支付宝支付结果尚未同步,请稍后再试', 'not_paid')
# 微信订单:主动查单
from jituan.services.member_recharge import query_wechat_v2_trade_state
trade_state, _ = query_wechat_v2_trade_state(dingdan_id, getattr(order, 'club_id', None))
if trade_state != 'SUCCESS':
raise CzjiluFulfillError('微信侧尚未支付成功,请稍后再试', 'not_paid')
paid = Decimal(str(order.jine))
club_id = getattr(order, 'club_id', None)
from jituan.services.pay_confirm import confirm_remote_payment
remote = confirm_remote_payment(dingdan_id, club_id, prefer_amount=paid)
if not remote.get('ok'):
if remote.get('pending'):
raise CzjiluFulfillError('支付结果尚未同步,请稍后再试', 'not_paid')
raise CzjiluFulfillError(remote.get('msg') or '支付未成功', 'not_paid')
if remote.get('amount') is not None:
paid = Decimal(str(remote['amount']))
# 查单确认支付成功执行履约fulfill_* 内行锁+仅 9/2→3防多加
if expected_leixing == 1:
from jituan.services.member_recharge import fulfill_member_recharge
return fulfill_member_recharge(dingdan_id, paid_amount_yuan=paid, source='confirm')
@@ -401,4 +480,15 @@ def confirm_czjilu_paid(dingdan_id, yonghuid, expected_leixing):
return fulfill_kaohe(dingdan_id, paid_amount_yuan=paid)
if expected_leixing == 6:
return fulfill_fakuan(dingdan_id, paid_amount_yuan=paid)
if expected_leixing == 7:
return fulfill_zhuanpan(dingdan_id, paid_amount_yuan=paid)
raise CzjiluFulfillError('不支持的充值类型', 'type_error')
def repair_czjilu_payment(dingdan_id):
"""客服补单:官方查单确认已付后,走同一套幂等履约(绝不多加)。"""
try:
order = Czjilu.query.get(dingdan_id=dingdan_id)
except Czjilu.DoesNotExist:
raise CzjiluFulfillError('订单不存在', 'not_found')
return confirm_czjilu_paid(dingdan_id, order.yonghuid, expected_leixing=order.leixing)

View File

@@ -11,26 +11,44 @@ from rest_framework.response import Response
# 定稿 B 方案 page_key
LUNBO_PAGE_ORDER_POOL = 'order_pool'
LUNBO_PAGE_ACCEPT_ORDER = 'accept_order'
LUNBO_PAGE_ACCEPT_BANNER2 = 'accept_order_banner2' # 点单页第二横幅轮播(如 UFO banner2
LUNBO_PAGE_MERCHANT_HOME = 'merchant_home'
LUNBO_PAGE_DASHOU_CENTER = 'dashou_center'
LUNBO_PAGE_BOSS_CENTER = 'boss_center' # 点单端「我的」顶区背景
LUNBO_CENTER_BG_KEYS = {LUNBO_PAGE_DASHOU_CENTER, LUNBO_PAGE_BOSS_CENTER}
LUNBO_CAROUSEL_KEYS = {
LUNBO_PAGE_ORDER_POOL,
LUNBO_PAGE_ACCEPT_ORDER,
LUNBO_PAGE_ACCEPT_BANNER2,
LUNBO_PAGE_MERCHANT_HOME,
}
LUNBO_PAGE_OPTIONS = [
(LUNBO_PAGE_ORDER_POOL, '抢单池'),
(LUNBO_PAGE_ACCEPT_ORDER, '点单页'),
(LUNBO_PAGE_ACCEPT_ORDER, '点单页主轮播'),
(LUNBO_PAGE_ACCEPT_BANNER2, '点单页第二横幅(可多图)'),
(LUNBO_PAGE_MERCHANT_HOME, '商家首页'),
(LUNBO_PAGE_DASHOU_CENTER, '打手个人中心背景'),
(LUNBO_PAGE_BOSS_CENTER, '点单个人中心背景(可多图)'),
]
def normalize_page_key(page_key, image_type=1):
key = (page_key or '').strip()
if key in LUNBO_CENTER_BG_KEYS or key in LUNBO_CAROUSEL_KEYS:
return key
if image_type == 2:
return LUNBO_PAGE_DASHOU_CENTER
if key in {LUNBO_PAGE_ORDER_POOL, LUNBO_PAGE_ACCEPT_ORDER, LUNBO_PAGE_MERCHANT_HOME}:
return key
return LUNBO_PAGE_ORDER_POOL
def lunbo_image_type_for_page(page_key):
"""个人中心背景页用 ImageType=2其余轮播用 1。"""
pk = normalize_page_key(page_key, image_type=1)
return 2 if pk in LUNBO_CENTER_BG_KEYS else 1
def forbid_display_write_in_all_scope(request):
if resolve_club_scope(request) == DATA_SCOPE_ALL:
return Response({'code': 403, 'msg': '请在子公司视图下修改展示配置'})
@@ -73,6 +91,9 @@ def get_lunbo_urls(request, page_key=None, image_type=1):
from config.models import Lunbo
club_id = _display_club_id(request)
pk = normalize_page_key(page_key, image_type=image_type)
# 个人中心背景页一律 ImageType=2避免误传 image_type=1 读不到
if pk in LUNBO_CENTER_BG_KEYS:
image_type = 2
qs = Lunbo.query.filter(club_id=club_id, ImageType=image_type, page_key=pk)
return [row.ImageURL for row in qs.order_by('id') if row.ImageURL]
@@ -108,12 +129,20 @@ def list_tupianpeizhi_by_type(club_id, image_type):
def upsert_tupianpeizhi_single(club_id, image_type, image_url):
"""ImageType 1/2 每俱乐部仅一条。"""
"""ImageType 1/2 每俱乐部仅一条;换图时删除旧 OSS 对象"""
from config.models import Tupianpeizhi
from utils.oss_utils import delete_from_oss
row = Tupianpeizhi.query.filter(club_id=club_id, ImageType=image_type).first()
if row:
old = (row.ImageURL or '').strip()
row.ImageURL = image_url
row.save(update_fields=['ImageURL', 'UpdateTime'])
if old and old != image_url:
try:
delete_from_oss(old)
except Exception:
pass
else:
Tupianpeizhi.query.create(
club_id=club_id, ImageType=image_type, ImageURL=image_url, AccessCount=0,

View File

@@ -0,0 +1,227 @@
"""付呗开放平台客户端:签名、小程序下单、退款、回调验签。"""
from __future__ import annotations
import hashlib
import json
import logging
import random
import string
import time
from decimal import Decimal, ROUND_HALF_UP
from typing import Any, Dict, Optional, Tuple
import requests
logger = logging.getLogger(__name__)
METHOD_MINA = 'openapi.payment.order.mina'
METHOD_REFUND = 'openapi.payment.order.refund'
METHOD_QUERY = 'openapi.payment.order.query'
def _yuan_str(amount) -> str:
d = Decimal(str(amount)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
return format(d, 'f')
def build_sign(params: Dict[str, Any], app_secret: str) -> str:
"""付呗签名:参数 ASCII 排序后 key=value&... 再无缝拼接 secretMD5 大写。"""
items = []
for k in sorted(params.keys()):
if k == 'sign':
continue
v = params[k]
if v is None or v == '':
continue
items.append(f'{k}={v}')
raw = '&'.join(items) + (app_secret or '')
return hashlib.md5(raw.encode('utf-8')).hexdigest().upper()
def verify_callback_sign(params: Dict[str, Any], app_secret: str) -> bool:
got = (params.get('sign') or '').strip().upper()
if not got:
return False
expect = build_sign(params, app_secret)
return got == expect
def _nonce(n: int = 24) -> str:
return ''.join(random.choices(string.ascii_letters + string.digits, k=n))
def call_fubei_api(
*,
gateway_url: str,
app_id: str,
app_secret: str,
method: str,
biz: Dict[str, Any],
timeout: int = 15,
) -> Dict[str, Any]:
"""调用付呗 gateway成功返回 data 字典;失败抛 Exception。"""
biz_content = json.dumps(biz, ensure_ascii=False, separators=(',', ':'))
payload = {
'app_id': app_id,
'method': method,
'format': 'json',
'sign_method': 'md5',
'nonce': _nonce(),
'version': '1.0',
'biz_content': biz_content,
}
payload['sign'] = build_sign(payload, app_secret)
url = (gateway_url or '').strip() or 'https://shq-api.51fubei.com/gateway'
logger.info('[FUBEI] method=%s app_id=%s biz_keys=%s', method, app_id, list(biz.keys()))
resp = requests.post(url, json=payload, timeout=timeout, headers={'Content-Type': 'application/json; charset=utf-8'})
try:
body = resp.json()
except Exception:
logger.error('[FUBEI] 非JSON响应 status=%s body=%s', resp.status_code, resp.text[:500])
raise Exception(f'付呗接口异常: HTTP {resp.status_code}')
result_code = body.get('result_code')
if str(result_code) not in ('200', '200.0'):
msg = body.get('result_message') or body.get('sub_code') or '付呗下单失败'
logger.warning('[FUBEI] 业务失败 method=%s code=%s msg=%s body=%s', method, result_code, msg, body)
raise Exception(str(msg))
data = body.get('data')
if isinstance(data, str):
try:
data = json.loads(data)
except Exception:
pass
if not isinstance(data, dict):
raise Exception('付呗返回 data 格式异常')
return data
def mina_prepay(
*,
gateway_url: str,
app_id: str,
app_secret: str,
merchant_order_sn: str,
sub_openid: str,
total_fee,
store_id: int,
call_back_url: str,
body: str = '',
attach: str = '',
) -> Dict[str, Any]:
"""小程序支付下单,返回含 sign_params / order_sn 等。"""
biz = {
'merchant_order_sn': merchant_order_sn,
'sub_openid': sub_openid,
'total_fee': float(_yuan_str(total_fee)),
'store_id': int(store_id),
}
if call_back_url:
biz['call_back_url'] = call_back_url
if body:
biz['body'] = (body or '')[:128]
if attach:
biz['attach'] = (attach or '')[:127]
return call_fubei_api(
gateway_url=gateway_url,
app_id=app_id,
app_secret=app_secret,
method=METHOD_MINA,
biz=biz,
)
def refund_order(
*,
gateway_url: str,
app_id: str,
app_secret: str,
merchant_order_sn: str,
merchant_refund_sn: str,
refund_money=None,
order_sn: str = '',
) -> Dict[str, Any]:
biz: Dict[str, Any] = {
'merchant_refund_sn': merchant_refund_sn,
}
if merchant_order_sn:
biz['merchant_order_sn'] = merchant_order_sn
if order_sn:
biz['order_sn'] = order_sn
if refund_money is not None:
biz['refund_money'] = float(_yuan_str(refund_money))
return call_fubei_api(
gateway_url=gateway_url,
app_id=app_id,
app_secret=app_secret,
method=METHOD_REFUND,
biz=biz,
)
def query_order(
*,
gateway_url: str,
app_id: str,
app_secret: str,
merchant_order_sn: str = '',
order_sn: str = '',
) -> Dict[str, Any]:
"""订单查询。trade_state=SUCCESS 表示已支付。"""
biz: Dict[str, Any] = {}
if merchant_order_sn:
biz['merchant_order_sn'] = merchant_order_sn
if order_sn:
biz['order_sn'] = order_sn
if not biz:
raise Exception('付呗查单缺少订单号')
return call_fubei_api(
gateway_url=gateway_url,
app_id=app_id,
app_secret=app_secret,
method=METHOD_QUERY,
biz=biz,
)
def parse_callback_form(post_dict: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
"""解析付呗 form 回调,返回 (顶层参数含 sign, data 业务字典)。"""
params = {k: (v if not isinstance(v, list) else (v[0] if v else '')) for k, v in post_dict.items()}
# 兼容 QueryDict.dict()
data_raw = params.get('data') or ''
data: Dict[str, Any] = {}
if isinstance(data_raw, dict):
data = data_raw
elif isinstance(data_raw, str) and data_raw.strip():
try:
data = json.loads(data_raw)
except Exception:
# 偶发双重编码
try:
data = json.loads(data_raw.replace("'", '"'))
except Exception:
logger.error('[FUBEI_NOTIFY] data 解析失败: %s', data_raw[:300])
data = {}
return params, data
def extract_jsapi_pay_params(sign_params: Dict[str, Any]) -> Dict[str, str]:
"""从付呗 sign_params 抽出小程序 wx.requestPayment 所需字段。"""
if not sign_params:
raise Exception('付呗未返回支付签名包')
# 部分字段可能混有 store_id 等,只取调起支付字段
out = {
'appId': str(sign_params.get('appId') or sign_params.get('appid') or ''),
'timeStamp': str(sign_params.get('timeStamp') or sign_params.get('timestamp') or ''),
'nonceStr': str(sign_params.get('nonceStr') or sign_params.get('noncestr') or ''),
'package': str(sign_params.get('package') or ''),
'signType': str(sign_params.get('signType') or sign_params.get('sign_type') or 'MD5'),
'paySign': str(sign_params.get('paySign') or sign_params.get('pay_sign') or ''),
}
if not out['timeStamp'] or not out['package'] or not out['paySign']:
raise Exception('付呗签名包字段不完整')
return out

View File

@@ -0,0 +1,170 @@
"""付呗支付异步回调:验签后按订单/充值单履约(前端无感知)。"""
from __future__ import annotations
import json
import logging
import traceback
from django.http import HttpResponse
from django.views import View
from jituan.constants import FUBEI_BIZ_CZJILU, FUBEI_BIZ_ORDER
from jituan.services.club_payment_channel import get_fubei_config
from jituan.services import fubei_client
from jituan.services.mini_pay_router import get_pay_ledger
from jituan.services.wechat_pay import club_id_from_czjilu, club_id_from_order
logger = logging.getLogger(__name__)
class FubeiPayNotifyView(View):
"""
付呗异步通知。
GET回调 URL 有效性验证,必须返回小写 success。
POST支付成功通知。
路径:/dingdan/fubei-notify/
"""
def get(self, request):
return HttpResponse('success', content_type='text/plain')
def post(self, request):
try:
raw_preview = ''
try:
raw_preview = (request.body or b'')[:800].decode('utf-8', errors='replace')
except Exception:
raw_preview = ''
post = request.POST.dict() if request.POST else {}
if not post and request.body:
# 偶发 JSON
try:
post = json.loads(request.body.decode('utf-8'))
except Exception:
post = {}
logger.info('[FUBEI_NOTIFY] raw=%s post_keys=%s', raw_preview, list(post.keys()))
params, data = fubei_client.parse_callback_form(post)
merchant_order_sn = str(data.get('merchant_order_sn') or '').strip()
result_code = str(params.get('result_code') or '')
logger.info(
'[FUBEI_NOTIFY] result_code=%s merchant_order_sn=%s trade_no=%s',
result_code, merchant_order_sn, data.get('trade_no') or data.get('platform_order_no'),
)
if not merchant_order_sn:
logger.warning('[FUBEI_NOTIFY] 缺少 merchant_order_sn')
return HttpResponse('fail', content_type='text/plain')
club_id = self._resolve_club_id(merchant_order_sn, data)
cfg = get_fubei_config(club_id)
if not cfg or not cfg.api_key:
logger.error('[FUBEI_NOTIFY] 无付呗密钥 club=%s order=%s', club_id, merchant_order_sn)
return HttpResponse('fail', content_type='text/plain')
if not fubei_client.verify_callback_sign(params, cfg.api_key):
# 验签失败仍记录,便于排查;同时尝试用 data 字符串原样参与(部分网关会改写)
logger.error(
'[FUBEI_NOTIFY] 验签失败 order=%s params=%s',
merchant_order_sn,
{k: params.get(k) for k in ('result_code', 'result_message', 'sign') if k in params},
)
return HttpResponse('fail', content_type='text/plain')
if result_code not in ('200', '200.0'):
logger.info('[FUBEI_NOTIFY] 非成功 result_code=%s order=%s', result_code, merchant_order_sn)
return HttpResponse('success', content_type='text/plain')
transaction_id = str(
data.get('trade_no')
or data.get('platform_order_no')
or data.get('order_sn')
or ''
)
total_fee = data.get('total_fee') or data.get('cash_fee') or data.get('order_price')
attach = str(data.get('attach') or '')
biz = self._biz_type(merchant_order_sn, attach)
if biz == FUBEI_BIZ_ORDER:
ok = self._fulfill_order(merchant_order_sn, transaction_id)
else:
ok = self._fulfill_czjilu(merchant_order_sn, total_fee, transaction_id)
return HttpResponse('success' if ok else 'fail', content_type='text/plain')
except Exception as exc:
logger.error('[FUBEI_NOTIFY] 异常: %s', exc)
logger.error(traceback.format_exc())
return HttpResponse('fail', content_type='text/plain')
def _resolve_club_id(self, merchant_order_sn: str, data: dict) -> str:
ledger = get_pay_ledger(merchant_order_sn)
if ledger and ledger.club_id:
return ledger.club_id
# 先试订单,再试充值单
try:
cid = club_id_from_order(merchant_order_sn)
if cid:
return cid
except Exception:
pass
try:
cid = club_id_from_czjilu(merchant_order_sn)
if cid:
return cid
except Exception:
pass
return 'xq'
def _biz_type(self, merchant_order_sn: str, attach: str) -> str:
ledger = get_pay_ledger(merchant_order_sn)
if ledger and ledger.biz_type:
return ledger.biz_type
if 'biz=order' in attach:
return FUBEI_BIZ_ORDER
if 'biz=czjilu' in attach:
return FUBEI_BIZ_CZJILU
# 启发式:点单 Order 表优先
try:
from orders.models import Order
if Order.query.filter(OrderID=merchant_order_sn).exists():
return FUBEI_BIZ_ORDER
except Exception:
pass
return FUBEI_BIZ_CZJILU
def _fulfill_order(self, out_trade_no: str, transaction_id: str) -> bool:
try:
from orders.views.payment import WechatPayNotifyView
view = WechatPayNotifyView()
view._handle_success(out_trade_no, transaction_id, None)
return True
except Exception as exc:
logger.error('[FUBEI_NOTIFY] 订单履约失败 %s: %s', out_trade_no, exc, exc_info=True)
return False
def _fulfill_czjilu(self, out_trade_no: str, total_fee, transaction_id: str) -> bool:
from products.models import Czjilu
from products.czjilu_types import (
CZJILU_PAID_STATUS,
is_czjilu_fulfillable_status,
)
from products.views.alipay_czjilu_notify import _fulfill_czjilu_by_leixing
order = Czjilu.query.filter(dingdan_id=out_trade_no).first()
if not order:
logger.error('[FUBEI_NOTIFY] 充值单不存在: %s', out_trade_no)
return False
if order.zhuangtai == CZJILU_PAID_STATUS:
return True
if not is_czjilu_fulfillable_status(order.zhuangtai):
logger.warning('[FUBEI_NOTIFY] 充值单终态不再履约 %s zhuangtai=%s', out_trade_no, order.zhuangtai)
return True
paid_amount = total_fee if total_fee not in (None, '') else order.jine
try:
_fulfill_czjilu_by_leixing(out_trade_no, order.leixing, paid_amount, source='fubei_callback')
return True
except Exception as exc:
logger.error('[FUBEI_NOTIFY] 充值履约失败 %s: %s', out_trade_no, exc, exc_info=True)
return False

View File

@@ -0,0 +1,130 @@
"""付呗支付结果确认:主动查单(轮询/复查兜底,不依赖回调)。
规则:
- 流水表标明付呗 → 只查付呗(绝不误查微信直连)
- 无流水且俱乐部当前为付呗 → 先尝试付呗查单;查无单再回落微信
- 其它 → 返回 None由调用方走微信/支付宝
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional, Tuple
from jituan.constants import (
CLUB_ID_DEFAULT,
MINI_PAY_CHANNEL_FUBEI,
PAY_CHANNEL_FUBEI_WX_MINI,
)
from jituan.services.club_payment_channel import get_fubei_config
from jituan.services import fubei_client
from jituan.services.mini_pay_router import get_club_mini_pay_channel, get_pay_ledger
logger = logging.getLogger(__name__)
def is_fubei_out_trade_no(out_trade_no: str) -> bool:
ledger = get_pay_ledger(out_trade_no)
return bool(ledger and ledger.channel == PAY_CHANNEL_FUBEI_WX_MINI)
def _should_try_fubei(out_trade_no: str, club_id: Optional[str]) -> Tuple[bool, str, str]:
"""
返回 (是否应付呗查单, club_id, provider_order_sn)。
有付呗流水 → 强制付呗;无流水但俱乐部选用付呗 → 尝试付呗。
"""
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
ledger = get_pay_ledger(out_trade_no)
if ledger and ledger.channel == PAY_CHANNEL_FUBEI_WX_MINI:
return True, (ledger.club_id or cid), (ledger.provider_order_sn or '')
if ledger:
# 明确记了其它通道,不查付呗
return False, cid, ''
if get_club_mini_pay_channel(cid) == MINI_PAY_CHANNEL_FUBEI:
return True, cid, ''
return False, cid, ''
def query_fubei_trade(
out_trade_no: str,
club_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""
查询付呗订单。
不应查付呗时返回 None
应查付呗时返回 data含 trade_state查单业务失败时抛 Exception。
"""
try_fb, cid, order_sn = _should_try_fubei(out_trade_no, club_id)
if not try_fb:
return None
cfg = get_fubei_config(cid)
if not cfg or not cfg.app_id or not cfg.api_key:
# 俱乐部标了付呗但配置不齐:有流水则报错;仅「尝试」则回落微信
ledger = get_pay_ledger(out_trade_no)
if ledger and ledger.channel == PAY_CHANNEL_FUBEI_WX_MINI:
logger.error('[FUBEI_QUERY] 配置缺失 club=%s out_trade_no=%s', cid, out_trade_no)
raise Exception('付呗查单配置缺失')
return None
data = fubei_client.query_order(
gateway_url=cfg.gateway_url,
app_id=cfg.app_id,
app_secret=cfg.api_key,
merchant_order_sn=out_trade_no,
order_sn=order_sn,
)
logger.info(
'[FUBEI_QUERY] out_trade_no=%s club=%s trade_state=%s',
out_trade_no, cid, data.get('trade_state'),
)
return data
def fubei_trade_is_success(data: Optional[Dict[str, Any]]) -> bool:
if not data:
return False
state = str(data.get('trade_state') or '').upper()
return state == 'SUCCESS'
def fubei_success_payload(
out_trade_no: str,
club_id: Optional[str] = None,
) -> Optional[Tuple[str, Any]]:
"""
返回值约定(供复查/轮询统一使用):
- None不是付呗单或不应走付呗调用方继续微信/支付宝
- ('', None):是付呗路径,但尚未支付成功(支付中)
- (transaction_id, total_fee):付呗已支付成功
"""
try_fb, _, _ = _should_try_fubei(out_trade_no, club_id)
if not try_fb:
return None
forced = is_fubei_out_trade_no(out_trade_no)
try:
data = query_fubei_trade(out_trade_no, club_id)
except Exception as exc:
msg = str(exc)
logger.error('[FUBEI_QUERY] 查单异常 %s: %s', out_trade_no, exc, exc_info=True)
# 无流水的「尝试」查询:订单不存在等可回落微信直连
if not forced and any(k in msg for k in ('不存在', '无此', '未找到', 'ORDER', 'order')):
return None
# 有流水的付呗单:绝不能回落微信
return ('', None)
if data is None:
return None if not forced else ('', None)
if not fubei_trade_is_success(data):
return ('', None)
txn = str(
data.get('trade_no')
or data.get('platform_order_no')
or data.get('order_sn')
or out_trade_no
or 'FUBEI_PAID'
)
total_fee = data.get('total_fee') or data.get('cash_fee') or data.get('order_price')
return (txn, total_fee)

View File

@@ -0,0 +1,251 @@
"""后台赠送会员(免费接单):与购买会员 Huiyuangoumai 分离。"""
from datetime import datetime, time
from django.db import transaction
from django.utils import timezone
from django.utils.dateparse import parse_datetime, parse_date
from products.models import Huiyuan
from users.models import DashouZengsongHuiyuan, UserDashou
GIFT_HUIYUAN_PERMS = frozenset({
'000001', '001aa', '001bb', '001cc', '001dd', '001ee',
})
TARGET_JIFEN_ON_GIFT = 10
def has_gift_huiyuan_permission(permissions):
return any(p in (permissions or []) for p in GIFT_HUIYUAN_PERMS)
def _gift_record_active(record, now=None):
if not record or not record.daoqi_time:
return False
from jituan.services.huiyuan_bundle import _normalize_datetime_for_compare
now = _normalize_datetime_for_compare(now or timezone.now())
daoqi = _normalize_datetime_for_compare(record.daoqi_time)
return daoqi > now
def user_has_any_active_gift_huiyuan(yonghu_id):
"""是否持有任意一条未过期的赠送会员(提现/收款资格用)。"""
if not yonghu_id:
return False
now = timezone.now()
for rec in DashouZengsongHuiyuan.query.filter(yonghu_id=yonghu_id):
if _gift_record_active(rec, now=now):
return True
return False
def parse_gift_daoqi_time(raw):
"""
解析到期时间。
支持:'YYYY-MM-DD HH:MM:SS' / ISO / 'YYYY-MM-DD'(当天 23:59:59
"""
if raw is None or str(raw).strip() == '':
return None, '请填写到期时间'
text = str(raw).strip()
dt = parse_datetime(text.replace('/', '-'))
if dt is None:
d = parse_date(text.replace('/', '-'))
if d is None:
try:
dt = datetime.strptime(text, '%Y-%m-%d %H:%M:%S')
except ValueError:
try:
d = datetime.strptime(text, '%Y-%m-%d').date()
except ValueError:
return None, '到期时间格式错误,请用 YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS'
dt = datetime.combine(d, time(23, 59, 59))
else:
dt = datetime.combine(d, time(23, 59, 59))
if timezone.is_naive(dt) and timezone.is_aware(timezone.now()):
dt = timezone.make_aware(dt, timezone.get_current_timezone())
elif (not timezone.is_naive(dt)) and (not timezone.is_aware(timezone.now())):
dt = timezone.make_naive(dt, timezone.get_current_timezone())
if dt <= timezone.now():
return None, '到期时间必须晚于当前时间'
return dt, None
def list_gift_huiyuan_for_admin(yonghu_id):
"""后台详情:全部赠送记录(含已过期),附会员名。"""
records = list(
DashouZengsongHuiyuan.query.filter(yonghu_id=yonghu_id).order_by('-daoqi_time')
)
if not records:
return []
name_map = {
h.huiyuan_id: h.jieshao
for h in Huiyuan.query.filter(
huiyuan_id__in=[r.huiyuan_id for r in records]
).only('huiyuan_id', 'jieshao')
}
now = timezone.now()
out = []
for r in records:
active = _gift_record_active(r, now=now)
out.append({
'id': r.id,
'huiyuan_id': r.huiyuan_id,
'huiyuan_name': name_map.get(r.huiyuan_id, f'会员{r.huiyuan_id}'),
'daoqi_time': r.daoqi_time.strftime('%Y-%m-%d %H:%M:%S') if r.daoqi_time else '',
'song_ren': r.song_ren or '',
'song_time': r.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if r.CreateTime else '',
'is_active': active,
})
return out
def user_has_gift_huiyuan_access(yonghu_id, required_huiyuan_id, club_id=None):
"""抢单资格:赠送会员是否覆盖所需会员类型(支持总会员展开)。"""
from jituan.services.huiyuan_bundle import (
huiyuan_ids_match,
is_bundle_huiyuan,
normalize_huiyuan_id,
resolve_bundle_included_ids,
_included_huiyuan_id_set,
)
req = normalize_huiyuan_id(required_huiyuan_id)
if not req:
return True
req_set = {req, req.lstrip('0') or '0'}
now = timezone.now()
for rec in DashouZengsongHuiyuan.query.filter(yonghu_id=yonghu_id):
if not _gift_record_active(rec, now=now):
continue
if huiyuan_ids_match(rec.huiyuan_id, req):
return True
if is_bundle_huiyuan(rec.huiyuan_id):
included = resolve_bundle_included_ids(
rec.huiyuan_id,
club_id=club_id,
yonghu_id=yonghu_id,
)
if req_set & _included_huiyuan_id_set(included):
return True
return False
def build_gift_clumber_items(yonghu_id, club_id=None):
"""抢单池 clumber未过期赠送会员条目。"""
from jituan.services.huiyuan_bundle import (
_append_bundle_sub_clumber,
_append_clumber_id_variants,
format_legacy_daoqi,
is_bundle_huiyuan,
resolve_bundle_included_ids,
_resolve_user_club_id,
)
items = []
seen = set()
effective_club = _resolve_user_club_id(yonghu_id, club_id)
now = timezone.now()
for rec in DashouZengsongHuiyuan.query.filter(yonghu_id=yonghu_id):
if not _gift_record_active(rec, now=now):
continue
try:
hy = Huiyuan.query.get(huiyuan_id=rec.huiyuan_id)
ming = hy.jieshao or f'会员{rec.huiyuan_id}'
except Huiyuan.DoesNotExist:
ming = f'会员{rec.huiyuan_id}'
base = {
'huiyuanming': ming,
'daoqi': format_legacy_daoqi(rec.daoqi_time),
'huiyuan_zhuangtai': 1,
'shifou_daoqi': False,
'is_gift': True,
}
if is_bundle_huiyuan(rec.huiyuan_id):
base['is_bundle'] = True
base['bundle_huiyuan_id'] = rec.huiyuan_id
base['included_huiyuan_ids'] = resolve_bundle_included_ids(
rec.huiyuan_id,
club_id=effective_club,
yonghu_id=yonghu_id,
)
_append_clumber_id_variants(items, seen, base, rec.huiyuan_id)
_append_bundle_sub_clumber(items, seen, base)
return items
def upsert_gift_huiyuan(*, yonghu_id, huiyuan_id, daoqi_time, song_ren):
"""
赠送或改期。同一打手同一会员类型仅一条记录。
返回 (record, created, jifen_changed_info_or_None, error_msg)
jifen_changed_info = {'before': x, 'after': 10} 若调整了积分
"""
huiyuan_id = str(huiyuan_id or '').strip()
if not huiyuan_id:
return None, False, None, '缺少会员ID'
if not Huiyuan.query.filter(huiyuan_id=huiyuan_id).exists():
return None, False, None, '会员类型不存在'
jifen_info = None
with transaction.atomic():
try:
dashou = UserDashou.objects.select_for_update().select_related('user').get(
user__UserUID=yonghu_id
)
except UserDashou.DoesNotExist:
return None, False, None, '打手不存在'
before_jifen = int(dashou.jifen or 0)
if before_jifen != TARGET_JIFEN_ON_GIFT:
dashou.jifen = TARGET_JIFEN_ON_GIFT
dashou.save(update_fields=['jifen'])
jifen_info = {'before': before_jifen, 'after': TARGET_JIFEN_ON_GIFT}
locked_qs = DashouZengsongHuiyuan.objects.select_for_update().filter(
yonghu_id=yonghu_id, huiyuan_id=huiyuan_id
)
existing = locked_qs.first()
if existing:
existing.daoqi_time = daoqi_time
existing.song_ren = song_ren
existing.save(update_fields=['daoqi_time', 'song_ren', 'UpdateTime'])
return existing, False, jifen_info, None
record = DashouZengsongHuiyuan.query.create(
yonghu_id=yonghu_id,
huiyuan_id=huiyuan_id,
daoqi_time=daoqi_time,
song_ren=song_ren,
)
return record, True, jifen_info, None
def update_gift_huiyuan_daoqi(*, record_id, yonghu_id, daoqi_time, song_ren):
"""按记录 ID 改期。"""
try:
rec = DashouZengsongHuiyuan.query.get(id=record_id)
except DashouZengsongHuiyuan.DoesNotExist:
return None, '赠送会员记录不存在'
if str(rec.yonghu_id) != str(yonghu_id):
return None, '记录与打手不匹配'
rec.daoqi_time = daoqi_time
if song_ren:
rec.song_ren = song_ren
rec.save(update_fields=['daoqi_time', 'song_ren', 'UpdateTime'])
return rec, None
def remove_gift_huiyuan(*, record_id=None, yonghu_id=None, huiyuan_id=None):
"""按记录 ID或 (yonghu_id, huiyuan_id) 移除。"""
qs = DashouZengsongHuiyuan.query
if record_id:
deleted, _ = qs.filter(id=record_id).delete()
if not deleted:
return False, '赠送会员记录不存在'
return True, None
yonghu_id = str(yonghu_id or '').strip()
huiyuan_id = str(huiyuan_id or '').strip()
if not yonghu_id or not huiyuan_id:
return False, '缺少记录ID或会员ID'
deleted, _ = qs.filter(yonghu_id=yonghu_id, huiyuan_id=huiyuan_id).delete()
if not deleted:
return False, '赠送会员记录不存在'
return True, None

View File

@@ -1,11 +1,24 @@
"""集团级资金处置权限:提现审核、加余额等。俱乐部 000001 超级管理不能绕过。"""
"""集团级资金处置权限:加余额等仍仅集团高管。
提现审核:权限码 005bb + 按俱乐部隔离——
- 子公司视图:持有 005bb 且任职含当前俱乐部 → 可审本俱乐部(同意/拒绝/自动打款)
- 集团汇总视图 / 跨俱乐部须集团资金处置权GROUP_OWNER / GROUP_SUPER_ADMIN
俱乐部本地 000001 不能绕过「跨店」闸门,但可审本店提现。
"""
from django.db.models import Q
from jituan.constants import DATA_SCOPE_ALL
from jituan.models import AdminAssignment
from jituan.services.admin_context import GROUP_MANAGE_ROLES, build_admin_club_context, is_system_super_admin
from jituan.services.admin_context import (
GROUP_MANAGE_ROLES,
build_admin_club_context,
get_allowed_club_ids,
is_system_super_admin,
)
MSG_GROUP_FINANCE_REQUIRED = '该操作仅集团高管可执行(需在数据范围配置中为集团级任职)'
MSG_WITHDRAW_CROSS_CLUB = '跨俱乐部提现审核仅集团高管可执行,请切换到具体俱乐部后再操作'
MSG_WITHDRAW_CLUB_DENIED = '无权审核该俱乐部提现'
def has_group_finance_exec(user, request=None):
@@ -35,16 +48,32 @@ def deny_group_finance_exec(user, request=None):
def require_withdraw_audit_access(request, phone):
"""提现审核:须 005bb 且集团资金处置权。"""
"""
提现审核访问:
1) 须权限码 005bb
2) 集团高管:可在 ALL_CLUBS 或任意子公司视图操作
3) 俱乐部管理员:仅 SINGLE_CLUB 且当前 club 在其任职范围内
"""
from backend.utils import verify_kefu_permission
from rest_framework.response import Response
from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope
kefu, permissions = verify_kefu_permission(request, phone)
if kefu is None:
return None, permissions
if '005bb' not in permissions:
return None, Response({'code': 403, 'msg': '您无权操作提现审核'})
deny = deny_group_finance_exec(request.user, request)
if deny:
return None, deny
if has_group_finance_exec(request.user, request):
return kefu, None
scope = resolve_club_scope(request)
if scope == DATA_SCOPE_ALL:
return None, Response({'code': 403, 'msg': MSG_WITHDRAW_CROSS_CLUB})
club_id = resolve_club_id_from_request(request)
allowed = get_allowed_club_ids(request.user)
if not club_id or club_id not in allowed:
return None, Response({'code': 403, 'msg': MSG_WITHDRAW_CLUB_DENIED})
return kefu, None

View File

@@ -71,12 +71,27 @@ def get_bundle_included_ids(club_id, bundle_huiyuan_id):
if not club_id or not bundle_huiyuan_id:
return []
for bid in expand_huiyuan_id_variants(bundle_huiyuan_id):
ids = list(
ClubHuiyuanBundleInclude.query.filter(
club_id=club_id,
bundle_huiyuan_id=bid,
).values_list('included_huiyuan_id', flat=True)
)
if ids:
return ids
stored_bids = ClubHuiyuanBundleInclude.objects.filter(
club_id=club_id,
).values_list('bundle_huiyuan_id', flat=True).distinct()
for stored_bid in stored_bids:
if huiyuan_ids_match(stored_bid, bundle_huiyuan_id):
return list(
ClubHuiyuanBundleInclude.query.filter(
club_id=club_id,
bundle_huiyuan_id=bundle_huiyuan_id,
bundle_huiyuan_id=stored_bid,
).values_list('included_huiyuan_id', flat=True)
)
return []
def _membership_club_candidates(yonghu_id, club_id=None, record_club_id=None):
@@ -108,22 +123,29 @@ def _membership_club_candidates(yonghu_id, club_id=None, record_club_id=None):
def resolve_bundle_included_ids(bundle_huiyuan_id, club_id=None, yonghu_id=None, record_club_id=None):
"""按候选俱乐部解析总会员包含的子会员(只读,首个非空配置即返回)。"""
"""按候选俱乐部合并总会员包含的子会员(去重,只读)。"""
if not bundle_huiyuan_id:
return []
merged = []
seen = set()
for cid in _membership_club_candidates(
yonghu_id, club_id=club_id, record_club_id=record_club_id,
):
ids = get_bundle_included_ids(cid, bundle_huiyuan_id)
if ids:
return list(ids)
return []
for hid in get_bundle_included_ids(cid, bundle_huiyuan_id):
norm = normalize_huiyuan_id(hid)
core = norm.lstrip('0') or '0' if norm else ''
dedupe_key = core or norm
if dedupe_key and dedupe_key not in seen:
seen.add(dedupe_key)
merged.append(hid)
return merged
def _included_huiyuan_id_set(included_ids):
included_set = set()
for x in included_ids or []:
sx = normalize_huiyuan_id(x)
for variant in expand_huiyuan_id_variants(x):
sx = normalize_huiyuan_id(variant)
if sx:
included_set.add(sx)
included_set.add(sx.lstrip('0') or '0')
@@ -159,9 +181,12 @@ def _record_club_id(record, club_id=None):
def is_bundle_huiyuan(huiyuan_id):
try:
return bool(Huiyuan.query.get(huiyuan_id=huiyuan_id).is_bundle)
except Huiyuan.DoesNotExist:
hid = normalize_huiyuan_id(huiyuan_id)
if not hid:
return False
for bid in Huiyuan.query.filter(is_bundle=True).values_list('huiyuan_id', flat=True):
if huiyuan_ids_match(bid, hid):
return True
return False
@@ -188,18 +213,13 @@ def user_has_huiyuan_access(yonghu_id, required_huiyuan_id, club_id=None):
def _record_valid(rec):
return _membership_record_active(rec)
for rec in Huiyuangoumai.query.filter(yonghu_id=yonghu_id):
if _record_valid(rec) and huiyuan_ids_match(rec.huiyuan_id, req):
return True
bundle_ids = set(
Huiyuan.query.filter(is_bundle=True).values_list('huiyuan_id', flat=True)
)
if bundle_ids:
req_set = {req, req.lstrip('0') or '0'}
for rec in Huiyuangoumai.query.filter(yonghu_id=yonghu_id, huiyuan_id__in=bundle_ids):
for rec in Huiyuangoumai.query.filter(yonghu_id=yonghu_id):
if not _record_valid(rec):
continue
if huiyuan_ids_match(rec.huiyuan_id, req):
return True
if is_bundle_huiyuan(rec.huiyuan_id):
included = resolve_bundle_included_ids(
rec.huiyuan_id,
club_id=club_id,
@@ -211,6 +231,10 @@ def user_has_huiyuan_access(yonghu_id, required_huiyuan_id, club_id=None):
if _czjilu_grants_huiyuan_access(yonghu_id, req, club_id):
return True
from jituan.services.gift_huiyuan import user_has_gift_huiyuan_access
if user_has_gift_huiyuan_access(yonghu_id, req, club_id=club_id):
return True
return False
@@ -478,26 +502,33 @@ def _build_clumber_from_paid_czjilu(yonghu_id, club_id=None):
def _finalize_clumber_for_legacy_client(items):
"""老端只认 huiyuanid去掉 is_trial 等内部字段。"""
"""老端只认 huiyuanid去掉 is_trial / is_gift 等内部字段。"""
cleaned = []
for it in items or []:
row = dict(it)
row.pop('is_trial', None)
row.pop('is_gift', None)
cleaned.append(row)
return cleaned
def ensure_grab_pool_clumber(yonghu_id, club_id=None):
"""
dddhq / dashouxinxi只读 huiyuangoumai + 有效期内充值单 生成 clumber(体验=正式)
dddhq / dashouxinxi只读 huiyuangoumai + 有效期内充值单 + 赠送会员 生成 clumber。
总会员展开子会员虚拟条目供老端 clumber 比对;不写库、不补开通。
"""
from jituan.services.gift_huiyuan import build_gift_clumber_items
effective_club = _resolve_user_club_id(yonghu_id, club_id)
items = _build_user_clumber_core(yonghu_id, club_id=effective_club)
items = _merge_clumber_extra_items(
items,
_build_clumber_from_paid_czjilu(yonghu_id, club_id=effective_club),
)
items = _merge_clumber_extra_items(
items,
build_gift_clumber_items(yonghu_id, club_id=effective_club),
)
items = _finalize_clumber_for_legacy_client(items)
return bool(items), items

View File

@@ -31,7 +31,11 @@ def assert_invite_same_club(request, inviter_user, invitee_user=None):
'invite_guard 管事俱乐部不符 inviter=%s inviter_club=%s req_club=%s',
getattr(inviter_user, 'UserUID', '?'), inviter_club, club_id,
)
return False, '邀请码不属于当前小程序俱乐部'
# 文案带上两侧 club方便排查「种子 ClubID ≠ 小程序 X-Club-Id」
return False, (
f'邀请码不属于当前小程序俱乐部'
f'(邀请人归属={inviter_club or ""},当前小程序={club_id or ""}'
)
if invitee_user is not None:
invitee_club = resolve_invite_party_club(invitee_user, club_id)

View File

@@ -30,10 +30,14 @@ def _has_super_perm(permissions):
def _page_visible(page, permissions, user):
page_id = getattr(page, 'page_id', '') or ''
if page.require_group_manage:
return can_manage_admin_assignments(user)
if getattr(page, 'require_admin_user_manage', False):
# 后台用户:本店超级管理 000001 / 集团 / 系统超管均可。
# 注意:历史上 DB 模型缺 require_admin_user_manage 字段,导致该页永远不可见,必须按 page_id 兜底。
if page_id == 'admin.user' or getattr(page, 'require_admin_user_manage', False):
return can_access_admin_user_management(user, permissions)
if page.require_super:
@@ -92,7 +96,7 @@ def _load_menu_pages():
pages = []
need_seed = not pages or not any(
p.page_id in ('miniapp.assets', 'product.fake-orders') for p in pages
p.page_id in ('miniapp.assets', 'product.fake-orders', 'miniapp.recharge-copy') for p in pages
)
if need_seed:
try:
@@ -165,6 +169,7 @@ def build_menu_access_payload(request, username=None):
path_by_id = {p.page_id: p.path for p in pages if p.path}
visible_paths = [path_by_id[pid] for pid in visible_ids if pid in path_by_id]
is_super = _has_super_perm(permissions) or is_system_super_admin(request.user)
return {
'visible_page_ids': sorted(set(visible_ids)),
'visible_paths': visible_paths,
@@ -178,6 +183,7 @@ def build_menu_access_payload(request, username=None):
'roles_in_scope': roles_detail,
'can_switch_club': bool(club_ctx.get('can_switch_club')),
'is_group_admin': bool(club_ctx.get('is_group_admin')),
'is_super': bool(is_super),
'menu_ready': True,
'menu_source': menu_source,
}, None

View File

@@ -54,22 +54,28 @@ KEFU_MENU_ROW_DEFS = [
'require_group_manage': True},
{'page_id': 'admin.club-config', 'name': '俱乐部密钥配置', 'path': '/admin/club-config', 'parent_id': 'admin', 'sort_order': 73,
'require_group_manage': True},
{'page_id': 'admin.order-grab-share', 'name': '订单互通抢单', 'path': '/admin/order-grab-share', 'parent_id': 'admin', 'sort_order': 76,
'require_group_manage': True},
{'page_id': 'admin.user', 'name': '后台用户', 'path': '/admin/user', 'parent_id': 'admin', 'sort_order': 74,
'require_admin_user_manage': True},
'require_super': True, 'require_admin_user_manage': True},
{'page_id': 'admin.operation-log', 'name': '操作日志', 'path': '/admin/operation-log', 'parent_id': 'admin', 'sort_order': 75,
'perm_codes': ['czrz666']},
{'page_id': 'punishment', 'name': '处罚管理', 'path': '/punishment', 'parent_id': '', 'sort_order': 80,
'perm_codes': ['005aa', '66693a', '66693b', '66693c', '66694c', '99933abs']},
{'page_id': 'punishment.detail', 'name': '积分处罚详情', 'path': '/punishment/detail', 'parent_id': 'punishment', 'sort_order': 81,
'perm_codes': ['005aa', '66693a', '66693b', '66693c', '66694c', '99933abs']},
{'page_id': 'gongdan', 'name': '投诉工单', 'path': '/gongdan', 'parent_id': '', 'sort_order': 82,
'perm_codes': ['gdck666', 'gdcl666']},
{'page_id': 'product', 'name': '商品管理', 'path': '', 'parent_id': '', 'sort_order': 90},
{'page_id': 'product.list', 'name': '商品列表', 'path': '/product/list', 'parent_id': 'product', 'sort_order': 91,
'perm_codes': ['2200a']},
{'page_id': 'product.type-zone', 'name': '商品类型专区管理', 'path': '/product/type-zone', 'parent_id': 'product', 'sort_order': 92,
'perm_codes': ['7007a']},
{'page_id': 'product.data', 'name': '商品数据分析', 'path': '/product/data', 'parent_id': 'product', 'sort_order': 93,
{'page_id': 'product.turntable', 'name': '转盘活动', 'path': '/product/turntable', 'parent_id': 'product', 'sort_order': 93,
'perm_codes': ['2200a']},
{'page_id': 'product.fake-orders', 'name': '假单管理', 'path': '/product/fake-orders', 'parent_id': 'product', 'sort_order': 94,
{'page_id': 'product.data', 'name': '商品数据分析', 'path': '/product/data', 'parent_id': 'product', 'sort_order': 94,
'perm_codes': ['2200a']},
{'page_id': 'product.fake-orders', 'name': '假单管理', 'path': '/product/fake-orders', 'parent_id': 'product', 'sort_order': 95,
'perm_codes': ['2200a']},
{'page_id': 'member', 'name': '会员管理', 'path': '', 'parent_id': '', 'sort_order': 100},
{'page_id': 'member.list', 'name': '会员管理', 'path': '/member/list', 'parent_id': 'member', 'sort_order': 101,
@@ -93,6 +99,8 @@ KEFU_MENU_ROW_DEFS = [
'perm_codes': ['dsks666', '8080a']},
{'page_id': 'miniapp.rank-reward', 'name': '排行榜奖励', 'path': '/miniapp/rank-reward', 'parent_id': 'miniapp', 'sort_order': 118,
'perm_codes': ['phbj666', '8080a']},
{'page_id': 'miniapp.recharge-copy', 'name': '充值页文案', 'path': '/miniapp/recharge-copy', 'parent_id': 'miniapp', 'sort_order': 119,
'perm_codes': ['8080a']},
{'page_id': 'shop', 'name': '店铺管理', 'path': '', 'parent_id': '', 'sort_order': 120},
{'page_id': 'shop.list', 'name': '店铺列表', 'path': '/shop/list', 'parent_id': 'shop', 'sort_order': 121,
'perm_codes': ['999a', '999b', '999c', '999d', '999e']},

View File

@@ -48,6 +48,9 @@ MEMBER_PAID_STATUS = 3
MEMBER_PENDING_STATUS = 9
MEMBER_FAILED_STATUS = 8
MEMBER_CANCELLED_STATUS = 2
# 与 czjilu_types.CZJILU_FULFILLABLE_STATUSES 一致:仅这些状态允许入账
MEMBER_FULFILLABLE_STATUSES = (MEMBER_PENDING_STATUS, MEMBER_CANCELLED_STATUS)
MEMBER_CANCELLED_STATUS = 2
ENTITLEMENT_REVOKED_MARKER = '权益已撤销'
MEMBER_JIFEN_CAP = 10
@@ -923,7 +926,7 @@ def fulfill_member_recharge(dingdan_id, paid_amount_yuan=None, source='callback'
if order.leixing != MEMBER_LEIXING:
raise MemberRechargeError('订单类型不是会员充值', 'type_error')
if order.zhuangtai != MEMBER_PENDING_STATUS:
if order.zhuangtai not in MEMBER_FULFILLABLE_STATUSES:
logger.info(
'会员订单已处理,跳过履约 dingdan=%s zhuangtai=%s source=%s',
dingdan_id, order.zhuangtai, source,
@@ -1093,21 +1096,18 @@ def confirm_member_recharge_paid(dingdan_id, yonghuid):
'is_trial': goumai.is_trial,
}
if order.zhuangtai != MEMBER_PENDING_STATUS:
if order.zhuangtai != MEMBER_PENDING_STATUS and order.zhuangtai != MEMBER_CANCELLED_STATUS:
raise MemberRechargeError(f'订单状态异常: {order.zhuangtai}', 'status_error')
# 支付宝订单:异步回调会更新状态,轮询时只看本地状态,未支付则提示稍后重试
from django.core.cache import cache
if cache.get(f'alipay_pay_url_{dingdan_id}') is not None:
raise MemberRechargeError('支付宝支付结果尚未同步,请稍后再试', 'not_paid')
club_id = getattr(order, 'club_id', None)
from jituan.services.pay_confirm import confirm_remote_payment
remote = confirm_remote_payment(dingdan_id, club_id, prefer_amount=order.jine)
if not remote.get('ok'):
if remote.get('pending'):
raise MemberRechargeError('支付结果尚未同步,请稍后再试', 'not_paid')
raise MemberRechargeError(remote.get('msg') or '支付未成功', 'not_paid')
trade_state, _ = query_wechat_v2_trade_state(
dingdan_id, getattr(order, 'club_id', None),
)
if trade_state != 'SUCCESS':
raise MemberRechargeError('微信侧尚未支付成功,请稍后再试', 'not_paid')
total_fee_yuan = Decimal(str(order.jine))
total_fee_yuan = Decimal(str(remote.get('amount') if remote.get('amount') is not None else order.jine))
result = fulfill_member_recharge(
dingdan_id, paid_amount_yuan=total_fee_yuan, source='confirm',
)

View File

@@ -0,0 +1,152 @@
"""小程序「微信支付」通道路由:按俱乐部选择微信直连或付呗;前端仍传 pay_method=wechat。"""
from __future__ import annotations
import logging
from decimal import Decimal
from typing import Any, Dict, Optional
from django.conf import settings
from jituan.constants import (
CLUB_ID_DEFAULT,
FUBEI_BIZ_CZJILU,
FUBEI_BIZ_ORDER,
MINI_PAY_CHANNEL_FUBEI,
MINI_PAY_CHANNEL_WECHAT,
PAY_CHANNEL_FUBEI_WX_MINI,
)
from jituan.models import Club, PayChannelLedger
from jituan.services.club_payment_channel import get_fubei_config, invalidate_club_payment_cache
from jituan.services import fubei_client
logger = logging.getLogger(__name__)
def get_club_mini_pay_channel(club_id: Optional[str] = None) -> str:
"""返回 wechat / fubei缺省 wechat现网行为"""
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
club = Club.query.filter(club_id=cid).first()
if not club:
return MINI_PAY_CHANNEL_WECHAT
ch = (getattr(club, 'mini_pay_channel', None) or MINI_PAY_CHANNEL_WECHAT).strip().lower()
if ch == MINI_PAY_CHANNEL_FUBEI:
return MINI_PAY_CHANNEL_FUBEI
return MINI_PAY_CHANNEL_WECHAT
def set_club_mini_pay_channel(club_id: str, channel: str) -> None:
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
ch = (channel or '').strip().lower()
if ch not in (MINI_PAY_CHANNEL_WECHAT, MINI_PAY_CHANNEL_FUBEI):
raise ValueError('mini_pay_channel 仅支持 wechat / fubei')
club = Club.query.filter(club_id=cid).first()
if not club:
raise ValueError(f'俱乐部 {cid} 不存在')
club.mini_pay_channel = ch
club.save(update_fields=['mini_pay_channel'])
invalidate_club_payment_cache(cid)
def _default_fubei_notify_url(club_id: Optional[str] = None) -> str:
base = 'https://www.abas.asia'
club = Club.query.filter(club_id=(club_id or CLUB_ID_DEFAULT)).first()
if club and (club.h5_domain or '').strip():
base = club.h5_domain.strip().rstrip('/')
return f'{base}/hqhd/dingdan/fubei-notify/'
def _remember_ledger(
*,
out_trade_no: str,
club_id: str,
channel: str,
channel_config_id: Optional[int],
biz_type: str,
provider_order_sn: str,
amount_yuan,
) -> None:
row = PayChannelLedger.query.filter(out_trade_no=out_trade_no).first()
fields = {
'club_id': club_id or CLUB_ID_DEFAULT,
'channel': channel,
'channel_config_id': channel_config_id,
'biz_type': biz_type or '',
'provider_order_sn': provider_order_sn or '',
'amount_yuan': Decimal(str(amount_yuan or 0)),
}
if row:
for k, v in fields.items():
setattr(row, k, v)
row.save()
else:
PayChannelLedger.objects.create(out_trade_no=out_trade_no, **fields)
def get_pay_ledger(out_trade_no: str) -> Optional[PayChannelLedger]:
if not out_trade_no:
return None
return PayChannelLedger.query.filter(out_trade_no=out_trade_no).first()
def maybe_create_fubei_jsapi_params(
*,
club_id: Optional[str],
out_trade_no: str,
amount_yuan,
body: str,
openid: str,
notify_url: str = '',
biz_type: str = FUBEI_BIZ_ORDER,
) -> Optional[Dict[str, Any]]:
"""若俱乐部选用付呗则下单并返回 wx.requestPayment 参数;否则返回 None走原微信逻辑"""
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
if get_club_mini_pay_channel(cid) != MINI_PAY_CHANNEL_FUBEI:
return None
cfg = get_fubei_config(cid)
if not cfg or not cfg.app_id or not cfg.api_key:
raise Exception('俱乐部已切换付呗收款,但付呗开放平台 ID/密钥未配置完整')
store_id = cfg.store_id
if store_id in (None, '', 0, '0'):
raise Exception('俱乐部已切换付呗收款,但门店 store_id 未配置')
if not openid:
raise Exception('付呗支付缺少用户 openid')
cb = (notify_url or '').strip() or (cfg.notify_url or '').strip() or _default_fubei_notify_url(cid)
# 强制走付呗专用回调(原微信 XML 回调无法解析付呗 form
if 'fubei-notify' not in cb:
cb = _default_fubei_notify_url(cid)
attach = f'biz={biz_type or FUBEI_BIZ_ORDER}'
data = fubei_client.mina_prepay(
gateway_url=cfg.gateway_url,
app_id=cfg.app_id,
app_secret=cfg.api_key,
merchant_order_sn=out_trade_no,
sub_openid=openid,
total_fee=amount_yuan,
store_id=int(store_id),
call_back_url=cb,
body=body or '',
attach=attach,
)
sign_params = data.get('sign_params') or {}
if isinstance(sign_params, str):
import json
sign_params = json.loads(sign_params)
pay_params = fubei_client.extract_jsapi_pay_params(sign_params)
_remember_ledger(
out_trade_no=out_trade_no,
club_id=cid,
channel=PAY_CHANNEL_FUBEI_WX_MINI,
channel_config_id=cfg.config_id,
biz_type=biz_type or FUBEI_BIZ_ORDER,
provider_order_sn=str(data.get('order_sn') or ''),
amount_yuan=amount_yuan,
)
logger.info(
'[FUBEI_PAY] club=%s out_trade_no=%s biz=%s order_sn=%s',
cid, out_trade_no, biz_type, data.get('order_sn'),
)
return pay_params

View File

@@ -27,6 +27,8 @@ MINIAPP_ICON_META = [
('fighter_recharge_member', '打手中心-会员充值横幅(左侧)'),
('fighter_recharge_deposit', '打手中心-保证金充值横幅(右侧)'),
('fighter_recharge_vip_bg', '打手会员充值页-顶部背景图'),
('fighter_mine_vip_bg', '打手「我的」-VIP条背景图'),
('fighter_mine_vip_icon', '打手「我的」-VIP条左侧图标'),
('fighter_deposit_bg', '打手保证金页-顶部背景图'),
('fighter_deposit_hero', '打手保证金页-卡片右侧装饰图'),
('fighter_deposit_benefit_1', '保证金页-权益图标1'),
@@ -53,12 +55,14 @@ MINIAPP_ICON_META = [
('fighter_invite_poster', '打手中心-推广海报'),
('fighter_invite_code', '打手中心-邀请码'),
('fighter_kuaishou_cash', '打手中心-快手提现'),
('fighter_follow_kuaishou', '打手中心-关注快手'),
('fighter_mine_copy', '打手中心-复制ID'),
('fighter_mine_edit', '打手中心-编辑资料'),
('fighter_mine_qiepian', '打手中心-切片活动横幅'),
('fighter_mine_penalty_tip', '打手中心-罚单提示图标'),
('fighter_mine_kefu', '打手中心-在线客服'),
('fighter_mine_shangjia', '打手中心-商家认证'),
('fighter_complaint_record', '打手中心-投诉记录'),
('fighter_mine_punish', '打手中心-我的处罚'),
('fighter_mine_medal', '打手中心-考核金牌'),
('fighter_mine_book', '打手中心-接单考试/规则'),
@@ -74,8 +78,17 @@ MINIAPP_ICON_META = [
('fighter_mine_switch', '打手中心-返回点单端'),
('mine_pindao', '频道入口'),
('icon_refresh', '通用刷新'),
# 点单端「我的」服务入口(客服 / 邀请须分开可配)
('cuser_mine_kefu', '点单端我的-在线客服'),
('cuser_mine_invite', '点单端我的-邀请点单'),
('cuser_mine_invitees', '点单端我的-我推荐的人'),
('guanshi_poster_bg', '管事推广海报背景'),
('zuzhang_poster_bg', '组长推广海报背景'),
('cuser_poster_bg', '点单端推荐官海报背景'),
# 打手接单池订单卡背景(未上传不下发,前端保留原样式)
('grab_card_pingtai_bg', '接单池-平台订单卡背景'),
('grab_card_youzhi_bg', '接单池-优质商家卡背景'),
('grab_card_shangjia_bg', '接单池-普通商家卡背景'),
]
MINIAPP_ICON_KEYS = [k for k, _ in MINIAPP_ICON_META]
@@ -99,6 +112,8 @@ MINIAPP_ICON_DEFAULTS['merchant_home_stat_bg'] = 'beijing/shangjiaduan/stat_card
MINIAPP_ICON_DEFAULTS['fighter_recharge_member'] = f'{ICON_FOLDER}/fighter_recharge_member.png'
MINIAPP_ICON_DEFAULTS['fighter_recharge_deposit'] = f'{ICON_FOLDER}/fighter_recharge_deposit.png'
MINIAPP_ICON_DEFAULTS['fighter_recharge_vip_bg'] = f'{ICON_FOLDER}/fighter_recharge_vip_bg.png'
MINIAPP_ICON_DEFAULTS['fighter_mine_vip_bg'] = f'{ICON_FOLDER}/fighter_mine_vip_bg.png'
MINIAPP_ICON_DEFAULTS['fighter_mine_vip_icon'] = f'{ICON_FOLDER}/fighter_mine_vip_icon.png'
MINIAPP_ICON_DEFAULTS['fighter_deposit_bg'] = f'{ICON_FOLDER}/fighter_deposit_bg.png'
MINIAPP_ICON_DEFAULTS['fighter_deposit_hero'] = f'{ICON_FOLDER}/fighter_deposit_hero.png'
MINIAPP_ICON_DEFAULTS['fighter_order_pending'] = f'{ICON_FOLDER}/fighter_order_pending.png'
@@ -114,12 +129,14 @@ MINIAPP_ICON_DEFAULTS['fighter_invite_subordinate'] = f'{ICON_FOLDER}/fighter_in
MINIAPP_ICON_DEFAULTS['fighter_invite_poster'] = f'{ICON_FOLDER}/fighter_invite_poster.png'
MINIAPP_ICON_DEFAULTS['fighter_invite_code'] = f'{ICON_FOLDER}/fighter_invite_code.png'
MINIAPP_ICON_DEFAULTS['fighter_kuaishou_cash'] = f'{ICON_FOLDER}/fighter_kuaishou_cash.png'
MINIAPP_ICON_DEFAULTS['fighter_follow_kuaishou'] = 'beijing/tubiao/grzx_guanzhualong.jpg'
MINIAPP_ICON_DEFAULTS['fighter_mine_copy'] = 'beijing/guanshiduan/icon-copy.png'
MINIAPP_ICON_DEFAULTS['fighter_mine_edit'] = 'beijing/dashouduan/dashoutubiaobeijing/icon-edit.jpg'
MINIAPP_ICON_DEFAULTS['fighter_mine_qiepian'] = f'{ICON_FOLDER}/fighter_mine_qiepian.png'
MINIAPP_ICON_DEFAULTS['fighter_mine_penalty_tip'] = f'{ICON_FOLDER}/fighter_mine_penalty_tip.png'
MINIAPP_ICON_DEFAULTS['fighter_mine_kefu'] = 'beijing/tubiao/grzx_kefu.jpg'
MINIAPP_ICON_DEFAULTS['fighter_mine_shangjia'] = 'beijing/tubiao/grzx_shangjia.jpg'
MINIAPP_ICON_DEFAULTS['fighter_complaint_record'] = 'beijing/guanshiduan/icon-contact.png'
MINIAPP_ICON_DEFAULTS['fighter_mine_punish'] = 'beijing/dashouduan/dashoutubiaobeijing/icon-punish.jpg'
MINIAPP_ICON_DEFAULTS['fighter_mine_medal'] = 'beijing/dashouduan/dashoutubiaobeijing/icon-medal.jpg'
MINIAPP_ICON_DEFAULTS['fighter_mine_book'] = 'beijing/dashouduan/dashoutubiaobeijing/icon-book.jpg'
@@ -134,16 +151,25 @@ MINIAPP_ICON_DEFAULTS['fighter_mine_dashou_auth'] = 'beijing/tubiao/grzx_dashou.
MINIAPP_ICON_DEFAULTS['fighter_mine_clear'] = 'beijing/tubiao/grzx_qingchu.jpg'
MINIAPP_ICON_DEFAULTS['fighter_mine_switch'] = f'{ICON_FOLDER}/fighter_mine_switch.png'
MINIAPP_ICON_DEFAULTS['mine_pindao'] = 'beijing/tubiao/grzx_pindao.jpg'
MINIAPP_ICON_DEFAULTS['cuser_mine_kefu'] = 'beijing/tubiao/grzx_kefu.jpg'
# 邀请默认用邀请图标,避免与客服同图
MINIAPP_ICON_DEFAULTS['cuser_mine_invite'] = 'beijing/guanshiduan/icon-invite.png'
# 推荐的人:用下级/人员图标,勿与关注快手同图;勿用可能不存在的 chengxutubiao 占位 png
MINIAPP_ICON_DEFAULTS['cuser_mine_invitees'] = 'beijing/guanshiduan/icon-sub.png'
MINIAPP_ICON_DEFAULTS['guanshi_poster_bg'] = 'beijing/haibaobeijing.jpg'
MINIAPP_ICON_DEFAULTS['zuzhang_poster_bg'] = 'beijing/zuzhangbeijing.jpg'
# 点单端推荐官海报:独立路径,禁止复用管事/打手背景
MINIAPP_ICON_DEFAULTS['cuser_poster_bg'] = 'beijing/haibao/cuser_poster_bg.jpg'
POSTER_BG_FIELDS = {
'guanshi_bg': 'guanshi_poster_bg',
'zuzhang_bg': 'zuzhang_poster_bg',
'cuser_bg': 'cuser_poster_bg',
}
POSTER_BG_DEFAULTS = {
'guanshi_bg': 'beijing/haibaobeijing.jpg',
'zuzhang_bg': 'beijing/zuzhangbeijing.jpg',
'cuser_bg': 'beijing/haibao/cuser_poster_bg.jpg',
}
POSTER_FOLDER = 'beijing/haibao'
POSTER_ICON_KEYS = frozenset(POSTER_BG_FIELDS.values())
@@ -151,8 +177,13 @@ POSTER_ICON_KEYS = frozenset(POSTER_BG_FIELDS.values())
# 仅后台上传后才下发给小程序(页面大背景等)
CONFIG_ONLY_DELIVERY_KEYS = frozenset([
'fighter_recharge_vip_bg',
'fighter_mine_vip_bg',
'fighter_mine_vip_icon',
'fighter_deposit_bg',
'fighter_deposit_hero',
'grab_card_pingtai_bg',
'grab_card_youzhi_bg',
'grab_card_shangjia_bg',
])
@@ -170,13 +201,20 @@ def get_miniapp_icons_dict(club_id: str) -> dict:
result = {}
for key, _label in MINIAPP_ICON_META:
row = rows.get(key)
if row and row.is_uploaded and (row.image_path or '').strip():
result[key] = row.image_path.strip()
path = (row.image_path if row else '') or ''
path = path.strip()
# 已上传:必须下发(含接单池卡背景 / 充值页大图)
if row and path and row.is_uploaded:
result[key] = path
continue
if key in CONFIG_ONLY_DELIVERY_KEYS:
# 兼容:有非默认路径但 is_uploaded 标志异常时仍下发
default_path = default_icon_path(key)
if row and path and path != default_path:
result[key] = path
continue
if row and (row.image_path or '').strip():
result[key] = row.image_path.strip()
if row and path:
result[key] = path
else:
result[key] = default_icon_path(key)
return result
@@ -214,6 +252,9 @@ def get_poster_payload(club_id: str) -> dict:
row = rows.get(icon_key)
if row and row.is_uploaded and (row.image_path or '').strip():
result[field] = row.image_path.strip()
elif field == 'cuser_bg':
# 点单端未上传时不回落到管事背景,避免错图
result[field] = (row.image_path.strip() if row and (row.image_path or '').strip() else '') or default
else:
result[field] = default
return result
@@ -222,6 +263,7 @@ def get_poster_payload(club_id: str) -> dict:
def build_miniapp_assets_payload(request) -> dict:
club_id = _display_club_id(request)
return {
'club_id': club_id,
'icons': get_miniapp_icons_dict(club_id),
'pindao': get_pindao_payload(club_id),
'poster': get_poster_payload(club_id),

View File

@@ -0,0 +1,90 @@
"""俱乐部订单单向互通抢单grabber 可看/抢 order 俱乐部的待接单。"""
from django.db.models import Q
from jituan.constants import CLUB_ID_DEFAULT
from jituan.models import Club, ClubOrderGrabLink
def normalize_club_id(club_id):
return (club_id or '').strip() or CLUB_ID_DEFAULT
def order_club_ids_for_grabber(grabber_club_id):
"""
打手所属俱乐部可见的订单 ClubID 集合(含本店 + 已配置可抢的对方店)。
"""
gc = normalize_club_id(grabber_club_id)
ids = {gc}
for oc in ClubOrderGrabLink.query.filter(
grabber_club_id=gc,
).values_list('order_club_id', flat=True):
oc = (oc or '').strip()
if oc:
ids.add(oc)
return ids
def order_club_q_for_grabber(grabber_club_id):
"""用于 Order 查询的 Q本店 + 互通店;本店含默认俱乐部时兼容空 ClubID 旧单。"""
allowed = order_club_ids_for_grabber(grabber_club_id)
q = Q(ClubID__in=list(allowed))
if CLUB_ID_DEFAULT in allowed:
q |= Q(ClubID__isnull=True) | Q(ClubID='')
return q
def can_grabber_take_order_club(grabber_club_id, order_club_id):
gc = normalize_club_id(grabber_club_id)
oc = normalize_club_id(order_club_id)
if gc == oc:
return True
return ClubOrderGrabLink.query.filter(
grabber_club_id=gc, order_club_id=oc,
).exists()
def list_grab_links():
name_map = {c.club_id: c.name for c in Club.query.filter(status=1)}
rows = list(ClubOrderGrabLink.query.order_by('grabber_club_id', 'order_club_id'))
return [
{
'id': r.id,
'grabber_club_id': r.grabber_club_id,
'grabber_club_name': name_map.get(r.grabber_club_id, r.grabber_club_id),
'order_club_id': r.order_club_id,
'order_club_name': name_map.get(r.order_club_id, r.order_club_id),
'create_time': r.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if r.CreateTime else '',
}
for r in rows
]
def add_grab_link(grabber_club_id, order_club_id):
gc = (grabber_club_id or '').strip()
oc = (order_club_id or '').strip()
if not gc or not oc:
return None, '请选择抢单方与被抢订单俱乐部'
if gc == oc:
return None, '不能配置本店抢本店(本店订单默认可见)'
if not Club.query.filter(club_id=gc, status=1).exists():
return None, f'抢单方俱乐部不存在: {gc}'
if not Club.query.filter(club_id=oc, status=1).exists():
return None, f'订单方俱乐部不存在: {oc}'
if ClubOrderGrabLink.query.filter(grabber_club_id=gc, order_club_id=oc).exists():
return None, '该互通规则已存在'
row = ClubOrderGrabLink.query.create(
grabber_club_id=gc,
order_club_id=oc,
)
return {'id': row.id, 'grabber_club_id': gc, 'order_club_id': oc}, None
def delete_grab_link(link_id):
try:
link_id = int(link_id)
except (TypeError, ValueError):
return False, '无效的规则 id'
deleted, _ = ClubOrderGrabLink.objects.filter(id=link_id).delete()
if not deleted:
return False, '规则不存在'
return True, None

View File

@@ -0,0 +1,88 @@
"""统一远程支付确认:微信直连 / 付呗 / 支付宝,供复查与轮询调用。
返回:
{'ok': True, 'channel': 'fubei'|'wechat'|'alipay', 'transaction_id': str|None, 'amount': Decimal|None}
{'ok': False, 'pending': True, 'channel': ...} # 支付中,前端应继续轮询
{'ok': False, 'pending': False, 'channel': ..., 'msg': ...} # 明确失败(少见)
"""
from __future__ import annotations
import logging
from decimal import Decimal
from typing import Any, Dict, Optional
from django.core.cache import cache
logger = logging.getLogger(__name__)
def confirm_remote_payment(
out_trade_no: str,
club_id: Optional[str] = None,
*,
prefer_amount: Any = None,
) -> Dict[str, Any]:
"""按通道查官方支付状态(付呗优先于微信直连,互不串单)。"""
from jituan.services.fubei_query import fubei_success_payload
fb = fubei_success_payload(out_trade_no, club_id)
if fb is not None:
txn, total_fee = fb
if not txn and total_fee is None:
return {'ok': False, 'pending': True, 'channel': 'fubei'}
amount = total_fee if total_fee not in (None, '') else prefer_amount
return {
'ok': True,
'pending': False,
'channel': 'fubei',
'transaction_id': txn,
'amount': Decimal(str(amount)) if amount not in (None, '') else None,
}
is_alipay = cache.get(f'alipay_pay_url_{out_trade_no}') is not None
if is_alipay:
from jituan.services.alipay_pay import query_alipay_trade
trade_status = query_alipay_trade(out_trade_no, club_id)
if trade_status in ('TRADE_SUCCESS', 'TRADE_FINISHED'):
return {
'ok': True,
'pending': False,
'channel': 'alipay',
'transaction_id': None,
'amount': Decimal(str(prefer_amount)) if prefer_amount not in (None, '') else None,
}
return {'ok': False, 'pending': True, 'channel': 'alipay'}
from jituan.services.member_recharge import query_wechat_v2_trade_state
trade_state, transaction_id = query_wechat_v2_trade_state(out_trade_no, club_id)
if trade_state == 'SUCCESS':
return {
'ok': True,
'pending': False,
'channel': 'wechat',
'transaction_id': transaction_id,
'amount': Decimal(str(prefer_amount)) if prefer_amount not in (None, '') else None,
}
# cache 过期兜底:再试支付宝
from jituan.services.alipay_pay import query_alipay_trade
alipay_status = query_alipay_trade(out_trade_no, club_id)
if alipay_status in ('TRADE_SUCCESS', 'TRADE_FINISHED'):
logger.info('confirm_remote: cache 过期但支付宝查单成功 %s', out_trade_no)
return {
'ok': True,
'pending': False,
'channel': 'alipay',
'transaction_id': None,
'amount': Decimal(str(prefer_amount)) if prefer_amount not in (None, '') else None,
}
if trade_state in (None, 'NOTPAY', 'USERPAYING', ''):
return {'ok': False, 'pending': True, 'channel': 'wechat', 'trade_state': trade_state}
return {
'ok': False,
'pending': False,
'channel': 'wechat',
'trade_state': trade_state,
'msg': f'支付状态异常: {trade_state}',
}

View File

@@ -0,0 +1,62 @@
"""按支付流水选择退款通道:付呗单走付呗,其余走原微信退款逻辑。"""
from __future__ import annotations
import logging
import random
import string
import time
from typing import Any, Dict, Optional
from jituan.constants import PAY_CHANNEL_FUBEI_WX_MINI
from jituan.services.club_payment_channel import get_fubei_config
from jituan.services import fubei_client
from jituan.services.mini_pay_router import get_pay_ledger
logger = logging.getLogger(__name__)
def _refund_sn(out_trade_no: str) -> str:
ts = str(int(time.time()))
rand = ''.join(random.choices('0123456789', k=4))
# 付呗 merchant_refund_sn 最长 32
base = f'{out_trade_no}R{ts}{rand}'
return base[:32]
def try_fubei_refund(
*,
out_trade_no: str,
amount_yuan,
club_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""
若该单曾走付呗,则发起付呗退款并返回 {'code': 0/非0, 'msg': ..., 'refund_id': ...}
非付呗单返回 None由调用方继续走原微信退款。
"""
ledger = get_pay_ledger(out_trade_no)
if not ledger or ledger.channel != PAY_CHANNEL_FUBEI_WX_MINI:
return None
cid = (club_id or ledger.club_id or '').strip()
cfg = get_fubei_config(cid)
if not cfg or not cfg.app_id or not cfg.api_key:
logger.error('[FUBEI_REFUND] 配置缺失 club=%s out_trade_no=%s', cid, out_trade_no)
return {'code': 500, 'msg': '付呗退款配置缺失,请检查俱乐部付呗通道'}
merchant_refund_sn = _refund_sn(out_trade_no)
try:
data = fubei_client.refund_order(
gateway_url=cfg.gateway_url,
app_id=cfg.app_id,
app_secret=cfg.api_key,
merchant_order_sn=out_trade_no,
merchant_refund_sn=merchant_refund_sn,
refund_money=amount_yuan,
order_sn=ledger.provider_order_sn or '',
)
refund_id = data.get('refund_sn') or merchant_refund_sn
logger.info('[FUBEI_REFUND] ok out_trade_no=%s refund=%s', out_trade_no, refund_id)
return {'code': 0, 'msg': 'success', 'refund_id': refund_id, 'raw': data}
except Exception as exc:
logger.error('[FUBEI_REFUND] fail out_trade_no=%s: %s', out_trade_no, exc, exc_info=True)
return {'code': 400, 'msg': str(exc)}

View File

@@ -0,0 +1,204 @@
"""会员/履约金充值页文案按俱乐部读写。"""
import json
from jituan.models import ClubRechargePageCopy
from jituan.services.club_context import resolve_club_id_from_request, resolve_effective_club_id
PAGE_HUIYUAN = 'huiyuan'
PAGE_YAJIN = 'yajin'
PAGE_KEYS = {PAGE_HUIYUAN, PAGE_YAJIN}
REGIONS_BY_PAGE = {
PAGE_HUIYUAN: ('rules', 'benefits', 'advantages'),
PAGE_YAJIN: ('rules', 'benefits'),
}
REGION_SORT = {
'rules': 10,
'benefits': 20,
'advantages': 30,
}
def normalize_page_key(page_key):
key = (page_key or '').strip().lower()
return key if key in PAGE_KEYS else ''
def normalize_region_key(page_key, region_key):
key = (region_key or '').strip().lower()
allowed = REGIONS_BY_PAGE.get(page_key) or ()
return key if key in allowed else ''
def _as_items_list(raw):
if raw is None:
return []
if isinstance(raw, str):
try:
raw = json.loads(raw)
except (TypeError, ValueError):
return []
if not isinstance(raw, list):
return []
out = []
for item in raw:
if isinstance(item, dict):
out.append(item)
elif isinstance(item, str) and item.strip():
out.append({'text': item.strip()})
return out
def _normalize_items_for_region(region_key, items):
"""按区域清洗条目字段,去掉空条目。"""
cleaned = []
for item in _as_items_list(items):
if region_key == 'rules':
text = (item.get('text') or '').strip()
if text:
cleaned.append({'text': text})
elif region_key == 'benefits':
# 会员权益title/desc履约金权益label
title = (item.get('title') or '').strip()
label = (item.get('label') or '').strip()
desc = (item.get('desc') or '').strip()
icon_url = (item.get('icon_url') or '').strip()
accent = (item.get('accent_color') or '').strip()
row = {}
if title:
row['title'] = title
if label:
row['label'] = label
if desc:
row['desc'] = desc
if icon_url:
row['icon_url'] = icon_url
if accent:
row['accent_color'] = accent
if row.get('title') or row.get('label') or row.get('desc'):
cleaned.append(row)
elif region_key == 'advantages':
title = (item.get('title') or '').strip()
desc = (item.get('desc') or '').strip()
icon_url = (item.get('icon_url') or '').strip()
if not (title or desc):
continue
row = {}
if title:
row['title'] = title
if desc:
row['desc'] = desc
if icon_url:
row['icon_url'] = icon_url
cleaned.append(row)
return cleaned
def _row_to_region_payload(row, for_admin=False):
items = _as_items_list(row.items_json)
payload = {
'title': (row.title or '').strip(),
'items': items,
'enabled': bool(row.enabled),
'sort_order': int(row.sort_order or 0),
}
if for_admin:
payload['region_key'] = row.region_key
payload['page_key'] = row.page_key
return payload
def get_public_regions(club_id, page_key):
"""小程序只读:仅返回 enabled 且 items 非空的区域。"""
page_key = normalize_page_key(page_key)
if not page_key or not club_id:
return {}
rows = (
ClubRechargePageCopy.query
.filter(club_id=club_id, page_key=page_key, enabled=True)
.order_by('sort_order', 'id')
)
regions = {}
for row in rows:
items = _as_items_list(row.items_json)
if not items:
continue
regions[row.region_key] = {
'title': (row.title or '').strip(),
'items': items,
}
return regions
def get_admin_regions(club_id, page_key):
"""后台:返回该页全部区域(含未启用/空),缺省区域补空壳。"""
page_key = normalize_page_key(page_key)
if not page_key or not club_id:
return {}
allowed = REGIONS_BY_PAGE[page_key]
existing = {
row.region_key: row
for row in ClubRechargePageCopy.query.filter(club_id=club_id, page_key=page_key)
}
regions = {}
for rk in allowed:
row = existing.get(rk)
if row:
regions[rk] = _row_to_region_payload(row, for_admin=True)
else:
regions[rk] = {
'region_key': rk,
'page_key': page_key,
'title': '',
'items': [],
'enabled': False,
'sort_order': REGION_SORT.get(rk, 0),
}
return regions
def save_region(club_id, page_key, region_key, *, title='', items=None, enabled=True, sort_order=None):
page_key = normalize_page_key(page_key)
region_key = normalize_region_key(page_key, region_key)
if not club_id or not page_key or not region_key:
return None, 'page_key 或 region_key 无效'
items_clean = _normalize_items_for_region(region_key, items)
title = (title or '').strip()[:64]
enabled = bool(enabled)
if sort_order is None:
sort_order = REGION_SORT.get(region_key, 0)
try:
sort_order = int(sort_order)
except (TypeError, ValueError):
sort_order = REGION_SORT.get(region_key, 0)
row = ClubRechargePageCopy.query.filter(
club_id=club_id, page_key=page_key, region_key=region_key,
).first()
if row:
row.title = title
row.items_json = items_clean
row.enabled = enabled
row.sort_order = sort_order
row.save(update_fields=['title', 'items_json', 'enabled', 'sort_order', 'UpdateTime'])
else:
row = ClubRechargePageCopy.query.create(
club_id=club_id,
page_key=page_key,
region_key=region_key,
title=title,
items_json=items_clean,
enabled=enabled,
sort_order=sort_order,
)
return _row_to_region_payload(row, for_admin=True), None
def club_id_for_mini(request):
return resolve_club_id_from_request(request)
def club_id_for_admin(request, user):
return resolve_effective_club_id(request, user)

View File

@@ -15,6 +15,24 @@ def club_id_for_tixian_yonghuid_safe(yonghuid):
return club_id_for_tixian_yonghuid(yonghuid)
def resolve_payout_club_id(*, audit=None, jilu=None, yonghuid=None, user=None):
"""
打款商户归属俱乐部:优先提现单/审核单上的 club_id再回落用户归属。
避免用户迁店后用新俱乐部商户号打旧单资金。
"""
for obj in (audit, jilu):
if obj is None:
continue
cid = (getattr(obj, 'club_id', None) or '').strip()
if cid:
return cid
if user is not None:
return get_user_club_id(user) or ''
if yonghuid:
return club_id_for_tixian_yonghuid(yonghuid) or ''
return ''
def filter_tixianjilu_by_request(qs, request):
return filter_queryset_by_club(qs, request, club_field='club_id')
@@ -22,6 +40,12 @@ def filter_tixianjilu_by_request(qs, request):
def forbid_if_tixian_out_of_scope(request, tixian_record):
"""提现记录不在当前俱乐部范围时返回 Response。"""
if resolve_club_scope(request) == DATA_SCOPE_ALL:
from jituan.services.group_finance_gate import has_group_finance_exec
if not has_group_finance_exec(request.user, request):
return Response({
'code': 403,
'msg': '跨俱乐部提现操作仅集团高管可执行,请切换到具体俱乐部',
})
return None
club_id = resolve_club_id_from_request(request)
rec_club = getattr(tixian_record, 'club_id', None) or club_id_for_tixian_yonghuid_safe(

View File

@@ -161,13 +161,22 @@ def login_or_register_by_club(code, club_id=None, app_id=None, client_ip=''):
refresh = RefreshToken.for_user(user)
token = str(refresh.access_token)
staff_fields = {}
order_counts = {}
group_info = {}
try:
from merchant_ops.services.authz import staff_fields_for_login
from users.views import WechatMiniProgramLoginView
staff_fields = staff_fields_for_login(user)
staff_fields = staff_fields_for_login(user) or {}
except Exception:
logger.exception('staff_fields_for_login 失败 uid=%s', getattr(user, 'UserUID', ''))
try:
from users.views import WechatMiniProgramLoginView
login_view = WechatMiniProgramLoginView()
order_counts = login_view.jisuanOrderShuliang(user.UserUID)
group_info = login_view.huoquQunPeizhi()
order_counts = login_view.jisuanOrderShuliang(user.UserUID) or {}
group_info = login_view.huoquQunPeizhi() or {}
except Exception:
logger.exception('登录附属信息失败 uid=%s', getattr(user, 'UserUID', ''))
data = {
'token': token,
@@ -189,3 +198,26 @@ def login_or_register_by_club(code, club_id=None, app_id=None, client_ip=''):
**staff_fields,
}
return data, None
def refresh_wx_openid_binding(code, user, club_id=None, app_id=None):
"""已登录用户:用当前小程序 code 刷新 user_wx_openid不换 JWT"""
wx_data = jscode2session(code, club_id=club_id, app_id=app_id)
if not wx_data or 'openid' not in wx_data:
return None, wx_data.get('errmsg', '微信授权失败')
openid = wx_data['openid']
unionid = wx_data.get('unionid', '')
resolved_club_id = wx_data.get('club_id') or club_id or CLUB_ID_DEFAULT
ensure_wx_openid_binding(resolved_club_id, openid, user, unionid)
ensure_user_club_id(user, resolved_club_id)
if unionid and unionid != (getattr(user, 'UnionID', None) or ''):
user.UnionID = unionid
user.save(update_fields=['UnionID'])
return {
'club_id': resolved_club_id,
'openid_preview': openid[:6] + '...' + openid[-4:] if len(openid) > 10 else openid,
'wx_appid': wx_data.get('wx_appid', ''),
}, None

View File

@@ -1,29 +1,189 @@
"""各俱乐部微信支付 V2MD5配置与验签。"""
import hashlib
import logging
import os
from django.conf import settings
from jituan.constants import CLUB_ID_DEFAULT
from jituan.models import Club
from jituan.services.club_resolver import get_club_miniapp_appid
logger = logging.getLogger(__name__)
# 星阙(xq) 生产微信支付 — club 表为空时的兜底(禁止回落 settings 旧商户 1746545364
XQ_WX_PAY = {
'mch_id': '1747846572',
'mch_key': 'dujie4637rufjd98ijuyrgcvbvbn4789',
'api_v3_key': 'dujie4637rufjd98ijuyrgcvbvbn4789',
'cert_serial_no': '481784CF56E872D9D8122BAC64E6BC7390803582',
'wx_appid': 'wx0e4be86faac4a8d1',
}
# 与星阙共用同一微信商户号/证书目录的俱乐部AppID 各读各的 club 表)
SHARED_WX_MERCHANT_CLUBS = frozenset({CLUB_ID_DEFAULT, 'xzj'})
# 兼容旧引用
WX_V3_CERT_SERIAL_NO_OVERRIDE = XQ_WX_PAY['cert_serial_no']
def _pick_club_field(club, cfg_json, *field_names):
"""从 club 列或 config_json 取第一个非空值。"""
if club:
for name in field_names:
if '.' not in name:
val = (getattr(club, name, None) or '').strip()
if val:
return val
cfg_json = cfg_json or {}
for name in field_names:
key = name.split('.')[-1] if '.' in name else name
val = (cfg_json.get(key) or '').strip()
if val:
return val
return ''
def _load_pem_private_key(private_key_path):
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
with open(private_key_path, 'rb') as f:
return serialization.load_pem_private_key(
f.read(), password=None, backend=default_backend(),
)
def _public_keys_match(private_key, cert_public_key):
try:
return private_key.public_key().public_numbers() == cert_public_key.public_numbers()
except Exception:
return False
def resolve_merchant_cert_serial(private_key_path, configured_serial=''):
"""
扫描私钥同目录下所有 PEM找到与 apiclient_key.pem 公钥匹配的商户证书,
返回其序列号。手填/数据库里的序列号不可信,必须以私钥配对证书为准。
"""
configured = (configured_serial or '').strip().upper()
if not private_key_path:
logger.error('[WX_V3_CONFIG] 未配置私钥路径')
return configured
if not os.path.isfile(private_key_path):
logger.error('[WX_V3_CONFIG] 私钥文件不存在 path=%s', private_key_path)
return configured
try:
private_key = _load_pem_private_key(private_key_path)
except Exception as exc:
logger.error('[WX_V3_CONFIG] 私钥加载失败 path=%s err=%s', private_key_path, exc)
return configured
cert_dir = os.path.dirname(os.path.abspath(private_key_path))
candidates = [
private_key_path.replace('apiclient_key.pem', 'apiclient_cert.pem'),
os.path.join(cert_dir, 'apiclient_cert.pem'),
os.path.join(cert_dir, 'wechatpay_cert.pem'),
os.path.join(cert_dir, 'cert.pem'),
]
try:
for fname in sorted(os.listdir(cert_dir)):
if not fname.lower().endswith('.pem'):
continue
lower = fname.lower()
# 只跳过私钥文件pub_key.pem 等仍尝试(有人误命名证书)
if lower in ('apiclient_key.pem',) or 'private' in lower:
continue
candidates.append(os.path.join(cert_dir, fname))
except OSError as exc:
logger.warning('[WX_V3_CONFIG] 扫描证书目录失败 dir=%s err=%s', cert_dir, exc)
seen = set()
from cryptography import x509
from cryptography.hazmat.backends import default_backend
for path in candidates:
path = os.path.abspath(path)
if path in seen or not os.path.isfile(path):
continue
seen.add(path)
try:
if os.path.samefile(path, private_key_path):
continue
except OSError:
pass
try:
with open(path, 'rb') as f:
data = f.read()
if b'PRIVATE KEY' in data:
continue
cert = x509.load_pem_x509_certificate(data, default_backend())
if not _public_keys_match(private_key, cert.public_key()):
continue
serial = format(cert.serial_number, 'X').upper()
logger.warning(
'[WX_V3_CONFIG] 私钥与证书已匹配 cert=%s serial=%s (configured=%s)',
path, serial, configured or '(none)',
)
return serial
except Exception as exc:
logger.debug('[WX_V3_CONFIG] 跳过证书 path=%s err=%s', path, exc)
logger.error(
'[WX_V3_CONFIG] 未找到与私钥匹配的 apiclient_cert.pem dir=%s key=%s configured=%s',
cert_dir, private_key_path, configured,
)
return configured
def _xq_defaults():
"""仅私钥路径/平台证书目录可回落 settings与商户号无关"""
wx = dict(getattr(settings, 'WECHAT_PAY_V3_CONFIG', {}) or {})
return {
'PRIVATE_KEY_PATH': wx.get('PRIVATE_KEY_PATH', ''),
'PLATFORM_CERT_DIR': wx.get('PLATFORM_CERT_DIR', ''),
'TRANSFER_SCENE_ID': wx.get('TRANSFER_SCENE_ID', '1005'),
}
def _xml_text(root, tag, default=''):
node = root.find(tag)
if node is None or node.text is None:
return default
return node.text
def _resolve_v2_mch_key(club, cfg_json, fallback_key, club_id=None):
"""V2 JSAPI 统一下单用的商户 API 密钥32 位 MD5 密钥,不是 api_v3_key"""
column_key = (getattr(club, 'mch_key', None) or '').strip() if club else ''
if column_key:
return column_key, 'club.mch_key'
cfg_json = cfg_json or {}
for field in ('mch_key', 'shanghumiyao', 'WEIXIN_SHANGHUMIYAO'):
val = (cfg_json.get(field) or '').strip()
if val:
return val, f'club.config_json.{field}'
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
if cid in SHARED_WX_MERCHANT_CLUBS and XQ_WX_PAY['mch_key']:
return XQ_WX_PAY['mch_key'], 'XQ_WX_PAY.mch_key'
fb = (fallback_key or '').strip()
if fb:
return fb, 'settings.WEIXIN_SHANGHUMIYAO'
return '', 'missing'
def get_wechat_v2_config(club_id=None):
"""
小程序 JSAPI 支付 V2 配置。
多 club 时读 club 表;缺省或 xq 回落 settings(现网不变)
各俱乐部(含 xq优先读 club 表;字段为空时按项回落 settings,兼容旧部署
"""
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
fallback = {
'appid': getattr(settings, 'WEIXIN_APPID', ''),
'mch_id': getattr(settings, 'WEIXIN_MCHID', ''),
'key': getattr(settings, 'WEIXIN_SHANGHUMIYAO', ''),
'club_id': CLUB_ID_DEFAULT,
'club_id': cid,
}
if cid == CLUB_ID_DEFAULT:
return fallback
try:
club = Club.query.get(club_id=cid)
@@ -32,20 +192,117 @@ def get_wechat_v2_config(club_id=None):
return fallback
cfg_json = club.config_json or {}
mch_key = (
cfg_json.get('mch_key')
or cfg_json.get('shanghumiyao')
or cfg_json.get('WEIXIN_SHANGHUMIYAO')
or fallback['key']
mch_key, key_source = _resolve_v2_mch_key(club, cfg_json, fallback['key'], club_id=cid)
mini_appid = get_club_miniapp_appid(club)
wx_a = (club.wx_appid or '').strip()
pay_a = (club.pay_app_id or '').strip()
if wx_a and pay_a and wx_a != pay_a:
logger.warning(
'[WX_V2_CONFIG] club=%s wx_appid(%s) 与 pay_app_id(%s) 不一致;'
'小程序 JSAPI 以 wx_appid 优先openid 与登录 AppID 绑定)',
cid, wx_a, pay_a,
)
if mini_appid:
appid = mini_appid
elif cid == CLUB_ID_DEFAULT:
appid = fallback['appid']
else:
logger.error(
'[WX_V2_CONFIG] club=%s 未配置 wx_appid/pay_app_id禁止回落星阙全局 AppID',
cid,
)
appid = ''
pay_fb = XQ_WX_PAY if cid in SHARED_WX_MERCHANT_CLUBS else {}
mch_id = (
_pick_club_field(club, cfg_json, 'mch_id')
or pay_fb.get('mch_id', '')
or fallback['mch_id']
)
if mch_id and key_source == 'settings.WEIXIN_SHANGHUMIYAO':
logger.warning(
'[WX_V2_CONFIG] club=%s 已配置 mch_id=%s 但未配置 V2 商户密钥 config_json.mch_key'
'当前回落全局 WEIXIN_SHANGHUMIYAO与 api_v3_key 不是同一个密钥,易导致签名错误)',
cid, mch_id,
)
# 收款商户证书(供退款等对齐收款商户;未配时留空,调用方可回落 settings
pay_cert = _pick_club_field(club, cfg_json, 'pay_cert_path')
pay_key = _pick_club_field(club, cfg_json, 'pay_key_path')
return {
'appid': club.pay_app_id or club.wx_appid or fallback['appid'],
'mch_id': club.mch_id or fallback['mch_id'],
'appid': appid,
'mch_id': mch_id,
'key': mch_key,
'club_id': cid,
'_key_source': key_source,
# 明确标记:这是点单/会员收款商户
'merchant_role': 'pay',
'cert_path': pay_cert,
'key_path': pay_key,
}
def log_wechat_v2_pay_diag(pay_cfg, *, scene='', openid='', order_hint=''):
"""下单前诊断日志(不打印完整密钥)。"""
oid = (openid or '')
oid_preview = (oid[:6] + '...' + oid[-4:]) if len(oid) > 10 else oid or '(empty)'
key = pay_cfg.get('key') or ''
logger.warning(
'[WX_V2_PAY_DIAG] scene=%s club=%s appid=%s mch_id=%s key_len=%d key_source=%s openid=%s hint=%s',
scene or '-',
pay_cfg.get('club_id'),
pay_cfg.get('appid'),
pay_cfg.get('mch_id'),
len(key),
pay_cfg.get('_key_source', '?'),
oid_preview,
order_hint or '-',
)
def parse_wechat_v2_unifiedorder_response(content, *, log_prefix='WX_UNIFIED'):
"""
解析微信 V2 统一下单 XML失败时记录完整原始响应并抛出可读异常。
成功返回 {'prepay_id': '...'}。
"""
import defusedxml.ElementTree as ET
raw = (
content.decode('utf-8', errors='replace')
if isinstance(content, (bytes, bytearray))
else str(content or '')
)
logger.warning('[%s] raw_response=%s', log_prefix, raw)
try:
root = ET.fromstring(content)
except Exception as exc:
raise Exception(f'微信支付返回非 XML: {raw[:800]}') from exc
return_code = _xml_text(root, 'return_code')
result_code = _xml_text(root, 'result_code')
if return_code == 'SUCCESS' and result_code == 'SUCCESS':
prepay_id = _xml_text(root, 'prepay_id')
if not prepay_id:
raise Exception(f'微信支付成功但缺少 prepay_id: {raw}')
return {'prepay_id': prepay_id}
parts = []
if return_code:
parts.append(f'return_code={return_code}')
if result_code:
parts.append(f'result_code={result_code}')
for tag in ('return_msg', 'err_code', 'err_code_des'):
val = _xml_text(root, tag)
if val:
parts.append(f'{tag}={val}')
if not parts:
parts.append(f'raw={raw[:800]}')
raise Exception(f'微信支付下单失败: {", ".join(parts)}')
def verify_wechat_v2_signature(xml_data, club_id=None):
"""验证微信 V2 支付回调 MD5 签名。"""
try:
@@ -94,63 +351,102 @@ def club_id_from_order(order_id):
return CLUB_ID_DEFAULT
def _log_v3_config(cfg, *, source=''):
key_path = (cfg.get('PRIVATE_KEY_PATH') or '').strip()
logger.warning(
'[WX_V3_CONFIG] source=%s club=%s appid=%s mch=%s serial=%s '
'api_v3_key_len=%d key_path=%s key_exists=%s',
source or '-',
cfg.get('club_id'),
cfg.get('APPID'),
cfg.get('MCHID'),
cfg.get('CERT_SERIAL_NO'),
len(cfg.get('API_V3_KEY') or ''),
key_path,
bool(key_path and os.path.isfile(key_path)),
)
def get_wechat_v3_config(club_id=None):
"""
微信 V3转账/查单)配置。
club 读 club 表;缺省或 xq 回落 settings.WECHAT_PAY_V3_CONFIG
优先 club 数据库 → 星阙写死兜底 → 仅私钥路径/平台证书目录回落 settings
禁止用 settings 里的旧商户号 1746545364。
"""
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
wx = dict(getattr(settings, 'WECHAT_PAY_V3_CONFIG', {}) or {})
fallback = {
'APPID': wx.get('APPID', ''),
'MCHID': wx.get('MCHID', ''),
'PRIVATE_KEY_PATH': wx.get('PRIVATE_KEY_PATH', ''),
'CERT_SERIAL_NO': wx.get('CERT_SERIAL_NO', ''),
'API_V3_KEY': wx.get('API_V3_KEY', ''),
'PLATFORM_CERT_DIR': wx.get('PLATFORM_CERT_DIR', ''),
'TRANSFER_SCENE_ID': wx.get('TRANSFER_SCENE_ID', '1005'),
'club_id': CLUB_ID_DEFAULT,
}
if cid == CLUB_ID_DEFAULT:
return fallback
path_defaults = _xq_defaults()
club = None
cfg_json = {}
try:
club = Club.query.get(club_id=cid)
except Club.DoesNotExist:
logger.warning('club %s 不存在,回落全局 V3 支付配置', cid)
return fallback
cfg_json = club.config_json or {}
return {
'APPID': club.pay_app_id or club.wx_appid or fallback['APPID'],
'MCHID': club.mch_id or fallback['MCHID'],
'PRIVATE_KEY_PATH': (
club.private_key_path
or cfg_json.get('private_key_path')
or fallback['PRIVATE_KEY_PATH']
),
'CERT_SERIAL_NO': (
club.cert_serial_no
or cfg_json.get('cert_serial_no')
or fallback['CERT_SERIAL_NO']
),
'API_V3_KEY': (
club.api_v3_key
or cfg_json.get('api_v3_key')
or fallback['API_V3_KEY']
),
'PLATFORM_CERT_DIR': (
club.platform_cert_dir
or cfg_json.get('platform_cert_dir')
or fallback['PLATFORM_CERT_DIR']
),
'TRANSFER_SCENE_ID': (
cfg_json.get('transfer_scene_id')
or cfg_json.get('TRANSFER_SCENE_ID')
or fallback['TRANSFER_SCENE_ID']
),
except Club.DoesNotExist:
logger.warning('club %s 不存在,使用星阙兜底或路径默认', cid)
xq = XQ_WX_PAY if cid in SHARED_WX_MERCHANT_CLUBS else {}
# 提现商户号:优先 withdraw_mch_id空则回落收款 mch_id兼容旧数据
mch_id = (
_pick_club_field(club, cfg_json, 'withdraw_mch_id')
or _pick_club_field(club, cfg_json, 'mch_id')
or xq.get('mch_id', '')
)
api_v3_key = _pick_club_field(club, cfg_json, 'api_v3_key') or xq.get('api_v3_key', '')
# 共用商户的俱乐部:默认用 settings 私钥;仅当库中路径存在且文件在盘上才覆盖
# (避免旧库里曾填过、但原先被忽略的无效路径突然接管提现)
# 非共享俱乐部:严禁回落星阙私钥/商户,缺配置直接空值,由上层拒绝打款
if cid in SHARED_WX_MERCHANT_CLUBS:
private_key_path = path_defaults['PRIVATE_KEY_PATH']
if club:
uploaded_key = (getattr(club, 'private_key_path', None) or '').strip()
if uploaded_key and os.path.isfile(uploaded_key):
private_key_path = uploaded_key
platform_cert_dir = (
_pick_club_field(club, cfg_json, 'platform_cert_dir')
or path_defaults['PLATFORM_CERT_DIR']
)
else:
private_key_path = _pick_club_field(club, cfg_json, 'private_key_path')
platform_cert_dir = _pick_club_field(club, cfg_json, 'platform_cert_dir')
if not mch_id or not api_v3_key or not private_key_path:
logger.error(
'[WX_V3_CONFIG] club=%s 提现商户配置不完整(mch=%s key=%s pem=%s)'
'禁止回落星阙商户,避免串号打款',
cid, bool(mch_id), bool(api_v3_key), bool(private_key_path),
)
transfer_scene_id = (
_pick_club_field(club, cfg_json, 'transfer_scene_id', 'TRANSFER_SCENE_ID')
or path_defaults['TRANSFER_SCENE_ID']
)
appid = get_club_miniapp_appid(club) if club else ''
if not appid and cid in SHARED_WX_MERCHANT_CLUBS:
appid = xq.get('wx_appid', '') if cid == CLUB_ID_DEFAULT else ''
# 序列号必须从与私钥配对的 apiclient_cert.pem 读取,禁止盲用数据库/写死值
cert_serial = resolve_merchant_cert_serial(private_key_path, '')
if not cert_serial:
cert_serial = (
_pick_club_field(club, cfg_json, 'cert_serial_no')
or xq.get('cert_serial_no', '')
)
cfg = {
'APPID': appid,
'MCHID': mch_id,
'PRIVATE_KEY_PATH': private_key_path,
'CERT_SERIAL_NO': cert_serial,
'API_V3_KEY': api_v3_key,
'PLATFORM_CERT_DIR': platform_cert_dir,
'TRANSFER_SCENE_ID': transfer_scene_id,
'club_id': cid,
'merchant_role': 'withdraw',
}
_log_v3_config(cfg, source='club_db' if club else 'xq_fallback')
return cfg
def club_id_for_tixian_yonghuid(yonghuid):

View File

@@ -18,6 +18,7 @@ from backend.view import (
KhgglView,
KefuGetDashouListView,
KefuGetShangjiaListView,
KefuAddShangjiaView,
PopupNoticeListAPIView,
PopupNoticeModifyAPIView,
ShgxgsjView,
@@ -41,6 +42,7 @@ from jituan.views import (
ClubAdminAssignmentListView,
ClubAuditLogListView,
ClubManageView,
ClubCertUploadView,
ClubCaiwuView,
ClubChongzhiFinanceView,
ClubDashouRegisterView,
@@ -56,6 +58,7 @@ from jituan.views import (
ClubSzxxView,
ClubUserSummaryView,
ClubWechatLoginView,
ClubWxBindRefreshView,
)
from jituan.views_dashou_exam import (
@@ -84,6 +87,7 @@ from jituan.views_club_catalog import (
ClubShangpinLeixingListView,
)
from jituan.views_shenhe_manage import ClubKhgglAddView
from jituan.views_payment_channel import ClubPaymentChannelManageView
from jituan.views_identity_tag import (
IdentityTagBindListView,
IdentityTagBindView,
@@ -91,15 +95,25 @@ from jituan.views_identity_tag import (
IdentityTagManageView,
IdentityTagMyTagsView,
)
from jituan.views_recharge_page_copy import (
RechargePageCopyAdminGetView,
RechargePageCopyAdminSaveView,
RechargePageCopyView,
)
from jituan.views_order_grab_share import ClubOrderGrabLinkManageView
urlpatterns = [
path('auth/wechat-login', ClubWechatLoginView.as_view(), name='jituan_wechat_login'),
path('auth/refresh-wx-bind', ClubWxBindRefreshView.as_view(), name='jituan_refresh_wx_bind'),
path('auth/kefu-login', ClubKefuLoginView.as_view(), name='jituan_kefu_login'),
path('auth/me-context', ClubMeContextView.as_view(), name='jituan_me_context'),
path('auth/menu-access', ClubKefuMenuAccessView.as_view(), name='jituan_menu_access'),
path('auth/perm-debug', ClubPermDebugView.as_view(), name='jituan_perm_debug'),
path('auth/dashou-register', ClubDashouRegisterView.as_view(), name='jituan_dashou_register'),
path('houtai/club-manage', ClubManageView.as_view(), name='jituan_club_manage'),
path('houtai/order-grab-link', ClubOrderGrabLinkManageView.as_view(), name='jituan_order_grab_link'),
path('houtai/payment-channel-manage', ClubPaymentChannelManageView.as_view(), name='jituan_payment_channel_manage'),
path('houtai/club-cert-upload', ClubCertUploadView.as_view(), name='jituan_club_cert_upload'),
path('houtai/admin-assignments', ClubAdminAssignmentListView.as_view(), name='jituan_admin_assignments'),
path('houtai/caiwu', ClubCaiwuView.as_view(), name='jituan_caiwu'),
path('houtai/szxx', ClubSzxxView.as_view(), name='jituan_szxx'),
@@ -117,10 +131,14 @@ urlpatterns = [
path('houtai/kefuhqdslb', KefuGetDashouListView.as_view(), name='jituan_dashou_list'),
path('houtai/hthqgslb', GetGuanliListView.as_view(), name='jituan_guanshi_list'),
path('houtai/hqsjgllb', KefuGetShangjiaListView.as_view(), name='jituan_shangjia_list'),
path('houtai/tjsj', KefuAddShangjiaView.as_view(), name='jituan_shangjia_add'),
path('houtai/hthqzzlb', GetZuzhangListView.as_view(), name='jituan_zuzhang_list'),
path('houtai/lunbo-list', ClubLunboListView.as_view(), name='jituan_lunbo_list'),
path('houtai/lunbo-manage', ClubLunboManageView.as_view(), name='jituan_lunbo_manage'),
path('houtai/gonggao', ClubGonggaoView.as_view(), name='jituan_gonggao'),
path('recharge-page-copy', RechargePageCopyView.as_view(), name='jituan_recharge_page_copy'),
path('houtai/recharge-page-copy/get', RechargePageCopyAdminGetView.as_view(), name='jituan_recharge_page_copy_get'),
path('houtai/recharge-page-copy/save', RechargePageCopyAdminSaveView.as_view(), name='jituan_recharge_page_copy_save'),
path('identity-tag/my-tags', IdentityTagMyTagsView.as_view(), name='jituan_identity_my_tags'),
path('houtai/identity-tag/list', IdentityTagListView.as_view(), name='jituan_identity_tag_list'),
path('houtai/identity-tag/manage', IdentityTagManageView.as_view(), name='jituan_identity_tag_manage'),

View File

@@ -3,7 +3,7 @@ import logging
from django.utils import timezone
from rest_framework import status
from rest_framework.parsers import JSONParser
from rest_framework.parsers import JSONParser, MultiPartParser, FormParser
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -14,7 +14,7 @@ from jituan.constants import SUPER_ADMIN_PHONES, ADMIN_ROLE_LABELS, CLUB_ID_DEFA
from jituan.models import Club, AdminAssignment
from jituan.services.admin_context import build_admin_club_context, is_kefu_backend_account, can_manage_admin_assignments
from jituan.services.kefu_menu import build_menu_access_payload
from jituan.services.wechat_login import login_or_register_by_club
from jituan.services.wechat_login import login_or_register_by_club, refresh_wx_openid_binding
from jituan.services.caiwu_stats import build_caiwu_payload
from jituan.services.szxx_stats import build_szxx_payload
from jituan.services.admin_assignments import (
@@ -66,12 +66,45 @@ class ClubWechatLoginView(APIView):
code, club_id=club_id, app_id=app_id, client_ip=_client_ip(request),
)
if err:
# 配置缺失 / 微信侧错误:明确 400方便小程序提示运营配 AppSecret
return Response({'code': 2, 'msg': f'微信登录失败: {err}', 'data': None},
status=status.HTTP_400_BAD_REQUEST)
return Response({'code': 0, 'msg': '登录成功', 'data': data})
except Exception as e:
logger.exception('ClubWechatLoginView 异常')
return Response({'code': 99, 'msg': '系统繁忙,请稍后重试', 'data': None},
return Response({
'code': 99,
'msg': f'登录异常: {type(e).__name__}: {e}',
'data': None,
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class ClubWxBindRefreshView(APIView):
"""
已登录用户静默刷新当前小程序 openid 绑定(支付前 AppID/openid 对齐)。
POST /jituan/auth/refresh-wx-bind
body: { code, club_id?, app_id? }
"""
permission_classes = [IsAuthenticated]
def post(self, request):
code = (request.data.get('code') or '').strip()
club_id = (request.data.get('club_id') or '').strip() or None
app_id = (request.data.get('app_id') or '').strip() or None
if not code:
return Response({'code': 1, 'msg': '微信授权码不能为空', 'data': None},
status=status.HTTP_400_BAD_REQUEST)
try:
data, err = refresh_wx_openid_binding(
code, request.user, club_id=club_id, app_id=app_id,
)
if err:
return Response({'code': 2, 'msg': err, 'data': None},
status=status.HTTP_400_BAD_REQUEST)
return Response({'code': 0, 'msg': 'ok', 'data': data})
except Exception:
logger.exception('ClubWxBindRefreshView 异常')
return Response({'code': 99, 'msg': '刷新绑定失败', 'data': None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@@ -421,13 +454,96 @@ class ClubManageView(APIView):
parser_classes = [JSONParser]
_EDITABLE_FIELDS = (
'name', 'status', 'wx_appid', 'wx_secret', 'mch_id', 'pay_app_id', 'api_v3_key',
'name', 'status', 'wx_appid', 'wx_secret',
# 收款商户
'mch_id', 'pay_app_id', 'mch_key', 'pay_cert_path', 'pay_key_path',
# 小程序收款通道wechat / fubei
'mini_pay_channel',
# 提现商户
'withdraw_mch_id', 'api_v3_key',
'cert_serial_no', 'private_key_path', 'platform_cert_dir',
'official_appid', 'official_secret', 'official_token', 'encoding_aes_key',
'template_id', 'template_max_per_minute', 'h5_domain', 'oss_prefix',
'goeasy_appkey', 'goeasy_secret', 'sort_order',
)
@staticmethod
def _effective_mch_key(club):
cfg_json = club.config_json or {}
return (
(club.mch_key or '').strip()
or (cfg_json.get('mch_key') or '').strip()
or (cfg_json.get('shanghumiyao') or '').strip()
)
@staticmethod
def _sync_mch_key_to_config_json(club, mch_key):
cfg = dict(club.config_json or {})
cfg['mch_key'] = (mch_key or '').strip()
club.config_json = cfg
def _pay_config_meta(self, club):
"""明确区分:小程序 V2 支付密钥 vs APIv3 密钥,避免后台误导。"""
from jituan.services.wechat_pay import get_wechat_v2_config
cfg_json = club.config_json or {}
mch_key = self._effective_mch_key(club)
api_v3 = (club.api_v3_key or '').strip()
v2_cfg = get_wechat_v2_config(club.club_id)
warnings = []
if not mch_key:
warnings.append(
'未配置收款 APIv2 密钥 mch_key点单/会员等收款不会使用提现 api_v3_key。'
)
if mch_key and api_v3 and mch_key == api_v3:
warnings.append(
'收款 mch_key 与提现 api_v3_key 相同:通常不正确,收款与提现是两套密钥。'
)
withdraw_mch = (getattr(club, 'withdraw_mch_id', None) or '').strip()
pay_mch = (club.mch_id or '').strip()
if withdraw_mch and pay_mch and withdraw_mch != pay_mch:
# 这是预期场景,给信息而非警告
pass
elif not withdraw_mch and pay_mch:
warnings.append(
'提现商户号未单独填写:提现将回落收款商户号 mch_id。若两套商户不同请填写 withdraw_mch_id。'
)
wx_a = (club.wx_appid or '').strip()
pay_a = (club.pay_app_id or '').strip()
if wx_a and pay_a and wx_a != pay_a:
warnings.append(
f'wx_appid({wx_a}) 与 pay_app_id({pay_a}) 不一致:'
'小程序登录/支付 openid 与 wx_appid 绑定,请保持一致或留空 pay_app_id。'
)
if not wx_a and pay_a:
warnings.append(
f'wx_appid 为空但 pay_app_id={pay_a}:登录仍可能回落旧全局 AppID'
'导致 openid 与支付 AppID 不匹配,请填写 wx_appid。'
)
return {
'mch_key': mch_key,
'mch_key_label': 'APIv2 密钥 (mch_key)',
'mch_key_help': '微信商户平台 → API安全 → 设置APIv2密钥用于会员/点单/押金/积分等 JSAPI 收款',
'mch_key_configured': bool(mch_key),
'miniapp_wechat_v2_key': mch_key,
'miniapp_wechat_v2_key_configured': bool(mch_key),
'miniapp_wechat_v2_key_label': 'APIv2 密钥 (mch_key)',
'miniapp_wechat_v2_key_help': '微信商户平台 → API安全 → 设置APIv2密钥用于会员/点单/押金/积分等 JSAPI 收款',
'api_v3_key': api_v3,
'api_v3_key_label': 'APIv3 密钥 (api_v3_key)',
'api_v3_key_help': '微信商户平台 → API安全 → 设置APIv3密钥仅用于自动提现/转账回调解密,不用于小程序收款',
'effective_miniapp_pay': {
'club_id': v2_cfg.get('club_id'),
'appid': v2_cfg.get('appid'),
'mch_id': v2_cfg.get('mch_id'),
'key_len': len(v2_cfg.get('key') or ''),
'key_source': v2_cfg.get('_key_source'),
},
'warnings': warnings,
}
def _serialize(self, club, full=False):
base = {
'club_id': club.club_id,
@@ -438,14 +554,21 @@ class ClubManageView(APIView):
'oss_prefix': club.oss_prefix,
'mch_id': club.mch_id,
'pay_app_id': club.pay_app_id,
'withdraw_mch_id': getattr(club, 'withdraw_mch_id', '') or '',
'mini_pay_channel': getattr(club, 'mini_pay_channel', None) or 'wechat',
'goeasy_appkey': club.goeasy_appkey,
'template_id': club.template_id,
'sort_order': club.sort_order,
}
if full:
mch_key = self._effective_mch_key(club)
base.update({
'wx_secret': club.wx_secret,
'mch_key': mch_key,
'pay_cert_path': getattr(club, 'pay_cert_path', '') or '',
'pay_key_path': getattr(club, 'pay_key_path', '') or '',
'api_v3_key': club.api_v3_key,
'pay_config': self._pay_config_meta(club),
'cert_serial_no': club.cert_serial_no,
'private_key_path': club.private_key_path,
'platform_cert_dir': club.platform_cert_dir,
@@ -492,8 +615,19 @@ class ClubManageView(APIView):
update_fields = []
for field in self._EDITABLE_FIELDS:
if field in request.data:
setattr(club, field, request.data[field])
val = request.data[field]
if field in ('mch_key', 'api_v3_key', 'wx_secret'):
val = (val or '').strip()
if field == 'mini_pay_channel':
val = (val or 'wechat').strip().lower()
if val not in ('wechat', 'fubei'):
return Response({'code': 400, 'msg': 'mini_pay_channel 仅支持 wechat/fubei'})
setattr(club, field, val)
update_fields.append(field)
if 'mch_key' in request.data:
self._sync_mch_key_to_config_json(club, request.data.get('mch_key'))
if 'config_json' not in update_fields:
update_fields.append('config_json')
if update_fields:
club.save(update_fields=update_fields)
return Response({
@@ -529,6 +663,69 @@ class ClubManageView(APIView):
return Response({'code': 99, 'msg': '俱乐部配置操作失败'}, status=500)
class ClubCertUploadView(APIView):
"""
POST /jituan/houtai/club-cert-upload
multipart: phone, club_id, kind, file
kind: pay_key | pay_cert | withdraw_key | withdraw_cert | withdraw_platform
"""
permission_classes = [IsAuthenticated]
parser_classes = [MultiPartParser, FormParser]
def post(self, request):
try:
username = (request.data.get('phone') or request.data.get('username') or '').strip()
if not username:
return Response({'code': 401, 'msg': '缺少账号'}, status=401)
kefu_obj, permissions = verify_kefu_permission(request, username)
if kefu_obj is None:
return permissions
if not can_manage_admin_assignments(request.user):
return Response({'code': 403, 'msg': '仅集团超管可上传商户证书'}, status=403)
club_id = (request.data.get('club_id') or '').strip()
kind = (request.data.get('kind') or '').strip()
uploaded = request.FILES.get('file')
if not club_id or not kind or not uploaded:
return Response({'code': 400, 'msg': '需要 club_id、kind、file'})
club = Club.query.filter(club_id=club_id).first()
if not club:
return Response({'code': 404, 'msg': f'俱乐部 {club_id} 不存在'})
from jituan.services.club_cert_upload import save_club_cert_file, CERT_KINDS
if kind not in CERT_KINDS:
return Response({
'code': 400,
'msg': f'kind 无效,支持: {", ".join(CERT_KINDS.keys())}',
})
try:
result = save_club_cert_file(club, kind, uploaded)
except ValueError as exc:
return Response({'code': 400, 'msg': str(exc)})
except OSError as exc:
logger.exception('证书落盘失败 club=%s kind=%s', club_id, kind)
return Response({'code': 500, 'msg': f'写入失败: {exc}'})
club = Club.query.filter(club_id=club_id).first()
return Response({
'code': 0,
'msg': '上传成功',
'data': {
**result,
'pay_cert_path': getattr(club, 'pay_cert_path', '') or '',
'pay_key_path': getattr(club, 'pay_key_path', '') or '',
'private_key_path': club.private_key_path or '',
'platform_cert_dir': club.platform_cert_dir or '',
'withdraw_mch_id': getattr(club, 'withdraw_mch_id', '') or '',
'mch_id': club.mch_id or '',
},
})
except Exception:
logger.exception('ClubCertUploadView 异常')
return Response({'code': 99, 'msg': '证书上传失败'}, status=500)
class ClubCaiwuView(APIView):
"""
俱乐部维度财务(新接口)。

View File

@@ -6,6 +6,13 @@ from rest_framework.permissions import IsAuthenticated
from backend.view import verify_kefu_permission
# 小程序配置 8080a 或 商品类型专区 7007a 均可上架/下架
CLUB_LEIXING_PERMS = ('8080a', '7007a')
def _has_club_leixing_perm(permissions):
return any(p in permissions for p in CLUB_LEIXING_PERMS)
class ClubShangpinLeixingListView(APIView):
"""POST /jituan/houtai/club-leixing-list"""
@@ -19,7 +26,7 @@ class ClubShangpinLeixingListView(APIView):
kefu, permissions = verify_kefu_permission(request, phone)
if kefu is None:
return permissions
if '8080a' not in permissions:
if not _has_club_leixing_perm(permissions):
return Response({'code': 403, 'msg': '无权限管理小程序商品类型'})
from jituan.services.club_shangpin_leixing import build_club_leixing_admin_list
payload = build_club_leixing_admin_list(request)
@@ -38,7 +45,7 @@ class ClubShangpinLeixingCatalogView(APIView):
kefu, permissions = verify_kefu_permission(request, phone)
if kefu is None:
return permissions
if '8080a' not in permissions:
if not _has_club_leixing_perm(permissions):
return Response({'code': 403, 'msg': '无权限'})
from jituan.services.club_shangpin_leixing import build_club_leixing_catalog
return Response({'code': 0, 'data': build_club_leixing_catalog(request)})
@@ -57,7 +64,7 @@ class ClubShangpinLeixingEnableView(APIView):
kefu, permissions = verify_kefu_permission(request, phone)
if kefu is None:
return permissions
if '8080a' not in permissions:
if not _has_club_leixing_perm(permissions):
return Response({'code': 403, 'msg': '无权限'})
from jituan.services.club_shangpin_leixing import enable_leixing_for_club
data, err = enable_leixing_for_club(request, int(leixing_id), request.data.get('paixu'))
@@ -79,7 +86,7 @@ class ClubShangpinLeixingDisableView(APIView):
kefu, permissions = verify_kefu_permission(request, phone)
if kefu is None:
return permissions
if '8080a' not in permissions:
if not _has_club_leixing_perm(permissions):
return Response({'code': 403, 'msg': '无权限'})
from jituan.services.club_shangpin_leixing import disable_leixing_for_club
data, err = disable_leixing_for_club(request, int(leixing_id))

View File

@@ -16,8 +16,8 @@ from jituan.services.display_config import (
get_lunbo_urls,
list_response_meta,
list_tupianpeizhi_by_type,
LUNBO_PAGE_DASHOU_CENTER,
LUNBO_PAGE_OPTIONS,
lunbo_image_type_for_page,
normalize_page_key,
TUPIAN_TYPE_LABELS,
upsert_tupianpeizhi_single,
@@ -44,7 +44,7 @@ class ClubLunboListView(APIView):
return Response({'code': 403, 'msg': '无权限管理轮播图'})
page_key = normalize_page_key(request.data.get('page_key'), image_type=1)
image_type = 2 if page_key == LUNBO_PAGE_DASHOU_CENTER else 1
image_type = lunbo_image_type_for_page(page_key)
urls = get_lunbo_urls(request, page_key=page_key, image_type=image_type)
return Response({
'code': 0,
@@ -79,7 +79,7 @@ class ClubLunboManageView(APIView):
action = (request.data.get('action') or '').strip()
club_id = resolve_effective_club_id(request, request.user)
page_key = normalize_page_key(request.data.get('page_key'), image_type=1)
image_type = 2 if page_key == LUNBO_PAGE_DASHOU_CENTER else 1
image_type = lunbo_image_type_for_page(page_key)
if action == 'delete':
tupian_url = (request.data.get('tupian_url') or '').strip()

View File

@@ -12,6 +12,7 @@ from jituan.services.club_context import resolve_effective_club_id
from jituan.services.display_config import forbid_display_write_in_all_scope, list_response_meta
from jituan.services.miniapp_assets import (
ICON_FOLDER,
CONFIG_ONLY_DELIVERY_KEYS,
MINIAPP_ICON_LABELS,
MINIAPP_ICON_META,
POSTER_FOLDER,
@@ -49,13 +50,21 @@ class ClubMiniappIconListView(APIView):
items = []
for key, label in MINIAPP_ICON_META:
row = icons_db.get(key)
uploaded = bool(row and row.is_uploaded and (row.image_path or '').strip())
if uploaded:
image_path = row.image_path.strip()
elif key in CONFIG_ONLY_DELIVERY_KEYS:
# 未上传的大背景不回默认路径,避免后台预览 404、误以为已配置
image_path = ''
else:
image_path = (row.image_path if row else '') or default_icon_path(key)
items.append({
'icon_key': key,
'label': label,
'image_path': (row.image_path if row else '') or default_icon_path(key),
'image_path': image_path,
'description': (row.description if row else '') or '',
'is_active': row.is_active if row else True,
'is_uploaded': row.is_uploaded if row else False,
'is_uploaded': uploaded,
'id': row.id if row else None,
})
return Response({
@@ -89,9 +98,20 @@ class ClubMiniappIconManageView(APIView):
action = (request.data.get('action') or 'upload').strip()
club_id = resolve_effective_club_id(request, request.user)
icon_key = (request.data.get('icon_key') or '').strip()
raw_key = request.data.get('icon_key')
if raw_key is None or raw_key == '':
raw_key = request.query_params.get('icon_key')
if isinstance(raw_key, (list, tuple)):
raw_key = raw_key[0] if raw_key else ''
icon_key = str(raw_key or '').strip()
if icon_key not in MINIAPP_ICON_LABELS:
return Response({'code': 400, 'msg': 'icon_key 无效'})
return Response({
'code': 400,
'msg': (
f'icon_key 无效: {icon_key or "(空)"}'
'若上传接单池卡背景,请确认 Django 已 pull 到含 grab_card_* 的提交并重启进程'
),
})
if action == 'save_meta':
description = (request.data.get('description') or '').strip()
@@ -118,15 +138,22 @@ class ClubMiniappIconManageView(APIView):
ext = os.path.splitext(file_obj.name)[1].lower() or '.png'
if ext not in ('.png', '.jpg', '.jpeg', '.webp'):
ext = '.png'
import uuid
stamp = uuid.uuid4().hex[:12]
# 路径必须含 club_id + 唯一后缀:否则同名覆盖后 CDN 仍显示旧图,且会串俱乐部
if icon_key in POSTER_ICON_KEYS:
oss_path = f'{POSTER_FOLDER}/{club_id}_{icon_key}{ext}'
oss_path = f'{POSTER_FOLDER}/{club_id}_{icon_key}_{stamp}{ext}'
else:
oss_path = f'{ICON_FOLDER}/{icon_key}{ext}'
oss_path = f'{ICON_FOLDER}/{club_id}/{icon_key}_{stamp}{ext}'
row = ClubMiniappIcon.query.filter(club_id=club_id, icon_key=icon_key).first()
if row and row.image_path and row.image_path != oss_path:
delete_from_oss(row.image_path)
old_path = (row.image_path or '').strip() if row else ''
if hasattr(file_obj, 'seek'):
try:
file_obj.seek(0)
except Exception:
pass
if not upload_to_oss(file_obj, oss_path):
return Response({'code': 500, 'msg': '图片上传失败'})
@@ -145,6 +172,11 @@ class ClubMiniappIconManageView(APIView):
is_active=True,
is_uploaded=True,
)
if old_path and old_path != oss_path:
try:
delete_from_oss(old_path)
except Exception:
pass
return Response({'code': 0, 'msg': '上传成功', 'data': {'image_path': oss_path}})

View File

@@ -0,0 +1,50 @@
"""集团后台:俱乐部订单单向互通抢单配置。"""
from rest_framework.parsers import JSONParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from backend.utils import verify_kefu_permission
from jituan.services.admin_context import can_manage_admin_assignments
from jituan.services.order_grab_share import add_grab_link, delete_grab_link, list_grab_links
class ClubOrderGrabLinkManageView(APIView):
"""
POST /jituan/houtai/order-grab-link
action: list | add | delete
仅集团权限can_manage_admin_assignments可操作。
"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
username = (request.data.get('username') or request.data.get('phone') or '').strip()
if not username:
return Response({'code': 400, 'msg': '缺少username'})
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return permissions
if not can_manage_admin_assignments(request.user, permissions):
return Response({'code': 403, 'msg': '仅集团权限可配置订单互通'})
action = (request.data.get('action') or 'list').strip()
if action == 'list':
return Response({'code': 0, 'msg': 'ok', 'data': {'list': list_grab_links()}})
if action == 'add':
data, err = add_grab_link(
request.data.get('grabber_club_id'),
request.data.get('order_club_id'),
)
if err:
return Response({'code': 400, 'msg': err})
return Response({'code': 0, 'msg': '已添加', 'data': data})
if action == 'delete':
ok, err = delete_grab_link(request.data.get('id'))
if not ok:
return Response({'code': 400, 'msg': err or '删除失败'})
return Response({'code': 0, 'msg': '已删除'})
return Response({'code': 400, 'msg': '无效 action'})

View File

@@ -0,0 +1,241 @@
"""俱乐部支付通道后台管理(付呗配置 CRUD + 选用 wechat/fubei"""
from __future__ import annotations
import logging
from rest_framework.parsers import JSONParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from backend.utils import verify_kefu_permission
from jituan.constants import (
CLUB_ID_DEFAULT,
MINI_PAY_CHANNEL_FUBEI,
MINI_PAY_CHANNEL_WECHAT,
PAY_CHANNEL_FUBEI_WX_MINI,
)
from jituan.models import Club, ClubPaymentChannel
from jituan.services.club_payment_channel import (
encrypt_secret_field,
invalidate_club_payment_cache,
)
from jituan.services.mini_pay_router import get_club_mini_pay_channel, set_club_mini_pay_channel
from jituan.services.admin_context import can_manage_admin_assignments
logger = logging.getLogger(__name__)
def _mask_secret(plain: str) -> str:
s = (plain or '').strip()
if not s:
return ''
if len(s) <= 8:
return '****'
return s[:4] + '****' + s[-4:]
def _serialize_channel(row: ClubPaymentChannel, reveal_secret: bool = False) -> dict:
from jituan.services.club_payment_channel import decrypt_secret_field
secret = decrypt_secret_field(row.api_key_enc) if row.api_key_enc else ''
extra = dict(row.extra_json or {})
return {
'id': row.id,
'club_id': row.club_id,
'channel': row.channel,
'name': row.name,
'enabled': row.enabled,
'is_default': row.is_default,
'sort_order': row.sort_order,
'sandbox': row.sandbox,
'app_id': row.app_id,
'mch_id': row.mch_id,
'mini_app_id': row.mini_app_id,
'store_id': extra.get('store_id') or extra.get('fubei_store_id') or '',
'merchant_id': extra.get('merchant_id') or row.mch_id or '',
'api_key_masked': _mask_secret(secret),
'api_key': secret if reveal_secret else '',
'api_key_configured': bool(secret),
'notify_url': row.notify_url,
'gateway_url': row.gateway_url,
'remark': row.remark,
'extra_json': extra,
}
class ClubPaymentChannelManageView(APIView):
"""POST /jituan/houtai/payment-channel-manage
actions:
- get: 当前俱乐部 mini_pay_channel + 付呗通道列表
- set_mini_pay_channel: {mini_pay_channel: wechat|fubei}
- save_fubei: 新增或更新付呗配置(按 id 或 name
- set_default / enable / disable
- delete
"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
try:
username = (request.data.get('phone') or request.data.get('username') or '').strip()
if not username:
return Response({'code': 401, 'msg': '缺少账号'}, status=401)
kefu_obj, permissions = verify_kefu_permission(request, username)
if kefu_obj is None:
return permissions
if not can_manage_admin_assignments(request.user):
return Response({'code': 403, 'msg': '仅集团超管可管理支付通道'}, status=403)
action = (request.data.get('action') or 'get').strip()
club_id = (request.data.get('club_id') or '').strip() or CLUB_ID_DEFAULT
club = Club.query.filter(club_id=club_id).first()
if not club:
return Response({'code': 1, 'msg': f'俱乐部 {club_id} 不存在'})
if action == 'get':
rows = ClubPaymentChannel.query.filter(
club_id=club_id, channel=PAY_CHANNEL_FUBEI_WX_MINI,
).order_by('-is_default', 'sort_order', 'id')
return Response({
'code': 0,
'data': {
'club_id': club_id,
'mini_pay_channel': get_club_mini_pay_channel(club_id),
'fubei_channels': [_serialize_channel(r) for r in rows],
},
})
if action == 'set_mini_pay_channel':
ch = (request.data.get('mini_pay_channel') or '').strip().lower()
try:
set_club_mini_pay_channel(club_id, ch)
except ValueError as exc:
return Response({'code': 400, 'msg': str(exc)})
if ch == MINI_PAY_CHANNEL_FUBEI:
fb = ClubPaymentChannel.query.filter(
club_id=club_id, channel=PAY_CHANNEL_FUBEI_WX_MINI, enabled=1,
).first()
if not fb:
return Response({
'code': 0,
'msg': '已切换为付呗,但尚未配置付呗通道,请先保存付呗配置',
'data': {'mini_pay_channel': ch},
'warning': True,
})
return Response({
'code': 0,
'msg': '已切换小程序收款通道',
'data': {'mini_pay_channel': ch},
})
if action == 'save_fubei':
return self._save_fubei(request, club_id)
if action in ('enable', 'disable', 'set_default', 'delete'):
row_id = request.data.get('id')
row = ClubPaymentChannel.query.filter(
id=row_id, club_id=club_id, channel=PAY_CHANNEL_FUBEI_WX_MINI,
).first()
if not row:
return Response({'code': 1, 'msg': '配置不存在'})
if action == 'delete':
row.delete()
invalidate_club_payment_cache(club_id, PAY_CHANNEL_FUBEI_WX_MINI)
return Response({'code': 0, 'msg': '已删除'})
if action == 'enable':
row.enabled = 1
row.save(update_fields=['enabled'])
elif action == 'disable':
row.enabled = 0
row.save(update_fields=['enabled'])
elif action == 'set_default':
ClubPaymentChannel.objects.filter(
club_id=club_id, channel=PAY_CHANNEL_FUBEI_WX_MINI, is_default=True,
).exclude(id=row.id).update(is_default=False)
row.is_default = True
row.enabled = 1
row.save(update_fields=['is_default', 'enabled'])
invalidate_club_payment_cache(club_id, PAY_CHANNEL_FUBEI_WX_MINI)
return Response({'code': 0, 'msg': 'ok', 'data': _serialize_channel(row)})
return Response({'code': 400, 'msg': f'未知 action: {action}'})
except Exception as exc:
logger.error('payment-channel-manage 异常: %s', exc, exc_info=True)
return Response({'code': 500, 'msg': str(exc)})
def _save_fubei(self, request, club_id: str):
name = (request.data.get('name') or '主付呗').strip() or '主付呗'
row_id = request.data.get('id')
app_id = (request.data.get('app_id') or '').strip()
api_key = request.data.get('api_key')
store_id = request.data.get('store_id')
merchant_id = (request.data.get('merchant_id') or request.data.get('mch_id') or '').strip()
mini_app_id = (request.data.get('mini_app_id') or '').strip()
remark = (request.data.get('remark') or '').strip()
if not app_id:
return Response({'code': 400, 'msg': '请填写付呗开放平台 app_id'})
if store_id in (None, ''):
return Response({'code': 400, 'msg': '请填写门店 store_id'})
row = None
if row_id:
row = ClubPaymentChannel.query.filter(
id=row_id, club_id=club_id, channel=PAY_CHANNEL_FUBEI_WX_MINI,
).first()
if not row:
row = ClubPaymentChannel.query.filter(
club_id=club_id, channel=PAY_CHANNEL_FUBEI_WX_MINI, name=name,
).first()
creating = row is None
if creating:
row = ClubPaymentChannel(
club_id=club_id,
channel=PAY_CHANNEL_FUBEI_WX_MINI,
name=name,
enabled=1,
is_default=True,
)
row.app_id = app_id
row.mch_id = merchant_id
row.mini_app_id = mini_app_id
row.remark = remark
if api_key is not None and str(api_key).strip():
row.api_key_enc = encrypt_secret_field(str(api_key).strip())
elif creating:
return Response({'code': 400, 'msg': '新建付呗配置必须填写接口密钥'})
extra = dict(row.extra_json or {})
try:
extra['store_id'] = int(store_id)
except (TypeError, ValueError):
return Response({'code': 400, 'msg': 'store_id 必须是数字'})
if merchant_id:
extra['merchant_id'] = merchant_id
row.extra_json = extra
# 设为默认时取消同通道其它默认
if request.data.get('is_default', True):
ClubPaymentChannel.objects.filter(
club_id=club_id, channel=PAY_CHANNEL_FUBEI_WX_MINI, is_default=True,
).exclude(id=getattr(row, 'id', None) or 0).update(is_default=False)
row.is_default = True
row.enabled = 1
row.save()
invalidate_club_payment_cache(club_id, PAY_CHANNEL_FUBEI_WX_MINI)
# 可选:保存后同时切到付呗
if request.data.get('activate'):
set_club_mini_pay_channel(club_id, MINI_PAY_CHANNEL_FUBEI)
return Response({
'code': 0,
'msg': '付呗配置已保存',
'data': {
'channel': _serialize_channel(row),
'mini_pay_channel': get_club_mini_pay_channel(club_id),
},
})

View File

@@ -0,0 +1,138 @@
"""会员/履约金页文案:小程序只读 + 客服后台读写。"""
from rest_framework.parsers import JSONParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from backend.utils import verify_kefu_permission
from jituan.services.display_config import forbid_display_write_in_all_scope, list_response_meta
from jituan.services.recharge_page_copy import (
PAGE_KEYS,
club_id_for_admin,
club_id_for_mini,
get_admin_regions,
get_public_regions,
normalize_page_key,
save_region,
)
POPUP_PERM = '8080a'
def _page_key_from_request(request):
if hasattr(request, 'query_params'):
q = request.query_params.get('page_key')
if q:
return q
data = getattr(request, 'data', None) or {}
return data.get('page_key') or ''
class RechargePageCopyView(APIView):
"""小程序只读 GET/POST /jituan/recharge-page-copy?page_key=huiyuan|yajin"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def get(self, request):
return self._handle(request)
def post(self, request):
return self._handle(request)
def _handle(self, request):
page_key = normalize_page_key(_page_key_from_request(request))
if not page_key:
return Response({'code': 400, 'msg': f'page_key 须为 {", ".join(sorted(PAGE_KEYS))}'})
club_id = club_id_for_mini(request)
regions = get_public_regions(club_id, page_key)
return Response({
'code': 0,
'data': {
'page_key': page_key,
'club_id': club_id,
'regions': regions,
},
})
class RechargePageCopyAdminGetView(APIView):
"""后台拉全量区域 POST /jituan/houtai/recharge-page-copy/get"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
phone = (request.data.get('phone') or request.data.get('username') or '').strip()
if not phone:
return Response({'code': 401, 'msg': '缺少客服账号'})
kefu, permissions = verify_kefu_permission(request, phone)
if kefu is None:
return permissions
if POPUP_PERM not in permissions:
return Response({'code': 403, 'msg': '无权限管理充值页文案'})
page_key = normalize_page_key(request.data.get('page_key'))
if not page_key:
return Response({'code': 400, 'msg': f'page_key 须为 {", ".join(sorted(PAGE_KEYS))}'})
club_id = club_id_for_admin(request, request.user)
regions = get_admin_regions(club_id, page_key)
return Response({
'code': 0,
'data': {
'page_key': page_key,
'club_id': club_id,
'regions': regions,
**list_response_meta(request),
},
})
class RechargePageCopyAdminSaveView(APIView):
"""后台保存某一区域 POST /jituan/houtai/recharge-page-copy/save"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
phone = (request.data.get('phone') or request.data.get('username') or '').strip()
if not phone:
return Response({'code': 401, 'msg': '缺少客服账号'})
kefu, permissions = verify_kefu_permission(request, phone)
if kefu is None:
return permissions
if POPUP_PERM not in permissions:
return Response({'code': 403, 'msg': '无权限管理充值页文案'})
deny = forbid_display_write_in_all_scope(request)
if deny:
return deny
page_key = request.data.get('page_key')
region_key = request.data.get('region_key')
title = request.data.get('title') or ''
items = request.data.get('items')
if items is None:
items = request.data.get('items_json')
enabled = request.data.get('enabled', True)
sort_order = request.data.get('sort_order')
club_id = club_id_for_admin(request, request.user)
data, err = save_region(
club_id,
page_key,
region_key,
title=title,
items=items,
enabled=enabled,
sort_order=sort_order,
)
if err:
return Response({'code': 400, 'msg': err})
return Response({
'code': 0,
'msg': '保存成功',
'data': {
'page_key': normalize_page_key(page_key),
'club_id': club_id,
'region': data,
},
})

View File

@@ -17,16 +17,20 @@ logger = logging.getLogger(__name__)
def _normalize_bankuai_ids(data):
"""将板块 ID 统一为整数bankuai_id 为 AutoField"""
ids = data.get('bankuai_ids')
if ids is None and data.get('bankuai_id'):
if ids is None and data.get('bankuai_id') is not None:
ids = [data.get('bankuai_id')]
if not isinstance(ids, list):
return []
out = []
for item in ids:
s = str(item or '').strip()
if s and s not in out:
out.append(s)
try:
bid = int(str(item).strip())
except (TypeError, ValueError):
continue
if bid > 0 and bid not in out:
out.append(bid)
return out

View File

@@ -14,6 +14,33 @@ INVITE_STATUS_EXPIRED = 3
ACTOR_MERCHANT = 'MERCHANT'
ACTOR_STAFF = 'STAFF'
# 商家订单操作流水行为码(数字,同一订单可重复记录)
ACTION_SETTLE = 1
ACTION_CANCEL = 2
ACTION_REFUND_APPLY = 3
ACTION_REFUND_CANCEL = 4
ACTION_REFUND_MODIFY = 5
ACTION_PENALTY_APPLY = 6
ACTION_PENALTY_CANCEL = 7
ACTION_PENALTY_MODIFY = 8
ACTION_PENALTY_RESUBMIT = 9
ACTION_CHANGE_PLAYER = 10
ACTION_DISPATCH = 11
ORDER_ACTION_LABELS = {
ACTION_SETTLE: '结算',
ACTION_CANCEL: '撤销订单',
ACTION_REFUND_APPLY: '申请退款',
ACTION_REFUND_CANCEL: '取消退款',
ACTION_REFUND_MODIFY: '修改退款理由',
ACTION_PENALTY_APPLY: '申请罚单',
ACTION_PENALTY_CANCEL: '撤销罚单',
ACTION_PENALTY_MODIFY: '修改罚单',
ACTION_PENALTY_RESUBMIT: '重新申请罚单',
ACTION_CHANGE_PLAYER: '更换打手',
ACTION_DISPATCH: '派单',
}
WALLET_ALLOCATE = 'ALLOCATE'
WALLET_DISPATCH_FREEZE = 'DISPATCH_FREEZE'
WALLET_DISPATCH_CONFIRM = 'DISPATCH_CONFIRM'

View File

@@ -2,14 +2,63 @@
import logging
from backend.utils import pick_order_id
from merchant_ops.services.authz import StaffAuthError, member_has_perm
from merchant_ops.services.authz import StaffAuthError, get_active_staff_member, member_has_perm
from merchant_ops.models import MerchantOrderDispatch
from orders.models import MerchantOrderExt
from orders.models import MerchantOrderExt, Penalty
from users.business_models import User
from users.models import UserShangjia
logger = logging.getLogger(__name__)
class PenaltyEvidenceAuthError(Exception):
def __init__(self, code, msg):
self.code = code
self.msg = msg
super().__init__(msg)
def resolve_penalty_evidence_upload_auth(request, dingdan_id, fadan_id=None):
"""
商家老板或具 penalty_apply / penalty_manage 且能查看该订单的子客服。
返回所属 merchant_idApplicantID
"""
user = request.user
try:
shangjia_profile = user.ShopProfile
if shangjia_profile.zhuangtai != 1:
raise PenaltyEvidenceAuthError(403, '商家状态异常')
try:
MerchantOrderExt.query.get(Order__OrderID=dingdan_id, MerchantID=user.UserUID)
except MerchantOrderExt.DoesNotExist:
raise PenaltyEvidenceAuthError(403, '订单不存在或无权操作')
if fadan_id and not Penalty.query.filter(
id=fadan_id,
RelatedOrderID=dingdan_id,
ApplicantID=user.UserUID,
).exists():
raise PenaltyEvidenceAuthError(403, '罚单不存在或无权操作')
return user.UserUID
except UserShangjia.DoesNotExist:
pass
member = get_active_staff_member(user)
if not member:
raise PenaltyEvidenceAuthError(403, '非商家或子客服无权上传')
if not (member_has_perm(member, 'penalty_apply') or member_has_perm(member, 'penalty_manage')):
raise PenaltyEvidenceAuthError(403, '无罚单权限')
ok, _ = staff_can_access_order(member, dingdan_id)
if not ok:
raise PenaltyEvidenceAuthError(403, '无权操作该订单')
if fadan_id and not Penalty.query.filter(
id=fadan_id,
RelatedOrderID=dingdan_id,
ApplicantID=member.merchant_id,
).exists():
raise PenaltyEvidenceAuthError(403, '罚单不存在或无权操作')
return member.merchant_id
def get_merchant_user(merchant_id):
return User.query.get(UserUID=merchant_id)

View File

@@ -0,0 +1,46 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('merchant_ops', '0002_merchantstaffmember_merchant_remark'),
]
operations = [
migrations.CreateModel(
name='MerchantOrderActionLog',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, default='xq', max_length=16)),
('merchant_id', models.CharField(db_index=True, max_length=7)),
('order_id', models.CharField(db_index=True, max_length=32)),
('action_code', models.PositiveSmallIntegerField(db_index=True, verbose_name='行为码')),
('operator_user_id', models.CharField(db_index=True, max_length=7)),
('operator_type', models.CharField(max_length=16, verbose_name='MERCHANT/STAFF')),
('staff_member_id', models.BigIntegerField(blank=True, db_index=True, null=True)),
('role_code', models.CharField(blank=True, default='', max_length=32)),
('role_name', models.CharField(blank=True, default='', max_length=64)),
('amount', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('leixing_id', models.IntegerField(blank=True, db_index=True, null=True)),
('remark', models.CharField(blank=True, default='', max_length=500)),
('CreateTime', models.DateTimeField(auto_now_add=True, db_index=True)),
],
options={
'verbose_name': '商家订单操作流水',
'db_table': 'merchant_order_action_log',
},
),
migrations.AddIndex(
model_name='merchantorderactionlog',
index=models.Index(fields=['merchant_id', 'operator_user_id', '-CreateTime'], name='merchant_or_merchan_op_idx'),
),
migrations.AddIndex(
model_name='merchantorderactionlog',
index=models.Index(fields=['merchant_id', 'action_code', '-CreateTime'], name='merchant_or_merchan_ac_idx'),
),
migrations.AddIndex(
model_name='merchantorderactionlog',
index=models.Index(fields=['merchant_id', 'leixing_id', '-CreateTime'], name='merchant_or_merchan_lx_idx'),
),
]

View File

@@ -223,3 +223,29 @@ class MerchantDispatchTemplate(QModel):
class Meta:
db_table = 'merchant_dispatch_template'
class MerchantOrderActionLog(QModel):
"""商家侧订单操作流水:同一订单可多次记录,按真实操作人统计。"""
club_id = models.CharField(max_length=16, default='xq', db_index=True)
merchant_id = models.CharField(max_length=7, db_index=True)
order_id = models.CharField(max_length=32, db_index=True)
action_code = models.PositiveSmallIntegerField(db_index=True, verbose_name='行为码')
operator_user_id = models.CharField(max_length=7, db_index=True)
operator_type = models.CharField(max_length=16, verbose_name='MERCHANT/STAFF')
staff_member_id = models.BigIntegerField(null=True, blank=True, db_index=True)
role_code = models.CharField(max_length=32, blank=True, default='')
role_name = models.CharField(max_length=64, blank=True, default='')
amount = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
leixing_id = models.IntegerField(null=True, blank=True, db_index=True)
remark = models.CharField(max_length=500, blank=True, default='')
CreateTime = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
db_table = 'merchant_order_action_log'
verbose_name = '商家订单操作流水'
indexes = [
models.Index(fields=['merchant_id', 'operator_user_id', '-CreateTime']),
models.Index(fields=['merchant_id', 'action_code', '-CreateTime']),
models.Index(fields=['merchant_id', 'leixing_id', '-CreateTime']),
]

View File

@@ -0,0 +1,157 @@
"""商家订单操作流水:真实操作人落库(支持同一订单多次操作)。"""
import logging
from decimal import Decimal, InvalidOperation
from merchant_ops.constants import (
ACTOR_MERCHANT,
ACTOR_STAFF,
ORDER_ACTION_LABELS,
)
from merchant_ops.models import MerchantOrderActionLog
logger = logging.getLogger(__name__)
def _to_decimal(amount):
if amount is None or amount == '':
return None
try:
return Decimal(str(amount))
except (InvalidOperation, TypeError, ValueError):
return None
def _client_ip(request):
if request is None:
return ''
xff = request.META.get('HTTP_X_FORWARDED_FOR')
if xff:
return xff.split(',')[0].strip()
return request.META.get('REMOTE_ADDR', '') or ''
def resolve_operator_from_request(request, merchant_id):
"""
优先 request._staff_member子客服代理否则记为商家本人。
返回 dict: operator_user_id/operator_type/staff_member_id/role_code/role_name
"""
mid = str(merchant_id or '').strip()
staff_member = getattr(request, '_staff_member', None) if request is not None else None
if staff_member is not None:
role = getattr(staff_member, 'role', None)
return {
'operator_user_id': str(getattr(staff_member, 'staff_user_id', '') or ''),
'operator_type': ACTOR_STAFF,
'staff_member_id': getattr(staff_member, 'id', None),
'role_code': getattr(role, 'role_code', '') or '',
'role_name': getattr(role, 'role_name', '') or '',
}
# 商家本人:代理场景下 request.user 已是商家
uid = ''
if request is not None and getattr(request, 'user', None) is not None:
uid = str(getattr(request.user, 'UserUID', '') or '')
if not uid:
uid = mid
return {
'operator_user_id': uid,
'operator_type': ACTOR_MERCHANT,
'staff_member_id': None,
'role_code': 'OWNER',
'role_name': '商家本人',
}
def record_order_action(
*,
merchant_id,
order_id,
action_code,
operator_user_id,
operator_type,
staff_member_id=None,
role_code='',
role_name='',
amount=None,
leixing_id=None,
remark='',
club_id='xq',
request=None,
):
"""写入一条操作流水。失败只打日志,不影响主业务。"""
try:
oid = str(order_id or '').strip()
mid = str(merchant_id or '').strip()
if not oid or not mid or not operator_user_id:
logger.warning(
'操作流水跳过:参数不完整 merchant=%s order=%s action=%s operator=%s',
mid, oid, action_code, operator_user_id,
)
return None
code = int(action_code)
row = MerchantOrderActionLog.objects.create(
club_id=(club_id or 'xq')[:16],
merchant_id=mid[:7],
order_id=oid[:32],
action_code=code,
operator_user_id=str(operator_user_id)[:7],
operator_type=(operator_type or ACTOR_MERCHANT)[:16],
staff_member_id=staff_member_id,
role_code=(role_code or '')[:32],
role_name=(role_name or '')[:64],
amount=_to_decimal(amount),
leixing_id=int(leixing_id) if leixing_id not in (None, '') else None,
remark=(remark or '')[:500],
)
logger.info(
'【商家操作流水】写入成功 id=%s merchant=%s order=%s action=%s(%s) '
'operator=%s type=%s role=%s amount=%s leixing=%s ip=%s',
row.id, mid, oid, code, ORDER_ACTION_LABELS.get(code, '?'),
operator_user_id, operator_type, role_code or role_name,
amount, leixing_id, _client_ip(request),
)
return row
except Exception:
logger.error(
'【商家操作流水】写入失败 merchant=%s order=%s action=%s',
merchant_id, order_id, action_code, exc_info=True,
)
return None
def record_order_action_from_request(
request,
action_code,
order_id,
*,
merchant_id=None,
amount=None,
leixing_id=None,
remark='',
club_id='xq',
):
"""从 request含 _staff_member自动识别操作人并落库。"""
mid = str(merchant_id or '').strip()
if not mid and request is not None and getattr(request, 'user', None) is not None:
mid = str(getattr(request.user, 'UserUID', '') or '')
op = resolve_operator_from_request(request, mid)
return record_order_action(
merchant_id=mid,
order_id=order_id,
action_code=action_code,
operator_user_id=op['operator_user_id'],
operator_type=op['operator_type'],
staff_member_id=op.get('staff_member_id'),
role_code=op.get('role_code', ''),
role_name=op.get('role_name', ''),
amount=amount,
leixing_id=leixing_id,
remark=remark,
club_id=club_id,
request=request,
)
def leixing_id_from_order(order):
if order is None:
return None
return getattr(order, 'ProductTypeID', None) or getattr(order, 'leixing_id', None)

View File

@@ -0,0 +1,286 @@
"""按真实操作人聚合商家订单操作流水统计。"""
from collections import defaultdict
from datetime import datetime
from decimal import Decimal
from django.conf import settings
from django.db.models import Count, Max, Sum
from django.utils import timezone
from merchant_ops.constants import (
ACTOR_MERCHANT,
ACTOR_STAFF,
ORDER_ACTION_LABELS,
)
from merchant_ops.models import MerchantOrderActionLog, MerchantStaffMember
from products.models import ShangpinLeixing
def _parse_dt(value, end_of_day=False):
"""解析时间USE_TZ=False 时必须返回 naive datetimeMySQL"""
if value is None or value == '':
return None
dt = None
if isinstance(value, datetime):
dt = value
else:
text = str(value).strip().replace('/', '-')
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M', '%Y-%m-%d'):
try:
dt = datetime.strptime(text, fmt)
if fmt == '%Y-%m-%d' and end_of_day:
dt = dt.replace(hour=23, minute=59, second=59)
break
except ValueError:
continue
if dt is None:
return None
use_tz = bool(getattr(settings, 'USE_TZ', False))
if use_tz:
if timezone.is_naive(dt):
dt = timezone.make_aware(dt, timezone.get_current_timezone())
else:
if timezone.is_aware(dt):
dt = timezone.make_naive(dt, timezone.get_current_timezone())
return dt
def _money(val):
if val is None:
return '0.00'
return f'{Decimal(str(val)):.2f}'
def _dec(val):
if val is None:
return Decimal('0')
try:
return Decimal(str(val))
except Exception:
return Decimal('0')
def build_action_stats_queryset(
merchant_id,
*,
operator_user_id=None,
operator_type=None,
role_code=None,
action_code=None,
action_codes=None,
leixing_id=None,
start_time=None,
end_time=None,
):
qs = MerchantOrderActionLog.objects.filter(merchant_id=str(merchant_id))
if operator_user_id:
qs = qs.filter(operator_user_id=str(operator_user_id))
if operator_type:
qs = qs.filter(operator_type=str(operator_type).upper())
if role_code:
qs = qs.filter(role_code=str(role_code))
if action_code not in (None, ''):
qs = qs.filter(action_code=int(action_code))
if action_codes:
codes = []
for c in action_codes:
try:
codes.append(int(c))
except (TypeError, ValueError):
pass
if codes:
qs = qs.filter(action_code__in=codes)
if leixing_id not in (None, ''):
qs = qs.filter(leixing_id=int(leixing_id))
st = _parse_dt(start_time, end_of_day=False)
et = _parse_dt(end_time, end_of_day=True)
if st:
qs = qs.filter(CreateTime__gte=st)
if et:
qs = qs.filter(CreateTime__lte=et)
return qs
def _aggregate_unique_orders(qs, group_fields):
"""
同一分组下:同一订单同一行为只计 1 单、金额取该订单该行为记录的 Max(amount)。
返回: {group_tuple: {'order_count', 'action_count', 'amount'}}
"""
# 操作次数(含重复)
action_cnt_map = {}
for row in qs.values(*group_fields).annotate(cnt=Count('id')):
key = tuple(row[f] for f in group_fields)
action_cnt_map[key] = int(row['cnt'] or 0)
# 订单去重:每个 (group..., order_id) 只留一笔金额
unique_rows = qs.values(*group_fields, 'order_id').annotate(one_amt=Max('amount'))
result = defaultdict(lambda: {
'order_count': 0,
'action_count': 0,
'amount': Decimal('0'),
})
for row in unique_rows:
key = tuple(row[f] for f in group_fields)
result[key]['order_count'] += 1
result[key]['amount'] += _dec(row['one_amt'])
for key, meta in result.items():
meta['action_count'] = action_cnt_map.get(key, 0)
# 只有重复操作、但 unique 漏掉的分组也补上(理论上 unique 覆盖全部)
for key, cnt in action_cnt_map.items():
if key not in result:
result[key] = {
'order_count': 0,
'action_count': cnt,
'amount': Decimal('0'),
}
return result
def summarize_action_logs(qs):
action_total = qs.aggregate(cnt=Count('id'))
order_total = qs.values('order_id').distinct().count()
# 总额:先按 (action_code, order_id) 去重取 Max(amount),再按订单取各行为中金额的?
# 用户关心订单量去重;总额对「全部」按 (action, order) 去重后的金额求和(不同行为各自算一笔金额)
unique_amt_rows = qs.values('action_code', 'order_id').annotate(one_amt=Max('amount'))
total_amount = sum((_dec(r['one_amt']) for r in unique_amt_rows), Decimal('0'))
by_action_map = _aggregate_unique_orders(qs, ['action_code'])
by_action = []
for key in sorted(by_action_map.keys(), key=lambda k: k[0] if k[0] is not None else 0):
code = int(key[0])
meta = by_action_map[key]
by_action.append({
'action_code': code,
'action_label': ORDER_ACTION_LABELS.get(code, f'未知({code})'),
'order_count': meta['order_count'],
'count': meta['order_count'], # 兼容旧字段:对外主展示订单量
'action_count': meta['action_count'],
'amount': _money(meta['amount']),
})
by_leixing_map = _aggregate_unique_orders(
qs.exclude(leixing_id__isnull=True),
['leixing_id'],
)
leixing_ids = [k[0] for k in by_leixing_map.keys() if k[0] is not None]
name_map = {}
if leixing_ids:
for lx in ShangpinLeixing.query.filter(id__in=leixing_ids).only('id', 'jieshao'):
name_map[lx.id] = getattr(lx, 'jieshao', '') or str(lx.id)
by_leixing = []
for key, meta in sorted(by_leixing_map.items(), key=lambda x: -x[1]['order_count']):
lid = key[0]
by_leixing.append({
'leixing_id': lid,
'leixing_name': name_map.get(lid, str(lid)),
'order_count': meta['order_count'],
'count': meta['order_count'],
'action_count': meta['action_count'],
'amount': _money(meta['amount']),
})
return {
'total_order_count': int(order_total or 0),
'total_count': int(order_total or 0), # 主数字=去重订单量
'total_action_count': int(action_total['cnt'] or 0),
'total_amount': _money(total_amount),
'by_action': by_action,
'by_leixing': by_leixing,
}
def build_operator_action_stats(merchant_id, filters):
qs = build_action_stats_queryset(merchant_id, **filters)
summary = summarize_action_logs(qs)
people_map = _aggregate_unique_orders(
qs,
['operator_user_id', 'operator_type', 'role_code', 'role_name'],
)
# 补职位备注
staff_uids = [
k[0] for k in people_map.keys()
if k[1] == ACTOR_STAFF
]
remark_map = {}
name_map = {}
if staff_uids:
for m in MerchantStaffMember.objects.filter(
merchant_id=str(merchant_id),
staff_user_id__in=staff_uids,
status=1,
).select_related('role'):
remark_map[m.staff_user_id] = m.merchant_remark or ''
name_map[m.staff_user_id] = m.display_name or ''
people = []
for key, meta in sorted(people_map.items(), key=lambda x: -x[1]['order_count']):
uid, op_type, role_code, role_name = key
role_name = role_name or ('商家本人' if op_type == ACTOR_MERCHANT else '')
remark = remark_map.get(uid, '') if op_type == ACTOR_STAFF else ''
display = name_map.get(uid, '') if op_type == ACTOR_STAFF else '商家本人'
people.append({
'operator_user_id': uid,
'operator_type': op_type,
'role_code': role_code or '',
'role_name': role_name,
'merchant_remark': remark,
'display_name': display,
'order_count': meta['order_count'],
'count': meta['order_count'],
'action_count': meta['action_count'],
'amount': _money(meta['amount']),
})
return {
**summary,
'people': people,
'action_defs': [
{'action_code': k, 'action_label': v}
for k, v in sorted(ORDER_ACTION_LABELS.items())
],
}
def list_operator_candidates(merchant_id):
"""经营数据页可选身份:商家本人 + 子客服(展示职位+备注)。"""
items = [{
'operator_user_id': str(merchant_id),
'operator_type': ACTOR_MERCHANT,
'role_code': 'OWNER',
'role_name': '商家本人',
'merchant_remark': '',
'display_name': '商家本人',
'staff_member_id': None,
}]
members = (
MerchantStaffMember.objects
.filter(merchant_id=str(merchant_id), status=1)
.select_related('role')
.order_by('id')
)
for m in members:
role_name = m.role.role_name if m.role else '子客服'
remark = (m.merchant_remark or '').strip()
nick = (m.display_name or '').strip()
parts = [role_name]
if remark:
parts.append(f'备注:{remark}')
elif nick:
parts.append(nick)
parts.append(m.staff_user_id)
items.append({
'operator_user_id': m.staff_user_id,
'operator_type': ACTOR_STAFF,
'role_code': m.role.role_code if m.role else '',
'role_name': role_name,
'merchant_remark': remark,
'display_name': ' · '.join(parts),
'staff_member_id': m.id,
})
return items

Some files were not shown because too many files have changed in this diff Show More