feat: 跨俱乐部资产迁移(计划/令牌/流水 + 用户与后台 API)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
174
jituan/migrations/0021_club_migrate.py
Normal file
174
jituan/migrations/0021_club_migrate.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
153
jituan/models.py
153
jituan/models.py
@@ -740,3 +740,156 @@ class DealStatEvent(QModel):
|
||||
class Meta:
|
||||
db_table = 'deal_stat_event'
|
||||
verbose_name = '成交指标事件'
|
||||
|
||||
|
||||
# ==================== 跨俱乐部资产迁移(独立模块,默认关闭不影响现网)====================
|
||||
|
||||
class ClubMigratePlan(QModel):
|
||||
"""迁移计划:from_club → to_club,未启用时小程序完全无感。"""
|
||||
plan_id = models.CharField(max_length=32, unique=True, db_index=True, verbose_name='计划ID')
|
||||
name = models.CharField(max_length=64, blank=True, default='', verbose_name='计划名称')
|
||||
from_club_id = models.CharField(max_length=16, db_index=True, verbose_name='源俱乐部')
|
||||
to_club_id = models.CharField(max_length=16, db_index=True, verbose_name='目标俱乐部')
|
||||
enabled = models.BooleanField(default=False, db_index=True, verbose_name='是否启用')
|
||||
# null=永久;有值则必须在区间内
|
||||
start_at = models.DateTimeField(null=True, blank=True, verbose_name='开始时间')
|
||||
end_at = models.DateTimeField(null=True, blank=True, verbose_name='结束时间')
|
||||
# ["dashou.yue","dashou.yajin","dashou.jifen","dashou.dongjie_yue","dashou.huiyuan", ...]
|
||||
asset_whitelist = models.JSONField(default=list, blank=True, verbose_name='资产白名单')
|
||||
# {"require_no_unpaid_penalty":true,"require_orders_closed":false,"require_not_banned":true}
|
||||
condition_json = models.JSONField(default=dict, blank=True, verbose_name='门槛条件')
|
||||
force_intercept = models.BooleanField(default=True, verbose_name='源店强制迁移页')
|
||||
# ["dashou"] 首期默认打手
|
||||
force_roles = models.JSONField(default=list, blank=True, verbose_name='强制角色列表')
|
||||
default_once_per_user = models.BooleanField(default=True, verbose_name='默认每人成功一次')
|
||||
remark = models.CharField(max_length=255, blank=True, default='')
|
||||
updated_by = models.CharField(max_length=32, blank=True, default='')
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_migrate_plan'
|
||||
verbose_name = '跨店资产迁移计划'
|
||||
indexes = [
|
||||
models.Index(fields=['from_club_id', 'enabled'], name='idx_cmp_from_en'),
|
||||
models.Index(fields=['to_club_id', 'enabled'], name='idx_cmp_to_en'),
|
||||
]
|
||||
|
||||
|
||||
class ClubMigrateToken(QModel):
|
||||
"""一次性迁移令牌(设密后签发,确认后作废)。"""
|
||||
STATUS_PENDING_PASSWORD = 0
|
||||
STATUS_ISSUED = 1
|
||||
STATUS_CONSUMED = 2
|
||||
STATUS_EXPIRED = 3
|
||||
STATUS_CANCELLED = 4
|
||||
|
||||
token = models.CharField(max_length=64, unique=True, db_index=True, verbose_name='令牌')
|
||||
plan_id = models.CharField(max_length=32, db_index=True)
|
||||
from_club_id = models.CharField(max_length=16, db_index=True)
|
||||
to_club_id = models.CharField(max_length=16, db_index=True)
|
||||
from_uid = models.CharField(max_length=16, db_index=True, verbose_name='源UserUID')
|
||||
password_hash = models.CharField(max_length=128, blank=True, default='', verbose_name='转移密码哈希')
|
||||
password_salt = models.CharField(max_length=32, blank=True, default='')
|
||||
status = models.SmallIntegerField(default=0, db_index=True)
|
||||
fail_count = models.PositiveIntegerField(default=0, verbose_name='验密失败次数')
|
||||
asset_snapshot = models.JSONField(default=dict, blank=True, verbose_name='发码时资产快照')
|
||||
qrcode_url = models.CharField(max_length=512, blank=True, default='', verbose_name='目标店小程序码URL')
|
||||
expire_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||
consumed_at = models.DateTimeField(null=True, blank=True)
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_migrate_token'
|
||||
verbose_name = '跨店迁移令牌'
|
||||
indexes = [
|
||||
models.Index(fields=['plan_id', 'from_uid', 'status'], name='idx_cmt_plan_uid_st'),
|
||||
]
|
||||
|
||||
|
||||
class ClubMigrateConsumption(QModel):
|
||||
"""计划×源用户成功占用(防并发双迁;默认一次)。"""
|
||||
plan_id = models.CharField(max_length=32, db_index=True)
|
||||
from_uid = models.CharField(max_length=16, db_index=True)
|
||||
success_count = models.PositiveIntegerField(default=0)
|
||||
last_ledger_id = models.BigIntegerField(null=True, blank=True)
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_migrate_consumption'
|
||||
verbose_name = '跨店迁移占用'
|
||||
unique_together = [['plan_id', 'from_uid']]
|
||||
|
||||
|
||||
class ClubMigrateUserQuota(QModel):
|
||||
"""例外:指定源用户在某计划下可额外成功次数。"""
|
||||
plan_id = models.CharField(max_length=32, db_index=True)
|
||||
from_uid = models.CharField(max_length=16, db_index=True)
|
||||
max_extra_times = models.PositiveIntegerField(default=1, verbose_name='额外成功次数')
|
||||
reason = models.CharField(max_length=255, blank=True, default='')
|
||||
updated_by = models.CharField(max_length=32, blank=True, default='')
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_migrate_user_quota'
|
||||
verbose_name = '跨店迁移二次白名单'
|
||||
unique_together = [['plan_id', 'from_uid']]
|
||||
|
||||
|
||||
class ClubMigrateLedger(QModel):
|
||||
"""一次成功过户主单(P0 溯源)。"""
|
||||
STATUS_SUCCESS = 1
|
||||
STATUS_REVERSED = 2
|
||||
|
||||
plan_id = models.CharField(max_length=32, db_index=True)
|
||||
token = models.CharField(max_length=64, blank=True, default='', db_index=True)
|
||||
from_club_id = models.CharField(max_length=16, db_index=True)
|
||||
to_club_id = models.CharField(max_length=16, db_index=True)
|
||||
from_uid = models.CharField(max_length=16, db_index=True)
|
||||
to_uid = models.CharField(max_length=16, db_index=True)
|
||||
migrate_seq = models.PositiveIntegerField(default=1, verbose_name='本计划第几次')
|
||||
status = models.SmallIntegerField(default=1, db_index=True)
|
||||
summary_json = models.JSONField(default=dict, blank=True, verbose_name='摘要')
|
||||
client_meta = models.JSONField(default=dict, blank=True)
|
||||
CreateTime = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_migrate_ledger'
|
||||
verbose_name = '跨店迁移流水主单'
|
||||
indexes = [
|
||||
models.Index(fields=['from_uid', 'plan_id'], name='idx_cml_from_plan'),
|
||||
models.Index(fields=['to_uid', 'plan_id'], name='idx_cml_to_plan'),
|
||||
models.Index(fields=['from_club_id', 'to_club_id', 'CreateTime'], name='idx_cml_clubs_t'),
|
||||
]
|
||||
|
||||
|
||||
class ClubMigrateLedgerItem(QModel):
|
||||
"""流水明细:每个角色字段一行(金额前后值必填,便于对账纠错)。"""
|
||||
ITEM_BALANCE = 'balance'
|
||||
ITEM_HUIYUAN = 'huiyuan'
|
||||
|
||||
ledger_id = models.BigIntegerField(db_index=True)
|
||||
plan_id = models.CharField(max_length=32, db_index=True)
|
||||
item_type = models.CharField(max_length=16, default='balance', db_index=True)
|
||||
role = models.CharField(max_length=16, db_index=True)
|
||||
field = models.CharField(max_length=32, blank=True, default='', verbose_name='字段名')
|
||||
amount = models.DecimalField(max_digits=14, decimal_places=2, default=Decimal('0'))
|
||||
before_src = models.CharField(max_length=64, blank=True, default='')
|
||||
after_src = models.CharField(max_length=64, blank=True, default='')
|
||||
before_dst = models.CharField(max_length=64, blank=True, default='')
|
||||
after_dst = models.CharField(max_length=64, blank=True, default='')
|
||||
# 会员专用
|
||||
huiyuan_id = models.CharField(max_length=16, blank=True, default='', db_index=True)
|
||||
meta_json = models.JSONField(default=dict, blank=True)
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_migrate_ledger_item'
|
||||
verbose_name = '跨店迁移流水明细'
|
||||
indexes = [
|
||||
models.Index(fields=['ledger_id', 'role'], name='idx_cmli_led_role'),
|
||||
]
|
||||
|
||||
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 {},
|
||||
}
|
||||
62
jituan/services/club_migrate_assets.py
Normal file
62
jituan/services/club_migrate_assets.py
Normal 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
|
||||
89
jituan/services/club_migrate_qr.py
Normal file
89
jituan/services/club_migrate_qr.py
Normal 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, ''
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
438
jituan/views_club_migrate.py
Normal file
438
jituan/views_club_migrate.py
Normal 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],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user