diff --git a/orders/migrations/0023_todayorderfakebatchstate_show_count.py b/orders/migrations/0023_todayorderfakebatchstate_show_count.py new file mode 100644 index 0000000..35c8ae0 --- /dev/null +++ b/orders/migrations/0023_todayorderfakebatchstate_show_count.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0022_today_order_fake'), + ] + + operations = [ + migrations.AddField( + model_name='todayorderfakebatchstate', + name='show_count', + field=models.PositiveIntegerField(default=0, verbose_name='当前展示假单数量'), + ), + migrations.AlterField( + model_name='todayorderfakebatchstate', + name='fake_ids', + field=models.JSONField(blank=True, default=list, verbose_name='已展示假单主键列表(兼容)'), + ), + migrations.AlterField( + model_name='todayorderfakebatchstate', + name='last_shuffle_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='上次累加时间'), + ), + ] diff --git a/orders/models.py b/orders/models.py index 6b2d1cb..37d8218 100644 --- a/orders/models.py +++ b/orders/models.py @@ -1308,14 +1308,16 @@ class TodayOrderShowConfig(QModel): class TodayOrderFakeBatchState(QModel): """ 当日假单累加状态(按俱乐部一行,不记历史日): - - fake_ids:已展示假单主键(只增不换) + - show_count:当前对外展示的假单数量(只增不减,按间隔累加) - last_shuffle_at:上次累加时间(跨日则重置) + - fake_ids:兼容旧字段,可空 """ id = models.BigAutoField(primary_key=True) club_id = models.CharField(max_length=16, unique=True, db_index=True) last_shuffle_at = models.DateTimeField(null=True, blank=True, verbose_name='上次累加时间') - fake_ids = models.JSONField(default=list, blank=True, verbose_name='已展示假单主键列表') + show_count = models.PositiveIntegerField(default=0, verbose_name='当前展示假单数量') + fake_ids = models.JSONField(default=list, blank=True, verbose_name='已展示假单主键列表(兼容)') UpdateTime = models.DateTimeField(auto_now=True) class Meta: diff --git a/orders/services/today_orders.py b/orders/services/today_orders.py index 061b6f5..f7bd1cd 100644 --- a/orders/services/today_orders.py +++ b/orders/services/today_orders.py @@ -2,13 +2,12 @@ from __future__ import annotations import logging -import random from datetime import datetime, time, timedelta from decimal import Decimal from django.conf import settings from django.db import IntegrityError, transaction -from django.db.models import Count, Q +from django.db.models import Q from django.utils import timezone from orders.models import Order, TodayOrderFake, TodayOrderFakeBatchState, TodayOrderShowConfig @@ -20,6 +19,10 @@ DOING_STATUSES = {1, 2, 7} # 不计入当日有效单:未支付、支付失败、已废弃 EXCLUDE_STATUSES = {9, 14, 25} +# 明细列表假单上限(角标仍用完整 show_count) +_FAKE_LIST_CAP = 3000 +_FAKE_COUNT_CAP = 20000 + def _now_naive(): """项目 USE_TZ=False,MySQL 只接受 naive datetime。""" @@ -84,7 +87,7 @@ def save_config(club_id: str, data: dict) -> dict: if 'batch_size' in data and data.get('batch_size') is not None: row.batch_size = max(0, min(500, int(data.get('batch_size') or 0))) if 'interval_seconds' in data and data.get('interval_seconds') is not None: - row.interval_seconds = max(10, min(86400, int(data.get('interval_seconds') or 120))) + row.interval_seconds = max(1, min(86400, int(data.get('interval_seconds') or 120))) row.save() return config_to_dict(row) @@ -138,7 +141,9 @@ def _ensure_batch_state(club_id: str) -> TodayOrderFakeBatchState: state = TodayOrderFakeBatchState.query.filter(club_id=club_id).first() if state: return state - state = TodayOrderFakeBatchState(club_id=club_id, fake_ids=[], last_shuffle_at=None) + state = TodayOrderFakeBatchState( + club_id=club_id, fake_ids=[], show_count=0, last_shuffle_at=None, + ) try: state.save() except IntegrityError: @@ -148,31 +153,27 @@ def _ensure_batch_state(club_id: str) -> TodayOrderFakeBatchState: return state -def _pick_fake_batch(club_id: str, cfg: TodayOrderShowConfig) -> list[TodayOrderFake]: +def _tick_fake_show_count(club_id: str, cfg: TodayOrderShowConfig) -> int: """ - 假单累加展示(惰性,仅有用户请求时推进): - - 当天首次:抽出 batch_size 条,写入状态 - - 之后每隔 interval_seconds,在原列表上再追加 batch_size(已展示的不变) - - 间隔未到:原样返回,刷新不重抽 - - 无用户刷新:时间到了也不自动变 - - 跨自然日:清空累加,重新从首批开始 + 惰性累加「假单展示数量」(抢单池「今日」角标用这个): + - 当天首次有人请求:show_count = batch_size(如 35) + - 之后每过 interval 秒再有人请求:show_count += batch_size(526→561→596) + - 未到间隔:数字不变 + - 无人请求:时间到了也不涨 + - 跨自然日:清零重计 + 数量与假单池「行数」脱钩;明细从池子循环填充。 """ if not cfg.mix_enabled or int(cfg.batch_size or 0) <= 0: - return [] - pool = list( - TodayOrderFake.query.filter(club_id=club_id, is_active=True).order_by('-sort_order', '-id') - ) - if not pool: - return [] + return 0 + if not TodayOrderFake.query.filter(club_id=club_id, is_active=True).exists(): + return 0 batch_n = max(1, int(cfg.batch_size)) - interval = max(10, int(cfg.interval_seconds or 120)) + interval = max(1, int(cfg.interval_seconds or 120)) now = _now_naive() today = now.date() - pool_by_id = {int(x.id): x for x in pool} with transaction.atomic(): - # 行锁,避免并发刷新重复累加 state = ( TodayOrderFakeBatchState.objects .select_for_update() @@ -180,7 +181,7 @@ def _pick_fake_batch(club_id: str, cfg: TodayOrderShowConfig) -> list[TodayOrder .first() ) if not state: - state = _ensure_batch_state(club_id) + _ensure_batch_state(club_id) state = ( TodayOrderFakeBatchState.objects .select_for_update() @@ -188,59 +189,60 @@ def _pick_fake_batch(club_id: str, cfg: TodayOrderShowConfig) -> list[TodayOrder .first() ) if not state: - return [] + return 0 last = state.last_shuffle_at if last and timezone.is_aware(last): last = timezone.make_naive(last) - ids = _parse_fake_ids(state.fake_ids) - # 跨日重置(不记历史,只保留「当前时段」) + show_count = int(getattr(state, 'show_count', 0) or 0) + # 兼容旧逻辑:只有 fake_ids + if show_count <= 0: + legacy_ids = _parse_fake_ids(state.fake_ids) + if legacy_ids: + show_count = len(legacy_ids) + if last is None or last.date() != today: - ids = [] + show_count = 0 last = None - # 池子里已删的 id 丢掉,保持展示稳定且可继续追加 - ids = [i for i in ids if i in pool_by_id] - id_set = set(ids) - add_batches = 0 - if not ids: + if show_count <= 0: add_batches = 1 elif last is not None: elapsed = (now - last).total_seconds() if elapsed >= interval: - # 离开期间跨过多档则一次补上(仍只在有人刷新时发生) add_batches = int(elapsed // interval) if add_batches > 0: - available = [x for x in pool if int(x.id) not in id_set] - for _ in range(add_batches): - if not available: - break - n = min(batch_n, len(available)) - picked = random.sample(available, n) - for row in picked: - rid = int(row.id) - ids.append(rid) - id_set.add(rid) - available = [x for x in available if int(x.id) not in id_set] - + show_count = min(show_count + batch_n * add_batches, _FAKE_COUNT_CAP) if last is None: new_last = now else: new_last = last + timedelta(seconds=add_batches * interval) if new_last > now: new_last = now - state.fake_ids = ids + state.show_count = show_count state.last_shuffle_at = new_last - state.save(update_fields=['fake_ids', 'last_shuffle_at', 'UpdateTime']) - elif ids != _parse_fake_ids(state.fake_ids): - # 仅清理失效 id - state.fake_ids = ids - state.save(update_fields=['fake_ids', 'UpdateTime']) + state.save(update_fields=['show_count', 'last_shuffle_at', 'UpdateTime']) + elif int(getattr(state, 'show_count', 0) or 0) != show_count: + state.show_count = show_count + state.save(update_fields=['show_count', 'UpdateTime']) - return [pool_by_id[i] for i in ids if i in pool_by_id] + return int(state.show_count or 0) + + +def _load_fake_display_rows(club_id: str, show_count: int) -> list[tuple[TodayOrderFake, int]]: + """按 show_count 从假单池循环取行。""" + if show_count <= 0: + return [] + pool = list( + TodayOrderFake.query.filter(club_id=club_id, is_active=True).order_by('-sort_order', '-id') + ) + if not pool: + return [] + n = min(int(show_count), _FAKE_LIST_CAP) + return [(pool[i % len(pool)], i) for i in range(n)] def _serialize_real(o: Order, icon_map: dict[int, str]) -> dict: @@ -267,7 +269,7 @@ def _serialize_real(o: Order, icon_map: dict[int, str]) -> dict: } -def _serialize_fake(f: TodayOrderFake, icon_map: dict[int, str]) -> dict: +def _serialize_fake(f: TodayOrderFake, icon_map: dict[int, str], seq: int = 0) -> dict: lid = int(f.leixing_id or 0) if f.leixing_id else 0 img = _abs_media(f.image_url or '') if not img and lid: @@ -276,9 +278,9 @@ def _serialize_fake(f: TodayOrderFake, icon_map: dict[int, str]) -> dict: source = 'pingtai' if platform == 1 else 'shangjia' tab = 'doing' if int(f.status or 1) == TodayOrderFake.STATUS_DOING else 'done' return { - 'id': f'f:{f.id}', + 'id': f'f:{f.id}:{seq}', 'is_fake': True, - 'order_id': f'FAKE-{f.id}', + 'order_id': f'FAKE-{f.id}-{seq}', 'source': source, 'platform': platform, 'tab': tab, @@ -290,21 +292,23 @@ def _serialize_fake(f: TodayOrderFake, icon_map: dict[int, str]) -> dict: } -def _merged_items(club_id: str) -> list[dict]: +def _merged_items(club_id: str) -> tuple[list[dict], int, int]: + """返回 (明细items, 真单数, 假单展示数 show_count)。""" cfg = get_or_create_config(club_id) reals = list(_real_qs(club_id).order_by('-CreateTime')[:800]) - fakes = _pick_fake_batch(club_id, cfg) + show_count = _tick_fake_show_count(club_id, cfg) + fake_rows = _load_fake_display_rows(club_id, show_count) lids = set() for o in reals: if o.ProductTypeID: lids.add(int(o.ProductTypeID)) - for f in fakes: + for f, _seq in fake_rows: if f.leixing_id: lids.add(int(f.leixing_id)) icon_map = _leixing_icon_map(club_id, lids) items = [_serialize_real(o, icon_map) for o in reals] - items.extend(_serialize_fake(f, icon_map) for f in fakes) - return items + items.extend(_serialize_fake(f, icon_map, seq=seq) for f, seq in fake_rows) + return items, len(reals), int(show_count) def _filter_items(items: list[dict], source: str, tab: str) -> list[dict]: @@ -318,27 +322,38 @@ def _filter_items(items: list[dict], source: str, tab: str) -> list[dict]: return out -def build_summary(items: list[dict], source: str = 'all') -> dict: +def build_summary(items: list[dict], source: str = 'all', *, total_override: int | None = None) -> dict: scoped = items src = (source or 'all').strip().lower() if src in ('pingtai', 'shangjia'): scoped = [x for x in items if x.get('source') == src] doing = sum(1 for x in scoped if x.get('tab') == 'doing') done = sum(1 for x in scoped if x.get('tab') == 'done') + total = int(total_override) if total_override is not None and src == 'all' else len(scoped) + # source=all 且明细截断时,用 override 保证角标一致;doing/done 按已生成明细比例不够精确时仍用明细 + if total_override is not None and src == 'all' and total > len(scoped): + # 把差额算进 done,避免 total != doing+done + done = max(done, total - doing) return { - 'total': len(scoped), + 'total': total, 'doing': doing, 'done': done, } def today_order_count(club_id: str) -> dict: - """抢单池角标:当日总量(真单 + 当前已累加假单),不分平台。""" + """抢单池角标:真单数 + 累加假单数(按间隔只增)。""" cid = (club_id or '').strip() if not cid: return {'total': 0} - items = _merged_items(cid) - return {'total': len(items)} + cfg = get_or_create_config(cid) + real_n = _real_qs(cid).count() + fake_n = _tick_fake_show_count(cid, cfg) + return { + 'total': int(real_n) + int(fake_n), + 'real': int(real_n), + 'fake': int(fake_n), + } def today_order_feed( @@ -351,8 +366,10 @@ def today_order_feed( cid = (club_id or '').strip() page = max(1, int(page or 1)) limit = max(1, min(50, int(limit or 15))) - items = _merged_items(cid) - summary = build_summary(items, source=source) + items, real_n, fake_n = _merged_items(cid) + summary = build_summary( + items, source=source, total_override=int(real_n) + int(fake_n), + ) filtered = _filter_items(items, source=source, tab=tab) start = (page - 1) * limit end = start + limit