feat: 排行榜奖励按俱乐部分奖与手动领取

- 新增奖励方案/档位/结算/领取四表与懒结算服务
- 小程序 phbjlxx/phbjllq/phbjlphb 新接口,phbhqsj 零改动
- 后台 phbjhq/phbjbc/phbjjl 与 phbj666 权限菜单

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-01 17:40:56 +08:00
parent cb03898986
commit 5ef4ff7c91
10 changed files with 1169 additions and 1 deletions

View File

@@ -0,0 +1,102 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('rank', '0003_rename_dashou_biao_dashou__36e1c3_idx_dashou_biao_dashou__502414_idx_and_more'),
]
operations = [
migrations.CreateModel(
name='RankRewardScheme',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16, verbose_name='俱乐部ID')),
('shenfen', models.CharField(db_index=True, max_length=16, verbose_name='身份')),
('period_type', models.CharField(choices=[('day', '日榜(昨日)'), ('week', '周榜(上周)'), ('month', '月榜(上月)')], max_length=16, verbose_name='周期类型')),
('sort_field', models.CharField(max_length=32, verbose_name='排序指标键')),
('title', models.CharField(blank=True, default='', max_length=128, verbose_name='展示标题')),
('description', models.TextField(blank=True, default='', verbose_name='规则说明')),
('enabled', models.BooleanField(default=False, verbose_name='启用')),
('claim_days', models.PositiveSmallIntegerField(default=30, verbose_name='领取有效天数')),
('min_metric', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='最低指标门槛')),
('CreateTime', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('UpdateTime', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
'verbose_name': '排行榜奖励方案',
'verbose_name_plural': '排行榜奖励方案',
'db_table': 'rank_reward_scheme',
'unique_together': {('club_id', 'shenfen', 'period_type')},
},
),
migrations.CreateModel(
name='RankRewardTier',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('rank_from', models.PositiveSmallIntegerField(verbose_name='名次起')),
('rank_to', models.PositiveSmallIntegerField(verbose_name='名次止')),
('reward_amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='奖金(元)')),
('label', models.CharField(blank=True, default='', max_length=32, verbose_name='档位标签')),
('scheme', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tiers', to='rank.rankrewardscheme', verbose_name='方案')),
],
options={
'verbose_name': '排行榜奖励档位',
'verbose_name_plural': '排行榜奖励档位',
'db_table': 'rank_reward_tier',
'ordering': ['rank_from'],
},
),
migrations.CreateModel(
name='RankRewardSettlement',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16, verbose_name='俱乐部ID')),
('period_key', models.CharField(db_index=True, max_length=32, verbose_name='周期键')),
('period_start', models.DateField(verbose_name='统计起始')),
('period_end', models.DateField(verbose_name='统计截止')),
('riqi_label', models.CharField(max_length=16, verbose_name='前端周期文案')),
('sort_field', models.CharField(max_length=32, verbose_name='结算时排序字段快照')),
('settled_at', models.DateTimeField(auto_now_add=True, verbose_name='结算时间')),
('claim_count', models.PositiveIntegerField(default=0, verbose_name='生成待领取数')),
('scheme', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='rank.rankrewardscheme', verbose_name='方案')),
],
options={
'verbose_name': '排行榜奖励结算',
'verbose_name_plural': '排行榜奖励结算',
'db_table': 'rank_reward_settlement',
'unique_together': {('club_id', 'scheme', 'period_key')},
},
),
migrations.CreateModel(
name='RankRewardClaim',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16, verbose_name='俱乐部ID')),
('yonghuid', models.CharField(db_index=True, max_length=7, verbose_name='用户ID')),
('shenfen', models.CharField(max_length=16, verbose_name='身份')),
('mingci', models.PositiveSmallIntegerField(verbose_name='名次')),
('metric_value', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='指标快照')),
('reward_amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='奖金')),
('status', models.PositiveSmallIntegerField(choices=[(0, '待领取'), (1, '已领取'), (2, '已过期'), (3, '已取消')], db_index=True, default=0)),
('expire_at', models.DateTimeField(verbose_name='领取截止')),
('claimed_at', models.DateTimeField(blank=True, null=True, verbose_name='领取时间')),
('balance_field', models.CharField(max_length=32, verbose_name='入账字段')),
('idempotent_key', models.CharField(max_length=96, unique=True, verbose_name='幂等键')),
('CreateTime', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('UpdateTime', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('settlement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='claims', to='rank.rankrewardsettlement', verbose_name='结算批次')),
],
options={
'verbose_name': '排行榜奖励领取',
'verbose_name_plural': '排行榜奖励领取',
'db_table': 'rank_reward_claim',
'indexes': [
models.Index(fields=['yonghuid', 'status'], name='rank_reward_yonghu_st_idx'),
models.Index(fields=['club_id', 'status'], name='rank_reward_club_st_idx'),
],
},
),
]

View File

@@ -221,4 +221,7 @@ class KaoheguanBankuai(QModel):
verbose_name = '考核官板块关联'
verbose_name_plural = verbose_name
unique_together = ('kaoheguan_id', 'bankuai')
indexes = [models.Index(fields=['kaoheguan_id'])]
indexes = [models.Index(fields=['kaoheguan_id'])]
from .reward_models import RankRewardScheme, RankRewardTier, RankRewardSettlement, RankRewardClaim # noqa: E402,F401

172
rank/reward_houtai_views.py Normal file
View File

@@ -0,0 +1,172 @@
"""排行榜奖励 — 后台 kefu API。"""
import logging
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.services.club_context import resolve_club_id_from_request
from jituan.services.display_config import forbid_display_write_in_all_scope
from rank.reward_models import RankRewardClaim, RankRewardScheme, RankRewardTier
from rank.reward_services import SORT_FIELD_REGISTRY, sort_field_options_for_shenfen
logger = logging.getLogger(__name__)
REWARD_PERMS = ('phbj666', '8080a')
def _has_perm(permissions):
return any(p in permissions for p in REWARD_PERMS)
def _serialize_scheme(scheme):
tiers = RankRewardTier.query.filter(scheme_id=scheme.id).order_by('rank_from')
return {
'id': scheme.id,
'club_id': scheme.club_id,
'shenfen': scheme.shenfen,
'period_type': scheme.period_type,
'sort_field': scheme.sort_field,
'sort_options': sort_field_options_for_shenfen(scheme.shenfen),
'title': scheme.title,
'description': scheme.description,
'enabled': scheme.enabled,
'claim_days': scheme.claim_days,
'min_metric': float(scheme.min_metric or 0),
'tiers': [
{
'id': t.id,
'rank_from': t.rank_from,
'rank_to': t.rank_to,
'reward_amount': float(t.reward_amount),
'label': t.label,
}
for t in tiers
],
}
class RankRewardHoutaiListView(APIView):
"""POST /houtai/phbjhq"""
permission_classes = [IsAuthenticated]
def post(self, request):
username = request.data.get('username')
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return Response({'code': 403, 'msg': '身份验证失败'})
if not _has_perm(permissions):
return Response({'code': 403, 'msg': '无排行榜奖励配置权限'})
club_id = resolve_club_id_from_request(request)
schemes = RankRewardScheme.query.filter(club_id=club_id).order_by('shenfen', 'period_type')
return Response({
'code': 0,
'data': {
'club_id': club_id,
'schemes': [_serialize_scheme(s) for s in schemes],
'shenfen_options': list(SORT_FIELD_REGISTRY.keys()),
'period_options': [
{'key': RankRewardScheme.PERIOD_DAY, 'label': '日榜(昨日)'},
{'key': RankRewardScheme.PERIOD_WEEK, 'label': '周榜(上周)'},
{'key': RankRewardScheme.PERIOD_MONTH, 'label': '月榜(上月)'},
],
},
})
class RankRewardHoutaiSaveView(APIView):
"""POST /houtai/phbjbc"""
permission_classes = [IsAuthenticated]
def post(self, request):
username = request.data.get('username')
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return Response({'code': 403, 'msg': '身份验证失败'})
if not _has_perm(permissions):
return Response({'code': 403, 'msg': '无权限'})
deny = forbid_display_write_in_all_scope(request)
if deny:
return deny
club_id = resolve_club_id_from_request(request)
data = request.data
shenfen = (data.get('shenfen') or '').strip()
period_type = (data.get('period_type') or '').strip()
sort_field = (data.get('sort_field') or '').strip()
if shenfen not in SORT_FIELD_REGISTRY:
return Response({'code': 400, 'msg': '身份无效'})
if period_type not in (RankRewardScheme.PERIOD_DAY, RankRewardScheme.PERIOD_WEEK, RankRewardScheme.PERIOD_MONTH):
return Response({'code': 400, 'msg': '周期无效'})
if sort_field not in SORT_FIELD_REGISTRY[shenfen]:
return Response({'code': 400, 'msg': '排序指标无效'})
tiers = data.get('tiers') or []
if not tiers:
return Response({'code': 400, 'msg': '请配置至少一个奖励档位'})
scheme, _ = RankRewardScheme.objects.update_or_create(
club_id=club_id,
shenfen=shenfen,
period_type=period_type,
defaults={
'sort_field': sort_field,
'title': (data.get('title') or '').strip(),
'description': (data.get('description') or '').strip(),
'enabled': bool(data.get('enabled', False)),
'claim_days': int(data.get('claim_days') or 30),
'min_metric': data.get('min_metric') or 0,
},
)
RankRewardTier.query.filter(scheme_id=scheme.id).delete()
for t in tiers:
RankRewardTier.query.create(
scheme_id=scheme.id,
rank_from=int(t['rank_from']),
rank_to=int(t['rank_to']),
reward_amount=t['reward_amount'],
label=(t.get('label') or '').strip(),
)
return Response({'code': 0, 'msg': '保存成功', 'data': _serialize_scheme(scheme)})
class RankRewardHoutaiClaimsView(APIView):
"""POST /houtai/phbjjl 领取明细"""
permission_classes = [IsAuthenticated]
def post(self, request):
username = request.data.get('username')
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return Response({'code': 403, 'msg': '身份验证失败'})
if not _has_perm(permissions):
return Response({'code': 403, 'msg': '无权限'})
club_id = resolve_club_id_from_request(request)
status = request.data.get('status')
qs = RankRewardClaim.query.filter(club_id=club_id).order_by('-CreateTime')[:100]
if status is not None:
qs = qs.filter(status=int(status))
status_map = {0: '待领取', 1: '已领取', 2: '已过期', 3: '已取消'}
items = [
{
'id': c.id,
'yonghuid': c.yonghuid,
'shenfen': c.shenfen,
'mingci': c.mingci,
'amount': float(c.reward_amount),
'status': c.status,
'status_text': status_map.get(c.status, ''),
'claimed_at': c.claimed_at.isoformat() if c.claimed_at else '',
'CreateTime': c.CreateTime.isoformat() if c.CreateTime else '',
}
for c in qs
]
return Response({'code': 0, 'data': {'list': items}})

117
rank/reward_models.py Normal file
View File

@@ -0,0 +1,117 @@
"""排行榜奖励 — 按俱乐部分奖,与集团展示榜分离。"""
from django.db import models
from gvsdsdk.model_base import QModel
class RankRewardScheme(QModel):
"""俱乐部 + 身份 + 周期 奖励方案"""
PERIOD_DAY = 'day'
PERIOD_WEEK = 'week'
PERIOD_MONTH = 'month'
PERIOD_CHOICES = [
(PERIOD_DAY, '日榜(昨日)'),
(PERIOD_WEEK, '周榜(上周)'),
(PERIOD_MONTH, '月榜(上月)'),
]
club_id = models.CharField(max_length=16, db_index=True, verbose_name='俱乐部ID')
shenfen = models.CharField(max_length=16, db_index=True, verbose_name='身份')
period_type = models.CharField(max_length=16, choices=PERIOD_CHOICES, verbose_name='周期类型')
sort_field = models.CharField(max_length=32, verbose_name='排序指标键')
title = models.CharField(max_length=128, blank=True, default='', verbose_name='展示标题')
description = models.TextField(blank=True, default='', verbose_name='规则说明')
enabled = models.BooleanField(default=False, verbose_name='启用')
claim_days = models.PositiveSmallIntegerField(default=30, verbose_name='领取有效天数')
min_metric = models.DecimalField(
max_digits=12, decimal_places=2, default=0, verbose_name='最低指标门槛',
)
CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间')
class Meta:
db_table = 'rank_reward_scheme'
verbose_name = '排行榜奖励方案'
verbose_name_plural = verbose_name
unique_together = [['club_id', 'shenfen', 'period_type']]
def __str__(self):
return f'{self.club_id}:{self.shenfen}:{self.period_type}'
class RankRewardTier(QModel):
scheme = models.ForeignKey(
RankRewardScheme, on_delete=models.CASCADE, related_name='tiers', verbose_name='方案',
)
rank_from = models.PositiveSmallIntegerField(verbose_name='名次起')
rank_to = models.PositiveSmallIntegerField(verbose_name='名次止')
reward_amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='奖金(元)')
label = models.CharField(max_length=32, blank=True, default='', verbose_name='档位标签')
class Meta:
db_table = 'rank_reward_tier'
verbose_name = '排行榜奖励档位'
verbose_name_plural = verbose_name
ordering = ['rank_from']
def __str__(self):
return f'{self.rank_from}-{self.rank_to} ¥{self.reward_amount}'
class RankRewardSettlement(QModel):
"""结算批次 — 同一 club+方案+period_key 仅一条"""
scheme = models.ForeignKey(RankRewardScheme, on_delete=models.CASCADE, verbose_name='方案')
club_id = models.CharField(max_length=16, db_index=True, verbose_name='俱乐部ID')
period_key = models.CharField(max_length=32, db_index=True, verbose_name='周期键')
period_start = models.DateField(verbose_name='统计起始')
period_end = models.DateField(verbose_name='统计截止')
riqi_label = models.CharField(max_length=16, verbose_name='前端周期文案')
sort_field = models.CharField(max_length=32, verbose_name='结算时排序字段快照')
settled_at = models.DateTimeField(auto_now_add=True, verbose_name='结算时间')
claim_count = models.PositiveIntegerField(default=0, verbose_name='生成待领取数')
class Meta:
db_table = 'rank_reward_settlement'
verbose_name = '排行榜奖励结算'
verbose_name_plural = verbose_name
unique_together = [['club_id', 'scheme', 'period_key']]
class RankRewardClaim(QModel):
STATUS_PENDING = 0
STATUS_CLAIMED = 1
STATUS_EXPIRED = 2
STATUS_CANCELLED = 3
STATUS_CHOICES = [
(STATUS_PENDING, '待领取'),
(STATUS_CLAIMED, '已领取'),
(STATUS_EXPIRED, '已过期'),
(STATUS_CANCELLED, '已取消'),
]
settlement = models.ForeignKey(
RankRewardSettlement, on_delete=models.CASCADE, related_name='claims', verbose_name='结算批次',
)
club_id = models.CharField(max_length=16, db_index=True, verbose_name='俱乐部ID')
yonghuid = models.CharField(max_length=7, db_index=True, verbose_name='用户ID')
shenfen = models.CharField(max_length=16, verbose_name='身份')
mingci = models.PositiveSmallIntegerField(verbose_name='名次')
metric_value = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='指标快照')
reward_amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='奖金')
status = models.PositiveSmallIntegerField(default=STATUS_PENDING, choices=STATUS_CHOICES, db_index=True)
expire_at = models.DateTimeField(verbose_name='领取截止')
claimed_at = models.DateTimeField(null=True, blank=True, verbose_name='领取时间')
balance_field = models.CharField(max_length=32, verbose_name='入账字段')
idempotent_key = models.CharField(max_length=96, unique=True, verbose_name='幂等键')
CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间')
class Meta:
db_table = 'rank_reward_claim'
verbose_name = '排行榜奖励领取'
verbose_name_plural = verbose_name
indexes = [
models.Index(fields=['yonghuid', 'status']),
models.Index(fields=['club_id', 'status']),
]

521
rank/reward_services.py Normal file
View File

@@ -0,0 +1,521 @@
"""排行榜奖励 — 俱乐部分榜结算、懒结算、领取入账(严格幂等)。"""
from __future__ import annotations
import logging
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import Optional
from django.db import IntegrityError, transaction
from django.db.models import F, Sum
from django.utils import timezone
from backend.models import (
LeaderDailyStats,
ManagerDailyStats,
MerchantDailyStats,
PlayerDailyStats,
)
from jituan.services.club_user import get_user_club_id
from rank.reward_models import (
RankRewardClaim,
RankRewardScheme,
RankRewardSettlement,
RankRewardTier,
)
from users.business_models import User
from users.models import UserDashou, UserGuanshi, UserShangjia, UserZuzhang
logger = logging.getLogger(__name__)
MAX_RANK = 50
RIQI_TO_PERIOD = {
'昨日': RankRewardScheme.PERIOD_DAY,
'上周': RankRewardScheme.PERIOD_WEEK,
'上月': RankRewardScheme.PERIOD_MONTH,
}
OPEN_RIQI = frozenset({'今日', '本周', '本月', '总榜'})
# shenfen -> sort_field_key -> (model, id_field, db_field, is_int)
SORT_FIELD_REGISTRY = {
'dashou': {
'chengjiao_zongliang': (PlayerDailyStats, 'PlayerID', 'CompletedOrderTotal', True),
'chengjiao_zonge': (PlayerDailyStats, 'PlayerID', 'CompletedAmount', False),
'jiedan_zongliang': (PlayerDailyStats, 'PlayerID', 'AcceptedOrderTotal', True),
'jiedan_zonge': (PlayerDailyStats, 'PlayerID', 'AcceptedAmount', False),
},
'guanshi': {
'chongzhi_dashou_shu': (ManagerDailyStats, 'ManagerID', 'RechargedPlayerCount', True),
'shouru_zonge': (ManagerDailyStats, 'ManagerID', 'TotalIncome', False),
'yaoqing_dashou_shu': (ManagerDailyStats, 'ManagerID', 'InvitedPlayerCount', True),
},
'zuzhang': {
'yaoqing_guanshi_shu': (LeaderDailyStats, 'LeaderID', 'InvitedManagerCount', True),
'shouru_zonge': (LeaderDailyStats, 'LeaderID', 'TotalIncome', False),
'fenyong_jine': (LeaderDailyStats, 'LeaderID', 'CommissionAmount', False),
},
'shangjia': {
'jiesuan_dingdan_shu': (MerchantDailyStats, 'MerchantID', 'SettledOrderCount', True),
'jiesuan_jine': (MerchantDailyStats, 'MerchantID', 'SettledAmount', False),
'paifa_dingdan_shu': (MerchantDailyStats, 'MerchantID', 'AssignedOrderCount', True),
'paifa_jine': (MerchantDailyStats, 'MerchantID', 'AssignedAmount', False),
},
}
SORT_FIELD_LABELS = {
'chengjiao_zongliang': '成交量',
'chengjiao_zonge': '成交总额',
'jiedan_zongliang': '接单量',
'jiedan_zonge': '接单总额',
'chongzhi_dashou_shu': '有效打手数',
'shouru_zonge': '收益总额',
'yaoqing_dashou_shu': '邀请打手数',
'yaoqing_guanshi_shu': '邀请管事数',
'fenyong_jine': '分佣金额',
'jiesuan_dingdan_shu': '结算单量',
'jiesuan_jine': '结算金额',
'paifa_dingdan_shu': '派单量',
'paifa_jine': '派单流水',
}
BALANCE_FIELD_MAP = {
'dashou': 'yue',
'guanshi': 'yue',
'zuzhang': 'ketixian_jine',
'shangjia': 'yue',
}
def sort_field_options_for_shenfen(shenfen: str) -> list:
reg = SORT_FIELD_REGISTRY.get(shenfen) or {}
return [
{'key': k, 'label': SORT_FIELD_LABELS.get(k, k)}
for k in reg.keys()
]
def resolve_period_dates(riqi: str) -> Optional[tuple[date, date, str]]:
today = date.today()
if riqi == '昨日':
d = today - timedelta(days=1)
return d, d, f'day:{d.isoformat()}'
if riqi == '上周':
this_monday = today - timedelta(days=today.weekday())
last_sunday = this_monday - timedelta(days=1)
last_monday = last_sunday - timedelta(days=6)
iso = last_monday.isocalendar()
return last_monday, last_sunday, f'week:{iso[0]}-W{iso[1]:02d}'
if riqi == '上月':
first_this = today.replace(day=1)
last_day = first_this - timedelta(days=1)
first_prev = last_day.replace(day=1)
return first_prev, last_day, f'month:{first_prev.strftime("%Y-%m")}'
return None
def _resolve_date_range_for_display(riqi: str):
"""与 paihang_views 一致,用于集团/俱乐部展示榜。"""
today = date.today()
if riqi == '今日':
return today, today
if riqi == '昨日':
d = today - timedelta(days=1)
return d, d
if riqi == '本周':
monday = today - timedelta(days=today.weekday())
return monday, today
if riqi == '上周':
this_monday = today - timedelta(days=today.weekday())
last_sunday = this_monday - timedelta(days=1)
last_monday = last_sunday - timedelta(days=6)
return last_monday, last_sunday
if riqi == '本月':
return today.replace(day=1), today
if riqi == '上月':
first_this = today.replace(day=1)
last_day = first_this - timedelta(days=1)
return last_day.replace(day=1), last_day
return None
def query_club_rank_rows(club_id: str, shenfen: str, sort_field: str, riqi: str, limit: int = MAX_RANK):
"""按 club_id 过滤的俱乐部榜(分奖唯一依据)。"""
reg = SORT_FIELD_REGISTRY.get(shenfen, {}).get(sort_field)
if not reg:
return []
model, id_field, db_field, is_int = reg
if riqi == '总榜':
rows = (
model.objects.filter(club_id=club_id)
.values(id_field)
.annotate(metric=Sum(db_field))
.order_by('-metric')[:limit]
)
return [
{'yonghuid': r[id_field], 'metric': r['metric'] or 0, 'is_int': is_int}
for r in rows if r.get('metric')
]
date_range = _resolve_date_range_for_display(riqi)
if not date_range:
return []
start_date, end_date = date_range
if start_date == end_date:
qs = model.objects.filter(club_id=club_id, Date=start_date).order_by(f'-{db_field}')[:limit]
return [
{'yonghuid': getattr(r, id_field), 'metric': getattr(r, db_field) or 0, 'is_int': is_int}
for r in qs if getattr(r, db_field)
]
rows = (
model.objects.filter(club_id=club_id, Date__gte=start_date, Date__lte=end_date)
.values(id_field)
.annotate(metric=Sum(db_field))
.order_by('-metric')[:limit]
)
return [
{'yonghuid': r[id_field], 'metric': r['metric'] or 0, 'is_int': is_int}
for r in rows if r.get('metric')
]
def query_group_rank_rows(shenfen: str, sort_field: str, riqi: str, limit: int = MAX_RANK):
"""集团总榜(仅展示,不过滤 club"""
return _query_group_rank_rows(shenfen, sort_field, riqi, limit)
def _query_group_rank_rows(shenfen: str, sort_field: str, riqi: str, limit: int = MAX_RANK):
reg = SORT_FIELD_REGISTRY.get(shenfen, {}).get(sort_field)
if not reg:
return []
model, id_field, db_field, is_int = reg
if riqi == '总榜':
rows = (
model.objects.values(id_field)
.annotate(metric=Sum(db_field))
.order_by('-metric')[:limit]
)
return [
{'yonghuid': r[id_field], 'metric': r['metric'] or 0, 'is_int': is_int}
for r in rows if r.get('metric')
]
date_range = _resolve_date_range_for_display(riqi)
if not date_range:
return []
start_date, end_date = date_range
if start_date == end_date:
qs = model.objects.filter(Date=start_date).order_by(f'-{db_field}')[:limit]
return [
{'yonghuid': getattr(r, id_field), 'metric': getattr(r, db_field) or 0, 'is_int': is_int}
for r in qs if getattr(r, db_field)
]
rows = (
model.objects.filter(Date__gte=start_date, Date__lte=end_date)
.values(id_field)
.annotate(metric=Sum(db_field))
.order_by('-metric')[:limit]
)
return [
{'yonghuid': r[id_field], 'metric': r['metric'] or 0, 'is_int': is_int}
for r in rows if r.get('metric')
]
def load_user_club_map(yonghuids: list) -> dict:
if not yonghuids:
return {}
users = User.objects.filter(UserUID__in=yonghuids)
return {u.UserUID: get_user_club_id(u) or getattr(u, 'ClubID', None) or '' for u in users}
def user_has_active_shenfen(yonghuid: str, shenfen: str) -> bool:
if shenfen == 'dashou':
return UserDashou.objects.filter(user__UserUID=yonghuid, zhanghaozhuangtai=1).exists()
if shenfen == 'guanshi':
return UserGuanshi.objects.filter(user__UserUID=yonghuid, zhuangtai=1).exists()
if shenfen == 'zuzhang':
return UserZuzhang.objects.filter(user__UserUID=yonghuid, zhuangtai=1).exists()
if shenfen == 'shangjia':
return UserShangjia.objects.filter(user__UserUID=yonghuid, zhuangtai=1).exists()
return False
def tier_amount_for_rank(tiers, mingci: int) -> Optional[Decimal]:
for t in tiers:
if t.rank_from <= mingci <= t.rank_to:
return t.reward_amount
return None
def maybe_settle_club_rewards(club_id: str, shenfen: str, riqi: str) -> Optional[RankRewardSettlement]:
"""懒结算:仅已结束周期;幂等。"""
if riqi in OPEN_RIQI:
return None
period_type = RIQI_TO_PERIOD.get(riqi)
if not period_type:
return None
period_info = resolve_period_dates(riqi)
if not period_info:
return None
period_start, period_end, period_key = period_info
scheme = RankRewardScheme.query.filter(
club_id=club_id, shenfen=shenfen, period_type=period_type, enabled=True,
).first()
if not scheme:
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
existing = RankRewardSettlement.query.filter(
club_id=club_id, scheme_id=scheme.id, period_key=period_key,
).first()
if existing:
return existing
rows = query_club_rank_rows(club_id, shenfen, scheme.sort_field, riqi)
tiers = list(RankRewardTier.query.filter(scheme_id=scheme.id).order_by('rank_from'))
if not tiers:
return None
min_metric = scheme.min_metric or Decimal('0')
club_map = load_user_club_map([r['yonghuid'] for r in rows])
expire_at = timezone.now() + timedelta(days=scheme.claim_days or 30)
balance_field = BALANCE_FIELD_MAP[shenfen]
claims_to_create = []
for idx, row in enumerate(rows):
mingci = idx + 1
metric = Decimal(str(row['metric'] or 0))
if metric < min_metric:
continue
amount = tier_amount_for_rank(tiers, mingci)
if not amount or amount <= 0:
continue
uid = row['yonghuid']
if club_map.get(uid) != club_id:
continue
if not user_has_active_shenfen(uid, shenfen):
continue
idem = f'{club_id}|{scheme.id}|{period_key}|{uid}'
claims_to_create.append(RankRewardClaim(
club_id=club_id,
yonghuid=uid,
shenfen=shenfen,
mingci=mingci,
metric_value=metric,
reward_amount=amount,
status=RankRewardClaim.STATUS_PENDING,
expire_at=expire_at,
balance_field=balance_field,
idempotent_key=idem,
))
try:
with transaction.atomic():
settlement = RankRewardSettlement.query.create(
scheme_id=scheme.id,
club_id=club_id,
period_key=period_key,
period_start=period_start,
period_end=period_end,
riqi_label=riqi,
sort_field=scheme.sort_field,
claim_count=0,
)
created = 0
for claim in claims_to_create:
claim.settlement_id = settlement.id
try:
claim.save()
created += 1
except IntegrityError:
pass
settlement.claim_count = created
settlement.save(update_fields=['claim_count'])
return settlement
except IntegrityError:
return RankRewardSettlement.query.filter(
club_id=club_id, scheme_id=scheme.id, period_key=period_key,
).first()
def build_reward_payload(club_id: str, shenfen: str, riqi: str, yonghuid: str = '') -> dict:
"""奖励规则 + 我的待领取/已领取状态。"""
period_type = RIQI_TO_PERIOD.get(riqi)
scheme = None
if period_type:
scheme = RankRewardScheme.query.filter(
club_id=club_id, shenfen=shenfen, period_type=period_type,
).first()
settlement = None
if scheme and riqi not in OPEN_RIQI:
maybe_settle_club_rewards(club_id, shenfen, riqi)
period_info = resolve_period_dates(riqi)
if period_info:
_, _, period_key = period_info
settlement = RankRewardSettlement.query.filter(
club_id=club_id, scheme_id=scheme.id, period_key=period_key,
).first()
tiers = []
if scheme:
tiers = [
{
'rank_from': t.rank_from,
'rank_to': t.rank_to,
'amount': float(t.reward_amount),
'label': t.label or '',
}
for t in RankRewardTier.query.filter(scheme_id=scheme.id).order_by('rank_from')
]
rank_rewards = {}
if scheme and settlement:
for c in RankRewardClaim.query.filter(settlement_id=settlement.id):
rank_rewards[c.mingci] = float(c.reward_amount)
my_claim = None
my_claimed = None
if yonghuid and settlement:
pending = RankRewardClaim.query.filter(
settlement_id=settlement.id, yonghuid=yonghuid, status=RankRewardClaim.STATUS_PENDING,
).first()
if pending:
if pending.expire_at < timezone.now():
pending.status = RankRewardClaim.STATUS_EXPIRED
pending.save(update_fields=['status', 'UpdateTime'])
else:
my_claim = {
'claim_id': pending.id,
'amount': float(pending.reward_amount),
'mingci': pending.mingci,
'status': 'pending',
'status_text': '待领取',
'expire_at': pending.expire_at.isoformat(),
'club_id': club_id,
}
claimed = RankRewardClaim.query.filter(
settlement_id=settlement.id, yonghuid=yonghuid, status=RankRewardClaim.STATUS_CLAIMED,
).first()
if claimed:
my_claimed = {
'claim_id': claimed.id,
'amount': float(claimed.reward_amount),
'mingci': claimed.mingci,
'status': 'claimed',
'status_text': '已领取',
'claimed_at': claimed.claimed_at.isoformat() if claimed.claimed_at else '',
}
return {
'club_id': club_id,
'shenfen': shenfen,
'riqi': riqi,
'scheme_enabled': bool(scheme and scheme.enabled),
'period_status': 'open' if riqi in OPEN_RIQI else 'closed',
'period_label': riqi,
'sort_field': scheme.sort_field if scheme else '',
'sort_label': SORT_FIELD_LABELS.get(scheme.sort_field if scheme else '', ''),
'title': scheme.title if scheme else '',
'description': scheme.description if scheme else '',
'tiers': tiers,
'rank_rewards': rank_rewards,
'my_claim': my_claim,
'my_claimed': my_claimed,
}
def list_my_pending_claims(yonghuid: str, club_id: str = '') -> list:
qs = RankRewardClaim.query.filter(
yonghuid=yonghuid, status=RankRewardClaim.STATUS_PENDING,
).order_by('-CreateTime')
if club_id:
qs = qs.filter(club_id=club_id)
now = timezone.now()
result = []
for c in qs[:20]:
if c.expire_at < now:
c.status = RankRewardClaim.STATUS_EXPIRED
c.save(update_fields=['status', 'UpdateTime'])
continue
result.append({
'claim_id': c.id,
'club_id': c.club_id,
'shenfen': c.shenfen,
'amount': float(c.reward_amount),
'mingci': c.mingci,
'status': 'pending',
'status_text': '待领取',
'expire_at': c.expire_at.isoformat(),
})
return result
def claim_reward(claim_id: int, user) -> dict:
uid = user.UserUID
user_club = get_user_club_id(user) or getattr(user, 'ClubID', None) or ''
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': '无权领取该奖励'}
if claim.club_id != user_club:
return {'ok': False, 'msg': '俱乐部不匹配,无法领取'}
if claim.status == RankRewardClaim.STATUS_CLAIMED:
return {'ok': True, 'msg': '已领取', 'amount': float(claim.reward_amount), 'duplicate': True}
if claim.status != RankRewardClaim.STATUS_PENDING:
return {'ok': False, 'msg': '奖励不可领取'}
if claim.expire_at < timezone.now():
claim.status = RankRewardClaim.STATUS_EXPIRED
claim.save(update_fields=['status', 'UpdateTime'])
return {'ok': False, 'msg': '奖励已过期'}
if not user_has_active_shenfen(uid, claim.shenfen):
return {'ok': False, 'msg': '身份状态异常,无法领取'}
amount = claim.reward_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'])
return {
'ok': True,
'msg': '领取成功',
'amount': float(amount),
'shenfen': claim.shenfen,
'balance_field': claim.balance_field,
}
def _credit_balance(shenfen: str, yonghuid: str, amount: Decimal):
amount = Decimal(str(amount))
if shenfen == 'dashou':
p = UserDashou.objects.select_for_update().get(user__UserUID=yonghuid)
UserDashou.objects.filter(pk=p.pk).update(yue=F('yue') + amount)
elif shenfen == 'guanshi':
p = UserGuanshi.objects.select_for_update().get(user__UserUID=yonghuid)
UserGuanshi.objects.filter(pk=p.pk).update(yue=F('yue') + amount)
elif shenfen == 'zuzhang':
p = UserZuzhang.objects.select_for_update().get(user__UserUID=yonghuid)
UserZuzhang.objects.filter(pk=p.pk).update(ketixian_jine=F('ketixian_jine') + amount)
elif shenfen == 'shangjia':
p = UserShangjia.objects.select_for_update().get(user__UserUID=yonghuid)
UserShangjia.objects.filter(pk=p.pk).update(yue=F('yue') + amount)
else:
raise ValueError(f'unsupported shenfen: {shenfen}')

235
rank/reward_views.py Normal file
View File

@@ -0,0 +1,235 @@
"""排行榜奖励 — 小程序 API不修改 phbhqsj"""
import logging
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from jituan.models import Club
from jituan.services.club_user import get_user_club_id
from rank.reward_services import (
BALANCE_FIELD_MAP,
OPEN_RIQI,
SORT_FIELD_LABELS,
build_reward_payload,
claim_reward,
list_my_pending_claims,
query_club_rank_rows,
query_group_rank_rows,
sort_field_options_for_shenfen,
)
from users.business_models import User
from users.models import UserDashou, UserShangjia
logger = logging.getLogger(__name__)
SHENFEN_OPTIONS = frozenset({'dashou', 'guanshi', 'zuzhang', 'shangjia'})
RIQI_OPTIONS = frozenset({'今日', '本周', '本月', '总榜', '昨日', '上周', '上月'})
def _resolve_club_id(request, data_club_id=''):
from jituan.services.club_context import resolve_club_id_from_request
cid = (data_club_id or '').strip()
if not cid:
cid = get_user_club_id(request.user) or getattr(request.user, 'ClubID', None) or ''
if not cid:
cid = resolve_club_id_from_request(request) or 'xq'
return cid
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}
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'):
if p.nicheng:
nick_map[p.user.UserUID] = p.nicheng
elif shenfen == 'shangjia':
for p in UserShangjia.objects.filter(user__UserUID__in=yonghuids).select_related('user'):
if p.nicheng:
nick_map[p.user.UserUID] = p.nicheng
return nick_map, avatar_map
def _format_rank_list(rows, shenfen, sort_field, sort_label):
yonghuids = [r['yonghuid'] for r in rows]
nick_map, avatar_map = _load_display_maps(yonghuids, shenfen)
club_map = {}
try:
from rank.reward_services import load_user_club_map
club_map = load_user_club_map(yonghuids)
except Exception:
pass
club_names = {}
if club_map:
cids = set(club_map.values()) - {''}
for c in Club.query.filter(club_id__in=cids):
club_names[c.club_id] = c.name
result = []
for idx, row in enumerate(rows):
uid = row['yonghuid']
cid = club_map.get(uid, '')
metric = row['metric']
is_int = row.get('is_int', False)
item = {
'mingci': idx + 1,
'yonghuid': uid,
'nicheng': nick_map.get(uid, '用户'),
'touxiang': avatar_map.get(uid, '') or '',
'club_id': cid,
'club_name': club_names.get(cid, cid),
'metric_value': int(metric) if is_int else float(metric),
'metric_label': sort_label,
}
# 兼容 fighter-rank 现有字段
if is_int:
item['chengjiao_zongliang'] = int(metric)
else:
item['chengjiao_zonge'] = float(metric)
result.append(item)
return result
class PhbJlXxView(APIView):
"""POST /yonghu/phbjlxx 奖励规则 + 我的待领/已领(按俱乐部分奖)"""
permission_classes = [IsAuthenticated]
def post(self, request):
shenfen = (request.data.get('shenfen') or 'dashou').strip()
riqi = (request.data.get('riqi') or '上周').strip()
club_id = _resolve_club_id(request, request.data.get('club_id'))
if shenfen not in SHENFEN_OPTIONS:
return Response({'code': 400, 'msg': '身份参数错误', 'data': None})
if riqi not in RIQI_OPTIONS:
return Response({'code': 400, 'msg': '周期参数错误', 'data': None})
uid = request.user.UserUID
payload = build_reward_payload(club_id, shenfen, riqi, uid)
payload['balance_hint'] = BALANCE_FIELD_MAP.get(shenfen, '')
return Response({'code': 200, 'msg': 'success', 'data': payload})
class PhbJlWdView(APIView):
"""POST /yonghu/phbjlwd 我的全部待领取"""
permission_classes = [IsAuthenticated]
def post(self, request):
club_id = _resolve_club_id(request, request.data.get('club_id'))
items = list_my_pending_claims(request.user.UserUID, club_id)
return Response({'code': 200, 'msg': 'success', 'data': {'list': items, 'total': len(items)}})
class PhbJlLqView(APIView):
"""POST /yonghu/phbjllq 领取奖励"""
permission_classes = [IsAuthenticated]
def post(self, request):
claim_id = request.data.get('claim_id')
if not claim_id:
return Response({'code': 400, 'msg': '缺少 claim_id', 'data': None})
try:
claim_id = int(claim_id)
except (TypeError, ValueError):
return Response({'code': 400, 'msg': 'claim_id 无效', 'data': None})
result = claim_reward(claim_id, request.user)
if not result.get('ok'):
return Response({'code': 400, 'msg': result.get('msg', '领取失败'), 'data': None})
return Response({
'code': 200,
'msg': result.get('msg', '领取成功'),
'data': {
'amount': result.get('amount'),
'shenfen': result.get('shenfen'),
'duplicate': result.get('duplicate', False),
},
})
class PhbJlPhbView(APIView):
"""
POST /yonghu/phbjlphb
俱乐部榜 / 集团榜(展示用);分奖仍只按 club 方案。
fanwei: jituan | club
"""
permission_classes = [IsAuthenticated]
def post(self, request):
shenfen = (request.data.get('shenfen') or 'dashou').strip()
riqi = (request.data.get('riqi') or '今日').strip()
fanwei = (request.data.get('fanwei') or 'jituan').strip()
club_id = _resolve_club_id(request, request.data.get('club_id'))
sort_field = (request.data.get('sort_field') or '').strip()
if shenfen not in SHENFEN_OPTIONS:
return Response({'code': 400, 'msg': '身份参数错误', 'data': {'list': []}})
if riqi not in RIQI_OPTIONS:
return Response({'code': 400, 'msg': '周期参数错误', 'data': {'list': []}})
from rank.reward_models import RankRewardScheme
from rank.reward_services import RIQI_TO_PERIOD, SORT_FIELD_REGISTRY
if not sort_field:
period_type = RIQI_TO_PERIOD.get(riqi)
if period_type and fanwei == 'club':
scheme = RankRewardScheme.query.filter(
club_id=club_id, shenfen=shenfen, period_type=period_type, enabled=True,
).first()
if scheme:
sort_field = scheme.sort_field
if not sort_field:
defaults = {
'dashou': 'chengjiao_zongliang',
'guanshi': 'chongzhi_dashou_shu',
'zuzhang': 'yaoqing_guanshi_shu',
'shangjia': 'jiesuan_jine',
}
sort_field = defaults.get(shenfen, 'chengjiao_zongliang')
if sort_field not in (SORT_FIELD_REGISTRY.get(shenfen) or {}):
return Response({'code': 400, 'msg': '排序指标无效', 'data': {'list': []}})
sort_label = SORT_FIELD_LABELS.get(sort_field, sort_field)
if fanwei == 'club':
rows = query_club_rank_rows(club_id, shenfen, sort_field, riqi)
else:
rows = query_group_rank_rows(shenfen, sort_field, riqi)
result_list = _format_rank_list(rows, shenfen, sort_field, sort_label)
reward = build_reward_payload(club_id, shenfen, riqi, request.user.UserUID)
# 给每行附加该 club 奖励金额(仅 club 方案启用且已结算)
rank_rewards = reward.get('rank_rewards') or {}
for item in result_list:
item['reward_amount'] = rank_rewards.get(item['mingci'], 0)
return Response({
'code': 200,
'msg': '获取成功',
'data': {
'list': result_list,
'fanwei': fanwei,
'club_id': club_id,
'sort_field': sort_field,
'sort_label': sort_label,
'reward': reward,
},
})
class PhbJlClubListView(APIView):
"""POST /yonghu/phbjljlb 有奖励方案的俱乐部列表(展示筛选用)"""
permission_classes = [IsAuthenticated]
def post(self, request):
from rank.reward_models import RankRewardScheme
cids = RankRewardScheme.query.filter(enabled=True).values_list('club_id', flat=True).distinct()
clubs = []
for c in Club.query.filter(club_id__in=list(cids)).order_by('sort_order', 'club_id'):
clubs.append({'club_id': c.club_id, 'name': c.name})
my_club = _resolve_club_id(request)
return Response({'code': 200, 'msg': 'success', 'data': {'clubs': clubs, 'my_club_id': my_club}})