214 Commits

Author SHA1 Message Date
XingQue
6d8ced78ca fix: 创建订单兼容 H5 的 zhiding_id 指定字段 2026-07-31 12:40:04 +08:00
XingQue
3ba3536b05 fix: 假单列表去掉共享打乱写库,避免刷新时空单 2026-07-31 10:17:54 +08:00
XingQue
62f121f438 feat: 管事自助更换组长(配额、记录、小程序与后台接口) 2026-07-31 10:10:12 +08:00
XingQue
1cc3f07480 feat: 抢单失败返回 fail_type,供前端跳转会员/押金页 2026-07-30 21:23:05 +08:00
XingQue
b5c719e7c1 feat: 俱乐部可关闭强制收集手机号(yhbdsjh 返回无需认证) 2026-07-30 21:12:56 +08:00
XingQue
09f05605ff feat: 假单计入类型数量;按类型配置强制展示订单价格 2026-07-30 21:04:17 +08:00
XingQue
9744e800eb feat: 假单与真单混排,会员或押金达标后过滤假单 2026-07-30 20:41:10 +08:00
XingQue
c8a3d0274d fix: 假单开启后无对应会员稳定走假池,避免真假交替 2026-07-30 20:03:42 +08:00
XingQue
bb74140150 feat: 假单按俱乐部开关,并按商品类型会员资格展示 2026-07-30 19:55:19 +08:00
XingQue
6f4e458871 fix: 待收款驳回按业务单号查打款记录,避免 pk 误查导致 400 2026-07-30 19:06:38 +08:00
XingQue
9df450b278 feat: 提现审核支持状态筛选、待收款改状态与驳回返还选项 2026-07-30 18:44:10 +08:00
XingQue
7cda8ccbe1 fix: 打手登录先认身份,默认邀请码改由后端下发 2026-07-30 18:05:45 +08:00
XingQue
3a9b275d0a fix: 抢单建聊与聊天订单摘要不返回订单价格 2026-07-30 06:13:54 +08:00
XingQue
148a35f04b feat: UFO H5 服务号 OAuth 登录与同商户公众号 JSAPI 支付 2026-07-30 00:12:37 +08:00
XingQue
531639731a fix: 打手有效会员改用 Exists,修复 values 字典导致 __in 永假 2026-07-29 22:39:30 +08:00
XingQue
ae8a2bb993 fix: 打手有效会员按到期时间判定,不再误滤 club_id 2026-07-29 20:56:08 +08:00
XingQue
e2bfed75fa feat: 打手列表支持会员押金统计与多维筛选 2026-07-29 18:53:18 +08:00
XingQue
fd2163f96a fix: 超管聊天权限判定,正确下发 GoEasy AppKey 2026-07-29 18:25:33 +08:00
XingQue
e6b1d29b11 fix: 系统超管 menu permission_codes 展开全量权限码(含002ad)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 11:06:22 +08:00
XingQue
af513738a5 feat: 客服改平台订单/商品抢单要求(002ad 隔离接口)
仅状态1/7可改订单;批量只改本店类型下商品三字段;各俱乐部闸门隔离无白名单特殊化。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 07:48:11 +08:00
XingQue
c3a0143eff fix: 集团任职硬删除,杜绝 NULL 重复冒出多条
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 06:53:06 +08:00
XingQue
52e714b9b2 fix: 紧急封杀后台账号命令 nuke_kefu_admin
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 06:49:11 +08:00
XingQue
559e903a0c fix: 停用集团任职同时摘掉 IsSuperuser,杜绝超管绕过
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 06:46:14 +08:00
XingQue
ad760a9b42 fix: 后台停用/禁用真正生效(重复集团任职与超管绕过)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 06:42:21 +08:00
XingQue
fa746448cc fix: 源店计划启用即返回 show_entry,与是否有资产无关
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 04:58:52 +08:00
XingQue
589a653a94 docs: 明确迁移入口仅随计划启用,与强制拦截解耦
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 04:51:28 +08:00
XingQue
da62ea8be9 fix: 迁移子会员时长按总会员有效覆盖累加,避免少加天数
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 04:46:29 +08:00
XingQue
07ae350d55 fix: 总会员按子会员迁出;我的会员去掉展开重复展示
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 04:37:08 +08:00
XingQue
2f684a0203 fix: 二次迁移可迁账上仍有效的充值单合成会员
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 04:30:31 +08:00
XingQue
2ab97b0579 fix: 会员按公共ID原样过户,禁止静默skip,残留资产可补迁
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 04:25:23 +08:00
XingQue
5f3a5f9e08 fix: 二次迁移名额有剩余次数即展示入口,不依赖强制拦截
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 04:19:25 +08:00
XingQue
b84bdc5a76 fix: 二次迁移名额可列表展示,保存后跳转用户页
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 04:12:10 +08:00
XingQue
e8de4b2cf9 fix: 会员迁移不盲信club_id,目标必落库并撤销源Czjilu权益
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 04:06:47 +08:00
XingQue
47f69dc8ab fix: 迁移会员过户统一 naive/aware 时间比较,避免 confirm 500
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 03:58:08 +08:00
XingQue
b324e3ee0f fix: 迁移码返回完整URL;确认须在目标店;兼容扫码接收
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 03:53:08 +08:00
XingQue
bd1284825c fix: 迁移预览兼容naive时间;无资产不强制进迁移页
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 03:47:53 +08:00
XingQue
7dc1dd6f04 fix: 迁移积分/分红次数/冻结入账,并修正无计划误导文案
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 03:44:53 +08:00
XingQue
2b6d91fc4a feat: 迁移预览展示会员名称与到期日,总会员按子会员加时长
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 03:30:08 +08:00
XingQue
df89c82731 fix: 迁移闸门优先用小程序 club_id/X-Club-Id,避免源店不弹窗
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 03:23:57 +08:00
XingQue
9346123e8c fix: fund_freeze 从 business_models 导入 User,修复提现驳回 500
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 01:14:52 +08:00
XingQue
36e1bf902a feat: 提现驳回可选返还手续费与入账冻结(单笔/批量)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 00:57:12 +08:00
XingQue
8e7481e25d fix: 管事/组长分红入账接入资金冻结网关(会员/商家单/押金)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 00:50:13 +08:00
XingQue
0ebe5b4872 feat: 跨俱乐部资产迁移(计划/令牌/流水 + 用户与后台 API)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 00:15:27 +08:00
XingQue
568dcb720e fix: IM俱乐部过滤返回current_club_id并规范化比对
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 15:28:24 +08:00
XingQue
e91bf6c982 fix: 强制结单组队分账+按打手俱乐部冻结
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 15:03:54 +08:00
XingQue
4414b74119 fix: 超管强制结单跳过客服统计,避免 kefu 为 None 整单回滚
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 14:43:51 +08:00
XingQue
bc20fd85e0 fix: kefu.py 补 import re,修复聊天俱乐部过滤接口加载失败
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 14:34:21 +08:00
XingQue
5ded53bca8 fix: 平台微信退款改用俱乐部收款配置(付呗/直连分路,不动支付宝)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 14:17:24 +08:00
XingQue
5cfb4a1b2b feat: 客服聊天按用户俱乐部过滤待接入会话
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 14:08:59 +08:00
XingQue
2ff5df09bc feat: 专区图标支持后台上传 OSS,查询接口下发 tupian_url
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 02:12:09 +08:00
XingQue
1c67f98866 feat: 罚款分红费率按俱乐部可读可配,接入提现设置
个人利率优先,其次 club_fadan_fenhong_lilv,再回退全局表;组队均摊仍按每张罚单金额×费率。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 19:54:58 +08:00
XingQue
75fb69e6da fix: 自动结算写入对齐 USE_TZ;lxs 打手注册同步开通管事
order_deal 不再 make_aware 入库以免 MySQL 报错;龙先生打手注册成功时创建管事且邀请人与打手相同。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 19:15:16 +08:00
XingQue
955b76cbea fix: 俱乐部超管可审本店提现与订单,禁跨店改单
放宽身份校验与权限推断,列表带回俱乐部信息,写操作按本店范围拦截。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:39:46 +08:00
XingQue
17b38d07a9 feat: 抢单池/组队角标计数多接口附加字段(兼容旧客户端) 2026-07-27 17:19:41 +08:00
XingQue
9b9203587c fix: 组队复用抢单配对群,不再另建 group_订单 2026-07-27 09:21:15 +08:00
XingQue
8e71cd4243 feat: 仅特约商家可更换打手,后台可开关 2026-07-27 09:16:27 +08:00
XingQue
b5506f5361 fix: 体验会员期判断统一 datetime 时区,避免提现审核报错
naive/aware 混比导致 can't compare offset-naive and offset-aware datetimes。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 08:57:10 +08:00
XingQue
3c02b9477d fix: 成交指标 ensure 遇唯一键冲突时复用已有行
回填并发插入 merchant_deal_stat/dashou_deal_stat 时不再因 Duplicate entry 中断。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 08:48:45 +08:00
XingQue
e3f9654002 fix: 成交指标始终记账并回填;抢单建聊移出事务并重试
抢单池刷新触发历史回填,旧单也能出成交率/罚款率/均时;建聊失败重试并订阅组队成员,避免消息列表空白。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 08:46:22 +08:00
XingQue
5e38a3d49a feat: 自动结算返回到期时间戳供前端秒级倒计时
详情/列表用 auto_expire_at_ts 做 HH:MM:SS 时钟;展示不依赖 Celery,真正自动结单才需要 beat。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 08:24:13 +08:00
XingQue
3fe24cbcce fix: 抢单/组队卡片成交指标有字段即返回,均时含0样本
商家指标俱乐部兜底;组队大厅附带商家成交率/罚款率/均时。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 08:21:33 +08:00
XingQue
871144b53b fix: 组队结单后罚款仍按队员均摊
商家常在结单后申请罚款,招募已是结束态;现含已结束组队,并从结算流水兜底还原队员。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 08:18:06 +08:00
XingQue
7d366e8075 fix: 组队仅队长提交修改,组员可见交付;罚单聚合改撤联动
详情返回 can_submit/can_modify;交付图按订单聚合;组队均摊罚单展示总额并联动修改/撤销;罚单 ClubID 归属订单店。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 08:05:34 +08:00
XingQue
c5ceacf3d3 fix: 进入组队订单群时一并订阅在队打手
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 08:00:27 +08:00
XingQue
caef4bf56c fix: 组队群统一 group_订单ID,入队发消息并拉商家/老板
开组/入队订阅派单方;发送「已组队」;进聊与代理发消息在有组队时走订单群;队友可进群。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 07:59:16 +08:00
XingQue
96f1a80395 feat: 俱乐部超级管理可加本店余额,不再强制集团高管
持有000001且处于当前俱乐部视图时可加余额/押金/可提现;强制目标用户同俱乐部,并在修改记录中写入俱乐部ID。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 07:53:33 +08:00
XingQue
69e1ca0e1a feat: 组队大厅返回队长结算率/被罚款率
与订单列表同口径,按招募与订单俱乐部兜底查询打手成交统计。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 07:43:26 +08:00
XingQue
fcb2f5364e feat: 组队大厅返回商家UID/头像与队长头像
serialize_recruit_public 对齐抢单池补 shangjia_id、sj_avatar、leader_avatar。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 07:22:19 +08:00
XingQue
69b16848c4 feat: 商家退款申请支持证据图片
新增 tuikuantupian 表;申请退款必传图片;打手详情与客服订单详情返回退款图。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 07:12:43 +08:00
XingQue
bf4fd468e8 fix: 封禁打手禁止 wdlyhdl 登录并返回明确提示
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 05:56:30 +08:00
XingQue
5b0db2c8fd feat: 封禁原因字段与身份状态刷新接口
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 05:40:50 +08:00
XingQue
40c489de39 fix: 补全组队接口 _club/_uid,修复 /zudui/dating 500
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 05:04:42 +08:00
XingQue
1ae4796e1a fix: wdlyhdl 不再把商家/管事身份错误写成恒为1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 04:57:58 +08:00
XingQue
842cfbe5e1 feat: 投诉工单支持填写QQ/微信/手机联系方式
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 04:15:59 +08:00
XingQue
f59ae2416f feat: 打手订单列表组队标记,队友也可看单;详情返回队友昵称
列表附 has_team/角色/人数;组队在队成员纳入订单列表查询。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 03:33:35 +08:00
XingQue
454f5abe90 fix: 组队强制本俱乐部隔离,修复组队分账冻结幂等键
组队接口一律校验 X-Club-Id=订单=帖;不走跨店互通。分账 biz_id 按队员区分。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 03:25:52 +08:00
XingQue
193a82930e chore: Beat仅保留冻结解冻+可控自动结算,并加固关开关立即停结
自动结算结单前再校验俱乐部开关与 SettlementTime>=开启时间;关开关仍清 eligible。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 03:17:01 +08:00
XingQue
7002da6093 feat: 组队招募(/zudui)+ 结单/自动结算/罚款挂钩
新增 Team 表与接口;进8强制锁、组队分账优先且失败不回退全额队长。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 03:12:04 +08:00
XingQue
e9e5ee85e2 feat: 客服平台/商家订单列表与详情返回自动结算倒计时并支持筛选
对齐小程序口径:eligible 才下发;auto_settle_pending=1 筛待自动结算。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 02:12:45 +08:00
XingQue
1b733d21cc feat: 订单列表/详情返回自动结算倒计时字段,并加小程序结算率接口
仅 eligible 结算中订单下发预计时间;支持 auto_settle_pending 筛选;新增 /dingdan/wo-de-deal-stat。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 02:08:32 +08:00
XingQue
9ac0a3a5ad feat: 投诉仅限本人邀请人+人工客服配置+抢单池商家成交指标
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 01:41:29 +08:00
XingQue
0e102fbcba feat: 成交指标新表+可控自动结算(默认关,仅新进结算中eligible)
按设计文档:SettlementTime起表;关开关清eligible;定时任务只结到期eligible单;打款走统一结单;指标从stats_since起幂等记账。不打开旧全局ORDER_AUTO_SETTLEMENT。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 01:28:44 +08:00
XingQue
97040c552e fix: 充值投诉仅允许已支付流水(前后端硬拦)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 01:12:30 +08:00
XingQue
332fde50a3 fix: 充值记录列表补全状态/类型展示字段
投诉选单接口返回可读时间与更完整状态文案,不筛选状态。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 01:05:41 +08:00
XingQue
4799282579 feat: 客服后台可开关投诉拦截提现(按当前俱乐部)
投诉工单页对应 API:GET/POST /gongdan/admin/club-config/

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 00:56:43 +08:00
XingQue
890e465e40 feat: 投诉工单正规化(介入/牵连提现拦截,仅俱乐部开关)
支持被投诉对象、平台介入、协商中状态与组长投诉;提现硬拦含管事向上牵连组长,默认仅 lxs 开启。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 00:50:31 +08:00
XingQue
fa6e679cbe feat: 角色协议(勾选+阅读秒数+卡点)与冻结说明按身份返回
新增协议表/签署留痕、后台与小程序 API;去掉手写依赖;支持抢单/注册/提现等卡点配置。冻结问号文案按角色字典下发。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 20:22:12 +08:00
XingQue
6c96eb3bcb fix: 冻结条件可评估+分期规范化,定时扫描更及时
后台条件真正按罚款率/单量等评估;选项式配置所需字段规范化;Celery 改为每5分钟;小程序说明文案接口补齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 19:36:10 +08:00
XingQue
a505a1ab2d feat: 小程序资金冻结只读接口(汇总/明细/兼容字段)
提现资产与角色信息增量返回 dongjie;新增 fund-freeze-mine/ledger 只读 API,不改扣款逻辑。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 19:11:47 +08:00
XingQue
3194a47f52 feat: 资金冻结 P2 客服后台 API(配置/明细/风险/手动解冻)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 18:55:26 +08:00
XingQue
6a1895e566 feat: 资金冻结 P1 打手结单收口网关 + 到期解冻定时任务骨架
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 18:49:44 +08:00
XingQue
d40a27a5ca feat: 资金冻结 P0(表结构+默认关闭入账网关+权限菜单种子)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 18:43:01 +08:00
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
239 changed files with 36849 additions and 4664 deletions

View File

@@ -1,120 +1,61 @@
""" """
阿龙电竞 - Celery定时任务主配置 阿龙电竞 - Celery 定时任务主配置
业务约定2026-07Beat 只跑两条
1) 资金冻结到期解冻
2) 可控订单自动结算
其余榜单/清零类任务保留代码但不进 beat_schedule不会定时执行。
""" """
import os import os
from celery import Celery from celery import Celery
from celery.schedules import crontab from celery.schedules import crontab
# 在 Django Shell 中执行
# 设置Django默认设置模块
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'a_long_dianjing.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'a_long_dianjing.settings')
# 创建Celery应用实例
app = Celery('a_long_dianjing') app = Celery('a_long_dianjing')
# 从Django settings中加载Celery配置CELERY_前缀
app.config_from_object('django.conf:settings', namespace='CELERY') app.config_from_object('django.conf:settings', namespace='CELERY')
# 自动发现所有已注册app中的tasks.py文件
#app.autodiscover_tasks()
# 自动发现任务(不含 orders.tasks订单超时自动结算已永久关闭
app.autodiscover_tasks(['users.tasks', 'users.ranking_tasks', 'config.tasks'])
# 配置周期性任务Celery Beat Schedule # 仅自动发现仍可能被调用的任务模块;旧超时结算 orders.tasks 永不加载
app.autodiscover_tasks(['jituan.tasks', 'users.tasks', 'users.ranking_tasks', 'config.tasks'])
# ========== Beat只保留这两个 ==========
app.conf.beat_schedule = { app.conf.beat_schedule = {
# 月榜数据转移每月最后一天23:50执行 # 资金冻结到期解冻(俱乐部未开冻结 / 无到期单 → 空跑
'yuebang_zhuanyi': { 'scan_fund_freeze_due': {
'task': 'users.ranking_tasks.zhuanyi_yuebang', 'task': 'jituan.tasks.scan_fund_freeze_due',
'schedule': crontab(hour=23, minute=50, day_of_month='28-31'), # ✅ 修改这里 'schedule': crontab(minute='*/5'),
'options': {'queue': 'periodic_tasks', 'priority': 7},
},
# 日榜数据转移每天23:55执行在清零前
'ribang_zhuanyi': {
'task': 'users.ranking_tasks.zhuanyi_ribang',
#'schedule': crontab(minute='*/1'), # 每分钟执行一次,测试用
'schedule': crontab(hour=23, minute=55), # 每天23:55
'options': {'queue': 'periodic_tasks', 'priority': 7}, # 优先级高于清零任务
},
# 清理旧历史数据每月1号凌晨1点执行
'qingli_jiulishuju': {
'task': 'users.ranking_tasks.qingli_jiulishuju',
'schedule': crontab(hour=1, minute=0, day_of_month=1), # 每月1号凌晨1点
'options': {'queue': 'periodic_tasks', 'priority': 4},
},
# 补偿检查任务(订单自动结算已禁用)
# 'check_order_expire_task': {
# 'task': 'orders.tasks.check_order_expire_task',
# 'schedule': crontab(minute='*/5000'),
# 'options': {'queue': 'order_tasks', 'priority': 3},
# },
# 1. 每日凌晨0点执行 - 清零任务
'daily_reset_task': {
'task': 'users.tasks.daily_reset_task',
'schedule': crontab(hour=0, minute=0), # 每天0点
'options': { 'options': {
'queue': 'periodic_tasks', 'queue': 'periodic_tasks',
'priority': 5 'priority': 4,
}, },
'args': (),
'kwargs': {}
}, },
# 可控自动结算(俱乐部后台开关关 → 空跑;只处理 AutoSettleEligible 新单)
# 2. 每月1日凌晨0点执行 - 月度清零任务 'scan_order_auto_settle_due': {
'monthly_reset_task': { 'task': 'jituan.tasks.scan_order_auto_settle_due',
'task': 'users.tasks.monthly_reset_task', 'schedule': crontab(minute='*/5'),
'schedule': crontab(hour=0, minute=0, day_of_month=1), # 每月1日0点
'options': { 'options': {
'queue': 'periodic_tasks', 'queue': 'periodic_tasks',
'priority': 5 'priority': 4,
}, },
'args': (),
'kwargs': {}
}, },
# 4. 每天凌晨0点5分清理收支记录
'daily_sz_reset_task': {
'task': 'config.tasks.daily_sz_reset_task',
'schedule': crontab(hour=0, minute=5), # 每天0点5分
'options': {
'queue': 'periodic_tasks',
'priority': 4
},
'args': (),
'kwargs': {}
}
} }
# 时区设置
app.conf.timezone = 'Asia/Shanghai' app.conf.timezone = 'Asia/Shanghai'
app.conf.enable_utc = True app.conf.enable_utc = True
# 队列路由配置orders.tasks 已移除:禁止 order_tasks 队列再调度超时结算)
app.conf.task_routes = { app.conf.task_routes = {
# 'orders.tasks.*': {'queue': 'order_tasks'}, 'jituan.tasks.*': {'queue': 'periodic_tasks'},
'users.tasks.*': {'queue': 'periodic_tasks'}, 'users.tasks.*': {'queue': 'periodic_tasks'},
'config.tasks.*': {'queue': 'periodic_tasks'}, 'config.tasks.*': {'queue': 'periodic_tasks'},
} }
# 任务序列化
app.conf.accept_content = ['json'] app.conf.accept_content = ['json']
app.conf.task_serializer = 'json' app.conf.task_serializer = 'json'
app.conf.result_serializer = 'json' app.conf.result_serializer = 'json'
app.conf.task_time_limit = 300
# 任务超时设置 app.conf.task_soft_time_limit = 240
app.conf.task_time_limit = 300 # 任务最大执行时间300秒
app.conf.task_soft_time_limit = 240 # 软超时240秒
# Worker并发设置
app.conf.worker_concurrency = 4 app.conf.worker_concurrency = 4
app.conf.worker_prefetch_multiplier = 1 app.conf.worker_prefetch_multiplier = 1
# 任务确认设置
app.conf.task_acks_late = True app.conf.task_acks_late = True
app.conf.worker_disable_rate_limits = True app.conf.worker_disable_rate_limits = True
app.conf.result_expires = 3600
# 结果过期时间
app.conf.result_expires = 3600 # 任务结果保留1小时

View File

@@ -42,6 +42,7 @@ INSTALLED_APPS = [
'rank', 'rank',
'merchant_ops', 'merchant_ops',
'jituan', 'jituan',
'gongdan',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -290,6 +291,12 @@ SHANGJIA_CZ_MIN_AMOUNT = 1
SHANGJIA_CZ_MAX_AMOUNT = 10000 SHANGJIA_CZ_MAX_AMOUNT = 10000
FAKUAN_PAY_DESCRIPTION = '罚款缴纳' FAKUAN_PAY_DESCRIPTION = '罚款缴纳'
FAKUAN_PAY_NOTIFY_URL = 'https://www.abas.asia/hqhd/yonghu/fk_huitiao' 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前缀 ==================== # ==================== 订单ID前缀 ====================
ORDER_PREFIX = { ORDER_PREFIX = {
@@ -363,6 +370,9 @@ CELERY_TASK_QUEUES = {
# ==================== 定时任务时间配置 ==================== # ==================== 定时任务时间配置 ====================
# 订单超时自动结算Celery process_expired_order / check_order_expire_task总开关False=彻底关闭 # 订单超时自动结算Celery process_expired_order / check_order_expire_task总开关False=彻底关闭
ORDER_AUTO_SETTLEMENT_ENABLED = False ORDER_AUTO_SETTLEMENT_ENABLED = False
# 打手抢单大厅假单展示False=仅返回真实订单(后台假单管理仍可用;改 True 可恢复假单池)
# 假单功能总闸(紧急关闭用)。实际是否对打手生效由各俱乐部 config_json.fake_grab_order_enabled 控制。
FAKE_GRAB_ORDER_POOL_ENABLED = True
ORDER_EXPIRE_SECONDS = 48 * 60 * 60 # 48小时 = 172800秒开关关闭时不生效 ORDER_EXPIRE_SECONDS = 48 * 60 * 60 # 48小时 = 172800秒开关关闭时不生效
DAILY_TASK_HOUR = 0 DAILY_TASK_HOUR = 0
DAILY_TASK_MINUTE = 30 DAILY_TASK_MINUTE = 30
@@ -375,11 +385,11 @@ ORDER_CHECK_INTERVAL = 5
# ==================== 微信支付 V3 ==================== # ==================== 微信支付 V3 ====================
WECHAT_PAY_V3_CONFIG = { WECHAT_PAY_V3_CONFIG = {
'MCHID': app_secrets.WECHAT_PAY_V3_MCHID, 'MCHID': '1747846572',
'APPID': app_secrets.WECHAT_PAY_V3_APPID, 'APPID': 'wx0e4be86faac4a8d1',
'API_V3_KEY': app_secrets.WECHAT_PAY_V3_API_V3_KEY, 'API_V3_KEY': 'dujie4637rufjd98ijuyrgcvbvbn4789',
'PRIVATE_KEY_PATH': app_secrets.WECHAT_PAY_V3_PRIVATE_KEY_PATH, '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, 'PLATFORM_CERT_DIR': app_secrets.WECHAT_PAY_V3_PLATFORM_CERT_DIR,
'NOTIFY_URL': 'https://www.abas.asia/hqhd/yonghu/tixiancallback', 'NOTIFY_URL': 'https://www.abas.asia/hqhd/yonghu/tixiancallback',
'TRANSFER_SCENE_ID': '1005', 'TRANSFER_SCENE_ID': '1005',
@@ -390,3 +400,6 @@ WECHAT_PAY_V3_CONFIG = {
ALIPAY_SANDBOX = app_secrets.ALIPAY_SANDBOX ALIPAY_SANDBOX = app_secrets.ALIPAY_SANDBOX
ALIPAY_PROD = app_secrets.ALIPAY_PROD ALIPAY_PROD = app_secrets.ALIPAY_PROD
ALIPAY_SANDBOX_CFG = app_secrets.ALIPAY_SANDBOX_CFG ALIPAY_SANDBOX_CFG = app_secrets.ALIPAY_SANDBOX_CFG
# 仅这些 club_id 禁止「发起」支付宝支付;回调/查单仍可用(提现收款不受影响)。
# 小溪/小汐 = xx。恢复改为 frozenset(),再重启 gunicorn。
ALIPAY_DISABLED_CLUB_IDS = frozenset({'xx'})

View File

@@ -14,6 +14,8 @@ urlpatterns = [
# 订单相关路由 # 订单相关路由
path('dingdan/', include('orders.urls')), path('dingdan/', include('orders.urls')),
# 组队
path('zudui/', include('orders.urls_zudui')),
# 商品/陪玩服务相关路由 # 商品/陪玩服务相关路由
path('shangpin/', include('products.urls')), path('shangpin/', include('products.urls')),
@@ -25,6 +27,7 @@ urlpatterns = [
path('shangdian/', include('shop.urls')), path('shangdian/', include('shop.urls')),
path('dengji/', include('rank.urls')), path('dengji/', include('rank.urls')),
path('jituan/', include('jituan.urls')), path('jituan/', include('jituan.urls')),
path('gongdan/', include('gongdan.urls')),
] ]
# 重要说明: # 重要说明:

View File

@@ -4,17 +4,19 @@ from django.views.decorators.csrf import csrf_exempt
from .view import (GetRolePermissionView, from .view import (GetRolePermissionView,
ModifyRolePermissionView, AddRoleView, GetAdminRolesView, GetAdminUserListView, AddAdminUserView, ModifyRolePermissionView, AddRoleView, GetAdminRolesView, GetAdminUserListView, AddAdminUserView,
ModifyAdminUserView, KefuGetDashouListView, KefuGetDashouDetailView, KefuUpdateDashouView, KefuGetShangjiaListView, ModifyAdminUserView, KefuGetDashouListView, KefuGetDashouDetailView, KefuUpdateDashouView, KefuGetShangjiaListView,
KefuGetShangjiaDetailView, KefuUpdateShangjiaView, GetProductBaseDataView, GetProductListView, SaveProductOrderView, KefuGetShangjiaDetailView, KefuUpdateShangjiaView, KefuAddShangjiaView, GetProductBaseDataView, GetProductListView, SaveProductOrderView,
AddProductView, DeleteProductView, GetProductDetailView, UpdateProductView, UpdateMemberView, GetMemberListView, AddProductView, DeleteProductView, GetProductDetailView, UpdateProductView, UpdateZhiKanShenheView, UpdateMemberView, GetMemberListView,
AddMemberView, GetGuanliListView, GetGuanliDetailView, PromoteGuanshiToZuzhangView, ChangeInviterView, UpdateGuanliView, GetWithdrawSettingsView, AddMemberView, GetGuanliListView, GetGuanliDetailView, PromoteGuanshiToZuzhangView, ChangeInviterView, UpdateGuanliView, GuanshiChangeLeaderQuotaView, GuanshiChangeLeaderLogView, GetWithdrawSettingsView,
UpdateWithdrawSettingsView, GetZuzhangListView, GetZuzhangDetailView, UpdateZuzhangView, GetOperationLogListView, GetProductTypeZoneView, UpdateWithdrawSettingsView, GetZuzhangListView, GetZuzhangDetailView, UpdateZuzhangView, GetOperationLogListView, GetProductTypeZoneView,
ModifyProductTypeZoneView, GetRateView, ModifyRateView, PopupNoticeListAPIView, PopupNoticeModifyAPIView,ShopListView, ModifyProductTypeZoneView, GetRateView, ModifyRateView, PopupNoticeListAPIView, PopupNoticeModifyAPIView,ShopListView,
ShopModifyView, ShopPublicTypeAndAuditView, ShopListForProductView, ShopProductModifyView, ShopModifyView, ShopPublicTypeAndAuditView, ShopListForProductView, ShopProductModifyView,
ShopProductTypeMappingView, ProductListView, ProductModifyView, ShopCopyCatalogView, KefuChatPermissionsView, ShopProductTypeMappingView, ProductListView, ProductModifyView, ShopCopyCatalogView, KefuChatPermissionsView,
KefuImUserClubFilterView,
FineApplyView, PunishDashouView, FineApplyView, PunishDashouView,
FaKuanTongJiView, FaKuanLieBiaoView, FaKuanChuLiView, FaKuanChuangJianView, FaKuanPingTaiShenHeView, FaKuanTongJiView, FaKuanLieBiaoView, FaKuanChuLiView, FaKuanChuangJianView, FaKuanPingTaiShenHeView,
HqbkxxView,BkxgView,CaiwuView,CwhybkhqView, HybkjtsjView, SzxxView, CwddhqlxView, CwhqjtddsjView, 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 config.views import MiniappScriptHoutaiListView, MiniappScriptHoutaiModifyView
from rank.reward_houtai_views import RankRewardHoutaiListView, RankRewardHoutaiSaveView, RankRewardHoutaiClaimsView from rank.reward_houtai_views import RankRewardHoutaiListView, RankRewardHoutaiSaveView, RankRewardHoutaiClaimsView
@@ -30,9 +32,12 @@ urlpatterns = [
path('kefuhqdslb', KefuGetDashouListView.as_view(), name='客服获取打手数据'), path('kefuhqdslb', KefuGetDashouListView.as_view(), name='客服获取打手数据'),
path('kefuhqdsxq', KefuGetDashouDetailView.as_view(), name='客服获取打手具体信息'), path('kefuhqdsxq', KefuGetDashouDetailView.as_view(), name='客服获取打手具体信息'),
path('kefuxgds', KefuUpdateDashouView.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('hqsjgllb', KefuGetShangjiaListView.as_view(), name='后台获取商家列表'),
path('hthqsjxq', KefuGetShangjiaDetailView.as_view(), name='后台获取商家详情'), path('hthqsjxq', KefuGetShangjiaDetailView.as_view(), name='后台获取商家详情'),
path('xgsjxx', KefuUpdateShangjiaView.as_view(), name='后台修改商家数据'), path('xgsjxx', KefuUpdateShangjiaView.as_view(), name='后台修改商家数据'),
path('tjsj', KefuAddShangjiaView.as_view(), name='后台添加商家'),
path('htsphqjcsx', GetProductBaseDataView.as_view(), name='后台获取商品类型专区'), path('htsphqjcsx', GetProductBaseDataView.as_view(), name='后台获取商品类型专区'),
path('hthqsplb', GetProductListView.as_view(), name='后台获取商品数据'), path('hthqsplb', GetProductListView.as_view(), name='后台获取商品数据'),
@@ -41,6 +46,7 @@ urlpatterns = [
path('htscsp', DeleteProductView.as_view(), name='后台删除商品'), path('htscsp', DeleteProductView.as_view(), name='后台删除商品'),
path('htsphqxq', GetProductDetailView.as_view(), name='后台获取商品详情'), path('htsphqxq', GetProductDetailView.as_view(), name='后台获取商品详情'),
path('bcspxg', UpdateProductView.as_view(), name='后台更新商品数据'), path('bcspxg', UpdateProductView.as_view(), name='后台更新商品数据'),
path('htxgzhikan', UpdateZhiKanShenheView.as_view(), name='后台更新只看审核开关'),
path('hthqhylb', GetMemberListView.as_view(), name='后台获取会员列表'), path('hthqhylb', GetMemberListView.as_view(), name='后台获取会员列表'),
path('htxghyxx', UpdateMemberView.as_view(), name='后台更新会员信息'), path('htxghyxx', UpdateMemberView.as_view(), name='后台更新会员信息'),
@@ -50,6 +56,8 @@ urlpatterns = [
path('hthqgsxq', GetGuanliDetailView.as_view(), name='后台获取管事详情信息'), path('hthqgsxq', GetGuanliDetailView.as_view(), name='后台获取管事详情信息'),
path('htgssjzz', PromoteGuanshiToZuzhangView.as_view(), name='后台管事升级组长'), path('htgssjzz', PromoteGuanshiToZuzhangView.as_view(), name='后台管事升级组长'),
path('htghyqr', ChangeInviterView.as_view(), name='后台更换邀请人'), path('htghyqr', ChangeInviterView.as_view(), name='后台更换邀请人'),
path('htgs_huan_zz_quota', GuanshiChangeLeaderQuotaView.as_view(), name='后台管事换组长次数'),
path('htgs_huan_zz_logs', GuanshiChangeLeaderLogView.as_view(), name='后台管事换组长记录'),
path('htxggsxx', UpdateGuanliView.as_view(), name='后台更新管事数据'), path('htxggsxx', UpdateGuanliView.as_view(), name='后台更新管事数据'),
path('hqczrz', GetOperationLogListView.as_view(), name='后台操作日志列表'), path('hqczrz', GetOperationLogListView.as_view(), name='后台操作日志列表'),
path('hthqtxpz', GetWithdrawSettingsView.as_view(), name='后台获取提现配置'), path('hthqtxpz', GetWithdrawSettingsView.as_view(), name='后台获取提现配置'),
@@ -85,6 +93,7 @@ urlpatterns = [
path('htfzdpspsj', ShopCopyCatalogView.as_view(), name='后台复制店铺商品目录'), path('htfzdpspsj', ShopCopyCatalogView.as_view(), name='后台复制店铺商品目录'),
path('kfhqltqx', KefuChatPermissionsView.as_view(), name='后台获取客服聊天配置'), path('kfhqltqx', KefuChatPermissionsView.as_view(), name='后台获取客服聊天配置'),
path('kfimssclub', KefuImUserClubFilterView.as_view(), name='客服聊天按俱乐部过滤客户ID'),
path('htglyhqcfsltj', FaKuanTongJiView.as_view(),name='后台罚单统计图片'), # 罚款统计 path('htglyhqcfsltj', FaKuanTongJiView.as_view(),name='后台罚单统计图片'), # 罚款统计

View File

@@ -85,9 +85,39 @@ def has_perm_code(permissions, *codes):
return any(c in perms for c in codes) return any(c in perms for c in codes)
def has_club_super_perm(permissions):
"""本店超级管理 000001含系统超管 _AllPermissions"""
if isinstance(permissions, _AllPermissions):
return True
return '000001' in (permissions or [])
# 本店超管可直接使用的核心业务码(不必再绑集团权限)
CLUB_SUPER_IMPLIED_PERMS = frozenset({
'002ab', # 平台订单
'002ac', # 商家订单
'003aa', # 跨平台订单(只读/本店范围仍由 club 闸门控制)
'005bb', # 提现审核
})
def has_perm_code_or_club_super(permissions, *codes):
"""指定业务码,或本店超管 000001对核心业务码生效"""
if has_perm_code(permissions, *codes):
return True
if has_club_super_perm(permissions) and any(c in CLUB_SUPER_IMPLIED_PERMS for c in codes):
return True
return False
def has_merchant_order_permission(permissions): def has_merchant_order_permission(permissions):
"""商家/链接订单处理权限002ac 商家订单、002ab 平台订单、003aa 跨平台。""" """商家/链接订单处理权限002ac 商家订单、002ab 平台订单、003aa 跨平台;本店超管 000001 亦可"""
return has_perm_code(permissions, '002ac', '002ab', '003aa') return has_perm_code_or_club_super(permissions, '002ac', '002ab', '003aa')
def has_platform_order_permission(permissions):
"""平台订单002ab 或本店超管 000001。"""
return has_perm_code_or_club_super(permissions, '002ab')
def _legacy_backend_role_permissions(phone): def _legacy_backend_role_permissions(phone):
@@ -141,25 +171,46 @@ def verify_kefu_permission(request, username_from_frontend=None):
# ---------- 1. 必须是客服或管理员 ---------- # ---------- 1. 必须是客服或管理员 ----------
from jituan.constants import SUPER_ADMIN_PHONES from jituan.constants import SUPER_ADMIN_PHONES
from jituan.services.admin_context import is_kefu_backend_account from jituan.services.admin_context import (
is_super_phone = getattr(current_user, 'Phone', '') in SUPER_ADMIN_PHONES is_kefu_backend_account,
is_kefu_account_disabled,
is_hardcoded_super_phone,
is_system_super_admin,
)
is_super_phone = is_hardcoded_super_phone(current_user)
is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser or is_super_phone is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser or is_super_phone
# 已禁用客服:非白名单即使带 IsSuperuser 也不能继续调接口
if is_kefu_account_disabled(current_user):
return None, Response({'code': 403, 'msg': '客服账号已被禁用'}, status=403)
if not is_kefu_backend_account(current_user): if not is_kefu_backend_account(current_user):
return None, Response({'code': 403, 'msg': '身份错误,非客服账号'}, status=403) return None, Response({'code': 403, 'msg': '身份错误,非客服账号'}, status=403)
# ---------- 2. 客服扩展表存在且状态正常(管理员跳过) ---------- # ---------- 2. 客服扩展表存在且状态正常(仅白名单超管可跳过) ----------
kefu = None kefu = None
if not is_admin: if is_admin and is_super_phone:
from types import SimpleNamespace
try:
kefu = current_user.KefuProfile
except ObjectDoesNotExist:
kefu = SimpleNamespace(user=current_user)
elif is_admin:
# 非白名单「超管」仍必须客服状态正常
try:
kefu = current_user.KefuProfile
if kefu.zhuangtai != 1:
return None, Response({'code': 403, 'msg': '客服账号已被禁用'}, status=403)
except ObjectDoesNotExist:
from types import SimpleNamespace
kefu = SimpleNamespace(user=current_user)
else:
try: try:
kefu = current_user.KefuProfile kefu = current_user.KefuProfile
if kefu.zhuangtai != 1: if kefu.zhuangtai != 1:
return None, Response({'code': 403, 'msg': '客服账号已被禁用'}, status=403) return None, Response({'code': 403, 'msg': '客服账号已被禁用'}, status=403)
except ObjectDoesNotExist: except ObjectDoesNotExist:
return None, Response({'code': 403, 'msg': '客服账号不存在'}, status=403) return None, Response({'code': 403, 'msg': '客服账号不存在'}, status=403)
else:
# 管理员无需客服扩展表,用占位对象以通过 kefu_obj is None 检查
from types import SimpleNamespace
kefu = SimpleNamespace(user=current_user)
# ---------- 3. 越权检测只记录前端篡改username的情况 ---------- # ---------- 3. 越权检测只记录前端篡改username的情况 ----------
if username_from_frontend and current_user.Phone != username_from_frontend: if username_from_frontend and current_user.Phone != username_from_frontend:

View File

@@ -35,11 +35,14 @@ from .views.roles import (
from .views.kefu import ( from .views.kefu import (
KefuGetDashouListView, KefuGetDashouDetailView, KefuUpdateDashouView, KefuGetDashouListView, KefuGetDashouDetailView, KefuUpdateDashouView,
KefuGetShangjiaListView, KefuGetShangjiaDetailView, KefuUpdateShangjiaView, KefuGetShangjiaListView, KefuGetShangjiaDetailView, KefuUpdateShangjiaView,
KefuChatPermissionsView, KefuAddShangjiaView,
KefuChatPermissionsView, KefuImUserClubFilterView,
DashouGiftHuiyuanSaveView, DashouGiftHuiyuanRemoveView,
) )
from .views.products import ( from .views.products import (
GetProductBaseDataView, GetProductListView, SaveProductOrderView, GetProductBaseDataView, GetProductListView, SaveProductOrderView,
AddProductView, DeleteProductView, GetProductDetailView, UpdateProductView, AddProductView, DeleteProductView, GetProductDetailView, UpdateProductView,
UpdateZhiKanShenheView,
) )
from .views.members import ( from .views.members import (
GetMemberListView, UpdateMemberView, AddMemberView, GetMemberListView, UpdateMemberView, AddMemberView,
@@ -48,7 +51,7 @@ from .views.members import (
) )
from .views.guanli import ( from .views.guanli import (
GetGuanliListView, GetGuanliDetailView, PromoteGuanshiToZuzhangView, GetGuanliListView, GetGuanliDetailView, PromoteGuanshiToZuzhangView,
UpdateGuanliView, UpdateGuanliView, GuanshiChangeLeaderQuotaView, GuanshiChangeLeaderLogView,
) )
from .views.withdraw_settings import ( from .views.withdraw_settings import (
GetWithdrawSettingsView, UpdateWithdrawSettingsView, GetWithdrawSettingsView, UpdateWithdrawSettingsView,
@@ -96,18 +99,21 @@ __all__ = [
'GetAdminRolesView', 'GetAdminUserListView', 'ModifyAdminUserView', 'AddAdminUserView', 'GetAdminRolesView', 'GetAdminUserListView', 'ModifyAdminUserView', 'AddAdminUserView',
# kefu # kefu
'KefuGetDashouListView', 'KefuGetDashouDetailView', 'KefuUpdateDashouView', 'KefuGetDashouListView', 'KefuGetDashouDetailView', 'KefuUpdateDashouView',
'DashouGiftHuiyuanSaveView', 'DashouGiftHuiyuanRemoveView',
'KefuGetShangjiaListView', 'KefuGetShangjiaDetailView', 'KefuUpdateShangjiaView', 'KefuGetShangjiaListView', 'KefuGetShangjiaDetailView', 'KefuUpdateShangjiaView',
'KefuChatPermissionsView', 'KefuAddShangjiaView',
'KefuChatPermissionsView', 'KefuImUserClubFilterView',
# products # products
'GetProductBaseDataView', 'GetProductListView', 'SaveProductOrderView', 'GetProductBaseDataView', 'GetProductListView', 'SaveProductOrderView',
'AddProductView', 'DeleteProductView', 'GetProductDetailView', 'UpdateProductView', 'AddProductView', 'DeleteProductView', 'GetProductDetailView', 'UpdateProductView',
'UpdateZhiKanShenheView',
# members # members
'GetMemberListView', 'UpdateMemberView', 'AddMemberView', 'GetMemberListView', 'UpdateMemberView', 'AddMemberView',
'ClubMemberCatalogView', 'ClubMemberEnableView', 'ClubMemberDisableView', 'ClubMemberCatalogView', 'ClubMemberEnableView', 'ClubMemberDisableView',
'MemberCardImageUploadView', 'MemberRechargeRecordsView', 'MemberBundleCreateView', 'MemberCardImageUploadView', 'MemberRechargeRecordsView', 'MemberBundleCreateView',
# guanli # guanli
'GetGuanliListView', 'GetGuanliDetailView', 'PromoteGuanshiToZuzhangView', 'GetGuanliListView', 'GetGuanliDetailView', 'PromoteGuanshiToZuzhangView',
'UpdateGuanliView', 'UpdateGuanliView', 'GuanshiChangeLeaderQuotaView', 'GuanshiChangeLeaderLogView',
# withdraw_settings # withdraw_settings
'GetWithdrawSettingsView', 'UpdateWithdrawSettingsView', 'GetWithdrawSettingsView', 'UpdateWithdrawSettingsView',
# zuzhang # zuzhang

View File

@@ -387,6 +387,19 @@ class GetGuanliDetailView(APIView):
# 注意:首次分红定制只存储有定制记录的会员,没有定制的会员使用默认值(前端通过会员表展示) # 注意:首次分红定制只存储有定制记录的会员,没有定制的会员使用默认值(前端通过会员表展示)
# 额外次数分红按次数返回 # 额外次数分红按次数返回
from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope
from jituan.constants import DATA_SCOPE_ALL
from jituan.services.club_user import get_user_club_id
from users.services.change_leader import get_or_create_quota, quota_payload, list_change_logs
if resolve_club_scope(request) == DATA_SCOPE_ALL:
quota_club = get_user_club_id(user)
else:
quota_club = resolve_club_id_from_request(request)
quota = get_or_create_quota(quota_club, yonghuid)
change_leader_quota = quota_payload(quota)
change_leader_logs = list_change_logs(quota_club, yonghuid, page=1, page_size=20)
return Response({ return Response({
'code': 0, 'code': 0,
'data': { 'data': {
@@ -398,6 +411,8 @@ class GetGuanliDetailView(APIView):
'extra_dividend_map': extra_map, 'extra_dividend_map': extra_map,
'all_members': all_members, 'all_members': all_members,
'is_zuzhang': is_zuzhang, 'is_zuzhang': is_zuzhang,
'change_leader_quota': change_leader_quota,
'change_leader_logs': change_leader_logs,
} }
}) })
@@ -636,8 +651,10 @@ class UpdateGuanliView(APIView):
if new_yue > old_yue: if new_yue > old_yue:
if '4400c' not in permissions: if '4400c' not in permissions:
return Response({'code': 403, 'msg': '无权限增加余额需要4400c'}) return Response({'code': 403, 'msg': '无权限增加余额需要4400c'})
from jituan.services.group_finance_gate import deny_group_finance_exec from jituan.services.group_finance_gate import deny_balance_add_exec
deny = deny_group_finance_exec(request.user, request) deny = deny_balance_add_exec(
request, permissions=permissions, target_user=user,
)
if deny: if deny:
return deny return deny
action_desc = f'增加{new_yue - old_yue}' action_desc = f'增加{new_yue - old_yue}'
@@ -650,6 +667,8 @@ class UpdateGuanliView(APIView):
if new_yue != old_yue: if new_yue != old_yue:
guanshi.yue = new_yue guanshi.yue = new_yue
guanshi.save() guanshi.save()
from jituan.services.club_context import resolve_club_id_from_request
club_id = resolve_club_id_from_request(request) or ''
write_xiugai_log( write_xiugai_log(
yonghuid=yonghuid, yonghuid=yonghuid,
xiugaiid=operator, xiugaiid=operator,
@@ -658,7 +677,7 @@ class UpdateGuanliView(APIView):
guanshiyueq=old_yue, guanshiyueq=old_yue,
qitashuoming=( qitashuoming=(
f'【后台】修改管事可提现余额:{old_yue}元 → {new_yue}元({action_desc}' f'【后台】修改管事可提现余额:{old_yue}元 → {new_yue}元({action_desc}'
f'管事ID={yonghuid},操作人={operator}' f'管事ID={yonghuid}俱乐部={club_id}操作人={operator}'
), ),
) )
@@ -851,7 +870,117 @@ class UpdateGuanliView(APIView):
return Response({'code': 0, 'msg': '修改成功'}) return Response({'code': 0, 'msg': '修改成功'})
def _admin_quota_club_id(request, user):
from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope
from jituan.constants import DATA_SCOPE_ALL
from jituan.services.club_user import get_user_club_id
if resolve_club_scope(request) == DATA_SCOPE_ALL:
return get_user_club_id(user)
return resolve_club_id_from_request(request)
class GuanshiChangeLeaderQuotaView(APIView):
"""
后台设置/追加管事换组长次数
POST /houtai/htgs_huan_zz_quota
参数username, yonghuid,
action=set|add,
total_quotaset或 add_countadd
权限4400a
"""
permission_classes = []
parser_classes = [JSONParser]
def post(self, request):
username = request.data.get('username', '').strip()
yonghuid = request.data.get('yonghuid', '').strip()
action = (request.data.get('action') or 'add').strip().lower()
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 '4400a' not in permissions and '000001' not in permissions:
return Response({'code': 403, 'msg': '无权限修改换组长次数需要4400a'})
try:
user = User.query.select_related('GuanshiProfile').get(UserUID=yonghuid)
_ = user.GuanshiProfile
except User.DoesNotExist:
return Response({'code': 404, 'msg': '管事不存在'})
except UserGuanshi.DoesNotExist:
return Response({'code': 404, 'msg': '用户不是管事'})
denied = forbid_if_user_out_of_scope(request, user)
if denied:
return denied
from users.services.change_leader import set_total_quota, add_quota
club_id = _admin_quota_club_id(request, user)
if action == 'set':
result = set_total_quota(club_id, yonghuid, request.data.get('total_quota'))
else:
result = add_quota(
club_id,
yonghuid,
request.data.get('add_count') or request.data.get('add'),
)
if not result.get('ok'):
return Response({'code': result.get('code', 400), 'msg': result.get('msg', '失败')})
return Response({'code': 0, 'msg': result.get('msg', 'ok'), 'data': result.get('data')})
class GuanshiChangeLeaderLogView(APIView):
"""
后台查询管事换组长记录
POST /houtai/htgs_huan_zz_logs
参数username, yonghuid, page, page_size
权限4400a-f 任一
"""
permission_classes = []
parser_classes = [JSONParser]
def post(self, request):
username = request.data.get('username', '').strip()
yonghuid = request.data.get('yonghuid', '').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
required_perms = {'4400a', '4400b', '4400c', '4400d', '4400e', '4400f', '000001'}
if not any(p in permissions for p in required_perms):
return Response({'code': 403, 'msg': '您没有权限查看换组长记录'})
try:
user = User.query.select_related('GuanshiProfile').get(UserUID=yonghuid)
_ = user.GuanshiProfile
except User.DoesNotExist:
return Response({'code': 404, 'msg': '管事不存在'})
except UserGuanshi.DoesNotExist:
return Response({'code': 404, 'msg': '用户不是管事'})
denied = forbid_if_user_out_of_scope(request, user)
if denied:
return denied
from users.services.change_leader import list_change_logs
club_id = _admin_quota_club_id(request, user)
data = list_change_logs(
club_id,
yonghuid,
page=request.data.get('page', 1),
page_size=request.data.get('page_size', 20),
)
return Response({'code': 0, 'data': data})

View File

@@ -3,6 +3,7 @@ import threading
import traceback import traceback
import uuid import uuid
import hashlib import hashlib
import re
import xmltodict import xmltodict
import time import time
import logging import logging
@@ -52,6 +53,7 @@ from ..utils import (
verify_kefu_permission, verify_kefu_permission,
PERMISSION_TO_SHENFEN, PERMISSION_TO_SHENFEN,
SHENFEN_PROFILE_MAP, SHENFEN_PROFILE_MAP,
has_perm_code,
has_fadan_view_permission, has_fadan_view_permission,
has_merchant_order_permission, has_merchant_order_permission,
check_fadan_permission, check_fadan_permission,
@@ -74,9 +76,11 @@ from backend.models import WithdrawalDailyStats
## users ## users
from users.models import ( from users.models import (
UserGuanshi, UserBoss, UserZuzhang, UserGuanshi, UserBoss, UserZuzhang,
UserKefu, UserDashou, Xiugaijilu, UserShangjia, UserShenheguan UserKefu, UserDashou, Xiugaijilu, UserShangjia, UserShenheguan,
DashouZengsongHuiyuan,
) )
from users.business_models import User from users.business_models import User
from jituan.constants import CLUB_ID_DEFAULT
## orders ## orders
from orders.models import ( from orders.models import (
@@ -121,90 +125,232 @@ class KefuGetDashouListView(APIView):
""" """
客服获取打手列表接口(仅打手类型) 客服获取打手列表接口(仅打手类型)
需要拥有以下任一权限001aa,001bb,001cc,001dd,001ee,001ff,001gg 需要拥有以下任一权限001aa,001bb,001cc,001dd,001ee,001ff,001gg
请求POST /yonghu/kefuhqdslb 请求POST /jituan/houtai/kefuhqdslb
参数: 参数:
username: 客服手机号(用于权限校验) username: 客服手机号(用于权限校验)
keyword: 搜索关键词可选搜索用户ID或昵称 keyword: 用户ID / 昵称 / 手机号(可选
page: 页码从1开始 zhanghaozhuangtai: 1正常 / 0封禁可选
page_size: 每页数量 has_member: 1有有效会员 / 0无可选
返回: huiyuan_id: 限定有效会员含该类型(可选)
code: 0 yajin_op + yajin_value: gte/lte/eq + 数值(可选)
data: { zaixianzhuangtai / zhuangtai: 在线 / 空闲接单(可选)
list: [打手对象], page / page_size: 分页
has_more: bool,
total: int, // 总打手数
valid_dashou_count: int // 有效打手数(有有效会员)
}
""" """
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
parser_classes = [JSONParser] parser_classes = [JSONParser]
def post(self, request): def post(self, request):
# 1. 获取参数(前端字段为 username username = (request.data.get('username') or '').strip()
username = request.data.get('username', '').strip() keyword = (request.data.get('keyword') or '').strip()
keyword = request.data.get('keyword', '').strip() huiyuan_id = (request.data.get('huiyuan_id') or '').strip()
yajin_op = (request.data.get('yajin_op') or '').strip().lower()
yajin_value_raw = request.data.get('yajin_value', None)
try: try:
page = int(request.data.get('page', 1)) page = int(request.data.get('page', 1))
page_size = int(request.data.get('page_size', 20)) page_size = int(request.data.get('page_size', 20))
except ValueError: except (TypeError, ValueError):
return Response({'code': 400, 'msg': '分页参数错误'}) return Response({'code': 400, 'msg': '分页参数错误'})
if not username: if not username:
return Response({'code': 401, 'msg': '认证失败'}) return Response({'code': 401, 'msg': '认证失败'})
# 2. 公共权限校验(验证客服身份,获取权限列表)
kefu, permissions = verify_kefu_permission(request, username) kefu, permissions = verify_kefu_permission(request, username)
if kefu is None: if kefu is None:
return permissions # 直接返回错误响应 return permissions
# 3. 检查是否拥有打手管理相关权限(至少一个) required_perms = ('001aa', '001bb', '001cc', '001dd', '001ee', '001ff', '001gg')
required_perms = {'001aa', '001bb', '001cc', '001dd', '001ee', '001ff', '001gg'} if not has_perm_code(permissions, *required_perms):
if not any(p in permissions for p in required_perms):
return Response({'code': 403, 'msg': '您没有权限查看打手列表'}) return Response({'code': 403, 'msg': '您没有权限查看打手列表'})
# 4. 构建打手查询集(仅打手类型)
dashou_qs = filter_user_related_by_club( dashou_qs = filter_user_related_by_club(
UserDashou.query.select_related('user').all(), request, UserDashou.query.select_related('user').all(), request,
) )
# 关键词筛选用户ID或昵称
if keyword: if keyword:
dashou_qs = dashou_qs.filter( dashou_qs = dashou_qs.filter(
Q(user__UserUID__icontains=keyword) | Q(user__UserUID__icontains=keyword) |
Q(nicheng__icontains=keyword) Q(nicheng__icontains=keyword) |
Q(user__Phone__icontains=keyword)
) )
# 5. 统计打手总数 zhanghao_raw = request.data.get('zhanghaozhuangtai', None)
total_dashou = dashou_qs.count() if zhanghao_raw is not None and zhanghao_raw != '':
try:
zhanghao_val = int(zhanghao_raw)
except (TypeError, ValueError):
return Response({'code': 400, 'msg': '账号状态格式错误'})
if zhanghao_val not in (0, 1):
return Response({'code': 400, 'msg': '账号状态值无效'})
dashou_qs = dashou_qs.filter(zhanghaozhuangtai=zhanghao_val)
zaixian_raw = request.data.get('zaixianzhuangtai', None)
if zaixian_raw is not None and zaixian_raw != '':
try:
zaixian_val = int(zaixian_raw)
except (TypeError, ValueError):
return Response({'code': 400, 'msg': '在线状态格式错误'})
if zaixian_val not in (0, 1):
return Response({'code': 400, 'msg': '在线状态值无效'})
dashou_qs = dashou_qs.filter(zaixianzhuangtai=zaixian_val)
zhuangtai_raw = request.data.get('zhuangtai', None)
if zhuangtai_raw is not None and zhuangtai_raw != '':
try:
zhuangtai_val = int(zhuangtai_raw)
except (TypeError, ValueError):
return Response({'code': 400, 'msg': '接单状态格式错误'})
if zhuangtai_val not in (0, 1):
return Response({'code': 400, 'msg': '接单状态值无效'})
dashou_qs = dashou_qs.filter(zhuangtai=zhuangtai_val)
# 6. 统计有效打手数(有会员且未过期)
dashou_user_ids = dashou_qs.values_list('user__UserUID', flat=True)
now = timezone.now() now = timezone.now()
hy_qs = filter_queryset_by_club(Huiyuangoumai.query.all(), request) # 有效会员:与打手详情 / clumber 一致——daoqi_time > now未过期
valid_dashou_count = hy_qs.filter( # 含购买表 Huiyuangoumai + 赠送表 DashouZengsongHuiyuan。
yonghu_id__in=dashou_user_ids, # 必须用 Exists(OuterRef),禁止 FluentQuery.values() 丢进 __in
huiyuan_zhuangtai=1, # values() 返回的是字典迭代器UserUID__in=[{'yonghu_id':...}] 永远匹配不到。
bought_any = Huiyuangoumai.objects.filter(
yonghu_id=OuterRef('user__UserUID'),
daoqi_time__gt=now, daoqi_time__gt=now,
).values('yonghu_id').distinct().count() )
gift_any = DashouZengsongHuiyuan.objects.filter(
yonghu_id=OuterRef('user__UserUID'),
daoqi_time__gt=now,
)
dashou_qs = dashou_qs.annotate(
_any_hy=Exists(bought_any),
_any_gift=Exists(gift_any),
)
if huiyuan_id:
bought_type = Huiyuangoumai.objects.filter(
yonghu_id=OuterRef('user__UserUID'),
daoqi_time__gt=now,
huiyuan_id=huiyuan_id,
)
gift_type = DashouZengsongHuiyuan.objects.filter(
yonghu_id=OuterRef('user__UserUID'),
daoqi_time__gt=now,
huiyuan_id=huiyuan_id,
)
dashou_qs = dashou_qs.annotate(
_type_hy=Exists(bought_type),
_type_gift=Exists(gift_type),
)
has_member_raw = request.data.get('has_member', None)
if has_member_raw is not None and has_member_raw != '':
try:
has_member_val = int(has_member_raw)
except (TypeError, ValueError):
return Response({'code': 400, 'msg': '会员筛选格式错误'})
if has_member_val == 1:
if huiyuan_id:
dashou_qs = dashou_qs.filter(Q(_type_hy=True) | Q(_type_gift=True))
else:
dashou_qs = dashou_qs.filter(Q(_any_hy=True) | Q(_any_gift=True))
elif has_member_val == 0:
dashou_qs = dashou_qs.filter(_any_hy=False, _any_gift=False)
else:
return Response({'code': 400, 'msg': '会员筛选值无效'})
elif huiyuan_id:
dashou_qs = dashou_qs.filter(Q(_type_hy=True) | Q(_type_gift=True))
# 押金≥阈值人数:在应用押金比较筛之前统计(相对其它已选条件)
yajin_gte_count = None
yajin_value = None
if yajin_value_raw is not None and yajin_value_raw != '':
try:
yajin_value = Decimal(str(yajin_value_raw))
except Exception:
return Response({'code': 400, 'msg': '押金数值格式错误'})
yajin_gte_count = dashou_qs.filter(yajin__gte=yajin_value).count()
op = yajin_op if yajin_op in ('gte', 'lte', 'eq') else 'gte'
if op == 'gte':
dashou_qs = dashou_qs.filter(yajin__gte=yajin_value)
elif op == 'lte':
dashou_qs = dashou_qs.filter(yajin__lte=yajin_value)
else:
dashou_qs = dashou_qs.filter(yajin=yajin_value)
total_dashou = dashou_qs.count()
_status_agg = dashou_qs.aggregate(
normal_count=Count('id', filter=Q(zhanghaozhuangtai=1)),
banned_count=Count('id', filter=Q(zhanghaozhuangtai=0)),
)
valid_dashou_count = dashou_qs.filter(Q(_any_hy=True) | Q(_any_gift=True)).count()
no_member_count = max(total_dashou - valid_dashou_count, 0)
# 7. 分页
offset = (page - 1) * page_size offset = (page - 1) * page_size
dashou_list = dashou_qs.order_by('-CreateTime')[offset:offset + page_size] dashou_list = list(dashou_qs.order_by('-CreateTime')[offset:offset + page_size])
page_uids = [d.user.UserUID for d in dashou_list if getattr(d, 'user', None)]
hy_by_uid = {}
if page_uids:
name_ids = set()
bought_rows = list(Huiyuangoumai.objects.filter(
yonghu_id__in=page_uids,
daoqi_time__gt=now,
).values('yonghu_id', 'huiyuan_id', 'jieshao'))
gift_rows = list(DashouZengsongHuiyuan.objects.filter(
yonghu_id__in=page_uids,
daoqi_time__gt=now,
).values('yonghu_id', 'huiyuan_id'))
for row in bought_rows:
name_ids.add(row['huiyuan_id'])
for row in gift_rows:
name_ids.add(row['huiyuan_id'])
name_map = {
h['huiyuan_id']: (h['jieshao'] or '')
for h in Huiyuan.objects.filter(huiyuan_id__in=name_ids).values('huiyuan_id', 'jieshao')
}
seen_pair = set()
for row in bought_rows:
uid = row['yonghu_id']
hid = row['huiyuan_id']
key = (uid, hid)
if key in seen_pair:
continue
seen_pair.add(key)
name = (row.get('jieshao') or '').strip() or name_map.get(hid, '') or hid
hy_by_uid.setdefault(uid, []).append({
'huiyuan_id': hid,
'name': name,
})
for row in gift_rows:
uid = row['yonghu_id']
hid = row['huiyuan_id']
key = (uid, hid)
if key in seen_pair:
continue
seen_pair.add(key)
name = name_map.get(hid, '') or hid
hy_by_uid.setdefault(uid, []).append({
'huiyuan_id': hid,
'name': name,
'is_gift': True,
})
# 8. 构建返回数据
result = [] result = []
for dashou in dashou_list: for dashou in dashou_list:
user = dashou.user user = dashou.user
uid = user.UserUID
result.append({ result.append({
'yonghuid': user.UserUID, 'yonghuid': uid,
'avatar': user.Avatar or '', 'avatar': user.Avatar or '',
'phone': user.Phone or '',
'club_id': getattr(user, 'ClubID', None) or '',
'zaixianzhuangtai': dashou.zaixianzhuangtai, 'zaixianzhuangtai': dashou.zaixianzhuangtai,
'zhanghaozhuangtai': dashou.zhanghaozhuangtai, 'zhanghaozhuangtai': dashou.zhanghaozhuangtai,
'nicheng': dashou.nicheng or '', 'nicheng': dashou.nicheng or '',
'zhuangtai': dashou.zhuangtai, 'zhuangtai': dashou.zhuangtai,
'yajin': str(dashou.yajin if dashou.yajin is not None else '0'),
'huiyuan_list': hy_by_uid.get(uid, []),
}) })
has_more = (offset + len(dashou_list)) < total_dashou has_more = (offset + len(dashou_list)) < total_dashou
huiyuan_options = list(
Huiyuan.query.all().order_by('huiyuan_id').values('huiyuan_id', 'jieshao')
)
meta = list_response_meta(request) meta = list_response_meta(request)
return Response({ return Response({
@@ -216,6 +362,11 @@ class KefuGetDashouListView(APIView):
'has_more': has_more, 'has_more': has_more,
'total': total_dashou, 'total': total_dashou,
'valid_dashou_count': valid_dashou_count, 'valid_dashou_count': valid_dashou_count,
'no_member_count': no_member_count,
'normal_count': _status_agg['normal_count'] or 0,
'banned_count': _status_agg['banned_count'] or 0,
'yajin_gte_count': yajin_gte_count,
'huiyuan_options': huiyuan_options,
} }
}) })
@@ -304,6 +455,7 @@ class KefuGetDashouDetailView(APIView):
'data': { 'data': {
'user_info': user_info, 'user_info': user_info,
'member_list': member_list, 'member_list': member_list,
'gift_member_list': [],
'all_members': all_members, 'all_members': all_members,
'permissions': permissions, 'permissions': permissions,
} }
@@ -316,6 +468,7 @@ class KefuGetDashouDetailView(APIView):
'zaixianzhuangtai': dashou.zaixianzhuangtai, 'zaixianzhuangtai': dashou.zaixianzhuangtai,
'zhanghaozhuangtai': dashou.zhanghaozhuangtai, 'zhanghaozhuangtai': dashou.zhanghaozhuangtai,
'zhuangtai': dashou.zhuangtai, 'zhuangtai': dashou.zhuangtai,
'fengjin_yuanyin': getattr(dashou, 'fengjin_yuanyin', '') or '',
'nicheng': dashou.nicheng or '', 'nicheng': dashou.nicheng or '',
'chenghao': dashou.chenghao or '', 'chenghao': dashou.chenghao or '',
'dianhua': dashou.dianhua or '', 'dianhua': dashou.dianhua or '',
@@ -329,6 +482,9 @@ class KefuGetDashouDetailView(APIView):
'tuikuanliang': dashou.tuikuanliang, 'tuikuanliang': dashou.tuikuanliang,
'jinrijiedan': dashou.jinrijiedan, 'jinrijiedan': dashou.jinrijiedan,
'yue': float(dashou.yue) if dashou.yue else 0.00, 'yue': float(dashou.yue) if dashou.yue else 0.00,
'dongjie_yue': float(getattr(dashou, 'dongjie_yue', 0) or 0),
'risk_fine_rate': float(getattr(dashou, 'risk_fine_rate', 0) or 0),
'risk_complaint_rate': float(getattr(dashou, 'risk_complaint_rate', 0) or 0),
'zonge': float(dashou.zonge) if dashou.zonge else 0.00, 'zonge': float(dashou.zonge) if dashou.zonge else 0.00,
'jifen': dashou.jifen, 'jifen': dashou.jifen,
'yajin': float(dashou.yajin) if dashou.yajin else 0.00, 'yajin': float(dashou.yajin) if dashou.yajin else 0.00,
@@ -369,11 +525,15 @@ class KefuGetDashouDetailView(APIView):
# 7. 查询所有会员类型(用于前端添加会员) # 7. 查询所有会员类型(用于前端添加会员)
all_members = list(Huiyuan.query.all().values('huiyuan_id', 'jieshao')) 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({ return Response({
'code': 0, 'code': 0,
'data': { 'data': {
'user_info': user_info, 'user_info': user_info,
'member_list': member_list, 'member_list': member_list,
'gift_member_list': gift_member_list,
'all_members': all_members, 'all_members': all_members,
'permissions': permissions, # 返回当前客服的权限列表 'permissions': permissions, # 返回当前客服的权限列表
} }
@@ -459,14 +619,18 @@ class KefuUpdateDashouView(APIView):
elif caozuo == 'jia_yue': elif caozuo == 'jia_yue':
if '001bb' not in permissions: if '001bb' not in permissions:
return Response({'code': 403, 'msg': '无权限加打手余额'}) return Response({'code': 403, 'msg': '无权限加打手余额'})
from jituan.services.group_finance_gate import deny_group_finance_exec from jituan.services.group_finance_gate import deny_balance_add_exec
deny = deny_group_finance_exec(request.user, request) from jituan.services.club_context import resolve_club_id_from_request
deny = deny_balance_add_exec(
request, permissions=permissions, target_user=dashou_user,
)
if deny: if deny:
return deny return deny
amount = Decimal(str(value)) amount = Decimal(str(value))
dashou_profile.yue += amount dashou_profile.yue += amount
dashou_profile.save() dashou_profile.save()
after = dashou_profile.yue after = dashou_profile.yue
club_id = resolve_club_id_from_request(request) or ''
write_xiugai_log( write_xiugai_log(
yonghuid=dashou_id, yonghuid=dashou_id,
xiugaiid=kefu.user.Phone, xiugaiid=kefu.user.Phone,
@@ -475,7 +639,8 @@ class KefuUpdateDashouView(APIView):
xiugaitijiaoq=before['yue'], xiugaitijiaoq=before['yue'],
qitashuoming=( qitashuoming=(
f'【后台】增加打手可提现余额:{before["yue"]}元 → {after}元,' f'【后台】增加打手可提现余额:{before["yue"]}元 → {after}元,'
f'增加{amount}打手ID={dashou_id}操作人={kefu.user.Phone}' f'增加{amount}打手ID={dashou_id}俱乐部={club_id}'
f'操作人={kefu.user.Phone}'
), ),
) )
return Response({'code': 0, 'msg': '加余额成功', 'data': {'new_yue': str(after)}}) return Response({'code': 0, 'msg': '加余额成功', 'data': {'new_yue': str(after)}})
@@ -506,14 +671,18 @@ class KefuUpdateDashouView(APIView):
elif caozuo == 'jia_yajin': elif caozuo == 'jia_yajin':
if '001bb' not in permissions: if '001bb' not in permissions:
return Response({'code': 403, 'msg': '无权限加打手押金'}) return Response({'code': 403, 'msg': '无权限加打手押金'})
from jituan.services.group_finance_gate import deny_group_finance_exec from jituan.services.group_finance_gate import deny_balance_add_exec
deny = deny_group_finance_exec(request.user, request) from jituan.services.club_context import resolve_club_id_from_request
deny = deny_balance_add_exec(
request, permissions=permissions, target_user=dashou_user,
)
if deny: if deny:
return deny return deny
amount = Decimal(str(value)) amount = Decimal(str(value))
dashou_profile.yajin += amount dashou_profile.yajin += amount
dashou_profile.save() dashou_profile.save()
after = dashou_profile.yajin after = dashou_profile.yajin
club_id = resolve_club_id_from_request(request) or ''
write_xiugai_log( write_xiugai_log(
yonghuid=dashou_id, yonghuid=dashou_id,
xiugaiid=kefu.user.Phone, xiugaiid=kefu.user.Phone,
@@ -522,7 +691,8 @@ class KefuUpdateDashouView(APIView):
yyajin=before['yajin'], yyajin=before['yajin'],
qitashuoming=( qitashuoming=(
f'【后台】增加打手押金:{before["yajin"]}元 → {after}元,' f'【后台】增加打手押金:{before["yajin"]}元 → {after}元,'
f'增加{amount}打手ID={dashou_id}操作人={kefu.user.Phone}' f'增加{amount}打手ID={dashou_id}俱乐部={club_id}'
f'操作人={kefu.user.Phone}'
), ),
) )
return Response({'code': 0, 'msg': '加押金成功', 'data': {'new_yajin': str(after)}}) return Response({'code': 0, 'msg': '加押金成功', 'data': {'new_yajin': str(after)}})
@@ -577,20 +747,33 @@ class KefuUpdateDashouView(APIView):
new_status = int(value) new_status = int(value)
if new_status not in [0, 1]: if new_status not in [0, 1]:
return Response({'code': 400, 'msg': '状态值无效0=封禁,1=正常)'}) return Response({'code': 400, 'msg': '状态值无效0=封禁,1=正常)'})
yuanyin = (request.data.get('yuanyin') or request.data.get('fengjin_yuanyin') or '').strip()
dashou_profile.zhanghaozhuangtai = new_status dashou_profile.zhanghaozhuangtai = new_status
dashou_profile.save() if new_status == 0:
dashou_profile.fengjin_yuanyin = yuanyin[:200]
else:
dashou_profile.fengjin_yuanyin = ''
dashou_profile.save(update_fields=['zhanghaozhuangtai', 'fengjin_yuanyin'])
status_label = '正常' if new_status == 1 else '封禁' status_label = '正常' if new_status == 1 else '封禁'
old_label = '正常' if before['zhanghaozhuangtai'] == 1 else '封禁' old_label = '正常' if before['zhanghaozhuangtai'] == 1 else '封禁'
reason_part = f',原因={yuanyin}' if (new_status == 0 and yuanyin) else ''
write_xiugai_log( write_xiugai_log(
yonghuid=dashou_id, yonghuid=dashou_id,
xiugaiid=kefu.user.Phone, xiugaiid=kefu.user.Phone,
leixing=XIUGAI_LEIXING_DASHOU, leixing=XIUGAI_LEIXING_DASHOU,
qitashuoming=( qitashuoming=(
f'【后台】修改打手账号状态:{old_label}{status_label}' f'【后台】修改打手账号状态:{old_label}{status_label}{reason_part}'
f'打手ID={dashou_id},操作人={kefu.user.Phone}' f'打手ID={dashou_id},操作人={kefu.user.Phone}'
), ),
) )
return Response({'code': 0, 'msg': '修改账号状态成功', 'data': {'new_zhanghaozhuangtai': new_status}}) return Response({
'code': 0,
'msg': '修改账号状态成功',
'data': {
'new_zhanghaozhuangtai': new_status,
'fengjin_yuanyin': dashou_profile.fengjin_yuanyin or '',
},
})
elif caozuo == 'gai_zaixianzhuangtai': elif caozuo == 'gai_zaixianzhuangtai':
if '001ee' not in permissions: if '001ee' not in permissions:
@@ -934,6 +1117,7 @@ class KefuGetShangjiaDetailView(APIView):
'jinyuedingdan': shangjia.jinyuedingdan, 'jinyuedingdan': shangjia.jinyuedingdan,
'jinyueliushui': str(shangjia.jinyueliushui), 'jinyueliushui': str(shangjia.jinyueliushui),
'is_youzhi_shangjia': bool(shangjia.is_youzhi_shangjia), 'is_youzhi_shangjia': bool(shangjia.is_youzhi_shangjia),
'is_teyue_shangjia': bool(getattr(shangjia, 'is_teyue_shangjia', False)),
'CreateTime': shangjia.CreateTime.isoformat() if shangjia.CreateTime else None, 'CreateTime': shangjia.CreateTime.isoformat() if shangjia.CreateTime else None,
'create_time': user.UserCreateTime.strftime('%Y-%m-%d %H:%M:%S') if getattr(user, 'UserCreateTime', None) else '', 'create_time': user.UserCreateTime.strftime('%Y-%m-%d %H:%M:%S') if getattr(user, 'UserCreateTime', None) else '',
} }
@@ -967,7 +1151,7 @@ class KefuUpdateShangjiaView(APIView):
return Response({'code': 401, 'msg': '缺少username'}) return Response({'code': 401, 'msg': '缺少username'})
if not yonghuid: if not yonghuid:
return Response({'code': 400, 'msg': '缺少商家ID'}) return Response({'code': 400, 'msg': '缺少商家ID'})
if action not in ('add_balance', 'sub_balance', 'ban', 'unban', 'set_youzhi'): if action not in ('add_balance', 'sub_balance', 'ban', 'unban', 'set_youzhi', 'set_teyue'):
return Response({'code': 400, 'msg': 'action参数无效'}) return Response({'code': 400, 'msg': 'action参数无效'})
# 权限校验 # 权限校验
@@ -976,7 +1160,7 @@ class KefuUpdateShangjiaView(APIView):
return permissions return permissions
# 操作权限校验 # 操作权限校验
if action in ('add_balance', 'ban', 'unban', 'set_youzhi') and '1100a' not in permissions: if action in ('add_balance', 'ban', 'unban', 'set_youzhi', 'set_teyue') and '1100a' not in permissions:
return Response({'code': 403, 'msg': '无权限执行此操作需要1100a'}) return Response({'code': 403, 'msg': '无权限执行此操作需要1100a'})
if action == 'sub_balance' and '1100b' not in permissions: if action == 'sub_balance' and '1100b' not in permissions:
return Response({'code': 403, 'msg': '无权限减少商家余额需要1100b'}) return Response({'code': 403, 'msg': '无权限减少商家余额需要1100b'})
@@ -990,8 +1174,11 @@ class KefuUpdateShangjiaView(APIView):
# 增加余额 # 增加余额
if action == 'add_balance': if action == 'add_balance':
from jituan.services.group_finance_gate import deny_group_finance_exec from jituan.services.group_finance_gate import deny_balance_add_exec
deny = deny_group_finance_exec(request.user, request) from jituan.services.club_context import resolve_club_id_from_request
deny = deny_balance_add_exec(
request, permissions=permissions, target_user=shangjia.user,
)
if deny: if deny:
return deny return deny
amount_str = request.data.get('amount') amount_str = request.data.get('amount')
@@ -1007,6 +1194,7 @@ class KefuUpdateShangjiaView(APIView):
return Response({'code': 400, 'msg': '增加后余额超出上限'}) return Response({'code': 400, 'msg': '增加后余额超出上限'})
shangjia.yue = new_yue shangjia.yue = new_yue
shangjia.save(update_fields=['yue']) shangjia.save(update_fields=['yue'])
club_id = resolve_club_id_from_request(request) or ''
write_xiugai_log( write_xiugai_log(
yonghuid=yonghuid, yonghuid=yonghuid,
xiugaiid=kefu.user.Phone, xiugaiid=kefu.user.Phone,
@@ -1015,7 +1203,8 @@ class KefuUpdateShangjiaView(APIView):
shangjiayueq=old_yue, shangjiayueq=old_yue,
qitashuoming=( qitashuoming=(
f'【后台】增加商家余额:{old_yue}元 → {new_yue}元,增加{amount}元,' f'【后台】增加商家余额:{old_yue}元 → {new_yue}元,增加{amount}元,'
f'商家ID={yonghuid},昵称={shangjia.nicheng}操作人={kefu.user.Phone}' f'商家ID={yonghuid},昵称={shangjia.nicheng}俱乐部={club_id}'
f'操作人={kefu.user.Phone}'
), ),
) )
return Response({'code': 0, 'msg': '余额增加成功', 'data': {'new_balance': str(shangjia.yue)}}) return Response({'code': 0, 'msg': '余额增加成功', 'data': {'new_balance': str(shangjia.yue)}})
@@ -1102,12 +1291,139 @@ class KefuUpdateShangjiaView(APIView):
'data': {'is_youzhi_shangjia': is_youzhi}, 'data': {'is_youzhi_shangjia': is_youzhi},
}) })
if action == 'set_teyue':
if '1100a' not in permissions:
return Response({'code': 403, 'msg': '无权限设置特约商家需要1100a'})
is_teyue = bool(request.data.get('is_teyue_shangjia', False))
shangjia.is_teyue_shangjia = is_teyue
shangjia.save(update_fields=['is_teyue_shangjia'])
write_xiugai_log(
yonghuid=yonghuid,
xiugaiid=kefu.user.Phone,
leixing=XIUGAI_LEIXING_SHANGJIA,
qitashuoming=(
f'【后台】设置特约商家(可更换打手):{is_teyue}'
f'商家ID={yonghuid},昵称={shangjia.nicheng},操作人={kefu.user.Phone}'
),
)
return Response({
'code': 0,
'msg': '特约商家设置成功',
'data': {'is_teyue_shangjia': is_teyue},
})
return Response({'code': 400, 'msg': '未知操作'}) 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): class KefuChatPermissionsView(APIView):
@@ -1132,7 +1448,9 @@ class KefuChatPermissionsView(APIView):
# ---------- 验证通过permissions 已经是权限码列表 ---------- # ---------- 验证通过permissions 已经是权限码列表 ----------
# 4. 检查是否拥有至少一个聊天权限 # 4. 检查是否拥有至少一个聊天权限
has_chat_perm = any(p in CHAT_PERM_CODES for p in permissions) # 必须用 has_perm_code系统超管是 _AllPermissions
# 「any(p in CHAT_PERM_CODES for p in permissions)」会漏掉超管(列表里往往只有 000001
has_chat_perm = has_perm_code(permissions, *CHAT_PERM_CODES)
# 5. 若有聊天权限,获取 GoEasy AppKey # 5. 若有聊天权限,获取 GoEasy AppKey
goeasy_appkey = '' goeasy_appkey = ''
@@ -1150,6 +1468,303 @@ class KefuChatPermissionsView(APIView):
}) })
class KefuImUserClubFilterView(APIView):
"""
批量校验 GoEasy 客户 ID 所属俱乐部(供客服待接入/已接入列表前端过滤)。
POST /houtai/kfimssclub
Body: { "phone": "客服手机号", "im_ids": ["Boss1234567", "Ds7654321"] }
返回 allowed_ids当前后台俱乐部可见的 IM ID集团汇总则全部可解析的都返回
"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
_IM_UID_RE = re.compile(
r'^(Boss|Ds|Sj|Gs|Zz|Guanshi|Zuzhang|Kaoheguan)(\d+)$',
re.IGNORECASE,
)
def post(self, request):
phone = (request.data.get('phone') or request.data.get('username') or '').strip()
kefu_obj, permissions = verify_kefu_permission(request, phone or None)
if kefu_obj is None:
return permissions
if not has_perm_code(permissions, *CHAT_PERM_CODES):
return Response({'code': 403, 'msg': '无聊天权限', 'data': None})
raw_ids = request.data.get('im_ids') or request.data.get('ids') or []
if not isinstance(raw_ids, list):
return Response({'code': 400, 'msg': 'im_ids 必须为数组', 'data': None})
# 去重、截断,避免一次打太多
im_ids = []
seen = set()
for x in raw_ids[:200]:
s = str(x or '').strip()
if s and s not in seen:
seen.add(s)
im_ids.append(s)
uid_to_ims = {}
for im_id in im_ids:
m = self._IM_UID_RE.match(im_id)
if not m:
continue
uid = m.group(2)
uid_to_ims.setdefault(uid, []).append(im_id)
club_map = {}
if uid_to_ims:
for u in User.query.filter(UserUID__in=list(uid_to_ims.keys())).only('UserUID', 'ClubID'):
cid = (getattr(u, 'ClubID', None) or '').strip() or CLUB_ID_DEFAULT
for im_id in uid_to_ims.get(u.UserUID, []):
club_map[im_id] = cid
from jituan.constants import DATA_SCOPE_ALL
from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope
scope = resolve_club_scope(request)
if scope == DATA_SCOPE_ALL:
allowed_ids = [i for i in im_ids if i in club_map]
else:
current = (resolve_club_id_from_request(request) or '').strip()
# 严格按用户归属俱乐部过滤ClubID 空的用户归入默认俱乐部
allowed_ids = [
i for i in im_ids
if (club_map.get(i) or '').strip() == current
]
return Response({
'code': 0,
'msg': 'ok',
'data': {
'allowed_ids': allowed_ids,
'club_map': club_map,
'current_club_id': (
None if scope == DATA_SCOPE_ALL
else (resolve_club_id_from_request(request) or '').strip()
),
'scope': 'all' if scope == DATA_SCOPE_ALL else 'single',
},
})
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

@@ -583,8 +583,13 @@ class FineApplyView(APIView):
if not dashou_id: if not dashou_id:
return Response({'code': 400, 'msg': '该订单无接单打手,无法罚款'}) return Response({'code': 400, 'msg': '该订单无接单打手,无法罚款'})
# 重复罚款检查 # 重复罚款检查(组队单按订单维度防重复)
if Penalty.query.filter(PenalizedUserID=dashou_id, RelatedOrderID=OrderID).exists(): from orders.services.team_recruit import get_active_recruit
_active_team = get_active_recruit(OrderID)
if _active_team:
if Penalty.query.filter(RelatedOrderID=OrderID).exclude(Status=4).exists():
return Response({'code': 400, 'msg': '该订单已有罚单'})
elif Penalty.query.filter(PenalizedUserID=dashou_id, RelatedOrderID=OrderID).exists():
return Response({'code': 400, 'msg': '该打手已被罚款过'}) return Response({'code': 400, 'msg': '该打手已被罚款过'})
# 检查权限(打手身份 shenfen=1 # 检查权限(打手身份 shenfen=1
@@ -610,34 +615,57 @@ class FineApplyView(APIView):
return Response({'code': 500, 'msg': '图片上传失败'}) return Response({'code': 500, 'msg': '图片上传失败'})
uploaded_urls.append(file_path) uploaded_urls.append(file_path)
# 创建罚单 # 创建罚单(组队均摊)
with transaction.atomic(): with transaction.atomic():
penalty = Penalty.query.create( from orders.services.team_recruit import maybe_split_penalties
PenalizedUserID=dashou_id,
ApplicantID=request.user.Phone,
Identity=1, # 被处罚人身份1 打手
Reason=Reason,
FineAmount=FineAmount,
RelatedOrderID=OrderID,
Status=1, # 待缴纳
AffectsGrabbing=AffectsGrabbing,
ApplicantIdentity=1, # 申请人身份1 客服
ClubID=resolve_penalty_club_id(
order=order,
request=request,
user=request.user,
applicant_id=getattr(request.user, 'UserUID', None),
),
)
for relative_url in uploaded_urls:
PenaltyAppealImage.query.create(
Penalty=penalty,
PenalizedUserID=dashou_id,
ImageURL=relative_url,
Purpose=1
)
from users.fadan_fenhong_utils import lock_penalty_bonus from users.fadan_fenhong_utils import lock_penalty_bonus
lock_penalty_bonus(penalty) club_for_p = resolve_penalty_club_id(
order=order,
request=request,
user=request.user,
applicant_id=getattr(request.user, 'UserUID', None),
)
split = maybe_split_penalties(
order_id=OrderID,
total_amount=FineAmount,
reason=Reason,
applicant_id=request.user.Phone,
applicant_identity=1,
affects_grabbing=AffectsGrabbing,
club_id=club_for_p,
)
if split:
for p in split:
for relative_url in uploaded_urls:
PenaltyAppealImage.query.create(
Penalty=p,
PenalizedUserID=p.PenalizedUserID,
ImageURL=relative_url,
Purpose=1,
)
lock_penalty_bonus(p)
penalty = split[0]
else:
penalty = Penalty.query.create(
PenalizedUserID=dashou_id,
ApplicantID=request.user.Phone,
Identity=1,
Reason=Reason,
FineAmount=FineAmount,
RelatedOrderID=OrderID,
Status=1,
AffectsGrabbing=AffectsGrabbing,
ApplicantIdentity=1,
ClubID=club_for_p,
)
for relative_url in uploaded_urls:
PenaltyAppealImage.query.create(
Penalty=penalty,
PenalizedUserID=dashou_id,
ImageURL=relative_url,
Purpose=1
)
lock_penalty_bonus(penalty)
logger.info(f"客服罚款申请成功: 订单 {OrderID}, 打手 {dashou_id}, 金额 {FineAmount}") logger.info(f"客服罚款申请成功: 订单 {OrderID}, 打手 {dashou_id}, 金额 {FineAmount}")
return Response({'code': 0, 'msg': '罚款已生成', 'data': {'id': penalty.id}}) return Response({'code': 0, 'msg': '罚款已生成', 'data': {'id': penalty.id}})

View File

@@ -141,62 +141,110 @@ class GetProductBaseDataView(APIView):
return permissions return permissions
if '2200a' not in permissions: if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'}) 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 升序);商品数按当前俱乐部统计
type_qs = ShangpinLeixing.query.all().order_by('paixu') from jituan.services.club_context import filter_club_char_field, resolve_club_scope
# 批量聚合商品数量,避免循环内 N+1 count from jituan.constants import DATA_SCOPE_ALL
_type_counts = { from jituan.services.club_write import resolve_club_id_for_write
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'))
}
type_list = []
for t in type_qs:
product_count = _type_counts.get(t.id, 0)
type_list.append({
'id': t.id,
'jieshao': t.jieshao or '',
'tupian_url': t.tupian_url or '',
'paixu': t.paixu,
'shenhezhuangtai': t.shenhezhuangtai,
'product_count': product_count,
})
# 获取所有专区(按 paixu 升序) try:
zone_qs = ShangpinZhuanqu.query.all().order_by('paixu') type_qs = ShangpinLeixing.query.all().order_by('paixu')
_zone_counts = { # 批量聚合商品数量,避免循环内 N+1 count按俱乐部
item['zhuanqu_id']: item['cnt'] _public_sp = filter_club_char_field(
for item in Shangpin.query.filter( Shangpin.query.filter(
dianpu__isnull=True, dianpu_leixing__isnull=True, zhuanqu_id__isnull=False dianpu__isnull=True, dianpu_leixing__isnull=True, leixing_id__isnull=False
).values('zhuanqu_id').annotate(cnt=Count('id')) ),
} request,
zone_list = [] field='club_id',
for z in zone_qs: )
product_count = _zone_counts.get(z.id, 0) # 用 Django QS 聚合,避免 Fluent values 链在部分环境下异常
zone_list.append({ _type_counts = {
'id': z.id, item['leixing_id']: item['cnt']
'mingzi': z.mingzi or '', for item in _public_sp.values('leixing_id').annotate(cnt=Count('id'))
'leixing_id': z.leixing_id,
'paixu': z.paixu,
'shenhezhuangtai': z.shenhezhuangtai,
'product_count': product_count,
})
# 获取所有会员(只需 ID、介绍、价格
member_list = list(Huiyuan.query.all().values('huiyuan_id', 'jieshao', 'jiage'))
return Response({
'code': 0,
'data': {
'type_list': type_list,
'zone_list': zone_list,
'member_list': member_list,
} }
}) type_list = []
for t in type_qs:
product_count = _type_counts.get(t.id, 0)
type_list.append({
'id': t.id,
'jieshao': t.jieshao or '',
'tupian_url': t.tupian_url or '',
'paixu': t.paixu,
'shenhezhuangtai': t.shenhezhuangtai,
'product_count': product_count,
})
# 获取专区(按俱乐部隔离;集团汇总看全部)
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 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 = []
for z in zone_qs:
product_count = _zone_counts.get(z.id, 0)
zone_list.append({
'id': z.id,
'mingzi': z.mingzi or '',
'leixing_id': z.leixing_id,
'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 = []
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,
'data': {
'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 return permissions
if '2200a' not in permissions: if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'}) 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') leixing_id = request.data.get('leixing_id')
@@ -257,11 +302,13 @@ class GetProductListView(APIView):
except: except:
return Response({'code': 400, 'msg': '分页参数错误'}) return Response({'code': 400, 'msg': '分页参数错误'})
# 🔥 修改:构建查询集,增加公共商品筛选(店铺为空且店铺商品类型为空 # 🔥 公共商品 + 按俱乐部隔离(集团汇总看全部
from jituan.services.club_context import filter_club_char_field
qs = Shangpin.query.filter( qs = Shangpin.query.filter(
dianpu__isnull=True, # 所属店铺为空 dianpu__isnull=True, # 所属店铺为空
dianpu_leixing__isnull=True # 店铺商品类型为空 dianpu_leixing__isnull=True # 店铺商品类型为空
) )
qs = filter_club_char_field(qs, request, field='club_id')
if leixing_id: if leixing_id:
try: try:
@@ -323,8 +370,10 @@ class GetProductListView(APIView):
'jiage': str(p.jiage), 'jiage': str(p.jiage),
'kucun': p.kucun, 'kucun': p.kucun,
'zhenshi_xiaoliang': p.zhenshi_xiaoliang, 'zhenshi_xiaoliang': p.zhenshi_xiaoliang,
'duiwai_xiaoliang': p.duiwai_xiaoliang,
'tupian_url': p.tupian_url or '', 'tupian_url': p.tupian_url or '',
'jieshao': p.jieshao or '', 'jieshao': p.jieshao or '',
'club_id': getattr(p, 'club_id', '') or '',
}) })
return Response({ return Response({
@@ -365,9 +414,6 @@ class GetProductListView(APIView):
return permissions return permissions
if '2200a' not in permissions: if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'}) 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 升序)
type_qs = ShangpinLeixing.query.all().order_by('paixu') type_qs = ShangpinLeixing.query.all().order_by('paixu')
@@ -404,6 +450,7 @@ class GetProductListView(APIView):
'paixu': z.paixu, 'paixu': z.paixu,
'shenhezhuangtai': z.shenhezhuangtai, 'shenhezhuangtai': z.shenhezhuangtai,
'product_count': product_count, 'product_count': product_count,
'tupian_url': getattr(z, 'tupian_url', '') or '',
}) })
# 获取所有会员(只需 ID、介绍、价格 # 获取所有会员(只需 ID、介绍、价格
@@ -454,9 +501,6 @@ class GetProductListView(APIView):
return permissions return permissions
if '2200a' not in permissions: if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'}) 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') leixing_id = request.data.get('leixing_id')
@@ -578,9 +622,6 @@ class SaveProductOrderView(APIView):
return permissions return permissions
if '2200a' not in permissions: if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'}) 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() sort_type = request.data.get('type', '').strip().lower()
if sort_type not in ('leixing', 'zhuanqu'): if sort_type not in ('leixing', 'zhuanqu'):
@@ -643,9 +684,6 @@ class AddProductView(APIView):
return permissions return permissions
if '2200a' not in permissions: if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'}) 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() biaoqian = request.data.get('biaoqian', '').strip()
@@ -658,7 +696,10 @@ class AddProductView(APIView):
huiyuan_id = request.data.get('huiyuan_id', '').strip() huiyuan_id = request.data.get('huiyuan_id', '').strip()
yongjin_str = request.data.get('yongjin', '0') yongjin_str = request.data.get('yongjin', '0')
fencheng_type = request.data.get('fencheng_type', 'default') fencheng_type = request.data.get('fencheng_type', 'default')
ewai_PlayerCommission_str = request.data.get('ewai_PlayerCommission', '0') # 兼容列表页 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() jieshao = request.data.get('jieshao', '').strip()
xiadan_xuzhi = request.data.get('xiadan_xuzhi', '').strip() xiadan_xuzhi = request.data.get('xiadan_xuzhi', '').strip()
# 图片文件 # 图片文件
@@ -707,6 +748,14 @@ class AddProductView(APIView):
zhuanqu_obj = ShangpinZhuanqu.query.get(id=zhuanqu_id) zhuanqu_obj = ShangpinZhuanqu.query.get(id=zhuanqu_id)
if zhuanqu_obj.leixing_id != leixing_id: if zhuanqu_obj.leixing_id != leixing_id:
return Response({'code': 400, 'msg': '专区不属于该商品类型'}) 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: except ShangpinZhuanqu.DoesNotExist:
return Response({'code': 400, 'msg': '商品专区不存在'}) return Response({'code': 400, 'msg': '商品专区不存在'})
@@ -774,6 +823,8 @@ class AddProductView(APIView):
# 创建商品 # 创建商品
try: try:
from jituan.services.club_write import resolve_club_id_for_write
club_id = resolve_club_id_for_write(request)
with transaction.atomic(): with transaction.atomic():
product = Shangpin.query.create( product = Shangpin.query.create(
biaoqian=biaoqian, biaoqian=biaoqian,
@@ -793,6 +844,7 @@ class AddProductView(APIView):
ewai_dashou_fencheng=ewai_fencheng, ewai_dashou_fencheng=ewai_fencheng,
paixu=0, # 新商品默认排序最后 paixu=0, # 新商品默认排序最后
shenhezhuangtai=1, # 正常状态 shenhezhuangtai=1, # 正常状态
club_id=club_id,
) )
except Exception as e: except Exception as e:
logger.error(f"创建商品失败: {str(e)}", exc_info=True) logger.error(f"创建商品失败: {str(e)}", exc_info=True)
@@ -843,9 +895,6 @@ class DeleteProductView(APIView):
return permissions return permissions
if '2200a' not in permissions: if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'}) return Response({'code': 403, 'msg': '无权限管理商品'})
scope_err = block_non_group_catalog_scope(request)
if scope_err:
return Response({'code': 400, 'msg': scope_err})
# 查询商品是否存在 # 查询商品是否存在
try: try:
@@ -908,9 +957,6 @@ class GetProductDetailView(APIView):
return permissions return permissions
if '2200a' not in permissions: if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'}) return Response({'code': 403, 'msg': '无权限管理商品'})
scope_err = block_non_group_catalog_scope(request)
if scope_err:
return Response({'code': 400, 'msg': scope_err})
# 查询商品 # 查询商品
try: try:
@@ -946,7 +992,10 @@ class GetProductDetailView(APIView):
'huiyuan_id': product.huiyuan_id, 'huiyuan_id': product.huiyuan_id,
'yongjin': str(product.yongjin) if product.yongjin is not None else None, 'yongjin': str(product.yongjin) if product.yongjin is not None else None,
'tupian_url': product.tupian_url, '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', '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, 'CreateTime': product.CreateTime.isoformat() if product.CreateTime else None,
'shenhezhuangtai': product.shenhezhuangtai, 'shenhezhuangtai': product.shenhezhuangtai,
@@ -992,9 +1041,6 @@ class UpdateProductView(APIView):
return permissions return permissions
if '2200a' not in permissions: if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'}) 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 放在事务内 # 使用事务,将 select_for_update 放在事务内
with transaction.atomic(): with transaction.atomic():
@@ -1070,16 +1116,26 @@ class UpdateProductView(APIView):
except: except:
yongjin = Decimal('0') yongjin = Decimal('0')
# ---------- 3. 打手分成 ---------- # ---------- 3. 打手分成(兼容详情页字段名与旧 PlayerCommission 别名) ----------
kaioi_ewai = request.data.get('kaioi_ewai_PlayerCommission') == 'true' 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') ewai_fencheng = Decimal('0')
if kaioi_ewai: 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: try:
ewai_fencheng = Decimal(str(request.data.get('ewai_PlayerCommission', 0))) ewai_fencheng = Decimal(str(raw_amt))
if ewai_fencheng < 0: if ewai_fencheng < 0:
return Response({'code': 400, 'msg': '分成金额不能为负数'}) return Response({'code': 400, 'msg': '分成金额不能为负数'})
except: except Exception:
return Response({'code': 400, 'msg': '分成金额格式错误'}) return Response({'code': 400, 'msg': '分成金额格式错误'})
else:
# 默认分成:清空固定金额,下单按俱乐部利率算
ewai_fencheng = None
jieshao = request.data.get('jieshao', '').strip() jieshao = request.data.get('jieshao', '').strip()
xiadan_xuzhi = request.data.get('xiadan_xuzhi', '').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}}) 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: if not role_code:
return Response({'code': 400, 'msg': '缺少角色编码'}) 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 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 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) 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) role = resolve_role_by_name(role_code, bind_club_id)
if not role: if not role:
return Response({'code': 404, 'msg': '角色不存在或与当前俱乐部不匹配'}) return Response({'code': 404, 'msg': '角色不存在或与当前俱乐部不匹配'})
@@ -354,11 +360,17 @@ class AddRoleView(APIView):
if '000001' not in permissions: if '000001' not in permissions:
return Response({'code': 403, 'msg': '您无权进行此操作'}) 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 from jituan.services.club_rbac import resolve_role_club_id, role_name_exists
target_club_id = resolve_role_club_id( target_club_id = resolve_role_club_id(
request, (request.data.get('target_club_id') or '').strip() or None, 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): if role_name_exists(role_name, target_club_id):
return Response({'code': 400, 'msg': '该俱乐部下角色名称已存在'}) return Response({'code': 400, 'msg': '该俱乐部下角色名称已存在'})
@@ -494,16 +506,45 @@ class GetAdminUserListView(APIView):
'is_primary': a.is_primary, '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 = [] data_list = []
for user in _page_users: 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({ data_list.append({
'phone': user.Phone or '', 'phone': user.Phone or '',
'yonghuid': user.UserUID or '', 'yonghuid': user.UserUID or '',
'nicheng': user.KefuProfile.nicheng, 'nicheng': user.KefuProfile.nicheng,
'club_id': getattr(user, 'ClubID', '') or '', 'club_id': getattr(user, 'ClubID', '') or '',
'roles': roles_data, 'roles': roles_data,
'assignments': _assignments_by_user.get(user.UserUID, []), 'assignments': _assignments_in_view(_assignments_by_user.get(user.UserUID, [])),
'status': user.KefuProfile.zhuangtai, 'status': user.KefuProfile.zhuangtai,
'create_time': user.UserCreateTime.strftime('%Y-%m-%d %H:%M:%S') if getattr(user, 'UserCreateTime', None) else '', '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 '', '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 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({ return Response({
'code': 0, 'code': 0,
'data': { 'data': {
@@ -520,7 +570,12 @@ class GetAdminUserListView(APIView):
'page': page, 'page': page,
'pageSize': page_size, 'pageSize': page_size,
'can_manage_club_assignments': can_manage_admin_assignments(request.user, permissions), '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): if not can_access_admin_user_management(request.user, permissions):
return Response({'code': 403, 'msg': '您无权访问此页面'}) 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 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') 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') roles = Role.objects.filter(RoleStatus=1).order_by('ClubID', 'RoleName').values('RoleName', 'ClubID')
else: else:
manageable = get_manageable_club_ids(request.user, permissions) roles = filter_roles_for_request(request).order_by('ClubID', 'RoleName').values('RoleName', 'ClubID')
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')
return Response({ return Response({
'code': 0, 'code': 0,
'data': { 'data': {
@@ -671,9 +730,11 @@ class ModifyAdminUserView(APIView):
club_assign_actions = {'add_club_assignment', 'remove_club_assignment'} club_assign_actions = {'add_club_assignment', 'remove_club_assignment'}
role_manage_actions = {'add_user_roles', 'remove_user_role'} role_manage_actions = {'add_user_roles', 'remove_user_role'}
if action in club_assign_actions: if action in club_assign_actions:
# 跨店任职仍需集团权限;本店超级管理只管功能角色与账号,不分配其他俱乐部任职
if not can_manage_admin_assignments(request.user, permissions): 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: elif action in role_manage_actions:
# 本店超级管理 000001 可给本俱乐部用户增删功能角色
if not can_access_admin_user_management(request.user, permissions): if not can_access_admin_user_management(request.user, permissions):
return Response({'code': 403, 'msg': '您无权管理角色分配'}) return Response({'code': 403, 'msg': '您无权管理角色分配'})
elif not can_manage_admin_assignments(request.user, permissions) and '000001' not in permissions: elif not can_manage_admin_assignments(request.user, permissions) and '000001' not in permissions:
@@ -703,7 +764,17 @@ class ModifyAdminUserView(APIView):
if nicheng is not None: if nicheng is not None:
target_kefu.nicheng = nicheng target_kefu.nicheng = nicheng
if status is not None: if status is not None:
target_kefu.zhuangtai = int(status) new_status = int(status)
target_kefu.zhuangtai = new_status
if new_status != 1:
# 禁用账号:停用全部集团/俱乐部任职,并摘掉非白名单的 IsSuperuser
from jituan.constants import SUPER_ADMIN_PHONES
from jituan.services.admin_assignments import deactivate_all_assignments_for_user
deactivate_all_assignments_for_user(target_user.UserUID)
if (target_user.Phone or '') not in SUPER_ADMIN_PHONES:
if target_user.IsSuperuser:
target_user.IsSuperuser = False
target_user.save(update_fields=['IsSuperuser'])
if password: if password:
if len(password) < 6: if len(password) < 6:
return Response({'code': 400, 'msg': '密码至少6位'}) return Response({'code': 400, 'msg': '密码至少6位'})
@@ -721,12 +792,16 @@ class ModifyAdminUserView(APIView):
role_codes = request.data.get('role_codes', []) role_codes = request.data.get('role_codes', [])
if not role_codes: if not role_codes:
return Response({'code': 400, 'msg': '请选择角色'}) return Response({'code': 400, 'msg': '请选择角色'})
from jituan.services.club_rbac import resolve_role_by_name from jituan.services.club_rbac import resolve_role_by_name, resolve_role_club_id
from jituan.services.club_context import resolve_club_id_from_request bind_club_id = resolve_role_club_id(
bind_club_id = (request.data.get('target_club_id') or '').strip() request, (request.data.get('target_club_id') or '').strip() or None,
)
if not bind_club_id: if not bind_club_id:
bind_club_id = resolve_club_id_from_request(request) return Response({'code': 400, 'msg': '请先切换到具体俱乐部再分配角色'})
if not can_manage_roles_for_club(request.user, permissions, bind_club_id): if not (
can_manage_roles_for_club(request.user, permissions, bind_club_id)
or ('000001' in permissions)
):
return Response({'code': 403, 'msg': '您无权管理该俱乐部的角色分配'}) return Response({'code': 403, 'msg': '您无权管理该俱乐部的角色分配'})
added, missing = [], [] added, missing = [], []
for rc in role_codes: for rc in role_codes:
@@ -766,12 +841,16 @@ class ModifyAdminUserView(APIView):
role_code = request.data.get('role_code') role_code = request.data.get('role_code')
if not role_code: if not role_code:
return Response({'code': 400, 'msg': '缺少角色编码'}) return Response({'code': 400, 'msg': '缺少角色编码'})
from jituan.services.club_rbac import resolve_role_by_name from jituan.services.club_rbac import resolve_role_by_name, resolve_role_club_id
from jituan.services.club_context import resolve_club_id_from_request bind_club_id = resolve_role_club_id(
bind_club_id = (request.data.get('target_club_id') or '').strip() request, (request.data.get('target_club_id') or '').strip() or None,
)
if not bind_club_id: if not bind_club_id:
bind_club_id = resolve_club_id_from_request(request) return Response({'code': 400, 'msg': '请先切换到具体俱乐部再操作角色'})
if not can_manage_roles_for_club(request.user, permissions, bind_club_id): if not (
can_manage_roles_for_club(request.user, permissions, bind_club_id)
or ('000001' in permissions)
):
return Response({'code': 403, 'msg': '您无权管理该俱乐部的角色分配'}) return Response({'code': 403, 'msg': '您无权管理该俱乐部的角色分配'})
role = resolve_role_by_name(role_code, bind_club_id) role = resolve_role_by_name(role_code, bind_club_id)
if not role: if not role:
@@ -839,12 +918,17 @@ class AddAdminUserView(APIView):
can_manage_admin_assignments, can_manage_admin_assignments,
can_manage_roles_for_club, 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) 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 ( if not (
can_manage_admin_assignments(request.user, permissions) can_manage_admin_assignments(request.user, permissions)
or can_manage_roles_for_club(request.user, permissions, target_club_id) or can_manage_roles_for_club(request.user, permissions, target_club_id)
or ('000001' in permissions)
): ):
return Response({'code': 403, 'msg': '您无权在该俱乐部添加用户'}) return Response({'code': 403, 'msg': '您无权在该俱乐部添加用户'})

View File

@@ -139,7 +139,7 @@ class HqbkxxView(APIView):
return Response({'code': 403, 'msg': '无板块管理权限'}, status=403) return Response({'code': 403, 'msg': '无板块管理权限'}, status=403)
scope_err = block_non_group_catalog_scope(request) scope_err = block_non_group_catalog_scope(request)
if scope_err: if scope_err:
return Response({'code': 400, 'msg': scope_err}) return Response({'code': 400, 'msg': scope_err}, status=400)
# 5. 查询所有板块 # 5. 查询所有板块
bankuai_list = [] bankuai_list = []
@@ -241,7 +241,7 @@ class BkxgView(APIView):
return Response({'code': 403, 'msg': '无板块管理权限'}, status=403) return Response({'code': 403, 'msg': '无板块管理权限'}, status=403)
scope_err = block_non_group_catalog_scope(request) scope_err = block_non_group_catalog_scope(request)
if scope_err: if scope_err:
return Response({'code': 400, 'msg': scope_err}) return Response({'code': 400, 'msg': scope_err}, status=400)
# 5. 获取操作类型 # 5. 获取操作类型
action = request.data.get('action') action = request.data.get('action')
@@ -279,9 +279,13 @@ class BkxgView(APIView):
}) })
# ==================== 以下操作需要 bankuai_id ==================== # ==================== 以下操作需要 bankuai_id ====================
bankuai_id = request.data.get('bankuai_id') raw_bankuai_id = request.data.get('bankuai_id')
if not bankuai_id: if raw_bankuai_id is None or str(raw_bankuai_id).strip() == '':
return Response({'code': 1, 'msg': '缺少 bankuai_id'}, status=400) 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: try:
bk = Bankuai.query.get(bankuai_id=bankuai_id) 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_min = request.data.get('zhengqu_zonge_min')
zhengqu_zonge_max = request.data.get('zhengqu_zonge_max') 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 = Dianpu.query.select_related('pingzheng').all()
queryset = filter_club_char_field(queryset, request, field='club_id')
if dianpu_mingcheng: if dianpu_mingcheng:
queryset = queryset.filter(dianpu_mingcheng__icontains=dianpu_mingcheng) queryset = queryset.filter(dianpu_mingcheng__icontains=dianpu_mingcheng)
@@ -189,6 +191,7 @@ class ShopListView(APIView):
'kaiqi_meiri_xiane': dianpu.kaiqi_meiri_xiane, 'kaiqi_meiri_xiane': dianpu.kaiqi_meiri_xiane,
'pingzheng__yonghu': pingzheng.yonghu, # 用户ID 'pingzheng__yonghu': pingzheng.yonghu, # 用户ID
'pingzheng__zhanghao': pingzheng.zhanghao, # 登录账号 'pingzheng__zhanghao': pingzheng.zhanghao, # 登录账号
'club_id': getattr(dianpu, 'club_id', '') or '',
}) })
# 获取默认配置ID=1不存在则创建 # 获取默认配置ID=1不存在则创建
@@ -327,6 +330,7 @@ class ShopModifyView(APIView):
mima=mima, mima=mima,
is_active=True is_active=True
) )
from jituan.services.club_write import resolve_club_id_for_write
dianpu = Dianpu.query.create( dianpu = Dianpu.query.create(
pingzheng=pingzheng, pingzheng=pingzheng,
dianpu_mingcheng=dianpu_mingcheng, dianpu_mingcheng=dianpu_mingcheng,
@@ -336,6 +340,7 @@ class ShopModifyView(APIView):
shouyi_choucheng_feilv=request.data.get('shouyi_choucheng_feilv', 0), shouyi_choucheng_feilv=request.data.get('shouyi_choucheng_feilv', 0),
kaiqi_meiri_xiane=request.data.get('kaiqi_meiri_xiane', False), kaiqi_meiri_xiane=request.data.get('kaiqi_meiri_xiane', False),
meiri_tixian_xiane=request.data.get('meiri_tixian_xiane', 0), 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}) return Response({'code': 0, 'msg': '店铺创建成功', 'dianpu_id': dianpu.id})
except IntegrityError as e: except IntegrityError as e:
@@ -416,15 +421,38 @@ class ShopPublicTypeAndAuditView(APIView):
logger.exception("查询公共商品类型失败") logger.exception("查询公共商品类型失败")
return Response({'code': 500, 'msg': '服务器错误,请稍后重试'}) return Response({'code': 500, 'msg': '服务器错误,请稍后重试'})
# ---------- 获取审核模式配置 ---------- # ---------- 获取本俱乐部审核模式配置 ----------
audit_config = DianpuShangpinShenheShezhi.query.filter(id=1).first() from jituan.services.club_write import resolve_club_id_for_write
kaiqi_shenhe = audit_config.kaiqi_shenhe if audit_config else False 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({ return Response({
'code': 0, 'code': 0,
'data': { 'data': {
'public_types': type_list, '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封禁不传则全部 # 店铺状态筛选1正常0封禁不传则全部
zhuangtai = request.data.get('zhuangtai') 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 = Dianpu.query.select_related('pingzheng').all()
shops = filter_club_char_field(shops, request, field='club_id')
# 状态筛选:只接受 0 或 1 # 状态筛选:只接受 0 或 1
if zhuangtai is not None: if zhuangtai is not None:
@@ -615,6 +645,8 @@ class ShopProductModifyView(APIView):
if action == 'update_audit_mode': if action == 'update_audit_mode':
return self._update_audit_mode(request, user_perms) 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': elif action == 'update_mapping_status':
return self._update_mapping_status(request, user_perms) return self._update_mapping_status(request, user_perms)
else: else:
@@ -622,22 +654,61 @@ class ShopProductModifyView(APIView):
@transaction.atomic # ✅ 事务修复点 @transaction.atomic # ✅ 事务修复点
def _update_audit_mode(self, request, user_perms): 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': '无权限修改审核模式'}) return Response({'code': 403, 'msg': '无权限修改审核模式'})
kaiqi = request.data.get('kaiqi_shenhe') kaiqi = request.data.get('kaiqi_shenhe')
if kaiqi is None: if kaiqi is None:
return Response({'code': 400, 'msg': '缺少参数 kaiqi_shenhe'}) 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: 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.kaiqi_shenhe = bool(kaiqi)
config.save(update_fields=['kaiqi_shenhe']) 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: except Exception as e:
logger.exception("更新审核模式失败") logger.exception("更新审核模式失败")
return Response({'code': 500, 'msg': '服务器错误'}) 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 @transaction.atomic
def _update_mapping_status(self, request, user_perms): def _update_mapping_status(self, request, user_perms):
mapping_id = request.data.get('mapping_id') mapping_id = request.data.get('mapping_id')

View File

@@ -236,65 +236,85 @@ class GetProductTypeZoneView(APIView):
return permissions return permissions
if '7007a' not in permissions: if '7007a' not in permissions:
return Response({'code': 403, 'msg': '您无权访问此页面'}) return Response({'code': 403, 'msg': '您无权访问此页面'})
scope_err = block_non_group_catalog_scope(request)
if scope_err:
return Response({'code': 400, 'msg': scope_err})
# 查询所有商品类型(含专区数量) from jituan.constants import DATA_SCOPE_ALL
# 一次性分组聚合所有类型的专区数量,避免循环内 N+1 count from jituan.services.club_context import resolve_club_scope, resolve_club_id_from_request
# 注ShangpinZhuanqu.leixing_id 是 PositiveIntegerField非 FK无法用反向 annotate from jituan.services.club_shangpin_leixing import build_club_leixing_admin_list
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}
types = ShangpinLeixing.query.all().order_by('-paixu') # paixu 越大越靠前 club_scoped = resolve_club_scope(request) != DATA_SCOPE_ALL
type_list = [] club_id = resolve_club_id_from_request(request)
for t in types:
# 从预聚合的 map 中取专区数量 try:
zone_count = zone_count_map.get(t.id, 0) zone_qs_for_count = filter_club_char_field(
type_list.append({ ShangpinZhuanqu.query.all(), request, field='club_id'
'id': t.id, )
'jieshao': t.jieshao or '', zone_count_map = {
'tupian_url': t.tupian_url or '', row['leixing_id']: row['cnt']
'yaoqiuleixing': t.yaoqiuleixing, for row in zone_qs_for_count.values('leixing_id').annotate(cnt=Count('id'))
'huiyuan_id': t.huiyuan_id or '', }
'yongjin': float(t.yongjin) if t.yongjin is not None else None,
'shenhezhuangtai': t.shenhezhuangtai, if club_scoped:
'paixu': t.paixu, # 具体俱乐部:仅本俱乐部已上架类型(与抢单端一致)
'product_count': zone_count, admin_payload = build_club_leixing_admin_list(request)
type_list = []
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 '',
'tupian_url': t.tupian_url or '',
'yaoqiuleixing': t.yaoqiuleixing,
'huiyuan_id': t.huiyuan_id or '',
'yongjin': float(t.yongjin) if t.yongjin is not None else None,
'shenhezhuangtai': t.shenhezhuangtai,
'paixu': t.paixu,
'product_count': zone_count_map.get(t.id, 0),
})
zones = filter_club_char_field(
ShangpinZhuanqu.query.all().order_by('-paixu'), request, field='club_id'
)
zone_list = [
{
'id': z.id,
'mingzi': z.mingzi or '',
'leixing_id': z.leixing_id,
'shenhezhuangtai': z.shenhezhuangtai,
'paixu': z.paixu,
'club_id': getattr(z, 'club_id', '') or '',
}
for z in zones
]
member_list = [
{
'huiyuan_id': m['huiyuan_id'],
'jieshao': m['jieshao'] or '',
'jiage': float(m['jiage'] or 0),
}
for m in Huiyuan.query.all().values('huiyuan_id', 'jieshao', 'jiage')
]
return Response({
'code': 0,
'data': {
'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 失败')
zones = ShangpinZhuanqu.query.all().order_by('-paixu') return Response({'code': 500, 'msg': '获取类型专区失败,请稍后重试'})
zone_list = [
{
'id': z.id,
'mingzi': z.mingzi or '',
'leixing_id': z.leixing_id,
'shenhezhuangtai': z.shenhezhuangtai,
'paixu': z.paixu,
}
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']),
}
for m in members
]
return Response({
'code': 0,
'data': {
'type_list': type_list,
'zone_list': zone_list,
'member_list': member_list,
}
})
@@ -321,11 +341,12 @@ class ModifyProductTypeZoneView(APIView):
return permissions return permissions
if '7007a' not in permissions: if '7007a' not in permissions:
return Response({'code': 403, 'msg': '您无权进行此操作'}) return Response({'code': 403, 'msg': '您无权进行此操作'})
scope_err = block_non_group_catalog_scope(request)
if scope_err:
return Response({'code': 400, 'msg': scope_err})
# 类型仍须集团汇总;专区允许在具体俱乐部操作
if obj_type == 'leixing': if obj_type == 'leixing':
scope_err = block_non_group_catalog_scope(request)
if scope_err:
return Response({'code': 400, 'msg': scope_err})
return self.handle_leixing(request, action) return self.handle_leixing(request, action)
elif obj_type == 'zhuanqu': elif obj_type == 'zhuanqu':
return self.handle_zhuanqu(request, action) return self.handle_zhuanqu(request, action)
@@ -566,6 +587,24 @@ class ModifyProductTypeZoneView(APIView):
else: else:
return Response({'code': 400, 'msg': '无效的action'}) return Response({'code': 400, 'msg': '无效的action'})
def _upload_zhuanqu_icon(self, image_file):
"""专区图标上传到 OSS相对路径前缀 shangpin/zhuanqu/(按俱乐部存库,路径共用前缀)。"""
try:
valid, msg = validate_image(image_file)
if not valid:
return None, msg
except ImportError:
pass
ext = image_file.name.split('.')[-1].lower() if image_file.name and '.' in image_file.name else 'png'
if ext not in ('jpg', 'jpeg', 'png', 'gif', 'webp'):
ext = 'png'
new_filename = f"{int(time.time())}_{uuid.uuid4().hex[:12]}.{ext}"
oss_path = f"shangpin/zhuanqu/{new_filename}"
url = upload_to_oss(image_file, oss_path)
if not url:
return None, '图片上传失败'
return oss_path, None
def add_zhuanqu(self, request): def add_zhuanqu(self, request):
mingzi = request.data.get('mingzi', '').strip() mingzi = request.data.get('mingzi', '').strip()
leixing_id = request.data.get('leixing_id') leixing_id = request.data.get('leixing_id')
@@ -588,17 +627,37 @@ class ModifyProductTypeZoneView(APIView):
except (TypeError, ValueError): except (TypeError, ValueError):
return Response({'code': 400, 'msg': '审核状态无效必须为1或2'}) return Response({'code': 400, 'msg': '审核状态无效必须为1或2'})
# 图标:优先上传文件;否则可选手填相对路径(兼容旧用法)
tupian_url = (request.data.get('tupian_url') or '').strip()
image_file = request.FILES.get('file')
uploaded_path = None
if image_file:
uploaded_path, err = self._upload_zhuanqu_icon(image_file)
if err:
return Response({'code': 500 if err == '图片上传失败' else 400, 'msg': err})
tupian_url = uploaded_path
try: try:
with transaction.atomic(): 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( zhuanqu = ShangpinZhuanqu.query.create(
mingzi=mingzi, mingzi=mingzi,
leixing_id=leixing_id, leixing_id=leixing_id,
shenhezhuangtai=shenhezhuangtai, shenhezhuangtai=shenhezhuangtai,
paixu=0 paixu=0,
club_id=club_id,
tupian_url=tupian_url,
) )
return Response({'code': 0, 'msg': '添加成功', 'data': {'id': zhuanqu.id}}) return Response({
'code': 0,
'msg': '添加成功',
'data': {'id': zhuanqu.id, 'club_id': club_id, 'tupian_url': tupian_url},
})
except Exception as e: except Exception as e:
logger.error(f"添加专区失败: {e}", exc_info=True) logger.error(f"添加专区失败: {e}", exc_info=True)
if uploaded_path:
delete_from_oss(uploaded_path)
return Response({'code': 500, 'msg': '数据库错误'}) return Response({'code': 500, 'msg': '数据库错误'})
def update_zhuanqu(self, request): def update_zhuanqu(self, request):
@@ -610,6 +669,15 @@ class ModifyProductTypeZoneView(APIView):
except ShangpinZhuanqu.DoesNotExist: except ShangpinZhuanqu.DoesNotExist:
return Response({'code': 404, 'msg': '专区不存在'}) 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 = {} changes = {}
mingzi = request.data.get('mingzi') mingzi = request.data.get('mingzi')
if mingzi is not None and mingzi.strip() != zhuanqu.mingzi: if mingzi is not None and mingzi.strip() != zhuanqu.mingzi:
@@ -637,6 +705,20 @@ class ModifyProductTypeZoneView(APIView):
except (TypeError, ValueError): except (TypeError, ValueError):
return Response({'code': 400, 'msg': '审核状态无效必须为1或2'}) return Response({'code': 400, 'msg': '审核状态无效必须为1或2'})
old_image_path = getattr(zhuanqu, 'tupian_url', None) or ''
image_file = request.FILES.get('file')
uploaded_path = None
if image_file:
uploaded_path, err = self._upload_zhuanqu_icon(image_file)
if err:
return Response({'code': 500 if err == '图片上传失败' else 400, 'msg': err})
if uploaded_path != old_image_path:
changes['tupian_url'] = uploaded_path
elif 'tupian_url' in request.data:
new_url = (request.data.get('tupian_url') or '').strip()
if new_url != old_image_path:
changes['tupian_url'] = new_url
if not changes: if not changes:
return Response({'code': 400, 'msg': '未做任何修改'}) return Response({'code': 400, 'msg': '未做任何修改'})
@@ -645,9 +727,18 @@ class ModifyProductTypeZoneView(APIView):
for field, value in changes.items(): for field, value in changes.items():
setattr(zhuanqu, field, value) setattr(zhuanqu, field, value)
zhuanqu.save() zhuanqu.save()
return Response({'code': 0, 'msg': '修改成功'}) # 换图或清空后删旧 OSS新上传路径与旧不同时
if 'tupian_url' in changes and old_image_path and old_image_path != changes['tupian_url']:
delete_from_oss(old_image_path)
return Response({
'code': 0,
'msg': '修改成功',
'data': {'id': zhuanqu.id, 'tupian_url': getattr(zhuanqu, 'tupian_url', '') or ''},
})
except Exception as e: except Exception as e:
logger.error(f"更新专区失败: {e}", exc_info=True) logger.error(f"更新专区失败: {e}", exc_info=True)
if uploaded_path:
delete_from_oss(uploaded_path)
return Response({'code': 500, 'msg': '修改失败'}) return Response({'code': 500, 'msg': '修改失败'})
def delete_zhuanqu(self, request): def delete_zhuanqu(self, request):
@@ -661,6 +752,16 @@ class ModifyProductTypeZoneView(APIView):
except ShangpinZhuanqu.DoesNotExist: except ShangpinZhuanqu.DoesNotExist:
return Response({'code': 404, 'msg': '专区不存在'}) 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': '专区不属于当前俱乐部'})
old_image_path = getattr(zhuanqu, 'tupian_url', None) or ''
with transaction.atomic(): with transaction.atomic():
if delete_products: if delete_products:
Shangpin.query.filter(zhuanqu_id=zone_id).delete() Shangpin.query.filter(zhuanqu_id=zone_id).delete()
@@ -668,6 +769,9 @@ class ModifyProductTypeZoneView(APIView):
Shangpin.query.filter(zhuanqu_id=zone_id).update(zhuanqu_id=None) Shangpin.query.filter(zhuanqu_id=zone_id).update(zhuanqu_id=None)
zhuanqu.delete() zhuanqu.delete()
if old_image_path:
delete_from_oss(old_image_path)
return Response({'code': 0, 'msg': '删除成功'}) return Response({'code': 0, 'msg': '删除成功'})

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): class ChzsgcView(APIView):
""" """
称号(标签)增删改查统一接口 称号(标签)增删改查统一接口
@@ -197,18 +249,21 @@ class ChzsgcView(APIView):
# ========== 创建称号 ========== # ========== 创建称号 ==========
if action == 'create': if action == 'create':
mingcheng = request.data.get('mingcheng', '').strip() mingcheng = (request.data.get('mingcheng') or '').strip()
leixing = request.data.get('leixing', 'dashou') leixing = _normalize_chenghao_leixing(request.data.get('leixing'), default='dashou')
bankuai_id = request.data.get('bankuai_id') bankuai_id = _parse_optional_bankuai_id(request.data.get('bankuai_id'))
texiao_miaoshu = request.data.get('texiao_miaoshu', '') texiao_miaoshu = request.data.get('texiao_miaoshu', '') or ''
kaohe_guize = request.data.get('kaohe_guize', '') kaohe_guize = request.data.get('kaohe_guize', '') or ''
kaioi_jinpai = request.data.get('kaioi_jinpai', False) kaioi_jinpai = bool(request.data.get('kaioi_jinpai', False))
feiyong_list = request.data.get('feiyong_list', []) feiyong_list = request.data.get('feiyong_list', [])
if not mingcheng: if not mingcheng:
return Response({'code': 1, 'msg': '称号名称不能为空'}, status=400) 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) 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(): if Chenghao.query.filter(mingcheng=mingcheng).exists():
return Response({'code': 1, 'msg': '称号名称已存在'}, status=400) return Response({'code': 1, 'msg': '称号名称已存在'}, status=400)
@@ -238,7 +293,7 @@ class ChzsgcView(APIView):
# ========== 修改称号 ========== # ========== 修改称号 ==========
elif action == 'update': elif action == 'update':
ch_id = request.data.get('chenghao_id') ch_id = _parse_chenghao_id(request.data.get('chenghao_id'))
if not ch_id: if not ch_id:
return Response({'code': 1, 'msg': '缺少称号ID'}, status=400) return Response({'code': 1, 'msg': '缺少称号ID'}, status=400)
try: try:
@@ -246,17 +301,25 @@ class ChzsgcView(APIView):
except Chenghao.DoesNotExist: except Chenghao.DoesNotExist:
return Response({'code': 1, 'msg': '称号不存在'}, status=404) return Response({'code': 1, 'msg': '称号不存在'}, status=404)
mingcheng = request.data.get('mingcheng', ch.mingcheng).strip() mingcheng = (request.data.get('mingcheng') if request.data.get('mingcheng') is not None else ch.mingcheng) or ''
leixing = request.data.get('leixing', ch.leixing) mingcheng = str(mingcheng).strip()
bankuai_id = request.data.get('bankuai_id', ch.bankuai_id) 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) texiao_miaoshu = request.data.get('texiao_miaoshu', ch.texiao_miaoshu)
kaohe_guize = request.data.get('kaohe_guize', ch.kaohe_guize) kaohe_guize = request.data.get('kaohe_guize', ch.kaohe_guize)
kaioi_jinpai = request.data.get('kaioi_jinpai', ch.kaioi_jinpai) kaioi_jinpai = request.data.get('kaioi_jinpai', ch.kaioi_jinpai)
if not mingcheng: if not mingcheng:
return Response({'code': 1, 'msg': '称号名称不能为空'}, status=400) 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) 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(): if Chenghao.query.filter(mingcheng=mingcheng).exclude(id=ch_id).exists():
return Response({'code': 1, 'msg': '称号名称已存在'}, status=400) return Response({'code': 1, 'msg': '称号名称已存在'}, status=400)
@@ -265,7 +328,7 @@ class ChzsgcView(APIView):
ch.bankuai_id = bankuai_id ch.bankuai_id = bankuai_id
ch.texiao_miaoshu = texiao_miaoshu ch.texiao_miaoshu = texiao_miaoshu
ch.kaohe_guize = kaohe_guize ch.kaohe_guize = kaohe_guize
ch.kaioi_jinpai = kaioi_jinpai ch.kaioi_jinpai = bool(kaioi_jinpai)
ch.save() ch.save()
# 如果前端传递了 feiyong_list则全量替换费用 # 如果前端传递了 feiyong_list则全量替换费用
@@ -284,7 +347,7 @@ class ChzsgcView(APIView):
# ========== 删除称号 ========== # ========== 删除称号 ==========
elif action == 'delete': elif action == 'delete':
ch_id = request.data.get('chenghao_id') ch_id = _parse_chenghao_id(request.data.get('chenghao_id'))
if not ch_id: if not ch_id:
return Response({'code': 1, 'msg': '缺少称号ID'}, status=400) return Response({'code': 1, 'msg': '缺少称号ID'}, status=400)
try: try:
@@ -295,11 +358,11 @@ class ChzsgcView(APIView):
return Response({'code': 0, 'msg': '删除成功'}) return Response({'code': 0, 'msg': '删除成功'})
# ========== 添加/修改某次费用 ========== # ========== 添加/修改某次费用 ==========
elif action == 'add_feiyong': elif action in ('add_feiyong', 'update_feiyong'):
ch_id = request.data.get('chenghao_id') ch_id = _parse_chenghao_id(request.data.get('chenghao_id'))
cishu = request.data.get('cishu') cishu = request.data.get('cishu')
feiyong = request.data.get('feiyong', 0) 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) return Response({'code': 1, 'msg': '缺少称号ID或次数'}, status=400)
try: try:
ch = Chenghao.query.get(id=ch_id) ch = Chenghao.query.get(id=ch_id)
@@ -308,16 +371,16 @@ class ChzsgcView(APIView):
if int(cishu) < 1: if int(cishu) < 1:
return Response({'code': 1, 'msg': '次数必须>=1'}, status=400) return Response({'code': 1, 'msg': '次数必须>=1'}, status=400)
KaoheCishuFeiyong.query.update_or_create( KaoheCishuFeiyong.query.update_or_create(
chenghao=ch, cishu=cishu, chenghao=ch, cishu=int(cishu),
defaults={'feiyong': feiyong} defaults={'feiyong': feiyong}
) )
return Response({'code': 0, 'msg': '添加/修改费用成功'}) return Response({'code': 0, 'msg': '添加/修改费用成功'})
# ========== 删除某次费用 ========== # ========== 删除某次费用 ==========
elif action == 'remove_feiyong': 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') 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) return Response({'code': 1, 'msg': '缺少称号ID或次数'}, status=400)
if int(cishu) == 1: if int(cishu) == 1:
return Response({'code': 1, 'msg': '不能删除第1次考核费用'}, status=400) return Response({'code': 1, 'msg': '不能删除第1次考核费用'}, status=400)
@@ -325,13 +388,9 @@ class ChzsgcView(APIView):
ch = Chenghao.query.get(id=ch_id) ch = Chenghao.query.get(id=ch_id)
except Chenghao.DoesNotExist: except Chenghao.DoesNotExist:
return Response({'code': 1, 'msg': '称号不存在'}, status=404) 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': '删除成功'}) return Response({'code': 0, 'msg': '删除成功'})
# ========== 修改某次费用等价于add ==========
elif action == 'update_feiyong':
return self.add_feiyong(request) # 复用逻辑
else: else:
return Response({'code': 1, 'msg': '未知操作'}, status=400) return Response({'code': 1, 'msg': '未知操作'}, status=400)

View File

@@ -505,8 +505,10 @@ class UpdateZuzhangView(APIView):
if new_val > old_ketixian_jine: if new_val > old_ketixian_jine:
if '6600a' not in permissions: if '6600a' not in permissions:
return Response({'code': 403, 'msg': '无权限增加可提现金额需要6600a'}) return Response({'code': 403, 'msg': '无权限增加可提现金额需要6600a'})
from jituan.services.group_finance_gate import deny_group_finance_exec from jituan.services.group_finance_gate import deny_balance_add_exec
deny = deny_group_finance_exec(request.user, request) deny = deny_balance_add_exec(
request, permissions=permissions, target_user=user,
)
if deny: if deny:
return deny return deny
action_desc = f'增加{new_val - old_ketixian_jine}' action_desc = f'增加{new_val - old_ketixian_jine}'
@@ -519,6 +521,8 @@ class UpdateZuzhangView(APIView):
if new_val != old_ketixian_jine: if new_val != old_ketixian_jine:
zuzhang.ketixian_jine = new_val zuzhang.ketixian_jine = new_val
zuzhang.save(update_fields=['ketixian_jine']) zuzhang.save(update_fields=['ketixian_jine'])
from jituan.services.club_context import resolve_club_id_from_request
club_id = resolve_club_id_from_request(request) or ''
write_xiugai_log( write_xiugai_log(
yonghuid=yonghuid, yonghuid=yonghuid,
xiugaiid=operator, xiugaiid=operator,
@@ -527,7 +531,7 @@ class UpdateZuzhangView(APIView):
xiugaitijiaoq=old_ketixian_jine, xiugaitijiaoq=old_ketixian_jine,
qitashuoming=( qitashuoming=(
f'【后台】修改组长可提现金额:{old_ketixian_jine}元 → {new_val}元({action_desc}' f'【后台】修改组长可提现金额:{old_ketixian_jine}元 → {new_val}元({action_desc}'
f'组长ID={yonghuid},操作人={operator}' f'组长ID={yonghuid}俱乐部={club_id}操作人={operator}'
), ),
) )

View File

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

View File

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

View File

@@ -81,15 +81,23 @@ class ShangpinGonggaoView(APIView):
""" """
try: try:
logger.info("收到商品公告和轮播图请求") 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) 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) 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 = { response_data = {
"shangpingonggao": gonggao_content, "shangpingonggao": gonggao_content,
"shangpinlunbo": lunbo_urls, "shangpinlunbo": lunbo_urls,
"page_key": page_key, "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}") logger.info(f"返回数据:公告长度{len(gonggao_content)},轮播图数量{len(lunbo_urls)} page_key={page_key}")
# 4. 返回响应 # 4. 返回响应

View File

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

View File

@@ -269,6 +269,16 @@ class CheckPhoneAuthView(APIView):
except User.DoesNotExist: except User.DoesNotExist:
user = request.user user = request.user
# 俱乐部关闭强制收集手机号 → 前端 ensurePhoneAuth 不再跳转认证页
try:
from jituan.services.club_context import resolve_effective_club_id
from jituan.services.force_phone_auth import is_club_force_phone_auth
club_id = resolve_effective_club_id(request, user)
if not is_club_force_phone_auth(club_id):
return Response({'code': 0, 'need_auth': False})
except Exception:
pass
if self._phone_auth_completed(user): if self._phone_auth_completed(user):
return Response({'code': 0, 'need_auth': False}) return Response({'code': 0, 'need_auth': False})

View File

@@ -351,9 +351,137 @@ class GuanshiQRCodeView(APIView):
return None 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): 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 = '投诉工单'

60
gongdan/constants.py Normal file
View File

@@ -0,0 +1,60 @@
"""投诉工单常量。"""
# 投诉类型
TYPE_ORDER = 1 # 订单投诉
TYPE_RECHARGE = 2 # 充值投诉
TYPE_PENALTY = 3 # 罚款投诉
TYPE_GUANSHI = 4 # 管事投诉
TYPE_FRAUD = 5 # 被骗投诉
TYPE_ZUZHANG = 6 # 组长投诉
TYPE_CHOICES = (
(TYPE_ORDER, '订单投诉'),
(TYPE_RECHARGE, '充值投诉'),
(TYPE_PENALTY, '罚款投诉'),
(TYPE_GUANSHI, '管事投诉'),
(TYPE_FRAUD, '被骗投诉'),
(TYPE_ZUZHANG, '组长投诉'),
)
TYPE_LABELS = dict(TYPE_CHOICES)
# 状态1处理中 2已处理 3不满意 4已关闭 5协商中
STATUS_PROCESSING = 1
STATUS_DONE = 2
STATUS_DISSATISFIED = 3
STATUS_CLOSED = 4
STATUS_NEGOTIATING = 5
STATUS_CHOICES = (
(STATUS_PROCESSING, '处理中'),
(STATUS_DONE, '已处理'),
(STATUS_DISSATISFIED, '不满意'),
(STATUS_CLOSED, '已关闭'),
(STATUS_NEGOTIATING, '协商中'),
)
STATUS_LABELS = dict(STATUS_CHOICES)
# 未结清(拦提现):处理中 / 不满意 / 协商中
OPEN_STATUSES = (STATUS_PROCESSING, STATUS_DISSATISFIED, STATUS_NEGOTIATING)
TARGET_ROLE_DASHOU = 'dashou'
TARGET_ROLE_GUANSHI = 'guanshi'
TARGET_ROLE_ZUZHANG = 'zuzhang'
TARGET_ROLE_PLATFORM = 'platform'
TARGET_ROLE_LABELS = {
TARGET_ROLE_DASHOU: '接单员',
TARGET_ROLE_GUANSHI: '管事',
TARGET_ROLE_ZUZHANG: '组长',
TARGET_ROLE_PLATFORM: '平台',
}
MAX_IMAGES = 9
MAX_SHUOMING_LEN = 500
MAX_SHENGJI_TIMES = 5
OSS_PREFIX = 'gongdan'
# 权限码
PERM_VIEW = 'gdck666'
PERM_HANDLE = 'gdcl666'

View File

View File

View File

@@ -0,0 +1,24 @@
"""种子:俱乐部工单提现拦截开关。默认仅 lxs 打开。"""
from django.core.management.base import BaseCommand
from gongdan.services import ensure_club_gongdan_config
class Command(BaseCommand):
help = '初始化俱乐部工单配置complaint_withdraw_block'
def add_arguments(self, parser):
parser.add_argument('--club-id', default='lxs', help='俱乐部 ID默认 lxs')
parser.add_argument('--enable', action='store_true', default=True, help='打开提现拦截')
parser.add_argument('--disable', action='store_true', help='关闭提现拦截')
def handle(self, *args, **options):
club_id = (options.get('club_id') or 'lxs').strip()
enable = not options.get('disable')
row = ensure_club_gongdan_config(club_id, withdraw_block=enable)
if row.complaint_withdraw_block != enable:
row.complaint_withdraw_block = enable
row.save(update_fields=['complaint_withdraw_block', 'UpdateTime'])
self.stdout.write(self.style.SUCCESS(
f'club={club_id} complaint_withdraw_block={row.complaint_withdraw_block}'
))

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

@@ -0,0 +1,81 @@
# 工单:被投诉对象 / 平台介入 / 协商中状态 / 俱乐部提现拦截开关
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('gongdan', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='gongdan',
name='target_role',
field=models.CharField(blank=True, db_index=True, default='', max_length=16, verbose_name='被投诉角色'),
),
migrations.AddField(
model_name='gongdan',
name='target_user_id',
field=models.CharField(blank=True, db_index=True, default='', max_length=32, verbose_name='被投诉人UID'),
),
migrations.AddField(
model_name='gongdan',
name='platform_intervened',
field=models.BooleanField(db_index=True, default=False, verbose_name='平台已介入'),
),
migrations.AddField(
model_name='gongdan',
name='intervene_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='介入时间'),
),
migrations.AddField(
model_name='gongdan',
name='intervene_by',
field=models.CharField(blank=True, default='', max_length=32, verbose_name='介入操作人UID(仅存库)'),
),
migrations.AddField(
model_name='gongdan',
name='intervene_reason',
field=models.CharField(blank=True, default='', max_length=32, verbose_name='介入原因码'),
),
migrations.AlterField(
model_name='gongdan',
name='leixing',
field=models.IntegerField(
choices=[
(1, '订单投诉'), (2, '充值投诉'), (3, '罚款投诉'),
(4, '管事投诉'), (5, '被骗投诉'), (6, '组长投诉'),
],
db_index=True,
verbose_name='投诉类型',
),
),
migrations.AlterField(
model_name='gongdan',
name='zhuangtai',
field=models.IntegerField(
choices=[
(1, '处理中'), (2, '已处理'), (3, '不满意'),
(4, '已关闭'), (5, '协商中'),
],
db_index=True,
default=1,
verbose_name='状态',
),
),
migrations.CreateModel(
name='ClubGongdanConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16, unique=True, verbose_name='俱乐部')),
('complaint_withdraw_block', models.BooleanField(default=False, verbose_name='投诉拦截提现')),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '俱乐部工单配置',
'db_table': 'club_gongdan_config',
},
),
]

View File

@@ -0,0 +1,26 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('gongdan', '0002_gongdan_target_intervene'),
]
operations = [
migrations.AddField(
model_name='clubgongdanconfig',
name='human_kefu_wechat',
field=models.CharField(blank=True, default='', max_length=64, verbose_name='人工客服微信号'),
),
migrations.AddField(
model_name='clubgongdanconfig',
name='human_kefu_link',
field=models.CharField(blank=True, default='', max_length=512, verbose_name='人工客服链接'),
),
migrations.AddField(
model_name='clubgongdanconfig',
name='human_kefu_tip',
field=models.CharField(blank=True, default='', max_length=120, verbose_name='人工客服提示文案'),
),
]

View File

@@ -0,0 +1,26 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('gongdan', '0003_club_human_kefu'),
]
operations = [
migrations.AddField(
model_name='gongdan',
name='contact_qq',
field=models.CharField(blank=True, default='', max_length=32, verbose_name='联系QQ'),
),
migrations.AddField(
model_name='gongdan',
name='contact_wechat',
field=models.CharField(blank=True, default='', max_length=64, verbose_name='联系微信'),
),
migrations.AddField(
model_name='gongdan',
name='contact_phone',
field=models.CharField(blank=True, default='', max_length=20, verbose_name='联系手机'),
),
]

View File

101
gongdan/models.py Normal file
View File

@@ -0,0 +1,101 @@
"""投诉工单模型:主单 + 证据图 + 升级补充说明 + 俱乐部开关。"""
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='投诉类型')
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(兼容)')
# 通用被投诉对象(存库;接口对普通端脱敏不展示 UID
target_role = models.CharField(max_length=16, blank=True, default='', db_index=True, verbose_name='被投诉角色')
target_user_id = models.CharField(max_length=32, blank=True, default='', db_index=True, verbose_name='被投诉人UID')
shuoming = models.CharField(max_length=500, verbose_name='投诉说明')
# 投诉人自填联系方式(选填,便于客服回访)
contact_qq = models.CharField(max_length=32, blank=True, default='', verbose_name='联系QQ')
contact_wechat = models.CharField(max_length=64, blank=True, default='', verbose_name='联系微信')
contact_phone = models.CharField(max_length=20, blank=True, default='', 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='处理时间')
# 平台介入(对外只展示「平台已介入」,不露客服 UID
platform_intervened = models.BooleanField(default=False, db_index=True, verbose_name='平台已介入')
intervene_at = models.DateTimeField(null=True, blank=True, verbose_name='介入时间')
intervene_by = models.CharField(max_length=32, blank=True, default='', verbose_name='介入操作人UID(仅存库)')
intervene_reason = models.CharField(max_length=32, blank=True, default='', 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_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):
"""升级/继续投诉补充说明。"""
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']]
class ClubGongdanConfig(QModel):
"""俱乐部工单相关开关(默认全关,仅种子打开的俱乐部生效)。"""
club_id = models.CharField(max_length=16, unique=True, db_index=True, verbose_name='俱乐部')
# 未结清被投诉/牵连时禁止提现
complaint_withdraw_block = models.BooleanField(default=False, verbose_name='投诉拦截提现')
# 投诉页人工客服(分俱乐部配置;有则前端展示)
human_kefu_wechat = models.CharField(max_length=64, blank=True, default='', verbose_name='人工客服微信号')
human_kefu_link = models.CharField(max_length=512, blank=True, default='', verbose_name='人工客服链接')
human_kefu_tip = models.CharField(max_length=120, blank=True, default='', verbose_name='人工客服提示文案')
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'club_gongdan_config'
verbose_name = '俱乐部工单配置'

831
gongdan/services.py Normal file
View File

@@ -0,0 +1,831 @@
"""工单业务:创建 / 升级 / 客服处理 / 提现拦截 / 列表范围。"""
from __future__ import annotations
import logging
import random
import time
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from gongdan.constants import (
TYPE_ORDER, TYPE_RECHARGE, TYPE_PENALTY, TYPE_GUANSHI, TYPE_FRAUD, TYPE_ZUZHANG,
TYPE_LABELS, STATUS_PROCESSING, STATUS_DONE, STATUS_DISSATISFIED, STATUS_CLOSED,
STATUS_NEGOTIATING, STATUS_LABELS, OPEN_STATUSES, MAX_IMAGES, MAX_SHUOMING_LEN,
MAX_SHENGJI_TIMES, TARGET_ROLE_GUANSHI, TARGET_ROLE_ZUZHANG, TARGET_ROLE_LABELS,
TARGET_ROLE_DASHOU, TARGET_ROLE_PLATFORM,
)
from gongdan.models import Gongdan, GongdanImage, GongdanShengji, ClubGongdanConfig
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 is_complaint_withdraw_block_enabled(club_id: str) -> bool:
cid = (club_id or '').strip()
if not cid:
return False
row = ClubGongdanConfig.query.filter(club_id=cid).first()
return bool(row and row.complaint_withdraw_block)
def ensure_club_gongdan_config(club_id: str, *, withdraw_block: bool = False) -> ClubGongdanConfig:
cid = (club_id or '').strip()
row = ClubGongdanConfig.query.filter(club_id=cid).first()
if not row:
row = ClubGongdanConfig(club_id=cid, complaint_withdraw_block=bool(withdraw_block))
row.save()
return row
def get_club_gongdan_config(club_id: str) -> dict:
cid = (club_id or '').strip()
if not cid:
return {
'club_id': '',
'complaint_withdraw_block': False,
'human_kefu_wechat': '',
'human_kefu_link': '',
'human_kefu_tip': '',
'human_kefu_available': False,
}
row = ClubGongdanConfig.query.filter(club_id=cid).first()
wechat = ((row.human_kefu_wechat if row else '') or '').strip()
link = ((row.human_kefu_link if row else '') or '').strip()
tip = ((row.human_kefu_tip if row else '') or '').strip()
return {
'club_id': cid,
'complaint_withdraw_block': bool(row and row.complaint_withdraw_block),
'human_kefu_wechat': wechat,
'human_kefu_link': link,
'human_kefu_tip': tip or ('添加人工客服微信咨询' if wechat else ('点击联系人工客服' if link else '')),
'human_kefu_available': bool(wechat or link),
}
def set_complaint_withdraw_block(club_id: str, enabled: bool) -> dict:
cid = (club_id or '').strip()
if not cid:
raise GongdanError('俱乐部不能为空', 'need_club')
row = ensure_club_gongdan_config(cid, withdraw_block=bool(enabled))
enabled = bool(enabled)
if row.complaint_withdraw_block != enabled:
row.complaint_withdraw_block = enabled
row.save(update_fields=['complaint_withdraw_block', 'UpdateTime'])
return get_club_gongdan_config(cid)
def update_club_gongdan_config(club_id: str, **fields) -> dict:
"""更新俱乐部工单配置(提现拦截 / 人工客服联系方式)。"""
cid = (club_id or '').strip()
if not cid:
raise GongdanError('俱乐部不能为空', 'need_club')
row = ensure_club_gongdan_config(cid)
dirty = []
if 'complaint_withdraw_block' in fields and fields['complaint_withdraw_block'] is not None:
val = bool(fields['complaint_withdraw_block'])
if row.complaint_withdraw_block != val:
row.complaint_withdraw_block = val
dirty.append('complaint_withdraw_block')
if 'human_kefu_wechat' in fields and fields['human_kefu_wechat'] is not None:
val = str(fields['human_kefu_wechat'] or '').strip()[:64]
if (row.human_kefu_wechat or '') != val:
row.human_kefu_wechat = val
dirty.append('human_kefu_wechat')
if 'human_kefu_link' in fields and fields['human_kefu_link'] is not None:
val = str(fields['human_kefu_link'] or '').strip()[:512]
if (row.human_kefu_link or '') != val:
row.human_kefu_link = val
dirty.append('human_kefu_link')
if 'human_kefu_tip' in fields and fields['human_kefu_tip'] is not None:
val = str(fields['human_kefu_tip'] or '').strip()[:120]
if (row.human_kefu_tip or '') != val:
row.human_kefu_tip = val
dirty.append('human_kefu_tip')
if dirty:
dirty.append('UpdateTime')
row.save(update_fields=dirty)
return get_club_gongdan_config(cid)
def resolve_inviter_complaint_target(yonghuid: str, leixing: int) -> dict:
"""
管事/组长投诉只能针对本人邀请人,禁止搜索乱选:
- 管事投诉:打手的 yaoqingren且对方须是管事
- 组长投诉:管事的 yaoqingren且对方须是组长
无邀请人则不可投诉。
"""
uid = (yonghuid or '').strip()
if not uid:
raise GongdanError('请先登录', 'need_login')
from users.models import UserDashou, UserGuanshi, UserZuzhang
if leixing == TYPE_GUANSHI:
ds = UserDashou.query.filter(user__UserUID=uid).select_related('user').first()
if not ds:
raise GongdanError('仅打手可投诉自己的管事', 'need_dashou')
inviter = (ds.yaoqingren or '').strip()
if not inviter:
raise GongdanError('你没有邀请人,无法投诉管事', 'no_inviter')
gs = UserGuanshi.query.filter(user__UserUID=inviter, zhuangtai=1).select_related('user').first()
if not gs:
raise GongdanError('你的邀请人不是有效管事,无法投诉', 'inviter_not_guanshi')
return {
'role': TARGET_ROLE_GUANSHI,
'role_label': TARGET_ROLE_LABELS[TARGET_ROLE_GUANSHI],
'user_id': inviter,
'nicheng': _user_nicheng(inviter),
'can_complain': True,
}
if leixing == TYPE_ZUZHANG:
gs = UserGuanshi.query.filter(user__UserUID=uid).select_related('user').first()
if not gs:
raise GongdanError('仅管事可投诉自己的组长', 'need_guanshi')
inviter = (gs.yaoqingren or '').strip()
if not inviter:
raise GongdanError('你没有邀请人,无法投诉组长', 'no_inviter')
zz = UserZuzhang.query.filter(user__UserUID=inviter, zhuangtai=1).select_related('user').first()
if not zz:
raise GongdanError('你的邀请人不是有效组长,无法投诉', 'inviter_not_zuzhang')
return {
'role': TARGET_ROLE_ZUZHANG,
'role_label': TARGET_ROLE_LABELS[TARGET_ROLE_ZUZHANG],
'user_id': inviter,
'nicheng': _user_nicheng(inviter),
'can_complain': True,
}
raise GongdanError('仅管事/组长投诉需要邀请人', 'bad_type')
def peek_inviter_complaint_target(yonghuid: str, leixing: int) -> dict:
"""前端展示用:失败时返回 can_complain=False + 原因,不抛错。"""
try:
return resolve_inviter_complaint_target(yonghuid, leixing)
except GongdanError as e:
role = TARGET_ROLE_GUANSHI if leixing == TYPE_GUANSHI else TARGET_ROLE_ZUZHANG
return {
'role': role,
'role_label': TARGET_ROLE_LABELS.get(role, ''),
'user_id': '',
'nicheng': '',
'can_complain': False,
'reason': str(e),
}
def _user_nicheng(uid: str) -> str:
"""展示昵称:打手 nicheng → 老板 nickname → 用户{UID}。不把 UID 当昵称展示。"""
uid = (uid or '').strip()
if not uid:
return ''
try:
from users.models import UserDashou, UserBoss
from users.business_models import User
ds = UserDashou.query.filter(user__UserUID=uid).select_related('user').first()
if ds and (ds.nicheng or '').strip():
return (ds.nicheng or '').strip()[:32]
boss = UserBoss.query.filter(user__UserUID=uid).select_related('user').first()
if boss and (boss.nickname or '').strip():
return (boss.nickname or '').strip()[:32]
u = User.query.filter(UserUID=uid).first() or User.objects.filter(UserUID=uid).first()
if u and (getattr(u, 'Phone', None) or '').strip():
phone = str(u.Phone).strip()
if len(phone) >= 4:
return f'用户*{phone[-4:]}'
return '用户'
except Exception:
return '用户'
def _resolve_target(leixing, guanshi_id=None, target_role=None, target_user_id=None, *, yonghuid=None):
"""归一化被投诉对象。管事/组长投诉强制本人邀请人,忽略前端传入的选人。"""
role = (target_role or '').strip().lower()
tid = (target_user_id or '').strip()
gid = (guanshi_id or '').strip() or None
if leixing == TYPE_GUANSHI:
info = resolve_inviter_complaint_target(yonghuid, TYPE_GUANSHI)
tid = info['user_id']
return TARGET_ROLE_GUANSHI, tid, tid
if leixing == TYPE_ZUZHANG:
info = resolve_inviter_complaint_target(yonghuid, TYPE_ZUZHANG)
tid = info['user_id']
return TARGET_ROLE_ZUZHANG, tid, None
if role and tid:
if role not in TARGET_ROLE_LABELS and role not in ('order', 'recharge', 'penalty'):
raise GongdanError('无效的被投诉角色', 'bad_target_role')
return role, tid, gid if role == TARGET_ROLE_GUANSHI else None
return role or '', tid or '', gid
def validate_type_refs(leixing, order_id=None, chongzhi_id=None, fadan_id=None, guanshi_id=None):
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
from products.czjilu_types import CZJILU_PAID_STATUS
cz = Czjilu.query.filter(dingdan_id=chongzhi_id).first()
if not cz:
raise GongdanError('充值记录不存在请核对充值ID', 'chongzhi_not_found')
try:
z = int(cz.zhuangtai) if cz.zhuangtai is not None else None
except (TypeError, ValueError):
z = None
if z != CZJILU_PAID_STATUS:
raise GongdanError('仅已支付的充值单可投诉', 'chongzhi_not_paid')
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:
return None, None, None, guanshi_id
if leixing in (TYPE_FRAUD, TYPE_ZUZHANG):
return None, None, None, None
raise GongdanError('不支持的投诉类型', 'bad_type')
def _effective_target_uid(gd) -> str:
return (gd.target_user_id or gd.guanshi_id or '').strip()
def _effective_target_role(gd) -> str:
role = (gd.target_role or '').strip()
if role:
return role
if gd.leixing == TYPE_GUANSHI or gd.guanshi_id:
return TARGET_ROLE_GUANSHI
if gd.leixing == TYPE_ZUZHANG:
return TARGET_ROLE_ZUZHANG
return ''
def serialize_gongdan(gd, include_images=True, include_shengji=False, *, viewer_uid=None, for_admin=False):
"""
序列化。默认脱敏:不把申请人/被投诉人/客服 UID 作为展示字段下发。
for_admin=True 时仍不主动展示 UID 列,只多给内部审计需要的标记。
contact_peer_uid 仅当 viewer 是当事方时返回,供 IM 打开(前端勿渲染)。
"""
t_role = _effective_target_role(gd)
t_uid = _effective_target_uid(gd)
relation = ''
if viewer_uid:
if gd.shenqingren_id == viewer_uid:
relation = 'created'
elif t_uid and t_uid == viewer_uid:
relation = 'received'
else:
relation = 'linked' if _viewer_is_linked_zuzhang(viewer_uid, gd) else ''
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 '',
'shuoming': gd.shuoming or '',
'contact_qq': getattr(gd, 'contact_qq', '') or '',
'contact_wechat': getattr(gd, 'contact_wechat', '') or '',
'contact_phone': getattr(gd, 'contact_phone', '') or '',
'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 '',
# 对外:只显示「平台已介入」,不返回客服 UID
'platform_intervened': bool(gd.platform_intervened),
'intervene_at': gd.intervene_at.isoformat() if gd.intervene_at else '',
'intervene_reason': gd.intervene_reason 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 '',
'target_role': t_role,
'target_role_label': TARGET_ROLE_LABELS.get(t_role, t_role or ''),
'target_nicheng': _user_nicheng(t_uid),
'complainant_nicheng': _user_nicheng(gd.shenqingren_id),
'relation': relation,
'is_open': gd.zhuangtai in OPEN_STATUSES,
'can_contact': False,
'contact_peer_uid': '',
'contact_peer_nicheng': '',
'can_contact_complainant': False,
'contact_complainant_uid': '',
'contact_complainant_nicheng': '',
}
if viewer_uid and relation in ('created', 'received'):
peer = t_uid if relation == 'created' else gd.shenqingren_id
if peer and peer != viewer_uid:
data['can_contact'] = True
data['contact_peer_uid'] = peer
data['contact_peer_nicheng'] = _user_nicheng(peer)
elif viewer_uid and relation == 'linked':
# 牵连组长:默认可联系主被投诉管事;另可联系投诉人
if t_uid and t_uid != viewer_uid:
data['can_contact'] = True
data['contact_peer_uid'] = t_uid
data['contact_peer_nicheng'] = _user_nicheng(t_uid)
if gd.shenqingren_id and gd.shenqingren_id != viewer_uid:
data['can_contact_complainant'] = True
data['contact_complainant_uid'] = gd.shenqingren_id
data['contact_complainant_nicheng'] = _user_nicheng(gd.shenqingren_id)
if for_admin:
# 客服需要 UID 做内部处理;用户端绝不下发
data['shenqingren_id'] = gd.shenqingren_id or ''
data['target_user_id'] = t_uid or ''
data['guanshi_id'] = gd.guanshi_id or ''
data['chuliren_id'] = gd.chuliren_id or ''
data['chuliren_name'] = gd.chuliren_name or ('平台客服' if gd.platform_intervened or gd.chuliren_id else '')
else:
data['chuliren_name'] = '平台客服' if (gd.platform_intervened or gd.chuliren_name) 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 _viewer_is_linked_zuzhang(viewer_uid: str, gd) -> bool:
"""下级管事被投诉时,其上级组长视为牵连方。"""
t_uid = _effective_target_uid(gd)
t_role = _effective_target_role(gd)
if not t_uid or t_role != TARGET_ROLE_GUANSHI:
return False
try:
from users.models import UserGuanshi
gs = UserGuanshi.objects.filter(user__UserUID=t_uid).first()
if not gs:
return False
return (gs.yaoqingren or '').strip() == str(viewer_uid)
except Exception:
return False
def _sub_guanshi_uids(zuzhang_uid: str):
from users.models import UserGuanshi
rows = UserGuanshi.objects.filter(yaoqingren=str(zuzhang_uid)).select_related('user')
out = []
for r in rows:
uid = getattr(getattr(r, 'user', None), 'UserUID', None)
if uid:
out.append(str(uid))
return out
def open_complaint_q_for_target_uids(uids):
"""未结清且指向这些 UID 的工单条件。"""
uids = [str(u) for u in (uids or []) if u]
if not uids:
return Q(pk__in=[])
return (
Q(zhuangtai__in=OPEN_STATUSES)
& (Q(target_user_id__in=uids) | Q(guanshi_id__in=uids))
)
def check_complaint_blocks_withdraw(user_main, club_id=None):
"""
提现拦截。
返回 (ok, msg, extra)
extra: reason=received|linked, tab, count
"""
try:
from jituan.services.club_user import get_user_club_id
cid = (club_id or get_user_club_id(user_main) or '').strip()
except Exception:
cid = (club_id or '').strip()
if not is_complaint_withdraw_block_enabled(cid):
return True, '', {}
uid = str(user_main.UserUID)
base = Gongdan.query.filter(club_id=cid) if cid else Gongdan.query.all()
# 1) 自己是主被投诉人
qs_recv = base.filter(open_complaint_q_for_target_uids([uid]))
cnt = qs_recv.count()
if cnt:
return False, '你有未处理完的投诉,请处理后再提现', {
'reason': 'received',
'tab': 'received',
'count': cnt,
'code': 'COMPLAINT_BLOCK_WITHDRAW',
}
# 2) 自己是组长,下级管事被投诉 → 牵连
try:
from users.models import UserZuzhang
is_zz = UserZuzhang.query.filter(user=user_main).exists()
except Exception:
is_zz = False
if is_zz:
subs = _sub_guanshi_uids(uid)
if subs:
qs_link = base.filter(open_complaint_q_for_target_uids(subs)).filter(
Q(target_role=TARGET_ROLE_GUANSHI) | Q(target_role='') | Q(leixing=TYPE_GUANSHI)
)
cnt2 = qs_link.count()
if cnt2:
return False, '下级管事有未处理投诉,暂不可提现', {
'reason': 'linked',
'tab': 'linked',
'count': cnt2,
'code': 'COMPLAINT_BLOCK_WITHDRAW',
}
return True, '', {}
def search_complaint_targets(*, role, keyword='', club_id=None, page=1, page_size=20, yonghuid=None):
"""已废弃搜索选人仅返回本人邀请人防乱投诉。keyword/club 忽略。"""
role = (role or '').strip().lower()
if role == TARGET_ROLE_GUANSHI:
leixing = TYPE_GUANSHI
elif role == TARGET_ROLE_ZUZHANG:
leixing = TYPE_ZUZHANG
else:
raise GongdanError('role 须为 guanshi 或 zuzhang', 'bad_role')
info = peek_inviter_complaint_target(yonghuid, leixing)
rows = []
if info.get('can_complain') and info.get('user_id'):
rows.append({
'user_id': info['user_id'],
'nicheng': info.get('nicheng') or '',
'role': info.get('role') or role,
'role_label': info.get('role_label') or '',
})
return {
'list': rows,
'total': len(rows),
'page': 1,
'page_size': 1,
'role': role,
'can_complain': bool(info.get('can_complain')),
'reason': info.get('reason') or '',
'invite_only': True,
}
def list_gongdan_for_user(yonghuid, *, scope='created', club_id=None, page=1, page_size=20):
"""
scope:
created — 我发起的
received — 我被投诉的
linked — 下级牵连(组长)
"""
scope = (scope or 'created').strip().lower()
page = max(int(page or 1), 1)
page_size = min(max(int(page_size or 20), 1), 50)
uid = str(yonghuid)
qs = Gongdan.query.all()
if club_id:
qs = qs.filter(club_id=club_id)
if scope == 'received':
qs = qs.filter(Q(target_user_id=uid) | Q(guanshi_id=uid)).order_by('-CreateTime')
elif scope == 'linked':
subs = _sub_guanshi_uids(uid)
if not subs:
qs = qs.none()
else:
qs = qs.filter(Q(target_user_id__in=subs) | Q(guanshi_id__in=subs)).filter(
Q(target_role=TARGET_ROLE_GUANSHI) | Q(target_role='') | Q(leixing=TYPE_GUANSHI)
).order_by('-CreateTime')
else:
qs = qs.filter(shenqingren_id=uid).order_by('-CreateTime')
total = qs.count()
start = (page - 1) * page_size
rows = list(qs[start:start + page_size])
return {
'list': [serialize_gongdan(g, include_images=False, viewer_uid=uid) for g in rows],
'total': total,
'page': page,
'page_size': page_size,
'scope': scope,
}
def user_can_view_gongdan(gd, yonghuid) -> bool:
uid = str(yonghuid)
if gd.shenqingren_id == uid:
return True
if _effective_target_uid(gd) == uid:
return True
if _viewer_is_linked_zuzhang(uid, gd):
return True
return False
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,
target_role=None,
target_user_id=None,
image_urls=None,
contact_qq=None,
contact_wechat=None,
contact_phone=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 or target_user_id,
)
t_role, t_uid, guanshi_id = _resolve_target(
leixing,
guanshi_id=guanshi_id,
target_role=target_role,
target_user_id=target_user_id or guanshi_id,
yonghuid=yonghuid,
)
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,
target_role=t_role or '',
target_user_id=t_uid or '',
shuoming=text,
contact_qq=_trim(contact_qq or '', 32),
contact_wechat=_trim(contact_wechat or '', 64),
contact_phone=_trim(contact_phone or '', 20),
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 target=%s/%s', gid, leixing, yonghuid, t_role, t_uid)
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)
return gd
@transaction.atomic
def handle_gongdan(
*,
gongdan_id,
handler_uid,
handler_name,
action,
chuli_jieguo='',
intervene_reason='',
):
"""
action:
done/finish/resolve → 已处理
close → 关闭
processing/reopen → 处理中
dissatisfied → 不满意
intervene → 标记平台介入(不改结案状态)
negotiate → 协商中(客服 sparingly
"""
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 ('intervene', 'platform_intervene'):
gd.platform_intervened = True
gd.intervene_at = timezone.now()
gd.intervene_by = (handler_uid or '')[:32]
gd.intervene_reason = (intervene_reason or 'kefu')[:32]
if gd.zhuangtai not in OPEN_STATUSES:
gd.zhuangtai = STATUS_PROCESSING
gd.save()
logger.info('工单平台介入 %s by=%s', gongdan_id, handler_uid)
return gd
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()
if not gd.platform_intervened:
gd.platform_intervened = True
gd.intervene_at = gd.intervene_at or timezone.now()
gd.intervene_by = (handler_uid or '')[:32]
gd.intervene_reason = gd.intervene_reason or 'kefu_done'
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 ('negotiate',):
gd.zhuangtai = STATUS_NEGOTIATING
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
@transaction.atomic
def user_resolve_gongdan(*, 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 not in OPEN_STATUSES:
raise GongdanError('当前状态无需确认解决', 'bad_status')
gd.zhuangtai = STATUS_DONE
gd.chuli_jieguo = gd.chuli_jieguo or '双方协商已解决'
gd.chuli_time = timezone.now()
gd.chuliren_name = gd.chuliren_name or '双方协商'
gd.save()
return gd
@transaction.atomic
def user_start_negotiate(*, gongdan_id, yonghuid):
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
if not gd:
raise GongdanError('工单不存在', 'not_found')
if not user_can_view_gongdan(gd, yonghuid):
raise GongdanError('无权操作此工单', 'forbidden')
if gd.zhuangtai not in (STATUS_PROCESSING, STATUS_DISSATISFIED, STATUS_NEGOTIATING):
raise GongdanError('当前状态不可转协商', 'bad_status')
gd.zhuangtai = STATUS_NEGOTIATING
gd.save(update_fields=['zhuangtai', 'UpdateTime'])
return gd
@transaction.atomic
def user_apply_intervene(*, 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 and _effective_target_uid(gd) != yonghuid:
raise GongdanError('无权申请介入', 'forbidden')
if gd.zhuangtai not in OPEN_STATUSES:
raise GongdanError('当前状态不可申请介入', 'bad_status')
gd.platform_intervened = True
if not gd.intervene_at:
gd.intervene_at = timezone.now()
gd.intervene_reason = gd.intervene_reason or 'user_apply'
if gd.zhuangtai == STATUS_NEGOTIATING:
gd.zhuangtai = STATUS_PROCESSING
gd.save()
return gd

41
gongdan/urls.py Normal file
View File

@@ -0,0 +1,41 @@
from django.urls import path
from gongdan.views import (
GongdanUploadImageView,
GongdanCreateView,
GongdanMyListView,
GongdanDetailView,
GongdanEscalateView,
GongdanDissatisfiedView,
GongdanResolveView,
GongdanNegotiateView,
GongdanApplyInterveneView,
GongdanWithdrawCheckView,
GongdanSearchTargetView,
GongdanMyInviterTargetView,
GongdanMetaView,
ChongzhiRecordListView,
GongdanAdminListView,
GongdanAdminHandleView,
GongdanAdminClubConfigView,
)
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('resolve/', GongdanResolveView.as_view(), name='投诉人确认解决'),
path('negotiate/', GongdanNegotiateView.as_view(), name='转协商中'),
path('apply-intervene/', GongdanApplyInterveneView.as_view(), name='申请平台介入'),
path('withdraw-block-check/', GongdanWithdrawCheckView.as_view(), name='提现投诉拦截检查'),
path('search-target/', GongdanSearchTargetView.as_view(), name='搜索被投诉对象'),
path('my-inviter-target/', GongdanMyInviterTargetView.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='客服处理工单'),
path('admin/club-config/', GongdanAdminClubConfigView.as_view(), name='工单俱乐部开关'),
]

602
gongdan/views.py Normal file
View File

@@ -0,0 +1,602 @@
"""投诉工单 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,
TYPE_GUANSHI, TYPE_ZUZHANG,
)
from gongdan.models import Gongdan
from gongdan.services import (
GongdanError,
create_gongdan,
escalate_gongdan,
handle_gongdan,
serialize_gongdan,
user_mark_dissatisfied,
list_gongdan_for_user,
user_can_view_gongdan,
user_resolve_gongdan,
user_start_negotiate,
user_apply_intervene,
check_complaint_blocks_withdraw,
search_complaint_targets,
get_club_gongdan_config,
set_complaint_withdraw_block,
update_club_gongdan_config,
peek_inviter_complaint_target,
)
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'),
target_role=d.get('target_role'),
target_user_id=d.get('target_user_id') or d.get('zuzhang_id') or d.get('guanshi_id'),
image_urls=images,
contact_qq=d.get('contact_qq') or d.get('qq') or '',
contact_wechat=d.get('contact_wechat') or d.get('wechat') or d.get('wx') or '',
contact_phone=d.get('contact_phone') or d.get('phone') or d.get('mobile') or '',
)
return _ok(
serialize_gongdan(gd, include_images=True, include_shengji=True, viewer_uid=request.user.UserUID),
'提交成功',
)
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):
scope = (
request.data.get('scope')
or request.data.get('tab')
or request.query_params.get('scope')
or request.query_params.get('tab')
or 'created'
)
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)
club_id = _resolve_club(request)
data = list_gongdan_for_user(
request.user.UserUID,
scope=scope,
club_id=club_id,
page=page,
page_size=page_size,
)
return _ok(data)
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 user_can_view_gongdan(gd, request.user.UserUID):
return _ok(serialize_gongdan(
gd, include_images=True, include_shengji=True, viewer_uid=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 = 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, for_admin=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, viewer_uid=request.user.UserUID),
'已提交补充投诉',
)
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, viewer_uid=request.user.UserUID), '已标记不满意,可继续投诉')
except GongdanError as e:
return _err(str(e))
class GongdanResolveView(APIView):
"""投诉人确认已解决。POST /gongdan/resolve/"""
permission_classes = [IsAuthenticated]
def post(self, request):
try:
gd = user_resolve_gongdan(
gongdan_id=(request.data.get('gongdan_id') or '').strip(),
yonghuid=request.user.UserUID,
)
return _ok(serialize_gongdan(gd, viewer_uid=request.user.UserUID), '已确认解决')
except GongdanError as e:
return _err(str(e))
class GongdanNegotiateView(APIView):
"""转协商中。POST /gongdan/negotiate/"""
permission_classes = [IsAuthenticated]
def post(self, request):
try:
gd = user_start_negotiate(
gongdan_id=(request.data.get('gongdan_id') or '').strip(),
yonghuid=request.user.UserUID,
)
return _ok(serialize_gongdan(gd, viewer_uid=request.user.UserUID), '已进入协商')
except GongdanError as e:
return _err(str(e))
class GongdanApplyInterveneView(APIView):
"""申请平台介入。POST /gongdan/apply-intervene/"""
permission_classes = [IsAuthenticated]
def post(self, request):
try:
gd = user_apply_intervene(
gongdan_id=(request.data.get('gongdan_id') or '').strip(),
yonghuid=request.user.UserUID,
)
return _ok(serialize_gongdan(gd, viewer_uid=request.user.UserUID), '已申请平台介入')
except GongdanError as e:
return _err(str(e))
class GongdanWithdrawCheckView(APIView):
"""提现前投诉拦截检查。POST /gongdan/withdraw-block-check/"""
permission_classes = [IsAuthenticated]
def post(self, request):
ok, msg, extra = check_complaint_blocks_withdraw(request.user, club_id=_resolve_club(request))
return _ok({
'blocked': not ok,
'msg': msg,
'reason': extra.get('reason') or '',
'tab': extra.get('tab') or '',
'count': extra.get('count') or 0,
})
class GongdanSearchTargetView(APIView):
"""本人邀请人已禁止全库搜索。POST /gongdan/search-target/"""
permission_classes = [IsAuthenticated]
def post(self, request):
d = request.data
try:
data = search_complaint_targets(
role=d.get('role') or d.get('target_role') or '',
keyword=d.get('keyword') or d.get('q') or '',
club_id=_resolve_club(request),
page=d.get('page') or 1,
page_size=d.get('page_size') or 20,
yonghuid=request.user.UserUID,
)
return _ok(data)
except GongdanError as e:
return _err(str(e))
class GongdanMyInviterTargetView(APIView):
"""查询本人可投诉的邀请人。POST /gongdan/my-inviter-target/ body: {leixing:4|6}"""
permission_classes = [IsAuthenticated]
def post(self, request):
try:
leixing = int(request.data.get('leixing') or 0)
except (TypeError, ValueError):
return _err('投诉类型无效')
if leixing not in (TYPE_GUANSHI, TYPE_ZUZHANG):
return _err('仅管事/组长投诉需要邀请人')
return _ok(peek_inviter_complaint_target(request.user.UserUID, leixing))
class GongdanMetaView(APIView):
"""类型/状态枚举 + 人工客服联系方式。GET /gongdan/meta/"""
permission_classes = [IsAuthenticated]
def get(self, request):
club_id = _resolve_club(request)
cfg = get_club_gongdan_config(club_id) if club_id else get_club_gongdan_config('')
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,
'human_kefu': {
'wechat': cfg.get('human_kefu_wechat') or '',
'link': cfg.get('human_kefu_link') or '',
'tip': cfg.get('human_kefu_tip') or '',
'available': bool(cfg.get('human_kefu_available')),
},
'inviter_targets': {
'guanshi': peek_inviter_complaint_target(request.user.UserUID, TYPE_GUANSHI),
'zuzhang': peek_inviter_complaint_target(request.user.UserUID, TYPE_ZUZHANG),
},
})
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))
# 投诉选单:仅已支付;普通充值记录页不传此参数则展示全部状态
only_paid = str(
request.data.get('only_paid')
or request.data.get('for_complaint')
or ''
).lower() in ('1', 'true', 'yes')
if only_paid:
from products.czjilu_types import CZJILU_PAID_STATUS
qs = qs.filter(zhuangtai=CZJILU_PAID_STATUS)
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: '罚款',
7: '转盘',
}
# 与 czjilu 实际状态一致:待支付/已取消/已支付等全量展示,不筛选
status_map = {
9: '待支付',
3: '已支付',
2: '已取消',
1: '处理中',
8: '履约失败',
10: '业务失败',
0: '未知',
}
rows = []
for o in qs[start:start + page_size]:
z = o.zhuangtai
try:
z_int = int(z) if z is not None else None
except (TypeError, ValueError):
z_int = None
ct = ''
if getattr(o, 'CreateTime', None):
try:
ct = o.CreateTime.strftime('%Y-%m-%d %H:%M:%S')
except Exception:
ct = o.CreateTime.isoformat()
rows.append({
'dingdan_id': o.dingdan_id,
'leixing': o.leixing,
'leixing_label': type_map.get(o.leixing, f'类型{o.leixing}'),
'jine': str(o.jine),
'zhuangtai': z_int if z_int is not None else z,
'zhuangtai_label': status_map.get(z_int, f'状态{z}' if z is not None else '未知'),
'shuoming': o.shuoming or '',
'club_id': getattr(o, 'club_id', '') or '',
'CreateTime': ct,
})
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) | Q(zhuangtai=5))
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) | Q(zhuangtai=5))
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(target_user_id__icontains=keyword)
| Q(guanshi_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_admin=True)
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(),
'negotiating': stats_qs.filter(zhuangtai=5).count(),
'escalated': stats_qs.filter(shengji_cishu__gt=0).filter(
Q(zhuangtai=1) | Q(zhuangtai=3) | Q(zhuangtai=5)
).count(),
'need_handle': stats_qs.filter(Q(zhuangtai=1) | Q(zhuangtai=3) | Q(zhuangtai=5)).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 '',
intervene_reason=d.get('intervene_reason') or '',
)
return _ok(
serialize_gongdan(gd, include_images=True, include_shengji=True, for_admin=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 GongdanAdminClubConfigView(APIView):
"""
当前俱乐部工单开关 + 人工客服联系方式。
GET/POST /gongdan/admin/club-config/
字段complaint_withdraw_block / human_kefu_wechat / human_kefu_link / human_kefu_tip
"""
permission_classes = [IsAuthenticated]
def _club_id(self, request):
try:
from jituan.services.club_context import resolve_club_id_from_request
return (resolve_club_id_from_request(request) or '').strip()
except Exception:
return (getattr(request.user, 'ClubID', None) or getattr(request.user, 'club_id', None) or '').strip()
def get(self, request):
kefu, perms, err = _require_kefu_perm(request, PERM_VIEW, PERM_HANDLE)
if err:
return err
club_id = self._club_id(request)
if not club_id:
return _err('请先选择俱乐部')
return _ok(get_club_gongdan_config(club_id))
def post(self, request):
kefu, perms, err = _require_kefu_perm(request, PERM_HANDLE)
if err:
return err
club_id = self._club_id(request)
if not club_id:
return _err('请先选择俱乐部')
d = request.data or {}
patch = {}
if 'complaint_withdraw_block' in d:
raw = d.get('complaint_withdraw_block')
if isinstance(raw, str):
patch['complaint_withdraw_block'] = raw.strip().lower() in ('1', 'true', 'yes', 'on')
else:
patch['complaint_withdraw_block'] = bool(raw)
for key in ('human_kefu_wechat', 'human_kefu_link', 'human_kefu_tip'):
if key in d:
patch[key] = d.get(key)
if not patch:
return _err('缺少可保存字段')
try:
# 兼容旧前端只传 withdraw 开关
if list(patch.keys()) == ['complaint_withdraw_block']:
data = set_complaint_withdraw_block(club_id, patch['complaint_withdraw_block'])
else:
data = update_club_gongdan_config(club_id, **patch)
return _ok(data, '已保存')
except GongdanError as e:
return _err(str(e))

View File

@@ -28,3 +28,40 @@ ADMIN_SCOPE_CLUB = 'CLUB'
# 集团最高权限账号(写死超级管理员,拥有全部功能权限与集团视图) # 集团最高权限账号(写死超级管理员,拥有全部功能权限与集团视图)
SUPER_ADMIN_PHONES = frozenset({'1383714110'}) SUPER_ADMIN_PHONES = frozenset({'1383714110'})
# 权限码:修改平台订单/商品抢单要求(须角色显式绑定,不随本店超管 000001 自动解锁)
PERM_GRAB_REQUIREMENT_EDIT = '002ad'
# —— 支付通道 ——
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,76 @@
"""紧急封杀后台账号:硬删任职、摘超管、禁用客服、改密、清角色。
用法:
python manage.py nuke_kefu_admin 13837714110
"""
from django.core.management.base import BaseCommand
from django.utils import timezone
class Command(BaseCommand):
help = '紧急封杀指定手机号的后台权限(硬删除任职)'
def add_arguments(self, parser):
parser.add_argument('phone', type=str, help='要封杀的手机号')
parser.add_argument(
'--also-whitelist',
action='store_true',
help='连代码白名单手机号也允许封',
)
def handle(self, *args, **options):
phone = (options['phone'] or '').strip()
if not phone:
self.stderr.write('缺少手机号')
return
from jituan.constants import SUPER_ADMIN_PHONES
if phone in SUPER_ADMIN_PHONES and not options['also_whitelist']:
self.stderr.write(
f'{phone} 在白名单,拒绝。确要封加 --also-whitelist'
)
return
from users.business_models import User
from jituan.models import AdminAssignment
from gvsdsdk.models import UserRole as SdkUserRole
import bcrypt
users = list(User.objects.filter(Phone=phone))
if not users:
self.stderr.write(f'未找到 {phone}')
return
new_pwd = f'LOCKED_{phone}_REVOKED'
for u in users:
self.stdout.write(f'HIT uid={u.UserUID} super={u.IsSuperuser}')
u.IsSuperuser = False
u.IsStaff = False
u.SetPassword(new_pwd)
u.save()
n, _ = AdminAssignment.objects.filter(yonghuid=u.UserUID).delete()
self.stdout.write(f' 硬删任职={n}')
n_role = SdkUserRole.objects.filter(UserUUID=u.UserUUID).delete()
self.stdout.write(f' 删功能角色={n_role}')
try:
kefu = u.KefuProfile
kefu.zhuangtai = 0
kefu.erjimima = bcrypt.hashpw(
b'LOCKED_REVOKED', bcrypt.gensalt(rounds=12)
).decode('utf-8')
kefu.save(update_fields=['zhuangtai', 'erjimima', 'UpdateTime'])
self.stdout.write(' 客服已禁用')
except Exception as e:
self.stdout.write(f' 无客服扩展: {e}')
# 顺手清全库 NULL 俱乐部垃圾重复
from jituan.services.admin_assignments import dedupe_active_assignments
d = dedupe_active_assignments()
self.stdout.write(f'全库去重硬删重复={d}')
self.stdout.write(self.style.SUCCESS(
f'已封杀 {phone}。密码={new_pwd}。请 restart gunicorn。'
))

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

View File

@@ -0,0 +1,125 @@
"""写入资金冻结相关权限码,并可按俱乐部挂到角色(可重复执行)。
权限码:
djcfg001 资金冻结规则配置
djled001 资金冻结明细/手动解冻
djrisk001 罚款投诉风险指标
用法:
python manage.py seed_fund_freeze_perm
python manage.py seed_fund_freeze_perm --club-id lxs --role-name 管理员
python manage.py seed_fund_freeze_perm --all-clubs --role-name 管理员 --dry-run
"""
import uuid
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone
from gvsdsdk.models import Permission, Role, RolePermission
from jituan.models import Club
PERMS = [
('djcfg001', '资金冻结规则配置', '配置本俱乐部各角色冻结比例/分期/解冻条件', '资金冻结'),
('djled001', '资金冻结明细', '查看冻结流水并手动解冻', '资金冻结'),
('djrisk001', '罚款投诉风险指标', '查看罚款率/投诉率等解冻相关指标', '资金冻结'),
]
class Command(BaseCommand):
help = '写入资金冻结权限码,并按俱乐部挂到角色'
def add_arguments(self, parser):
parser.add_argument('--club-id', default='', help='仅处理该俱乐部')
parser.add_argument('--all-clubs', action='store_true')
parser.add_argument('--role-name', default='', help='角色名;空则只确保权限码')
parser.add_argument('--dry-run', action='store_true')
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_objs = []
for code, name, desc, cat in PERMS:
perm = Permission.objects.filter(PermCode=code).first()
if perm:
self.stdout.write(f' 权限码已存在: {code}')
if not dry_run:
changed = False
if perm.PermName != name:
perm.PermName = name
changed = True
if (perm.PermDesc or '') != desc:
perm.PermDesc = desc
changed = True
if (perm.PermCategory or '') != cat:
perm.PermCategory = cat
changed = True
if changed:
perm.save(update_fields=['PermName', 'PermDesc', 'PermCategory'])
else:
if dry_run:
self.stdout.write(f'would create perm {code}')
else:
perm = Permission.objects.create(
PermUUID=uuid.uuid4().bytes,
TenantUUID=tenant_uuid,
PermCode=code,
PermName=name,
PermDesc=desc,
PermCategory=cat,
PermStatus=1,
CreateTime=timezone.now(),
UpdateTime=timezone.now(),
)
self.stdout.write(self.style.SUCCESS(f' 已创建权限码: {code}'))
if perm:
perm_objs.append(perm)
if not role_name:
self.stdout.write('未指定 --role-name跳过挂角色')
return
clubs = []
if all_clubs:
clubs = list(Club.query.filter(status=1).values_list('club_id', flat=True))
elif club_id:
clubs = [club_id]
else:
self.stdout.write(self.style.WARNING('请指定 --club-id 或 --all-clubs'))
return
with transaction.atomic():
for cid in clubs:
roles = Role.objects.filter(RoleName=role_name, ClubID=cid, RoleStatus=1)
if not roles.exists():
# 兼容无 ClubID 字段旧数据:仅精确 club 时再试全局同名
roles = Role.objects.filter(RoleName=role_name, RoleStatus=1)
roles = [r for r in roles if (getattr(r, 'ClubID', None) or '') == cid]
for role in roles:
for perm in perm_objs:
exists = RolePermission.objects.filter(
RoleUUID=role.RoleUUID, PermUUID=perm.PermUUID,
).exists()
if exists:
self.stdout.write(f' 已挂: {cid} {role_name} <- {perm.PermCode}')
continue
if dry_run:
self.stdout.write(f'would link {cid} {role_name} <- {perm.PermCode}')
else:
RolePermission.objects.create(
RoleUUID=role.RoleUUID,
PermUUID=perm.PermUUID,
CreateTime=timezone.now(),
)
self.stdout.write(self.style.SUCCESS(
f' 已挂: {cid} {role_name} <- {perm.PermCode}'
))

View File

@@ -26,12 +26,60 @@ EXTRA_PERMISSIONS = [
'配置客服欢迎语、发图确认弹窗、关键词自动回复(会话管理-客服聊天配置)', '配置客服欢迎语、发图确认弹窗、关键词自动回复(会话管理-客服聊天配置)',
'会话管理', '会话管理',
), ),
(
'gdck666',
'投诉工单查看',
'查看用户投诉工单列表与详情',
'投诉工单',
),
(
'gdcl666',
'投诉工单处理',
'处理/关闭用户投诉工单',
'投诉工单',
),
( (
'sfbq666', 'sfbq666',
'身份装饰标签管理', '身份装饰标签管理',
'管理小程序身份装饰标签(与用户管理-身份装饰标签菜单对应)', '管理小程序身份装饰标签(与用户管理-身份装饰标签菜单对应)',
'用户管理', '用户管理',
), ),
(
'005bb',
'提现审核(按俱乐部)',
'审核本俱乐部提现(同意/拒绝/自动打款)。跨俱乐部仅集团高管;须角色 ClubID + 数据范围任职。',
'提现管理',
),
(
'djcfg001',
'资金冻结规则配置',
'配置本俱乐部各角色冻结比例/分期/解冻条件',
'资金冻结',
),
(
'djled001',
'资金冻结明细',
'查看冻结流水并手动解冻',
'资金冻结',
),
(
'djrisk001',
'罚款投诉风险指标',
'查看罚款率/投诉率等解冻相关指标',
'资金冻结',
),
(
'xycfg001',
'角色协议配置',
'配置打手/管事/组长用户协议并发布版本',
'小程序配置',
),
(
'002ad',
'修改抢单要求',
'修改平台订单/本店商品的抢单要求类型(会员/押金门槛)。须显式绑定;本店超管 000001 不会自动拥有。',
'订单管理',
),
# ---------- 店铺管理999 系列:列表/详情/复制/提现等) ---------- # ---------- 店铺管理999 系列:列表/详情/复制/提现等) ----------
('999a', '店铺状态管理', '修改店铺封禁/正常状态', '店铺管理'), ('999a', '店铺状态管理', '修改店铺封禁/正常状态', '店铺管理'),
('999b', '店铺余额与创建', '修改可提现余额、添加新店铺', '店铺管理'), ('999b', '店铺余额与创建', '修改可提现余额、添加新店铺', '店铺管理'),

View File

@@ -37,3 +37,12 @@ class Command(BaseCommand):
call_command('seed_gvsdsdk_permissions') call_command('seed_gvsdsdk_permissions')
except Exception as e: except Exception as e:
self.stdout.write(self.style.WARNING(f'seed_gvsdsdk_permissions 跳过: {e}')) self.stdout.write(self.style.WARNING(f'seed_gvsdsdk_permissions 跳过: {e}'))
try:
from django.core.management import call_command
call_command('seed_fund_freeze_perm')
except Exception as e:
self.stdout.write(self.style.WARNING(f'seed_fund_freeze_perm 跳过: {e}'))
try:
call_command('seed_role_agreement_perm')
except Exception as e:
self.stdout.write(self.style.WARNING(f'seed_role_agreement_perm 跳过: {e}'))

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,119 @@
"""写入角色协议权限码,并可按俱乐部挂到角色。
权限码:
xycfg001 角色协议配置
用法:
python manage.py seed_role_agreement_perm
python manage.py seed_role_agreement_perm --club-id lxs --role-name 管理员
"""
import uuid
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone
from gvsdsdk.models import Permission, Role, RolePermission
from jituan.models import Club
PERMS = [
('xycfg001', '角色协议配置', '配置打手/管事/组长用户协议并发布版本', '小程序配置'),
]
class Command(BaseCommand):
help = '写入角色协议权限码,并按俱乐部挂到角色'
def add_arguments(self, parser):
parser.add_argument('--club-id', default='', help='仅处理该俱乐部')
parser.add_argument('--all-clubs', action='store_true')
parser.add_argument('--role-name', default='', help='角色名;空则只确保权限码')
parser.add_argument('--dry-run', action='store_true')
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_objs = []
for code, name, desc, cat in PERMS:
perm = Permission.objects.filter(PermCode=code).first()
if perm:
self.stdout.write(f' 权限码已存在: {code}')
if not dry_run:
changed = False
if perm.PermName != name:
perm.PermName = name
changed = True
if (perm.PermDesc or '') != desc:
perm.PermDesc = desc
changed = True
if (perm.PermCategory or '') != cat:
perm.PermCategory = cat
changed = True
if changed:
perm.save(update_fields=['PermName', 'PermDesc', 'PermCategory'])
else:
if dry_run:
self.stdout.write(f'would create perm {code}')
else:
perm = Permission.objects.create(
PermUUID=uuid.uuid4().bytes,
TenantUUID=tenant_uuid,
PermCode=code,
PermName=name,
PermDesc=desc,
PermCategory=cat,
PermStatus=1,
CreateTime=timezone.now(),
UpdateTime=timezone.now(),
)
self.stdout.write(self.style.SUCCESS(f' 已创建权限码: {code}'))
if perm:
perm_objs.append(perm)
if not role_name:
self.stdout.write('未指定 --role-name跳过挂角色')
return
clubs = []
if all_clubs:
clubs = list(Club.query.filter(status=1).values_list('club_id', flat=True))
elif club_id:
clubs = [club_id]
else:
self.stdout.write(self.style.WARNING('请指定 --club-id 或 --all-clubs'))
return
with transaction.atomic():
for cid in clubs:
roles = Role.objects.filter(RoleName=role_name, ClubID=cid, RoleStatus=1)
if not roles.exists():
roles = Role.objects.filter(RoleName=role_name, RoleStatus=1)
roles = [r for r in roles if (getattr(r, 'ClubID', None) or '') == cid]
for role in roles:
for perm in perm_objs:
exists = RolePermission.objects.filter(
RoleUUID=role.RoleUUID, PermUUID=perm.PermUUID,
).exists()
if exists:
self.stdout.write(f' 已挂: {cid} {role_name} <- {perm.PermCode}')
continue
if dry_run:
self.stdout.write(f'would link {cid} {role_name} <- {perm.PermCode}')
else:
RolePermission.objects.create(
RoleUUID=role.RoleUUID,
PermUUID=perm.PermUUID,
CreateTime=timezone.now(),
)
self.stdout.write(self.style.SUCCESS(
f' 已挂: {cid} {role_name} <- {perm.PermCode}'
))

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,38 @@
# -*- coding: utf-8 -*-
"""写入俱乐部服务号配置H5 网页授权)。不改小程序 wx_appid。"""
from django.core.management.base import BaseCommand, CommandError
from jituan.models import Club
class Command(BaseCommand):
help = '设置俱乐部 official_appid / official_secret / h5_domain'
def add_arguments(self, parser):
parser.add_argument('--club-id', required=True)
parser.add_argument('--official-appid', required=True)
parser.add_argument('--official-secret', required=True)
parser.add_argument('--h5-domain', default='')
def handle(self, *args, **options):
club_id = (options['club_id'] or '').strip()
appid = (options['official_appid'] or '').strip()
secret = (options['official_secret'] or '').strip()
h5_domain = (options['h5_domain'] or '').strip()
if not club_id or not appid or not secret:
raise CommandError('club-id / official-appid / official-secret 必填')
try:
club = Club.query.get(club_id=club_id)
except Club.DoesNotExist:
raise CommandError(f'俱乐部不存在: {club_id}')
club.official_appid = appid
club.official_secret = secret
if h5_domain:
club.h5_domain = h5_domain
club.save()
self.stdout.write(self.style.SUCCESS(
f'已更新 club={club_id} official_appid={appid} '
f'secret_len={len(secret)} h5_domain={club.h5_domain or "(未改)"}'
))

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

@@ -0,0 +1,131 @@
# Generated manually for fund freeze P0 tables
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('jituan', '0016_club_order_grab_link'),
]
operations = [
migrations.CreateModel(
name='ClubFundFreezeConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16, verbose_name='俱乐部')),
('role', models.CharField(choices=[('dashou', '打手'), ('guanshi', '管事'), ('zuzhang', '组长'), ('shenheguan', '考核官')], db_index=True, max_length=16, verbose_name='角色')),
('enabled', models.BooleanField(default=False, verbose_name='是否启用冻结')),
('freeze_mode', models.SmallIntegerField(choices=[(1, '按比例'), (2, '固定额'), (3, '比例+单笔上限')], default=1)),
('freeze_rate', models.DecimalField(decimal_places=6, default=Decimal('0'), help_text='0.2=20%', max_digits=8, verbose_name='冻结比例')),
('freeze_fixed', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12)),
('freeze_cap', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12, verbose_name='单笔冻结上限(0=不限)')),
('min_credit_to_freeze', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12, verbose_name='低于此入账不冻')),
('source_scope', models.JSONField(blank=True, default=list, help_text='空=全部来源;如 ["order_settle"]', verbose_name='入账来源白名单')),
('unfreeze_trigger', models.CharField(choices=[('time_only', '仅到期'), ('condition_only', '仅条件达标'), ('time_and_condition', '到期且达标'), ('time_or_condition', '到期或达标'), ('manual_only', '仅手动')], default='time_only', max_length=32)),
('schedule_mode', models.CharField(choices=[('all_at_once', '一次全解'), ('installment', '分期解冻')], default='all_at_once', max_length=16)),
('installment_json', models.JSONField(blank=True, default=dict, help_text='{"steps":[...]}', verbose_name='分期计划')),
('condition_json', models.JSONField(blank=True, default=dict, verbose_name='解冻条件')),
('freeze_min_days', models.IntegerField(default=0, verbose_name='最短冻结天数')),
('remark', models.CharField(blank=True, default='', max_length=255)),
('updated_by', models.CharField(blank=True, default='', max_length=32)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '资金冻结规则',
'db_table': 'club_fund_freeze_config',
'unique_together': {('club_id', 'role')},
},
),
migrations.CreateModel(
name='FundFreezeLedger',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16)),
('user_id', models.CharField(db_index=True, max_length=16, verbose_name='UserUID')),
('role', models.CharField(db_index=True, max_length=16)),
('amount_total', models.DecimalField(decimal_places=2, max_digits=12)),
('amount_unfrozen', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12)),
('status', models.SmallIntegerField(db_index=True, default=1)),
('frozen_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('source_type', models.CharField(db_index=True, max_length=32)),
('order_id', models.CharField(blank=True, db_index=True, default='', max_length=64)),
('czjilu_id', models.CharField(blank=True, db_index=True, default='', max_length=64)),
('biz_id', models.CharField(max_length=64, verbose_name='幂等业务键')),
('freeze_reason', models.CharField(blank=True, default='', max_length=255)),
('config_snapshot', models.JSONField(blank=True, default=dict)),
('current_step', models.IntegerField(default=1)),
('next_eligible_at', models.DateTimeField(blank=True, db_index=True, null=True)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '资金冻结主单',
'db_table': 'fund_freeze_ledger',
'unique_together': {('club_id', 'role', 'source_type', 'biz_id')},
},
),
migrations.CreateModel(
name='FundUnfreezeEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ledger_id', models.BigIntegerField(db_index=True)),
('club_id', models.CharField(db_index=True, max_length=16)),
('user_id', models.CharField(db_index=True, max_length=16)),
('role', models.CharField(max_length=16)),
('step_seq', models.IntegerField(default=1)),
('amount', models.DecimalField(decimal_places=2, max_digits=12)),
('unfrozen_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('trigger', models.CharField(default='schedule', max_length=16)),
('reason', models.CharField(blank=True, default='', max_length=255)),
('operator_id', models.CharField(blank=True, default='', max_length=32)),
('condition_snapshot', models.JSONField(blank=True, default=dict)),
],
options={
'verbose_name': '资金解冻事件',
'db_table': 'fund_unfreeze_event',
},
),
migrations.CreateModel(
name='FundRiskStatDaily',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16)),
('user_id', models.CharField(db_index=True, max_length=16)),
('role', models.CharField(db_index=True, max_length=16)),
('day', models.DateField(db_index=True)),
('completed_orders', models.IntegerField(default=0)),
('fine_count', models.IntegerField(default=0)),
('complaint_count', models.IntegerField(default=0)),
('valid_invite_count', models.IntegerField(default=0)),
('meta_json', models.JSONField(blank=True, default=dict)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '资金风险日汇总',
'db_table': 'fund_risk_stat_daily',
'unique_together': {('club_id', 'user_id', 'role', 'day')},
},
),
migrations.AddIndex(
model_name='fundfreezeledger',
index=models.Index(fields=['status', 'next_eligible_at'], name='idx_ffl_status_next'),
),
migrations.AddIndex(
model_name='fundfreezeledger',
index=models.Index(fields=['user_id', 'role', 'status'], name='idx_ffl_user_role'),
),
migrations.AddIndex(
model_name='fundfreezeledger',
index=models.Index(fields=['club_id', 'status', 'frozen_at'], name='idx_ffl_club_st'),
),
migrations.AddIndex(
model_name='fundriskstatdaily',
index=models.Index(fields=['club_id', 'role', 'day'], name='idx_frsd_club_role_day'),
),
]

View File

@@ -0,0 +1,66 @@
# Generated manually for role agreement tables
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('jituan', '0017_fund_freeze'),
]
operations = [
migrations.CreateModel(
name='ClubRoleAgreement',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16)),
('role', models.CharField(choices=[('dashou', '打手'), ('guanshi', '管事'), ('zuzhang', '组长')], db_index=True, max_length=16)),
('enabled', models.BooleanField(default=False, verbose_name='是否要求签署')),
('title', models.CharField(blank=True, default='', max_length=128, verbose_name='协议标题')),
('content_text', models.TextField(blank=True, default='', verbose_name='协议正文')),
('images_json', models.JSONField(blank=True, default=list, verbose_name='说明图片URL列表')),
('version', models.PositiveIntegerField(default=0, verbose_name='已发布版本号')),
('published_at', models.DateTimeField(blank=True, null=True)),
('updated_by', models.CharField(blank=True, default='', max_length=32)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '角色协议',
'db_table': 'club_role_agreement',
'unique_together': {('club_id', 'role')},
},
),
migrations.CreateModel(
name='ClubRoleAgreementSign',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16)),
('user_id', models.CharField(db_index=True, max_length=16, verbose_name='UserUID')),
('role', models.CharField(db_index=True, max_length=16)),
('agreement_id', models.BigIntegerField(db_index=True, default=0)),
('version', models.PositiveIntegerField(db_index=True)),
('title_snapshot', models.CharField(blank=True, default='', max_length=128)),
('content_snapshot', models.TextField(blank=True, default='')),
('images_snapshot', models.JSONField(blank=True, default=list)),
('sign_image_url', models.CharField(blank=True, default='', max_length=512)),
('client_meta', models.JSONField(blank=True, default=dict)),
('signed_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '角色协议签署记录',
'db_table': 'club_role_agreement_sign',
'unique_together': {('club_id', 'user_id', 'role', 'version')},
},
),
migrations.AddIndex(
model_name='clubroleagreementsign',
index=models.Index(fields=['user_id', 'role', 'version'], name='idx_cras_user_role_ver'),
),
migrations.AddIndex(
model_name='clubroleagreementsign',
index=models.Index(fields=['club_id', 'role', 'signed_at'], name='idx_cras_club_role_t'),
),
]

View File

@@ -0,0 +1,22 @@
# 角色协议:阅读秒数 + 卡点配置(去掉手写依赖,字段兼容保留)
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('jituan', '0018_role_agreement'),
]
operations = [
migrations.AddField(
model_name='clubroleagreement',
name='read_seconds',
field=models.PositiveIntegerField(default=2, verbose_name='阅读秒数'),
),
migrations.AddField(
model_name='clubroleagreement',
name='gate_config',
field=models.JSONField(blank=True, default=dict, verbose_name='签署卡点配置'),
),
]

View File

@@ -0,0 +1,87 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('jituan', '0019_role_agreement_gates'),
]
operations = [
migrations.CreateModel(
name='ClubOrderDealConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16, unique=True, verbose_name='俱乐部')),
('auto_settle_enabled', models.BooleanField(default=False, verbose_name='自动结算开关')),
('auto_settle_enabled_at', models.DateTimeField(blank=True, null=True, verbose_name='本次打开自动结算时间')),
('hours_platform', models.PositiveIntegerField(default=48, verbose_name='平台单超时小时')),
('hours_merchant', models.PositiveIntegerField(default=48, verbose_name='商家单超时小时')),
('stats_enabled', models.BooleanField(default=False, verbose_name='成交指标统计开关')),
('stats_since', models.DateTimeField(blank=True, null=True, verbose_name='指标统计起点')),
('updated_by', models.CharField(blank=True, default='', max_length=32)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '成交与自动结算配置',
'db_table': 'club_order_deal_config',
},
),
migrations.CreateModel(
name='MerchantDealStat',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16)),
('merchant_uid', models.CharField(db_index=True, max_length=16, verbose_name='商家UserUID')),
('order_count', models.PositiveIntegerField(default=0)),
('completed_count', models.PositiveIntegerField(default=0)),
('refund_count', models.PositiveIntegerField(default=0)),
('fine_paid_count', models.PositiveIntegerField(default=0)),
('settle_duration_sum_seconds', models.BigIntegerField(default=0)),
('settle_duration_sample_count', models.PositiveIntegerField(default=0)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '商家成交指标',
'db_table': 'merchant_deal_stat',
'unique_together': {('club_id', 'merchant_uid')},
},
),
migrations.CreateModel(
name='DashouDealStat',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16)),
('dashou_uid', models.CharField(db_index=True, max_length=16, verbose_name='打手UserUID')),
('order_count', models.PositiveIntegerField(default=0)),
('completed_count', models.PositiveIntegerField(default=0)),
('fine_paid_count', models.PositiveIntegerField(default=0)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '打手成交指标',
'db_table': 'dashou_deal_stat',
'unique_together': {('club_id', 'dashou_uid')},
},
),
migrations.CreateModel(
name='DealStatEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16)),
('event_key', models.CharField(db_index=True, max_length=96, unique=True)),
('event_type', models.CharField(db_index=True, max_length=32)),
('order_id', models.CharField(blank=True, db_index=True, default='', max_length=32)),
('subject_uid', models.CharField(blank=True, db_index=True, default='', max_length=16)),
('meta_json', models.JSONField(blank=True, default=dict)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': '成交指标事件',
'db_table': 'deal_stat_event',
},
),
]

View File

@@ -0,0 +1,174 @@
# Generated manually for club migrate module
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('jituan', '0020_order_deal_config_stats'),
]
operations = [
migrations.CreateModel(
name='ClubMigratePlan',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('plan_id', models.CharField(db_index=True, max_length=32, unique=True, verbose_name='计划ID')),
('name', models.CharField(blank=True, default='', max_length=64, verbose_name='计划名称')),
('from_club_id', models.CharField(db_index=True, max_length=16, verbose_name='源俱乐部')),
('to_club_id', models.CharField(db_index=True, max_length=16, verbose_name='目标俱乐部')),
('enabled', models.BooleanField(db_index=True, default=False, verbose_name='是否启用')),
('start_at', models.DateTimeField(blank=True, null=True, verbose_name='开始时间')),
('end_at', models.DateTimeField(blank=True, null=True, verbose_name='结束时间')),
('asset_whitelist', models.JSONField(blank=True, default=list, verbose_name='资产白名单')),
('condition_json', models.JSONField(blank=True, default=dict, verbose_name='门槛条件')),
('force_intercept', models.BooleanField(default=True, verbose_name='源店强制迁移页')),
('force_roles', models.JSONField(blank=True, default=list, verbose_name='强制角色列表')),
('default_once_per_user', models.BooleanField(default=True, verbose_name='默认每人成功一次')),
('remark', models.CharField(blank=True, default='', max_length=255)),
('updated_by', models.CharField(blank=True, default='', max_length=32)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '跨店资产迁移计划',
'db_table': 'club_migrate_plan',
},
),
migrations.CreateModel(
name='ClubMigrateToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token', models.CharField(db_index=True, max_length=64, unique=True, verbose_name='令牌')),
('plan_id', models.CharField(db_index=True, max_length=32)),
('from_club_id', models.CharField(db_index=True, max_length=16)),
('to_club_id', models.CharField(db_index=True, max_length=16)),
('from_uid', models.CharField(db_index=True, max_length=16, verbose_name='源UserUID')),
('password_hash', models.CharField(blank=True, default='', max_length=128, verbose_name='转移密码哈希')),
('password_salt', models.CharField(blank=True, default='', max_length=32)),
('status', models.SmallIntegerField(db_index=True, default=0)),
('fail_count', models.PositiveIntegerField(default=0, verbose_name='验密失败次数')),
('asset_snapshot', models.JSONField(blank=True, default=dict, verbose_name='发码时资产快照')),
('qrcode_url', models.CharField(blank=True, default='', max_length=512, verbose_name='目标店小程序码URL')),
('expire_at', models.DateTimeField(blank=True, db_index=True, null=True)),
('consumed_at', models.DateTimeField(blank=True, null=True)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '跨店迁移令牌',
'db_table': 'club_migrate_token',
},
),
migrations.CreateModel(
name='ClubMigrateConsumption',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('plan_id', models.CharField(db_index=True, max_length=32)),
('from_uid', models.CharField(db_index=True, max_length=16)),
('success_count', models.PositiveIntegerField(default=0)),
('last_ledger_id', models.BigIntegerField(blank=True, null=True)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '跨店迁移占用',
'db_table': 'club_migrate_consumption',
'unique_together': {('plan_id', 'from_uid')},
},
),
migrations.CreateModel(
name='ClubMigrateUserQuota',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('plan_id', models.CharField(db_index=True, max_length=32)),
('from_uid', models.CharField(db_index=True, max_length=16)),
('max_extra_times', models.PositiveIntegerField(default=1, verbose_name='额外成功次数')),
('reason', models.CharField(blank=True, default='', max_length=255)),
('updated_by', models.CharField(blank=True, default='', max_length=32)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '跨店迁移二次白名单',
'db_table': 'club_migrate_user_quota',
'unique_together': {('plan_id', 'from_uid')},
},
),
migrations.CreateModel(
name='ClubMigrateLedger',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('plan_id', models.CharField(db_index=True, max_length=32)),
('token', models.CharField(blank=True, db_index=True, default='', max_length=64)),
('from_club_id', models.CharField(db_index=True, max_length=16)),
('to_club_id', models.CharField(db_index=True, max_length=16)),
('from_uid', models.CharField(db_index=True, max_length=16)),
('to_uid', models.CharField(db_index=True, max_length=16)),
('migrate_seq', models.PositiveIntegerField(default=1, verbose_name='本计划第几次')),
('status', models.SmallIntegerField(db_index=True, default=1)),
('summary_json', models.JSONField(blank=True, default=dict, verbose_name='摘要')),
('client_meta', models.JSONField(blank=True, default=dict)),
('CreateTime', models.DateTimeField(auto_now_add=True, db_index=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '跨店迁移流水主单',
'db_table': 'club_migrate_ledger',
},
),
migrations.CreateModel(
name='ClubMigrateLedgerItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ledger_id', models.BigIntegerField(db_index=True)),
('plan_id', models.CharField(db_index=True, max_length=32)),
('item_type', models.CharField(db_index=True, default='balance', max_length=16)),
('role', models.CharField(db_index=True, max_length=16)),
('field', models.CharField(blank=True, default='', max_length=32, verbose_name='字段名')),
('amount', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=14)),
('before_src', models.CharField(blank=True, default='', max_length=64)),
('after_src', models.CharField(blank=True, default='', max_length=64)),
('before_dst', models.CharField(blank=True, default='', max_length=64)),
('after_dst', models.CharField(blank=True, default='', max_length=64)),
('huiyuan_id', models.CharField(blank=True, db_index=True, default='', max_length=16)),
('meta_json', models.JSONField(blank=True, default=dict)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': '跨店迁移流水明细',
'db_table': 'club_migrate_ledger_item',
},
),
migrations.AddIndex(
model_name='clubmigrateplan',
index=models.Index(fields=['from_club_id', 'enabled'], name='idx_cmp_from_en'),
),
migrations.AddIndex(
model_name='clubmigrateplan',
index=models.Index(fields=['to_club_id', 'enabled'], name='idx_cmp_to_en'),
),
migrations.AddIndex(
model_name='clubmigratetoken',
index=models.Index(fields=['plan_id', 'from_uid', 'status'], name='idx_cmt_plan_uid_st'),
),
migrations.AddIndex(
model_name='clubmigrateledger',
index=models.Index(fields=['from_uid', 'plan_id'], name='idx_cml_from_plan'),
),
migrations.AddIndex(
model_name='clubmigrateledger',
index=models.Index(fields=['to_uid', 'plan_id'], name='idx_cml_to_plan'),
),
migrations.AddIndex(
model_name='clubmigrateledger',
index=models.Index(fields=['from_club_id', 'to_club_id', 'CreateTime'], name='idx_cml_clubs_t'),
),
migrations.AddIndex(
model_name='clubmigrateledgeritem',
index=models.Index(fields=['ledger_id', 'role'], name='idx_cmli_led_role'),
),
]

View File

@@ -0,0 +1,22 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('jituan', '0021_club_migrate'),
]
operations = [
migrations.AddField(
model_name='userwxopenid',
name='wx_appid',
field=models.CharField(
blank=True,
default='',
db_index=True,
help_text='产生该 openid 的 AppID小程序或服务号空=历史小程序绑定',
max_length=32,
),
),
]

View File

@@ -0,0 +1,22 @@
# Generated manually for ClubShangpinLeixingConfig.show_price
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('jituan', '0022_user_wx_openid_wx_appid'),
]
operations = [
migrations.AddField(
model_name='clubshangpinleixingconfig',
name='show_price',
field=models.BooleanField(
default=False,
help_text='开启后小程序该类型订单显示分成价格,即使未开通对应会员',
verbose_name='强制展示订单价格',
),
),
]

View File

@@ -13,12 +13,34 @@ class Club(QModel):
status = models.IntegerField(default=1, verbose_name='1启用0停用') status = models.IntegerField(default=1, verbose_name='1启用0停用')
wx_appid = models.CharField(max_length=32, blank=True, default='') wx_appid = models.CharField(max_length=32, blank=True, default='')
wx_secret = models.CharField(max_length=128, blank=True, default='') wx_secret = models.CharField(max_length=128, blank=True, default='')
mch_id = models.CharField(max_length=32, blank=True, default='') # —— 点单/会员等收款商户V2 JSAPI退款也应对齐这套——
pay_app_id = models.CharField(max_length=32, blank=True, default='') mch_id = models.CharField(max_length=32, blank=True, default='', verbose_name='收款商户号')
api_v3_key = models.CharField(max_length=128, blank=True, default='') pay_app_id = models.CharField(max_length=32, blank=True, default='', verbose_name='收款AppID')
cert_serial_no = models.CharField(max_length=64, blank=True, default='') mch_key = models.CharField(max_length=128, blank=True, default='', verbose_name='收款APIv2密钥')
private_key_path = models.CharField(max_length=255, blank=True, default='') pay_cert_path = models.CharField(
platform_cert_dir = models.CharField(max_length=255, blank=True, default='') 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_appid = models.CharField(max_length=32, blank=True, default='')
official_secret = models.CharField(max_length=128, blank=True, default='') official_secret = models.CharField(max_length=128, blank=True, default='')
official_token = models.CharField(max_length=64, 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_appkey = models.CharField(max_length=64, blank=True, default='')
goeasy_secret = models.CharField(max_length=128, blank=True, default='') goeasy_secret = models.CharField(max_length=128, blank=True, default='')
config_json = models.JSONField(default=dict, blank=True) 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) company_uuid = models.BinaryField(max_length=16, null=True, blank=True)
sort_order = models.IntegerField(default=0) sort_order = models.IntegerField(default=0)
CreateTime = models.DateTimeField(auto_now_add=True) CreateTime = models.DateTimeField(auto_now_add=True)
@@ -40,12 +70,81 @@ class Club(QModel):
verbose_name = '俱乐部' 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): class UserWxOpenid(QModel):
"""微信 openid 与俱乐部用户绑定club_id + openid 唯一)。""" """微信 openid 与俱乐部用户绑定club_id + openid 唯一)。"""
club_id = models.CharField(max_length=16, db_index=True) club_id = models.CharField(max_length=16, db_index=True)
openid = models.CharField(max_length=64, db_index=True) openid = models.CharField(max_length=64, db_index=True)
yonghuid = models.CharField(max_length=7, db_index=True, verbose_name='用户ID') yonghuid = models.CharField(max_length=7, db_index=True, verbose_name='用户ID')
unionid = models.CharField(max_length=64, null=True, blank=True) unionid = models.CharField(max_length=64, null=True, blank=True)
# 小程序与服务号 openid 不同;同用户同俱乐部可各绑一条,支付时按 AppID 选取
wx_appid = models.CharField(
max_length=32, blank=True, default='', db_index=True,
help_text='产生该 openid 的 AppID小程序或服务号空=历史小程序绑定',
)
CreateTime = models.DateTimeField(auto_now_add=True) CreateTime = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
@@ -147,6 +246,12 @@ class ClubShangpinLeixingConfig(QModel):
is_enabled = models.BooleanField(default=True, verbose_name='是否上架') is_enabled = models.BooleanField(default=True, verbose_name='是否上架')
tupian_url = models.CharField(max_length=500, blank=True, default='', verbose_name='俱乐部专属图标') tupian_url = models.CharField(max_length=500, blank=True, default='', verbose_name='俱乐部专属图标')
paixu = models.IntegerField(default=0, verbose_name='排序权重') paixu = models.IntegerField(default=0, verbose_name='排序权重')
# 新字段:旧小程序忽略;开启后该类型下真单强制展示价格(不改 hasRequiredMember 抢单逻辑)
show_price = models.BooleanField(
default=False,
verbose_name='强制展示订单价格',
help_text='开启后小程序该类型订单显示分成价格,即使未开通对应会员',
)
CreateTime = models.DateTimeField(auto_now_add=True) CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True) UpdateTime = models.DateTimeField(auto_now=True)
@@ -316,6 +421,24 @@ class ClubDashouExamPass(QModel):
unique_together = [['club_id', 'yonghuid']] 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): class KefuMenuPage(QModel):
"""客服后台左侧菜单页(固定 page_id与前端路由对应""" """客服后台左侧菜单页(固定 page_id与前端路由对应"""
page_id = models.CharField(max_length=64, primary_key=True, verbose_name='页面ID') page_id = models.CharField(max_length=64, primary_key=True, verbose_name='页面ID')
@@ -332,3 +455,452 @@ class KefuMenuPage(QModel):
class Meta: class Meta:
db_table = 'kefu_menu_page' db_table = 'kefu_menu_page'
ordering = ['sort_order', 'page_id'] 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'),
]
# ==================== 资金冻结(默认关闭,按俱乐部×角色开启)====================
class ClubFundFreezeConfig(QModel):
"""俱乐部×角色冻结规则。enabled=False 时入账行为与现网完全一致。"""
ROLE_CHOICES = (
('dashou', '打手'),
('guanshi', '管事'),
('zuzhang', '组长'),
('shenheguan', '考核官'),
)
FREEZE_MODE_CHOICES = (
(1, '按比例'),
(2, '固定额'),
(3, '比例+单笔上限'),
)
UNFREEZE_TRIGGER_CHOICES = (
('time_only', '仅到期'),
('condition_only', '仅条件达标'),
('time_and_condition', '到期且达标'),
('time_or_condition', '到期或达标'),
('manual_only', '仅手动'),
)
SCHEDULE_MODE_CHOICES = (
('all_at_once', '一次全解'),
('installment', '分期解冻'),
)
club_id = models.CharField(max_length=16, db_index=True, verbose_name='俱乐部')
role = models.CharField(max_length=16, choices=ROLE_CHOICES, db_index=True, verbose_name='角色')
enabled = models.BooleanField(default=False, verbose_name='是否启用冻结')
freeze_mode = models.SmallIntegerField(default=1, choices=FREEZE_MODE_CHOICES)
freeze_rate = models.DecimalField(
max_digits=8, decimal_places=6, default=Decimal('0'),
verbose_name='冻结比例', help_text='0.2=20%',
)
freeze_fixed = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0'))
freeze_cap = models.DecimalField(
max_digits=12, decimal_places=2, default=Decimal('0'),
verbose_name='单笔冻结上限(0=不限)',
)
min_credit_to_freeze = models.DecimalField(
max_digits=12, decimal_places=2, default=Decimal('0'),
verbose_name='低于此入账不冻',
)
source_scope = models.JSONField(
default=list, blank=True,
verbose_name='入账来源白名单',
help_text='空=全部来源;如 ["order_settle"]',
)
unfreeze_trigger = models.CharField(
max_length=32, default='time_only', choices=UNFREEZE_TRIGGER_CHOICES,
)
schedule_mode = models.CharField(
max_length=16, default='all_at_once', choices=SCHEDULE_MODE_CHOICES,
)
installment_json = models.JSONField(
default=dict, blank=True,
verbose_name='分期计划',
help_text='{"steps":[{"seq":1,"after_days":7,"type":"ratio","value":0.3,"need_condition":false}]}',
)
condition_json = models.JSONField(default=dict, blank=True, verbose_name='解冻条件')
freeze_min_days = models.IntegerField(default=0, verbose_name='最短冻结天数')
remark = models.CharField(max_length=255, blank=True, default='')
updated_by = models.CharField(max_length=32, blank=True, default='')
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'club_fund_freeze_config'
verbose_name = '资金冻结规则'
unique_together = [['club_id', 'role']]
class FundFreezeLedger(QModel):
"""单笔入账冻结主单。"""
STATUS_FROZEN = 1
STATUS_PARTIAL = 2
STATUS_DONE = 3
STATUS_VOID = 4
club_id = models.CharField(max_length=16, db_index=True)
user_id = models.CharField(max_length=16, db_index=True, verbose_name='UserUID')
role = models.CharField(max_length=16, db_index=True)
amount_total = models.DecimalField(max_digits=12, decimal_places=2)
amount_unfrozen = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0'))
status = models.SmallIntegerField(default=1, db_index=True)
frozen_at = models.DateTimeField(auto_now_add=True, db_index=True)
source_type = models.CharField(max_length=32, db_index=True)
order_id = models.CharField(max_length=64, blank=True, default='', db_index=True)
czjilu_id = models.CharField(max_length=64, blank=True, default='', db_index=True)
biz_id = models.CharField(max_length=64, verbose_name='幂等业务键')
freeze_reason = models.CharField(max_length=255, blank=True, default='')
config_snapshot = models.JSONField(default=dict, blank=True)
current_step = models.IntegerField(default=1)
next_eligible_at = models.DateTimeField(null=True, blank=True, db_index=True)
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'fund_freeze_ledger'
verbose_name = '资金冻结主单'
unique_together = [['club_id', 'role', 'source_type', 'biz_id']]
indexes = [
models.Index(fields=['status', 'next_eligible_at'], name='idx_ffl_status_next'),
models.Index(fields=['user_id', 'role', 'status'], name='idx_ffl_user_role'),
models.Index(fields=['club_id', 'status', 'frozen_at'], name='idx_ffl_club_st'),
]
class FundUnfreezeEvent(QModel):
"""每一次解冻执行记录。"""
ledger_id = models.BigIntegerField(db_index=True)
club_id = models.CharField(max_length=16, db_index=True)
user_id = models.CharField(max_length=16, db_index=True)
role = models.CharField(max_length=16)
step_seq = models.IntegerField(default=1)
amount = models.DecimalField(max_digits=12, decimal_places=2)
unfrozen_at = models.DateTimeField(auto_now_add=True, db_index=True)
trigger = models.CharField(max_length=16, default='schedule')
reason = models.CharField(max_length=255, blank=True, default='')
operator_id = models.CharField(max_length=32, blank=True, default='')
condition_snapshot = models.JSONField(default=dict, blank=True)
class Meta:
db_table = 'fund_unfreeze_event'
verbose_name = '资金解冻事件'
class FundRiskStatDaily(QModel):
"""日汇总:罚款/投诉/完成单等,供解冻条件与后台展示。"""
club_id = models.CharField(max_length=16, db_index=True)
user_id = models.CharField(max_length=16, db_index=True)
role = models.CharField(max_length=16, db_index=True)
day = models.DateField(db_index=True)
completed_orders = models.IntegerField(default=0)
fine_count = models.IntegerField(default=0)
complaint_count = models.IntegerField(default=0)
valid_invite_count = models.IntegerField(default=0)
meta_json = models.JSONField(default=dict, blank=True)
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'fund_risk_stat_daily'
verbose_name = '资金风险日汇总'
unique_together = [['club_id', 'user_id', 'role', 'day']]
indexes = [
models.Index(fields=['club_id', 'role', 'day'], name='idx_frsd_club_role_day'),
]
class ClubRoleAgreement(QModel):
"""俱乐部×身份用户协议(当前生效配置,发布时 version+1"""
ROLE_CHOICES = (
('dashou', '打手'),
('guanshi', '管事'),
('zuzhang', '组长'),
)
club_id = models.CharField(max_length=16, db_index=True)
role = models.CharField(max_length=16, choices=ROLE_CHOICES, db_index=True)
enabled = models.BooleanField(default=False, verbose_name='是否要求签署')
title = models.CharField(max_length=128, blank=True, default='', verbose_name='协议标题')
content_text = models.TextField(blank=True, default='', verbose_name='协议正文')
images_json = models.JSONField(default=list, blank=True, verbose_name='说明图片URL列表')
# 勾选前强制停留秒数0=可立即勾选
read_seconds = models.PositiveIntegerField(default=2, verbose_name='阅读秒数')
# 卡点开关qiangdan/register/tixian/invite按角色默认见服务层
gate_config = models.JSONField(default=dict, blank=True, verbose_name='签署卡点配置')
version = models.PositiveIntegerField(default=0, verbose_name='已发布版本号')
published_at = models.DateTimeField(null=True, blank=True)
updated_by = models.CharField(max_length=32, blank=True, default='')
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'club_role_agreement'
verbose_name = '角色协议'
unique_together = [['club_id', 'role']]
class ClubRoleAgreementSign(QModel):
"""用户签署某俱乐部某身份某版本协议的留痕(勾选确认,无手写图)。"""
club_id = models.CharField(max_length=16, db_index=True)
user_id = models.CharField(max_length=16, db_index=True, verbose_name='UserUID')
role = models.CharField(max_length=16, db_index=True)
agreement_id = models.BigIntegerField(db_index=True, default=0)
version = models.PositiveIntegerField(db_index=True)
title_snapshot = models.CharField(max_length=128, blank=True, default='')
content_snapshot = models.TextField(blank=True, default='')
images_snapshot = models.JSONField(default=list, blank=True)
# 兼容旧字段,新签署固定留空
sign_image_url = models.CharField(max_length=512, blank=True, default='')
client_meta = models.JSONField(default=dict, blank=True)
signed_at = models.DateTimeField(auto_now_add=True, db_index=True)
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'club_role_agreement_sign'
verbose_name = '角色协议签署记录'
unique_together = [['club_id', 'user_id', 'role', 'version']]
indexes = [
models.Index(fields=['user_id', 'role', 'version'], name='idx_cras_user_role_ver'),
models.Index(fields=['club_id', 'role', 'signed_at'], name='idx_cras_club_role_t'),
]
class ClubOrderDealConfig(QModel):
"""俱乐部:成交指标统计开关 + 可控自动结算开关(默认全关)。"""
club_id = models.CharField(max_length=16, unique=True, db_index=True, verbose_name='俱乐部')
auto_settle_enabled = models.BooleanField(default=False, verbose_name='自动结算开关')
auto_settle_enabled_at = models.DateTimeField(null=True, blank=True, verbose_name='本次打开自动结算时间')
hours_platform = models.PositiveIntegerField(default=48, verbose_name='平台单超时小时')
hours_merchant = models.PositiveIntegerField(default=48, verbose_name='商家单超时小时')
stats_enabled = models.BooleanField(default=False, verbose_name='成交指标统计开关')
stats_since = models.DateTimeField(null=True, blank=True, verbose_name='指标统计起点')
updated_by = models.CharField(max_length=32, blank=True, default='')
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'club_order_deal_config'
verbose_name = '成交与自动结算配置'
class MerchantDealStat(QModel):
"""商家成交/罚款/退款指标快照(仅统计起点之后事件累加)。"""
club_id = models.CharField(max_length=16, db_index=True)
merchant_uid = models.CharField(max_length=16, db_index=True, verbose_name='商家UserUID')
order_count = models.PositiveIntegerField(default=0)
completed_count = models.PositiveIntegerField(default=0)
refund_count = models.PositiveIntegerField(default=0)
fine_paid_count = models.PositiveIntegerField(default=0)
settle_duration_sum_seconds = models.BigIntegerField(default=0)
settle_duration_sample_count = models.PositiveIntegerField(default=0)
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'merchant_deal_stat'
verbose_name = '商家成交指标'
unique_together = [['club_id', 'merchant_uid']]
class DashouDealStat(QModel):
"""打手成交/被罚款指标快照。"""
club_id = models.CharField(max_length=16, db_index=True)
dashou_uid = models.CharField(max_length=16, db_index=True, verbose_name='打手UserUID')
order_count = models.PositiveIntegerField(default=0)
completed_count = models.PositiveIntegerField(default=0)
fine_paid_count = models.PositiveIntegerField(default=0)
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'dashou_deal_stat'
verbose_name = '打手成交指标'
unique_together = [['club_id', 'dashou_uid']]
class DealStatEvent(QModel):
"""指标幂等事件(防重复记账)。"""
club_id = models.CharField(max_length=16, db_index=True)
event_key = models.CharField(max_length=96, unique=True, db_index=True)
event_type = models.CharField(max_length=32, db_index=True)
order_id = models.CharField(max_length=32, blank=True, default='', db_index=True)
subject_uid = models.CharField(max_length=16, blank=True, default='', db_index=True)
meta_json = models.JSONField(default=dict, blank=True)
CreateTime = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'deal_stat_event'
verbose_name = '成交指标事件'
# ==================== 跨俱乐部资产迁移(独立模块,默认关闭不影响现网)====================
class ClubMigratePlan(QModel):
"""迁移计划from_club → to_club未启用时小程序完全无感。"""
plan_id = models.CharField(max_length=32, unique=True, db_index=True, verbose_name='计划ID')
name = models.CharField(max_length=64, blank=True, default='', verbose_name='计划名称')
from_club_id = models.CharField(max_length=16, db_index=True, verbose_name='源俱乐部')
to_club_id = models.CharField(max_length=16, db_index=True, verbose_name='目标俱乐部')
enabled = models.BooleanField(default=False, db_index=True, verbose_name='是否启用')
# null=永久;有值则必须在区间内
start_at = models.DateTimeField(null=True, blank=True, verbose_name='开始时间')
end_at = models.DateTimeField(null=True, blank=True, verbose_name='结束时间')
# ["dashou.yue","dashou.yajin","dashou.jifen","dashou.dongjie_yue","dashou.huiyuan", ...]
asset_whitelist = models.JSONField(default=list, blank=True, verbose_name='资产白名单')
# {"require_no_unpaid_penalty":true,"require_orders_closed":false,"require_not_banned":true}
condition_json = models.JSONField(default=dict, blank=True, verbose_name='门槛条件')
force_intercept = models.BooleanField(default=True, verbose_name='源店强制迁移页')
# ["dashou"] 首期默认打手
force_roles = models.JSONField(default=list, blank=True, verbose_name='强制角色列表')
default_once_per_user = models.BooleanField(default=True, verbose_name='默认每人成功一次')
remark = models.CharField(max_length=255, blank=True, default='')
updated_by = models.CharField(max_length=32, blank=True, default='')
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'club_migrate_plan'
verbose_name = '跨店资产迁移计划'
indexes = [
models.Index(fields=['from_club_id', 'enabled'], name='idx_cmp_from_en'),
models.Index(fields=['to_club_id', 'enabled'], name='idx_cmp_to_en'),
]
class ClubMigrateToken(QModel):
"""一次性迁移令牌(设密后签发,确认后作废)。"""
STATUS_PENDING_PASSWORD = 0
STATUS_ISSUED = 1
STATUS_CONSUMED = 2
STATUS_EXPIRED = 3
STATUS_CANCELLED = 4
token = models.CharField(max_length=64, unique=True, db_index=True, verbose_name='令牌')
plan_id = models.CharField(max_length=32, db_index=True)
from_club_id = models.CharField(max_length=16, db_index=True)
to_club_id = models.CharField(max_length=16, db_index=True)
from_uid = models.CharField(max_length=16, db_index=True, verbose_name='源UserUID')
password_hash = models.CharField(max_length=128, blank=True, default='', verbose_name='转移密码哈希')
password_salt = models.CharField(max_length=32, blank=True, default='')
status = models.SmallIntegerField(default=0, db_index=True)
fail_count = models.PositiveIntegerField(default=0, verbose_name='验密失败次数')
asset_snapshot = models.JSONField(default=dict, blank=True, verbose_name='发码时资产快照')
qrcode_url = models.CharField(max_length=512, blank=True, default='', verbose_name='目标店小程序码URL')
expire_at = models.DateTimeField(null=True, blank=True, db_index=True)
consumed_at = models.DateTimeField(null=True, blank=True)
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'club_migrate_token'
verbose_name = '跨店迁移令牌'
indexes = [
models.Index(fields=['plan_id', 'from_uid', 'status'], name='idx_cmt_plan_uid_st'),
]
class ClubMigrateConsumption(QModel):
"""计划×源用户成功占用(防并发双迁;默认一次)。"""
plan_id = models.CharField(max_length=32, db_index=True)
from_uid = models.CharField(max_length=16, db_index=True)
success_count = models.PositiveIntegerField(default=0)
last_ledger_id = models.BigIntegerField(null=True, blank=True)
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'club_migrate_consumption'
verbose_name = '跨店迁移占用'
unique_together = [['plan_id', 'from_uid']]
class ClubMigrateUserQuota(QModel):
"""例外:指定源用户在某计划下可额外成功次数。"""
plan_id = models.CharField(max_length=32, db_index=True)
from_uid = models.CharField(max_length=16, db_index=True)
max_extra_times = models.PositiveIntegerField(default=1, verbose_name='额外成功次数')
reason = models.CharField(max_length=255, blank=True, default='')
updated_by = models.CharField(max_length=32, blank=True, default='')
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'club_migrate_user_quota'
verbose_name = '跨店迁移二次白名单'
unique_together = [['plan_id', 'from_uid']]
class ClubMigrateLedger(QModel):
"""一次成功过户主单P0 溯源)。"""
STATUS_SUCCESS = 1
STATUS_REVERSED = 2
plan_id = models.CharField(max_length=32, db_index=True)
token = models.CharField(max_length=64, blank=True, default='', db_index=True)
from_club_id = models.CharField(max_length=16, db_index=True)
to_club_id = models.CharField(max_length=16, db_index=True)
from_uid = models.CharField(max_length=16, db_index=True)
to_uid = models.CharField(max_length=16, db_index=True)
migrate_seq = models.PositiveIntegerField(default=1, verbose_name='本计划第几次')
status = models.SmallIntegerField(default=1, db_index=True)
summary_json = models.JSONField(default=dict, blank=True, verbose_name='摘要')
client_meta = models.JSONField(default=dict, blank=True)
CreateTime = models.DateTimeField(auto_now_add=True, db_index=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'club_migrate_ledger'
verbose_name = '跨店迁移流水主单'
indexes = [
models.Index(fields=['from_uid', 'plan_id'], name='idx_cml_from_plan'),
models.Index(fields=['to_uid', 'plan_id'], name='idx_cml_to_plan'),
models.Index(fields=['from_club_id', 'to_club_id', 'CreateTime'], name='idx_cml_clubs_t'),
]
class ClubMigrateLedgerItem(QModel):
"""流水明细:每个角色字段一行(金额前后值必填,便于对账纠错)。"""
ITEM_BALANCE = 'balance'
ITEM_HUIYUAN = 'huiyuan'
ledger_id = models.BigIntegerField(db_index=True)
plan_id = models.CharField(max_length=32, db_index=True)
item_type = models.CharField(max_length=16, default='balance', db_index=True)
role = models.CharField(max_length=16, db_index=True)
field = models.CharField(max_length=32, blank=True, default='', verbose_name='字段名')
amount = models.DecimalField(max_digits=14, decimal_places=2, default=Decimal('0'))
before_src = models.CharField(max_length=64, blank=True, default='')
after_src = models.CharField(max_length=64, blank=True, default='')
before_dst = models.CharField(max_length=64, blank=True, default='')
after_dst = models.CharField(max_length=64, blank=True, default='')
# 会员专用
huiyuan_id = models.CharField(max_length=16, blank=True, default='', db_index=True)
meta_json = models.JSONField(default=dict, blank=True)
CreateTime = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'club_migrate_ledger_item'
verbose_name = '跨店迁移流水明细'
indexes = [
models.Index(fields=['ledger_id', 'role'], name='idx_cmli_led_role'),
]

View File

@@ -1,25 +1,46 @@
"""admin_assignment 任职 CRUD(修复停用不生效、重复插入)。""" """admin_assignment 任职 CRUD
集团任职用 club_id=''(禁止 NULLMySQL 对 NULL 唯一约束失效,会无限复制,
导致「停一条冒出五六条」。停用改为硬删除。
"""
from django.db.models import Q from django.db.models import Q
from django.utils import timezone from django.utils import timezone
from jituan.constants import DATA_SCOPE_ALL, DATA_SCOPE_SINGLE from jituan.constants import DATA_SCOPE_ALL, DATA_SCOPE_SINGLE, SUPER_ADMIN_PHONES
from jituan.models import AdminAssignment from jituan.models import AdminAssignment
GROUP_ROLE_CODES = frozenset({
'GROUP_OWNER',
'GROUP_SUPER_ADMIN',
'GROUP_AFTER_SALES',
'GROUP_FINANCE',
'GROUP_CONFIG',
})
def normalize_club_id(club_id):
"""集团级统一成空串,禁止 NULL避免唯一索引失效"""
if club_id is None:
return ''
s = str(club_id).strip()
return s
def _club_filter(qs, club_id): def _club_filter(qs, club_id):
if club_id is None: cid = normalize_club_id(club_id)
if not cid:
return qs.filter(Q(club_id__isnull=True) | Q(club_id='')) return qs.filter(Q(club_id__isnull=True) | Q(club_id=''))
return qs.filter(club_id=club_id) return qs.filter(club_id=cid)
def find_assignment(yonghuid, club_id, role_code): def find_assignment(yonghuid, club_id, role_code):
qs = AdminAssignment.query.filter(yonghuid=yonghuid, role_code=role_code) qs = AdminAssignment.query.filter(yonghuid=yonghuid, role_code=role_code)
return _club_filter(qs, club_id).first() return _club_filter(qs, club_id).order_by('-id').first()
def find_active_assignment(yonghuid, club_id, role_code): def find_active_assignment(yonghuid, club_id, role_code):
qs = AdminAssignment.query.filter(yonghuid=yonghuid, role_code=role_code, status=1) qs = AdminAssignment.query.filter(yonghuid=yonghuid, role_code=role_code, status=1)
return _club_filter(qs, club_id).first() return _club_filter(qs, club_id).order_by('-id').first()
def clear_other_primary_assignments(yonghuid, except_id=None): def clear_other_primary_assignments(yonghuid, except_id=None):
@@ -38,6 +59,25 @@ def _clear_other_primary(yonghuid, except_id=None):
clear_other_primary_assignments(yonghuid, except_id=except_id) clear_other_primary_assignments(yonghuid, except_id=except_id)
def _strip_system_super_if_needed(yonghuid):
yonghuid = (yonghuid or '').strip()
if not yonghuid:
return
try:
from users.business_models import User
except Exception:
return
user = User.objects.filter(UserUID=yonghuid).first()
if not user:
return
if (user.Phone or '') in SUPER_ADMIN_PHONES:
return
if user.IsSuperuser or user.IsStaff:
user.IsSuperuser = False
user.IsStaff = False
user.save(update_fields=['IsSuperuser', 'IsStaff'])
def create_or_reactivate_assignment( def create_or_reactivate_assignment(
yonghuid, yonghuid,
club_id, club_id,
@@ -46,61 +86,98 @@ def create_or_reactivate_assignment(
is_primary, is_primary,
granted_by, granted_by,
): ):
active = find_active_assignment(yonghuid, club_id, role_code) club_id = normalize_club_id(club_id)
if active: if not club_id:
data_scope = DATA_SCOPE_ALL
# 创建前先清同键垃圾(含 NULL/空串/status=0 重复)
junk = _club_filter(
AdminAssignment.objects.filter(yonghuid=yonghuid, role_code=role_code),
club_id,
)
active = junk.filter(status=1).order_by('-id')
if active.exists():
keep = active.first()
junk.exclude(pk=keep.pk).delete()
return None, '该任职记录已存在且有效' return None, '该任职记录已存在且有效'
junk.delete()
if is_primary: if is_primary:
_clear_other_primary(yonghuid) _clear_other_primary(yonghuid)
inactive = find_assignment(yonghuid, club_id, role_code) row = AdminAssignment.objects.create(
if inactive and inactive.status != 1:
if is_primary:
_clear_other_primary(yonghuid)
AdminAssignment.objects.filter(pk=inactive.id).update(
status=1,
data_scope=data_scope,
is_primary=is_primary,
granted_by=granted_by,
UpdateTime=timezone.now(),
)
return inactive.id, None
row = AdminAssignment.query.create(
yonghuid=yonghuid, yonghuid=yonghuid,
club_id=club_id, club_id=club_id,
role_code=role_code, role_code=role_code,
data_scope=data_scope, data_scope=data_scope,
is_primary=is_primary, is_primary=is_primary,
granted_by=granted_by, granted_by=granted_by or '',
status=1, status=1,
) )
return row.id, None return row.id, None
def _is_group_level(row):
return (
not normalize_club_id(row.club_id)
or (row.data_scope or '') == DATA_SCOPE_ALL
or (row.role_code or '') in GROUP_ROLE_CODES
)
def deactivate_assignment(assignment_id): def deactivate_assignment(assignment_id):
"""停用=硬删除。集团级则删光该人全部集团任职并摘 IsSuperuser。"""
try: try:
pk = int(assignment_id) pk = int(assignment_id)
except (TypeError, ValueError): except (TypeError, ValueError):
return False, '任职 id 无效' return False, '任职 id 无效'
updated = AdminAssignment.objects.filter(pk=pk, status=1).update( row = AdminAssignment.objects.filter(pk=pk).first()
status=0, if not row:
UpdateTime=timezone.now(), # 可能已被删,幂等成功
)
if updated:
return True, None return True, None
exists = AdminAssignment.objects.filter(pk=pk).exists() yonghuid = row.yonghuid
if exists: role_code = row.role_code
return True, None club_id = row.club_id
return False, '任职记录不存在' group_level = _is_group_level(row)
# 同人同角色同俱乐部(含 NULL/'')全部硬删
_club_filter(
AdminAssignment.objects.filter(yonghuid=yonghuid, role_code=role_code),
club_id,
).delete()
if group_level:
AdminAssignment.objects.filter(yonghuid=yonghuid).filter(
Q(club_id__isnull=True)
| Q(club_id='')
| Q(data_scope=DATA_SCOPE_ALL)
| Q(role_code__in=list(GROUP_ROLE_CODES))
).delete()
_strip_system_super_if_needed(yonghuid)
return True, None
def deactivate_all_assignments_for_user(yonghuid):
"""账号禁用:硬删全部任职 + 摘超管。"""
yonghuid = (yonghuid or '').strip()
if not yonghuid:
return 0
deleted, _ = AdminAssignment.objects.filter(yonghuid=yonghuid).delete()
_strip_system_super_if_needed(yonghuid)
return deleted
def dedupe_active_assignments(): def dedupe_active_assignments():
"""停用重复的有效任职,每组 (yonghuid, club_id, role_code) 只保留一条""" """硬删重复任职;并把 club_id=NULL 归一成 ''"""
# 归一 NULL -> ''
AdminAssignment.objects.filter(club_id__isnull=True).update(club_id='')
rows = list( rows = list(
AdminAssignment.query.filter(status=1).order_by('yonghuid', 'club_id', 'role_code', 'id') AdminAssignment.objects.filter(status=1).order_by('yonghuid', 'club_id', 'role_code', 'id')
) )
seen = {} seen = {}
dup_ids = [] dup_ids = []
@@ -111,8 +188,5 @@ def dedupe_active_assignments():
else: else:
seen[key] = row.id seen[key] = row.id
if dup_ids: if dup_ids:
AdminAssignment.objects.filter(pk__in=dup_ids).update( AdminAssignment.objects.filter(pk__in=dup_ids).delete()
status=0,
UpdateTime=timezone.now(),
)
return len(dup_ids) return len(dup_ids)

View File

@@ -149,16 +149,40 @@ def build_admin_club_context(user):
def is_system_super_admin(user): def is_system_super_admin(user):
"""系统级超管Django 超管 / admin 账号 / 白名单手机号),非俱乐部 000001 角色。""" """
return ( 系统级超管。
bool(user.IsSuperuser) 白名单手机号:始终超管。
or user.UserType == 'admin' 其它账号:仅看 IsSuperuser / UserType=admin停用集团任职时会清掉 IsSuperuser
or getattr(user, 'Phone', '') in SUPER_ADMIN_PHONES 避免「任职停了仍是集团上帝」。
) """
if getattr(user, 'Phone', '') in SUPER_ADMIN_PHONES:
return True
return bool(user.IsSuperuser) or user.UserType == 'admin'
def is_hardcoded_super_phone(user):
"""仅代码白名单手机号(不可被后台「禁用」锁死本人)。"""
return getattr(user, 'Phone', '') in SUPER_ADMIN_PHONES
def is_kefu_account_disabled(user):
"""
客服扩展表已禁用。
非白名单账号:即使挂了 IsSuperuser 也必须认 zhuangtai否则后台点禁用等于摆设
"""
if is_hardcoded_super_phone(user):
return False
try:
kefu = user.KefuProfile
except Exception:
return False
return int(getattr(kefu, 'zhuangtai', 1) or 0) != 1
def is_kefu_backend_account(user): def is_kefu_backend_account(user):
"""可登录客服后台:超管 / UserType=kefu / 有正常 KefuProfile。""" """可登录客服后台:超管 / UserType=kefu / 有正常 KefuProfile。"""
if is_kefu_account_disabled(user):
return False
if is_system_super_admin(user): if is_system_super_admin(user):
return True return True
if getattr(user, 'UserType', '') == 'kefu': if getattr(user, 'UserType', '') == 'kefu':
@@ -190,27 +214,36 @@ def get_switchable_club_ids(user):
def can_manage_roles_for_club(user, permissions, club_id): def can_manage_roles_for_club(user, permissions, club_id):
""" """
是否可为指定俱乐部分配/移除功能角色。 是否可为指定俱乐部分配/移除功能角色、添加本店后台用户
集团高层:全部俱乐部;否则须在该俱乐部拥有 000001 超级管理权限。 集团高层:全部俱乐部;否则须在该俱乐部拥有 000001 超级管理(不必有集团权限
""" """
if can_manage_admin_assignments(user, permissions): if can_manage_admin_assignments(user, permissions):
return True return True
cid = (club_id or '').strip() cid = (club_id or '').strip()
if not cid: if not cid:
return False return False
from jituan.services.club_rbac import resolve_permission_codes_for_club from jituan.constants import DATA_SCOPE_SINGLE
codes = resolve_permission_codes_for_club(user, cid) from jituan.services.club_rbac import resolve_effective_permission_codes
return '000001' in 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): if can_manage_admin_assignments(user, permissions):
return {c.club_id for c in Club.query.filter(status=1)} 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) c.club_id for c in Club.query.filter(status=1)
if can_manage_roles_for_club(user, permissions, c.club_id) 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): 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__) 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: else:
cfg = getattr(settings, 'ALIPAY_PROD', {}) 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 { return {
'APP_ID': cfg.get('APP_ID', ''), 'APP_ID': cfg.get('APP_ID', ''),
'PID': cfg.get('PID', ''), 'PID': cfg.get('PID', ''),
'APP_PRIVATE_KEY': cfg.get('APP_PRIVATE_KEY', ''), 'APP_PRIVATE_KEY': cfg.get('APP_PRIVATE_KEY', ''),
'ALIPAY_PUBLIC_KEY': cfg.get('ALIPAY_PUBLIC_KEY', ''), 'ALIPAY_PUBLIC_KEY': cfg.get('ALIPAY_PUBLIC_KEY', ''),
'GATEWAY': cfg.get('GATEWAY', 'https://openapi.alipay.com/gateway.do'), 'GATEWAY': cfg.get('GATEWAY', 'https://openapi.alipay.com/gateway.do'),
'NOTIFY_URL': cfg.get('NOTIFY_URL', ''), 'NOTIFY_URL': order_notify,
'NOTIFY_URL_CZJILU': cfg.get('NOTIFY_URL_CZJILU', '') or cfg.get('NOTIFY_URL', ''), 'NOTIFY_URL_CZJILU': czjilu_notify,
'RETURN_URL': cfg.get('RETURN_URL', ''), 'RETURN_URL': cfg.get('RETURN_URL', ''),
'sandbox': sandbox, 'sandbox': sandbox,
} }
def get_czjilu_notify_url(club_id=None): def get_czjilu_notify_url(club_id=None):
"""获取 Czjilu 充值(押金/积分/会员/商家/考核/罚款)的支付宝异步回调地址。""" """获取 Czjilu 充值回调地址secrets 可选覆盖,否则用 settings 写死的正确地址。
return get_alipay_config(club_id)['NOTIFY_URL_CZJILU']
密钥/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): 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 标记该订单为支付宝订单 返回 {qr_code, qr_image, pay_method},并在 cache 标记该订单为支付宝订单
(轮询接口据此跳过微信查单,直接等异步回调更新本地状态)。 (轮询接口据此跳过微信查单,直接等异步回调更新本地状态)。
""" """
assert_alipay_allowed_for_club(club_id)
from django.core.cache import cache from django.core.cache import cache
notify_url = get_czjilu_notify_url(club_id) notify_url = get_czjilu_notify_url(club_id)
pay_url = build_wap_pay_url( 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, club_id=club_id,
expire_minutes=expire_minutes, expire_minutes=expire_minutes,
notify_url=notify_url, notify_url=notify_url,
_skip_club_gate=True,
) )
qr_image = qr_link_to_base64(pay_url) qr_image = qr_link_to_base64(pay_url)
cache.set(f'alipay_pay_url_{dingdanid}', pay_url, 60 * 30) 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 ==================== # ==================== 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 方式)。 生成 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 或字符串,单位元) amount: 订单金额Decimal 或字符串,单位元)
subject: 订单标题 subject: 订单标题
body: 订单描述(可选) body: 订单描述(可选)
club_id: 预留多 club暂未使用 club_id: 俱乐部;命中 ALIPAY_DISABLED_CLUB_IDS 时拒绝发起
expire_minutes: 超时关闭时间(分钟) expire_minutes: 超时关闭时间(分钟)
notify_url: 自定义异步回调地址(可选,默认用 cfg['NOTIFY_URL'] notify_url: 自定义异步回调地址(可选,默认用 cfg['NOTIFY_URL']
_skip_club_gate: 内部已校验过时跳过(避免双重校验)
返回: 返回:
完整的支付宝跳转 URL前端 web-view 直接加载即可。 完整的支付宝跳转 URL前端 web-view 直接加载即可。
""" """
if not _skip_club_gate:
assert_alipay_allowed_for_club(club_id)
cfg = get_alipay_config(club_id) cfg = get_alipay_config(club_id)
biz_content = { biz_content = {
@@ -295,6 +354,7 @@ def build_precreate_qr(out_trade_no, amount, subject, body='', club_id=None, exp
""" """
import requests import requests
assert_alipay_allowed_for_club(club_id)
cfg = get_alipay_config(club_id) cfg = get_alipay_config(club_id)
biz_content = { biz_content = {
@@ -387,3 +447,61 @@ def club_id_from_czjilu(dingdan_id):
except Exception: except Exception:
pass pass
return CLUB_ID_DEFAULT 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.constants import DATA_SCOPE_ALL
from jituan.services.club_context import resolve_club_scope from jituan.services.club_context import resolve_club_scope
GROUP_CATALOG_SCOPE_MSG = ( GROUP_CATALOG_SCOPE_MSG = (
'商品类型与专区为集团全局数据,请在顶栏切换为「集团汇总(全部子公司)」后操作;' '商品类型为集团全局数据,请在顶栏切换为「集团汇总(全部子公司)」后操作;'
'具体小程序仅可在「小程序配置」中做上架/图标等俱乐部侧配置' '商品与专区已按俱乐部隔离,切换对应俱乐部即可管理'
) )
def block_non_group_catalog_scope(request): def block_non_group_catalog_scope(request):
"""非集团汇总视图时返回错误文案,否则返回 None。""" """非集团汇总视图时返回错误文案,否则返回 None。用于类型等全局目录。"""
if resolve_club_scope(request) != DATA_SCOPE_ALL: if resolve_club_scope(request) != DATA_SCOPE_ALL:
return GROUP_CATALOG_SCOPE_MSG return GROUP_CATALOG_SCOPE_MSG
return None 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: for m in members:
member_list.append(_format_member(m)) member_list.append(_format_member(m))
else: else:
# 有本俱乐部专属价:只展示本店上架项;尚无专属价:回落集团默认会员定义。
# 绝不混入其他俱乐部的 ClubHuiyuanPrice。
has_club_prices = bool(price_map)
for m in members: for m in members:
row = price_map.get(m.huiyuan_id) row = price_map.get(m.huiyuan_id)
if row is None and club_id != CLUB_ID_DEFAULT: if has_club_prices:
continue if row is None or not row.is_enabled:
if row is not None and not row.is_enabled: continue
continue item = _format_member(m, row, club_id=club_id)
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) item['goumai_cishu'] = _sales_map.get(m.huiyuan_id, 0)
member_list.append(item) member_list.append(item)
@@ -143,7 +147,7 @@ def build_member_list_payload(request):
'club_id': club_id, 'club_id': club_id,
'scope': scope, 'scope': scope,
'price_note': ( 'price_note': (
f'当前俱乐部 {club_id} 专属售价club_huiyuan_price' f'当前俱乐部 {club_id} 专属售价;未配置专属价时展示集团默认会员'
if scope != DATA_SCOPE_ALL else 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): 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 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): if club_write_blocked(request):
return None, CLUB_WRITE_SCOPE_MSG return None, CLUB_WRITE_SCOPE_MSG
@@ -285,18 +293,36 @@ def upload_member_card_image(request, huiyuan_id, field, file_obj):
return None, msg return None, msg
club_id = club_id_for_write(request) 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() row = ClubHuiyuanPrice.query.filter(club_id=club_id, huiyuan_id=huiyuan_id).first()
if not row: if not row:
return None, '请先在当前俱乐部上架该会员' 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) setattr(row, field, rel_path)
row.save(update_fields=[field, 'UpdateTime']) 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 return {field: rel_path, 'huiyuan_id': huiyuan_id}, None

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
"""跨俱乐部资产迁移 — 资产字段白名单字典(只迁登记过的字段)。"""
from __future__ import annotations
from typing import Dict, List, Tuple
# key = "role.field" huiyuan 为特殊包
ASSET_CATALOG: Dict[str, dict] = {
'dashou.yue': {'role': 'dashou', 'field': 'yue', 'label': '打手可提现', 'kind': 'money'},
'dashou.yajin': {'role': 'dashou', 'field': 'yajin', 'label': '打手押金', 'kind': 'money'},
'dashou.jifen': {'role': 'dashou', 'field': 'jifen', 'label': '打手积分', 'kind': 'int'},
'dashou.dongjie_yue': {'role': 'dashou', 'field': 'dongjie_yue', 'label': '打手冻结资金', 'kind': 'money'},
'dashou.huiyuan': {'role': 'dashou', 'field': 'huiyuan', 'label': '打手会员资格', 'kind': 'huiyuan'},
'shangjia.yue': {'role': 'shangjia', 'field': 'yue', 'label': '商家余额', 'kind': 'money'},
'guanshi.yue': {'role': 'guanshi', 'field': 'yue', 'label': '管事可提现', 'kind': 'money'},
'guanshi.dongjie_yue': {'role': 'guanshi', 'field': 'dongjie_yue', 'label': '管事冻结资金', 'kind': 'money'},
'zuzhang.ketixian_jine': {'role': 'zuzhang', 'field': 'ketixian_jine', 'label': '组长可提现', 'kind': 'money'},
'zuzhang.dongjie_yue': {'role': 'zuzhang', 'field': 'dongjie_yue', 'label': '组长冻结资金', 'kind': 'money'},
'shenheguan.yue': {'role': 'shenheguan', 'field': 'yue', 'label': '考核官可提现', 'kind': 'money'},
'shenheguan.dongjie_yue': {'role': 'shenheguan', 'field': 'dongjie_yue', 'label': '考核官冻结资金', 'kind': 'money'},
}
ROLE_PROFILE_ATTR = {
'dashou': 'DashouProfile',
'shangjia': 'ShopProfile',
'guanshi': 'GuanshiProfile',
'zuzhang': 'ZuzhangProfile',
'shenheguan': 'ShenheguanProfile',
}
def normalize_whitelist(raw) -> List[str]:
if not raw:
return []
out = []
seen = set()
for x in raw:
k = str(x or '').strip()
if k in ASSET_CATALOG and k not in seen:
seen.add(k)
out.append(k)
return out
def catalog_for_admin() -> List[dict]:
return [
{'key': k, 'label': v['label'], 'role': v['role'], 'field': v['field'], 'kind': v['kind']}
for k, v in ASSET_CATALOG.items()
]
def roles_from_whitelist(keys: List[str]) -> List[str]:
roles = []
seen = set()
for k in keys:
meta = ASSET_CATALOG.get(k)
if not meta:
continue
r = meta['role']
if r not in seen:
seen.add(r)
roles.append(r)
return roles

View File

@@ -0,0 +1,90 @@
"""迁移码:按目标俱乐部 AppID 生成无限小程序码并上传 COS。"""
from __future__ import annotations
import io
import logging
import time
from typing import Optional, Tuple
import requests
from django.conf import settings
from jituan.services.club_migrate import SCENE_PREFIX, scene_for_token
logger = logging.getLogger(__name__)
RECEIVE_PAGE = 'pages/migrate/receive'
def generate_migrate_qrcode(*, to_club_id: str, token: str) -> Tuple[str, str]:
"""返回 (relative_url, err)。成功时 err 为空。"""
from utils.weixin_token import get_club_mini_access_token, is_weixin_token_invalid
club_id = (to_club_id or '').strip()
if not club_id:
return '', '缺少目标俱乐部'
scene = scene_for_token(token)
if len(scene) > 32:
return '', 'scene 超长'
if not scene.startswith(SCENE_PREFIX):
return '', 'scene 非法'
access_token = get_club_mini_access_token(club_id)
if not access_token:
return '', f'俱乐部 {club_id} 获取微信 token 失败'
post_data = {
'scene': scene,
'page': RECEIVE_PAGE,
'width': 430,
'auto_color': False,
'line_color': {'r': 0, 'g': 0, 'b': 0},
'check_path': False,
}
def _req(tok: str):
url = f'https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={tok}'
return requests.post(url, json=post_data, timeout=15)
wx_resp = _req(access_token)
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 = _req(access_token)
except Exception:
pass
content_type = wx_resp.headers.get('content-type', '')
if wx_resp.status_code != 200 or 'image' not in content_type:
errmsg = '未知错误'
try:
error_info = wx_resp.json()
errmsg = error_info.get('errmsg', errmsg)
logger.error('migrate qr fail club=%s err=%s', club_id, error_info)
except Exception:
logger.error('migrate qr fail club=%s status=%s', club_id, wx_resp.status_code)
return '', f'生成小程序码失败: {errmsg}'
from utils.oss_utils import upload_to_oss
filename = f"migrate_{club_id}_{token}_{int(time.time())}.png"
oss_path = f"club_migrate/{club_id}/{filename}"
file_obj = io.BytesIO(wx_resp.content)
try:
full_url = upload_to_oss(file_obj, oss_path, content_type='image/png')
except Exception as e:
logger.exception('migrate qr upload fail: %s', e)
return '', f'上传小程序码失败: {e}'
if not full_url:
return '', '上传小程序码失败'
# 前端保存相册必须用 https 完整链;相对路径在 oss 未就绪时会保存失败
if str(full_url).startswith('http'):
return str(full_url), ''
cos_domain = getattr(settings, 'COS_DOMAIN', '').rstrip('/')
if cos_domain:
return f'{cos_domain}/{str(full_url).lstrip("/")}', ''
return str(full_url), ''

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): 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: if explicit:
return (explicit or '').strip() return (explicit or '').strip()
scope = resolve_club_scope(request) return ''
if scope == DATA_SCOPE_ALL:
return ''
return resolve_club_id_from_request(request)
def filter_roles_for_request(request): def filter_roles_for_request(request):
""" """
子公司视图:仅本俱乐部角色。 子公司视图:优先本俱乐部角色;本俱乐部尚无角色时仅回落集团全局角色。
集团汇总视图:全部角色(含各俱乐部 + 全局 club_id 为空) 绝不混入其他俱乐部角色
集团汇总视图:全部角色(含各俱乐部 + 全局)。
""" """
qs = Role.objects.filter(RoleStatus=1) qs = Role.objects.filter(RoleStatus=1)
scope = resolve_club_scope(request) scope = resolve_club_scope(request)
if scope == DATA_SCOPE_ALL: if scope == DATA_SCOPE_ALL:
return qs return qs
club_id = resolve_club_id_from_request(request) 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): 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) _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): def resolve_club_by_appid(app_id):
if not app_id: if not app_id:
return None return None
app_id = app_id.strip()
try: 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: 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): def get_club_wx_credentials(club_id):
"""优先读 club 表xq 缺省时回退 settings 全局配置(兼容现网)。""" """优先读 club 表xq 缺省时回退 settings 全局配置(兼容现网)。"""
club_id = club_id or CLUB_ID_DEFAULT club_id = club_id or CLUB_ID_DEFAULT
club = None
try: try:
club = Club.query.get(club_id=club_id) club = Club.query.get(club_id=club_id)
except Club.DoesNotExist: except Club.DoesNotExist:
club = None club = None
except Exception:
logger.exception('读取俱乐部失败 club_id=%s', club_id)
club = None
if club and club.wx_appid and club.wx_secret: appid = get_club_miniapp_appid(club)
return club.wx_appid, club.wx_secret, club secret = (club.wx_secret or '').strip() if club else ''
if appid and secret:
return appid, secret, club
if club_id == CLUB_ID_DEFAULT: if club_id == CLUB_ID_DEFAULT:
return ( return (
@@ -39,7 +59,10 @@ def get_club_wx_credentials(club_id):
getattr(settings, 'WEIXIN_SECRET', ''), getattr(settings, 'WEIXIN_SECRET', ''),
club, club,
) )
return '', '', club # 非默认俱乐部未配置时给出明确原因,避免后续空指针 500
if not club:
return '', '', None
return appid or '', secret or '', club
def _invite_code_from_request(request): def _invite_code_from_request(request):
@@ -120,6 +143,62 @@ def wechat_jscode2session_for_request(request, code):
return jscode2session(code, club_id=club_id) return jscode2session(code, club_id=club_id)
def get_club_official_credentials(club_id):
"""服务号 AppID/SecretH5 网页授权 + 公众号 JSAPI"""
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 not club:
return '', '', None
appid = (club.official_appid or '').strip()
secret = (club.official_secret or '').strip()
return appid, secret, club
def oauth2_access_token(code, club_id=None):
"""
服务号网页授权code → openidsns/oauth2/access_token
"""
appid, secret, club = get_club_official_credentials(club_id)
if not appid or not secret:
return {'errmsg': '俱乐部服务号配置未设置official_appid/official_secret'}
url = 'https://api.weixin.qq.com/sns/oauth2/access_token'
params = {
'appid': appid,
'secret': secret,
'code': code,
'grant_type': 'authorization_code',
}
try:
response = requests.get(url, params=params, timeout=10)
result = response.json()
if 'openid' in result:
return {
'openid': result['openid'],
'access_token': result.get('access_token', ''),
'refresh_token': result.get('refresh_token', ''),
'scope': result.get('scope', ''),
'unionid': result.get('unionid', ''),
'club_id': (club.club_id if club else None) or club_id or CLUB_ID_DEFAULT,
'wx_appid': appid,
}
errcode = result.get('errcode', 'unknown')
errmsg = result.get('errmsg', '未知错误')
return {'errmsg': f'[{errcode}]{errmsg}'}
except requests.exceptions.Timeout:
return {'errmsg': '请求微信接口超时'}
except Exception as e:
logger.exception('oauth2_access_token 异常 club_id=%s', club_id)
return {'errmsg': f'请求微信接口异常: {str(e)}'}
def jscode2session(code, club_id=None, app_id=None): def jscode2session(code, club_id=None, app_id=None):
""" """
按俱乐部微信配置换取 openid。 按俱乐部微信配置换取 openid。

View File

@@ -62,6 +62,8 @@ def build_leixing_item(leixing, cfg=None):
'bankuai_id': leixing.bankuai_id, 'bankuai_id': leixing.bankuai_id,
'club_enabled': True, 'club_enabled': True,
'icon_source': 'club' if cfg and cfg.tupian_url else 'global', 'icon_source': 'club' if cfg and cfg.tupian_url else 'global',
# 新字段旧客户端忽略1/true 时该类型订单强制展示价格
'show_price': bool(getattr(cfg, 'show_price', False)) if cfg else False,
} }
return item return item
@@ -165,6 +167,33 @@ def disable_leixing_for_club(request, leixing_id):
return {'leixing_id': leixing_id}, None return {'leixing_id': leixing_id}, None
def set_leixing_show_price(request, leixing_id, show_price):
"""按俱乐部商品类型开关:是否强制展示订单价格。"""
if club_write_blocked(request):
return None, CLUB_WRITE_SCOPE_MSG
club_id = club_id_for_write(request)
try:
lx = ShangpinLeixing.query.get(id=leixing_id)
except ShangpinLeixing.DoesNotExist:
return None, '商品类型不存在'
row, _ = ClubShangpinLeixingConfig.query.get_or_create(
club_id=club_id,
leixing_id=int(leixing_id),
defaults={
'is_enabled': club_id == CLUB_ID_DEFAULT,
'paixu': int(lx.paixu or 0),
'show_price': False,
},
)
row.show_price = bool(show_price)
row.save(update_fields=['show_price', 'UpdateTime'])
return {
'leixing_id': int(leixing_id),
'show_price': bool(row.show_price),
}, None
def upload_leixing_icon(request, leixing_id, file_obj): def upload_leixing_icon(request, leixing_id, file_obj):
if club_write_blocked(request): if club_write_blocked(request):
return None, CLUB_WRITE_SCOPE_MSG return None, CLUB_WRITE_SCOPE_MSG
@@ -206,4 +235,5 @@ def copy_club_shangpin_leixing_config(template_club_id, new_club_id):
is_enabled=row.is_enabled, is_enabled=row.is_enabled,
tupian_url='', # 新俱乐部需自行上传图标,不复制 OSS 路径 tupian_url='', # 新俱乐部需自行上传图标,不复制 OSS 路径
paixu=row.paixu, paixu=row.paixu,
show_price=bool(getattr(row, 'show_price', False)),
) )

View File

@@ -1,9 +1,13 @@
"""用户所属俱乐部解析。""" """用户所属俱乐部解析。"""
import logging
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from jituan.constants import CLUB_ID_DEFAULT from jituan.constants import CLUB_ID_DEFAULT
from jituan.models import UserWxOpenid from jituan.models import UserWxOpenid
logger = logging.getLogger(__name__)
def resolve_invite_party_club(user, request_club_id=None): def resolve_invite_party_club(user, request_club_id=None):
""" """
@@ -95,7 +99,12 @@ def find_user_by_wx_openid(club_id, openid):
return None return None
def ensure_wx_openid_binding(club_id, openid, user, unionid=''): def ensure_wx_openid_binding(club_id, openid, user, unionid='', wx_appid=''):
"""
绑定 openid。
wx_appid 用于区分小程序 / 服务号;只清理「同 club + 同 wx_appid」的旧绑定
避免 H5 登录冲掉小程序 openid或反过来
"""
if not club_id or not openid or not user: if not club_id or not openid or not user:
return None return None
uid = getattr(user, 'UserUID', None) uid = getattr(user, 'UserUID', None)
@@ -104,25 +113,58 @@ def ensure_wx_openid_binding(club_id, openid, user, unionid=''):
club_id = (club_id or '').strip() club_id = (club_id or '').strip()
openid = (openid or '').strip() openid = (openid or '').strip()
wx_appid = (wx_appid or '').strip()
binding = UserWxOpenid.query.filter(club_id=club_id, openid=openid).first() binding = UserWxOpenid.query.filter(club_id=club_id, openid=openid).first()
if binding: if binding and binding.yonghuid == uid:
if wx_appid and getattr(binding, 'wx_appid', '') != wx_appid:
binding.wx_appid = wx_appid
if unionid:
binding.unionid = unionid
binding.save()
return binding return binding
try: try:
# 独立 savepoint并发/重复绑定时不污染外层 transaction.atomic
with transaction.atomic(): with transaction.atomic():
return UserWxOpenid.query.create( # 仅清理同一 AppID 下的过期绑定;小程序与服务号可并存
stale_qs = UserWxOpenid.query.filter(yonghuid=uid, club_id=club_id).exclude(openid=openid)
if wx_appid:
stale_qs = stale_qs.filter(wx_appid=wx_appid)
else:
stale_qs = stale_qs.filter(wx_appid='')
stale_count = stale_qs.count()
if stale_count:
logger.info(
'[WX_OPENID_BIND] uid=%s club=%s appid=%s 清除 %d 条过期 openid 绑定',
uid, club_id, wx_appid or '(legacy)', stale_count,
)
stale_qs.delete()
binding = UserWxOpenid.query.filter(club_id=club_id, openid=openid).first()
if binding:
if binding.yonghuid != uid:
return binding
if wx_appid and getattr(binding, 'wx_appid', '') != wx_appid:
binding.wx_appid = wx_appid
binding.save(update_fields=['wx_appid'])
return binding
row = UserWxOpenid.query.create(
club_id=club_id, club_id=club_id,
openid=openid, openid=openid,
yonghuid=uid, yonghuid=uid,
unionid=unionid or getattr(user, 'UnionID', None) or '', unionid=unionid or getattr(user, 'UnionID', None) or '',
wx_appid=wx_appid,
) )
if club_id == CLUB_ID_DEFAULT and getattr(user, 'OpenID', None) != openid:
user.OpenID = openid
user.save(update_fields=['OpenID'])
return row
except IntegrityError: except IntegrityError:
return UserWxOpenid.query.filter(club_id=club_id, openid=openid).first() return UserWxOpenid.query.filter(club_id=club_id, openid=openid).first()
def resolve_or_create_wx_user(club_id, openid, generate_uid, unionid=''): def resolve_or_create_wx_user(club_id, openid, generate_uid, unionid='', wx_appid=''):
""" """
微信登录/注册统一找用户,避免 UserName(openid) 唯一键冲突。 微信登录/注册统一找用户,避免 UserName(openid) 唯一键冲突。
星之界等俱乐部用户常 UserName=openid 且 OpenID 为空,不可 get_or_create(OpenID=...)。 星之界等俱乐部用户常 UserName=openid 且 OpenID 为空,不可 get_or_create(OpenID=...)。
@@ -133,14 +175,31 @@ def resolve_or_create_wx_user(club_id, openid, generate_uid, unionid=''):
openid = (openid or '').strip() openid = (openid or '').strip()
club_id = (club_id or '').strip() club_id = (club_id or '').strip()
wx_appid = (wx_appid or '').strip()
if not openid: if not openid:
raise ValueError('openid 不能为空') raise ValueError('openid 不能为空')
user = find_user_by_wx_openid(club_id, openid) user = find_user_by_wx_openid(club_id, openid)
if user: if user:
ensure_wx_openid_binding(club_id, openid, user, unionid) ensure_wx_openid_binding(club_id, openid, user, unionid, wx_appid=wx_appid)
return user, False return user, False
# 有 unionid 时尽量合并到已有用户(小程序 / 服务号同人)
if unionid:
binding_u = UserWxOpenid.query.filter(unionid=unionid).order_by('id').first()
if binding_u:
user = User.objects.filter(UserUID=binding_u.yonghuid).first()
if not user:
user = User.query.filter(UserUID=binding_u.yonghuid).first()
if user:
ensure_wx_openid_binding(club_id, openid, user, unionid, wx_appid=wx_appid)
return user, False
for manager in _user_querysets():
user = manager.filter(UnionID=unionid).first()
if user:
ensure_wx_openid_binding(club_id, openid, user, unionid, wx_appid=wx_appid)
return user, False
try: try:
with transaction.atomic(): with transaction.atomic():
user = User( user = User(
@@ -156,10 +215,10 @@ def resolve_or_create_wx_user(club_id, openid, generate_uid, unionid=''):
user = find_user_by_wx_openid(club_id, openid) user = find_user_by_wx_openid(club_id, openid)
if not user: if not user:
raise raise
ensure_wx_openid_binding(club_id, openid, user, unionid) ensure_wx_openid_binding(club_id, openid, user, unionid, wx_appid=wx_appid)
return user, False return user, False
ensure_wx_openid_binding(club_id, openid, user, unionid) ensure_wx_openid_binding(club_id, openid, user, unionid, wx_appid=wx_appid)
return user, True return user, True
@@ -200,32 +259,148 @@ def _looks_like_wx_openid(value):
return len(v) >= 20 and v.startswith('o') return len(v) >= 20 and v.startswith('o')
def get_wx_openid_for_user(user, club_id=None): def _client_type_from_request(request):
if not request:
return 'mini'
meta = getattr(request, 'META', None) or {}
header = (meta.get('HTTP_X_CLIENT') or meta.get('HTTP_X_PAY_CLIENT') or '').strip().lower()
data = getattr(request, 'data', None) or {}
body = ''
if isinstance(data, dict):
body = (data.get('client_type') or data.get('pay_client') or '').strip().lower()
val = header or body
if val in ('h5', 'official', 'mp-h5', 'oa'):
return 'h5'
return 'mini'
def resolve_prefer_wx_appid(club_id, client_type='mini'):
"""按端解析应付的 AppIDH5→服务号小程序→wx_appid。"""
from jituan.models import Club
cid = (club_id or '').strip()
if not cid:
return ''
try:
club = Club.query.get(club_id=cid)
except Exception:
return ''
if client_type == 'h5':
return (club.official_appid or '').strip()
return (club.wx_appid or club.pay_app_id or '').strip()
def resolve_club_payment_openid(user, club_id=None, prefer_wx_appid=''):
""" """
按俱乐部取微信支付 JSAPI 用 openid。 支付专用:按俱乐部取 openid。
多俱乐部用户 openid 在 user_wx_openidxq 老用户可回落 User.OpenID prefer_wx_appid 有值时优先取该 AppID 绑定H5 服务号 / 小程序分流)。
星之界等俱乐部用户常 UserName=openid 且 OpenID 为空,再回落 UserName。 1) user_wx_openid(yonghuid, club_id[, wx_appid])
2) 无本 club 绑定但有其他 club → 拒绝(须在本端重新登录)
3) 无任何绑定时 lazy backfillxq→User.OpenIDxzj 等→UserName(openid)
""" """
if not user: if not user:
return '' return '', 'none'
cid = (club_id or '').strip() or get_user_club_id(user) cid = (club_id or '').strip() or get_user_club_id(user)
uid = getattr(user, 'UserUID', None) or getattr(user, 'yonghuid', None) uid = getattr(user, 'UserUID', None) or getattr(user, 'yonghuid', None)
if uid and cid: if not uid or not cid:
binding = UserWxOpenid.query.filter(yonghuid=uid, club_id=cid).first() return '', 'none'
if binding and binding.openid:
return binding.openid prefer = (prefer_wx_appid or '').strip()
legacy = getattr(user, 'OpenID', None) or getattr(user, 'openid', None) qs = UserWxOpenid.query.filter(yonghuid=uid, club_id=cid)
if legacy: binding = None
return legacy.strip() if prefer:
binding = qs.filter(wx_appid=prefer).order_by('-id').first()
if not binding and prefer:
# 历史行 wx_appid 为空:仅小程序端可回落
pass
if not binding and not prefer:
binding = qs.order_by('-id').first()
if not binding and prefer:
# 小程序端:允许回落空 wx_appid 历史绑定
from jituan.models import Club
try:
club = Club.query.get(club_id=cid)
mini = (club.wx_appid or club.pay_app_id or '').strip()
except Exception:
mini = ''
if prefer == mini:
binding = qs.filter(wx_appid='').order_by('-id').first()
if binding and binding.openid:
return binding.openid.strip(), f'user_wx_openid#{binding.id}'
if prefer:
logger.warning(
'[PAY_OPENID] uid=%s club=%s 无 appid=%s 绑定,须先在对应端完成微信登录',
uid, cid, prefer,
)
return '', 'missing_appid_bind'
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() username = (getattr(user, 'UserName', None) or '').strip()
if _looks_like_wx_openid(username): legacy_oid = (getattr(user, 'OpenID', None) or getattr(user, 'openid', None) or '').strip()
return username candidate = ''
return '' 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, prefer_wx_appid=''):
openid, _ = resolve_club_payment_openid(user, club_id, prefer_wx_appid=prefer_wx_appid)
return openid
def get_wx_openid_for_user_with_source(user, club_id=None, prefer_wx_appid=''):
return resolve_club_payment_openid(user, club_id, prefer_wx_appid=prefer_wx_appid)
def get_payment_openid(request, user=None, club_id=None): def get_payment_openid(request, user=None, club_id=None):
"""下单/充值:按请求俱乐部解析 openid。""" """下单/充值:按请求俱乐部 + 端类型解析 openid。"""
from jituan.services.club_write import resolve_club_id_for_write from jituan.services.club_write import resolve_club_id_for_write
user = user or getattr(request, 'user', None) user = user or getattr(request, 'user', None)
cid = club_id or resolve_club_id_for_write(request, user) cid = (club_id or '').strip() or resolve_club_id_for_write(request, user)
return get_wx_openid_for_user(user, cid), cid client_type = _client_type_from_request(request)
prefer = resolve_prefer_wx_appid(cid, client_type)
openid, source = get_wx_openid_for_user_with_source(user, cid, prefer_wx_appid=prefer)
if openid:
logger.info(
'[PAY_OPENID] uid=%s club=%s client=%s appid=%s openid=%s… source=%s',
getattr(user, 'UserUID', ''), cid, client_type, prefer or '-',
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 client=%s appid=%s source=%s user_club=%s',
uid, cid, client_type, prefer or '-', source, get_user_club_id(user),
)
return openid, cid
def get_pay_app_source(request):
"""供统一下单选用 AppIDh5→official其它→mini。"""
return 'official' if _client_type_from_request(request) == 'h5' else 'mini'

View File

@@ -41,3 +41,20 @@ def forbid_if_order_out_of_scope(request, order):
if order_belongs_to_request(order, request): if order_belongs_to_request(order, request):
return None return None
return Response({'code': 403, 'msg': '该订单不属于当前俱乐部数据范围'}) return Response({'code': 403, 'msg': '该订单不属于当前俱乐部数据范围'})
def forbid_if_order_mutate_out_of_scope(request, order):
"""
订单写操作闸门:
- SINGLE_CLUB只能处理当前俱乐部订单
- ALL_CLUBS仅集团高管可处理本店超管须切到具体俱乐部
"""
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
return forbid_if_order_out_of_scope(request, order)

View File

@@ -5,12 +5,14 @@ import logging
from decimal import Decimal from decimal import Decimal
from django.db import transaction from django.db import transaction
from django.db.models import F
from backend.utils import update_guanshi_daily_by_action from backend.utils import update_guanshi_daily_by_action
from jituan.constants import CLUB_ID_DEFAULT from jituan.constants import CLUB_ID_DEFAULT
from jituan.services.club_penalty import resolve_gsfenhong_club_id from jituan.services.club_penalty import resolve_gsfenhong_club_id
from orders.models import CommissionRate from orders.models import CommissionRate
from products.models import Czjilu, Gsfenhong from products.models import Czjilu, Gsfenhong
from products.czjilu_types import is_czjilu_fulfillable_status
from users.models import UserDashou, UserGuanshi from users.models import UserDashou, UserGuanshi
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -26,15 +28,73 @@ class CzjiluFulfillError(Exception):
self.code = code 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 @transaction.atomic
def fulfill_yajin_recharge(dingdan_id, paid_amount_yuan=None): 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() order = Czjilu.query.filter(dingdan_id=dingdan_id).select_for_update().first()
if not order: if not order:
raise CzjiluFulfillError('订单不存在', 'not_found') raise CzjiluFulfillError('订单不存在', 'not_found')
if order.leixing != 2: if order.leixing != 2:
raise CzjiluFulfillError('订单类型不是押金', 'type_error') raise CzjiluFulfillError('订单类型不是押金', 'type_error')
if order.zhuangtai != 9: if not is_czjilu_fulfillable_status(order.zhuangtai):
try: try:
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
repair_wechat_income_if_missing( repair_wechat_income_if_missing(
@@ -93,9 +153,20 @@ def _apply_yajin_fenhong(order, dashou, amount):
if fenhong_jine <= 0: if fenhong_jine <= 0:
return return
guanshi = UserGuanshi.objects.select_for_update().get(user__UserUID=guanshi_id) guanshi = UserGuanshi.objects.select_for_update().get(user__UserUID=guanshi_id)
guanshi.yue += fenhong_jine from jituan.services.fund_freeze import credit_guanshi_fenhong
guanshi.chongzhifenrun += fenhong_jine dingdan_id = str(order.dingdan_id or '')
guanshi.save(update_fields=['yue', 'chongzhifenrun']) result = credit_guanshi_fenhong(
user_id=str(guanshi_id),
amount=fenhong_jine,
biz_id=f'{dingdan_id}:guanshi:yajin',
profile=guanshi,
czjilu_id=dingdan_id,
freeze_reason=f'押金分红 {dingdan_id}',
)
if not result.get('idempotent'):
UserGuanshi.objects.filter(pk=guanshi.pk).update(
chongzhifenrun=F('chongzhifenrun') + fenhong_jine,
)
user_main = dashou.user user_main = dashou.user
Gsfenhong.query.create( Gsfenhong.query.create(
dingdan_id=order.dingdan_id, dingdan_id=order.dingdan_id,
@@ -118,13 +189,13 @@ def _apply_yajin_fenhong(order, dashou, amount):
@transaction.atomic @transaction.atomic
def fulfill_jifen_recharge(dingdan_id, paid_amount_yuan=None): 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() order = Czjilu.query.filter(dingdan_id=dingdan_id).select_for_update().first()
if not order: if not order:
raise CzjiluFulfillError('订单不存在', 'not_found') raise CzjiluFulfillError('订单不存在', 'not_found')
if order.leixing != 3: if order.leixing != 3:
raise CzjiluFulfillError('订单类型不是积分', 'type_error') raise CzjiluFulfillError('订单类型不是积分', 'type_error')
if order.zhuangtai != 9: if not is_czjilu_fulfillable_status(order.zhuangtai):
try: try:
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
repair_wechat_income_if_missing( repair_wechat_income_if_missing(
@@ -179,7 +250,7 @@ def fulfill_fakuan(dingdan_id, paid_amount_yuan=None):
raise CzjiluFulfillError('订单不存在', 'not_found') raise CzjiluFulfillError('订单不存在', 'not_found')
if order.leixing != 6: if order.leixing != 6:
raise CzjiluFulfillError('订单类型不是罚款', 'type_error') raise CzjiluFulfillError('订单类型不是罚款', 'type_error')
if order.zhuangtai != 9: if not is_czjilu_fulfillable_status(order.zhuangtai):
try: try:
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
repair_wechat_income_if_missing( repair_wechat_income_if_missing(
@@ -205,25 +276,51 @@ def fulfill_fakuan(dingdan_id, paid_amount_yuan=None):
fadan = None fadan = None
m = re.search(r'#fadan:(\d+)', order.shuoming or '') m = re.search(r'#fadan:(\d+)', order.shuoming or '')
if m: if m:
fadan_id_str = m.group(1)
fadan = Penalty.query.filter( fadan = Penalty.query.filter(
id=int(m.group(1)), id=int(fadan_id_str),
PenalizedUserID=order.yonghuid, PenalizedUserID=order.yonghuid,
Status__in=[1, 3], Status__in=[1, 3],
).first() ).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: if not fadan:
fadan = Penalty.query.filter( fadan = Penalty.query.filter(
PenalizedUserID=order.yonghuid, PenalizedUserID=order.yonghuid,
FineAmount=order.jine, FineAmount=order.jine,
Status__in=[1, 3], Status__in=[1, 3],
).order_by('-CreateTime').first() ).order_by('-CreateTime').first()
if fadan:
logger.warning(
'罚款订单 %s 回退金额匹配到罚单 %s(金额=%s',
dingdan_id, fadan.id, order.jine,
)
if fadan: if fadan:
fadan.Status = 2 fadan.Status = 2
fadan.save(update_fields=['Status']) fadan.save(update_fields=['Status'])
try:
from jituan.services.deal_stats import on_penalty_paid
on_penalty_paid(fadan)
except Exception as exc:
logger.warning('成交罚款指标记账跳过 fadan=%s: %s', fadan.id, exc)
success, msg = process_fadan_fenhong(fadan.id) success, msg = process_fadan_fenhong(fadan.id)
if not success: if not success:
logger.error('罚单 %s 分红处理失败: %s', fadan.id, msg) logger.error('罚单 %s 分红处理失败: %s', fadan.id, msg)
else: else:
logger.info('罚单 %s 分红处理完成: %s', fadan.id, msg) logger.info('罚单 %s 分红处理完成: %s', fadan.id, msg)
else:
logger.error(
'罚款订单 %s 未找到对应罚单,罚单状态未更新(被处罚者=%s, 金额=%s)',
dingdan_id, order.yonghuid, order.jine,
)
except Exception as exc: except Exception as exc:
logger.error('罚款罚单状态更新失败 dingdan=%s: %s', dingdan_id, exc, exc_info=True) logger.error('罚款罚单状态更新失败 dingdan=%s: %s', dingdan_id, exc, exc_info=True)
@@ -239,7 +336,7 @@ def fulfill_fakuan(dingdan_id, paid_amount_yuan=None):
@transaction.atomic @transaction.atomic
def fulfill_kaohe(dingdan_id, paid_amount_yuan=None): def fulfill_kaohe(dingdan_id, paid_amount_yuan=None):
"""考核支付幂等履约:仅 zhuangtai==9 时入账。""" """考核支付幂等履约:仅待履约状态(9/2)时入账。"""
from rank.models import KaohePayTemp from rank.models import KaohePayTemp
from rank.utils import create_shenhe_jilu_from_temp from rank.utils import create_shenhe_jilu_from_temp
from users.business_models import User from users.business_models import User
@@ -249,7 +346,7 @@ def fulfill_kaohe(dingdan_id, paid_amount_yuan=None):
raise CzjiluFulfillError('订单不存在', 'not_found') raise CzjiluFulfillError('订单不存在', 'not_found')
if order.leixing != 5: if order.leixing != 5:
raise CzjiluFulfillError('订单类型不是考核', 'type_error') raise CzjiluFulfillError('订单类型不是考核', 'type_error')
if order.zhuangtai != 9: if not is_czjilu_fulfillable_status(order.zhuangtai):
try: try:
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
repair_wechat_income_if_missing( repair_wechat_income_if_missing(
@@ -302,7 +399,7 @@ def fulfill_kaohe(dingdan_id, paid_amount_yuan=None):
@transaction.atomic @transaction.atomic
def fulfill_shangjia(dingdan_id, paid_amount_yuan=None): def fulfill_shangjia(dingdan_id, paid_amount_yuan=None):
"""商家充值幂等履约:仅 zhuangtai==9 时入账""" """商家充值幂等履约:仅待履约状态(9/2)时入账;已付(3)跳过防多加"""
from users.models import UserShangjia from users.models import UserShangjia
order = Czjilu.query.filter(dingdan_id=dingdan_id).select_for_update().first() order = Czjilu.query.filter(dingdan_id=dingdan_id).select_for_update().first()
@@ -310,7 +407,7 @@ def fulfill_shangjia(dingdan_id, paid_amount_yuan=None):
raise CzjiluFulfillError('订单不存在', 'not_found') raise CzjiluFulfillError('订单不存在', 'not_found')
if order.leixing != 4: if order.leixing != 4:
raise CzjiluFulfillError('订单类型不是商家充值', 'type_error') raise CzjiluFulfillError('订单类型不是商家充值', 'type_error')
if order.zhuangtai != 9: if not is_czjilu_fulfillable_status(order.zhuangtai):
try: try:
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
repair_wechat_income_if_missing( repair_wechat_income_if_missing(
@@ -350,9 +447,7 @@ def fulfill_shangjia(dingdan_id, paid_amount_yuan=None):
def confirm_czjilu_paid(dingdan_id, yonghuid, expected_leixing): def confirm_czjilu_paid(dingdan_id, yonghuid, expected_leixing):
"""轮询确认:支付宝订单直接看本地状态,微信订单查微信 SUCCESS 后幂等履约""" """轮询确认:付呗/微信/支付宝官方查单成功后幂等履约(防多加)"""
from django.core.cache import cache
try: try:
order = Czjilu.query.get(dingdan_id=dingdan_id, yonghuid=yonghuid) order = Czjilu.query.get(dingdan_id=dingdan_id, yonghuid=yonghuid)
except Czjilu.DoesNotExist: except Czjilu.DoesNotExist:
@@ -373,21 +468,22 @@ def confirm_czjilu_paid(dingdan_id, yonghuid, expected_leixing):
logger.error('充值单补记收支失败 dingdan=%s: %s', dingdan_id, exc, exc_info=True) logger.error('充值单补记收支失败 dingdan=%s: %s', dingdan_id, exc, exc_info=True)
return {'already_done': True, 'zhuangtai': 3} 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') 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)) 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: if expected_leixing == 1:
from jituan.services.member_recharge import fulfill_member_recharge from jituan.services.member_recharge import fulfill_member_recharge
return fulfill_member_recharge(dingdan_id, paid_amount_yuan=paid, source='confirm') return fulfill_member_recharge(dingdan_id, paid_amount_yuan=paid, source='confirm')
@@ -401,4 +497,15 @@ def confirm_czjilu_paid(dingdan_id, yonghuid, expected_leixing):
return fulfill_kaohe(dingdan_id, paid_amount_yuan=paid) return fulfill_kaohe(dingdan_id, paid_amount_yuan=paid)
if expected_leixing == 6: if expected_leixing == 6:
return fulfill_fakuan(dingdan_id, paid_amount_yuan=paid) 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') 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

@@ -0,0 +1,416 @@
"""成交/罚款指标幂等记账(仅 stats_enabled 且 CreateTime>=stats_since"""
from __future__ import annotations
import logging
from django.db import transaction
from django.db.models import F
from django.utils import timezone
logger = logging.getLogger(__name__)
def _cfg(club_id: str):
from jituan.services.order_deal import get_or_create_deal_config
return get_or_create_deal_config(club_id)
def _stats_on(cfg, order) -> bool:
"""
成交指标始终记账(抢单池要展示成交率/罚款率/均时)。
若配置了 stats_since只统计该时间点之后的订单。
"""
if not order:
return False
since = getattr(cfg, 'stats_since', None) if cfg else None
if since:
ct = getattr(order, 'CreateTime', None)
if ct and ct < since:
return False
return True
def ensure_club_stats_bootstrapped(club_id: str, *, lookback_days: int = 180):
"""
俱乐部首次拉取抢单池商家指标时,若从未记账则回填近 N 天历史订单。
幂等:用 DealStatEvent 打标记。
"""
cid = (club_id or '').strip()
if not cid:
return
marker = f'{cid}:stats_bootstrap_v1'
from jituan.models import DealStatEvent, MerchantDealStat
if DealStatEvent.query.filter(event_key=marker).exists():
return
# 已有商家样本则只打标,避免重复重算
if MerchantDealStat.query.filter(club_id=cid, order_count__gt=0).exists():
_claim_event(cid, marker, 'stats_bootstrap', subject_uid='system')
return
try:
from orders.models import Order
from datetime import timedelta
since = timezone.now() - timedelta(days=max(7, int(lookback_days or 180)))
qs = (
Order.query.filter(ClubID=cid, CreateTime__gte=since)
.exclude(PlayerID__isnull=True)
.exclude(PlayerID='')
.filter(Status__in=[2, 3, 4, 5, 6, 8])
.order_by('CreateTime')[:800]
)
for order in qs:
try:
on_order_enter_pool(order, force=True)
if int(order.Status or 0) == 3:
on_order_completed(order, settle_started_at=getattr(order, 'SettlementTime', None), force=True)
elif int(order.Status or 0) == 5:
on_order_refunded(order, force=True)
except Exception as e:
logger.warning('回填单条成交指标失败 order=%s: %s', getattr(order, 'OrderID', ''), e)
_claim_event(cid, marker, 'stats_bootstrap', subject_uid='system', meta={'n': len(qs)})
logger.info('俱乐部 %s 成交指标回填完成 n=%s', cid, len(qs))
except Exception as e:
logger.error('俱乐部成交指标回填失败 club=%s: %s', cid, e, exc_info=True)
def _claim_event(club_id: str, event_key: str, event_type: str, order_id='', subject_uid='', meta=None) -> bool:
from jituan.models import DealStatEvent
if DealStatEvent.query.filter(event_key=event_key).exists():
return False
try:
DealStatEvent.objects.create(
club_id=club_id,
event_key=event_key[:96],
event_type=event_type[:32],
order_id=(order_id or '')[:32],
subject_uid=(subject_uid or '')[:16],
meta_json=meta or {},
)
return True
except Exception:
return False
def _ensure_merchant(club_id, uid):
from jituan.models import MerchantDealStat
from django.db import IntegrityError
row = MerchantDealStat.query.filter(club_id=club_id, merchant_uid=uid).first()
if row:
return row
try:
row = MerchantDealStat(club_id=club_id, merchant_uid=uid)
row.save()
return row
except IntegrityError:
# 并发回填/记账时可能已有同 club+merchant 行,读回即可
return MerchantDealStat.query.filter(club_id=club_id, merchant_uid=uid).first()
def _ensure_dashou(club_id, uid):
from jituan.models import DashouDealStat
from django.db import IntegrityError
row = DashouDealStat.query.filter(club_id=club_id, dashou_uid=uid).first()
if row:
return row
try:
row = DashouDealStat(club_id=club_id, dashou_uid=uid)
row.save()
return row
except IntegrityError:
return DashouDealStat.query.filter(club_id=club_id, dashou_uid=uid).first()
def _merchant_uid(order):
try:
ext = getattr(order, 'shangjia_kuozhan', None)
return (getattr(ext, 'MerchantID', None) or '') if ext else ''
except Exception:
return ''
@transaction.atomic
def on_order_enter_pool(order, *, force=False):
"""接单入池:商家/打手 order_count+1。force=True 用于历史回填(忽略 since"""
club_id = (getattr(order, 'ClubID', None) or '').strip()
cfg = _cfg(club_id)
if not force and not _stats_on(cfg, order):
return
oid = order.OrderID
dashou = (order.PlayerID or '').strip()
if dashou and _claim_event(club_id, f'{oid}:dashou_in', 'order_in_pool', oid, dashou):
_ensure_dashou(club_id, dashou)
from jituan.models import DashouDealStat
DashouDealStat.objects.filter(club_id=club_id, dashou_uid=dashou).update(
order_count=F('order_count') + 1, UpdateTime=timezone.now(),
)
if order.Platform == 2:
mid = _merchant_uid(order)
if mid and _claim_event(club_id, f'{oid}:merchant_in', 'order_in_pool', oid, mid):
_ensure_merchant(club_id, mid)
from jituan.models import MerchantDealStat
MerchantDealStat.objects.filter(club_id=club_id, merchant_uid=mid).update(
order_count=F('order_count') + 1, UpdateTime=timezone.now(),
)
@transaction.atomic
def on_order_completed(order, settle_started_at=None, *, force=False):
club_id = (getattr(order, 'ClubID', None) or '').strip()
cfg = _cfg(club_id)
if not force and not _stats_on(cfg, order):
return
oid = order.OrderID
if not _claim_event(club_id, f'{oid}:completed', 'completed', oid):
return
# 确保入池
on_order_enter_pool(order, force=force)
st = settle_started_at or getattr(order, '_deal_settle_started_at', None)
duration = None
if st:
try:
duration = max(0, int((timezone.now() - st).total_seconds()))
except Exception:
duration = None
dashou = (order.PlayerID or '').strip()
if dashou:
_ensure_dashou(club_id, dashou)
from jituan.models import DashouDealStat
DashouDealStat.objects.filter(club_id=club_id, dashou_uid=dashou).update(
completed_count=F('completed_count') + 1, UpdateTime=timezone.now(),
)
if order.Platform == 2:
mid = _merchant_uid(order)
if mid:
_ensure_merchant(club_id, mid)
from jituan.models import MerchantDealStat
qs = MerchantDealStat.objects.filter(club_id=club_id, merchant_uid=mid)
qs.update(completed_count=F('completed_count') + 1, UpdateTime=timezone.now())
if duration is not None:
qs.update(
settle_duration_sum_seconds=F('settle_duration_sum_seconds') + duration,
settle_duration_sample_count=F('settle_duration_sample_count') + 1,
)
@transaction.atomic
def on_order_refunded(order, *, force=False):
club_id = (getattr(order, 'ClubID', None) or '').strip()
cfg = _cfg(club_id)
if (not force and not _stats_on(cfg, order)) or order.Platform != 2:
return
oid = order.OrderID
mid = _merchant_uid(order)
if not mid:
return
if not _claim_event(club_id, f'{oid}:refunded', 'refunded', oid, mid):
return
on_order_enter_pool(order, force=force)
_ensure_merchant(club_id, mid)
from jituan.models import MerchantDealStat
MerchantDealStat.objects.filter(club_id=club_id, merchant_uid=mid).update(
refund_count=F('refund_count') + 1, UpdateTime=timezone.now(),
)
@transaction.atomic
def on_penalty_paid(penalty):
"""罚单 Status→2 已缴纳。"""
from utils.penalty_status import PENALTY_PAID
if int(getattr(penalty, 'Status', 0) or 0) != PENALTY_PAID:
return
club_id = (getattr(penalty, 'ClubID', None) or '').strip()
if not club_id:
return
pid = getattr(penalty, 'id', None) or getattr(penalty, 'pk', None)
key = f'penalty:{pid}:paid'
if not _claim_event(club_id, key, 'fine_paid', getattr(penalty, 'RelatedOrderID', '') or ''):
return
applicant = (getattr(penalty, 'ApplicantID', None) or '').strip()
penalized = (getattr(penalty, 'PenalizedUserID', None) or '').strip()
# 商家申请人 → 商家罚款率分子
if applicant:
_ensure_merchant(club_id, applicant)
from jituan.models import MerchantDealStat
MerchantDealStat.objects.filter(club_id=club_id, merchant_uid=applicant).update(
fine_paid_count=F('fine_paid_count') + 1, UpdateTime=timezone.now(),
)
# 被罚打手 → 被罚款率
if penalized:
_ensure_dashou(club_id, penalized)
from jituan.models import DashouDealStat
DashouDealStat.objects.filter(club_id=club_id, dashou_uid=penalized).update(
fine_paid_count=F('fine_paid_count') + 1, UpdateTime=timezone.now(),
)
def serialize_merchant_stat(row) -> dict:
n = int(row.order_count or 0)
c = int(row.completed_count or 0)
r = int(row.refund_count or 0)
f = int(row.fine_paid_count or 0)
sc = int(row.settle_duration_sample_count or 0)
ss = int(row.settle_duration_sum_seconds or 0)
return {
'merchant_uid': row.merchant_uid,
'order_count': n,
'completed_count': c,
'refund_count': r,
'fine_paid_count': f,
'deal_rate': round(c / n, 6) if n else 0,
'refund_rate': round(r / n, 6) if n else 0,
'fine_rate': round(f / n, 6) if n else 0,
'avg_deal_hours': round((ss / sc) / 3600.0, 4) if sc else 0,
'settle_duration_sample_count': sc,
}
def pool_merchant_stat_payload(row) -> dict | None:
"""
抢单池展示用:有订单样本才返回。
成交率/罚款率有样本即返回均时仅有结算样本时返回含均时≈0
前端按字段有哪个展示哪个。
"""
if not row:
return None
n = int(row.order_count or 0)
if n <= 0:
return None
full = serialize_merchant_stat(row)
out = {
'deal_rate': full['deal_rate'],
'fine_rate': full['fine_rate'],
'deal_rate_text': f"{round(full['deal_rate'] * 100, 1)}%",
'fine_rate_text': f"{round(full['fine_rate'] * 100, 1)}%",
}
sc = int(full.get('settle_duration_sample_count') or 0)
if sc > 0:
h = float(full.get('avg_deal_hours') or 0)
out['avg_deal_hours'] = h
if h < 1:
out['avg_deal_hours_text'] = f'{max(1, int(round(h * 60)))}分钟' if h > 0 else '不足1分钟'
else:
out['avg_deal_hours_text'] = f'{round(h, 1)}小时'
return out
def batch_pool_merchant_stats(club_id: str, merchant_uids, order_club_map=None) -> dict:
"""
批量取商家抢单池指标。
order_club_map: {merchant_uid: order_club_id} 优先用订单所属俱乐部查指标。
返回 {merchant_uid: payload}
"""
from jituan.models import MerchantDealStat
uids = [str(u).strip() for u in (merchant_uids or []) if u and str(u).strip()]
if not uids:
return {}
default_club = (club_id or '').strip()
# 首次无样本时回填历史,保证旧单刷新也能看到成交率/罚款率/均时
try:
if default_club:
ensure_club_stats_bootstrapped(default_club)
if order_club_map:
for cid in set((c or '').strip() for c in order_club_map.values() if c):
if cid and cid != default_club:
ensure_club_stats_bootstrapped(cid)
except Exception as e:
logger.warning('抢单池成交指标回填触发失败: %s', e)
club_ids = {default_club} if default_club else set()
if order_club_map:
for cid in order_club_map.values():
c = (cid or '').strip()
if c:
club_ids.add(c)
if not club_ids:
return {}
rows = list(
MerchantDealStat.query.filter(
club_id__in=list(club_ids),
merchant_uid__in=uids,
)
)
by_key = {(r.club_id, r.merchant_uid): r for r in rows}
out = {}
missing = []
for uid in uids:
prefer = ((order_club_map or {}).get(uid) or default_club or '').strip()
row = by_key.get((prefer, uid))
if not row:
for cid in club_ids:
row = by_key.get((cid, uid))
if row:
break
if not row:
missing.append(uid)
continue
payload = pool_merchant_stat_payload(row)
if payload:
out[uid] = payload
# 订单俱乐部对不上时,按商家 UID 兜底(取样本最多的一条)
if missing:
for r in MerchantDealStat.query.filter(merchant_uid__in=missing).order_by('-order_count'):
uid = r.merchant_uid
if uid in out:
continue
payload = pool_merchant_stat_payload(r)
if payload:
out[uid] = payload
return out
def serialize_dashou_stat(row) -> dict:
n = int(row.order_count or 0)
c = int(row.completed_count or 0)
f = int(row.fine_paid_count or 0)
return {
'dashou_uid': row.dashou_uid,
'order_count': n,
'completed_count': c,
'fine_paid_count': f,
'deal_rate': round(c / n, 6) if n else 0,
'fined_rate': round(f / n, 6) if n else 0,
}
def miniapp_merchant_deal_payload(row) -> dict | None:
"""小程序商家订单列表顶:有样本才返回;文案字段供前端标「结算率」。"""
if not row:
return None
n = int(row.order_count or 0)
if n <= 0:
return None
full = serialize_merchant_stat(row)
out = {
'role': 'merchant',
'order_count': full['order_count'],
'deal_rate': full['deal_rate'],
'fine_rate': full['fine_rate'],
'deal_rate_text': f"{round(full['deal_rate'] * 100, 1)}%",
'fine_rate_text': f"{round(full['fine_rate'] * 100, 1)}%",
}
sc = int(full.get('settle_duration_sample_count') or 0)
if sc > 0:
h = float(full.get('avg_deal_hours') or 0)
out['avg_deal_hours'] = h
if h < 1:
out['avg_deal_hours_text'] = f'{max(1, int(round(h * 60)))}分钟' if h > 0 else '不足1分钟'
else:
out['avg_deal_hours_text'] = f'{round(h, 1)}小时'
return out
def miniapp_dashou_deal_payload(row) -> dict | None:
"""小程序打手订单列表顶:有样本才返回。"""
if not row:
return None
n = int(row.order_count or 0)
if n <= 0:
return None
full = serialize_dashou_stat(row)
return {
'role': 'dashou',
'order_count': full['order_count'],
'deal_rate': full['deal_rate'],
'fined_rate': full['fined_rate'],
'deal_rate_text': f"{round(full['deal_rate'] * 100, 1)}%",
'fined_rate_text': f"{round(full['fined_rate'] * 100, 1)}%",
}

View File

@@ -0,0 +1,47 @@
"""俱乐部默认打手邀请码(一键登录无扫码时使用)。"""
import logging
logger = logging.getLogger(__name__)
CONFIG_KEY = 'default_dashou_invite'
# 与 club_migrate.CLUB_MIGRATE_DEFAULT_GUANSHI_INVITE 保持同步(无 config_json 时回落)
_FALLBACK_DEFAULT_INVITE = {
'lxs': '0000008VfJKPu4Spmcjt',
'xq': '0000008RffVgKHMj7kQC',
}
def get_club_default_dashou_invite(club_id: str) -> str:
"""
优先读 Club.config_json.default_dashou_invite
否则回落内置各店默认管事邀请码。
"""
cid = (club_id or '').strip()
if not cid:
return ''
try:
from jituan.models import Club
club = Club.query.filter(club_id=cid).first()
if club:
cfg = club.config_json or {}
code = (cfg.get(CONFIG_KEY) or '').strip()
if code:
return code
except Exception:
logger.exception('read default_dashou_invite failed club=%s', cid)
try:
from jituan.services.club_migrate import CLUB_MIGRATE_DEFAULT_GUANSHI_INVITE
code = (CLUB_MIGRATE_DEFAULT_GUANSHI_INVITE.get(cid) or '').strip()
if code:
return code
except Exception:
pass
return (_FALLBACK_DEFAULT_INVITE.get(cid) or '').strip()
def set_club_default_dashou_invite(club, invite_code: str) -> None:
"""写入 club.config_json调用方负责 save。"""
cfg = dict(getattr(club, 'config_json', None) or {})
cfg[CONFIG_KEY] = (invite_code or '').strip()
club.config_json = cfg

View File

@@ -11,26 +11,44 @@ from rest_framework.response import Response
# 定稿 B 方案 page_key # 定稿 B 方案 page_key
LUNBO_PAGE_ORDER_POOL = 'order_pool' LUNBO_PAGE_ORDER_POOL = 'order_pool'
LUNBO_PAGE_ACCEPT_ORDER = 'accept_order' LUNBO_PAGE_ACCEPT_ORDER = 'accept_order'
LUNBO_PAGE_ACCEPT_BANNER2 = 'accept_order_banner2' # 点单页第二横幅轮播(如 UFO banner2
LUNBO_PAGE_MERCHANT_HOME = 'merchant_home' LUNBO_PAGE_MERCHANT_HOME = 'merchant_home'
LUNBO_PAGE_DASHOU_CENTER = 'dashou_center' 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_OPTIONS = [
(LUNBO_PAGE_ORDER_POOL, '抢单池'), (LUNBO_PAGE_ORDER_POOL, '抢单池'),
(LUNBO_PAGE_ACCEPT_ORDER, '点单页'), (LUNBO_PAGE_ACCEPT_ORDER, '点单页主轮播'),
(LUNBO_PAGE_ACCEPT_BANNER2, '点单页第二横幅(可多图)'),
(LUNBO_PAGE_MERCHANT_HOME, '商家首页'), (LUNBO_PAGE_MERCHANT_HOME, '商家首页'),
(LUNBO_PAGE_DASHOU_CENTER, '打手个人中心背景'), (LUNBO_PAGE_DASHOU_CENTER, '打手个人中心背景'),
(LUNBO_PAGE_BOSS_CENTER, '点单个人中心背景(可多图)'),
] ]
def normalize_page_key(page_key, image_type=1): def normalize_page_key(page_key, image_type=1):
key = (page_key or '').strip() key = (page_key or '').strip()
if key in LUNBO_CENTER_BG_KEYS or key in LUNBO_CAROUSEL_KEYS:
return key
if image_type == 2: if image_type == 2:
return LUNBO_PAGE_DASHOU_CENTER 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 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): def forbid_display_write_in_all_scope(request):
if resolve_club_scope(request) == DATA_SCOPE_ALL: if resolve_club_scope(request) == DATA_SCOPE_ALL:
return Response({'code': 403, 'msg': '请在子公司视图下修改展示配置'}) 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 from config.models import Lunbo
club_id = _display_club_id(request) club_id = _display_club_id(request)
pk = normalize_page_key(page_key, image_type=image_type) 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) 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] 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): def upsert_tupianpeizhi_single(club_id, image_type, image_url):
"""ImageType 1/2 每俱乐部仅一条。""" """ImageType 1/2 每俱乐部仅一条;换图时删除旧 OSS 对象"""
from config.models import Tupianpeizhi 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() row = Tupianpeizhi.query.filter(club_id=club_id, ImageType=image_type).first()
if row: if row:
old = (row.ImageURL or '').strip()
row.ImageURL = image_url row.ImageURL = image_url
row.save(update_fields=['ImageURL', 'UpdateTime']) row.save(update_fields=['ImageURL', 'UpdateTime'])
if old and old != image_url:
try:
delete_from_oss(old)
except Exception:
pass
else: else:
Tupianpeizhi.query.create( Tupianpeizhi.query.create(
club_id=club_id, ImageType=image_type, ImageURL=image_url, AccessCount=0, club_id=club_id, ImageType=image_type, ImageURL=image_url, AccessCount=0,

View File

@@ -113,6 +113,8 @@ def build_fake_order_list_payload(request, leixing_id=None, page=1, page_size=20
'scope': resolve_club_scope(request), 'scope': resolve_club_scope(request),
'scope_error': CLUB_READ_SCOPE_MSG, 'scope_error': CLUB_READ_SCOPE_MSG,
'leixing_options': build_leixing_options(), 'leixing_options': build_leixing_options(),
'fake_grab_enabled': False,
'fake_yajin_hide_threshold': 10,
**list_response_meta(request), **list_response_meta(request),
} }
@@ -129,6 +131,11 @@ def build_fake_order_list_payload(request, leixing_id=None, page=1, page_size=20
rows = list(qs[offset:offset + page_size]) rows = list(qs[offset:offset + page_size])
names = _leixing_name_map() names = _leixing_name_map()
from jituan.services.fake_grab_switch import (
get_fake_yajin_hide_threshold,
is_club_fake_grab_enabled,
)
return { return {
'list': [_row_to_dict(r, names) for r in rows], 'list': [_row_to_dict(r, names) for r in rows],
'total': total, 'total': total,
@@ -137,10 +144,64 @@ def build_fake_order_list_payload(request, leixing_id=None, page=1, page_size=20
'club_id': club_id, 'club_id': club_id,
'scope': resolve_club_scope(request), 'scope': resolve_club_scope(request),
'leixing_options': build_leixing_options(), 'leixing_options': build_leixing_options(),
'fake_grab_enabled': is_club_fake_grab_enabled(club_id),
'fake_yajin_hide_threshold': float(get_fake_yajin_hide_threshold(club_id)),
**list_response_meta(request), **list_response_meta(request),
} }
def set_fake_grab_enabled(request, enabled):
"""开启/关闭本俱乐部打手侧假单功能。"""
if club_write_blocked(request):
return None, CLUB_WRITE_SCOPE_MSG
from jituan.models import Club
from jituan.services.fake_grab_switch import (
get_fake_yajin_hide_threshold,
is_club_fake_grab_enabled,
set_club_fake_grab_enabled,
)
club_id = club_id_for_write(request)
club = Club.query.filter(club_id=club_id).first()
if not club:
return None, '俱乐部不存在'
set_club_fake_grab_enabled(club, bool(enabled))
club.save(update_fields=['config_json'])
return {
'club_id': club_id,
'fake_grab_enabled': is_club_fake_grab_enabled(club_id),
'fake_yajin_hide_threshold': float(get_fake_yajin_hide_threshold(club_id)),
}, None
def set_fake_yajin_threshold(request, threshold):
"""设置押金 ≥ 该值时过滤假单。"""
if club_write_blocked(request):
return None, CLUB_WRITE_SCOPE_MSG
from jituan.models import Club
from jituan.services.fake_grab_switch import (
get_fake_yajin_hide_threshold,
is_club_fake_grab_enabled,
set_fake_yajin_hide_threshold,
)
club_id = club_id_for_write(request)
club = Club.query.filter(club_id=club_id).first()
if not club:
return None, '俱乐部不存在'
val = set_fake_yajin_hide_threshold(club, threshold)
club.save(update_fields=['config_json'])
return {
'club_id': club_id,
'fake_grab_enabled': is_club_fake_grab_enabled(club_id),
'fake_yajin_hide_threshold': float(val),
}, None
def _parse_decimal(value, field_name='金额'): def _parse_decimal(value, field_name='金额'):
try: try:
val = Decimal(str(value)) val = Decimal(str(value))

View File

@@ -0,0 +1,86 @@
"""俱乐部假单开关 + 押金过滤门槛config_json"""
import logging
from decimal import Decimal, InvalidOperation
logger = logging.getLogger(__name__)
CONFIG_KEY = 'fake_grab_order_enabled'
YAJIN_THRESHOLD_KEY = 'fake_grab_yajin_hide_threshold'
DEFAULT_YAJIN_HIDE_THRESHOLD = Decimal('10')
def is_club_fake_grab_enabled(club_id: str) -> bool:
"""某俱乐部是否对打手侧开启假单池。默认关闭。"""
cid = (club_id or '').strip()
if not cid:
return False
try:
from django.conf import settings
if not getattr(settings, 'FAKE_GRAB_ORDER_POOL_ENABLED', True):
return False
except Exception:
pass
try:
from jituan.models import Club
club = Club.query.filter(club_id=cid).first()
if not club and cid != cid.lower():
club = Club.query.filter(club_id=cid.lower()).first()
if not club:
return False
cfg = club.config_json or {}
raw = cfg.get(CONFIG_KEY)
if isinstance(raw, str):
return raw.strip().lower() in ('1', 'true', 'yes', 'on')
return bool(raw)
except Exception:
logger.exception('read fake_grab_order_enabled failed club=%s', cid)
return False
def get_fake_yajin_hide_threshold(club_id: str) -> Decimal:
"""
押金 ≥ 该值时过滤假单(只看真单)。
后台可配,默认 10。
"""
cid = (club_id or '').strip()
if not cid:
return DEFAULT_YAJIN_HIDE_THRESHOLD
try:
from jituan.models import Club
club = Club.query.filter(club_id=cid).first()
if not club:
return DEFAULT_YAJIN_HIDE_THRESHOLD
cfg = club.config_json or {}
raw = cfg.get(YAJIN_THRESHOLD_KEY, DEFAULT_YAJIN_HIDE_THRESHOLD)
try:
val = Decimal(str(raw))
except (InvalidOperation, TypeError, ValueError):
return DEFAULT_YAJIN_HIDE_THRESHOLD
if val < 0:
return DEFAULT_YAJIN_HIDE_THRESHOLD
return val
except Exception:
logger.exception('read fake_grab_yajin_hide_threshold failed club=%s', cid)
return DEFAULT_YAJIN_HIDE_THRESHOLD
def set_club_fake_grab_enabled(club, enabled: bool) -> None:
"""写入 club.config_json调用方负责 save。"""
cfg = dict(getattr(club, 'config_json', None) or {})
cfg[CONFIG_KEY] = bool(enabled)
club.config_json = cfg
def set_fake_yajin_hide_threshold(club, threshold) -> Decimal:
"""写入押金过滤门槛,调用方负责 save。返回规范化后的值。"""
try:
val = Decimal(str(threshold))
except (InvalidOperation, TypeError, ValueError):
val = DEFAULT_YAJIN_HIDE_THRESHOLD
if val < 0:
val = DEFAULT_YAJIN_HIDE_THRESHOLD
cfg = dict(getattr(club, 'config_json', None) or {})
# 存成可 JSON 序列化的数字
cfg[YAJIN_THRESHOLD_KEY] = float(val)
club.config_json = cfg
return val

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