From 280d8742c97db9cbd9b2d2ad72ff6f581785f05d Mon Sep 17 00:00:00 2001 From: XingQue Date: Sun, 26 Jul 2026 01:20:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8F=90=E7=8E=B0=E5=AE=A1=E6=A0=B8?= =?UTF-8?q?=E6=8C=89=E4=BF=B1=E4=B9=90=E9=83=A8=E9=9A=94=E7=A6=BB=E5=B9=B6?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA=E6=89=93=E6=AC=BE=E5=95=86=E6=88=B7=E5=8F=B7?= =?UTF-8?q?=EF=BC=8C=E6=96=B0=E5=A2=9E=E5=B0=8F=E6=BA=AA=E5=AE=A1=E6=A0=B8?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E5=A4=8D=E5=88=B6=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../commands/copy_lxs_shenhe_catalog_to_xx.py | 387 ++++++++++++++++++ .../commands/seed_club_withdraw_audit_perm.py | 153 +++++++ .../commands/seed_gvsdsdk_permissions.py | 6 + jituan/services/group_finance_gate.py | 41 +- jituan/services/tixian_club.py | 24 ++ jituan/services/wechat_pay.py | 32 +- users/tixian_shenhe_services.py | 6 +- users/tixian_shenhe_views.py | 22 +- users/views/admin.py | 21 +- users/views/kefu_withdraw.py | 7 +- 10 files changed, 661 insertions(+), 38 deletions(-) create mode 100644 jituan/management/commands/copy_lxs_shenhe_catalog_to_xx.py create mode 100644 jituan/management/commands/seed_club_withdraw_audit_perm.py diff --git a/jituan/management/commands/copy_lxs_shenhe_catalog_to_xx.py b/jituan/management/commands/copy_lxs_shenhe_catalog_to_xx.py new file mode 100644 index 0000000..3064149 --- /dev/null +++ b/jituan/management/commands/copy_lxs_shenhe_catalog_to_xx.py @@ -0,0 +1,387 @@ +# -*- coding: utf-8 -*- +""" +把龙先生(lxs)审核版商品目录复制到小溪(xx)。 + +范围(必须全部满足): + - 源俱乐部固定 lxs(name 含「龙先生」) + - 目标俱乐部固定 xx(name 含「小溪」或「小西」) + - 仅全局 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)重新编译验证。' + )) diff --git a/jituan/management/commands/seed_club_withdraw_audit_perm.py b/jituan/management/commands/seed_club_withdraw_audit_perm.py new file mode 100644 index 0000000..499d317 --- /dev/null +++ b/jituan/management/commands/seed_club_withdraw_audit_perm.py @@ -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 任职。' + ) + ) diff --git a/jituan/management/commands/seed_gvsdsdk_permissions.py b/jituan/management/commands/seed_gvsdsdk_permissions.py index 7a66353..9a3b17d 100644 --- a/jituan/management/commands/seed_gvsdsdk_permissions.py +++ b/jituan/management/commands/seed_gvsdsdk_permissions.py @@ -44,6 +44,12 @@ EXTRA_PERMISSIONS = [ '管理小程序身份装饰标签(与用户管理-身份装饰标签菜单对应)', '用户管理', ), + ( + '005bb', + '提现审核(按俱乐部)', + '审核本俱乐部提现(同意/拒绝/自动打款)。跨俱乐部仅集团高管;须角色 ClubID + 数据范围任职。', + '提现管理', + ), # ---------- 店铺管理(999 系列:列表/详情/复制/提现等) ---------- ('999a', '店铺状态管理', '修改店铺封禁/正常状态', '店铺管理'), ('999b', '店铺余额与创建', '修改可提现余额、添加新店铺', '店铺管理'), diff --git a/jituan/services/group_finance_gate.py b/jituan/services/group_finance_gate.py index 1e22d14..d038dd9 100644 --- a/jituan/services/group_finance_gate.py +++ b/jituan/services/group_finance_gate.py @@ -1,11 +1,24 @@ -"""集团级资金处置权限:提现审核、加余额等。俱乐部 000001 超级管理不能绕过。""" +"""集团级资金处置权限:加余额等仍仅集团高管。 + +提现审核:权限码 005bb + 按俱乐部隔离—— +- 子公司视图:持有 005bb 且任职含当前俱乐部 → 可审本俱乐部(同意/拒绝/自动打款) +- 集团汇总视图 / 跨俱乐部:须集团资金处置权(GROUP_OWNER / GROUP_SUPER_ADMIN) +俱乐部本地 000001 不能绕过「跨店」闸门,但可审本店提现。 +""" from django.db.models import Q from jituan.constants import DATA_SCOPE_ALL from jituan.models import AdminAssignment -from jituan.services.admin_context import GROUP_MANAGE_ROLES, build_admin_club_context, is_system_super_admin +from jituan.services.admin_context import ( + GROUP_MANAGE_ROLES, + build_admin_club_context, + get_allowed_club_ids, + is_system_super_admin, +) MSG_GROUP_FINANCE_REQUIRED = '该操作仅集团高管可执行(需在数据范围配置中为集团级任职)' +MSG_WITHDRAW_CROSS_CLUB = '跨俱乐部提现审核仅集团高管可执行,请切换到具体俱乐部后再操作' +MSG_WITHDRAW_CLUB_DENIED = '无权审核该俱乐部提现' def has_group_finance_exec(user, request=None): @@ -35,16 +48,32 @@ def deny_group_finance_exec(user, request=None): def require_withdraw_audit_access(request, phone): - """提现审核:须 005bb 且集团资金处置权。""" + """ + 提现审核访问: + 1) 须权限码 005bb + 2) 集团高管:可在 ALL_CLUBS 或任意子公司视图操作 + 3) 俱乐部管理员:仅 SINGLE_CLUB 且当前 club 在其任职范围内 + """ from backend.utils import verify_kefu_permission from rest_framework.response import Response + from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope kefu, permissions = verify_kefu_permission(request, phone) if kefu is None: return None, permissions if '005bb' not in permissions: return None, Response({'code': 403, 'msg': '您无权操作提现审核'}) - deny = deny_group_finance_exec(request.user, request) - if deny: - return None, deny + + if has_group_finance_exec(request.user, request): + return kefu, None + + scope = resolve_club_scope(request) + if scope == DATA_SCOPE_ALL: + return None, Response({'code': 403, 'msg': MSG_WITHDRAW_CROSS_CLUB}) + + club_id = resolve_club_id_from_request(request) + allowed = get_allowed_club_ids(request.user) + if not club_id or club_id not in allowed: + return None, Response({'code': 403, 'msg': MSG_WITHDRAW_CLUB_DENIED}) + return kefu, None diff --git a/jituan/services/tixian_club.py b/jituan/services/tixian_club.py index e8a5895..a636ff6 100644 --- a/jituan/services/tixian_club.py +++ b/jituan/services/tixian_club.py @@ -15,6 +15,24 @@ def club_id_for_tixian_yonghuid_safe(yonghuid): return club_id_for_tixian_yonghuid(yonghuid) +def resolve_payout_club_id(*, audit=None, jilu=None, yonghuid=None, user=None): + """ + 打款商户归属俱乐部:优先提现单/审核单上的 club_id,再回落用户归属。 + 避免用户迁店后用新俱乐部商户号打旧单资金。 + """ + for obj in (audit, jilu): + if obj is None: + continue + cid = (getattr(obj, 'club_id', None) or '').strip() + if cid: + return cid + if user is not None: + return get_user_club_id(user) or '' + if yonghuid: + return club_id_for_tixian_yonghuid(yonghuid) or '' + return '' + + def filter_tixianjilu_by_request(qs, request): return filter_queryset_by_club(qs, request, club_field='club_id') @@ -22,6 +40,12 @@ def filter_tixianjilu_by_request(qs, request): def forbid_if_tixian_out_of_scope(request, tixian_record): """提现记录不在当前俱乐部范围时返回 Response。""" if resolve_club_scope(request) == DATA_SCOPE_ALL: + from jituan.services.group_finance_gate import has_group_finance_exec + if not has_group_finance_exec(request.user, request): + return Response({ + 'code': 403, + 'msg': '跨俱乐部提现操作仅集团高管可执行,请切换到具体俱乐部', + }) return None club_id = resolve_club_id_from_request(request) rec_club = getattr(tixian_record, 'club_id', None) or club_id_for_tixian_yonghuid_safe( diff --git a/jituan/services/wechat_pay.py b/jituan/services/wechat_pay.py index e278a5c..fd15e4c 100644 --- a/jituan/services/wechat_pay.py +++ b/jituan/services/wechat_pay.py @@ -396,21 +396,27 @@ def get_wechat_v3_config(club_id=None): # 共用商户的俱乐部:默认用 settings 私钥;仅当库中路径存在且文件在盘上才覆盖 # (避免旧库里曾填过、但原先被忽略的无效路径突然接管提现) - private_key_path = path_defaults['PRIVATE_KEY_PATH'] - if cid not in SHARED_WX_MERCHANT_CLUBS: - private_key_path = ( - _pick_club_field(club, cfg_json, 'private_key_path') - or private_key_path + # 非共享俱乐部:严禁回落星阙私钥/商户,缺配置直接空值,由上层拒绝打款 + if cid in SHARED_WX_MERCHANT_CLUBS: + private_key_path = path_defaults['PRIVATE_KEY_PATH'] + if club: + uploaded_key = (getattr(club, 'private_key_path', None) or '').strip() + if uploaded_key and os.path.isfile(uploaded_key): + private_key_path = uploaded_key + platform_cert_dir = ( + _pick_club_field(club, cfg_json, 'platform_cert_dir') + or path_defaults['PLATFORM_CERT_DIR'] ) - elif club: - uploaded_key = (getattr(club, 'private_key_path', None) or '').strip() - if uploaded_key and os.path.isfile(uploaded_key): - private_key_path = uploaded_key + else: + private_key_path = _pick_club_field(club, cfg_json, 'private_key_path') + platform_cert_dir = _pick_club_field(club, cfg_json, 'platform_cert_dir') + if not mch_id or not api_v3_key or not private_key_path: + logger.error( + '[WX_V3_CONFIG] club=%s 提现商户配置不完整(mch=%s key=%s pem=%s),' + '禁止回落星阙商户,避免串号打款', + cid, bool(mch_id), bool(api_v3_key), bool(private_key_path), + ) - platform_cert_dir = ( - _pick_club_field(club, cfg_json, 'platform_cert_dir') - or path_defaults['PLATFORM_CERT_DIR'] - ) transfer_scene_id = ( _pick_club_field(club, cfg_json, 'transfer_scene_id', 'TRANSFER_SCENE_ID') or path_defaults['TRANSFER_SCENE_ID'] diff --git a/users/tixian_shenhe_services.py b/users/tixian_shenhe_services.py index dcb66dc..929412e 100644 --- a/users/tixian_shenhe_services.py +++ b/users/tixian_shenhe_services.py @@ -1105,7 +1105,7 @@ def query_wechat_transfer_bill(out_bill_no, club_id=None): try: rec = TixianAutoRecord.query.filter(tixian_id=out_bill_no).first() if rec: - club_id = club_id_for_tixian_yonghuid(rec.yonghuid) + club_id = (getattr(rec, 'club_id', None) or '').strip() or club_id_for_tixian_yonghuid(rec.yonghuid) except Exception: pass @@ -1152,8 +1152,8 @@ def _parse_package_info(raw): def _apply_platform_accounting(auto_record): """平台收支统计(幂等由 mark_transfer_success 外层保证只调一次)""" from jituan.services.szjilu_accounting import apply_szjilu_expense - from jituan.services.wechat_pay import club_id_for_tixian_yonghuid - payout_club = club_id_for_tixian_yonghuid(auto_record.yonghuid) + from jituan.services.tixian_club import resolve_payout_club_id + payout_club = resolve_payout_club_id(audit=None, jilu=auto_record, yonghuid=auto_record.yonghuid) update_daily_payout(auto_record.shijidaozhang, payout_club) apply_szjilu_expense(auto_record.shijidaozhang, payout_club) diff --git a/users/tixian_shenhe_views.py b/users/tixian_shenhe_views.py index 5661431..d54881a 100644 --- a/users/tixian_shenhe_views.py +++ b/users/tixian_shenhe_views.py @@ -202,17 +202,12 @@ def process_audit_collect(request): try: from users.tixian_shenhe_services import get_auto_withdraw_openid - from jituan.services.club_user import get_user_club_id from jituan.services.wechat_pay import get_wechat_v3_config + from jituan.services.tixian_club import resolve_payout_club_id - payout_club_id = get_user_club_id(user_main) payout_openid = get_auto_withdraw_openid(user_main) if not payout_openid: return Response({'code': 10, 'msg': '用户未绑定微信,无法收款'}) - wx_cfg = get_wechat_v3_config(payout_club_id) - if not wx_cfg.get('APPID') or not wx_cfg.get('MCHID') or not wx_cfg.get('TRANSFER_SCENE_ID'): - logger.error('微信打款配置不完整 club=%s APPID=%s MCHID=%s', payout_club_id, wx_cfg.get('APPID'), wx_cfg.get('MCHID')) - return Response({'code': 11, 'msg': '系统配置异常,请联系客服'}) ctx, err = resolve_collect_context( yonghuid, @@ -228,6 +223,18 @@ def process_audit_collect(request): tixianjilu_id_val = ctx['jilu'].id if ctx.get('jilu') else audit.tixianjilu_id leixing = audit.leixing + # 打款商户严格跟提现单 club_id,禁止因用户迁店串到其他俱乐部商户号 + payout_club_id = resolve_payout_club_id( + audit=audit, jilu=ctx.get('jilu'), user=user_main, + ) + wx_cfg = get_wechat_v3_config(payout_club_id) + if not wx_cfg.get('APPID') or not wx_cfg.get('MCHID') or not wx_cfg.get('TRANSFER_SCENE_ID'): + logger.error( + '微信打款配置不完整 club=%s APPID=%s MCHID=%s audit=%s', + payout_club_id, wx_cfg.get('APPID'), wx_cfg.get('MCHID'), getattr(audit, 'id', None), + ) + return Response({'code': 11, 'msg': '系统配置异常,请联系客服'}) + amount_ok, amount_msg = validate_collect_amount(audit) if not amount_ok: with transaction.atomic(): @@ -303,10 +310,9 @@ def process_audit_collect(request): quota_reserved = True tixian_id = generate_tixian_id() - from jituan.services.club_user import get_user_club_id TixianAutoRecord.query.create( tixian_id=tixian_id, - club_id=get_user_club_id(user_main), + club_id=payout_club_id, yonghuid=yonghuid, leixing=leixing, jine=jine, diff --git a/users/views/admin.py b/users/views/admin.py index 3adef87..36db630 100644 --- a/users/views/admin.py +++ b/users/views/admin.py @@ -2285,6 +2285,8 @@ class AddtxshView(APIView): # 🔴【修改】排序改为按申请时间升序(最早申请排最前) queryset = Tixianjilu.query.filter(query).order_by('CreateTime') + from jituan.services.tixian_club import filter_tixianjilu_by_request + queryset = filter_tixianjilu_by_request(queryset, request) # 🔴【修改】分页逻辑:如果是搜索模式,不分页,返回所有匹配记录 if search_uid: @@ -2297,8 +2299,11 @@ class AddtxshView(APIView): current_count = len(records) has_more = (offset + current_count) < total_count - # 获取状态统计(统计与搜索无关,依然按筛选条件统计) - _agg = Tixianjilu.query.filter(leixing=leixing).aggregate( + # 获取状态统计(统计与搜索无关,依然按筛选条件统计;按俱乐部隔离) + _status_qs = filter_tixianjilu_by_request( + Tixianjilu.query.filter(leixing=leixing), request, + ) + _agg = _status_qs.aggregate( awaiting=Count('id', filter=Q(zhuangtai=1)), processed=Count('id', filter=Q(zhuangtai__in=[2, 3])), ) @@ -2393,6 +2398,11 @@ class AdtixianqkView(APIView): 'data': None }, status=status.HTTP_404_NOT_FOUND) + from jituan.services.tixian_club import forbid_if_tixian_out_of_scope + deny = forbid_if_tixian_out_of_scope(request, withdrawal) + if deny: + return deny + # 检查状态,只有待审核的可以处理 if withdrawal.zhuangtai != 1: return Response({ @@ -2418,9 +2428,10 @@ class AdtixianqkView(APIView): try: from jituan.services.szjilu_accounting import apply_szjilu_expense tixian_jine = withdrawal.jine - from jituan.services.club_user import get_user_club_id - payout_club = get_user_club_id( - User.query.filter(UserUID=withdrawal.yonghuid).first() + from jituan.services.tixian_club import resolve_payout_club_id + payout_club = resolve_payout_club_id( + jilu=withdrawal, + user=User.query.filter(UserUID=withdrawal.yonghuid).first(), ) update_daily_payout(tixian_jine, payout_club) apply_szjilu_expense(tixian_jine, payout_club) diff --git a/users/views/kefu_withdraw.py b/users/views/kefu_withdraw.py index 6761ccf..bb0ae28 100644 --- a/users/views/kefu_withdraw.py +++ b/users/views/kefu_withdraw.py @@ -699,14 +699,15 @@ class KefuWithdrawActionView(APIView): except User.DoesNotExist: raise ValueError(f'用户{tixian.yonghuid}不存在') - from jituan.services.club_user import get_user_club_id + from jituan.services.tixian_club import resolve_payout_club_id if action == 1: tixian.zhuangtai = 2 try: from jituan.services.szjilu_accounting import apply_szjilu_expense - update_daily_payout(tixian.jine, get_user_club_id(user)) - apply_szjilu_expense(tixian.jine, get_user_club_id(user)) + payout_club = resolve_payout_club_id(jilu=tixian, user=user) + update_daily_payout(tixian.jine, payout_club) + apply_szjilu_expense(tixian.jine, payout_club) except Exception as e: logger.error(f'更新收支记录失败: {e}') else: