feat: 跨俱乐部资产迁移(计划/令牌/流水 + 用户与后台 API)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-29 00:15:27 +08:00
parent 568dcb720e
commit 0ebe5b4872
9 changed files with 1658 additions and 1 deletions

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

@@ -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'),
]

View File

@@ -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 {},
}

View File

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

View File

@@ -0,0 +1,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, ''

View File

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

View File

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

View File

@@ -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'),

View File

@@ -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],
},
})