Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57731f2e8d | |||
| 5cd1c63d8d |
@@ -86,7 +86,12 @@ def _success_rates(rows: list) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad_revenue_yuan: float = 0.0) -> dict:
|
||||
def _session_to_row(
|
||||
r,
|
||||
phone: str | None = None,
|
||||
nickname: str | None = None,
|
||||
ad_revenue_yuan: float | None = None,
|
||||
) -> dict:
|
||||
"""CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。"""
|
||||
return {
|
||||
"id": r.id,
|
||||
@@ -252,7 +257,7 @@ def coupon_data_report(
|
||||
items = []
|
||||
for r in page:
|
||||
phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None)
|
||||
items.append(_session_to_row(r, phone, nickname, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)))
|
||||
items.append(_session_to_row(r, phone, nickname, ad_revenue_yuan=rev_map.get(r.trace_id)))
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
@@ -276,7 +281,7 @@ def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict:
|
||||
).scalar_one()
|
||||
rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows])
|
||||
return {
|
||||
"items": [_session_to_row(r, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)) for r in rows],
|
||||
"items": [_session_to_row(r, ad_revenue_yuan=rev_map.get(r.trace_id)) for r in rows],
|
||||
"total": int(total),
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -109,6 +109,23 @@ def _date_range(date_from: date, date_to: date) -> list[date]:
|
||||
return [date_from + timedelta(days=i) for i in range(days + 1)]
|
||||
|
||||
|
||||
def _duration_percentile(sorted_values: list[int], q: float) -> int | None:
|
||||
"""Linear-interpolated percentile with the same half-up rounding as Math.round."""
|
||||
if not sorted_values:
|
||||
return None
|
||||
if len(sorted_values) == 1:
|
||||
return sorted_values[0]
|
||||
index = (len(sorted_values) - 1) * q
|
||||
lower = int(index)
|
||||
upper = min(lower + 1, len(sorted_values) - 1)
|
||||
fraction = Decimal(str(index - lower))
|
||||
value = (
|
||||
Decimal(sorted_values[lower]) * (Decimal(1) - fraction)
|
||||
+ Decimal(sorted_values[upper]) * fraction
|
||||
)
|
||||
return int(value.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _id_set(db: Session, stmt) -> set[int]:
|
||||
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
|
||||
|
||||
@@ -242,16 +259,46 @@ def dashboard_overview(
|
||||
ComparisonRecord.created_at >= start_local,
|
||||
ComparisonRecord.created_at < end_local,
|
||||
)
|
||||
period_comparison_total = _count(ComparisonRecord, *period_comparison_conds)
|
||||
period_comparison_success = _count(
|
||||
ComparisonRecord,
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.status == "success",
|
||||
period_comparison_stats = db.execute(
|
||||
select(
|
||||
func.count(ComparisonRecord.id),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(ComparisonRecord.status.in_(("success", "failed")), 1),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case((ComparisonRecord.status == "cancelled", 1), else_=0)
|
||||
),
|
||||
0,
|
||||
),
|
||||
func.coalesce(
|
||||
func.sum(case((ComparisonRecord.status == "success", 1), else_=0)),
|
||||
0,
|
||||
),
|
||||
func.coalesce(func.sum(ComparisonRecord.llm_cost_yuan), 0.0),
|
||||
).where(*period_comparison_conds)
|
||||
).one()
|
||||
period_comparison_total = int(period_comparison_stats[0])
|
||||
period_comparison_completed = int(period_comparison_stats[1])
|
||||
period_comparison_cancelled = int(period_comparison_stats[2])
|
||||
period_comparison_success = int(period_comparison_stats[3])
|
||||
period_comparison_token_cost_yuan = float(period_comparison_stats[4])
|
||||
period_comparison_success_denominator = (
|
||||
period_comparison_total - period_comparison_cancelled
|
||||
)
|
||||
period_comparison_success_rate = (
|
||||
round(period_comparison_success / period_comparison_total, 4)
|
||||
if period_comparison_total
|
||||
else 0.0
|
||||
round(
|
||||
period_comparison_success / period_comparison_success_denominator,
|
||||
4,
|
||||
)
|
||||
if period_comparison_success_denominator > 0
|
||||
else None
|
||||
)
|
||||
period_saved_positive_count = _count(
|
||||
ComparisonRecord,
|
||||
@@ -282,6 +329,47 @@ def dashboard_overview(
|
||||
if period_avg_duration_ms is not None
|
||||
else None
|
||||
)
|
||||
completed_duration_conds = (
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.status.in_(("success", "failed")),
|
||||
ComparisonRecord.total_ms.is_not(None),
|
||||
)
|
||||
if db.bind is not None and db.bind.dialect.name == "postgresql":
|
||||
period_median_duration_ms, period_p95_duration_ms = db.execute(
|
||||
select(
|
||||
func.percentile_cont(0.5).within_group(ComparisonRecord.total_ms),
|
||||
func.percentile_cont(0.95).within_group(ComparisonRecord.total_ms),
|
||||
).where(*completed_duration_conds)
|
||||
).one()
|
||||
period_median_duration_ms = (
|
||||
int(
|
||||
Decimal(str(period_median_duration_ms)).quantize(
|
||||
Decimal("1"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
)
|
||||
if period_median_duration_ms is not None
|
||||
else None
|
||||
)
|
||||
period_p95_duration_ms = (
|
||||
int(
|
||||
Decimal(str(period_p95_duration_ms)).quantize(
|
||||
Decimal("1"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
)
|
||||
if period_p95_duration_ms is not None
|
||||
else None
|
||||
)
|
||||
else:
|
||||
# SQLite 测试环境没有 percentile_cont;仅回退读取耗时单列,不加载完整记录。
|
||||
completed_durations = list(
|
||||
db.execute(
|
||||
select(ComparisonRecord.total_ms)
|
||||
.where(*completed_duration_conds)
|
||||
.order_by(ComparisonRecord.total_ms)
|
||||
).scalars()
|
||||
)
|
||||
period_median_duration_ms = _duration_percentile(completed_durations, 0.5)
|
||||
period_p95_duration_ms = _duration_percentile(completed_durations, 0.95)
|
||||
|
||||
ordered_exists = (
|
||||
select(SavingsRecord.id)
|
||||
@@ -622,11 +710,16 @@ def dashboard_overview(
|
||||
},
|
||||
"comparison": {
|
||||
"total": period_comparison_total,
|
||||
"completed": period_comparison_completed,
|
||||
"cancelled": period_comparison_cancelled,
|
||||
"success": period_comparison_success,
|
||||
"success_rate": period_comparison_success_rate,
|
||||
"ordered": period_ordered_count,
|
||||
"average_duration_ms": period_avg_duration_ms,
|
||||
"median_duration_ms": period_median_duration_ms,
|
||||
"p95_duration_ms": period_p95_duration_ms,
|
||||
"average_saved_cents": period_avg_saved_cents,
|
||||
"token_cost_total_yuan": period_comparison_token_cost_yuan,
|
||||
},
|
||||
"coupon": {
|
||||
"started": coupon_started,
|
||||
|
||||
@@ -70,8 +70,9 @@ class CouponDataRow(BaseModel):
|
||||
started_at: datetime = Field(..., description="发起时刻(明细「时间」列)")
|
||||
claimed_count: int | None = None
|
||||
trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id")
|
||||
ad_revenue_yuan: float = Field(
|
||||
0.0, description="本次领券看的信息流广告预估收益(元);按 trace_id 聚合 ad_ecpm_record"
|
||||
ad_revenue_yuan: float | None = Field(
|
||||
None,
|
||||
description="本次领券看的信息流广告预估收益(元);按 trace_id 聚合 ad_ecpm_record;无填充记录为 null",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -53,11 +53,16 @@ class DashboardPeriodUsers(BaseModel):
|
||||
|
||||
class DashboardPeriodComparison(BaseModel):
|
||||
total: int
|
||||
completed: int
|
||||
cancelled: int
|
||||
success: int
|
||||
success_rate: float
|
||||
success_rate: float | None = None
|
||||
ordered: int
|
||||
average_duration_ms: int | None = None
|
||||
median_duration_ms: int | None = None
|
||||
p95_duration_ms: int | None = None
|
||||
average_saved_cents: int | None = None
|
||||
token_cost_total_yuan: float = 0.0
|
||||
|
||||
|
||||
class DashboardPeriodCoupon(BaseModel):
|
||||
|
||||
@@ -169,14 +169,6 @@ class Settings(BaseSettings):
|
||||
# 进程内自动兑换 worker 的检查间隔(秒):每隔这么久醒一次,跨过北京 0 点就跑一轮。
|
||||
# 默认 600s=10min,即 0 点后最多 10 分钟内兑完(客户端文案已注明「可能存在延迟」)。
|
||||
AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600
|
||||
# 连续 N 天无活跃(无比价 / 领券,登录不算)账户金币+现金清零:进程内 worker
|
||||
# app.core.inactive_clear_worker 跨北京 0 点跑一轮 inactive_clear.clear_inactive_accounts。
|
||||
# 邀请奖励金物理隔离,不在清空范围。⚠️ 不可逆批量资金操作,口径见 repositories/inactive_clear。
|
||||
# 运营要临时停在 .env 置 false(worker 不启动)。
|
||||
INACTIVE_CLEAR_ENABLED: bool = True
|
||||
# 连续无活跃**满这么多整天之后**才清:15 → 最后活跃在 15 天前当天仍保留,第 16 天起清。
|
||||
INACTIVE_CLEAR_DAYS: int = 15
|
||||
INACTIVE_CLEAR_CHECK_INTERVAL_SEC: int = 600
|
||||
# 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。
|
||||
# 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容
|
||||
# (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
"""连续 N 天无活跃账户金币 / 现金清零的进程内定时任务。
|
||||
|
||||
结构同 app.core.daily_exchange_worker:每 `INACTIVE_CLEAR_CHECK_INTERVAL_SEC` 醒一次,
|
||||
跨进北京新的一天(0 点后)就跑一轮 inactive_clear.clear_inactive_accounts。
|
||||
|
||||
健壮性:
|
||||
- **天然幂等**:只清有余额的账户,清完余额=0,下一轮 / 重启 / 多次唤醒都不重复清。
|
||||
- **当天首跑即补**:进程起来时若当天还没跑过,立即跑一轮。
|
||||
- **同机多进程互斥**:文件锁保证多 worker 只有一个实际跑。
|
||||
- **开关**:settings.INACTIVE_CLEAR_ENABLED=false 时不启动。
|
||||
|
||||
⚠️ 这是不可逆的批量资金操作(清空用户金币 + 金币现金,邀请金除外)。活跃口径与清零
|
||||
细节见 app.repositories.inactive_clear。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.core import rewards
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal
|
||||
from app.repositories import inactive_clear as inactive_clear_repo
|
||||
|
||||
logger = logging.getLogger("shagua.inactive_clear")
|
||||
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "inactive_clear.lock"
|
||||
|
||||
|
||||
def _touch_lock() -> None:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
os.utime(_LOCK_PATH, None)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]:
|
||||
"""同机多进程保护:同一时间只允许一个清零 worker 运行。"""
|
||||
_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd: int | None = None
|
||||
try:
|
||||
try:
|
||||
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
except FileExistsError:
|
||||
try:
|
||||
age = time.time() - _LOCK_PATH.stat().st_mtime
|
||||
except FileNotFoundError:
|
||||
age = stale_after_sec + 1
|
||||
if age > stale_after_sec:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
_LOCK_PATH.unlink()
|
||||
try:
|
||||
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
except FileExistsError:
|
||||
fd = None
|
||||
|
||||
if fd is None:
|
||||
yield False
|
||||
return
|
||||
|
||||
os.write(fd, f"pid={os.getpid()} started_at={int(time.time())}\n".encode("ascii"))
|
||||
yield True
|
||||
finally:
|
||||
if fd is not None:
|
||||
os.close(fd)
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
_LOCK_PATH.unlink()
|
||||
|
||||
|
||||
def _clear_once() -> dict:
|
||||
with SessionLocal() as db:
|
||||
return inactive_clear_repo.clear_inactive_accounts(
|
||||
db, days=settings.INACTIVE_CLEAR_DAYS
|
||||
)
|
||||
|
||||
|
||||
async def _run_loop() -> None:
|
||||
interval = max(60, int(settings.INACTIVE_CLEAR_CHECK_INTERVAL_SEC))
|
||||
lock_stale_after = max(interval * 3, 1800)
|
||||
with _single_instance_lock(lock_stale_after) as lock_acquired:
|
||||
if not lock_acquired:
|
||||
logger.warning("inactive-clear skipped: another worker owns lock")
|
||||
return
|
||||
await _run_locked_loop(interval)
|
||||
|
||||
|
||||
async def _run_locked_loop(interval: int) -> None:
|
||||
logger.info(
|
||||
"inactive-clear worker started interval=%ss days=%s",
|
||||
interval,
|
||||
settings.INACTIVE_CLEAR_DAYS,
|
||||
)
|
||||
# 本进程上次跑过的北京日;None=尚未跑过本进程(启动即补当天)。
|
||||
last_run: date | None = None
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
_touch_lock()
|
||||
today = rewards.cn_today()
|
||||
if last_run != today:
|
||||
result = await asyncio.to_thread(_clear_once)
|
||||
last_run = today
|
||||
logger.info("inactive-clear done date=%s result=%s", today, result)
|
||||
except SQLAlchemyError:
|
||||
logger.exception("inactive-clear db error")
|
||||
except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出
|
||||
logger.exception("inactive-clear unexpected error")
|
||||
await asyncio.sleep(interval)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("inactive-clear worker stopped")
|
||||
raise
|
||||
|
||||
|
||||
def start_inactive_clear_worker() -> asyncio.Task | None:
|
||||
if not settings.INACTIVE_CLEAR_ENABLED:
|
||||
logger.info("inactive-clear disabled (INACTIVE_CLEAR_ENABLED=false)")
|
||||
return None
|
||||
return asyncio.create_task(_run_loop(), name="inactive-clear")
|
||||
|
||||
|
||||
async def stop_inactive_clear_worker(task: asyncio.Task | None) -> None:
|
||||
if task is None:
|
||||
return
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
@@ -49,10 +49,6 @@ from app.core.heartbeat_monitor_worker import (
|
||||
start_heartbeat_monitor,
|
||||
stop_heartbeat_monitor,
|
||||
)
|
||||
from app.core.inactive_clear_worker import (
|
||||
start_inactive_clear_worker,
|
||||
stop_inactive_clear_worker,
|
||||
)
|
||||
from app.core.logging import setup_logging
|
||||
from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client
|
||||
from app.core.withdraw_reconcile_worker import (
|
||||
@@ -78,21 +74,18 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
try:
|
||||
# 预热离线地理库:首次加载 ~2.5M 行 CSV + 建 KDTree,摊到启动、不砸首个按城市过滤的请求
|
||||
from app.utils import geo
|
||||
|
||||
geo.ensure_loaded()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("reverse_geocoder 预热失败(城市反查将在首个请求时懒加载)")
|
||||
reconcile_task = start_withdraw_reconcile_worker()
|
||||
heartbeat_task = start_heartbeat_monitor()
|
||||
daily_exchange_task = start_daily_exchange_worker()
|
||||
inactive_clear_task = start_inactive_clear_worker()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await stop_heartbeat_monitor(heartbeat_task)
|
||||
await stop_withdraw_reconcile_worker(reconcile_task)
|
||||
await stop_daily_exchange_worker(daily_exchange_task)
|
||||
await stop_inactive_clear_worker(inactive_clear_task)
|
||||
await aclose_pricebot_client()
|
||||
logger.info("shutting down")
|
||||
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
"""连续 N 天无活跃账户的金币 / 现金清零。
|
||||
|
||||
规则(2026-07-15 产品定):某用户连续 `days` 天(默认 15)既没发起比价、也没发起领券
|
||||
→ 判定为「流失」,清空其金币余额(coin_balance)与金币兑换现金(cash_balance_cents)。
|
||||
**单纯登录(打开 App)不算活跃**——光登录、不用比价/领券的用户视为流失照清。
|
||||
**邀请奖励金(invite_cash_balance_cents)物理隔离,不在清空范围**(产品红线,见
|
||||
models/wallet.py CoinAccount 注释);total_coin_earned(历史累计赚取,只增不减)也不动。
|
||||
|
||||
活跃口径:发起比价 real_compare_start + 发起领券 real_coupon_start / claim_started
|
||||
两路并集(**不含登录 last_login_at**)。⚠️与 admin 大盘 DAU / 后台「最近活跃」列**不同**
|
||||
——那两处含登录,此处刻意去掉,只认真正用了核心功能;若两处口径要同步须留意此差异。
|
||||
|
||||
新用户保护:从没发起过比价/领券的用户永远不在活跃集合,故用**注册时间兜底**——注册未满
|
||||
days 天(created_at 在 today-days 之后)的用户豁免清零,新用户有 days 天宽限,不会刚注册
|
||||
(有礼金)就被清。(登录已不算活跃,故不能再靠 last_login 兜底新用户。)
|
||||
|
||||
清零走 wallet.grant_coins / grant_cash(负数出账入口):更新余额快照 + 写一笔
|
||||
biz_type=inactive_clear 的流水(带 balance_after),可逐笔回溯 / 人工恢复;grant_coins
|
||||
对负数不累加 total_coin_earned。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.models.analytics_event import AnalyticsEvent
|
||||
from app.models.coupon_state import CouponPromptEngagement
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CoinAccount
|
||||
from app.repositories.wallet import get_or_create_account, grant_cash, grant_coins
|
||||
|
||||
CLEAR_BIZ_TYPE = "inactive_clear"
|
||||
# 「活跃」计入的埋点事件:发起比价 + 发起领券(登录不算)
|
||||
_ACTIVE_EVENTS = ("real_compare_start", "real_coupon_start")
|
||||
|
||||
|
||||
def _cn_date_start_utc(d: date) -> datetime:
|
||||
"""北京自然日 d 的 00:00 → UTC aware 下界。
|
||||
|
||||
analytics_event.created_at / user.created_at 存 UTC(见各自模型),须用 UTC 边界比较;
|
||||
CN_TZ 是固定 +8 偏移(rewards.CN_TZ),无 DST 歧义。
|
||||
"""
|
||||
return datetime(d.year, d.month, d.day, tzinfo=rewards.CN_TZ).astimezone(UTC)
|
||||
|
||||
|
||||
def _active_user_ids_since(db: Session, since_cn: date) -> set[int]:
|
||||
"""北京自然日 since_cn(含)当天 0 点起 **发起过比价 / 领券** 的 user_id 集合。
|
||||
|
||||
**登录不算活跃**(产品定):只认核心功能行为。两路并集:
|
||||
- 发起比价 / 领券:analytics_event.event in _ACTIVE_EVENTS 且 created_at >= since(UTC 边界)
|
||||
- 发起领券:coupon_prompt_engagement.engage_type=claim_started 且 engage_date >= since(北京日列)
|
||||
"""
|
||||
since_utc = _cn_date_start_utc(since_cn)
|
||||
ids: set[int] = set()
|
||||
|
||||
ids.update(
|
||||
uid
|
||||
for uid in db.execute(
|
||||
select(AnalyticsEvent.user_id).where(
|
||||
AnalyticsEvent.user_id.is_not(None),
|
||||
AnalyticsEvent.event.in_(_ACTIVE_EVENTS),
|
||||
AnalyticsEvent.created_at >= since_utc,
|
||||
)
|
||||
).scalars()
|
||||
if uid is not None
|
||||
)
|
||||
ids.update(
|
||||
uid
|
||||
for uid in db.execute(
|
||||
select(CouponPromptEngagement.user_id).where(
|
||||
CouponPromptEngagement.user_id.is_not(None),
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
CouponPromptEngagement.engage_date >= since_cn,
|
||||
)
|
||||
).scalars()
|
||||
if uid is not None
|
||||
)
|
||||
return ids
|
||||
|
||||
|
||||
def _new_user_ids_since(db: Session, since_cn: date) -> set[int]:
|
||||
"""注册时间 >= since_cn 北京日 0 点的 user_id——注册未满 days 天的新用户,豁免清零。"""
|
||||
since_utc = _cn_date_start_utc(since_cn)
|
||||
return set(db.execute(select(User.id).where(User.created_at >= since_utc)).scalars())
|
||||
|
||||
|
||||
def clear_inactive_accounts(db: Session, *, days: int, dry_run: bool = False) -> dict:
|
||||
"""连续无活跃**满 `days` 整天之后**把用户金币 + 金币现金清零(邀请金不动),逐用户独立事务。
|
||||
|
||||
- 活跃 = 发起比价 / 发起领券(**登录不算**)。last_active >= today-days 视为活跃;连续无
|
||||
比价无领券满 days 天(且注册也满 days 天)才清。days=15 → 最后一次比价/领券在 15 天前
|
||||
当天仍留、第 16 天起清。today 一律北京时(rewards.cn_today())。
|
||||
- 新用户保护:注册未满 days 天(created_at 在 today-days 之后)豁免,不会刚注册就被清。
|
||||
- 只扫描有余额(coin_balance>0 或 cash_balance_cents>0)的账户:清零后余额=0,下一轮
|
||||
自然跳过 → 天然幂等,重启 / 多次唤醒 / 补跑都安全。
|
||||
- 逐用户独立事务:单用户异常 rollback 不影响其他人。
|
||||
- dry_run=True:只统计不写库(供运营上线前预演:看会清哪些人、清多少)。
|
||||
|
||||
返回统计 dict(scanned/cleared/skipped_active/skipped_new_user/coin_cleared/
|
||||
cents_cleared/failed)。
|
||||
"""
|
||||
# days<1 会把 active_since 推到未来 → 全员判流失清空(灾难);防御性钳到 >=1。
|
||||
days = max(1, days)
|
||||
today = rewards.cn_today()
|
||||
# 连续无活跃满 days 整天之后才清:active_since=today-days,活跃/新用户都以此为边界。
|
||||
active_since = today - timedelta(days=days)
|
||||
active_ids = _active_user_ids_since(db, active_since)
|
||||
new_user_ids = _new_user_ids_since(db, active_since)
|
||||
|
||||
# 只取候选 user_id;清零金额在循环内读**实时**余额(不用此处快照)——活跃集合算完到逐行
|
||||
# 清零之间余额可能变(如并发看广告发币),用快照 grant 会清不干净、balance_after≠0。
|
||||
candidate_ids = (
|
||||
db.execute(
|
||||
select(CoinAccount.user_id).where(
|
||||
or_(CoinAccount.coin_balance > 0, CoinAccount.cash_balance_cents > 0)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
stats = {
|
||||
"scanned": 0,
|
||||
"cleared": 0,
|
||||
"skipped_active": 0,
|
||||
"skipped_new_user": 0,
|
||||
"coin_cleared": 0,
|
||||
"cents_cleared": 0,
|
||||
"failed": 0,
|
||||
}
|
||||
remark = f"连续{days}天无活跃清零"
|
||||
for user_id in candidate_ids:
|
||||
stats["scanned"] += 1
|
||||
if user_id in active_ids:
|
||||
stats["skipped_active"] += 1
|
||||
continue
|
||||
if user_id in new_user_ids: # 注册未满 days 天,新用户宽限
|
||||
stats["skipped_new_user"] += 1
|
||||
continue
|
||||
if dry_run:
|
||||
acc = db.get(CoinAccount, user_id)
|
||||
if acc is not None:
|
||||
stats["cleared"] += 1
|
||||
stats["coin_cleared"] += acc.coin_balance
|
||||
stats["cents_cleared"] += acc.cash_balance_cents
|
||||
continue
|
||||
try:
|
||||
# lock=True 对该账户行加 FOR UPDATE(PG 生效,SQLite no-op),读-清-写串行化防并发
|
||||
acc = get_or_create_account(db, user_id, commit=False, lock=True)
|
||||
coin, cents = acc.coin_balance, acc.cash_balance_cents
|
||||
if coin <= 0 and cents <= 0:
|
||||
continue # 快照后被并发清空 / 变动,无需再清
|
||||
if coin > 0:
|
||||
grant_coins(db, user_id, -coin, biz_type=CLEAR_BIZ_TYPE, remark=remark)
|
||||
if cents > 0:
|
||||
grant_cash(db, user_id, -cents, biz_type=CLEAR_BIZ_TYPE, remark=remark)
|
||||
db.commit()
|
||||
stats["cleared"] += 1
|
||||
stats["coin_cleared"] += coin
|
||||
stats["cents_cleared"] += cents
|
||||
except Exception: # noqa: BLE001 - 批处理不因单用户异常中断
|
||||
db.rollback()
|
||||
stats["failed"] += 1
|
||||
|
||||
return stats
|
||||
@@ -1,12 +1,15 @@
|
||||
"""Admin M2 读接口测试:大盘聚合 + 用户/流水/提现/反馈列表 + 鉴权拦截。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.admin.main import admin_app
|
||||
from app.admin.repositories import admin_user as admin_repo
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.wallet import CashTransaction, WithdrawOrder
|
||||
from app.repositories import user as user_repo
|
||||
@@ -69,6 +72,49 @@ def test_dashboard_overview(admin_client: TestClient, admin_token: str) -> None:
|
||||
assert "jd_order_count" in data["cps"]
|
||||
|
||||
|
||||
def test_dashboard_period_comparison_is_aggregated_by_backend(
|
||||
admin_client: TestClient, admin_token: str
|
||||
) -> None:
|
||||
created_at = datetime(2037, 1, 15, 12)
|
||||
rows = [
|
||||
("dashboard-aggregate-success", "success", 101, 0.1),
|
||||
("dashboard-aggregate-failed", "failed", 200, 0.2),
|
||||
("dashboard-aggregate-cancelled", "cancelled", 300, 0.3),
|
||||
("dashboard-aggregate-running", "running", 400, 0.4),
|
||||
]
|
||||
db = SessionLocal()
|
||||
try:
|
||||
for trace_id, status, total_ms, llm_cost_yuan in rows:
|
||||
db.add(
|
||||
ComparisonRecord(
|
||||
trace_id=trace_id,
|
||||
status=status,
|
||||
total_ms=total_ms,
|
||||
llm_cost_yuan=llm_cost_yuan,
|
||||
created_at=created_at,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
response = admin_client.get(
|
||||
"/admin/api/stats/overview",
|
||||
params={"date_from": "2037-01-15", "date_to": "2037-01-15"},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
comparison = response.json()["period"]["comparison"]
|
||||
assert comparison["total"] == 4
|
||||
assert comparison["completed"] == 2
|
||||
assert comparison["cancelled"] == 1
|
||||
assert comparison["success"] == 1
|
||||
assert comparison["success_rate"] == 0.3333
|
||||
assert comparison["median_duration_ms"] == 151
|
||||
assert comparison["p95_duration_ms"] == 195
|
||||
assert comparison["token_cost_total_yuan"] == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> None:
|
||||
uid = _seed_user_with_data("13800000002")
|
||||
r = admin_client.get("/admin/api/users", headers=_auth(admin_token))
|
||||
|
||||
@@ -43,6 +43,35 @@ def test_coupon_data_report_includes_ad_revenue() -> None:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_coupon_data_report_distinguishes_unfilled_from_zero_revenue() -> None:
|
||||
"""无 eCPM 记录返回 None;已填充但 eCPM=0 返回 0.0。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add_all([
|
||||
CouponSession(
|
||||
trace_id="rev-cp-unfilled", device_id="d-unfilled", status="completed", app_env="prod",
|
||||
platforms=["meituan-waimai"], platform_success=["meituan-waimai"],
|
||||
started_at=datetime(2020, 1, 3, tzinfo=UTC), started_date=date(2020, 1, 3),
|
||||
),
|
||||
CouponSession(
|
||||
trace_id="rev-cp-zero", device_id="d-zero", status="completed", app_env="prod",
|
||||
platforms=["meituan-waimai"], platform_success=["meituan-waimai"],
|
||||
started_at=datetime(2020, 1, 3, tzinfo=UTC), started_date=date(2020, 1, 3),
|
||||
),
|
||||
_ecpm("rev-cp-zero", "0", "cp-sess-zero", "coupon"),
|
||||
])
|
||||
db.flush()
|
||||
|
||||
res = coupon_data_report(db, date_from="2020-01-03", date_to="2020-01-03", app_env="prod")
|
||||
rows = {row["trace_id"]: row for row in res["items"]}
|
||||
|
||||
assert rows["rev-cp-unfilled"]["ad_revenue_yuan"] is None
|
||||
assert rows["rev-cp-zero"]["ad_revenue_yuan"] == 0.0
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_comparison_list_includes_ad_revenue() -> None:
|
||||
"""比价记录列表项带本次广告收益;200 分 → 0.002 元。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user