feat(rank): 排行榜奖励活动期、领取审计与校验;修复邀请二维码路径
This commit is contained in:
48
rank/migrations/0005_rank_reward_activity_and_claim_log.py
Normal file
48
rank/migrations/0005_rank_reward_activity_and_claim_log.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rank', '0004_rank_reward'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='rankrewardscheme',
|
||||
name='activity_start',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='活动开始日期'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rankrewardscheme',
|
||||
name='activity_end',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='活动结束日期'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RankRewardClaimLog',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('yonghuid', models.CharField(db_index=True, max_length=7, verbose_name='用户ID')),
|
||||
('action', models.CharField(
|
||||
choices=[('claim_start', '开始领取'), ('claim_success', '领取成功'), ('claim_reject', '领取拒绝')],
|
||||
max_length=32, verbose_name='动作',
|
||||
)),
|
||||
('amount', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='金额')),
|
||||
('detail', models.CharField(blank=True, default='', max_length=256, verbose_name='说明')),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('claim', models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='logs',
|
||||
to='rank.rankrewardclaim',
|
||||
verbose_name='领取记录',
|
||||
)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '排行榜奖励领取日志',
|
||||
'verbose_name_plural': '排行榜奖励领取日志',
|
||||
'db_table': 'rank_reward_claim_log',
|
||||
'ordering': ['-CreateTime'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -224,4 +224,4 @@ class KaoheguanBankuai(QModel):
|
||||
indexes = [models.Index(fields=['kaoheguan_id'])]
|
||||
|
||||
|
||||
from .reward_models import RankRewardScheme, RankRewardTier, RankRewardSettlement, RankRewardClaim # noqa: E402,F401
|
||||
from .reward_models import RankRewardScheme, RankRewardTier, RankRewardSettlement, RankRewardClaim, RankRewardClaimLog # noqa: E402,F401
|
||||
@@ -16,6 +16,21 @@ logger = logging.getLogger(__name__)
|
||||
REWARD_PERMS = ('phbj666', '8080a')
|
||||
|
||||
|
||||
def _parse_date(value):
|
||||
if not value:
|
||||
return None
|
||||
from datetime import date
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
text = str(value).strip()[:10]
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _has_perm(permissions):
|
||||
return any(p in permissions for p in REWARD_PERMS)
|
||||
|
||||
@@ -34,6 +49,8 @@ def _serialize_scheme(scheme):
|
||||
'enabled': scheme.enabled,
|
||||
'claim_days': scheme.claim_days,
|
||||
'min_metric': float(scheme.min_metric or 0),
|
||||
'activity_start': scheme.activity_start.isoformat() if scheme.activity_start else '',
|
||||
'activity_end': scheme.activity_end.isoformat() if scheme.activity_end else '',
|
||||
'tiers': [
|
||||
{
|
||||
'id': t.id,
|
||||
@@ -109,6 +126,11 @@ class RankRewardHoutaiSaveView(APIView):
|
||||
if not tiers:
|
||||
return Response({'code': 400, 'msg': '请配置至少一个奖励档位'})
|
||||
|
||||
activity_start = _parse_date(data.get('activity_start'))
|
||||
activity_end = _parse_date(data.get('activity_end'))
|
||||
if activity_start and activity_end and activity_start > activity_end:
|
||||
return Response({'code': 400, 'msg': '活动开始日期不能晚于结束日期'})
|
||||
|
||||
scheme, _ = RankRewardScheme.objects.update_or_create(
|
||||
club_id=club_id,
|
||||
shenfen=shenfen,
|
||||
@@ -120,6 +142,8 @@ class RankRewardHoutaiSaveView(APIView):
|
||||
'enabled': bool(data.get('enabled', False)),
|
||||
'claim_days': int(data.get('claim_days') or 30),
|
||||
'min_metric': data.get('min_metric') or 0,
|
||||
'activity_start': activity_start,
|
||||
'activity_end': activity_end,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ class RankRewardScheme(QModel):
|
||||
description = models.TextField(blank=True, default='', verbose_name='规则说明')
|
||||
enabled = models.BooleanField(default=False, verbose_name='启用')
|
||||
claim_days = models.PositiveSmallIntegerField(default=30, verbose_name='领取有效天数')
|
||||
activity_start = models.DateField(null=True, blank=True, verbose_name='活动开始日期')
|
||||
activity_end = models.DateField(null=True, blank=True, verbose_name='活动结束日期')
|
||||
min_metric = models.DecimalField(
|
||||
max_digits=12, decimal_places=2, default=0, verbose_name='最低指标门槛',
|
||||
)
|
||||
@@ -115,3 +117,31 @@ class RankRewardClaim(QModel):
|
||||
models.Index(fields=['yonghuid', 'status']),
|
||||
models.Index(fields=['club_id', 'status']),
|
||||
]
|
||||
|
||||
|
||||
class RankRewardClaimLog(QModel):
|
||||
"""领取审计 — 先记日志再入账"""
|
||||
|
||||
ACTION_START = 'claim_start'
|
||||
ACTION_SUCCESS = 'claim_success'
|
||||
ACTION_REJECT = 'claim_reject'
|
||||
ACTION_CHOICES = [
|
||||
(ACTION_START, '开始领取'),
|
||||
(ACTION_SUCCESS, '领取成功'),
|
||||
(ACTION_REJECT, '领取拒绝'),
|
||||
]
|
||||
|
||||
claim = models.ForeignKey(
|
||||
RankRewardClaim, on_delete=models.CASCADE, related_name='logs', verbose_name='领取记录',
|
||||
)
|
||||
yonghuid = models.CharField(max_length=7, db_index=True, verbose_name='用户ID')
|
||||
action = models.CharField(max_length=32, choices=ACTION_CHOICES, verbose_name='动作')
|
||||
amount = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name='金额')
|
||||
detail = models.CharField(max_length=256, blank=True, default='', verbose_name='说明')
|
||||
CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'rank_reward_claim_log'
|
||||
verbose_name = '排行榜奖励领取日志'
|
||||
verbose_name_plural = verbose_name
|
||||
ordering = ['-CreateTime']
|
||||
|
||||
@@ -19,6 +19,7 @@ from backend.models import (
|
||||
from jituan.services.club_user import get_user_club_id
|
||||
from rank.reward_models import (
|
||||
RankRewardClaim,
|
||||
RankRewardClaimLog,
|
||||
RankRewardScheme,
|
||||
RankRewardSettlement,
|
||||
RankRewardTier,
|
||||
@@ -100,6 +101,66 @@ BALANCE_FIELD_MAP = {
|
||||
}
|
||||
|
||||
|
||||
def _scheme_activity_bounds(scheme) -> tuple[Optional[date], Optional[date]]:
|
||||
if not scheme:
|
||||
return None, None
|
||||
return scheme.activity_start, scheme.activity_end
|
||||
|
||||
|
||||
def scheme_activity_open_today(scheme) -> tuple[bool, str]:
|
||||
"""当前日期是否在活动开关窗口内(用于领取校验)。"""
|
||||
if not scheme or not scheme.enabled:
|
||||
return False, '奖励方案未启用'
|
||||
today = date.today()
|
||||
start, end = _scheme_activity_bounds(scheme)
|
||||
if start and today < start:
|
||||
return False, f'活动将于 {start.isoformat()} 开始'
|
||||
if end and today > end:
|
||||
return False, f'活动已于 {end.isoformat()} 结束'
|
||||
return True, ''
|
||||
|
||||
|
||||
def period_overlaps_activity(scheme, period_start: date, period_end: date) -> bool:
|
||||
"""统计周期与活动窗口有交集才允许结算/展示可领。"""
|
||||
if not scheme or not scheme.enabled:
|
||||
return False
|
||||
start, end = _scheme_activity_bounds(scheme)
|
||||
if not start and not end:
|
||||
return True
|
||||
if start and period_end < start:
|
||||
return False
|
||||
if end and period_start > end:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def serialize_scheme_activity(scheme) -> dict:
|
||||
if not scheme:
|
||||
return {
|
||||
'activity_start': '',
|
||||
'activity_end': '',
|
||||
'activity_open': False,
|
||||
'activity_reason': '未配置方案',
|
||||
}
|
||||
open_today, reason = scheme_activity_open_today(scheme)
|
||||
return {
|
||||
'activity_start': scheme.activity_start.isoformat() if scheme.activity_start else '',
|
||||
'activity_end': scheme.activity_end.isoformat() if scheme.activity_end else '',
|
||||
'activity_open': open_today,
|
||||
'activity_reason': reason,
|
||||
}
|
||||
|
||||
|
||||
def _write_claim_log(claim, yonghuid: str, action: str, amount, detail: str = ''):
|
||||
RankRewardClaimLog.objects.create(
|
||||
claim_id=claim.id,
|
||||
yonghuid=yonghuid,
|
||||
action=action,
|
||||
amount=amount,
|
||||
detail=(detail or '')[:256],
|
||||
)
|
||||
|
||||
|
||||
def sort_field_options_for_shenfen(shenfen: str) -> list:
|
||||
reg = SORT_FIELD_REGISTRY.get(shenfen) or {}
|
||||
return [
|
||||
@@ -490,6 +551,13 @@ def maybe_settle_club_rewards(club_id: str, shenfen: str, riqi: str) -> Optional
|
||||
if not scheme:
|
||||
return None
|
||||
|
||||
if not period_overlaps_activity(scheme, period_start, period_end):
|
||||
logger.info(
|
||||
'skip settle: period %s-%s outside activity window scheme=%s',
|
||||
period_start, period_end, scheme.id,
|
||||
)
|
||||
return None
|
||||
|
||||
if scheme.sort_field not in (SORT_FIELD_REGISTRY.get(shenfen) or {}):
|
||||
logger.warning('invalid sort_field %s for %s', scheme.sort_field, shenfen)
|
||||
return None
|
||||
@@ -639,11 +707,16 @@ def build_reward_payload(club_id: str, shenfen: str, riqi: str, yonghuid: str =
|
||||
'claimed_at': claimed.claimed_at.isoformat() if claimed.claimed_at else '',
|
||||
}
|
||||
|
||||
activity_meta = serialize_scheme_activity(scheme if scheme and scheme.enabled else None)
|
||||
if scheme and scheme.enabled and not activity_meta['activity_open']:
|
||||
# 活动窗口外:不展示待领取(已结算的 pending 也视为不可领)
|
||||
my_claim = None
|
||||
|
||||
return {
|
||||
'club_id': club_id,
|
||||
'shenfen': shenfen,
|
||||
'riqi': riqi,
|
||||
'scheme_enabled': bool(scheme and scheme.enabled),
|
||||
'scheme_enabled': bool(scheme and scheme.enabled and activity_meta['activity_open']),
|
||||
'period_status': 'open' if riqi in OPEN_RIQI else 'closed',
|
||||
'period_label': riqi,
|
||||
'sort_field': (scheme.sort_field if scheme and scheme.enabled else display_sort),
|
||||
@@ -658,6 +731,7 @@ def build_reward_payload(club_id: str, shenfen: str, riqi: str, yonghuid: str =
|
||||
'rank_rewards': rank_rewards,
|
||||
'my_claim': my_claim,
|
||||
'my_claimed': my_claimed,
|
||||
**activity_meta,
|
||||
}
|
||||
|
||||
|
||||
@@ -687,17 +761,23 @@ def list_my_pending_claims(yonghuid: str, club_id: str = '') -> list:
|
||||
return result
|
||||
|
||||
|
||||
def claim_reward(claim_id: int, user) -> dict:
|
||||
def claim_reward(claim_id: int, user, request_shenfen: str = '') -> dict:
|
||||
uid = user.UserUID
|
||||
user_club = get_user_club_id(user) or getattr(user, 'ClubID', None) or ''
|
||||
req_sf = (request_shenfen or '').strip()
|
||||
|
||||
with transaction.atomic():
|
||||
claim = RankRewardClaim.objects.select_for_update().filter(id=claim_id).first()
|
||||
if not claim:
|
||||
return {'ok': False, 'msg': '奖励记录不存在'}
|
||||
if claim.yonghuid != uid:
|
||||
return {'ok': False, 'msg': '无权领取该奖励'}
|
||||
_write_claim_log(claim, uid, RankRewardClaimLog.ACTION_REJECT, claim.reward_amount, '非本人')
|
||||
return {'ok': False, 'msg': '只能领取本人的奖励'}
|
||||
if req_sf and req_sf != claim.shenfen:
|
||||
_write_claim_log(claim, uid, RankRewardClaimLog.ACTION_REJECT, claim.reward_amount, '身份不匹配')
|
||||
return {'ok': False, 'msg': '请使用对应身份领取'}
|
||||
if claim.club_id != user_club:
|
||||
_write_claim_log(claim, uid, RankRewardClaimLog.ACTION_REJECT, claim.reward_amount, '俱乐部不匹配')
|
||||
return {'ok': False, 'msg': '俱乐部不匹配,无法领取'}
|
||||
if claim.status == RankRewardClaim.STATUS_CLAIMED:
|
||||
return {'ok': True, 'msg': '已领取', 'amount': float(claim.reward_amount), 'duplicate': True}
|
||||
@@ -706,17 +786,34 @@ def claim_reward(claim_id: int, user) -> dict:
|
||||
if claim.expire_at < timezone.now():
|
||||
claim.status = RankRewardClaim.STATUS_EXPIRED
|
||||
claim.save(update_fields=['status', 'UpdateTime'])
|
||||
_write_claim_log(claim, uid, RankRewardClaimLog.ACTION_REJECT, claim.reward_amount, '已过期')
|
||||
return {'ok': False, 'msg': '奖励已过期'}
|
||||
|
||||
if not user_has_active_shenfen(uid, claim.shenfen):
|
||||
_write_claim_log(claim, uid, RankRewardClaimLog.ACTION_REJECT, claim.reward_amount, '身份未激活')
|
||||
return {'ok': False, 'msg': '身份状态异常,无法领取'}
|
||||
|
||||
settlement = RankRewardSettlement.query.select_related('scheme').filter(id=claim.settlement_id).first()
|
||||
scheme = settlement.scheme if settlement else None
|
||||
if not scheme or not scheme.enabled:
|
||||
return {'ok': False, 'msg': '奖励方案未启用'}
|
||||
open_today, act_reason = scheme_activity_open_today(scheme)
|
||||
if not open_today:
|
||||
_write_claim_log(claim, uid, RankRewardClaimLog.ACTION_REJECT, claim.reward_amount, act_reason)
|
||||
return {'ok': False, 'msg': act_reason or '不在活动有效期内'}
|
||||
if settlement and not period_overlaps_activity(scheme, settlement.period_start, settlement.period_end):
|
||||
_write_claim_log(claim, uid, RankRewardClaimLog.ACTION_REJECT, claim.reward_amount, '周期不在活动内')
|
||||
return {'ok': False, 'msg': '该周期不在活动有效期内'}
|
||||
|
||||
amount = claim.reward_amount
|
||||
_write_claim_log(claim, uid, RankRewardClaimLog.ACTION_START, amount, '开始入账')
|
||||
|
||||
_credit_balance(claim.shenfen, uid, amount)
|
||||
|
||||
claim.status = RankRewardClaim.STATUS_CLAIMED
|
||||
claim.claimed_at = timezone.now()
|
||||
claim.save(update_fields=['status', 'claimed_at', 'UpdateTime'])
|
||||
_write_claim_log(claim, uid, RankRewardClaimLog.ACTION_SUCCESS, amount, claim.balance_field)
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
|
||||
@@ -41,7 +41,7 @@ def _resolve_club_id(request, data_club_id=''):
|
||||
|
||||
def _load_display_maps(yonghuids, shenfen):
|
||||
users = User.objects.filter(UserUID__in=yonghuids)
|
||||
nick_map = {u.UserUID: u.UserName or u.Phone or '用户' for u in users}
|
||||
nick_map = {u.UserUID: (u.UserName or u.Phone or u.UserUID) for u in users}
|
||||
avatar_map = {u.UserUID: u.Avatar or '' for u in users}
|
||||
if shenfen == 'dashou':
|
||||
for p in UserDashou.objects.filter(user__UserUID__in=yonghuids).select_related('user'):
|
||||
@@ -139,7 +139,8 @@ class PhbJlLqView(APIView):
|
||||
except (TypeError, ValueError):
|
||||
return Response({'code': 400, 'msg': 'claim_id 无效', 'data': None})
|
||||
|
||||
result = claim_reward(claim_id, request.user)
|
||||
shenfen = (request.data.get('shenfen') or '').strip()
|
||||
result = claim_reward(claim_id, request.user, request_shenfen=shenfen)
|
||||
if not result.get('ok'):
|
||||
return Response({'code': 400, 'msg': result.get('msg', '领取失败'), 'data': None})
|
||||
return Response({
|
||||
|
||||
Reference in New Issue
Block a user