"""今日订单:真单统计 + 可选假数据混合(独立于抢单池假单)。""" from __future__ import annotations import logging 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 Q from django.utils import timezone from orders.models import Order, TodayOrderFake, TodayOrderFakeBatchState, TodayOrderShowConfig logger = logging.getLogger(__name__) # 进行中:已下单/待抢、进行中、指定中 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。""" now = timezone.now() if timezone.is_aware(now): return timezone.make_naive(now) return now def _today_range(): now = _now_naive() start = datetime.combine(now.date(), time.min) end = start + timedelta(days=1) return start, end def _abs_media(path: str) -> str: p = (path or '').strip() if not p: return '' if p.startswith('http://') or p.startswith('https://'): return p base = (getattr(settings, 'COS_DOMAIN', '') or '').rstrip('/') return f'{base}/{p.lstrip("/")}' if base else p def get_or_create_config(club_id: str) -> TodayOrderShowConfig: cid = (club_id or '').strip() row = TodayOrderShowConfig.query.filter(club_id=cid).first() if row: return row row = TodayOrderShowConfig(club_id=cid, mix_enabled=False, batch_size=5, interval_seconds=120) try: row.save() except IntegrityError: row = TodayOrderShowConfig.query.filter(club_id=cid).first() if not row: raise return row def config_to_dict(row: TodayOrderShowConfig | None) -> dict: if not row: return { 'club_id': '', 'mix_enabled': False, 'batch_size': 5, 'interval_seconds': 120, } return { 'club_id': row.club_id, 'mix_enabled': bool(row.mix_enabled), 'batch_size': int(row.batch_size or 5), 'interval_seconds': int(row.interval_seconds or 120), } def save_config(club_id: str, data: dict) -> dict: row = get_or_create_config(club_id) def _as_bool(v): if isinstance(v, bool): return v if isinstance(v, (int, float)): return bool(v) if isinstance(v, (list, tuple)): v = v[-1] if v else False s = str(v or '').strip().lower() if s in ('1', 'true', 'yes', 'on'): return True if s in ('0', 'false', 'no', 'off', ''): return False return bool(v) def _as_int(v, default): if isinstance(v, (list, tuple)): v = v[-1] if v else default if v is None or v == '': return default try: return int(float(v)) except (TypeError, ValueError): return default if 'mix_enabled' in data: row.mix_enabled = _as_bool(data.get('mix_enabled')) if 'batch_size' in data and data.get('batch_size') is not None and data.get('batch_size') != '': row.batch_size = max(0, min(500, _as_int(data.get('batch_size'), row.batch_size or 5))) if 'interval_seconds' in data and data.get('interval_seconds') is not None and data.get('interval_seconds') != '': row.interval_seconds = max( 1, min(86400, _as_int(data.get('interval_seconds'), row.interval_seconds or 120)), ) row.save() return config_to_dict(row) def _leixing_icon_map(club_id: str, leixing_ids: set[int]) -> dict[int, str]: if not leixing_ids: return {} out = {} try: from jituan.services.club_shangpin_leixing import build_club_leixing_list_for_api for row in build_club_leixing_list_for_api(club_id): lid = int(row.get('id') or 0) if lid in leixing_ids: out[lid] = _abs_media(row.get('tupian_url') or '') except Exception as e: logger.warning('leixing icon map failed: %s', e) if len(out) < len(leixing_ids): try: from products.models import ShangpinLeixing for lx in ShangpinLeixing.query.filter(id__in=list(leixing_ids)): if lx.id not in out: out[int(lx.id)] = _abs_media(lx.tupian_url or '') except Exception as e: logger.warning('fallback leixing icons failed: %s', e) return out def _real_qs(club_id: str): start, end = _today_range() return ( Order.query.filter(ClubID=club_id, CreateTime__gte=start, CreateTime__lt=end) .exclude(Status__in=list(EXCLUDE_STATUSES)) ) def _parse_fake_ids(raw) -> list[int]: if not isinstance(raw, list): return [] out = [] for x in raw: try: if isinstance(x, bool): continue out.append(int(x)) except (TypeError, ValueError): continue return out 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=[], show_count=0, last_shuffle_at=None, ) try: state.save() except IntegrityError: state = TodayOrderFakeBatchState.query.filter(club_id=club_id).first() if not state: raise return state def _tick_fake_show_count(club_id: str, cfg: TodayOrderShowConfig) -> int: """ 惰性累加「假单展示数量」(抢单池「今日」角标用这个): - 当天首次有人请求: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 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(1, int(cfg.interval_seconds or 120)) now = _now_naive() today = now.date() with transaction.atomic(): state = ( TodayOrderFakeBatchState.objects .select_for_update() .filter(club_id=club_id) .first() ) if not state: _ensure_batch_state(club_id) state = ( TodayOrderFakeBatchState.objects .select_for_update() .filter(club_id=club_id) .first() ) if not state: return 0 last = state.last_shuffle_at if last and timezone.is_aware(last): last = timezone.make_naive(last) 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: show_count = 0 last = None add_batches = 0 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: 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.show_count = show_count state.last_shuffle_at = new_last 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 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: lid = int(o.ProductTypeID or 0) if o.ProductTypeID else 0 img = _abs_media(o.ImageURL or '') if not img and lid: img = icon_map.get(lid) or '' st = int(o.Status or 0) tab = 'doing' if st in DOING_STATUSES else 'done' platform = int(o.Platform or 0) source = 'pingtai' if platform == 1 else ('shangjia' if platform == 2 else 'pingtai') return { 'id': f'r:{o.OrderID}', 'is_fake': False, 'order_id': o.OrderID, 'source': source, 'platform': platform, 'tab': tab, 'status': st, 'price': str(o.Amount if o.Amount is not None else '0.00'), 'jieshao': (o.Description or o.Remark or '').strip() or '订单', 'image_url': img, 'leixing_id': lid or None, } 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: img = icon_map.get(lid) or '' platform = int(f.platform or 1) 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}:{seq}', 'is_fake': True, 'order_id': f'FAKE-{f.id}-{seq}', 'source': source, 'platform': platform, 'tab': tab, 'status': int(f.status or 1), 'price': str(f.price if f.price is not None else '0.00'), 'jieshao': (f.jieshao or '').strip() or '订单', 'image_url': img, 'leixing_id': lid or None, } 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]) 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, _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, 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]: src = (source or 'all').strip().lower() tb = (tab or 'doing').strip().lower() out = items if src in ('pingtai', 'shangjia'): out = [x for x in out if x.get('source') == src] if tb in ('doing', 'done'): out = [x for x in out if x.get('tab') == tb] return out 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': total, 'doing': doing, 'done': done, } def today_order_count(club_id: str) -> dict: """抢单池角标:真单数 + 累加假单数(按间隔只增)。""" cid = (club_id or '').strip() if not cid: return {'total': 0} 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( club_id: str, source: str = 'all', tab: str = 'doing', page: int = 1, limit: int = 15, ) -> dict: cid = (club_id or '').strip() page = max(1, int(page or 1)) limit = max(1, min(50, int(limit or 15))) 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 chunk = filtered[start:end] return { 'summary': summary, 'list': chunk, 'more': end < len(filtered), 'page': page, 'limit': limit, } def fake_to_admin_dict(row: TodayOrderFake) -> dict: return { 'id': row.id, 'club_id': row.club_id, 'platform': int(row.platform or 1), 'leixing_id': row.leixing_id, 'jieshao': row.jieshao or '', 'price': str(row.price or '0.00'), 'image_url': row.image_url or '', 'image_full_url': _abs_media(row.image_url or ''), 'status': int(row.status or 1), 'is_active': bool(row.is_active), 'sort_order': int(row.sort_order or 0), } def list_fakes(club_id: str) -> list[dict]: rows = TodayOrderFake.query.filter(club_id=club_id).order_by('-sort_order', '-id') return [fake_to_admin_dict(r) for r in rows] def _scalar(val, default=None): """QueryDict / multipart 偶发 list,取末项。""" if isinstance(val, (list, tuple)): val = val[-1] if val else default return default if val is None else val def upsert_fake(club_id: str, data: dict, fake_id=None) -> tuple[dict | None, str]: cid = (club_id or '').strip() if not cid: return None, '缺少俱乐部' try: platform = int(_scalar(data.get('platform'), 1) or 1) except (TypeError, ValueError): return None, 'platform 无效' if platform not in (1, 2): return None, 'platform 须为 1 或 2' try: status = int(_scalar(data.get('status'), TodayOrderFake.STATUS_DOING) or TodayOrderFake.STATUS_DOING) except (TypeError, ValueError): return None, 'status 无效' if status not in (TodayOrderFake.STATUS_DOING, TodayOrderFake.STATUS_DONE): return None, 'status 仅支持进行中/已完成' try: price = Decimal(str(_scalar(data.get('price'), '0') if data.get('price') is not None else '0')) except Exception: return None, '价格无效' leixing_id = _scalar(data.get('leixing_id')) try: leixing_id = int(leixing_id) if leixing_id not in (None, '') else None except (TypeError, ValueError): return None, '商品类型无效' if fake_id: if isinstance(fake_id, (list, tuple)): try: fake_id = int(fake_id[-1] if fake_id else 0) except (TypeError, ValueError): fake_id = None row = TodayOrderFake.query.filter(id=fake_id, club_id=cid).first() if not row: return None, '假单不存在' else: row = TodayOrderFake(club_id=cid) row.platform = platform row.leixing_id = leixing_id row.jieshao = str(_scalar(data.get('jieshao'), '') or '')[:2000] row.price = price if 'image_url' in data: row.image_url = str(_scalar(data.get('image_url'), '') or '').strip()[:500] row.status = status if 'is_active' in data: raw = _scalar(data.get('is_active')) if isinstance(raw, str): row.is_active = raw.strip().lower() in ('1', 'true', 'yes', 'on') else: row.is_active = bool(raw) if 'sort_order' in data and data.get('sort_order') is not None: try: row.sort_order = int(_scalar(data.get('sort_order'), 0) or 0) except (TypeError, ValueError): row.sort_order = 0 row.save() return fake_to_admin_dict(row), '' def delete_fake(club_id: str, fake_id: int) -> tuple[bool, str]: row = TodayOrderFake.query.filter(id=fake_id, club_id=club_id).first() if not row: return False, '假单不存在' row.delete() return True, ''