fix: 成交指标始终记账并回填;抢单建聊移出事务并重试
抢单池刷新触发历史回填,旧单也能出成交率/罚款率/均时;建聊失败重试并订阅组队成员,避免消息列表空白。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -15,14 +15,62 @@ def _cfg(club_id: str):
|
|||||||
|
|
||||||
|
|
||||||
def _stats_on(cfg, order) -> bool:
|
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)
|
若配置了 stats_since,只统计该时间点之后的订单。
|
||||||
if not ct or ct < cfg.stats_since:
|
"""
|
||||||
|
if not order:
|
||||||
return False
|
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
|
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:
|
def _claim_event(club_id: str, event_key: str, event_type: str, order_id='', subject_uid='', meta=None) -> bool:
|
||||||
from jituan.models import DealStatEvent
|
from jituan.models import DealStatEvent
|
||||||
if DealStatEvent.query.filter(event_key=event_key).exists():
|
if DealStatEvent.query.filter(event_key=event_key).exists():
|
||||||
@@ -68,11 +116,11 @@ def _merchant_uid(order):
|
|||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def on_order_enter_pool(order):
|
def on_order_enter_pool(order, *, force=False):
|
||||||
"""接单入池:商家/打手 order_count+1。"""
|
"""接单入池:商家/打手 order_count+1。force=True 用于历史回填(忽略 since)。"""
|
||||||
club_id = (getattr(order, 'ClubID', None) or '').strip()
|
club_id = (getattr(order, 'ClubID', None) or '').strip()
|
||||||
cfg = _cfg(club_id)
|
cfg = _cfg(club_id)
|
||||||
if not _stats_on(cfg, order):
|
if not force and not _stats_on(cfg, order):
|
||||||
return
|
return
|
||||||
oid = order.OrderID
|
oid = order.OrderID
|
||||||
dashou = (order.PlayerID or '').strip()
|
dashou = (order.PlayerID or '').strip()
|
||||||
@@ -93,16 +141,16 @@ def on_order_enter_pool(order):
|
|||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
@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()
|
club_id = (getattr(order, 'ClubID', None) or '').strip()
|
||||||
cfg = _cfg(club_id)
|
cfg = _cfg(club_id)
|
||||||
if not _stats_on(cfg, order):
|
if not force and not _stats_on(cfg, order):
|
||||||
return
|
return
|
||||||
oid = order.OrderID
|
oid = order.OrderID
|
||||||
if not _claim_event(club_id, f'{oid}:completed', 'completed', oid):
|
if not _claim_event(club_id, f'{oid}:completed', 'completed', oid):
|
||||||
return
|
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)
|
st = settle_started_at or getattr(order, '_deal_settle_started_at', None)
|
||||||
duration = None
|
duration = None
|
||||||
@@ -134,10 +182,10 @@ def on_order_completed(order, settle_started_at=None):
|
|||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def on_order_refunded(order):
|
def on_order_refunded(order, *, force=False):
|
||||||
club_id = (getattr(order, 'ClubID', None) or '').strip()
|
club_id = (getattr(order, 'ClubID', None) or '').strip()
|
||||||
cfg = _cfg(club_id)
|
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
|
return
|
||||||
oid = order.OrderID
|
oid = order.OrderID
|
||||||
mid = _merchant_uid(order)
|
mid = _merchant_uid(order)
|
||||||
@@ -145,7 +193,7 @@ def on_order_refunded(order):
|
|||||||
return
|
return
|
||||||
if not _claim_event(club_id, f'{oid}:refunded', 'refunded', oid, mid):
|
if not _claim_event(club_id, f'{oid}:refunded', 'refunded', oid, mid):
|
||||||
return
|
return
|
||||||
on_order_enter_pool(order)
|
on_order_enter_pool(order, force=force)
|
||||||
_ensure_merchant(club_id, mid)
|
_ensure_merchant(club_id, mid)
|
||||||
from jituan.models import MerchantDealStat
|
from jituan.models import MerchantDealStat
|
||||||
MerchantDealStat.objects.filter(club_id=club_id, merchant_uid=mid).update(
|
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:
|
if int(getattr(penalty, 'Status', 0) or 0) != PENALTY_PAID:
|
||||||
return
|
return
|
||||||
club_id = (getattr(penalty, 'ClubID', None) or '').strip()
|
club_id = (getattr(penalty, 'ClubID', None) or '').strip()
|
||||||
cfg = _cfg(club_id)
|
if not club_id:
|
||||||
if not cfg or not cfg.stats_enabled or not cfg.stats_since:
|
|
||||||
return
|
return
|
||||||
pid = getattr(penalty, 'id', None) or getattr(penalty, 'pk', None)
|
pid = getattr(penalty, 'id', None) or getattr(penalty, 'pk', None)
|
||||||
key = f'penalty:{pid}:paid'
|
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:
|
if not uids:
|
||||||
return {}
|
return {}
|
||||||
default_club = (club_id or '').strip()
|
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()
|
club_ids = {default_club} if default_club else set()
|
||||||
if order_club_map:
|
if order_club_map:
|
||||||
for cid in order_club_map.values():
|
for cid in order_club_map.values():
|
||||||
|
|||||||
@@ -17,12 +17,18 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def get_or_create_deal_config(club_id: str):
|
def get_or_create_deal_config(club_id: str):
|
||||||
from jituan.models import ClubOrderDealConfig
|
from jituan.models import ClubOrderDealConfig
|
||||||
|
from datetime import datetime
|
||||||
cid = (club_id or '').strip()
|
cid = (club_id or '').strip()
|
||||||
if not cid:
|
if not cid:
|
||||||
return None
|
return None
|
||||||
row = ClubOrderDealConfig.query.filter(club_id=cid).first()
|
row = ClubOrderDealConfig.query.filter(club_id=cid).first()
|
||||||
if not row:
|
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()
|
row.save()
|
||||||
return row
|
return row
|
||||||
|
|
||||||
@@ -118,7 +124,9 @@ def save_deal_config(club_id: str, data: dict, updated_by: str = '') -> dict:
|
|||||||
enabled = bool(data['stats_enabled'])
|
enabled = bool(data['stats_enabled'])
|
||||||
row.stats_enabled = enabled
|
row.stats_enabled = enabled
|
||||||
if enabled and not was_stats:
|
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:
|
if not enabled:
|
||||||
# 停止写入;保留 since 供只读理解历史起点;再次开启会刷新 since
|
# 停止写入;保留 since 供只读理解历史起点;再次开启会刷新 since
|
||||||
pass
|
pass
|
||||||
@@ -127,10 +135,19 @@ def save_deal_config(club_id: str, data: dict, updated_by: str = '') -> dict:
|
|||||||
|
|
||||||
# 若刚打开统计且 since 空
|
# 若刚打开统计且 since 空
|
||||||
if row.stats_enabled and not row.stats_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.updated_by = (updated_by or '')[:32]
|
||||||
row.save()
|
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)
|
return config_to_dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -507,8 +507,15 @@ class QiangdanView(APIView):
|
|||||||
return Response({'code': 400, 'msg': validation_result['message']})
|
return Response({'code': 400, 'msg': validation_result['message']})
|
||||||
self._execute_qiangdan(order, dashou_profile, request.user.UserUID)
|
self._execute_qiangdan(order, dashou_profile, request.user.UserUID)
|
||||||
|
|
||||||
# ========== 【唯一改动】聊天建立方式 ==========
|
# 建聊必须在事务提交后:GoEasy HTTP 不可放在行锁事务里,失败也不回滚抢单
|
||||||
|
chat_success = False
|
||||||
|
try:
|
||||||
chat_success = establish_order_chat(dingdan_id)
|
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:
|
try:
|
||||||
update_dashou_daily_by_action(
|
update_dashou_daily_by_action(
|
||||||
|
|||||||
@@ -413,16 +413,40 @@ def establish_order_chat(dingdan_id):
|
|||||||
|
|
||||||
# ========== 普通订单 / 我方派单 ==========
|
# ========== 普通订单 / 我方派单 ==========
|
||||||
def _handle_local_order(order, dashou_goeasy_id, dashou_name, dashou_avatar, appkey, secret):
|
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_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('')
|
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)
|
partner_goeasy_id, partner_name, partner_avatar = _get_local_partner_info(order)
|
||||||
if not partner_goeasy_id:
|
if not partner_goeasy_id:
|
||||||
|
logger.error('抢单建聊失败:找不到商家/老板 partner order=%s', order.OrderID)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 订阅双方
|
subscribe_ids = [dashou_goeasy_id, partner_goeasy_id]
|
||||||
if not _subscribe_users_to_group([dashou_goeasy_id, partner_goeasy_id], [group_id], appkey, secret):
|
# 组队订单:把在队队员也拉进群,避免队员消息列表空白
|
||||||
|
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
|
return False
|
||||||
|
|
||||||
# 1. 打手发送初始化消息
|
# 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,
|
appkey, secret, group_id, dashou_goeasy_id, dashou_name, group_avatar,
|
||||||
init_msg_text, None, group_name=group_name, order_id=order.OrderID
|
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. 【新增】派单方也发一条消息,确保他也能看到群聊
|
# 2. 派单方也发一条,确保商家/老板会话列表能立刻出现该群
|
||||||
partner_msg = f"订单已确认,请开始服务。"
|
partner_msg = f"订单已被接单,请及时沟通。"
|
||||||
success2 = _send_group_message(
|
success2 = _send_group_message(
|
||||||
appkey, secret, group_id, partner_goeasy_id, partner_name, partner_avatar,
|
appkey, secret, group_id, partner_goeasy_id, partner_name, partner_avatar,
|
||||||
partner_msg, None, group_name=group_name, order_id=order.OrderID
|
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):
|
def _get_local_partner_info(order):
|
||||||
"""本地订单下单方:老板或商家"""
|
"""本地订单下单方:老板或商家"""
|
||||||
|
|||||||
Reference in New Issue
Block a user