From 0fe7041faf7e7cd55037f163e456a236dbe1c3c6 Mon Sep 17 00:00:00 2001 From: XingQue Date: Mon, 27 Jul 2026 08:46:22 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=88=90=E4=BA=A4=E6=8C=87=E6=A0=87?= =?UTF-8?q?=E5=A7=8B=E7=BB=88=E8=AE=B0=E8=B4=A6=E5=B9=B6=E5=9B=9E=E5=A1=AB?= =?UTF-8?q?=EF=BC=9B=E6=8A=A2=E5=8D=95=E5=BB=BA=E8=81=8A=E7=A7=BB=E5=87=BA?= =?UTF-8?q?=E4=BA=8B=E5=8A=A1=E5=B9=B6=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抢单池刷新触发历史回填,旧单也能出成交率/罚款率/均时;建聊失败重试并订阅组队成员,避免消息列表空白。 Co-authored-by: Cursor --- jituan/services/deal_stats.py | 87 +++++++++++++++++++++++++++++------ jituan/services/order_deal.py | 23 +++++++-- orders/views/dashou.py | 9 +++- utils/chat_utils.py | 49 +++++++++++++++++--- 4 files changed, 143 insertions(+), 25 deletions(-) diff --git a/jituan/services/deal_stats.py b/jituan/services/deal_stats.py index f082502..d3f2427 100644 --- a/jituan/services/deal_stats.py +++ b/jituan/services/deal_stats.py @@ -15,14 +15,62 @@ def _cfg(club_id: str): def _stats_on(cfg, order) -> bool: - if not cfg or not cfg.stats_enabled or not cfg.stats_since: - return False - ct = getattr(order, 'CreateTime', None) - if not ct or ct < cfg.stats_since: + """ + 成交指标始终记账(抢单池要展示成交率/罚款率/均时)。 + 若配置了 stats_since,只统计该时间点之后的订单。 + """ + if not order: return False + since = getattr(cfg, 'stats_since', None) if cfg else None + if since: + ct = getattr(order, 'CreateTime', None) + if ct and ct < since: + return False return True +def ensure_club_stats_bootstrapped(club_id: str, *, lookback_days: int = 180): + """ + 俱乐部首次拉取抢单池商家指标时,若从未记账则回填近 N 天历史订单。 + 幂等:用 DealStatEvent 打标记。 + """ + cid = (club_id or '').strip() + if not cid: + return + marker = f'{cid}:stats_bootstrap_v1' + from jituan.models import DealStatEvent, MerchantDealStat + if DealStatEvent.query.filter(event_key=marker).exists(): + return + # 已有商家样本则只打标,避免重复重算 + if MerchantDealStat.query.filter(club_id=cid, order_count__gt=0).exists(): + _claim_event(cid, marker, 'stats_bootstrap', subject_uid='system') + return + try: + from orders.models import Order + from datetime import timedelta + since = timezone.now() - timedelta(days=max(7, int(lookback_days or 180))) + qs = ( + Order.query.filter(ClubID=cid, CreateTime__gte=since) + .exclude(PlayerID__isnull=True) + .exclude(PlayerID='') + .filter(Status__in=[2, 3, 4, 5, 6, 8]) + .order_by('CreateTime')[:800] + ) + for order in qs: + try: + on_order_enter_pool(order, force=True) + if int(order.Status or 0) == 3: + on_order_completed(order, settle_started_at=getattr(order, 'SettlementTime', None), force=True) + elif int(order.Status or 0) == 5: + on_order_refunded(order, force=True) + except Exception as e: + logger.warning('回填单条成交指标失败 order=%s: %s', getattr(order, 'OrderID', ''), e) + _claim_event(cid, marker, 'stats_bootstrap', subject_uid='system', meta={'n': len(qs)}) + logger.info('俱乐部 %s 成交指标回填完成 n=%s', cid, len(qs)) + except Exception as e: + logger.error('俱乐部成交指标回填失败 club=%s: %s', cid, e, exc_info=True) + + def _claim_event(club_id: str, event_key: str, event_type: str, order_id='', subject_uid='', meta=None) -> bool: from jituan.models import DealStatEvent if DealStatEvent.query.filter(event_key=event_key).exists(): @@ -68,11 +116,11 @@ def _merchant_uid(order): @transaction.atomic -def on_order_enter_pool(order): - """接单入池:商家/打手 order_count+1。""" +def on_order_enter_pool(order, *, force=False): + """接单入池:商家/打手 order_count+1。force=True 用于历史回填(忽略 since)。""" club_id = (getattr(order, 'ClubID', None) or '').strip() cfg = _cfg(club_id) - if not _stats_on(cfg, order): + if not force and not _stats_on(cfg, order): return oid = order.OrderID dashou = (order.PlayerID or '').strip() @@ -93,16 +141,16 @@ def on_order_enter_pool(order): @transaction.atomic -def on_order_completed(order, settle_started_at=None): +def on_order_completed(order, settle_started_at=None, *, force=False): club_id = (getattr(order, 'ClubID', None) or '').strip() cfg = _cfg(club_id) - if not _stats_on(cfg, order): + if not force and not _stats_on(cfg, order): return oid = order.OrderID if not _claim_event(club_id, f'{oid}:completed', 'completed', oid): return # 确保入池 - on_order_enter_pool(order) + on_order_enter_pool(order, force=force) st = settle_started_at or getattr(order, '_deal_settle_started_at', None) duration = None @@ -134,10 +182,10 @@ def on_order_completed(order, settle_started_at=None): @transaction.atomic -def on_order_refunded(order): +def on_order_refunded(order, *, force=False): club_id = (getattr(order, 'ClubID', None) or '').strip() cfg = _cfg(club_id) - if not _stats_on(cfg, order) or order.Platform != 2: + if (not force and not _stats_on(cfg, order)) or order.Platform != 2: return oid = order.OrderID mid = _merchant_uid(order) @@ -145,7 +193,7 @@ def on_order_refunded(order): return if not _claim_event(club_id, f'{oid}:refunded', 'refunded', oid, mid): return - on_order_enter_pool(order) + on_order_enter_pool(order, force=force) _ensure_merchant(club_id, mid) from jituan.models import MerchantDealStat MerchantDealStat.objects.filter(club_id=club_id, merchant_uid=mid).update( @@ -160,8 +208,7 @@ def on_penalty_paid(penalty): if int(getattr(penalty, 'Status', 0) or 0) != PENALTY_PAID: return club_id = (getattr(penalty, 'ClubID', None) or '').strip() - cfg = _cfg(club_id) - if not cfg or not cfg.stats_enabled or not cfg.stats_since: + if not club_id: return pid = getattr(penalty, 'id', None) or getattr(penalty, 'pk', None) key = f'penalty:{pid}:paid' @@ -246,6 +293,16 @@ def batch_pool_merchant_stats(club_id: str, merchant_uids, order_club_map=None) if not uids: return {} default_club = (club_id or '').strip() + # 首次无样本时回填历史,保证旧单刷新也能看到成交率/罚款率/均时 + try: + if default_club: + ensure_club_stats_bootstrapped(default_club) + if order_club_map: + for cid in set((c or '').strip() for c in order_club_map.values() if c): + if cid and cid != default_club: + ensure_club_stats_bootstrapped(cid) + except Exception as e: + logger.warning('抢单池成交指标回填触发失败: %s', e) club_ids = {default_club} if default_club else set() if order_club_map: for cid in order_club_map.values(): diff --git a/jituan/services/order_deal.py b/jituan/services/order_deal.py index e0a38b0..88967fa 100644 --- a/jituan/services/order_deal.py +++ b/jituan/services/order_deal.py @@ -17,12 +17,18 @@ logger = logging.getLogger(__name__) def get_or_create_deal_config(club_id: str): from jituan.models import ClubOrderDealConfig + from datetime import datetime cid = (club_id or '').strip() if not cid: return None row = ClubOrderDealConfig.query.filter(club_id=cid).first() if not row: - row = ClubOrderDealConfig(club_id=cid) + # 新建俱乐部默认开启成交指标,便于抢单池立刻展示成交率/罚款率/均时 + row = ClubOrderDealConfig( + club_id=cid, + stats_enabled=True, + stats_since=timezone.make_aware(datetime(2020, 1, 1)), + ) row.save() return row @@ -118,7 +124,9 @@ def save_deal_config(club_id: str, data: dict, updated_by: str = '') -> dict: enabled = bool(data['stats_enabled']) row.stats_enabled = enabled if enabled and not was_stats: - row.stats_since = now + # 开启时默认从较早起点计,便于旧单刷新也能出成交率 + from datetime import datetime + row.stats_since = timezone.make_aware(datetime(2020, 1, 1)) if not enabled: # 停止写入;保留 since 供只读理解历史起点;再次开启会刷新 since pass @@ -127,10 +135,19 @@ def save_deal_config(club_id: str, data: dict, updated_by: str = '') -> dict: # 若刚打开统计且 since 空 if row.stats_enabled and not row.stats_since: - row.stats_since = now + from datetime import datetime + row.stats_since = timezone.make_aware(datetime(2020, 1, 1)) row.updated_by = (updated_by or '')[:32] row.save() + # 保存后触发一次历史回填,保证抢单池马上能刷到成交率/罚款率 + try: + from jituan.models import DealStatEvent + from jituan.services.deal_stats import ensure_club_stats_bootstrapped + DealStatEvent.objects.filter(event_key=f'{row.club_id}:stats_bootstrap_v1').delete() + ensure_club_stats_bootstrapped(row.club_id) + except Exception as e: + logger.warning('保存成交配置后回填失败 club=%s: %s', row.club_id, e) return config_to_dict(row) diff --git a/orders/views/dashou.py b/orders/views/dashou.py index f81afbe..3010479 100644 --- a/orders/views/dashou.py +++ b/orders/views/dashou.py @@ -507,8 +507,15 @@ class QiangdanView(APIView): return Response({'code': 400, 'msg': validation_result['message']}) self._execute_qiangdan(order, dashou_profile, request.user.UserUID) - # ========== 【唯一改动】聊天建立方式 ========== + # 建聊必须在事务提交后:GoEasy HTTP 不可放在行锁事务里,失败也不回滚抢单 + chat_success = False + try: chat_success = establish_order_chat(dingdan_id) + if not chat_success: + # 再试一次,避免偶发订阅/发消息失败导致消息列表空白 + chat_success = establish_order_chat(dingdan_id) + except Exception as e: + logger.error(f"抢单后建立聊天失败 dingdan={dingdan_id}: {e}", exc_info=True) try: update_dashou_daily_by_action( diff --git a/utils/chat_utils.py b/utils/chat_utils.py index c9bf473..b0c8217 100644 --- a/utils/chat_utils.py +++ b/utils/chat_utils.py @@ -413,16 +413,40 @@ def establish_order_chat(dingdan_id): # ========== 普通订单 / 我方派单 ========== def _handle_local_order(order, dashou_goeasy_id, dashou_name, dashou_avatar, appkey, secret): - group_id = resolve_pair_group_id(order) or f"group_{order.OrderID}" + # 与详情「联系」一致:有组队用 group_订单,否则配对群 + group_id = resolve_order_chat_group_id(order) or f"group_{order.OrderID}" group_name = (order.Description[:20] + '…') if order.Description and len(order.Description) > 20 else (order.Description or f"订单{order.OrderID}") group_avatar = _full_local_avatar(order.ImageURL) if order.ImageURL else _full_local_avatar('') partner_goeasy_id, partner_name, partner_avatar = _get_local_partner_info(order) if not partner_goeasy_id: + logger.error('抢单建聊失败:找不到商家/老板 partner order=%s', order.OrderID) return False - # 订阅双方 - if not _subscribe_users_to_group([dashou_goeasy_id, partner_goeasy_id], [group_id], appkey, secret): + subscribe_ids = [dashou_goeasy_id, partner_goeasy_id] + # 组队订单:把在队队员也拉进群,避免队员消息列表空白 + try: + from orders.models import TeamMember, TeamRecruit + rec = TeamRecruit.query.filter( + order_id=order.OrderID, + status__in=[TeamRecruit.STATUS_RECRUITING, TeamRecruit.STATUS_LOCKED], + ).first() + if rec: + for uid in TeamMember.query.filter( + recruit_id=rec.id, join_status=TeamMember.JOIN_ACTIVE, + ).values_list('user_uid', flat=True): + u = (uid or '').strip() + if u: + subscribe_ids.append(f'Ds{u}') + except Exception as e: + logger.warning('抢单建聊订阅组队成员失败: %s', e) + + # 订阅双方(失败则重试) + subscribed = _subscribe_users_to_group(subscribe_ids, [group_id], appkey, secret) + if not subscribed: + subscribed = _subscribe_users_to_group(subscribe_ids, [group_id], appkey, secret) + if not subscribed: + logger.error('抢单建聊订阅失败 order=%s group=%s', order.OrderID, group_id) return False # 1. 打手发送初始化消息 @@ -431,15 +455,28 @@ def _handle_local_order(order, dashou_goeasy_id, dashou_name, dashou_avatar, app appkey, secret, group_id, dashou_goeasy_id, dashou_name, group_avatar, init_msg_text, None, group_name=group_name, order_id=order.OrderID ) + if not success1: + success1 = _send_group_message( + appkey, secret, group_id, dashou_goeasy_id, dashou_name, group_avatar, + init_msg_text, None, group_name=group_name, order_id=order.OrderID + ) - # 2. 【新增】派单方也发一条消息,确保他也能看到群聊 - partner_msg = f"订单已确认,请开始服务。" + # 2. 派单方也发一条,确保商家/老板会话列表能立刻出现该群 + partner_msg = f"订单已被接单,请及时沟通。" success2 = _send_group_message( appkey, secret, group_id, partner_goeasy_id, partner_name, partner_avatar, partner_msg, None, group_name=group_name, order_id=order.OrderID ) + if not success2: + success2 = _send_group_message( + appkey, secret, group_id, partner_goeasy_id, partner_name, partner_avatar, + partner_msg, None, group_name=group_name, order_id=order.OrderID + ) - return success1 and success2 + ok = bool(success1 or success2) + if not ok: + logger.error('抢单建聊发消息失败 order=%s group=%s', order.OrderID, group_id) + return ok def _get_local_partner_info(order): """本地订单下单方:老板或商家"""