Compare commits

...

14 Commits

Author SHA1 Message Date
guke eb15018322 test(welfare): 不活跃测试加 autouse 隔离夹具(清零余额+清活跃事件/审计),去掉临时 band-aid
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 19:13:25 +08:00
guke a6cfef64a8 feat(welfare): 不活跃清零-选取与逐用户清零(三桶归零+审计+流水) 2026-07-16 18:54:40 +08:00
guke 7802aeb163 feat(welfare): 可插拔不活跃预警通知器 + LogNotifier 占位
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 18:47:50 +08:00
guke f843e68d0f feat(welfare): INACTIVITY_* 配置项 + warn stages 解析
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 18:47:02 +08:00
guke 108c175142 feat(welfare): 活跃口径共享模块-子查询与 last_active_expr 2026-07-16 18:40:36 +08:00
guke e6f467a935 feat(welfare): 活跃口径共享模块-常量与北京0点cutoff助手 2026-07-16 18:39:51 +08:00
guke 708e454959 feat(welfare): 迁移新增 inactivity_reset_log / inactivity_notification_log 两表
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 18:34:07 +08:00
guke 0ec6b2bc56 feat(welfare): inactivity_reset_log + inactivity_notification_log 模型 2026-07-16 18:23:20 +08:00
guke 23e4671312 docs(welfare): 15天不活跃清零 实现计划(11 任务, TDD 分解)
从已定稿 spec 拆出可执行计划:模型/迁移/活跃口径共享模块/配置/通知器/清零+预警业务逻辑/
每日 worker/admin 重构/表字典。每任务含失败测试→实现→通过→提交的 bite-sized 步骤,含完整代码。
活跃口径 = created_at 基线 + home_view/比价/领券(不含 last_login_at);home_view 事件名以
HOME_VIEW_EVENT 常量占位(前端明天定名);总闸默认关。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:16:47 +08:00
guke b3ed66d4eb docs(welfare): 活跃口径移除 last_login_at 改用 created_at 基线;home_view 事件名待定
按评审结论定稿:
- ① 彻底移除 last_login_at:活跃口径 = max(home_view, 比价, 领券);登录/re-login 不再计为活跃
- 连带:用 User.created_at 接替"恒非空基线"位(注册即第1日活跃,只有真实使用才推进)。
  无任何信号的用户 = 注册满 15 天即清,新用户有 15 天宽限
- ② home_view 事件名待前端明天加埋点时定,后端以 HOME_VIEW_EVENT 常量占位
- 连带影响:共享口径 admin 侧同步(移除 last_login_at + created_at 基线 + 纳入 home_view),
  admin 最近活跃/DAU 口径随之变化,回归测试按新口径更新预期
- 同步 §3/§4/§9/§10/§11/§12/§13/§14

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:58:45 +08:00
guke c9c35a4350 docs(welfare): 明确不活跃清零的时间边界(北京自然日, 第16日0点)
补充清零日界定义:末次活跃日记为第1日,从末次活跃起「第16日0点」(北京)清零
= 北京 00:00 of (last_active_date + 15天),按自然日 0 点对齐,非从末次活跃时刻滚动 15×24h。
- 应清零 ⟺ (cn_today() − last_active_date).days ≥ RESET_DAYS
- SQL cutoff = 北京 00:00 of (cn_today() − (RESET_DAYS−1)),新增 activity.reset_cutoff() 辅助
- 资格边界(第16日0点)与 worker 执行点 RUN_HOUR 解耦;inactive_days 改按北京自然日
- 同步更新 §4/§6/§8/§10

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:50:03 +08:00
guke 5f1d1a5e5f docs(welfare): 15天不活跃清零金币/现金 设计文档(spec)
连续15天不活跃自动清零金币+折算现金+邀请现金,清零前可配置节奏预警,全程留审计以备纠纷排查。

关键决策:
- 活跃口径复用"用户管理"并抽共享模块 activity.py(admin 一并重构);新增 home_view 埋点代表"进首页",last_login_at 仅作灰度期兜底
- 审计双写:inactivity_reset_log 专表(记清零前三桶余额)+ 钱包流水 biz_type=inactivity_reset
- 预警:可插拔通知器 + 日志占位(v1),后续接 JPush/短信
- 触发:进程内每日 worker(仿 daily_exchange_worker),总闸默认关

仅设计文档,尚未实现。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:39:10 +08:00
zhuzihao e9fd51d119 fix(auth): 发码防刷改为按成功计数 + 新增每设备每日发码上限 (#136)
- 修复:发码限流原为原子「判+记」,被单号 60s 冷却挡下的重发也占设备额度
  → 正常用户连点重发可能被误锁 1 小时。改为「先判后记、只对成功发码计数」:
  check 判在真发之前(超限直接 429、不真发),record 只在 send_code 成功后调;
  被单号冷却 / 供应商失败抛 429 时直接返回、不计数。
- 新增:同一设备(device_id)+ IP 每天最多 20 次发码上限,与原每小时 5 次两道闸并存,
  均按成功计数,叠一层日封顶挡低频长时间轰炸。
- 基建:ratelimit.py 新增 RateLimitRule + check_rate_limits / record_rate_limits
  (peek/commit 拆分);原子的 enforce_rate_limit 仍保留给登录爆破(失败也计)不变。
- 测试:补 2 个用例(冷却挡下不占额度 / 每日上限)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #136
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-16 09:40:56 +08:00
guke 5c6840dd71 feat(compare): 比价记录 LLM token 成本落库与展示(按当时价冻结) (#133)
- comparison_record 加 llm_cost_yuan(元/float)+ llm_price_snapshot(JSON)两列
- _backfill_llm_calls 回填时按 app_config 当时单价逐模型算成本、冻结成本+快照到记录
- app_config 新增 llm_token_price 配置(per_model + default 兜底,运营在系统配置页可改)
- services/llm_cost.py:compute_llm_cost 纯函数(按 model 分桶、error/无 usage 跳过、
  脏价格当 unpriced 不抛异常以免连累 token 回填)+ get_llm_prices reader
- admin schema 暴露成本:列表项带 llm_cost_yuan,详情另带价格快照
- tests/test_llm_cost.py(10 测试);scripts/seed_mock_llm_cost.py(mock seeder)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #133
2026-07-13 17:46:11 +08:00
24 changed files with 3054 additions and 24 deletions
@@ -0,0 +1,68 @@
"""add inactivity tables
Revision ID: 135e79414fd0
Revises: comparison_llm_cost
Create Date: 2026-07-16 18:31:02.105929
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '135e79414fd0'
down_revision: Union[str, Sequence[str], None] = 'comparison_llm_cost'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"inactivity_reset_log",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("coin_balance_before", sa.Integer(), nullable=False),
sa.Column("cash_balance_cents_before", sa.Integer(), nullable=False),
sa.Column("invite_cash_balance_cents_before", sa.Integer(), nullable=False),
sa.Column("last_active_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("inactive_days", sa.Integer(), nullable=False),
sa.Column("reason", sa.String(length=32), nullable=False),
sa.Column("reset_at", sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("inactivity_reset_log", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_inactivity_reset_log_user_id"), ["user_id"], unique=False)
batch_op.create_index(batch_op.f("ix_inactivity_reset_log_reset_at"), ["reset_at"], unique=False)
op.create_table(
"inactivity_notification_log",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("stage", sa.Integer(), nullable=False),
sa.Column("inactive_days", sa.Integer(), nullable=False),
sa.Column("coin_balance", sa.Integer(), nullable=False),
sa.Column("cash_balance_cents", sa.Integer(), nullable=False),
sa.Column("invite_cash_balance_cents", sa.Integer(), nullable=False),
sa.Column("channel", sa.String(length=16), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("inactivity_notification_log", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_inactivity_notification_log_user_id"), ["user_id"], unique=False)
batch_op.create_index(batch_op.f("ix_inactivity_notification_log_created_at"), ["created_at"], unique=False)
def downgrade() -> None:
with op.batch_alter_table("inactivity_notification_log", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_inactivity_notification_log_created_at"))
batch_op.drop_index(batch_op.f("ix_inactivity_notification_log_user_id"))
op.drop_table("inactivity_notification_log")
with op.batch_alter_table("inactivity_reset_log", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_inactivity_reset_log_reset_at"))
batch_op.drop_index(batch_op.f("ix_inactivity_reset_log_user_id"))
op.drop_table("inactivity_reset_log")
+33
View File
@@ -0,0 +1,33 @@
"""comparison_record: llm_cost_yuan + llm_price_snapshot(比价 LLM 调用成本 + 当时单价快照)
回填 llm_calls 时按「当时的价」逐模型算出本次比价 LLM 总成本(元),连同所用单价快照一起冻结到
记录上;admin 比价记录详情展示实际成本(旧记录 NULL → 前端回退估算)。见 services/llm_cost.py。
Revision ID: comparison_llm_cost
Revises: ad_ecpm_trace_id
Create Date: 2026-07-13
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "comparison_llm_cost"
down_revision: str | Sequence[str] | None = "ad_ecpm_trace_id"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_JSONB = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
def upgrade() -> None:
# 均可空、无索引;SQLite 原生支持 ADD COLUMN,无需 batch_alter_table(同 comparison_debug_fields)。
op.add_column("comparison_record", sa.Column("llm_cost_yuan", sa.Float(), nullable=True))
op.add_column("comparison_record", sa.Column("llm_price_snapshot", _JSONB, nullable=True))
def downgrade() -> None:
op.drop_column("comparison_record", "llm_price_snapshot")
op.drop_column("comparison_record", "llm_cost_yuan")
+4
View File
@@ -36,6 +36,8 @@ class AdminComparisonListItem(BaseModel):
retry_count: int | None = None
input_tokens: int | None = None # Σ usage.prompt_tokens(server 派生)
output_tokens: int | None = None # Σ usage.completion_tokens(server 派生)
# 本次比价 LLM 总成本(元,按当时价冻结);旧记录/未回填为 None → 前端「成本」列回退估算。见 services/llm_cost.py。
llm_cost_yuan: float | None = None
device_model: str | None = None
rom_vendor: str | None = None
rom_name: str | None = None
@@ -72,3 +74,5 @@ class AdminComparisonDetail(AdminComparisonListItem):
# 原始上报全量;「卡在哪一步」从 raw_payload.platform_results[*].status 读
# (store_not_found/items_not_found/below_minimum/unsupported = 卡在 找店/加菜/起送/读价)。
raw_payload: dict | None = None
# 算成本所用单价快照 {mode, prices:{model:{...}}}(llm_cost_yuan 继承自列表项)。见 services/llm_cost.py。
llm_price_snapshot: dict | None = None
+24 -15
View File
@@ -12,11 +12,16 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request, status
from fastapi import APIRouter, HTTPException, Request
from app.api.deps import CurrentUser, DbSession
from app.core import test_account
from app.core.ratelimit import enforce_rate_limit
from app.core.ratelimit import (
RateLimitRule,
check_rate_limits,
enforce_rate_limit,
record_rate_limits,
)
from app.core.security import TokenError, decode_token, issue_token_pair
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
from app.integrations.sms import SmsError, send_code, verify_code
@@ -40,9 +45,10 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
# 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。
SMS_LOGIN_MAX_PER_HOUR = 5
# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。
# 发码防刷(同一设备 device_id + 同一 IP,**只按成功发码计数**;被单号 60s 冷却挡下的重发不占额度):
# 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5 # 每小时上限
SMS_SEND_MAX_PER_DAY_PER_DEVICE = 20 # 每天上限(再叠一层日封顶,挡低频长时间轰炸)
def _login_response(
@@ -99,23 +105,26 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
logger.info("test_account sms_send short-circuit (不真发)")
return SmsSendResponse(sent=True, mock=True, cooldown_sec=0)
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,
# 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。
enforce_rate_limit(
request,
scope="sms-send-device",
subject=req.device_id,
limit=SMS_SEND_MAX_PER_HOUR_PER_DEVICE,
window_sec=3600,
detail="操作过于频繁,请稍后再试",
)
# 发码防刷:同一设备(device_id) + 同一 IP,每小时 / 每天两道闸,**均只按成功发码计数**
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,挡短信轰炸/烧钱。
# 关键:被单号 60s 冷却挡下的重发是「没真发、没烧钱」→ 不该占额度。故 check(先判)放在真发之前
# (超限直接 429、不真发),record(计数)只在 send_code 成功后调 —— 冷却/供应商失败抛 429 时直接返回、不计数。
send_rules = [
RateLimitRule("sms-send-device", SMS_SEND_MAX_PER_HOUR_PER_DEVICE, 3600,
"操作过于频繁,请稍后再试"),
RateLimitRule("sms-send-device-daily", SMS_SEND_MAX_PER_DAY_PER_DEVICE, 86400,
"今日验证码发送次数过多,请明天再试"),
]
check_rate_limits(request, subject=req.device_id, rules=send_rules)
try:
cooldown = send_code(req.phone)
except SmsError as e:
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
# 发码成功 → 两道闸各 +1(被单号冷却挡下的重发走不到这里,故不占额度)
record_rate_limits(request, subject=req.device_id, rules=send_rules)
from app.core.config import settings # 局部 import 避免循环
return SmsSendResponse(sent=True, mock=settings.SMS_MOCK, cooldown_sec=cooldown)
+3
View File
@@ -27,6 +27,7 @@ from app.schemas.compare_record import (
ComparisonRecordOut,
ComparisonRecordPage,
)
from app.services.llm_cost import compute_llm_cost, get_llm_prices
from app.services.pricebot_llm_calls import fetch_llm_calls
logger = logging.getLogger("shagua.compare_record")
@@ -81,6 +82,8 @@ def _backfill_llm_calls(record_id: int, trace_id: str) -> None:
# error 的调用 usage 可能为 None,or {} 兜底)
rec.input_tokens = sum((c.get("usage") or {}).get("prompt_tokens") or 0 for c in calls)
rec.output_tokens = sum((c.get("usage") or {}).get("completion_tokens") or 0 for c in calls)
# 本次比价 LLM 成本(元)+ 当时单价快照:按 app_config 现价逐模型算好冻结(services/llm_cost.py)。
rec.llm_cost_yuan, rec.llm_price_snapshot = compute_llm_cost(calls, get_llm_prices(db))
db.commit()
logger.info(
"backfill llm_calls trace=%s n=%d in_tok=%d out_tok=%d",
+20
View File
@@ -169,6 +169,13 @@ class Settings(BaseSettings):
# 进程内自动兑换 worker 的检查间隔(秒):每隔这么久醒一次,跨过北京 0 点就跑一轮。
# 默认 600s=10min,即 0 点后最多 10 分钟内兑完(客户端文案已注明「可能存在延迟」)。
AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600
# === 15 天不活跃清零(app.core.inactivity_reset_worker)===
INACTIVITY_RESET_ENABLED: bool = False # 总闸,默认关;灰度验证后再开
INACTIVITY_RESET_DAYS: int = 15 # 不活跃阈值(天),第 (N+1) 日 0 点清
INACTIVITY_WARN_DAYS_BEFORE: str = "7,2" # 清零前几天各推一次;""=不推。逗号分隔
INACTIVITY_RESET_RUN_HOUR: int = 3 # 北京时间每日执行点(0-23)
INACTIVITY_NOTIFY_CHANNEL: str = "log" # log(占位) / jpush / sms
INACTIVITY_RESET_CHECK_INTERVAL_SEC: int = 1800 # worker 唤醒间隔(秒)
# 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。
# 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容
# (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
@@ -189,6 +196,19 @@ class Settings(BaseSettings):
"""免确认收款授权可用 = 微信支付凭证齐全 + 授权回调地址已配。"""
return bool(self.wxpay_configured and self.WXPAY_AUTH_NOTIFY_URL)
@property
def inactivity_warn_stages(self) -> list[int]:
"""解析 INACTIVITY_WARN_DAYS_BEFORE → 降序去重的提前天数列表。
丢弃非数字 / <=0 / >=RESET_DAYS 的项(空串 → 空列表 = 不推)。"""
out: list[int] = []
for part in (self.INACTIVITY_WARN_DAYS_BEFORE or "").split(","):
part = part.strip()
if part.isdigit():
v = int(part)
if 0 < v < self.INACTIVITY_RESET_DAYS and v not in out:
out.append(v)
return sorted(out, reverse=True)
# ===== 穿山甲激励视频(服务端发奖回调)=====
# 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。
# 穿山甲后台配置的"奖励校验密钥"(m-key),验签用。每个 GroMore 广告位 m-key 不同(后台各自
+15
View File
@@ -96,4 +96,19 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
"group": "首页轮播", "type": "enum", "hidden": True,
"help": "mixed=真实优先+种子补位(默认);real=只用真实比价记录;seed=只用种子/合成(演示)。",
},
# 比价 LLM 调用成本计价。值是嵌套 JSON(非 str→int),借 dict_str_int 类型在配置页走原始 JSON
# 编辑框;set_value 不校验类型,嵌套 JSON 照存。
"llm_token_price": {
"default": {
"per_model": {"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0}},
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0},
"currency": "CNY", "unit": "per_1m_tokens",
},
"label": "LLM 模型单价(元/百万 token)",
"group": "LLM 成本", "type": "dict_str_int",
"help": (
"比价 LLM 调用成本计价。JSON:per_model 按模型配 input/output 单价(元/1M token),"
"default 兜底未登记的模型。改价只影响之后回填的新记录,历史记录用当时价格快照。"
),
},
}
+93 -8
View File
@@ -9,29 +9,41 @@ from __future__ import annotations
import threading
import time
from typing import NamedTuple
from fastapi import HTTPException, Request, status
from app.core.config import settings
# key -> (window_start_ts, count)
_buckets: dict[str, tuple[float, int]] = {}
# key -> (window_start_ts, count, window_sec)
# 存每个 key 自己的 window_sec:_buckets 混着不同窗口(60s 广告 / 3600s 登录 / 86400s 日闸)的 key,
# GC 必须按各 key 自己的窗口判过期(见 [_purge_expired]),否则短窗口调用触发的 GC 会误删长窗口 key。
_buckets: dict[str, tuple[float, int, float]] = {}
_lock = threading.Lock()
_GC_THRESHOLD = 10000 # _buckets 超此阈值才顺手清过期 key(仿 sms.py;测试可 monkeypatch 调小强制每次扫)
def _purge_expired(now: float) -> None:
"""清过期 key(**仅在持有 _lock 时调用**)。按每个 key 自己存的 window_sec 判过期,而非调用方的窗口
—— _buckets 是全局共享、混着 60s(广告)/3600s(登录)/86400s(日闸)不同窗口的 key;若用调用方窗口,
高频的 60s 广告端点触发 GC 时会把本该活 3600s/86400s 的登录/日闸计数一并删掉,使其在规模上(超阈值才
触发本清理)被反复清零而失效。仅在超阈值时扫,低频、开销可忽略。"""
if len(_buckets) <= _GC_THRESHOLD:
return
for k in [k for k, (s, _, w) in _buckets.items() if now - s >= w]:
_buckets.pop(k, None)
def _hit(key: str, limit: int, window_sec: float) -> bool:
"""记一次访问。返回 True=放行,False=超限。"""
now = time.monotonic()
with _lock:
start, count = _buckets.get(key, (now, 0))
start, count, _ = _buckets.get(key, (now, 0, window_sec))
if now - start >= window_sec: # 窗口过期,重置
start, count = now, 0
count += 1
_buckets[key] = (start, count)
# 顺手清过期 key,防内存无限涨(低频访问足够)
if len(_buckets) > 10000:
for k in [k for k, (s, _) in _buckets.items() if now - s >= window_sec]:
_buckets.pop(k, None)
_buckets[key] = (start, count, window_sec)
_purge_expired(now) # 顺手清过期 key(按各自窗口),防内存无限涨
return count <= limit
@@ -83,3 +95,76 @@ def enforce_rate_limit(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=detail,
)
# ===================== 先判 / 后记(只按「成功」计数)=====================
# _hit 是原子「判+记」:一调用就 +1,适合登录爆破(失败尝试也要计)。但对「短信发码」这类
# **只想给成功动作计数**的场景不合适 —— 被单号冷却挡下的重发没真发、没烧钱,不该占额度。
# 故拆成 _peek(只判不记)+ _commit(只记):check_rate_limits 先判 → 动作 → 成功后 record。
class RateLimitRule(NamedTuple):
"""一条限流规则。scope 区分不同闸(不同 key 前缀);同一 (subject, IP) 在 window_sec
内最多 limit 次,超限抛 429 用 detail 文案。
(scope, window_sec) 成对绑在一条规则里 —— check(先判)与 record(计数)复用同一条,
避免两处把窗口/scope 写歪导致 key 对不上。
"""
scope: str
limit: int
window_sec: float
detail: str = "操作过于频繁,请稍后再试"
def _peek(key: str, limit: int, window_sec: float) -> bool:
"""只读:当前窗口内是否还没到上限(count < limit)。**不改计数**。
与 [_commit] 配对实现「先判后记」——只在动作成功后才 _commit。"""
now = time.monotonic()
with _lock:
start, count, _ = _buckets.get(key, (now, 0, window_sec))
if now - start >= window_sec: # 窗口已过期 → 视作已重置(count 归零)
count = 0
return count < limit
def _commit(key: str, window_sec: float) -> None:
"""记一次访问(+1)。窗口过期则以本次为起点重置。仅在动作成功后调用。"""
now = time.monotonic()
with _lock:
start, count, _ = _buckets.get(key, (now, 0, window_sec))
if now - start >= window_sec: # 窗口过期,重置
start, count = now, 0
_buckets[key] = (start, count + 1, window_sec)
_purge_expired(now) # 顺手清过期 key(按各自窗口,同 [_hit])
def check_rate_limits(request: Request, subject: str, rules: list[RateLimitRule]) -> None:
"""【先判】一组限流:任一规则已达上限即抛 429,且**不改计数**。
配合 [record_rate_limits] 实现「只按成功计数」:先 check 所有闸(全未超才继续)→ 执行动作
→ 动作**成功后**再 record。动作被下游挡下(如短信单号冷却)、没真正发生时不 record → 不占额度。
key = `scope:subject:client_ip`(与 [enforce_rate_limit] 同款)。
"""
if not settings.RATE_LIMIT_ENABLED:
return
ip = _client_ip(request)
for rule in rules:
if not _peek(f"{rule.scope}:{subject}:{ip}", rule.limit, rule.window_sec):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=rule.detail,
)
def record_rate_limits(request: Request, subject: str, rules: list[RateLimitRule]) -> None:
"""【记一次】一组限流(每条规则 +1)。仅在动作成功后调用,与 [check_rate_limits] 配对。
⚠️ check→动作→record 非原子:并发突发下计数可能略超 limit(每个在途请求各 +1)。对
「防脚本/防轰炸」的安全网定位可接受;要精确配额需迁 Redis(见模块 docstring)。
"""
if not settings.RATE_LIMIT_ENABLED:
return
ip = _client_ip(request)
for rule in rules:
_commit(f"{rule.scope}:{subject}:{ip}", rule.window_sec)
+43
View File
@@ -0,0 +1,43 @@
"""不活跃预警通知器(可插拔)。
v1 仅日志占位(LogNotifier):现状无真实推送能力(极光只用于一键登录解密 + 设备心跳告警,
心跳 worker 也只打印),先把清零主流程 + 审计做扎实。后续实现同协议的 JPushNotifier /
SmsNotifier 即可替换,worker/repo 不改。
"""
from __future__ import annotations
import logging
from typing import Protocol
logger = logging.getLogger("shagua.inactivity")
class InactivityNotifier(Protocol):
channel: str
def warn(self, *, user_id: int, coin: int, cash_cents: int, invite_cash_cents: int,
stage: int, days_until_reset: int) -> str:
"""发预警,返回状态:'sent' / 'failed' / 'placeholder'"""
...
class LogNotifier:
"""占位实现:只打印,不真推。参照 heartbeat_monitor_worker「本期先不接推送」先例。"""
channel = "log"
def warn(self, *, user_id: int, coin: int, cash_cents: int, invite_cash_cents: int,
stage: int, days_until_reset: int) -> str:
logger.warning(
"[inactivity-warn] user=%s coin=%s cash_cents=%s invite_cash_cents=%s "
"stage=T-%s days_until_reset=%s",
user_id, coin, cash_cents, invite_cash_cents, stage, days_until_reset,
)
return "placeholder"
def get_notifier(channel: str) -> InactivityNotifier:
"""按配置返回通知器。未实现的通道(jpush/sms)暂回退 LogNotifier 占位。"""
# 后续:if channel == "jpush": return JPushNotifier()
# if channel == "sms": return SmsNotifier()
return LogNotifier()
+2 -1
View File
@@ -13,7 +13,8 @@ worker / 多机时内存不共享 → 冷却、校验都会失效,届时迁移
防刷两层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)
2. 单设备(device_id)每小时频控(api auth.sms_send enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)
2. 单设备(device_id)+ IP 每小时 / 每天频控(api auth.sms_send check/record_rate_limits,
**只按成功发码计数** 被本文件单号冷却挡下的重发不占额度)+ 极光控制台 IP 白名单/防轰炸(运维侧)
IP 频控(rate_limit 依赖)2026-06-26 按产品要求删除改设备维度; device_id 客户端可伪造/轮换,
脚本轮换 id 能绕过本层 挡脚本狂发主要靠极光控制台侧(+ 可选 nginx 限流)
单号每日上限2026-07-03 按精简要求删除(mentor :登录风控只留单号冷却 + 单设备频控);
+4
View File
@@ -27,6 +27,10 @@ from app.models.coupon_state import ( # noqa: F401
CouponSession,
)
from app.models.feedback import Feedback # noqa: F401
from app.models.inactivity import ( # noqa: F401
InactivityNotificationLog,
InactivityResetLog,
)
from app.models.invite import InviteRelation # noqa: F401
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401
+6
View File
@@ -137,6 +137,12 @@ class ComparisonRecord(Base):
# 每次 LLM 调用明细 [{scene,model,input_messages,output,usage,latency_ms,error}];
# server 收上报后按 trace_id 同机拉 pricebot 落库(见 compare_record 端点)。旧记录/未采集为 None。
llm_calls: Mapped[list | None] = mapped_column(_JSON, nullable=True)
# 本次比价 LLM 总成本(元):回填时按「当时的价」逐模型算好冻结(见 services/llm_cost.py)。
# 单次亚分级 → float「元」(不用 *_cents)。旧记录/未回填为 None,前端回退「估算成本」。
llm_cost_yuan: Mapped[float | None] = mapped_column(Float, nullable=True)
# 算成本所用单价快照 {mode, prices:{model:{input_per_1m,output_per_1m,_source}}}:app_config 只存
# 当前价、不留历史,故把当时价冻结进来供审计/复算。
llm_price_snapshot: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
+56
View File
@@ -0,0 +1,56 @@
"""15 天不活跃清零相关表。
- inactivity_reset_log:每次清零一行,记清零前三桶余额 + 原因 + 判定时活跃时间/不活跃天数,
供纠纷排查(需求①)清零同时另写 3 条钱包流水(biz_type=inactivity_reset),资金流可逐笔回溯
- inactivity_notification_log:每次预警一行,记推送时余额快照 + 档位 + 通道 + 状态,
兼作"预警去重"依据(created_at > last_active)"待推送"占位 outbox(v1 通道=log)
append-only,不更新user_id 只索引不设外键( analytics_event,避免删用户级联/历史留痕)
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class InactivityResetLog(Base):
__tablename__ = "inactivity_reset_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
coin_balance_before: Mapped[int] = mapped_column(Integer, nullable=False)
cash_balance_cents_before: Mapped[int] = mapped_column(Integer, nullable=False)
invite_cash_balance_cents_before: Mapped[int] = mapped_column(Integer, nullable=False)
last_active_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
inactive_days: Mapped[int] = mapped_column(Integer, nullable=False)
reason: Mapped[str] = mapped_column(String(32), nullable=False)
reset_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<InactivityResetLog id={self.id} user_id={self.user_id} coin={self.coin_balance_before}>"
class InactivityNotificationLog(Base):
__tablename__ = "inactivity_notification_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
stage: Mapped[int] = mapped_column(Integer, nullable=False) # 提前天数档(如 7 / 2)
inactive_days: Mapped[int] = mapped_column(Integer, nullable=False)
coin_balance: Mapped[int] = mapped_column(Integer, nullable=False)
cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False)
invite_cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False)
channel: Mapped[str] = mapped_column(String(16), nullable=False) # log / jpush / sms
status: Mapped[str] = mapped_column(String(16), nullable=False) # placeholder / sent / failed
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<InactivityNotificationLog id={self.id} user_id={self.user_id} stage={self.stage}>"
+89
View File
@@ -0,0 +1,89 @@
"""活跃口径唯一真源:worker(不活跃清零)与 admin(最近活跃/DAU)共用,防两处漂移。
口径 = max(User.created_at, AnalyticsEvent[ACTIVE_EVENTS], CouponPromptEngagement[claim_started])
**不含 last_login_at**(登录/re-login 不代表在用 App);created_at 为恒非空基线
清零/预警按北京自然日 0 点对齐( reset_cutoff)
"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ, cn_today
from app.models.analytics_event import AnalyticsEvent
from app.models.coupon_state import CouponPromptEngagement
# —— 活跃事件名(与"用户管理"口径一致)——
# home_view:进首页(前端埋点,名称前端明天敲定,此处占位;定名后仅改这一常量)。
HOME_VIEW_EVENT = "home_view"
COMPARE_START_EVENT = "real_compare_start" # 发起比价(含浮窗触发)
COUPON_START_EVENT = "real_coupon_start" # 发起领券
ACTIVE_EVENTS = (HOME_VIEW_EVENT, COMPARE_START_EVENT, COUPON_START_EVENT)
ACTIVE_ENGAGE_TYPE = "claim_started" # coupon_prompt_engagement 一键领取
def as_utc(value: datetime) -> datetime:
"""任意 datetime → tz-aware UTC(无时区按 UTC 解释)。用于与 DateTime(timezone=True) 列比较,
比较绝对时刻与会话时区无关(口径同 admin queries._as_utc)"""
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def norm_utc(dt: datetime | None) -> datetime | None:
"""naive 视为 UTC 补 tzinfo(SQLite 读回 naive、PG 读回 aware,混着 max() 会 TypeError)。"""
if dt is None:
return None
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
def cn_midnight_utc(d: date) -> datetime:
"""北京 d 日 00:00 → tz-aware UTC datetime。"""
return as_utc(datetime(d.year, d.month, d.day, tzinfo=CN_TZ))
def reset_cutoff(reset_days: int, today: date | None = None) -> datetime:
"""应清零边界(tz-aware UTC):last_active < 此值 ⟺ 距末次活跃已满 reset_days 天(北京 0 点对齐)。
= 北京 00:00 of (today (reset_days 1)):reset_days=15today=1/20 北京 1/6 00:00"""
today = today or cn_today()
return cn_midnight_utc(today - timedelta(days=reset_days - 1))
def last_active_subqueries(db: Session):
"""两个按 user_id 预聚合的派生表:最近活跃事件(ACTIVE_EVENTS)、最近领券发起(claim_started)。
返回 (ev_sub, eng_sub)口径同 admin,LEFT JOIN ,借事件索引只扫活跃事件"""
ev_sub = (
select(
AnalyticsEvent.user_id.label("user_id"),
func.max(AnalyticsEvent.created_at).label("last_at"),
)
.where(AnalyticsEvent.user_id.is_not(None), AnalyticsEvent.event.in_(ACTIVE_EVENTS))
.group_by(AnalyticsEvent.user_id)
.subquery()
)
eng_sub = (
select(
CouponPromptEngagement.user_id.label("user_id"),
func.max(CouponPromptEngagement.created_at).label("last_at"),
)
.where(
CouponPromptEngagement.user_id.is_not(None),
CouponPromptEngagement.engage_type == ACTIVE_ENGAGE_TYPE,
)
.group_by(CouponPromptEngagement.user_id)
.subquery()
)
return ev_sub, eng_sub
def last_active_expr(base_col, ev_sub, eng_sub, dialect: str):
"""max(base_col, 最近活跃事件, 最近领券) 的 SQL 表达式。PG 用 greatest、SQLite 用 max。
子聚合缺失(未命中) coalesce base_col(= User.created_at,恒非空基线)"""
greatest = func.greatest if dialect == "postgresql" else func.max
return greatest(
base_col,
func.coalesce(ev_sub.c.last_at, base_col),
func.coalesce(eng_sub.c.last_at, base_col),
)
+107
View File
@@ -0,0 +1,107 @@
"""15 天不活跃清零业务逻辑(纯同步,可单测)。worker 只是它的 asyncio 外壳。
活跃口径复用 app.repositories.activity;清零走 wallet.grant_*(负数出账写流水 commit)
逐用户独立事务,一个失败不影响其余
"""
from __future__ import annotations
from datetime import date, datetime, timezone
from sqlalchemy import or_, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ
from app.models.inactivity import InactivityResetLog
from app.models.user import User
from app.models.wallet import CoinAccount
from app.repositories import activity
from app.repositories import wallet as wallet_repo
RESET_BIZ_TYPE = "inactivity_reset"
RESET_REMARK = "15天不活跃清零"
_ANY_BALANCE = or_(
CoinAccount.coin_balance > 0,
CoinAccount.cash_balance_cents > 0,
CoinAccount.invite_cash_balance_cents > 0,
)
def _base_query(db: Session):
"""select(user_id, last_active, 三桶余额),join CoinAccount + 两活跃子查询。"""
ev_sub, eng_sub = activity.last_active_subqueries(db)
dialect = db.get_bind().dialect.name
last_active = activity.last_active_expr(User.created_at, ev_sub, eng_sub, dialect)
stmt = (
select(
User.id.label("user_id"),
last_active.label("last_active"),
CoinAccount.coin_balance,
CoinAccount.cash_balance_cents,
CoinAccount.invite_cash_balance_cents,
)
.join(CoinAccount, CoinAccount.user_id == User.id)
.outerjoin(ev_sub, ev_sub.c.user_id == User.id)
.outerjoin(eng_sub, eng_sub.c.user_id == User.id)
)
return stmt, last_active
def _cn_date(dt: datetime) -> date:
"""datetime → 北京自然日(naive 视为 UTC)。"""
return activity.norm_utc(dt).astimezone(CN_TZ).date()
def _inactive_days(last_active: datetime, today: date) -> int:
return (today - _cn_date(last_active)).days
def select_inactive_users(db: Session, *, cutoff: datetime):
"""应清零用户:last_active < cutoff 且三桶有余额。返回 Row 列表(值已快照,可跨 commit)。"""
stmt, last_active = _base_query(db)
stmt = stmt.where(_ANY_BALANCE, last_active < activity.as_utc(cutoff))
return db.execute(stmt).all()
def clear_user(db: Session, *, user_id: int, last_active: datetime, inactive_days: int, reason: str) -> bool:
"""单用户清零(独立事务、行锁)。三桶归零 + 写审计 + 3 条流水。返回是否真清了(有余额)。"""
acc = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
coin, cash, invite = acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents
if coin == 0 and cash == 0 and invite == 0:
return False
log = InactivityResetLog(
user_id=user_id, coin_balance_before=coin, cash_balance_cents_before=cash,
invite_cash_balance_cents_before=invite, last_active_at=activity.norm_utc(last_active),
inactive_days=inactive_days, reason=reason,
)
db.add(log)
db.flush() # 拿 log.id 作 ref_id 交叉链接审计↔流水
ref = str(log.id)
if coin:
wallet_repo.grant_coins(db, user_id, -coin, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK)
if cash:
wallet_repo.grant_cash(db, user_id, -cash, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK)
if invite:
wallet_repo.grant_invite_cash(db, user_id, -invite, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK)
db.commit()
return True
def run_reset_once(db: Session, *, reset_days: int, today: date) -> dict:
"""扫一轮清零。逐用户独立 commit,失败隔离。"""
stats = {"scanned": 0, "cleared": 0, "failed": 0}
cutoff = activity.reset_cutoff(reset_days, today)
reason = f"inactive_{reset_days}d"
rows = select_inactive_users(db, cutoff=cutoff) # 先物化,避免边遍历边 commit
for row in rows:
stats["scanned"] += 1
idays = _inactive_days(row.last_active, today)
try:
if clear_user(db, user_id=row.user_id, last_active=row.last_active,
inactive_days=idays, reason=reason):
stats["cleared"] += 1
except SQLAlchemyError:
db.rollback()
stats["failed"] += 1
return stats
+53
View File
@@ -0,0 +1,53 @@
"""LLM 调用成本计算(纯逻辑,无 DB):按 model 分桶累加 token × 单价,返回总成本(元)+ 价格快照。
用量取自 comparison_record.llm_calls[].usage(pricebot 已归一为 prompt/completion_tokens);
error / usage 的调用跳过price_cfg = {per_model:{model:{input_per_1m,output_per_1m}}, default:{...}}
成本单位单次亚分级, float(不用 *_cents);snapshot 只含本次用到的模型的价(审计用,
不存整张价表)用到但没配价(既无 per_model 又无 default)的模型 快照标 unpriced,成本按 0
"""
from __future__ import annotations
_PRICE_KEY = "llm_token_price"
def get_llm_prices(db) -> dict:
"""读 LLM 单价配置(app_config;表内无则回退 CONFIG_DEFS 默认)。返回 compute_llm_cost 的 price_cfg。"""
from app.repositories import app_config # 延迟 import:compute_llm_cost 纯逻辑不牵连 DB 层
return app_config.get_value(db, _PRICE_KEY)
def compute_llm_cost(calls: list[dict], price_cfg: dict) -> tuple[float | None, dict | None]:
"""遍历 calls 按 model 分桶,cost = Σ(入/1e6*入价 + 出/1e6*出价);无有效调用 → (None, None)。"""
if not calls:
return None, None
per_model = price_cfg.get("per_model") or {}
default = price_cfg.get("default")
buckets: dict[str, list[int]] = {} # model -> [Σprompt_tokens, Σcompletion_tokens]
for c in calls:
if c.get("error"):
continue
usage = c.get("usage") or {}
model = c.get("model") or "unknown"
b = buckets.setdefault(model, [0, 0])
b[0] += usage.get("prompt_tokens") or 0
b[1] += usage.get("completion_tokens") or 0
if not buckets: # 全是 error / 无 usage
return None, None
total = 0.0
prices: dict[str, dict] = {}
for model, (tin, tout) in buckets.items():
price = per_model.get(model, default)
in_p = price.get("input_per_1m") if isinstance(price, dict) else None
out_p = price.get("output_per_1m") if isinstance(price, dict) else None
# 没配价 / 无 default / 单价残缺或非法(配置页手改 JSON 可能存出脏数据)→ 标记待补价、
# 不计入成本;绝不抛异常,以免连累同一回填里的 token/llm_calls 落库。
if not isinstance(in_p, (int, float)) or not isinstance(out_p, (int, float)):
prices[model] = {"input_per_1m": in_p, "output_per_1m": out_p, "unpriced": True}
continue
total += tin / 1e6 * in_p + tout / 1e6 * out_p
prices[model] = {
"input_per_1m": in_p,
"output_per_1m": out_p,
"_source": "per_model" if model in per_model else "default",
}
return round(total, 6), {"mode": "per_model", "prices": prices}
+2
View File
@@ -40,6 +40,8 @@
| `raw_payload` | JSON(PG: JSONB) | nullable | 客户端原始上报全量(calibration + done.params),取数兜底 |
| `input_tokens` | Integer | nullable | 本次 LLM 累计输入 token = Σ `llm_calls[].usage.prompt_tokens`(server 收上报后从 `llm_calls` 累加;旧记录/未采集为 null) |
| `output_tokens` | Integer | nullable | 本次 LLM 累计输出 token = Σ `llm_calls[].usage.completion_tokens`(同上) |
| `llm_cost_yuan` | Float | nullable | 本次比价 LLM 总成本(元),回填时按「当时价」逐模型算好冻结(见 `services/llm_cost.py`);旧记录/未回填为 null → 前端回退「估算成本」 |
| `llm_price_snapshot` | JSON(PG: JSONB) | nullable | 算成本所用单价快照 `{mode, prices:{model:{input_per_1m,output_per_1m,_source}}}`;`app_config` 只存当前价、不留历史,故冻结当时价供审计/复算 |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
> `ordered`(已下单)是**瞬态字段**,不在表里:`list_records` 读取时按 `store_name ∈ 该用户 source='compare' 的 savings_record.shop_name 集合` 现挂到实例上供出参用。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,296 @@
# 15 天不活跃自动清零(金币 + 现金)设计
- **日期**2026-07-16
- **状态**Draft — 待评审
- **所属**app-server`app/`),含一处 admin 侧重构 + 一项 Android 端埋点依赖
- **一句话**:连续 15 天不活跃的用户,自动清零其金币与现金;清零前按可配置节奏预警;全过程留审计以备纠纷排查。
---
## 1. 背景与目标
运营需要对**长期不活跃**用户的钱包余额做清理。两条硬性要求:
1. **可审计**:记录清零原因与**清零前的三桶余额**,便于后续排查与处理客户纠纷。
2. **临清预警**:在临近清零前推送信息告知用户"因账号不活跃,账户里的 xx 金币和 xx 现金将被清零"。
### 非目标(本期不做)
- 不做真实推送通道(极光 JPush / 短信)的对接 —— 仅做**可插拔通知器 + 日志占位**,接口预留、后续无缝替换。
- 不改动提现(`WithdrawOrder`)流程。
- 不新增 `User.last_active_at` 列、不改鉴权热路径。
---
## 2. 需求
| # | 需求 | 落地 |
|---|---|---|
| R1 | 连续 15 天不活跃 → 清零金币 + 现金 | 每日 worker 扫描 + 逐用户事务清零(§6) |
| R2 | 记录清零原因 + 清零前余额 | `inactivity_reset_log` 审计表 + 3 条钱包流水(§5、§7) |
| R3 | 临清前预警"xx 金币 xx 现金将清零" | 阶段 A 预警 + `inactivity_notification_log`(§6、§7 |
| R4 | 活跃口径与"用户管理"一致 | 抽共享模块 `activity.py`admin 与 worker 共用(§4、§12 |
| R5 | 预警时机完全可配置 | `INACTIVITY_*` 配置项(§8 |
---
## 3. 决策记录(来自评审问答)
| 决策点 | 结论 | 理由 |
|---|---|---|
| **活跃口径** | 与"用户管理"一致:`max(home_view, 比价, 领券)`**不含 last_login_at**;无任何信号时以 `created_at` 为非空基线 | 比价可从**浮窗**触发、不进首页;`last_login_at` 只在登录/换绑动作更新(re-login 也算),代表不了"在用 App",故彻底排除 |
| **"进首页"信号落地** | **方案 A:前端上报 `home_view` 埋点**(复用 `/analytics/events`),非新接口 | 三个活跃信号统一为同类埋点事件;零新接口零新列;与 admin 口径天然一致。B(鉴权接口 + 列)"更权威"的优势是假的——比价/领券仍是端上报事件,最弱环决定整体可信度 |
| **清零范围** | **金币 + 折算现金 + 邀请现金** 三桶全清 | 对应"账户里的金币和现金" |
| **预警推送** | **可插拔通知器 + 日志占位**v1),后续接 JPush/短信 | 现状无真实推送能力;先把清零主流程 + 审计做扎实,不阻塞 |
| **预警时机** | **完全可配置**(提前天数列表 + 次数 + 执行点 + 通道) | R5 |
| **触发方式** | **进程内每日 worker**,仿 `daily_exchange_worker` | 与项目最新模式一致,无需外部 cron |
| **admin 共享口径** | 共享模块 + **重构 admin 改用它** | 单一真源,永不漂移(R4 |
### 已知取舍(可接受)
- analytics 的 `user_id` 是**端上报、未鉴权**(可伪造)。但伪造只能"保自己活跃、避免被清",无收益,且正是本功能要防的行为,风险良性。活跃时间的非空基线由服务端权威的 `User.created_at` 提供(见 §4),不再依赖 `last_login_at`。与"用户管理"口径一致。
---
## 4. 活跃口径与共享模块 `app/repositories/activity.py`(新建)
活跃口径的**唯一真源**。app 侧模块,admin 可 import`app.main` 不 import `app.admin`,反向允许)。
### 口径
```
last_active = max(
User.created_at, # 注册基线(恒非空;re-login 不推进,只有真实使用才推进)
max AnalyticsEvent.created_at WHERE event IN ACTIVE_EVENTS,
max CouponPromptEngagement.created_at WHERE engage_type == "claim_started",
)
不活跃判定:按北京自然日、0 点对齐(非从末次活跃时刻滚动 15×24h)
last_active_date = 北京(last_active).date() # 末次活跃的北京日,记为「第 1 日」
清零边界 = 北京 00:00 of (last_active_date + RESET_DAYS 天) =「第 (RESET_DAYS+1) 日 0 点」 # 15 → 第16日0点
应清零 ⟺ (cn_today() last_active_date).days ≥ RESET_DAYS
⟺ last_active < cutoff cutoff = 北京 00:00 of (cn_today() (RESET_DAYS1)) # 供 SQL 比较
inactive_days = (cn_today() last_active_date).days # 清零当日恰 = RESET_DAYS
例:末次活跃 1/1 → 1/16 00:00(第16日0点)清零,当日 inactive_days=151/15 及之前不清
```
### 模块内容
- 常量:
- `ACTIVE_EVENTS = (HOME_VIEW_EVENT, "real_compare_start", "real_coupon_start")` —— 进首页 + 比价 + 领券。**`HOME_VIEW_EVENT` 事件名待前端确认**(明天加埋点时定;暂记 `"home_view"`)。
- `ACTIVE_ENGAGE_TYPE = "claim_started"`
- `last_active_subqueries(db)` —— 复刻现 admin `queries._last_active_parts()`:两个按 `user_id``GROUP BY max(created_at)` 聚合子查询。
- `last_active_expr(base_col, ev_sub, eng_sub, dialect)` —— 生成 `greatest`/`max`PG `func.greatest`SQLite `func.max`);子聚合缺失时 `coalesce(子聚合, User.created_at)` 兜底(注册基线恒非空,**替代原 last_login_at**)。
- `_norm_utc()` —— 沿用现 admin 的 naive→UTC 归一(SQLite naive / PG aware 混算保护)。
- `reset_cutoff(reset_days)` / `warn_cutoff(reset_days, k)` —— 生成**北京 0 点对齐**的边界 datetime(见口径):`reset_cutoff = 北京 00:00 of (cn_today() (reset_days1))`,供下面查询按 `last_active < cutoff` 比较。
- `select_inactive_users(db, *, cutoff, with_balance=True)` —— **worker 专用**join `CoinAccount`,筛 `last_active < cutoff`(cutoff = 北京 0 点对齐边界,见口径)且(`coin_balance>0 OR cash_balance_cents>0 OR invite_cash_balance_cents>0`),返回 `(user, account, last_active, inactive_days)`
- `select_warn_targets(db, *, reset_days, warn_days_before)` —— **worker 专用**:返回 `(user, account, last_active, inactive_days, stage)` 元组——各"提前天数"窗口内、有余额、本 streak 未推过档 `stage` 的用户(去重结合 `notification_log`,逻辑见 §9)。
> **参考现状**:现口径散落在 `app/admin/repositories/queries.py:38,91-124,199-204``_ACTIVE_EVENTS`/`_last_active_parts`/`greatest`)与 `app/admin/repositories/stats.py:51-52,138-146``COMPARE_START_EVENT`/`COUPON_START_EVENT`/活跃用户集)。这些改为从 `activity.py` 导入(§12)。
---
## 5. 数据模型(2 张新表,不动 `User`
两表均登记进 `app/models/__init__.py`;一个 Alembic 迁移建两表(`render_as_batch`SQLite 兼容)。
### ① `inactivity_reset_log` —— 清零审计(R2
仿 `app/models/phone_rebind_log.py` 的简单审计表风格。
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int PK autoincrement | |
| `user_id` | int, index, not null | |
| `coin_balance_before` | int, not null | 清零前金币 |
| `cash_balance_cents_before` | int, not null | 清零前折算现金(分) |
| `invite_cash_balance_cents_before` | int, not null | 清零前邀请现金(分) |
| `last_active_at` | DateTime(tz), nullable | 判定时的最近活跃时间 |
| `inactive_days` | int, not null | 判定时不活跃天数 |
| `reason` | String(32), not null | 如 `"inactive_15d"` |
| `reset_at` | DateTime(tz), server_default now(), index, not null | 清零时刻 |
### ② `inactivity_notification_log` —— 预警记录 + 去重 + 占位 outboxR3
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int PK autoincrement | |
| `user_id` | int, index, not null | |
| `stage` | int, not null | 提前天数档(如 7 / 2 |
| `inactive_days` | int, not null | 推送时不活跃天数 |
| `coin_balance` | int, not null | 推送快照:告知用户的金币数 |
| `cash_balance_cents` | int, not null | 推送快照:折算现金 |
| `invite_cash_balance_cents` | int, not null | 推送快照:邀请现金 |
| `channel` | String(16), not null | `"log"` / `"jpush"` / `"sms"` |
| `status` | String(16), not null | `"placeholder"` / `"sent"` / `"failed"` |
| `created_at` | DateTime(tz), server_default now(), index, not null | 去重锚点(见 §9 |
> 备注:不新增 `User.last_active_at` 列,不改 `get_current_user`。活跃时间由 §4 口径**实时计算**。
---
## 6. 清零 worker `app/core/inactivity_reset_worker.py`(新建)
**完全仿 [`app/core/daily_exchange_worker.py`](../../../app/core/daily_exchange_worker.py)**App 启动自带 asyncio 任务,文件锁(`data/inactivity_reset.lock`)防同机多进程并发,总闸 `INACTIVITY_RESET_ENABLED`(默认关)。
### 调度
- 每 `INACTIVITY_RESET_CHECK_INTERVAL_SEC` 秒醒一次;`last_run: date` 守卫**北京日**,保证每日只跑一轮。
- 仅当 `cn_today() != last_run` 且当前北京小时 `>= INACTIVITY_RESET_RUN_HOUR` 时执行(启动补跑同 daily_exchange 语义)。
- **清零资格边界 = 第 16 日 0 点(北京,见 §4),与 worker 执行点解耦**worker 于当日 `RUN_HOUR`(默认 3 点)跑,把已过边界者一并清;若要严格 0 点触发可置 `RUN_HOUR=0`,但注意与 `daily_auto_exchange` 的 0 点任务错峰。
- lifespan 里 `start_inactivity_reset_worker()` / `stop_...`(仿 `start_daily_exchange_worker``app/main.py` 的接线)。
### 一轮 `run_once(db)` 两阶段(同一次运行、各自逐用户独立 commit)
**阶段 A — 预警**
```
for user, acc, last_active, inactive_days, stage in activity.select_warn_targets(...):
notifier.send_inactivity_warning(user, balances=snapshot(acc), stage=stage, days_until_reset=RESET_DAYS-inactive_days)
db.add(InactivityNotificationLog(..., channel=notifier.channel, status=notifier.last_status))
db.commit() # 逐条独立
```
**阶段 B — 清零**`biz_type="inactivity_reset"`
```
for user, acc, last_active, inactive_days in activity.select_inactive_users(db, cutoff=activity.reset_cutoff(RESET_DAYS)): # 北京 00:00 of (今天−(RESET_DAYS1))
try:
acc = wallet.get_or_create_account(db, user.id, commit=False, lock=True) # 行锁
before = (acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents)
if before == (0,0,0): continue
log = InactivityResetLog(user_id=user.id, coin_balance_before=before[0],
cash_balance_cents_before=before[1], invite_cash_balance_cents_before=before[2],
last_active_at=last_active, inactive_days=inactive_days, reason=f"inactive_{RESET_DAYS}d")
db.add(log); db.flush() # 拿 log.id 作 ref_id 交叉链接
if acc.coin_balance: wallet.grant_coins(db, user.id, -acc.coin_balance, biz_type="inactivity_reset", ref_id=str(log.id), remark="15天不活跃清零")
if acc.cash_balance_cents: wallet.grant_cash(db, user.id, -acc.cash_balance_cents, biz_type="inactivity_reset", ref_id=str(log.id), remark="15天不活跃清零")
if acc.invite_cash_balance_cents: wallet.grant_invite_cash(db, user.id, -acc.invite_cash_balance_cents, biz_type="inactivity_reset", ref_id=str(log.id), remark="15天不活跃清零")
db.commit()
except SQLAlchemyError:
db.rollback(); stats["failed"] += 1
```
- `grant_*` 负数出账、`balance_after=0`、写三条流水;`grant_coins` 负数**不**动 `total_coin_earned`(历史累计保留)。
- 逐用户独立 commit:一个失败不影响其余。返回 `stats = {scanned, warned, cleared, failed}``logger.info`
---
## 7. 预警与可插拔通知器
`app/integrations/notifier.py` 定义协议(外部投递属 integrations 层):
```python
class InactivityNotifier(Protocol):
channel: str # "log" / "jpush" / "sms"
last_status: str # "placeholder" / "sent" / "failed"
def send_inactivity_warning(self, user, *, balances, stage, days_until_reset) -> None: ...
```
- **v1 `LogNotifier`**`channel="log"`):`logger.warning("[inactivity-warn] user=%s coin=%s cash=%s invite=%s T-%s", ...)``last_status="placeholder"`。参照 `heartbeat_monitor_worker` 先例("本期先不接推送,用终端打印代替")。
- 未来 `JPushNotifier` / `SmsNotifier`:实现同协议即可替换,worker 不改。
- 选择:`INACTIVITY_NOTIFY_CHANNEL` → 工厂返回对应实现(未配到真实实现时回退 `LogNotifier`)。
- 预警文案数据来自快照 `balances`,满足 R3"告知 xx 金币 xx 现金"。
---
## 8. 配置项(`app/core/config.py`
```
INACTIVITY_RESET_ENABLED = False # 总闸,默认关;灰度验证后再开
INACTIVITY_RESET_DAYS = 15 # 不活跃阈值(天)
INACTIVITY_WARN_DAYS_BEFORE = "7,2" # 清零前几天各推一次;空串=不推。逗号分隔,降序解析
INACTIVITY_RESET_RUN_HOUR = 3 # 北京时间每日执行点(0-23)
INACTIVITY_NOTIFY_CHANNEL = "log" # log(占位) / jpush / sms
INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800 # worker 唤醒间隔(可复用现有间隔常量)
```
- 清零范围(三桶)固定为常量,不做配置。
- `INACTIVITY_WARN_DAYS_BEFORE` 语义(`inactive_days` 为北京自然日,见 §4):档位 `k` ⟹ 当 `inactive_days >= RESET_DAYS-k``< RESET_DAYS` 且本 streak 未推过档 `k` 时预警,即在北京日 `last_active_date + (RESET_DAYSk)` 触发(漏跑某天时补发最紧急未推档,§9)。
- `INACTIVITY_RESET_RUN_HOUR` 只决定 worker 每日执行点,**不改变**"第 16 日 0 点"这一资格边界(§4/§6)。
---
## 9. 幂等与重新活跃
- **重新活跃自动退出**`inactive_days` 由 §4 口径**实时算**。用户一有 `home_view`/比价/领券(**登录本身不算**),`last_active` 前移,自动移出预警与清零队列。**无需**显式"重置标记"。
- **预警去重**`inactivity_notification_log` 中存在 `stage==k 且 created_at > last_active` 的行 ⟹ 本 streak 已推过档 `k`,不重推。用户回归后 `last_active` 前移,旧预警行自然"失效",开启新 streak。
- **清零幂等**:阶段 B 只处理三桶非全 0 者;清完 = 0,次日不再匹配。worker 重启 / 多次唤醒 / 补跑均安全,不产生重复清零或重复流水。
- **稳健补发**:worker 漏跑数天后,某用户可能同时满足多档;只补发**最紧急的未推档**(最小 `k`),避免一次刷屏。
---
## 10. 边界与安全
| 场景 | 处理 |
|---|---|
| 新用户 | `created_at` 作活跃基线(恒非空)→ 注册即"第 1 日活跃";注册后连续 15 天无 home_view/比价/领券 才清 |
| 在途提现 | 提现申请时现金已扣入 `WithdrawOrder`,当前余额已不含在途;只清当前余额、不动提现单。提现失败退款到已清账户 = 用户的钱,正常 |
| 与 `daily_auto_exchange` 并存 | 各自逐用户幂等;金币多已日结折现金,三桶全清正好覆盖 |
| 时区/日界 | 统一北京(`rewards.cn_today()`/`CN_TZ`);**清零/预警按北京自然日 0 点对齐**(末次活跃记为第 1 日 → 第 16 日 0 点清零,见 §4),非滚动 24h;流水 `created_at` 沿用北京 wall-clock naive |
| 误清防护 | 总闸默认关;先灰度只看预警/清零**名单**对不对,再开清零(§13) |
---
## 11. 前端依赖:`home_view` 埋点(跨仓 — Android
- **Android 端**`shaguabijia-app-android`)需在**首页可见**`onResume`/Tab 切入)时,向现有 `POST /api/v1/analytics/events` 批量上报里加一条 `event=<首页可见事件名>`(名称明天加埋点时定,暂记 `"home_view"` 的事件,**携带登录后的 `user_id`**。
- 客户端按会话/前台去重即可(服务端只取 `max(created_at)`,多报无害)。
- **上线顺序依赖**`home_view` 全量覆盖前,"进首页"信号缺失,只有比价/领券能推进活跃、其余落到 `created_at` 基线("只开首页不操作"且注册满 15 天的用户会被误清)—— 故清零总闸必须待 `home_view` 铺满后再开(§13)。
---
## 12. admin 重构范围与影响(R4
- `app/admin/repositories/queries.py`:删本地 `_ACTIVE_EVENTS`/`_last_active_parts()`,改用 `activity.py` 的常量与子查询构造;`list_users``greatest(...)` 排序/筛选、`_attach_last_active` 均改走共享构造器。
- `app/admin/repositories/stats.py``COMPARE_START_EVENT`/`COUPON_START_EVENT`/活跃用户集(`:138-146`)改用共享常量与口径。
- **行为变化(预期内、需产品知会)**admin 的"最近活跃 / DAU"口径变化——**移除 `last_login_at`(登录不再计为活跃)、以 `created_at` 为基线、纳入 `home_view`**。net`home_view` 铺满后更准(真正把"开首页"算进活跃);铺满前"只登录不操作"的用户活跃度会下降。
- **回归底线**:现有 admin 用户列表 / stats 测试按新口径**更新预期**last_login_at 移除 + created_at 基线 + home_view 纳入);非活跃口径部分行为不变。
---
## 13. 灰度与上线顺序(安全优先)
1. **后端先行**:合入共享模块 + 两表 + worker + 通知器,`INACTIVITY_RESET_ENABLED=False`;活跃口径以 `created_at` 为非空基线、**不含 last_login_at**。
2. **Android 发版**:上报 `home_view`;观察 analytics 覆盖率。
3. **只读灰度**:临时开一个"dry-run/只出名单不清零"路径或看阶段 A 预警日志,核对预警/清零**名单**准确。
4. **开清零**:确认无误后置 `INACTIVITY_RESET_ENABLED=True`
5. **收尾/监控**:持续观察 `home_view` 覆盖率与预警/清零名单;发现"活跃却被判不活跃"的漏报即回查埋点覆盖(口径已不含 last_login_at,登录不再兜底)。
---
## 14. 测试计划
- **活跃口径(共享模块)**`home_view`/比价/领券 各单独命中都算活跃;**纯登录不算**;无信号用户以 `created_at` 计;`max` 取最新;naive/aware 混算不崩。
- **admin 回归**:用户列表 / stats 按新口径更新预期(移除 last_login_at + created_at 基线 + home_view)。
- **不活跃判定**`last_active` 分别 `<15d / =15d / >15d` × 有/无余额 的命中矩阵。
- **清零**:三桶归零;`inactivity_reset_log` 清前值正确;三条流水 `biz_type=inactivity_reset``balance_after=0``ref_id=log.id``total_coin_earned` 不变。
- **预警**:命中窗口调 notifier + 写 `notification_log`;同 streak 不重推;回归后 `last_active` 前移可再次预警;漏跑补发最紧急档。
- **worker**:总闸关不启动;文件锁互斥;逐用户失败隔离(一个抛错不影响其余,`failed` 计数);重复跑幂等。
- **配置**`INACTIVITY_WARN_DAYS_BEFORE` 解析(含空串=不推);`RESET_DAYS`/`RUN_HOUR` 生效。
- 沿用 `tests/conftest.py`(临时 SQLite、`RATE_LIMIT_ENABLED=false`);外部通知 monkeypatch。
---
## 15. 未来工作
- 接真实 `JPushNotifier`(需用户级 `registration_id` 覆盖 + JPush push API/ `SmsNotifier`
- 如需 admin 后台可视化:不活跃/预警/清零名单与历史查询接口。
- 如量级增长导致每日 join 扫描变慢:再考虑物化 `last_active_at`(当前每日一次可接受)。
---
## 附:涉及文件清单
**新增**
- `app/repositories/activity.py` — 活跃口径唯一真源
- `app/models/inactivity_reset_log.py` — 审计表
- `app/models/inactivity_notification_log.py` — 预警/占位表
- `app/core/inactivity_reset_worker.py` — 每日 worker(仿 daily_exchange_worker
- `app/integrations/notifier.py` — 通知器协议 + `LogNotifier`(真实 JPush/短信后续同层扩展)
- `alembic/versions/<...>_add_inactivity_tables.py` — 建两表迁移
- `docs/database/inactivity_reset_log.md` / `inactivity_notification_log.md` — 表字典(随实现补)
- 对应 `tests/test_inactivity_reset.py`
**改动**
- `app/models/__init__.py` — 注册两模型
- `app/core/config.py``INACTIVITY_*` 配置
- `app/main.py` — lifespan 接线 start/stop worker
- `app/admin/repositories/queries.py``stats.py` — 改用 `activity.py`(§12
+248
View File
@@ -0,0 +1,248 @@
"""一次性 mock:造带 LLM token 成本的比价记录 + 配好 app_config 模型单价,用于测「管理后端」LLM 成本展示。
覆盖 admin比价记录详情抽屉的LLM 成本展示分支:
app_config.llm_token_price 写一条多模型单价(= 配置页LLM 成本卡片已改,get_llm_prices 读它)
comparison_record 5 ,逐条**复用生产的 compute_llm_cost + _backfill_llm_calls 同款派生**
(llm_call_count/retry_count/input_tokens/output_tokens/llm_cost_yuan/llm_price_snapshot),
确保 mock = 真实回填产出5 条刻意覆盖:
单模型真实样本(qwen3.5-flash ×4) ¥0.006184(核对精确值)
多模型(flash + plus) 快照含两个模型各自 _source=per_model
未登记模型(deepseek-v3) default,快照 _source=default
旧记录( token cost) llm_cost_yuan=NULL 前端回退估算成本
error 调用 error 那次跳过计费retry_count+1
记录挂到库里第一个真实用户(admin 列表能显示手机号);无用户则 user_id=NULL(孤儿行,admin 照样全看)
created_at 用北京 naive最近几分钟内错开,详情列表倒序即 置顶
幂等:重跑先按 trace_id 前缀MOCKLLM-清旧再建app_config 单价是 upsert(不随 --clean-only ,
因该 key 本就是本需求新增无历史真实值;要改价直接去配置页或重跑本脚本)
python -m scripts.seed_mock_llm_cost # 造价格 + 5 条记录
python -m scripts.seed_mock_llm_cost --clean-only # 只清 MOCKLLM- 记录(保留单价)
验收:admin比价记录 traceMOCKLLM- 5 点开详情看LLM 成本:
显示实际·当时价+ 价格快照; 显示估算
"""
from __future__ import annotations
import argparse
import sys
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, select
from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
from app.models.user import User
from app.repositories import app_config
from app.services.llm_cost import compute_llm_cost
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8") # Windows 控制台输出中文/¥
_BJ = timezone(timedelta(hours=8))
ID_PREFIX = "MOCKLLM-"
# ── 写进 app_config 的模型单价(get_llm_prices 读它;配置页「LLM 成本」卡片可再改)──
PRICE_CFG = {
"per_model": {
"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0},
"qwen3.5-plus": {"input_per_1m": 4.0, "output_per_1m": 12.0},
},
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0},
"currency": "CNY",
"unit": "per_1m_tokens",
}
def _c(scene: str, model: str, pin: int, cout: int, error: str | None = None) -> dict:
"""一条 llm_calls 明细,结构对齐真实 pricebot 归一后契约:
{scene, model, input_messages:[{role,content}], output, usage:{prompt/completion/total_tokens},
latency_ms, error}(详情抽屉会遍历 input_messages,缺了会崩)error 的调用无 usage/output"""
return {
"scene": scene,
"model": model,
"error": error,
"input_messages": [
{"role": "system", "content": f"你是比价助手,负责 {scene} 环节。"},
{"role": "user", "content": f"[mock] 请处理本次比价的 {scene} 任务。"},
],
"output": None if error else f"[mock] {scene} 环节完成。",
"usage": None if error else {
"prompt_tokens": pin, "completion_tokens": cout, "total_tokens": pin + cout,
},
"latency_ms": 780,
}
# ── 5 条记录蓝本:calls 决定成本;freeze=False 模拟旧记录(有 token 无 cost)──
RECORDS = [
{
"label": "①单模型·真实样本",
"source": ("美团外卖", 4280), "best": ("京东秒送", 3680),
"store": "肯德基(建国路店)", "product": "疯狂星期四全家桶",
"info": "在京东秒送找到同款,到手价 ¥36.80,省 ¥6.00",
"freeze": True,
"calls": [
_c("store_match", "qwen3.5-flash", 1512, 22),
_c("dish_match", "qwen3.5-flash", 2111, 160),
_c("dish_match", "qwen3.5-flash", 1940, 142),
_c("summary", "qwen3.5-flash", 1325, 13),
],
},
{
"label": "②多模型·flash+plus",
"source": ("淘宝闪购", 5900), "best": ("美团外卖", 5200),
"store": "瑞幸咖啡(国贸店)", "product": "生椰拿铁×2、丝绒拿铁",
"info": "在美团外卖找到同款,到手价 ¥52.00,省 ¥7.00",
"freeze": True,
"calls": [
_c("store_match", "qwen3.5-flash", 2000, 50),
_c("dish_match", "qwen3.5-flash", 1800, 40),
_c("reasoning", "qwen3.5-plus", 3000, 500),
],
},
{
"label": "③未登记模型走 default",
"source": ("京东秒送", 3100), "best": ("美团外卖", 2650),
"store": "麦当劳(soho店)", "product": "麦辣鸡腿堡套餐",
"info": "在美团外卖找到同款,到手价 ¥26.50,省 ¥4.50",
"freeze": True,
"calls": [
_c("store_match", "deepseek-v3", 5000, 800),
],
},
{
"label": "④旧记录·有token无成本(回退估算)",
"source": ("美团外卖", 3600), "best": ("淘宝闪购", 3200),
"store": "华莱士(双井店)", "product": "全鸡汉堡套餐",
"info": "在淘宝闪购找到同款,到手价 ¥32.00,省 ¥4.00",
"freeze": False, # 模拟本需求上线前的老记录:llm_cost_yuan=NULL → 前端回退估算
"calls": [
_c("store_match", "qwen3.5-flash", 2000, 100),
],
},
{
"label": "⑤含 error 调用(跳过计费)",
"source": ("淘宝闪购", 4100), "best": ("京东秒送", 3750),
"store": "海底捞(合生汇店)", "product": "番茄锅底、肥牛卷",
"info": "在京东秒送找到同款,到手价 ¥37.50,省 ¥3.50",
"freeze": True,
"calls": [
_c("store_match", "qwen3.5-flash", 0, 0, error="timeout"),
_c("store_match", "qwen3.5-flash", 1500, 30),
],
},
]
_PLATFORM_ID = { # 展示名 → 平台代号(comparison_results / source/best 列用)
"美团外卖": "meituan", "京东秒送": "jd", "淘宝闪购": "taobao",
}
def _naive_bj_now() -> datetime:
return datetime.now(_BJ).replace(tzinfo=None)
def clean(db) -> int:
n = db.execute(
delete(ComparisonRecord).where(ComparisonRecord.trace_id.like(f"{ID_PREFIX}%"))
).rowcount or 0
db.commit()
return n
def _build_record(spec: dict, owner_id: int | None, created_at: datetime) -> tuple[ComparisonRecord, float | None]:
"""按蓝本造一条记录,LLM 派生完全对齐 _backfill_llm_calls;返回 (记录, 冻结成本或 None)。"""
calls = spec["calls"]
src_name, src_cents = spec["source"]
best_name, best_cents = spec["best"]
# —— 与 _backfill_llm_calls 同款派生 ——
llm_call_count = len(calls)
retry_count = sum(1 for c in calls if c.get("error"))
input_tokens = sum((c.get("usage") or {}).get("prompt_tokens") or 0 for c in calls)
output_tokens = sum((c.get("usage") or {}).get("completion_tokens") or 0 for c in calls)
if spec["freeze"]:
cost, snapshot = compute_llm_cost(calls, PRICE_CFG) # 复用生产纯函数
else:
cost, snapshot = None, None # 旧记录:回填这段代码上线前就有,只有 token 没成本
rec = ComparisonRecord(
user_id=owner_id,
device_id=f"{ID_PREFIX.lower()}dev",
business_type="food",
trace_id=f"{ID_PREFIX}{spec['label'][0]}", # ①..⑤ 各一,唯一
status="success",
source_platform_id=_PLATFORM_ID.get(src_name), source_platform_name=src_name,
source_price_cents=src_cents,
best_platform_id=_PLATFORM_ID.get(best_name), best_platform_name=best_name,
best_price_cents=best_cents,
saved_amount_cents=src_cents - best_cents,
is_source_best=False,
store_name=spec["store"],
product_names=spec["product"],
information=spec["info"],
items=[{"name": spec["product"], "qty": 1}],
comparison_results=[
{"platform_id": _PLATFORM_ID.get(src_name), "platform_name": src_name,
"price": src_cents / 100, "is_source": True, "rank": 2},
{"platform_id": _PLATFORM_ID.get(best_name), "platform_name": best_name,
"price": best_cents / 100, "is_source": False, "rank": 1},
],
total_ms=90_000 + llm_call_count * 1000,
step_count=llm_call_count * 3,
llm_call_count=llm_call_count,
retry_count=retry_count,
input_tokens=input_tokens,
output_tokens=output_tokens,
llm_calls=calls,
llm_cost_yuan=cost,
llm_price_snapshot=snapshot,
created_at=created_at,
)
return rec, cost
def seed(db) -> list[tuple[str, float | None]]:
app_config.set_value(db, "llm_token_price", PRICE_CFG, admin_id=None) # upsert 单价
owner_id = db.execute(select(User.id).order_by(User.id).limit(1)).scalar()
base = _naive_bj_now()
out: list[tuple[str, float | None]] = []
for i, spec in enumerate(RECORDS):
rec, cost = _build_record(spec, owner_id, base - timedelta(minutes=i * 3))
db.add(rec)
out.append((spec["label"], cost))
db.commit()
return out, owner_id
def main() -> None:
parser = argparse.ArgumentParser(description="造带 LLM 成本的比价记录 + app_config 模型单价(测管理后端)")
parser.add_argument("--clean-only", action="store_true", help="只清 MOCKLLM- 记录,不重建(保留单价)")
args = parser.parse_args()
db = SessionLocal()
try:
removed = clean(db)
if removed:
print(f"🧹 已清理旧 mock 记录 {removed}")
if args.clean_only:
print("✅ 仅清理,已完成(app_config 单价保留)。")
return
results, owner_id = seed(db)
print(f"\n✅ 已写入 app_config.llm_token_price(单价)+ {len(results)} 条比价记录"
f"(挂 user_id={owner_id or 'NULL(孤儿行)'})")
print("\n📋 每条冻结成本(admin 详情「LLM 成本」应显示):")
for label, cost in results:
shown = "NULL → 前端回退「估算」" if cost is None else f"¥{cost}"
print(f" {label:<20} {shown}")
print("\n👉 验收:admin「比价记录」→ trace 搜「MOCKLLM-」→ 点开详情核对 LLM 成本 + 价格快照。")
print(" 配置页「系统配置」→「福利页」Tab →「LLM 成本」卡片,单价应为「已改」态。")
finally:
db.close()
if __name__ == "__main__":
main()
+61
View File
@@ -107,6 +107,67 @@ def test_sms_send_device_ip_rate_limit(client, monkeypatch) -> None:
assert r.status_code == 200, r.text
def test_sms_send_cooldown_reject_not_counted(client, monkeypatch) -> None:
"""发码额度只算「成功发码」:被单号 60s 冷却挡下的重发(429)不占设备额度。
做法:同号狂发只成功 1 其余被冷却挡下;把小时额度设 2,证明换号后仍能再成功发 1
若冷却重发也计数,额度早被那几次耗尽"""
from app.api.v1 import auth
from app.core import ratelimit
monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_HOUR_PER_DEVICE", 2)
ratelimit._buckets.clear()
device = "dev-cooldown"
phone_a = "13710137000"
# 首发成功(小时闸计 1/2)
assert client.post(
"/api/v1/auth/sms/send", json={"phone": phone_a, "device_id": device}
).status_code == 200
# 同号连发 3 次:都被单号 60s 冷却挡下 → 429,且**不占**设备额度
for _ in range(3):
r = client.post(
"/api/v1/auth/sms/send", json={"phone": phone_a, "device_id": device}
)
assert r.status_code == 429, r.text
# 换号再发:设备额度只用了 1/2(冷却那几次没算)→ 仍放行(计到 2/2)
assert client.post(
"/api/v1/auth/sms/send", json={"phone": "13710137001", "device_id": device}
).status_code == 200
# 又换号:此时小时闸已 2/2 → 429(反证成功发码确实各计了 1)
r = client.post(
"/api/v1/auth/sms/send", json={"phone": "13710137002", "device_id": device}
)
assert r.status_code == 429, r.text
def test_sms_send_daily_cap(client, monkeypatch) -> None:
"""每天发码上限(设备 + IP):成功发码累计到日上限即 429(用不同手机号绕开单号冷却)。
抬高小时闸单独测日闸;超限文案含今日以便前端提示明天再来"""
from app.api.v1 import auth
from app.core import ratelimit
monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_HOUR_PER_DEVICE", 100) # 抬高小时闸,不干扰
monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_DAY_PER_DEVICE", 3)
ratelimit._buckets.clear()
device = "dev-daily"
for i in range(3):
r = client.post(
"/api/v1/auth/sms/send",
json={"phone": f"13720137{i:03d}", "device_id": device},
)
assert r.status_code == 200, f"{i + 1} 次应放行: {r.text}"
# 第 4 次:同设备同 IP 当日超限 → 429
r = client.post(
"/api/v1/auth/sms/send",
json={"phone": "13720137999", "device_id": device},
)
assert r.status_code == 429, r.text
assert "今日" in r.json()["detail"]
def test_sms_login_device_ip_rate_limit(client, monkeypatch) -> None:
"""防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试,超出 429。
conftest 默认 RATE_LIMIT_ENABLED=false(内存计数跨用例累加),本用例临时打开并清空计数隔离"""
+192
View File
@@ -0,0 +1,192 @@
"""15 天不活跃清零:模型 / 活跃口径 / 清零 / 预警 / 配置 / worker。"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
import pytest
from sqlalchemy import delete, select, update
from app.db.session import SessionLocal
from app.models.inactivity import InactivityNotificationLog, InactivityResetLog
from app.repositories import activity
def test_reset_and_notification_models_persist() -> None:
db = SessionLocal()
try:
db.add(InactivityResetLog(
user_id=1, coin_balance_before=10, cash_balance_cents_before=20,
invite_cash_balance_cents_before=30,
last_active_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
inactive_days=15, reason="inactive_15d",
))
db.add(InactivityNotificationLog(
user_id=1, stage=7, inactive_days=8, coin_balance=10,
cash_balance_cents=20, invite_cash_balance_cents=30,
channel="log", status="placeholder",
))
db.commit()
r = db.execute(select(InactivityResetLog).where(InactivityResetLog.user_id == 1)).scalar_one()
assert r.reason == "inactive_15d" and r.reset_at is not None
n = db.execute(select(InactivityNotificationLog).where(InactivityNotificationLog.user_id == 1)).scalar_one()
assert n.stage == 7 and n.created_at is not None
finally:
db.rollback()
db.close()
def test_reset_cutoff_is_cn_midnight_of_today_minus_days_minus_1() -> None:
# RESET_DAYS=15, today=1/20 → cutoff = 北京 00:00 of 1/6 = 1/5 16:00 UTC
cutoff = activity.reset_cutoff(15, today=date(2026, 1, 20))
assert cutoff == datetime(2026, 1, 5, 16, 0, tzinfo=timezone.utc)
def test_active_event_constants() -> None:
assert activity.HOME_VIEW_EVENT in activity.ACTIVE_EVENTS
assert "real_compare_start" in activity.ACTIVE_EVENTS
assert "real_coupon_start" in activity.ACTIVE_EVENTS
assert activity.ACTIVE_ENGAGE_TYPE == "claim_started"
def test_as_utc_normalizes() -> None:
assert activity.as_utc(datetime(2026, 1, 1)) == datetime(2026, 1, 1, tzinfo=timezone.utc)
cn = datetime(2026, 1, 1, tzinfo=activity.CN_TZ) # 北京 0 点 = 前一天 16:00 UTC
assert activity.as_utc(cn) == datetime(2025, 12, 31, 16, 0, tzinfo=timezone.utc)
from app.core.rewards import CN_TZ
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 import wallet as wallet_repo
_PHONE_SEQ = [0]
@pytest.fixture(autouse=True)
def _isolate_inactivity_state():
"""本文件的测试都做全表扫描 + 全局计数,而 SQLite 测试库 session 级共享、无逐用例回滚
(commit 后的 rollback no-op),故先把可能泄漏的余额清零 + 清掉活跃事件/审计行,
保证每个用例干净起步不删 User(零余额用户不会被扫描选中,避免跨文件/外键影响)"""
db = SessionLocal()
try:
db.execute(update(CoinAccount).values(
coin_balance=0, cash_balance_cents=0, invite_cash_balance_cents=0))
for model in (AnalyticsEvent, CouponPromptEngagement,
InactivityResetLog, InactivityNotificationLog):
db.execute(delete(model))
db.commit()
finally:
db.close()
yield
def _new_user(db, *, created_at, coin=0, cash=0, invite=0) -> int:
"""直接建一个 User + CoinAccount,created_at 可控。返回 user_id。"""
_PHONE_SEQ[0] += 1
u = User(phone=f"139{_PHONE_SEQ[0]:08d}", created_at=created_at,
last_login_at=created_at, status="active",
username=f"2{_PHONE_SEQ[0]:010d}")
db.add(u)
db.flush()
acc = wallet_repo.get_or_create_account(db, u.id, commit=False)
acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents = coin, cash, invite
acc.total_coin_earned = coin
db.flush()
return u.id
def _add_event(db, user_id, event, when: datetime) -> None:
db.add(AnalyticsEvent(event=event, device_id="d", user_id=user_id, client_ts=0, created_at=when))
def _add_engage(db, user_id, when: datetime, engage_type="claim_started") -> None:
db.add(CouponPromptEngagement(device_id=f"dev{user_id}", package="p", user_id=user_id,
engage_date=when.date(), engage_type=engage_type, created_at=when))
def test_last_active_expr_takes_max_of_baseline_and_events() -> None:
from sqlalchemy import select
db = SessionLocal()
try:
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
uid = _new_user(db, created_at=base, coin=5)
_add_event(db, uid, "real_compare_start", datetime(2026, 1, 10, tzinfo=timezone.utc))
db.commit()
ev_sub, eng_sub = activity.last_active_subqueries(db)
dialect = db.get_bind().dialect.name
expr = activity.last_active_expr(User.created_at, ev_sub, eng_sub, dialect)
stmt = (select(expr).select_from(User)
.outerjoin(ev_sub, ev_sub.c.user_id == User.id)
.outerjoin(eng_sub, eng_sub.c.user_id == User.id)
.where(User.id == uid))
got = activity.norm_utc(db.execute(stmt).scalar_one())
assert got == datetime(2026, 1, 10, tzinfo=timezone.utc) # 事件 > 基线
finally:
db.rollback()
db.close()
def test_inactivity_warn_stages_parsing() -> None:
from app.core.config import Settings
s = Settings(INACTIVITY_WARN_DAYS_BEFORE="7,2", INACTIVITY_RESET_DAYS=15)
assert s.inactivity_warn_stages == [7, 2] # 降序去重
s2 = Settings(INACTIVITY_WARN_DAYS_BEFORE="", INACTIVITY_RESET_DAYS=15)
assert s2.inactivity_warn_stages == [] # 空=不推
s3 = Settings(INACTIVITY_WARN_DAYS_BEFORE="2,20,7,2", INACTIVITY_RESET_DAYS=15)
assert s3.inactivity_warn_stages == [7, 2] # 去重 + 丢弃 >=RESET_DAYS(20)
def test_log_notifier_returns_placeholder(caplog) -> None:
from app.integrations.notifier import LogNotifier, get_notifier
n = get_notifier("log")
assert isinstance(n, LogNotifier) and n.channel == "log"
status = n.warn(user_id=1, coin=10, cash_cents=20, invite_cash_cents=30, stage=7, days_until_reset=8)
assert status == "placeholder"
# 未实现通道回退 LogNotifier(占位)
assert get_notifier("jpush").channel == "log"
def test_run_reset_clears_three_buckets_and_writes_audit_and_flows() -> None:
from sqlalchemy import select
from app.models.wallet import CoinAccount, CoinTransaction, CashTransaction, InviteCashTransaction
from app.repositories import inactivity
db = SessionLocal()
try:
today = date(2026, 2, 1)
# 末次活跃 = created_at 基线 = 1/10(距 today 22 天 → 应清)
old = _new_user(db, created_at=datetime(2026, 1, 10, tzinfo=timezone.utc),
coin=100, cash=200, invite=300)
# 活跃用户:昨天有 home_view → 不清
fresh = _new_user(db, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), coin=50)
_add_event(db, fresh, "home_view", datetime(2026, 1, 31, tzinfo=timezone.utc))
db.commit()
stats = inactivity.run_reset_once(db, reset_days=15, today=today)
assert stats["cleared"] == 1 and stats["failed"] == 0
acc = db.get(CoinAccount, old)
assert (acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents) == (0, 0, 0)
assert acc.total_coin_earned == 100 # 历史累计不动
log = db.execute(select(InactivityResetLog).where(InactivityResetLog.user_id == old)).scalar_one()
assert (log.coin_balance_before, log.cash_balance_cents_before,
log.invite_cash_balance_cents_before) == (100, 200, 300)
assert log.inactive_days == 22 and log.reason == "inactive_15d"
ct = db.execute(select(CoinTransaction).where(
CoinTransaction.user_id == old, CoinTransaction.biz_type == "inactivity_reset")).scalar_one()
assert ct.amount == -100 and ct.balance_after == 0 and ct.ref_id == str(log.id)
assert db.execute(select(CashTransaction).where(
CashTransaction.user_id == old, CashTransaction.biz_type == "inactivity_reset")).scalar_one().amount_cents == -200
assert db.execute(select(InviteCashTransaction).where(
InviteCashTransaction.user_id == old, InviteCashTransaction.biz_type == "inactivity_reset")).scalar_one().amount_cents == -300
# 活跃用户不动;再跑一次幂等(已清零 → 不再匹配)
assert db.get(CoinAccount, fresh).coin_balance == 50
assert inactivity.run_reset_once(db, reset_days=15, today=today)["cleared"] == 0
finally:
db.rollback()
db.close()
+184
View File
@@ -0,0 +1,184 @@
"""LLM 调用成本计算 compute_llm_cost:按模型分桶累加 token × 单价;error/无 usage 跳过。"""
from __future__ import annotations
from app.services.llm_cost import compute_llm_cost
_PRICE = {
"per_model": {"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0}},
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0},
}
def test_sums_per_model_single_model():
# 真实样本:4 次 qwen3.5-flash;Σprompt=6888、Σcompletion=337
calls = [
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1512, "completion_tokens": 22}},
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 2111, "completion_tokens": 160}},
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1940, "completion_tokens": 142}},
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1325, "completion_tokens": 13}},
]
cost, snapshot = compute_llm_cost(calls, _PRICE)
# 6888/1e6*0.8 + 337/1e6*2.0 = 0.0055104 + 0.000674 = 0.0061844 → round(6)
assert cost == 0.006184
assert snapshot == {
"mode": "per_model",
"prices": {
"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0, "_source": "per_model"},
},
}
def test_multi_model_prices_each_bucket_separately():
calls = [
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 0}},
{"model": "gpt-x", "error": None, "usage": {"prompt_tokens": 0, "completion_tokens": 1_000_000}},
]
price = {
"per_model": {
"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0},
"gpt-x": {"input_per_1m": 10.0, "output_per_1m": 30.0},
},
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0},
}
cost, snap = compute_llm_cost(calls, price)
assert cost == 30.8 # qwen 1M入×0.8=0.8 + gpt-x 1M出×30=30.0
assert set(snap["prices"]) == {"qwen3.5-flash", "gpt-x"}
def test_unknown_model_falls_back_to_default():
calls = [{"model": "mystery", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 0}}]
price = {"per_model": {}, "default": {"input_per_1m": 3.0, "output_per_1m": 15.0}}
cost, snap = compute_llm_cost(calls, price)
assert cost == 3.0
assert snap["prices"]["mystery"]["_source"] == "default"
def test_unpriced_model_marked_and_zero_cost():
calls = [{"model": "mystery", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 999}}]
cost, snap = compute_llm_cost(calls, {"per_model": {}}) # 无 default
assert cost == 0.0
assert snap["prices"]["mystery"]["unpriced"] is True
def test_error_and_missing_usage_calls_skipped():
calls = [
{"model": "qwen3.5-flash", "error": "boom", "usage": {"prompt_tokens": 9_999_999, "completion_tokens": 9_999_999}},
{"model": "qwen3.5-flash", "error": None, "usage": None}, # 无 usage
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 0}},
]
cost, _ = compute_llm_cost(calls, _PRICE)
assert cost == 0.8 # 只有第 3 条计入
def test_empty_or_all_error_returns_none():
assert compute_llm_cost([], _PRICE) == (None, None)
assert compute_llm_cost(None, _PRICE) == (None, None)
all_error = [{"model": "x", "error": "boom", "usage": {"prompt_tokens": 100, "completion_tokens": 100}}]
assert compute_llm_cost(all_error, _PRICE) == (None, None)
def test_malformed_price_entry_is_treated_as_unpriced_not_raised():
# 手改配置页可能存出残缺/非法单价(缺 output_per_1m、非 dict);不能抛异常连累 token 回填。
calls = [
{"model": "bad-a", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 5}},
{"model": "bad-b", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 5}},
{"model": "ok", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 0}},
]
price = {
"per_model": {
"bad-a": {"input_per_1m": 0.8}, # 缺 output_per_1m
"bad-b": 5, # 非 dict
"ok": {"input_per_1m": 3.0, "output_per_1m": 15.0},
},
}
cost, snap = compute_llm_cost(calls, price) # 不得抛异常
assert cost == 3.0 # 只有 ok(1M 入 × 3.0)计入;两个残缺项按 unpriced
assert snap["prices"]["bad-a"].get("unpriced") is True
assert snap["prices"]["bad-b"].get("unpriced") is True
def test_get_llm_prices_falls_back_to_default_then_uses_override():
from app.db.session import SessionLocal
from app.models.app_config import AppConfig
from app.repositories import app_config
from app.services.llm_cost import get_llm_prices
db = SessionLocal()
try:
# 无 override → CONFIG_DEFS 默认(含 per_model / default)
prices = get_llm_prices(db)
assert "per_model" in prices and "default" in prices
# 有 override → 用 DB 值
app_config.set_value(
db, "llm_token_price",
{"per_model": {"m": {"input_per_1m": 1.0, "output_per_1m": 2.0}},
"default": {"input_per_1m": 0.0, "output_per_1m": 0.0}},
admin_id=1,
)
assert get_llm_prices(db)["per_model"]["m"]["input_per_1m"] == 1.0
finally:
row = db.get(AppConfig, "llm_token_price")
if row is not None:
db.delete(row)
db.commit()
db.close()
def test_backfill_llm_calls_stores_cost_and_snapshot(monkeypatch):
from datetime import UTC, datetime
from app.api.v1 import compare_record
from app.db.session import SessionLocal
from app.models.app_config import AppConfig
from app.models.comparison import ComparisonRecord
from app.repositories import app_config
sample = [
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1512, "completion_tokens": 22}},
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 2111, "completion_tokens": 160}},
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1940, "completion_tokens": 142}},
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1325, "completion_tokens": 13}},
]
monkeypatch.setattr(compare_record, "fetch_llm_calls", lambda trace_id: sample)
db = SessionLocal()
try:
app_config.set_value(
db, "llm_token_price",
{"per_model": {"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0}},
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0}},
admin_id=1,
)
rec = ComparisonRecord(
trace_id="llmcost-bf-1", status="success",
created_at=datetime.now(UTC).replace(tzinfo=None),
)
db.add(rec)
db.commit()
rid = rec.id
finally:
db.close()
compare_record._backfill_llm_calls(rid, "llmcost-bf-1") # 独立 session 内回填
db = SessionLocal()
try:
rec = db.get(ComparisonRecord, rid)
assert rec.llm_cost_yuan == 0.006184
assert rec.llm_price_snapshot["prices"]["qwen3.5-flash"]["input_per_1m"] == 0.8
assert rec.input_tokens == 6888 # 现有 token 派生仍在
finally:
db.delete(db.get(ComparisonRecord, rid))
row = db.get(AppConfig, "llm_token_price")
if row is not None:
db.delete(row)
db.commit()
db.close()
def test_admin_detail_schema_exposes_llm_cost_fields():
from app.admin.schemas.comparison import AdminComparisonDetail
fields = AdminComparisonDetail.model_fields
assert "llm_cost_yuan" in fields
assert "llm_price_snapshot" in fields
+57
View File
@@ -0,0 +1,57 @@
"""ratelimit 内存桶过期清理(GC)测试。
回归重点:_buckets **全局共享**混着不同窗口(60s 广告 / 3600s 登录 / 86400s 日闸) key
GC 必须按每个 key 自己存的 window_sec判过期,而不是当前调用方的窗口 否则高频的 60s 端点
触发 GC 时会把本该存活更久的 3600s/86400s 计数(如短信日闸)一并删掉,使其被反复清零限流失效
monkeypatch _GC_THRESHOLD 0 强制每次都扫,免造上万条(仿 test_auth 里对 sms._GC_THRESHOLD 的做法)
"""
from __future__ import annotations
from app.core import ratelimit
def test_purge_expired_respects_each_key_own_window(monkeypatch) -> None:
"""短窗口(60s)触发的 GC 只删真正过期的 key,不得删掉仍在自身窗口内的长窗口 key。"""
monkeypatch.setattr(ratelimit, "_GC_THRESHOLD", 0) # 强制每次都扫
ratelimit._buckets.clear()
now = 1_000_000.0
# 日闸:100s 前开窗、window=86400 → 远未过期,必须保留
ratelimit._buckets["sms-send-device-daily:D:IP"] = (now - 100, 7, 86400.0)
# 登录:1800s、window=3600 → 未过期,保留
ratelimit._buckets["sms-login-device:D:IP"] = (now - 1800, 2, 3600.0)
# 广告:120s、window=60 → 已过期,应删
ratelimit._buckets["ad-watch-report:IP2"] = (now - 120, 3, 60.0)
ratelimit._purge_expired(now)
assert "sms-send-device-daily:D:IP" in ratelimit._buckets
assert "sms-login-device:D:IP" in ratelimit._buckets
assert "ad-watch-report:IP2" not in ratelimit._buckets
def test_purge_expired_keeps_long_window_key_older_than_short_window(monkeypatch) -> None:
"""反证旧 bug:日闸 key 已老于 3600s,旧代码在 60s/3600s 端点触发 GC 时会误删它;
现在按自身 86400s 窗口判 未过期 必须保留"""
monkeypatch.setattr(ratelimit, "_GC_THRESHOLD", 0)
ratelimit._buckets.clear()
now = 2_000_000.0
# 3700s 前开窗(> 1 小时),但 window=86400 → 未过期
ratelimit._buckets["sms-send-device-daily:D:IP"] = (now - 3700, 20, 86400.0)
ratelimit._purge_expired(now)
assert "sms-send-device-daily:D:IP" in ratelimit._buckets
def test_purge_expired_noop_below_threshold(monkeypatch) -> None:
"""未超阈值时不扫(即便有过期 key 也不动),避免每次请求都 O(n) 扫全表。"""
monkeypatch.setattr(ratelimit, "_GC_THRESHOLD", 10)
ratelimit._buckets.clear()
now = 3_000_000.0
ratelimit._buckets["stale:IP"] = (now - 999, 1, 60.0) # 早过期,但没超阈值
ratelimit._purge_expired(now)
assert "stale:IP" in ratelimit._buckets # 桶数没超阈值 → 不清理