feat(rank): 排行榜奖励活动期、领取审计与校验;修复邀请二维码路径

This commit is contained in:
XingQue
2026-07-02 00:40:50 +08:00
parent 9f4d57a677
commit 9fe03727c5
6 changed files with 206 additions and 6 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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({