480 lines
16 KiB
Python
480 lines
16 KiB
Python
"""今日订单:真单统计 + 可选假数据混合(独立于抢单池假单)。"""
|
||
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)
|
||
if 'mix_enabled' in data:
|
||
row.mix_enabled = bool(data.get('mix_enabled'))
|
||
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(1, min(86400, int(data.get('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, ''
|