feat: 跨俱乐部资产迁移(计划/令牌/流水 + 用户与后台 API)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
715
jituan/services/club_migrate.py
Normal file
715
jituan/services/club_migrate.py
Normal 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 {},
|
||||
}
|
||||
Reference in New Issue
Block a user