diff --git a/rank/migrations/0005_rank_reward_activity_and_claim_log.py b/rank/migrations/0005_rank_reward_activity_and_claim_log.py new file mode 100644 index 0000000..15daca0 --- /dev/null +++ b/rank/migrations/0005_rank_reward_activity_and_claim_log.py @@ -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'], + }, + ), + ] diff --git a/rank/models.py b/rank/models.py index 7c4382c..9077ba8 100644 --- a/rank/models.py +++ b/rank/models.py @@ -224,4 +224,4 @@ class KaoheguanBankuai(QModel): indexes = [models.Index(fields=['kaoheguan_id'])] -from .reward_models import RankRewardScheme, RankRewardTier, RankRewardSettlement, RankRewardClaim # noqa: E402,F401 \ No newline at end of file +from .reward_models import RankRewardScheme, RankRewardTier, RankRewardSettlement, RankRewardClaim, RankRewardClaimLog # noqa: E402,F401 \ No newline at end of file diff --git a/rank/reward_houtai_views.py b/rank/reward_houtai_views.py index f4c5fd2..cd2dec4 100644 --- a/rank/reward_houtai_views.py +++ b/rank/reward_houtai_views.py @@ -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, }, ) diff --git a/rank/reward_models.py b/rank/reward_models.py index e693c3c..a2438aa 100644 --- a/rank/reward_models.py +++ b/rank/reward_models.py @@ -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'] diff --git a/rank/reward_services.py b/rank/reward_services.py index 56f79e1..cbf77dc 100644 --- a/rank/reward_services.py +++ b/rank/reward_services.py @@ -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, diff --git a/rank/reward_views.py b/rank/reward_views.py index 3fbdd57..6dbae61 100644 --- a/rank/reward_views.py +++ b/rank/reward_views.py @@ -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({