306 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
XingQue
2a99e2ea10 fix: 会员首次充值积分加满10分,后续充值不再加分
体验会员原仅加5分且正式会员会重复加分;改为首次充值会员(任意类型)补满至10分,此后任意会员充值不再增加积分。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 01:45:50 +08:00
XingQue
390202b9e4 fix: 资料页仅改头像不再误判为手机号认证
手机号认证流需同时带头像与 shoujihao_code 且无昵称;普通编辑只上传头像可正常保存。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 00:12:34 +08:00
XingQue
65dae4f3ab fix: 店铺二维码下载拒绝异常大文件
超过2MB的OSS对象视为路径错误,提示重新生成而非原样返回。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 00:04:21 +08:00
XingQue
df49631401 fix: 店铺二维码生成与下载接口
使用 stable access_token 并重试;规范化 OSS Key;下载接口返回 JSON 错误便于前端识别。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 23:51:00 +08:00
XingQue
90e187b65c chore: 补齐店铺管理权限码种子与SQL脚本
新增999a~999e与1199ab~1199abe权限定义,并提供可重复执行的查询/插入SQL。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 23:09:37 +08:00
XingQue
a92352f2b0 fix: 店铺管理接口超级管理员权限校验失效
改用 has_perm_code 替代 set(permissions) 交集判断,避免 _AllPermissions 被转成 set 后丢失超管放行能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 23:06:34 +08:00
XingQue
214b52badd feat: 新增后台复制店铺商品目录接口
支持按用户ID复制商品类型、专区与商品,OSS图片独立拷贝,并提供覆盖/补充两种模式。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 22:58:56 +08:00
581295443a 给所有支付接口加上了支付宝 2026-07-09 22:06:46 +08:00
XingQue
914e8c70f2 修复手机号认证完成后仍反复弹窗的问题
认证提交接口在仅上传头像时错误返回成功,检查接口也未兼容已有手机号的老数据。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 21:57:23 +08:00
7443fef6de 修复了认证上传失效问题 2026-07-09 21:09:39 +08:00
d734ce1dcc 测试 2026-07-09 21:05:09 +08:00
6c0a461a2b 修复了订单状态的验签问题 2026-07-09 20:49:26 +08:00
XingQue
f2295717ee Merge branch 'main' of https://git.gvsds.com/XingQue/Django 2026-07-09 20:42:11 +08:00
XingQue
fd7bcde9d9 fix: 店铺订单接口 PlatformOrderExt 查询字段修正
将 dianpu_id 改为模型字段 ShopID,修复 ddltj/ddlbhq 的 FieldError;统计聚合改用 id__in 匹配 Order 主键。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 20:39:05 +08:00
fb8d905fb0 修复了订单状态更新 BUG 2026-07-09 20:32:40 +08:00
f702c13422 修复了支付宝 fucha 逻辑 2026-07-09 20:28:12 +08:00
bf1088d0c5 修复二维码当面付问题 2026-07-09 20:06:01 +08:00
51edcf9fce 使用二维码进行支付宝交易 2026-07-09 19:50:40 +08:00
0826b0749d 修复了验签机制 2026-07-09 19:22:48 +08:00
aa3aa932fe 修复签名生成机制 2026-07-09 19:14:57 +08:00
5755730093 使用生产模式密钥 2026-07-09 18:06:24 +08:00
de3ff211a6 fixed: fucha接口硬编码了微信查询 2026-07-09 03:08:14 +08:00
e2b95edeb8 修复 URL 过长的问题 2026-07-09 02:53:52 +08:00
92abc0d9e7 修复导入配置 2026-07-09 02:06:52 +08:00
d7d38d9b08 修复了沙箱私钥解析问题 2026-07-09 01:58:38 +08:00
9c11698660 测试中,尝试接入支付宝(沙箱) 2026-07-09 01:51:18 +08:00
XingQue
d64042fe46 fix: 微信openid绑定重复时不污染外层事务,登录可正常回落
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 01:18:42 +08:00
XingQue
5754f6a509 fix: 微信登录并发注册撞唯一键时正确回落已有用户
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 01:12:46 +08:00
XingQue
d5fbc08364 fix: 自动提现按俱乐部解析微信openid,修复已登录仍提示微信登录 2026-07-09 01:06:08 +08:00
XingQue
b2a2da0f97 fix: 自动提现不再要求手机号/收款码,仅校验微信OpenID;手动提现保持原校验收款信息
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 22:16:26 +08:00
XingQue
c2db2ad583 fix: 总会员只读展开子会员类型,修复club_id默认xq查错配置;dddhq不再写库补会员
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 22:13:12 +08:00
XingQue
2248a61936 fix: 添加后台用户不再硬编码绑定客服角色,按所选俱乐部角色分配
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 21:47:50 +08:00
XingQue
39dfe3d52e fix: 统一微信用户定位,修复wechatlogin等UserName(openid)重复1062
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 21:28:23 +08:00
XingQue
1ffd92758d fix: 押金提现不再封号,统一加强打手/收款校验
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 21:06:28 +08:00
XingQue
ad33ee6c86 fix: User.ClubID默认xq导致星之界新用户误判,创建时置空并以wx绑定为准
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 18:34:45 +08:00
XingQue
d025287785 fix: 星之界扫码允许老xq账号通过本俱乐部管事码注册
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 18:31:14 +08:00
XingQue
e0c48073f1 fix: 老小程序不改端,后端兼容扫码注册(dashouzhuce/wdlyhdl)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 18:20:45 +08:00
XingQue
7fe49775b9 fix: dashouzhuce返回dashoustatus供前端识别注册成功
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 18:06:54 +08:00
XingQue
2225722ab5 fix: 邀请校验管事无ClubID时以当前小程序为准,注册失败打日志
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 18:06:22 +08:00
XingQue
aeaa54583a fix: 扫码注册统一用户查找与俱乐部校验,修复wdlyhdl与自动注册失败
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 18:00:29 +08:00
XingQue
f23c8a3360 fix: 俱乐部微信登录兼容旧用户,避免UserName重复
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 17:43:39 +08:00
XingQue
a47d4ee0a6 fix: 扫码注册按俱乐部微信配置换openid,Referer识别小程序
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 17:37:51 +08:00
XingQue
95d7e8d13d fix: 体验会员抢单池与正式同等展示,修复总会员子类型与club_id
clumber 传递 club_id 展开总会员子类型;充值单路径同步;不暴露 is_trial。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 16:51:42 +08:00
XingQue
f87a40fbd6 fix: 抢单池 clumber 只读合并有效期内充值单,体验会员按正式展示
不写库、不 sync;修复体验单已付但 huiyuangoumai 未落库时老端显示未开通。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 16:41:09 +08:00
XingQue
627eeeb29b fix: 修复 rollback --since 日期字符串解析报错
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 16:26:19 +08:00
XingQue
c464c31140 fix: 加固会员回滚命令,有未过期充值单绝不收回
增加 repair 指纹校验;减会员后重新付费不再误收。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 16:20:33 +08:00
XingQue
7c3f04e7f3 fix: 禁止过期充值单补开通 + 管理命令收回错误恢复会员
rollback_erroneous_member_restore:后台已减仍有效、充值单已过期仍显示有效。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 15:56:11 +08:00
XingQue
1c6e908f28 fix: 补 import _czjilu_entitlement_revoked
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 15:49:38 +08:00
XingQue
cec7d69199 fix: 停止每次拉单补回已删会员;后台移除标记充值单撤销
ddhq 不再全量 sync 充值单;jian_huiyuan 标记权益已撤销防复活;clumber 只读 huiyuangoumai。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 15:49:07 +08:00
XingQue
38fc52241f fix: dddhq 不再把 yaoqiuleixing 改成 0,原样返回 1+huiyuan_id
体验会员与正式同走 clumber 匹配;仅提现校验正式会员。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 15:41:25 +08:00
XingQue
15cef26d81 fix: 修复 dddhq resolve_effective_club_id 未导入导致 500
函数内局部 import 导致 UnboundLocalError,将会员刷新 import 提前到使用处。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 15:37:26 +08:00
XingQue
aaa745baf4 fix: dddhq与dashouxinxi每次请求同步刷新会员clumber
抢单池拉单前先 sync 权益;老端依赖 yaoqiuleixing=0 与 clumber 即时更新。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 15:34:10 +08:00
XingQue
12f9100b17 fix: 适配老小程序抢单池展示,登录clumber含体验会员
老端仅认 yaoqiuleixing=0 或 clumber.huiyuanid 严格匹配;统一 build_auth_clumber 与 dddhq 同源含 czjilu 兜底。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 15:27:58 +08:00
XingQue
bcfa30d5de fix: 抢单池展示只校验对应会员未过期,体验与正式同等
ddhq 不区分体验/正式;qiangdan 校验会员+过期;提现仍禁体验会员。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 15:21:01 +08:00
XingQue
7edb8f22cb fix: 抢单池体验会员按正式展示,修复 czjilu 权益被总会员逻辑短路
ddhq 仅展示层变通;qiangdan 仍按会员类型校验,提现仍禁体验会员。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 15:14:02 +08:00
XingQue
4eb74ae968 fix: 修复抢单池 clumber 合成时 naive/aware 时间比较报错
统一 czjilu 到期时间与 now 的时区类型,修复 ddhq 500。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 15:09:48 +08:00
XingQue
7819f1a897 fix: 体验会员本单有权限时 yaoqiuleixing 返回 0 仅适配老端展示
逐单判断会员类型:有权限的单显示可抢,无权限/过期仍显示未开通;抢单与提现后端校验不变。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 15:05:48 +08:00
XingQue
3ec821ae6a fix: 抢单池按订单会员类型逐单校验,取消任意会员放开全部单
会员单不再统一 yaoqiuleixing=0;clumber 裁剪为仅有权限的会员;ddhq 返回 hasRequiredMember。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 14:59:00 +08:00
XingQue
b54904a9f2 fix: 严格校验体验会员不可提现,抢单按会员类型匹配
展示层 clumber 与提现/抢单权限分离:佣金提现须正式会员并双重拦截体验期;抢单走 resolve_order_membership_ids + 总会员包含判断。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 14:26:16 +08:00
XingQue
abfe497ddc fix: 抢单池有体验/已付会员一律按已开通返回
/ddhq 有任意有效会员时 yaoqiuleixing 强制为 0;czjilu 已付单可合成 clumber;抢单校验支持充值单兜底。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 14:18:36 +08:00
XingQue
f46e36b539 fix: 老端抢单池有体验/正式会员时 yaoqiuleixing 返回 0
/ddhq 在用户有有效会员(含体验)时将 yaoqiuleixing 置 0,老小程序不再显示未开通会员;抢单仍由服务端校验。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 14:13:24 +08:00
XingQue
eedd5fbd46 fix: 体验会员按正式会员返回抢单池数据 + 修复 MerchantStaffMember
clumber/dashouxinxi 有效期内体验会员与正式会员同格式返回;ddhq 保持原 yaoqiuleixing/huiyuan_id 供老端匹配;补 merchant-staff 缺 import。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 14:09:12 +08:00
XingQue
86e7a60642 fix: 纯后端适配老小程序抢单池会员展示
/ddhq 有权限时返回 yaoqiuleixing=0;clumber 统一 huiyuanid+daoqi 字符串并补齐 ID 变体;读取前自动补写已付未落库的体验/正式会员。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 14:05:22 +08:00
XingQue
dfd4bed221 fix: 抢单池会员展示加强校验与 hasRequiredMember 直返
会员 ID 忽略前导零等价匹配;订单缺 huiyuan_id 时回退商品类型;接口直返 hasRequiredMember 并将已授权订单 yaoqiuleixing 置 0。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 13:56:54 +08:00
XingQue
3a2b8a2bba fix: 体验/总会员抢单池显示与接单权限
统一 clumber 返回 huiyuanid 并展开总会员子类型;抢单池接口对已具备会员权限的用户将 yaoqiuleixing 置 0,使小程序无需发版即可正常显示分佣并抢单。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 13:47:05 +08:00
XingQue
0062d495c9 fix: 回填命令兼容 FluentQuery(不用 iterator)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 00:14:29 +08:00
XingQue
7dc78acb73 fix: 会员充值链路加固(体验资格、积分分红、czjilu审计、余额抵扣)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 00:07:54 +08:00
XingQue
1e3d65e0e4 fix: 排行榜昵称与管事/组长统计字段对齐
管事/组长不再展示 openid,改读业务昵称;俱乐部榜补充无效/有效邀请与收益字段;管事按有效人数、组长按收益总额排序。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 21:49:36 +08:00
XingQue
bfd5a63cb4 fix: 订单类型接口与 menu-access 对齐鉴权,去掉业务 401
kfhqptddlx 不再校验 phone/UserType,仅 JWT + is_kefu_backend_account,避免菜单能进但类型接口 401 踢登录。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 19:35:23 +08:00
XingQue
6e2e1944b6 fix: 角色UUID bytes比对修复权限全空,legacy权限合并兜底 2026-07-06 19:28:35 +08:00
XingQue
2edca09859 fix: 俱乐部角色ORM过滤+商家订单权限002ab/003aa+legacy兜底+perm-debug 2026-07-06 19:25:15 +08:00
XingQue
7bb891187b fix: 按俱乐部隔离权限,menu-access 返回任职无角色提示 2026-07-06 19:21:38 +08:00
XingQue
a14cba63e6 fix: 集团可给各俱乐部分配不同功能角色,按俱乐部隔离权限校验 2026-07-06 19:14:00 +08:00
XingQue
ebcedbdea1 fix: 商家订单权限与菜单一致,含本俱乐部+全局角色
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 19:03:23 +08:00
XingQue
45a4dde034 fix: 切换俱乐部仅算本俱乐部角色权限,统一权限解析
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 19:00:18 +08:00
XingQue
553d2d5707 fix: 俱乐部+全局角色权限并集,修复002ac校验
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 18:44:11 +08:00
XingQue
1e54d8b871 fix: 多俱乐部任职即可切换,权限按当前俱乐部计算
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 18:40:42 +08:00
XingQue
df6c6912d9 fix: 恢复全局角色权限校验,修复商家订单403
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 18:36:39 +08:00
XingQue
64ddb7d0b8 fix: 俱乐部000001不再展开全部菜单权限
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 18:32:01 +08:00
XingQue
7c763ad5b4 fix: 顶栏仅可切换有功能角色的俱乐部
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 18:28:24 +08:00
XingQue
1c6607fa6e fix: 角色分配按俱乐部超管权限隔离
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 18:23:29 +08:00
XingQue
2bbe166d63 fix: 角色操作按俱乐部解析避免同名Role冲突
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 18:17:37 +08:00
XingQue
2573db1bbf fix: 俱乐部分配任职仅集团高层可操作
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 18:06:37 +08:00
XingQue
1dfb46c38c feat: 多俱乐部后台权限与管理员用户俱乐部分配
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 17:59:59 +08:00
f1c8633345 perf: P1 性能优化批量修复 + kefu 视图拆分
P1 性能优化(避免循环内 N+1 / N 次 DB 查询):
- shop_order_views._batch_images: 移除 ThreadPoolExecutor 掩盖的 N 次查询,改单次批量 + Python 侧分组
- product_query.DashouHuiyuanList: 5N 查询 -> 3 次批量预取 + 本地闭包判断
- roles.GetRolePermissionView / 用户列表: 循环内 RolePermission/Permission/UserRole/Role -> 批量 __in 预取
- guanli / zuzhang 杜次分红 4.2/4.3: 循环内 update_or_create + 单条 delete -> bulk_create + bulk_update + 批量 delete
- admin_config QQ 群配置: 循环内 exists/first/save/create -> 批量预取 + bulk_create + bulk_update
- jituan 服务层多处合并统计查询、批量预取 map
- rank 多处循环 N+1 改批量预取
- backend 多处循环内 count/create 改批量
- config/orders/merchant_ops 多处循环 N+1 改批量预取

其他改动:
- users/views/kefu.py 拆分为 kefu_base/kefu_dashou/kefu_orders/kefu_punishment/kefu_withdraw 5 个文件
- 删除遗留脚本 check_prod_uid.py / create_rbac_tables.sql
2026-07-05 23:17:38 +08:00
XingQue
76ea5f8255 fix: 假单管理补全 DATA_SCOPE_ALL 导入
修复 fake-grab-order-list 500 NameError。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 22:35:30 +08:00
XingQue
59a6deef73 fix: 菜单种子自动补全假单管理页
检测 product.fake-orders 缺失时自动执行 seed_kefu_menu。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 22:32:38 +08:00
XingQue
ddfd3d3ac7 feat: 假单后台管理接口(按俱乐部+商品类型 CRUD)
新增 fake-grab-order-list/manage/avatar 接口,权限 2200a;支持增删改价格介绍头像等;假单展示使用自定义头像。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 22:28:13 +08:00
XingQue
2a350a2ec8 feat: 会员充值明细接口与俱乐部一键创建总会员
新增 hy-recharge-records 查询 czjilu 会员单并关联 gsfenhong 分红;hy-bundle-create 支持俱乐部一步创建总会员;会员数据分析页权限改为 3300a。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 18:27:48 +08:00
XingQue
37bc8f960a feat: 总会员(包含式)模型、迁移、抢单校验与后台配置
- Huiyuan.is_bundle + club_huiyuan_bundle_include 表
- 购买/到期逻辑不变,抢单时总会员覆盖子会员类型
- dshyhq/clumber 返回 is_bundle 与 included_huiyuan_ids

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 18:12:57 +08:00
XingQue
5754a69e0b feat: 打手中心全量可配置图标下发与会员卡片图上传接口
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 17:36:31 +08:00
XingQue
69c7ffd962 fix: dashou 订单列表补全 ZHIDING_HF_TEXT 常量
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 17:06:32 +08:00
278 changed files with 47158 additions and 8991 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,12 +385,21 @@ 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',
} }
# ==================== 支付宝支付 ====================
ALIPAY_SANDBOX = app_secrets.ALIPAY_SANDBOX
ALIPAY_PROD = app_secrets.ALIPAY_PROD
ALIPAY_SANDBOX_CFG = app_secrets.ALIPAY_SANDBOX_CFG
# 仅这些 club_id 禁止「发起」支付宝支付;回调/查单仍可用(提现收款不受影响)。
# 小溪/小汐 = xx。恢复改为 frozenset(),再重启 gunicorn。
ALIPAY_DISABLED_CLUB_IDS = frozenset({'xx'})

View File

@@ -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

View File

@@ -0,0 +1,259 @@
"""
店铺商品目录复制服务:复制店铺商品类型、专区、商品(含 OSS 图片独立拷贝)。
"""
import logging
import time
import uuid
from django.db import transaction
from products.models import Shangpin, ShangpinZhuanqu
from shop.models import Dianpu, ShangpinLeixingDianpu
from utils.oss_utils import copy_in_oss, delete_from_oss, _url_to_oss_key
logger = logging.getLogger('houtai')
COPY_MODE_REPLACE = 'replace'
COPY_MODE_APPEND = 'append'
class ShopCopyError(Exception):
def __init__(self, message, code=400):
super().__init__(message)
self.message = message
self.code = code
def resolve_dianpu_by_user_uid(user_uid):
"""通过小程序用户 IDYonghuPingzheng.yonghu全局唯一定位店铺"""
uid = str(user_uid or '').strip()
if not uid:
raise ShopCopyError('用户ID不能为空')
try:
return Dianpu.objects.select_related('pingzheng').get(pingzheng__yonghu=uid)
except Dianpu.DoesNotExist:
raise ShopCopyError(f'未找到用户ID {uid} 对应的店铺', 404)
def _gen_dest_key(target_dianpu_id, category, src_url):
src_key = _url_to_oss_key(src_url)
ext = 'png'
if src_key and '.' in src_key:
ext = src_key.rsplit('.', 1)[-1].lower() or 'png'
ts = int(time.time() * 1000)
rand = uuid.uuid4().hex[:8]
return f"dianpu/shangpin/{category}/{target_dianpu_id}_{ts}_{rand}.{ext}"
def _copy_image_url(src_url, target_dianpu_id, category):
"""复制 OSS 图片到目标店铺独立路径,返回相对 URL"""
if not src_url:
return None
dest_key = _gen_dest_key(target_dianpu_id, category, src_url)
if not copy_in_oss(src_url, dest_key):
raise ShopCopyError(f'图片复制失败: {src_url}')
return dest_key
def _clear_target_catalog_db_only(target_dianpu):
"""全部覆盖模式:仅删除目标店铺商品目录 DB 记录,返回待清理的 OSS URL"""
dianpu_id = target_dianpu.id
oss_to_delete = []
products = list(Shangpin.query.filter(dianpu_id=dianpu_id))
for p in products:
if p.tupian_url:
oss_to_delete.append(p.tupian_url)
if p.guize_tupian:
oss_to_delete.append(p.guize_tupian)
if products:
Shangpin.query.filter(dianpu_id=dianpu_id).delete()
ShangpinZhuanqu.query.filter(dianpu_id=dianpu_id).delete()
types = list(ShangpinLeixingDianpu.query.filter(dianpu_id=dianpu_id))
for t in types:
if t.tupian_url:
oss_to_delete.append(t.tupian_url)
if types:
ShangpinLeixingDianpu.query.filter(dianpu_id=dianpu_id).delete()
return oss_to_delete
def _delete_oss_urls(urls):
for url in urls:
if url:
delete_from_oss(url)
def _pre_copy_source_images(source_dianpu, target_dianpu):
"""
预复制源店铺所有图片到目标店铺 OSS 路径。
返回结构化的预复制数据,供事务内写库使用。
"""
src_id = source_dianpu.id
tgt_id = target_dianpu.id
source_types = list(
ShangpinLeixingDianpu.query.filter(dianpu_id=src_id).order_by('paixu', 'id')
)
type_payloads = []
for st in source_types:
type_payloads.append({
'source': st,
'tupian_url': _copy_image_url(st.tupian_url, tgt_id, 'shangpinleixing'),
})
source_zones = list(
ShangpinZhuanqu.query.filter(dianpu_id=src_id).order_by('paixu', 'id')
)
zone_payloads = []
for sz in source_zones:
zone_payloads.append({'source': sz})
source_products = list(
Shangpin.query.filter(dianpu_id=src_id).order_by('paixu', 'id')
)
product_payloads = []
for sp in source_products:
product_payloads.append({
'source': sp,
'tupian_url': _copy_image_url(sp.tupian_url, tgt_id, 'shangpin'),
'guize_tupian': _copy_image_url(sp.guize_tupian, tgt_id, 'shangpinguize') if sp.guize_tupian else None,
})
return type_payloads, zone_payloads, product_payloads
def _insert_copied_catalog(target_dianpu, type_payloads, zone_payloads, product_payloads):
"""在目标店铺插入复制后的类型、专区、商品,维护 ID 映射"""
type_id_map = {}
zone_id_map = {}
for item in type_payloads:
st = item['source']
new_type = ShangpinLeixingDianpu.query.create(
dianpu_id=target_dianpu.id,
gonggong_leixing_id=st.gonggong_leixing_id,
jieshao=st.jieshao,
tupian_url=item['tupian_url'],
paixu=st.paixu,
shangjia_zhuangtai=st.shangjia_zhuangtai,
fengjin_zhuangtai=st.fengjin_zhuangtai,
shenhe_zhuangtai=st.shenhe_zhuangtai,
)
type_id_map[st.id] = new_type.id
for item in zone_payloads:
sz = item['source']
new_dianpu_leixing_id = type_id_map.get(sz.dianpu_leixing_id)
if not new_dianpu_leixing_id:
raise ShopCopyError(f'专区「{sz.mingzi}」关联的商品类型在复制映射中缺失')
new_zone = ShangpinZhuanqu.query.create(
dianpu_id=target_dianpu.id,
mingzi=sz.mingzi,
dianpu_leixing_id=new_dianpu_leixing_id,
leixing_id=sz.leixing_id,
paixu=sz.paixu,
shangjia_zhuangtai=sz.shangjia_zhuangtai,
fengjin_zhuangtai=sz.fengjin_zhuangtai,
shenhezhuangtai=sz.shenhezhuangtai,
)
zone_id_map[sz.id] = new_zone.id
copied_products = 0
for item in product_payloads:
sp = item['source']
new_dianpu_leixing_id = type_id_map.get(sp.dianpu_leixing_id)
new_zhuanqu_id = zone_id_map.get(sp.zhuanqu_id) if sp.zhuanqu_id else None
if sp.dianpu_leixing_id and not new_dianpu_leixing_id:
raise ShopCopyError(f'商品「{sp.biaoqian}」关联的商品类型在复制映射中缺失')
if sp.zhuanqu_id and not new_zhuanqu_id:
raise ShopCopyError(f'商品「{sp.biaoqian}」关联的专区在复制映射中缺失')
Shangpin.query.create(
biaoqian=sp.biaoqian,
jiage=sp.jiage,
kucun=sp.kucun,
leixing_id=sp.leixing_id,
zhuanqu_id=new_zhuanqu_id,
zhenshi_xiaoliang=0,
duiwai_xiaoliang=0,
jieshao=sp.jieshao,
xiadan_xuzhi=sp.xiadan_xuzhi,
guize_tupian=item['guize_tupian'],
yaoqiuleixing=sp.yaoqiuleixing,
huiyuan_id=sp.huiyuan_id,
yongjin=sp.yongjin,
kaioi_ewai_dashou_fencheng=sp.kaioi_ewai_dashou_fencheng,
ewai_dashou_fencheng=sp.ewai_dashou_fencheng,
tupian_url=item['tupian_url'],
paixu=sp.paixu,
shenhezhuangtai=sp.shenhezhuangtai,
dianpu_id=target_dianpu.id,
dianpu_leixing_id=new_dianpu_leixing_id,
shangjia_zhuangtai=sp.shangjia_zhuangtai,
fengjin_zhuangtai=sp.fengjin_zhuangtai,
shenhe_zhuangtai=sp.shenhe_zhuangtai,
)
copied_products += 1
return {
'types': len(type_payloads),
'zones': len(zone_payloads),
'products': copied_products,
}
def copy_shop_catalog(source_user_uid, target_user_uid, copy_mode=COPY_MODE_APPEND):
"""
复制店铺商品目录。
:param source_user_uid: 源店铺小程序用户 IDYonghuPingzheng.yonghu唯一
:param target_user_uid: 目标店铺小程序用户 ID
:param copy_mode: 'replace' 全部覆盖 | 'append' 补充追加
"""
if copy_mode not in (COPY_MODE_REPLACE, COPY_MODE_APPEND):
raise ShopCopyError('copy_mode 无效,仅支持 replace 或 append')
source_dianpu = resolve_dianpu_by_user_uid(source_user_uid)
target_dianpu = resolve_dianpu_by_user_uid(target_user_uid)
if source_dianpu.id == target_dianpu.id:
raise ShopCopyError('源店铺与目标店铺不能相同')
# 1. 预复制 OSS 图片DB 变更前完成,失败则目标店不受影响)
type_payloads, zone_payloads, product_payloads = _pre_copy_source_images(
source_dianpu, target_dianpu
)
oss_cleanup = []
# 2. 数据库事务:锁定目标店 → 可选清空 → 写入
with transaction.atomic():
Dianpu.objects.select_for_update().get(id=target_dianpu.id)
if copy_mode == COPY_MODE_REPLACE:
oss_cleanup = _clear_target_catalog_db_only(target_dianpu)
stats = _insert_copied_catalog(target_dianpu, type_payloads, zone_payloads, product_payloads)
if oss_cleanup:
_delete_oss_urls(oss_cleanup)
logger.info(
'店铺商品复制完成: source=%s(%s) -> target=%s(%s) mode=%s stats=%s',
source_dianpu.id, source_user_uid,
target_dianpu.id, target_user_uid,
copy_mode, stats,
)
return {
'source_dianpu_id': source_dianpu.id,
'source_dianpu_name': source_dianpu.dianpu_mingcheng,
'target_dianpu_id': target_dianpu.id,
'target_dianpu_name': target_dianpu.dianpu_mingcheng,
'copy_mode': copy_mode,
'copied_types': stats['types'],
'copied_zones': stats['zones'],
'copied_products': stats['products'],
}

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, 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='后台获取提现配置'),
@@ -82,8 +90,10 @@ urlpatterns = [
path('hqhtdpsplx', ProductListView.as_view(), name='后台获取店铺商品数据'), path('hqhtdpsplx', ProductListView.as_view(), name='后台获取店铺商品数据'),
path('htxgdpspsj', ProductModifyView.as_view(), name='后台修改店铺商品数据'), path('htxgdpspsj', ProductModifyView.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

@@ -77,11 +77,47 @@ class _AllPermissions(list):
return True return True
def has_merchant_order_permission(permissions): def has_perm_code(permissions, *codes):
"""商家/链接订单处理权限002ac 商家订单、002ab 平台订单、003aa 跨平台""" """权限码 in 检查;系统超管 _AllPermissions 视为全部通过"""
if isinstance(permissions, _AllPermissions): if isinstance(permissions, _AllPermissions):
return True return True
return any(p in permissions for p in ('002ac', '002ab', '003aa')) perms = permissions or []
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):
"""商家/链接订单处理权限002ac 商家订单、002ab 平台订单、003aa 跨平台;本店超管 000001 亦可。"""
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):
@@ -135,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:
@@ -164,25 +221,44 @@ def verify_kefu_permission(request, username_from_frontend=None):
'msg': '身份错误,无权操作其他账号,相关操作已记录' 'msg': '身份错误,无权操作其他账号,相关操作已记录'
}, status=403) }, status=403)
# ---------- 4. 获取用户的所有权限 ---------- # ---------- 4. 获取用户的所有权限(与菜单 menu-access 同一套算法) ----------
# 使用 gvsdsdk RBAC 表UserRole → RolePermission → Permission from jituan.constants import CLUB_ID_DEFAULT, DATA_SCOPE_SINGLE
from jituan.services.admin_context import resolve_admin_effective_club, get_allowed_club_ids, is_system_super_admin
from jituan.services.club_rbac import resolve_effective_permission_codes
eff_club = getattr(request, 'club_id', None) or CLUB_ID_DEFAULT
eff_scope = getattr(request, 'club_scope', None) or DATA_SCOPE_SINGLE
allowed_club_ids = None
if not is_system_super_admin(current_user):
eff_club, eff_scope, club_err = resolve_admin_effective_club(current_user, request)
if club_err:
return None, Response({'code': 403, 'msg': club_err}, status=403)
request.club_id = eff_club
request.club_scope = eff_scope
allowed_club_ids = list(get_allowed_club_ids(current_user))
try: try:
user_uuid = current_user.UserUUID if is_system_super_admin(current_user):
role_uuids = list(SdkUserRole.objects.filter( user_uuid = current_user.UserUUID
UserUUID=user_uuid role_uuids = list(SdkUserRole.objects.filter(
).values_list('RoleUUID', flat=True)) UserUUID=user_uuid
).values_list('RoleUUID', flat=True))
if role_uuids: if role_uuids:
perm_uuids = list(SdkRolePermission.objects.filter( perm_uuids = list(SdkRolePermission.objects.filter(
RoleUUID__in=role_uuids RoleUUID__in=role_uuids
).values_list('PermUUID', flat=True)) ).values_list('PermUUID', flat=True))
permissions = list(SdkPermission.objects.filter(
permissions = list(SdkPermission.objects.filter( PermUUID__in=perm_uuids, PermStatus=1
PermUUID__in=perm_uuids, ).values_list('PermCode', flat=True).distinct())
PermStatus=1 else:
).values_list('PermCode', flat=True).distinct()) permissions = []
legacy_codes, _ = _legacy_backend_role_permissions(current_user.Phone)
if legacy_codes:
permissions = list(set(list(permissions) + legacy_codes))
else: else:
permissions = [] permissions = resolve_effective_permission_codes(
current_user, eff_club, eff_scope, allowed_club_ids,
)
except Exception as e: except Exception as e:
logger.warning( logger.warning(
'verify_kefu_permission failed: UserUUID=%s Phone=%s err=%s', 'verify_kefu_permission failed: UserUUID=%s Phone=%s err=%s',
@@ -193,21 +269,10 @@ def verify_kefu_permission(request, username_from_frontend=None):
) )
permissions = [] permissions = []
# 旧权限表user_role 按手机号),与 gvsdsdk 合并 # 仅系统级超管自动拥有全部功能权限;俱乐部 000001 只解锁超管页,不展开全部菜单
legacy_codes, _ = _legacy_backend_role_permissions(current_user.Phone) if is_system_super_admin(current_user):
if legacy_codes: if '000001' not in permissions:
if isinstance(permissions, _AllPermissions): permissions.insert(0, '000001')
pass
else:
permissions = list(set(list(permissions) + legacy_codes))
# 管理员/超级用户自动拥有所有权限
if is_admin and '000001' not in permissions:
permissions.insert(0, '000001')
# 拥有 000001超级管理权限的用户自动获得所有功能权限
# 使用 _AllPermissions 包装,使任何权限码检查都自动通过
if '000001' in permissions:
permissions = _AllPermissions(permissions) permissions = _AllPermissions(permissions)
if not permissions: if not permissions:

View File

@@ -35,19 +35,23 @@ 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,
ClubMemberCatalogView, ClubMemberEnableView, ClubMemberDisableView, ClubMemberCatalogView, ClubMemberEnableView, ClubMemberDisableView,
MemberCardImageUploadView, MemberRechargeRecordsView, MemberBundleCreateView,
) )
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,
@@ -62,6 +66,7 @@ from .views.system import (
from .views.shops import ( from .views.shops import (
ShopListView, ShopModifyView, ShopPublicTypeAndAuditView, ShopListForProductView, ShopListView, ShopModifyView, ShopPublicTypeAndAuditView, ShopListForProductView,
ShopProductTypeMappingView, ShopProductModifyView, ProductListView, ProductModifyView, ShopProductTypeMappingView, ShopProductModifyView, ProductListView, ProductModifyView,
ShopCopyCatalogView,
) )
from .views.penalties import ( from .views.penalties import (
FaKuanTongJiView, FaKuanLieBiaoView, FaKuanChuLiView, FaKuanPingTaiShenHeView, FaKuanTongJiView, FaKuanLieBiaoView, FaKuanChuLiView, FaKuanPingTaiShenHeView,
@@ -94,17 +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',
# guanli # guanli
'GetGuanliListView', 'GetGuanliDetailView', 'PromoteGuanshiToZuzhangView', 'GetGuanliListView', 'GetGuanliDetailView', 'PromoteGuanshiToZuzhangView',
'UpdateGuanliView', 'UpdateGuanliView', 'GuanshiChangeLeaderQuotaView', 'GuanshiChangeLeaderLogView',
# withdraw_settings # withdraw_settings
'GetWithdrawSettingsView', 'UpdateWithdrawSettingsView', 'GetWithdrawSettingsView', 'UpdateWithdrawSettingsView',
# zuzhang # zuzhang
@@ -115,6 +124,7 @@ __all__ = [
# shops # shops
'ShopListView', 'ShopModifyView', 'ShopPublicTypeAndAuditView', 'ShopListForProductView', 'ShopListView', 'ShopModifyView', 'ShopPublicTypeAndAuditView', 'ShopListForProductView',
'ShopProductTypeMappingView', 'ShopProductModifyView', 'ProductListView', 'ProductModifyView', 'ShopProductTypeMappingView', 'ShopProductModifyView', 'ProductListView', 'ProductModifyView',
'ShopCopyCatalogView',
# penalties # penalties
'FaKuanTongJiView', 'FaKuanLieBiaoView', 'FaKuanChuLiView', 'FaKuanPingTaiShenHeView', 'FaKuanTongJiView', 'FaKuanLieBiaoView', 'FaKuanChuLiView', 'FaKuanPingTaiShenHeView',
'FaKuanChuangJianView', 'FineApplyView', 'PunishDashouView', 'FaKuanChuangJianView', 'FineApplyView', 'PunishDashouView',

View File

@@ -580,12 +580,19 @@ class KhjlglView(APIView):
pending=Count('jilu_id', filter=Q(zhuangtai=3)), pending=Count('jilu_id', filter=Q(zhuangtai=3)),
).order_by('-total') ).order_by('-total')
_stats_agg = stats_base.aggregate(
total=Count('id'),
passed=Count('id', filter=Q(zhuangtai=1)),
failed=Count('id', filter=Q(zhuangtai=2)),
in_progress=Count('id', filter=Q(zhuangtai=0)),
pending=Count('id', filter=Q(zhuangtai=3)),
)
stats = { stats = {
'total': stats_base.count(), 'total': _stats_agg['total'],
'passed': stats_base.filter(zhuangtai=1).count(), 'passed': _stats_agg['passed'],
'failed': stats_base.filter(zhuangtai=2).count(), 'failed': _stats_agg['failed'],
'in_progress': stats_base.filter(zhuangtai=0).count(), 'in_progress': _stats_agg['in_progress'],
'pending': stats_base.filter(zhuangtai=3).count(), 'pending': _stats_agg['pending'],
'total_fee': float(agg['total_fee'] or 0), 'total_fee': float(agg['total_fee'] or 0),
'tag_stats': [ 'tag_stats': [
{ {
@@ -702,8 +709,11 @@ class KhjlczView(APIView):
for f in KaoheJiluFeiyong.query.filter(jilu_id=jilu_id).order_by('cishu') for f in KaoheJiluFeiyong.query.filter(jilu_id=jilu_id).order_by('cishu')
] ]
reject_list = [] reject_list = []
for r in KaoheJujueJilu.query.filter(jilu_id=jilu_id).order_by('-CreateTime'): _reject_records = list(KaoheJujueJilu.query.filter(jilu_id=jilu_id).order_by('-CreateTime'))
kg_prof = _batch_user_profile_map([r.shenheguan_id]).get(r.shenheguan_id, {}) _kg_ids = [r.shenheguan_id for r in _reject_records if r.shenheguan_id]
_kg_prof_map = _batch_user_profile_map(_kg_ids) if _kg_ids else {}
for r in _reject_records:
kg_prof = _kg_prof_map.get(r.shenheguan_id, {})
reject_list.append({ reject_list.append({
'cishu': r.cishu, 'cishu': r.cishu,
'shenheguan_id': r.shenheguan_id, 'shenheguan_id': r.shenheguan_id,

View File

@@ -1,4 +1,4 @@
import hmac import hmac
import threading import threading
import traceback import traceback
import uuid import uuid
@@ -12,6 +12,7 @@ import secrets
import random import random
import string import string
from calendar import monthrange from calendar import monthrange
from collections import defaultdict
from decimal import Decimal from decimal import Decimal
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from django.utils import timezone from django.utils import timezone
@@ -155,13 +156,18 @@ class CwhybkhqView(APIView):
if 'huiyuancaiwu' not in permissions: if 'huiyuancaiwu' not in permissions:
return Response({'code': 403, 'msg': '无会员财务查看权限'}, status=403) return Response({'code': 403, 'msg': '无会员财务查看权限'}, status=403)
all_bankuai = list(Bankuai.query.all())
bankuai_ids = [bk.bankuai_id for bk in all_bankuai]
all_members = Huiyuan.query.filter(bankuai_id__in=bankuai_ids).values('huiyuan_id', 'jieshao', 'bankuai_id')
members_by_bankuai = defaultdict(list)
for m in all_members:
members_by_bankuai[m['bankuai_id']].append(dict(m))
bankuai_list = [] bankuai_list = []
for bk in Bankuai.query.all(): for bk in all_bankuai:
members = Huiyuan.query.filter(bankuai=bk).values('huiyuan_id', 'jieshao', 'bankuai_id')
bankuai_list.append({ bankuai_list.append({
'bankuai_id': bk.bankuai_id, 'bankuai_id': bk.bankuai_id,
'mingcheng': bk.mingcheng, 'mingcheng': bk.mingcheng,
'members': list(members) 'members': members_by_bankuai.get(bk.bankuai_id, [])
}) })
return Response({'code': 0, 'msg': 'ok', 'data': bankuai_list}) return Response({'code': 0, 'msg': 'ok', 'data': bankuai_list})
@@ -597,21 +603,30 @@ class CwhqjtddsjView(APIView):
summary_qs = filter_queryset_by_club( summary_qs = filter_queryset_by_club(
Order.query.filter(**filters), request, club_field='ClubID', Order.query.filter(**filters), request, club_field='ClubID',
) )
total_order_count = summary_qs.count() agg = summary_qs.aggregate(
total_order_amount = summary_qs.aggregate(s=Sum('Amount'))['s'] or 0 total_order_count=Count('id'),
total_success_count = summary_qs.filter(Status=3).count() total_order_amount=Sum('Amount'),
total_success_amount = summary_qs.filter(Status=3).aggregate(s=Sum('Amount'))['s'] or 0 total_success_count=Count('id', filter=Q(Status=3)),
total_refund_count = summary_qs.filter(Status=5).count() total_success_amount=Sum('Amount', filter=Q(Status=3)),
total_dashou = summary_qs.filter(Status=3).aggregate(s=Sum('PlayerCommission'))['s'] or 0 total_refund_count=Count('id', filter=Q(Status=5)),
total_dianpu_fenhong = summary_qs.filter(Status=3, Platform=1).aggregate( total_dashou=Sum('PlayerCommission', filter=Q(Status=3)),
s=Sum('pingtai_kuozhan__ShopIncome'))['s'] or 0 total_dianpu_fenhong=Sum('pingtai_kuozhan__ShopIncome', filter=Q(Status=3, Platform=1)),
platform_amount=Sum('Amount', filter=Q(Status=3, Platform=1)),
platform_amount = summary_qs.filter(Status=3, Platform=1).aggregate(s=Sum('Amount'))['s'] or 0 merchant_amount=Sum('Amount', filter=Q(Status=3, Platform=2)),
merchant_amount = summary_qs.filter(Status=3, Platform=2).aggregate(s=Sum('Amount'))['s'] or 0 platform_dashou_sum=Sum('PlayerCommission', filter=Q(Status=3, Platform=1)),
platform_dashou_sum = \ merchant_dashou_sum=Sum('PlayerCommission', filter=Q(Status=3, Platform=2)),
summary_qs.filter(Status=3, Platform=1).aggregate(s=Sum('PlayerCommission'))['s'] or 0 )
merchant_dashou_sum = \ total_order_count = agg['total_order_count'] or 0
summary_qs.filter(Status=3, Platform=2).aggregate(s=Sum('PlayerCommission'))['s'] or 0 total_order_amount = agg['total_order_amount'] or 0
total_success_count = agg['total_success_count'] or 0
total_success_amount = agg['total_success_amount'] or 0
total_refund_count = agg['total_refund_count'] or 0
total_dashou = agg['total_dashou'] or 0
total_dianpu_fenhong = agg['total_dianpu_fenhong'] or 0
platform_amount = agg['platform_amount'] or 0
merchant_amount = agg['merchant_amount'] or 0
platform_dashou_sum = agg['platform_dashou_sum'] or 0
merchant_dashou_sum = agg['merchant_dashou_sum'] or 0
profit_platform = platform_amount - platform_dashou_sum - total_dianpu_fenhong profit_platform = platform_amount - platform_dashou_sum - total_dianpu_fenhong
profit_merchant = merchant_amount - merchant_dashou_sum profit_merchant = merchant_amount - merchant_dashou_sum

View File

@@ -1,4 +1,4 @@
import hmac import hmac
import threading import threading
import traceback import traceback
import uuid import uuid
@@ -357,9 +357,16 @@ class GetGuanliDetailView(APIView):
custom_first = {} # 首次分红定制cishu=1 custom_first = {} # 首次分红定制cishu=1
extra_map = {} # 其他次数 { cishu: { huiyuan_id: { guanshi_fenhong, jieshao, jiage } } } extra_map = {} # 其他次数 { cishu: { huiyuan_id: { guanshi_fenhong, jieshao, jiage } } }
# 批量预取会员信息
_huiyuan_ids = [r.huiyuan for r in duoci_records]
_huiyuan_map = {
h.huiyuan_id: h
for h in Huiyuan.query.filter(huiyuan_id__in=_huiyuan_ids).only('huiyuan_id', 'jieshao', 'jiage')
}
for record in duoci_records: for record in duoci_records:
# 获取会员信息 # 获取会员信息
member = Huiyuan.query.filter(huiyuan_id=record.huiyuan).first() member = _huiyuan_map.get(record.huiyuan)
if not member: if not member:
continue continue
member_info = { member_info = {
@@ -380,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': {
@@ -391,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,
} }
}) })
@@ -629,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}'
@@ -643,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,
@@ -651,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}'
), ),
) )
@@ -711,10 +737,16 @@ class UpdateGuanliView(APIView):
if custom_first is not None and isinstance(custom_first, dict): if custom_first is not None and isinstance(custom_first, dict):
from jituan.services.club_penalty import resolve_gsfenhong_club_id from jituan.services.club_penalty import resolve_gsfenhong_club_id
guanshi_club = resolve_gsfenhong_club_id(dashouid=yonghuid) guanshi_club = resolve_gsfenhong_club_id(dashouid=yonghuid)
# 取当前所有首次定制记录 # 批量预取当前所有首次定制记录(避免循环内 N+1
existing_first = DuociFenhong.query.filter( existing_first_list = list(
club_id=guanshi_club, yonghuid=yonghuid, cishu=1, DuociFenhong.query.filter(
club_id=guanshi_club, yonghuid=yonghuid, cishu=1,
)
) )
_existing_map = {r.huiyuan: r for r in existing_first_list}
_to_create = []
_to_update = []
_to_delete_ids = []
# 前端传来的定制字典 {会员ID: 金额} # 前端传来的定制字典 {会员ID: 金额}
for huiyuan_id, amount in custom_first.items(): for huiyuan_id, amount in custom_first.items():
try: try:
@@ -722,24 +754,34 @@ class UpdateGuanliView(APIView):
except: except:
continue continue
if amount <= 0: if amount <= 0:
DuociFenhong.query.filter( rec = _existing_map.get(huiyuan_id)
club_id=guanshi_club, yonghuid=yonghuid, huiyuan=huiyuan_id, cishu=1, if rec is not None:
).delete() _to_delete_ids.append(rec.id)
else: else:
DuociFenhong.query.update_or_create( rec = _existing_map.get(huiyuan_id)
club_id=guanshi_club, if rec is not None:
yonghuid=yonghuid, rec.guanshi_fenhong = amount
huiyuan=huiyuan_id, rec.zuzhang_fenhong = Decimal('0')
cishu=1, _to_update.append(rec)
defaults={ else:
'guanshi_fenhong': amount, _to_create.append(DuociFenhong(
'zuzhang_fenhong': Decimal('0'), club_id=guanshi_club,
}, yonghuid=yonghuid,
) huiyuan=huiyuan_id,
cishu=1,
guanshi_fenhong=amount,
zuzhang_fenhong=Decimal('0'),
))
# 删除那些在前端字典中不存在的首次定制记录(即已取消定制) # 删除那些在前端字典中不存在的首次定制记录(即已取消定制)
for record in existing_first: for rec in existing_first_list:
if record.huiyuan not in custom_first: if rec.huiyuan not in custom_first and rec.id not in _to_delete_ids:
record.delete() _to_delete_ids.append(rec.id)
if _to_create:
DuociFenhong.objects.bulk_create(_to_create)
if _to_update:
DuociFenhong.objects.bulk_update(_to_update, ['guanshi_fenhong', 'zuzhang_fenhong'])
if _to_delete_ids:
DuociFenhong.objects.filter(id__in=_to_delete_ids).delete()
# 4.3 额外次数分红cishu >= 2 # 4.3 额外次数分红cishu >= 2
if extra_map is not None and isinstance(extra_map, dict): if extra_map is not None and isinstance(extra_map, dict):
@@ -747,6 +789,8 @@ class UpdateGuanliView(APIView):
guanshi_club = resolve_gsfenhong_club_id(dashouid=yonghuid) guanshi_club = resolve_gsfenhong_club_id(dashouid=yonghuid)
# 收集需要保留的记录 # 收集需要保留的记录
keep_set = set() keep_set = set()
# 解析前端数据,先汇总 keep_set 与目标金额
_extra_targets = [] # [(cishu, huiyuan_id, amount)]
for times_str, members in extra_map.items(): for times_str, members in extra_map.items():
try: try:
cishu = int(times_str) cishu = int(times_str)
@@ -767,24 +811,42 @@ class UpdateGuanliView(APIView):
except: except:
continue continue
keep_set.add((cishu, huiyuan_id)) keep_set.add((cishu, huiyuan_id))
# 更新或创建 _extra_targets.append((cishu, huiyuan_id, amount))
DuociFenhong.query.update_or_create( # 批量预取已有额外次数记录
existing_extra_list = list(
DuociFenhong.query.filter(
club_id=guanshi_club, yonghuid=yonghuid, cishu__gte=2,
)
)
_extra_map = {(r.cishu, r.huiyuan): r for r in existing_extra_list}
_to_create = []
_to_update = []
_to_delete_ids = []
for cishu, huiyuan_id, amount in _extra_targets:
rec = _extra_map.get((cishu, huiyuan_id))
if rec is not None:
rec.guanshi_fenhong = amount
rec.zuzhang_fenhong = Decimal('0')
_to_update.append(rec)
else:
_to_create.append(DuociFenhong(
club_id=guanshi_club, club_id=guanshi_club,
yonghuid=yonghuid, yonghuid=yonghuid,
huiyuan=huiyuan_id, huiyuan=huiyuan_id,
cishu=cishu, cishu=cishu,
defaults={ guanshi_fenhong=amount,
'guanshi_fenhong': amount, zuzhang_fenhong=Decimal('0'),
'zuzhang_fenhong': Decimal('0') ))
}
)
# 删除不在 keep_set 中的额外次数记录 # 删除不在 keep_set 中的额外次数记录
existing_extra = DuociFenhong.query.filter( for rec in existing_extra_list:
club_id=guanshi_club, yonghuid=yonghuid, cishu__gte=2, if (rec.cishu, rec.huiyuan) not in keep_set:
) _to_delete_ids.append(rec.id)
for record in existing_extra: if _to_create:
if (record.cishu, record.huiyuan) not in keep_set: DuociFenhong.objects.bulk_create(_to_create)
record.delete() if _to_update:
DuociFenhong.objects.bulk_update(_to_update, ['guanshi_fenhong', 'zuzhang_fenhong'])
if _to_delete_ids:
DuociFenhong.objects.filter(id__in=_to_delete_ids).delete()
dividend_parts = [] dividend_parts = []
if perm_enabled is not None: if perm_enabled is not None:
@@ -808,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,
@@ -342,6 +498,12 @@ class KefuGetDashouDetailView(APIView):
member_records = Huiyuangoumai.query.filter( member_records = Huiyuangoumai.query.filter(
yonghu_id=uid yonghu_id=uid
).order_by('-CreateTime') ).order_by('-CreateTime')
# 批量预取会员信息
_huiyuan_ids = [r.huiyuan_id for r in member_records]
_huiyuan_map = {
h.huiyuan_id: h.jieshao
for h in Huiyuan.query.filter(huiyuan_id__in=_huiyuan_ids).only('huiyuan_id', 'jieshao')
}
member_list = [] member_list = []
for record in member_records: for record in member_records:
# 检查是否过期 # 检查是否过期
@@ -351,11 +513,7 @@ class KefuGetDashouDetailView(APIView):
is_expired = record.daoqi_time < timezone.now() if record.daoqi_time else True is_expired = record.daoqi_time < timezone.now() if record.daoqi_time else True
if not is_expired: if not is_expired:
try: huiyuan_name = _huiyuan_map.get(record.huiyuan_id, '未知会员')
huiyuan = Huiyuan.query.get(huiyuan_id=record.huiyuan_id)
huiyuan_name = huiyuan.jieshao
except Huiyuan.DoesNotExist:
huiyuan_name = '未知会员'
member_list.append({ member_list.append({
'id': record.id, 'id': record.id,
'huiyuan_name': huiyuan_name, 'huiyuan_name': huiyuan_name,
@@ -367,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, # 返回当前客服的权限列表
} }
@@ -457,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,
@@ -473,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)}})
@@ -504,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,
@@ -520,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)}})
@@ -575,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:
@@ -671,11 +856,10 @@ class KefuUpdateDashouView(APIView):
return Response({'code': 403, 'msg': '无权限给打手减会员'}) return Response({'code': 403, 'msg': '无权限给打手减会员'})
if not huiyuan_id: if not huiyuan_id:
return Response({'code': 400, 'msg': '缺少会员ID'}) return Response({'code': 400, 'msg': '缺少会员ID'})
try: from jituan.services.member_recharge import revoke_member_entitlement_admin
record = Huiyuangoumai.query.get(yonghu_id=dashou_id, huiyuan_id=huiyuan_id) removed, err = revoke_member_entitlement_admin(dashou_id, huiyuan_id)
except Huiyuangoumai.DoesNotExist: if err:
return Response({'code': 404, 'msg': '该打手未购买此会员'}) return Response({'code': 404, 'msg': err})
record.delete()
write_xiugai_log( write_xiugai_log(
yonghuid=dashou_id, yonghuid=dashou_id,
xiugaiid=kefu.user.Phone, xiugaiid=kefu.user.Phone,
@@ -828,8 +1012,12 @@ class KefuGetShangjiaListView(APIView):
return Response({'code': 400, 'msg': '商家状态格式错误'}) return Response({'code': 400, 'msg': '商家状态格式错误'})
# 5. 统计总商家数(应用筛选后) # 5. 统计总商家数(应用筛选后)
total = shangjia_qs.count() _agg = shangjia_qs.aggregate(
valid_merchant_count = shangjia_qs.filter(zhuangtai=1).count() total=Count('id'),
valid=Count('id', filter=Q(zhuangtai=1)),
)
total = _agg['total']
valid_merchant_count = _agg['valid']
# 6. 分页 # 6. 分页
offset = (page - 1) * page_size offset = (page - 1) * page_size
@@ -929,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 '',
} }
@@ -962,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参数无效'})
# 权限校验 # 权限校验
@@ -971,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'})
@@ -985,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')
@@ -1002,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,
@@ -1010,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)}})
@@ -1097,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):
@@ -1127,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 = ''
@@ -1145,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

@@ -206,6 +206,7 @@ class UpdateMemberView(APIView):
'trial_zuzhangfc': request.data.get('trial_zuzhangfc'), 'trial_zuzhangfc': request.data.get('trial_zuzhangfc'),
'card_image': request.data.get('card_image'), 'card_image': request.data.get('card_image'),
'card_bg': request.data.get('card_bg'), 'card_bg': request.data.get('card_bg'),
'included_huiyuan_ids': request.data.get('included_huiyuan_ids'),
} }
data, err = update_member_for_club( data, err = update_member_for_club(
request, huiyuan_id, jieshao, jiage, guanshifc, zuzhangfc, jtjieshao, extra=extra, request, huiyuan_id, jieshao, jiage, guanshifc, zuzhangfc, jtjieshao, extra=extra,
@@ -270,7 +271,10 @@ class AddMemberView(APIView):
'msg': '仅集团汇总视图可新建全局会员类型;俱乐部请从会员 catalog 上架', 'msg': '仅集团汇总视图可新建全局会员类型;俱乐部请从会员 catalog 上架',
}) })
data, err = add_global_member(jieshao, jiage, guanshifc, zuzhangfc, jtjieshao) data, err = add_global_member(
jieshao, jiage, guanshifc, zuzhangfc, jtjieshao,
is_bundle=bool(request.data.get('is_bundle')),
)
if err: if err:
return Response({'code': 400, 'msg': err}) return Response({'code': 400, 'msg': err})
return Response({'code': 0, 'msg': '添加成功', 'data': data}) return Response({'code': 0, 'msg': '添加成功', 'data': data})
@@ -349,5 +353,105 @@ class ClubMemberDisableView(APIView):
return Response({'code': 0, 'msg': '已下架', 'data': data}) return Response({'code': 0, 'msg': '已下架', 'data': data})
class MemberCardImageUploadView(APIView):
"""POST /jituan/houtai/hy-card-upload 上传会员充值卡片图"""
permission_classes = []
parser_classes = [MultiPartParser, FormParser, JSONParser]
def post(self, request):
username = (request.data.get('username') or request.data.get('phone') or '').strip()
huiyuan_id = (request.data.get('huiyuan_id') or '').strip()
field = (request.data.get('field') or 'card_image').strip()
file_obj = request.FILES.get('file')
if not username or not huiyuan_id or not file_obj:
return Response({'code': 400, 'msg': '参数不完整'})
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return permissions
if '3300a' not in permissions:
return Response({'code': 403, 'msg': '无权限'})
from jituan.services.club_member_admin import upload_member_card_image
data, err = upload_member_card_image(request, huiyuan_id, field, file_obj)
if err:
return Response({'code': 400, 'msg': err})
return Response({'code': 0, 'msg': '上传成功', 'data': data})
class MemberRechargeRecordsView(APIView):
"""POST /jituan/houtai/hy-recharge-records — 会员充值记录明细 + 分红"""
permission_classes = []
parser_classes = [JSONParser]
def post(self, request):
username = (request.data.get('username') or request.data.get('phone') or '').strip()
if not username:
return Response({'code': 401, 'msg': '缺少username'})
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return permissions
if '3300a' not in permissions:
return Response({'code': 403, 'msg': '无权限查看会员充值记录'})
from jituan.services.member_recharge_records import build_member_recharge_records_payload
filters = request.data.get('filters') or {}
for key in (
'dingdan_id', 'yonghuid', 'buyer_keyword', 'huiyuan_id', 'is_trial',
'zhuangtai', 'date_from', 'date_to', 'jine_min', 'jine_max',
'guanshi_id', 'zuzhang_id', 'keyword', 'is_bundle', 'has_fenhong', 'club_id',
):
if request.data.get(key) not in (None, ''):
filters[key] = request.data.get(key)
payload = build_member_recharge_records_payload(
request,
filters=filters,
page=request.data.get('page', 1),
page_size=request.data.get('page_size', 20),
)
return Response({'code': 0, 'data': payload})
class MemberBundleCreateView(APIView):
"""POST /jituan/houtai/hy-bundle-create — 俱乐部一键创建总会员并上架"""
permission_classes = []
parser_classes = [JSONParser]
def post(self, request):
username = (request.data.get('username') or request.data.get('phone') or '').strip()
if not username:
return Response({'code': 401, 'msg': '缺少username'})
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return permissions
if '3300a' not in permissions:
return Response({'code': 403, 'msg': '无权限'})
jieshao = (request.data.get('jieshao') or '').strip()
if not jieshao:
return Response({'code': 400, 'msg': '会员名称不能为空'})
try:
jiage = Decimal(str(request.data.get('jiage')))
guanshifc = Decimal(str(request.data.get('guanshifc')))
zuzhangfc = Decimal(str(request.data.get('zuzhangfc')))
except Exception:
return Response({'code': 400, 'msg': '金额格式错误'})
from jituan.services.club_member_admin import create_bundle_member_for_club
data, err = create_bundle_member_for_club(
request,
jieshao=jieshao,
jiage=jiage,
guanshifc=guanshifc,
zuzhangfc=zuzhangfc,
jtjieshao=(request.data.get('jtjieshao') or '').strip(),
formal_days=int(request.data.get('formal_days') or 30),
included_huiyuan_ids=request.data.get('included_huiyuan_ids') or [],
)
if err:
return Response({'code': 400, 'msg': err})
return Response({'code': 0, 'msg': '总会员创建并上架成功', 'data': data})

View File

@@ -1,4 +1,4 @@
import hmac import hmac
import threading import threading
import traceback import traceback
import uuid import uuid
@@ -518,13 +518,15 @@ class FaKuanChuangJianView(APIView):
user=request.user, user=request.user,
), ),
) )
for relative_url in uploaded_urls: PenaltyAppealImage.objects.bulk_create([
PenaltyAppealImage.query.create( PenaltyAppealImage(
Penalty=penalty, Penalty=penalty,
PenalizedUserID=PenalizedUserID, PenalizedUserID=PenalizedUserID,
ImageURL=relative_url, ImageURL=relative_url,
Purpose=1 Purpose=1,
) )
for relative_url in uploaded_urls
])
from users.fadan_fenhong_utils import lock_penalty_bonus from users.fadan_fenhong_utils import lock_penalty_bonus
lock_penalty_bonus(penalty) lock_penalty_bonus(penalty)
@@ -581,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
@@ -608,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

@@ -1,4 +1,4 @@
import hmac import hmac
import threading import threading
import traceback import traceback
import uuid import uuid
@@ -141,59 +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
type_list = [] from jituan.constants import DATA_SCOPE_ALL
for t in type_qs: from jituan.services.club_write import resolve_club_id_for_write
# 🔥 修改:统计该类型下的公共商品数量(店铺为空且店铺商品类型为空)
product_count = Shangpin.query.filter(
leixing_id=t.id,
dianpu__isnull=True, # 所属店铺为空
dianpu_leixing__isnull=True # 店铺商品类型为空
).count()
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_list = [] # 批量聚合商品数量,避免循环内 N+1 count按俱乐部
for z in zone_qs: _public_sp = filter_club_char_field(
# 🔥 修改:统计该专区下的公共商品数量(店铺为空且店铺商品类型为空) Shangpin.query.filter(
product_count = Shangpin.query.filter( dianpu__isnull=True, dianpu_leixing__isnull=True, leixing_id__isnull=False
zhuanqu_id=z.id, ),
dianpu__isnull=True, # 所属店铺为空 request,
dianpu_leixing__isnull=True # 店铺商品类型为空 field='club_id',
).count() )
zone_list.append({ # 用 Django QS 聚合,避免 Fluent values 链在部分环境下异常
'id': z.id, _type_counts = {
'mingzi': z.mingzi or '', item['leixing_id']: item['cnt']
'leixing_id': z.leixing_id, for item in _public_sp.values('leixing_id').annotate(cnt=Count('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': '获取商品基础数据失败,请稍后重试'})
@@ -232,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')
@@ -254,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:
@@ -320,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({
@@ -362,16 +414,17 @@ 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')
# 批量聚合商品数量,避免循环内 N+1 count
_type_counts = {
item['leixing_id']: item['cnt']
for item in Shangpin.query.filter(leixing_id__isnull=False).values('leixing_id').annotate(cnt=Count('id'))
}
type_list = [] type_list = []
for t in type_qs: for t in type_qs:
# 统计该类型下的商品数量(忽略审核状态?通常统计所有商品,可根据需求调整) product_count = _type_counts.get(t.id, 0)
product_count = Shangpin.query.filter(leixing_id=t.id).count()
type_list.append({ type_list.append({
'id': t.id, 'id': t.id,
'jieshao': t.jieshao or '', 'jieshao': t.jieshao or '',
@@ -383,9 +436,13 @@ class GetProductListView(APIView):
# 获取所有专区(按 paixu 升序) # 获取所有专区(按 paixu 升序)
zone_qs = ShangpinZhuanqu.query.all().order_by('paixu') zone_qs = ShangpinZhuanqu.query.all().order_by('paixu')
_zone_counts = {
item['zhuanqu_id']: item['cnt']
for item in Shangpin.query.filter(zhuanqu_id__isnull=False).values('zhuanqu_id').annotate(cnt=Count('id'))
}
zone_list = [] zone_list = []
for z in zone_qs: for z in zone_qs:
product_count = Shangpin.query.filter(zhuanqu_id=z.id).count() product_count = _zone_counts.get(z.id, 0)
zone_list.append({ zone_list.append({
'id': z.id, 'id': z.id,
'mingzi': z.mingzi or '', 'mingzi': z.mingzi or '',
@@ -393,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、介绍、价格
@@ -443,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')
@@ -567,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'):
@@ -632,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()
@@ -647,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()
# 图片文件 # 图片文件
@@ -696,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': '商品专区不存在'})
@@ -763,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,
@@ -782,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)
@@ -832,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:
@@ -897,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:
@@ -935,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,
@@ -981,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():
@@ -1059,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()
@@ -1135,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

@@ -1,4 +1,4 @@
import hmac import hmac
import threading import threading
import traceback import traceback
import uuid import uuid
@@ -144,8 +144,8 @@ class GetRolePermissionView(APIView):
if kefu is None: if kefu is None:
return permissions # 直接返回错误响应 return permissions # 直接返回错误响应
# 2. 检查是否有 000001 权限 from jituan.services.admin_context import can_access_admin_user_management
if '000001' not in permissions: if not can_access_admin_user_management(request.user, permissions):
return Response({'code': 403, 'msg': '您无权访问此页面'}) return Response({'code': 403, 'msg': '您无权访问此页面'})
# 3. 获取所有权限 # 3. 获取所有权限
@@ -157,19 +157,29 @@ class GetRolePermissionView(APIView):
kefu_user_uuids = list( kefu_user_uuids = list(
User.objects.filter(KefuProfile__isnull=False).values_list('UserUUID', flat=True) User.objects.filter(KefuProfile__isnull=False).values_list('UserUUID', flat=True)
) )
roles = filter_roles_for_request(request) roles = list(filter_roles_for_request(request))
# 批量预取 RolePermission + Permission避免循环内 N+1
_role_uuids = [r.RoleUUID for r in roles]
_rp_pairs = list(
RolePermission.objects.filter(RoleUUID__in=_role_uuids)
.values_list('RoleUUID', 'PermUUID')
) if _role_uuids else []
_perm_uuids = list({p for _, p in _rp_pairs})
_perm_rows_by_uuid = {
p['PermUUID']: p
for p in Permission.objects.filter(PermUUID__in=_perm_uuids)
.values('PermUUID', 'PermCode', 'PermName', 'PermDesc')
} if _perm_uuids else {}
_perms_by_role = {}
for role_uuid, perm_uuid in _rp_pairs:
perm_row = _perm_rows_by_uuid.get(perm_uuid)
if perm_row is not None:
_perms_by_role.setdefault(role_uuid, []).append(_serialize_permission_row(perm_row))
roles_data = [] roles_data = []
for role in roles: for role in roles:
user_count = count_role_users_in_scope(role, request, kefu_user_uuids) user_count = count_role_users_in_scope(role, request, kefu_user_uuids)
role_perm_uuids = RolePermission.objects.filter( perms_of_role = _perms_by_role.get(role.RoleUUID, [])
RoleUUID=role.RoleUUID
).values_list('PermUUID', flat=True)
perms_of_role = [
_serialize_permission_row(p)
for p in Permission.objects.filter(
PermUUID__in=list(role_perm_uuids)
).values('PermCode', 'PermName', 'PermDesc')
]
roles_data.append({ roles_data.append({
'role_code': role.RoleName, 'role_code': role.RoleName,
'role_name': role.RoleName, 'role_name': role.RoleName,
@@ -208,15 +218,23 @@ class ModifyRolePermissionView(APIView):
if '000001' not in permissions: if '000001' not in permissions:
return Response({'code': 403, 'msg': '您无权进行此操作'}) return Response({'code': 403, 'msg': '您无权进行此操作'})
# 获取角色(需要角色名称 # 获取角色(按当前俱乐部解析,避免多俱乐部同名角色冲突
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': '缺少角色编码'})
try: from jituan.services.admin_context import can_manage_admin_assignments, can_manage_roles_for_club
role = Role.objects.get(RoleName=role_code) from jituan.services.club_rbac import resolve_role_by_name, resolve_role_club_id
except Role.DoesNotExist: explicit_club = (request.data.get('target_club_id') or request.data.get('club_id') or '').strip() or None
return Response({'code': 404, 'msg': '角色不存在'}) bind_club_id = resolve_role_club_id(request, explicit_club)
if bind_club_id:
if not can_manage_roles_for_club(request.user, permissions, bind_club_id):
return Response({'code': 403, 'msg': '您无权管理该俱乐部的角色'})
elif not can_manage_admin_assignments(request.user, permissions):
return Response({'code': 403, 'msg': '您无权管理全局角色'})
role = resolve_role_by_name(role_code, bind_club_id)
if not role:
return Response({'code': 404, 'msg': '角色不存在或与当前俱乐部不匹配'})
# 超级管理员角色的特殊限制 # 超级管理员角色的特殊限制
if role.RoleName == '管理员': if role.RoleName == '管理员':
@@ -275,8 +293,11 @@ class ModifyRolePermissionView(APIView):
if role.RoleName == '管理员': if role.RoleName == '管理员':
role_name = role.RoleName role_name = role.RoleName
elif role_name and role_name != role.RoleName: elif role_name and role_name != role.RoleName:
if Role.objects.filter(RoleName=role_name).exclude(RoleUUID=role.RoleUUID).exists(): role_club_id = getattr(role, 'ClubID', '') or ''
return Response({'code': 400, 'msg': '角色名称已存在'}) if Role.objects.filter(
RoleName=role_name, ClubID=role_club_id,
).exclude(RoleUUID=role.RoleUUID).exists():
return Response({'code': 400, 'msg': '该俱乐部下角色名称已存在'})
with transaction.atomic(): with transaction.atomic():
if role.RoleName != '管理员': if role.RoleName != '管理员':
if role_name: if role_name:
@@ -339,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': '该俱乐部下角色名称已存在'})
@@ -395,7 +422,8 @@ class GetAdminUserListView(APIView):
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
if '000001' not in permissions: from jituan.services.admin_context import can_access_admin_user_management
if not can_access_admin_user_management(request.user, permissions):
return Response({'code': 403, 'msg': '您无权访问此页面'}) return Response({'code': 403, 'msg': '您无权访问此页面'})
phone = request.data.get('phone', '').strip() phone = request.data.get('phone', '').strip()
@@ -410,8 +438,10 @@ class GetAdminUserListView(APIView):
KefuProfile__isnull=False KefuProfile__isnull=False
).select_related('KefuProfile') ).select_related('KefuProfile')
from jituan.services.club_rbac import filter_kefu_users_qs from jituan.models import AdminAssignment, Club
users = filter_kefu_users_qs(users, request) from jituan.constants import ADMIN_ROLE_LABELS
from jituan.services.club_rbac import filter_kefu_users_by_admin_scope
users = filter_kefu_users_by_admin_scope(users, request)
if phone: if phone:
users = users.filter(Phone__icontains=phone) users = users.filter(Phone__icontains=phone)
@@ -420,7 +450,9 @@ class GetAdminUserListView(APIView):
# 关键修正:先求值为列表,再传入 phone__in # 关键修正:先求值为列表,再传入 phone__in
if role_code: if role_code:
target_role = Role.objects.filter(RoleName=role_code).first() from jituan.services.club_rbac import resolve_role_by_name, resolve_role_club_id
filter_club_id = resolve_role_club_id(request)
target_role = resolve_role_by_name(role_code, filter_club_id)
if target_role: if target_role:
user_uuids = list( user_uuids = list(
UserRole.objects.filter(RoleUUID=target_role.RoleUUID) UserRole.objects.filter(RoleUUID=target_role.RoleUUID)
@@ -434,24 +466,85 @@ class GetAdminUserListView(APIView):
paginator = Paginator(users, page_size) paginator = Paginator(users, page_size)
page_obj = paginator.get_page(page) page_obj = paginator.get_page(page)
data_list = [] # 批量预取本页所有用户的 UserRole + Role避免循环内 N+1
for user in page_obj: _page_users = list(page_obj)
# 通过 gvsdsdk UserRole 获取用户角色 _page_user_uuids = [u.UserUUID for u in _page_users]
user_role_uuids = list( _ur_pairs = list(
UserRole.objects.filter(UserUUID=user.UserUUID) UserRole.objects.filter(UserUUID__in=_page_user_uuids)
.values_list('RoleUUID', flat=True) .values_list('UserUUID', 'RoleUUID')
) ) if _page_user_uuids else []
user_roles = Role.objects.filter(RoleUUID__in=user_role_uuids) _role_uuids_in_page = list({r for _, r in _ur_pairs})
roles_data = [ _role_info_by_uuid = {
{'role_code': r.RoleName, 'role_name': r.RoleName} r.RoleUUID: {'role_name': r.RoleName, 'club_id': getattr(r, 'ClubID', '') or ''}
for r in user_roles for r in Role.objects.filter(RoleUUID__in=_role_uuids_in_page)
} if _role_uuids_in_page else {}
_roles_by_user = {}
for user_uuid, role_uuid in _ur_pairs:
info = _role_info_by_uuid.get(role_uuid)
if info is not None:
_roles_by_user.setdefault(user_uuid, []).append({
'role_code': info['role_name'],
'role_name': info['role_name'],
'club_id': info['club_id'],
})
_page_yonghuids = [u.UserUID for u in _page_users]
_club_name_map = {c.club_id: c.name for c in Club.query.filter(status=1)}
_assignments_by_user = {}
if _page_yonghuids:
for a in AdminAssignment.query.filter(yonghuid__in=_page_yonghuids, status=1):
if a.club_id:
club_label = _club_name_map.get(a.club_id, a.club_id)
else:
club_label = '集团(全部子公司)'
_assignments_by_user.setdefault(a.yonghuid, []).append({
'id': a.id,
'club_id': a.club_id,
'club_label': club_label,
'role_code': a.role_code,
'role_name': ADMIN_ROLE_LABELS.get(a.role_code, a.role_code),
'is_primary': a.is_primary,
})
from jituan.constants import DATA_SCOPE_ALL
from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope
from jituan.services.club_rbac import filter_roles_for_request
scope = resolve_club_scope(request)
curr_club = (resolve_club_id_from_request(request) or '').strip()
# 单俱乐部视图:角色下拉/展示只允许「本俱乐部角色」;若本店无角色则仅全局
allowed_role_club_ids = None
if scope != DATA_SCOPE_ALL:
scoped_roles = list(filter_roles_for_request(request).values_list('ClubID', flat=True))
allowed_role_club_ids = {(c or '').strip() for c in scoped_roles}
def _roles_in_view(roles_data):
if scope == DATA_SCOPE_ALL or allowed_role_club_ids is None:
return roles_data
return [
r for r in roles_data
if (r.get('club_id') or '').strip() in allowed_role_club_ids
] ]
def _assignments_in_view(assignments):
if scope == DATA_SCOPE_ALL:
return assignments
# 本俱乐部任职 + 集团级club_id 空);绝不展示其他子公司任职
return [
a for a in assignments
if not a.get('club_id') or a.get('club_id') == curr_club
]
data_list = []
for user in _page_users:
roles_data = _roles_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_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 '',
@@ -459,13 +552,30 @@ class GetAdminUserListView(APIView):
'UpdateTime': user.KefuProfile.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') if getattr(user.KefuProfile, 'UpdateTime', None) else '', 'UpdateTime': user.KefuProfile.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') if getattr(user.KefuProfile, 'UpdateTime', None) else '',
}) })
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': {
'list': data_list, 'list': data_list,
'total': paginator.count, 'total': paginator.count,
'page': page, 'page': page,
'pageSize': page_size 'pageSize': page_size,
'can_manage_club_assignments': can_manage_admin_assignments(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,
} }
}) })
@@ -487,11 +597,27 @@ class GetAdminRolesView(APIView):
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
if '000001' not in permissions: from jituan.services.admin_context import can_access_admin_user_management
if not can_access_admin_user_management(request.user, permissions):
return Response({'code': 403, 'msg': '您无权访问此页面'}) return Response({'code': 403, 'msg': '您无权访问此页面'})
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
roles = filter_roles_for_request(request).values('RoleName', 'ClubID')
scope = resolve_club_scope(request)
all_clubs = request.data.get('all_clubs') in (True, 1, '1', 'true', 'yes')
# 仅集团汇总视图 + 集团任职管理权 才可拉全量子公司角色;
# 顶栏在具体俱乐部时(含集团账号切到某店),绝不容许其他俱乐部角色泄露。
if (
scope == DATA_SCOPE_ALL
and all_clubs
and can_manage_admin_assignments(request.user, permissions)
):
roles = Role.objects.filter(RoleStatus=1).order_by('ClubID', 'RoleName').values('RoleName', 'ClubID')
else:
roles = filter_roles_for_request(request).order_by('ClubID', 'RoleName').values('RoleName', 'ClubID')
return Response({ return Response({
'code': 0, 'code': 0,
'data': { 'data': {
@@ -523,7 +649,8 @@ class GetAdminRolesView(APIView):
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
if '000001' not in permissions: from jituan.services.admin_context import can_access_admin_user_management
if not can_access_admin_user_management(request.user, permissions):
return Response({'code': 403, 'msg': '您无权访问此页面'}) return Response({'code': 403, 'msg': '您无权访问此页面'})
# 筛选条件 # 筛选条件
@@ -593,7 +720,24 @@ class ModifyAdminUserView(APIView):
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
if '000001' not in permissions:
from jituan.services.admin_context import (
can_manage_admin_assignments,
can_manage_roles_for_club,
can_access_admin_user_management,
)
club_assign_actions = {'add_club_assignment', 'remove_club_assignment'}
role_manage_actions = {'add_user_roles', 'remove_user_role'}
if action in club_assign_actions:
# 跨店任职仍需集团权限;本店超级管理只管功能角色与账号,不分配其他俱乐部任职
if not can_manage_admin_assignments(request.user, permissions):
return Response({'code': 403, 'msg': '无权分配俱乐部任职(需集团权限)'})
elif action in role_manage_actions:
# 本店超级管理 000001 可给本俱乐部用户增删功能角色
if not can_access_admin_user_management(request.user, permissions):
return Response({'code': 403, 'msg': '您无权管理角色分配'})
elif not can_manage_admin_assignments(request.user, permissions) and '000001' not in permissions:
return Response({'code': 403, 'msg': '您无权进行此操作'}) return Response({'code': 403, 'msg': '您无权进行此操作'})
target_yonghuid = request.data.get('yonghuid', '').strip() target_yonghuid = request.data.get('yonghuid', '').strip()
@@ -620,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位'})
@@ -638,14 +792,27 @@ 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
club_id = getattr(target_user, 'ClubID', '') or '' bind_club_id = resolve_role_club_id(
request, (request.data.get('target_club_id') or '').strip() or None,
)
if not bind_club_id:
return Response({'code': 400, 'msg': '请先切换到具体俱乐部再分配角色'})
if not (
can_manage_roles_for_club(request.user, permissions, bind_club_id)
or ('000001' in permissions)
):
return Response({'code': 403, 'msg': '您无权管理该俱乐部的角色分配'})
added, missing = [], [] added, missing = [], []
for rc in role_codes: for rc in role_codes:
role = resolve_role_by_name(rc, club_id) role = resolve_role_by_name(rc, bind_club_id)
if not role: if not role:
missing.append(rc) missing.append(rc)
continue continue
role_club = (getattr(role, 'ClubID', None) or '').strip()
if role_club and role_club != bind_club_id:
missing.append(rc)
continue
_, created = UserRole.objects.get_or_create( _, created = UserRole.objects.get_or_create(
UserUUID=target_user.UserUUID, UserUUID=target_user.UserUUID,
RoleUUID=role.RoleUUID, RoleUUID=role.RoleUUID,
@@ -674,14 +841,59 @@ 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
club_id = getattr(target_user, 'ClubID', '') or '' bind_club_id = resolve_role_club_id(
role = resolve_role_by_name(role_code, club_id) request, (request.data.get('target_club_id') or '').strip() or None,
)
if not bind_club_id:
return Response({'code': 400, 'msg': '请先切换到具体俱乐部再操作角色'})
if not (
can_manage_roles_for_club(request.user, permissions, bind_club_id)
or ('000001' in permissions)
):
return Response({'code': 403, 'msg': '您无权管理该俱乐部的角色分配'})
role = resolve_role_by_name(role_code, bind_club_id)
if not role: if not role:
return Response({'code': 404, 'msg': '角色不存在或与用户俱乐部不匹配'}) return Response({'code': 404, 'msg': '角色不存在或与所选俱乐部不匹配'})
UserRole.objects.filter(UserUUID=target_user.UserUUID, RoleUUID=role.RoleUUID).delete() UserRole.objects.filter(UserUUID=target_user.UserUUID, RoleUUID=role.RoleUUID).delete()
return Response({'code': 0, 'msg': '角色移除成功'}) return Response({'code': 0, 'msg': '角色移除成功'})
elif action == 'add_club_assignment':
club_id = (request.data.get('club_id') or '').strip()
if not club_id:
return Response({'code': 400, 'msg': '请选择俱乐部'})
from jituan.services.admin_assignments import create_or_reactivate_assignment
from jituan.constants import DATA_SCOPE_SINGLE
role_code = (request.data.get('role_code') or 'CLUB_ADMIN').strip()
is_primary = bool(request.data.get('is_primary', False))
_, err = create_or_reactivate_assignment(
yonghuid=target_user.UserUID,
club_id=club_id,
role_code=role_code,
data_scope=DATA_SCOPE_SINGLE,
is_primary=is_primary,
granted_by=request.user.UserUID,
)
if err:
return Response({'code': 400, 'msg': err})
return Response({'code': 0, 'msg': '俱乐部任职已添加'})
elif action == 'remove_club_assignment':
assignment_id = request.data.get('assignment_id')
if not assignment_id:
return Response({'code': 400, 'msg': '缺少任职 id'})
from jituan.services.admin_assignments import deactivate_assignment
from jituan.models import AdminAssignment
row = AdminAssignment.query.filter(
id=assignment_id, yonghuid=target_user.UserUID, status=1,
).first()
if not row:
return Response({'code': 404, 'msg': '任职记录不存在'})
ok, err = deactivate_assignment(assignment_id)
if not ok:
return Response({'code': 404, 'msg': err or '停用失败'})
return Response({'code': 0, 'msg': '俱乐部任职已移除'})
else: else:
return Response({'code': 400, 'msg': '无效的action'}) return Response({'code': 400, 'msg': '无效的action'})
@@ -701,8 +913,24 @@ class AddAdminUserView(APIView):
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
if '000001' not in permissions:
return Response({'code': 403, 'msg': '您无权进行此操作'}) from jituan.services.admin_context import (
can_manage_admin_assignments,
can_manage_roles_for_club,
)
from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope
from jituan.constants import DATA_SCOPE_ALL
target_club_id = resolve_club_id_from_request(request)
if resolve_club_scope(request) == DATA_SCOPE_ALL or not target_club_id:
return Response({'code': 400, 'msg': '请先切换到具体俱乐部再添加用户'})
# 俱乐部超级管理 000001 即可添加本店后台用户,无需集团权限
if not (
can_manage_admin_assignments(request.user, permissions)
or can_manage_roles_for_club(request.user, permissions, target_club_id)
or ('000001' in permissions)
):
return Response({'code': 403, 'msg': '您无权在该俱乐部添加用户'})
phone = request.data.get('phone', '').strip() phone = request.data.get('phone', '').strip()
password = request.data.get('password', '').strip() password = request.data.get('password', '').strip()
@@ -715,11 +943,8 @@ class AddAdminUserView(APIView):
if len(password) < 6 or len(erjimima) < 6: if len(password) < 6 or len(erjimima) < 6:
return Response({'code': 400, 'msg': '密码和二级密码至少6位'}) return Response({'code': 400, 'msg': '密码和二级密码至少6位'})
from jituan.services.club_context import resolve_club_id_from_request
from jituan.services.club_rbac import resolve_roles_for_binding from jituan.services.club_rbac import resolve_roles_for_binding
target_club_id = resolve_club_id_from_request(request)
# 校验手机号是否已被后台客服占用(同俱乐部优先) # 校验手机号是否已被后台客服占用(同俱乐部优先)
existing_kefu = User.query.filter( existing_kefu = User.query.filter(
Phone=phone, KefuProfile__isnull=False Phone=phone, KefuProfile__isnull=False
@@ -729,11 +954,13 @@ class AddAdminUserView(APIView):
if not isinstance(role_codes, list): if not isinstance(role_codes, list):
return Response({'code': 400, 'msg': 'role_codes 格式错误'}) return Response({'code': 400, 'msg': 'role_codes 格式错误'})
if not role_codes:
return Response({'code': 400, 'msg': '请至少选择一个角色'})
# 分配角色(默认客服 + 所选角色,去重;任一角色找不到则整单拒绝 # 仅绑定前端所选角色(各俱乐部角色名不同,勿硬编码「客服」
role_names_to_bind = [] role_names_to_bind = []
seen_names = set() seen_names = set()
for name in (['客服'] if '客服' not in role_codes else []) + list(role_codes): for name in role_codes:
n = (name or '').strip() n = (name or '').strip()
if n and n not in seen_names: if n and n not in seen_names:
seen_names.add(n) seen_names.add(n)
@@ -795,6 +1022,17 @@ class AddAdminUserView(APIView):
}, },
) )
from jituan.services.admin_assignments import create_or_reactivate_assignment
from jituan.constants import DATA_SCOPE_SINGLE
create_or_reactivate_assignment(
yonghuid=yonghuid,
club_id=target_club_id,
role_code='CLUB_ADMIN',
data_scope=DATA_SCOPE_SINGLE,
is_primary=True,
granted_by=request.user.UserUID,
)
return Response({'code': 0, 'msg': '添加成功'}) return Response({'code': 0, 'msg': '添加成功'})

View File

@@ -1,4 +1,4 @@
import hmac import hmac
import threading import threading
import traceback import traceback
import uuid import uuid
@@ -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 = []
@@ -173,19 +173,27 @@ class HqbkxxView(APIView):
price_map[row.huiyuan_id] = row price_map[row.huiyuan_id] = row
huiyuan_list = [] huiyuan_list = []
for hy in Huiyuan.query.all(): # 在受限 scope 下预过滤,避免 Huiyuan 全表遍历
row = price_map.get(hy.huiyuan_id) # 注:当 scope != DATA_SCOPE_ALL 且 club_id != CLUB_ID_DEFAULT 时,
# 只有 price_map 中存在记录的会员才可能被保留(无记录的会被跳过)
huiyuan_qs = Huiyuan.query.all()
if scope != DATA_SCOPE_ALL and club_id != CLUB_ID_DEFAULT:
huiyuan_qs = huiyuan_qs.filter(huiyuan_id__in=list(price_map.keys()))
# 仅查询所需字段减少内存占用dict 比 model 实例更轻)
huiyuan_qs = huiyuan_qs.values('huiyuan_id', 'jieshao', 'bankuai_id', 'jiage', 'guanshifc', 'zuzhangfc')
for hy in huiyuan_qs:
row = price_map.get(hy['huiyuan_id'])
if scope != DATA_SCOPE_ALL and club_id != CLUB_ID_DEFAULT and row is None: if scope != DATA_SCOPE_ALL and club_id != CLUB_ID_DEFAULT and row is None:
continue continue
if row is not None and not row.is_enabled: if row is not None and not row.is_enabled:
continue continue
item = { item = {
'huiyuan_id': hy.huiyuan_id, 'huiyuan_id': hy['huiyuan_id'],
'jieshao': hy.jieshao, 'jieshao': hy['jieshao'],
'bankuai_id': hy.bankuai_id, 'bankuai_id': hy['bankuai_id'],
'jiage': str(row.jiage) if row else str(hy.jiage), 'jiage': str(row.jiage) if row else str(hy['jiage']),
'guanshifc': str(row.guanshifc) if row else str(hy.guanshifc), 'guanshifc': str(row.guanshifc) if row else str(hy['guanshifc']),
'zuzhangfc': str(row.zuzhangfc) if row else str(hy.zuzhangfc), 'zuzhangfc': str(row.zuzhangfc) if row else str(hy['zuzhangfc']),
} }
huiyuan_list.append(item) huiyuan_list.append(item)
@@ -233,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')
@@ -271,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

@@ -50,6 +50,7 @@ from products.utils import update_shangpin_daily_stat
from orders.notice_tasks import dingdan_guangbo from orders.notice_tasks import dingdan_guangbo
from ..utils import ( from ..utils import (
verify_kefu_permission, verify_kefu_permission,
has_perm_code,
PERMISSION_TO_SHENFEN, PERMISSION_TO_SHENFEN,
SHENFEN_PROFILE_MAP, SHENFEN_PROFILE_MAP,
has_fadan_view_permission, has_fadan_view_permission,
@@ -113,6 +114,7 @@ from ..serializers import PopupPageSerializer
# 全局常量、日志对象 # 全局常量、日志对象
logger = logging.getLogger('houtai') logger = logging.getLogger('houtai')
SHOP_PRODUCT_PERMS = ['1199ab', '1199abc', '1199abd'] SHOP_PRODUCT_PERMS = ['1199ab', '1199abc', '1199abd']
SHOP_MANAGE_PERMS = ('999a', '999b', '999c', '999d', '999e')
class ShopListView(APIView): class ShopListView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@@ -125,8 +127,7 @@ class ShopListView(APIView):
return Response({'code': 403, 'msg': '身份验证失败,请检查登录状态'}) return Response({'code': 403, 'msg': '身份验证失败,请检查登录状态'})
# 2. 检查是否拥有所需权限999a~999e 任意一个) # 2. 检查是否拥有所需权限999a~999e 任意一个)
required_perms = {'999a', '999b', '999c', '999d', '999e'} if not has_perm_code(permissions, *SHOP_MANAGE_PERMS):
if not required_perms.intersection(set(permissions)):
return Response({'code': 403, 'msg': '您没有权限访问店铺管理功能'}) return Response({'code': 403, 'msg': '您没有权限访问店铺管理功能'})
# 3. 解析请求参数 # 3. 解析请求参数
@@ -141,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)
@@ -188,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不存在则创建
@@ -220,21 +224,19 @@ class ShopModifyView(APIView):
if kefu is None: if kefu is None:
return Response({'code': 403, 'msg': '身份验证失败,请检查登录状态'}) return Response({'code': 403, 'msg': '身份验证失败,请检查登录状态'})
# 转换权限为集合方便判断
user_perms = set(permissions)
action = request.data.get('action', '') action = request.data.get('action', '')
# 2. 根据 action 分派任务 # 2. 根据 action 分派任务
if action == 'update_dianpu': if action == 'update_dianpu':
return self._update_dianpu(request, user_perms) return self._update_dianpu(request, permissions)
elif action == 'add_dianpu': elif action == 'add_dianpu':
return self._add_dianpu(request, user_perms) return self._add_dianpu(request, permissions)
elif action == 'update_default': elif action == 'update_default':
return self._update_default(request, user_perms) return self._update_default(request, permissions)
else: else:
return Response({'code': 400, 'msg': '未知操作类型'}) return Response({'code': 400, 'msg': '未知操作类型'})
def _update_dianpu(self, request, user_perms): def _update_dianpu(self, request, permissions):
"""修改店铺信息,精确权限控制""" """修改店铺信息,精确权限控制"""
dianpu_id = request.data.get('dianpu_id') dianpu_id = request.data.get('dianpu_id')
if not dianpu_id: if not dianpu_id:
@@ -246,7 +248,7 @@ class ShopModifyView(APIView):
# 基础信息修改 (999e) # 基础信息修改 (999e)
if any(k in request.data for k in ['dianpu_mingcheng', 'lianxi_dianhua', 'weixinhao']): if any(k in request.data for k in ['dianpu_mingcheng', 'lianxi_dianhua', 'weixinhao']):
if '999e' not in user_perms: if not has_perm_code(permissions, '999e'):
return Response({'code': 403, 'msg': '无权限修改店铺基本信息(999e)'}) return Response({'code': 403, 'msg': '无权限修改店铺基本信息(999e)'})
if 'dianpu_mingcheng' in request.data: if 'dianpu_mingcheng' in request.data:
dianpu.dianpu_mingcheng = request.data['dianpu_mingcheng'] dianpu.dianpu_mingcheng = request.data['dianpu_mingcheng']
@@ -257,7 +259,7 @@ class ShopModifyView(APIView):
# 店铺状态 (999a) # 店铺状态 (999a)
if 'zhuangtai' in request.data: if 'zhuangtai' in request.data:
if '999a' not in user_perms: if not has_perm_code(permissions, '999a'):
return Response({'code': 403, 'msg': '无权限修改店铺状态(999a)'}) return Response({'code': 403, 'msg': '无权限修改店铺状态(999a)'})
new_status = int(request.data['zhuangtai']) new_status = int(request.data['zhuangtai'])
if new_status not in (0, 1): if new_status not in (0, 1):
@@ -266,13 +268,13 @@ class ShopModifyView(APIView):
# 可提现余额 (999b) # 可提现余额 (999b)
if 'ketixian_yue' in request.data: if 'ketixian_yue' in request.data:
if '999b' not in user_perms: if not has_perm_code(permissions, '999b'):
return Response({'code': 403, 'msg': '无权限修改可提现余额(999b)'}) return Response({'code': 403, 'msg': '无权限修改可提现余额(999b)'})
dianpu.ketixian_yue = request.data['ketixian_yue'] dianpu.ketixian_yue = request.data['ketixian_yue']
# 提现限额相关 (999c) # 提现限额相关 (999c)
if 'kaiqi_meiri_xiane' in request.data or 'meiri_tixian_xiane' in request.data: if 'kaiqi_meiri_xiane' in request.data or 'meiri_tixian_xiane' in request.data:
if '999c' not in user_perms: if not has_perm_code(permissions, '999c'):
return Response({'code': 403, 'msg': '无权限修改提现限额配置(999c)'}) return Response({'code': 403, 'msg': '无权限修改提现限额配置(999c)'})
if 'kaiqi_meiri_xiane' in request.data: if 'kaiqi_meiri_xiane' in request.data:
dianpu.kaiqi_meiri_xiane = request.data['kaiqi_meiri_xiane'] dianpu.kaiqi_meiri_xiane = request.data['kaiqi_meiri_xiane']
@@ -281,7 +283,7 @@ class ShopModifyView(APIView):
# 抽成相关 (999d) # 抽成相关 (999d)
if 'kaiqi_shouyi_choucheng' in request.data or 'shouyi_choucheng_feilv' in request.data: if 'kaiqi_shouyi_choucheng' in request.data or 'shouyi_choucheng_feilv' in request.data:
if '999d' not in user_perms: if not has_perm_code(permissions, '999d'):
return Response({'code': 403, 'msg': '无权限修改抽成配置(999d)'}) return Response({'code': 403, 'msg': '无权限修改抽成配置(999d)'})
if 'kaiqi_shouyi_choucheng' in request.data: if 'kaiqi_shouyi_choucheng' in request.data:
dianpu.kaiqi_shouyi_choucheng = request.data['kaiqi_shouyi_choucheng'] dianpu.kaiqi_shouyi_choucheng = request.data['kaiqi_shouyi_choucheng']
@@ -297,9 +299,9 @@ class ShopModifyView(APIView):
logger.exception("修改店铺失败") logger.exception("修改店铺失败")
return Response({'code': 500, 'msg': '修改失败,请稍后重试'}) return Response({'code': 500, 'msg': '修改失败,请稍后重试'})
def _add_dianpu(self, request, user_perms): def _add_dianpu(self, request, permissions):
"""添加店铺,需要权限 999b""" """添加店铺,需要权限 999b"""
if '999b' not in user_perms: if not has_perm_code(permissions, '999b'):
return Response({'code': 403, 'msg': '无权限添加店铺(999b)'}) return Response({'code': 403, 'msg': '无权限添加店铺(999b)'})
yonghu_id = request.data.get('yonghu_id') yonghu_id = request.data.get('yonghu_id')
@@ -328,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,
@@ -337,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:
@@ -346,16 +350,16 @@ class ShopModifyView(APIView):
logger.exception("创建店铺失败") logger.exception("创建店铺失败")
return Response({'code': 500, 'msg': '创建失败,请稍后重试'}) return Response({'code': 500, 'msg': '创建失败,请稍后重试'})
def _update_default(self, request, user_perms): def _update_default(self, request, permissions):
"""修改默认配置,按修改内容校验权限""" """修改默认配置,按修改内容校验权限"""
feilv = request.data.get('moren_choucheng_feilv') feilv = request.data.get('moren_choucheng_feilv')
xiane = request.data.get('moren_tixian_xiane') xiane = request.data.get('moren_tixian_xiane')
modify_commission = feilv is not None modify_commission = feilv is not None
modify_withdraw = xiane is not None modify_withdraw = xiane is not None
if modify_commission and '999d' not in user_perms: if modify_commission and not has_perm_code(permissions, '999d'):
return Response({'code': 403, 'msg': '无权限修改默认抽成费率(999d)'}) return Response({'code': 403, 'msg': '无权限修改默认抽成费率(999d)'})
if modify_withdraw and '999c' not in user_perms: if modify_withdraw and not has_perm_code(permissions, '999c'):
return Response({'code': 403, 'msg': '无权限修改默认可提现限额(999c)'}) return Response({'code': 403, 'msg': '无权限修改默认可提现限额(999c)'})
try: try:
@@ -394,7 +398,7 @@ class ShopPublicTypeAndAuditView(APIView):
return Response({'code': 403, 'msg': '身份验证失败,请检查登录状态'}) return Response({'code': 403, 'msg': '身份验证失败,请检查登录状态'})
# 拥有 1199ab/1199abc/1199abd 任一个即可访问 # 拥有 1199ab/1199abc/1199abd 任一个即可访问
if not set(permissions).intersection(SHOP_PRODUCT_PERMS): if not has_perm_code(permissions, *SHOP_PRODUCT_PERMS):
return Response({'code': 403, 'msg': '无权访问店铺商品管理功能'}) return Response({'code': 403, 'msg': '无权访问店铺商品管理功能'})
# ---------- 查询公共商品类型(仅正常审核状态的) ---------- # ---------- 查询公共商品类型(仅正常审核状态的) ----------
@@ -417,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,
} }
}) })
@@ -444,7 +471,7 @@ class ShopListForProductView(APIView):
kefu, permissions = verify_kefu_permission(request, username) kefu, permissions = verify_kefu_permission(request, username)
if kefu is None: if kefu is None:
return Response({'code': 403, 'msg': '身份验证失败'}) return Response({'code': 403, 'msg': '身份验证失败'})
if not set(permissions).intersection(SHOP_PRODUCT_PERMS): if not has_perm_code(permissions, *SHOP_PRODUCT_PERMS):
return Response({'code': 403, 'msg': '无权访问店铺列表'}) return Response({'code': 403, 'msg': '无权访问店铺列表'})
# ---------- 安全解析分页参数 ---------- # ---------- 安全解析分页参数 ----------
@@ -459,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:
@@ -520,7 +549,7 @@ class ShopProductTypeMappingView(APIView):
kefu, permissions = verify_kefu_permission(request, username) kefu, permissions = verify_kefu_permission(request, username)
if kefu is None: if kefu is None:
return Response({'code': 403, 'msg': '身份验证失败'}) return Response({'code': 403, 'msg': '身份验证失败'})
if not set(permissions).intersection(SHOP_PRODUCT_PERMS): if not has_perm_code(permissions, *SHOP_PRODUCT_PERMS):
return Response({'code': 403, 'msg': '无权访问商品类型映射'}) return Response({'code': 403, 'msg': '无权访问商品类型映射'})
# ---------- 安全解析参数 ---------- # ---------- 安全解析参数 ----------
@@ -611,11 +640,13 @@ class ShopProductModifyView(APIView):
if kefu is None: if kefu is None:
return Response({'code': 403, 'msg': '身份验证失败'}) return Response({'code': 403, 'msg': '身份验证失败'})
user_perms = set(permissions) user_perms = permissions
action = request.data.get('action') action = request.data.get('action')
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:
@@ -623,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 '1199ab' not in user_perms: 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')
@@ -657,25 +727,25 @@ class ShopProductModifyView(APIView):
# 权限判断 # 权限判断
if fengjin is not None: if fengjin is not None:
if fengjin: if fengjin:
if '1199abc' not in user_perms: if not has_perm_code(user_perms, '1199abc'):
return Response({'code': 403, 'msg': '无权限封禁'}) return Response({'code': 403, 'msg': '无权限封禁'})
else: else:
if '1199abd' not in user_perms: if not has_perm_code(user_perms, '1199abd'):
return Response({'code': 403, 'msg': '无权限解封'}) return Response({'code': 403, 'msg': '无权限解封'})
mapping.fengjin_zhuangtai = bool(fengjin) mapping.fengjin_zhuangtai = bool(fengjin)
if shangjia is not None: if shangjia is not None:
if not shangjia: if not shangjia:
if '1199abc' not in user_perms: if not has_perm_code(user_perms, '1199abc'):
return Response({'code': 403, 'msg': '无权限下架'}) return Response({'code': 403, 'msg': '无权限下架'})
else: else:
if '1199abd' not in user_perms: if not has_perm_code(user_perms, '1199abd'):
return Response({'code': 403, 'msg': '无权限上架'}) return Response({'code': 403, 'msg': '无权限上架'})
mapping.shangjia_zhuangtai = bool(shangjia) mapping.shangjia_zhuangtai = bool(shangjia)
if shenhe is not None: if shenhe is not None:
if shenhe: if shenhe:
if '1199abd' not in user_perms: if not has_perm_code(user_perms, '1199abd'):
return Response({'code': 403, 'msg': '无权限审核通过'}) return Response({'code': 403, 'msg': '无权限审核通过'})
mapping.shenhe_zhuangtai = bool(shenhe) mapping.shenhe_zhuangtai = bool(shenhe)
@@ -706,7 +776,7 @@ class ProductListView(APIView):
if kefu is None: if kefu is None:
return Response({'code': 403, 'msg': '身份验证失败'}) return Response({'code': 403, 'msg': '身份验证失败'})
if not set(permissions).intersection(PRODUCT_PERMS): if not has_perm_code(permissions, *PRODUCT_PERMS):
return Response({'code': 403, 'msg': '无商品管理权限'}) return Response({'code': 403, 'msg': '无商品管理权限'})
# ----- 安全解析分页与筛选参数 ----- # ----- 安全解析分页与筛选参数 -----
@@ -844,16 +914,15 @@ class ProductModifyView(APIView):
if kefu is None: if kefu is None:
return Response({'code': 403, 'msg': '身份验证失败'}) return Response({'code': 403, 'msg': '身份验证失败'})
user_perms = set(permissions)
action = request.data.get('action', '') action = request.data.get('action', '')
if action != 'update_product': if action != 'update_product':
return Response({'code': 400, 'msg': '未知操作'}) return Response({'code': 400, 'msg': '未知操作'})
return self._update_product(request, user_perms) return self._update_product(request, permissions)
@transaction.atomic @transaction.atomic
def _update_product(self, request, user_perms): def _update_product(self, request, permissions):
"""执行商品修改,严格校验权限""" """执行商品修改,严格校验权限"""
product_id = request.data.get('product_id') product_id = request.data.get('product_id')
if not product_id: if not product_id:
@@ -877,18 +946,18 @@ class ProductModifyView(APIView):
# ---------- 权限分类检查 ---------- # ---------- 权限分类检查 ----------
# 1. 封禁/下架操作 (消极操作) -> 1199abc # 1. 封禁/下架操作 (消极操作) -> 1199abc
if any(status_changes.get(k) is not None and status_changes[k] == False for k in ['shangjia', 'shenhe']): if any(status_changes.get(k) is not None and status_changes[k] == False for k in ['shangjia', 'shenhe']):
if '1199abc' not in user_perms: if not has_perm_code(permissions, '1199abc'):
return Response({'code': 403, 'msg': '无权限执行下架/封禁/驳回审核操作(1199abc)'}) return Response({'code': 403, 'msg': '无权限执行下架/封禁/驳回审核操作(1199abc)'})
if status_changes.get('fengjin') is not None and status_changes['fengjin'] == True: if status_changes.get('fengjin') is not None and status_changes['fengjin'] == True:
if '1199abc' not in user_perms: if not has_perm_code(permissions, '1199abc'):
return Response({'code': 403, 'msg': '无权限执行封禁操作(1199abc)'}) return Response({'code': 403, 'msg': '无权限执行封禁操作(1199abc)'})
# 2. 解封/上架/过审 (积极操作) -> 1199abd # 2. 解封/上架/过审 (积极操作) -> 1199abd
if any(status_changes.get(k) is not None and status_changes[k] == True for k in ['shangjia', 'shenhe']): if any(status_changes.get(k) is not None and status_changes[k] == True for k in ['shangjia', 'shenhe']):
if '1199abd' not in user_perms: if not has_perm_code(permissions, '1199abd'):
return Response({'code': 403, 'msg': '无权限执行上架/解封/审核通过操作(1199abd)'}) return Response({'code': 403, 'msg': '无权限执行上架/解封/审核通过操作(1199abd)'})
if status_changes.get('fengjin') is not None and status_changes['fengjin'] == False: if status_changes.get('fengjin') is not None and status_changes['fengjin'] == False:
if '1199abd' not in user_perms: if not has_perm_code(permissions, '1199abd'):
return Response({'code': 403, 'msg': '无权限执行解封操作(1199abd)'}) return Response({'code': 403, 'msg': '无权限执行解封操作(1199abd)'})
# 3. 修改其他数据(非状态字段)-> 1199abe # 3. 修改其他数据(非状态字段)-> 1199abe
@@ -896,7 +965,7 @@ class ProductModifyView(APIView):
other_fields = [k for k in request.data.keys() if k not in ['action', 'product_id', 'username', other_fields = [k for k in request.data.keys() if k not in ['action', 'product_id', 'username',
'shangjia_zhuangtai', 'fengjin_zhuangtai', 'shangjia_zhuangtai', 'fengjin_zhuangtai',
'shenhe_zhuangtai']] 'shenhe_zhuangtai']]
if other_fields and '1199abe' not in user_perms: if other_fields and not has_perm_code(permissions, '1199abe'):
return Response({'code': 403, 'msg': '无权限修改商品基本信息(1199abe)'}) return Response({'code': 403, 'msg': '无权限修改商品基本信息(1199abe)'})
# ---------- 应用修改 ---------- # ---------- 应用修改 ----------
@@ -931,3 +1000,45 @@ class ProductModifyView(APIView):
product.save(update_fields=update_fields) product.save(update_fields=update_fields)
return Response({'code': 0, 'msg': '商品修改成功'}) return Response({'code': 0, 'msg': '商品修改成功'})
class ShopCopyCatalogView(APIView):
"""
复制店铺商品目录(类型 + 专区 + 商品,含 OSS 图片独立拷贝)。
请求路径: /houtai/htfzdpspsj
权限: 店铺管理权限 999a~999e 任一
"""
permission_classes = [IsAuthenticated]
def post(self, request):
username = request.data.get('username')
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return Response({'code': 403, 'msg': '身份验证失败,请检查登录状态'})
if not has_perm_code(permissions, *SHOP_MANAGE_PERMS):
return Response({'code': 403, 'msg': '您没有权限执行店铺商品复制操作'})
source_user_uid = str(request.data.get('source_user_uid', '')).strip()
target_user_uid = str(request.data.get('target_user_uid', '')).strip()
copy_mode = str(request.data.get('copy_mode', 'append')).strip().lower()
if not source_user_uid or not target_user_uid:
return Response({'code': 400, 'msg': '请填写源店铺与目标店铺的用户ID'})
from backend.services.shop_copy_service import copy_shop_catalog, ShopCopyError
try:
result = copy_shop_catalog(source_user_uid, target_user_uid, copy_mode)
return Response({
'code': 0,
'msg': (
f'复制成功:商品类型 {result["copied_types"]} 个,'
f'专区 {result["copied_zones"]} 个,商品 {result["copied_products"]}'
),
'data': result,
})
except ShopCopyError as e:
return Response({'code': e.code, 'msg': e.message})
except Exception:
logger.exception('复制店铺商品失败')
return Response({'code': 500, 'msg': '复制失败,请稍后重试'})

View File

@@ -1,4 +1,4 @@
import hmac import hmac
import threading import threading
import traceback import traceback
import uuid import uuid
@@ -236,60 +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
types = ShangpinLeixing.query.all().order_by('-paixu') # paixu 越大越靠前 from jituan.services.club_context import resolve_club_scope, resolve_club_id_from_request
type_list = [] from jituan.services.club_shangpin_leixing import build_club_leixing_admin_list
for t in types:
# 统计该类型下的专区数量 club_scoped = resolve_club_scope(request) != DATA_SCOPE_ALL
zone_count = ShangpinZhuanqu.query.filter(leixing_id=t.id).count() club_id = resolve_club_id_from_request(request)
type_list.append({
'id': t.id, try:
'jieshao': t.jieshao or '', zone_qs_for_count = filter_club_char_field(
'tupian_url': t.tupian_url or '', ShangpinZhuanqu.query.all(), request, field='club_id'
'yaoqiuleixing': t.yaoqiuleixing, )
'huiyuan_id': t.huiyuan_id or '', zone_count_map = {
'yongjin': float(t.yongjin) if t.yongjin is not None else None, row['leixing_id']: row['cnt']
'shenhezhuangtai': t.shenhezhuangtai, for row in zone_qs_for_count.values('leixing_id').annotate(cnt=Count('id'))
'paixu': t.paixu, }
'product_count': zone_count,
if club_scoped:
# 具体俱乐部:仅本俱乐部已上架类型(与抢单端一致)
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,
}
})
@@ -316,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)
@@ -531,10 +557,11 @@ class ModifyProductTypeZoneView(APIView):
# 删除该类型下的所有专区 # 删除该类型下的所有专区
zones = ShangpinZhuanqu.query.filter(leixing_id=type_id) zones = ShangpinZhuanqu.query.filter(leixing_id=type_id)
if delete_products: if delete_products:
# 删除专区下的所有商品 # 收集 zone_ids 后批量删除,避免循环内 N+1 delete
for zone in zones: zone_ids = list(zones.values_list('id', flat=True))
Shangpin.query.filter(zhuanqu_id=zone.id).delete() if zone_ids:
zones.delete() Shangpin.query.filter(zhuanqu_id__in=zone_ids).delete()
ShangpinZhuanqu.query.filter(id__in=zone_ids).delete()
# 删除直接关联该类型的商品 # 删除直接关联该类型的商品
Shangpin.query.filter(leixing_id=type_id).delete() Shangpin.query.filter(leixing_id=type_id).delete()
else: else:
@@ -560,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')
@@ -582,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):
@@ -604,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:
@@ -631,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': '未做任何修改'})
@@ -639,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):
@@ -655,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()
@@ -662,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': '删除成功'})
@@ -928,7 +1038,8 @@ class PopupNoticeModifyAPIView(APIView):
if cascade: if cascade:
# 级联删除:使用事务保证原子性 # 级联删除:使用事务保证原子性
with transaction.atomic(): with transaction.atomic():
popups = page.popups.all() # 预加载 images 关联,避免循环内 N+1 访问 related manager
popups = page.popups.prefetch_related('images')
for popup in popups: for popup in popups:
# 删除弹窗下的所有图片文件 # 删除弹窗下的所有图片文件
for img in popup.images.all(): for img in popup.images.all():

View File

@@ -1,4 +1,4 @@
import hmac import hmac
import threading import threading
import traceback import traceback
import uuid import uuid
@@ -19,7 +19,7 @@ from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db import models, transaction, IntegrityError from django.db import models, transaction, IntegrityError
from django.db.models import Q, Count, Sum, F, Exists, OuterRef from django.db.models import Q, Count, Sum, F, Exists, OuterRef, Prefetch
from gvsdsdk.fluent import db, func, FQ from gvsdsdk.fluent import db, func, FQ
from django.db.models.functions import TruncDate, TruncMonth, TruncYear from django.db.models.functions import TruncDate, TruncMonth, TruncYear
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
@@ -132,13 +132,16 @@ class KhpzhqView(APIView):
bankuai_list = list(Bankuai.query.values('bankuai_id', 'mingcheng')) bankuai_list = list(Bankuai.query.values('bankuai_id', 'mingcheng'))
chenghao_qs = Chenghao.query.prefetch_related('kaohecishufeiyong_set').all() chenghao_qs = Chenghao.query.prefetch_related(
Prefetch('kaohecishufeiyong_set', queryset=KaoheCishuFeiyong.objects.order_by('cishu'))
).all()
chenghao_list = [] chenghao_list = []
for ch in chenghao_qs: for ch in chenghao_qs:
# 获取费用:按次数排序,转为 {cishu: feiyong} 字典 # 获取费用:按次数排序,转为 {cishu: feiyong} 字典
# prefetch_related 已用 Prefetch 排序,直接遍历不会绕过缓存
feiyong_dict = { feiyong_dict = {
item.cishu: float(item.feiyong) item.cishu: float(item.feiyong)
for item in ch.kaohecishufeiyong_set.order_by('cishu') for item in ch.kaohecishufeiyong_set.all()
} }
max_cishu = max(feiyong_dict.keys()) if feiyong_dict else 0 max_cishu = max(feiyong_dict.keys()) if feiyong_dict else 0
@@ -172,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):
""" """
称号(标签)增删改查统一接口 称号(标签)增删改查统一接口
@@ -194,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)
@@ -235,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:
@@ -243,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)
@@ -262,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则全量替换费用
@@ -281,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:
@@ -292,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)
@@ -305,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)
@@ -322,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

@@ -1,4 +1,4 @@
import hmac import hmac
import threading import threading
import traceback import traceback
import uuid import uuid
@@ -351,9 +351,16 @@ class GetZuzhangDetailView(APIView):
custom_first = {} # 首次分红定制cishu=1 custom_first = {} # 首次分红定制cishu=1
extra_map = {} # 其他次数 { cishu: { huiyuan_id: { zuzhang_fenhong, jieshao, jiage } } } extra_map = {} # 其他次数 { cishu: { huiyuan_id: { zuzhang_fenhong, jieshao, jiage } } }
# 批量预取会员信息
_huiyuan_ids = [r.huiyuan for r in duoci_records]
_huiyuan_map = {
h.huiyuan_id: h
for h in Huiyuan.query.filter(huiyuan_id__in=_huiyuan_ids).only('huiyuan_id', 'jieshao', 'jiage')
}
for record in duoci_records: for record in duoci_records:
# 获取会员信息 # 获取会员信息
member = Huiyuan.query.filter(huiyuan_id=record.huiyuan).first() member = _huiyuan_map.get(record.huiyuan)
if not member: if not member:
continue continue
member_info = { member_info = {
@@ -498,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}'
@@ -512,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,
@@ -520,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}'
), ),
) )
@@ -580,9 +591,16 @@ class UpdateZuzhangView(APIView):
if custom_first is not None and isinstance(custom_first, dict): if custom_first is not None and isinstance(custom_first, dict):
from jituan.services.club_penalty import resolve_gsfenhong_club_id from jituan.services.club_penalty import resolve_gsfenhong_club_id
zuzhang_club = resolve_gsfenhong_club_id(dashouid=yonghuid) zuzhang_club = resolve_gsfenhong_club_id(dashouid=yonghuid)
existing_first = DuociFenhong.query.filter( # 批量预取当前所有首次定制记录(避免循环内 N+1
club_id=zuzhang_club, yonghuid=yonghuid, cishu=1, existing_first_list = list(
DuociFenhong.query.filter(
club_id=zuzhang_club, yonghuid=yonghuid, cishu=1,
)
) )
_existing_map = {r.huiyuan: r for r in existing_first_list}
_to_create = []
_to_update = []
_to_delete_ids = []
for huiyuan_id, amount in custom_first.items(): for huiyuan_id, amount in custom_first.items():
try: try:
amount = Decimal(str(amount)) amount = Decimal(str(amount))
@@ -590,30 +608,41 @@ class UpdateZuzhangView(APIView):
continue continue
if amount <= 0: if amount <= 0:
# 删除定制(恢复默认) # 删除定制(恢复默认)
DuociFenhong.query.filter( rec = _existing_map.get(huiyuan_id)
club_id=zuzhang_club, yonghuid=yonghuid, huiyuan=huiyuan_id, cishu=1, if rec is not None:
).delete() _to_delete_ids.append(rec.id)
else: else:
DuociFenhong.query.update_or_create( rec = _existing_map.get(huiyuan_id)
club_id=zuzhang_club, if rec is not None:
yonghuid=yonghuid, rec.zuzhang_fenhong = amount
huiyuan=huiyuan_id, rec.guanshi_fenhong = Decimal('0') # 组长表只关心组长分红
cishu=1, _to_update.append(rec)
defaults={ else:
'zuzhang_fenhong': amount, _to_create.append(DuociFenhong(
'guanshi_fenhong': Decimal('0') # 组长表只关心组长分红 club_id=zuzhang_club,
} yonghuid=yonghuid,
) huiyuan=huiyuan_id,
cishu=1,
zuzhang_fenhong=amount,
guanshi_fenhong=Decimal('0'),
))
# 删除那些在前端字典中不存在的首次定制记录(即已取消定制) # 删除那些在前端字典中不存在的首次定制记录(即已取消定制)
for record in existing_first: for rec in existing_first_list:
if record.huiyuan not in custom_first: if rec.huiyuan not in custom_first and rec.id not in _to_delete_ids:
record.delete() _to_delete_ids.append(rec.id)
if _to_create:
DuociFenhong.objects.bulk_create(_to_create)
if _to_update:
DuociFenhong.objects.bulk_update(_to_update, ['zuzhang_fenhong', 'guanshi_fenhong'])
if _to_delete_ids:
DuociFenhong.objects.filter(id__in=_to_delete_ids).delete()
# 4.3 额外次数分红cishu >= 2 # 4.3 额外次数分红cishu >= 2
if extra_map is not None and isinstance(extra_map, dict): if extra_map is not None and isinstance(extra_map, dict):
from jituan.services.club_penalty import resolve_gsfenhong_club_id from jituan.services.club_penalty import resolve_gsfenhong_club_id
zuzhang_club = resolve_gsfenhong_club_id(dashouid=yonghuid) zuzhang_club = resolve_gsfenhong_club_id(dashouid=yonghuid)
keep_set = set() keep_set = set()
_extra_targets = [] # [(cishu, huiyuan_id, amount)]
for times_str, members in extra_map.items(): for times_str, members in extra_map.items():
try: try:
cishu = int(times_str) cishu = int(times_str)
@@ -634,23 +663,42 @@ class UpdateZuzhangView(APIView):
except: except:
continue continue
keep_set.add((cishu, huiyuan_id)) keep_set.add((cishu, huiyuan_id))
DuociFenhong.query.update_or_create( _extra_targets.append((cishu, huiyuan_id, amount))
# 批量预取已有额外次数记录
existing_extra_list = list(
DuociFenhong.query.filter(
club_id=zuzhang_club, yonghuid=yonghuid, cishu__gte=2,
)
)
_extra_map = {(r.cishu, r.huiyuan): r for r in existing_extra_list}
_to_create = []
_to_update = []
_to_delete_ids = []
for cishu, huiyuan_id, amount in _extra_targets:
rec = _extra_map.get((cishu, huiyuan_id))
if rec is not None:
rec.zuzhang_fenhong = amount
rec.guanshi_fenhong = Decimal('0')
_to_update.append(rec)
else:
_to_create.append(DuociFenhong(
club_id=zuzhang_club, club_id=zuzhang_club,
yonghuid=yonghuid, yonghuid=yonghuid,
huiyuan=huiyuan_id, huiyuan=huiyuan_id,
cishu=cishu, cishu=cishu,
defaults={ zuzhang_fenhong=amount,
'zuzhang_fenhong': amount, guanshi_fenhong=Decimal('0'),
'guanshi_fenhong': Decimal('0') ))
}
)
# 删除不在 keep_set 中的额外次数记录 # 删除不在 keep_set 中的额外次数记录
existing_extra = DuociFenhong.query.filter( for rec in existing_extra_list:
club_id=zuzhang_club, yonghuid=yonghuid, cishu__gte=2, if (rec.cishu, rec.huiyuan) not in keep_set:
) _to_delete_ids.append(rec.id)
for record in existing_extra: if _to_create:
if (record.cishu, record.huiyuan) not in keep_set: DuociFenhong.objects.bulk_create(_to_create)
record.delete() if _to_update:
DuociFenhong.objects.bulk_update(_to_update, ['zuzhang_fenhong', 'guanshi_fenhong'])
if _to_delete_ids:
DuociFenhong.objects.filter(id__in=_to_delete_ids).delete()
dividend_parts = [] dividend_parts = []
if perm_enabled is not None: if perm_enabled is not None:

View File

@@ -175,7 +175,7 @@ class ZxkfghdsView(APIView):
shangjia_ext = old_order.shangjia_kuozhan shangjia_ext = old_order.shangjia_kuozhan
shangjia_id = shangjia_ext.MerchantID shangjia_id = shangjia_ext.MerchantID
# 获取商家昵称用于新订单扩展表 # 获取商家昵称用于新订单扩展表
shangjia_user = User.query.get(UserUID=shangjia_id) shangjia_user = User.query.select_related('ShopProfile').get(UserUID=shangjia_id)
shangjia_nicheng = shangjia_user.ShopProfile.nicheng or f"商家{shangjia_id}" shangjia_nicheng = shangjia_user.ShopProfile.nicheng or f"商家{shangjia_id}"
except (ObjectDoesNotExist, UserShangjia.DoesNotExist): except (ObjectDoesNotExist, UserShangjia.DoesNotExist):
logger.error(f"商家信息缺失,订单: {OrderID}") logger.error(f"商家信息缺失,订单: {OrderID}")

View File

@@ -1,27 +0,0 @@
"""检查生产库中 UserUID 为空的用户"""
import pymysql
conn = pymysql.connect(
host='gvsds.com', port=50030,
user='root', password='sajksh.sdfGH3YUge.wjkd+',
database='xaio_cheng_xu', charset='utf8mb4'
)
cur = conn.cursor()
# UserUID 为 NULL 的用户数
cur.execute("SELECT COUNT(*) FROM user WHERE UserUID IS NULL OR UserUID = ''")
print(f'UserUID为空的用户数: {cur.fetchone()[0]}')
# 总用户数
cur.execute("SELECT COUNT(*) FROM user")
print(f'总用户数: {cur.fetchone()[0]}')
# 抽样 UserUID 为 NULL 的用户
cur.execute("SELECT UserUUID, UserName, OpenID, Phone FROM user WHERE UserUID IS NULL OR UserUID = '' LIMIT 5")
rows = cur.fetchall()
if rows:
print('\nUserUID为空的用户示例:')
for r in rows:
print(f' UserName={r[1]}, OpenID={r[2]}, Phone={r[3]}')
conn.close()

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

@@ -1,4 +1,4 @@
import io import io
import os import os
import re import re
import time import time
@@ -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. 返回响应
@@ -394,40 +402,46 @@ class AdminUpdateConfigView(APIView):
# 🔴 修复更新QQ群配置 - 确保根据peizhiid正确更新对应的记录 # 🔴 修复更新QQ群配置 - 确保根据peizhiid正确更新对应的记录
qq_groups = request.data.get('qq_groups') qq_groups = request.data.get('qq_groups')
if qq_groups and isinstance(qq_groups, list): if qq_groups and isinstance(qq_groups, list):
# 收集所有 peizhiid批量预取已有记录避免循环内 N+1
_peizhiids = [g.get('peizhiid') for g in qq_groups if g.get('peizhiid')]
_existing_map = {}
if _peizhiids:
for q in Qunpeizhi.query.filter(GroupType__in=_peizhiids):
# 如果存在多个相同 peizhiid 的记录,取第一个(应该只有一个)
if q.GroupType not in _existing_map:
_existing_map[q.GroupType] = q
_to_create = []
_to_update = []
updated_groups = [] updated_groups = []
for group_data in qq_groups: for group_data in qq_groups:
peizhiid = group_data.get('peizhiid') peizhiid = group_data.get('peizhiid')
if not peizhiid: if not peizhiid:
continue continue
# 🔴 修复使用filter()确保查询到正确的记录 neirong = group_data.get('neirong')
qun_configs = Qunpeizhi.query.filter(GroupType=peizhiid) qunid = group_data.get('qunid')
jieshao = group_data.get('jieshao')
if qun_configs.exists():
# 如果存在多个相同peizhiid的记录取第一个应该只有一个
qun_config = qun_configs.first()
# 更新字段
neirong = group_data.get('neirong')
qunid = group_data.get('qunid')
jieshao = group_data.get('jieshao')
qun_config = _existing_map.get(peizhiid)
if qun_config is not None:
# 更新已有记录
if neirong is not None: if neirong is not None:
qun_config.GroupContent = neirong or '' qun_config.GroupContent = neirong or ''
if qunid is not None: if qunid is not None:
qun_config.GroupID = qunid or '' qun_config.GroupID = qunid or ''
if jieshao is not None: if jieshao is not None:
qun_config.Description = jieshao or '' qun_config.Description = jieshao or ''
_to_update.append(qun_config)
qun_config.save()
else: else:
# 如果不存在创建新的使用提供的peizhiid # 不存在创建新的
qun_config = Qunpeizhi.query.create( qun_config = Qunpeizhi(
GroupType=peizhiid, GroupType=peizhiid,
GroupContent=group_data.get('neirong', '') or '', GroupContent=neirong or '',
GroupID=group_data.get('qunid', '') or '', GroupID=qunid or '',
Description=group_data.get('jieshao', '') or '' Description=jieshao or '',
) )
_to_create.append(qun_config)
updated_groups.append({ updated_groups.append({
'peizhiid': qun_config.GroupType, 'peizhiid': qun_config.GroupType,
@@ -436,6 +450,13 @@ class AdminUpdateConfigView(APIView):
'jieshao': qun_config.Description 'jieshao': qun_config.Description
}) })
if _to_create:
Qunpeizhi.objects.bulk_create(_to_create)
if _to_update:
Qunpeizhi.objects.bulk_update(
_to_update, ['GroupContent', 'GroupID', 'Description']
)
if updated_groups: if updated_groups:
response_data['qq_groups'] = updated_groups response_data['qq_groups'] = updated_groups

View File

@@ -1,4 +1,4 @@
import io import io
import os import os
import re import re
import time import time
@@ -270,6 +270,13 @@ class DashouPaihangView(APIView):
# 第一名数量不足,不展示 # 第一名数量不足,不展示
return [], False return [], False
# 批量预取老板扩展表昵称
_boss_user_ids = [g.user_id for g in guanshi_list]
_boss_nicheng_map = {
b.user_id: b.nickname
for b in UserBoss.query.filter(user_id__in=_boss_user_ids).only('user_id', 'nickname')
}
# 构建返回数据 # 构建返回数据
paihang_list = [] paihang_list = []
for index, guanshi in enumerate(guanshi_list): for index, guanshi in enumerate(guanshi_list):
@@ -277,8 +284,7 @@ class DashouPaihangView(APIView):
user_main = guanshi.user user_main = guanshi.user
# 管事的昵称需要查询老板扩展表 # 管事的昵称需要查询老板扩展表
boss_profile = UserBoss.query.filter(user=user_main).first() guanshi_nicheng = _boss_nicheng_map.get(guanshi.user_id, '未设置昵称')
guanshi_nicheng = boss_profile.nickname if boss_profile else '未设置昵称'
# 获取头像相对URL # 获取头像相对URL
touxiang_url = user_main.Avatar or '' touxiang_url = user_main.Avatar or ''
@@ -337,6 +343,13 @@ class DashouPaihangView(APIView):
# 第一名流水不足,不展示 # 第一名流水不足,不展示
return [], False return [], False
# 批量预取老板扩展表昵称
_boss_user_ids = [s.user_id for s in shangjia_list]
_boss_nicheng_map = {
b.user_id: b.nickname
for b in UserBoss.query.filter(user_id__in=_boss_user_ids).only('user_id', 'nickname')
}
# 构建返回数据 # 构建返回数据
paihang_list = [] paihang_list = []
for index, shangjia in enumerate(shangjia_list): for index, shangjia in enumerate(shangjia_list):
@@ -344,8 +357,7 @@ class DashouPaihangView(APIView):
user_main = shangjia.user user_main = shangjia.user
# 商家的昵称需要查询老板扩展表 # 商家的昵称需要查询老板扩展表
boss_profile = UserBoss.query.filter(user=user_main).first() shangjia_nicheng = _boss_nicheng_map.get(shangjia.user_id, '未设置昵称')
shangjia_nicheng = boss_profile.nickname if boss_profile else '未设置昵称'
# 获取头像相对URL # 获取头像相对URL
touxiang_url = user_main.Avatar or '' touxiang_url = user_main.Avatar or ''
@@ -604,21 +616,21 @@ class GetXiugaiJiluView(APIView):
records = cursor.fetchall() records = cursor.fetchall()
# 🔥 6. 处理查询结果(严格按照字段类型处理) # 🔥 6. 处理查询结果(严格按照字段类型处理)
# 预查询修改者账号信息(循环外执行一次,避免循环内重复查询同一 SQL
xiugaizhe_zhanghao = ''
with connection.cursor() as cursor2:
cursor2.execute(
"SELECT phone, yonghuid FROM user_main WHERE phone = %s OR yonghuid = %s LIMIT 1",
[xiugaizhe_zhanghaoid, xiugaizhe_zhanghaoid]
)
xiugaizhe_info = cursor2.fetchone()
if xiugaizhe_info:
xiugaizhe_zhanghao = str(xiugaizhe_info[0]) if xiugaizhe_info[0] else str(xiugaizhe_info[1]) if \
xiugaizhe_info[1] else xiugaizhe_zhanghaoid
jilu_list = [] jilu_list = []
for record in records: for record in records:
# 获取修改者账号信息(都是字符串) # 修改者账号信息已预查询,直接使用
xiugaizhe_zhanghao = ''
with connection.cursor() as cursor2:
# 🔥 phone和yonghuid都是CharField用字符串查询
cursor2.execute(
"SELECT phone, yonghuid FROM user_main WHERE phone = %s OR yonghuid = %s LIMIT 1",
[xiugaizhe_zhanghaoid, xiugaizhe_zhanghaoid]
)
xiugaizhe_info = cursor2.fetchone()
if xiugaizhe_info:
# 🔥 都是字符串
xiugaizhe_zhanghao = str(xiugaizhe_info[0]) if xiugaizhe_info[0] else str(xiugaizhe_info[1]) if \
xiugaizhe_info[1] else xiugaizhe_zhanghaoid
# 获取会员名称(如果有) # 获取会员名称(如果有)
huiyuan_mingzi = '' huiyuan_mingzi = ''

View File

@@ -1,4 +1,4 @@
import io import io
import os import os
import re import re
import time import time
@@ -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,
) )
# 商家扩展表 # 商家扩展表
@@ -508,7 +509,7 @@ class KehuGetDingdanLianjieView(APIView):
# 获取商家昵称 # 获取商家昵称
shangjia_nicheng = '' shangjia_nicheng = ''
try: try:
user_main = User.query.get(UserUID=lianjie_obj.UserID) user_main = User.query.select_related('ShopProfile').get(UserUID=lianjie_obj.UserID)
shangjia_obj = user_main.ShopProfile shangjia_obj = user_main.ShopProfile
shangjia_nicheng = shangjia_obj.nicheng shangjia_nicheng = shangjia_obj.nicheng
except (User.DoesNotExist, UserShangjia.DoesNotExist): except (User.DoesNotExist, UserShangjia.DoesNotExist):
@@ -816,7 +817,7 @@ class KehuTianxieDingdanView(APIView):
# 验证打手是否存在且有效 # 验证打手是否存在且有效
try: try:
# 先查询用户主表 # 先查询用户主表
dashou_user = User.query.get(UserUID=zhiding_dashou) dashou_user = User.query.select_related('DashouProfile').get(UserUID=zhiding_dashou)
logger.info(f"找到打手用户 - 用户ID: {zhiding_dashou}") logger.info(f"找到打手用户 - 用户ID: {zhiding_dashou}")
# 通过反向关系获取打手扩展信息 # 通过反向关系获取打手扩展信息
@@ -922,7 +923,7 @@ class KehuTianxieDingdanView(APIView):
# 获取商家信息用于返回 # 获取商家信息用于返回
try: try:
user_main = User.query.get(UserUID=lianjie_obj.UserID) user_main = User.query.select_related('ShopProfile').get(UserUID=lianjie_obj.UserID)
shangjia_obj = user_main.ShopProfile shangjia_obj = user_main.ShopProfile
shangjia_mingcheng = shangjia_obj.nicheng shangjia_mingcheng = shangjia_obj.nicheng
except: except:

View File

@@ -1,4 +1,4 @@
import io import io
import os import os
import re import re
import time import time
@@ -142,17 +142,21 @@ class ShangjiaMobanListView(APIView):
return Response({'code': 400, 'msg': '页码超出范围', 'data': {}}, status=400) return Response({'code': 400, 'msg': '页码超出范围', 'data': {}}, status=400)
current_page = paginator.page(page) current_page = paginator.page(page)
# 批量预取所有称号,避免循环内 N+1
_mobans_list = list(current_page.object_list)
_title_ids = [m.TitleID for m in _mobans_list if m.TitleID]
_ch_map = {
ch.id: ch
for ch in Chenghao.query.filter(id__in=_title_ids).only('id', 'mingcheng', 'texiao_miaoshu')
} if _title_ids else {}
formatted = [] formatted = []
for moban in current_page.object_list: for moban in _mobans_list:
texiao_json = '' texiao_json = ''
label_name = '' label_name = ''
if moban.TitleID: ch = _ch_map.get(moban.TitleID)
try: if ch:
ch = Chenghao.query.get(id=moban.TitleID) label_name = ch.mingcheng
label_name = ch.mingcheng texiao_json = ch.texiao_miaoshu or ''
texiao_json = ch.texiao_miaoshu or ''
except Chenghao.DoesNotExist:
pass
formatted.append({ formatted.append({
'mobanId': moban.id, 'mobanId': moban.id,
'shangpinTypeId': moban.ProductTypeID, 'shangpinTypeId': moban.ProductTypeID,

View File

@@ -252,10 +252,34 @@ class CheckPhoneAuthView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
def post(self, request): @staticmethod
user = request.user def _phone_auth_completed(user):
"""是否已完成手机号认证(兼容 PhoneVerified 未写入的老数据)。"""
if bool(getattr(user, 'PhoneVerified', False)):
return True
phone = (getattr(user, 'Phone', None) or '').strip()
return len(phone) >= 11
if getattr(user, 'PhoneVerified', False): def post(self, request):
uid = getattr(request.user, 'UserUID', None)
user = request.user
if uid:
try:
user = User.query.get(UserUID=uid)
except User.DoesNotExist:
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):
return Response({'code': 0, 'need_auth': False}) return Response({'code': 0, 'need_auth': False})
if self._dashou_meets_threshold(user): if self._dashou_meets_threshold(user):

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):

View File

@@ -1,82 +0,0 @@
-- ============================================================
-- RBAC 表创建脚本
-- 仅创建生产数据库中缺失的表,已存在的表会被跳过
-- 执行方式: mysql -u root -p xaio_cheng_xu < create_rbac_tables.sql
-- 注意: 索引和外键单独处理,避免重复创建报错
-- ============================================================
-- ============================================================
-- Part 1: backend RBAC 表小写表名verify_kefu_permission 使用)
-- ============================================================
CREATE TABLE IF NOT EXISTS `role` (
`role_id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
`role_code` varchar(50) NOT NULL UNIQUE,
`role_name` varchar(50) NOT NULL,
`description` varchar(200) NOT NULL,
`create_time` datetime(6) NOT NULL,
`update_time` datetime(6) NOT NULL
);
CREATE TABLE IF NOT EXISTS `permission` (
`perm_id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
`perm_code` varchar(100) NOT NULL UNIQUE,
`perm_name` varchar(100) NOT NULL,
`description` varchar(200) NOT NULL,
`create_time` datetime(6) NOT NULL,
`update_time` datetime(6) NOT NULL
);
CREATE TABLE IF NOT EXISTS `user_role` (
`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
`account_id` varchar(11) NOT NULL,
`create_time` datetime(6) NOT NULL,
`update_time` datetime(6) NOT NULL,
`role_id` integer NOT NULL,
UNIQUE KEY `user_role_account_id_role_id_uniq` (`account_id`, `role_id`)
);
CREATE TABLE IF NOT EXISTS `role_permission` (
`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
`create_time` datetime(6) NOT NULL,
`permission_id` integer NOT NULL,
`role_id` integer NOT NULL,
UNIQUE KEY `role_permission_role_id_permission_id_uniq` (`role_id`, `permission_id`)
);
-- backend 表索引(忽略已存在的错误)
CREATE INDEX `user_role_account_id_idx` ON `user_role` (`account_id`);
-- backend 外键约束
ALTER TABLE `user_role` ADD CONSTRAINT `user_role_role_fk` FOREIGN KEY (`role_id`) REFERENCES `role` (`role_id`);
ALTER TABLE `role_permission` ADD CONSTRAINT `role_permission_permission_fk` FOREIGN KEY (`permission_id`) REFERENCES `permission` (`perm_id`);
ALTER TABLE `role_permission` ADD CONSTRAINT `role_permission_role_fk` FOREIGN KEY (`role_id`) REFERENCES `role` (`role_id`);
-- ============================================================
-- Part 2: gvsdsdk RBAC 表大写表名UUID 主键)
-- ============================================================
CREATE TABLE IF NOT EXISTS `AccountActivation` (`AccountActivationCode` BINARY(6) NOT NULL PRIMARY KEY, `UserUUID` BINARY(16) NOT NULL, `CreateTime` datetime(6) NOT NULL);
CREATE TABLE IF NOT EXISTS `EphemeralToken` (`ELTUUID` BINARY(16) NOT NULL PRIMARY KEY, `ELTUserUUID` BINARY(16) NOT NULL, `ELTCreateTime` datetime(6) NOT NULL, `ELTEndTime` datetime(6) NOT NULL, `ELTType` smallint NOT NULL, `ELTAllowIP` json NOT NULL, `ELTStatus` smallint NOT NULL);
CREATE TABLE IF NOT EXISTS `Password` (`UserUUID` BINARY(16) NOT NULL PRIMARY KEY, `Password` varchar(1024) NOT NULL);
CREATE TABLE IF NOT EXISTS `Permission` (`PermUUID` BINARY(16) NOT NULL PRIMARY KEY, `TenantUUID` BINARY(16) NOT NULL, `PermName` varchar(128) NOT NULL, `PermCode` varchar(128) NOT NULL, `PermCategory` varchar(64) NOT NULL, `PermStatus` smallint NOT NULL, `PermDesc` varchar(512) NOT NULL, `CreateTime` datetime(6) NOT NULL);
CREATE TABLE IF NOT EXISTS `Position` (`PositionUUID` BINARY(16) NOT NULL PRIMARY KEY, `TenantUUID` BINARY(16) NOT NULL, `PositionName` varchar(128) NOT NULL, `PositionCode` varchar(64) NOT NULL, `PositionLevel` smallint NOT NULL, `PositionStatus` smallint NOT NULL, `PositionDesc` varchar(512) NOT NULL, `CreateTime` datetime(6) NOT NULL, `UpdateTime` datetime(6) NOT NULL);
CREATE TABLE IF NOT EXISTS `PositionTemplate` (`PositionTemplateUUID` BINARY(16) NOT NULL PRIMARY KEY, `TenantUUID` BINARY(16) NOT NULL, `TemplateName` varchar(128) NOT NULL, `TemplateCode` varchar(64) NOT NULL, `TemplateStatus` smallint NOT NULL, `TemplateDesc` varchar(512) NOT NULL, `CreateTime` datetime(6) NOT NULL, `UpdateTime` datetime(6) NOT NULL);
CREATE TABLE IF NOT EXISTS `PositionTemplatePermission` (`PTPermUUID` BINARY(16) NOT NULL PRIMARY KEY, `PositionTemplateUUID` BINARY(16) NOT NULL, `PermUUID` BINARY(16) NOT NULL, `CreateTime` datetime(6) NOT NULL);
CREATE TABLE IF NOT EXISTS `Role` (`RoleUUID` BINARY(16) NOT NULL PRIMARY KEY, `TenantUUID` BINARY(16) NOT NULL, `RoleName` varchar(128) NOT NULL, `RoleType` smallint NOT NULL, `RoleStatus` smallint NOT NULL, `AssignScope` smallint NOT NULL, `AssignScopeUUID` BINARY(16) NULL, `RoleDesc` varchar(512) NOT NULL, `CreateTime` datetime(6) NOT NULL);
CREATE TABLE IF NOT EXISTS `RolePermission` (`RolePermUUID` BINARY(16) NOT NULL PRIMARY KEY, `RoleUUID` BINARY(16) NOT NULL, `PermUUID` BINARY(16) NOT NULL, `CreateTime` datetime(6) NOT NULL);
CREATE TABLE IF NOT EXISTS `RoleShop` (`RoleShopUUID` BINARY(16) NOT NULL PRIMARY KEY, `RoleUUID` BINARY(16) NOT NULL, `ShopUUID` BINARY(16) NOT NULL, `IsMainShop` smallint NOT NULL, `CreateTime` datetime(6) NOT NULL);
CREATE TABLE IF NOT EXISTS `Shop` (`ShopUUID` BINARY(16) NOT NULL PRIMARY KEY, `CompanyUUID` BINARY(16) NOT NULL, `ShopName` varchar(128) NOT NULL, `ShopCode` varchar(64) NOT NULL, `ShopType` smallint NOT NULL, `ShopStatus` smallint NOT NULL, `ShopAddress` varchar(512) NOT NULL, `ShopContact` varchar(128) NOT NULL, `ShopPhone` varchar(32) NOT NULL, `ShopDesc` varchar(512) NOT NULL, `CreateTime` datetime(6) NOT NULL, `UpdateTime` datetime(6) NOT NULL);
CREATE TABLE IF NOT EXISTS `User2FA` (`User2FAUUID` BINARY(16) NOT NULL PRIMARY KEY, `UserUUID` BINARY(16) NOT NULL, `SecretKey` varchar(64) NOT NULL, `IsEnabled` smallint NOT NULL, `Verified` smallint NOT NULL, `LastUsedAt` datetime(6) NULL, `CreateTime` datetime(6) NOT NULL);
CREATE TABLE IF NOT EXISTS `UserABAC` (`UserABACUUID` BINARY(16) NOT NULL PRIMARY KEY, `UserUUID` BINARY(16) NOT NULL, `HierarchicalPermissionIdentifiers` varchar(256) NOT NULL);
CREATE TABLE IF NOT EXISTS `UserAvatar` (`UserUUID` BINARY(16) NOT NULL PRIMARY KEY, `UserAvatar` longblob NOT NULL, `AvatarUploadTime` datetime(6) NOT NULL, `AvatarFileType` varchar(16) NOT NULL);
CREATE TABLE IF NOT EXISTS `UserDept` (`UserDeptUUID` BINARY(16) NOT NULL PRIMARY KEY, `UserUUID` BINARY(16) NOT NULL, `DeptUUID` BINARY(16) NOT NULL, `PositionUUID` BINARY(16) NULL, `IsMainDept` smallint NOT NULL, `CreateTime` datetime(6) NOT NULL);
CREATE TABLE IF NOT EXISTS `UserLoginLog` (`LogUUID` BINARY(16) NOT NULL PRIMARY KEY, `UserUUID` BINARY(16) NOT NULL, `LoginIP` varchar(39) NOT NULL, `LoginPlatform` varchar(32) NOT NULL, `LoginStatus` smallint NOT NULL, `LoginTime` datetime(6) NOT NULL, `UserAgent` varchar(512) NOT NULL);
CREATE TABLE IF NOT EXISTS `UserPending2FA` (`PendingUUID` BINARY(16) NOT NULL PRIMARY KEY, `UserUUID` BINARY(16) NOT NULL UNIQUE, `SecretKey` varchar(64) NOT NULL, `ExpireAt` datetime(6) NOT NULL);
CREATE TABLE IF NOT EXISTS `UserRole` (`UserRoleUUID` BINARY(16) NOT NULL PRIMARY KEY, `UserUUID` BINARY(16) NOT NULL, `RoleUUID` BINARY(16) NOT NULL, `CompanyUUID` BINARY(16) NOT NULL, `CreateTime` datetime(6) NOT NULL);
-- gvsdsdk 索引
CREATE INDEX `EphemELTUserUUID_idx` ON `EphemeralToken` (`ELTUserUUID`);
CREATE INDEX `EphemELTStatus_idx` ON `EphemeralToken` (`ELTStatus`);
CREATE INDEX `User2FA_UserUUID_idx` ON `User2FA` (`UserUUID`);
CREATE INDEX `UserLoginLog_UserUUID_idx` ON `UserLoginLog` (`UserUUID`);

View File

@@ -0,0 +1,111 @@
-- ============================================================
-- 店铺相关权限:查询 + 缺失则插入
-- 主权限表Permissiongvsdsdk RBAC角色管理页读取此表
-- 兼容旧表permissionlegacy部分账号仍走 user_role.account_id=手机号)
-- ============================================================
-- ---------- 1. 查询 gvsdsdk 主权限表 ----------
SELECT PermCode, PermName, PermDesc, PermCategory, PermStatus
FROM `Permission`
WHERE PermCode IN (
'999a', '999b', '999c', '999d', '999e',
'1199ab', '1199abc', '1199abd', '1199abe'
)
ORDER BY PermCode;
-- ---------- 2. 查询 legacy 权限表(如仍在使用) ----------
SELECT perm_id, perm_code, perm_name, description
FROM `permission`
WHERE perm_code IN (
'999a', '999b', '999c', '999d', '999e',
'1199ab', '1199abc', '1199abd', '1199abe'
)
ORDER BY perm_code;
-- ---------- 3. 缺失则写入 Permission主表推荐 ----------
INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`)
SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺状态管理', '999a', '店铺管理', 1, '修改店铺封禁/正常状态', NOW()
FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t
WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '999a');
INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`)
SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺余额与创建', '999b', '店铺管理', 1, '修改可提现余额、添加新店铺', NOW()
FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t
WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '999b');
INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`)
SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺提现限额', '999c', '店铺管理', 1, '修改每日提现限额及开关', NOW()
FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t
WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '999c');
INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`)
SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺抽成配置', '999d', '店铺管理', 1, '修改店铺订单收益抽成及默认抽成费率', NOW()
FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t
WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '999d');
INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`)
SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺基本信息', '999e', '店铺管理', 1, '修改店铺名称、电话、微信号', NOW()
FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t
WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '999e');
INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`)
SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺商品审核模式', '1199ab', '店铺管理', 1, '查看/修改店铺商品全局审核开关', NOW()
FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t
WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '1199ab');
INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`)
SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺商品封禁下架', '1199abc', '店铺管理', 1, '封禁、下架、驳回店铺商品/类型审核', NOW()
FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t
WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '1199abc');
INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`)
SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺商品上架解封', '1199abd', '店铺管理', 1, '上架、解封、审核通过店铺商品/类型', NOW()
FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t
WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '1199abd');
INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`)
SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺商品信息修改', '1199abe', '店铺管理', 1, '修改店铺商品标题、价格、库存等基本信息', NOW()
FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t
WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '1199abe');
-- ---------- 4. 可选legacy permission 表(若你们仍在用旧角色表) ----------
INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`)
SELECT '999a', '店铺状态管理', '修改店铺封禁/正常状态', NOW(), NOW()
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '999a');
INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`)
SELECT '999b', '店铺余额与创建', '修改可提现余额、添加新店铺', NOW(), NOW()
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '999b');
INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`)
SELECT '999c', '店铺提现限额', '修改每日提现限额及开关', NOW(), NOW()
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '999c');
INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`)
SELECT '999d', '店铺抽成配置', '修改店铺订单收益抽成及默认抽成费率', NOW(), NOW()
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '999d');
INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`)
SELECT '999e', '店铺基本信息', '修改店铺名称、电话、微信号', NOW(), NOW()
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '999e');
INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`)
SELECT '1199ab', '店铺商品审核模式', '查看/修改店铺商品全局审核开关', NOW(), NOW()
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '1199ab');
INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`)
SELECT '1199abc', '店铺商品封禁下架', '封禁、下架、驳回店铺商品/类型审核', NOW(), NOW()
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '1199abc');
INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`)
SELECT '1199abd', '店铺商品上架解封', '上架、解封、审核通过店铺商品/类型', NOW(), NOW()
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '1199abd');
INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`)
SELECT '1199abe', '店铺商品信息修改', '修改店铺商品标题、价格、库存等基本信息', NOW(), NOW()
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '1199abe');
-- ---------- 5. 插入后再查一遍确认 ----------
SELECT PermCode, PermName, PermStatus FROM `Permission`
WHERE PermCode IN ('999a','999b','999c','999d','999e','1199ab','1199abc','1199abd','1199abe')
ORDER BY PermCode;

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,217 @@
"""
全面审计会员 sync Bug 影响范围(与 rollback 的 44 条不是同一口径)。
rollback 只收「当前仍错误有效」的记录。
本命令额外统计:
- 全库会员/充值单规模
- 当前仍有效 vs 充值单原定期限已过(= rollback 第二类)
- 疑似被 repair 写回过(含已再次过期、当前看不出的历史受害)
用法:
python manage.py audit_member_bug_impact
python manage.py audit_member_bug_impact --since 2026-06-15
"""
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.utils import timezone
from jituan.services.huiyuan_bundle import (
_normalize_datetime_for_compare,
huiyuan_ids_match,
)
from jituan.services.member_recharge import (
MEMBER_LEIXING,
MEMBER_PAID_STATUS,
_czjilu_entitlement_end,
_czjilu_entitlement_revoked,
_parse_since_option,
club_huiyuan_sellable,
_order_is_trial_like,
)
from products.models import Czjilu, Huiyuangoumai
from users.models import Xiugaijilu
def _order_purchase_days(order, club_id=None):
cid = getattr(order, 'club_id', None) or club_id or 'xq'
is_trial = _order_is_trial_like(order, cid)
days = int(getattr(order, 'purchase_days', None) or 0)
if days <= 0:
_, _, days = club_huiyuan_sellable(cid, order.huiyuan_id, is_trial=is_trial)
return max(int(days or 0), 1)
def _paid_orders_for(yonghu_id, huiyuan_id):
return [
o for o in Czjilu.query.filter(
yonghuid=yonghu_id,
leixing=MEMBER_LEIXING,
zhuangtai=MEMBER_PAID_STATUS,
)
if huiyuan_ids_match(o.huiyuan_id, huiyuan_id)
]
def _suspected_repair_restore(rec, since=None):
"""
repair 特征UpdateTime 时点将 daoqi 设为 UpdateTime+days
且存在匹配充值单原定期限在 UpdateTime 之前已结束。
"""
updated = _normalize_datetime_for_compare(rec.UpdateTime)
since_dt = _parse_since_option(since)
if since_dt and updated < since_dt:
return None
daoqi = _normalize_datetime_for_compare(rec.daoqi_time)
gap = daoqi - updated
gap_days = gap.days
if gap_days < 1 or gap_days > 400:
return None
orders = _paid_orders_for(rec.yonghu_id, rec.huiyuan_id)
if not orders:
return None
for order in orders:
if _czjilu_entitlement_revoked(order):
continue
days = _order_purchase_days(order, rec.club_id)
if abs(days - gap_days) > 2:
continue
end = _czjilu_entitlement_end(order, rec.club_id)
if end < updated:
return order.dingdan_id
return None
class Command(BaseCommand):
help = '审计会员 sync Bug 影响规模(全库口径 vs rollback 口径)'
def add_arguments(self, parser):
parser.add_argument(
'--since',
default='2026-06-01',
help='疑似 repair 的 UpdateTime 起始日(默认 2026-06-01',
)
parser.add_argument(
'--sample',
type=int,
default=30,
help='每类样例输出条数',
)
def handle(self, *args, **options):
since = options['since']
sample_n = int(options['sample'] or 30)
now = _normalize_datetime_for_compare(timezone.now())
total_hy = Huiyuangoumai.query.count()
active_hy = Huiyuangoumai.query.filter(
huiyuan_zhuangtai=1,
daoqi_time__gt=timezone.now(),
).count()
expired_czjilu_rows = 0
expired_czjilu_users = set()
for o in Czjilu.query.filter(leixing=MEMBER_LEIXING, zhuangtai=MEMBER_PAID_STATUS):
end = _czjilu_entitlement_end(o)
if end <= now:
expired_czjilu_rows += 1
expired_czjilu_users.add(o.yonghuid)
rollback_active_orphan = 0
rollback_admin_still_active = 0
suspected_all = []
suspected_still_active = []
suspected_already_expired = []
seen_admin = set()
for log in Xiugaijilu.query.filter(huiyuan_id__isnull=False).exclude(huiyuan_id=''):
note = log.qitashuoming or ''
if '移除' not in note and '减会员' not in note:
continue
if not log.yonghuid or not log.huiyuan_id:
continue
pair = (log.yonghuid, str(log.huiyuan_id).strip())
if pair in seen_admin:
continue
seen_admin.add(pair)
for rec in Huiyuangoumai.query.filter(yonghu_id=log.yonghuid):
if not huiyuan_ids_match(rec.huiyuan_id, log.huiyuan_id):
continue
if not rec.jiance_shifou_daoqi() and rec.huiyuan_zhuangtai == 1:
rollback_admin_still_active += 1
break
for rec in Huiyuangoumai.query.all():
is_active = (
rec.huiyuan_zhuangtai == 1
and _normalize_datetime_for_compare(rec.daoqi_time) > now
)
paid = _paid_orders_for(rec.yonghu_id, rec.huiyuan_id)
if paid and is_active:
has_live_czjilu = any(
not _czjilu_entitlement_revoked(o)
and _czjilu_entitlement_end(o, rec.club_id) > now
for o in paid
)
if not has_live_czjilu:
rollback_active_orphan += 1
dingdan_id = _suspected_repair_restore(rec, since=since)
if dingdan_id:
row = (
rec.yonghu_id,
rec.huiyuan_id,
str(rec.daoqi_time),
str(rec.UpdateTime),
dingdan_id,
'仍有效' if is_active else '已再次过期',
)
suspected_all.append(row)
if is_active:
suspected_still_active.append(row)
else:
suspected_already_expired.append(row)
self.stdout.write(self.style.WARNING('=== 全库规模(回答「几万条过期充值」)==='))
self.stdout.write(f' huiyuangoumai 总记录数: {total_hy}')
self.stdout.write(f' 当前仍有效会员记录: {active_hy}')
self.stdout.write(
f' 已付会员充值单且原定期限已过: {expired_czjilu_rows} 条,'
f'{len(expired_czjilu_users)} 个用户',
)
self.stdout.write(self.style.WARNING(
'\n=== rollback 命令口径(只收「当前仍错误有效」)===',
))
self.stdout.write(f' 后台已减仍有效: {rollback_admin_still_active}')
self.stdout.write(f' 充值单全过期但仍有效: {rollback_active_orphan}')
self.stdout.write(
f' 合计约: {rollback_admin_still_active + rollback_active_orphan}',
)
self.stdout.write(self.style.WARNING(
f'\n=== 更广口径:疑似 repair 写回过UpdateTime>={since}===',
))
self.stdout.write(f' 疑似受害记录总数: {len(suspected_all)}')
self.stdout.write(f' 其中当前仍有效: {len(suspected_still_active)}')
self.stdout.write(f' 其中已再次过期rollback 收不到): {len(suspected_already_expired)}')
unique_users = {r[0] for r in suspected_all}
self.stdout.write(f' 涉及不同用户数: {len(unique_users)}')
self.stdout.write('\n样例(疑似 repair含已再次过期:')
for row in suspected_all[:sample_n]:
self.stdout.write(
f' 用户{row[0]} 会员{row[1]} {row[5]} '
f'到期{row[2]} 更新{row[3]} 依据单{row[4]}',
)
if len(suspected_all) > sample_n:
self.stdout.write(f' ... 另有 {len(suspected_all) - sample_n}')
self.stdout.write(self.style.SUCCESS(
'\n说明: 几万条过期充值单是历史正常数据Bug 不会批量改全库,'
'只在用户打开抢单池时触发。rollback 只处理「此刻仍错误有效」的记录。',
))

View File

@@ -0,0 +1,52 @@
"""回填会员体验标记czjilu.is_trial + huiyuangoumai.has_used_trial。"""
from django.core.management.base import BaseCommand
from django.db import transaction
from jituan.services.member_recharge import (
_order_is_trial_like,
has_paid_trial_purchase,
)
from products.models import Czjilu, Huiyuangoumai
class Command(BaseCommand):
help = '回填体验会员订单/权益标记(修复重复购买体验、正式计次错误)'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='仅统计,不写库',
)
def handle(self, *args, **options):
dry_run = bool(options.get('dry_run'))
czjilu_fixed = 0
goumai_fixed = 0
member_orders = list(Czjilu.query.filter(leixing=1, zhuangtai=3))
with transaction.atomic():
for order in member_orders:
club_id = getattr(order, 'club_id', None)
if _order_is_trial_like(order, club_id) and not order.is_trial:
czjilu_fixed += 1
if not dry_run:
Czjilu.query.filter(dingdan_id=order.dingdan_id).update(is_trial=True)
goumai_rows = list(Huiyuangoumai.query.all())
for row in goumai_rows:
if row.has_used_trial:
continue
if has_paid_trial_purchase(row.yonghu_id, row.huiyuan_id, row.club_id):
goumai_fixed += 1
if not dry_run:
Huiyuangoumai.query.filter(id=row.id).update(has_used_trial=True)
if dry_run:
transaction.set_rollback(True)
mode = 'DRY-RUN' if dry_run else 'APPLIED'
self.stdout.write(self.style.SUCCESS(
f'[{mode}] czjilu.is_trial 修复 {czjilu_fixed} 条,'
f'huiyuangoumai.has_used_trial 修复 {goumai_fixed}',
))

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,139 @@
"""
收回因 dddhq 每次 sync 错误补开通的会员权益(保守策略,优先不误伤正常付费)。
安全规则(按优先级):
1. 有任意未过期已付充值单支撑 → 绝不收回
2. 无充值单的老数据 → 不自动处理
3. 默认要求符合 bug repair 指纹UpdateTime 写回 + 旧单原定期限已过)
4. 后台减会员:仅当仍有效且无未过期充值单时才 revoke
用法:
python manage.py rollback_erroneous_member_restore --dry-run
python manage.py rollback_erroneous_member_restore
python manage.py rollback_erroneous_member_restore --since 2026-06-01
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from jituan.services.huiyuan_bundle import huiyuan_ids_match
from jituan.services.member_recharge import (
expire_huiyuangoumai_record,
revoke_member_entitlement_admin,
should_rollback_erroneous_membership,
user_has_active_czjilu_entitlement,
)
from products.models import Huiyuangoumai
from users.models import Xiugaijilu
class Command(BaseCommand):
help = '收回错误恢复的会员(保守:有未过期充值单绝不碰)'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='仅统计,不写库',
)
parser.add_argument(
'--since',
default='2026-06-01',
help='repair 指纹 UpdateTime 起始日(默认 2026-06-01',
)
parser.add_argument(
'--no-repair-signature',
action='store_true',
help='不要求 repair 指纹(更激进,不推荐)',
)
def handle(self, *args, **options):
dry_run = bool(options.get('dry_run'))
since = options.get('since')
require_sig = not bool(options.get('no_repair_signature'))
admin_revoke = 0
expire_orphan = 0
skipped_safe = 0
samples = []
with transaction.atomic():
seen_pairs = set()
logs = Xiugaijilu.query.filter(
huiyuan_id__isnull=False,
).exclude(huiyuan_id='').order_by('-CreateTime')
for log in logs:
note = log.qitashuoming or ''
if '移除' not in note and '减会员' not in note:
continue
if not log.yonghuid or not log.huiyuan_id:
continue
pair = (log.yonghuid, str(log.huiyuan_id).strip())
if pair in seen_pairs:
continue
seen_pairs.add(pair)
for rec in Huiyuangoumai.query.filter(yonghu_id=log.yonghuid):
if not huiyuan_ids_match(rec.huiyuan_id, log.huiyuan_id):
continue
if rec.jiance_shifou_daoqi() or rec.huiyuan_zhuangtai != 1:
continue
if user_has_active_czjilu_entitlement(
rec.yonghu_id, rec.huiyuan_id, rec.club_id,
):
skipped_safe += 1
continue
ok, detail = should_rollback_erroneous_membership(
rec, since=since, require_repair_signature=require_sig,
)
if not ok:
skipped_safe += 1
continue
admin_revoke += 1
samples.append(
f'[后台已减仍有效] {log.yonghuid} 会员{log.huiyuan_id} '
f'依据单{detail or "-"}',
)
if not dry_run:
revoke_member_entitlement_admin(log.yonghuid, log.huiyuan_id)
break
for rec in Huiyuangoumai.query.filter(huiyuan_zhuangtai=1):
if rec.jiance_shifou_daoqi():
continue
ok, detail = should_rollback_erroneous_membership(
rec, since=since, require_repair_signature=require_sig,
)
if not ok:
if detail == '有未过期充值单支撑':
skipped_safe += 1
continue
expire_orphan += 1
samples.append(
f'[充值单已过期仍有效] {rec.yonghu_id} 会员{rec.huiyuan_id} '
f'到期{rec.daoqi_time} 依据单{detail or "-"}',
)
if not dry_run:
expire_huiyuangoumai_record(
rec,
reason='rollback_erroneous_member_restore',
)
if dry_run:
transaction.set_rollback(True)
mode = 'DRY-RUN' if dry_run else 'APPLIED'
self.stdout.write(self.style.SUCCESS(
f'[{mode}] 后台减会员仍有效收回 {admin_revoke} 条;'
f'无有效充值单支撑过期 {expire_orphan} 条;'
f'安全跳过 {skipped_safe} 条(含正常付费/不符合指纹)',
))
if require_sig:
self.stdout.write(' 已启用 repair 指纹校验(推荐)')
else:
self.stdout.write(self.style.WARNING(' 未启用 repair 指纹(激进模式,慎用)'))
for line in samples[:50]:
self.stdout.write(f' {line}')
if len(samples) > 50:
self.stdout.write(f' ... 另有 {len(samples) - 50}')

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,71 @@ EXTRA_PERMISSIONS = [
'配置客服欢迎语、发图确认弹窗、关键词自动回复(会话管理-客服聊天配置)', '配置客服欢迎语、发图确认弹窗、关键词自动回复(会话管理-客服聊天配置)',
'会话管理', '会话管理',
), ),
(
'gdck666',
'投诉工单查看',
'查看用户投诉工单列表与详情',
'投诉工单',
),
(
'gdcl666',
'投诉工单处理',
'处理/关闭用户投诉工单',
'投诉工单',
),
( (
'sfbq666', 'sfbq666',
'身份装饰标签管理', '身份装饰标签管理',
'管理小程序身份装饰标签(与用户管理-身份装饰标签菜单对应)', '管理小程序身份装饰标签(与用户管理-身份装饰标签菜单对应)',
'用户管理', '用户管理',
), ),
(
'005bb',
'提现审核(按俱乐部)',
'审核本俱乐部提现(同意/拒绝/自动打款)。跨俱乐部仅集团高管;须角色 ClubID + 数据范围任职。',
'提现管理',
),
(
'djcfg001',
'资金冻结规则配置',
'配置本俱乐部各角色冻结比例/分期/解冻条件',
'资金冻结',
),
(
'djled001',
'资金冻结明细',
'查看冻结流水并手动解冻',
'资金冻结',
),
(
'djrisk001',
'罚款投诉风险指标',
'查看罚款率/投诉率等解冻相关指标',
'资金冻结',
),
(
'xycfg001',
'角色协议配置',
'配置打手/管事/组长用户协议并发布版本',
'小程序配置',
),
(
'002ad',
'修改抢单要求',
'修改平台订单/本店商品的抢单要求类型(会员/押金门槛)。须显式绑定;本店超管 000001 不会自动拥有。',
'订单管理',
),
# ---------- 店铺管理999 系列:列表/详情/复制/提现等) ----------
('999a', '店铺状态管理', '修改店铺封禁/正常状态', '店铺管理'),
('999b', '店铺余额与创建', '修改可提现余额、添加新店铺', '店铺管理'),
('999c', '店铺提现限额', '修改每日提现限额及开关', '店铺管理'),
('999d', '店铺抽成配置', '修改店铺订单收益抽成及默认抽成费率', '店铺管理'),
('999e', '店铺基本信息', '修改店铺名称、电话、微信号', '店铺管理'),
# ---------- 店铺商品管理1199 系列:商品/类型/审核) ----------
('1199ab', '店铺商品审核模式', '查看/修改店铺商品全局审核开关', '店铺管理'),
('1199abc', '店铺商品封禁下架', '封禁、下架、驳回店铺商品/类型审核', '店铺管理'),
('1199abd', '店铺商品上架解封', '上架、解封、审核通过店铺商品/类型', '店铺管理'),
('1199abe', '店铺商品信息修改', '修改店铺商品标题、价格、库存等基本信息', '店铺管理'),
] ]

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,26 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('jituan', '0009_club_huiyuan_recharge_ui'),
]
operations = [
migrations.CreateModel(
name='ClubHuiyuanBundleInclude',
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)),
('bundle_huiyuan_id', models.CharField(db_index=True, max_length=6, verbose_name='总会员ID')),
('included_huiyuan_id', models.CharField(db_index=True, max_length=6, verbose_name='包含的子会员ID')),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'club_huiyuan_bundle_include',
'unique_together': {('club_id', 'bundle_huiyuan_id', 'included_huiyuan_id')},
},
),
]

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:
@@ -127,6 +226,19 @@ class ClubHuiyuanPrice(QModel):
unique_together = [['club_id', 'huiyuan_id']] unique_together = [['club_id', 'huiyuan_id']]
class ClubHuiyuanBundleInclude(QModel):
"""俱乐部总会员包含的子会员类型(仅 is_bundle 会员使用)。"""
club_id = models.CharField(max_length=16, db_index=True)
bundle_huiyuan_id = models.CharField(max_length=6, db_index=True, verbose_name='总会员ID')
included_huiyuan_id = models.CharField(max_length=6, db_index=True, verbose_name='包含的子会员ID')
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'club_huiyuan_bundle_include'
unique_together = [['club_id', 'bundle_huiyuan_id', 'included_huiyuan_id']]
class ClubShangpinLeixingConfig(QModel): class ClubShangpinLeixingConfig(QModel):
"""各俱乐部小程序商品类型上架与图标(全局 ShangpinLeixing 由集团定义)。""" """各俱乐部小程序商品类型上架与图标(全局 ShangpinLeixing 由集团定义)。"""
club_id = models.CharField(max_length=16, db_index=True) club_id = models.CharField(max_length=16, db_index=True)
@@ -134,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)
@@ -303,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')
@@ -319,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,81 @@
"""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):
qs = AdminAssignment.query.filter(yonghuid=yonghuid, status=1, is_primary=True)
if except_id:
qs = qs.exclude(id=except_id)
ids = list(qs.values_list('id', flat=True))
if ids:
AdminAssignment.objects.filter(pk__in=ids).update(
is_primary=False,
UpdateTime=timezone.now(),
)
def _clear_other_primary(yonghuid, except_id=None):
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(
@@ -30,56 +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, '该任职记录已存在且有效'
inactive = find_assignment(yonghuid, club_id, role_code) junk.delete()
if inactive and inactive.status != 1:
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( if is_primary:
_clear_other_primary(yonghuid)
row = AdminAssignment.objects.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 = []
@@ -90,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

@@ -14,6 +14,55 @@ GROUP_MANAGE_ROLES = frozenset({
}) })
def get_allowed_club_ids(user):
"""用户可切换/访问的子公司 club_id 集合(不含集团汇总)。"""
if is_system_super_admin(user):
return {c.club_id for c in Club.query.filter(status=1)}
assignments = AdminAssignment.query.filter(yonghuid=user.UserUID, status=1)
has_group = assignments.filter(
club_id__isnull=True
).exists() or assignments.filter(data_scope=DATA_SCOPE_ALL).exists()
if has_group:
return {c.club_id for c in Club.query.filter(status=1)}
allowed = {
a.club_id for a in assignments if a.club_id
}
if not allowed:
cid = (getattr(user, 'ClubID', None) or '').strip()
if cid:
allowed = {cid}
return allowed
def resolve_admin_effective_club(user, request):
"""
校验后台用户请求的俱乐部/范围,返回 (club_id, scope, error_msg)。
无权限访问请求的俱乐部时,回退到主任职俱乐部(避免 localStorage 脏数据串俱乐部)。
"""
from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope
requested_club = resolve_club_id_from_request(request)
requested_scope = resolve_club_scope(request)
if is_system_super_admin(user):
return requested_club, requested_scope, None
ctx = build_admin_club_context(user)
allowed = get_allowed_club_ids(user)
if requested_scope == DATA_SCOPE_ALL:
if not ctx.get('is_group_admin'):
primary = ctx.get('club_id') or CLUB_ID_DEFAULT
return primary, DATA_SCOPE_SINGLE, None
return ctx.get('club_id') or CLUB_ID_DEFAULT, DATA_SCOPE_ALL, None
if requested_club not in allowed:
primary = ctx.get('club_id') or CLUB_ID_DEFAULT
return primary, DATA_SCOPE_SINGLE, None
return requested_club, DATA_SCOPE_SINGLE, None
def build_admin_club_context(user): def build_admin_club_context(user):
""" """
根据 admin_assignment + 超管推断后台登录后的俱乐部上下文。 根据 admin_assignment + 超管推断后台登录后的俱乐部上下文。
@@ -50,13 +99,16 @@ def build_admin_club_context(user):
) )
if not assignments: if not assignments:
allowed_ids = get_allowed_club_ids(user)
visible_clubs = [c for c in all_clubs if c['club_id'] in allowed_ids]
club_id = next(iter(allowed_ids), CLUB_ID_DEFAULT) if allowed_ids else CLUB_ID_DEFAULT
return _pack( return _pack(
scope=DATA_SCOPE_SINGLE, scope=DATA_SCOPE_SINGLE,
club_id=CLUB_ID_DEFAULT, club_id=club_id,
is_group_admin=False, is_group_admin=False,
assignments=[], assignments=[],
clubs=all_clubs, clubs=visible_clubs,
can_switch_club=False, can_switch_club=len(visible_clubs) > 1,
role_code='CLUB_ADMIN', role_code='CLUB_ADMIN',
role_name='子公司客服(默认)', role_name='子公司客服(默认)',
) )
@@ -66,18 +118,22 @@ def build_admin_club_context(user):
a.club_id is None or a.data_scope == DATA_SCOPE_ALL a.club_id is None or a.data_scope == DATA_SCOPE_ALL
for a in assignments for a in assignments
) )
allowed_club_ids = {a.club_id for a in assignments if a.club_id} allowed_ids = get_allowed_club_ids(user)
if has_group: if has_group:
scope = DATA_SCOPE_ALL scope = DATA_SCOPE_ALL
club_id = CLUB_ID_DEFAULT club_id = CLUB_ID_DEFAULT
visible_clubs = all_clubs
else: else:
scope = DATA_SCOPE_SINGLE scope = DATA_SCOPE_SINGLE
club_id = primary.club_id or CLUB_ID_DEFAULT primary_cid = primary.club_id or CLUB_ID_DEFAULT
if primary_cid in allowed_ids:
visible_clubs = all_clubs if has_group else [ club_id = primary_cid
c for c in all_clubs if c['club_id'] in allowed_club_ids elif allowed_ids:
] club_id = sorted(allowed_ids)[0]
else:
club_id = primary_cid
visible_clubs = [c for c in all_clubs if c['club_id'] in allowed_ids]
role_code = primary.role_code or 'CLUB_ADMIN' role_code = primary.role_code or 'CLUB_ADMIN'
return _pack( return _pack(
@@ -93,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':
@@ -127,18 +207,76 @@ def can_manage_admin_assignments(user, permissions=None):
) )
def get_switchable_club_ids(user):
"""顶栏可切换俱乐部:有任职/数据范围即可切换;菜单按当前俱乐部功能角色展示。"""
return get_allowed_club_ids(user)
def can_manage_roles_for_club(user, permissions, club_id):
"""
是否可为指定俱乐部分配/移除功能角色、添加本店后台用户。
集团高层:全部俱乐部;否则须在该俱乐部拥有 000001 超级管理(不必有集团权限)。
"""
if can_manage_admin_assignments(user, permissions):
return True
cid = (club_id or '').strip()
if not cid:
return False
from jituan.constants import DATA_SCOPE_SINGLE
from jituan.services.club_rbac import resolve_effective_permission_codes
# 与菜单/verify_kefu 同一套:含 legacy避免「页面能进但添加按钮出不来」
codes = resolve_effective_permission_codes(user, cid, DATA_SCOPE_SINGLE)
return '000001' in (codes or [])
def get_manageable_club_ids(user, permissions=None, current_club_id=None):
"""
当前操作者可管理角色/后台用户的俱乐部集合。
current_club_id若请求上下文已带 000001务必把当前顶栏俱乐部算进去。
"""
if can_manage_admin_assignments(user, permissions):
return {c.club_id for c in Club.query.filter(status=1)}
ids = {
c.club_id for c in Club.query.filter(status=1)
if can_manage_roles_for_club(user, permissions, c.club_id)
}
cid = (current_club_id or '').strip()
if cid and permissions and '000001' in permissions:
ids.add(cid)
return ids
def can_access_admin_user_management(user, permissions=None):
"""
是否可访问「后台用户」页(列表、按俱乐部分配功能角色)。
系统超管 / 000001 / 集团高管 / 任一俱乐部超管均可。
"""
if is_system_super_admin(user):
return True
if permissions and '000001' in permissions:
return True
if can_manage_admin_assignments(user, permissions):
return True
return bool(get_manageable_club_ids(user, permissions))
def _pack(scope, club_id, is_group_admin, assignments, clubs, can_switch_club, def _pack(scope, club_id, is_group_admin, assignments, clubs, can_switch_club,
role_code, role_name): role_code, role_name):
allowed_club_ids = sorted({
c['club_id'] for c in clubs if c.get('club_id')
})
return { return {
'scope': scope, 'scope': scope,
'club_id': club_id, 'club_id': club_id,
'is_group_admin': is_group_admin, 'is_group_admin': is_group_admin,
'role_code': role_code, 'role_code': role_code,
'role_name': role_name, 'role_name': role_name,
'allowed_club_ids': allowed_club_ids,
'perm_source': 'gvsdsdk', 'perm_source': 'gvsdsdk',
'perm_note': ( 'perm_note': (
'【功能权限】在「角色管理」绑 gvsdsdk 角色(如 caiwu、dingdan、000001' '【功能权限】在「角色管理」为各俱乐部创建角色,在「管理员用户」绑定'
'【数据范围】在「数据范围配置」维护任职(能看/管哪个俱乐部)。两套独立' '切换俱乐部后菜单按该俱乐部角色生效'
'【数据范围】在本页添加任职,可分配多个俱乐部,无需集团权限即可切换。'
), ),
'assignments': [ 'assignments': [
{ {

View File

@@ -0,0 +1,507 @@
"""支付宝支付RSA2配置、签名、验签与 wap.pay 跳转 URL 生成。
沙箱/生产由 settings.ALIPAY_SANDBOX 开关控制,配置存 app_secrets.py。
多 club 暂回落全局配置(与 wechat_pay 一致),未来再迁移 Club 表。
"""
import base64
import json
import logging
import time
from urllib.parse import quote
from django.conf import settings
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from jituan.constants import CLUB_ID_DEFAULT
logger = logging.getLogger(__name__)
ALIPAY_DISABLED_MSG = '支付宝支付功能暂不可用,请使用微信支付'
def normalize_pay_club_id(club_id=None):
return (club_id or '').strip().lower() or CLUB_ID_DEFAULT
def is_alipay_enabled_for_club(club_id=None):
"""
是否允许该俱乐部「新发起」支付宝支付。
仅 settings.ALIPAY_DISABLED_CLUB_IDS 中的俱乐部为 False其余一律 True。
不影响异步回调验签与查单;不影响提现支付宝收款。
"""
cid = normalize_pay_club_id(club_id)
disabled = getattr(settings, 'ALIPAY_DISABLED_CLUB_IDS', None) or ()
try:
disabled_set = {str(x).strip().lower() for x in disabled if x is not None}
except TypeError:
disabled_set = set()
return cid not in disabled_set
def assert_alipay_allowed_for_club(club_id=None):
if not is_alipay_enabled_for_club(club_id):
raise Exception(ALIPAY_DISABLED_MSG)
def alipay_pay_method_block_reason(pay_method, club_id=None):
"""支付接口入口:仅 pay_method=alipay 且俱乐部禁用时返回文案;微信/其它一律 None。"""
if (pay_method or '').strip().lower() != 'alipay':
return None
if is_alipay_enabled_for_club(club_id):
return None
return ALIPAY_DISABLED_MSG
# ==================== 配置读取 ====================
def get_alipay_config(club_id=None):
"""
读取支付宝配置。
根据 settings.ALIPAY_SANDBOX 开关返回沙箱或生产配置。
club_id 暂未使用(预留多 club 扩展),统一回落全局配置。
"""
sandbox = getattr(settings, 'ALIPAY_SANDBOX', True)
if sandbox:
cfg = getattr(settings, 'ALIPAY_SANDBOX_CFG', {})
else:
cfg = getattr(settings, 'ALIPAY_PROD', {})
# 充值回调优先读 secrets空则用 settings 默认地址(绝不回落到订单 NOTIFY_URL
czjilu_notify = (cfg.get('NOTIFY_URL_CZJILU') or '').strip()
order_notify = (cfg.get('NOTIFY_URL') or '').strip()
default_czjilu = (getattr(settings, 'ALIPAY_CZJILU_NOTIFY_URL', '') or '').strip()
if not czjilu_notify or (order_notify and czjilu_notify.rstrip('/') == order_notify.rstrip('/')):
czjilu_notify = default_czjilu
return {
'APP_ID': cfg.get('APP_ID', ''),
'PID': cfg.get('PID', ''),
'APP_PRIVATE_KEY': cfg.get('APP_PRIVATE_KEY', ''),
'ALIPAY_PUBLIC_KEY': cfg.get('ALIPAY_PUBLIC_KEY', ''),
'GATEWAY': cfg.get('GATEWAY', 'https://openapi.alipay.com/gateway.do'),
'NOTIFY_URL': order_notify,
'NOTIFY_URL_CZJILU': czjilu_notify,
'RETURN_URL': cfg.get('RETURN_URL', ''),
'sandbox': sandbox,
}
def get_czjilu_notify_url(club_id=None):
"""获取 Czjilu 充值回调地址secrets 可选覆盖,否则用 settings 写死的正确地址。
密钥/APP_ID 仍用原有 ALIPAY_PROD不必再配新密钥。
禁止回落到订单 NOTIFY_URL避免充值通知被订单回调吞掉。
"""
cfg = get_alipay_config(club_id)
notify_url = (cfg.get('NOTIFY_URL_CZJILU') or '').strip()
if not notify_url:
notify_url = (getattr(settings, 'ALIPAY_CZJILU_NOTIFY_URL', '') or '').strip()
if not notify_url:
raise Exception('缺少支付宝充值回调地址 ALIPAY_CZJILU_NOTIFY_URL')
return notify_url
def generate_alipay_czjilu_pay_params(dingdanid, jine, subject, club_id=None, expire_minutes=30):
"""生成 Czjilu 类充值的支付宝 wap.pay 二维码参数。
返回 {qr_code, qr_image, pay_method},并在 cache 标记该订单为支付宝订单
(轮询接口据此跳过微信查单,直接等异步回调更新本地状态)。
"""
assert_alipay_allowed_for_club(club_id)
from django.core.cache import cache
notify_url = get_czjilu_notify_url(club_id)
pay_url = build_wap_pay_url(
out_trade_no=dingdanid,
amount=jine,
subject=subject or '充值支付',
club_id=club_id,
expire_minutes=expire_minutes,
notify_url=notify_url,
_skip_club_gate=True,
)
qr_image = qr_link_to_base64(pay_url)
cache.set(f'alipay_pay_url_{dingdanid}', pay_url, 60 * 30)
return {
'qr_code': pay_url,
'qr_image': qr_image,
'pay_method': 'alipay',
}
# ==================== 密钥加载 ====================
def _load_private_key(key_str):
"""加载 RSA 私钥,兼容 PEM、PKCS#8 DER、PKCS#1、PKCS#8 多种格式。
沙箱私钥常为 PKCS#1 格式(裸 base64 前缀 MIIEowIBAAKCAQEA
生产私钥常为 PKCS#8 格式(裸 base64 前缀 MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcw
此处按顺序尝试多种加载方式以兼容两者。
"""
if isinstance(key_str, bytes):
key_str = key_str.decode('utf-8')
key_str = key_str.strip()
# 1. 标准 PEM含 -----BEGIN 头)
if '-----BEGIN' in key_str:
return serialization.load_pem_private_key(
key_str.encode('utf-8'), password=None,
)
raw = key_str.replace('\n', '').replace('\r', '').replace(' ', '')
# 2. PKCS#8 DER裸 base64
try:
der_bytes = base64.b64decode(raw)
return serialization.load_der_private_key(der_bytes, password=None)
except Exception:
pass
# 3. PKCS#1 PEM 包装(-----BEGIN RSA PRIVATE KEY-----
pem_pkcs1 = _wrap_pem(raw, 'RSA PRIVATE KEY')
try:
return serialization.load_pem_private_key(pem_pkcs1.encode('utf-8'), password=None)
except Exception:
pass
# 4. PKCS#8 PEM 包装(-----BEGIN PRIVATE KEY-----
pem_pkcs8 = _wrap_pem(raw, 'PRIVATE KEY')
return serialization.load_pem_private_key(pem_pkcs8.encode('utf-8'), password=None)
def _load_public_key(key_str):
"""加载支付宝公钥,兼容 PEM、X.509 DER 及 PEM 包装格式。
支付宝公钥标准为 X.509 SubjectPublicKeyInfo裸 base64 前缀通常为
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8Aload_der_public_key 原生支持;
为稳妥起见追加 PEM 包装兜底。
"""
if not key_str:
return None
if isinstance(key_str, bytes):
key_str = key_str.decode('utf-8')
key_str = key_str.strip()
# 1. 标准 PEM含 -----BEGIN 头)
if '-----BEGIN' in key_str:
return serialization.load_pem_public_key(key_str.encode('utf-8'))
raw = key_str.replace('\n', '').replace('\r', '').replace(' ', '')
# 2. X.509 DER裸 base64
try:
der_bytes = base64.b64decode(raw)
return serialization.load_der_public_key(der_bytes)
except Exception:
pass
# 3. PEM 包装兜底(-----BEGIN PUBLIC KEY-----
pem_pub = _wrap_pem(raw, 'PUBLIC KEY')
return serialization.load_pem_public_key(pem_pub.encode('utf-8'))
def _wrap_pem(raw_b64, label):
"""将裸 base64 字符串包装为标准 PEM 格式(每行 64 字符)。"""
lines = [raw_b64[i:i + 64] for i in range(0, len(raw_b64), 64)]
return f'-----BEGIN {label}-----\n' + '\n'.join(lines) + f'\n-----END {label}-----'
# ==================== 签名 / 验签 ====================
def _build_sign_content(params):
"""构建待签名字符串:按 key 字典序排列,仅排除 sign空值不参与。
注意sign_type 必须参与签名(支付宝验签字符串包含 sign_type
"""
sorted_keys = sorted(k for k in params if k != 'sign')
return '&'.join(f'{k}={params[k]}' for k in sorted_keys if params[k] != '')
def sign_rsa2(content, private_key_str):
"""RSA2 (SHA256WithRSA) 签名,返回 Base64 编码的签名字符串。"""
private_key = _load_private_key(private_key_str)
signature = private_key.sign(
content.encode('utf-8'),
padding.PKCS1v15(),
hashes.SHA256(),
)
return base64.b64encode(signature).decode('ascii')
def verify_rsa2(content, sign_b64, public_key_str):
"""使用支付宝公钥验证 RSA2 签名。"""
public_key = _load_public_key(public_key_str)
if public_key is None:
return False
try:
signature = base64.b64decode(sign_b64)
public_key.verify(
signature,
content.encode('utf-8'),
padding.PKCS1v15(),
hashes.SHA256(),
)
return True
except Exception as e:
logger.warning('支付宝验签失败: %s', e)
return False
def verify_notify_params(params, club_id=None):
"""
验证支付宝异步回调签名。
params 为已解析的字典(来自 POST form 数据)。
注意:回调验签与请求签名规则不同!
- 请求签名sign_type 参与签名(只排除 sign
- 回调验签sign_type 不参与签名(排除 sign 和 sign_type
支付宝官方文档:通知返回参数中除去 sign、sign_type 两个参数外,皆是待验签参数。
"""
cfg = get_alipay_config(club_id)
sign = params.get('sign', '')
if not sign:
logger.warning('[ALIPAY_VERIFY_DEBUG] sign 为空')
return False
sign_type = params.get('sign_type', '')
if sign_type and sign_type.upper() != 'RSA2':
logger.warning(f'[ALIPAY_VERIFY_DEBUG] sign_type 非 RSA2: {sign_type}')
return False
# 回调验签:排除 sign 和 sign_type支付宝官方规则
sorted_keys = sorted(k for k in params if k not in ('sign', 'sign_type'))
sign_content = '&'.join(f'{k}={params[k]}' for k in sorted_keys if params[k] != '')
logger.warning(f'[ALIPAY_VERIFY_DEBUG] sign_content(前300)={sign_content[:300]}')
logger.warning(f'[ALIPAY_VERIFY_DEBUG] sign(前50)={sign[:50]}')
result = verify_rsa2(sign_content, sign, cfg['ALIPAY_PUBLIC_KEY'])
logger.warning(f'[ALIPAY_VERIFY_DEBUG] verify_rsa2 result={result}')
return result
# ==================== wap.pay 跳转 URL ====================
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 方式)。
参数:
out_trade_no: 商户订单号(对应 Order.OrderID 或 Czjilu.dingdan_id
amount: 订单金额Decimal 或字符串,单位元)
subject: 订单标题
body: 订单描述(可选)
club_id: 俱乐部;命中 ALIPAY_DISABLED_CLUB_IDS 时拒绝发起
expire_minutes: 超时关闭时间(分钟)
notify_url: 自定义异步回调地址(可选,默认用 cfg['NOTIFY_URL']
_skip_club_gate: 内部已校验过时跳过(避免双重校验)
返回:
完整的支付宝跳转 URL前端 web-view 直接加载即可。
"""
if not _skip_club_gate:
assert_alipay_allowed_for_club(club_id)
cfg = get_alipay_config(club_id)
biz_content = {
'out_trade_no': out_trade_no,
'total_amount': str(amount),
'subject': subject or '订单支付',
'body': body or '',
'product_code': 'QUICK_WAP_WAY',
'timeout_express': f'{expire_minutes}m',
}
params = {
'app_id': cfg['APP_ID'],
'method': 'alipay.trade.wap.pay',
'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),
'notify_url': notify_url or cfg['NOTIFY_URL'],
'return_url': cfg['RETURN_URL'],
}
# 签名
sign_content = _build_sign_content(params)
params['sign'] = sign_rsa2(sign_content, cfg['APP_PRIVATE_KEY'])
# 拼接 GET URL
parts = []
for k in sorted(params.keys()):
parts.append(f'{k}={quote(str(params[k]), safe="")}')
query_string = '&'.join(parts)
return f"{cfg['GATEWAY']}?{query_string}"
# ==================== 当面付二维码precreate====================
def build_precreate_qr(out_trade_no, amount, subject, body='', club_id=None, expire_minutes=30):
"""
调用 alipay.trade.precreate 生成当面付二维码链接。
与 wap.pay 不同precreate 是服务器主动 POST 调用,返回 qr_code 链接,
由商户自行生成二维码展示;用户用支付宝 APP 扫码完成支付。
适用于微信小程序等无法跳转 alipay:// scheme 的环境。
返回:
dict: {'qr_code': 'https://qr.alipay.com/...', 'out_trade_no': ...}
失败抛异常。
"""
import requests
assert_alipay_allowed_for_club(club_id)
cfg = get_alipay_config(club_id)
biz_content = {
'out_trade_no': out_trade_no,
'total_amount': str(amount),
'subject': subject or '订单支付',
'body': body or '',
'timeout_express': f'{expire_minutes}m',
}
params = {
'app_id': cfg['APP_ID'],
'method': 'alipay.trade.precreate',
'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),
'notify_url': cfg['NOTIFY_URL'],
}
# 签名sign_type 参与签名,与 wap.pay 一致)
sign_content = _build_sign_content(params)
params['sign'] = sign_rsa2(sign_content, cfg['APP_PRIVATE_KEY'])
# 服务器主动调用网关
resp = requests.post(cfg['GATEWAY'], data=params, timeout=10)
try:
result = resp.json()
except Exception:
raise Exception(f"支付宝 precreate 返回非 JSON: {resp.text[:500]}")
biz_resp = result.get('alipay_trade_precreate_response', {})
code = biz_resp.get('code', '')
if code == '10000':
return {
'qr_code': biz_resp.get('qr_code', ''),
'out_trade_no': biz_resp.get('out_trade_no', out_trade_no),
}
# 失败:拼接完整错误信息
sub_code = biz_resp.get('sub_code', '')
sub_msg = biz_resp.get('sub_msg', '')
raise Exception(
f"支付宝预下单失败: code={code}, msg={biz_resp.get('msg', '')}, "
f"sub_code={sub_code}, sub_msg={sub_msg}"
)
def qr_link_to_base64(qr_link):
"""
把二维码链接内容转成 PNG base64 data URI供前端 image 直接展示)。
依赖 qrcode + Pillow缺失时抛 ImportError 提示安装。
"""
import io
import base64
try:
import qrcode
except ImportError:
raise ImportError("缺少 qrcode 库,请执行: pip install qrcode[pil]")
img = qrcode.make(qr_link)
buf = io.BytesIO()
img.save(buf, format='PNG')
return 'data:image/png;base64,' + base64.b64encode(buf.getvalue()).decode('ascii')
# ==================== 辅助 ====================
def club_id_from_order(order_id):
"""从订单反查 club_id与 wechat_pay 一致,暂回落默认)。"""
from orders.models import Order
try:
row = Order.query.filter(OrderID=order_id).first()
if row and getattr(row, 'ClubID', None):
return row.ClubID
except Exception:
pass
return CLUB_ID_DEFAULT
def club_id_from_czjilu(dingdan_id):
"""从 Czjilu 充值单反查 club_id支付宝 czjilu 回调用)。"""
from products.models import Czjilu
try:
row = Czjilu.query.filter(dingdan_id=dingdan_id).first()
if row and getattr(row, 'club_id', None):
return row.club_id
except Exception:
pass
return CLUB_ID_DEFAULT
# ==================== 主动查单alipay.trade.query====================
def query_alipay_trade(out_trade_no, club_id=None):
"""主动查询支付宝交易状态。
调用 alipay.trade.query 接口,返回 trade_status 字符串:
WAIT_BUYER_PAY / TRADE_CLOSED / TRADE_SUCCESS / TRADE_FINISHED
查询失败或异常返回空字符串。
用于轮询补偿:支付宝异步回调未到达时,通过主动查单确认支付状态。
"""
import requests
cfg = get_alipay_config(club_id)
biz_content = {
'out_trade_no': out_trade_no,
}
params = {
'app_id': cfg['APP_ID'],
'method': 'alipay.trade.query',
'charset': 'utf-8',
'sign_type': 'RSA2',
'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'),
'version': '1.0',
'biz_content': json.dumps(biz_content, separators=(',', ':'), ensure_ascii=False),
}
sign_content = _build_sign_content(params)
params['sign'] = sign_rsa2(sign_content, cfg['APP_PRIVATE_KEY'])
try:
resp = requests.post(cfg['GATEWAY'], data=params, timeout=10)
result = resp.json()
except Exception as exc:
logger.error('支付宝查单请求异常 out_trade_no=%s: %s', out_trade_no, exc, exc_info=True)
return ''
biz_resp = result.get('alipay_trade_query_response', {})
code = biz_resp.get('code', '')
trade_status = biz_resp.get('trade_status', '')
if code == '10000':
logger.info('支付宝查单成功 out_trade_no=%s trade_status=%s', out_trade_no, trade_status)
return trade_status
# 40004 = 交易不存在(订单未在支付宝侧创建或已关闭)
sub_code = biz_resp.get('sub_code', '')
sub_msg = biz_resp.get('sub_msg', '')
logger.warning(
'支付宝查单返回非成功 out_trade_no=%s code=%s msg=%s sub_code=%s sub_msg=%s',
out_trade_no, code, biz_resp.get('msg', ''), sub_code, sub_msg,
)
return trade_status or ''

View File

@@ -0,0 +1,18 @@
"""老小程序兼容:统一 JSON 错误格式(勿改前端时靠后端适配)。"""
from rest_framework import status
from rest_framework.response import Response
def legacy_miniapp_error(code, message, http_status=status.HTTP_200_OK):
"""
业务错误返回 HTTP 200 + body.code。
老端 request.js 会把 HTTP 403 当成「请先登录」,导致只显示「注册失败」。
同时写 message 与 msg兼容 dashouzhuce / wdlyhdl 两种字段名。
"""
text = message or '操作失败'
return Response({
'code': code,
'message': text,
'msg': text,
'data': None,
}, status=http_status)

View File

@@ -1,7 +1,7 @@
"""俱乐部维度财务统计(供 /jituan/houtai/caiwu旧 /houtai/caiwu 不变)。""" """俱乐部维度财务统计(供 /jituan/houtai/caiwu旧 /houtai/caiwu 不变)。"""
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from django.db.models import Sum from django.db.models import Count, Q, Sum
from jituan.constants import DATA_SCOPE_ALL from jituan.constants import DATA_SCOPE_ALL
from jituan.services.club_context import ( from jituan.services.club_context import (
@@ -61,31 +61,35 @@ def build_caiwu_payload(request):
today_income_amount, today_income_count = today_income_for_dashboard(today, request) today_income_amount, today_income_count = today_income_for_dashboard(today, request)
today_payout_amount, today_payout_count = sum_payout_stat_for_date(today, request) today_payout_amount, today_payout_count = sum_payout_stat_for_date(today, request)
today_orders = order_qs.filter( # 合并订单统计6 次独立 count/aggregate → 1 次 aggregate
_today_order_agg = order_qs.filter(
CreateTime__gte=today_start, CreateTime__gte=today_start,
CreateTime__lt=today_end, CreateTime__lt=today_end,
Status__in=[1, 2, 3, 4, 5, 6, 7, 8], Status__in=[1, 2, 3, 4, 5, 6, 7, 8],
).aggregate(
today_order_count=Count('id'),
today_order_amount=Sum('Amount'),
today_completed_count=Count('id', filter=Q(Status=3)),
today_completed_amount=Sum('Amount', filter=Q(Status=3)),
today_tuikuan_count=Count('id', filter=Q(Status=5)),
today_tuikuan_amount=Sum('Amount', filter=Q(Status=5)),
) )
today_order_count = today_orders.count() today_order_count = _today_order_agg['today_order_count'] or 0
today_order_amount = float(today_orders.aggregate(total=Sum('Amount'))['total'] or 0.00) today_order_amount = float(_today_order_agg['today_order_amount'] or 0.00)
today_completed_count = _today_order_agg['today_completed_count'] or 0
today_completed_amount = float(_today_order_agg['today_completed_amount'] or 0.00)
today_tuikuan_count = _today_order_agg['today_tuikuan_count'] or 0
today_tuikuan_amount = float(_today_order_agg['today_tuikuan_amount'] or 0.00)
today_completed = order_qs.filter( # 合并充值统计2 次独立 count/aggregate → 1 次 aggregate
CreateTime__gte=today_start, CreateTime__lt=today_end, Status=3, _today_chongzhi_agg = cz_qs.filter(
)
today_completed_count = today_completed.count()
today_completed_amount = float(today_completed.aggregate(total=Sum('Amount'))['total'] or 0.00)
today_tuikuan = order_qs.filter(
CreateTime__gte=today_start, CreateTime__lt=today_end, Status=5,
)
today_tuikuan_count = today_tuikuan.count()
today_tuikuan_amount = float(today_tuikuan.aggregate(total=Sum('Amount'))['total'] or 0.00)
today_chongzhi = cz_qs.filter(
CreateTime__gte=today_start, CreateTime__lt=today_end, leixing=1, zhuangtai=3, CreateTime__gte=today_start, CreateTime__lt=today_end, leixing=1, zhuangtai=3,
).aggregate(
count=Count('id'),
total=Sum('jine'),
) )
today_chongzhi_count = today_chongzhi.count() today_chongzhi_count = _today_chongzhi_agg['count'] or 0
today_chongzhi_amount = float(today_chongzhi.aggregate(total=Sum('jine'))['total'] or 0.00) today_chongzhi_amount = float(_today_chongzhi_agg['total'] or 0.00)
new_huiyuan_user_ids = hy_qs.filter( new_huiyuan_user_ids = hy_qs.filter(
CreateTime__gte=today_start, CreateTime__lt=today_end, CreateTime__gte=today_start, CreateTime__lt=today_end,

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

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