From 0ebe5b4872f6d5d454902b425739b357c5030583 Mon Sep 17 00:00:00 2001 From: XingQue Date: Wed, 29 Jul 2026 00:15:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B7=A8=E4=BF=B1=E4=B9=90=E9=83=A8?= =?UTF-8?q?=E8=B5=84=E4=BA=A7=E8=BF=81=E7=A7=BB=EF=BC=88=E8=AE=A1=E5=88=92?= =?UTF-8?q?/=E4=BB=A4=E7=89=8C/=E6=B5=81=E6=B0=B4=20+=20=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=B8=8E=E5=90=8E=E5=8F=B0=20API=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- jituan/migrations/0021_club_migrate.py | 174 ++++++ jituan/models.py | 153 +++++ jituan/services/club_migrate.py | 715 +++++++++++++++++++++++ jituan/services/club_migrate_assets.py | 62 ++ jituan/services/club_migrate_qr.py | 89 +++ jituan/services/kefu_menu.py | 8 +- jituan/services/kefu_menu_definitions.py | 2 + jituan/urls.py | 18 + jituan/views_club_migrate.py | 438 ++++++++++++++ 9 files changed, 1658 insertions(+), 1 deletion(-) create mode 100644 jituan/migrations/0021_club_migrate.py create mode 100644 jituan/services/club_migrate.py create mode 100644 jituan/services/club_migrate_assets.py create mode 100644 jituan/services/club_migrate_qr.py create mode 100644 jituan/views_club_migrate.py diff --git a/jituan/migrations/0021_club_migrate.py b/jituan/migrations/0021_club_migrate.py new file mode 100644 index 0000000..2784de4 --- /dev/null +++ b/jituan/migrations/0021_club_migrate.py @@ -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'), + ), + ] diff --git a/jituan/models.py b/jituan/models.py index 29c39a3..42985c6 100644 --- a/jituan/models.py +++ b/jituan/models.py @@ -740,3 +740,156 @@ class DealStatEvent(QModel): 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'), + ] diff --git a/jituan/services/club_migrate.py b/jituan/services/club_migrate.py new file mode 100644 index 0000000..fb25089 --- /dev/null +++ b/jituan/services/club_migrate.py @@ -0,0 +1,715 @@ +"""跨俱乐部资产迁移核心服务(独立模块,未开计划时零影响)。 + +P0:所有成功过户必须写 Ledger + Item;金额以锁库实读为准;令牌单次消费;占用表防双迁。 +""" +from __future__ import annotations + +import hashlib +import logging +import secrets +import uuid +from datetime import timedelta +from decimal import Decimal, ROUND_HALF_UP +from typing import Dict, List, Optional, Tuple + +from django.db import transaction +from django.db.models import F, Q +from django.utils import timezone + +from jituan.models import ( + ClubMigrateConsumption, + ClubMigrateLedger, + ClubMigrateLedgerItem, + ClubMigratePlan, + ClubMigrateToken, + ClubMigrateUserQuota, +) +from jituan.services.club_migrate_assets import ( + ASSET_CATALOG, + ROLE_PROFILE_ATTR, + normalize_whitelist, + roles_from_whitelist, +) + +logger = logging.getLogger(__name__) + +TOKEN_TTL_HOURS = 24 +PASSWORD_MAX_FAIL = 5 +SCENE_PREFIX = 'MIG' +# 未完结订单:待抢/进行中/退款审/指定中/结算中/未支付 +OPEN_ORDER_STATUSES = (1, 2, 4, 7, 8, 9) +# 未缴清罚单:待缴纳/申诉中/平台审核中 +UNPAID_PENALTY_STATUSES = (1, 3, 5) + + +def _money(v) -> Decimal: + return Decimal(str(v or 0)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + + +def _now(): + return timezone.now() + + +def _hash_password(password: str, salt: str) -> str: + raw = f'{salt}:{password}'.encode('utf-8') + return hashlib.sha256(raw).hexdigest() + + +def new_plan_id() -> str: + return 'mp' + uuid.uuid4().hex[:14] + + +def new_token() -> str: + # 短令牌便于塞进微信 scene(总长 32) + return secrets.token_urlsafe(12).replace('-', '').replace('_', '')[:20] + + +def scene_for_token(token: str) -> str: + return f'{SCENE_PREFIX}{token}' + + +def parse_migrate_scene(scene: str) -> str: + s = str(scene or '').strip() + if s.upper().startswith(SCENE_PREFIX): + return s[len(SCENE_PREFIX):] + return '' + + +def plan_in_window(plan: ClubMigratePlan, now=None) -> bool: + now = now or _now() + if plan.start_at and now < plan.start_at: + return False + if plan.end_at and now > plan.end_at: + return False + return True + + +def find_active_plan_for_source(from_club_id: str) -> Optional[ClubMigratePlan]: + cid = (from_club_id or '').strip() + if not cid: + return None + qs = ClubMigratePlan.query.filter(from_club_id=cid, enabled=True).order_by('-id') + now = _now() + for p in qs[:20]: + if plan_in_window(p, now): + return p + return None + + +def find_active_plan_for_target(to_club_id: str) -> Optional[ClubMigratePlan]: + cid = (to_club_id or '').strip() + if not cid: + return None + qs = ClubMigratePlan.query.filter(to_club_id=cid, enabled=True).order_by('-id') + now = _now() + for p in qs[:20]: + if plan_in_window(p, now): + return p + return None + + +def max_allowed_success(plan: ClubMigratePlan, from_uid: str) -> int: + base = 1 if plan.default_once_per_user else 999 + q = ClubMigrateUserQuota.query.filter(plan_id=plan.plan_id, from_uid=str(from_uid)).first() + extra = int(q.max_extra_times or 0) if q else 0 + return base + extra + + +def success_count(plan_id: str, from_uid: str) -> int: + row = ClubMigrateConsumption.query.filter(plan_id=plan_id, from_uid=str(from_uid)).first() + return int(row.success_count or 0) if row else 0 + + +def can_migrate_again(plan: ClubMigratePlan, from_uid: str) -> bool: + return success_count(plan.plan_id, from_uid) < max_allowed_success(plan, from_uid) + + +def _load_user(uid: str): + from users.business_models import User + return User.query.filter(UserUID=str(uid)).first() + + +def _get_or_create_profile(user, role: str): + """目标店无角色时创建空扩展行(仅迁移模块调用)。""" + from users.models import UserDashou, UserShangjia, UserGuanshi, UserZuzhang, UserShenheguan + + mapping = { + 'dashou': (UserDashou, 'DashouProfile'), + 'shangjia': (UserShangjia, 'ShopProfile'), + 'guanshi': (UserGuanshi, 'GuanshiProfile'), + 'zuzhang': (UserZuzhang, 'ZuzhangProfile'), + 'shenheguan': (UserShenheguan, 'ShenheguanProfile'), + } + model, attr = mapping[role] + try: + return getattr(user, attr) + except Exception: + pass + defaults = {'user': user} + if role == 'shangjia': + defaults['nicheng'] = f'SJ{user.UserUID}' + elif role == 'dashou': + defaults['nicheng'] = f'DS{user.UserUID}' + obj = model.query.create(**defaults) + return obj + + +def _profile_of(user, role: str): + attr = ROLE_PROFILE_ATTR.get(role) + if not attr: + return None + try: + return getattr(user, attr) + except Exception: + return None + + +def _lock_profile(user, role: str): + """事务内锁扩展行;无行返回 None。""" + from users.models import UserDashou, UserShangjia, UserGuanshi, UserZuzhang, UserShenheguan + + mapping = { + 'dashou': UserDashou, + 'shangjia': UserShangjia, + 'guanshi': UserGuanshi, + 'zuzhang': UserZuzhang, + 'shenheguan': UserShenheguan, + } + model = mapping.get(role) + if not model: + return None + return model.objects.select_for_update().filter(user=user).first() + + +def _read_field(profile, field: str, kind: str): + if profile is None: + return Decimal('0.00') if kind != 'int' else 0 + val = getattr(profile, field, None) + if kind == 'int': + try: + return int(val or 0) + except (TypeError, ValueError): + return 0 + return _money(val) + + +def preview_assets(plan: ClubMigratePlan, from_uid: str) -> List[dict]: + keys = normalize_whitelist(plan.asset_whitelist) + user = _load_user(from_uid) + if not user: + return [] + items = [] + for key in keys: + meta = ASSET_CATALOG[key] + role, field, kind = meta['role'], meta['field'], meta['kind'] + if kind == 'huiyuan': + items.extend(_preview_huiyuan(from_uid, plan.from_club_id, role)) + continue + profile = _profile_of(user, role) + amount = _read_field(profile, field, kind) + items.append({ + 'key': key, + 'role': role, + 'field': field, + 'label': meta['label'], + 'kind': kind, + 'amount': str(amount), + 'has_profile': profile is not None, + }) + return items + + +def _preview_huiyuan(uid: str, club_id: str, role: str) -> List[dict]: + from products.models import Huiyuangoumai + now = _now() + rows = list( + Huiyuangoumai.query.filter( + yonghu_id=str(uid), + club_id=club_id, + huiyuan_zhuangtai=1, + daoqi_time__gt=now, + ) + ) + out = [] + for r in rows: + out.append({ + 'key': f'{role}.huiyuan', + 'role': role, + 'field': 'huiyuan', + 'label': f'会员 {r.huiyuan_id}', + 'kind': 'huiyuan', + 'amount': '1', + 'huiyuan_id': r.huiyuan_id, + 'daoqi_time': r.daoqi_time.isoformat() if r.daoqi_time else '', + 'jieshao': r.jieshao or '', + 'has_profile': True, + }) + return out + + +def check_conditions(plan: ClubMigratePlan, from_uid: str) -> Tuple[bool, str]: + """返回 (ok, msg)。""" + cond = plan.condition_json or {} + user = _load_user(from_uid) + if not user: + return False, '源用户不存在' + + if cond.get('require_not_banned', True): + ds = _profile_of(user, 'dashou') + if ds is not None and int(getattr(ds, 'zhanghaozhuangtai', 1) or 1) != 1: + return False, '账号已封禁,无法迁移' + + if cond.get('require_no_unpaid_penalty'): + try: + from orders.models import Penalty + unpaid = Penalty.query.filter( + PenalizedUserID=str(from_uid), + ClubID=plan.from_club_id, + Status__in=list(UNPAID_PENALTY_STATUSES), + ).exists() + if unpaid: + return False, '存在未缴罚单,请先缴清' + except Exception as e: + logger.warning('migrate penalty check failed uid=%s: %s', from_uid, e) + return False, '罚单门槛校验失败,请稍后重试' + + if cond.get('require_orders_closed'): + try: + from orders.models import Order + open_st = Order.objects.filter( + PlayerID=str(from_uid), + ClubID=plan.from_club_id, + Status__in=list(OPEN_ORDER_STATUSES), + ).exists() + if open_st: + return False, '仍有未完结订单,请先结算或退款完成' + except Exception as e: + logger.warning('migrate order check failed uid=%s: %s', from_uid, e) + return False, '订单门槛校验失败,请稍后重试' + + return True, '' + + +def gate_for_user(*, club_id: str, user_uid: str, roles: Optional[List[str]] = None) -> dict: + """小程序启动/进打手端调用。无计划 → 全 false,现网无感。""" + plan = find_active_plan_for_source(club_id) + if not plan: + return { + 'active': False, + 'force': False, + 'show_entry': False, + 'completed': False, + 'plan_id': '', + 'to_club_id': '', + } + done = not can_migrate_again(plan, user_uid) + force_roles = plan.force_roles or ['dashou'] + role_hit = True + if roles: + role_hit = any(r in force_roles for r in roles) + force = bool(plan.force_intercept and role_hit and not done) + return { + 'active': True, + 'force': force, + 'show_entry': True, + 'completed': done, + 'plan_id': plan.plan_id, + 'plan_name': plan.name or '', + 'from_club_id': plan.from_club_id, + 'to_club_id': plan.to_club_id, + 'force_roles': force_roles, + 'success_count': success_count(plan.plan_id, user_uid), + 'max_allowed': max_allowed_success(plan, user_uid), + } + + +@transaction.atomic +def set_password_and_issue( + *, + plan: ClubMigratePlan, + from_uid: str, + password: str, + qrcode_url: str = '', +) -> Tuple[Optional[ClubMigrateToken], str]: + pwd = str(password or '').strip() + if not (pwd.isdigit() and len(pwd) == 6): + return None, '转移密码须为6位数字' + if not can_migrate_again(plan, from_uid): + return None, '本计划已迁移完成,不可再次发码' + ok, msg = check_conditions(plan, from_uid) + if not ok: + return None, msg + + ClubMigrateToken.query.filter( + plan_id=plan.plan_id, + from_uid=str(from_uid), + status=ClubMigrateToken.STATUS_ISSUED, + ).update(status=ClubMigrateToken.STATUS_CANCELLED, UpdateTime=_now()) + + salt = secrets.token_hex(8) + token = new_token() + snap = {'assets': preview_assets(plan, from_uid), 'at': _now().isoformat()} + row = ClubMigrateToken.query.create( + token=token, + plan_id=plan.plan_id, + from_club_id=plan.from_club_id, + to_club_id=plan.to_club_id, + from_uid=str(from_uid), + password_hash=_hash_password(pwd, salt), + password_salt=salt, + status=ClubMigrateToken.STATUS_ISSUED, + asset_snapshot=snap, + qrcode_url=(qrcode_url or '')[:512], + expire_at=_now() + timedelta(hours=TOKEN_TTL_HOURS), + ) + return row, '' + + +def verify_token_password(token_row: ClubMigrateToken, password: str) -> Tuple[bool, str]: + if token_row.status != ClubMigrateToken.STATUS_ISSUED: + return False, '令牌已失效' + if token_row.expire_at and _now() > token_row.expire_at: + token_row.status = ClubMigrateToken.STATUS_EXPIRED + token_row.save(update_fields=['status', 'UpdateTime']) + return False, '令牌已过期,请回源店重新设密出码' + if int(token_row.fail_count or 0) >= PASSWORD_MAX_FAIL: + return False, '密码错误次数过多,请回源店重置密码' + pwd = str(password or '').strip() + expect = _hash_password(pwd, token_row.password_salt) + if expect != token_row.password_hash: + ClubMigrateToken.query.filter(pk=token_row.pk).update(fail_count=F('fail_count') + 1) + left = PASSWORD_MAX_FAIL - int(token_row.fail_count or 0) - 1 + return False, f'转移密码错误(还可试{max(left, 0)}次)' + return True, '' + + +@transaction.atomic +def confirm_migrate( + *, + token: str, + password: str, + to_uid: str, + client_meta: Optional[dict] = None, +) -> Tuple[Optional[dict], str]: + """目标店确认过户。成功返回 ledger 摘要。""" + token_row = ( + ClubMigrateToken.query.select_for_update() + .filter(token=str(token or '').strip()) + .first() + ) + if not token_row: + return None, '令牌无效' + + if token_row.status == ClubMigrateToken.STATUS_CONSUMED: + led = ClubMigrateLedger.query.filter(token=token_row.token).order_by('-id').first() + if led: + return serialize_ledger(led), '' + return None, '令牌已使用' + + ok, msg = verify_token_password(token_row, password) + if not ok: + return None, msg + + plan = ClubMigratePlan.query.filter(plan_id=token_row.plan_id).first() + if not plan or not plan.enabled or not plan_in_window(plan): + return None, '迁移计划未生效' + + if not can_migrate_again(plan, token_row.from_uid): + return None, '本计划已迁移完成' + + ok, msg = check_conditions(plan, token_row.from_uid) + if not ok: + return None, msg + + to_user = _load_user(to_uid) + if not to_user: + return None, '目标用户不存在,请先登录' + from_user = _load_user(token_row.from_uid) + if not from_user: + return None, '源用户不存在' + + ds = _lock_profile(to_user, 'dashou') + if ds is not None and int(getattr(ds, 'zhanghaozhuangtai', 1) or 1) != 1: + return None, '目标账号已封禁' + + cons, _ = ClubMigrateConsumption.query.get_or_create( + plan_id=plan.plan_id, + from_uid=str(token_row.from_uid), + defaults={'success_count': 0}, + ) + cons = ClubMigrateConsumption.query.select_for_update().filter(pk=cons.pk).first() + allowed = max_allowed_success(plan, token_row.from_uid) + if int(cons.success_count or 0) >= allowed: + return None, '本计划已迁移完成' + + keys = normalize_whitelist(plan.asset_whitelist) + # 目标店:白名单涉及角色先建空扩展行 + for role in roles_from_whitelist(keys): + if _profile_of(to_user, role) is None: + _get_or_create_profile(to_user, role) + + items_payload = [] + migrate_seq = int(cons.success_count or 0) + 1 + + for key in keys: + meta = ASSET_CATALOG[key] + if meta['kind'] == 'huiyuan': + items_payload.extend( + _transfer_huiyuan( + from_uid=token_row.from_uid, + to_uid=str(to_uid), + from_club=plan.from_club_id, + to_club=plan.to_club_id, + role=meta['role'], + ) + ) + continue + items_payload.append( + _transfer_balance_field( + from_user=from_user, + to_user=to_user, + role=meta['role'], + field=meta['field'], + kind=meta['kind'], + key=key, + label=meta['label'], + ) + ) + + ledger = ClubMigrateLedger.query.create( + plan_id=plan.plan_id, + token=token_row.token, + from_club_id=plan.from_club_id, + to_club_id=plan.to_club_id, + from_uid=str(token_row.from_uid), + to_uid=str(to_uid), + migrate_seq=migrate_seq, + status=ClubMigrateLedger.STATUS_SUCCESS, + summary_json={'item_count': len(items_payload), 'keys': keys}, + client_meta=client_meta or {}, + ) + for it in items_payload: + ClubMigrateLedgerItem.query.create( + ledger_id=ledger.id, + plan_id=plan.plan_id, + item_type=it['item_type'], + role=it['role'], + field=it.get('field', ''), + amount=_money(it.get('amount') or 0), + before_src=str(it.get('before_src', '')), + after_src=str(it.get('after_src', '')), + before_dst=str(it.get('before_dst', '')), + after_dst=str(it.get('after_dst', '')), + huiyuan_id=str(it.get('huiyuan_id') or ''), + meta_json=it.get('meta_json') or {}, + ) + + cons.success_count = migrate_seq + cons.last_ledger_id = ledger.id + cons.save(update_fields=['success_count', 'last_ledger_id', 'UpdateTime']) + + token_row.status = ClubMigrateToken.STATUS_CONSUMED + token_row.consumed_at = _now() + token_row.save(update_fields=['status', 'consumed_at', 'UpdateTime']) + + logger.info( + 'club_migrate ok plan=%s seq=%s from=%s to=%s ledger=%s items=%s', + plan.plan_id, migrate_seq, token_row.from_uid, to_uid, ledger.id, len(items_payload), + ) + return serialize_ledger(ledger), '' + + +def _transfer_balance_field(*, from_user, to_user, role, field, kind, key, label) -> dict: + src_prof = _lock_profile(from_user, role) + amount = _read_field(src_prof, field, kind) + before_src = amount + after_src = Decimal('0.00') if kind != 'int' else 0 + + if src_prof is not None and amount: + if kind == 'int': + type(src_prof).objects.filter(pk=src_prof.pk).update(**{field: 0}) + else: + type(src_prof).objects.filter(pk=src_prof.pk).update(**{field: Decimal('0.00')}) + + dst_prof = _lock_profile(to_user, role) + created = False + if dst_prof is None: + dst_prof = _get_or_create_profile(to_user, role) + created = True + dst_prof = _lock_profile(to_user, role) + + before_dst = _read_field(dst_prof, field, kind) if dst_prof else (0 if kind == 'int' else Decimal('0.00')) + after_dst = before_dst + if dst_prof is not None and amount: + if kind == 'int': + type(dst_prof).objects.filter(pk=dst_prof.pk).update(**{field: F(field) + int(amount)}) + after_dst = int(before_dst) + int(amount) + else: + type(dst_prof).objects.filter(pk=dst_prof.pk).update(**{field: F(field) + _money(amount)}) + after_dst = _money(before_dst) + _money(amount) + + return { + 'item_type': ClubMigrateLedgerItem.ITEM_BALANCE, + 'role': role, + 'field': field, + 'amount': amount, + 'before_src': before_src, + 'after_src': after_src if amount else before_src, + 'before_dst': before_dst, + 'after_dst': after_dst, + 'meta_json': {'key': key, 'label': label, 'dst_created': created}, + } + + +def _aware(dt): + if dt is None: + return None + if timezone.is_naive(dt): + return timezone.make_aware(dt, timezone.get_current_timezone()) + return dt + + +def _transfer_huiyuan(*, from_uid, to_uid, from_club, to_club, role) -> List[dict]: + from products.models import Huiyuangoumai + + now = _now() + rows = list( + Huiyuangoumai.objects.select_for_update().filter( + yonghu_id=str(from_uid), + club_id=from_club, + huiyuan_zhuangtai=1, + daoqi_time__gt=now, + ) + ) + out = [] + for r in rows: + src_exp = r.daoqi_time + before = { + 'huiyuan_id': r.huiyuan_id, + 'daoqi_time': src_exp.isoformat() if src_exp else '', + 'zhuangtai': r.huiyuan_zhuangtai, + 'is_trial': bool(r.is_trial), + 'jieshao': r.jieshao or '', + } + # 源失效(保留原到期进流水 before) + r.huiyuan_zhuangtai = 0 + r.daoqi_time = now + r.save(update_fields=['huiyuan_zhuangtai', 'daoqi_time', 'UpdateTime']) + + dst = Huiyuangoumai.objects.select_for_update().filter( + yonghu_id=str(to_uid), + huiyuan_id=r.huiyuan_id, + ).first() + before_dst = {} + exp = _aware(src_exp) or now + if dst: + before_dst = { + 'daoqi_time': dst.daoqi_time.isoformat() if dst.daoqi_time else '', + 'zhuangtai': dst.huiyuan_zhuangtai, + 'club_id': dst.club_id, + } + dst_exp = _aware(dst.daoqi_time) + if dst_exp and dst_exp > exp: + exp = dst_exp + dst.daoqi_time = exp + dst.huiyuan_zhuangtai = 1 + dst.club_id = to_club + dst.jieshao = r.jieshao or dst.jieshao + dst.save(update_fields=['daoqi_time', 'huiyuan_zhuangtai', 'club_id', 'jieshao', 'UpdateTime']) + else: + Huiyuangoumai.query.create( + huiyuan_id=r.huiyuan_id, + yonghu_id=str(to_uid), + huiyuan_zhuangtai=1, + jieshao=before.get('jieshao') or '', + daoqi_time=exp, + club_id=to_club, + is_trial=bool(r.is_trial), + has_used_trial=bool(r.has_used_trial), + ) + out.append({ + 'item_type': ClubMigrateLedgerItem.ITEM_HUIYUAN, + 'role': role, + 'field': 'huiyuan', + 'amount': Decimal('1'), + 'before_src': before.get('daoqi_time', ''), + 'after_src': 'expired', + 'before_dst': before_dst.get('daoqi_time', ''), + 'after_dst': exp.isoformat() if exp else '', + 'huiyuan_id': r.huiyuan_id, + 'meta_json': {'before_src': before, 'before_dst': before_dst}, + }) + return out + + +def serialize_ledger(ledger: ClubMigrateLedger) -> dict: + items = list(ClubMigrateLedgerItem.query.filter(ledger_id=ledger.id).order_by('id')) + return { + 'ledger_id': ledger.id, + 'plan_id': ledger.plan_id, + 'from_club_id': ledger.from_club_id, + 'to_club_id': ledger.to_club_id, + 'from_uid': ledger.from_uid, + 'to_uid': ledger.to_uid, + 'migrate_seq': ledger.migrate_seq, + 'status': ledger.status, + 'created_at': ledger.CreateTime.isoformat() if ledger.CreateTime else '', + 'items': [ + { + 'item_type': it.item_type, + 'role': it.role, + 'field': it.field, + 'amount': str(it.amount), + 'before_src': it.before_src, + 'after_src': it.after_src, + 'before_dst': it.before_dst, + 'after_dst': it.after_dst, + 'huiyuan_id': it.huiyuan_id, + 'meta': it.meta_json or {}, + } + for it in items + ], + } + + +def list_user_records(uid: str, limit: int = 50) -> List[dict]: + qs = ( + ClubMigrateLedger.query.filter(Q(from_uid=str(uid)) | Q(to_uid=str(uid))) + .order_by('-id')[: max(1, min(int(limit or 50), 100))] + ) + return [serialize_ledger(x) for x in qs] + + +def serialize_plan(plan: ClubMigratePlan) -> dict: + return { + 'plan_id': plan.plan_id, + 'name': plan.name or '', + 'from_club_id': plan.from_club_id, + 'to_club_id': plan.to_club_id, + 'enabled': bool(plan.enabled), + 'start_at': plan.start_at.isoformat() if plan.start_at else '', + 'end_at': plan.end_at.isoformat() if plan.end_at else '', + 'asset_whitelist': normalize_whitelist(plan.asset_whitelist), + 'condition_json': plan.condition_json or {}, + 'force_intercept': bool(plan.force_intercept), + 'force_roles': plan.force_roles or ['dashou'], + 'default_once_per_user': bool(plan.default_once_per_user), + 'remark': plan.remark or '', + 'updated_by': plan.updated_by or '', + 'created_at': plan.CreateTime.isoformat() if plan.CreateTime else '', + 'updated_at': plan.UpdateTime.isoformat() if plan.UpdateTime else '', + } + + +def serialize_token(token_row: ClubMigrateToken) -> dict: + return { + 'token': token_row.token, + 'scene': scene_for_token(token_row.token), + 'plan_id': token_row.plan_id, + 'from_club_id': token_row.from_club_id, + 'to_club_id': token_row.to_club_id, + 'status': token_row.status, + 'qrcode_url': token_row.qrcode_url or '', + 'expire_at': token_row.expire_at.isoformat() if token_row.expire_at else '', + 'asset_snapshot': token_row.asset_snapshot or {}, + } diff --git a/jituan/services/club_migrate_assets.py b/jituan/services/club_migrate_assets.py new file mode 100644 index 0000000..7ea1cff --- /dev/null +++ b/jituan/services/club_migrate_assets.py @@ -0,0 +1,62 @@ +"""跨俱乐部资产迁移 — 资产字段白名单字典(只迁登记过的字段)。""" +from __future__ import annotations + +from typing import Dict, List, Tuple + +# key = "role.field" ;huiyuan 为特殊包 +ASSET_CATALOG: Dict[str, dict] = { + 'dashou.yue': {'role': 'dashou', 'field': 'yue', 'label': '打手可提现', 'kind': 'money'}, + 'dashou.yajin': {'role': 'dashou', 'field': 'yajin', 'label': '打手押金', 'kind': 'money'}, + 'dashou.jifen': {'role': 'dashou', 'field': 'jifen', 'label': '打手积分', 'kind': 'int'}, + 'dashou.dongjie_yue': {'role': 'dashou', 'field': 'dongjie_yue', 'label': '打手冻结资金', 'kind': 'money'}, + 'dashou.huiyuan': {'role': 'dashou', 'field': 'huiyuan', 'label': '打手会员资格', 'kind': 'huiyuan'}, + 'shangjia.yue': {'role': 'shangjia', 'field': 'yue', 'label': '商家余额', 'kind': 'money'}, + 'guanshi.yue': {'role': 'guanshi', 'field': 'yue', 'label': '管事可提现', 'kind': 'money'}, + 'guanshi.dongjie_yue': {'role': 'guanshi', 'field': 'dongjie_yue', 'label': '管事冻结资金', 'kind': 'money'}, + 'zuzhang.ketixian_jine': {'role': 'zuzhang', 'field': 'ketixian_jine', 'label': '组长可提现', 'kind': 'money'}, + 'zuzhang.dongjie_yue': {'role': 'zuzhang', 'field': 'dongjie_yue', 'label': '组长冻结资金', 'kind': 'money'}, + 'shenheguan.yue': {'role': 'shenheguan', 'field': 'yue', 'label': '考核官可提现', 'kind': 'money'}, + 'shenheguan.dongjie_yue': {'role': 'shenheguan', 'field': 'dongjie_yue', 'label': '考核官冻结资金', 'kind': 'money'}, +} + +ROLE_PROFILE_ATTR = { + 'dashou': 'DashouProfile', + 'shangjia': 'ShopProfile', + 'guanshi': 'GuanshiProfile', + 'zuzhang': 'ZuzhangProfile', + 'shenheguan': 'ShenheguanProfile', +} + + +def normalize_whitelist(raw) -> List[str]: + if not raw: + return [] + out = [] + seen = set() + for x in raw: + k = str(x or '').strip() + if k in ASSET_CATALOG and k not in seen: + seen.add(k) + out.append(k) + return out + + +def catalog_for_admin() -> List[dict]: + return [ + {'key': k, 'label': v['label'], 'role': v['role'], 'field': v['field'], 'kind': v['kind']} + for k, v in ASSET_CATALOG.items() + ] + + +def roles_from_whitelist(keys: List[str]) -> List[str]: + roles = [] + seen = set() + for k in keys: + meta = ASSET_CATALOG.get(k) + if not meta: + continue + r = meta['role'] + if r not in seen: + seen.add(r) + roles.append(r) + return roles diff --git a/jituan/services/club_migrate_qr.py b/jituan/services/club_migrate_qr.py new file mode 100644 index 0000000..4eba410 --- /dev/null +++ b/jituan/services/club_migrate_qr.py @@ -0,0 +1,89 @@ +"""迁移码:按目标俱乐部 AppID 生成无限小程序码并上传 COS。""" +from __future__ import annotations + +import io +import logging +import time +from typing import Optional, Tuple + +import requests +from django.conf import settings + +from jituan.services.club_migrate import SCENE_PREFIX, scene_for_token + +logger = logging.getLogger(__name__) + +RECEIVE_PAGE = 'pages/migrate/receive' + + +def generate_migrate_qrcode(*, to_club_id: str, token: str) -> Tuple[str, str]: + """返回 (relative_url, err)。成功时 err 为空。""" + from utils.weixin_token import get_club_mini_access_token, is_weixin_token_invalid + + club_id = (to_club_id or '').strip() + if not club_id: + return '', '缺少目标俱乐部' + scene = scene_for_token(token) + if len(scene) > 32: + return '', 'scene 超长' + if not scene.startswith(SCENE_PREFIX): + return '', 'scene 非法' + + access_token = get_club_mini_access_token(club_id) + if not access_token: + return '', f'俱乐部 {club_id} 获取微信 token 失败' + + post_data = { + 'scene': scene, + 'page': RECEIVE_PAGE, + 'width': 430, + 'auto_color': False, + 'line_color': {'r': 0, 'g': 0, 'b': 0}, + 'check_path': False, + } + + def _req(tok: str): + url = f'https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={tok}' + return requests.post(url, json=post_data, timeout=15) + + wx_resp = _req(access_token) + if wx_resp.headers.get('content-type', '').startswith('application/json'): + try: + err = wx_resp.json() + if is_weixin_token_invalid(err.get('errcode'), err.get('errmsg', '')): + access_token = get_club_mini_access_token(club_id, force_refresh=True) + if access_token: + wx_resp = _req(access_token) + except Exception: + pass + + content_type = wx_resp.headers.get('content-type', '') + if wx_resp.status_code != 200 or 'image' not in content_type: + errmsg = '未知错误' + try: + error_info = wx_resp.json() + errmsg = error_info.get('errmsg', errmsg) + logger.error('migrate qr fail club=%s err=%s', club_id, error_info) + except Exception: + logger.error('migrate qr fail club=%s status=%s', club_id, wx_resp.status_code) + return '', f'生成小程序码失败: {errmsg}' + + from utils.oss_utils import upload_to_oss + + filename = f"migrate_{club_id}_{token}_{int(time.time())}.png" + oss_path = f"club_migrate/{club_id}/{filename}" + file_obj = io.BytesIO(wx_resp.content) + try: + full_url = upload_to_oss(file_obj, oss_path) + except Exception as e: + logger.exception('migrate qr upload fail: %s', e) + return '', f'上传小程序码失败: {e}' + if not full_url: + return '', '上传小程序码失败' + + cos_domain = getattr(settings, 'COS_DOMAIN', '').rstrip('/') + if cos_domain and full_url.startswith(cos_domain): + relative_path = full_url.replace(f'{cos_domain}/', '', 1) + else: + relative_path = oss_path + return relative_path, '' diff --git a/jituan/services/kefu_menu.py b/jituan/services/kefu_menu.py index 2791700..a9479e1 100644 --- a/jituan/services/kefu_menu.py +++ b/jituan/services/kefu_menu.py @@ -101,7 +101,13 @@ def _load_menu_pages(): pages = [] need_seed = not pages or not any( - p.page_id in ('miniapp.assets', 'product.fake-orders', 'miniapp.recharge-copy') for p in pages + p.page_id in ( + 'miniapp.assets', + 'product.fake-orders', + 'miniapp.recharge-copy', + 'admin.club-migrate', + ) + for p in pages ) if need_seed: try: diff --git a/jituan/services/kefu_menu_definitions.py b/jituan/services/kefu_menu_definitions.py index 9fd7639..51c28ea 100644 --- a/jituan/services/kefu_menu_definitions.py +++ b/jituan/services/kefu_menu_definitions.py @@ -64,6 +64,8 @@ KEFU_MENU_ROW_DEFS = [ 'require_group_manage': True}, {'page_id': 'admin.order-grab-share', 'name': '订单互通抢单', 'path': '/admin/order-grab-share', 'parent_id': 'admin', 'sort_order': 76, 'require_group_manage': True}, + {'page_id': 'admin.club-migrate', 'name': '跨店资产迁移', 'path': '/admin/club-migrate', 'parent_id': 'admin', 'sort_order': 77, + 'require_group_manage': True}, {'page_id': 'admin.user', 'name': '后台用户', 'path': '/admin/user', 'parent_id': 'admin', 'sort_order': 74, 'require_super': True, 'require_admin_user_manage': True}, {'page_id': 'admin.operation-log', 'name': '操作日志', 'path': '/admin/operation-log', 'parent_id': 'admin', 'sort_order': 75, diff --git a/jituan/urls.py b/jituan/urls.py index 698a118..79e3495 100644 --- a/jituan/urls.py +++ b/jituan/urls.py @@ -106,6 +106,16 @@ from jituan.views_fund_freeze import ( FundFreezeLedgerView, FundFreezeRiskView, ) +from jituan.views_club_migrate import ( + ClubMigrateAdminView, + ClubMigrateConfirmView, + ClubMigrateGateView, + ClubMigrateIssueQrView, + ClubMigrateMyRecordsView, + ClubMigratePreviewView, + ClubMigrateSetPasswordView, + ClubMigrateTokenInfoView, +) from jituan.views_role_agreement import RoleAgreementAdminView from jituan.views_order_deal import ( OrderDealConfigView, @@ -126,6 +136,14 @@ urlpatterns = [ path('houtai/fund-freeze-config', FundFreezeConfigView.as_view(), name='jituan_fund_freeze_config'), path('houtai/fund-freeze-ledger', FundFreezeLedgerView.as_view(), name='jituan_fund_freeze_ledger'), path('houtai/fund-freeze-risk', FundFreezeRiskView.as_view(), name='jituan_fund_freeze_risk'), + path('houtai/club-migrate', ClubMigrateAdminView.as_view(), name='jituan_club_migrate_admin'), + path('migrate/gate', ClubMigrateGateView.as_view(), name='jituan_migrate_gate'), + path('migrate/preview', ClubMigratePreviewView.as_view(), name='jituan_migrate_preview'), + path('migrate/set_password', ClubMigrateSetPasswordView.as_view(), name='jituan_migrate_set_password'), + path('migrate/issue_qr', ClubMigrateIssueQrView.as_view(), name='jituan_migrate_issue_qr'), + path('migrate/confirm', ClubMigrateConfirmView.as_view(), name='jituan_migrate_confirm'), + path('migrate/my_records', ClubMigrateMyRecordsView.as_view(), name='jituan_migrate_my_records'), + path('migrate/token_info', ClubMigrateTokenInfoView.as_view(), name='jituan_migrate_token_info'), path('houtai/role-agreement', RoleAgreementAdminView.as_view(), name='jituan_role_agreement'), path('houtai/order-deal-config', OrderDealConfigView.as_view(), name='jituan_order_deal_config'), path('houtai/merchant-deal-stat', MerchantDealStatView.as_view(), name='jituan_merchant_deal_stat'), diff --git a/jituan/views_club_migrate.py b/jituan/views_club_migrate.py new file mode 100644 index 0000000..124b6af --- /dev/null +++ b/jituan/views_club_migrate.py @@ -0,0 +1,438 @@ +"""跨俱乐部资产迁移 API(用户端 + 客服后台)。全新接口,旧逻辑零侵入。""" +from __future__ import annotations + +import logging +from datetime import datetime + +from django.db.models import Q +from django.utils import timezone +from django.utils.dateparse import parse_datetime +from rest_framework.parsers import JSONParser +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from backend.utils import verify_kefu_permission +from jituan.models import ClubMigrateLedger, ClubMigratePlan, ClubMigrateUserQuota +from jituan.services import club_migrate as migrate_svc +from jituan.services.admin_context import can_manage_admin_assignments +from jituan.services.club_migrate_assets import catalog_for_admin, normalize_whitelist + +logger = logging.getLogger(__name__) + + +def _auth_group_admin(request): + """仅集团超管(系统超管 / GROUP_OWNER / GROUP_SUPER_ADMIN)。俱乐部 000001 不可见不可调。""" + username = (request.data.get('username') or request.data.get('phone') or '').strip() + if not username: + return None, None, Response({'code': 400, 'msg': '缺少username'}) + kefu, permissions = verify_kefu_permission(request, username) + if kefu is None: + return None, None, permissions + if not can_manage_admin_assignments(request.user, permissions): + return None, None, Response({'code': 403, 'msg': '仅集团超管可管理跨店资产迁移'}) + return kefu, permissions, None + + +def _user_club(user) -> str: + return str(getattr(user, 'ClubID', '') or '').strip() + + +def _user_roles(user) -> list: + roles = [] + for role, attr in ( + ('dashou', 'DashouProfile'), + ('shangjia', 'ShopProfile'), + ('guanshi', 'GuanshiProfile'), + ('zuzhang', 'ZuzhangProfile'), + ('shenheguan', 'ShenheguanProfile'), + ): + try: + getattr(user, attr) + roles.append(role) + except Exception: + pass + return roles + + +def _parse_dt(v): + if not v: + return None + if isinstance(v, datetime): + return v if timezone.is_aware(v) else timezone.make_aware(v) + dt = parse_datetime(str(v).replace('Z', '+00:00')) + if dt is None: + return None + if timezone.is_naive(dt): + dt = timezone.make_aware(dt, timezone.get_current_timezone()) + return dt + + +# ==================== 小程序用户端 ==================== + +class ClubMigrateGateView(APIView): + """POST /jituan/migrate/gate""" + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + club_id = (request.data.get('club_id') or _user_club(request.user)).strip() + data = migrate_svc.gate_for_user( + club_id=club_id, + user_uid=str(request.user.UserUID), + roles=_user_roles(request.user), + ) + return Response({'code': 0, 'msg': 'ok', 'data': data}) + + +class ClubMigratePreviewView(APIView): + """POST /jituan/migrate/preview""" + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + club_id = (request.data.get('club_id') or _user_club(request.user)).strip() + plan = migrate_svc.find_active_plan_for_source(club_id) + if not plan: + return Response({'code': 400, 'msg': '当前俱乐部无有效迁移计划'}) + uid = str(request.user.UserUID) + ok, msg = migrate_svc.check_conditions(plan, uid) + return Response({ + 'code': 0, + 'msg': 'ok', + 'data': { + 'plan': migrate_svc.serialize_plan(plan), + 'assets': migrate_svc.preview_assets(plan, uid), + 'condition_ok': ok, + 'condition_msg': msg, + 'can_migrate': migrate_svc.can_migrate_again(plan, uid), + 'success_count': migrate_svc.success_count(plan.plan_id, uid), + 'max_allowed': migrate_svc.max_allowed_success(plan, uid), + }, + }) + + +class ClubMigrateSetPasswordView(APIView): + """POST /jituan/migrate/set_password — 设密并发令牌+目标店码""" + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + club_id = (request.data.get('club_id') or _user_club(request.user)).strip() + password = str(request.data.get('password') or '').strip() + password2 = str(request.data.get('password2') or '').strip() + if password != password2: + return Response({'code': 400, 'msg': '两次密码不一致'}) + plan = migrate_svc.find_active_plan_for_source(club_id) + if not plan: + return Response({'code': 400, 'msg': '当前俱乐部无有效迁移计划'}) + + token_row, err = migrate_svc.set_password_and_issue( + plan=plan, + from_uid=str(request.user.UserUID), + password=password, + ) + if err: + return Response({'code': 400, 'msg': err}) + + from jituan.services.club_migrate_qr import generate_migrate_qrcode + qr_url, qr_err = generate_migrate_qrcode(to_club_id=plan.to_club_id, token=token_row.token) + if qr_err: + logger.warning('migrate qr gen fail: %s', qr_err) + # 令牌已签发,前端仍可用 scene;码失败单独提示 + else: + token_row.qrcode_url = qr_url + token_row.save(update_fields=['qrcode_url', 'UpdateTime']) + + data = migrate_svc.serialize_token(token_row) + if qr_err: + data['qr_error'] = qr_err + return Response({'code': 0, 'msg': '已设密并发码', 'data': data}) + + +class ClubMigrateIssueQrView(APIView): + """POST /jituan/migrate/issue_qr — 对已签发令牌重出码""" + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + from jituan.models import ClubMigrateToken + from jituan.services.club_migrate_qr import generate_migrate_qrcode + + club_id = (request.data.get('club_id') or _user_club(request.user)).strip() + uid = str(request.user.UserUID) + plan = migrate_svc.find_active_plan_for_source(club_id) + if not plan: + return Response({'code': 400, 'msg': '当前俱乐部无有效迁移计划'}) + token_row = ( + ClubMigrateToken.query.filter( + plan_id=plan.plan_id, + from_uid=uid, + status=ClubMigrateToken.STATUS_ISSUED, + ) + .order_by('-id') + .first() + ) + if not token_row: + return Response({'code': 400, 'msg': '请先设置转移密码'}) + qr_url, qr_err = generate_migrate_qrcode(to_club_id=plan.to_club_id, token=token_row.token) + if qr_err: + return Response({'code': 400, 'msg': qr_err}) + token_row.qrcode_url = qr_url + token_row.save(update_fields=['qrcode_url', 'UpdateTime']) + return Response({'code': 0, 'msg': 'ok', 'data': migrate_svc.serialize_token(token_row)}) + + +class ClubMigrateConfirmView(APIView): + """POST /jituan/migrate/confirm — 目标店确认过户""" + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + token = str(request.data.get('token') or '').strip() + scene = str(request.data.get('scene') or '').strip() + if not token and scene: + token = migrate_svc.parse_migrate_scene(scene) + password = str(request.data.get('password') or '').strip() + if not token: + return Response({'code': 400, 'msg': '缺少令牌'}) + data, err = migrate_svc.confirm_migrate( + token=token, + password=password, + to_uid=str(request.user.UserUID), + client_meta={ + 'club_id': _user_club(request.user), + 'ip': request.META.get('REMOTE_ADDR', ''), + }, + ) + if err: + return Response({'code': 400, 'msg': err}) + return Response({'code': 0, 'msg': '迁移成功', 'data': data}) + + +class ClubMigrateMyRecordsView(APIView): + """POST /jituan/migrate/my_records""" + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + limit = request.data.get('limit') or 50 + try: + limit = int(limit) + except (TypeError, ValueError): + limit = 50 + rows = migrate_svc.list_user_records(str(request.user.UserUID), limit=limit) + return Response({'code': 0, 'msg': 'ok', 'data': {'list': rows}}) + + +class ClubMigrateTokenInfoView(APIView): + """POST /jituan/migrate/token_info — 目标店扫码后预览(不验密)""" + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + from jituan.models import ClubMigrateToken + + token = str(request.data.get('token') or '').strip() + scene = str(request.data.get('scene') or '').strip() + if not token and scene: + token = migrate_svc.parse_migrate_scene(scene) + if not token: + return Response({'code': 400, 'msg': '缺少令牌'}) + row = ClubMigrateToken.query.filter(token=token).first() + if not row: + return Response({'code': 400, 'msg': '令牌无效'}) + plan = ClubMigratePlan.query.filter(plan_id=row.plan_id).first() + return Response({ + 'code': 0, + 'msg': 'ok', + 'data': { + 'token': migrate_svc.serialize_token(row), + 'plan': migrate_svc.serialize_plan(plan) if plan else None, + 'can_confirm': row.status == ClubMigrateToken.STATUS_ISSUED, + }, + }) + + +# ==================== 客服后台 ==================== + +class ClubMigrateAdminView(APIView): + """ + POST /jituan/houtai/club-migrate + action: catalog | list | get | save | set_enabled | set_quota | ledger_list | ledger_detail + """ + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + kefu, permissions, err = _auth_group_admin(request) + if err: + return err + action = (request.data.get('action') or 'list').strip().lower() + operator = str(getattr(request.user, 'UserUID', '') or '') + + if action == 'catalog': + return Response({'code': 0, 'msg': 'ok', 'data': {'assets': catalog_for_admin()}}) + + if action == 'list': + qs = ClubMigratePlan.query.all().order_by('-id') + from_club = (request.data.get('from_club_id') or '').strip() + to_club = (request.data.get('to_club_id') or '').strip() + if from_club: + qs = qs.filter(from_club_id=from_club) + if to_club: + qs = qs.filter(to_club_id=to_club) + return Response({ + 'code': 0, + 'msg': 'ok', + 'data': {'list': [migrate_svc.serialize_plan(p) for p in qs[:200]]}, + }) + + if action == 'get': + plan_id = (request.data.get('plan_id') or '').strip() + plan = ClubMigratePlan.query.filter(plan_id=plan_id).first() + if not plan: + return Response({'code': 404, 'msg': '计划不存在'}) + return Response({'code': 0, 'msg': 'ok', 'data': migrate_svc.serialize_plan(plan)}) + + if action == 'save': + return self._save_plan(request, operator) + + if action == 'set_enabled': + plan_id = (request.data.get('plan_id') or '').strip() + plan = ClubMigratePlan.query.filter(plan_id=plan_id).first() + if not plan: + return Response({'code': 404, 'msg': '计划不存在'}) + enabled = request.data.get('enabled') + plan.enabled = bool(enabled in (True, 1, '1', 'true', 'True')) + plan.updated_by = operator + plan.save(update_fields=['enabled', 'updated_by', 'UpdateTime']) + return Response({'code': 0, 'msg': '已更新', 'data': migrate_svc.serialize_plan(plan)}) + + if action == 'set_quota': + plan_id = (request.data.get('plan_id') or '').strip() + from_uid = (request.data.get('from_uid') or '').strip() + if not plan_id or not from_uid: + return Response({'code': 400, 'msg': '缺少 plan_id/from_uid'}) + try: + extra = int(request.data.get('max_extra_times') or 1) + except (TypeError, ValueError): + return Response({'code': 400, 'msg': 'max_extra_times 无效'}) + row, _ = ClubMigrateUserQuota.query.get_or_create( + plan_id=plan_id, + from_uid=from_uid, + defaults={'max_extra_times': max(0, extra), 'updated_by': operator}, + ) + row.max_extra_times = max(0, extra) + row.reason = str(request.data.get('reason') or '')[:255] + row.updated_by = operator + row.save(update_fields=['max_extra_times', 'reason', 'updated_by', 'UpdateTime']) + return Response({ + 'code': 0, + 'msg': '已设置二次名额', + 'data': { + 'plan_id': plan_id, + 'from_uid': from_uid, + 'max_extra_times': row.max_extra_times, + }, + }) + + if action == 'ledger_list': + return self._ledger_list(request) + + if action == 'ledger_detail': + try: + lid = int(request.data.get('ledger_id') or 0) + except (TypeError, ValueError): + return Response({'code': 400, 'msg': 'ledger_id 无效'}) + led = ClubMigrateLedger.query.filter(id=lid).first() + if not led: + return Response({'code': 404, 'msg': '流水不存在'}) + return Response({'code': 0, 'msg': 'ok', 'data': migrate_svc.serialize_ledger(led)}) + + return Response({'code': 400, 'msg': '无效 action'}) + + def _save_plan(self, request, operator: str): + data = request.data + plan_id = (data.get('plan_id') or '').strip() + from_club = (data.get('from_club_id') or '').strip() + to_club = (data.get('to_club_id') or '').strip() + if not from_club or not to_club: + return Response({'code': 400, 'msg': '缺少 from_club_id/to_club_id'}) + if from_club == to_club: + return Response({'code': 400, 'msg': '源与目标俱乐部不能相同'}) + whitelist = normalize_whitelist(data.get('asset_whitelist') or []) + if not whitelist: + return Response({'code': 400, 'msg': '请至少勾选一项资产白名单'}) + + force_roles = data.get('force_roles') or ['dashou'] + if not isinstance(force_roles, list): + force_roles = ['dashou'] + condition = data.get('condition_json') or {} + if not isinstance(condition, dict): + condition = {} + + if plan_id: + plan = ClubMigratePlan.query.filter(plan_id=plan_id).first() + if not plan: + return Response({'code': 404, 'msg': '计划不存在'}) + else: + plan = ClubMigratePlan( + plan_id=migrate_svc.new_plan_id(), + from_club_id=from_club, + to_club_id=to_club, + ) + + plan.name = str(data.get('name') or '')[:64] + plan.from_club_id = from_club + plan.to_club_id = to_club + if 'enabled' in data: + plan.enabled = bool(data.get('enabled') in (True, 1, '1', 'true', 'True')) + plan.start_at = _parse_dt(data.get('start_at')) + plan.end_at = _parse_dt(data.get('end_at')) + plan.asset_whitelist = whitelist + plan.condition_json = condition + if 'force_intercept' in data: + plan.force_intercept = bool(data.get('force_intercept') in (True, 1, '1', 'true', 'True')) + plan.force_roles = force_roles + if 'default_once_per_user' in data: + plan.default_once_per_user = bool( + data.get('default_once_per_user') in (True, 1, '1', 'true', 'True') + ) + plan.remark = str(data.get('remark') or '')[:255] + plan.updated_by = operator + plan.save() + return Response({'code': 0, 'msg': '已保存', 'data': migrate_svc.serialize_plan(plan)}) + + def _ledger_list(self, request): + qs = ClubMigrateLedger.query.all().order_by('-id') + plan_id = (request.data.get('plan_id') or '').strip() + uid = (request.data.get('uid') or '').strip() + from_club = (request.data.get('from_club_id') or '').strip() + to_club = (request.data.get('to_club_id') or '').strip() + if plan_id: + qs = qs.filter(plan_id=plan_id) + if uid: + qs = qs.filter(Q(from_uid=uid) | Q(to_uid=uid)) + if from_club: + qs = qs.filter(from_club_id=from_club) + if to_club: + qs = qs.filter(to_club_id=to_club) + try: + page = max(1, int(request.data.get('page') or 1)) + page_size = min(100, max(1, int(request.data.get('page_size') or 20))) + except (TypeError, ValueError): + page, page_size = 1, 20 + total = qs.count() + start = (page - 1) * page_size + rows = list(qs[start:start + page_size]) + return Response({ + 'code': 0, + 'msg': 'ok', + 'data': { + 'total': total, + 'page': page, + 'page_size': page_size, + 'list': [migrate_svc.serialize_ledger(x) for x in rows], + }, + })