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