fix: 组队强制本俱乐部隔离,修复组队分账冻结幂等键

组队接口一律校验 X-Club-Id=订单=帖;不走跨店互通。分账 biz_id 按队员区分。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-27 03:25:52 +08:00
parent 193a82930e
commit 454f5abe90
3 changed files with 85 additions and 29 deletions

View File

@@ -293,6 +293,7 @@ def credit_dashou_order_settle(
amount,
set_idle_status: bool = False,
increment_chengjiao: bool = True,
settle_biz_id: str = '',
) -> Dict[str, Any]:
"""
打手订单结算专用入账P1
@@ -300,6 +301,7 @@ def credit_dashou_order_settle(
- 可提现走 credit_role_balance未开冻结=全额进 yue与改造前一致
- zonge / 今日今月收益仍按「全额」累加(统计口径不变)
- 成交单量 +1、可选置空闲状态
- settle_biz_id组队多人分账时传入 `{order_id}:team:{uid}`,避免同单幂等撞键
调用方须已在 transaction.atomic() 内;本函数用 F() 更新,勿再对同一 profile save 覆盖余额字段。
"""
from users.models import UserDashou
@@ -307,6 +309,7 @@ def credit_dashou_order_settle(
credit = _money(amount)
order_id = str(getattr(order, 'OrderID', None) or getattr(order, 'order_id', '') or '')
cid = order_club_id(order)
biz_id = (settle_biz_id or order_id or '').strip()
if credit <= 0:
updates = {}
@@ -329,7 +332,7 @@ def credit_dashou_order_settle(
role=ROLE_DASHOU,
amount=credit,
source_type=SOURCE_ORDER_SETTLE,
biz_id=order_id,
biz_id=biz_id,
profile=dashou_profile,
order_id=order_id,
freeze_reason=f'订单结算 {order_id}',

View File

@@ -315,7 +315,7 @@ def on_order_enter_status_8(order):
@transaction.atomic
def join_recruit(*, recruit_id: int, user_uid: str, game_id: str, request=None):
def join_recruit(*, recruit_id: int, user_uid: str, game_id: str, request=None, require_club_id=None):
from orders.models import TeamRecruit, TeamMember, Order
from users.models import UserDashou
from utils.fadan_utils import check_fadan_qiangdan_eligible
@@ -327,6 +327,9 @@ def join_recruit(*, recruit_id: int, user_uid: str, game_id: str, request=None):
rec = TeamRecruit.objects.select_for_update().filter(id=recruit_id).first()
if not rec:
raise ValueError('组队不存在')
# 组队强制本俱乐部:不认跨店互通抢单
if require_club_id and (rec.club_id or '') != require_club_id:
raise ValueError('仅本俱乐部可加入组队')
if rec.status != TeamRecruit.STATUS_RECRUITING:
raise ValueError('招募已结束')
if int(rec.joined_count or 0) >= int(rec.need_count or 0):
@@ -341,6 +344,8 @@ def join_recruit(*, recruit_id: int, user_uid: str, game_id: str, request=None):
raise ValueError('订单状态不可加入')
if (getattr(order, 'ClubID', '') or '') != rec.club_id:
raise ValueError('俱乐部不匹配')
if require_club_id and (order.ClubID or '') != require_club_id:
raise ValueError('仅本俱乐部订单可加入组队')
# 忙碌 / 罚单
try:
@@ -541,6 +546,7 @@ def settle_team_or_legacy(order, *, set_idle_leader=False) -> bool:
amount=amt,
set_idle_status=True,
increment_chengjiao=True,
settle_biz_id=biz_id,
)
TeamSettleLedger(
club_id=rec.club_id,

View File

@@ -15,30 +15,45 @@ from orders.services import team_recruit as team_svc
logger = logging.getLogger(__name__)
def _uid(request):
return getattr(request.user, 'UserUID', '') or ''
def _require_club(request):
club = _club(request)
if not club:
return None, Response({'code': 400, 'msg': '缺少俱乐部'})
return club, None
def _club(request):
return (resolve_club_id_from_request(request) or '').strip()
def _assert_recruit_same_club(rec, club):
"""组队永不走跨店互通:帖/订单/当前俱乐部必须一致。"""
if not rec:
return Response({'code': 404, 'msg': '组队不存在'})
if (rec.club_id or '') != club:
return Response({'code': 403, 'msg': '仅本俱乐部可操作组队'})
order = Order.query.filter(OrderID=rec.order_id).first()
if not order:
return Response({'code': 404, 'msg': '订单不存在'})
if (order.ClubID or '') != club:
return Response({'code': 403, 'msg': '订单不属于本俱乐部'})
return None
class ZuduiKaqiView(APIView):
"""开启组队 POST /zudui/kaqi"""
"""开启组队 POST /zudui/kaqi(仅本俱乐部订单,与跨店抢单无关)"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
uid = _uid(request)
club, err = _require_club(request)
if err:
return err
oid = str(request.data.get('dingdan_id') or request.data.get('order_id') or '').strip()
if not oid:
return Response({'code': 400, 'msg': '缺少订单ID'})
order = Order.query.filter(OrderID=oid).first()
if not order:
return Response({'code': 404, 'msg': '订单不存在'})
club = _club(request)
if club and (order.ClubID or '') != club:
return Response({'code': 403, 'msg': '俱乐部不匹配'})
if (order.ClubID or '') != club:
return Response({'code': 403, 'msg': '仅本俱乐部订单可开启组队'})
try:
rec = team_svc.open_recruit(
order=order,
@@ -69,11 +84,18 @@ class ZuduiGaiyaoqiuView(APIView):
def post(self, request):
uid = _uid(request)
club, err = _require_club(request)
if err:
return err
rid = request.data.get('recruit_id')
try:
rid = int(rid)
except (TypeError, ValueError):
return Response({'code': 400, 'msg': '缺少 recruit_id'})
rec = TeamRecruit.query.filter(id=rid).first()
deny = _assert_recruit_same_club(rec, club)
if deny:
return deny
try:
kw = {'recruit_id': rid, 'leader_uid': uid}
if 'need_count' in request.data:
@@ -101,14 +123,18 @@ class ZuduiGuantingView(APIView):
def post(self, request):
uid = _uid(request)
club, err = _require_club(request)
if err:
return err
rid = request.data.get('recruit_id')
try:
rid = int(rid)
except (TypeError, ValueError):
return Response({'code': 400, 'msg': '缺少 recruit_id'})
rec = TeamRecruit.query.filter(id=rid).first()
if not rec:
return Response({'code': 404, 'msg': '组队不存在'})
deny = _assert_recruit_same_club(rec, club)
if deny:
return deny
if rec.leader_uid != uid:
return Response({'code': 403, 'msg': '仅队长可关停'})
try:
@@ -124,14 +150,14 @@ class ZuduiGuantingView(APIView):
class ZuduiDatingView(APIView):
"""组队大厅:仅招募中"""
"""组队大厅:仅本俱乐部招募中(不读跨店互通)"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
club = _club(request)
if not club:
return Response({'code': 400, 'msg': '缺少俱乐部'})
club, err = _require_club(request)
if err:
return err
page = max(1, int(request.data.get('page') or 1))
page_size = min(50, max(1, int(request.data.get('page_size') or 10)))
leixing = request.data.get('leixing_id')
@@ -142,22 +168,27 @@ class ZuduiDatingView(APIView):
status=TeamRecruit.STATUS_RECRUITING,
).order_by('-opened_at')
# 可选:按订单类型过滤
order_ids = None
if leixing not in (None, '') or pingtai not in (None, ''):
oq = Order.query.filter(ClubID=club, Status=2)
if leixing not in (None, ''):
try:
oq = oq.filter(ProductTypeID=int(leixing))
except (TypeError, ValueError):
pass
# 商品类型必选过滤(与前端大厅一致)
if leixing not in (None, ''):
try:
lid = int(leixing)
except (TypeError, ValueError):
return Response({'code': 400, 'msg': '商品类型无效'})
oq = Order.query.filter(ClubID=club, Status=2, ProductTypeID=lid)
if pingtai not in (None, ''):
try:
oq = oq.filter(Platform=int(pingtai))
except (TypeError, ValueError):
pass
order_ids = list(oq.values_list('OrderID', flat=True)[:500])
order_ids = list(oq.values_list('OrderID', flat=True)[:800])
qs = qs.filter(order_id__in=order_ids or ['__none__'])
elif pingtai not in (None, ''):
try:
oq = Order.query.filter(ClubID=club, Status=2, Platform=int(pingtai))
order_ids = list(oq.values_list('OrderID', flat=True)[:800])
qs = qs.filter(order_id__in=order_ids or ['__none__'])
except (TypeError, ValueError):
pass
total = qs.count()
start = (page - 1) * page_size
@@ -187,6 +218,9 @@ class ZuduiXiangqingView(APIView):
def post(self, request):
uid = _uid(request)
club, err = _require_club(request)
if err:
return err
rid = request.data.get('recruit_id')
oid = str(request.data.get('dingdan_id') or request.data.get('order_id') or '').strip()
rec = None
@@ -196,9 +230,12 @@ class ZuduiXiangqingView(APIView):
except (TypeError, ValueError):
pass
if not rec and oid:
rec = TeamRecruit.query.filter(order_id=oid).order_by('-id').first()
rec = TeamRecruit.query.filter(order_id=oid, club_id=club).order_by('-id').first()
if not rec:
return Response({'code': 404, 'msg': '组队不存在'})
deny = _assert_recruit_same_club(rec, club)
if deny:
return deny
is_member = TeamMember.query.filter(
recruit_id=rec.id, user_uid=uid, join_status=TeamMember.JOIN_ACTIVE,
@@ -218,16 +255,24 @@ class ZuduiRuiduiView(APIView):
def post(self, request):
uid = _uid(request)
club, err = _require_club(request)
if err:
return err
try:
rid = int(request.data.get('recruit_id'))
except (TypeError, ValueError):
return Response({'code': 400, 'msg': '缺少 recruit_id'})
rec0 = TeamRecruit.query.filter(id=rid).first()
deny = _assert_recruit_same_club(rec0, club)
if deny:
return deny
try:
rec = team_svc.join_recruit(
recruit_id=rid,
user_uid=uid,
game_id=request.data.get('game_id') or '',
request=request,
require_club_id=club,
)
order = Order.query.filter(OrderID=rec.order_id).first()
return Response({
@@ -248,7 +293,9 @@ class ZuduiWodeView(APIView):
def post(self, request):
uid = _uid(request)
club = _club(request)
club, err = _require_club(request)
if err:
return err
as_leader = TeamRecruit.query.filter(
club_id=club,
leader_uid=uid,
@@ -263,7 +310,7 @@ class ZuduiWodeView(APIView):
id__in=member_rids or [0],
club_id=club,
status__in=[TeamRecruit.STATUS_RECRUITING, TeamRecruit.STATUS_LOCKED],
) if member_rids and club else []
) if member_rids else []
as_member = list(as_member_qs) if as_member_qs else []
as_leader_list = list(as_leader)
oids = [r.order_id for r in as_leader_list + as_member]