合并 main 并解决 API 文档冲突

This commit is contained in:
左辰勇
2026-07-20 23:58:31 +08:00
287 changed files with 18313 additions and 916 deletions
+170
View File
@@ -0,0 +1,170 @@
"""把 meituan_coupon 的线上采样 TSV 灌进本地 SQLite。
用途:本地开发/调试时,把线上 `meituan_coupon` 表的采样数据(tests/meituan_coupon_data.tsv)
灌进 dev 库(默认 `./data/app.db`),免得每次都实时打美团接口。
TSV 说明:
- 制表符分隔,每行一条记录,列顺序与线上 PostgreSQL 物理列一致
(image_size / image_type 是后加的迁移,排在最后两列 —— 与本地 SQLite 一致)。
- 空字段 = NULL(文件里没有 `\\N` 标记)。
- 个别记录的文本/JSON 字段内含换行,会把一条逻辑行拆成多物理行 —— 按“累计到 26 列”重组。
- 文件尾部可能有一条被导出截断的残行(列数不足 / raw JSON 不完整),直接跳过。
datetime 三列(first_seen/last_seen/updated_at)去掉尾部时区偏移(`+08`),
存成 SQLAlchemy 在 SQLite 上用的朴素格式 `YYYY-MM-DD HH:MM:SS.ffffff`,保证 ORM 能读回。
用法:
python scripts/load_meituan_coupon_tsv.py # 默认 TSV + .env 里的库
python scripts/load_meituan_coupon_tsv.py path/to.tsv # 指定 TSV
DATABASE_URL=sqlite:///./data/app.db python scripts/load_meituan_coupon_tsv.py
"""
from __future__ import annotations
import json
import os
import re
import sqlite3
import sys
from pathlib import Path
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
# 列顺序 = TSV 字段顺序 = 本地 SQLite 物理列顺序
COLS = [
"id", "source", "platform", "biz_line", "city_id", "product_view_sign",
"sku_view_id", "name", "brand_name", "sell_price_cents", "original_price_cents",
"head_url", "sale_volume", "sale_volume_num", "commission_percent",
"commission_amount_cents", "poi_name", "available_poi_num", "delivery_distance_m",
"dedup_key", "raw", "first_seen", "last_seen", "updated_at", "image_size", "image_type",
]
NCOL = len(COLS)
# 按列做类型转换(空串 -> None)。未列出的列 = 原样字符串(source/city_id/... 等 NOT NULL 文本)。
_INT_COLS = {0, 2, 3, 9, 10, 13, 15, 17, 24} # id, platform, biz_line, prices, ...
_FLOAT_COLS = {14, 18} # commission_percent, delivery_distance_m
_NULLABLE_STR_COLS = {6, 7, 8, 11, 12, 16, 25} # sku_view_id, name, brand_name, ...
_DT_COLS = {21, 22, 23} # first_seen, last_seen, updated_at
_RAW_COL = 20
_TZ_SUFFIX = re.compile(r"[+-]\d{2}(:?\d{2})?$") # 尾部时区偏移 +08 / +08:00 / +0800
def _resolve_sqlite_path() -> Path:
"""从 DATABASE_URL(env 或 .env)解析出 SQLite 文件路径。只支持 sqlite://。"""
url = os.environ.get("DATABASE_URL", "")
if not url:
env = _PROJECT_ROOT / ".env"
if env.exists():
for line in env.read_text(encoding="utf-8").splitlines():
if line.strip().startswith("DATABASE_URL="):
url = line.split("=", 1)[1].strip()
break
if not url:
url = "sqlite:///./data/app.db"
if not url.startswith("sqlite:"):
sys.exit(f"仅支持 sqlite:// 库,当前 DATABASE_URL={url!r}")
rest = url.split("sqlite:///", 1)[1] if "sqlite:///" in url else url.split("sqlite://", 1)[1]
p = Path(rest)
if not p.is_absolute():
p = (_PROJECT_ROOT / rest).resolve()
return p
def _reconstruct_rows(text: str) -> tuple[list[list[str]], int]:
"""把文件文本重组成一条条 26 列的逻辑行。返回 (rows, skipped)。
单个字段内含换行 -> 一条逻辑行被拆成多物理行:累计字段,拆点用 \\n 重新拼回,
直到凑满 26 列。列数溢出(内嵌 TAB / 错位)或文件尾残行 -> 跳过并计数。
"""
lines = text.split("\n")
while lines and lines[-1] == "":
lines.pop()
rows: list[list[str]] = []
skipped = 0
buf: list[str] = []
for raw_line in lines:
parts = raw_line.split("\t")
if not buf:
buf = parts
else:
buf[-1] += "\n" + parts[0] # 拼回被换行拆开的字段
buf.extend(parts[1:])
if len(buf) == NCOL:
rows.append(buf)
buf = []
elif len(buf) > NCOL: # 溢出:数据异常,丢弃这段重新开始
print(f" [skip] 列数溢出({len(buf)}>{NCOL}),field0={buf[0][:20]!r}")
skipped += 1
buf = []
if buf: # 文件尾被截断的残行
print(f" [skip] 尾部残行不足 {NCOL} 列(实 {len(buf)} 列),field0={buf[0][:20]!r}")
skipped += 1
return rows, skipped
def _convert(row: list[str]) -> tuple | None:
"""按列类型转换一行;非法(必填 int 为空 / raw 非 JSON)返回 None。"""
out: list = []
for i, v in enumerate(row):
if i in _DT_COLS:
out.append(_TZ_SUFFIX.sub("", v))
continue
if i == _RAW_COL:
try:
json.loads(v)
except Exception as e:
print(f" [skip] id={row[0]} raw 非法 JSON: {e}")
return None
out.append(v)
continue
if i in _INT_COLS:
out.append(int(v) if v != "" else None)
elif i in _FLOAT_COLS:
out.append(float(v) if v != "" else None)
elif i in _NULLABLE_STR_COLS:
out.append(v if v != "" else None)
else: # 必填文本列,原样
out.append(v)
return tuple(out)
def main() -> None:
tsv = Path(sys.argv[1]) if len(sys.argv) > 1 else _PROJECT_ROOT / "tests" / "meituan_coupon_data.tsv"
if not tsv.is_absolute():
tsv = (_PROJECT_ROOT / tsv).resolve()
db = _resolve_sqlite_path()
print(f"TSV: {tsv}")
print(f"DB : {db}")
if not tsv.exists():
sys.exit(f"TSV 不存在: {tsv}")
if not db.exists():
sys.exit(f"SQLite 库不存在: {db}(先跑 alembic upgrade head 建表)")
rows, skipped = _reconstruct_rows(tsv.read_text(encoding="utf-8"))
print(f"重组逻辑行: {len(rows)} 跳过(残/异常): {skipped}")
records = []
bad = 0
for r in rows:
rec = _convert(r)
if rec is None:
bad += 1
continue
records.append(rec)
print(f"可入库: {len(records)} 转换失败: {bad}")
placeholders = ",".join(["?"] * NCOL)
sql = f"INSERT OR REPLACE INTO meituan_coupon ({','.join(COLS)}) VALUES ({placeholders})"
con = sqlite3.connect(str(db))
try:
before = con.execute("SELECT count(*) FROM meituan_coupon").fetchone()[0]
con.executemany(sql, records)
con.commit()
after = con.execute("SELECT count(*) FROM meituan_coupon").fetchone()[0]
finally:
con.close()
print(f"入库前 {before} 行 -> 入库后 {after} 行(本次 {len(records)} 条)")
if __name__ == "__main__":
main()
+189
View File
@@ -0,0 +1,189 @@
"""给指定用户灌一批 mock 比价记录(开发 / 真机走查用)。
为什么:首页「上次比价」横幅(4 分钟新鲜窗口)、比价记录页、「我的」省钱战绩卡都读
`comparison_record`。dev 库里这个用户没数据时这些 UI 是空的,本脚本灌一批真实感的外卖
比价记录,方便端上走查。
用法(项目根、已 pip install -e . 的环境):
python scripts/mock_compare_records.py # 默认 user 昵称后缀 DlSKoc5S7, 10 条
python scripts/mock_compare_records.py --user DlSKoc5S7 # 按 昵称后缀/用户名/邀请码/手机号
python scripts/mock_compare_records.py --user 5 # 直接按 user.id
python scripts/mock_compare_records.py --user 5 --count 6
要点:
- 幂等:trace_id 固定为 mock-<标识>-NN,重跑覆盖同号记录(不会越灌越多)。
- 第 1 条 created_at = 跑脚本当下(4 分钟内),其余铺在近 7 天;重跑会刷新时间戳
(所以重跑一次即可让「上次比价」横幅重新进入 4 分钟窗口)。
- best_* / saved / status 由真实的 repositories.comparison.upsert_record 从 comparison_results 派生。
"""
from __future__ import annotations
import argparse
from datetime import datetime, timedelta
from sqlalchemy import or_, select
from app.core.rewards import CN_TZ
from app.db.session import SessionLocal
from app.models.user import User
from app.repositories.comparison import upsert_record
from app.schemas.compare_record import ComparisonRecordIn
# 平台名 → id(展示用;横幅按 platform_name 渲染,id 仅留痕)
_PLATFORM_ID = {"美团": "meituan", "京东": "jd", "淘宝闪购": "taobao", "饿了么": "ele"}
# 10 单真实感外卖订单:source=美团,targets=(平台, 到手价/元, 优惠来源名|None, 红包额/元)。
# failed=True 表示目标平台没采到价(targets 价置 None → 派生 status=failed)。
_CATALOG = [
dict(store="瑞幸咖啡(国贸店)", items=[("生椰拿铁(大杯)", 2)],
source=("美团", 39.8),
targets=[("淘宝闪购", 29.9, "平台红包", 6.0), ("京东", 33.0, None, 0)]),
dict(store="茶百道(大悦城店)", items=[("杨梅冰萃(大杯)", 1), ("脆波波奶茶", 1)],
source=("美团", 31.0),
targets=[("京东", 24.5, "百亿补贴", 4.5), ("饿了么", 27.9, None, 0)]),
dict(store="麦当劳(西单店)", items=[("板烧鸡腿堡套餐", 1)],
source=("美团", 32.0),
targets=[("京东", 25.9, "神券", 3.0), ("淘宝闪购", 28.0, None, 0)]),
dict(store="肯德基(朝阳大悦城店)", items=[("疯狂星期四全家桶", 1)],
source=("美团", 89.0),
targets=[("京东", 79.9, "百亿补贴", 9.0)]),
dict(store="张亮麻辣烫(双井店)", items=[("自选麻辣烫", 1)],
source=("美团", 35.0),
targets=[("京东", None, None, 0), ("淘宝闪购", None, None, 0)],
failed=True, fail_reason="京东外卖、淘宝闪购均未找到该店"),
dict(store="必胜客(王府井店)", items=[("超级至尊比萨(9寸)", 1), ("香辣鸡翅", 1)],
source=("美团", 78.0),
targets=[("淘宝闪购", 62.0, "跨店满减", 10.0), ("京东", 69.0, None, 0)]),
dict(store="蜜雪冰城(中关村店)", items=[("多肉葡萄", 2), ("冰鲜柠檬水", 1)],
source=("美团", 21.0),
targets=[("京东", 16.5, None, 0)]),
dict(store="海底捞外送(三里屯店)", items=[("番茄锅底", 1), ("鲜毛肚", 2), ("虾滑", 1)],
source=("美团", 168.0),
targets=[("京东", 155.0, "大额神券", 13.0), ("饿了么", 162.0, None, 0)]),
dict(store="华莱士(回龙观店)", items=[("全鸡汉堡套餐", 1)],
source=("美团", 26.0),
targets=[("京东", None, None, 0)],
failed=True, fail_reason="该商品在京东外卖未上架"),
dict(store="星巴克(国贸店)", items=[("燕麦拿铁(大杯)", 2)],
source=("美团", 66.0),
targets=[("饿了么", 58.0, "会员券", 8.0), ("京东", 61.0, None, 0)]),
]
# 第 2..N 条的 created_at 相对当下的回退量(都 > 4 分钟,确保只有第 1 条落在新鲜窗口)。
_OFFSETS = [
timedelta(minutes=25), timedelta(hours=2, minutes=10), timedelta(hours=6),
timedelta(days=1, hours=3), timedelta(days=1, hours=20), timedelta(days=2, hours=9),
timedelta(days=3, hours=14), timedelta(days=5, hours=7), timedelta(days=7, hours=2),
]
def _resolve_user(db, ident: str) -> User | None:
"""按 id(纯数字)/ 用户名 / 邀请码 / 手机号 / 昵称(或昵称去掉「用户」前缀的后缀)解析用户。"""
if ident.isdigit():
u = db.get(User, int(ident))
if u is not None:
return u
return db.execute(
select(User).where(
or_(
User.username == ident,
User.invite_code == ident,
User.phone == ident,
User.nickname == ident,
User.nickname == f"用户{ident}",
User.nickname.like(f"%{ident}"),
)
)
).scalars().first()
def _build_results(order: dict) -> tuple[list[dict], str]:
"""造 comparison_results + information 文案;返回 (results, information)。"""
src_name, src_price = order["source"]
failed = order.get("failed", False)
rows: list[dict] = [dict(
platform_id=_PLATFORM_ID.get(src_name), platform_name=src_name,
price=src_price, is_source=True, status="success",
)]
for pname, price, cname, csaved in order["targets"]:
row = dict(
platform_id=_PLATFORM_ID.get(pname), platform_name=pname,
price=(None if failed else price), is_source=False,
status=("store_not_found" if failed else "success"),
)
if not failed and csaved:
row["coupon_saved"] = csaved
row["coupon_name"] = cname
rows.append(row)
# rank:有价的按升序 1..N;无价的不排
priced = sorted((r for r in rows if r["price"] is not None), key=lambda r: r["price"])
for i, r in enumerate(priced, start=1):
r["rank"] = i
if failed:
return rows, order.get("fail_reason", "目标平台未找到该商品")
best = priced[0]
saved = src_price - best["price"]
if best["is_source"]:
info = f"{src_name}已是最低价,本单未比出更低"
else:
info = f"已在{best['platform_name']}比出最低价 ¥{best['price']:.2f}(比{src_name}省 ¥{saved:.2f})"
return rows, info
def main() -> None:
parser = argparse.ArgumentParser(description="给用户灌一批 mock 比价记录")
parser.add_argument("--user", default="DlSKoc5S7", help="user.id / 用户名 / 邀请码 / 手机号 / 昵称后缀")
parser.add_argument("--count", type=int, default=10, help="条数(1..10),默认 10")
args = parser.parse_args()
count = max(1, min(args.count, len(_CATALOG)))
now = datetime.now(CN_TZ).replace(tzinfo=None)
db = SessionLocal()
try:
user = _resolve_user(db, args.user)
if user is None:
raise SystemExit(f"❌ 找不到用户:{args.user}(试试 --user <id>)")
print(f"→ 用户 id={user.id} nickname={user.nickname} phone={user.phone}")
ok = failed = 0
for idx in range(count):
order = _CATALOG[idx]
src_name, src_price = order["source"]
results, info = _build_results(order)
payload = ComparisonRecordIn(
trace_id=f"mock-{args.user}-{idx:02d}",
business_type="food",
device_id="mock-seed",
store_name=order["store"],
source_platform_id=_PLATFORM_ID.get(src_name),
source_platform_name=src_name,
source_price=src_price,
items=[{"name": n, "qty": q} for n, q in order["items"]],
comparison_results=results,
total_dish_count=sum(q for _, q in order["items"]),
information=info,
app_version="mock",
)
rec = upsert_record(db, user_id=user.id, payload=payload)
# created_at:第 1 条 = 当下(4 分钟新鲜窗口);其余铺近 7 天。重跑刷新时间戳。
rec.created_at = now if idx == 0 else now - _OFFSETS[idx - 1]
db.commit()
if rec.status == "success":
ok += 1
else:
failed += 1
tag = "🆕now" if idx == 0 else f"-{_OFFSETS[idx - 1]}"
print(f" [{rec.status:7}] id={rec.id} {order['store']} best={rec.best_platform_name} "
f"¥{(rec.best_price_cents or 0)/100:.2f} saved=¥{(rec.saved_amount_cents or 0)/100:.2f} {tag}")
print(f"✅ 已为 user id={user.id} 写入 {count} 条(success {ok} / failed {failed});"
f"第 1 条 created_at=当下,4 分钟内进 app 可见「上次比价」横幅。")
finally:
db.close()
if __name__ == "__main__":
main()
+166
View File
@@ -0,0 +1,166 @@
"""收益明细「金币记录」文案验证脚手架(2026-07 文案改版验收用)。
问题:客户端按 bizType 显示固定文案(路线B,强制覆盖后端 remark),但账号若没有对应
bizType 的流水,收益明细页就空着,无从验证。本脚本往指定测试用户塞每种 bizType 各一条
流水,让你在手机收益明细页一屏核对全部新文案;验收完 --clean 一键删除,不污染数据。
⚠️ 仅 dev 库用(APP_ENV=dev 时才允许 --seed/--clean)。所有测试流水 ref_id 前缀 TESTDOC,
按前缀精确清理,不会误删真实流水。
用法(pricebot env 直调,见项目 CLAUDE.md):
D:/miniconda/envs/pricebot/python.exe scripts/seed_coinhistory_labels_test.py --show
D:/miniconda/envs/pricebot/python.exe scripts/seed_coinhistory_labels_test.py --seed
D:/miniconda/envs/pricebot/python.exe scripts/seed_coinhistory_labels_test.py --clean
"""
from __future__ import annotations
import argparse
import sys
from app.core.config import settings
from app.core.rewards import CN_TZ
from datetime import datetime
from app.db.session import SessionLocal
from app.models.user import User
from app.models.wallet import CoinAccount, CoinTransaction
TEST_PHONE = "11111111111"
REF_PREFIX = "TESTDOC" # 所有本脚本造的流水都带这个 ref_id 前缀,便于精确清理
# 每条 = (bizType, 故意写错/留空的 remark, 金币数)。
# remark 故意填「错的」→ 若客户端仍显示新文案 = 证明路线B强制覆盖生效(无视后端 remark)。
# task_ 一条留空 remark → 走客户端兜底映射。顺序即手机上从新到旧的展示顺序(后塞的在最上)。
CASES: list[tuple[str, str, int]] = [
("signin", "每日签到 第99天(旧文案,应被覆盖)", 220),
("signin_boost", "签到膨胀 第99天", 3000),
("reward_video", "看视频奖励金币(旧文案,应被覆盖)", 200),
("feed_ad_reward_comparison", "", 50), # 后端新拆:比价场景 → 比价奖励
("feed_ad_reward_coupon", "", 50), # 后端新拆:领券场景 → 领券奖励
("feed_ad_reward", "信息流广告奖励(welfare/旧数据兜底)", 50),
("price_report_reward", "上报更低价审核通过(旧文案,应被覆盖)", 1000),
("feedback_reward", "意见反馈被采纳(旧文案,应被覆盖)", 10000),
("task_enable_notification", "", 750), # remark 留空 → 客户端「打开消息提醒奖励」
# 下两条现实中不会进金币记录(invite 发现金进邀请钱包 / compare_milestone 后端死代码不发钱),
# 仅用于验证「杀掉好友比价奖励 + 兜底改任务奖励」:两条都应显示「任务奖励」(remark 被强制无视)。
("invite", "好友比价奖励(旧文案,应被杀→任务奖励)", 200),
("compare_milestone", "", 120),
]
def _client_coin_title(biz_type: str, remark: str | None) -> str:
"""复刻 CoinHistoryViewModel.coinTitle 的最新逻辑(路线B),用于 --show 预览。
⚠️ 必须与客户端保持一致;客户端改了这里也要同步,否则预览会骗人。
"""
fixed = {
"exchange_out": "金币兑换现金",
"signin": "每日签到奖励",
"signin_boost": "签到膨胀奖励",
"reward_video": "看视频赚金币",
"ad_reward": "看视频赚金币",
"feed_ad_reward_comparison": "比价奖励",
"feed_ad_reward_coupon": "领券奖励",
"feed_ad_reward": "信息流广告奖励",
"price_report_reward": "爆料奖励",
"feedback_reward": "反馈奖励",
"invite": "任务奖励", # 杀掉"好友比价奖励",归兜底
}
if biz_type in fixed:
return fixed[biz_type]
if remark:
return remark
if biz_type == "task_enable_notification":
return "打开消息提醒奖励"
return "任务奖励"
def _get_user(db) -> User:
u = db.query(User).filter(User.phone == TEST_PHONE).first()
if not u:
print(f"✗ 库里没有测试号 {TEST_PHONE} —— 先在手机上用这个号登录一次再跑本脚本。")
sys.exit(1)
return u
def cmd_show() -> None:
"""只打印:每种 bizType 经客户端映射后会显示成什么(不写库)。"""
print("bizType 造流水后,收益明细页预期显示的文案:\n")
print(f" {'bizType':32} {'后端remark(故意填的)':32} → 手机显示")
print(" " + "-" * 90)
for biz, remark, _coin in CASES:
shown = _client_coin_title(biz, remark or None)
rk = (remark or "(空)")
print(f" {biz:32} {rk:32}{shown}")
print("\n注:remark 列是故意填的『旧/错』文案;'手机显示'若为新文案 = 强制覆盖生效。")
print("invite / compare_milestone 已从客户端映射删除:invite 有 remark 故显示原样,")
print("compare_milestone remark 空故落兜底『奖励』—— 两者都不再有专属新文案(符合『去掉』)。")
def cmd_seed() -> None:
if settings.APP_ENV != "dev":
print(f"✗ 拒绝:APP_ENV={settings.APP_ENV},本脚本只在 dev 库造测试数据。")
sys.exit(1)
db = SessionLocal()
u = _get_user(db)
acc = db.query(CoinAccount).filter(CoinAccount.user_id == u.id).first()
if acc is None:
acc = CoinAccount(user_id=u.id, coin_balance=0, cash_balance_cents=0)
db.add(acc)
db.flush()
now = datetime.now(CN_TZ).replace(tzinfo=None)
made = 0
for i, (biz, remark, coin) in enumerate(CASES):
ref = f"{REF_PREFIX}:{biz}:{i}"
exists = db.query(CoinTransaction).filter(CoinTransaction.ref_id == ref).first()
if exists:
continue
acc.coin_balance += coin
db.add(CoinTransaction(
user_id=u.id, amount=coin, balance_after=acc.coin_balance,
biz_type=biz, ref_id=ref, remark=remark or None, created_at=now,
))
made += 1
db.commit()
print(f"✓ 已给 user_id={u.id}({TEST_PHONE})造 {made} 条测试流水,当前金币余额 {acc.coin_balance}")
print(" → 打开手机 App「收益明细 / 金币记录」下拉刷新,逐条核对文案。")
print(" → 验收完跑 --clean 删除这些测试流水。")
def cmd_clean() -> None:
if settings.APP_ENV != "dev":
print(f"✗ 拒绝:APP_ENV={settings.APP_ENV}")
sys.exit(1)
db = SessionLocal()
u = _get_user(db)
rows = db.query(CoinTransaction).filter(
CoinTransaction.user_id == u.id,
CoinTransaction.ref_id.like(f"{REF_PREFIX}:%"),
).all()
total = sum(r.amount for r in rows)
for r in rows:
db.delete(r)
acc = db.query(CoinAccount).filter(CoinAccount.user_id == u.id).first()
if acc is not None:
acc.coin_balance -= total # 把造流水时加的余额扣回,还原
db.commit()
print(f"✓ 已删除 {len(rows)} 条 TESTDOC 测试流水,余额回扣 {total},当前 {acc.coin_balance if acc else 0}")
def main() -> None:
ap = argparse.ArgumentParser(description="收益明细金币文案验证脚手架")
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--show", action="store_true", help="只打印每种 bizType 的预期显示文案,不写库")
g.add_argument("--seed", action="store_true", help="往测试号造每种 bizType 各一条流水")
g.add_argument("--clean", action="store_true", help="删除本脚本造的所有测试流水")
args = ap.parse_args()
if args.show:
cmd_show()
elif args.seed:
cmd_seed()
elif args.clean:
cmd_clean()
if __name__ == "__main__":
main()
+139
View File
@@ -0,0 +1,139 @@
"""人工验证用:按「金币/现金/邀请」排列组合 + 活跃/新用户对照,造一批账号。
用法(仓库根目录,venv 解释器):
.venv/Scripts/python.exe scripts/seed_inactivity_cases.py # 造号(会先清掉上次 vcase*)
.venv/Scripts/python.exe scripts/seed_inactivity_cases.py --clean # 只清理,不造
配合默认配置 INACTIVITY_RESET_DAYS=15 / INACTIVITY_WARN_DAYS_BEFORE=7,2 验证。
造完把 worker 打开(见 README/对话里的 .env),启动服务即会在 RUN_HOUR 后跑一轮。
⚠️ worker 清零针对**库里所有**符合条件的用户,不止 vcase*——dev 库里若有其它"老且有余额、
无近期活跃事件"的用户,也会被一起清。要干净验证建议用一个空/副本 dev 库。
"""
from __future__ import annotations
import os
import sys
from datetime import UTC, datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import delete, select # noqa: E402
from app.db.session import SessionLocal # noqa: E402
from app.models.analytics_event import AnalyticsEvent # noqa: E402
from app.models.inactivity import ( # noqa: E402
InactivityNotificationLog,
InactivityResetLog,
)
from app.models.user import User # noqa: E402
from app.models.wallet import ( # noqa: E402
CashTransaction,
CoinAccount,
CoinTransaction,
InviteCashTransaction,
)
from app.repositories import wallet as wallet_repo # noqa: E402
MARK = "vcase" # username 前缀,用于清理
# label, 创建于N天前, coin, cash, invite, 近期事件(N天前)or None, 预期
CASES = [
("1 三桶全有", 30, 100, 200, 300, None, "清 coin+cash;invite=300 保留;审计1行+2流水"),
("2 金币+现金", 30, 100, 200, 0, None, "清 coin+cash;审计1行+2流水"),
("3 金币+邀请", 30, 100, 0, 300, None, "清 coin;invite=300 保留;审计1行+1流水"),
("4 现金+邀请", 30, 0, 200, 300, None, "清 cash;invite=300 保留;审计1行+1流水"),
("5 只有金币", 30, 100, 0, 0, None, "清 coin;审计1行+1流水"),
("6 只有现金", 30, 0, 200, 0, None, "清 cash;审计1行+1流水"),
("7 只有邀请(红线)", 30, 0, 0, 300, None, "不选中/不清/无审计/无流水;invite=300 原封"),
("8 预警窗(10天)", 10, 50, 60, 70, None, "不清;发 T-7 预警;notification_log 1行;余额不动"),
("9 活跃兜底", 30, 100, 200, 300, 1, "昨日 home_view→last_active 近→不清不警"),
("10 新用户(3天)", 3, 100, 200, 0, None, "created_at 近→不清不警"),
]
def _mark_uids(db) -> list[int]:
return list(db.execute(select(User.id).where(User.username.like(f"{MARK}%"))).scalars())
def clean(db) -> int:
uids = _mark_uids(db)
if uids:
for model in (
InactivityResetLog, InactivityNotificationLog,
CoinTransaction, CashTransaction, InviteCashTransaction,
AnalyticsEvent, CoinAccount,
):
db.execute(delete(model).where(model.user_id.in_(uids)))
db.execute(delete(User).where(User.id.in_(uids)))
db.commit()
return len(uids)
def seed(db) -> None:
now = datetime.now(UTC)
print(f"{'#':>3} {'uid':>5} {'案例':<16} {'coin/cash/invite':<18} {'创建':<7} 预期")
for i, (label, days_ago, coin, cash, invite, ev_days, expected) in enumerate(CASES, 1):
u = User(
phone=f"seed_tmp_{i}", username=f"{MARK}{i}", status="active",
created_at=now - timedelta(days=days_ago),
last_login_at=now, # 登录很新——但登录不算活跃,清零该发生照发生
)
db.add(u)
db.flush() # 拿自增 id
u.phone = f"1{u.id:010d}" # 用全局唯一 id 拼 "100…" 段手机号,dev 库里绝不撞
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
if ev_days is not None:
db.add(AnalyticsEvent( # 首页可见 = event=show + page=home
event="show", page="home", device_id=MARK, user_id=u.id,
client_ts=0, created_at=now - timedelta(days=ev_days),
))
db.flush()
print(f"{i:>3} {u.id:>5} {label:<16} {f'{coin}/{cash}/{invite}':<18} {f'{days_ago}天前':<7} {expected}")
db.commit()
def check(db) -> None:
"""worker 跑完后:打印每个 vcase 账号的当前三桶余额 + 是否有审计/预警行。"""
rows = db.execute(
select(User.id, User.username).where(User.username.like(f"{MARK}%")).order_by(User.id)
).all()
if not rows:
print("没有 vcase* 账号(先跑一次不带参数造号)")
return
print(f"{'uid':>5} {'账号':<8} {'coin/cash/invite(现在)':<24} {'审计':<5} 预警")
for uid, uname in rows:
acc = db.get(CoinAccount, uid)
bal = f"{acc.coin_balance}/{acc.cash_balance_cents}/{acc.invite_cash_balance_cents}" if acc else ""
has_reset = db.execute(
select(InactivityResetLog.id).where(InactivityResetLog.user_id == uid).limit(1)
).first()
stages = db.execute(
select(InactivityNotificationLog.stage).where(InactivityNotificationLog.user_id == uid)
).scalars().all()
warn = ",".join(f"T-{s}" for s in stages) if stages else ""
print(f"{uid:>5} {uname:<8} {bal:<24} {'' if has_reset else '':<5} {warn}")
def main() -> None:
db = SessionLocal()
try:
if "--check" in sys.argv:
check(db)
return
removed = clean(db)
if removed:
print(f"已清理上次 {removed}{MARK}* 账号")
if "--clean" in sys.argv:
return
seed(db)
print("\n造号完成。打开 worker(INACTIVITY_RESET_ENABLED=true, RUN_HOUR=17)后启动服务,"
"≥17:00 首个 tick 即跑一轮。验完 `--clean` 清理。")
finally:
db.close()
if __name__ == "__main__":
main()
+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「比价记录」→ 找 trace「MOCKLLM-」的 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()