Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2cdbcffff | |||
| dc63632e77 |
+2
-5
@@ -6,11 +6,8 @@ APP_NAME=shaguabijia-app-server
|
||||
APP_DEBUG=true
|
||||
|
||||
# ===== 数据库 =====
|
||||
# 本地开发/测试统一用 Docker PostgreSQL:run.bat/run.sh 会自动拉起容器
|
||||
# (docker-compose.yml + scripts/ensure_pg.py)。详见 docs/database/postgres-migration.md。
|
||||
# 生产用原生 PG,由 scripts/init_postgres.py 写入强随机密码的连接串。
|
||||
# ⚠️ scheme 必须是 postgresql+psycopg://(psycopg3);不要写成 postgresql://(会去找未装的 psycopg2)。
|
||||
DATABASE_URL=postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia
|
||||
# SQLite 本地文件路径。生产环境用 /opt/shaguabijia-app-server/data.db
|
||||
DATABASE_URL=sqlite:///./data/app.db
|
||||
|
||||
# ===== JWT =====
|
||||
# 生产部署务必改成随机长字符串,可用:python -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||
|
||||
@@ -57,6 +57,3 @@ tests/meituan_coupon_bj.tsv
|
||||
tests/meituan_coupon_data.tsv
|
||||
tests/meituan_coupon_fz.tsv
|
||||
tests/meituan_coupon_xm.tsv
|
||||
|
||||
# git worktrees (superpowers 隔离工作区)
|
||||
.worktrees/
|
||||
|
||||
@@ -76,8 +76,8 @@ Endpoints under `app/api/internal/` are for server-to-server communication (pric
|
||||
|
||||
## Database
|
||||
|
||||
- **Dev/Test**: Docker PostgreSQL 16 — `run.sh`/`run.bat` auto-start it via `scripts/ensure_pg.py` + `docker-compose.yml`; `.env.example` ships the PG URL by default; pytest uses the same container's `shaguabijia_test` DB. **Local no longer uses SQLite** (the SQLite branch in `db/session.py` is retained as a fallback only).
|
||||
- **Prod**: native PostgreSQL — bootstrap with `scripts/init_postgres.py` (no Docker). Pool size 10 + max overflow 20, pool_recycle 3600.
|
||||
- **Dev**: SQLite (`sqlite:///./data/app.db`), `check_same_thread=False`, no connection pool.
|
||||
- **Prod**: PostgreSQL — just change `DATABASE_URL` in `.env`. Pool size 10 + max overflow 20, pool_recycle 3600.
|
||||
- **Migrations**: Alembic with `render_as_batch` for SQLite compatibility. ~60+ migration files in `alembic/versions/` (filenames are descriptive, not hex prefixes). Migration chain uses `down_revision` within each file.
|
||||
- **New models**: Define in `app/models/`, import in `app/models/__init__.py`, then run `alembic revision --autogenerate`.
|
||||
|
||||
@@ -89,7 +89,7 @@ All config via `pydantic-settings` in `app/core/config.py`. Single `Settings` cl
|
||||
|
||||
## Testing
|
||||
|
||||
- `tests/conftest.py`: Sets env vars BEFORE imports, ensures the Docker PG `shaguabijia_test` DB via `scripts/ensure_pg.py`, builds all tables with `Base.metadata.create_all()` (drop+create for a clean start), tears down with `drop_all()`.
|
||||
- `tests/conftest.py`: Sets env vars BEFORE imports, creates temp SQLite file, builds all tables with `Base.metadata.create_all()`, tears down with `drop_all()` + unlink.
|
||||
- External integrations are monkeypatched in tests (e.g., WeChat Pay, Jiguang, Pangle callbacks) — tests never make real HTTP calls.
|
||||
- `TestClient` from FastAPI is used for all tests. Rate limiting is disabled globally in tests.
|
||||
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
"""admin_role 表(RBAC 角色 → 可见页面)+ 种子内建角色
|
||||
|
||||
新增后台 RBAC:角色持有一组页面 key,登录后左侧只展示这些页。super_admin 内建全权(pages 存空、
|
||||
effective_pages 特判为全部)、不可编辑删除;operator/finance 播默认页集,可由 super_admin 增删改。
|
||||
admin_user.role 弱引用本表 name。全新环境顺序应用即得三条内建角色。
|
||||
|
||||
Revision ID: admin_role_table
|
||||
Revises: comparison_product_names
|
||||
Create Date: 2026-07-04 00:00:00.000000
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "admin_role_table"
|
||||
down_revision: str | Sequence[str] | None = "comparison_product_names"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
|
||||
|
||||
# 与 app/admin/permissions.py 的 BUILTIN_ROLES 同口径(迁移内联一份,不耦合 app 常量)。
|
||||
# 页集对齐 Prototypes/dashboard/permissions.md 的 ROLES。key 承重(require_role),label 为展示名。
|
||||
_BUILTIN = [
|
||||
{"name": "super_admin", "label": "管理员", "pages": [], "is_builtin": True},
|
||||
{"name": "operator", "label": "运营", "pages": [
|
||||
"dashboard", "coupon-data", "ad-revenue-report", "comparison-records",
|
||||
"cps", "device-liveness", "price-reports", "feedbacks",
|
||||
], "is_builtin": False},
|
||||
{"name": "finance", "label": "财务", "pages": [
|
||||
"dashboard", "ad-revenue-report", "cps", "withdraws",
|
||||
], "is_builtin": False},
|
||||
{"name": "tech", "label": "技术", "pages": [
|
||||
"dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs",
|
||||
], "is_builtin": False},
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"admin_role",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("name", sa.String(length=32), nullable=False),
|
||||
sa.Column("label", sa.String(length=32), nullable=False, server_default=""),
|
||||
sa.Column("pages", _JSON, nullable=False),
|
||||
sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(), nullable=False,
|
||||
),
|
||||
)
|
||||
op.create_index("ix_admin_role_name", "admin_role", ["name"], unique=True)
|
||||
|
||||
tbl = sa.table(
|
||||
"admin_role",
|
||||
sa.column("name", sa.String),
|
||||
sa.column("label", sa.String),
|
||||
sa.column("pages", _JSON),
|
||||
sa.column("is_builtin", sa.Boolean),
|
||||
)
|
||||
op.bulk_insert(tbl, _BUILTIN)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_admin_role_name", table_name="admin_role")
|
||||
op.drop_table("admin_role")
|
||||
@@ -1,29 +0,0 @@
|
||||
"""admin_user 加 plain_password 列(明文登录密码,仅后台 UI 建/重置的账号留存)
|
||||
|
||||
权限管理页需能复看某成员已确定的登录密码转交本人。系统只存哈希无法反推,故对「后台 UI 创建/重置」
|
||||
的管理员额外留存一份明文;脚本/起后台建的超管为 None(前端不显示密码)。旧账号无此列值 → 同样不显示。
|
||||
|
||||
Revision ID: admin_user_plain_password
|
||||
Revises: admin_role_table
|
||||
Create Date: 2026-07-04 00:00:00.000000
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "admin_user_plain_password"
|
||||
down_revision: str | Sequence[str] | None = "admin_role_table"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("admin_user", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("plain_password", sa.String(length=128), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("admin_user", schema=None) as batch_op:
|
||||
batch_op.drop_column("plain_password")
|
||||
@@ -1,70 +0,0 @@
|
||||
"""comparison_record 加 product_names 列(下单商品名派生串)+ 回填历史行
|
||||
|
||||
admin 比价记录页要把原「店/商品」一列拆成「店」+「商品」两列、并支持按商品名搜索。
|
||||
items 是 JSON(SQLite 下 json.dumps ensure_ascii 会把中文转义存成 \\uXXXX,无法直接 CAST+LIKE
|
||||
命中中文),故把商品名派生成普通文本列,跨库 LIKE 一致、可索引。
|
||||
|
||||
写路径(upsert_record / harvest_done)已同步派生;本迁移建列并从已存 items 回填历史行。
|
||||
全新环境顺序应用即得空列 + 回填(表本为空,回填 no-op)。
|
||||
|
||||
Revision ID: comparison_product_names
|
||||
Revises: comparison_record_trace_unique
|
||||
Create Date: 2026-07-04 00:00:00.000000
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "comparison_product_names"
|
||||
down_revision: str | Sequence[str] | None = "comparison_record_trace_unique"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _product_names(items) -> str | None:
|
||||
"""items([{name,...}]) → 顿号分隔的商品名串(去重保序)。与 repositories.comparison
|
||||
._product_names_from_items 同口径,迁移内联一份避免耦合 app 代码。"""
|
||||
if isinstance(items, str): # SQLite: items 以 JSON 文本存,取回是 str
|
||||
try:
|
||||
items = json.loads(items)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(items, list) or not items:
|
||||
return None
|
||||
names: list[str] = []
|
||||
for it in items:
|
||||
name = it.get("name") if isinstance(it, dict) else None
|
||||
if not name:
|
||||
continue
|
||||
s = str(name).strip()
|
||||
if s and s not in names:
|
||||
names.append(s)
|
||||
joined = "、".join(names)
|
||||
return joined[:500] or None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("comparison_record", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("product_names", sa.String(length=512), nullable=True))
|
||||
|
||||
# 回填:从已存 items 派生商品名。items 小(菜名列表),一次取回即可;只更新有商品名的行。
|
||||
bind = op.get_bind()
|
||||
rows = bind.execute(
|
||||
sa.text("SELECT id, items FROM comparison_record")
|
||||
).fetchall()
|
||||
for rid, items in rows:
|
||||
pn = _product_names(items)
|
||||
if pn:
|
||||
bind.execute(
|
||||
sa.text("UPDATE comparison_record SET product_names = :pn WHERE id = :id"),
|
||||
{"pn": pn, "id": rid},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("comparison_record", schema=None) as batch_op:
|
||||
batch_op.drop_column("product_names")
|
||||
@@ -1,49 +0,0 @@
|
||||
"""comparison_record: 唯一键 (user_id,trace_id) → trace_id 单列 + user_id 可空
|
||||
|
||||
Revision ID: comparison_record_trace_unique
|
||||
Revises: feedback_type_reply
|
||||
Create Date: 2026-07-03 12:00:00.000000
|
||||
|
||||
比价记录改「后端 harvest」:app-server 在帧0(pricebot 出 trace_id)即建行,随 done/finalize
|
||||
逐步补全,客户端不再 POST 记录。故本表要两处调整:
|
||||
1. user_id 改**可空**——harvest 建行那刻(软鉴权 / 老客户端匿名)可能还没有 user_id。
|
||||
2. 唯一键 (user_id, trace_id) → **trace_id 单列**——trace_id 由 app-server 签发、全局唯一,
|
||||
一次比价一行;harvest 建行时 user_id 还没有,不能再用复合键去重。
|
||||
|
||||
⚠️ 上线前(QA)务必确认 comparison_record 无重复 trace_id:旧复合唯一键**允许**同 trace_id
|
||||
跨不同 user_id(实际上客户端 UUID 从不重复,但约束没拦),若真有重复,建 trace 单列唯一会失败。
|
||||
查: SELECT trace_id, COUNT(*) c FROM comparison_record GROUP BY trace_id HAVING c > 1;
|
||||
|
||||
SQLite 不支持直接 drop/alter 约束/列,用 batch_alter_table(建临时表+拷数据+换名),
|
||||
与 coupon_engage_per_package / store_mapping_* 同款;Postgres 直接执行原生 ALTER。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'comparison_record_trace_unique'
|
||||
down_revision: Union[str, Sequence[str], None] = 'feedback_type_reply'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
|
||||
# harvest 帧0 建行时 user_id 可能暂缺 → 放开非空。
|
||||
batch_op.alter_column('user_id', existing_type=sa.Integer(), nullable=True)
|
||||
# 复合唯一 → trace_id 单列唯一。
|
||||
batch_op.drop_constraint('uq_comparison_user_trace', type_='unique')
|
||||
batch_op.create_unique_constraint('uq_comparison_trace', ['trace_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
|
||||
batch_op.drop_constraint('uq_comparison_trace', type_='unique')
|
||||
batch_op.create_unique_constraint(
|
||||
'uq_comparison_user_trace', ['user_id', 'trace_id']
|
||||
)
|
||||
# 回退非空前提是当时无 null user_id 行(harvest 期可能有孤儿行,回滚需先清理)。
|
||||
batch_op.alter_column('user_id', existing_type=sa.Integer(), nullable=False)
|
||||
@@ -32,7 +32,6 @@ from app.admin.routers.feedback_qr import router as feedback_qr_router
|
||||
from app.admin.routers.onboarding import router as onboarding_router
|
||||
from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router
|
||||
from app.admin.routers.price_report import router as price_report_router
|
||||
from app.admin.routers.roles import router as roles_router
|
||||
from app.admin.routers.users import router as users_router
|
||||
from app.admin.routers.wallet import router as wallet_router
|
||||
from app.admin.routers.withdraw import router as withdraw_router
|
||||
@@ -99,7 +98,6 @@ admin_app.include_router(feedback_router)
|
||||
admin_app.include_router(event_logs_router)
|
||||
admin_app.include_router(feedback_qr_router)
|
||||
admin_app.include_router(admins_router)
|
||||
admin_app.include_router(roles_router)
|
||||
admin_app.include_router(audit_router)
|
||||
admin_app.include_router(config_router)
|
||||
admin_app.include_router(comparison_router)
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
"""admin 后台「页面权限目录」—— RBAC 的权限侧单一真源。
|
||||
|
||||
一个权限 = 一个页面(= 左侧导航项)。角色持有一组 page key,登录后左侧只展示这些页(见前端
|
||||
layout.tsx 按 pages 过滤导航)。key 必须与前端路由一级对齐(/dashboard → "dashboard")。
|
||||
|
||||
super_admin 为内建全权角色,恒可见全部页(effective_pages 特判)。新增页面 = 这里加一条 +
|
||||
前端导航加对应项(key 一致即可被各角色勾选可见)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
SUPER_ADMIN_ROLE = "super_admin"
|
||||
|
||||
# 分组镜像前端导航(app/(main)/layout.tsx 的 NAV_GROUPS);key = 路由一级
|
||||
PERMISSION_CATALOG: list[dict] = [
|
||||
{"group": "看板", "pages": [
|
||||
{"key": "dashboard", "label": "数据大盘"},
|
||||
{"key": "coupon-data", "label": "领券数据"},
|
||||
{"key": "ad-revenue-report", "label": "广告收益"},
|
||||
{"key": "comparison-records", "label": "比价记录"},
|
||||
{"key": "cps", "label": "CPS收益"},
|
||||
{"key": "device-liveness", "label": "设备存活"},
|
||||
]},
|
||||
{"group": "奖励审核", "pages": [
|
||||
{"key": "withdraws", "label": "提现审核"},
|
||||
{"key": "price-reports", "label": "低价审核"},
|
||||
{"key": "feedbacks", "label": "用户反馈"},
|
||||
]},
|
||||
{"group": "数据配置", "pages": [
|
||||
{"key": "config", "label": "系统配置"},
|
||||
{"key": "ad-revenue", "label": "广告配置"},
|
||||
{"key": "users", "label": "用户管理"},
|
||||
]},
|
||||
{"group": "其他", "pages": [
|
||||
{"key": "admins", "label": "权限管理"},
|
||||
{"key": "event-logs", "label": "埋点日志"},
|
||||
{"key": "audit-logs", "label": "审计日志"},
|
||||
]},
|
||||
]
|
||||
|
||||
# 全部页面 key(super_admin 有效可见 = 此全集;也用于校验角色 pages 合法性)
|
||||
ALL_PAGE_KEYS: list[str] = [p["key"] for g in PERMISSION_CATALOG for p in g["pages"]]
|
||||
_ALL_SET = set(ALL_PAGE_KEYS)
|
||||
|
||||
# 内建角色定义(种子 / ensure 兜底的单一真源):(key, 中文展示名, 默认可见页)。
|
||||
# 页集对齐 Prototypes/dashboard/permissions.md 的 ROLES;super_admin 恒全权(pages 空、bypass)。
|
||||
# key 承重(require_role / admin_user.role),勿改;label 为 UI 展示名。
|
||||
BUILTIN_ROLES: list[dict] = [
|
||||
{"name": SUPER_ADMIN_ROLE, "label": "管理员", "pages": []},
|
||||
{"name": "operator", "label": "运营", "pages": [
|
||||
"dashboard", "coupon-data", "ad-revenue-report", "comparison-records",
|
||||
"cps", "device-liveness", "price-reports", "feedbacks",
|
||||
]},
|
||||
{"name": "finance", "label": "财务", "pages": [
|
||||
"dashboard", "ad-revenue-report", "cps", "withdraws",
|
||||
]},
|
||||
{"name": "tech", "label": "技术", "pages": [
|
||||
"dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs",
|
||||
]},
|
||||
]
|
||||
|
||||
# 内建 key → 中文展示名(展示 / 自愈用)
|
||||
BUILTIN_LABELS: dict[str, str] = {r["name"]: r["label"] for r in BUILTIN_ROLES}
|
||||
|
||||
|
||||
def sanitize_pages(pages: list[str] | None) -> list[str]:
|
||||
"""过滤掉不在目录里的 key(去重、保序),防脏数据/目录收缩后的悬空 key。"""
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for k in pages or []:
|
||||
if k in _ALL_SET and k not in seen:
|
||||
seen.add(k)
|
||||
out.append(k)
|
||||
return out
|
||||
|
||||
|
||||
def effective_pages(role_name: str, role_pages: list[str] | None) -> list[str]:
|
||||
"""某角色的有效可见页:super_admin → 全部;其余 → 其 pages 与目录取交(自愈悬空 key)。"""
|
||||
if role_name == SUPER_ADMIN_ROLE:
|
||||
return list(ALL_PAGE_KEYS)
|
||||
return sanitize_pages(role_pages)
|
||||
@@ -385,33 +385,10 @@ def ad_revenue_report(
|
||||
for k, v in type_map.items()
|
||||
}
|
||||
|
||||
# 分场景小计(按 feed_scene:展示条数 + 预估收益),同 type_stats 基于全量 events——
|
||||
# 供数据大盘「领券广告 / 比价广告」卡用。此前大盘是在分页 items 里按 feed_scene 现算,
|
||||
# 2026-07-02 起信息流逐条展示行(唯一带收益 + 场景的行)不再进主表 items,现算恒为 0;
|
||||
# 改为服务端在全量上聚合下发(也顺带不受 limit 分页截断影响)。feed_scene 为空(激励视频 /
|
||||
# 旧数据)不计入任何场景桶。
|
||||
scene_map: dict[str, dict] = {}
|
||||
for e in events:
|
||||
sc = e.get("feed_scene")
|
||||
if not sc:
|
||||
continue
|
||||
s = scene_map.get(sc)
|
||||
if s is None:
|
||||
s = {"impressions": 0, "revenue_yuan": 0.0}
|
||||
scene_map[sc] = s
|
||||
s["impressions"] += e["impressions"]
|
||||
s["revenue_yuan"] += e["revenue_yuan"]
|
||||
scene_stats = {
|
||||
k: {"impressions": v["impressions"], "revenue_yuan": round(v["revenue_yuan"], 6)}
|
||||
for k, v in scene_map.items()
|
||||
}
|
||||
|
||||
# DAU:复用数据大盘活跃用户口径(登录 + 开始比价 + 开始领券,按用户去重),按所选日期区间
|
||||
# 统计(含今日),历史 / 多天区间同样有值。ARPU = 区间预估收益 ÷ 区间活跃用户。全局口径,
|
||||
# 不随 user / ad_type / feed_scene / app_env 筛选变化(活跃用户口径无这些维度)。
|
||||
dau = admin_stats.period_active_dau(
|
||||
db, _date.fromisoformat(date_from), _date.fromisoformat(date_to)
|
||||
)
|
||||
# DAU:复用大盘「今日活跃」口径(stats.today_dau,last_login_at)。该口径只能算今日,
|
||||
# 故仅当查询=今日单天时给值;历史 / 多天区间返回 None,前端显示「-」。
|
||||
is_today = date_from == date_to == rewards.cn_today().isoformat()
|
||||
dau = admin_stats.today_dau(db) if is_today else None
|
||||
|
||||
# 主表「逐行」= 单次广告行为(2026-07 按「一次比价/领券放一块」聚合):激励视频 = 一次观看一行(展示+发奖
|
||||
# 按 ad_session_id 合并);一次比价 / 一次领券 = 该次整场多条广告按 ad_session_id 聚成一行(展开看逐条)。
|
||||
@@ -439,7 +416,6 @@ def ad_revenue_report(
|
||||
"daily": daily,
|
||||
"hourly": hourly,
|
||||
"type_stats": type_stats,
|
||||
"scene_stats": scene_stats,
|
||||
"dau": dau,
|
||||
"items": main_rows[offset:offset + limit],
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
"""admin_role 表 CRUD + 「角色 → 有效可见页」解析。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.permissions import (
|
||||
ALL_PAGE_KEYS,
|
||||
BUILTIN_ROLES,
|
||||
SUPER_ADMIN_ROLE,
|
||||
sanitize_pages,
|
||||
)
|
||||
from app.models.admin_role import AdminRole
|
||||
|
||||
|
||||
def ensure_builtin_roles(db: Session) -> None:
|
||||
"""空表时播种内建角色(管理员/运营/财务/技术)。幂等:表非空即 no-op。
|
||||
给 create_all 环境(测试 / 未跑迁移的全新库)兜底,迁移已播种则不重复。
|
||||
仅 super_admin 为 is_builtin(锁定不可改删);运营/财务/技术可编辑页/删除。"""
|
||||
if db.execute(select(AdminRole.id).limit(1)).first() is not None:
|
||||
return
|
||||
db.add_all([
|
||||
AdminRole(
|
||||
name=r["name"], label=r["label"], pages=list(r["pages"]),
|
||||
is_builtin=(r["name"] == SUPER_ADMIN_ROLE),
|
||||
)
|
||||
for r in BUILTIN_ROLES
|
||||
])
|
||||
db.commit()
|
||||
|
||||
|
||||
def list_roles(db: Session) -> list[AdminRole]:
|
||||
ensure_builtin_roles(db)
|
||||
# super_admin(内建)恒排最前,其余按创建序
|
||||
return list(
|
||||
db.execute(
|
||||
select(AdminRole).order_by(AdminRole.is_builtin.desc(), AdminRole.id)
|
||||
).scalars().all()
|
||||
)
|
||||
|
||||
|
||||
def get_role(db: Session, name: str) -> AdminRole | None:
|
||||
return db.execute(
|
||||
select(AdminRole).where(AdminRole.name == name)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def create_role(db: Session, *, name: str, label: str, pages: list[str]) -> AdminRole:
|
||||
# 自定义角色:name(key)= label = 用户输入的名称
|
||||
role = AdminRole(name=name, label=label, pages=sanitize_pages(pages), is_builtin=False)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
return role
|
||||
|
||||
|
||||
def update_role(
|
||||
db: Session, role: AdminRole, *, label: str | None = None, pages: list[str] | None = None
|
||||
) -> AdminRole:
|
||||
# 只改展示名 label + 可见页;name(key)不可变(admin_user.role / require_role 承重)
|
||||
if label is not None:
|
||||
role.label = label
|
||||
if pages is not None:
|
||||
role.pages = sanitize_pages(pages)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
return role
|
||||
|
||||
|
||||
def delete_role(db: Session, role: AdminRole) -> None:
|
||||
db.delete(role)
|
||||
db.commit()
|
||||
|
||||
|
||||
def effective_pages_of(db: Session, role_name: str) -> list[str]:
|
||||
"""某角色名的有效可见页:super_admin → 全部;其余 → 查表 pages 与目录取交。"""
|
||||
if role_name == SUPER_ADMIN_ROLE:
|
||||
return list(ALL_PAGE_KEYS)
|
||||
ensure_builtin_roles(db)
|
||||
role = get_role(db, role_name)
|
||||
return sanitize_pages(role.pages if role else None)
|
||||
@@ -20,20 +20,12 @@ def get_by_username(db: Session, username: str) -> AdminUser | None:
|
||||
|
||||
|
||||
def create_admin(
|
||||
db: Session,
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
role: str = "operator",
|
||||
plain_password: str | None = None,
|
||||
db: Session, *, username: str, password: str, role: str = "operator"
|
||||
) -> AdminUser:
|
||||
"""建管理员。plain_password 非空则额外留存明文(后台 UI 建的账号传,供权限管理页复看);
|
||||
脚本/起后台建账号不传(留 None → 前端不显示密码)。"""
|
||||
admin = AdminUser(
|
||||
username=username,
|
||||
password_hash=hash_password(password),
|
||||
role=role,
|
||||
plain_password=plain_password,
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
|
||||
@@ -51,27 +51,7 @@ def _yuan_to_cents(v: object) -> int | None:
|
||||
|
||||
def _ts_to_dt(ts: object) -> datetime | None:
|
||||
"""秒级时间戳 → tz-aware UTC datetime(绝对时刻,前端按北京展示)。"""
|
||||
if ts is None:
|
||||
return None
|
||||
if isinstance(ts, datetime):
|
||||
return ts if ts.tzinfo else ts.replace(tzinfo=_BJ_TZ).astimezone(timezone.utc)
|
||||
s = str(ts).strip()
|
||||
if not s or s.lower() == "null":
|
||||
return None
|
||||
try:
|
||||
seconds = float(Decimal(s))
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
if seconds == 0:
|
||||
return None
|
||||
# 美团文档是秒级时间戳,这里顺手兼容毫秒/微秒,避免上游格式变化导致时间再次落空。
|
||||
if abs(seconds) > 10_000_000_000_000:
|
||||
seconds /= 1_000_000
|
||||
elif abs(seconds) > 10_000_000_000:
|
||||
seconds /= 1_000
|
||||
try:
|
||||
return datetime.fromtimestamp(seconds, tz=timezone.utc)
|
||||
except (OverflowError, OSError, ValueError):
|
||||
if not ts:
|
||||
return None
|
||||
|
||||
|
||||
@@ -103,6 +83,10 @@ def _pick(row: dict[str, Any], *keys: str) -> Any:
|
||||
if key in row and row[key] is not None:
|
||||
return row[key]
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(int(ts), tz=timezone.utc)
|
||||
except (ValueError, OSError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
# ───────────── 群 ─────────────
|
||||
|
||||
@@ -11,7 +11,6 @@ from zoneinfo import ZoneInfo
|
||||
from sqlalchemy import Select, asc, case, desc, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.repositories.stats import COMPARE_START_EVENT, COUPON_START_EVENT
|
||||
from app.core import rewards
|
||||
from app.core.config import settings
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
@@ -19,22 +18,12 @@ from app.models.ad_reward import AdRewardRecord
|
||||
from app.models.admin import AdminAuditLog
|
||||
from app.models.analytics_event import AnalyticsEvent
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.coupon_state import CouponPromptEngagement
|
||||
from app.models.device import DeviceLiveness
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.onboarding import OnboardingCompletion
|
||||
from app.models.price_report import PriceReport
|
||||
from app.models.user import User
|
||||
from app.models.wallet import (
|
||||
CashTransaction,
|
||||
CoinAccount,
|
||||
CoinTransaction,
|
||||
InviteCashTransaction,
|
||||
WithdrawOrder,
|
||||
)
|
||||
|
||||
# 「最近活跃」计入的行为事件(与大盘 DAU/留存活跃口径一致:开始比价 + 开始领券)
|
||||
_ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT)
|
||||
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
|
||||
|
||||
# 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取")
|
||||
_NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct")
|
||||
@@ -87,86 +76,6 @@ def offset_paginate(
|
||||
return items, next_cursor, total
|
||||
|
||||
|
||||
def _last_active_parts():
|
||||
"""「最近活跃」的两个按 user_id 预聚合派生表(最近开始比价/领券事件、最近领券发起)。
|
||||
|
||||
活跃口径与大盘 DAU/留存一致(2026-07-05 产品定:进入 App≈登录 last_login_at +
|
||||
发起比价 real_compare_start + 发起领券 real_coupon_start/claim_started)。
|
||||
用 LEFT JOIN 预聚合而非相关标量子查询:后者在 PG 上对 users 每行各跑一个 SubPlan
|
||||
(排序键、range 筛选、offset_paginate 的 count 三处叠加),埋点表大了会拖垮列表接口;
|
||||
预聚合借 analytics_event.event 索引只扫两类 start 事件,每次查询聚合一次。
|
||||
"""
|
||||
ev_agg = (
|
||||
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_agg = (
|
||||
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 == "claim_started",
|
||||
)
|
||||
.group_by(CouponPromptEngagement.user_id)
|
||||
.subquery()
|
||||
)
|
||||
return ev_agg, eng_agg
|
||||
|
||||
|
||||
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 _attach_last_active(db: Session, users: list[User]) -> None:
|
||||
"""给本页用户瞬态挂 last_active_at(非 DB 列,供 AdminUserListItem from_attributes 读)。
|
||||
|
||||
口径同 [_last_active_expr];按本页 user_id 批量两次 GROUP BY,防 N+1。
|
||||
"""
|
||||
uids = [u.id for u in users]
|
||||
if not uids:
|
||||
return
|
||||
ev_map = dict(
|
||||
db.execute(
|
||||
select(AnalyticsEvent.user_id, func.max(AnalyticsEvent.created_at))
|
||||
.where(
|
||||
AnalyticsEvent.user_id.in_(uids),
|
||||
AnalyticsEvent.event.in_(_ACTIVE_EVENTS),
|
||||
)
|
||||
.group_by(AnalyticsEvent.user_id)
|
||||
).all()
|
||||
)
|
||||
eng_map = dict(
|
||||
db.execute(
|
||||
select(CouponPromptEngagement.user_id, func.max(CouponPromptEngagement.created_at))
|
||||
.where(
|
||||
CouponPromptEngagement.user_id.in_(uids),
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
)
|
||||
.group_by(CouponPromptEngagement.user_id)
|
||||
).all()
|
||||
)
|
||||
for u in users:
|
||||
candidates = [
|
||||
_norm_utc(u.last_login_at),
|
||||
_norm_utc(ev_map.get(u.id)),
|
||||
_norm_utc(eng_map.get(u.id)),
|
||||
]
|
||||
u.last_active_at = max((c for c in candidates if c is not None), default=None)
|
||||
|
||||
|
||||
def list_users(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -178,34 +87,16 @@ def list_users(
|
||||
created_to: datetime | None = None,
|
||||
last_login_from: datetime | None = None,
|
||||
last_login_to: datetime | None = None,
|
||||
last_active_from: datetime | None = None,
|
||||
last_active_to: datetime | None = None,
|
||||
sort_by: str = "id",
|
||||
sort_order: str = "desc",
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[User], int | None, int]:
|
||||
"""用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录·最近活跃
|
||||
时间范围筛选,按 id·注册时间·最近登录·最近活跃排序;每页附带计算列 last_active_at
|
||||
(口径见 [_last_active_expr])。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
|
||||
"""用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录时间范围筛选,
|
||||
按 id·注册时间·最近登录排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
|
||||
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。
|
||||
日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。"""
|
||||
# 最近活跃 = max(最近登录, 最近行为事件, 最近领券发起)。PG 用 GREATEST;SQLite 标量 max()
|
||||
# 任一参数 NULL 即返回 NULL,故 LEFT JOIN 未命中侧 coalesce 到 last_login_at 兜底
|
||||
# (注册即登录,该列恒非空)。派生表 1:1(按 user_id 聚合),outerjoin 不会放大行数,
|
||||
# offset_paginate 的 count 不受影响。
|
||||
ev_agg, eng_agg = _last_active_parts()
|
||||
greatest = func.greatest if db.get_bind().dialect.name == "postgresql" else func.max
|
||||
last_active = greatest(
|
||||
User.last_login_at,
|
||||
func.coalesce(ev_agg.c.last_at, User.last_login_at),
|
||||
func.coalesce(eng_agg.c.last_at, User.last_login_at),
|
||||
)
|
||||
stmt = (
|
||||
select(User)
|
||||
.outerjoin(ev_agg, ev_agg.c.user_id == User.id)
|
||||
.outerjoin(eng_agg, eng_agg.c.user_id == User.id)
|
||||
)
|
||||
stmt = select(User)
|
||||
if phone:
|
||||
stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配
|
||||
if register_channel:
|
||||
@@ -222,25 +113,16 @@ def list_users(
|
||||
stmt = stmt.where(User.last_login_at >= _as_utc(last_login_from))
|
||||
if last_login_to is not None:
|
||||
stmt = stmt.where(User.last_login_at <= _as_utc(last_login_to))
|
||||
if last_active_from is not None:
|
||||
stmt = stmt.where(last_active >= _as_utc(last_active_from))
|
||||
if last_active_to is not None:
|
||||
stmt = stmt.where(last_active <= _as_utc(last_active_to))
|
||||
|
||||
sort_cols = {
|
||||
"id": User.id,
|
||||
"created_at": User.created_at,
|
||||
"last_login_at": User.last_login_at,
|
||||
"last_active_at": last_active,
|
||||
}
|
||||
sort_col = sort_cols.get(sort_by, User.id)
|
||||
order_fn = asc if sort_order == "asc" else desc
|
||||
id_order = asc(User.id) if sort_order == "asc" else desc(User.id)
|
||||
items, next_cursor, total = offset_paginate(
|
||||
db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor
|
||||
)
|
||||
_attach_last_active(db, items)
|
||||
return items, next_cursor, total
|
||||
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
def _attach_user_info(db: Session, records: list[ComparisonRecord | Feedback | PriceReport]) -> None:
|
||||
@@ -266,14 +148,11 @@ def list_comparison_records(
|
||||
phone: str | None = None,
|
||||
status: str | None = None,
|
||||
business_type: str | None = None,
|
||||
store: str | None = None,
|
||||
product: str | None = None,
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[ComparisonRecord], int | None, int]:
|
||||
"""admin 比价记录列表(debug)。按 user_id 精确 或 phone 前缀定位用户 + status/业务类型筛,
|
||||
store(店名)/product(商品名)子串模糊匹配,offset 分页(创建时间倒序、id 兜底)。
|
||||
join User 取 phone/nickname 瞬态挂记录上。"""
|
||||
offset 分页(创建时间倒序、id 兜底)。join User 取 phone/nickname 瞬态挂记录上。"""
|
||||
stmt = select(ComparisonRecord)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(ComparisonRecord.user_id == user_id)
|
||||
@@ -287,11 +166,6 @@ def list_comparison_records(
|
||||
stmt = stmt.where(ComparisonRecord.status == status)
|
||||
if business_type:
|
||||
stmt = stmt.where(ComparisonRecord.business_type == business_type)
|
||||
if store:
|
||||
stmt = stmt.where(ComparisonRecord.store_name.like(f"%{store}%"))
|
||||
if product:
|
||||
# 商品名搜 product_names 派生文本列(非 items JSON:SQLite 下 JSON 中文被转义无法直接 LIKE)。
|
||||
stmt = stmt.where(ComparisonRecord.product_names.like(f"%{product}%"))
|
||||
items, next_cursor, total = offset_paginate(
|
||||
db, stmt,
|
||||
(desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)),
|
||||
@@ -852,19 +726,26 @@ def withdraw_risk_flags(
|
||||
return flags, score
|
||||
|
||||
|
||||
def _check_withdraw_ledger_side(
|
||||
orders: list[WithdrawOrder], txns: list, *, withdraw_biz: str, refund_biz: str
|
||||
) -> dict:
|
||||
"""对某一本账(普通现金 / 邀请奖励金)做提现单 ↔ 流水的交叉校验。
|
||||
def withdraw_ledger_check(db: Session) -> dict:
|
||||
cash_balance_total = int(
|
||||
db.execute(select(func.coalesce(func.sum(CoinAccount.cash_balance_cents), 0))).scalar_one()
|
||||
)
|
||||
cash_txn_total = int(
|
||||
db.execute(select(func.coalesce(func.sum(CashTransaction.amount_cents), 0))).scalar_one()
|
||||
)
|
||||
|
||||
orders 已按 source 过滤到本账;txns 是本账流水表里 withdraw_biz/refund_biz 两类流水。
|
||||
规则:每单发起应有一条扣款流水(ref_id=out_bill_no);失败/拒绝单应有且仅一条退款流水;
|
||||
非退款终态不应出现退款流水。四个计数全为 0 即本账自洽。
|
||||
"""
|
||||
withdraw_refs = {txn.ref_id for txn in txns if txn.biz_type == withdraw_biz}
|
||||
orders = list(db.execute(select(WithdrawOrder)).scalars().all())
|
||||
cash_txns = list(
|
||||
db.execute(
|
||||
select(CashTransaction).where(
|
||||
CashTransaction.biz_type.in_(("withdraw", "withdraw_refund"))
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
withdraw_refs = {txn.ref_id for txn in cash_txns if txn.biz_type == "withdraw"}
|
||||
refund_counts: dict[str, int] = {}
|
||||
for txn in txns:
|
||||
if txn.biz_type == refund_biz and txn.ref_id:
|
||||
for txn in cash_txns:
|
||||
if txn.biz_type == "withdraw_refund" and txn.ref_id:
|
||||
refund_counts[txn.ref_id] = refund_counts.get(txn.ref_id, 0) + 1
|
||||
|
||||
missing_withdraw = 0
|
||||
@@ -879,95 +760,24 @@ def _check_withdraw_ledger_side(
|
||||
if has_refund and order.status not in {"failed", "rejected"}:
|
||||
refund_on_non_terminal += 1
|
||||
|
||||
return {
|
||||
"missing_withdraw": missing_withdraw,
|
||||
"missing_refund": missing_refund,
|
||||
"duplicate_refund": sum(1 for count in refund_counts.values() if count > 1),
|
||||
"refund_on_non_terminal": refund_on_non_terminal,
|
||||
}
|
||||
|
||||
|
||||
def withdraw_ledger_check(db: Session) -> dict:
|
||||
"""现金账本校验:两本物理隔离的账各自对账(产品红线:coin_cash / invite_cash 不串)。
|
||||
|
||||
普通现金:CoinAccount.cash_balance_cents ↔ cash_transaction(withdraw/withdraw_refund);
|
||||
邀请奖励金:CoinAccount.invite_cash_balance_cents ↔ invite_cash_transaction
|
||||
(invite_withdraw/invite_withdraw_refund)。
|
||||
提现单按 source 分流到对应账核对——邀请提现的流水写在 invite_cash_transaction 表,
|
||||
绝不能拿去和普通现金流水比(否则每笔邀请提现单都会被误报「缺扣款/缺退款流水」)。
|
||||
分流口径与 create_withdraw 一致:仅 source==invite_cash 走邀请账,其余(含历史空值)归普通现金。
|
||||
"""
|
||||
orders = list(db.execute(select(WithdrawOrder)).scalars().all())
|
||||
coin_orders = [o for o in orders if o.source != "invite_cash"]
|
||||
invite_orders = [o for o in orders if o.source == "invite_cash"]
|
||||
|
||||
# —— 普通现金账(coin_cash) ——
|
||||
cash_balance_total = int(
|
||||
db.execute(select(func.coalesce(func.sum(CoinAccount.cash_balance_cents), 0))).scalar_one()
|
||||
)
|
||||
cash_txn_total = int(
|
||||
db.execute(select(func.coalesce(func.sum(CashTransaction.amount_cents), 0))).scalar_one()
|
||||
)
|
||||
cash_txns = list(
|
||||
db.execute(
|
||||
select(CashTransaction).where(
|
||||
CashTransaction.biz_type.in_(("withdraw", "withdraw_refund"))
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
coin = _check_withdraw_ledger_side(
|
||||
coin_orders, cash_txns, withdraw_biz="withdraw", refund_biz="withdraw_refund"
|
||||
)
|
||||
cash_diff = cash_balance_total - cash_txn_total
|
||||
|
||||
# —— 邀请奖励金账(invite_cash,独立账户 + 独立流水表) ——
|
||||
invite_balance_total = int(
|
||||
db.execute(
|
||||
select(func.coalesce(func.sum(CoinAccount.invite_cash_balance_cents), 0))
|
||||
).scalar_one()
|
||||
)
|
||||
invite_txn_total = int(
|
||||
db.execute(
|
||||
select(func.coalesce(func.sum(InviteCashTransaction.amount_cents), 0))
|
||||
).scalar_one()
|
||||
)
|
||||
invite_txns = list(
|
||||
db.execute(
|
||||
select(InviteCashTransaction).where(
|
||||
InviteCashTransaction.biz_type.in_(("invite_withdraw", "invite_withdraw_refund"))
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
invite = _check_withdraw_ledger_side(
|
||||
invite_orders, invite_txns,
|
||||
withdraw_biz="invite_withdraw", refund_biz="invite_withdraw_refund",
|
||||
)
|
||||
invite_diff = invite_balance_total - invite_txn_total
|
||||
|
||||
duplicate_refund = sum(1 for count in refund_counts.values() if count > 1)
|
||||
diff = cash_balance_total - cash_txn_total
|
||||
ok = (
|
||||
cash_diff == 0
|
||||
and invite_diff == 0
|
||||
and all(v == 0 for v in coin.values())
|
||||
and all(v == 0 for v in invite.values())
|
||||
diff == 0
|
||||
and missing_withdraw == 0
|
||||
and missing_refund == 0
|
||||
and duplicate_refund == 0
|
||||
and refund_on_non_terminal == 0
|
||||
)
|
||||
return {
|
||||
"ok": ok,
|
||||
# 普通现金账(coin_cash:金币兑换的现金)
|
||||
"cash_balance_total_cents": cash_balance_total,
|
||||
"cash_transaction_total_cents": cash_txn_total,
|
||||
"balance_diff_cents": cash_diff,
|
||||
"missing_withdraw_txn_count": coin["missing_withdraw"],
|
||||
"missing_refund_txn_count": coin["missing_refund"],
|
||||
"duplicate_refund_txn_count": coin["duplicate_refund"],
|
||||
"refund_txn_on_non_terminal_count": coin["refund_on_non_terminal"],
|
||||
# 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账)
|
||||
"invite_cash_balance_total_cents": invite_balance_total,
|
||||
"invite_cash_transaction_total_cents": invite_txn_total,
|
||||
"invite_balance_diff_cents": invite_diff,
|
||||
"invite_missing_withdraw_txn_count": invite["missing_withdraw"],
|
||||
"invite_missing_refund_txn_count": invite["missing_refund"],
|
||||
"invite_duplicate_refund_txn_count": invite["duplicate_refund"],
|
||||
"invite_refund_txn_on_non_terminal_count": invite["refund_on_non_terminal"],
|
||||
"balance_diff_cents": diff,
|
||||
"missing_withdraw_txn_count": missing_withdraw,
|
||||
"missing_refund_txn_count": missing_refund,
|
||||
"duplicate_refund_txn_count": duplicate_refund,
|
||||
"refund_txn_on_non_terminal_count": refund_on_non_terminal,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+71
-199
@@ -5,23 +5,17 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.repositories.coupon_data import _percentile
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
from app.models.ad_reward import AdRewardRecord
|
||||
from app.models.analytics_event import AnalyticsEvent
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.coupon_state import (
|
||||
CouponClaimRecord,
|
||||
CouponPromptEngagement,
|
||||
CouponSession,
|
||||
)
|
||||
from app.models.coupon_state import CouponPromptEngagement
|
||||
from app.models.cps_order import CpsOrder
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.savings import SavingsRecord
|
||||
@@ -61,10 +55,29 @@ def _beijing_today_start_utc() -> datetime:
|
||||
def today_dau(db: Session) -> int:
|
||||
"""今日活跃用户数(DAU):登录 + 开始比价 + 开始领券,按用户去重。
|
||||
|
||||
= period_active_dau(今天, 今天);历史 / 多天窗口用 period_active_dau 传区间(广告收益报表复用)。
|
||||
广告收益报表复用这个函数;历史窗口 DAU 由 dashboard_overview 的 period 口径另算。
|
||||
"""
|
||||
today_bj = datetime.now(_BEIJING).date()
|
||||
return period_active_dau(db, today_bj, today_bj)
|
||||
today_start = _beijing_today_start_utc()
|
||||
tomorrow_start = today_start + timedelta(days=1)
|
||||
login_user_ids = _id_set(
|
||||
db,
|
||||
select(User.id).where(User.last_login_at >= today_start, User.last_login_at < tomorrow_start),
|
||||
)
|
||||
compare_start_user_ids = _event_user_ids(
|
||||
db, (COMPARE_START_EVENT,), today_start, tomorrow_start
|
||||
)
|
||||
coupon_event_user_ids = _event_user_ids(
|
||||
db, (COUPON_START_EVENT,), today_start, tomorrow_start
|
||||
)
|
||||
coupon_claim_user_ids = _id_set(
|
||||
db,
|
||||
select(CouponPromptEngagement.user_id).where(
|
||||
CouponPromptEngagement.engage_date == today_bj,
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
),
|
||||
)
|
||||
return len(login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids)
|
||||
|
||||
|
||||
def _default_period_end() -> date:
|
||||
@@ -121,56 +134,6 @@ def _event_user_ids(
|
||||
)
|
||||
|
||||
|
||||
def _period_active_user_ids(
|
||||
db: Session,
|
||||
*,
|
||||
start_utc: datetime,
|
||||
end_utc: datetime,
|
||||
period_from: date,
|
||||
period_to: date,
|
||||
) -> set[int]:
|
||||
"""区间去重活跃用户 id 集合:登录(last_login_at)+ 开始比价(real_compare_start)+
|
||||
开始领券(real_coupon_start / claim_started)。
|
||||
|
||||
user / analytics_event 用 UTC aware 边界 [start_utc, end_utc);coupon_prompt_engagement
|
||||
的 engage_date 是北京自然日 date 列,用 [period_from, period_to] 闭区间。今日 / 历史 / 多天通用。
|
||||
"""
|
||||
login_user_ids = _id_set(
|
||||
db,
|
||||
select(User.id).where(User.last_login_at >= start_utc, User.last_login_at < end_utc),
|
||||
)
|
||||
compare_start_user_ids = _event_user_ids(db, (COMPARE_START_EVENT,), start_utc, end_utc)
|
||||
coupon_event_user_ids = _event_user_ids(db, (COUPON_START_EVENT,), start_utc, end_utc)
|
||||
coupon_claim_user_ids = _id_set(
|
||||
db,
|
||||
select(CouponPromptEngagement.user_id).where(
|
||||
CouponPromptEngagement.engage_date >= period_from,
|
||||
CouponPromptEngagement.engage_date <= period_to,
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
),
|
||||
)
|
||||
return login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids
|
||||
|
||||
|
||||
def period_active_dau(db: Session, date_from: date, date_to: date) -> int:
|
||||
"""任意北京自然日区间 [date_from, date_to] 的去重活跃用户数。
|
||||
|
||||
与数据大盘 period.users.active 同口径(登录 + 开始比价 + 开始领券);广告收益报表按所选
|
||||
日期区间(含今日)复用,ARPU = 区间预估收益 ÷ 本数。全局口径,不随用户 / 类型 / 场景筛选变化。
|
||||
"""
|
||||
period_from, period_to = _normalize_period(date_from, date_to)
|
||||
start_utc, end_utc, _start_local, _end_local = _period_bounds(period_from, period_to)
|
||||
return len(
|
||||
_period_active_user_ids(
|
||||
db,
|
||||
start_utc=start_utc,
|
||||
end_utc=end_utc,
|
||||
period_from=period_from,
|
||||
period_to=period_to,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _commission_rate_percent(raw: str | None) -> Decimal | None:
|
||||
"""美团 commissionRate 原值: "300"=3%, "10"=0.1%;也兼容 "3%"。"""
|
||||
if raw is None:
|
||||
@@ -298,19 +261,31 @@ def dashboard_overview(
|
||||
period_new_user_ids = _user_id_set(
|
||||
select(User.id).where(User.created_at >= start_utc, User.created_at < end_utc)
|
||||
)
|
||||
period_active_user_ids = _period_active_user_ids(
|
||||
db,
|
||||
start_utc=start_utc,
|
||||
end_utc=end_utc,
|
||||
period_from=period_from,
|
||||
period_to=period_to,
|
||||
login_user_ids = _user_id_set(
|
||||
select(User.id).where(User.last_login_at >= start_utc, User.last_login_at < end_utc)
|
||||
)
|
||||
compare_start_user_ids = _event_user_ids(
|
||||
db, (COMPARE_START_EVENT,), start_utc, end_utc
|
||||
)
|
||||
coupon_event_user_ids = _event_user_ids(
|
||||
db, (COUPON_START_EVENT,), start_utc, end_utc
|
||||
)
|
||||
coupon_claim_user_ids = _user_id_set(
|
||||
select(CouponPromptEngagement.user_id).where(
|
||||
CouponPromptEngagement.engage_date >= period_from,
|
||||
CouponPromptEngagement.engage_date <= period_to,
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
)
|
||||
)
|
||||
period_active_user_ids = (
|
||||
login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids
|
||||
)
|
||||
period_retained_new_user_ids = period_new_user_ids & period_active_user_ids
|
||||
period_retention_rate = (
|
||||
round(len(period_retained_new_user_ids) / len(period_new_user_ids), 4)
|
||||
if period_new_user_ids
|
||||
else None
|
||||
)
|
||||
# 留存口径(2026-07-05 产品改):次日留存——窗口内每天 D,取 **D-1 日(前日)新增**用户,
|
||||
# 统计其 D 日活跃(登录/开始比价/开始领券)比例,逐日累加。默认窗口=昨日单天,即
|
||||
# 「前日新增用户的昨日留存」。原口径(窗口内新增∩窗口内活跃)在单日窗口下≈100% 无意义
|
||||
# (注册即登录,当天新增必然当天活跃)。逐日 cohort 在下方 trend 循环内顺带累计。
|
||||
retention_cohort_total = 0
|
||||
retention_retained_total = 0
|
||||
trend_points: list[dict] = []
|
||||
for cur_date in _date_range(period_from, period_to):
|
||||
day_start_utc, day_end_utc, day_start_local, day_end_local = _period_bounds(
|
||||
@@ -320,26 +295,33 @@ def dashboard_overview(
|
||||
ComparisonRecord.created_at >= day_start_local,
|
||||
ComparisonRecord.created_at < day_end_local,
|
||||
)
|
||||
daily_active_user_ids = _period_active_user_ids(
|
||||
db,
|
||||
start_utc=day_start_utc,
|
||||
end_utc=day_end_utc,
|
||||
period_from=cur_date,
|
||||
period_to=cur_date,
|
||||
)
|
||||
# 次日留存:cohort = 前一日(D-1)新增用户,留存 = 其中当日(D)活跃者(口径见上)。
|
||||
cohort_ids = _user_id_set(
|
||||
daily_login_user_ids = _user_id_set(
|
||||
select(User.id).where(
|
||||
User.created_at >= day_start_utc - timedelta(days=1),
|
||||
User.created_at < day_end_utc - timedelta(days=1),
|
||||
User.last_login_at >= day_start_utc,
|
||||
User.last_login_at < day_end_utc,
|
||||
)
|
||||
)
|
||||
daily_compare_start_user_ids = _event_user_ids(
|
||||
db, (COMPARE_START_EVENT,), day_start_utc, day_end_utc
|
||||
)
|
||||
daily_coupon_event_user_ids = _event_user_ids(
|
||||
db, (COUPON_START_EVENT,), day_start_utc, day_end_utc
|
||||
)
|
||||
daily_coupon_claim_user_ids = _user_id_set(
|
||||
select(CouponPromptEngagement.user_id).where(
|
||||
CouponPromptEngagement.engage_date == cur_date,
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
)
|
||||
)
|
||||
retention_cohort_total += len(cohort_ids)
|
||||
retention_retained_total += len(cohort_ids & daily_active_user_ids)
|
||||
trend_points.append(
|
||||
{
|
||||
"date": cur_date,
|
||||
"active_users": len(daily_active_user_ids),
|
||||
"active_users": len(
|
||||
daily_login_user_ids
|
||||
| daily_compare_start_user_ids
|
||||
| daily_coupon_event_user_ids
|
||||
| daily_coupon_claim_user_ids
|
||||
),
|
||||
"new_users": _count(
|
||||
User,
|
||||
User.created_at >= day_start_utc,
|
||||
@@ -348,11 +330,6 @@ def dashboard_overview(
|
||||
"comparisons": _count(ComparisonRecord, *daily_comparison_conds),
|
||||
}
|
||||
)
|
||||
period_retention_rate = (
|
||||
round(retention_retained_total / retention_cohort_total, 4)
|
||||
if retention_cohort_total
|
||||
else None
|
||||
)
|
||||
|
||||
period_coin_conds = (
|
||||
CoinTransaction.created_at >= start_local,
|
||||
@@ -436,98 +413,6 @@ def dashboard_overview(
|
||||
else None
|
||||
)
|
||||
|
||||
# ===== 领券核心数据(2026-07-05 产品新增)=====
|
||||
# 数据源:coupon_session(一次领券一行,started_date 北京自然日)+ coupon_claim_record
|
||||
# (一券/点位一天一条终态,claim_date 北京自然日)。点位与 session 不按 trace_id 关联——
|
||||
# record_claims 更新路径不覆盖 trace_id(同设备同券同日重跑归第一次的 trace),按
|
||||
# (device_id, 自然日) 桶关联才可靠;同桶多次发起共享同一份点位终态。
|
||||
period_coupon_sessions = db.execute(
|
||||
select(
|
||||
CouponSession.device_id,
|
||||
CouponSession.started_date,
|
||||
CouponSession.status,
|
||||
CouponSession.elapsed_ms,
|
||||
).where(
|
||||
CouponSession.started_date >= period_from,
|
||||
CouponSession.started_date <= period_to,
|
||||
# 只统计正式环境,同「领券数据」页默认口径(防 debug 包调试数据串台;
|
||||
# 命中 ix_coupon_session_date_env)。点位表无 app_env 列,但点位指标只经
|
||||
# 下方 prod session 触达的 (device, 日) 桶进入统计,随之收敛到 prod。
|
||||
CouponSession.app_env == "prod",
|
||||
)
|
||||
).all()
|
||||
coupon_started = len(period_coupon_sessions)
|
||||
coupon_completed_elapsed = sorted(
|
||||
s.elapsed_ms
|
||||
for s in period_coupon_sessions
|
||||
if s.status == "completed" and s.elapsed_ms is not None
|
||||
)
|
||||
# 点位桶:(device, 日) → (点位总数, 成功点位数)。成功口径与「我的」页累计领券一致
|
||||
# (sum_claimed_count,2026-06-15 产品定):success + already_claimed(已领过=持有券)都算成功。
|
||||
point_buckets: dict[tuple[str, date], tuple[int, int]] = {
|
||||
(dev, d): (int(total), int(succ or 0))
|
||||
for dev, d, total, succ in db.execute(
|
||||
select(
|
||||
CouponClaimRecord.device_id,
|
||||
CouponClaimRecord.claim_date,
|
||||
func.count(),
|
||||
func.sum(
|
||||
case(
|
||||
(CouponClaimRecord.status.in_(("success", "already_claimed")), 1),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
)
|
||||
.where(
|
||||
CouponClaimRecord.claim_date >= period_from,
|
||||
# 上界放宽一天:跨零点场次(23:5x 发起)的点位 claim_date 落在发起日+1,
|
||||
# 桶只经下方 session 触达的键参与计数,放宽不会引入无关数据。
|
||||
CouponClaimRecord.claim_date <= period_to + timedelta(days=1),
|
||||
)
|
||||
.group_by(CouponClaimRecord.device_id, CouponClaimRecord.claim_date)
|
||||
).all()
|
||||
}
|
||||
# 全部领成功的次数:completed 且其 (device, 日) 桶内点位全部成功(桶为空不算)。
|
||||
coupon_all_success = 0
|
||||
completed_bucket_totals: list[int] = []
|
||||
session_bucket_keys: set[tuple[str, date]] = set()
|
||||
for s in period_coupon_sessions:
|
||||
key = (s.device_id, s.started_date)
|
||||
if key not in point_buckets:
|
||||
# 跨零点回退:发起日桶不存在(点位终态全部落在次日)时取 (device, 发起日+1)。
|
||||
# 仅在发起日桶完全缺失时回退,避免抢占该设备次日 session 自己的桶。
|
||||
next_key = (s.device_id, s.started_date + timedelta(days=1))
|
||||
if next_key in point_buckets:
|
||||
key = next_key
|
||||
bucket = point_buckets.get(key)
|
||||
if bucket is not None:
|
||||
session_bucket_keys.add(key)
|
||||
if s.status != "completed" or bucket is None:
|
||||
continue
|
||||
total, succ = bucket
|
||||
completed_bucket_totals.append(total)
|
||||
if total > 0 and succ == total:
|
||||
coupon_all_success += 1
|
||||
# 每次发起的应领点位数:取「完成过的领券」实际点位数的众数(done 帧会给所有点位终态,
|
||||
# 完成场的点位数=当前配置的全量点位数;数据自校准,配置改点位数无需改代码)。本期无完成场
|
||||
# 时给不出,点位成功率置空。
|
||||
coupon_points_per_session = (
|
||||
Counter(completed_bucket_totals).most_common(1)[0][0]
|
||||
if completed_bucket_totals
|
||||
else None
|
||||
)
|
||||
# 成功点位数:本期 session 触达过的 (device, 日) 桶内成功点位之和(桶级去重,同桶重试不重复计)。
|
||||
coupon_point_success = sum(point_buckets[k][1] for k in session_bucket_keys)
|
||||
# 点位成功率 = 成功点位数 / (发起数 × 应领点位数):中途退出未跑到的点位不产生记录,
|
||||
# 但发起数×点位数把它们计入分母 → 视为失败,符合产品口径;重试会拉低该率(分母按次数计)。
|
||||
coupon_point_success_rate = (
|
||||
round(
|
||||
min(1.0, coupon_point_success / (coupon_started * coupon_points_per_session)), 4
|
||||
)
|
||||
if coupon_started and coupon_points_per_session
|
||||
else None
|
||||
)
|
||||
|
||||
return {
|
||||
"users": {
|
||||
"total": _count(User),
|
||||
@@ -591,13 +476,11 @@ def dashboard_overview(
|
||||
"users": {
|
||||
"new": len(period_new_user_ids),
|
||||
"active": len(period_active_user_ids),
|
||||
"retained_new_users": retention_retained_total,
|
||||
"retention_cohort": retention_cohort_total,
|
||||
"retained_new_users": len(period_retained_new_user_ids),
|
||||
"retention_rate": period_retention_rate,
|
||||
"retention_note": (
|
||||
"次日留存:窗口内每天取前一日新增用户,统计其当日活跃"
|
||||
"(登录/开始比价/开始领券,按用户去重)比例,逐日累加;"
|
||||
"默认窗口=昨日,即前日新增用户的昨日留存"
|
||||
"口径:登录(last_login_at)+开始比价(real_compare_start)+"
|
||||
"开始领券(real_coupon_start/claim_started),按用户去重"
|
||||
),
|
||||
},
|
||||
"comparison": {
|
||||
@@ -608,17 +491,6 @@ def dashboard_overview(
|
||||
"average_duration_ms": period_avg_duration_ms,
|
||||
"average_saved_cents": period_avg_saved_cents,
|
||||
},
|
||||
"coupon": {
|
||||
"started": coupon_started,
|
||||
"all_success": coupon_all_success,
|
||||
"success_rate": (
|
||||
round(coupon_all_success / coupon_started, 4) if coupon_started else None
|
||||
),
|
||||
"point_success": coupon_point_success,
|
||||
"points_per_session": coupon_points_per_session,
|
||||
"point_success_rate": coupon_point_success_rate,
|
||||
"median_elapsed_ms": _percentile(coupon_completed_elapsed, 50),
|
||||
},
|
||||
"coins": {
|
||||
"granted_total": _sum(CoinTransaction.amount, *period_coin_conds),
|
||||
"reward_video_coin_total": period_reward_video_coin_total,
|
||||
|
||||
@@ -91,7 +91,6 @@ def get_ad_revenue_report(
|
||||
daily=[AdRevenueDaily(**d) for d in result["daily"]],
|
||||
hourly=[AdRevenueHourly(**h) for h in result["hourly"]],
|
||||
type_stats={k: AdRevenueTypeStat(**v) for k, v in result["type_stats"].items()},
|
||||
scene_stats={k: AdRevenueTypeStat(**v) for k, v in result["scene_stats"].items()},
|
||||
dau=result["dau"],
|
||||
total=result["total"],
|
||||
truncated=result["truncated"],
|
||||
|
||||
@@ -5,8 +5,6 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_role
|
||||
from app.admin.permissions import SUPER_ADMIN_ROLE
|
||||
from app.admin.repositories import admin_role as role_repo
|
||||
from app.admin.repositories import admin_user as admin_repo
|
||||
from app.admin.schemas.admin import AdminCreateRequest, AdminUpdateRequest
|
||||
from app.admin.schemas.auth import AdminOut
|
||||
@@ -25,23 +23,9 @@ def _active_super_count(db: AdminDb) -> int:
|
||||
)
|
||||
|
||||
|
||||
def _validate_role(db: AdminDb, role: str) -> None:
|
||||
"""角色必须是 super_admin 或 admin_role 表里已存在的角色,否则 400。"""
|
||||
if role == SUPER_ADMIN_ROLE:
|
||||
return
|
||||
role_repo.ensure_builtin_roles(db) # 空表(测试/全新库)兜底播种,再校验
|
||||
if role_repo.get_role(db, role) is None:
|
||||
raise HTTPException(status_code=400, detail=f"角色不存在: {role}")
|
||||
|
||||
|
||||
@router.get("", response_model=list[AdminOut], summary="管理员列表(含明文密码,super_admin 专属)")
|
||||
@router.get("", response_model=list[AdminOut], summary="管理员列表")
|
||||
def list_admins(db: AdminDb) -> list[AdminOut]:
|
||||
out: list[AdminOut] = []
|
||||
for a in admin_repo.list_admins(db):
|
||||
item = AdminOut.model_validate(a)
|
||||
item.password = a.plain_password # 明文:仅本 super_admin 专属路由下发,供权限管理页复看
|
||||
out.append(item)
|
||||
return out
|
||||
return [AdminOut.model_validate(a) for a in admin_repo.list_admins(db)]
|
||||
|
||||
|
||||
@router.post("", response_model=AdminOut, summary="创建管理员")
|
||||
@@ -50,10 +34,8 @@ def create_admin(
|
||||
) -> AdminOut:
|
||||
if admin_repo.get_by_username(db, body.username) is not None:
|
||||
raise HTTPException(status_code=409, detail="用户名已存在")
|
||||
_validate_role(db, body.role)
|
||||
new = admin_repo.create_admin(
|
||||
db, username=body.username, password=body.password, role=body.role,
|
||||
plain_password=body.password, # UI 建的账号留存明文,供权限管理页复看
|
||||
db, username=body.username, password=body.password, role=body.role
|
||||
)
|
||||
write_audit(
|
||||
db, admin, action="admin.create", target_type="admin", target_id=new.id,
|
||||
@@ -85,9 +67,6 @@ def update_admin(
|
||||
if demotes_super and _active_super_count(db) <= 1:
|
||||
raise HTTPException(status_code=400, detail="不能降级/禁用最后一个超级管理员")
|
||||
|
||||
if body.role is not None:
|
||||
_validate_role(db, body.role)
|
||||
|
||||
changes: dict = {}
|
||||
if body.role is not None and body.role != target.role:
|
||||
changes["role"] = {"before": target.role, "after": body.role}
|
||||
@@ -98,7 +77,6 @@ def update_admin(
|
||||
if body.password is not None:
|
||||
changes["password"] = "reset"
|
||||
target.password_hash = hash_password(body.password)
|
||||
target.plain_password = body.password # 同步留存明文,权限管理页复看保持一致
|
||||
if not changes:
|
||||
raise HTTPException(status_code=400, detail="无任何变更字段")
|
||||
db.commit()
|
||||
@@ -108,27 +86,3 @@ def update_admin(
|
||||
detail=changes, ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return AdminOut.model_validate(target)
|
||||
|
||||
|
||||
@router.delete("/{admin_id}", summary="删除管理员(带审计)")
|
||||
def delete_admin(admin_id: int, request: Request, admin: CurrentAdmin, db: AdminDb) -> dict:
|
||||
target = admin_repo.get_by_id(db, admin_id)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="管理员不存在")
|
||||
if admin_id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="不能删除自己")
|
||||
# 防自锁:删掉某个 active super_admin 前,确认后仍至少剩 1 个,否则进「零可用超管」死局。
|
||||
if (
|
||||
target.role == "super_admin"
|
||||
and target.status == "active"
|
||||
and _active_super_count(db) <= 1
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="不能删除最后一个超级管理员")
|
||||
username = target.username
|
||||
db.delete(target)
|
||||
db.commit()
|
||||
write_audit(
|
||||
db, admin, action="admin.delete", target_type="admin", target_id=admin_id,
|
||||
detail={"username": username, "role": target.role}, ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return {"deleted": True}
|
||||
|
||||
@@ -6,7 +6,6 @@ import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.admin.deps import AdminDb, CurrentAdmin
|
||||
from app.admin.repositories import admin_role as role_repo
|
||||
from app.admin.repositories import admin_user as admin_repo
|
||||
from app.admin.schemas.auth import AdminLoginRequest, AdminLoginResponse, AdminOut
|
||||
from app.admin.security import create_admin_token
|
||||
@@ -18,13 +17,6 @@ logger = logging.getLogger("shagua.admin.auth")
|
||||
router = APIRouter(prefix="/admin/api/auth", tags=["admin-auth"])
|
||||
|
||||
|
||||
def _admin_out_with_pages(admin, db: AdminDb) -> AdminOut: # noqa: ANN001
|
||||
"""AdminOut + 当前角色有效可见页(前端左侧导航按此过滤)。"""
|
||||
out = AdminOut.model_validate(admin)
|
||||
out.pages = role_repo.effective_pages_of(db, admin.role)
|
||||
return out
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=AdminLoginResponse,
|
||||
@@ -47,10 +39,10 @@ def login(req: AdminLoginRequest, db: AdminDb) -> AdminLoginResponse:
|
||||
return AdminLoginResponse(
|
||||
access_token=token,
|
||||
expires_in=expires_in,
|
||||
admin=_admin_out_with_pages(admin, db),
|
||||
admin=AdminOut.model_validate(admin),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=AdminOut, summary="当前管理员(含有效可见页)")
|
||||
def me(admin: CurrentAdmin, db: AdminDb) -> AdminOut:
|
||||
return _admin_out_with_pages(admin, db)
|
||||
@router.get("/me", response_model=AdminOut, summary="当前管理员")
|
||||
def me(admin: CurrentAdmin) -> AdminOut:
|
||||
return AdminOut.model_validate(admin)
|
||||
|
||||
@@ -32,15 +32,12 @@ def list_comparison_records(
|
||||
phone: Annotated[str | None, Query(description="手机号前缀")] = None,
|
||||
status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None,
|
||||
business_type: Annotated[str | None, Query()] = None,
|
||||
store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None,
|
||||
product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[AdminComparisonListItem]:
|
||||
items, next_cursor, total = queries.list_comparison_records(
|
||||
db, user_id=user_id, phone=phone, status=status,
|
||||
business_type=business_type, store=store, product=product,
|
||||
limit=limit, cursor=cursor,
|
||||
business_type=business_type, limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[AdminComparisonListItem.model_validate(r) for r in items],
|
||||
|
||||
@@ -55,14 +55,9 @@ def _item(db, key: str) -> ConfigItemOut:
|
||||
raise HTTPException(status_code=404, detail="未知配置项")
|
||||
|
||||
|
||||
@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值(不含 hidden)")
|
||||
@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值")
|
||||
def list_config(db: AdminDb) -> list[ConfigItemOut]:
|
||||
# hidden 项(已下线/由专用页管理,如福利页任务·里程碑·看广告调参、首页轮播数据源)不在本页渲染。
|
||||
return [
|
||||
ConfigItemOut(**item)
|
||||
for item in app_config.list_all(db)
|
||||
if not CONFIG_DEFS[item["key"]].get("hidden")
|
||||
]
|
||||
return [ConfigItemOut(**item) for item in app_config.list_all(db)]
|
||||
|
||||
|
||||
@router.patch("/{key}", response_model=ConfigItemOut, summary="改某项配置(带审计)")
|
||||
|
||||
@@ -8,7 +8,6 @@ from __future__ import annotations
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||
@@ -23,11 +22,7 @@ from app.admin.schemas.ops_marquee_seed import (
|
||||
)
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.ops_marquee_seed import OpsMarqueeSeed
|
||||
from app.repositories import app_config, ops_marquee
|
||||
|
||||
|
||||
class MarqueeModeUpdate(BaseModel):
|
||||
mode: str # mixed / real / seed
|
||||
from app.repositories import ops_marquee
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/marquee-seeds",
|
||||
@@ -57,42 +52,9 @@ def list_seeds(db: AdminDb) -> list[OpsMarqueeSeedOut]:
|
||||
def preview_feed(
|
||||
db: AdminDb,
|
||||
limit: Annotated[int, Query(ge=1, le=30)] = 8,
|
||||
mode: Annotated[str | None, Query(description="mixed/real/seed;不传=当前持久化模式")] = None,
|
||||
) -> OpsSavingsFeedPreviewOut:
|
||||
"""返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。
|
||||
|
||||
mode 显式指定则预览该模式(**不改持久化配置**,供前端切换开关时实时预览);不传则用当前持久化模式。
|
||||
"""
|
||||
if mode is not None and mode not in ops_marquee.FEED_MODES:
|
||||
raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed")
|
||||
return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit, mode=mode))
|
||||
|
||||
|
||||
# 注:/mode 两个端点须在 /{seed_id} 之前注册,否则 PATCH /mode 会被 /{seed_id} 抢先按 id 解析。
|
||||
@router.get("/mode", summary="首页轮播数据源模式(mixed/real/seed)")
|
||||
def get_feed_mode(db: AdminDb) -> dict:
|
||||
"""当前轮播取数模式:mixed=真实优先+种子补位(默认)/ real=只真实 / seed=只种子·合成。"""
|
||||
return {"mode": ops_marquee.get_feed_mode(db)}
|
||||
|
||||
|
||||
@router.patch("/mode", summary="改首页轮播数据源模式(带审计)")
|
||||
def set_feed_mode(
|
||||
body: MarqueeModeUpdate,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> dict:
|
||||
if body.mode not in ops_marquee.FEED_MODES:
|
||||
raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed")
|
||||
before = ops_marquee.get_feed_mode(db)
|
||||
app_config.set_value(db, "marquee_feed_mode", body.mode, admin_id=admin.id, commit=False)
|
||||
write_audit(
|
||||
db, admin, action="ops_marquee_seed.set_mode", target_type="config",
|
||||
target_id="marquee_feed_mode", detail={"before": before, "after": body.mode},
|
||||
ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return {"mode": body.mode}
|
||||
"""返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。"""
|
||||
return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit))
|
||||
|
||||
|
||||
@router.post("", response_model=OpsMarqueeSeedOut, summary="新增轮播种子(带审计)")
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
"""admin RBAC 角色管理(仅 super_admin):列角色 / 权限目录 / 增删改角色。均写审计。
|
||||
|
||||
角色 = 一组「可见页面」(见 app/admin/permissions.py)。角色登录后台后左侧只展示其 pages 对应导航项。
|
||||
只有 super_admin(内建全权角色)能进本组端点(dependencies=require_role() 无参 = 仅 super_admin)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_role
|
||||
from app.admin.permissions import (
|
||||
PERMISSION_CATALOG,
|
||||
SUPER_ADMIN_ROLE,
|
||||
effective_pages,
|
||||
sanitize_pages,
|
||||
)
|
||||
from app.admin.repositories import admin_role as role_repo
|
||||
from app.admin.schemas.role import (
|
||||
PermissionGroup,
|
||||
RoleCreateRequest,
|
||||
RoleOut,
|
||||
RoleUpdateRequest,
|
||||
)
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.admin_role import AdminRole
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/roles",
|
||||
tags=["admin-roles"],
|
||||
dependencies=[Depends(require_role())], # 无参 = 仅 super_admin
|
||||
)
|
||||
|
||||
|
||||
def _usage_counts(db: AdminDb) -> dict[str, int]:
|
||||
rows = db.execute(
|
||||
select(AdminUser.role, func.count(AdminUser.id)).group_by(AdminUser.role)
|
||||
).all()
|
||||
return {r: c for r, c in rows}
|
||||
|
||||
|
||||
def _to_out(role: AdminRole, usage: dict[str, int]) -> RoleOut:
|
||||
return RoleOut(
|
||||
id=role.id,
|
||||
name=role.name,
|
||||
label=role.label or role.name,
|
||||
pages=effective_pages(role.name, role.pages),
|
||||
is_builtin=role.is_builtin,
|
||||
in_use=usage.get(role.name, 0),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[RoleOut], summary="角色列表(含可见页 + 使用数)")
|
||||
def list_roles(db: AdminDb) -> list[RoleOut]:
|
||||
usage = _usage_counts(db)
|
||||
return [_to_out(r, usage) for r in role_repo.list_roles(db)]
|
||||
|
||||
|
||||
@router.get("/catalog", response_model=list[PermissionGroup], summary="页面权限目录(分组)")
|
||||
def get_catalog() -> list[PermissionGroup]:
|
||||
return [PermissionGroup(**g) for g in PERMISSION_CATALOG]
|
||||
|
||||
|
||||
@router.post("", response_model=RoleOut, summary="新增角色(带审计)")
|
||||
def create_role(
|
||||
body: RoleCreateRequest, request: Request, admin: CurrentAdmin, db: AdminDb
|
||||
) -> RoleOut:
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="角色名称不能为空")
|
||||
if name == SUPER_ADMIN_ROLE:
|
||||
raise HTTPException(status_code=400, detail="super_admin 为内建角色,不能新建")
|
||||
if role_repo.get_role(db, name) is not None:
|
||||
raise HTTPException(status_code=409, detail="角色名称已存在")
|
||||
# 自定义角色:name(key)= label = 输入名称
|
||||
role = role_repo.create_role(db, name=name, label=name, pages=body.pages)
|
||||
write_audit(
|
||||
db, admin, action="role.create", target_type="role", target_id=str(role.id),
|
||||
detail={"name": name, "pages": role.pages}, ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return _to_out(role, _usage_counts(db))
|
||||
|
||||
|
||||
@router.patch("/{role_id}", response_model=RoleOut, summary="改角色(展示名/可见页,带审计)")
|
||||
def update_role(
|
||||
role_id: int, body: RoleUpdateRequest, request: Request, admin: CurrentAdmin, db: AdminDb
|
||||
) -> RoleOut:
|
||||
role = db.get(AdminRole, role_id)
|
||||
if role is None:
|
||||
raise HTTPException(status_code=404, detail="角色不存在")
|
||||
if role.is_builtin:
|
||||
raise HTTPException(status_code=400, detail="内建角色「管理员」不可编辑")
|
||||
|
||||
new_label = body.label.strip() if body.label is not None else None
|
||||
changes: dict = {}
|
||||
if new_label and new_label != role.label:
|
||||
changes["label"] = {"before": role.label, "after": new_label}
|
||||
if body.pages is not None:
|
||||
changes["pages"] = {"before": role.pages, "after": sanitize_pages(body.pages)}
|
||||
if not changes:
|
||||
raise HTTPException(status_code=400, detail="无任何变更字段")
|
||||
|
||||
# 只改展示名 + 可见页;key(name)不可变,故无需级联 admin_user.role
|
||||
role_repo.update_role(db, role, label=new_label, pages=body.pages)
|
||||
write_audit(
|
||||
db, admin, action="role.update", target_type="role", target_id=str(role_id),
|
||||
detail=changes, ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return _to_out(role, _usage_counts(db))
|
||||
|
||||
|
||||
@router.delete("/{role_id}", summary="删角色(带审计;内建/在用不可删)")
|
||||
def delete_role(role_id: int, request: Request, admin: CurrentAdmin, db: AdminDb) -> dict:
|
||||
role = db.get(AdminRole, role_id)
|
||||
if role is None:
|
||||
raise HTTPException(status_code=404, detail="角色不存在")
|
||||
if role.is_builtin:
|
||||
raise HTTPException(status_code=400, detail="内建角色不可删除")
|
||||
used = _usage_counts(db).get(role.name, 0)
|
||||
if used > 0:
|
||||
raise HTTPException(status_code=400, detail=f"该角色仍有 {used} 名管理员在用,请先改派再删")
|
||||
name = role.name
|
||||
role_repo.delete_role(db, role)
|
||||
write_audit(
|
||||
db, admin, action="role.delete", target_type="role", target_id=str(role_id),
|
||||
detail={"name": name}, ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return {"deleted": True}
|
||||
@@ -42,12 +42,7 @@ def list_users(
|
||||
created_to: Annotated[datetime | None, Query()] = None,
|
||||
last_login_from: Annotated[datetime | None, Query()] = None,
|
||||
last_login_to: Annotated[datetime | None, Query()] = None,
|
||||
# 最近活跃(登录/发起比价/发起领券取最大,见 queries._last_active_expr)筛选与排序
|
||||
last_active_from: Annotated[datetime | None, Query()] = None,
|
||||
last_active_to: Annotated[datetime | None, Query()] = None,
|
||||
sort_by: Annotated[
|
||||
str, Query(pattern="^(id|created_at|last_login_at|last_active_at)$")
|
||||
] = "id",
|
||||
sort_by: Annotated[str, Query(pattern="^(id|created_at|last_login_at)$")] = "id",
|
||||
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
@@ -56,7 +51,6 @@ def list_users(
|
||||
db, phone=phone, register_channel=register_channel, status=status,
|
||||
nickname=nickname, created_from=created_from, created_to=created_to,
|
||||
last_login_from=last_login_from, last_login_to=last_login_to,
|
||||
last_active_from=last_active_from, last_active_to=last_active_to,
|
||||
sort_by=sort_by, sort_order=sort_order, limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
|
||||
@@ -136,16 +136,9 @@ class AdRevenueReportOut(BaseModel):
|
||||
default_factory=dict,
|
||||
description="按广告类型(ad_type)小计 {ad_type: {impressions, revenue_yuan}};前端取 draw / reward_video 做分类大盘",
|
||||
)
|
||||
scene_stats: dict[str, AdRevenueTypeStat] = Field(
|
||||
default_factory=dict,
|
||||
description="按信息流场景(feed_scene)小计 {comparison/coupon/welfare: {impressions, revenue_yuan}};"
|
||||
"全量统计(不受分页截断),供数据大盘「领券广告 / 比价广告」卡;feed_scene 为空的事件不计入",
|
||||
)
|
||||
dau: int | None = Field(
|
||||
None,
|
||||
description="所选日期区间的去重活跃用户数(口径同数据大盘 period.users.active:登录 + 开始比价 + "
|
||||
"开始领券)。按 date_from~date_to 区间统计,含今日、近 7 天、近 30 天等任意区间;全局口径,"
|
||||
"不随 user_id / ad_type / feed_scene / app_env 筛选变化",
|
||||
description="今日活跃用户数(复用大盘口径,last_login_at);**仅查询=今日单天时有值**,历史/多天为 null",
|
||||
)
|
||||
total: int = Field(..., description="广告事件总数(全量,不受分页影响;= 当前筛选下的分页总条数)")
|
||||
truncated: bool = Field(..., description="当前页之后是否还有更多事件(len(events) > offset + limit)")
|
||||
|
||||
@@ -6,19 +6,19 @@ from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
# 角色不再硬编码枚举:改为任意角色名(自定义角色由 admin_role 表管理),存在性在路由层校验。
|
||||
_Role = Literal["super_admin", "finance", "operator"]
|
||||
|
||||
|
||||
class AdminCreateRequest(BaseModel):
|
||||
username: str = Field(..., min_length=3, max_length=64)
|
||||
password: str = Field(..., min_length=8, max_length=72) # bcrypt ≤72 字节
|
||||
role: str = Field("operator", min_length=1, max_length=32)
|
||||
role: _Role = "operator"
|
||||
|
||||
|
||||
class AdminUpdateRequest(BaseModel):
|
||||
"""改角色 / 启用禁用 / 重置密码,字段都可选(只改传了的)。"""
|
||||
|
||||
role: str | None = Field(None, min_length=1, max_length=32)
|
||||
role: _Role | None = None
|
||||
status: Literal["active", "disabled"] | None = None
|
||||
password: str | None = Field(None, min_length=8, max_length=72)
|
||||
|
||||
|
||||
@@ -20,12 +20,6 @@ class AdminOut(BaseModel):
|
||||
status: str
|
||||
created_at: datetime
|
||||
last_login_at: datetime | None = None
|
||||
# 该管理员当前角色的有效可见页(= 左侧导航项 key);仅登录 / /me 填充,列表接口默认空。
|
||||
# 前端据此过滤左侧导航(super_admin = 全部页)。见 app/admin/permissions.py。
|
||||
pages: list[str] = []
|
||||
# 明文登录密码:仅「管理员账号列表」(super_admin 专属路由)填充,供权限管理页编辑时复看;
|
||||
# 无留存(脚本建的超管 / 旧账号)为 None → 前端不显示。登录 / /me 不下发(保持 None)。
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class AdminLoginResponse(BaseModel):
|
||||
|
||||
@@ -23,7 +23,6 @@ class AdminComparisonListItem(BaseModel):
|
||||
status: str
|
||||
information: str | None = None
|
||||
store_name: str | None = None
|
||||
product_names: str | None = None # 下单商品名派生串(顿号分隔;「商品」列展示 + 商品搜索)
|
||||
source_platform_name: str | None = None
|
||||
best_platform_name: str | None = None
|
||||
source_price_cents: int | None = None
|
||||
|
||||
@@ -43,10 +43,7 @@ class DashboardComparison(BaseModel):
|
||||
class DashboardPeriodUsers(BaseModel):
|
||||
new: int
|
||||
active: int
|
||||
# 次日留存(2026-07-05 起):retained_new_users = 窗口内逐日「前一日新增且当日活跃」用户数之和,
|
||||
# retention_cohort = 对应的前一日新增基数之和,retention_rate = 两者之比。
|
||||
retained_new_users: int
|
||||
retention_cohort: int = 0
|
||||
retention_rate: float | None = None
|
||||
retention_note: str
|
||||
|
||||
@@ -60,24 +57,6 @@ class DashboardPeriodComparison(BaseModel):
|
||||
average_saved_cents: int | None = None
|
||||
|
||||
|
||||
class DashboardPeriodCoupon(BaseModel):
|
||||
"""领券核心数据(2026-07-05 产品新增)。点位=一张券(coupon_claim_record 一天一条终态);
|
||||
成功口径 success+already_claimed(与「我的」页累计领券一致)。"""
|
||||
|
||||
started: int = 0
|
||||
# 全部领成功的次数:completed 且当日该设备全部点位成功
|
||||
all_success: int = 0
|
||||
success_rate: float | None = None
|
||||
# 本期 session 触达的点位中成功的条数(同设备同日去重)
|
||||
point_success: int = 0
|
||||
# 每次发起的应领点位数(本期完成场实际点位数的众数;无完成场为空)
|
||||
points_per_session: int | None = None
|
||||
# 点位成功率 = point_success / (started × points_per_session);未跑到的点位计入分母视为失败
|
||||
point_success_rate: float | None = None
|
||||
# 耗时中位数(仅 completed 的 elapsed_ms,同「领券数据」页口径)
|
||||
median_elapsed_ms: int | None = None
|
||||
|
||||
|
||||
class DashboardPeriodCoins(BaseModel):
|
||||
granted_total: int
|
||||
reward_video_coin_total: int = 0
|
||||
@@ -106,7 +85,6 @@ class DashboardPeriod(BaseModel):
|
||||
date_to: date
|
||||
users: DashboardPeriodUsers
|
||||
comparison: DashboardPeriodComparison
|
||||
coupon: DashboardPeriodCoupon = DashboardPeriodCoupon()
|
||||
coins: DashboardPeriodCoins
|
||||
cash: DashboardPeriodCash
|
||||
trend: list[DashboardTrendPoint] = []
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
"""admin RBAC 角色 + 权限目录 schemas。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PermissionPage(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
|
||||
|
||||
class PermissionGroup(BaseModel):
|
||||
group: str
|
||||
pages: list[PermissionPage]
|
||||
|
||||
|
||||
class RoleOut(BaseModel):
|
||||
id: int
|
||||
name: str # 角色 key(承重,不可变)
|
||||
label: str # 展示名(UI 显示)
|
||||
pages: list[str] # 有效可见页 key(super_admin = 全部页)
|
||||
is_builtin: bool # 内建角色(super_admin):不可编辑/删除
|
||||
in_use: int # 使用该角色的管理员数(删除前端提示 / 拦截用)
|
||||
|
||||
|
||||
class RoleCreateRequest(BaseModel):
|
||||
# 新增自定义角色:name 即用户输入的名称(同时作 key 与展示名)
|
||||
name: str = Field(..., min_length=1, max_length=32)
|
||||
pages: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RoleUpdateRequest(BaseModel):
|
||||
# 只改展示名 + 可见页;key 不可变
|
||||
label: str | None = Field(None, min_length=1, max_length=32)
|
||||
pages: list[str] | None = None
|
||||
@@ -20,9 +20,6 @@ class AdminUserListItem(BaseModel):
|
||||
wechat_nickname: str | None = None
|
||||
created_at: datetime
|
||||
last_login_at: datetime
|
||||
# 最近活跃 = max(最近登录, 最近发起比价, 最近发起领券);列表页由 queries._attach_last_active
|
||||
# 瞬态挂上。其他复用本 schema 的入口(用户 360 等)没挂该属性 → None(前端显示 '-')。
|
||||
last_active_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminUserOverview(BaseModel):
|
||||
|
||||
@@ -130,7 +130,6 @@ class WithdrawBulkResult(BaseModel):
|
||||
|
||||
class WithdrawLedgerCheckOut(BaseModel):
|
||||
ok: bool
|
||||
# 普通现金账(coin_cash:金币兑换的现金)
|
||||
cash_balance_total_cents: int
|
||||
cash_transaction_total_cents: int
|
||||
balance_diff_cents: int
|
||||
@@ -138,14 +137,6 @@ class WithdrawLedgerCheckOut(BaseModel):
|
||||
missing_refund_txn_count: int
|
||||
duplicate_refund_txn_count: int
|
||||
refund_txn_on_non_terminal_count: int
|
||||
# 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账)。默认 0 向后兼容。
|
||||
invite_cash_balance_total_cents: int = 0
|
||||
invite_cash_transaction_total_cents: int = 0
|
||||
invite_balance_diff_cents: int = 0
|
||||
invite_missing_withdraw_txn_count: int = 0
|
||||
invite_missing_refund_txn_count: int = 0
|
||||
invite_duplicate_refund_txn_count: int = 0
|
||||
invite_refund_txn_on_non_terminal_count: int = 0
|
||||
|
||||
|
||||
class WxpayHealthCheckOut(BaseModel):
|
||||
|
||||
@@ -54,29 +54,5 @@ def get_current_user(
|
||||
return user
|
||||
|
||||
|
||||
def get_current_user_optional(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> User | None:
|
||||
"""软鉴权:有合法 Bearer 就返回 user,否则(无 header / 无效 token / 用户禁用)一律返回
|
||||
None,**不 raise**。
|
||||
|
||||
比价 step / finalize 灰度期用:新客户端带 JWT → 拿到 user_id 绑定 harvest 行;
|
||||
老客户端(不带 JWT)→ None,harvest 行 user_id 暂空,由其后续 /compare/record 上报补齐。
|
||||
等新版覆盖率够高,再把这几条端点从软鉴权收紧成硬 get_current_user。
|
||||
"""
|
||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||
return None
|
||||
try:
|
||||
payload = decode_token(credentials.credentials, expected_type="access")
|
||||
user = db.get(User, int(payload["sub"]))
|
||||
except (TokenError, KeyError, ValueError, TypeError):
|
||||
return None
|
||||
if user is None or user.status != "active":
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
OptionalUser = Annotated[User | None, Depends(get_current_user_optional)]
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
+7
-6
@@ -12,11 +12,11 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
|
||||
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 enforce_rate_limit, rate_limit
|
||||
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
|
||||
@@ -41,7 +41,7 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
# 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。
|
||||
SMS_LOGIN_MAX_PER_HOUR = 5
|
||||
# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。
|
||||
# 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。
|
||||
# 堵「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞 —— 那两道是单号维度,一机换号能绕开。
|
||||
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5
|
||||
|
||||
|
||||
@@ -100,8 +100,8 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
|
||||
return SmsSendResponse(sent=True, mock=True, cooldown_sec=0)
|
||||
|
||||
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码。
|
||||
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,
|
||||
# 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。
|
||||
# 补「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞(那两道是单号维度,一机换号能绕);设备维度按机器封顶,
|
||||
# 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。与路由上 IP 维度(10次/分钟)互补。
|
||||
enforce_rate_limit(
|
||||
request,
|
||||
scope="sms-send-device",
|
||||
@@ -125,6 +125,7 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
|
||||
"/sms/login",
|
||||
response_model=TokenWithUser,
|
||||
summary="手机号+验证码登录",
|
||||
dependencies=[Depends(rate_limit(20, 60, "sms-login"))], # 防撞库爆破(另有单码失败次数上限)
|
||||
)
|
||||
def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWithUser:
|
||||
# 测试账号:免验证码登录 + 每日上限 + 每次都走新手引导(详见 app/core/test_account.py)。
|
||||
@@ -142,7 +143,7 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
|
||||
return _login_response(user, onboarding_completed=False, force_onboarding=True)
|
||||
|
||||
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试。放在验证码校验
|
||||
# **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破(另有单码失败 SMS_MAX_VERIFY_ATTEMPTS 次即作废兜底)。
|
||||
# **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破。与路由上 IP 维度的 sms-login 限流(同 IP)互补。
|
||||
# ⚠️ 按设备而非手机号 → 一台机器换不同手机号刷登录也受限(防一机狂登多号);device_id 空(老客户端)时
|
||||
# 退化为该 IP 下所有空设备聚一桶,仍受限。
|
||||
enforce_rate_limit(
|
||||
|
||||
+60
-203
@@ -1,114 +1,49 @@
|
||||
"""外卖比价业务透传端点 + 后端 harvest 落库。
|
||||
"""外卖比价业务透传端点。
|
||||
|
||||
把客户端 POST /api/v1/intent/* 和 /api/v1/price/step 转发给 pricebot,同时**由 app-server
|
||||
在透传里直接落库比价记录**(不再靠客户端 POST /compare/record):
|
||||
- 帧0(客户端首帧不带 trace_id)→ app-server 用 uuid 签发 trace_id、注入转发 body、回给
|
||||
客户端;并按 trace_id 建 running 行(harvest_running)。
|
||||
- 最终 done 帧 → 更新成 success/failed + 结果(harvest_done)。发奖不在这里:#113 已把
|
||||
邀请奖口径从「比价」移到「实际下单」(order.py)。
|
||||
- /trace/finalize(用户终止/Phase1 未识别,无 done)→ 更新成 cancelled/failed
|
||||
(harvest_abort,**不降级 success**)。
|
||||
trace_url 从 pricebot 响应**顶层 trace_url** 取(pricebot 每帧都带,见其 goal_engine.process_step)。
|
||||
把客户端的 POST /api/v1/intent/recognize 和 /api/v1/price/step 请求原样转发给
|
||||
pricebot-backend 的 /api/intent/recognize 和 /api/price/step。MVP 阶段**不鉴权**
|
||||
(同 coupon/step,见 docs/待办与技术债.md P1:device_id 透传区分设备,待补 JWT +
|
||||
device_id↔user_id 绑定后才能做用户级画像)。
|
||||
|
||||
鉴权:软鉴权(OptionalUser)——新客户端带 JWT → 绑 user_id;老客户端不带 → user_id 暂空,
|
||||
由其后续 /compare/record 上报补(灰度期两条写路径按 trace_id reconcile,success 不被降级)。
|
||||
- Phase 1 /intent/recognize:从源平台(淘宝闪购/美团/京东外卖)购物车页识别店名+菜品+
|
||||
价格,一次性。
|
||||
- Phase 2 /price/step:多轮循环,在目标平台复现订单读到手价,直到 done。
|
||||
|
||||
真正的目标驱动比价逻辑在 pricebot-backend(GoalEngine,另一个 repo),本接口是透传壳 + 落库。
|
||||
pricebot 协议文档: pricebot-backend/docs/main/02_api_protocol.md
|
||||
真正的目标驱动比价逻辑在 pricebot-backend(GoalEngine,另一个 repo),本接口只是透传壳。
|
||||
电商(ecom)那两个端点 food MVP 暂不需要,以后放开 scene 时再加 /ecom/intent/recognize
|
||||
和 /ecom/step 两行即可。
|
||||
|
||||
pricebot 协议文档:
|
||||
pricebot-backend/docs/main/02_api_protocol.md
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
from app.api.deps import OptionalUser
|
||||
from app.core.config import settings
|
||||
from app.core.logging import trace_id_ctx
|
||||
from app.core.pricebot_client import get_pricebot_client
|
||||
from app.core.pricebot_router import pick_pricebot
|
||||
from app.db.session import SessionLocal
|
||||
from app.repositories import comparison as crud_compare
|
||||
|
||||
logger = logging.getLogger("shagua.compare")
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["compare"])
|
||||
|
||||
|
||||
# ============================================================
|
||||
# harvest 落库(阻塞 SQLAlchemy → run_in_threadpool,独立 SessionLocal,不阻塞事件循环;
|
||||
# 任何写库异常都吞掉、绝不连累比价透传返回 —— 同 coupon.py 现有 best-effort 写法)。
|
||||
# ============================================================
|
||||
async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
|
||||
"""把请求体原样转发给 pricebot-backend 的 {upstream_path}。
|
||||
|
||||
|
||||
def _harvest_running_blocking(
|
||||
trace_id: str, user_id: int | None, business_type: str,
|
||||
device_id: str | None, device_info: dict | None, trace_url: str | None,
|
||||
) -> None:
|
||||
with SessionLocal() as db:
|
||||
crud_compare.harvest_running(
|
||||
db, trace_id=trace_id, user_id=user_id, business_type=business_type,
|
||||
device_id=device_id, device_info=device_info, trace_url=trace_url,
|
||||
)
|
||||
logger.info(
|
||||
"harvest running row (user=%s)", user_id,
|
||||
extra={"phase": "harvest_running", "status": "running", "user_id": user_id},
|
||||
)
|
||||
|
||||
|
||||
def _harvest_done_blocking(
|
||||
trace_id: str, user_id: int | None, done_params: dict, business_type: str,
|
||||
device_id: str | None, device_info: dict | None, trace_url: str | None,
|
||||
) -> None:
|
||||
with SessionLocal() as db:
|
||||
rec, newly_success = crud_compare.harvest_done(
|
||||
db, trace_id=trace_id, user_id=user_id, done_params=done_params,
|
||||
business_type=business_type, device_id=device_id,
|
||||
device_info=device_info, trace_url=trace_url,
|
||||
)
|
||||
logger.info(
|
||||
"harvest done → %s saved=%s newly=%s", rec.status,
|
||||
rec.saved_amount_cents, newly_success,
|
||||
extra={"phase": "harvest_done", "status": rec.status,
|
||||
"saved_cents": rec.saved_amount_cents,
|
||||
"best_platform": rec.best_platform_id, "newly_success": newly_success,
|
||||
"user_id": user_id},
|
||||
)
|
||||
# 不在此处发邀请奖:#113 已把发奖口径从「比价」移到「实际下单」(order.py),harvest
|
||||
# 只记录比价、不发奖。否则比价先于下单 + try_reward 幂等闸会让奖落在「比价」这步,
|
||||
# 架空 #113 的「下单才发奖」防刷意图(newly_success 仅留作日志观测)。
|
||||
|
||||
|
||||
def _harvest_abort_blocking(
|
||||
trace_id: str, status_hint: str, reason: str | None, trace_url: str | None,
|
||||
) -> None:
|
||||
with SessionLocal() as db:
|
||||
rec = crud_compare.harvest_abort(
|
||||
db, trace_id=trace_id, status=status_hint, reason=reason, trace_url=trace_url,
|
||||
)
|
||||
logger.info(
|
||||
"harvest abort → %s", (rec.status if rec else "no-row"),
|
||||
extra={"phase": "harvest_abort",
|
||||
"status": (rec.status if rec else None), "reason": reason},
|
||||
)
|
||||
|
||||
|
||||
async def _forward(
|
||||
request: Request, upstream_path: str, user, *, harvest_first_frame: bool = True,
|
||||
) -> tuple[dict[str, Any], str, dict[str, Any]]:
|
||||
"""透传给 pricebot 的 {upstream_path},并 mint trace_id + 首帧建 running 行。
|
||||
返回 (resp_json, trace_id, meta)。
|
||||
|
||||
- 客户端首帧不带 trace_id → app-server 用 uuid 签发、写进转发 body、回填进响应顶层
|
||||
`trace_id`(客户端据此拿到后续帧都带上)。带了(老客户端 / 后续帧)→ 原样用、走原始 bytes 快路。
|
||||
- 仅**本次 mint(=首帧)**建 running 行(idempotent);后续帧不再写库。
|
||||
跟 coupon_step 同款透传壳:不鉴权、不做 schema 校验,仅读 device_id/trace_id/step
|
||||
打日志。比价单帧是大上下文 / 逐帧 LLM,超时用 PRICEBOT_COMPARE_TIMEOUT_SEC(60s,
|
||||
比领券的 30s 长)。
|
||||
"""
|
||||
# 读原始字节,避免"反序列化→再序列化"的双重 JSON(省 ~一半透传 CPU,让 app-server
|
||||
# 单 worker 也扛得住高并发)。只 json.loads 一次拿 trace_id 做亲和 + 打日志,转发时
|
||||
# 直接发原始 bytes(content=raw),不重新 dumps。
|
||||
raw = await request.body()
|
||||
try:
|
||||
meta = json.loads(raw)
|
||||
@@ -117,41 +52,26 @@ async def _forward(
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
|
||||
trace_id = meta.get("trace_id")
|
||||
minted = False
|
||||
if not trace_id:
|
||||
trace_id = str(uuid.uuid4())
|
||||
meta["trace_id"] = trace_id
|
||||
raw = json.dumps(meta).encode() # 仅首帧重新序列化(注入 trace_id);后续帧走原始 bytes
|
||||
minted = True
|
||||
|
||||
# 请求级 trace_id 贯穿:此后本请求(含 run_in_threadpool 里的 harvest)每行日志自动带 trace_id
|
||||
trace_id_ctx.set(trace_id)
|
||||
|
||||
base = pick_pricebot(trace_id)
|
||||
# 按 trace_id 一致性 hash 选 pricebot 实例(同一比价的所有帧落同一进程,内存维护 state)
|
||||
base = pick_pricebot(meta.get("trace_id"))
|
||||
url = f"{base.rstrip('/')}{upstream_path}"
|
||||
timeout = settings.PRICEBOT_COMPARE_TIMEOUT_SEC
|
||||
step = meta.get("step")
|
||||
|
||||
logger.info(
|
||||
"→ pricebot %s step=%s%s", upstream_path, step, " [mint]" if minted else "",
|
||||
extra={"phase": "forward", "endpoint": upstream_path, "step": step,
|
||||
"device_id": meta.get("device_id"), "minted": minted,
|
||||
"query": meta.get("query"), "user_id": (user.id if user else None)},
|
||||
"compare %s device_id=%s trace_id=%s step=%s",
|
||||
upstream_path,
|
||||
meta.get("device_id"),
|
||||
meta.get("trace_id"),
|
||||
meta.get("step"),
|
||||
)
|
||||
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
client = get_pricebot_client()
|
||||
resp = await client.post(
|
||||
url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.error(
|
||||
"← pricebot %s 不可达: %s", upstream_path, e,
|
||||
extra={"phase": "resp", "endpoint": upstream_path, "step": step,
|
||||
"error": "upstream_unreachable"},
|
||||
)
|
||||
logger.error("[pricebot] request failed: %s", e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"pricebot upstream unreachable: {e}",
|
||||
@@ -159,112 +79,49 @@ async def _forward(
|
||||
|
||||
if resp.status_code >= 500:
|
||||
logger.error(
|
||||
"← pricebot %s 5xx=%d", upstream_path, resp.status_code,
|
||||
extra={"phase": "resp", "endpoint": upstream_path, "step": step,
|
||||
"http_status": resp.status_code, "error": "upstream_5xx"},
|
||||
"[pricebot] 5xx status=%d body=%s",
|
||||
resp.status_code,
|
||||
resp.text[:500],
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"pricebot upstream returned {resp.status_code}",
|
||||
)
|
||||
|
||||
resp_json = resp.json()
|
||||
cost_ms = int((time.monotonic() - t0) * 1000)
|
||||
if not isinstance(resp_json, dict):
|
||||
return {}, trace_id, meta
|
||||
# 回传 app-server 签发的 trace_id(新客户端首帧据此拿到,后续帧都带上它)
|
||||
resp_json.setdefault("trace_id", trace_id)
|
||||
_action = resp_json.get("action") or {}
|
||||
logger.info(
|
||||
"← pricebot %s cmd=%s cont=%s %dms", upstream_path,
|
||||
_action.get("command"), resp_json.get("continue"), cost_ms,
|
||||
extra={"phase": "resp", "endpoint": upstream_path, "step": step,
|
||||
"command": _action.get("command"), "continue": resp_json.get("continue"),
|
||||
"cost_ms": cost_ms},
|
||||
)
|
||||
|
||||
# 首帧建 running 行(仅本次 mint;老客户端自带 trace_id → 不 mint → 由 done / 其 POST 建行)
|
||||
if minted and harvest_first_frame:
|
||||
try:
|
||||
await run_in_threadpool(
|
||||
_harvest_running_blocking, trace_id,
|
||||
(user.id if user else None), "food",
|
||||
meta.get("device_id"), meta.get("device_info"),
|
||||
resp_json.get("trace_url"),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("harvest_running failed trace=%s: %s", trace_id, e)
|
||||
|
||||
return resp_json, trace_id, meta
|
||||
return resp.json()
|
||||
|
||||
|
||||
@router.post("/intent/recognize", summary="外卖比价 Phase 1 意图识别 (透传 + 建 running 行)")
|
||||
async def intent_recognize(request: Request, user: OptionalUser) -> dict[str, Any]:
|
||||
resp, _, _ = await _forward(request, "/api/intent/recognize", user)
|
||||
return resp
|
||||
@router.post("/intent/recognize", summary="外卖比价 Phase 1 意图识别 (透传到 pricebot)")
|
||||
async def intent_recognize(request: Request) -> dict[str, Any]:
|
||||
return await _passthrough(request, "/api/intent/recognize")
|
||||
|
||||
|
||||
@router.post("/intent/step", summary="外卖比价 Phase 1 多帧意图识别 (透传, 仅淘宝源)")
|
||||
async def intent_step(request: Request, user: OptionalUser) -> dict[str, Any]:
|
||||
resp, _, _ = await _forward(request, "/api/intent/step", user)
|
||||
return resp
|
||||
@router.post("/intent/step", summary="外卖比价 Phase 1 多帧意图识别 (透传到 pricebot, 仅淘宝源)")
|
||||
async def intent_step(request: Request) -> dict[str, Any]:
|
||||
# 多帧版意图识别(展开+滚动采集→提取): 循环调用直到 done(done 帧顶层带
|
||||
# result+calibration)。目前仅淘宝源走这条, 其它源走上面单次 /intent/recognize。
|
||||
return await _passthrough(request, "/api/intent/step")
|
||||
|
||||
|
||||
@router.post("/intent/precoupon/step", summary="外卖比价 Phase 0 识别前先用券 (透传, 仅美团源)")
|
||||
async def intent_precoupon_step(request: Request, user: OptionalUser) -> dict[str, Any]:
|
||||
resp, _, _ = await _forward(request, "/api/intent/precoupon/step", user)
|
||||
return resp
|
||||
@router.post(
|
||||
"/intent/precoupon/step",
|
||||
summary="外卖比价 Phase 0 意图识别前先用券 (透传到 pricebot, 仅美团源)",
|
||||
)
|
||||
async def intent_precoupon_step(request: Request) -> dict[str, Any]:
|
||||
# 美团源平台『意图识别前先用券』多帧循环: 客户端在调 /intent/recognize 之前先循环
|
||||
# 调本端点到 done(continue=false)。订单页底部有『点击使用X红包』就自动选最大免费券
|
||||
# 用上, 已用券/无券则首帧秒过。与 /intent/step 同属 intent 域, 复用同一透传壳。
|
||||
return await _passthrough(request, "/api/intent/precoupon/step")
|
||||
|
||||
|
||||
@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传 + done 落库)")
|
||||
async def price_step(request: Request, user: OptionalUser) -> dict[str, Any]:
|
||||
resp, trace_id, meta = await _forward(request, "/api/price/step", user)
|
||||
# 最终 done 帧(command=done 且 continue=false)→ harvest 更新成终态。
|
||||
# (单平台中途 done 被 pricebot 改写成 wait+continue=true,不会命中这里,同 coupon 语义。)
|
||||
action = resp.get("action") or {}
|
||||
if action.get("command") == "done" and not resp.get("continue", True):
|
||||
done_params = action.get("params") or {}
|
||||
try:
|
||||
await run_in_threadpool(
|
||||
_harvest_done_blocking, trace_id, (user.id if user else None),
|
||||
done_params, "food",
|
||||
meta.get("device_id"), meta.get("device_info"),
|
||||
resp.get("trace_url") or done_params.get("trace_url"),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("harvest_done failed trace=%s: %s", trace_id, e)
|
||||
return resp
|
||||
@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传到 pricebot)")
|
||||
async def price_step(request: Request) -> dict[str, Any]:
|
||||
return await _passthrough(request, "/api/price/step")
|
||||
|
||||
|
||||
@router.post("/trace/epilogue", summary="比价结果页尾声帧 (透传到 pricebot, 用户视角截图入 trace)")
|
||||
async def trace_epilogue(request: Request, user: OptionalUser) -> dict[str, Any]:
|
||||
# App 收到 done、渲染完结果页后,把自己页面的截图(base64)传给 pricebot 存进 trace
|
||||
# 目录并触发重传 —— trace 里补上"用户实际看到的汇总页"(步骤帧只有目标 App 画面)。
|
||||
# body ~几百 KB(截图 base64), 纯透传壳(harvest_first_frame=False:不建行/不落库,
|
||||
# 该 trace 的记录已由 done/finalize 落终态)。#112 原走 _passthrough,合并到 harvest
|
||||
# 分支后统一走 _forward(_passthrough 已并入它)。
|
||||
resp, _, _ = await _forward(
|
||||
request, "/api/trace/epilogue", user, harvest_first_frame=False,
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传 + 夭折落库)")
|
||||
async def trace_finalize(request: Request, user: OptionalUser) -> dict[str, Any]:
|
||||
# 用户终止 / Phase1 未识别没到 done 帧: pricebot 打包半截上云返回 {trace_url};
|
||||
# app-server 顺手把该 trace 的 running 行更新成夭折终态(**不降级 success**)。
|
||||
# 客户端 finalize body 带 status(cancelled/failed)+ reason;老客户端只带 trace_id →
|
||||
# 默认 cancelled,其后续 /compare/record 上报再补精确态。
|
||||
resp, trace_id, meta = await _forward(
|
||||
request, "/api/trace/finalize", user, harvest_first_frame=False,
|
||||
)
|
||||
try:
|
||||
await run_in_threadpool(
|
||||
_harvest_abort_blocking, trace_id,
|
||||
(meta.get("status") or "cancelled"),
|
||||
(meta.get("reason") or meta.get("information")),
|
||||
(resp.get("trace_url") if isinstance(resp, dict) else None),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("harvest_abort failed trace=%s: %s", trace_id, e)
|
||||
return resp
|
||||
@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传到 pricebot, 终止/未识别拿 trace_url)")
|
||||
async def trace_finalize(request: Request) -> dict[str, Any]:
|
||||
# 用户终止 / Phase1 未识别没走到 done 帧, pricebot 没上云也没回传 trace_url。客户端收尾时
|
||||
# 打这个, _passthrough 按 trace_id 一致性 hash 落到处理这条 trace 的同一 pricebot 进程
|
||||
# (dir_cache 在那, 才能算对 trace 目录), 由后者打包上云返回 {trace_url}。
|
||||
return await _passthrough(request, "/api/trace/finalize")
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"""比价记录 endpoint(「我的比价记录」+ admin 数据源)。
|
||||
"""比价记录 endpoint(「我的比价记录」数据源)。
|
||||
|
||||
路由前缀 `/api/v1/compare`:
|
||||
POST /record (灰度期兼容)老客户端上报,按 **trace_id** 幂等 + 不降级 success
|
||||
POST /record 上报一次比价结果(幂等:同 user+trace_id 覆盖)
|
||||
GET /records 比价记录列表(游标分页)
|
||||
GET /records/{id} 单条详情(含 raw_payload 全量)
|
||||
|
||||
**均需鉴权**(CurrentUser)。⚠️ 写路径现以 `compare.py` 透传壳的**后端 harvest** 为主
|
||||
(帧0 建 running 行 → done/finalize 落终态,新客户端不再 POST);本 POST /record 仅灰度期
|
||||
给老客户端用,与 harvest 按 trace_id reconcile。新版覆盖够高后可下线本 POST(阶段3)。
|
||||
**均需鉴权**(CurrentUser)——与同文件无关的不鉴权透传 `compare.py` 分开:那个是
|
||||
转发壳(MVP 不鉴权),这里是按用户维度落库的业务接口,必须有 user_id。
|
||||
|
||||
注:本轮只做 server 端,客户端(android 仓)在 done 帧后调 POST /record 上报的改动
|
||||
另起一轮(见 app-server docs/待办与技术债.md P1)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -19,15 +21,16 @@ from app.api.deps import CurrentUser, DbSession
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.repositories import comparison as crud_compare
|
||||
from app.repositories import invite as crud_invite
|
||||
from app.services.pricebot_llm_calls import fetch_llm_calls
|
||||
from app.schemas.compare_record import (
|
||||
CompareStatsOut,
|
||||
ComparisonRecordCreatedOut,
|
||||
ComparisonRecordDetailOut,
|
||||
ComparisonRecordIn,
|
||||
ComparisonRecordOut,
|
||||
ComparisonRecordPage,
|
||||
ComparisonRecordOut,
|
||||
)
|
||||
from app.services.pricebot_llm_calls import fetch_llm_calls
|
||||
|
||||
logger = logging.getLogger("shagua.compare_record")
|
||||
|
||||
@@ -50,8 +53,18 @@ def report_record(
|
||||
# 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用
|
||||
# 独立 session 按 record id 回填 llm_calls + 派生 llm_call_count/retry_count。
|
||||
background_tasks.add_task(_backfill_llm_calls, rec.id, rec.trace_id)
|
||||
# 注:邀请发奖已从"比价成功"挪到"实际下单"(见 api/v1/order.py report_order)——
|
||||
# 冰拍板:被邀请人完成比价并实际下单才算邀请成功,仅完成比价不再发奖。
|
||||
# 邀请 v2 发奖:被邀请人完成一次【成功】比价 → 给邀请人发邀请奖励金(幂等,只发一次)。
|
||||
# best-effort:发奖异常不影响比价上报本身(rec 已 commit),只 log;邀请人补偿靠后续对账。
|
||||
if rec.status == "success":
|
||||
try:
|
||||
reward = crud_invite.try_reward_on_compare(db, user.id)
|
||||
if reward.status == "granted":
|
||||
logger.info(
|
||||
"invite compare reward granted inviter=%s invitee=%s cents=%s",
|
||||
reward.inviter_user_id, user.id, reward.reward_cents,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 best-effort,发奖失败不阻塞上报
|
||||
logger.warning("invite compare reward failed invitee=%s: %s", user.id, e)
|
||||
logger.info(
|
||||
"compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)",
|
||||
user.id,
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.repositories import invite as crud_invite
|
||||
from app.repositories import savings as crud_savings
|
||||
from app.schemas.order import OrderReportOut, OrderReportRequest
|
||||
|
||||
logger = logging.getLogger("shagua.order")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/order", tags=["order"])
|
||||
|
||||
|
||||
@@ -20,20 +15,6 @@ router = APIRouter(prefix="/api/v1/order", tags=["order"])
|
||||
def report_order(req: OrderReportRequest, user: CurrentUser, db: DbSession) -> OrderReportOut:
|
||||
# 记账唯一真相表是 savings_record(source='compare')。
|
||||
rec, duplicated = crud_savings.create_from_report(db, user.id, req)
|
||||
# 邀请 v2 发奖:被邀请人【实际下单】(而非仅完成比价)才给邀请人发邀请奖励金(幂等,只发一次)。
|
||||
# 触发点从"比价成功上报"挪到这里(冰:完成比价并实际下单才算成功)。仅首次真实上报触发,
|
||||
# 重复上报(duplicated)跳过。best-effort:发奖异常不影响订单上报本身(rec 已 commit),只 log;
|
||||
# try_reward_on_compare 自带 compare_reward_granted 幂等闸,漏发靠后续对账补。
|
||||
if not duplicated:
|
||||
try:
|
||||
reward = crud_invite.try_reward_on_compare(db, user.id)
|
||||
if reward.status == "granted":
|
||||
logger.info(
|
||||
"invite order reward granted inviter=%s invitee=%s cents=%s",
|
||||
reward.inviter_user_id, user.id, reward.reward_cents,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 best-effort,发奖失败不阻塞订单上报
|
||||
logger.warning("invite order reward failed invitee=%s: %s", user.id, e)
|
||||
return OrderReportOut(
|
||||
id=rec.id,
|
||||
platform=rec.platform or req.platform,
|
||||
|
||||
@@ -69,18 +69,6 @@ def complete_onboarding(
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.post("/onboarding/reset", response_model=OkResponse, summary="重置新手引导(删该 设备+账号 完成标记,下次登录重走)")
|
||||
def reset_onboarding(
|
||||
req: OnboardingCompleteRequest, user: CurrentUser, db: DbSession
|
||||
) -> OkResponse:
|
||||
"""删该 (当前账号, device_id) 的引导完成标记 → 下次登录 onboarding_completed=false,客户端重走。
|
||||
与 /onboarding/complete 互逆。device_id 取客户端硬件级 ANDROID_ID(与登录/complete 一致)。
|
||||
幂等:无记录也返回 ok。仅删自己(当前 JWT 用户)这台设备的记录,不影响别的账号/设备。"""
|
||||
deleted = onboarding_repo.delete_completion(db, user_id=user.id, device_id=req.device_id)
|
||||
logger.info("onboarding reset user_id=%d device_len=%d deleted=%d", user.id, len(req.device_id), deleted)
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/onboarding/status",
|
||||
response_model=OnboardingStatusResponse,
|
||||
|
||||
@@ -81,6 +81,7 @@ class Settings(BaseSettings):
|
||||
SMS_SIGN_ID: int = 31729 # 极光短信签名 ID(非机密,可被 .env 覆盖)
|
||||
SMS_TEMPLATE_ID: int = 1 # 极光短信模板 ID(变量名 code,有效期 5 分钟)
|
||||
SMS_CODE_LENGTH: int = 6 # 验证码位数(本服务生成;前端 code 字段 4-8 位兼容)
|
||||
SMS_DAILY_LIMIT_PER_PHONE: int = 10 # 单手机号每日发送上限(防刷 + 控费)
|
||||
SMS_MAX_VERIFY_ATTEMPTS: int = 5 # 单个验证码最多校验失败次数,超过即作废(防爆破)
|
||||
|
||||
# ===== 测试账号(release 包全流程联调用)=====
|
||||
|
||||
@@ -11,10 +11,7 @@ from typing import Any
|
||||
|
||||
from app.core import rewards as r
|
||||
|
||||
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool / enum
|
||||
# hidden=True:仍是合法可配项(业务照常 get_value / admin 可经专用端点读写),但**不在通用
|
||||
# 「系统配置」页渲染**(admin/routers/config.py:list_config 按此过滤)。用于把已下线/已改由
|
||||
# 专用页管理的项从福利页 Tab 收起,同时保留后端默认值与写入能力。
|
||||
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool
|
||||
CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
"signin_rewards": {
|
||||
"default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位",
|
||||
@@ -33,21 +30,18 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
"default": r.WITHDRAW_MAX_CENTS, "label": "提现最高额(分)",
|
||||
"group": "钱包", "type": "int",
|
||||
},
|
||||
# 从福利页 Tab 收起(hidden)的项:任务 / 里程碑 整组 + 看广告组里的「单次金币(遗留展示值)/
|
||||
# 每轮次数 / 比价领券信息流广告开关 comparing_ad_enabled」。key 与默认值保留、后端业务照常读取,
|
||||
# 仅不在配置页渲染。看广告组保留可见的:每日上限 / 单次金币上限 / 关闭后冷却。
|
||||
"task_rewards": {
|
||||
"default": dict(r.TASK_REWARDS), "label": "一次性任务奖励",
|
||||
"group": "任务", "type": "dict_str_int", "help": "task_key → 金币。", "hidden": True,
|
||||
"group": "任务", "type": "dict_str_int", "help": "task_key → 金币。",
|
||||
},
|
||||
"record_milestones": {
|
||||
"default": list(r.RECORD_MILESTONES), "label": "比价里程碑金币档位",
|
||||
"group": "里程碑", "type": "int_list", "hidden": True,
|
||||
"group": "里程碑", "type": "int_list",
|
||||
"help": "累计成功比价第 1~N 档解锁发的金币。",
|
||||
},
|
||||
"ad_reward_coin": {
|
||||
"default": r.AD_REWARD_COIN, "label": "看广告单次金币",
|
||||
"group": "看广告", "type": "int", "hidden": True,
|
||||
"group": "看广告", "type": "int",
|
||||
"help": "历史兼容/测试展示值;正式发放按 eCPM 公式计算。",
|
||||
},
|
||||
"ad_daily_limit": {
|
||||
@@ -60,7 +54,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
"ad_round_count": {
|
||||
"default": r.VIDEO_ROUND_REQUIRED_COUNT, "label": "每轮看广告次数",
|
||||
"group": "看广告", "type": "int", "hidden": True, "help": "当前为 1,表示每次广告关闭后触发短冷却。",
|
||||
"group": "看广告", "type": "int", "help": "当前为 1,表示每次广告关闭后触发短冷却。",
|
||||
},
|
||||
"ad_cooldown_sec": {
|
||||
"default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)",
|
||||
@@ -73,7 +67,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
"comparing_ad_enabled": {
|
||||
"default": True, "label": "比价/领券期信息流广告",
|
||||
"group": "看广告", "type": "bool", "hidden": True,
|
||||
"group": "看广告", "type": "bool",
|
||||
"help": (
|
||||
"开启后,比价进行中 + 领券等候期会在悬浮窗展示穿山甲信息流广告(变现行为);"
|
||||
"关闭则全程不出广告。客户端按 app 启动 / 每场比价开始时拉取并缓存,故为「最终一致」的"
|
||||
@@ -90,10 +84,4 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
"提现页「批量对账」手动按钮不受影响。"
|
||||
),
|
||||
},
|
||||
# 首页轮播数据源(hidden:不进通用配置 Tab,由「首页轮播种子」页的专用端点 /marquee-seeds/mode 读写)。
|
||||
"marquee_feed_mode": {
|
||||
"default": "mixed", "label": "首页轮播数据源",
|
||||
"group": "首页轮播", "type": "enum", "hidden": True,
|
||||
"help": "mixed=真实优先+种子补位(默认);real=只用真实比价记录;seed=只用种子/合成(演示)。",
|
||||
},
|
||||
}
|
||||
|
||||
+8
-57
@@ -2,15 +2,9 @@
|
||||
|
||||
业务代码用 `logger = logging.getLogger("shagua.xxx")` 即可, 本模块在 main.py 启动时调一次。
|
||||
|
||||
- 控制台(stdout): 人类可读文本, 给 systemd / 本地看; 有 trace 时行尾附 `trace=xxx`。
|
||||
- 文件 `logs/app-server.log`: 单行 JSON, 供**阿里云 SLS / Logtail** 采集(JSON 模式零正则);
|
||||
异常栈内嵌为字段不换行 → 每条日志一行。
|
||||
- **结构化字段(SLS 可直接查/聚合)**:
|
||||
- `trace_id`: 请求级贯穿——在入口 `trace_id_ctx.set(...)` 后, 本请求内**每一行日志**(含
|
||||
run_in_threadpool 里的 harvest, contextvars 自动拷进线程)都自动带上, 无需手写。
|
||||
SLS 里 `trace_id: "xxx"` 一查即得整条比价链路, 按 time 升序即请求顺序。
|
||||
- 任意 `logger.info(msg, extra={"phase": ..., "step": ..., "command": ...})` 的 extra
|
||||
键都会平铺进 JSON 顶层 → SLS 可按 phase/step/command/cost_ms 等过滤聚合。
|
||||
- 控制台(stdout): 人类可读文本, 给 systemd / 本地看。
|
||||
- 文件 `logs/app-server.log`: 单行 JSON, 供阿里云 SLS/Logtail 采集(JSON 模式零正则);
|
||||
异常栈作为字段内嵌不换行 → 每条日志一行。
|
||||
- 环境变量:
|
||||
- LOG_JSON_CONSOLE=1 控制台也输出 JSON
|
||||
- LOG_DIR / LOG_FILE 改落盘路径(默认 logs/app-server.log)
|
||||
@@ -23,36 +17,13 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# 请求级 trace_id:入口(如 compare.py 透传壳)set 之后, 本请求上下文(含 run_in_threadpool
|
||||
# 拷贝出去的线程)内所有日志自动带上。默认空串 = 非请求上下文(启动/后台 worker)。
|
||||
trace_id_ctx: ContextVar[str] = ContextVar("trace_id", default="")
|
||||
|
||||
|
||||
# 标准 LogRecord 属性 + 格式化期附加项:凡不在此集合的 record 属性都视为业务 extra, 平铺进 JSON。
|
||||
_RESERVED = set(
|
||||
logging.LogRecord("", 0, "", 0, "", (), None).__dict__
|
||||
) | {"message", "asctime", "trace_id", "taskName"}
|
||||
|
||||
|
||||
class _ContextFilter(logging.Filter):
|
||||
"""把 trace_id_ctx 注入每条 record(供两个 formatter 取用)。挂在 handler 上,
|
||||
命中每条(含 propagate 上来的)记录, 在 format 之前置好 record.trace_id。"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
if not hasattr(record, "trace_id"):
|
||||
record.trace_id = trace_id_ctx.get()
|
||||
return True
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
"""LogRecord → 单行 JSON(SLS/Logtail 友好)。trace_id 提到顶层、extra 键平铺, 异常栈内嵌。"""
|
||||
|
||||
"""把 LogRecord 序列化成单行 JSON(SLS/Logtail 友好)。异常栈内嵌为字段, 整条仍是一行。"""
|
||||
def __init__(self, service: str = "app-server"):
|
||||
super().__init__()
|
||||
self.service = service
|
||||
@@ -64,17 +35,10 @@ class JsonFormatter(logging.Formatter):
|
||||
"level": record.levelname,
|
||||
"service": self.service,
|
||||
"logger": record.name,
|
||||
"func": record.funcName,
|
||||
"line": record.lineno,
|
||||
"message": record.getMessage(),
|
||||
}
|
||||
tid = getattr(record, "trace_id", "") or trace_id_ctx.get()
|
||||
if tid:
|
||||
data["trace_id"] = tid
|
||||
# 业务 extra 字段(phase / step / endpoint / command / cost_ms / status ...)平铺进顶层
|
||||
for k, v in record.__dict__.items():
|
||||
if k not in _RESERVED and not k.startswith("_"):
|
||||
data[k] = v
|
||||
data["func"] = record.funcName
|
||||
data["line"] = record.lineno
|
||||
data["message"] = record.getMessage()
|
||||
if record.exc_info:
|
||||
data["exception"] = self.formatException(record.exc_info)
|
||||
if record.stack_info:
|
||||
@@ -82,15 +46,6 @@ class JsonFormatter(logging.Formatter):
|
||||
return json.dumps(data, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
class TextFormatter(logging.Formatter):
|
||||
"""控制台文本:标准行 + 有 trace_id 时行尾附 `trace=xxx`(无 trace 的启动/后台日志不加噪)。"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
base = super().format(record)
|
||||
tid = getattr(record, "trace_id", "") or trace_id_ctx.get()
|
||||
return f"{base} trace={tid}" if tid else base
|
||||
|
||||
|
||||
_CONFIGURED = False
|
||||
|
||||
|
||||
@@ -108,17 +63,14 @@ def setup_logging(debug: bool = False) -> None:
|
||||
for h in list(root.handlers):
|
||||
root.removeHandler(h)
|
||||
|
||||
ctx_filter = _ContextFilter()
|
||||
|
||||
# 控制台: 默认文本(systemd/本地看); LOG_JSON_CONSOLE=1 时输出 JSON
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
if os.getenv("LOG_JSON_CONSOLE") == "1":
|
||||
console.setFormatter(JsonFormatter(service))
|
||||
else:
|
||||
console.setFormatter(
|
||||
TextFormatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
)
|
||||
console.addFilter(ctx_filter)
|
||||
root.addHandler(console)
|
||||
|
||||
# 文件: 单行 JSON, 供 Logtail 采集(自动轮转, 单文件 10MB, 保留 5 个)
|
||||
@@ -130,7 +82,6 @@ def setup_logging(debug: bool = False) -> None:
|
||||
log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8",
|
||||
)
|
||||
file_handler.setFormatter(JsonFormatter(service))
|
||||
file_handler.addFilter(ctx_filter)
|
||||
root.addHandler(file_handler)
|
||||
|
||||
# 第三方库降噪
|
||||
|
||||
+25
-8
@@ -8,16 +8,15 @@
|
||||
校验)→ 鉴权复用极光一键登录的 `JG_APP_KEY`/`JG_MASTER_SECRET`(同一极光应用)。
|
||||
|
||||
验证码存储:**进程内存**(单 worker uvicorn 够用)。重启丢失(用户重发即可)。多
|
||||
worker / 多机时内存不共享 → 冷却、校验都会失效,届时迁移到 DB/Redis。
|
||||
worker / 多机时内存不共享 → 冷却、每日上限、校验都会失效,届时迁移到 DB/Redis。
|
||||
见 docs/待办与技术债.md。
|
||||
|
||||
防刷两层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
|
||||
防刷三层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
|
||||
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)
|
||||
2. 单设备(device_id)每小时频控(api 层 auth.sms_send 内 enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)。
|
||||
2. 单号每日 `SMS_DAILY_LIMIT_PER_PHONE` 条上限(本文件)
|
||||
3. 单设备(device_id)每小时频控(api 层 auth.sms_send 内 enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)。
|
||||
⚠️ 原「单 IP 频控(rate_limit 依赖)」2026-06-26 按产品要求删除、改设备维度;但 device_id 客户端可伪造/轮换,
|
||||
脚本轮换 id 能绕过本层 → 挡脚本狂发主要靠极光控制台侧(+ 可选 nginx 限流)。
|
||||
⚠️ 原「单号每日上限」2026-07-03 按精简要求删除(mentor 定:登录风控只留单号冷却 + 单设备频控);
|
||||
单号维度现仅剩 60s 冷却,「换号轰炸」由单设备频控封顶。
|
||||
另:单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废(防爆破),验过即作废(一次性)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -27,6 +26,7 @@ import logging
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from threading import Lock
|
||||
|
||||
import httpx
|
||||
@@ -56,17 +56,22 @@ class _CodeRecord:
|
||||
# 进程内存(单 worker 有效;多 worker 不共享,见模块 docstring)
|
||||
_codes: dict[str, _CodeRecord] = {} # phone -> 当前有效验证码
|
||||
_last_sent: dict[str, float] = {} # phone -> 上次发送 epoch(冷却)
|
||||
_daily_count: dict[str, tuple[str, int]] = {} # phone -> (date_str, 当日发送数)
|
||||
_lock = Lock()
|
||||
_GC_THRESHOLD = 10000 # 任一内存 dict 超此阈值,send 时顺手清过期项(防无限增长,仿 ratelimit)
|
||||
|
||||
|
||||
def _today() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _gen_code() -> str:
|
||||
"""生成 N 位数字验证码(用 secrets 而非 random;允许前导 0)。"""
|
||||
return "".join(secrets.choice("0123456789") for _ in range(settings.SMS_CODE_LENGTH))
|
||||
|
||||
|
||||
def _gc(now: float) -> None:
|
||||
"""顺手清理过期内存项,防两个 dict 无限增长。仅在持锁时调用,且某 dict 超
|
||||
"""顺手清理过期内存项,防三个 dict 无限增长。仅在持锁时调用,且某 dict 超
|
||||
_GC_THRESHOLD 才扫它(低频,开销可忽略)。"""
|
||||
if len(_codes) > _GC_THRESHOLD:
|
||||
for p in [p for p, r in _codes.items() if now > r.expires_at]:
|
||||
@@ -75,6 +80,10 @@ def _gc(now: float) -> None:
|
||||
cutoff = now - settings.SMS_SEND_INTERVAL_SEC
|
||||
for p in [p for p, ts in _last_sent.items() if ts < cutoff]:
|
||||
_last_sent.pop(p, None)
|
||||
if len(_daily_count) > _GC_THRESHOLD:
|
||||
today = _today()
|
||||
for p in [p for p, (d, _c) in _daily_count.items() if d != today]:
|
||||
_daily_count.pop(p, None)
|
||||
|
||||
|
||||
def send_code(phone: str) -> int:
|
||||
@@ -93,9 +102,17 @@ def send_code(phone: str) -> int:
|
||||
remain = int(settings.SMS_SEND_INTERVAL_SEC - elapsed)
|
||||
raise SmsError(f"发送过于频繁,请 {remain}s 后再试")
|
||||
|
||||
today = _today()
|
||||
day, cnt = _daily_count.get(phone, ("", 0))
|
||||
if day != today:
|
||||
cnt = 0
|
||||
if cnt >= settings.SMS_DAILY_LIMIT_PER_PHONE:
|
||||
raise SmsError("今日验证码发送次数已达上限,请明天再试")
|
||||
|
||||
code = _gen_code()
|
||||
# 预占:先记冷却/存码,释放锁后再发网络(发失败保留冷却,见下)
|
||||
# 预占:先记冷却/计数/存码,释放锁后再发网络(发失败保留冷却+计数,见下)
|
||||
_last_sent[phone] = now
|
||||
_daily_count[phone] = (today, cnt + 1)
|
||||
_codes[phone] = _CodeRecord(code=code, expires_at=now + settings.SMS_CODE_TTL_SEC)
|
||||
|
||||
# --- lock 外:真正发送(网络 IO 不持锁)---
|
||||
@@ -106,7 +123,7 @@ def send_code(phone: str) -> int:
|
||||
_send_via_jiguang(phone, code)
|
||||
logger.info("[SMS] sent to %s****", phone[:3])
|
||||
except Exception as e:
|
||||
# 发送失败:**保留冷却**(失败也限速,挡住余额不足/签名失效时
|
||||
# 发送失败:**保留冷却 + 每日计数**(失败也限速,挡住余额不足/签名失效时
|
||||
# 前端重试狂打极光),只清掉没发出去的码(用户收不到,留着无意义且占内存)。
|
||||
with _lock:
|
||||
_codes.pop(phone, None)
|
||||
|
||||
@@ -5,7 +5,6 @@ from app.models.ad_pangle_revenue import AdPangleDailyRevenue # noqa: F401
|
||||
from app.models.ad_reward import AdRewardRecord # noqa: F401
|
||||
from app.models.ad_watch_log import AdWatchLog # noqa: F401
|
||||
from app.models.admin import AdminAuditLog, AdminUser # noqa: F401
|
||||
from app.models.admin_role import AdminRole # noqa: F401
|
||||
from app.models.analytics_event import AnalyticsEvent # noqa: F401
|
||||
from app.models.app_config import AppConfig # noqa: F401
|
||||
from app.models.comparison import ComparisonRecord # noqa: F401
|
||||
|
||||
@@ -25,9 +25,6 @@ class AdminUser(Base):
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
# 明文登录密码:仅「后台 UI 创建/重置」的管理员留存,供超管在权限管理页复看转交。
|
||||
# 脚本/起后台时建的超管账号不写(为 None → 前端「不显示密码」)。⚠️ 内部工具便利取舍,见 create/list。
|
||||
plain_password: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# super_admin(全权+管账号)/ finance(钱:提现+金币)/ operator(用户+反馈+大盘)
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False, default="operator")
|
||||
# active / disabled
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"""admin 角色 → 可见页面(权限)映射表。
|
||||
|
||||
RBAC 的「角色」侧:每个角色持有一组「页面 key」(= 左侧导航项),决定该角色登录后台后左边能看到
|
||||
哪些页。super_admin 是内建全权角色(is_builtin=True),恒可见全部页(effective_pages 里特判,
|
||||
不依赖本表存的 pages)、不可编辑/删除。其余角色(含 operator/finance)可由 super_admin 增删改。
|
||||
|
||||
admin_user.role 存的是本表的 name(字符串弱引用,不建外键——与既有 require_role 字符串口径一致;
|
||||
删除在用角色由业务层拦截,见 routers/roles.py)。页面 key 清单见 app/admin/permissions.py。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
_JSON = JSON().with_variant(JSONB(), "postgresql")
|
||||
|
||||
|
||||
class AdminRole(Base):
|
||||
__tablename__ = "admin_role"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# 角色标识 key(不可变,承重):admin_user.role 引用它、require_role 按它鉴权。
|
||||
# 内建 = super_admin/operator/finance/tech(英文,勿改);自定义 = 创建时的名称。
|
||||
name: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False)
|
||||
# 展示名(可改):内建 = 管理员/运营/财务/技术;自定义默认 = name。UI 一律展示 label。
|
||||
label: Mapped[str] = mapped_column(String(32), nullable=False, default="")
|
||||
# 该角色可见页面 key 列表(= 左侧导航项),见 app/admin/permissions.py 的 PERMISSION_CATALOG
|
||||
pages: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
|
||||
# 内建角色(当前仅 super_admin):不可编辑/删除,恒全权
|
||||
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<AdminRole name={self.name} pages={len(self.pages)} builtin={self.is_builtin}>"
|
||||
@@ -39,19 +39,16 @@ _JSON = JSON().with_variant(JSONB(), "postgresql")
|
||||
class ComparisonRecord(Base):
|
||||
__tablename__ = "comparison_record"
|
||||
__table_args__ = (
|
||||
# trace_id 由 app-server 签发、全局唯一 → 一次比价一行,后端 harvest 按它 upsert。
|
||||
# (原 (user_id,trace_id) 复合唯一改为 trace_id 单列:harvest 帧0 建行时 user_id 可能暂缺。)
|
||||
UniqueConstraint("trace_id", name="uq_comparison_trace"),
|
||||
# 同一用户同一次比价(trace_id)只存一条:客户端重试/误点重复上报时幂等覆盖。
|
||||
UniqueConstraint("user_id", "trace_id", name="uq_comparison_user_trace"),
|
||||
# 首页轮播 / 省钱战绩聚合都按 status='success' 过滤 + created_at 近期排序;
|
||||
# 复合索引避免随数据量增大退化成全表扫(单列 created_at 索引不含 status)。
|
||||
Index("ix_comparison_status_created", "status", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# 后端 harvest 在帧0(pricebot 出 trace_id)即建行,软鉴权下 user_id 可能暂缺(老客户端/匿名)→ 可空。
|
||||
# C 端「我的比价记录」按 user_id 过滤天然排除 null-user 行;admin 全看(含孤儿行)。
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=True
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
# 仍记录设备号(同一用户多设备的行为区分 / 与不鉴权期 device_id 数据对账)
|
||||
device_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
@@ -86,10 +83,6 @@ class ComparisonRecord(Base):
|
||||
|
||||
# ===== 订单概要 =====
|
||||
store_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# 下单商品名拼接串(顿号分隔,从 items[].name 去重派生),供 admin 列表「商品」列展示 + 商品名搜索。
|
||||
# items 是 JSON(SQLite 下 json.dumps ensure_ascii 把中文转义,无法直接 CAST+LIKE),故另派生成普通
|
||||
# 文本列——跨库 LIKE 一致、可加索引。写路径(upsert_record / harvest_done)落库时同步派生。
|
||||
product_names: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
total_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
skipped_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
|
||||
@@ -24,23 +24,6 @@ def _yuan_to_cents(yuan: float | None) -> int | None:
|
||||
return round(yuan * 100)
|
||||
|
||||
|
||||
def _product_names_from_items(items: list | None) -> str | None:
|
||||
"""下单商品 items([{name, qty, specs?}])→ 顿号分隔的商品名串(去重保序),
|
||||
供 admin「商品」列展示 + 商品名 LIKE 搜索。空 / 无名 → None;超列宽(512)截断留余量。"""
|
||||
if not items:
|
||||
return None
|
||||
names: list[str] = []
|
||||
for it in items:
|
||||
name = it.get("name") if isinstance(it, dict) else None
|
||||
if not name:
|
||||
continue
|
||||
s = str(name).strip()
|
||||
if s and s not in names:
|
||||
names.append(s)
|
||||
joined = "、".join(names)
|
||||
return joined[:500] or None
|
||||
|
||||
|
||||
def _derive(payload: ComparisonRecordIn) -> dict:
|
||||
"""从上报 payload 派生结构化列(best/saved/is_source_best/status)。"""
|
||||
results = payload.comparison_results
|
||||
@@ -91,19 +74,12 @@ def _derive(payload: ComparisonRecordIn) -> dict:
|
||||
def upsert_record(
|
||||
db: Session, *, user_id: int, payload: ComparisonRecordIn
|
||||
) -> ComparisonRecord:
|
||||
"""按 **trace_id** 幂等写入(唯一键已从 user_id+trace_id 改为 trace_id):已存在则合并
|
||||
(回填 null user_id + 覆盖字段,但**不降级 success**),否则新建。
|
||||
|
||||
灰度期老客户端 POST /compare/record 走这条,与后端 harvest 按 trace_id reconcile;
|
||||
新客户端不再 POST(改由 compare.py 透传壳 harvest 落库)。
|
||||
"""
|
||||
"""按 (user_id, trace_id) 幂等写入:已存在则覆盖(更完整的重试上报胜出),否则新建。"""
|
||||
derived = _derive(payload)
|
||||
items = [it.model_dump(exclude_none=True) for it in payload.items]
|
||||
fields = dict(
|
||||
device_id=payload.device_id,
|
||||
business_type=payload.business_type,
|
||||
store_name=payload.store_name,
|
||||
product_names=_product_names_from_items(items),
|
||||
source_platform_id=payload.source_platform_id,
|
||||
source_platform_name=payload.source_platform_name,
|
||||
source_package=payload.source_package,
|
||||
@@ -112,7 +88,7 @@ def upsert_record(
|
||||
trace_url=payload.trace_url,
|
||||
total_dish_count=payload.total_dish_count,
|
||||
skipped_dish_count=payload.skipped_dish_count,
|
||||
items=items,
|
||||
items=[it.model_dump(exclude_none=True) for it in payload.items],
|
||||
comparison_results=[r.model_dump() for r in payload.comparison_results],
|
||||
skipped_dish_names=list(payload.skipped_dish_names),
|
||||
# 客户端环境 / 性能(debug,客户端上报;旧客户端为 None)
|
||||
@@ -134,29 +110,14 @@ def upsert_record(
|
||||
**derived,
|
||||
)
|
||||
|
||||
# 按 trace_id 定位(唯一键已改 trace_id):后端 harvest 可能已建行,这里的客户端上报
|
||||
# (灰度期老客户端 / 新客户端不再走这条)与之 reconcile。
|
||||
existing = db.execute(
|
||||
select(ComparisonRecord).where(
|
||||
ComparisonRecord.user_id == user_id,
|
||||
ComparisonRecord.trace_id == payload.trace_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if existing is not None:
|
||||
# 不降级:harvest 或更早上报已落成 success,收尾期 fromFailure 的 cancelled/failed
|
||||
# 不许把它盖回去(老 comparisonReported bug 的服务端兜底)——只补 user_id / trace_url。
|
||||
if existing.status == "success" and fields.get("status") != "success":
|
||||
if existing.user_id is None and user_id is not None:
|
||||
existing.user_id = user_id
|
||||
if fields.get("trace_url"):
|
||||
existing.trace_url = fields["trace_url"]
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return existing
|
||||
# 只**填**空缺 user_id、不 reassign:trace_id 全局唯一,一条 trace 只属一个用户;
|
||||
# 绝不把已有归属的记录改判给另一个上报者(仅 harvest 建的 null-user 行在此绑上)。
|
||||
if existing.user_id is None:
|
||||
existing.user_id = user_id
|
||||
for k, v in fields.items():
|
||||
setattr(existing, k, v)
|
||||
db.commit()
|
||||
@@ -178,203 +139,6 @@ def upsert_record(
|
||||
return rec
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 后端 harvest:app-server 从 pricebot 透传响应里直接落库(不靠客户端上报)。
|
||||
# 帧0 建行(running) → 最终 done 更新(success/failed) → finalize 更新(aborted)。
|
||||
# 全按 trace_id upsert;success 行永不被后到的 failed/aborted 降级。
|
||||
# ============================================================
|
||||
|
||||
|
||||
def _derive_from_results(results: list[dict]) -> dict:
|
||||
"""从 done 帧 comparison_results(pricebot 原始 dict 列表)派生结构化列。
|
||||
等价 _derive,但吃原始字段(is_source/price/rank/platform_id/store_name...)而非 pydantic 对象。"""
|
||||
priced = [r for r in results if r.get("price") is not None]
|
||||
best = None
|
||||
if priced:
|
||||
best = min(
|
||||
priced,
|
||||
key=lambda r: (r.get("rank") if r.get("rank") is not None else 10**9, r["price"]),
|
||||
)
|
||||
src_row = next((r for r in results if r.get("is_source")), None)
|
||||
|
||||
source_price_cents = None
|
||||
if src_row is not None and src_row.get("price") is not None:
|
||||
source_price_cents = _yuan_to_cents(src_row["price"])
|
||||
best_price_cents = _yuan_to_cents(best["price"]) if best else None
|
||||
saved_amount_cents = None
|
||||
if source_price_cents is not None and best_price_cents is not None:
|
||||
saved_amount_cents = source_price_cents - best_price_cents
|
||||
|
||||
has_valid_target = any(
|
||||
(not r.get("is_source")) and r.get("price") is not None for r in results
|
||||
)
|
||||
return {
|
||||
"source_platform_id": (src_row or {}).get("platform_id"),
|
||||
"source_platform_name": (src_row or {}).get("platform_name"),
|
||||
"source_package": (src_row or {}).get("package"),
|
||||
"source_price_cents": source_price_cents,
|
||||
"best_platform_id": best.get("platform_id") if best else None,
|
||||
"best_platform_name": best.get("platform_name") if best else None,
|
||||
"best_price_cents": best_price_cents,
|
||||
"saved_amount_cents": saved_amount_cents,
|
||||
"is_source_best": best.get("is_source") if best else None,
|
||||
"store_name": (src_row or {}).get("store_name") or None,
|
||||
"status": "success" if has_valid_target else "failed",
|
||||
}
|
||||
|
||||
|
||||
def _device_cols_from_info(device_info: dict | None) -> dict:
|
||||
"""step 帧 device_info({locale,brand,model,android_version,rom_version})→ 表列。
|
||||
step 帧只带这几项;rom_name / android_sdk / app_version / 耗时步数 harvest 拿不到 → 留 None
|
||||
(灰度期老客户端 fromComparison 会补齐;要新客户端也全带需扩 collectDeviceInfo,后续)。"""
|
||||
d = device_info or {}
|
||||
rv = str(d.get("rom_version") or "")
|
||||
return {
|
||||
"device_model": d.get("model") or None,
|
||||
"device_manufacturer": d.get("brand") or None,
|
||||
"android_version": d.get("android_version") or None,
|
||||
"rom_version": int(rv) if rv.isdigit() else None,
|
||||
}
|
||||
|
||||
|
||||
def _get_by_trace(db: Session, trace_id: str) -> ComparisonRecord | None:
|
||||
return db.execute(
|
||||
select(ComparisonRecord).where(ComparisonRecord.trace_id == trace_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def harvest_running(
|
||||
db: Session,
|
||||
*,
|
||||
trace_id: str,
|
||||
user_id: int | None,
|
||||
business_type: str = "food",
|
||||
device_id: str | None = None,
|
||||
device_info: dict | None = None,
|
||||
trace_url: str | None = None,
|
||||
) -> ComparisonRecord:
|
||||
"""帧0(或任一尚未建行的帧)建/补 running 行。幂等:已存在只补空缺(user_id/trace_url/
|
||||
device_id/机型),绝不动已落定的 status / 结果。"""
|
||||
rec = _get_by_trace(db, trace_id)
|
||||
if rec is None:
|
||||
rec = ComparisonRecord(
|
||||
trace_id=trace_id,
|
||||
user_id=user_id,
|
||||
business_type=business_type or "food",
|
||||
device_id=device_id,
|
||||
status="running",
|
||||
trace_url=trace_url,
|
||||
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
|
||||
**_device_cols_from_info(device_info),
|
||||
)
|
||||
db.add(rec)
|
||||
db.commit()
|
||||
db.refresh(rec)
|
||||
return rec
|
||||
changed = False
|
||||
if rec.user_id is None and user_id is not None:
|
||||
rec.user_id = user_id
|
||||
changed = True
|
||||
if not rec.trace_url and trace_url:
|
||||
rec.trace_url = trace_url
|
||||
changed = True
|
||||
if rec.device_id is None and device_id:
|
||||
rec.device_id = device_id
|
||||
changed = True
|
||||
for k, v in _device_cols_from_info(device_info).items():
|
||||
if getattr(rec, k) is None and v is not None:
|
||||
setattr(rec, k, v)
|
||||
changed = True
|
||||
if changed:
|
||||
db.commit()
|
||||
db.refresh(rec)
|
||||
return rec
|
||||
|
||||
|
||||
def harvest_done(
|
||||
db: Session,
|
||||
*,
|
||||
trace_id: str,
|
||||
user_id: int | None,
|
||||
done_params: dict,
|
||||
business_type: str = "food",
|
||||
device_id: str | None = None,
|
||||
device_info: dict | None = None,
|
||||
trace_url: str | None = None,
|
||||
) -> tuple[ComparisonRecord, bool]:
|
||||
"""最终 done 帧:running 行 → 终态(success/failed)+结果+trace_url。
|
||||
返回 (记录, 是否本次**新**落成 success)——供调用方据此幂等发一次邀请奖。
|
||||
行不存在(理论上帧0已建;防御)则新建。"""
|
||||
results = done_params.get("comparison_results") or []
|
||||
derived = _derive_from_results(results)
|
||||
# 菜品:pricebot 已把源单菜品塞进 comparison_results[源行].items
|
||||
items = next((r.get("items") or [] for r in results if r.get("is_source")), [])
|
||||
fields = dict(
|
||||
business_type=business_type or "food",
|
||||
information=done_params.get("information") or None,
|
||||
# best_deeplink 来自客户端剪贴板采集,harvest 拿不到 → 留空(灰度期 fromComparison 会补;
|
||||
# 纯 harvest 行「再次比价」退化为按 package 拉起 App。要精确深链需客户端另传,后续)。
|
||||
trace_url=trace_url or done_params.get("trace_url"),
|
||||
total_dish_count=done_params.get("total_dish_count"),
|
||||
skipped_dish_count=done_params.get("skipped_dish_count"),
|
||||
skipped_dish_names=list(done_params.get("skipped_dish_names") or []),
|
||||
comparison_results=results,
|
||||
items=items,
|
||||
product_names=_product_names_from_items(items),
|
||||
raw_payload=done_params,
|
||||
**derived,
|
||||
**_device_cols_from_info(device_info),
|
||||
)
|
||||
|
||||
rec = _get_by_trace(db, trace_id)
|
||||
was_success = rec is not None and rec.status == "success"
|
||||
if rec is None:
|
||||
rec = ComparisonRecord(
|
||||
trace_id=trace_id,
|
||||
user_id=user_id,
|
||||
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
|
||||
device_id=device_id,
|
||||
**fields,
|
||||
)
|
||||
db.add(rec)
|
||||
else:
|
||||
if user_id is not None and rec.user_id is None:
|
||||
rec.user_id = user_id
|
||||
if device_id and rec.device_id is None:
|
||||
rec.device_id = device_id
|
||||
for k, v in fields.items():
|
||||
setattr(rec, k, v)
|
||||
db.commit()
|
||||
db.refresh(rec)
|
||||
newly_success = (rec.status == "success") and not was_success
|
||||
return rec, newly_success
|
||||
|
||||
|
||||
def harvest_abort(
|
||||
db: Session,
|
||||
*,
|
||||
trace_id: str,
|
||||
status: str,
|
||||
reason: str | None,
|
||||
trace_url: str | None = None,
|
||||
) -> ComparisonRecord | None:
|
||||
"""finalize(用户终止/超时/异常,无 done 帧):running 行 → aborted/failed + trace_url。
|
||||
**不降级 success**:行已 success(收尾取消那种 finalize 后到)只 refresh trace_url。
|
||||
行不存在(极少:帧0没建成)→ 返回 None,不凭空造。"""
|
||||
rec = _get_by_trace(db, trace_id)
|
||||
if rec is None:
|
||||
return None
|
||||
if trace_url and not rec.trace_url:
|
||||
rec.trace_url = trace_url
|
||||
if rec.status != "success":
|
||||
rec.status = status or "cancelled"
|
||||
if reason:
|
||||
rec.information = reason
|
||||
db.commit()
|
||||
db.refresh(rec)
|
||||
return rec
|
||||
|
||||
|
||||
def _ordered_shop_names(db: Session, user_id: int) -> set[str]:
|
||||
"""该用户「真实下单」(source='compare')覆盖到的店名集合,用来给比价记录打「已下单」。
|
||||
|
||||
|
||||
@@ -304,9 +304,6 @@ def get_invitees(
|
||||
"avatar_url": u.avatar_url or u.wechat_avatar_url or None,
|
||||
"coins": rel.inviter_coin, # v3 起恒 0(邀请人收益改走邀请奖励金)
|
||||
"invited_at": rel.created_at,
|
||||
# 是否已完成过一次比价(= 已发过邀请奖励金)。客户端据此:好友列表分"去提醒/邀请成功"、
|
||||
# 在途列表只取未比价(is_compared=False)、算在途好友数与在途收益(未比价数×2元)。
|
||||
"is_compared": bool(rel.compare_reward_granted),
|
||||
})
|
||||
has_more = offset + len(rows) < int(total)
|
||||
return items, int(total), has_more
|
||||
|
||||
@@ -5,7 +5,7 @@ device_id 为空(老客户端 / 取不到 ANDROID_ID)一律按"未完成"处理,
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -33,18 +33,3 @@ def mark_completed(db: Session, *, user_id: int, device_id: str) -> None:
|
||||
except IntegrityError:
|
||||
# 并发 / 重复提交撞唯一约束:已有行即视为成功。
|
||||
db.rollback()
|
||||
|
||||
|
||||
def delete_completion(db: Session, *, user_id: int, device_id: str) -> int:
|
||||
"""删该 (账号, 设备) 的引导完成标记 → 下次登录 is_completed=False → 客户端重走。
|
||||
与 [mark_completed] 互逆。device_id 为空忽略(返 0);无记录也幂等(返 0)。返回删除行数。"""
|
||||
if not device_id:
|
||||
return 0
|
||||
result = db.execute(
|
||||
delete(OnboardingCompletion).where(
|
||||
OnboardingCompletion.user_id == user_id,
|
||||
OnboardingCompletion.device_id == device_id,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return result.rowcount
|
||||
|
||||
@@ -29,18 +29,6 @@ from app.core.rewards import CN_TZ
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.ops_marquee_seed import OpsMarqueeSeed
|
||||
from app.models.user import User
|
||||
from app.repositories import app_config
|
||||
from app.repositories.user import is_default_nickname
|
||||
|
||||
# 首页轮播数据源模式(存 app_config.marquee_feed_mode):
|
||||
# mixed=真实优先+种子补位+合成兜底(默认,原行为);real=只真实(不足则少/空);seed=只种子+合成兜底。
|
||||
FEED_MODES = ("mixed", "real", "seed")
|
||||
|
||||
|
||||
def get_feed_mode(db: Session) -> str:
|
||||
"""读首页轮播数据源模式;非法/未配置回退 mixed(= 原行为)。"""
|
||||
mode = app_config.get_value(db, "marquee_feed_mode")
|
||||
return mode if mode in FEED_MODES else "mixed"
|
||||
|
||||
# feed 运行时随机源(每次请求结果不同 = 轮播想要的「鲜活感」)
|
||||
_rng = random.Random()
|
||||
@@ -141,12 +129,9 @@ def _synth_masked_name(rng: random.Random) -> str:
|
||||
|
||||
def _mask_real(nickname: str | None, user_id: int) -> str:
|
||||
"""真实用户脱敏(对齐 PRD「用户标识打码规则」):设过昵称→昵称脱敏(中英文皆可);
|
||||
没昵称→「用户」+5星+id 后 2 位(用户*****08),按 user_id 稳定、刷新不变脸。
|
||||
|
||||
创建时自动分配的默认昵称(「用户」+9 位随机,见 user.is_default_nickname)不算用户主动设的昵称,
|
||||
按「无昵称」处理走 id 规则(产品决策 2026-07:默认昵称归入「没昵称」档)。"""
|
||||
没昵称→「用户」+5星+id 后 2 位(用户*****08),按 user_id 稳定、刷新不变脸。"""
|
||||
nick = (nickname or "").strip()
|
||||
if nick and not is_default_nickname(nick):
|
||||
if nick:
|
||||
return _mask_nickname(nick)
|
||||
return _mask_anon(user_id)
|
||||
|
||||
@@ -217,11 +202,9 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]:
|
||||
return out
|
||||
|
||||
|
||||
def get_feed(db: Session, limit: int = 8, mode: str | None = None) -> list[dict]:
|
||||
def get_feed(db: Session, limit: int = 8) -> list[dict]:
|
||||
"""返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}。
|
||||
|
||||
mode:显式传入(admin 预览指定模式)则用它、**不改持久化配置**;不传(客户端 /savings-feed)读
|
||||
持久化的 marquee_feed_mode;非法值一律回退到持久化模式。
|
||||
真实条:success 且 0 < saved ≤ 上限,按 user 去重(同一用户只取最新一条,避免单人刷屏)。
|
||||
不足用启用的种子补齐——**公平随机抽取** need 个(而非固定取前 N),让所有种子都有机会露出;
|
||||
种子用户名留空则随机合成(避开撞名),金额取**长尾随机**(小额居多、偶尔大额,更像真实分布)。
|
||||
@@ -229,57 +212,50 @@ def get_feed(db: Session, limit: int = 8, mode: str | None = None) -> list[dict]
|
||||
展示时间统一「刷新」成相对现在的最近时刻(从 now 往前**随机抖动**递减),保证轮播永远像刚发生、
|
||||
节奏自然不机械(真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时时间)。
|
||||
"""
|
||||
# 预览可显式指定模式(所见=选中模式,不依赖 PATCH 落库时序);None/非法 → 读持久化配置。
|
||||
mode = mode if mode in FEED_MODES else get_feed_mode(db) # mixed / real / seed
|
||||
# 真实条:取较多近期记录(带 ~30s 缓存)后按 user 去重;金额超上限的异常值已在查询剔除。
|
||||
rows = _recent_real_rows(db)
|
||||
|
||||
items: list[dict] = []
|
||||
used_names: set[str] = set()
|
||||
seen_users: set[int] = set()
|
||||
for uid, sc, nick in rows:
|
||||
if uid in seen_users:
|
||||
continue
|
||||
seen_users.add(uid)
|
||||
name = _mask_real(nick, uid)
|
||||
used_names.add(name) # 真实名按昵称/id 稳定;偶发撞名可接受
|
||||
items.append({"masked_user": name, "saved_amount_cents": int(sc)})
|
||||
if len(items) >= limit:
|
||||
break
|
||||
|
||||
# 真实条(mixed / real):取较多近期记录(带 ~30s 缓存)后按 user 去重;金额超上限的异常值已在查询剔除。
|
||||
if mode != "seed":
|
||||
rows = _recent_real_rows(db)
|
||||
seen_users: set[int] = set()
|
||||
for uid, sc, nick in rows:
|
||||
if uid in seen_users:
|
||||
continue
|
||||
seen_users.add(uid)
|
||||
name = _mask_real(nick, uid)
|
||||
used_names.add(name) # 真实名按昵称/id 稳定;偶发撞名可接受
|
||||
items.append({"masked_user": name, "saved_amount_cents": int(sc)})
|
||||
if len(items) >= limit:
|
||||
break
|
||||
|
||||
# 种子补位 + 合成兜底(mixed / seed):mixed 下补真实不足的部分,seed 下全量用种子/合成。
|
||||
# real 模式**跳过**——只出真实,不掺任何假数据(真实不足则少于 limit,为 0 时返回空)。
|
||||
if mode != "real":
|
||||
need = limit - len(items)
|
||||
if need > 0:
|
||||
seeds = db.execute(
|
||||
select(OpsMarqueeSeed).where(OpsMarqueeSeed.enabled.is_(True))
|
||||
).scalars().all()
|
||||
# 公平随机抽取 need 个(池子够大则不放回抽样;否则全用并打散),让所有启用种子都有机会露出。
|
||||
chosen = _rng.sample(seeds, need) if len(seeds) > need else list(seeds)
|
||||
_rng.shuffle(chosen)
|
||||
for s in chosen:
|
||||
# 用户名:旧的「用户****xxx」统一模板名 / 留空 → 一律走新混合合成(避开同屏撞名、自愈历史种子);
|
||||
# 仅运营手动设的非模板真名才原样用。
|
||||
fixed = (s.masked_user or "").strip()
|
||||
name = fixed if fixed and not fixed.startswith("用户****") else _unique_name(used_names)
|
||||
used_names.add(name)
|
||||
# 金额:在 [min,max] 取长尾随机值(小额居多、偶尔大额;固定金额则 min==max)。
|
||||
lo = max(0, int(s.min_cents))
|
||||
hi = max(lo, int(s.max_cents))
|
||||
items.append({"masked_user": name, "saved_amount_cents": _skewed_amount(lo, hi)})
|
||||
|
||||
# 兜底:真实 + 种子仍凑不满 limit(种子被全停用 / 数量太少)→ 用内置合成补满,
|
||||
# 保证轮播既不空也不稀疏(稀疏的几条循环同样像假)。
|
||||
while len(items) < limit:
|
||||
name = _unique_name(used_names)
|
||||
need = limit - len(items)
|
||||
if need > 0:
|
||||
seeds = db.execute(
|
||||
select(OpsMarqueeSeed).where(OpsMarqueeSeed.enabled.is_(True))
|
||||
).scalars().all()
|
||||
# 公平随机抽取 need 个(池子够大则不放回抽样;否则全用并打散),让所有启用种子都有机会露出。
|
||||
chosen = _rng.sample(seeds, need) if len(seeds) > need else list(seeds)
|
||||
_rng.shuffle(chosen)
|
||||
for s in chosen:
|
||||
# 用户名:旧的「用户****xxx」统一模板名 / 留空 → 一律走新混合合成(避开同屏撞名、自愈历史种子);
|
||||
# 仅运营手动设的非模板真名才原样用。
|
||||
fixed = (s.masked_user or "").strip()
|
||||
name = fixed if fixed and not fixed.startswith("用户****") else _unique_name(used_names)
|
||||
used_names.add(name)
|
||||
items.append({
|
||||
"masked_user": name,
|
||||
"saved_amount_cents": _skewed_amount(_FALLBACK_MIN_CENTS, _FALLBACK_MAX_CENTS),
|
||||
})
|
||||
# 金额:在 [min,max] 取长尾随机值(小额居多、偶尔大额;固定金额则 min==max)。
|
||||
lo = max(0, int(s.min_cents))
|
||||
hi = max(lo, int(s.max_cents))
|
||||
items.append({"masked_user": name, "saved_amount_cents": _skewed_amount(lo, hi)})
|
||||
|
||||
# 兜底:真实 + 种子仍凑不满 limit(种子被全停用 / 数量太少)→ 用内置合成补满,
|
||||
# 保证轮播既不空也不稀疏(稀疏的几条循环同样像假)。
|
||||
while len(items) < limit:
|
||||
name = _unique_name(used_names)
|
||||
used_names.add(name)
|
||||
items.append({
|
||||
"masked_user": name,
|
||||
"saved_amount_cents": _skewed_amount(_FALLBACK_MIN_CENTS, _FALLBACK_MAX_CENTS),
|
||||
})
|
||||
|
||||
# 统一赋「最近」时间:从 now 往前**随机抖动**递减,避免固定节奏被看出规律。
|
||||
# 首条几十秒前;其余多数 1~5 分钟,偶尔扎堆(20~55s)或较长(5~9 分钟),降序、像真实流水。
|
||||
|
||||
@@ -318,14 +318,11 @@ def update_config(
|
||||
row.random_current = _monotonic(row, random_initial)
|
||||
row.random_last_tick_at = now
|
||||
elif apply_now:
|
||||
# 立即更新:不等钟点,马上刷新一次。
|
||||
# random:走一档增长;real/manual:直接落到「配置目标值」(manual_value / max(真实,保底)),
|
||||
# **显式保存即所见即所得,绕过「只增不减」护栏**——运营手动改值就是要按配置显示(含调小)。
|
||||
# (护栏仍作用于自动 tick 的 _refresh:防门面在两次保存之间被真实刷新/自增长悄悄缩水。)
|
||||
# 立即更新:不等钟点,马上刷新一次
|
||||
if row.mode == "random" and row.random_current is not None:
|
||||
row.random_current = _grow(row, row.random_current, 1)
|
||||
else:
|
||||
row.random_current = _current_target(db, row)
|
||||
row.random_current = _monotonic(row, _current_target(db, row))
|
||||
row.random_last_tick_at = now
|
||||
elif row.random_current is None:
|
||||
# 首次无值:按当前模式播种,使配置后立即有合理展示值
|
||||
|
||||
@@ -42,22 +42,6 @@ def _gen_nickname() -> str:
|
||||
)
|
||||
|
||||
|
||||
def is_default_nickname(nickname: str | None) -> bool:
|
||||
"""是否为创建时自动分配的默认昵称(= "用户" + 9 位字母数字,见 [_gen_nickname])。
|
||||
|
||||
这类不是用户主动设置的昵称,展示脱敏时按「无昵称」处理(走 id 规则,见 ops_marquee._mask_real)。
|
||||
精确匹配生成格式(前缀 + 定长字母数字集),不误伤真人以「用户」开头的昵称(如「用户体验师」含汉字、
|
||||
长度也不符)。用户改过昵称即不再匹配。"""
|
||||
if not nickname:
|
||||
return False
|
||||
s = nickname.strip()
|
||||
return (
|
||||
len(s) == len(_NICKNAME_PREFIX) + _NICKNAME_LEN
|
||||
and s.startswith(_NICKNAME_PREFIX)
|
||||
and all(c in _NICKNAME_ALPHABET for c in s[len(_NICKNAME_PREFIX):])
|
||||
)
|
||||
|
||||
|
||||
def get_user_by_username(db: Session, username: str) -> User | None:
|
||||
return db.execute(
|
||||
select(User).where(User.username == username)
|
||||
|
||||
@@ -296,9 +296,6 @@ def daily_auto_exchange(db: Session) -> dict:
|
||||
- **逐用户独立事务**:单个用户异常 rollback 不影响其他人;exchange_coins_to_cash 内部按用户 commit。
|
||||
返回统计 dict(scanned/converted/skipped_done/skipped_dust/failed/total_cents)。
|
||||
"""
|
||||
# ⚠️「今天」写死北京时(rewards.cn_today() = datetime.now(CN_TZ).date(), CN_TZ=+8),与服务器/用户
|
||||
# 时区无关(用户 2026-07-01 硬约束:兑换一律北京 0 点日切)。**禁止**改成 date.today() / 裸
|
||||
# datetime.now().date()——那会跟随进程本地时区,服务器非 CST 时会在错误的"天"兑/重复兑。
|
||||
today = rewards.cn_today()
|
||||
stats = {
|
||||
"scanned": 0, "converted": 0, "skipped_done": 0,
|
||||
|
||||
@@ -69,7 +69,6 @@ class InviteeItem(BaseModel):
|
||||
avatar_url: str | None = None # 头像 URL;null = 前端画默认色块
|
||||
coins: int # 这次邀请给我(邀请人)发的金币
|
||||
invited_at: datetime # 邀请绑定时间(前端转"今天/3天前")
|
||||
is_compared: bool = False # 该好友是否已完成过一次比价(好友列表分"去提醒/邀请成功";在途列表只取 False)
|
||||
|
||||
|
||||
class InviteeListOut(BaseModel):
|
||||
|
||||
@@ -19,9 +19,6 @@ Type=oneshot
|
||||
User=root
|
||||
WorkingDirectory=/opt/shaguabijia-app-server
|
||||
Environment="PATH=/opt/shaguabijia-app-server/.venv/bin:/usr/bin:/bin"
|
||||
# 写死北京时:兑换的"当天/0 点"一律按北京时,不随服务器 OS 时区漂(用户 2026-07-01 硬约束)。
|
||||
# 脚本内的日期判断本就走 rewards.cn_today()(CN_TZ=+8),这里再把进程 TZ 也钉成北京,双保险。
|
||||
Environment="TZ=Asia/Shanghai"
|
||||
EnvironmentFile=/opt/shaguabijia-app-server/.env
|
||||
ExecStart=/opt/shaguabijia-app-server/.venv/bin/python -m scripts.daily_auto_exchange --once
|
||||
SyslogIdentifier=daily-exchange
|
||||
|
||||
@@ -4,10 +4,8 @@
|
||||
Description=Run daily auto-exchange coins->cash at midnight
|
||||
|
||||
[Timer]
|
||||
# 每天【北京时】0 点跑,写死时区(用户 2026-07-01 硬约束:兑换一律北京 0 点,不随服务器本地时区漂)。
|
||||
# systemd OnCalendar 支持尾缀时区;不写时区会按服务器 OS 本地时区触发 → 服务器非 CST 时会在错误时刻兑。
|
||||
# 客户端文案已注明「可能存在延迟」,可按需改 00:05 错开整点扎堆。
|
||||
OnCalendar=*-*-* 00:00:00 Asia/Shanghai
|
||||
# 每天 0 点跑。客户端文案已注明「可能存在延迟」,可按需改 00:05 错开整点扎堆。
|
||||
OnCalendar=*-*-* 00:00:00
|
||||
# 服务器宕机/重启后,补跑错过的那一轮(而不是干等次日)。
|
||||
Persistent=true
|
||||
AccuracySec=1min
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# 本地开发/测试用 PostgreSQL。生产用原生 PG(scripts/init_postgres.py),不使用本文件。
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: shaguabijia-pg
|
||||
environment:
|
||||
POSTGRES_USER: shaguabijia_app
|
||||
POSTGRES_PASSWORD: shaguabijia_dev_pw
|
||||
POSTGRES_DB: shaguabijia
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./docker/initdb:/docker-entrypoint-initdb.d:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U shaguabijia_app -d shaguabijia"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -1,3 +0,0 @@
|
||||
-- 仅在 pgdata 卷首次初始化时执行一次(以 shaguabijia_app 连 shaguabijia 库运行)。
|
||||
-- 幂等兜底见 scripts/ensure_pg.py 的 _ensure_test_db()。
|
||||
CREATE DATABASE shaguabijia_test OWNER shaguabijia_app;
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](../README.md)
|
||||
|
||||
比价 `done` 帧后上报一条比价结果,落 `comparison_record` 表,作为「我的比价记录」数据源 + 用户级行为画像。
|
||||
比价 `done` 帧后,客户端用**带 JWT 的通道**上报一条比价结果,落 `comparison_record` 表,作为「我的比价记录」数据源 + 用户级行为画像。
|
||||
|
||||
> ⚠️ **灰度定位(2026-07)**:写 `comparison_record` 现以透传壳 `compare.py` 的**后端 harvest** 为主(帧0 建 `running` 行 → done/finalize 落终态,新客户端不再 POST)。本接口降级为**老客户端兼容**通道,与 harvest 按 `trace_id` reconcile(**success 不被降级**);新版覆盖够高后可下线本 POST。
|
||||
> 与软鉴权透传端点 [`/api/v1/price/step`](./compare-price-step.md) 不同:那是转发壳(顺带 harvest 落库),本接口按用户维度显式上报,**必须鉴权**。
|
||||
> ⚠️ 与不鉴权的透传端点 [`/api/v1/price/step`](./compare-price-step.md) 不同:那是转发壳,本接口按用户维度落库,**必须鉴权**。
|
||||
> 本轮只做 server 端;客户端在 done 帧后调本接口的改动另起一轮(见 [待办与技术债.md](../guides/待办与技术债.md) P1)。
|
||||
|
||||
## 入参(JSON body)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `trace_id` | string | ✅ | — | 一次比价的唯一标识(app-server 帧0 签发)。**幂等键(trace_id 单列唯一)**:同 trace_id 重复上报覆盖、返回同一 id;与 harvest 行按它 reconcile |
|
||||
| `trace_id` | string | ✅ | — | pricebot 侧 trace_id。**幂等键**:同用户同 trace_id 重复上报覆盖、返回同一 id |
|
||||
| `business_type` | string | ❌ | `food` | `food`(外卖,当前唯一接通) / `ecom`(电商) / `coupon`(领券) |
|
||||
| `device_id` | string \| null | ❌ | null | 设备号 |
|
||||
| `store_name` | string \| null | ❌ | null | 店铺名(外卖,来自 calibration.result) |
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
> 模型 `app/models/comparison.py` · 仓库 `app/repositories/comparison.py` · 接口 [compare-record-report](../api/compare-record-report.md) / [compare-records](../api/compare-records.md) / [compare-record-detail](../api/compare-record-detail.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
每完成一次比价(外卖/电商/领券)记一行。**写入以 app-server 后端 harvest 为主**(2026-07 起):比价透传壳 `compare.py` 在帧0(pricebot 出 trace_id)即建 `running` 行,随 done / `trace/finalize` 逐步补全成终态,客户端不再主动 POST 记录;老客户端仍可走**带 JWT** 的 `POST /compare/record` 兜底(灰度期两条写路径按 `trace_id` reconcile)。App「我的比价记录」列表/详情的数据源,也是比价战绩里程碑解锁进度的计数源(`status='success'` 条数),还被「上报更低价」反查原最低价。
|
||||
每完成一次比价(外卖/电商/领券),客户端在 done 帧后用**带 JWT** 的通道上报一条。App「我的比价记录」列表/详情的数据源,也是比价战绩里程碑解锁进度的计数源(`status='success'` 条数),还被「上报更低价」反查原最低价。
|
||||
|
||||
> 与 `savings_record` 的区别:本表是「每一次**比价行为**的完整明细」(不省钱、甚至失败也记);`savings_record` 是「真正**下单成交**省了多少」。两表独立、互不喂数据。
|
||||
> 与 [`price_observation`](./price_observation.md) / `store_mapping` 的区别:本表是**用户视角**(登录后按 `user_id` 存「我的比价记录」);后两张是 server 侧无条件沉淀的**平台/门店视角客观事实**(价格事实 / 跨平台店铺身份映射),与本表 `trace_id` 同源但不互相 join,各存各的视角。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C / U(harvest 为主,按 `trace_id` 幂等)**:透传壳 `compare.py` 三段式落库(`app/repositories/comparison.py`)——`harvest_running`(帧0 建 `running` 行)→ `harvest_done`(done 帧转 `success`/`failed` + 派生 `best_*`/`saved_amount_cents` + 返 `newly_success` 供幂等发邀请奖)→ `harvest_abort`(`trace/finalize` 转 `cancelled`/`failed`,**不降级已 success**)。老客户端仍可 `POST /compare/record`(`upsert_record`,按 `trace_id` 查、整行覆盖)兜底。`best_*`/`saved_amount_cents`/`is_source_best`/`status` 一律由 `_derive` 从 `comparison_results` 算出(协议已按 price 升序、rank=1 最便宜),不信客户端自算。
|
||||
- **C / U(upsert,幂等)**:`POST /compare/record`(`upsert_record`)。按 `(user_id, trace_id)` 查:不存在→新建;已存在→整行覆盖(客户端重试/重复上报时,**更完整的那次胜出**)。`best_*`/`saved_amount_cents`/`is_source_best`/`status` 由 `_derive` 从 `comparison_results` 算出(协议已按 price 升序、rank=1 最便宜),不信客户端自算。
|
||||
- **D**:无(关联的 `price_report` 也只把 `comparison_record_id` 置空,不删本表)。
|
||||
- **R**:`GET /compare/records`(列表,`created_at` 倒序游标 + 「已下单」标记)、`GET /compare/records/{id}`(详情,限本人);`count_success` 给里程碑;`get_stats`(`status='success'` 计数 + `saved_amount_cents` 求和)给 [`GET /compare/stats`](../api/compare-stats.md) 喂「我的」页省钱战绩卡(完成比价 + 累计发现可省,**比价口径**);`report.py` 反查 `best_*`;admin 大盘/明细。
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | 被 `price_report.comparison_record_id` 引用 |
|
||||
| `user_id` | Integer | FK→user.id, index, **nullable**(2026-07 从 NOT NULL 放开) | 归属用户。后端 harvest 帧0 建行时(软鉴权 / 老客户端匿名)可能暂缺 → 可空;C 端「我的比价记录」按 `user_id` 过滤天然排除 null 行,admin 全看(含孤儿行) |
|
||||
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
|
||||
| `device_id` | String(64) | nullable | 设备号(多设备区分 / 与不鉴权期对账) |
|
||||
| `business_type` | String(16) | NOT NULL, default `food`, index | 取值:`food`(当前唯一接通)/ `ecom` / `coupon` |
|
||||
| `trace_id` | String(64) | NOT NULL, **UNIQUE** | 一次比价的唯一标识。**由 app-server 帧0 用 uuid 签发**(注入转发 body + 回填响应顶层给客户端;老客户端自带),全局唯一 = harvest upsert 键 + 关联 pricebot 调试落盘 |
|
||||
| `trace_id` | String(64) | NOT NULL | pricebot 侧 trace_id(关联调试落盘 + 幂等键) |
|
||||
| `trace_url` | String(512) | nullable | 本次比价公网调试链接(`price.shaguabijia.com/traces/{dir}/`);dir 名含 pricebot 落盘时分秒,前端/server 拼不出必须存。查看接口按 `user.debug_trace_enabled`(或本机 agent 调试 `include_trace`)决定返不返回。旧记录 / 未开上云为 null |
|
||||
| `source_platform_id` / `_name` | String(32) | nullable | 源平台代号 / 中文名 |
|
||||
| `source_package` | String(128) | nullable | 源平台 Android 包名 |
|
||||
@@ -31,7 +31,7 @@
|
||||
| `is_source_best` | Boolean | nullable | 源平台就是最便宜(= 这次没省到) |
|
||||
| `store_name` | String(128) | nullable | 店铺名。**与 `savings_record.shop_name` 按字符串相等关联**,给本记录打「已下单」 |
|
||||
| `total_dish_count` / `skipped_dish_count` | Integer | nullable | 菜品总数 / 目标平台没找到被跳过数 |
|
||||
| `status` | String(16) | NOT NULL, default `success` | 取值:`running`(harvest 帧0 建行、比价进行中)/ `success`(有非源且有价的目标结果)/ `failed`(出错/没采到目标价)/ `cancelled`(用户终止 / Phase1 未识别,`harvest_abort` 写,**不降级已 success**)。**里程碑只数 success** |
|
||||
| `status` | String(16) | NOT NULL, default `success` | 取值:`success`(有非源且有价的目标结果)/ `failed`(出错/没采到目标价)。**里程碑只数 success** |
|
||||
| `information` | String(256) | nullable | done 帧文案;成功=摘要,失败=具体原因(前端失败时当原因展示) |
|
||||
| `items` | JSON(PG: JSONB) | NOT NULL, default [] | 下单菜品 `[{name, qty, specs?}]` |
|
||||
| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved(元),coupon_name,applied_coupons}]`;`coupon_saved`=该平台主优惠额(美团红包/淘宝平台红包/京东百亿补贴,只取一笔),`coupon_name`=优惠来源名(展示用),`applied_coupons`=`[{name,amount}]` 多券明细 |
|
||||
@@ -50,8 +50,7 @@
|
||||
- 被 `comparison_milestone_claim` 间接依赖:解锁进度 = 本表 `status='success'` 计数。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;index `user_id`、`business_type`、`created_at`;复合 index `ix_comparison_status_created`(`status`, `created_at`)(按 `status='success'` 过滤 + 近期排序的聚合/轮播,避免随数据量退化为全表扫);UNIQUE(`trace_id`) = `uq_comparison_trace`(harvest 按它 upsert,一次比价一行)。
|
||||
> 2026-07 迁移 `comparison_record_trace_unique`:唯一键从复合 `uq_comparison_user_trace`(`user_id`,`trace_id`)改为 `trace_id` 单列——harvest 帧0 建行时 user_id 可能暂缺,不能再用复合键去重。上线前须确认历史无重复 `trace_id`(`SELECT trace_id,COUNT(*) c FROM comparison_record GROUP BY trace_id HAVING c>1`),否则建单列唯一会失败。
|
||||
- PK `id`;index `user_id`、`business_type`、`created_at`;复合 index `ix_comparison_status_created`(`status`, `created_at`)(按 `status='success'` 过滤 + 近期排序的聚合/轮播,避免随数据量退化为全表扫);UNIQUE(`user_id`, `trace_id`) = `uq_comparison_user_trace`(幂等覆盖)。
|
||||
|
||||
## 注意
|
||||
- 4 个 JSON 列用 `JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。结构化金额列存「分」,`comparison_results.price`/`coupon_saved` 原样存「元」。
|
||||
|
||||
@@ -27,21 +27,7 @@ PG 默认上 16 版(工具链最齐),驱动用 **psycopg3**(SQLAlchemy 2.0 时
|
||||
|
||||
## 1. 本地起 PG + 跑通空库(半天)
|
||||
|
||||
### 1.0 推荐:Docker 一键起(本地开发/测试)
|
||||
|
||||
本地开发不必手动装 PG。已提供 `docker-compose.yml` + `scripts/ensure_pg.py`:
|
||||
|
||||
```bash
|
||||
cp .env.example .env # DATABASE_URL 默认已是 Docker PG 连接串
|
||||
./run.sh # 或 run.bat;会自动:探测 PG → 没起则启 Docker → 起 PG 容器 → 建库 → alembic → uvicorn
|
||||
pytest # conftest 自动引导同一容器的 shaguabijia_test 库
|
||||
```
|
||||
|
||||
容器:`postgres:16-alpine`(名 `shaguabijia-pg`,端口 5432,命名卷 `pgdata` 持久化),
|
||||
首启即建业务库 `shaguabijia` 与测试库 `shaguabijia_test`。下面 1.1-1.5 的手动装 PG 步骤仅在
|
||||
不用 Docker 时才需要;生产仍走 §4 的原生 PG。
|
||||
|
||||
### 1.1 装 PG(不用 Docker 时的手动方式)
|
||||
### 1.1 装 PG
|
||||
|
||||
macOS:
|
||||
```bash
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
## 函数 / 异常
|
||||
| 函数 | 行为 |
|
||||
|---|---|
|
||||
| `send_code(phone) -> int` | 防刷检查(冷却)→ `secrets` 生成 N 位码 → **预占**(冷却/存码)→ mock 打日志 / real 调极光 → 失败**回滚预占**。返回距下次可发秒数 |
|
||||
| `send_code(phone) -> int` | 防刷检查(冷却 + 每日上限)→ `secrets` 生成 N 位码 → **预占**(冷却/计数/存码)→ mock 打日志 / real 调极光 → 失败**回滚预占**。返回距下次可发秒数 |
|
||||
| `verify_code(phone, code) -> bool` | mock 放行任意 6 位;real 比对存码,匹配即作废,失败累计到上限作废 |
|
||||
| `SmsError(msg, status_code)` | `status_code` 决定 HTTP 码:过频 **429**、供应商失败 **503**、号码无效 **400** |
|
||||
| `SmsError(msg, status_code)` | `status_code` 决定 HTTP 码:过频/每日超限 **429**、供应商失败 **503**、号码无效 **400** |
|
||||
|
||||
## 配置
|
||||
| 配置项 | 默认 | 说明 |
|
||||
@@ -30,16 +30,17 @@
|
||||
| `SMS_SIGN_ID` | 31729 | 极光签名 ID |
|
||||
| `SMS_TEMPLATE_ID` | 1 | 极光模板 ID(变量名 `code`) |
|
||||
| `SMS_CODE_LENGTH` | 6 | 验证码位数(本服务生成;前端 code 4-8 位兼容) |
|
||||
| `SMS_DAILY_LIMIT_PER_PHONE` | 10 | 单号每日发送上限(防刷 + 控费) |
|
||||
| `SMS_MAX_VERIFY_ATTEMPTS` | 5 | 单码最多校验失败次数,超过作废(防爆破) |
|
||||
|
||||
> **鉴权复用极光一键登录**:`/v1/messages` 用 `base64(JG_APP_KEY:JG_MASTER_SECRET)` 做 HTTP Basic Auth——短信与一键登录是**同一个极光应用**(同 AppKey)。**上线不需要额外凭证,只需 `SMS_MOCK=false`**(`JG_*` 一键登录已配)。
|
||||
|
||||
## 防刷(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权)
|
||||
> 2026-07-03 精简:登录风控只留「单号冷却 + 单设备频控」两道主策略(删单号每日上限、删登录纯 IP `rate_limit`);单码失败上限属验证码安全底线,保留。
|
||||
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(单号维度)
|
||||
2. 单设备(`device_id` + IP)每小时频控:`/sms/send` ≤ `SMS_SEND_MAX_PER_HOUR_PER_DEVICE`(5)、`/sms/login` ≤ `SMS_LOGIN_MAX_PER_HOUR`(5)——堵「换号绕开单号冷却」+ 挡登录撞库,在 `app/api/v1/auth.py` 内 `enforce_rate_limit`
|
||||
3. 单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废 + 验过即作废(一次性)
|
||||
4. 运维侧建议在极光控制台叠加:**IP 白名单**(只许服务器 IP)+ **防轰炸设置**
|
||||
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却
|
||||
2. 单号每日 `SMS_DAILY_LIMIT_PER_PHONE` 条上限
|
||||
3. 单 IP 频控:`/sms/send` 挂 `rate_limit(10,60)`、`/sms/login` 挂 `rate_limit(20,60)`
|
||||
4. 单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废
|
||||
5. 运维侧建议在极光控制台叠加:**IP 白名单**(只许服务器 IP)+ **防轰炸设置**
|
||||
|
||||
## 极光错误码(节选,映射在 `_send_via_jiguang`)
|
||||
| code | 含义 | 处理 |
|
||||
@@ -55,4 +56,4 @@
|
||||
3. 真机发一条验证:收到短信 + 能登录
|
||||
|
||||
## 已知局限
|
||||
**验证码存进程内存**:单 worker uvicorn 够用;重启丢码(用户重发即可);**多 worker / 多机不共享 → 冷却 / 校验失效**,扩 worker 前迁移到 DB/Redis。见 [待办与技术债](../guides/待办与技术债.md)。
|
||||
**验证码存进程内存**:单 worker uvicorn 够用;重启丢码(用户重发即可);**多 worker / 多机不共享 → 冷却 / 每日上限 / 校验失效**,扩 worker 前迁移到 DB/Redis。见 [待办与技术债](../guides/待办与技术债.md)。
|
||||
|
||||
@@ -1,740 +0,0 @@
|
||||
# 本地开发切 Docker PostgreSQL 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 让 app-server 本地开发运行与 pytest 都跑在 Docker 化的 PostgreSQL 16 上,退掉 SQLite,`run.bat`/`run.sh` 启动时自动检测并拉起 PG(必要时先启 Docker Desktop、缺镜像先拉)。
|
||||
|
||||
**Architecture:** 新增 `docker-compose.yml`(声明 PG 服务/卷/健康检查)+ `scripts/ensure_pg.py`(跨平台引导:探测→启 Docker→compose up→等就绪→幂等建测试库),由 `run.sh`/`run.bat`/`tests/conftest.py` 三处共用。`.env.example` 默认切 PG。`app/db/session.py` 与 `alembic/env.py` 已天然支持 PG,无需改。
|
||||
|
||||
**Tech Stack:** Docker Compose、`postgres:16-alpine`、Python 3.10+ 标准库(`socket`/`subprocess`/`urllib.parse`)、psycopg3(已装)、SQLAlchemy 2.0 + Alembic、pytest。
|
||||
|
||||
**工作目录:** 本计划在 worktree `.worktrees/local-dev-postgres-docker`(分支 `chore/local-dev-postgres-docker`,基于 `main`)内执行。下面所有路径相对该 worktree 根(= 仓库根)。
|
||||
|
||||
**设计依据:** [docs/superpowers/specs/2026-07-08-local-dev-postgres-docker-design.md](../specs/2026-07-08-local-dev-postgres-docker-design.md)
|
||||
|
||||
**连接参数(全程固定值):**
|
||||
- 镜像 `postgres:16-alpine`,容器名 `shaguabijia-pg`,宿主端口 `5432`
|
||||
- 用户 `shaguabijia_app`,dev 密码 `shaguabijia_dev_pw`(本地非机密)
|
||||
- 业务库 `shaguabijia`,测试库 `shaguabijia_test`,命名卷 `pgdata`
|
||||
- dev URL:`postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia`
|
||||
- test URL:`...@localhost:5432/shaguabijia_test`
|
||||
|
||||
**前置:** 执行机已安装 Docker Desktop。
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Docker Compose + 测试库 initdb 脚本
|
||||
|
||||
**Files:**
|
||||
- Create: `docker-compose.yml`
|
||||
- Create: `docker/initdb/01-create-test-db.sql`
|
||||
|
||||
- [ ] **Step 1: 写 `docker-compose.yml`**
|
||||
|
||||
```yaml
|
||||
# 本地开发/测试用 PostgreSQL。生产用原生 PG(scripts/init_postgres.py),不使用本文件。
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: shaguabijia-pg
|
||||
environment:
|
||||
POSTGRES_USER: shaguabijia_app
|
||||
POSTGRES_PASSWORD: shaguabijia_dev_pw
|
||||
POSTGRES_DB: shaguabijia
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./docker/initdb:/docker-entrypoint-initdb.d:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U shaguabijia_app -d shaguabijia"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 写 `docker/initdb/01-create-test-db.sql`**
|
||||
|
||||
```sql
|
||||
-- 仅在 pgdata 卷首次初始化时执行一次(以 shaguabijia_app 连 shaguabijia 库运行)。
|
||||
-- 幂等兜底见 scripts/ensure_pg.py 的 _ensure_test_db()。
|
||||
CREATE DATABASE shaguabijia_test OWNER shaguabijia_app;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 起容器验证**
|
||||
|
||||
Run: `docker compose up -d`
|
||||
Expected: 拉取 `postgres:16-alpine`(首次)后 `Container shaguabijia-pg Started`。
|
||||
|
||||
- [ ] **Step 4: 验证两个库都在 + 健康**
|
||||
|
||||
Run: `docker compose exec -T postgres psql -U shaguabijia_app -d shaguabijia -tAc "SELECT datname FROM pg_database WHERE datname IN ('shaguabijia','shaguabijia_test') ORDER BY 1"`
|
||||
Expected 输出:
|
||||
```
|
||||
shaguabijia
|
||||
shaguabijia_test
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 验证 `.worktrees/` 与 `data/` 忽略不受影响、compose 无落盘到项目目录**
|
||||
|
||||
Run: `git status --short`
|
||||
Expected: 只列出本任务新增的 `docker-compose.yml`、`docker/initdb/01-create-test-db.sql`(数据在命名卷 `pgdata`,不在项目目录;`.worktrees/` 已忽略)。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add docker-compose.yml docker/initdb/01-create-test-db.sql
|
||||
git commit -m "feat(dev): docker-compose 起本地 PostgreSQL(含测试库 initdb)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `scripts/ensure_pg.py` 引导脚本(TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/ensure_pg.py`
|
||||
- Test: `tests/test_ensure_pg.py`
|
||||
|
||||
> 说明:纯函数(URL 解析 / sqlite 判定 / 端口探测 / 平台命令映射 / sqlite 守卫 / 端口通时短路)走 TDD 单测;真正拉 Docker 的编排 `ensure()` 全链路靠 Task 3/5 的运行来验证(需真 Docker,不做单测)。此时 `conftest.py` 仍是 SQLite,不依赖 PG,单测可独立跑。
|
||||
|
||||
- [ ] **Step 1: 写失败测试 `tests/test_ensure_pg.py`**
|
||||
|
||||
```python
|
||||
"""scripts/ensure_pg.py 纯函数单测(不需要 Docker/PG)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
|
||||
from scripts.ensure_pg import (
|
||||
_docker_desktop_cmd,
|
||||
_is_sqlite,
|
||||
_parse_host_port,
|
||||
_port_open,
|
||||
ensure,
|
||||
)
|
||||
|
||||
|
||||
def test_is_sqlite():
|
||||
assert _is_sqlite("sqlite:///./data/app.db")
|
||||
assert _is_sqlite(" SQLite:///x ")
|
||||
assert not _is_sqlite("postgresql+psycopg://u:p@localhost:5432/db")
|
||||
|
||||
|
||||
def test_parse_host_port_full():
|
||||
assert _parse_host_port(
|
||||
"postgresql+psycopg://u:p@localhost:5432/shaguabijia"
|
||||
) == ("localhost", 5432)
|
||||
|
||||
|
||||
def test_parse_host_port_defaults():
|
||||
# 缺端口 → 5432
|
||||
assert _parse_host_port("postgresql+psycopg://u:p@db.example/x")[1] == 5432
|
||||
# 缺 host → localhost
|
||||
assert _parse_host_port("postgresql+psycopg:///x") == ("localhost", 5432)
|
||||
|
||||
|
||||
def test_parse_host_port_testdb():
|
||||
assert _parse_host_port(
|
||||
"postgresql+psycopg://u:p@localhost:5432/shaguabijia_test"
|
||||
) == ("localhost", 5432)
|
||||
|
||||
|
||||
def test_port_open_true():
|
||||
srv = socket.socket()
|
||||
srv.bind(("127.0.0.1", 0))
|
||||
srv.listen(1)
|
||||
port = srv.getsockname()[1]
|
||||
try:
|
||||
assert _port_open("127.0.0.1", port, timeout=1.0)
|
||||
finally:
|
||||
srv.close()
|
||||
|
||||
|
||||
def test_port_open_false():
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close() # 释放端口,无人监听 → 连接应失败
|
||||
assert not _port_open("127.0.0.1", port, timeout=0.3)
|
||||
|
||||
|
||||
def test_docker_desktop_cmd_windows():
|
||||
cmd = _docker_desktop_cmd("win32", r"C:\Program Files")
|
||||
assert cmd is not None
|
||||
assert cmd[0].endswith("Docker Desktop.exe")
|
||||
assert "Docker" in cmd[0]
|
||||
|
||||
|
||||
def test_docker_desktop_cmd_darwin():
|
||||
assert _docker_desktop_cmd("darwin", "") == ["open", "-a", "Docker"]
|
||||
|
||||
|
||||
def test_docker_desktop_cmd_linux():
|
||||
assert _docker_desktop_cmd("linux", "") is None
|
||||
|
||||
|
||||
def test_ensure_rejects_sqlite():
|
||||
# dev 守卫:sqlite 直接 False(不碰 Docker)
|
||||
assert ensure("sqlite:///./data/app.db") is False
|
||||
|
||||
|
||||
def test_ensure_shortcircuits_when_pg_up(monkeypatch):
|
||||
# 端口通 → 直接 True,绝不触碰 docker
|
||||
monkeypatch.setattr("scripts.ensure_pg._port_open", lambda *a, **k: True)
|
||||
|
||||
def _boom():
|
||||
raise AssertionError("端口通时不应调用 docker")
|
||||
|
||||
monkeypatch.setattr("scripts.ensure_pg._docker_cli_ok", _boom)
|
||||
assert ensure("postgresql+psycopg://u:p@localhost:5432/shaguabijia") is True
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `pytest tests/test_ensure_pg.py -v`
|
||||
Expected: FAIL —— `ModuleNotFoundError: No module named 'scripts.ensure_pg'`(还没建)。
|
||||
|
||||
- [ ] **Step 3: 写实现 `scripts/ensure_pg.py`**
|
||||
|
||||
```python
|
||||
"""确保本地 PostgreSQL 就绪(开发/测试统一用 Docker PG)。
|
||||
|
||||
被三处复用:
|
||||
- run.sh / run.bat:`python -m scripts.ensure_pg`(CLI,失败退非 0)
|
||||
- tests/conftest.py:`from scripts.ensure_pg import ensure; ensure(test_url)`
|
||||
|
||||
流程:读 DATABASE_URL → TCP 探测 → 没起就(必要时启 Docker Desktop)→
|
||||
`docker compose up -d` → 等 PG ready → 幂等确保测试库存在。全程无 SQLite 兜底。
|
||||
|
||||
生产用原生 PG(scripts/init_postgres.py),不走本模块。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# 日志里可能含 emoji(如 ✅);Windows GBK 控制台(cmd.exe)无法编码会抛 UnicodeEncodeError → 脚本崩、
|
||||
# run.bat 误判 ensure_pg 失败。用 backslashreplace 保底:中文仍正常,仅不可编码字符被转义,不崩。
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.reconfigure(errors="backslashreplace")
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
|
||||
APP_DB = "shaguabijia"
|
||||
TEST_DB = "shaguabijia_test"
|
||||
DB_USER = "shaguabijia_app"
|
||||
COMPOSE_SERVICE = "postgres"
|
||||
|
||||
DOCKER_START_TIMEOUT = int(os.environ.get("ENSURE_PG_DOCKER_TIMEOUT", "120"))
|
||||
PG_READY_TIMEOUT = int(os.environ.get("ENSURE_PG_READY_TIMEOUT", "60"))
|
||||
POLL_INTERVAL = 3.0
|
||||
|
||||
SQLITE_FIX_HINT = (
|
||||
"postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia"
|
||||
)
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
print(f"[ensure_pg] {msg}", flush=True)
|
||||
|
||||
|
||||
def _is_sqlite(url: str) -> bool:
|
||||
return url.strip().lower().startswith("sqlite")
|
||||
|
||||
|
||||
def _parse_host_port(url: str) -> tuple[str, int]:
|
||||
"""从 SQLAlchemy URL 取 host/port,缺省 localhost:5432。"""
|
||||
parts = urlsplit(url)
|
||||
return (parts.hostname or "localhost"), (parts.port or 5432)
|
||||
|
||||
|
||||
def _port_open(host: str, port: int, timeout: float = 1.0) -> bool:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _docker_desktop_cmd(platform: str, program_files: str) -> list[str] | None:
|
||||
"""按平台给出启动 Docker Desktop 的命令;Linux 返回 None(daemon 需 sudo,让用户手动)。"""
|
||||
if platform.startswith("win"):
|
||||
return [str(Path(program_files) / "Docker" / "Docker" / "Docker Desktop.exe")]
|
||||
if platform == "darwin":
|
||||
return ["open", "-a", "Docker"]
|
||||
return None
|
||||
|
||||
|
||||
def _docker_ok(subcmd: str) -> bool:
|
||||
"""`docker version`(CLI 在不在)/`docker info`(daemon 起没起)成功与否。"""
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", subcmd],
|
||||
cwd=ROOT,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
return True
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
return False
|
||||
|
||||
|
||||
def _docker_cli_ok() -> bool:
|
||||
return _docker_ok("version")
|
||||
|
||||
|
||||
def _docker_daemon_ok() -> bool:
|
||||
return _docker_ok("info")
|
||||
|
||||
|
||||
def _start_docker_daemon() -> bool:
|
||||
"""守护进程没起时按平台拉起,轮询到就绪。返回是否成功。"""
|
||||
if _docker_daemon_ok():
|
||||
return True
|
||||
cmd = _docker_desktop_cmd(
|
||||
sys.platform, os.environ.get("ProgramFiles", r"C:\Program Files")
|
||||
)
|
||||
if cmd is None:
|
||||
_log("Docker 守护进程未运行。Linux 请手动:sudo systemctl start docker,然后重试。")
|
||||
return False
|
||||
if sys.platform.startswith("win") and not Path(cmd[0]).exists():
|
||||
_log(f"找不到 Docker Desktop:{cmd[0]}。请手动启动 Docker Desktop 后重试。")
|
||||
return False
|
||||
_log(f"启动 Docker Desktop(首次冷启可能 30-60s)…")
|
||||
try:
|
||||
subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except OSError as e:
|
||||
_log(f"启动 Docker Desktop 失败:{e}")
|
||||
return False
|
||||
deadline = time.monotonic() + DOCKER_START_TIMEOUT
|
||||
while time.monotonic() < deadline:
|
||||
if _docker_daemon_ok():
|
||||
_log("Docker 守护进程已就绪。")
|
||||
return True
|
||||
_log("等待 Docker 守护进程…")
|
||||
time.sleep(POLL_INTERVAL)
|
||||
_log(f"等待 Docker 守护进程超时({DOCKER_START_TIMEOUT}s)。")
|
||||
return False
|
||||
|
||||
|
||||
def _compose_up() -> bool:
|
||||
_log("docker compose up -d(镜像缺失会自动拉取,首用约几十秒)…")
|
||||
try:
|
||||
subprocess.run(["docker", "compose", "up", "-d"], cwd=ROOT, check=True)
|
||||
return True
|
||||
except (OSError, subprocess.CalledProcessError) as e:
|
||||
_log(f"docker compose up 失败:{e}")
|
||||
return False
|
||||
|
||||
|
||||
def _pg_isready() -> bool:
|
||||
r = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
|
||||
"pg_isready", "-U", DB_USER, "-d", APP_DB],
|
||||
cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return r.returncode == 0
|
||||
|
||||
|
||||
def _wait_pg_ready(host: str, port: int) -> bool:
|
||||
deadline = time.monotonic() + PG_READY_TIMEOUT
|
||||
while time.monotonic() < deadline:
|
||||
if _port_open(host, port) and _pg_isready():
|
||||
_log("PostgreSQL 已就绪。")
|
||||
return True
|
||||
_log("等待 PostgreSQL 就绪…")
|
||||
time.sleep(POLL_INTERVAL)
|
||||
_log(f"等待 PostgreSQL 就绪超时({PG_READY_TIMEOUT}s)。")
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_test_db() -> None:
|
||||
"""幂等建测试库(兼容老 pgdata 卷首启没跑 initdb 的情况)。"""
|
||||
check = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
|
||||
"psql", "-U", DB_USER, "-d", APP_DB, "-tAc",
|
||||
f"SELECT 1 FROM pg_database WHERE datname='{TEST_DB}'"],
|
||||
cwd=ROOT, capture_output=True, text=True,
|
||||
)
|
||||
if check.returncode == 0 and check.stdout.strip() == "1":
|
||||
return
|
||||
_log(f"建测试库 {TEST_DB}…")
|
||||
subprocess.run(
|
||||
["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
|
||||
"psql", "-U", DB_USER, "-d", APP_DB, "-c",
|
||||
f"CREATE DATABASE {TEST_DB} OWNER {DB_USER}"],
|
||||
cwd=ROOT, check=False,
|
||||
)
|
||||
|
||||
|
||||
def ensure(database_url: str | None = None) -> bool:
|
||||
"""确保 PG 就绪,返回 True/False。database_url 缺省从 settings 读(尊重 .env)。"""
|
||||
if database_url is None:
|
||||
from app.core.config import settings # 延迟导入,避免过早固化 settings
|
||||
|
||||
database_url = settings.DATABASE_URL
|
||||
|
||||
if _is_sqlite(database_url):
|
||||
_log("检测到 DATABASE_URL 仍是 SQLite。本地开发/测试已切 PostgreSQL,请改成:")
|
||||
_log(f" DATABASE_URL={SQLITE_FIX_HINT}")
|
||||
return False
|
||||
|
||||
host, port = _parse_host_port(database_url)
|
||||
|
||||
if _port_open(host, port):
|
||||
_log(f"✅ PostgreSQL 已在 {host}:{port} 运行,跳过 Docker。")
|
||||
return True
|
||||
|
||||
_log(f"{host}:{port} 无 PostgreSQL,准备用 Docker 拉起…")
|
||||
|
||||
if not _docker_cli_ok():
|
||||
_log("未检测到 docker 命令。请先安装 Docker Desktop:")
|
||||
_log(" https://www.docker.com/products/docker-desktop/")
|
||||
return False
|
||||
if not _start_docker_daemon():
|
||||
return False
|
||||
if not _compose_up():
|
||||
return False
|
||||
if not _wait_pg_ready(host, port):
|
||||
return False
|
||||
|
||||
_ensure_test_db()
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(0 if ensure() else 1)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `pytest tests/test_ensure_pg.py -v`
|
||||
Expected: 11 passed。
|
||||
|
||||
- [ ] **Step 5: 手动冒烟(PG 已在跑时应秒过短路)**
|
||||
|
||||
Run: `python -m scripts.ensure_pg`
|
||||
Expected: 打印 `[ensure_pg] ✅ PostgreSQL 已在 localhost:5432 运行,跳过 Docker。`,退出码 0。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/ensure_pg.py tests/test_ensure_pg.py
|
||||
git commit -m "feat(dev): scripts/ensure_pg.py 探测/拉起本地 Docker PostgreSQL"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `run.sh` / `run.bat` 接入 ensure_pg
|
||||
|
||||
**Files:**
|
||||
- Modify: `run.sh`(在 `alembic upgrade head` 前插一步)
|
||||
- Modify: `run.bat`(同上)
|
||||
|
||||
- [ ] **Step 1: 改 `run.sh`**
|
||||
|
||||
在 `mkdir -p data` 之后、`"$PY" -m alembic upgrade head` 之前插入:
|
||||
|
||||
```bash
|
||||
"$PY" -m scripts.ensure_pg # 确保本地 Docker PostgreSQL 就绪(没起会自动拉起;失败即退出)
|
||||
```
|
||||
|
||||
(`set -e` 已在文件顶部,ensure_pg 失败会自动终止脚本。)
|
||||
|
||||
- [ ] **Step 2: 改 `run.bat`**
|
||||
|
||||
在 `if not exist data mkdir data` 之后、`call "%PY%" -m alembic upgrade head` 之前插入:
|
||||
|
||||
```bat
|
||||
REM 确保本地 Docker PostgreSQL 就绪(没起会自动拉起 Docker + PG 容器)
|
||||
call "%PY%" -m scripts.ensure_pg
|
||||
if errorlevel 1 (
|
||||
echo [X] ensure_pg failed ^(PostgreSQL 未就绪^)
|
||||
exit /b %errorlevel%
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证 `run.sh`(PG 已在跑,应短路后继续 alembic + uvicorn)**
|
||||
|
||||
Run(Git Bash):`bash run.sh 8770`
|
||||
Expected: 依次出现 `[ensure_pg] ✅ PostgreSQL 已在 localhost:5432 运行` → alembic 无报错 → uvicorn `Application startup complete`。`Ctrl-C` 停。
|
||||
|
||||
- [ ] **Step 4: 验证 `run.bat`(同上,Windows 原生)**
|
||||
|
||||
Run(cmd/PowerShell):`.\run.bat 8770`
|
||||
Expected: 同 Step 3。`Ctrl-C` 停。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add run.sh run.bat
|
||||
git commit -m "feat(dev): run.sh/run.bat 启动前确保 Docker PostgreSQL 就绪"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `.env.example` 默认切 PostgreSQL
|
||||
|
||||
**Files:**
|
||||
- Modify: `.env.example`(第 8-10 行「数据库」段)
|
||||
|
||||
- [ ] **Step 1: 改 `.env.example` 的 DATABASE_URL**
|
||||
|
||||
把:
|
||||
```ini
|
||||
# ===== 数据库 =====
|
||||
# SQLite 本地文件路径。生产环境用 /opt/shaguabijia-app-server/data.db
|
||||
DATABASE_URL=sqlite:///./data/app.db
|
||||
```
|
||||
改成:
|
||||
```ini
|
||||
# ===== 数据库 =====
|
||||
# 本地开发/测试统一用 Docker PostgreSQL:run.bat/run.sh 会自动拉起容器
|
||||
# (docker-compose.yml + scripts/ensure_pg.py)。详见 docs/database/postgres-migration.md。
|
||||
# 生产用原生 PG,由 scripts/init_postgres.py 写入强随机密码的连接串。
|
||||
# ⚠️ scheme 必须是 postgresql+psycopg://(psycopg3);不要写成 postgresql://(会去找未装的 psycopg2)。
|
||||
DATABASE_URL=postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证(新 .env 从模板复制后能起服务)**
|
||||
|
||||
Run: `cp .env.example /tmp/env.check && grep '^DATABASE_URL=' /tmp/env.check`
|
||||
Expected: `DATABASE_URL=postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .env.example
|
||||
git commit -m "feat(dev): .env.example 默认 DATABASE_URL 切 Docker PostgreSQL"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `tests/conftest.py` 切 PostgreSQL 测试库
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/conftest.py`(整体替换:去掉临时 SQLite,改指 `shaguabijia_test` + 调 ensure_pg + fixture 改 drop/create)
|
||||
|
||||
- [ ] **Step 1: 整体替换 `tests/conftest.py`**
|
||||
|
||||
```python
|
||||
"""测试用 fixtures。
|
||||
|
||||
测试库用 Docker PG 的 shaguabijia_test(与 dev 业务库 shaguabijia 隔离)。
|
||||
顺序(必须):设 test DATABASE_URL(在 import app.* 之前)→ ensure PG 就绪 →
|
||||
import app → 建表。持久卷可能残留上次的表 → session 开头先 drop 再 create。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
|
||||
# 1) 测试库连接串——必须在 import app.* 之前设好(app.db.session 在 import 期建 engine)
|
||||
_TEST_DB_URL = (
|
||||
"postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia_test"
|
||||
)
|
||||
os.environ["DATABASE_URL"] = _TEST_DB_URL
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-please-ignore-this-is-only-for-pytest-not-real")
|
||||
os.environ.setdefault("ADMIN_JWT_SECRET", "test-admin-secret-please-ignore-only-for-pytest-not-real")
|
||||
os.environ.setdefault("JG_APP_KEY", "test-key")
|
||||
os.environ.setdefault("JG_MASTER_SECRET", "test-secret")
|
||||
os.environ.setdefault("SMS_MOCK", "true")
|
||||
os.environ.setdefault("APP_ENV", "dev")
|
||||
os.environ.setdefault("APP_DEBUG", "false") # 测试不打 SQL 日志
|
||||
os.environ.setdefault("WECHAT_APP_ID", "wxtest0000000000")
|
||||
os.environ.setdefault("WECHAT_APP_SECRET", "test-secret")
|
||||
os.environ.setdefault("WXPAY_MCH_ID", "test-mch")
|
||||
os.environ.setdefault("WXPAY_MCH_SERIAL_NO", "test-serial")
|
||||
os.environ.setdefault("WXPAY_PUBLIC_KEY_ID", "test-pubkey-id")
|
||||
os.environ.setdefault("RATE_LIMIT_ENABLED", "false")
|
||||
os.environ.setdefault("PANGLE_CALLBACK_ENABLED", "true")
|
||||
os.environ.setdefault("PANGLE_REWARD_SECRET", "test-pangle-secret-only-for-pytest")
|
||||
|
||||
# 2) 保证 Docker PG 就绪 + 测试库存在(必须在 import app.db.session 建 engine 之前)
|
||||
from scripts.ensure_pg import ensure
|
||||
|
||||
if not ensure(_TEST_DB_URL):
|
||||
raise RuntimeError(
|
||||
"测试需要 Docker PostgreSQL 就绪。请确认已装 Docker Desktop;"
|
||||
"或先跑一次 run.bat/run.sh 把 PG 拉起,再重试 pytest。"
|
||||
)
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.db.base import Base
|
||||
from app.db.session import engine
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _setup_db() -> Iterator[None]:
|
||||
# 持久卷可能残留上次跑崩后的表/数据 → 先 drop 再 create,保证干净起点
|
||||
Base.metadata.drop_all(engine)
|
||||
Base.metadata.create_all(engine)
|
||||
yield
|
||||
Base.metadata.drop_all(engine)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 conftest 能引导 PG 并收集用例(选一个不涉 DB 的测试文件)**
|
||||
|
||||
Run: `pytest tests/test_ensure_pg.py -v`
|
||||
Expected: conftest 先打印 `[ensure_pg] ✅ PostgreSQL 已在 localhost:5432 运行`(或拉起过程),随后 12 passed。说明「测试走 PG 引导」链路通、且纯函数测试不受影响。
|
||||
|
||||
- [ ] **Step 3: 验证建表落到 PG 测试库(跑一个 DB 相关用例)**
|
||||
|
||||
Run: `pytest tests/test_invite.py -v`
|
||||
Expected: 用例在 `shaguabijia_test` 上建表并执行(可能有个别红,留待 Task 6);关键是不再出现 SQLite 临时文件、engine 连的是 PG。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/conftest.py
|
||||
git commit -m "test(dev): conftest 切 shaguabijia_test(Docker PG),引导+drop/create"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 全量跑 pytest on PG,逐个修红用例
|
||||
|
||||
> SQLite 宽松、PG 严格,切库会暴露一批真 bug(迁移指南 §2.2 已列)。本任务是**发现驱动**:先跑全量、按类别归因、按下述配方修,直到全绿。修改范围限被测业务/模型代码,不改测试来掩盖真 bug(除非测试本身依赖 SQLite 特性,如秒级时间精度)。
|
||||
|
||||
**Files:**
|
||||
- Modify: 视失败而定(常见:`app/models/*.py`、`app/**/repositories/*.py`、少量 `tests/*.py`)
|
||||
|
||||
- [ ] **Step 1: 全量跑,拿到失败清单**
|
||||
|
||||
Run: `pytest -q`
|
||||
Expected: 大部分通过;记录所有 FAIL 的用例名与报错文本,按下面类别归因。
|
||||
|
||||
- [ ] **Step 2: 修「naive datetime / 时区」类**
|
||||
|
||||
定位:`git grep -n "utcnow()" app/`。把 `datetime.utcnow()` 改成 `datetime.now(timezone.utc)`(并 `from datetime import timezone`)。
|
||||
症状:PG `TIMESTAMPTZ` 与 naive datetime 比较/写入报错或结果错位;`tests/test_cps_admin.py` 已注释过 SQLite 忽略 tzinfo 的行为。
|
||||
|
||||
例:
|
||||
```python
|
||||
# 改前
|
||||
from datetime import datetime
|
||||
ts = datetime.utcnow()
|
||||
# 改后
|
||||
from datetime import datetime, timezone
|
||||
ts = datetime.now(timezone.utc)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 修「字符串/整数隐式比较」类**
|
||||
|
||||
症状:SQLite 允许 `WHERE phone = 13800138000`(自动转型),PG 直接报类型错。定位报错用例引用的查询,确保比较两侧类型一致(手机号等一律按字符串传参 `:phone`,不要传裸 int)。
|
||||
|
||||
- [ ] **Step 3.5: 修「事务已中止」类**
|
||||
|
||||
症状:某用例后续报 `current transaction is aborted, commands ignored until end of transaction block`,根因是前一句 SQL 出错后业务代码缺 `db.rollback()`/`db.commit()` 边界。补上正确的 commit/rollback。
|
||||
|
||||
- [ ] **Step 4: 修「测试依赖 SQLite 特性」类(仅此类可改测试)**
|
||||
|
||||
症状:测试断言依赖 SQLite 秒级时间精度或 FK 不强制(见 `test_invite.py:328`、`test_compare_harvest.py:153` 的注释)。PG 下时间精度更高/FK 更严——调整测试数据(如手动拉开时间间隔、用合法 FK)使断言在 PG 下成立,不改业务逻辑。
|
||||
|
||||
- [ ] **Step 5: 反复跑到全绿**
|
||||
|
||||
Run: `pytest -q`
|
||||
Expected: `N passed`(0 failed)。若仍有红,回到 Step 2-4 继续归因。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix(db): 测试套件切 PostgreSQL 后修复严格性暴露的用例"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 文档更新
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/database/postgres-migration.md`(§1 增「本地 Docker 一键起」小节)
|
||||
- Modify: `CLAUDE.md`(DB 段注明 dev/test = Docker PG)
|
||||
- Modify: `scripts/init_postgres.py`(顶部注释区分生产/本地)
|
||||
|
||||
- [ ] **Step 1: `postgres-migration.md` 在「## 1. 本地起 PG」开头插入推荐做法**
|
||||
|
||||
在 `## 1. 本地起 PG + 跑通空库(半天)` 标题下、`### 1.1 装 PG` 之前插入:
|
||||
|
||||
```markdown
|
||||
### 1.0 推荐:Docker 一键起(本地开发/测试)
|
||||
|
||||
本地开发不必手动装 PG。已提供 `docker-compose.yml` + `scripts/ensure_pg.py`:
|
||||
|
||||
```bash
|
||||
cp .env.example .env # DATABASE_URL 默认已是 Docker PG 连接串
|
||||
./run.sh # 或 run.bat;会自动:探测 PG → 没起则启 Docker → 起 PG 容器 → 建库 → alembic → uvicorn
|
||||
pytest # conftest 自动引导同一容器的 shaguabijia_test 库
|
||||
```
|
||||
|
||||
容器:`postgres:16-alpine`(名 `shaguabijia-pg`,端口 5432,命名卷 `pgdata` 持久化),
|
||||
首启即建业务库 `shaguabijia` 与测试库 `shaguabijia_test`。下面 1.1-1.5 的手动装 PG 步骤仅在
|
||||
不用 Docker 时才需要;生产仍走 §4 的原生 PG。
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `CLAUDE.md` DB 段补充**
|
||||
|
||||
找到 DB 相关行(`**Prod**: PostgreSQL — just change DATABASE_URL...`)所在段,在其上方加一行:
|
||||
|
||||
```markdown
|
||||
- **Dev/Test**: Docker PostgreSQL 16 — `run.sh`/`run.bat` 经 `scripts/ensure_pg.py` + `docker-compose.yml` 自动拉起;`.env.example` 默认即 PG 连接串;pytest 用同容器的 `shaguabijia_test` 库。**本地不再用 SQLite**。
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `scripts/init_postgres.py` 顶部注释区分场景**
|
||||
|
||||
把模块 docstring 第一行下方(`新机器初始化用。前置:...` 那行)改为:
|
||||
|
||||
```python
|
||||
新机器初始化用(面向【生产原生 PG】:apt/systemd 装好的 PostgreSQL)。
|
||||
本地开发/测试请改用 docker-compose.yml + scripts/ensure_pg.py(run.sh/run.bat 自动拉起),不必跑本脚本。
|
||||
前置:已装 PostgreSQL 16 + 知道 postgres 超级用户密码。
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 验证无坏链接/格式**
|
||||
|
||||
Run: `git diff --stat`
|
||||
Expected: 三个文档文件有改动,无其他文件被误改。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/database/postgres-migration.md CLAUDE.md scripts/init_postgres.py
|
||||
git commit -m "docs(dev): 记录本地 Docker PostgreSQL 用法,区分生产原生 PG 路径"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完成标准(对齐 spec §8 验收)
|
||||
|
||||
- [ ] 全新机器(装了 Docker Desktop、`.env` 从 `.env.example` 复制)跑 `run.bat`/`run.sh` 全自动拉起 PG 并起服务,无手动装 PG。
|
||||
- [ ] `docker ps` 见 `shaguabijia-pg` healthy;`shaguabijia` 与 `shaguabijia_test` 两库都在。
|
||||
- [ ] PG 已在跑时再跑 `run`,ensure_pg 秒过短路。
|
||||
- [ ] `pytest -q` 全绿(连 `shaguabijia_test`)。
|
||||
- [ ] `.env` 改回 sqlite 时,`python -m scripts.ensure_pg` 硬失败并打印正确 PG 串。
|
||||
- [ ] 能在 `admin/repositories` 写一段 PG 专有聚合(如 `count(*) FILTER (WHERE ...)`),`run` 手动跑通且相应 pytest 通过。
|
||||
|
||||
## Self-Review 记录(计划作者已核)
|
||||
|
||||
- **Spec 覆盖:** spec §9 待实现清单 8 项 → Task 1(compose+initdb)、Task 2(ensure_pg)、Task 3(run 接线)、Task 4(.env.example)、Task 5(conftest)、Task 6(修红用例)、Task 7(文档);「session.py 无需改」在 Header 与 spec §4.7 说明;「data/ 忽略」在 Task 1 Step 5 验证。无遗漏。
|
||||
- **占位符:** 全部步骤含真实代码/命令/期望输出。Task 6 是发现驱动,已用「类别+具体转换配方+定位命令」代替不可预知的逐条 diff——非占位。
|
||||
- **类型/命名一致:** `ensure(database_url=None)` 签名在 Task 2 定义,Task 5 以 `ensure(_TEST_DB_URL)` 调用一致;库名/用户/密码/端口全程为 Header 固定值;compose 服务名 `postgres` 与 ensure_pg `COMPOSE_SERVICE` 一致;`shaguabijia_test` 在 initdb SQL、`_ensure_test_db()`、conftest 三处一致。
|
||||
@@ -1,281 +0,0 @@
|
||||
# 本地开发切 Docker PostgreSQL —— 设计文档
|
||||
|
||||
> 让本地开发与测试统一跑在 Docker 化的 PostgreSQL 上,彻底退掉 SQLite。
|
||||
> `run.bat` / `run.sh` 启动时自动检测本机 PG,没起就拉起 Docker → 起 PG 容器(镜像缺失先拉),
|
||||
> 目的是让开发/大模型能放心用 PG 专有的高效聚合函数,不再为兼容 SQLite 而退化成"取基础数据后内存聚合"。
|
||||
>
|
||||
> 状态:已定稿(待用户复核)。作者对话日期:2026-07-08。
|
||||
> 关联:[postgres-migration.md](../../database/postgres-migration.md)(切引擎完整步骤)、`scripts/init_postgres.py`(生产原生 PG 初始化)。
|
||||
> ⚠️ **2026-07-27 增补(见 §10)**:D4 已从「sqlite 硬失败」松为「显式 SQLite 逃生舱」。§1-9 描述的是初版「彻底退掉 SQLite」设计;凡涉及「无 Docker / DATABASE_URL 是 sqlite 时如何处理」,**以 §10 为准**(测试仍只跑 PG 不变)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 问题
|
||||
当前开发环境默认用 SQLite(`DATABASE_URL=sqlite:///./data/app.db`),生产用 PostgreSQL 16。两套引擎并存,导致写数据访问代码时(尤其 `app/admin/repositories/` 的报表聚合)为了"两边都能跑",放弃 PG 专有能力(窗口函数、`FILTER`、`JSONB` 操作符、`GROUPING SETS` 等),改成"先查基础数据、再在 Python 内存里聚合"——既慢又啰嗦。
|
||||
|
||||
### 目标
|
||||
本地开发与测试都跑在 PG 上,SQLite 退出本地开发闭环。之后写 PG 专有 SQL 时:
|
||||
- 开发运行时(`run.bat`/`run.sh`)直接连 PG,手动验证可行;
|
||||
- `pytest` 也连 PG,PG 专有 SQL 在被测代码路径里也安全,不会因 SQLite 而挂——**这是"双库兼容代码彻底消失"的必要条件**。
|
||||
|
||||
### 非目标(本期不做)
|
||||
- 不动**生产**部署(生产仍是原生 PG16 + systemd,无 Docker;`init_postgres.py` 保持不变)。
|
||||
- 不做数据搬迁(MVP 阶段无真实用户数据,详见迁移指南背景假设)。
|
||||
- 不接 CI(仓库当前无 `.github/workflows`;若将来加 CI,再单独让 CI 起 PG service)。
|
||||
- 不引入 Redis / testcontainers / 连接池中间件。
|
||||
|
||||
---
|
||||
|
||||
## 2. 决策记录(本次对话已拍板)
|
||||
|
||||
| # | 决策点 | 结论 | 理由 |
|
||||
|---|---|---|---|
|
||||
| D1 | PG 覆盖范围 | **dev 运行 + 测试都切 PG** | 只切运行时的话,被 SQLite 测试覆盖的代码路径(如 `test_cps_admin.py` 覆盖的 `admin/repositories/cps.py`)仍不能用 PG 专有 SQL,双库代码不会真正消失 |
|
||||
| D2 | 打包方式 | **方案 A:Compose + `scripts/ensure_pg.py`** | 唯一真正需要定制的部分(启动 Docker 守护进程、等 PG 就绪)集中到一个跨平台模块,`run.bat`/`run.sh`/`conftest.py` 共用;声明式的容器/卷/健康检查交给 Compose |
|
||||
| D3 | 宿主端口 | **5432**(与生产/文档一致) | 边界:若本机已有原生 PG 占 5432,`ensure_pg` 会探测到"PG 已在"直接复用它(可能连到不带业务库的实例)——见 §7 风险,文档提示 |
|
||||
| D4 | dev 下 `DATABASE_URL` 仍是 sqlite | **硬失败**(打印一行 fix 后非 0 退出) | 彻底断掉 SQLite 退路,符合"让大家都用 PG"的目标 |
|
||||
| D5 | dev 数据库密码 | 固定 `shaguabijia_dev_pw`,写进 compose + `.env.example` | 本地容器仅绑 `localhost`,非机密;保证 `.env.example` 复制即可用。生产密码另由 `init_postgres.py` 强随机生成,不复用 |
|
||||
| D6 | 改哪些启动脚本 | `run.bat` 与 `run.sh` **都改** | 仓库一贯保持两者同步 |
|
||||
| D7 | 镜像 | `postgres:16-alpine` | 对齐生产 PG16;alpine 体积小 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 现状(改动前)
|
||||
|
||||
- **配置**:`app/core/config.py` `DATABASE_URL` 默认 `sqlite:///./data/app.db`,pydantic-settings 从 `.env` 读(环境变量优先级高于 `.env` 文件)。
|
||||
- **引擎**:`app/db/session.py` 用 `_is_sqlite = DATABASE_URL.startswith("sqlite")` 分流——SQLite 加 `check_same_thread=False`、不建池;非 SQLite 加 `pool_size=10/max_overflow=20/pool_recycle=3600`。**已天然支持 PG,无需改。**
|
||||
- **迁移**:`alembic/env.py` 从 `settings.DATABASE_URL` 读连接串,`render_as_batch` 仅对 sqlite 开;PG 下自动关。**无 psycopg2 硬编码,切 PG 无需改。**
|
||||
- **驱动**:`pyproject.toml` 已装 `psycopg[binary]>=3.1`(psycopg3)。URL scheme 必须 `postgresql+psycopg://`(裸 `postgresql://` 会被 SQLAlchemy 路由到未安装的 psycopg2 → ModuleNotFoundError)。
|
||||
- **测试**:`tests/conftest.py` 在 import app 前把 `DATABASE_URL` 设成临时文件 SQLite;session 级 autouse fixture 做 `Base.metadata.create_all(engine)` / 结束 `drop_all`(schema 来自 model 而非 alembic,无逐用例 rollback,全会话共享一个库)。
|
||||
- **启动脚本**:`run.bat` / `run.sh` 均为:校验 `.env` 存在 → `mkdir data` → `alembic upgrade head` → uvicorn 监听 `0.0.0.0:8770`(`.sh` 带 `--reload --reload-dir app`)。
|
||||
- **现有 PG 资产**:`scripts/init_postgres.py`(交互式:建用户/建库/授权/写 .env/跑迁移,面向**已装好的原生 PG**)、`docs/database/postgres-migration.md`(切引擎完整步骤,含 §2 测试切 PG、§2.2 会暴露的真 bug 清单)。
|
||||
- **CI**:无(`.github/workflows` 不存在),故测试切 PG 无 CI 联动负担。
|
||||
|
||||
---
|
||||
|
||||
## 4. 方案详解
|
||||
|
||||
### 4.1 新增 `docker-compose.yml`(app-server 根目录)
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: shaguabijia-pg
|
||||
environment:
|
||||
POSTGRES_USER: shaguabijia_app
|
||||
POSTGRES_PASSWORD: shaguabijia_dev_pw
|
||||
POSTGRES_DB: shaguabijia
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./docker/initdb:/docker-entrypoint-initdb.d:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U shaguabijia_app -d shaguabijia"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
volumes:
|
||||
pgdata:
|
||||
```
|
||||
- `POSTGRES_USER` 设定后,该用户以超级用户身份创建并拥有 `POSTGRES_DB`,故能再建测试库。
|
||||
- 首启 initdb 脚本建测试库(见 4.2)。命名卷 `pgdata` 让数据跨重启留存。
|
||||
|
||||
### 4.2 新增 `docker/initdb/01-create-test-db.sql`
|
||||
```sql
|
||||
-- 仅在 pgdata 卷首次初始化时执行一次。以 shaguabijia_app(超级用户)连 shaguabijia 库运行。
|
||||
CREATE DATABASE shaguabijia_test OWNER shaguabijia_app;
|
||||
```
|
||||
|
||||
### 4.3 新增 `scripts/ensure_pg.py`(纯标准库 + docker CLI,跨平台)
|
||||
对外同时暴露**可导入函数** `ensure()`(供 `conftest.py` 直接调)和 **CLI 入口** `if __name__ == "__main__": sys.exit(0 if ensure() else 1)`(供 `run` 以 `python -m scripts.ensure_pg` 跑)。`ensure()` 从 `app.core.config.settings` 读 `DATABASE_URL`,解析 host/port,主流程:
|
||||
1. **sqlite 守卫**:若 `DATABASE_URL` 以 `sqlite` 开头 → 打印"dev 已切 PG,请把 .env 的 DATABASE_URL 改成 `postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia`"→ 非 0 退出(D4)。
|
||||
2. **TCP 探测** `host:port`(stdlib `socket`,超时 1s)。通 → 打印"✅ PG 已就绪"直接返回(幂等:PG 已在跑时开销≈一次握手)。
|
||||
3. 不通 → `docker version` 探 CLI;缺失 → 中文报错"请先安装 Docker Desktop:https://www.docker.com/products/docker-desktop/" → 非 0 退出。
|
||||
4. `docker info` 探守护进程;不通 → 按平台启动:
|
||||
- Windows:`start "" "%ProgramFiles%\Docker\Docker\Docker Desktop.exe"`(找不到则报错让用户手动开)
|
||||
- macOS:`open -a Docker`
|
||||
- Linux:不自动 sudo,打印 `sudo systemctl start docker` 让用户执行后重试
|
||||
然后轮询 `docker info` 直到就绪或超时(默认 120s,每 3s 一次,打印进度)。
|
||||
5. `docker compose up -d`(Compose 在镜像缺失时**自动拉取**,首用拉 alpine ~90MB;有进度输出)。
|
||||
6. 轮询 healthcheck(`docker inspect` 的 health 状态)/ TCP 直到 PG 接受连接(默认 60s 超时)。
|
||||
7. **幂等确保测试库存在**(兼容"老 pgdata 卷没跑过 initdb"的情况):
|
||||
`docker compose exec -T postgres psql -U shaguabijia_app -tc "SELECT 1 FROM pg_database WHERE datname='shaguabijia_test'"`,不存在则 `CREATE DATABASE shaguabijia_test OWNER shaguabijia_app`。
|
||||
|
||||
失败即清晰中文报错 + 非 0 退出,**全程不回退 SQLite**。所有超时可用环境变量覆盖(如 `ENSURE_PG_DOCKER_TIMEOUT`)。
|
||||
|
||||
### 4.4 `run.bat` / `run.sh` 接线
|
||||
在 `alembic upgrade head` **之前**插一行调用,失败即退出:
|
||||
- `run.sh`:`"$PY" -m scripts.ensure_pg`(`set -e` 已在,失败自动退出)
|
||||
- `run.bat`:`call "%PY%" -m scripts.ensure_pg` + `if errorlevel 1 exit /b 1`
|
||||
|
||||
其余逻辑不动(`mkdir data` 保留给 media 等落盘目录)。
|
||||
|
||||
### 4.5 `.env.example` 默认切 PG
|
||||
把
|
||||
```ini
|
||||
DATABASE_URL=sqlite:///./data/app.db
|
||||
```
|
||||
改为
|
||||
```ini
|
||||
# 本地开发/测试统一用 Docker PG(run.bat/run.sh 会自动拉起容器;详见 docs/database/postgres-migration.md §本地 Docker 一键起)。
|
||||
# 生产用原生 PG,由 scripts/init_postgres.py 写入强随机密码的连接串。
|
||||
DATABASE_URL=postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia
|
||||
```
|
||||
|
||||
### 4.6 `tests/conftest.py` 切 PG
|
||||
调整顶部顺序(仍必须在 `import app.*` 之前完成 env 设定):
|
||||
1. 设 `os.environ["DATABASE_URL"] = "postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia_test"`(测试库,永不碰 dev 业务库)。
|
||||
2. 调 `scripts.ensure_pg.ensure()`(保证容器在 + 测试库在;PG 已在时几乎零开销)。
|
||||
3. `import app...`。
|
||||
session 级 autouse fixture:改为 **`Base.metadata.drop_all(engine)` → `create_all(engine)`(开头先清干净,防持久卷里上一次跑残留的表/数据)→ yield → 结束 `drop_all`**;删掉临时 SQLite 文件相关代码。
|
||||
|
||||
> 预期:部分用例会因 PG 的严格性变红(SQLite 宽松、PG 严格),按迁移指南 §2.2 逐个修——常见为:字符串/整数隐式比较、`datetime.utcnow()` naive vs `TIMESTAMPTZ`、事务边界(`current transaction is aborted`)。这既是工作量也是本次改造的**直接收益**(暴露真 bug)。实现阶段需为"跑 pytest 并修红用例"单列步骤。
|
||||
|
||||
### 4.7 `app/db/session.py`
|
||||
**无需改动**——`_is_sqlite` 为假时自动走 PG 池化分支。
|
||||
|
||||
### 4.8 文档
|
||||
- `docs/database/postgres-migration.md` 增一节「本地 Docker 一键起 PG(推荐)」,指向 compose + `ensure_pg`,并说明它替代了 §1.1 的手动 brew/apt 装 PG。
|
||||
- `CLAUDE.md` 的 DB 段注明:dev/test = Docker PG(`run` 自动拉起);prod = 原生 PG(`init_postgres.py`)。
|
||||
- `scripts/init_postgres.py` 顶部注释补一句"本脚本面向生产原生 PG;本地开发用 docker-compose + scripts/ensure_pg"。
|
||||
|
||||
---
|
||||
|
||||
## 5. 连接参数汇总
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 镜像 | `postgres:16-alpine` |
|
||||
| 容器名 | `shaguabijia-pg` |
|
||||
| 宿主端口 | `5432` |
|
||||
| 超级/业务用户 | `shaguabijia_app` |
|
||||
| dev 密码 | `shaguabijia_dev_pw`(本地非机密) |
|
||||
| 业务库(dev 运行) | `shaguabijia` |
|
||||
| 测试库(pytest) | `shaguabijia_test` |
|
||||
| dev `DATABASE_URL` | `postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia` |
|
||||
| test `DATABASE_URL` | `postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia_test` |
|
||||
| 数据持久化 | 命名卷 `pgdata` |
|
||||
|
||||
---
|
||||
|
||||
## 6. 失败处理矩阵(无 SQLite 兜底)
|
||||
|
||||
| 情形 | ensure_pg 行为 |
|
||||
|---|---|
|
||||
| `DATABASE_URL` 是 sqlite | 打印应改成的 PG 串 → 非 0 退出 |
|
||||
| PG 已在跑(TCP 通) | 打印"已就绪" → 返回 0(跳过 docker) |
|
||||
| 无 docker CLI | 提示装 Docker Desktop + 官网链接 → 非 0 退出 |
|
||||
| docker 守护进程未起 | 尝试按平台启动 Docker Desktop,轮询到就绪;超时则报错 → 非 0 退出 |
|
||||
| 镜像缺失 | `docker compose up -d` 自动拉取(不额外处理) |
|
||||
| 容器起了但 PG 未 ready | 轮询 healthcheck 到超时;超时报错 → 非 0 退出 |
|
||||
| 老 pgdata 卷缺测试库 | 幂等 `CREATE DATABASE shaguabijia_test` |
|
||||
|
||||
---
|
||||
|
||||
## 7. 风险与边界
|
||||
|
||||
- **端口占用(原生 PG 撞 5432)**:D3 选了 5432。若开发机已有原生 PG 监听 5432,step 2 的 TCP 探测会判"PG 已在"并复用它——但那个实例可能没有 `shaguabijia`/`shaguabijia_test` 库或用户,后续 `alembic upgrade head` / 测试会报连不上库或认证失败。**缓解**:文档提示"本机别再单独跑原生 PG";报错信息里提示检查是不是撞了原生 PG。
|
||||
- **首次启动慢**:首用需 Docker Desktop 冷启(~30–60s)+ 拉镜像(~数十秒~数分钟,视网络)。`ensure_pg` 全程打印进度,超时可配。
|
||||
- **`DATABASE_URL` 环境变量优先级**:pydantic-settings 里 shell 环境变量优先于 `.env`。若开发者 shell 残留旧的 `DATABASE_URL`(如指向 sqlite),会盖过 `.env`。sqlite 守卫(D4)能挡住 sqlite 残留;但若残留的是另一个 PG 串,则以它为准——文档提示。
|
||||
- **持久卷脏状态**:测试用 drop_all→create_all 开头清库,避免上次崩溃残留污染;dev 业务库随卷留存(符合预期)。
|
||||
- **Docker 未安装/公司网络拉镜像受限**:硬失败并给出明确指引;不提供 SQLite 退路是刻意选择(D4/目标)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
1. 全新机器(装了 Docker Desktop、`.env` 从 `.env.example` 复制)执行 `run.bat`(或 `run.sh`):自动拉起 Docker→起 PG 容器→建库→`alembic upgrade head`→uvicorn 起在 8770,无手动装 PG 步骤。
|
||||
2. `docker ps` 见 `shaguabijia-pg` 健康;`psql`/客户端能连 `shaguabijia` 与 `shaguabijia_test` 两个库。
|
||||
3. PG 已在跑时再次 `run`,`ensure_pg` 秒过(不重复拉容器)。
|
||||
4. `pytest` 连 `shaguabijia_test` 跑;红用例全部修绿(PG 严格性暴露的问题)。
|
||||
5. `.env` 的 `DATABASE_URL` 改回 sqlite 时,`run`/`pytest` 硬失败并打印正确的 PG 串。
|
||||
6. 能在 `admin/repositories/` 里写一段 PG 专有聚合 SQL(如带 `FILTER (WHERE ...)` 的聚合),`run` 下手动跑通、相应 pytest 也通过——即"双库兼容负担消失"的实证。
|
||||
|
||||
---
|
||||
|
||||
## 9. 待实现清单(供 writing-plans 拆解)
|
||||
|
||||
- [ ] 新增 `docker-compose.yml`
|
||||
- [ ] 新增 `docker/initdb/01-create-test-db.sql`
|
||||
- [ ] 新增 `scripts/ensure_pg.py`(TCP 探测 / 启 Docker Desktop 轮询 / compose up / 等 healthy / 幂等建测试库 / sqlite 守卫)
|
||||
- [ ] `run.sh`、`run.bat` 接入 `ensure_pg`
|
||||
- [ ] `.env.example` 的 `DATABASE_URL` 切 PG
|
||||
- [ ] `tests/conftest.py` 切 `shaguabijia_test` + 调 `ensure_pg` + fixture 改 drop/create
|
||||
- [ ] 跑 `pytest`,按迁移指南 §2.2 修红用例
|
||||
- [ ] 文档:`postgres-migration.md` 增「本地 Docker 一键起」节;`CLAUDE.md` DB 段;`init_postgres.py` 注释
|
||||
- [ ] `.gitignore` 确认 `data/` 已忽略(compose 用命名卷,不落项目目录,无需额外忽略)
|
||||
|
||||
---
|
||||
|
||||
## 10. 增补(2026-07-27):D4 反转 —— 显式 SQLite 逃生舱
|
||||
|
||||
> 背景:§2 的 D4 定为「dev 下 `DATABASE_URL` 仍是 sqlite → 硬失败」,目的是彻底断掉 SQLite 退路。实践中这对「本机装不了 Docker」的开发者过于刚性——直接被卡死、连跑都跑不起来。本次(2026-07-27 对话)把 D4 从「硬失败」松成「**显式逃生舱**」:工具**从不替你静默切库**,但会在没 Docker 时告诉你怎么手动降级,且降级时每次启动都醒目告警。
|
||||
|
||||
### 10.1 决策更新
|
||||
|
||||
| # | 原决策 | 新决策 | 理由 |
|
||||
|---|---|---|---|
|
||||
| D4′ | dev sqlite URL → 硬失败退出 | **放行 + 每次打印醒目降级横幅**(仍非静默) | 已手动改 `.env`=sqlite = 开发者的显式选择,尊重它;但吼一嗓子防止忘了自己在降级、把 PG 专有 SQL 提交上去 |
|
||||
| D8(新) | (无) | 无 docker CLI 时,报错里**追加逃生舱指路**(改 `.env`=sqlite),但仍非 0 退出 | 「显式」的关键:工具不替你切库,只指路;开发者改完 `.env` 再跑一次才真正降级 |
|
||||
|
||||
**未变**:D1(测试仍只跑 PG)、D2-D3、D5-D7 全部保留。逃生舱**只作用于 `run.sh`/`run.bat` 运行时**;`pytest` 仍写死连 PG 测试库(`conftest.py` 传 PG URL,sqlite 分支根本不触发),没 Docker 就 `raise`、跑不了完整套件——这正是 D1「测试上 PG 才能暴露真 bug」的初衷,刻意不给逃生舱。
|
||||
|
||||
### 10.2 代码改动(仅 `scripts/ensure_pg.py` 的 `ensure()`)
|
||||
|
||||
1. **sqlite 分支**(原 `return False`)→ 打印多行降级横幅后 `return True`。横幅点明:PG 专有 SQL/严格类型在此模式**不被验证**、提交前须在有 Docker 的机器上用 PG 复跑、装好 Docker 后把 `DATABASE_URL` 改回 PG 串。
|
||||
2. **无 docker CLI 分支**(原仅提示装 Docker + `return False`)→ 追加一句「装不了 Docker?把 `.env` 的 `DATABASE_URL` 改成 `sqlite:///./data/app.db` 可降级运行」;**仍 `return False`**(run 脚本照常退出,开发者需显式改 .env 再跑)。
|
||||
3. **常量**:新增 `SQLITE_URL = "sqlite:///./data/app.db"`(逃生舱指路用);`SQLITE_FIX_HINT` 重命名 `PG_URL`(降级横幅"改回 PG"引用)。
|
||||
4. 更新模块 docstring 中「全程无 SQLite 兜底」一句,改述为「无 Docker/sqlite URL 时【显式】降级 SQLite(带醒目告警),测试侧不降级」。
|
||||
|
||||
**其余全不动**:`run.sh`/`run.bat`(sqlite 下 `ensure` 返 True → 照常 `alembic upgrade head` + uvicorn)、`docker-compose.yml`、`app/db/session.py`(SQLite 引擎分支本就保留为 fallback)、`tests/conftest.py`、`.env.example`(默认仍 PG)。
|
||||
|
||||
### 10.3 改完后行为矩阵(覆盖用户列的 5 场景)
|
||||
|
||||
| 场景 | `DATABASE_URL` | ensure_pg 行为 |
|
||||
|---|---|---|
|
||||
| ① 无 Docker | PG(默认) | 报错 + 指逃生舱 → 退出;开发者改 `.env`=sqlite → 再跑 → **放行 + 降级横幅**,alembic/uvicorn 跑 SQLite |
|
||||
| ② 有 Docker 未启动 | PG | 启 Docker Desktop → `compose up` → 等 ready → 建测试库(**不变**) |
|
||||
| ③ 有 Docker 已启动 | PG | `compose up` → 等 ready(**不变**) |
|
||||
| ④ PG 已在跑 | PG | TCP 通 → 秒过跳过 Docker(**不变**) |
|
||||
| ⑤ PG 起来后 | 任意 | run 脚本 `alembic upgrade head`(**不变**;SQLite 走 `render_as_batch`) |
|
||||
|
||||
### 10.4 风险
|
||||
|
||||
- **降级被忽视**:横幅仅在 `run` 启动时打印一次;若开发者用 IDE 直接起 uvicorn(绕过 run 脚本)则看不到。缓解:横幅足够醒目 + 文档强调;**不**引入 app 启动期重复告警(YAGNI)。
|
||||
- **测试无 Docker 跑不了**:刻意保留(D1)。文档提示无 Docker 者:要么装 Docker 跑全量测试,要么只在 CI/有 Docker 的机器上验证 PG 相关改动。
|
||||
|
||||
### 10.5 Redis 前瞻(不在本次)
|
||||
|
||||
§2 未涉及 Redis。②③ 场景未来若加 Redis 实例:在 `docker-compose.yml` 增 `redis` 服务即可,`docker compose up -d` 天然带起;仅当启动期有组件依赖 Redis 才需给 `ensure_pg` 加 redis readiness 探测。本次不做,方案对它友好。
|
||||
|
||||
### 10.6 附带修复:`_docker_cli_ok` 守护进程误判(2026-07-27)
|
||||
|
||||
诊断「装了 Docker Desktop 却报未检测到 docker」时发现的真 bug:`_docker_cli_ok()` 原用 `docker version`
|
||||
判断 CLI 是否存在,但该命令**要连 daemon**,守护进程没起时退非零 → 把「Docker 装了但没启动」
|
||||
误判成「没装 CLI」,`ensure()` 直接打印"请安装 Docker Desktop"并 `return False`,**绕过了专为需求②
|
||||
写的 `_start_docker_daemon()` 自动拉起逻辑**——需求②(有 Docker 未启动 → 自动启动)因此从未真正生效。
|
||||
修复:改用 `docker --version`(纯客户端、不连 daemon、退 0)。`_docker_daemon_ok()` 仍用 `docker info`
|
||||
(正确,该检查本就依赖 daemon)。实测机器:Docker Desktop 20.10.12 已装但引擎未起,修复前 `_docker_cli_ok()`
|
||||
误报 False,修复后 True。
|
||||
|
||||
### 10.7 附带修复:固定 compose 项目名 + 清理残留同名容器(2026-07-27)
|
||||
|
||||
诊断「`docker compose up` 报 `container name "/shaguabijia-pg" already in use`」时发现的又一 bug:compose
|
||||
项目名默认取运行目录 basename,在不同目录/worktree(如 `local-dev-postgres-docker` vs `shaguabijia-app-server`)
|
||||
之间切换会各自成一个项目;而 `docker-compose.yml` 写死了 `container_name: shaguabijia-pg`(全局唯一名),
|
||||
于是新项目 `up` 时要创建同名容器 → 撞上旧项目留下的那个 → 冲突。副作用:`pgdata` 卷也按项目名分裂
|
||||
成 `local-dev-postgres-docker_pgdata` / `shaguabijia-app-server_pgdata`,数据被切成两半。
|
||||
|
||||
修复(均在 `scripts/ensure_pg.py`,`docker-compose.yml` 不动、容器名仍是 `shaguabijia-pg`):
|
||||
1. 模块级 `os.environ.setdefault("COMPOSE_PROJECT_NAME", "shaguabijia")` —— 钉死项目名,无论从哪个
|
||||
目录/worktree 跑都是同一个项目、同一个卷 `shaguabijia_pgdata`,所有 `docker compose up/exec` 一致。
|
||||
2. `_compose_up()` 前置 `_remove_stale_container()`:若存在「同名但不属于本项目」的残留容器,先 `docker rm -f`
|
||||
再 up(靠 `docker ps --filter name/label` 判归属;数据在命名卷里,删容器不丢)。旧目录/worktree 留下的
|
||||
残留容器就此自动清掉,不需手动干预。
|
||||
|
||||
影响:本次修复后首跑,旧的 `shaguabijia-pg`(属项目 `local-dev-postgres-docker`)会被自动删除、在项目
|
||||
`shaguabijia` 下重建,挂载全新的 `shaguabijia_pgdata`(空库,`alembic upgrade head` 重建表)。旧数据仍留在
|
||||
`local-dev-postgres-docker_pgdata` 卷里(未删,可恢复);确认不需要后可 `docker volume rm` 清理两个旧卷。
|
||||
+1
-1
@@ -169,7 +169,7 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
|
||||
|
||||
短信冷却表存进程内存(`--workers 1` 下够用,多 worker/重启即失效)。
|
||||
|
||||
**real 模式(`SMS_MOCK=false`,生产)**:自定义验证码——本服务 `secrets` 生成 6 位码 → 极光 `/v1/messages` 只负责发 → 本地校验(一次性 / 防爆破),鉴权**复用 `JG_APP_KEY`/`JG_MASTER_SECRET`**(短信与一键登录同一极光应用,**上线只需 `SMS_MOCK=false`**)。防刷(单号冷却 + 单设备频控 + 单码失败次数;2026-07-03 精简删单号每日上限与登录纯 IP 限流)+ 错误码 429/503/400。详见 [integrations/sms](./integrations/sms.md)。
|
||||
**real 模式(`SMS_MOCK=false`,生产)**:自定义验证码——本服务 `secrets` 生成 6 位码 → 极光 `/v1/messages` 只负责发 → 本地校验(一次性 / 防爆破),鉴权**复用 `JG_APP_KEY`/`JG_MASTER_SECRET`**(短信与一键登录同一极光应用,**上线只需 `SMS_MOCK=false`**)。防刷四层(单号冷却 + 单号每日上限 + 单 IP `rate_limit` + 单码失败次数)+ 错误码 429/503/400。详见 [integrations/sms](./integrations/sms.md)。
|
||||
|
||||
### 4.3 Token 与刷新
|
||||
|
||||
|
||||
@@ -30,14 +30,7 @@ if not exist .env (
|
||||
|
||||
if not exist data mkdir data
|
||||
|
||||
REM Ensure local Docker PostgreSQL is up (auto-starts Docker + PG container if needed)
|
||||
call "%PY%" -m scripts.ensure_pg
|
||||
if errorlevel 1 (
|
||||
echo [X] ensure_pg failed ^(PostgreSQL not ready^)
|
||||
exit /b %errorlevel%
|
||||
)
|
||||
|
||||
REM Build/upgrade schema (idempotent; no-op if already at head)
|
||||
REM Build/upgrade SQLite schema (idempotent; no-op if already at head)
|
||||
call "%PY%" -m alembic upgrade head
|
||||
if errorlevel 1 (
|
||||
echo [X] alembic upgrade head failed
|
||||
|
||||
@@ -18,8 +18,7 @@ if [ ! -f .env ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p data # 运行期落盘目录(媒体上传等)
|
||||
"$PY" -m scripts.ensure_pg # 确保本地 Docker PostgreSQL 就绪(没起会自动拉起;失败即退出)
|
||||
mkdir -p data # sqlite 文件所在目录
|
||||
"$PY" -m alembic upgrade head # 确保表已建(幂等,已是最新则 no-op)
|
||||
|
||||
# --reload 只盯源码目录 app/:别去监视 logs/(日志写入触发"检测→再写日志"回环)和
|
||||
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
@echo off
|
||||
REM Admin backend startup (Windows) - the :8771 peer of run.bat.
|
||||
REM
|
||||
REM Usage:
|
||||
REM cd shaguabijia-app-server
|
||||
REM run8771.bat
|
||||
REM
|
||||
REM Runs the ADMIN FastAPI app (app.admin.main:admin_app) on 127.0.0.1:8771 —
|
||||
REM a SEPARATE process from run.bat (which runs app.main:app on 8770). The admin
|
||||
REM web frontend (Next.js :3001) points at http://localhost:8771. Auto-reload on
|
||||
REM code change.
|
||||
REM
|
||||
REM Prerequisite (first time):
|
||||
REM conda activate pricebot ^&^& pip install -e .
|
||||
REM copy .env.example .env ^&^& fill JWT_SECRET_KEY
|
||||
REM
|
||||
REM Tip: shaguabijia-admin-web\start.bat starts user-api(8770) + admin-api(8771)
|
||||
REM + frontend(3001) in one go, if you prefer a single command.
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
REM Prefer the project virtualenv (.venv) so we never inherit a wrong
|
||||
REM global/conda interpreter. FastAPI<0.115 on Pydantic 2.12 crashes at import
|
||||
REM with "'FieldInfo' object has no attribute 'in_'". Falls back to PATH python.
|
||||
set "PY=python"
|
||||
if exist "%~dp0.venv\Scripts\python.exe" set "PY=%~dp0.venv\Scripts\python.exe"
|
||||
|
||||
if not exist .env (
|
||||
echo [X] Missing .env. Run: copy .env.example .env and fill JWT_SECRET_KEY ^(plus MT_CPS_* if you test Meituan^)
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist data mkdir data
|
||||
|
||||
REM Ensure local Docker PostgreSQL is up (auto-starts Docker + PG container if needed)
|
||||
call "%PY%" -m scripts.ensure_pg
|
||||
if errorlevel 1 (
|
||||
echo [X] ensure_pg failed ^(PostgreSQL not ready^)
|
||||
exit /b %errorlevel%
|
||||
)
|
||||
|
||||
REM Build/upgrade schema (idempotent; no-op if already at head)
|
||||
call "%PY%" -m alembic upgrade head
|
||||
if errorlevel 1 (
|
||||
echo [X] alembic upgrade head failed
|
||||
exit /b %errorlevel%
|
||||
)
|
||||
|
||||
REM Long-running foreground process. Ctrl+C to stop.
|
||||
"%PY%" -m uvicorn app.admin.main:admin_app --host 127.0.0.1 --port 8771 --reload
|
||||
@@ -1,317 +0,0 @@
|
||||
"""确保本地 PostgreSQL 就绪(开发/测试统一用 Docker PG)。
|
||||
|
||||
被三处复用:
|
||||
- run.sh / run.bat:`python -m scripts.ensure_pg`(CLI,失败退非 0)
|
||||
- tests/conftest.py:`from scripts.ensure_pg import ensure; ensure(test_url)`
|
||||
|
||||
流程:读 DATABASE_URL → TCP 探测 → 没起就(必要时启 Docker Desktop)→
|
||||
`docker compose up -d` → 等 PG ready → 幂等确保测试库存在。
|
||||
|
||||
运行时(run.sh/run.bat)支持【显式】SQLite 逃生舱:DATABASE_URL 设为 sqlite → 放行并打印
|
||||
醒目降级横幅(绝不静默替你切库);无 docker CLI 时报错里也指路该逃生舱。测试侧
|
||||
(conftest 传 PG URL)不降级——sqlite 分支不触发,没 PG 直接 raise。详见设计文档 §10。
|
||||
|
||||
生产用原生 PG(scripts/init_postgres.py),不走本模块。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# 日志里可能含 emoji(如 ✅);Windows GBK 控制台(cmd.exe)无法编码会抛 UnicodeEncodeError → 脚本崩、
|
||||
# run.bat 误判 ensure_pg 失败。用 backslashreplace 保底:中文仍正常,仅不可编码字符被转义,不崩。
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.reconfigure(errors="backslashreplace")
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
|
||||
APP_DB = "shaguabijia"
|
||||
TEST_DB = "shaguabijia_test"
|
||||
DB_USER = "shaguabijia_app"
|
||||
COMPOSE_SERVICE = "postgres"
|
||||
CONTAINER_NAME = "shaguabijia-pg" # 必须与 docker-compose.yml 的 container_name 一致
|
||||
|
||||
# 钉死 compose 项目名:否则它默认取运行目录 basename,在不同目录/worktree 之间切会各自
|
||||
# 成一个项目 → 同一个固定 container_name 撞名报错、pgdata 卷还会按项目名分裂成多份。
|
||||
# 钉成 app 名后,无论从哪个目录/worktree 跑都是同一个项目、同一个卷。setdefault:尊重外部覆盖。
|
||||
os.environ.setdefault("COMPOSE_PROJECT_NAME", "shaguabijia")
|
||||
|
||||
DOCKER_START_TIMEOUT = int(os.environ.get("ENSURE_PG_DOCKER_TIMEOUT", "120"))
|
||||
PG_READY_TIMEOUT = int(os.environ.get("ENSURE_PG_READY_TIMEOUT", "60"))
|
||||
# 单条 docker 探测/exec 命令的超时:防 Docker 守护进程半死(尤其 Windows 冷启)时
|
||||
# docker info / exec 无限挂起、绕过上面的总超时。
|
||||
DOCKER_CMD_TIMEOUT = int(os.environ.get("ENSURE_PG_CMD_TIMEOUT", "15"))
|
||||
POLL_INTERVAL = 3.0
|
||||
|
||||
PG_URL = (
|
||||
"postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia"
|
||||
)
|
||||
SQLITE_URL = "sqlite:///./data/app.db" # 无 Docker 时的显式降级逃生舱(仅 run 运行时)
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
print(f"[ensure_pg] {msg}", flush=True)
|
||||
|
||||
|
||||
def _is_sqlite(url: str) -> bool:
|
||||
return url.strip().lower().startswith("sqlite")
|
||||
|
||||
|
||||
def _parse_host_port(url: str) -> tuple[str, int]:
|
||||
"""从 SQLAlchemy URL 取 host/port,缺省 localhost:5432。"""
|
||||
parts = urlsplit(url)
|
||||
return (parts.hostname or "localhost"), (parts.port or 5432)
|
||||
|
||||
|
||||
def _port_open(host: str, port: int, timeout: float = 1.0) -> bool:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _docker_desktop_cmd(platform: str, program_files: str) -> list[str] | None:
|
||||
"""按平台给出启动 Docker Desktop 的命令;Linux 返回 None(daemon 需 sudo,让用户手动)。"""
|
||||
if platform.startswith("win"):
|
||||
return [str(Path(program_files) / "Docker" / "Docker" / "Docker Desktop.exe")]
|
||||
if platform == "darwin":
|
||||
return ["open", "-a", "Docker"]
|
||||
return None
|
||||
|
||||
|
||||
def _docker_ok(subcmd: str) -> bool:
|
||||
"""`docker --version`(CLI 在不在,纯客户端)/`docker info`(daemon 起没起)成功与否。"""
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", subcmd],
|
||||
cwd=ROOT,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
timeout=DOCKER_CMD_TIMEOUT,
|
||||
)
|
||||
return True
|
||||
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
|
||||
def _docker_cli_ok() -> bool:
|
||||
# 必须用 `docker --version`(纯客户端,不连 daemon)而非 `docker version`
|
||||
# (后者要连 daemon,守护进程没起时退非零)——否则「装了 Docker 但没启动」
|
||||
# 会被误判成「没装 CLI」,直接绕过下面 _start_docker_daemon() 的自动拉起(需求②)。
|
||||
return _docker_ok("--version")
|
||||
|
||||
|
||||
def _docker_daemon_ok() -> bool:
|
||||
return _docker_ok("info")
|
||||
|
||||
|
||||
def _start_docker_daemon() -> bool:
|
||||
"""守护进程没起时按平台拉起,轮询到就绪。返回是否成功。"""
|
||||
if _docker_daemon_ok():
|
||||
return True
|
||||
cmd = _docker_desktop_cmd(
|
||||
sys.platform, os.environ.get("ProgramFiles", r"C:\Program Files")
|
||||
)
|
||||
if cmd is None:
|
||||
_log("Docker 守护进程未运行。Linux 请手动:sudo systemctl start docker,然后重试。")
|
||||
return False
|
||||
if sys.platform.startswith("win") and not Path(cmd[0]).exists():
|
||||
_log(f"找不到 Docker Desktop:{cmd[0]}。请手动启动 Docker Desktop 后重试。")
|
||||
return False
|
||||
_log("启动 Docker Desktop(首次冷启可能 30-60s)…")
|
||||
try:
|
||||
subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except OSError as e:
|
||||
_log(f"启动 Docker Desktop 失败:{e}")
|
||||
return False
|
||||
deadline = time.monotonic() + DOCKER_START_TIMEOUT
|
||||
while time.monotonic() < deadline:
|
||||
if _docker_daemon_ok():
|
||||
_log("Docker 守护进程已就绪。")
|
||||
return True
|
||||
_log("等待 Docker 守护进程…")
|
||||
time.sleep(POLL_INTERVAL)
|
||||
_log(f"等待 Docker 守护进程超时({DOCKER_START_TIMEOUT}s)。")
|
||||
return False
|
||||
|
||||
|
||||
def _ps_names(*filters: str) -> str:
|
||||
"""docker ps -a 按 filter 查容器名(每行一个);失败返回空串。"""
|
||||
args = ["docker", "ps", "-a", "--format", "{{.Names}}"]
|
||||
for f in filters:
|
||||
args += ["--filter", f]
|
||||
try:
|
||||
r = subprocess.run(
|
||||
args, cwd=ROOT, capture_output=True, text=True, timeout=DOCKER_CMD_TIMEOUT,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return ""
|
||||
return r.stdout if r.returncode == 0 else ""
|
||||
|
||||
|
||||
def _remove_stale_container() -> None:
|
||||
"""删掉「同名但不属于本 compose 项目」的残留容器(旧目录/worktree 建的)。
|
||||
|
||||
固定的 container_name 是全局唯一名:若旧项目留下一个同名容器,`docker compose up`
|
||||
会因撞名报 "container name already in use" 而失败。这里在 up 之前主动清掉它。
|
||||
数据在命名卷(<project>_pgdata)里,删容器不删卷、不丢数据。
|
||||
"""
|
||||
project = os.environ.get("COMPOSE_PROJECT_NAME", "")
|
||||
name_filter = f"name=^{CONTAINER_NAME}$"
|
||||
if CONTAINER_NAME not in _ps_names(name_filter).split():
|
||||
return # 没有同名容器
|
||||
ours = _ps_names(name_filter, f"label=com.docker.compose.project={project}")
|
||||
if CONTAINER_NAME in ours.split():
|
||||
return # 就是本项目的容器,compose 会自己 start/复用,别删
|
||||
_log(f"发现残留同名容器 {CONTAINER_NAME}(非本项目 '{project}'),删除以避免撞名"
|
||||
f"(数据在卷里,不丢)…")
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", CONTAINER_NAME], cwd=ROOT,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=DOCKER_CMD_TIMEOUT,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
_log(f"⚠️ 删除残留容器失败,可手动: docker rm -f {CONTAINER_NAME}")
|
||||
|
||||
|
||||
def _compose_up() -> bool:
|
||||
_remove_stale_container()
|
||||
_log("docker compose up -d(镜像缺失会自动拉取,首用约几十秒)…")
|
||||
try:
|
||||
subprocess.run(["docker", "compose", "up", "-d"], cwd=ROOT, check=True)
|
||||
return True
|
||||
except (OSError, subprocess.CalledProcessError) as e:
|
||||
_log(f"docker compose up 失败:{e}")
|
||||
return False
|
||||
|
||||
|
||||
def _pg_isready() -> bool:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
|
||||
"pg_isready", "-U", DB_USER, "-d", APP_DB],
|
||||
cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
timeout=DOCKER_CMD_TIMEOUT,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return r.returncode == 0
|
||||
|
||||
|
||||
def _wait_pg_ready(host: str, port: int) -> bool:
|
||||
deadline = time.monotonic() + PG_READY_TIMEOUT
|
||||
while time.monotonic() < deadline:
|
||||
if _port_open(host, port) and _pg_isready():
|
||||
_log("PostgreSQL 已就绪。")
|
||||
return True
|
||||
_log("等待 PostgreSQL 就绪…")
|
||||
time.sleep(POLL_INTERVAL)
|
||||
_log(f"等待 PostgreSQL 就绪超时({PG_READY_TIMEOUT}s)。")
|
||||
return False
|
||||
|
||||
|
||||
def _test_db_exists() -> bool:
|
||||
"""测试库是否已存在(连业务库 shaguabijia 查 pg_database)。"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
|
||||
"psql", "-U", DB_USER, "-d", APP_DB, "-tAc",
|
||||
f"SELECT 1 FROM pg_database WHERE datname='{TEST_DB}'"],
|
||||
cwd=ROOT, capture_output=True, text=True, timeout=DOCKER_CMD_TIMEOUT,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return r.returncode == 0 and r.stdout.strip() == "1"
|
||||
|
||||
|
||||
def ensure_test_db() -> bool:
|
||||
"""幂等建测试库(兼容老 pgdata 卷首启没跑 initdb 的情况)。返回测试库是否就绪。
|
||||
|
||||
公开给 conftest 单独调用:ensure() 在「端口已通」时会短路返回、不建测试库,
|
||||
所以测试侧需在 ensure() 之后再显式补一刀(best-effort)。
|
||||
"""
|
||||
if _test_db_exists():
|
||||
return True
|
||||
_log(f"建测试库 {TEST_DB}…")
|
||||
try:
|
||||
create = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
|
||||
"psql", "-U", DB_USER, "-d", APP_DB, "-c",
|
||||
f"CREATE DATABASE {TEST_DB} OWNER {DB_USER}"],
|
||||
cwd=ROOT, capture_output=True, text=True, timeout=DOCKER_CMD_TIMEOUT,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
_log(f"⚠️ 建测试库 {TEST_DB} 失败:{e}")
|
||||
return False
|
||||
# returncode==0=建成功;非 0 但库已存在=与并发创建者竞争失败(42P04),仍算就绪
|
||||
if create.returncode == 0 or _test_db_exists():
|
||||
return True
|
||||
_log(f"⚠️ 建测试库 {TEST_DB} 失败:{(create.stderr or '').strip()}")
|
||||
return False
|
||||
|
||||
|
||||
def _warn_sqlite_degraded() -> None:
|
||||
"""DATABASE_URL 是 SQLite 时打印醒目降级横幅(显式逃生舱,非静默切库)。"""
|
||||
for line in (
|
||||
"⚠️ ================= 降级模式(SQLite) =================",
|
||||
"⚠️ DATABASE_URL 是 SQLite,不是 PostgreSQL。",
|
||||
"⚠️ PG 专有 SQL(窗口函数/FILTER/JSONB)与严格类型在此模式【不被验证】。",
|
||||
"⚠️ 提交前请在装了 Docker 的机器上用 PG 复跑;装好后把 DATABASE_URL 改回:",
|
||||
f"⚠️ {PG_URL}",
|
||||
"⚠️ ===================================================",
|
||||
):
|
||||
_log(line)
|
||||
|
||||
|
||||
def ensure(database_url: str | None = None) -> bool:
|
||||
"""确保 PG 就绪,返回 True/False。database_url 缺省从 settings 读(尊重 .env)。
|
||||
|
||||
运行时若 DATABASE_URL 是 SQLite → 打印降级横幅并返回 True(显式逃生舱);
|
||||
conftest 传的是 PG URL,故测试侧永不走此分支。
|
||||
"""
|
||||
if database_url is None:
|
||||
from app.core.config import settings # 延迟导入,避免过早固化 settings
|
||||
|
||||
database_url = settings.DATABASE_URL
|
||||
|
||||
if _is_sqlite(database_url):
|
||||
_warn_sqlite_degraded()
|
||||
return True
|
||||
|
||||
host, port = _parse_host_port(database_url)
|
||||
|
||||
if _port_open(host, port):
|
||||
_log(f"✅ PostgreSQL 已在 {host}:{port} 运行,跳过 Docker。")
|
||||
return True
|
||||
|
||||
_log(f"{host}:{port} 无 PostgreSQL,准备用 Docker 拉起…")
|
||||
|
||||
if not _docker_cli_ok():
|
||||
_log("未检测到 docker 命令。请先安装 Docker Desktop:")
|
||||
_log(" https://www.docker.com/products/docker-desktop/")
|
||||
_log(f"装不了 Docker?把 .env 的 DATABASE_URL 改成 {SQLITE_URL} 可降级用 SQLite 跑")
|
||||
_log(" (PG 专有 SQL/严格性不被验证,仅救急);改完重跑 run.sh/run.bat。")
|
||||
return False
|
||||
if not _start_docker_daemon():
|
||||
return False
|
||||
if not _compose_up():
|
||||
return False
|
||||
if not _wait_pg_ready(host, port):
|
||||
return False
|
||||
if not ensure_test_db():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(0 if ensure() else 1)
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Bootstrap PostgreSQL: 建用户 + 建库 + 授权 + 写 .env + 跑迁移。
|
||||
|
||||
新机器初始化用(面向【生产原生 PG】:apt/systemd 装好的 PostgreSQL)。
|
||||
本地开发/测试请改用 docker-compose.yml + scripts/ensure_pg.py(run.sh/run.bat 自动拉起),不必跑本脚本。
|
||||
前置:已装 PostgreSQL 16 + 知道 postgres 超级用户密码。
|
||||
新机器初始化用。前置:已装 PostgreSQL 16 + 知道 postgres 超级用户密码。
|
||||
|
||||
用法:
|
||||
python scripts/init_postgres.py
|
||||
|
||||
+15
-22
@@ -1,19 +1,22 @@
|
||||
"""测试用 fixtures。
|
||||
|
||||
测试库用 Docker PostgreSQL 的 shaguabijia_test(与 dev 业务库 shaguabijia 隔离)。
|
||||
顺序(必须):设 test DATABASE_URL(在 import app.* 之前)→ ensure PG 就绪 + 测试库存在 →
|
||||
import app → 建表。持久卷可能残留上次的表 → session 开头先 drop 再 create。
|
||||
测试 DB 用临时文件 SQLite。
|
||||
- 不用 in-memory:in-memory 默认 per-connection,跨连接看不到表。
|
||||
- 用临时文件保证 SessionLocal 每次新连都看到同一份 schema。
|
||||
|
||||
顺序:set env(必须在 import app.* 之前) → import app → 建表 → TestClient
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Iterator
|
||||
|
||||
# 1) 测试库连接串——必须在 import app.* 之前设好(app.db.session 在 import 期就建 engine)
|
||||
_TEST_DB_URL = (
|
||||
"postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia_test"
|
||||
)
|
||||
os.environ["DATABASE_URL"] = _TEST_DB_URL
|
||||
# 临时 db 文件路径,进程退出后清理
|
||||
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_tmp_db.close()
|
||||
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{_tmp_db.name}"
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-please-ignore-this-is-only-for-pytest-not-real")
|
||||
os.environ.setdefault("ADMIN_JWT_SECRET", "test-admin-secret-please-ignore-only-for-pytest-not-real")
|
||||
os.environ.setdefault("JG_APP_KEY", "test-key")
|
||||
@@ -32,18 +35,6 @@ os.environ.setdefault("RATE_LIMIT_ENABLED", "false") # 限流内存计数会跨
|
||||
os.environ.setdefault("PANGLE_CALLBACK_ENABLED", "true")
|
||||
os.environ.setdefault("PANGLE_REWARD_SECRET", "test-pangle-secret-only-for-pytest")
|
||||
|
||||
# 2) 保证 Docker PG 就绪(必须在 import app.db.session 建 engine 之前)。
|
||||
# ensure() 在「端口已通」时会短路、不建测试库,故随后再显式补一刀 ensure_test_db()。
|
||||
from scripts.ensure_pg import ensure, ensure_test_db
|
||||
|
||||
if not ensure(_TEST_DB_URL):
|
||||
raise RuntimeError(
|
||||
"测试需要 Docker PostgreSQL 就绪。请确认已装 Docker Desktop;"
|
||||
"或先跑一次 run.bat/run.sh 把 PG 拉起,再重试 pytest。"
|
||||
)
|
||||
# best-effort 兜底建测试库(PG 已在跑但测试库缺失=老卷)。真缺库时下面 create_all 会明确报错。
|
||||
ensure_test_db()
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -54,11 +45,13 @@ from app.main import app
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _setup_db() -> Iterator[None]:
|
||||
# 持久卷可能残留上次跑崩后的表/数据 → 先 drop 再 create,保证干净起点
|
||||
Base.metadata.drop_all(engine)
|
||||
Base.metadata.create_all(engine)
|
||||
yield
|
||||
Base.metadata.drop_all(engine)
|
||||
try:
|
||||
os.unlink(_tmp_db.name)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
|
||||
@@ -68,14 +68,7 @@ def test_list_config(admin_client: TestClient, token: str) -> None:
|
||||
r = admin_client.get("/admin/api/config", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
items = {i["key"]: i for i in r.json()}
|
||||
# 非 hidden 项照常返回;看广告组保留可见的:每日上限 / 单次金币上限 / 关闭后冷却。
|
||||
assert "signin_rewards" in items and "ad_daily_limit" in items and "ad_cooldown_sec" in items
|
||||
# hidden 项(任务/里程碑、首页轮播数据源、看广告组的单次金币/每轮次数/信息流广告开关)不在配置页返回。
|
||||
for hidden_key in (
|
||||
"task_rewards", "record_milestones", "marquee_feed_mode",
|
||||
"ad_reward_coin", "ad_round_count", "comparing_ad_enabled",
|
||||
):
|
||||
assert hidden_key not in items, f"{hidden_key} 应被 hidden 过滤"
|
||||
assert "signin_rewards" in items and "ad_daily_limit" in items
|
||||
assert items["signin_rewards"]["value"] == [
|
||||
200, 200, 300, 200, 400, 400, 800,
|
||||
]
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
"""Admin RBAC 角色/权限测试:可见页下发、角色 CRUD 仅 super_admin、内建/在用保护。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.admin.main import admin_app
|
||||
from app.admin.repositories import admin_user as admin_repo
|
||||
from app.core.security import hash_password
|
||||
from app.db.session import SessionLocal
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin_client() -> TestClient:
|
||||
return TestClient(admin_app)
|
||||
|
||||
|
||||
def _token(username: str, role: str) -> str:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
a = admin_repo.get_by_username(db, username)
|
||||
if a is None:
|
||||
admin_repo.create_admin(db, username=username, password="pass1234", role=role)
|
||||
else:
|
||||
a.password_hash = hash_password("pass1234")
|
||||
a.role = role
|
||||
a.status = "active"
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
c = TestClient(admin_app)
|
||||
return c.post(
|
||||
"/admin/api/auth/login", json={"username": username, "password": "pass1234"}
|
||||
).json()["access_token"]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def super_token() -> str:
|
||||
return _token("r_super", "super_admin")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def operator_token() -> str:
|
||||
return _token("r_operator", "operator")
|
||||
|
||||
|
||||
def _auth(t: str) -> dict:
|
||||
return {"Authorization": f"Bearer {t}"}
|
||||
|
||||
|
||||
def test_super_pages_all_operator_limited(admin_client, super_token, operator_token) -> None:
|
||||
su = admin_client.get("/admin/api/auth/me", headers=_auth(super_token)).json()
|
||||
assert "admins" in su["pages"] and "dashboard" in su["pages"] # 超管全页
|
||||
op = admin_client.get("/admin/api/auth/me", headers=_auth(operator_token)).json()
|
||||
assert "dashboard" in op["pages"] and "admins" not in op["pages"] # 运营看不到管理员页
|
||||
|
||||
|
||||
def test_roles_endpoints_super_only(admin_client, super_token, operator_token) -> None:
|
||||
assert admin_client.get("/admin/api/roles", headers=_auth(super_token)).status_code == 200
|
||||
assert admin_client.get("/admin/api/roles", headers=_auth(operator_token)).status_code == 403
|
||||
|
||||
|
||||
def test_role_crud(admin_client, super_token) -> None:
|
||||
cat = admin_client.get("/admin/api/roles/catalog", headers=_auth(super_token)).json()
|
||||
assert any(g["group"] == "看板" for g in cat)
|
||||
|
||||
r = admin_client.post(
|
||||
"/admin/api/roles", json={"name": "审核", "pages": ["dashboard", "feedbacks", "xx-bad"]},
|
||||
headers=_auth(super_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
rid = r.json()["id"]
|
||||
assert set(r.json()["pages"]) == {"dashboard", "feedbacks"} # 非法 key 被过滤
|
||||
|
||||
# 重名 409
|
||||
assert admin_client.post(
|
||||
"/admin/api/roles", json={"name": "审核", "pages": []}, headers=_auth(super_token)
|
||||
).status_code == 409
|
||||
|
||||
# 改可见页
|
||||
u = admin_client.patch(
|
||||
f"/admin/api/roles/{rid}", json={"pages": ["dashboard"]}, headers=_auth(super_token)
|
||||
)
|
||||
assert u.status_code == 200 and u.json()["pages"] == ["dashboard"]
|
||||
|
||||
# 删
|
||||
assert admin_client.delete(f"/admin/api/roles/{rid}", headers=_auth(super_token)).status_code == 200
|
||||
|
||||
|
||||
def test_builtin_super_admin_protected(admin_client, super_token) -> None:
|
||||
roles = admin_client.get("/admin/api/roles", headers=_auth(super_token)).json()
|
||||
sa = next(r for r in roles if r["name"] == "super_admin")
|
||||
assert sa["is_builtin"] and len(sa["pages"]) >= 10 # 全部页
|
||||
assert admin_client.patch(
|
||||
f"/admin/api/roles/{sa['id']}", json={"pages": []}, headers=_auth(super_token)
|
||||
).status_code == 400
|
||||
assert admin_client.delete(
|
||||
f"/admin/api/roles/{sa['id']}", headers=_auth(super_token)
|
||||
).status_code == 400
|
||||
|
||||
|
||||
def test_delete_role_in_use_blocked(admin_client, super_token) -> None:
|
||||
admin_client.post(
|
||||
"/admin/api/admins",
|
||||
json={"username": "role_holder", "password": "pass1234", "role": "operator"},
|
||||
headers=_auth(super_token),
|
||||
)
|
||||
roles = admin_client.get("/admin/api/roles", headers=_auth(super_token)).json()
|
||||
op = next(r for r in roles if r["name"] == "operator")
|
||||
assert op["in_use"] >= 1
|
||||
assert admin_client.delete(
|
||||
f"/admin/api/roles/{op['id']}", headers=_auth(super_token)
|
||||
).status_code == 400
|
||||
|
||||
|
||||
def test_builtin_roles_labels_and_pages(admin_client, super_token) -> None:
|
||||
roles = {r["name"]: r for r in admin_client.get("/admin/api/roles", headers=_auth(super_token)).json()}
|
||||
# 中文展示名(key 不变)
|
||||
assert roles["super_admin"]["label"] == "管理员"
|
||||
assert roles["operator"]["label"] == "运营"
|
||||
assert roles["finance"]["label"] == "财务"
|
||||
assert roles["tech"]["label"] == "技术"
|
||||
# 页集对齐 Prototypes/dashboard/permissions.md 的 ROLES
|
||||
assert set(roles["finance"]["pages"]) == {"dashboard", "ad-revenue-report", "cps", "withdraws"}
|
||||
assert set(roles["tech"]["pages"]) == {
|
||||
"dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs",
|
||||
}
|
||||
|
||||
|
||||
def test_ui_created_admin_shows_password_script_admin_hidden(admin_client, super_token) -> None:
|
||||
# UI 建号:账号列表回显明文登录密码(供权限管理页编辑复看)
|
||||
admin_client.post(
|
||||
"/admin/api/admins",
|
||||
json={"username": "pw_show_user", "password": "pass1234", "role": "operator"},
|
||||
headers=_auth(super_token),
|
||||
)
|
||||
admins = {a["username"]: a for a in admin_client.get("/admin/api/admins", headers=_auth(super_token)).json()}
|
||||
assert admins["pw_show_user"]["password"] == "pass1234"
|
||||
# 经 repo/脚本直建的账号(r_super,无留存明文)→ password 为 None,前端不显示
|
||||
assert admins["r_super"]["password"] is None
|
||||
# 登录 / me 不下发明文(保持 None)
|
||||
me = admin_client.get("/admin/api/auth/me", headers=_auth(super_token)).json()
|
||||
assert me.get("password") is None
|
||||
|
||||
|
||||
def test_create_admin_rejects_unknown_role(admin_client, super_token) -> None:
|
||||
r = admin_client.post(
|
||||
"/admin/api/admins",
|
||||
json={"username": "bad_role_user", "password": "pass1234", "role": "不存在的角色"},
|
||||
headers=_auth(super_token),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
+22
-2
@@ -17,6 +17,7 @@ def _reset(phone: str) -> None:
|
||||
"""清该号的进程内存状态,隔离 real 模式用例。"""
|
||||
sms._codes.pop(phone, None)
|
||||
sms._last_sent.pop(phone, None)
|
||||
sms._daily_count.pop(phone, None)
|
||||
|
||||
|
||||
class _OkResp:
|
||||
@@ -75,7 +76,7 @@ def test_sms_send_too_frequent(client) -> None:
|
||||
|
||||
def test_sms_send_device_ip_rate_limit(client, monkeypatch) -> None:
|
||||
"""发码防刷:同一设备(device_id) + 同一 IP 每小时最多 N 次发码,超出 429。
|
||||
用不同手机号(绕开单号 60s 冷却)证明限流按设备封顶 —— 堵「换号绕开单号冷却」的洞。
|
||||
用不同手机号(绕开单号 60s 冷却)证明限流按设备封顶 —— 堵「换号绕开单号冷却/每日上限」的洞。
|
||||
conftest 默认关限流;本用例临时打开 + 调小阈值便于测 + 清计数隔离。"""
|
||||
from app.api.v1 import auth
|
||||
from app.core import ratelimit
|
||||
@@ -154,6 +155,21 @@ def test_phone_format_validation(client) -> None:
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_sms_daily_limit(monkeypatch) -> None:
|
||||
"""单号每日上限(send 的防刷逻辑 mock/real 都跑;函数级绕开 60s 冷却)。"""
|
||||
phone = "13511135000"
|
||||
_reset(phone)
|
||||
limit = 3
|
||||
monkeypatch.setattr(sms.settings, "SMS_DAILY_LIMIT_PER_PHONE", limit)
|
||||
|
||||
for _ in range(limit):
|
||||
sms.send_code(phone)
|
||||
sms._last_sent.pop(phone, None) # 绕开冷却,只测每日上限
|
||||
|
||||
with pytest.raises(sms.SmsError, match="上限"):
|
||||
sms.send_code(phone)
|
||||
|
||||
|
||||
# ============================ real 路径(不真发)============================
|
||||
|
||||
def test_sms_real_send_calls_jiguang(monkeypatch) -> None:
|
||||
@@ -239,20 +255,24 @@ def test_sms_real_balance_error_keeps_cooldown(monkeypatch) -> None:
|
||||
|
||||
|
||||
def test_sms_gc_purges_stale_only(monkeypatch) -> None:
|
||||
"""GC 清过期码 / 旧冷却,但不动今天有效的(阈值设 0 强制每次扫)。"""
|
||||
"""GC 清过期码 / 旧冷却 / 隔日计数,但不动今天有效的(阈值设 0 强制每次扫)。"""
|
||||
monkeypatch.setattr(sms, "_GC_THRESHOLD", 0)
|
||||
sms._codes.clear()
|
||||
sms._last_sent.clear()
|
||||
sms._daily_count.clear()
|
||||
now = time.time()
|
||||
sms._codes["stale"] = sms._CodeRecord(code="111111", expires_at=now - 1)
|
||||
sms._codes["fresh"] = sms._CodeRecord(code="222222", expires_at=now + 999)
|
||||
sms._last_sent["old"] = now - 99999
|
||||
sms._last_sent["recent"] = now
|
||||
sms._daily_count["yesterday"] = ("2000-01-01", 3)
|
||||
sms._daily_count["today"] = (sms._today(), 1)
|
||||
|
||||
sms._gc(now)
|
||||
|
||||
assert "stale" not in sms._codes and "fresh" in sms._codes
|
||||
assert "old" not in sms._last_sent and "recent" in sms._last_sent
|
||||
assert "yesterday" not in sms._daily_count and "today" in sms._daily_count
|
||||
|
||||
|
||||
# ============================ 用户名 / 默认昵称 ============================
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
"""后端 harvest 落库测试(比价记录改后端 harvest 后新增)。
|
||||
|
||||
覆盖:
|
||||
repo 层:harvest_running(建行/幂等回填)、harvest_done(派生+newly_success 幂等)、
|
||||
harvest_abort(夭折 / **不降级 success**)、upsert_record 不降级。
|
||||
端点层:price/step 首帧 mint trace_id + 回传 + 建 running 行;done 帧落 success;
|
||||
trace/finalize 落 cancelled;软鉴权(无 token 也放行、user_id 暂空)。
|
||||
pricebot 用 httpx mock,不真连(同 test_compare_proxy)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.repositories import comparison as crud
|
||||
from app.repositories.user import get_user_by_phone
|
||||
from app.schemas.compare_record import ComparisonRecordIn
|
||||
from sqlalchemy import select
|
||||
|
||||
|
||||
def _tid() -> str:
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
def _done_params() -> dict:
|
||||
"""一份典型 done 帧 params:美团 25 元 vs 源淘宝闪购 30 元 → 省 5 元、success。"""
|
||||
return {
|
||||
"comparison_results": [
|
||||
{"platform_id": "meituan", "platform_name": "美团", "package": "com.sankuai.meituan",
|
||||
"price": 25.0, "is_source": False, "rank": 1, "items": []},
|
||||
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购",
|
||||
"package": "com.taobao.taobao", "price": 30.0, "is_source": True, "rank": 2,
|
||||
"store_name": "测试店", "items": [{"name": "肥牛饭", "qty": 1}]},
|
||||
],
|
||||
"information": "美团更便宜",
|
||||
"trace_url": "https://price.shaguabijia.com/traces/done/",
|
||||
}
|
||||
|
||||
|
||||
def _get(db, trace_id: str) -> ComparisonRecord | None:
|
||||
return db.execute(
|
||||
select(ComparisonRecord).where(ComparisonRecord.trace_id == trace_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _make_user(client, phone: str) -> int:
|
||||
"""登录建号并返回其真实 user_id。
|
||||
|
||||
PG 强制 comparison_record.user_id → user.id 外键,须引用真实存在的用户;
|
||||
用本用例自己登录出的用户,不会与别的用例撞。
|
||||
"""
|
||||
client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
|
||||
with SessionLocal() as db:
|
||||
return get_user_by_phone(db, phone).id
|
||||
|
||||
|
||||
# ============================================================
|
||||
# repo 层
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_harvest_running_creates_row(client) -> None:
|
||||
tid = _tid()
|
||||
with SessionLocal() as db:
|
||||
crud.harvest_running(
|
||||
db, trace_id=tid, user_id=None, device_id="dev-1",
|
||||
device_info={"brand": "vivo", "model": "V2309A", "android_version": "14",
|
||||
"rom_version": "14"},
|
||||
trace_url="https://price.shaguabijia.com/traces/run/",
|
||||
)
|
||||
rec = _get(db, tid)
|
||||
assert rec is not None
|
||||
assert rec.status == "running"
|
||||
assert rec.user_id is None
|
||||
assert rec.device_id == "dev-1"
|
||||
assert rec.device_model == "V2309A"
|
||||
assert rec.device_manufacturer == "vivo"
|
||||
assert rec.rom_version == 14
|
||||
assert rec.trace_url.endswith("/run/")
|
||||
|
||||
|
||||
def test_harvest_running_idempotent_backfills(client) -> None:
|
||||
tid = _tid()
|
||||
with SessionLocal() as db:
|
||||
crud.harvest_running(db, trace_id=tid, user_id=None, trace_url=None)
|
||||
# 第二帧带上 trace_url + user_id → 回填空缺,不新建、不改 status
|
||||
crud.harvest_running(db, trace_id=tid, user_id=None,
|
||||
trace_url="https://price.shaguabijia.com/traces/late/")
|
||||
rows = db.execute(
|
||||
select(ComparisonRecord).where(ComparisonRecord.trace_id == tid)
|
||||
).scalars().all()
|
||||
assert len(rows) == 1 # 幂等:仍一行
|
||||
assert rows[0].status == "running"
|
||||
assert rows[0].trace_url.endswith("/late/") # 空缺被回填
|
||||
|
||||
|
||||
def test_harvest_done_derives_and_newly_success_once(client) -> None:
|
||||
tid = _tid()
|
||||
with SessionLocal() as db:
|
||||
crud.harvest_running(db, trace_id=tid, user_id=None)
|
||||
rec, newly = crud.harvest_done(db, trace_id=tid, user_id=None,
|
||||
done_params=_done_params())
|
||||
assert newly is True # running → success 是"新落成"
|
||||
assert rec.status == "success"
|
||||
assert rec.source_platform_id == "taobao_flash"
|
||||
assert rec.source_price_cents == 3000
|
||||
assert rec.best_platform_id == "meituan"
|
||||
assert rec.best_price_cents == 2500
|
||||
assert rec.saved_amount_cents == 500 # 30 - 25
|
||||
assert rec.is_source_best is False
|
||||
assert rec.store_name == "测试店"
|
||||
assert rec.information == "美团更便宜"
|
||||
assert rec.items == [{"name": "肥牛饭", "qty": 1}]
|
||||
assert rec.trace_url.endswith("/done/")
|
||||
# 再来一次(重试 done)→ 已 success,newly_success=False(发奖不重复触发)
|
||||
_rec2, newly2 = crud.harvest_done(db, trace_id=tid, user_id=None,
|
||||
done_params=_done_params())
|
||||
assert newly2 is False
|
||||
|
||||
|
||||
def test_harvest_abort_cancels_running(client) -> None:
|
||||
tid = _tid()
|
||||
with SessionLocal() as db:
|
||||
crud.harvest_running(db, trace_id=tid, user_id=None)
|
||||
rec = crud.harvest_abort(db, trace_id=tid, status="cancelled",
|
||||
reason="用户终止比价",
|
||||
trace_url="https://price.shaguabijia.com/traces/ab/")
|
||||
assert rec is not None
|
||||
assert rec.status == "cancelled"
|
||||
assert rec.information == "用户终止比价"
|
||||
assert rec.trace_url.endswith("/ab/")
|
||||
|
||||
|
||||
def test_harvest_abort_no_downgrade_success(client) -> None:
|
||||
"""已 success 的行,finalize 后到(收尾取消)→ 只 refresh trace_url,status 不降级。"""
|
||||
tid = _tid()
|
||||
with SessionLocal() as db:
|
||||
crud.harvest_running(db, trace_id=tid, user_id=None)
|
||||
crud.harvest_done(db, trace_id=tid, user_id=None, done_params=_done_params())
|
||||
rec = crud.harvest_abort(db, trace_id=tid, status="cancelled", reason="收尾误触")
|
||||
assert rec.status == "success" # 不降级
|
||||
assert rec.information == "美团更便宜" # 不被 abort 的 reason 覆盖
|
||||
|
||||
|
||||
def test_harvest_abort_missing_row_returns_none(client) -> None:
|
||||
with SessionLocal() as db:
|
||||
assert crud.harvest_abort(db, trace_id=_tid(), status="cancelled",
|
||||
reason=None) is None
|
||||
|
||||
|
||||
def test_upsert_record_no_downgrade_after_harvest_success(client) -> None:
|
||||
"""harvest 落 success 后,老客户端 fromFailure 的 cancelled 上报不许把它盖回去。"""
|
||||
tid = _tid()
|
||||
# PG 强制 comparison_record.user_id → user.id 外键(SQLite 不强制,老写法用合成 id
|
||||
# 987654)。用本用例自己登录出的真实用户,既满足外键、又不与别的用例撞。
|
||||
uid = _make_user(client, "13800007701")
|
||||
with SessionLocal() as db:
|
||||
crud.harvest_done(db, trace_id=tid, user_id=None, done_params=_done_params())
|
||||
payload = ComparisonRecordIn(
|
||||
trace_id=tid, business_type="food", status="cancelled",
|
||||
information="用户终止", comparison_results=[],
|
||||
)
|
||||
rec = crud.upsert_record(db, user_id=uid, payload=payload)
|
||||
assert rec.status == "success" # 不降级
|
||||
assert rec.user_id == uid # 但补上了 user_id(原为 None)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 端点层(mock pricebot)
|
||||
# ============================================================
|
||||
|
||||
|
||||
def _mock_pricebot(resp_json: dict):
|
||||
"""patch httpx.AsyncClient.post 返回给定响应;捕获转发的 content。"""
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_post(self, url, content=None, **kw):
|
||||
captured["url"] = url
|
||||
captured["content"] = content
|
||||
m = MagicMock()
|
||||
m.status_code = 200
|
||||
m.json = lambda: dict(resp_json)
|
||||
return m
|
||||
|
||||
return patch.object(httpx.AsyncClient, "post", fake_post), captured
|
||||
|
||||
|
||||
def _stub_body(**over) -> dict:
|
||||
body = {
|
||||
"device_id": "dev-x",
|
||||
"step": 0,
|
||||
"query": "海底捞",
|
||||
"device_info": {"brand": "OPPO", "model": "PEXM00", "android_version": "13",
|
||||
"rom_version": "13"},
|
||||
"screen_state": {"screen": {"width": 1080, "height": 2340, "density": 3.0},
|
||||
"foreground": {"package": "com.taobao.taobao", "activity": ""},
|
||||
"windows": []},
|
||||
}
|
||||
body.update(over)
|
||||
return body
|
||||
|
||||
|
||||
def test_price_step_mints_trace_id_and_creates_running(client) -> None:
|
||||
"""首帧不带 trace_id → app-server 签发 + 回传;并建 running 行(带机型/设备)。"""
|
||||
wait_frame = {"success": True, "action": {"command": "wait", "params": {"duration_ms": 500}},
|
||||
"continue": True, "trace_url": "https://price.shaguabijia.com/traces/mint/"}
|
||||
p, _cap = _mock_pricebot(wait_frame)
|
||||
with p:
|
||||
r = client.post("/api/v1/price/step", json=_stub_body()) # 无 trace_id、无 token
|
||||
assert r.status_code == 200, r.text
|
||||
tid = r.json().get("trace_id")
|
||||
assert tid and len(tid) >= 16 # 回传了签发的 trace_id
|
||||
with SessionLocal() as db:
|
||||
rec = _get(db, tid)
|
||||
assert rec is not None and rec.status == "running"
|
||||
assert rec.user_id is None # 无 token → 软鉴权放行、user_id 暂空
|
||||
assert rec.device_model == "PEXM00"
|
||||
assert rec.trace_url.endswith("/mint/")
|
||||
|
||||
|
||||
def test_price_step_done_harvests_success(client) -> None:
|
||||
tid = _tid()
|
||||
done_frame = {"success": True, "continue": False,
|
||||
"action": {"command": "done", "params": _done_params()},
|
||||
"trace_url": "https://price.shaguabijia.com/traces/done2/"}
|
||||
p, _cap = _mock_pricebot(done_frame)
|
||||
with p:
|
||||
r = client.post("/api/v1/price/step", json=_stub_body(trace_id=tid, step=8))
|
||||
assert r.status_code == 200
|
||||
with SessionLocal() as db:
|
||||
rec = _get(db, tid)
|
||||
assert rec is not None and rec.status == "success"
|
||||
assert rec.best_platform_id == "meituan"
|
||||
assert rec.saved_amount_cents == 500
|
||||
|
||||
|
||||
def test_trace_finalize_harvests_abort(client) -> None:
|
||||
tid = _tid()
|
||||
with SessionLocal() as db: # 先有 running 行(帧0建的)
|
||||
crud.harvest_running(db, trace_id=tid, user_id=None)
|
||||
p, _cap = _mock_pricebot({"trace_url": "https://price.shaguabijia.com/traces/fin/"})
|
||||
with p:
|
||||
r = client.post("/api/v1/trace/finalize",
|
||||
json={"trace_id": tid, "status": "cancelled", "reason": "用户终止"})
|
||||
assert r.status_code == 200
|
||||
with SessionLocal() as db:
|
||||
rec = _get(db, tid)
|
||||
assert rec is not None and rec.status == "cancelled"
|
||||
assert rec.trace_url.endswith("/fin/")
|
||||
|
||||
|
||||
def test_price_step_binds_user_when_authed(client) -> None:
|
||||
"""带 JWT 的首帧 → running 行绑上 user_id。"""
|
||||
client.post("/api/v1/auth/sms/send", json={"phone": "13800009001"})
|
||||
token = client.post("/api/v1/auth/sms/login",
|
||||
json={"phone": "13800009001", "code": "123456"}).json()["access_token"]
|
||||
wait_frame = {"success": True, "action": {"command": "wait", "params": {}},
|
||||
"continue": True, "trace_url": "https://price.shaguabijia.com/traces/auth/"}
|
||||
p, _cap = _mock_pricebot(wait_frame)
|
||||
with p:
|
||||
r = client.post("/api/v1/price/step", json=_stub_body(),
|
||||
headers={"Authorization": f"Bearer {token}"})
|
||||
tid = r.json()["trace_id"]
|
||||
with SessionLocal() as db:
|
||||
rec = _get(db, tid)
|
||||
assert rec is not None and rec.user_id is not None # 绑上了登录用户
|
||||
@@ -20,10 +20,8 @@ def _auth(token: str) -> dict[str, str]:
|
||||
|
||||
def _report_success(client, token: str, trace_id: str) -> None:
|
||||
"""上报一条成功比价(有非源有效价 → status 派生 success)。"""
|
||||
# trace_id 现全局唯一(prod 由 app-server 签发 UUID);测试共用 session DB,故按 token
|
||||
# (每用户不同)加前缀防跨用户/跨文件复用 "s-1" 撞车(见 comparison_record_trace_unique 迁移)。
|
||||
payload = {
|
||||
"trace_id": f"{token[-16:]}:{trace_id}",
|
||||
"trace_id": trace_id,
|
||||
"business_type": "food",
|
||||
"store_name": "测试店",
|
||||
"source_platform_id": "taobao_flash",
|
||||
@@ -42,7 +40,7 @@ def _report_success(client, token: str, trace_id: str) -> None:
|
||||
def _report_failed(client, token: str, trace_id: str) -> None:
|
||||
"""上报一条失败比价(只有源 → status 派生 failed,不计入解锁)。"""
|
||||
payload = {
|
||||
"trace_id": f"{token[-16:]}:{trace_id}",
|
||||
"trace_id": trace_id,
|
||||
"business_type": "food",
|
||||
"comparison_results": [
|
||||
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购", "price": 30.0,
|
||||
|
||||
@@ -9,7 +9,6 @@ mock 掉对 pricebot 的 httpx 调用,验证(两个端点参数化同跑):
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
@@ -42,8 +41,7 @@ def _stub_body() -> dict:
|
||||
|
||||
@pytest.mark.parametrize("path,upstream", ENDPOINTS)
|
||||
def test_passes_body_through(client, path, upstream) -> None:
|
||||
"""无 token(软鉴权放行)+ pricebot 200 → 响应透传(+ app-server 注入 trace_id),
|
||||
body 原样转发(自带 trace_id 时不重签),URL 去掉 /v1。"""
|
||||
"""无 token + pricebot 200 → 响应原样透传,body 原样转发,URL 去掉 /v1。"""
|
||||
fake_resp = {
|
||||
"success": True,
|
||||
"action": {"command": "wait", "params": {"duration_ms": 500}},
|
||||
@@ -51,25 +49,20 @@ def test_passes_body_through(client, path, upstream) -> None:
|
||||
}
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_post(self, url, content=None, **kw):
|
||||
# 透传壳用 content=raw(bytes)转发,不是 json=;这里捕获 content 解回来核对
|
||||
async def fake_post(self, url, json=None, **kw):
|
||||
captured["url"] = url
|
||||
captured["content"] = content
|
||||
captured["json"] = json
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json = lambda: dict(fake_resp) # 每次新副本(端点会 setdefault trace_id 进去)
|
||||
mock_resp.json = lambda: fake_resp
|
||||
return mock_resp
|
||||
|
||||
with patch.object(httpx.AsyncClient, "post", fake_post):
|
||||
r = client.post(path, json=_stub_body())
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["success"] is True
|
||||
assert body["action"] == fake_resp["action"]
|
||||
assert body["trace_id"] == "test-trace-1" # 自带 trace_id → 原样回传, 不 mint
|
||||
# body 原样转发(content=raw bytes;自带 trace_id 时不重新序列化)
|
||||
assert json.loads(captured["content"]) == _stub_body()
|
||||
assert r.json() == fake_resp
|
||||
assert captured["json"] == _stub_body() # body 原样转发(不鉴权,无需 token)
|
||||
assert captured["url"].endswith(upstream) # /api/v1/xxx → /api/xxx
|
||||
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
"""scripts/ensure_pg.py 纯函数单测(不需要 Docker/PG)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
|
||||
from scripts.ensure_pg import (
|
||||
_docker_desktop_cmd,
|
||||
_is_sqlite,
|
||||
_parse_host_port,
|
||||
_port_open,
|
||||
ensure,
|
||||
)
|
||||
|
||||
|
||||
def test_is_sqlite():
|
||||
assert _is_sqlite("sqlite:///./data/app.db")
|
||||
assert _is_sqlite(" SQLite:///x ")
|
||||
assert not _is_sqlite("postgresql+psycopg://u:p@localhost:5432/db")
|
||||
|
||||
|
||||
def test_parse_host_port_full():
|
||||
assert _parse_host_port(
|
||||
"postgresql+psycopg://u:p@localhost:5432/shaguabijia"
|
||||
) == ("localhost", 5432)
|
||||
|
||||
|
||||
def test_parse_host_port_defaults():
|
||||
# 缺端口 → 5432
|
||||
assert _parse_host_port("postgresql+psycopg://u:p@db.example/x")[1] == 5432
|
||||
# 缺 host → localhost
|
||||
assert _parse_host_port("postgresql+psycopg:///x") == ("localhost", 5432)
|
||||
|
||||
|
||||
def test_parse_host_port_testdb():
|
||||
assert _parse_host_port(
|
||||
"postgresql+psycopg://u:p@localhost:5432/shaguabijia_test"
|
||||
) == ("localhost", 5432)
|
||||
|
||||
|
||||
def test_port_open_true():
|
||||
srv = socket.socket()
|
||||
srv.bind(("127.0.0.1", 0))
|
||||
srv.listen(1)
|
||||
port = srv.getsockname()[1]
|
||||
try:
|
||||
assert _port_open("127.0.0.1", port, timeout=1.0)
|
||||
finally:
|
||||
srv.close()
|
||||
|
||||
|
||||
def test_port_open_false():
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close() # 释放端口,无人监听 → 连接应失败
|
||||
assert not _port_open("127.0.0.1", port, timeout=0.3)
|
||||
|
||||
|
||||
def test_docker_desktop_cmd_windows():
|
||||
cmd = _docker_desktop_cmd("win32", r"C:\Program Files")
|
||||
assert cmd is not None
|
||||
assert cmd[0].endswith("Docker Desktop.exe")
|
||||
assert "Docker" in cmd[0]
|
||||
|
||||
|
||||
def test_docker_desktop_cmd_darwin():
|
||||
assert _docker_desktop_cmd("darwin", "") == ["open", "-a", "Docker"]
|
||||
|
||||
|
||||
def test_docker_desktop_cmd_linux():
|
||||
assert _docker_desktop_cmd("linux", "") is None
|
||||
|
||||
|
||||
def test_ensure_rejects_sqlite():
|
||||
# dev 守卫:sqlite 直接 False(不碰 Docker)
|
||||
assert ensure("sqlite:///./data/app.db") is False
|
||||
|
||||
|
||||
def test_ensure_shortcircuits_when_pg_up(monkeypatch):
|
||||
# 端口通 → 直接 True,绝不触碰 docker
|
||||
monkeypatch.setattr("scripts.ensure_pg._port_open", lambda *a, **k: True)
|
||||
|
||||
def _boom():
|
||||
raise AssertionError("端口通时不应调用 docker")
|
||||
|
||||
monkeypatch.setattr("scripts.ensure_pg._docker_cli_ok", _boom)
|
||||
assert ensure("postgresql+psycopg://u:p@localhost:5432/shaguabijia") is True
|
||||
@@ -65,28 +65,3 @@ def test_onboarding_missing_device_id_treated_as_incomplete(client) -> None:
|
||||
phone = "13800138003"
|
||||
data = _login(client, phone, device_id=None)
|
||||
assert data["onboarding_completed"] is False
|
||||
|
||||
|
||||
def _reset(client, access: str, device_id: str):
|
||||
return client.post(
|
||||
"/api/v1/user/onboarding/reset",
|
||||
json={"device_id": device_id},
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
)
|
||||
|
||||
|
||||
def test_onboarding_reset_makes_relogin_reonboard(client) -> None:
|
||||
"""重置(删完成标记)后,同 (账号, 设备) 再登录 → onboarding_completed=False,重走引导。
|
||||
对应客户端「开发设置 → 重置新手引导」:先删后端行,再退登录重开。"""
|
||||
phone = "13800138004"
|
||||
dev = "android-id-reset"
|
||||
|
||||
# ① 走完引导 → 标记完成 → 再登录已完成
|
||||
access = _login(client, phone, dev)["access_token"]
|
||||
assert _mark_complete(client, access, dev).status_code == 200
|
||||
assert _login(client, phone, dev)["onboarding_completed"] is True
|
||||
|
||||
# ② 重置(幂等:重复重置也 200)→ 再登录变未完成 → 重走引导
|
||||
assert _reset(client, access, dev).status_code == 200
|
||||
assert _reset(client, access, dev).json()["ok"] is True
|
||||
assert _login(client, phone, dev)["onboarding_completed"] is False
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
"""提现现金账本校验(admin ledger-check)测试:两本物理隔离的账各自对账。
|
||||
|
||||
历史盲区:`withdraw_ledger_check` 曾拿全部提现单去和**普通现金流水**(cash_transaction)比对,
|
||||
而 source=invite_cash 的提现单流水其实在 invite_cash_transaction 表,导致每笔邀请提现单都被
|
||||
误报「缺扣款/缺退款流水」。这里用真实提现 API 造单 + before/after 差值断言锁定修复:
|
||||
1) 邀请提现单不再污染普通现金账的缺流水计数;
|
||||
2) 邀请账户已被纳入对账(能抓到它自己的缺流水);
|
||||
3) 普通现金账的原有对账未被改坏。
|
||||
|
||||
conftest 的库是 session 级共享、测试间不清,故一律用 before/after 差值,只反映本用例造的数据。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.admin.repositories.queries import withdraw_ledger_check
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CoinAccount, InviteCashTransaction
|
||||
from app.repositories import wallet as crud_wallet
|
||||
|
||||
|
||||
def _login(client, phone: str) -> str:
|
||||
client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
||||
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _auth(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _patch_userinfo(monkeypatch, openid: str) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.code_to_userinfo",
|
||||
lambda code: {"openid": openid, "nickname": None, "avatar_url": None, "raw": {}},
|
||||
)
|
||||
|
||||
|
||||
def _seed_balances(client, token: str, phone: str, *, cash: int = 0, invite_cash: int = 0) -> None:
|
||||
client.get("/api/v1/wallet/account", headers=_auth(token))
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.execute(select(User).where(User.phone == phone)).scalar_one()
|
||||
acc = db.get(CoinAccount, user.id)
|
||||
acc.cash_balance_cents = cash
|
||||
acc.invite_cash_balance_cents = invite_cash
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _reject(bill: str, reason: str = "测试拒绝") -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
crud_wallet.reject_withdraw(db, bill, reason)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _ledger() -> dict:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return withdraw_ledger_check(db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_rejected_invite_withdraw_not_flagged_missing(client, monkeypatch) -> None:
|
||||
"""核心回归:一笔被拒绝的 invite_cash 提现单,扣款/退款流水都在 invite_cash_transaction,
|
||||
不应让普通现金账的缺扣款/缺退款计数增加(修复前每笔会各 +1)。"""
|
||||
before = _ledger()
|
||||
|
||||
_patch_userinfo(monkeypatch, "openid_lc_1")
|
||||
token = _login(client, "13800005001")
|
||||
_seed_balances(client, token, "13800005001", cash=0, invite_cash=500)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
r = client.post(
|
||||
"/api/v1/wallet/withdraw",
|
||||
json={"amount_cents": 200, "source": "invite_cash"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
_reject(r.json()["out_bill_no"]) # rejected + 退款流水落 invite_cash_transaction
|
||||
|
||||
after = _ledger()
|
||||
|
||||
# 普通现金账不该因这笔 invite 单产生缺流水(修复前会各 +1 → 就是页面上看到的误报)
|
||||
assert after["missing_withdraw_txn_count"] == before["missing_withdraw_txn_count"]
|
||||
assert after["missing_refund_txn_count"] == before["missing_refund_txn_count"]
|
||||
# 邀请账扣款 + 退款流水齐全,邀请账自身也不该缺
|
||||
assert after["invite_missing_withdraw_txn_count"] == before["invite_missing_withdraw_txn_count"]
|
||||
assert after["invite_missing_refund_txn_count"] == before["invite_missing_refund_txn_count"]
|
||||
|
||||
|
||||
def test_invite_ledger_detects_missing_withdraw_txn(client, monkeypatch) -> None:
|
||||
"""删掉一笔 invite 提现单的扣款流水 → 邀请账缺扣款计数 +1、ok=False,
|
||||
证明邀请账户已真正纳入对账(修复前邀请账完全不校验、永远报不出问题)。"""
|
||||
_patch_userinfo(monkeypatch, "openid_lc_2")
|
||||
token = _login(client, "13800005002")
|
||||
_seed_balances(client, token, "13800005002", cash=0, invite_cash=500)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
r = client.post(
|
||||
"/api/v1/wallet/withdraw",
|
||||
json={"amount_cents": 200, "source": "invite_cash"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
bill = r.json()["out_bill_no"]
|
||||
|
||||
before = _ledger()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.execute(
|
||||
delete(InviteCashTransaction).where(
|
||||
InviteCashTransaction.ref_id == bill,
|
||||
InviteCashTransaction.biz_type == "invite_withdraw",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
after = _ledger()
|
||||
|
||||
assert (
|
||||
after["invite_missing_withdraw_txn_count"]
|
||||
== before["invite_missing_withdraw_txn_count"] + 1
|
||||
)
|
||||
assert after["ok"] is False
|
||||
|
||||
|
||||
def test_coin_cash_withdraw_still_reconciled(client, monkeypatch) -> None:
|
||||
"""普通现金 coin_cash 提现单齐全时不新增缺流水(确保分账改造没弄坏原有普通现金对账)。"""
|
||||
_patch_userinfo(monkeypatch, "openid_lc_3")
|
||||
token = _login(client, "13800005003")
|
||||
_seed_balances(client, token, "13800005003", cash=500, invite_cash=0)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
|
||||
before = _ledger()
|
||||
r = client.post(
|
||||
"/api/v1/wallet/withdraw",
|
||||
json={"amount_cents": 200, "source": "coin_cash"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
after = _ledger()
|
||||
|
||||
# 普通现金提现扣款流水随单写入 cash_transaction,缺扣款计数不变;邀请账更不受影响
|
||||
assert after["missing_withdraw_txn_count"] == before["missing_withdraw_txn_count"]
|
||||
assert after["invite_missing_withdraw_txn_count"] == before["invite_missing_withdraw_txn_count"]
|
||||
Reference in New Issue
Block a user