Compare commits

..

1 Commits

Author SHA1 Message Date
marco d9babc0ba3 更新文档0709 2026-07-09 14:23:59 +08:00
112 changed files with 124 additions and 11256 deletions
-16
View File
@@ -137,19 +137,3 @@ PANGLE_REPORT_SECURITY_KEY=
# GroMore AppId(报表 site_id 维度)→ 应用环境;默认取现网两个应用,按需覆盖。
PANGLE_REPORT_SITE_ID_PROD=5830519
PANGLE_REPORT_SITE_ID_TEST=5832303
# ===== 可观测(OpenObserve 接口指标)=====
# 采集每个接口 QPS + 耗时 + 错误率,批量直采到 OpenObserve(本地 Docker,见 deploy/openobserve/)。
# 默认关;开启需 ENABLED=true 且填 USER/PASSWORD(与 docker-compose 里 root 账号一致)。
# 未开/缺凭证 → 中间件透传、worker 不启动,整套 no-op,不影响业务。
OBSERVE_ENABLED=false
OBSERVE_ENDPOINT=http://localhost:5080
OBSERVE_ORG=default
OBSERVE_STREAM=app_requests
OBSERVE_USER=admin@shaguabijia.local
OBSERVE_PASSWORD=Complexpass#123
# 进阶(一般不用改):攒批间隔秒 / 单批最大条数 / 有界队列上限(满则丢) / 上报超时秒
OBSERVE_FLUSH_INTERVAL_SEC=5
OBSERVE_BATCH_MAX=200
OBSERVE_QUEUE_MAX=10000
OBSERVE_TIMEOUT_SEC=5
@@ -1,72 +0,0 @@
"""analytics_selfstat tables
Revision ID: 11c44afbea58
Revises: admin_user_plain_password
Create Date: 2026-07-08 16:32:49.351817
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '11c44afbea58'
down_revision: str | Sequence[str] | None = 'admin_user_plain_password'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
'analytics_selfstat',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('device_id', sa.String(length=64), nullable=False),
sa.Column('epoch_id', sa.String(length=64), nullable=False),
sa.Column('app_ver', sa.String(length=32), nullable=True),
sa.Column('oem', sa.String(length=32), nullable=True),
sa.Column('os', sa.String(length=32), nullable=True),
sa.Column('batches_attempted', sa.BigInteger(), nullable=False, server_default='0'),
sa.Column('batches_ok', sa.BigInteger(), nullable=False, server_default='0'),
sa.Column('batches_fail', sa.BigInteger(), nullable=False, server_default='0'),
sa.Column('retries', sa.BigInteger(), nullable=False, server_default='0'),
sa.Column('queue_depth', sa.Integer(), nullable=False, server_default='0'),
sa.Column('sent_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
with op.batch_alter_table('analytics_selfstat', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_analytics_selfstat_created_at'), ['created_at'], unique=False)
batch_op.create_index(batch_op.f('ix_analytics_selfstat_device_id'), ['device_id'], unique=False)
batch_op.create_index(batch_op.f('ix_analytics_selfstat_epoch_id'), ['epoch_id'], unique=False)
op.create_table(
'analytics_selfstat_event',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('snapshot_id', sa.Integer(), nullable=False),
sa.Column('event', sa.String(length=64), nullable=False),
sa.Column('attempted', sa.BigInteger(), nullable=False, server_default='0'),
sa.Column('drop_capture', sa.BigInteger(), nullable=False, server_default='0'),
sa.Column('delivered', sa.BigInteger(), nullable=False, server_default='0'),
sa.Column('drop_undelivered', sa.BigInteger(), nullable=False, server_default='0'),
sa.ForeignKeyConstraint(['snapshot_id'], ['analytics_selfstat.id'], ),
sa.PrimaryKeyConstraint('id'),
)
with op.batch_alter_table('analytics_selfstat_event', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_analytics_selfstat_event_event'), ['event'], unique=False)
batch_op.create_index(batch_op.f('ix_analytics_selfstat_event_snapshot_id'), ['snapshot_id'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('analytics_selfstat_event', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_event_snapshot_id'))
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_event_event'))
op.drop_table('analytics_selfstat_event')
with op.batch_alter_table('analytics_selfstat', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_epoch_id'))
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_device_id'))
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_created_at'))
op.drop_table('analytics_selfstat')
@@ -1,68 +0,0 @@
"""add inactivity tables
Revision ID: 135e79414fd0
Revises: comparison_llm_cost
Create Date: 2026-07-16 18:31:02.105929
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '135e79414fd0'
down_revision: Union[str, Sequence[str], None] = 'comparison_llm_cost'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"inactivity_reset_log",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("coin_balance_before", sa.Integer(), nullable=False),
sa.Column("cash_balance_cents_before", sa.Integer(), nullable=False),
sa.Column("invite_cash_balance_cents_before", sa.Integer(), nullable=False),
sa.Column("last_active_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("inactive_days", sa.Integer(), nullable=False),
sa.Column("reason", sa.String(length=32), nullable=False),
sa.Column("reset_at", sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("inactivity_reset_log", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_inactivity_reset_log_user_id"), ["user_id"], unique=False)
batch_op.create_index(batch_op.f("ix_inactivity_reset_log_reset_at"), ["reset_at"], unique=False)
op.create_table(
"inactivity_notification_log",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("stage", sa.Integer(), nullable=False),
sa.Column("inactive_days", sa.Integer(), nullable=False),
sa.Column("coin_balance", sa.Integer(), nullable=False),
sa.Column("cash_balance_cents", sa.Integer(), nullable=False),
sa.Column("invite_cash_balance_cents", sa.Integer(), nullable=False),
sa.Column("channel", sa.String(length=16), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("inactivity_notification_log", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_inactivity_notification_log_user_id"), ["user_id"], unique=False)
batch_op.create_index(batch_op.f("ix_inactivity_notification_log_created_at"), ["created_at"], unique=False)
def downgrade() -> None:
with op.batch_alter_table("inactivity_notification_log", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_inactivity_notification_log_created_at"))
batch_op.drop_index(batch_op.f("ix_inactivity_notification_log_user_id"))
op.drop_table("inactivity_notification_log")
with op.batch_alter_table("inactivity_reset_log", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_inactivity_reset_log_reset_at"))
batch_op.drop_index(batch_op.f("ix_inactivity_reset_log_user_id"))
op.drop_table("inactivity_reset_log")
-44
View File
@@ -1,44 +0,0 @@
"""ad_ecpm_record.trace_id(展示收益归属到比价/领券 trace)
信息流(Draw)展示 eCPM 上报时带上本场比价/领券 trace_id,落此列;领券数据 / 比价记录看板
按 trace_id 聚合"本次广告收益"。激励视频/福利/旧客户端为 NULL。
本迁移原以 (11c44afbea58, merge_pages_override_coupon_slot) 为双亲、顺带收敛双 head,
但与它并行落 main 的 merge_selfstat_coupon_slot 已用同一对双亲做了纯收敛 → 同一对
父节点出现两个收敛点、main 上又成双 head。故重挂到该 merge 之后成单链(仅改链接、
schema 改动不变;两文件都保留,已 stamp 在 merge 上的库可直接线性升级)。
Revision ID: ad_ecpm_trace_id
Revises: merge_selfstat_coupon_slot
Create Date: 2026-07-10
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "ad_ecpm_trace_id"
down_revision: Union[str, Sequence[str], None] = "merge_selfstat_coupon_slot"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# SQLite 下 ADD COLUMN(可空)与 CREATE INDEX 均原生支持,无需 batch_alter_table
# (同 ad_feed_reward_trace_id 迁移)。
op.add_column(
"ad_ecpm_record",
sa.Column("trace_id", sa.String(length=64), nullable=True),
)
op.create_index(
op.f("ix_ad_ecpm_record_trace_id"),
"ad_ecpm_record",
["trace_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index(op.f("ix_ad_ecpm_record_trace_id"), table_name="ad_ecpm_record")
op.drop_column("ad_ecpm_record", "trace_id")
@@ -1,36 +0,0 @@
"""analytics_event 活跃口径复合索引
Revision ID: analytics_active_idx
Revises: 135e79414fd0
Create Date: 2026-07-18 17:35:00.000000
给 analytics_event 加活跃口径热点复合索引 (event, page, user_id, created_at):
activity.active_event_condition 按 (event=show & page=home) 比价 领券 过滤后
group by user_id、max(created_at)。覆盖索引让该聚合走 index-only,避免高频 show 事件全表扫。
⚠️ 本分支迁移树有**既有多头**:135e79414fd0(不活跃两表)与 phone_rebind_log 同从
comparison_llm_cost 分叉,`alembic upgrade head` 会多头报错。本迁移挂在 135e79414fd0
一侧;集成到 main 时需 `alembic merge` 合并 phone_rebind_log 那个头(与本迁移无关的既有问题)。
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "analytics_active_idx"
down_revision: Union[str, Sequence[str], None] = "135e79414fd0"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index(
"ix_analytics_event_active",
"analytics_event",
["event", "page", "user_id", "created_at"],
unique=False,
)
def downgrade() -> None:
op.drop_index("ix_analytics_event_active", table_name="analytics_event")
-33
View File
@@ -1,33 +0,0 @@
"""comparison_record: llm_cost_yuan + llm_price_snapshot(比价 LLM 调用成本 + 当时单价快照)
回填 llm_calls 时按「当时的价」逐模型算出本次比价 LLM 总成本(元),连同所用单价快照一起冻结到
记录上;admin 比价记录详情展示实际成本(旧记录 NULL → 前端回退估算)。见 services/llm_cost.py。
Revision ID: comparison_llm_cost
Revises: ad_ecpm_trace_id
Create Date: 2026-07-13
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "comparison_llm_cost"
down_revision: str | Sequence[str] | None = "ad_ecpm_trace_id"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_JSONB = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
def upgrade() -> None:
# 均可空、无索引;SQLite 原生支持 ADD COLUMN,无需 batch_alter_table(同 comparison_debug_fields)。
op.add_column("comparison_record", sa.Column("llm_cost_yuan", sa.Float(), nullable=True))
op.add_column("comparison_record", sa.Column("llm_price_snapshot", _JSONB, nullable=True))
def downgrade() -> None:
op.drop_column("comparison_record", "llm_price_snapshot")
op.drop_column("comparison_record", "llm_cost_yuan")
-32
View File
@@ -1,32 +0,0 @@
"""coupon_claim_record 加 app_env 列(领券所属 session 环境;每券成功率表按它过滤 prod/dev)
Revision ID: coupon_claim_app_env
Revises: coupon_session_platform_success
Create Date: 2026-07-08 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "coupon_claim_app_env"
down_revision: str | Sequence[str] | None = "coupon_session_platform_success"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("coupon_claim_record", schema=None) as batch_op:
batch_op.add_column(sa.Column("app_env", sa.String(length=16), nullable=True))
batch_op.create_index(
"ix_coupon_claim_record_app_env", ["app_env"], unique=False
)
def downgrade() -> None:
with op.batch_alter_table("coupon_claim_record", schema=None) as batch_op:
batch_op.drop_index("ix_coupon_claim_record_app_env")
batch_op.drop_column("app_env")
@@ -1,37 +0,0 @@
"""coupon_session 加 platform_success 列(本次至少领到一张的平台 id 列表)
供 admin「领券数据」算 ②整单成功率 / ③点位成功率(平台粒度)。数据落点:服务端 /step 逐帧
按 trace_id 并集写入(见 app/repositories/coupon_state.merge_session_platform_success)。旧行 NULL
视作空集,已建表环境靠它补列、全新环境顺序应用不重复加列。设计:docs/guides/领券成功率指标-设计与埋点.md。
Revision ID: coupon_session_platform_success
Revises: admin_user_plain_password
Create Date: 2026-07-07 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "coupon_session_platform_success"
down_revision: str | Sequence[str] | None = "admin_user_plain_password"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# PG 用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 model 的 _JSON variant / 建表迁移)。
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
def upgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.add_column(sa.Column("platform_success", _JSON, nullable=True))
def downgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.drop_column("platform_success")
@@ -1,25 +0,0 @@
"""merge inactivity(analytics_active_idx) + phone_rebind_log heads
Revision ID: merge_active_phone
Revises: analytics_active_idx, phone_rebind_log
Create Date: 2026-07-18 18:52:34.001148
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'merge_active_phone'
down_revision: Union[str, Sequence[str], None] = ('analytics_active_idx', 'phone_rebind_log')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -1,28 +0,0 @@
"""合并两个 alembic head:admin_user_pages_override(#126 权限)+ coupon_claim_app_env(领券成功率)。
两条迁移都从 admin_user_plain_password 分叉——#126 经 pull main 进入本分支,领券成功率为本分支新增——
于是出现两个 head。本迁移仅把二者收敛成单 head,让 `alembic upgrade head`(单数,部署/run.sh 用)
恢复正常;**不含任何表结构 / 数据改动**(纯 merge)。
Revision ID: merge_pages_override_coupon_slot
Revises: admin_user_pages_override, coupon_claim_app_env
Create Date: 2026-07-09 00:00:00.000000
"""
from collections.abc import Sequence
revision: str = "merge_pages_override_coupon_slot"
down_revision: str | Sequence[str] | None = (
"admin_user_pages_override",
"coupon_claim_app_env",
)
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""纯合并 head,无 schema 改动。"""
def downgrade() -> None:
"""拆回两个 head,无 schema 改动。"""
@@ -1,29 +0,0 @@
"""合并两个 alembic head:11c44afbea58(#127 埋点健康度 selfstat)+ merge_pages_override_coupon_slot(#130 自带的合并迁移)。
三条分支都从 admin_user_plain_password 分叉(#126 权限 / #127 selfstat / #130 领券成功率)。
#130 自带的 merge 创建时本地 main 尚无 #127 的 11c44afbea58,只收敛了 #126 + 自身两条,
#130 合入后 main 上仍留两个 head → `alembic upgrade head`(单数,部署/run.sh 用)直接报错、服务起不来。
本迁移仅把二者收敛成单 head;**不含任何表结构 / 数据改动**(纯 merge)。
Revision ID: merge_selfstat_coupon_slot
Revises: 11c44afbea58, merge_pages_override_coupon_slot
Create Date: 2026-07-10 00:00:00.000000
"""
from collections.abc import Sequence
revision: str = "merge_selfstat_coupon_slot"
down_revision: str | Sequence[str] | None = (
"11c44afbea58",
"merge_pages_override_coupon_slot",
)
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""纯合并 head,无 schema 改动。"""
def downgrade() -> None:
"""拆回两个 head,无 schema 改动。"""
-32
View File
@@ -1,32 +0,0 @@
"""phone_rebind_log 表(M2 换绑 30 天限制台账)
Revision ID: phone_rebind_log
Revises: comparison_llm_cost
"""
from alembic import op
import sqlalchemy as sa
revision = "phone_rebind_log"
down_revision = "comparison_llm_cost"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"phone_rebind_log",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("phone", sa.String(length=20), nullable=False),
sa.Column("old_user_id", sa.Integer(), nullable=True),
sa.Column("new_user_id", sa.Integer(), nullable=False),
sa.Column("source", sa.String(length=32), nullable=False, server_default="wechat_conflict"),
sa.Column("rebound_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_phone_rebind_log_phone", "phone_rebind_log", ["phone"])
op.create_index("ix_phone_rebind_log_rebound_at", "phone_rebind_log", ["rebound_at"])
def downgrade() -> None:
op.drop_index("ix_phone_rebind_log_rebound_at", table_name="phone_rebind_log")
op.drop_index("ix_phone_rebind_log_phone", table_name="phone_rebind_log")
op.drop_table("phone_rebind_log")
-4
View File
@@ -26,11 +26,9 @@ from app.admin.routers.cps import router as cps_router
from app.admin.routers.dashboard import router as dashboard_router
from app.admin.routers.device_liveness import router as device_liveness_router
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
from app.admin.routers.analytics_health import router as analytics_health_router
from app.admin.routers.event_logs import router as event_logs_router
from app.admin.routers.feedback import router as feedback_router
from app.admin.routers.feedback_qr import router as feedback_qr_router
from app.admin.routers.huawei_review import router as huawei_review_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
@@ -99,7 +97,6 @@ admin_app.include_router(withdraw_router)
admin_app.include_router(price_report_router)
admin_app.include_router(feedback_router)
admin_app.include_router(event_logs_router)
admin_app.include_router(analytics_health_router)
admin_app.include_router(feedback_qr_router)
admin_app.include_router(admins_router)
admin_app.include_router(roles_router)
@@ -110,5 +107,4 @@ admin_app.include_router(cps_router)
admin_app.include_router(coupon_data_router)
admin_app.include_router(ad_audit_router)
admin_app.include_router(ad_config_router)
admin_app.include_router(huawei_review_router)
admin_app.include_router(ad_revenue_router)
+2 -4
View File
@@ -31,7 +31,6 @@ PERMISSION_CATALOG: list[dict] = [
{"group": "数据配置", "pages": [
{"key": "config", "label": "系统配置"},
{"key": "ad-revenue", "label": "广告配置"},
{"key": "huawei-review", "label": "华为审核开关"},
{"key": "users", "label": "用户管理"},
]},
{"group": "其他", "pages": [
@@ -52,14 +51,13 @@ 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", "huawei-review",
"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", "huawei-review",
"event-logs", "audit-logs",
"dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs",
]},
]
-146
View File
@@ -1,146 +0,0 @@
"""埋点健康度聚合(埋点成功率 / 上报成功率)。
只存原始累计快照,查询时在 Python 侧差分聚合(admin 低频、量级小,跨 PG/SQLite 无方言坑;
与 cps.py / coupon_data.py 同款约定)。差分按 (device_id, epoch_id, event) 分区、created_at
升序,相邻做差、负值夹 0;每增量按其快照 created_at 归入北京天桶。
"""
from __future__ import annotations
from collections import defaultdict
from datetime import UTC, datetime
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core import rewards
from app.models.analytics_selfstat import AnalyticsSelfStat as H
from app.models.analytics_selfstat import AnalyticsSelfStatEvent as E
_COUNTS = ("attempted", "drop_capture", "delivered", "drop_undelivered")
def diff_snapshots(rows: list[dict]) -> list[dict]:
"""累计快照行 → 每快照增量行(纯逻辑)。
rows 每行含 device_id/epoch_id/event/created_at/app_ver/oem/os + 四个累计计数。
返回每行含 dims + created_at + 四个增量 d_*(分区首行增量=累计值;负值夹 0)。
"""
parts: dict[tuple, list[dict]] = defaultdict(list)
for r in rows:
parts[(r["device_id"], r["epoch_id"], r["event"])].append(r)
out: list[dict] = []
for group in parts.values():
group.sort(key=lambda r: (r["created_at"], r.get("id", 0)))
prev = {k: 0 for k in _COUNTS}
for r in group:
deltas = {f"d_{k}": max(0, int(r[k]) - prev[k]) for k in _COUNTS}
out.append({
"device_id": r["device_id"], "epoch_id": r["epoch_id"], "event": r["event"],
"created_at": r["created_at"], "app_ver": r["app_ver"],
"oem": r["oem"], "os": r["os"], **deltas,
})
prev = {k: int(r[k]) for k in _COUNTS}
return out
def _cn_day(dt: datetime) -> str:
"""created_at(UTC 口径)→ 北京日期字符串 YYYY-MM-DD。naive 当 UTC,tz-aware 直接换算。"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return dt.astimezone(rewards.CN_TZ).date().isoformat()
def _rates(sums: dict) -> dict:
"""由四个增量和派生两段率(分母 0 → None)。"""
persisted_denom = sums["attempted"]
report_denom = sums["delivered"] + sums["drop_undelivered"]
return {
**sums,
"track_success_rate": (
(sums["attempted"] - sums["drop_capture"]) / persisted_denom
if persisted_denom else None
),
"report_success_rate": (
sums["delivered"] / report_denom if report_denom else None
),
}
def _sum_deltas(deltas: list[dict]) -> dict:
return {k: sum(d[f"d_{k}"] for d in deltas) for k in _COUNTS}
def _fetch_rows(db: Session, date_from: datetime, date_to: datetime) -> list[dict]:
"""取 [from, to) 区间行 + 每分区在 from 左侧的最后一条基线行(供第一条区间增量做差)。"""
cols = (
H.id, H.device_id, H.epoch_id, E.event, H.created_at,
H.app_ver, H.oem, H.os,
E.attempted, E.drop_capture, E.delivered, E.drop_undelivered,
)
in_range = db.execute(
select(*cols).join(E, E.snapshot_id == H.id)
.where(H.created_at >= date_from, H.created_at < date_to)
).mappings().all()
# 注:基线子查询无下界扫 from 左侧全量(spec §7 已接受的取舍;量级变大再上物化 rollup)。
# 用 max(id) 而非 max(created_at) 选"最新一条":id 严格单调,避免 SQLite 秒级时间戳撞车时选歧义。
sub = (
select(H.device_id, H.epoch_id, E.event, func.max(H.id).label("max_id"))
.join(E, E.snapshot_id == H.id)
.where(H.created_at < date_from)
.group_by(H.device_id, H.epoch_id, E.event)
.subquery()
)
baseline = db.execute(
select(*cols).join(E, E.snapshot_id == H.id).join(
sub, sub.c.max_id == H.id
)
).mappings().all()
return [dict(r) for r in list(baseline) + list(in_range)]
def _in_range_deltas(db: Session, date_from: datetime, date_to: datetime) -> list[dict]:
"""差分后只保留 created_at ∈ [from, to) 的增量(基线行被差分用后丢弃)。
Python 侧过滤需对齐 tz 口径:SQLite 返回 naive UTC,PG 返回 aware UTC。
统一转成 naive UTC 再比较,兼容两种后端。
"""
def _to_naive_utc(dt: datetime) -> datetime:
if dt.tzinfo is not None:
return dt.astimezone(UTC).replace(tzinfo=None)
return dt
from_naive = _to_naive_utc(date_from)
to_naive = _to_naive_utc(date_to)
deltas = diff_snapshots(_fetch_rows(db, date_from, date_to))
return [d for d in deltas if from_naive <= _to_naive_utc(d["created_at"]) < to_naive]
def overview(db: Session, date_from: datetime, date_to: datetime) -> dict:
deltas = _in_range_deltas(db, date_from, date_to)
return _rates(_sum_deltas(deltas))
def trend(db: Session, date_from: datetime, date_to: datetime) -> list[dict]:
deltas = _in_range_deltas(db, date_from, date_to)
by_day: dict[str, list[dict]] = defaultdict(list)
for d in deltas:
by_day[_cn_day(d["created_at"])].append(d)
return [
{"day": day, **_rates(_sum_deltas(items))}
for day, items in sorted(by_day.items())
]
def breakdown(db: Session, date_from: datetime, date_to: datetime, dim: str) -> list[dict]:
if dim not in ("event", "app_ver", "oem"):
raise ValueError(f"invalid dim: {dim!r}")
deltas = _in_range_deltas(db, date_from, date_to)
by_key: dict[str, list[dict]] = defaultdict(list)
for d in deltas:
by_key[d.get(dim) or "(unknown)"].append(d)
rows = [{"key": key, **_rates(_sum_deltas(items))} for key, items in by_key.items()]
rows.sort(key=lambda r: (r["report_success_rate"] is None, r["report_success_rate"] or 0.0))
return rows
+6 -111
View File
@@ -5,21 +5,17 @@
- 发起数 = 区间内全部 session(含 started/completed/failed/abandoned),= 流失统计的基数。
- 完成数 / 耗时均值 / 分位 = 仅 status==completed 子集(成功跑完才有可比的"领券耗时")。
- summary/daily/hourly/total 在全量上算,不受分页;items 为排序后当前页。
- 另含 coupon_slot_report(数据源 coupon_claim_record):按 coupon_id「按券成功率」表,见设计 §13。
"""
from __future__ import annotations
from datetime import UTC, datetime
from datetime import date as _date
from datetime import UTC, date as _date, datetime
from sqlalchemy import case, func, or_, select
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.core import rewards
from app.models.coupon_state import CouponClaimRecord, CouponSession
from app.models.coupon_state import CouponSession
from app.models.user import User
from app.repositories import ad_ecpm as crud_ecpm
from app.repositories.coupon_state import DEFAULT_PLATFORMS, coupon_id_to_platform
def _cn_hour(dt: datetime) -> int:
@@ -46,47 +42,7 @@ def _avg(vals: list[int]) -> int | None:
return round(sum(vals) / len(vals)) if vals else None
def _success_rates(rows: list) -> dict:
"""平台粒度成功率(见 docs/guides/领券成功率指标-设计与埋点.md §3/§12):
- sel(s) = 勾选平台(`platforms` 空 → 全领三档 DEFAULT_PLATFORMS);
- succ(s) = `platform_success` ∩ sel(至少领到一张的平台);
- ② 整单成功率 = #{sel⊆succ 且 sel≠∅} / 发起数;
- ③ 点位成功率 = Σ|succ| / Σ|sel|;per_platform[p] = 勾了 p 且成功 / 勾了 p。
基数含全部 session(started/completed/failed/abandoned),与「发起数」同基数。
"""
started = len(rows)
full_success = 0
point_success = 0
point_total = 0
per_succ = {p: 0 for p in DEFAULT_PLATFORMS}
per_total = {p: 0 for p in DEFAULT_PLATFORMS}
for r in rows:
sel = set(r.platforms) if r.platforms else set(DEFAULT_PLATFORMS)
succ = set(r.platform_success or []) & sel
point_success += len(succ)
point_total += len(sel)
if sel and succ == sel:
full_success += 1
for p in sel:
if p in per_total: # 只统计三档已知平台;未知/非法平台 id 不进 per_platform
per_total[p] += 1
if p in succ:
per_succ[p] += 1
return {
"full_success_count": full_success,
"full_success_rate": round(full_success / started, 4) if started else None,
"point_success_count": point_success,
"point_total_count": point_total,
"point_success_rate": round(point_success / point_total, 4) if point_total else None,
"per_platform": {
p: (round(per_succ[p] / per_total[p], 4) if per_total[p] else None)
for p in DEFAULT_PLATFORMS
},
}
def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad_revenue_yuan: float = 0.0) -> dict:
def _session_to_row(r, phone: str | None = None, nickname: str | None = None) -> dict:
"""CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。"""
return {
"id": r.id,
@@ -105,7 +61,6 @@ def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad
"started_at": r.started_at,
"claimed_count": r.claimed_count,
"trace_url": r.trace_url,
"ad_revenue_yuan": ad_revenue_yuan,
}
@@ -129,7 +84,6 @@ def coupon_data_report(
date_to: str,
user: str | None = None,
app_env: str | None = None,
statuses: list[str] | None = None,
granularity: str = "day",
limit: int = 500,
offset: int = 0,
@@ -139,8 +93,6 @@ def coupon_data_report(
- user:手机号/昵称模糊搜(匹配不到任何用户 → 空结果)。
- app_env:prod/dev 精确;None=全部。
- statuses:领券状态多选(started/completed/failed/abandoned);None/空=全部。整个视图
(汇总/成功率/趋势/明细)按选中状态算,与 app_env 同级过滤(方案 A)。
- sort:time=发起时刻倒序(默认) / elapsed=全程耗时倒序(None 末尾)。
"""
by_hour = granularity == "hour"
@@ -163,8 +115,6 @@ def coupon_data_report(
)
if app_env is not None:
stmt = stmt.where(CouponSession.app_env == app_env)
if statuses:
stmt = stmt.where(CouponSession.status.in_(statuses))
if user_ids is not None:
stmt = stmt.where(CouponSession.user_id.in_(user_ids))
rows = list(db.execute(stmt).scalars())
@@ -181,7 +131,6 @@ def coupon_data_report(
"p50_ms": _percentile(completed_elapsed, 50),
"p95_ms": _percentile(completed_elapsed, 95),
"p99_ms": _percentile(completed_elapsed, 99),
**_success_rates(rows),
}
# ── 按天趋势(柱=发起/完成数,线=平均耗时)──
@@ -248,11 +197,10 @@ def coupon_data_report(
select(User.id, User.phone, User.nickname).where(User.id.in_(uids))
).all()
}
rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in page])
items = []
for r in page:
phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None)
items.append(_session_to_row(r, phone, nickname, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)))
items.append(_session_to_row(r, phone, nickname))
return {
"summary": summary,
@@ -274,57 +222,4 @@ def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict:
total = db.execute(
select(func.count()).select_from(CouponSession).where(CouponSession.user_id == user_id)
).scalar_one()
rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows])
return {
"items": [_session_to_row(r, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)) for r in rows],
"total": int(total),
}
_SLOT_OK = ("success", "already_claimed")
_SLOT_TRIED = ("success", "already_claimed", "failed")
def coupon_slot_report(
db: Session, *, date_from: str, date_to: str, app_env: str | None = None
) -> dict:
"""按 coupon_id(具体券)聚合成功率(见 docs/guides/领券成功率指标-设计与埋点.md §13)。
数据源 coupon_claim_record(粒度=设备-天,唯一键 device+coupon+day)。
- 尝试 = status ∈ {success, already_claimed, failed}(skipped 排除);
- 成功 = status ∈ {success, already_claimed};成功率 = 成功/尝试;
- claim_date 区间 + app_env(None=全部)过滤;按 tried 倒序返回。
"""
d_from = _date.fromisoformat(date_from)
d_to = _date.fromisoformat(date_to)
ok = case((CouponClaimRecord.status.in_(_SLOT_OK), 1), else_=0)
stmt = (
select(
CouponClaimRecord.coupon_id,
func.max(CouponClaimRecord.coupon_name).label("coupon_name"),
func.count().label("tried"),
func.sum(ok).label("succeeded"),
)
.where(
CouponClaimRecord.claim_date >= d_from,
CouponClaimRecord.claim_date <= d_to,
CouponClaimRecord.status.in_(_SLOT_TRIED),
)
.group_by(CouponClaimRecord.coupon_id)
)
if app_env is not None:
stmt = stmt.where(CouponClaimRecord.app_env == app_env)
items = []
for coupon_id, coupon_name, tried, succeeded in db.execute(stmt).all():
tried = int(tried or 0)
succeeded = int(succeeded or 0)
items.append({
"coupon_id": coupon_id,
"coupon_name": coupon_name,
"platform": coupon_id_to_platform(coupon_id),
"tried": tried,
"succeeded": succeeded,
"success_rate": round(succeeded / tried, 4) if tried else None,
})
items.sort(key=lambda x: (-x["tried"], x["coupon_id"]))
return {"items": items}
return {"items": [_session_to_row(r) for r in rows], "total": int(total)}
+61 -16
View File
@@ -11,6 +11,7 @@ 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
@@ -31,7 +32,9 @@ from app.models.wallet import (
InviteCashTransaction,
WithdrawOrder,
)
from app.repositories import activity, ad_ecpm
# 「最近活跃」计入的行为事件(与大盘 DAU/留存活跃口径一致:开始比价 + 开始领券)
_ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT)
# 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取")
_NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct")
@@ -84,6 +87,49 @@ 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 读)。
@@ -97,7 +143,7 @@ def _attach_last_active(db: Session, users: list[User]) -> None:
select(AnalyticsEvent.user_id, func.max(AnalyticsEvent.created_at))
.where(
AnalyticsEvent.user_id.in_(uids),
activity.active_event_condition(),
AnalyticsEvent.event.in_(_ACTIVE_EVENTS),
)
.group_by(AnalyticsEvent.user_id)
).all()
@@ -114,9 +160,9 @@ def _attach_last_active(db: Session, users: list[User]) -> None:
)
for u in users:
candidates = [
activity.norm_utc(u.created_at), # baseline 由 last_login_at 改为 created_at(登录不算活跃)
activity.norm_utc(ev_map.get(u.id)),
activity.norm_utc(eng_map.get(u.id)),
_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)
@@ -144,12 +190,16 @@ def list_users(
(口径见 [_last_active_expr])。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。
日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。"""
# 最近活跃 = max(注册时间, 最近行为事件, 最近领券发起)。baseline 由 last_login_at 改为 created_at
#(登录不代表在用 App;口径统一到 activity.py,含 home_view + 比价 + 领券,见 activity.ACTIVE_EVENTS)。
# 未命中侧 coalesce 到 created_at(恒非空基线)。派生表 1:1,outerjoin 不放大行数
ev_agg, eng_agg = activity.last_active_subqueries(db)
last_active = activity.last_active_expr(
User.created_at, ev_agg, eng_agg, db.get_bind().dialect.name
# 最近活跃 = 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)
@@ -248,11 +298,6 @@ def list_comparison_records(
limit=limit, cursor=cursor,
)
_attach_user_info(db, items)
# 「本次比价看广告的预估收益」:按本页 trace_id 一次性聚合(同 _attach_user_info 逐页范式)。
# ad_revenue_yuan 非 ORM 列,仅瞬态挂实例上供 AdminComparisonListItem(from_attributes)读出。
rev = ad_ecpm.revenue_yuan_by_trace(db, [it.trace_id for it in items])
for it in items:
it.ad_revenue_yuan = rev.get(it.trace_id, 0.0)
return items, next_cursor, total
+9 -102
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
from collections import Counter
from datetime import date, datetime, time, timedelta, timezone
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from decimal import Decimal, InvalidOperation
from sqlalchemy import case, func, select
from sqlalchemy.orm import Session
@@ -109,23 +109,6 @@ def _date_range(date_from: date, date_to: date) -> list[date]:
return [date_from + timedelta(days=i) for i in range(days + 1)]
def _duration_percentile(sorted_values: list[int], q: float) -> int | None:
"""Linear-interpolated percentile with the same half-up rounding as Math.round."""
if not sorted_values:
return None
if len(sorted_values) == 1:
return sorted_values[0]
index = (len(sorted_values) - 1) * q
lower = int(index)
upper = min(lower + 1, len(sorted_values) - 1)
fraction = Decimal(str(index - lower))
value = (
Decimal(sorted_values[lower]) * (Decimal(1) - fraction)
+ Decimal(sorted_values[upper]) * fraction
)
return int(value.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
def _id_set(db: Session, stmt) -> set[int]:
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
@@ -259,46 +242,16 @@ def dashboard_overview(
ComparisonRecord.created_at >= start_local,
ComparisonRecord.created_at < end_local,
)
period_comparison_stats = db.execute(
select(
func.count(ComparisonRecord.id),
func.coalesce(
func.sum(
case(
(ComparisonRecord.status.in_(("success", "failed")), 1),
else_=0,
)
),
0,
),
func.coalesce(
func.sum(
case((ComparisonRecord.status == "cancelled", 1), else_=0)
),
0,
),
func.coalesce(
func.sum(case((ComparisonRecord.status == "success", 1), else_=0)),
0,
),
func.coalesce(func.sum(ComparisonRecord.llm_cost_yuan), 0.0),
).where(*period_comparison_conds)
).one()
period_comparison_total = int(period_comparison_stats[0])
period_comparison_completed = int(period_comparison_stats[1])
period_comparison_cancelled = int(period_comparison_stats[2])
period_comparison_success = int(period_comparison_stats[3])
period_comparison_token_cost_yuan = float(period_comparison_stats[4])
period_comparison_success_denominator = (
period_comparison_total - period_comparison_cancelled
period_comparison_total = _count(ComparisonRecord, *period_comparison_conds)
period_comparison_success = _count(
ComparisonRecord,
*period_comparison_conds,
ComparisonRecord.status == "success",
)
period_comparison_success_rate = (
round(
period_comparison_success / period_comparison_success_denominator,
4,
)
if period_comparison_success_denominator > 0
else None
round(period_comparison_success / period_comparison_total, 4)
if period_comparison_total
else 0.0
)
period_saved_positive_count = _count(
ComparisonRecord,
@@ -329,47 +282,6 @@ def dashboard_overview(
if period_avg_duration_ms is not None
else None
)
completed_duration_conds = (
*period_comparison_conds,
ComparisonRecord.status.in_(("success", "failed")),
ComparisonRecord.total_ms.is_not(None),
)
if db.bind is not None and db.bind.dialect.name == "postgresql":
period_median_duration_ms, period_p95_duration_ms = db.execute(
select(
func.percentile_cont(0.5).within_group(ComparisonRecord.total_ms),
func.percentile_cont(0.95).within_group(ComparisonRecord.total_ms),
).where(*completed_duration_conds)
).one()
period_median_duration_ms = (
int(
Decimal(str(period_median_duration_ms)).quantize(
Decimal("1"), rounding=ROUND_HALF_UP
)
)
if period_median_duration_ms is not None
else None
)
period_p95_duration_ms = (
int(
Decimal(str(period_p95_duration_ms)).quantize(
Decimal("1"), rounding=ROUND_HALF_UP
)
)
if period_p95_duration_ms is not None
else None
)
else:
# SQLite 测试环境没有 percentile_cont;仅回退读取耗时单列,不加载完整记录。
completed_durations = list(
db.execute(
select(ComparisonRecord.total_ms)
.where(*completed_duration_conds)
.order_by(ComparisonRecord.total_ms)
).scalars()
)
period_median_duration_ms = _duration_percentile(completed_durations, 0.5)
period_p95_duration_ms = _duration_percentile(completed_durations, 0.95)
ordered_exists = (
select(SavingsRecord.id)
@@ -710,16 +622,11 @@ def dashboard_overview(
},
"comparison": {
"total": period_comparison_total,
"completed": period_comparison_completed,
"cancelled": period_comparison_cancelled,
"success": period_comparison_success,
"success_rate": period_comparison_success_rate,
"ordered": period_ordered_count,
"average_duration_ms": period_avg_duration_ms,
"median_duration_ms": period_median_duration_ms,
"p95_duration_ms": period_p95_duration_ms,
"average_saved_cents": period_avg_saved_cents,
"token_cost_total_yuan": period_comparison_token_cost_yuan,
},
"coupon": {
"started": coupon_started,
-49
View File
@@ -1,49 +0,0 @@
"""admin 埋点健康度:埋点成功率 / 上报成功率 总览 + 趋势 + 下钻(只读)。"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import analytics_health as repo
from app.admin.schemas.analytics_health import (
HealthBreakdownRow,
HealthMetrics,
HealthTrendPoint,
)
router = APIRouter(
prefix="/admin/api/analytics-health",
tags=["admin-analytics-health"],
dependencies=[Depends(get_current_admin)],
)
@router.get("/overview", response_model=HealthMetrics, summary="两段成功率总览")
def overview(
db: AdminDb,
date_from: Annotated[datetime, Query()],
date_to: Annotated[datetime, Query()],
) -> HealthMetrics:
return HealthMetrics(**repo.overview(db, date_from, date_to))
@router.get("/trend", response_model=list[HealthTrendPoint], summary="按北京天趋势")
def trend(
db: AdminDb,
date_from: Annotated[datetime, Query()],
date_to: Annotated[datetime, Query()],
) -> list[HealthTrendPoint]:
return [HealthTrendPoint(**p) for p in repo.trend(db, date_from, date_to)]
@router.get("/breakdown", response_model=list[HealthBreakdownRow], summary="按维度下钻")
def breakdown(
db: AdminDb,
date_from: Annotated[datetime, Query()],
date_to: Annotated[datetime, Query()],
dim: Annotated[str, Query(pattern="^(event|app_ver|oem)$")] = "event",
) -> list[HealthBreakdownRow]:
return [HealthBreakdownRow(**r) for r in repo.breakdown(db, date_from, date_to, dim)]
+1 -36
View File
@@ -18,8 +18,6 @@ from app.admin.schemas.coupon_data import (
CouponDataOut,
CouponDataRow,
CouponDataSummary,
CouponSlotRow,
CouponSlotsOut,
CouponUserRecordsOut,
)
from app.core.rewards import cn_today
@@ -54,10 +52,6 @@ def get_coupon_data(
date_to: Annotated[str | None, Query(description="结束日 北京 YYYY-MM-DD,闭区间,默认=date_from")] = None,
user: Annotated[str | None, Query(description="用户手机号/昵称模糊搜;不传=全部")] = None,
app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod",
status: Annotated[
list[str] | None,
Query(description="领券状态多选 started/completed/failed/abandoned;不传=全部"),
] = None,
granularity: Annotated[
str, Query(description="day=按天 / hour=按小时(北京);区间>1 天建议 day")
] = "day",
@@ -79,7 +73,7 @@ def get_coupon_data(
env = None if app_env == "all" else app_env
result = coupon_data.coupon_data_report(
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
user=user, app_env=env, statuses=status, granularity=granularity,
user=user, app_env=env, granularity=granularity,
limit=limit, offset=offset, sort=sort,
)
return CouponDataOut(
@@ -93,35 +87,6 @@ def get_coupon_data(
)
@router.get(
"/coupons",
response_model=CouponSlotsOut,
summary="按券成功率(coupon_id 粒度;成功/(成功+失败),skipped 排除,设备-天口径)",
)
def get_coupon_slots(
db: AdminDb,
date_from: Annotated[str | None, Query(description="起始日 北京 YYYY-MM-DD,默认今天")] = None,
date_to: Annotated[str | None, Query(description="结束日 北京 YYYY-MM-DD,闭区间,默认=date_from")] = None,
app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod",
) -> CouponSlotsOut:
today = cn_today()
d_from = _parse_day(date_from, field="date_from", default=today)
d_to = _parse_day(date_to, field="date_to", default=d_from)
if d_to < d_from:
raise HTTPException(status_code=422, detail="date_to 不能早于 date_from")
if (d_to - d_from).days + 1 > _MAX_RANGE_DAYS:
raise HTTPException(status_code=422, detail=f"区间最长 {_MAX_RANGE_DAYS}")
env = None if app_env == "all" else app_env
result = coupon_data.coupon_slot_report(
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(), app_env=env
)
return CouponSlotsOut(
date_from=d_from.isoformat(),
date_to=d_to.isoformat(),
items=[CouponSlotRow(**r) for r in result["items"]],
)
@router.get(
"/user-records",
response_model=CouponUserRecordsOut,
-58
View File
@@ -1,58 +0,0 @@
"""admin 华为审核开关:控制新手引导页(快速设置)在华为 ROM 客户端能否被用户关闭。
存在 app_config 表的 huawei_review dict( repositories/app_config.get_huawei_review/set_huawei_review)
客户端经 /api/v1/platform/huawei-review 拉取(且只有华为 ROM 机型会去拉)权限 operator/tech + 审计
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.schemas.huawei_review import HuaweiReviewOut, HuaweiReviewUpdate
from app.models.admin import AdminUser
from app.models.app_config import AppConfig
from app.repositories import app_config
router = APIRouter(
prefix="/admin/api/huawei-review",
tags=["admin-huawei-review"],
dependencies=[Depends(get_current_admin)],
)
def _out(db: Session) -> HuaweiReviewOut:
row = db.get(AppConfig, app_config.HUAWEI_REVIEW_KEY)
return HuaweiReviewOut(
mode=app_config.get_huawei_review(db)["mode"],
updated_at=row.updated_at.isoformat() if row is not None else None,
)
@router.get("", response_model=HuaweiReviewOut, summary="华为审核开关当前状态")
def get_huawei_review(db: AdminDb) -> HuaweiReviewOut:
return _out(db)
@router.patch("", response_model=HuaweiReviewOut, summary="切换华为审核开关(带审计)")
def update_huawei_review(
body: HuaweiReviewUpdate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator", "tech"))],
db: AdminDb,
) -> HuaweiReviewOut:
before = app_config.get_huawei_review(db)["mode"]
try:
app_config.set_huawei_review(db, body.mode, admin_id=admin.id, commit=False)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
write_audit(
db, admin, action="huawei_review.set", target_type="huawei_review",
target_id=app_config.HUAWEI_REVIEW_KEY,
detail={"before": before, "after": body.mode}, ip=get_client_ip(request), commit=False,
)
db.commit()
return _out(db)
-21
View File
@@ -1,21 +0,0 @@
"""埋点健康度 admin 响应 schema。"""
from __future__ import annotations
from pydantic import BaseModel
class HealthMetrics(BaseModel):
attempted: int
drop_capture: int
delivered: int
drop_undelivered: int
track_success_rate: float | None
report_success_rate: float | None
class HealthTrendPoint(HealthMetrics):
day: str
class HealthBreakdownRow(HealthMetrics):
key: str
-5
View File
@@ -36,14 +36,11 @@ class AdminComparisonListItem(BaseModel):
retry_count: int | None = None
input_tokens: int | None = None # Σ usage.prompt_tokens(server 派生)
output_tokens: int | None = None # Σ usage.completion_tokens(server 派生)
# 本次比价 LLM 总成本(元,按当时价冻结);旧记录/未回填为 None → 前端「成本」列回退估算。见 services/llm_cost.py。
llm_cost_yuan: float | None = None
device_model: str | None = None
rom_vendor: str | None = None
rom_name: str | None = None
android_version: str | None = None
app_version: str | None = None
ad_revenue_yuan: float = 0.0 # 本次比价看的信息流广告预估收益(元),queries 瞬态挂 ORM 实例上
created_at: datetime
@@ -74,5 +71,3 @@ class AdminComparisonDetail(AdminComparisonListItem):
# 原始上报全量;「卡在哪一步」从 raw_payload.platform_results[*].status 读
# (store_not_found/items_not_found/below_minimum/unsupported = 卡在 找店/加菜/起送/读价)。
raw_payload: dict | None = None
# 算成本所用单价快照 {mode, prices:{model:{...}}}(llm_cost_yuan 继承自列表项)。见 services/llm_cost.py。
llm_price_snapshot: dict | None = None
-32
View File
@@ -19,16 +19,6 @@ class CouponDataSummary(BaseModel):
p50_ms: int | None = Field(None, description="耗时 50 分位(ms,中位数)")
p95_ms: int | None = Field(None, description="耗时 95 分位(ms)")
p99_ms: int | None = Field(None, description="耗时 99 分位(ms)")
# 平台粒度成功率(见 docs/guides/领券成功率指标-设计与埋点.md):基数含全部 session。
full_success_count: int = Field(0, description="整单成功数(勾选平台全部领到的 session 数)")
full_success_rate: float | None = Field(None, description="整单成功率②=整单成功数/发起数;无数据为空")
point_success_count: int = Field(0, description="成功平台点位数(Σ 每次成功的平台数)")
point_total_count: int = Field(0, description="总平台点位数(Σ 每次勾选平台数;空勾选=全领三档)")
point_success_rate: float | None = Field(None, description="点位成功率③=成功点位/总点位;无数据为空")
per_platform: dict[str, float | None] = Field(
default_factory=dict,
description="分平台点位成功率 {平台id: rate|None};恒含美团/淘宝/京东三档,区间内无人勾选的平台为 None",
)
class CouponDataDaily(BaseModel):
@@ -70,9 +60,6 @@ class CouponDataRow(BaseModel):
started_at: datetime = Field(..., description="发起时刻(明细「时间」列)")
claimed_count: int | None = None
trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id")
ad_revenue_yuan: float = Field(
0.0, description="本次领券看的信息流广告预估收益(元);按 trace_id 聚合 ad_ecpm_record"
)
class CouponDataOut(BaseModel):
@@ -94,22 +81,3 @@ class CouponUserRecordsOut(BaseModel):
items: list[CouponDataRow]
total: int
class CouponSlotRow(BaseModel):
"""按券成功率一行(§13):粒度=设备-天;成功率=成功/(成功+失败),skipped 排除。"""
coupon_id: str
coupon_name: str | None = None
platform: str | None = Field(None, description="美团/淘宝/京东 平台 id;无法识别为空")
tried: int = Field(..., description="尝试数(success+already_claimed+failed 的设备-天数)")
succeeded: int = Field(..., description="成功数(success+already_claimed)")
success_rate: float | None = Field(None, description="成功率=成功/尝试")
class CouponSlotsOut(BaseModel):
"""按券成功率表响应(§13)。"""
date_from: str
date_to: str
items: list[CouponSlotRow]
+1 -6
View File
@@ -53,16 +53,11 @@ class DashboardPeriodUsers(BaseModel):
class DashboardPeriodComparison(BaseModel):
total: int
completed: int
cancelled: int
success: int
success_rate: float | None = None
success_rate: float
ordered: int
average_duration_ms: int | None = None
median_duration_ms: int | None = None
p95_duration_ms: int | None = None
average_saved_cents: int | None = None
token_cost_total_yuan: float = 0.0
class DashboardPeriodCoupon(BaseModel):
-19
View File
@@ -1,19 +0,0 @@
"""admin 华为审核开关 schemas(两态:default / review)。"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel
class HuaweiReviewOut(BaseModel):
"""当前开关状态。updated_at 给后台展示「谁什么时候切的」提供时间锚点。"""
mode: Literal["default", "review"]
updated_at: str | None = None # ISO 字符串;从未切过为 None
class HuaweiReviewUpdate(BaseModel):
"""切换开关。整值覆盖,不做部分更新(就一个字段)。"""
mode: Literal["default", "review"]
-9
View File
@@ -280,21 +280,12 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
Bearer 鉴权,user_id 取自 JWT(不信 body)best-effort:落库即 ok,客户端 fire-and-forget,
丢一两条不影响业务(穿山甲后台报表是结算权威)eCPM 与发奖(S2S)是两条独立流,不逐条关联
"""
attributed_trace_id = crud_ecpm.attributable_trace_id(
db, feed_scene=payload.feed_scene, trace_id=payload.trace_id
)
if payload.trace_id and attributed_trace_id is None:
logger.info(
"detach late coupon ad impression from failed trace user_id=%d trace=%s session=%s",
user.id, payload.trace_id, payload.ad_session_id,
)
crud_ecpm.create_ecpm_record(
db, user.id,
ad_type=payload.ad_type, ecpm_raw=payload.ecpm,
ad_session_id=payload.ad_session_id,
adn=payload.adn, slot_id=payload.slot_id,
feed_scene=payload.feed_scene,
trace_id=attributed_trace_id,
app_env=payload.app_env, our_code_id=payload.our_code_id,
)
logger.info(
+1 -16
View File
@@ -6,18 +6,13 @@ POST /api/v1/analytics/events — 批量接收新手引导(及后续)埋点,appe
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Request
from app.api.deps import DbSession
from app.repositories import analytics as analytics_repo
from app.repositories import analytics_selfstat as selfstat_repo
from app.schemas.analytics import AnalyticsBatchIn, AnalyticsIngestOut
from app.schemas.analytics_selfstat import SelfStatBatchIn, SelfStatIngestOut
router = APIRouter(prefix="/api/v1/analytics", tags=["analytics"])
logger = logging.getLogger("shagua.analytics")
def _client_ip(request: Request) -> str:
@@ -34,13 +29,3 @@ def ingest_events(
) -> AnalyticsIngestOut:
n = analytics_repo.record_batch(db, batch, client_ip=_client_ip(request))
return AnalyticsIngestOut(received=n)
@router.post("/selfstat", response_model=SelfStatIngestOut, summary="上报自报计数快照")
def ingest_selfstat(batch: SelfStatBatchIn, db: DbSession) -> SelfStatIngestOut:
try:
snap_id = selfstat_repo.record_selfstat(db, batch)
except Exception: # noqa: BLE001 — 计数链路要稳,落库失败不裸奔 500,记日志回明确错误
logger.exception("selfstat ingest failed device=%s epoch=%s", batch.device_id, batch.epoch_id)
raise HTTPException(status_code=503, detail="selfstat ingest failed") from None
return SelfStatIngestOut(snapshot_id=snap_id)
+16 -299
View File
@@ -12,36 +12,19 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from sqlalchemy.exc import IntegrityError
from fastapi import APIRouter, HTTPException, Request, status
from app.api.deps import CurrentUser, DbSession
from app.core import test_account
from app.core.ratelimit import (
RateLimitRule,
check_rate_limits,
enforce_rate_limit,
record_rate_limits,
)
from app.core.security import (
TokenError,
create_bind_ticket,
create_conflict_ticket,
decode_bind_ticket,
decode_conflict_ticket,
decode_token,
issue_token_pair,
)
from app.integrations import wxpay
from app.core.ratelimit import enforce_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
from app.repositories import onboarding as onboarding_repo
from app.repositories import phone_rebind as rebind_repo
from app.repositories import user as user_repo
from app.schemas.auth import (
JverifyLoginRequest,
LogoutResponse,
OccupiedAccountInfo,
RefreshRequest,
SmsLoginRequest,
SmsSendRequest,
@@ -49,13 +32,6 @@ from app.schemas.auth import (
TokenPair,
TokenWithUser,
UserOut,
WechatBindPhoneJverifyRequest,
WechatBindPhoneSmsRequest,
WechatBindResultResponse,
WechatConflictContinueRequest,
WechatConflictRebindRequest,
WechatLoginRequest,
WechatLoginResponse,
)
logger = logging.getLogger("shagua.auth")
@@ -64,10 +40,9 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
# 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。
SMS_LOGIN_MAX_PER_HOUR = 5
# 发码防刷(同一设备 device_id + 同一 IP,**只按成功发码计数**;被单号 60s 冷却挡下的重发不占额度):
# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。
# 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5 # 每小时上限
SMS_SEND_MAX_PER_DAY_PER_DEVICE = 20 # 每天上限(再叠一层日封顶,挡低频长时间轰炸)
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5
def _login_response(
@@ -124,26 +99,23 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
logger.info("test_account sms_send short-circuit (不真发)")
return SmsSendResponse(sent=True, mock=True, cooldown_sec=0)
# 发码防刷:同一设备(device_id) + 同一 IP,每小时 / 每天两道闸,**均只按成功发码计数**
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,挡短信轰炸/烧钱。
# 关键:被单号 60s 冷却挡下的重发是「没真发、没烧钱」→ 不该占额度。故 check(先判)放在真发之前
# (超限直接 429、不真发),record(计数)只在 send_code 成功后调 —— 冷却/供应商失败抛 429 时直接返回、不计数。
send_rules = [
RateLimitRule("sms-send-device", SMS_SEND_MAX_PER_HOUR_PER_DEVICE, 3600,
"操作过于频繁,请稍后再试"),
RateLimitRule("sms-send-device-daily", SMS_SEND_MAX_PER_DAY_PER_DEVICE, 86400,
"今日验证码发送次数过多,请明天再试"),
]
check_rate_limits(request, subject=req.device_id, rules=send_rules)
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,
# 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。
enforce_rate_limit(
request,
scope="sms-send-device",
subject=req.device_id,
limit=SMS_SEND_MAX_PER_HOUR_PER_DEVICE,
window_sec=3600,
detail="操作过于频繁,请稍后再试",
)
try:
cooldown = send_code(req.phone)
except SmsError as e:
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
# 发码成功 → 两道闸各 +1(被单号冷却挡下的重发走不到这里,故不占额度)
record_rate_limits(request, subject=req.device_id, rules=send_rules)
from app.core.config import settings # 局部 import 避免循环
return SmsSendResponse(sent=True, mock=settings.SMS_MOCK, cooldown_sec=cooldown)
@@ -194,261 +166,6 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
return _login_response(user, onboarding_completed=completed)
# ===================== 微信登录 =====================
@router.post(
"/wechat-login",
response_model=WechatLoginResponse,
summary="微信登录(openid 命中即登入,否则发绑号令牌)",
)
def wechat_login(req: WechatLoginRequest, db: DbSession) -> WechatLoginResponse:
from app.core.config import settings # 局部 import,避免循环
# 微信登录只需 code→openid(sns/oauth2),不需要商户转账证书;故只校验 APP_ID/SECRET。
if not (settings.WECHAT_APP_ID and settings.WECHAT_APP_SECRET):
raise HTTPException(status_code=503, detail="wechat login not configured")
try:
info = wxpay.code_to_userinfo(req.code) # {openid, nickname, avatar_url, raw};失败抛 ValueError
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
openid = info["openid"]
user = user_repo.get_user_by_wechat_openid(db, openid)
if user is not None:
# openid 命中 → 直接登入(绝不套用提现 bind-wechat 的"撞号即 409"逻辑)
if user.status != "active":
raise HTTPException(status_code=403, detail="account disabled")
user_repo.touch_last_login(db, user)
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
logger.info("wechat_login hit user_id=%d openid=%s*** onboarded=%s", user.id, openid[:6], completed)
return WechatLoginResponse(
status="logged_in",
token=_login_response(user, onboarding_completed=completed),
)
# 未命中 → 签发短时 bind_ticket,进手机号绑定流程(账号此刻还不建)
ticket = create_bind_ticket(
openid=openid,
wechat_nickname=info["nickname"],
wechat_avatar_url=info["avatar_url"],
)
logger.info("wechat_login new openid=%s*** issue bind_ticket", openid[:6])
return WechatLoginResponse(
status="need_bind_phone",
bind_ticket=ticket,
wechat_nickname=info["nickname"],
wechat_avatar_url=info["avatar_url"],
)
def _finish_wechat_bind(
db,
*,
openid: str,
wechat_nickname: str | None,
wechat_avatar_url: str | None,
phone: str,
device_id: str,
) -> WechatBindResultResponse:
"""绑手机建号的公共尾段:手机号被占用 → 返回 phone_occupied(M2 处理 3 选 1);
未占用 新建微信账号(channel=wechat,昵称头像取微信) token 登入"""
existing = user_repo.get_user_by_phone(db, phone)
if existing is not None:
from app.core.config import settings # 局部 import,避免循环
ticket = create_conflict_ticket(
openid=openid,
wechat_nickname=wechat_nickname,
wechat_avatar_url=wechat_avatar_url,
phone=phone,
)
blocked = rebind_repo.rebound_within_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS)
logger.info(
"wechat bind phone occupied phone=%s by user_id=%d has_wechat=%s",
mask_phone(phone), existing.id, bool(existing.wechat_openid),
)
return WechatBindResultResponse(
status="phone_occupied",
occupied_account=OccupiedAccountInfo(
nickname=existing.nickname,
avatar_url=existing.avatar_url,
created_at=existing.created_at,
has_wechat=bool(existing.wechat_openid),
),
conflict_ticket=ticket,
rebind_available=not blocked,
rebind_blocked_days=(
rebind_repo.remaining_block_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS)
if blocked else 0
),
)
user = user_repo.create_wechat_user(
db,
phone=phone,
openid=openid,
wechat_nickname=wechat_nickname,
wechat_avatar_url=wechat_avatar_url,
)
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=device_id)
logger.info("wechat bind ok user_id=%d phone=%s openid=%s*** onboarded=%s",
user.id, mask_phone(phone), openid[:6], completed)
return WechatBindResultResponse(
status="logged_in",
token=_login_response(user, onboarding_completed=completed),
)
@router.post(
"/wechat/bind-phone/sms",
response_model=WechatBindResultResponse,
summary="微信登录·其他手机号(短信)绑定",
)
def wechat_bind_phone_sms(
req: WechatBindPhoneSmsRequest, request: Request, db: DbSession
) -> WechatBindResultResponse:
try:
claims = decode_bind_ticket(req.bind_ticket)
except TokenError as e:
raise HTTPException(status_code=401, detail="授权已过期,请重新用微信登录") from e
# 防刷:同 sms/login,按 设备+IP 每小时限流(放在验证码校验之前,失败也计数)
enforce_rate_limit(
request,
scope="wechat-bind-sms-device",
subject=req.device_id,
limit=SMS_LOGIN_MAX_PER_HOUR,
window_sec=3600,
detail="登录尝试过于频繁,请稍后再试",
)
if not verify_code(req.phone, req.code):
raise HTTPException(status_code=400, detail="invalid sms code")
return _finish_wechat_bind(
db,
openid=claims["openid"],
wechat_nickname=claims["wnk"],
wechat_avatar_url=claims["wav"],
phone=req.phone,
device_id=req.device_id,
)
@router.post(
"/wechat/bind-phone/jverify",
response_model=WechatBindResultResponse,
summary="微信登录·本机号(极光)绑定",
)
def wechat_bind_phone_jverify(
req: WechatBindPhoneJverifyRequest, db: DbSession
) -> WechatBindResultResponse:
try:
claims = decode_bind_ticket(req.bind_ticket)
except TokenError as e:
raise HTTPException(status_code=401, detail="授权已过期,请重新用微信登录") from e
try:
phone = verify_and_get_phone(req.login_token)
except JiguangError as e:
logger.error("[JG] verify+decrypt failed: %s", e, exc_info=True)
raise HTTPException(status_code=502, detail=f"jiguang verify failed: {e}") from e
return _finish_wechat_bind(
db,
openid=claims["openid"],
wechat_nickname=claims["wnk"],
wechat_avatar_url=claims["wav"],
phone=phone,
device_id=req.device_id,
)
# ===================== 微信占用冲突(M2) =====================
@router.post(
"/wechat/conflict/continue",
response_model=WechatBindResultResponse,
summary="微信占用冲突·继续绑定(登录老账号,能绑就绑)",
)
def wechat_conflict_continue(
req: WechatConflictContinueRequest, request: Request, db: DbSession
) -> WechatBindResultResponse:
try:
claims = decode_conflict_ticket(req.conflict_ticket)
except TokenError as e:
raise HTTPException(status_code=401, detail="操作超时,请重新用微信登录") from e
enforce_rate_limit(
request, scope="wechat-conflict-device", subject=req.device_id,
limit=SMS_LOGIN_MAX_PER_HOUR, window_sec=3600, detail="操作过于频繁,请稍后再试",
)
user = user_repo.get_user_by_phone(db, claims["phone"])
if user is None:
# P 期间被腾空(老账号改号/注销)→ 前提已变,让前端重走
raise HTTPException(status_code=409, detail="账号状态已变化,请重新登录")
if user.status != "active":
raise HTTPException(status_code=403, detail="account disabled")
if user.wechat_openid is None:
try:
user_repo.attach_wechat_to_user(
db, user, openid=claims["openid"],
wechat_nickname=claims["wnk"], wechat_avatar_url=claims["wav"],
)
except IntegrityError:
db.rollback() # openid 被别处绑走 → 只登入不绑
user_repo.touch_last_login(db, user)
else:
user_repo.touch_last_login(db, user) # X 已绑别的微信 → 只登入,丢弃本次 openid
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
logger.info("wechat conflict continue user_id=%d openid=%s***", user.id, claims["openid"][:6])
return WechatBindResultResponse(
status="logged_in",
token=_login_response(user, onboarding_completed=completed),
)
@router.post(
"/wechat/conflict/rebind",
response_model=WechatBindResultResponse,
summary="微信占用冲突·换绑(注销老账号+用该号重建全新账号)",
)
def wechat_conflict_rebind(
req: WechatConflictRebindRequest, request: Request, db: DbSession
) -> WechatBindResultResponse:
from app.core.config import settings # 局部 import,避免循环
try:
claims = decode_conflict_ticket(req.conflict_ticket)
except TokenError as e:
raise HTTPException(status_code=401, detail="操作超时,请重新用微信登录") from e
enforce_rate_limit(
request, scope="wechat-conflict-device", subject=req.device_id,
limit=SMS_LOGIN_MAX_PER_HOUR, window_sec=3600, detail="操作过于频繁,请稍后再试",
)
phone = claims["phone"]
if rebind_repo.rebound_within_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS):
days = rebind_repo.remaining_block_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS)
raise HTTPException(status_code=409, detail=f"该手机号 {days} 天内已换绑过,暂不能再次换绑")
user = user_repo.rebind_account(
db, phone=phone, openid=claims["openid"],
wechat_nickname=claims["wnk"], wechat_avatar_url=claims["wav"],
)
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
logger.info("wechat conflict rebind new_user_id=%d phone=%s openid=%s***",
user.id, mask_phone(phone), claims["openid"][:6])
return WechatBindResultResponse(
status="logged_in",
token=_login_response(user, onboarding_completed=completed),
)
# ===================== Refresh =====================
@router.post("/refresh", response_model=TokenPair, summary="用 refresh_token 换新 token 对")
-3
View File
@@ -27,7 +27,6 @@ from app.schemas.compare_record import (
ComparisonRecordOut,
ComparisonRecordPage,
)
from app.services.llm_cost import compute_llm_cost, get_llm_prices
from app.services.pricebot_llm_calls import fetch_llm_calls
logger = logging.getLogger("shagua.compare_record")
@@ -82,8 +81,6 @@ def _backfill_llm_calls(record_id: int, trace_id: str) -> None:
# error 的调用 usage 可能为 None,or {} 兜底)
rec.input_tokens = sum((c.get("usage") or {}).get("prompt_tokens") or 0 for c in calls)
rec.output_tokens = sum((c.get("usage") or {}).get("completion_tokens") or 0 for c in calls)
# 本次比价 LLM 成本(元)+ 当时单价快照:按 app_config 现价逐模型算好冻结(services/llm_cost.py)。
rec.llm_cost_yuan, rec.llm_price_snapshot = compute_llm_cost(calls, get_llm_prices(db))
db.commit()
logger.info(
"backfill llm_calls trace=%s n=%d in_tok=%d out_tok=%d",
+1 -10
View File
@@ -81,16 +81,7 @@ def _record_claims_blocking(
device_id: str, user_id: int | None, trace_id: str | None, results: list[dict]
) -> None:
with SessionLocal() as db:
# 取本次 session 环境,给 coupon_claim_record 打 app_env 标(每券成功率表按它过滤;设计 §13)。
app_env = coupon_repo.session_app_env(db, trace_id)
coupon_repo.record_claims(db, device_id, user_id, trace_id, results, app_env=app_env)
# 顺带把本帧「成功平台」并入 coupon_session.platform_success(admin 领券数据 ②整单/③点位成功率;
# 设计 route B,见 docs/guides/领券成功率指标-设计与埋点.md)。复用同一 SessionLocal、紧接 record_claims,
# 不新增连接;并集幂等(无新平台不写),trace_id 缺失或 session 行未落库则跳过。
if trace_id:
coupon_repo.merge_session_platform_success(
db, trace_id, coupon_repo.succeeded_platforms(results)
)
coupon_repo.record_claims(db, device_id, user_id, trace_id, results)
def _mark_completed_blocking(
-10
View File
@@ -20,7 +20,6 @@ from app.schemas.platform import (
AdConfigPublicOut,
AppFlagsOut,
AppVersionOut,
HuaweiReviewOut,
PlatformStatsOut,
SavingsFeedItem,
SavingsFeedOut,
@@ -73,15 +72,6 @@ def ad_config(db: DbSession) -> AdConfigPublicOut:
)
@router.get("/huawei-review", response_model=HuaweiReviewOut, summary="华为审核开关(不鉴权)")
def huawei_review(db: DbSession) -> HuaweiReviewOut:
"""客户端进新手引导前拉一次,决定「快速设置」权限步左上角要不要给退出按钮。
不鉴权:引导页在登录之前就展示,此时必然没有 token空库回退 default(=不给退出按钮,维持现状)
只有华为 ROM 客户端会来拉(荣耀 MagicOS 不拉),故这里不做机型判断,由客户端自己 gate"""
mode = app_config.get_huawei_review(db)["mode"]
return HuaweiReviewOut(mode=mode, onboarding_closable=(mode == "review"))
@router.get("/app-version", response_model=AppVersionOut, summary="最新 App 版本(OTA 检查更新,不鉴权)")
def app_version(db: DbSession) -> AppVersionOut:
"""客户端启动 / 手动检查更新时拉取。不鉴权:版本信息非敏感,且检查更新可能在登录前。
+2 -14
View File
@@ -43,7 +43,6 @@ from app.schemas.welfare import (
WithdrawRequest,
WithdrawResultOut,
WithdrawStatusOut,
WithdrawTierOut,
)
logger = logging.getLogger("shagua.wallet")
@@ -174,15 +173,8 @@ def unbind_wechat(
return UnbindWechatResultOut(bound=False)
@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关/档位")
def withdraw_info(
user: CurrentUser,
db: DbSession,
source: str = Query(
"coin_cash",
description="提现账户:coin_cash(福利页,下发 tiers 档位) / invite_cash(邀请页,tiers 为空走旧逻辑)",
),
) -> WithdrawInfoOut:
@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关")
def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut:
u = db.get(User, user.id)
# 顺带同步免确认授权状态(捕获首单确认后已生效的授权 pending→active),让开关展示实时
auth = crud_wallet.sync_transfer_auth(db, user.id)
@@ -193,7 +185,6 @@ def withdraw_info(
wechat_nickname=u.wechat_nickname if u else None,
wechat_avatar_url=u.wechat_avatar_url if u else None,
transfer_auth_enabled=bool(auth and auth.state == "active"),
tiers=[WithdrawTierOut(**t) for t in crud_wallet.withdraw_tier_states(db, user.id, source)],
)
@@ -227,9 +218,6 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
status_code=status.HTTP_409_CONFLICT,
detail="已有提现申请正在审核或打款中,请处理完成后再申请",
) from e
except crud_wallet.WithdrawTierUnavailableError as e:
# 福利页档位闸(7-9):次数满/已选其他额度。正常客户端已按 tiers 预拦,此处兜底防绕过。
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="今日额度已达上限") from e
except crud_wallet.InsufficientCashError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="现金余额不足") from e
-56
View File
@@ -44,11 +44,6 @@ class Settings(BaseSettings):
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 120
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30
# 微信登录未命中 openid 时签发的"待绑手机"令牌有效期(JWT_SECRET_KEY 签名,typ=wechat_bind;
# 见 security.create_bind_ticket)。需覆盖"授权→输手机号→收短信→输验证码"整个绑定流程。
WECHAT_BIND_TICKET_EXPIRE_MINUTES: int = 10
# 一个手机号 30 天内最多换绑一次(微信占用冲突页的"换绑"动作)。见 phone_rebind_log。
PHONE_REBIND_LIMIT_DAYS: int = 30
# ===== Admin 后台 =====
# admin 用独立 JWT secret(≠ JWT_SECRET_KEY),App 用户 token 无法越权访问后台。
@@ -86,7 +81,6 @@ 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 包全流程联调用)=====
@@ -112,9 +106,6 @@ class Settings(BaseSettings):
# 美团调用走的代理。本机开发直连美团会 SSL EOF,需填 http://127.0.0.1:7897;
# 线上国内服务器留空(=直连)。见 .env.example 与 integrations/meituan.py。
MT_CPS_PROXY: str = ""
# 本地开发:开启后 /feed 接口直接返回 mock 数据,不调美团 API、不查离线库,
# 方便前端联调 feed 卡片样式、分页、距离排序等 UI。生产必须 false。
MT_CPS_MOCK_FEED: bool = True
@property
def mt_cps_configured(self) -> bool:
@@ -178,15 +169,6 @@ class Settings(BaseSettings):
# 进程内自动兑换 worker 的检查间隔(秒):每隔这么久醒一次,跨过北京 0 点就跑一轮。
# 默认 600s=10min,即 0 点后最多 10 分钟内兑完(客户端文案已注明「可能存在延迟」)。
AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600
# === 15 天不活跃清零(app.core.inactivity_reset_worker,worker 常驻)===
# ENABLED 只决定是否**真清**:false(默认)= 只记审计名单、不动钱(dry-run,灰度看名单);
# true = 真清金币 + 折算现金(邀请金不清)。看准名单后再置 true。
INACTIVITY_RESET_ENABLED: bool = False
INACTIVITY_RESET_DAYS: int = 15 # 不活跃阈值(天),第 (N+1) 日 0 点清
INACTIVITY_WARN_DAYS_BEFORE: str = "7,2" # 清零前几天各推一次;""=不推。逗号分隔
INACTIVITY_RESET_RUN_HOUR: int = 3 # 北京时间每日执行点(0-23)
INACTIVITY_NOTIFY_CHANNEL: str = "log" # log(占位) / jpush / sms
INACTIVITY_RESET_CHECK_INTERVAL_SEC: int = 1800 # worker 唤醒间隔(秒)
# 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。
# 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容
# (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
@@ -207,19 +189,6 @@ class Settings(BaseSettings):
"""免确认收款授权可用 = 微信支付凭证齐全 + 授权回调地址已配。"""
return bool(self.wxpay_configured and self.WXPAY_AUTH_NOTIFY_URL)
@property
def inactivity_warn_stages(self) -> list[int]:
"""解析 INACTIVITY_WARN_DAYS_BEFORE → 降序去重的提前天数列表。
丢弃非数字 / <=0 / >=RESET_DAYS 的项(空串 空列表 = 不推)"""
out: list[int] = []
for part in (self.INACTIVITY_WARN_DAYS_BEFORE or "").split(","):
part = part.strip()
if part.isdigit():
v = int(part)
if 0 < v < self.INACTIVITY_RESET_DAYS and v not in out:
out.append(v)
return sorted(out, reverse=True)
# ===== 穿山甲激励视频(服务端发奖回调)=====
# 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。
# 穿山甲后台配置的"奖励校验密钥"(m-key),验签用。每个 GroMore 广告位 m-key 不同(后台各自
@@ -347,31 +316,6 @@ class Settings(BaseSettings):
return []
return [o.strip() for o in self.CORS_ALLOW_ORIGINS.split(",") if o.strip()]
# ===== 可观测(OpenObserve 接口指标)=====
# 采集每个接口的 QPS + 耗时 + 错误率,批量直采到 OpenObserve(本地 Docker)。
# 默认关(prod 安全):未开启 → 中间件透传、worker 不启动,整套 no-op。
# 开启需 ENABLED=true 且 ENDPOINT/USER/PASSWORD 齐全(见 observe_configured)。
OBSERVE_ENABLED: bool = False
OBSERVE_ENDPOINT: str = "http://localhost:5080" # OpenObserve base URL
OBSERVE_ORG: str = "default" # 组织名
OBSERVE_STREAM: str = "app_requests" # stream 名(首次上报自动建)
OBSERVE_USER: str = "" # Basic auth 邮箱
OBSERVE_PASSWORD: str = "" # Basic auth 密码/token
OBSERVE_FLUSH_INTERVAL_SEC: float = 5.0 # worker 最长攒批间隔
OBSERVE_BATCH_MAX: int = 200 # 单批最大事件数
OBSERVE_QUEUE_MAX: int = 10000 # 有界队列上限,满则丢
OBSERVE_TIMEOUT_SEC: float = 5.0 # 上报 HTTP 超时
@property
def observe_configured(self) -> bool:
"""观测上报可用 = 总开关开 且 endpoint/账号/密码齐全(缺则整套 no-op)。"""
return bool(
self.OBSERVE_ENABLED
and self.OBSERVE_ENDPOINT
and self.OBSERVE_USER
and self.OBSERVE_PASSWORD
)
@property
def is_prod(self) -> bool:
return self.APP_ENV == "prod"
-15
View File
@@ -96,19 +96,4 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
"group": "首页轮播", "type": "enum", "hidden": True,
"help": "mixed=真实优先+种子补位(默认);real=只用真实比价记录;seed=只用种子/合成(演示)。",
},
# 比价 LLM 调用成本计价。值是嵌套 JSON(非 str→int),借 dict_str_int 类型在配置页走原始 JSON
# 编辑框;set_value 不校验类型,嵌套 JSON 照存。
"llm_token_price": {
"default": {
"per_model": {"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0}},
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0},
"currency": "CNY", "unit": "per_1m_tokens",
},
"label": "LLM 模型单价(元/百万 token)",
"group": "LLM 成本", "type": "dict_str_int",
"help": (
"比价 LLM 调用成本计价。JSON:per_model 按模型配 input/output 单价(元/1M token),"
"default 兜底未登记的模型。改价只影响之后回填的新记录,历史记录用当时价格快照。"
),
},
}
-145
View File
@@ -1,145 +0,0 @@
"""15 天不活跃清零的进程内每日任务。
仿 daily_exchange_worker:App 启动自带, `INACTIVITY_RESET_CHECK_INTERVAL_SEC` 醒一次,
跨进北京新的一天且到达 `INACTIVITY_RESET_RUN_HOUR`(默认 3 )后跑一轮 `run_once`(预警 + 清零)
健壮性:
- **逐用户幂等**:清完余额=0 次日不再匹配;预警按 streak 去重启动补跑 / 多次唤醒 / 重启都安全
- **同机多进程互斥**:文件锁保证多 worker 只有一个实际跑
- **常驻 + dry-run 默认**:worker 一直跑;INACTIVITY_RESET_ENABLED=false(默认)只记审计名单
不动钱(dry-run 灰度看名单),=true 才真清
这是不可逆批量资金操作(清空金币 + 折算现金,**邀请现金不清**)口径见
app.repositories.inactivity / app.repositories.activity
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import time
from collections.abc import Iterator
from datetime import date, datetime
from pathlib import Path
from sqlalchemy.exc import SQLAlchemyError
from app.core.config import settings
from app.core.rewards import CN_TZ, cn_today
from app.db.session import SessionLocal
from app.integrations.notifier import get_notifier
from app.repositories import inactivity as inactivity_repo
logger = logging.getLogger("shagua.inactivity")
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "inactivity_reset.lock"
def _cn_today() -> date:
return cn_today()
def _touch_lock() -> None:
with contextlib.suppress(FileNotFoundError):
os.utime(_LOCK_PATH, None)
@contextlib.contextmanager
def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]:
"""同机多进程保护:同一时间只允许一个清零 worker 运行。"""
_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
fd: int | None = None
try:
try:
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
try:
age = time.time() - _LOCK_PATH.stat().st_mtime
except FileNotFoundError:
age = stale_after_sec + 1
if age > stale_after_sec:
with contextlib.suppress(FileNotFoundError):
_LOCK_PATH.unlink()
try:
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
fd = None
if fd is None:
yield False
return
os.write(fd, f"pid={os.getpid()} started_at={int(time.time())}\n".encode("ascii"))
yield True
finally:
if fd is not None:
os.close(fd)
with contextlib.suppress(FileNotFoundError):
_LOCK_PATH.unlink()
def _run_once_entry() -> dict:
"""跑一轮(预警 + 清零)。独立开 Session。"""
notifier = get_notifier(settings.INACTIVITY_NOTIFY_CHANNEL)
with SessionLocal() as db:
return inactivity_repo.run_once(
db,
notifier=notifier,
reset_days=settings.INACTIVITY_RESET_DAYS,
warn_stages=settings.inactivity_warn_stages,
today=_cn_today(),
dry_run=not settings.INACTIVITY_RESET_ENABLED, # ENABLED=false → 只记审计名单、不清
)
async def _run_loop() -> None:
interval = max(60, int(settings.INACTIVITY_RESET_CHECK_INTERVAL_SEC))
lock_stale_after = max(interval * 3, 1800)
with _single_instance_lock(lock_stale_after) as lock_acquired:
if not lock_acquired:
logger.warning("inactivity reset skipped: another worker owns lock")
return
await _run_locked_loop(interval)
async def _run_locked_loop(interval: int) -> None:
logger.info(
"inactivity reset worker started interval=%ss run_hour=%s mode=%s",
interval,
settings.INACTIVITY_RESET_RUN_HOUR,
"clear" if settings.INACTIVITY_RESET_ENABLED else "dry-run(audit-only)",
)
# 本进程上次跑过的北京日;None=尚未跑过本进程(当天到点即补)。
last_run: date | None = None
try:
while True:
try:
_touch_lock()
today = _cn_today()
hour = datetime.now(CN_TZ).hour
if last_run != today and hour >= int(settings.INACTIVITY_RESET_RUN_HOUR):
result = await asyncio.to_thread(_run_once_entry)
last_run = today
logger.info("inactivity reset done date=%s result=%s", today, result)
except SQLAlchemyError:
logger.exception("inactivity reset db error")
except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出
logger.exception("inactivity reset unexpected error")
await asyncio.sleep(interval)
except asyncio.CancelledError:
logger.info("inactivity reset worker stopped")
raise
def start_inactivity_reset_worker() -> asyncio.Task | None:
# worker 常驻(不再有"完全关"档);INACTIVITY_RESET_ENABLED 只决定是否**真清**:
# false(默认)= 只记审计名单(dry-run,不动钱),true = 真清金币+现金。
return asyncio.create_task(_run_loop(), name="inactivity-reset")
async def stop_inactivity_reset_worker(task: asyncio.Task | None) -> None:
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
-110
View File
@@ -1,110 +0,0 @@
"""接口指标埋点:有界事件队列 + 纯 ASGI 中间件。
每个 HTTP 请求测总耗时抓路由模板 + 状态码,非阻塞塞进有界队列; observe_worker
后台批量上报到 OpenObserve请求路径上无任何 I/O未配置观测时中间件直接透传
"""
from __future__ import annotations
import asyncio
import os
import time
from starlette.routing import Match
from app.core.config import settings
# 不采集的路径(纯噪音):健康检查。
_SKIP_PATHS = frozenset({"/health"})
# 未匹配路由(404/扫描器)归一到此,防维度爆炸。
_UNMATCHED = "__unmatched__"
# service 字段:与 logging.py 同源(LOG_SERVICE_NAME),默认 app-server。
_SERVICE = os.getenv("LOG_SERVICE_NAME", "app-server")
# 有界事件队列(懒创建,见 get_queue):首次取用时在运行中的 loop 里建,避免 import 期
# 无 loop 的边角问题;put_nowait/get_nowait 不需运行中的 loop → 可在无 loop 下测试。
_queue: asyncio.Queue[dict] | None = None
# 队列满时的丢弃计数,worker 定期取出打日志。
_dropped = 0
def get_queue() -> asyncio.Queue[dict]:
"""返回全局有界事件队列(懒创建)。测试可 monkeypatch 模块级 _queue 换成小队列。"""
global _queue
if _queue is None:
_queue = asyncio.Queue(maxsize=settings.OBSERVE_QUEUE_MAX)
return _queue
def take_dropped() -> int:
"""取出并清零累计丢弃数(供 worker 打点)。"""
global _dropped
n, _dropped = _dropped, 0
return n
def record_event(event: dict) -> None:
"""非阻塞入队;队列满则丢弃当前事件并计数。永不抛异常、永不阻塞请求。"""
global _dropped
try:
get_queue().put_nowait(event)
except asyncio.QueueFull:
_dropped += 1
def _resolve_route(scope) -> str:
"""从 scope 取路由模板(如 /things/{tid})。优先 scope['route'](现代 Starlette
路由后写入);取不到则手动匹配一次(老版本兜底);仍无 __unmatched__(404/扫描器)"""
route = scope.get("route")
path = getattr(route, "path", None)
if path:
return path
app_ = scope.get("app")
router = getattr(app_, "router", None)
for candidate in getattr(router, "routes", []):
try:
match, _ = candidate.matches(scope)
except Exception: # noqa: BLE001 - 匹配兜底,任一路由异常不影响整体
continue
if match == Match.FULL and getattr(candidate, "path", None):
return candidate.path
return _UNMATCHED
class RequestMetricsMiddleware:
"""纯 ASGI 中间件:测每个 http 请求耗时,记 method/route/status/duration。
放在最外层(main.py CORS 之后 add),测到含 CORS 的完整耗时未配置观测 透传
"""
def __init__(self, app) -> None:
self.app = app
async def __call__(self, scope, receive, send) -> None:
if scope["type"] != "http" or not settings.observe_configured:
await self.app(scope, receive, send)
return
if scope.get("path") in _SKIP_PATHS:
await self.app(scope, receive, send)
return
start = time.perf_counter()
status_holder = {"status": 500} # 下游异常未产出 response 时兜底 500
async def send_wrapper(message) -> None:
if message["type"] == "http.response.start":
status_holder["status"] = message["status"]
await send(message)
try:
await self.app(scope, receive, send_wrapper)
finally:
duration_ms = (time.perf_counter() - start) * 1000.0
record_event({
"_timestamp": int(time.time() * 1_000_000), # µs,OpenObserve 时间列
"service": _SERVICE,
"env": settings.APP_ENV,
"method": scope.get("method", ""),
"route": _resolve_route(scope),
"status": status_holder["status"],
"duration_ms": round(duration_ms, 3),
})
-128
View File
@@ -1,128 +0,0 @@
"""接口指标后台上报 worker:批量 drain 事件队列 → POST 到 OpenObserve。
对齐 heartbeat_monitor_worker 等的 start_*/stop_* 形态best-effort 遥测:catch 全部
异常,上报失败直接丢批不重试未配置观测 start 返回 None(不启动),整套 no-op
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import httpx
from app.core.config import settings
from app.core.observe import get_queue, take_dropped
logger = logging.getLogger("shagua.observe")
# 上报用的 httpx client,start 时建、stop 时关。
_client: httpx.AsyncClient | None = None
async def _collect_batch() -> list[dict]:
"""等到 ≥1 条(或到 flush 间隔)后,连抽到 BATCH_MAX 条或抽空。超时且空 → 返回 []。"""
queue = get_queue()
batch: list[dict] = []
try:
first = await asyncio.wait_for(
queue.get(), timeout=settings.OBSERVE_FLUSH_INTERVAL_SEC
)
except asyncio.TimeoutError: # noqa: UP041 - 3.10 兼容:该版 wait_for 抛的 asyncio.TimeoutError ≠ 内置 TimeoutError
return batch
batch.append(first)
while len(batch) < settings.OBSERVE_BATCH_MAX:
try:
batch.append(queue.get_nowait())
except asyncio.QueueEmpty:
break
return batch
async def _post_batch(client: httpx.AsyncClient, batch: list[dict]) -> None:
"""POST 一批事件到 OpenObserve 的 _json ingest 端点。非 2xx 仅告警。"""
url = f"/api/{settings.OBSERVE_ORG}/{settings.OBSERVE_STREAM}/_json"
resp = await client.post(url, json=batch)
if resp.status_code >= 300:
logger.warning(
"observe ingest failed status=%s body=%s",
resp.status_code,
resp.text[:200],
)
async def _run_loop(client: httpx.AsyncClient) -> None:
try:
while True:
batch = await _collect_batch()
dropped = take_dropped()
if dropped:
logger.warning("observe dropped %d events (queue full)", dropped)
if not batch:
continue
try:
await _post_batch(client, batch)
except Exception: # noqa: BLE001 - best-effort 遥测,失败丢批不重试、不退出
logger.warning(
"observe post batch failed, dropped %d events",
len(batch),
exc_info=True,
)
except asyncio.CancelledError:
logger.info("observe worker stopped")
raise
def start_observe_worker() -> asyncio.Task | None:
"""启动上报 worker。未配置观测 → 返回 None(no-op)。约定每进程只调一次(lifespan)。"""
global _client
if not settings.observe_configured:
return None
if _client is not None:
# 约定 start 每进程只调一次;已启动则不重复建 client(避免泄漏旧连接池)。
logger.warning("observe worker already started; ignoring duplicate start")
return None
_client = httpx.AsyncClient(
base_url=settings.OBSERVE_ENDPOINT,
auth=(settings.OBSERVE_USER, settings.OBSERVE_PASSWORD),
timeout=settings.OBSERVE_TIMEOUT_SEC,
)
logger.info(
"observe worker started endpoint=%s org=%s stream=%s",
settings.OBSERVE_ENDPOINT,
settings.OBSERVE_ORG,
settings.OBSERVE_STREAM,
)
return asyncio.create_task(_run_loop(_client), name="observe-worker")
async def stop_observe_worker(task: asyncio.Task | None) -> None:
"""收尾:cancel worker → best-effort 发最后一批 → 关 client。"""
global _client
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
dropped = take_dropped() # 收口:补记最后一个 flush 窗口累计的丢弃数,不让账丢在关停期
if dropped:
logger.warning("observe dropped %d events (queue full) before shutdown", dropped)
if _client is not None:
# worker 已停,安全 drain 剩余并 best-effort 发最后一批(短超时,不卡关停);
# 超过一批(BATCH_MAX)的剩余直接丢,不做多轮 flush(best-effort,关停从速)。
try:
queue = get_queue()
final: list[dict] = []
while len(final) < settings.OBSERVE_BATCH_MAX:
try:
final.append(queue.get_nowait())
except asyncio.QueueEmpty:
break
if final:
await asyncio.wait_for(
_post_batch(_client, final), timeout=settings.OBSERVE_TIMEOUT_SEC
)
except Exception: # noqa: BLE001 - 关停期尽力而为,失败忽略
pass
await _client.aclose()
_client = None
+8 -93
View File
@@ -9,41 +9,29 @@ from __future__ import annotations
import threading
import time
from typing import NamedTuple
from fastapi import HTTPException, Request, status
from app.core.config import settings
# key -> (window_start_ts, count, window_sec)
# 存每个 key 自己的 window_sec:_buckets 混着不同窗口(60s 广告 / 3600s 登录 / 86400s 日闸)的 key,
# GC 必须按各 key 自己的窗口判过期(见 [_purge_expired]),否则短窗口调用触发的 GC 会误删长窗口 key。
_buckets: dict[str, tuple[float, int, float]] = {}
# key -> (window_start_ts, count)
_buckets: dict[str, tuple[float, int]] = {}
_lock = threading.Lock()
_GC_THRESHOLD = 10000 # _buckets 超此阈值才顺手清过期 key(仿 sms.py;测试可 monkeypatch 调小强制每次扫)
def _purge_expired(now: float) -> None:
"""清过期 key(**仅在持有 _lock 时调用**)。按每个 key 自己存的 window_sec 判过期,而非调用方的窗口
_buckets 是全局共享混着 60s(广告)/3600s(登录)/86400s(日闸)不同窗口的 key;若用调用方窗口,
高频的 60s 广告端点触发 GC 时会把本该活 3600s/86400s 的登录/日闸计数一并删掉,使其在规模上(超阈值才
触发本清理)被反复清零而失效仅在超阈值时扫,低频开销可忽略"""
if len(_buckets) <= _GC_THRESHOLD:
return
for k in [k for k, (s, _, w) in _buckets.items() if now - s >= w]:
_buckets.pop(k, None)
def _hit(key: str, limit: int, window_sec: float) -> bool:
"""记一次访问。返回 True=放行,False=超限。"""
now = time.monotonic()
with _lock:
start, count, _ = _buckets.get(key, (now, 0, window_sec))
start, count = _buckets.get(key, (now, 0))
if now - start >= window_sec: # 窗口过期,重置
start, count = now, 0
count += 1
_buckets[key] = (start, count, window_sec)
_purge_expired(now) # 顺手清过期 key(按各自窗口),防内存无限涨
_buckets[key] = (start, count)
# 顺手清过期 key,防内存无限涨(低频访问足够)
if len(_buckets) > 10000:
for k in [k for k, (s, _) in _buckets.items() if now - s >= window_sec]:
_buckets.pop(k, None)
return count <= limit
@@ -95,76 +83,3 @@ def enforce_rate_limit(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=detail,
)
# ===================== 先判 / 后记(只按「成功」计数)=====================
# _hit 是原子「判+记」:一调用就 +1,适合登录爆破(失败尝试也要计)。但对「短信发码」这类
# **只想给成功动作计数**的场景不合适 —— 被单号冷却挡下的重发没真发、没烧钱,不该占额度。
# 故拆成 _peek(只判不记)+ _commit(只记):check_rate_limits 先判 → 动作 → 成功后 record。
class RateLimitRule(NamedTuple):
"""一条限流规则。scope 区分不同闸(不同 key 前缀);同一 (subject, IP) 在 window_sec
内最多 limit ,超限抛 429 detail 文案
(scope, window_sec) 成对绑在一条规则里 check(先判) record(计数)复用同一条,
避免两处把窗口/scope 写歪导致 key 对不上
"""
scope: str
limit: int
window_sec: float
detail: str = "操作过于频繁,请稍后再试"
def _peek(key: str, limit: int, window_sec: float) -> bool:
"""只读:当前窗口内是否还没到上限(count < limit)。**不改计数**。
[_commit] 配对实现先判后记只在动作成功后才 _commit"""
now = time.monotonic()
with _lock:
start, count, _ = _buckets.get(key, (now, 0, window_sec))
if now - start >= window_sec: # 窗口已过期 → 视作已重置(count 归零)
count = 0
return count < limit
def _commit(key: str, window_sec: float) -> None:
"""记一次访问(+1)。窗口过期则以本次为起点重置。仅在动作成功后调用。"""
now = time.monotonic()
with _lock:
start, count, _ = _buckets.get(key, (now, 0, window_sec))
if now - start >= window_sec: # 窗口过期,重置
start, count = now, 0
_buckets[key] = (start, count + 1, window_sec)
_purge_expired(now) # 顺手清过期 key(按各自窗口,同 [_hit])
def check_rate_limits(request: Request, subject: str, rules: list[RateLimitRule]) -> None:
"""【先判】一组限流:任一规则已达上限即抛 429,且**不改计数**。
配合 [record_rate_limits] 实现只按成功计数: check 所有闸(全未超才继续) 执行动作
动作**成功后** record动作被下游挡下(如短信单号冷却)没真正发生时不 record 不占额度
key = `scope:subject:client_ip`( [enforce_rate_limit] 同款)
"""
if not settings.RATE_LIMIT_ENABLED:
return
ip = _client_ip(request)
for rule in rules:
if not _peek(f"{rule.scope}:{subject}:{ip}", rule.limit, rule.window_sec):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=rule.detail,
)
def record_rate_limits(request: Request, subject: str, rules: list[RateLimitRule]) -> None:
"""【记一次】一组限流(每条规则 +1)。仅在动作成功后调用,与 [check_rate_limits] 配对。
check动作record 非原子:并发突发下计数可能略超 limit(每个在途请求各 +1)
防脚本/防轰炸的安全网定位可接受;要精确配额需迁 Redis(见模块 docstring)
"""
if not settings.RATE_LIMIT_ENABLED:
return
ip = _client_ip(request)
for rule in rules:
_commit(f"{rule.scope}:{subject}:{ip}", rule.window_sec)
-25
View File
@@ -6,7 +6,6 @@
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from typing import NamedTuple
# 业务时区:签到的"今天"按北京时间算,不能用 UTC。
# 否则 UTC+8 的凌晨 0~8 点会被算成 UTC 的前一天,导致签到日期错位。
@@ -53,30 +52,6 @@ WITHDRAW_MIN_CENTS: int = 10
WITHDRAW_MAX_CENTS: int = 5_000_000 # 5 万元
# ===== 提现档位(福利页 coin_cash;7-9 对齐原型 withdrawal.html)=====
# 后端是档位唯一真相源:withdraw-info 按此下发,create_withdraw 按此校验(防绕过客户端刷)。
# 规则(2026-07-09 拍板):
# - 新人档(is_newbie):账号历史一次性,"发起就算用过"(任意状态含被拒),用过即不再下发;
# 0.1 与 0.3 各自独立同天可各提一次,且不参与常规档"每日选一个额度"互斥。
# - 常规档:按北京日计次(0.5×3 / 10×1 / 20×1),三档每天只能选一个。
# invite_cash(邀请页)本轮无档位概念,不在此表。改档位=改这里发版。
class WithdrawTier(NamedTuple):
amount_cents: int
label: str # 客户端档位方块展示文案
badge: str | None # 角标文案;None=无角标
daily_limit: int # 每日次数上限(新人档的"历史一次性"另由 is_newbie 判定)
is_newbie: bool
WITHDRAW_TIERS_COIN_CASH: tuple[WithdrawTier, ...] = (
WithdrawTier(10, "0.1", "新人福利", 1, True),
WithdrawTier(30, "0.3", "新人福利", 1, True),
WithdrawTier(50, "0.5", None, 3, False),
WithdrawTier(1000, "10", None, 1, False),
WithdrawTier(2000, "20", None, 1, False),
)
# ===== 一次性任务(领一次,user_task 去重)=====
TASK_ENABLE_NOTIFICATION = "enable_notification"
-85
View File
@@ -87,91 +87,6 @@ def issue_token_pair(user_id: int) -> dict[str, Any]:
}
def create_bind_ticket(
*, openid: str, wechat_nickname: str | None, wechat_avatar_url: str | None
) -> str:
"""微信登录未命中 openid 时,签发短时"待绑手机"令牌,承载 openid + 微信昵称头像。
typ='wechat_bind'sub=openid;有效期 settings.WECHAT_BIND_TICKET_EXPIRE_MINUTES 分钟
access/refresh 用同一 JWT_SECRET_KEY 签名, typ 区分,decode_bind_ticket 校验 typ
"""
now = _now()
expire = now + timedelta(minutes=settings.WECHAT_BIND_TICKET_EXPIRE_MINUTES)
payload: dict[str, Any] = {
"sub": openid,
"typ": "wechat_bind",
"wnk": wechat_nickname,
"wav": wechat_avatar_url,
"iat": int(now.timestamp()),
"exp": int(expire.timestamp()),
}
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
def decode_bind_ticket(token: str) -> dict[str, Any]:
"""解析"待绑手机"令牌,校验签名/过期/类型。失败抛 TokenError。
返回 {'openid': str, 'wnk': str|None, 'wav': str|None}
"""
try:
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
except jwt.ExpiredSignatureError as e:
raise TokenError("bind ticket expired") from e
except jwt.InvalidTokenError as e:
raise TokenError(f"invalid bind ticket: {e}") from e
if payload.get("typ") != "wechat_bind":
raise TokenError(f"wrong token type: want=wechat_bind got={payload.get('typ')}")
if "sub" not in payload:
raise TokenError("bind ticket missing sub")
return {"openid": payload["sub"], "wnk": payload.get("wnk"), "wav": payload.get("wav")}
def create_conflict_ticket(
*, openid: str, wechat_nickname: str | None, wechat_avatar_url: str | None, phone: str
) -> str:
"""手机号占用时签发的短时"冲突处理"令牌。
bind_ticket 多编码 **已验证的手机号 phone** 换绑/继续绑定只认它,证明"这对
openid/手机号刚在绑号时验证通过",免用户重输验证码,又堵住"拿自己 openid + 任意手机号
去夺号"的接管漏洞。typ='wechat_conflict';有效期复用 WECHAT_BIND_TICKET_EXPIRE_MINUTES。
"""
now = _now()
expire = now + timedelta(minutes=settings.WECHAT_BIND_TICKET_EXPIRE_MINUTES)
payload: dict[str, Any] = {
"sub": openid,
"typ": "wechat_conflict",
"wnk": wechat_nickname,
"wav": wechat_avatar_url,
"phn": phone,
"iat": int(now.timestamp()),
"exp": int(expire.timestamp()),
}
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
def decode_conflict_ticket(token: str) -> dict[str, Any]:
"""解析"冲突处理"令牌,校验签名/过期/类型。失败抛 TokenError。
返回 {'openid': str, 'wnk': str|None, 'wav': str|None, 'phone': str}
"""
try:
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
except jwt.ExpiredSignatureError as e:
raise TokenError("conflict ticket expired") from e
except jwt.InvalidTokenError as e:
raise TokenError(f"invalid conflict ticket: {e}") from e
if payload.get("typ") != "wechat_conflict":
raise TokenError(f"wrong token type: want=wechat_conflict got={payload.get('typ')}")
if "sub" not in payload or "phn" not in payload:
raise TokenError("conflict ticket missing sub/phn")
return {
"openid": payload["sub"],
"wnk": payload.get("wnk"),
"wav": payload.get("wav"),
"phone": payload["phn"],
}
# ===================== 密码 hash(admin 后台账号用)=====================
# 用户侧是手机号+验证码登录,不存密码;仅 admin 账号用 username+password 登录。
-43
View File
@@ -1,43 +0,0 @@
"""不活跃预警通知器(可插拔)。
v1 仅日志占位(LogNotifier):现状无真实推送能力(极光只用于一键登录解密 + 设备心跳告警,
心跳 worker 也只打印),先把清零主流程 + 审计做扎实后续实现同协议的 JPushNotifier /
SmsNotifier 即可替换,worker/repo 不改
"""
from __future__ import annotations
import logging
from typing import Protocol
logger = logging.getLogger("shagua.inactivity")
class InactivityNotifier(Protocol):
channel: str
def warn(self, *, user_id: int, coin: int, cash_cents: int,
stage: int, days_until_reset: int) -> str:
"""发预警(只涉及会被清的金币 + 折算现金;邀请现金不清、不预警)。
返回状态:'sent' / 'failed' / 'placeholder'"""
...
class LogNotifier:
"""占位实现:只打印,不真推。参照 heartbeat_monitor_worker「本期先不接推送」先例。"""
channel = "log"
def warn(self, *, user_id: int, coin: int, cash_cents: int,
stage: int, days_until_reset: int) -> str:
logger.warning(
"[inactivity-warn] user=%s coin=%s cash_cents=%s stage=T-%s days_until_reset=%s",
user_id, coin, cash_cents, stage, days_until_reset,
)
return "placeholder"
def get_notifier(channel: str) -> InactivityNotifier:
"""按配置返回通知器。未实现的通道(jpush/sms)暂回退 LogNotifier 占位。"""
# 后续:if channel == "jpush": return JPushNotifier()
# if channel == "sms": return SmsNotifier()
return LogNotifier()
+1 -2
View File
@@ -13,8 +13,7 @@ worker / 多机时内存不共享 → 冷却、校验都会失效,届时迁移
防刷两层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)
2. 单设备(device_id)+ IP 每小时 / 每天频控(api auth.sms_send check/record_rate_limits,
**只按成功发码计数** 被本文件单号冷却挡下的重发不占额度)+ 极光控制台 IP 白名单/防轰炸(运维侧)
2. 单设备(device_id)每小时频控(api auth.sms_send enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)
IP 频控(rate_limit 依赖)2026-06-26 按产品要求删除改设备维度; device_id 客户端可伪造/轮换,
脚本轮换 id 能绕过本层 挡脚本狂发主要靠极光控制台侧(+ 可选 nginx 限流)
单号每日上限2026-07-03 按精简要求删除(mentor :登录风控只留单号冷却 + 单设备频控);
-16
View File
@@ -49,16 +49,7 @@ from app.core.heartbeat_monitor_worker import (
start_heartbeat_monitor,
stop_heartbeat_monitor,
)
from app.core.inactivity_reset_worker import (
start_inactivity_reset_worker,
stop_inactivity_reset_worker,
)
from app.core.logging import setup_logging
from app.core.observe import RequestMetricsMiddleware
from app.core.observe_worker import (
start_observe_worker,
stop_observe_worker,
)
from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client
from app.core.withdraw_reconcile_worker import (
start_withdraw_reconcile_worker,
@@ -89,16 +80,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
reconcile_task = start_withdraw_reconcile_worker()
heartbeat_task = start_heartbeat_monitor()
daily_exchange_task = start_daily_exchange_worker()
observe_task = start_observe_worker()
inactivity_task = start_inactivity_reset_worker()
try:
yield
finally:
await stop_heartbeat_monitor(heartbeat_task)
await stop_withdraw_reconcile_worker(reconcile_task)
await stop_daily_exchange_worker(daily_exchange_task)
await stop_observe_worker(observe_task)
await stop_inactivity_reset_worker(inactivity_task)
await aclose_pricebot_client()
logger.info("shutting down")
@@ -120,9 +107,6 @@ if settings.cors_origins_list:
allow_headers=["*"],
)
# 接口指标埋点(放在 CORS 之后 = 最外层:测到含 CORS 的完整耗时)。未配置观测时中间件自 no-op。
app.add_middleware(RequestMetricsMiddleware)
@app.get("/health", tags=["meta"])
def health() -> dict[str, str]:
-9
View File
@@ -7,10 +7,6 @@ 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.analytics_selfstat import ( # noqa: F401
AnalyticsSelfStat,
AnalyticsSelfStatEvent,
)
from app.models.app_config import AppConfig # noqa: F401
from app.models.comparison import ComparisonRecord # noqa: F401
from app.models.cps_activity import CpsActivity # noqa: F401
@@ -27,16 +23,11 @@ from app.models.coupon_state import ( # noqa: F401
CouponSession,
)
from app.models.feedback import Feedback # noqa: F401
from app.models.inactivity import ( # noqa: F401
InactivityNotificationLog,
InactivityResetLog,
)
from app.models.invite import InviteRelation # noqa: F401
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401
from app.models.meituan_coupon import MeituanCoupon # noqa: F401
from app.models.onboarding import OnboardingCompletion # noqa: F401
from app.models.phone_rebind_log import PhoneRebindLog # noqa: F401
from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401
from app.models.ops_stat_config import OpsStatConfig # noqa: F401
from app.models.price_observation import PriceObservation # noqa: F401
-3
View File
@@ -32,9 +32,6 @@ class AdEcpmRecord(Base):
# 点位场景:comparison(比价) / coupon(领券) / welfare(福利),供收益报表区分比价/领券 Draw 收益;
# 仅信息流/Draw 上报(比价与领券共用同一代码位,只能客户端各调用点显式打标),激励视频为 NULL。
feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True)
# 本次比价/领券 trace_id(信息流场景客户端带上):把这条展示收益归属到对应比价/领券记录。
# 领券数据 / 比价记录看板按 trace_id 聚合"本次广告收益"。激励视频/福利/旧客户端 = NULL。
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# 客户端生成的一次广告会话 id;激励视频 S2S 回调 extra 会透传同值
ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# 实际投放的 ADN(穿山甲 getShowEcpm().getSdkName(),如 pangle / gdt)
+1 -7
View File
@@ -15,7 +15,7 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, BigInteger, DateTime, Index, Integer, String, func
from sqlalchemy import JSON, BigInteger, DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
@@ -23,12 +23,6 @@ from app.db.base import Base
class AnalyticsEvent(Base):
__tablename__ = "analytics_event"
__table_args__ = (
# 活跃口径聚合热点(activity.active_event_condition + last_active_subqueries):
# 按 (event,page) 过滤 首页可见(show/home)∪比价∪领券,再 group by user_id 取
# max(created_at)。覆盖索引 → 该聚合走 index-only,避免高频 show 事件全表扫。
Index("ix_analytics_event_active", "event", "page", "user_id", "created_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
-59
View File
@@ -1,59 +0,0 @@
"""埋点/上报成功率自报计数快照表(append-only)。
客户端周期上报 epoch 起算的累计计数;服务端只存原始快照,查询时在 Python 侧差分聚合
( app/admin/repositories/analytics_health.py)与既有 analytics_event 表完全独立
- analytics_selfstat :一快照一行(快照头 + 设备维度 + 设备级诊断量)
- analytics_selfstat_event : event 一行(四类累计计数),外键指向快照头
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class AnalyticsSelfStat(Base):
__tablename__ = "analytics_selfstat"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
epoch_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
# 设备维度(每设备固定,下钻用)
app_ver: Mapped[str | None] = mapped_column(String(32), nullable=True)
oem: Mapped[str | None] = mapped_column(String(32), nullable=True)
os: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 设备级诊断量(累计;queue_depth 是瞬时 gauge)
batches_attempted: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
batches_ok: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
batches_fail: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
retries: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
queue_depth: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
sent_at: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # 端上报时刻 epoch ms
# 服务端接收时间(权威,用于时间分桶与分区排序)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<AnalyticsSelfStat id={self.id} device={self.device_id} epoch={self.epoch_id}>"
class AnalyticsSelfStatEvent(Base):
__tablename__ = "analytics_selfstat_event"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
snapshot_id: Mapped[int] = mapped_column(
ForeignKey("analytics_selfstat.id"), index=True, nullable=False
)
event: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
attempted: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
drop_capture: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
delivered: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
drop_undelivered: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
def __repr__(self) -> str: # pragma: no cover
return f"<AnalyticsSelfStatEvent snap={self.snapshot_id} event={self.event}>"
-6
View File
@@ -137,12 +137,6 @@ class ComparisonRecord(Base):
# 每次 LLM 调用明细 [{scene,model,input_messages,output,usage,latency_ms,error}];
# server 收上报后按 trace_id 同机拉 pricebot 落库(见 compare_record 端点)。旧记录/未采集为 None。
llm_calls: Mapped[list | None] = mapped_column(_JSON, nullable=True)
# 本次比价 LLM 总成本(元):回填时按「当时的价」逐模型算好冻结(见 services/llm_cost.py)。
# 单次亚分级 → float「元」(不用 *_cents)。旧记录/未回填为 None,前端回退「估算成本」。
llm_cost_yuan: Mapped[float | None] = mapped_column(Float, nullable=True)
# 算成本所用单价快照 {mode, prices:{model:{input_per_1m,output_per_1m,_source}}}:app_config 只存
# 当前价、不留历史,故把当时价冻结进来供审计/复算。
llm_price_snapshot: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
-7
View File
@@ -66,9 +66,6 @@ class CouponClaimRecord(Base):
# success / already_claimed / failed / skipped(原样取 pricebot coupon 结果)
status: Mapped[str] = mapped_column(String(24), nullable=False)
# 领券所属 session 的环境 prod/dev(/step 按 trace_id 查 coupon_session.app_env 打标)。
# 旧行 NULL(不回填)。admin「按券成功率」表据此过滤环境。见设计 §13。
app_env: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
vendor: Mapped[str | None] = mapped_column(String(48), nullable=True)
coupon_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 这张领到几张(pricebot display_count;给不出时为 None)
@@ -243,10 +240,6 @@ class CouponSession(Base):
platform_elapsed: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
# 领到总张数(收尾帧带)。
claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 本次 session 至少领到一张(status∈{success,already_claimed})的平台 id 列表,如 ["meituan-waimai","jd-waimai"]。
# admin「领券数据」据此算整单成功率(②)/点位成功率(③);服务端 /step 逐帧按 trace_id 并集写入
# (见 coupon_state.merge_session_platform_success)。旧行=NULL → 视作空集。
platform_success: Mapped[list | None] = mapped_column(_JSON, nullable=True)
# pricebot done 帧回传的公网调试链接(price.shaguabijia.com/traces/{dir});含落盘时分秒、拼不出,只能存
# (同 ComparisonRecord.trace_url)。admin「领券数据」明细据此渲染可点 trace 链接;未到 done(failed/abandoned)为空。
trace_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
-58
View File
@@ -1,58 +0,0 @@
"""15 天不活跃清零相关表。
- inactivity_reset_log:每次清零一行,记清零前三桶余额快照 + 原因 + 判定时活跃时间/不活跃天数,
供纠纷排查(需求①)清零同时另写 2 条钱包流水(金币 + 折算现金,biz_type=inactivity_reset),
资金流可逐笔回溯**邀请现金是产品红线不清零**,invite_cash_balance_cents_before 仅为清零时
仍保留的邀请现金快照(便于排查非被清金额; wallet.CoinAccount 注释)
- inactivity_notification_log:每次预警一行,记推送时余额快照 + 档位 + 通道 + 状态,
兼作"预警去重"依据(created_at > last_active)"待推送"占位 outbox(v1 通道=log)
append-only,不更新user_id 只索引不设外键( analytics_event,避免删用户级联/历史留痕)
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class InactivityResetLog(Base):
__tablename__ = "inactivity_reset_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
coin_balance_before: Mapped[int] = mapped_column(Integer, nullable=False)
cash_balance_cents_before: Mapped[int] = mapped_column(Integer, nullable=False)
invite_cash_balance_cents_before: Mapped[int] = mapped_column(Integer, nullable=False)
last_active_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
inactive_days: Mapped[int] = mapped_column(Integer, nullable=False)
reason: Mapped[str] = mapped_column(String(32), nullable=False)
reset_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<InactivityResetLog id={self.id} user_id={self.user_id} coin={self.coin_balance_before}>"
class InactivityNotificationLog(Base):
__tablename__ = "inactivity_notification_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
stage: Mapped[int] = mapped_column(Integer, nullable=False) # 提前天数档(如 7 / 2)
inactive_days: Mapped[int] = mapped_column(Integer, nullable=False)
coin_balance: Mapped[int] = mapped_column(Integer, nullable=False)
cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False)
invite_cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False)
channel: Mapped[str] = mapped_column(String(16), nullable=False) # log / jpush / sms
status: Mapped[str] = mapped_column(String(16), nullable=False) # placeholder / sent / failed
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<InactivityNotificationLog id={self.id} user_id={self.user_id} stage={self.stage}>"
-31
View File
@@ -1,31 +0,0 @@
"""手机号换绑台账。
记录"手机号从老账号被夺走、重建为新账号(X 注销 → Y)"这一破坏性事件,支撑"一个手机号
30 天内最多换绑一次"的限制。手机号级、渠道无关(source 标来源);普通微信绑定不写此表。
M2 spec §4.1
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class PhoneRebindLog(Base):
__tablename__ = "phone_rebind_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 被换绑的真实手机号(注意:存真实号,不是老账号被腾号后的 deleted_<id>)
phone: Mapped[str] = mapped_column(String(20), index=True, nullable=False)
# 被注销的老账号 X;P 换绑时已被腾空(极边界)则为空
old_user_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 换绑后新建的账号 Y
new_user_id: Mapped[int] = mapped_column(Integer, nullable=False)
# 换绑来源。手机号级配额、渠道无关,留字段给未来其他换绑路径共用同一份 30 天限制。
source: Mapped[str] = mapped_column(String(32), nullable=False, default="wechat_conflict")
rebound_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
-101
View File
@@ -1,101 +0,0 @@
"""活跃口径唯一真源:worker(不活跃清零)与 admin(最近活跃/DAU)共用,防两处漂移。
口径 = max(User.created_at, AnalyticsEvent[首页可见 show/home + 比价 + 领券], CouponPromptEngagement[claim_started])
**不含 last_login_at**(登录/re-login 不代表在用 App);created_at 为恒非空基线
清零/预警按北京自然日 0 点对齐( reset_cutoff)
"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ, cn_today
from app.models.analytics_event import AnalyticsEvent
from app.models.coupon_state import CouponPromptEngagement
# —— 活跃口径事件(与"用户管理"口径一致)——
# 首页可见:前端埋点 event=show + page=home(组合判定,单个 event 名不足以区分,见
# active_event_condition);其余为纯 event 名。
HOME_VIEW_EVENT = "show"
HOME_VIEW_PAGE = "home"
COMPARE_START_EVENT = "real_compare_start" # 发起比价(含浮窗触发)
COUPON_START_EVENT = "real_coupon_start" # 发起领券
# 纯 event 名即可判定的活跃事件(首页可见是 event+page 组合、不在此列)
ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT)
ACTIVE_ENGAGE_TYPE = "claim_started" # coupon_prompt_engagement 一键领取
def active_event_condition():
"""analytics_event 中算"活跃"的行为过滤:首页可见(event=show & page=home)
发起比价 发起领券worker 子查询与 admin 展示共用,单一真源"""
return or_(
and_(AnalyticsEvent.event == HOME_VIEW_EVENT, AnalyticsEvent.page == HOME_VIEW_PAGE),
AnalyticsEvent.event.in_(ACTIVE_EVENTS),
)
def as_utc(value: datetime) -> datetime:
"""任意 datetime → tz-aware UTC(无时区按 UTC 解释)。用于与 DateTime(timezone=True) 列比较,
比较绝对时刻与会话时区无关(口径同 admin queries._as_utc)"""
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def norm_utc(dt: datetime | None) -> datetime | None:
"""naive 视为 UTC 补 tzinfo(SQLite 读回 naive、PG 读回 aware,混着 max() 会 TypeError)。"""
if dt is None:
return None
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
def cn_midnight_utc(d: date) -> datetime:
"""北京 d 日 00:00 → tz-aware UTC datetime。"""
return as_utc(datetime(d.year, d.month, d.day, tzinfo=CN_TZ))
def reset_cutoff(reset_days: int, today: date | None = None) -> datetime:
"""应清零边界(tz-aware UTC):last_active < 此值 ⟺ 距末次活跃已满 reset_days 天(北京 0 点对齐)。
= 北京 00:00 of (today (reset_days 1)):reset_days=15today=1/20 北京 1/6 00:00"""
today = today or cn_today()
return cn_midnight_utc(today - timedelta(days=reset_days - 1))
def last_active_subqueries(db: Session):
"""两个按 user_id 预聚合的派生表:最近活跃事件(见 active_event_condition)、
最近领券发起(claim_started)返回 (ev_sub, eng_sub)口径同 admin,LEFT JOIN """
ev_sub = (
select(
AnalyticsEvent.user_id.label("user_id"),
func.max(AnalyticsEvent.created_at).label("last_at"),
)
.where(AnalyticsEvent.user_id.is_not(None), active_event_condition())
.group_by(AnalyticsEvent.user_id)
.subquery()
)
eng_sub = (
select(
CouponPromptEngagement.user_id.label("user_id"),
func.max(CouponPromptEngagement.created_at).label("last_at"),
)
.where(
CouponPromptEngagement.user_id.is_not(None),
CouponPromptEngagement.engage_type == ACTIVE_ENGAGE_TYPE,
)
.group_by(CouponPromptEngagement.user_id)
.subquery()
)
return ev_sub, eng_sub
def last_active_expr(base_col, ev_sub, eng_sub, dialect: str):
"""max(base_col, 最近活跃事件, 最近领券) 的 SQL 表达式。PG 用 greatest、SQLite 用 max。
子聚合缺失(未命中) coalesce base_col(= User.created_at,恒非空基线)"""
greatest = func.greatest if dialect == "postgresql" else func.max
return greatest(
base_col,
func.coalesce(ev_sub.c.last_at, base_col),
func.coalesce(eng_sub.c.last_at, base_col),
)
-46
View File
@@ -10,28 +10,8 @@ from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core import rewards
from app.core.rewards import cn_today
from app.models.ad_ecpm import AdEcpmRecord
from app.models.coupon_state import CouponSession
def attributable_trace_id(
db: Session, *, feed_scene: str | None, trace_id: str | None
) -> str | None:
"""返回广告展示允许归属的业务 trace。
领券任务可能在 Draw 广告异步加载完成前已经失败或被用户放弃非完成终态先落库
广告回调后到时收益仍需保留在总广告报表中但不能再挂到该死亡领券明细
因此清空关联 trace其它场景找不到 session进行中或已完成状态保持原值
由客户端生命周期修复负责主防线
"""
if feed_scene != "coupon" or not trace_id:
return trace_id
session_status = db.execute(
select(CouponSession.status).where(CouponSession.trace_id == trace_id)
).scalar_one_or_none()
return None if session_status in {"failed", "abandoned"} else trace_id
def create_ecpm_record(
@@ -44,7 +24,6 @@ def create_ecpm_record(
adn: str | None = None,
slot_id: str | None = None,
feed_scene: str | None = None,
trace_id: str | None = None,
app_env: str | None = None,
our_code_id: str | None = None,
) -> AdEcpmRecord:
@@ -64,7 +43,6 @@ def create_ecpm_record(
adn=adn,
slot_id=slot_id,
feed_scene=feed_scene,
trace_id=trace_id,
app_env=app_env,
our_code_id=our_code_id,
ecpm_raw=ecpm_raw,
@@ -127,27 +105,3 @@ def count_today(db: Session, user_id: int) -> int:
AdEcpmRecord.report_date == cn_today().isoformat(),
)
).scalar_one()
def revenue_yuan_by_trace(db: Session, trace_ids: list[str]) -> dict[str, float]:
"""各 trace_id 的广告预估收益(元):按 trace_id 聚合 ad_ecpm_record 的展示收益。
单条展示收益 = min(eCPM元, AD_ECPM_MAX_FEN/100) / 1000( admin 广告收益报表同口径)
ecpm_raw 是字符串且需逐条钳顶,故取回后 Python 求和(行数=本页各 trace 的展示条数,很小)
trace_id 仅信息流(比价/领券)场景客户端带,激励视频/旧数据为 NULL, trace_id 过滤天然只算对应场景
只喂**当前页** trace_id( 一页条数);空集合直接返回(避免 IN () 非法)
"""
if not trace_ids:
return {}
rows = db.execute(
select(AdEcpmRecord.trace_id, AdEcpmRecord.ecpm_raw).where(
AdEcpmRecord.trace_id.in_(trace_ids),
)
).all()
cap_yuan = rewards.AD_ECPM_MAX_FEN / 100.0
out: dict[str, float] = {}
for tid, ecpm_raw in rows:
if not tid:
continue
out[tid] = out.get(tid, 0.0) + min(rewards.parse_ecpm_yuan(ecpm_raw), cap_yuan) / 1000.0
return {tid: round(v, 6) for tid, v in out.items()}
+2 -11
View File
@@ -176,19 +176,10 @@ def grant_feed_reward(
)
return _commit_record(db, rec, client_event_id)
# 按点位场景拆流水文案(2026-07):比价等候期看的广告 vs 领券时看的广告,在收益明细里分开显示。
# feed_scene=comparison→比价奖励 / coupon→领券奖励;其它(welfare/空/旧端不带)维持通用「信息流广告奖励」。
# 客户端按此 biz_type 直显固定文案(见 CoinHistoryViewModel.coinTitle),故 remark 只作后台留痕/兜底。
if feed_scene == "comparison":
reward_biz, reward_remark = "feed_ad_reward_comparison", "比价奖励"
elif feed_scene == "coupon":
reward_biz, reward_remark = "feed_ad_reward_coupon", "领券奖励"
else:
reward_biz, reward_remark = "feed_ad_reward", "信息流广告奖励"
crud_wallet.grant_coins(
db, user_id, coin,
biz_type=reward_biz, ref_id=client_event_id,
remark=reward_remark,
biz_type="feed_ad_reward", ref_id=client_event_id,
remark="信息流广告奖励",
)
rec = AdFeedRewardRecord(
client_event_id=client_event_id,
-38
View File
@@ -1,38 +0,0 @@
"""自报计数快照落库。一次事务:插 1 条快照头 + N 条 event 行,返回快照 id。"""
from __future__ import annotations
from sqlalchemy.orm import Session
from app.models.analytics_selfstat import AnalyticsSelfStat, AnalyticsSelfStatEvent
from app.schemas.analytics_selfstat import SelfStatBatchIn
def record_selfstat(db: Session, batch: SelfStatBatchIn) -> int:
snap = AnalyticsSelfStat(
device_id=batch.device_id,
epoch_id=batch.epoch_id,
app_ver=batch.app_ver,
oem=batch.oem,
os=batch.os,
batches_attempted=batch.batches_attempted,
batches_ok=batch.batches_ok,
batches_fail=batch.batches_fail,
retries=batch.retries,
queue_depth=batch.queue_depth,
sent_at=batch.sent_at,
)
db.add(snap)
db.flush() # 拿到 snap.id
db.add_all([
AnalyticsSelfStatEvent(
snapshot_id=snap.id,
event=e.event,
attempted=e.attempted,
drop_capture=e.drop_capture,
delivered=e.delivered,
drop_undelivered=e.drop_undelivered,
)
for e in batch.events
])
db.commit()
return snap.id
-44
View File
@@ -142,47 +142,3 @@ def set_ad_config(db: Session, data: dict, *, admin_id: int, commit: bool = True
else:
db.flush()
return row
# ── 华为审核开关(admin 可切,仅华为 ROM 客户端拉)────────────────────────────────
# 同 ad_config:复用 AppConfig 表但不进 CONFIG_DEFS——它由「华为审核开关」专用页管理,
# 有自己的两态语义,混进通用系统配置页只会显示成一个没头没尾的 on/off。
# default → 新手引导页(快速设置)强制展示,用户无法关闭(= 上线至今的现状)
# review → 快速设置的权限步左上角出现退出按钮,可直接进首页(过华为应用市场审核用)
# 空库 = default = 行为完全不变。客户端经 /api/v1/platform/huawei-review 拉取。
HUAWEI_REVIEW_KEY = "huawei_review"
HUAWEI_REVIEW_MODES = ("default", "review")
_HUAWEI_REVIEW_DEFAULTS: dict[str, Any] = {
"mode": "default",
}
def get_huawei_review(db: Session) -> dict:
"""读华为审核开关。DB 无 / 脏值一律回退 default(宁可不给退出按钮,也不误放开)。"""
row = db.get(AppConfig, HUAWEI_REVIEW_KEY)
merged = dict(_HUAWEI_REVIEW_DEFAULTS)
if row is not None and isinstance(row.value, dict):
merged.update(row.value)
if merged.get("mode") not in HUAWEI_REVIEW_MODES:
merged["mode"] = _HUAWEI_REVIEW_DEFAULTS["mode"]
return merged
def set_huawei_review(db: Session, mode: str, *, admin_id: int, commit: bool = True) -> AppConfig:
"""admin 切换华为审核开关。非法 mode 抛 ValueError(路由转 400)。"""
if mode not in HUAWEI_REVIEW_MODES:
raise ValueError(f"invalid mode: {mode} (expected one of {list(HUAWEI_REVIEW_MODES)})")
row = db.get(AppConfig, HUAWEI_REVIEW_KEY)
value = {"mode": mode}
if row is None:
row = AppConfig(key=HUAWEI_REVIEW_KEY, value=value, updated_by_admin_id=admin_id)
db.add(row)
else:
row.value = value
row.updated_by_admin_id = admin_id
if commit:
db.commit()
db.refresh(row)
else:
db.flush()
return row
+1 -83
View File
@@ -147,22 +147,12 @@ def reset_today_completion(db: Session, device_id: str) -> int:
# ===== 领券记录(coupon_claim_record)=====
def session_app_env(db: Session, trace_id: str | None) -> str | None:
"""按 trace_id 取 coupon_session.app_env(每券成功率表打环境标用);无 trace_id / 查不到 → None。"""
if not trace_id:
return None
return db.execute(
select(CouponSession.app_env).where(CouponSession.trace_id == trace_id)
).scalar_one_or_none()
def record_claims(
db: Session,
device_id: str,
user_id: int | None,
trace_id: str | None,
results: list[dict],
app_env: str | None = None,
) -> int:
"""一批券领取结果幂等写入,返回写入(新增 + 更新)条数。
@@ -196,15 +186,12 @@ def record_claims(
row.user_id = user_id
if count is not None:
row.claimed_count = count
if app_env is not None:
row.app_env = app_env
row.extra = r
else:
db.add(CouponClaimRecord(
device_id=device_id, user_id=user_id,
coupon_id=coupon_id, claim_date=today,
status=status, app_env=app_env,
vendor=r.get("vendor"), coupon_name=r.get("name"),
status=status, vendor=r.get("vendor"), coupon_name=r.get("name"),
claimed_count=count, trace_id=trace_id, reason=r.get("reason"),
extra=r,
))
@@ -248,47 +235,6 @@ def sum_claimed_count(db: Session, user_id: int) -> int:
return int(total or 0)
# ===== 领券平台推导(coupon_id → 平台;成功平台集)=====
# 成功语义:success + already_claimed 算成功(pricebot 代码 emit already_claimed,协议 enum 漏了);
# failed / skipped 不算。与 sum_claimed_count 同口径。
_SUCCESS_STATUSES = frozenset({"success", "already_claimed"})
# 三档平台 id 及固定序(美团→淘宝→京东),与客户端 DEFAULT_PLATFORM_ORDER 对齐。
DEFAULT_PLATFORMS: tuple[str, ...] = ("meituan-waimai", "taobao-shanguang", "jd-waimai")
def coupon_id_to_platform(coupon_id: str | None) -> str | None:
"""coupon_id 前缀 → 平台 id;无法识别 / 空 → None。
与客户端 `CouponForegroundService.couponIdToPlatform` 同词表:
mt_美团外卖 / tb_·ele_·elm_淘宝闪购 / jd_京东外卖
"""
if not coupon_id:
return None
if coupon_id.startswith("mt_"):
return "meituan-waimai"
if coupon_id.startswith(("tb_", "ele_", "elm_")):
return "taobao-shanguang"
if coupon_id.startswith("jd_"):
return "jd-waimai"
return None
def succeeded_platforms(results: list[dict]) -> list[str]:
"""一批券结果 → 至少领到一张的平台集(按 DEFAULT_PLATFORMS 去重保序)。
只取 status{success, already_claimed} 的券;失败/跳过无法识别平台的券跳过
"""
ok: set[str] = set()
for r in results:
if r.get("status") in _SUCCESS_STATUSES:
platform = coupon_id_to_platform(r.get("coupon_id"))
if platform is not None:
ok.add(platform)
return [p for p in DEFAULT_PLATFORMS if p in ok]
# ===== 领券任务流水(coupon_session,admin「领券数据」看板数据源)=====
def upsert_coupon_session(
@@ -375,31 +321,3 @@ def upsert_coupon_session(
except IntegrityError:
# 并发下另一请求刚插了同 trace_id → 唯一约束撞,回滚忽略(本就幂等)。
db.rollback()
def merge_session_platform_success(
db: Session, trace_id: str, platforms: list[str]
) -> None:
"""把本帧「成功平台」并入 coupon_session.platform_success(按 trace_id,并集幂等,按 DEFAULT_PLATFORMS 保序)。
- 领券 /step 每逢带券结果的帧调一次(平台成败布尔,跨帧取并集天然幂等,不重复计)
- 读不到该 trace_id 的行 **静默跳过**(不建兜底行;设计 §5:started 帧几乎必先落库)
- 并集无变化(该平台已记过) 不写库,省一次 UPDATE
- fire-and-forget:调用方已吞异常;并发唯一冲突回滚忽略
"""
if not platforms:
return
row = db.execute(
select(CouponSession).where(CouponSession.trace_id == trace_id)
).scalar_one_or_none()
if row is None:
return
merged = set(row.platform_success or []) | set(platforms)
new_list = [p for p in DEFAULT_PLATFORMS if p in merged]
if new_list == (row.platform_success or []):
return # 幂等:无新平台,不写
row.platform_success = new_list
try:
db.commit()
except IntegrityError:
db.rollback()
-198
View File
@@ -1,198 +0,0 @@
"""15 天不活跃清零业务逻辑(纯同步,可单测)。worker 只是它的 asyncio 外壳。
活跃口径复用 app.repositories.activity;清零走 wallet.grant_*(负数出账写流水 commit)
逐用户独立事务,一个失败不影响其余
"""
from __future__ import annotations
import logging
from datetime import date, datetime
from sqlalchemy import or_, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ
from app.integrations.notifier import InactivityNotifier
from app.models.inactivity import InactivityNotificationLog, InactivityResetLog
from app.models.user import User
from app.models.wallet import CoinAccount
from app.repositories import activity
from app.repositories import wallet as wallet_repo
logger = logging.getLogger("shagua.inactivity")
RESET_BIZ_TYPE = "inactivity_reset"
RESET_REMARK = "15天不活跃清零"
# 清零候选口径:金币或折算现金有余额即入选。**邀请现金不算**——它是产品红线、不清零
# (见 wallet.CoinAccount 注释),只有邀请现金余额的用户没有可清项,故不入选。
_ANY_BALANCE = or_(
CoinAccount.coin_balance > 0,
CoinAccount.cash_balance_cents > 0,
)
def _base_query(db: Session):
"""select(user_id, last_active, 三桶余额),join CoinAccount + 两活跃子查询。"""
ev_sub, eng_sub = activity.last_active_subqueries(db)
dialect = db.get_bind().dialect.name
last_active = activity.last_active_expr(User.created_at, ev_sub, eng_sub, dialect)
stmt = (
select(
User.id.label("user_id"),
last_active.label("last_active"),
CoinAccount.coin_balance,
CoinAccount.cash_balance_cents,
CoinAccount.invite_cash_balance_cents,
)
.join(CoinAccount, CoinAccount.user_id == User.id)
.outerjoin(ev_sub, ev_sub.c.user_id == User.id)
.outerjoin(eng_sub, eng_sub.c.user_id == User.id)
)
return stmt, last_active
def _cn_date(dt: datetime) -> date:
"""datetime → 北京自然日(naive 视为 UTC)。"""
return activity.norm_utc(dt).astimezone(CN_TZ).date()
def _inactive_days(last_active: datetime, today: date) -> int:
return (today - _cn_date(last_active)).days
def select_inactive_users(db: Session, *, cutoff: datetime):
"""应清零用户:last_active < cutoff 且金币/折算现金有余额(邀请现金不清、不计)。
返回 Row 列表(值已快照,可跨 commit)"""
stmt, last_active = _base_query(db)
stmt = stmt.where(_ANY_BALANCE, last_active < activity.as_utc(cutoff))
return db.execute(stmt).all()
def clear_user(db: Session, *, user_id: int, last_active: datetime, inactive_days: int,
reason: str, dry_run: bool = False) -> bool:
"""单用户清零(独立事务、行锁)。金币 + 折算现金归零 + 写审计 + 2 条流水;**邀请现金不清**
(产品红线, wallet.CoinAccount 注释),仅作快照记入审计返回是否真处理了(有可清余额)
dry_run=True:**只写审计名单不动钱不写流水**(灰度看名单) streak 去重 streak
已记过(reset_at > last_active)就跳,避免 worker 每日重复记"""
acc = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
coin, cash, invite = acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents
if coin == 0 and cash == 0: # 邀请现金不清,故不算"有可清余额"
return False
if dry_run and db.execute(
select(InactivityResetLog.id).where(
InactivityResetLog.user_id == user_id,
InactivityResetLog.reset_at > activity.as_utc(last_active),
).limit(1)
).first():
return False # dry-run:本 streak 已记过审计,不重复记
log = InactivityResetLog(
user_id=user_id, coin_balance_before=coin, cash_balance_cents_before=cash,
invite_cash_balance_cents_before=invite, last_active_at=activity.norm_utc(last_active),
inactive_days=inactive_days, reason=reason,
)
db.add(log)
db.flush() # 拿 log.id 作 ref_id 交叉链接审计↔流水
if not dry_run: # dry-run 只记审计名单,不真出账
ref = str(log.id)
if coin:
wallet_repo.grant_coins(db, user_id, -coin, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK)
if cash:
wallet_repo.grant_cash(db, user_id, -cash, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK)
# 邀请现金(invite_cash_balance_cents)刻意不动:两本账物理隔离、邀请金是产品红线。
db.commit()
return True
def run_reset_once(db: Session, *, reset_days: int, today: date, dry_run: bool = False) -> dict:
"""扫一轮清零。逐用户独立 commit,失败隔离。dry_run=True 只记审计名单、不动钱(见 clear_user)。"""
stats = {"scanned": 0, "cleared": 0, "failed": 0}
cutoff = activity.reset_cutoff(reset_days, today)
reason = f"inactive_{reset_days}d" + ("_dryrun" if dry_run else "")
rows = select_inactive_users(db, cutoff=cutoff) # 先物化,避免边遍历边 commit
for row in rows:
stats["scanned"] += 1
idays = _inactive_days(row.last_active, today)
try:
if clear_user(db, user_id=row.user_id, last_active=row.last_active,
inactive_days=idays, reason=reason, dry_run=dry_run):
stats["cleared"] += 1
except SQLAlchemyError:
db.rollback()
stats["failed"] += 1
return stats
def select_warn_candidates(db: Session, *, clear_cutoff: datetime, warn_hi: datetime):
"""预警候选:clear_cutoff <= last_active < warn_hi 且有可清余额(即已进预警窗、尚未到清零)。"""
stmt, last_active = _base_query(db)
stmt = stmt.where(
_ANY_BALANCE,
last_active >= activity.as_utc(clear_cutoff),
last_active < activity.as_utc(warn_hi),
)
return db.execute(stmt).all()
def run_warn_once(db: Session, notifier: InactivityNotifier, *,
reset_days: int, warn_stages: list[int], today: date) -> dict:
"""扫一轮预警。每人取"最紧急的已到达档",按 streak 去重(notification_log.created_at > last_active)。
预警只涉及会被清的金币 + 折算现金;邀请现金不清不预警(仅在 notification_log 记快照)
逐用户 try/except 隔离:单用户通知器抛错 / DB 错不阻断其余,也绝不能拖累后续清零"""
stats = {"warned": 0, "warn_skipped": 0, "warn_failed": 0}
if not warn_stages:
return stats
clear_cutoff = activity.reset_cutoff(reset_days, today) # 到此即清零,不再预警
warn_hi = activity.reset_cutoff(reset_days - max(warn_stages), today) # 最早预警档边界
ascending = sorted(warn_stages) # 最紧急(最小 k)在前
for row in select_warn_candidates(db, clear_cutoff=clear_cutoff, warn_hi=warn_hi):
idays = _inactive_days(row.last_active, today)
stage = next((k for k in ascending if idays >= reset_days - k), None)
if stage is None: # 防御:候选已在预警窗内、stage 必命中,此分支实际不可达
continue
try:
already = db.execute(
select(InactivityNotificationLog.id).where(
InactivityNotificationLog.user_id == row.user_id,
InactivityNotificationLog.stage == stage,
InactivityNotificationLog.created_at > activity.as_utc(row.last_active),
).limit(1)
).first()
if already:
stats["warn_skipped"] += 1
continue
status = notifier.warn(
user_id=row.user_id, coin=row.coin_balance, cash_cents=row.cash_balance_cents,
stage=stage, days_until_reset=reset_days - idays,
)
db.add(InactivityNotificationLog(
user_id=row.user_id, stage=stage, inactive_days=idays,
coin_balance=row.coin_balance, cash_balance_cents=row.cash_balance_cents,
invite_cash_balance_cents=row.invite_cash_balance_cents, # 快照,不参与"将清"额度
channel=notifier.channel, status=status,
))
db.commit()
stats["warned"] += 1
except Exception: # noqa: BLE001 - 单用户预警失败(通知器抛错/DB 错)隔离,不阻断其余、不拖累清零
db.rollback()
stats["warn_failed"] += 1
return stats
def run_once(db: Session, *, notifier: InactivityNotifier, reset_days: int,
warn_stages: list[int], today: date, dry_run: bool = False) -> dict:
"""一轮完整任务:先预警(阶段 A)再清零(阶段 B)。返回合并统计。
预警整段异常也**绝不阻塞清零**清零是核心不可逆资金操作,不能被通知故障拖住
dry_run=True(灰度默认):只记审计名单不清**也不预警**(不通知一个不会发生的清零)"""
warn = {"warned": 0, "warn_skipped": 0, "warn_failed": 0}
if not dry_run:
try:
warn = run_warn_once(db, notifier, reset_days=reset_days, warn_stages=warn_stages, today=today)
except Exception: # noqa: BLE001 - 预警阶段整体失败(如候选查询失败)也要继续清零
logger.exception("inactivity warn phase failed; proceeding to reset")
db.rollback()
warn = {"warned": 0, "warn_skipped": 0, "warn_failed": 0, "warn_phase_error": 1}
reset = run_reset_once(db, reset_days=reset_days, today=today, dry_run=dry_run)
return {**warn, **reset}
-39
View File
@@ -1,39 +0,0 @@
"""手机号换绑台账(phone_rebind_log)的查询与写入。见 M2 spec §4.1。"""
from __future__ import annotations
import math
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.phone_rebind_log import PhoneRebindLog
def rebound_within_days(db: Session, phone: str, days: int) -> bool:
"""该手机号在最近 days 天内是否换绑过(命中 → 禁止再次换绑)。"""
since = datetime.now(timezone.utc) - timedelta(days=days)
stmt = (
select(PhoneRebindLog.id)
.where(PhoneRebindLog.phone == phone, PhoneRebindLog.rebound_at >= since)
.limit(1)
)
return db.execute(stmt).first() is not None
def remaining_block_days(db: Session, phone: str, days: int) -> int:
"""距离该手机号可再次换绑还剩几天(向上取整;无记录返回 0)。"""
last = db.execute(
select(func.max(PhoneRebindLog.rebound_at)).where(PhoneRebindLog.phone == phone)
).scalar_one_or_none()
if last is None:
return 0
if last.tzinfo is None: # SQLite 取回 naive datetime,按 UTC 归一
last = last.replace(tzinfo=timezone.utc)
remaining = (last + timedelta(days=days) - datetime.now(timezone.utc)).total_seconds()
return max(0, math.ceil(remaining / 86400))
def add_rebind_log(db: Session, *, phone: str, old_user_id: int | None, new_user_id: int, source: str) -> None:
"""写一条换绑台账(**不 commit**,交给调用方 rebind_account 的单事务)。"""
db.add(PhoneRebindLog(phone=phone, old_user_id=old_user_id, new_user_id=new_user_id, source=source))
-132
View File
@@ -12,7 +12,6 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.user import User
from app.repositories import phone_rebind
# ===== 创建时分配的标识:用户名(对外展示账号 ID)+ 默认昵称 =====
@@ -59,17 +58,6 @@ def is_default_nickname(nickname: str | None) -> bool:
)
def apply_wechat_display_identity(
user: User, *, wechat_nickname: str | None, wechat_avatar_url: str | None
) -> None:
"""§10:用已有账号绑微信时,仅当展示字段仍为默认才用微信昵称/头像替换(两规则独立);
自定义(改过昵称/传过头像)则保留只改内存对象,由调用方 commit"""
if is_default_nickname(user.nickname) and wechat_nickname:
user.nickname = wechat_nickname
if user.avatar_url is None and wechat_avatar_url:
user.avatar_url = wechat_avatar_url
def get_user_by_username(db: Session, username: str) -> User | None:
return db.execute(
select(User).where(User.username == username)
@@ -98,85 +86,6 @@ def get_user_by_phone(db: Session, phone: str) -> User | None:
return db.execute(stmt).scalar_one_or_none()
def get_user_by_wechat_openid(db: Session, openid: str) -> User | None:
stmt = select(User).where(User.wechat_openid == openid)
return db.execute(stmt).scalar_one_or_none()
def touch_last_login(db: Session, user: User) -> User:
"""openid 命中登录时更新 last_login_at(手机号登录在 upsert_user_for_login 里已更新)。"""
user.last_login_at = datetime.now(timezone.utc)
db.commit()
db.refresh(user)
return user
def attach_wechat_to_user(
db: Session, user: User, *, openid: str, wechat_nickname: str | None, wechat_avatar_url: str | None
) -> User:
"""继续绑定:把微信 openid + 微信源字段并入已存在账号(调用方保证 user.wechat_openid 为空)。
wechat_openid / wechat_nickname / wechat_avatar_url,并按 §10 规则回填展示字段:
仅当昵称仍为默认值(is_default_nickname)时用微信昵称替换,仅当头像为 null 时用微信头像替换;
用户已自定义的展示昵称/头像始终保留,两规则相互独立
openid 唯一约束(O 期间被别处绑走,极罕见)时由调用方捕获 IntegrityError 兜底降级为"只登入不绑"
"""
user.wechat_openid = openid
user.wechat_nickname = wechat_nickname
user.wechat_avatar_url = wechat_avatar_url
user.last_login_at = datetime.now(timezone.utc)
apply_wechat_display_identity(user, wechat_nickname=wechat_nickname, wechat_avatar_url=wechat_avatar_url)
db.commit()
db.refresh(user)
return user
def _build_wechat_user(
db: Session,
*,
phone: str,
openid: str,
wechat_nickname: str | None,
wechat_avatar_url: str | None,
) -> User:
"""构造并 db.add 一个微信账号行(register_channel='wechat',展示昵称头像取微信,缺则默认),
** commit**create_wechat_user rebind_account 共用,保证建号逻辑单一来源"""
user = User(
phone=phone,
username=_gen_unique_username(db),
nickname=wechat_nickname or _gen_nickname(),
avatar_url=wechat_avatar_url,
register_channel="wechat",
wechat_openid=openid,
wechat_nickname=wechat_nickname,
wechat_avatar_url=wechat_avatar_url,
last_login_at=datetime.now(timezone.utc),
)
db.add(user)
return user
def create_wechat_user(
db: Session,
*,
phone: str,
openid: str,
wechat_nickname: str | None,
wechat_avatar_url: str | None,
) -> User:
"""微信登录新建账号(未占用分支)。见 _build_wechat_user。
openid 唯一约束是并发/重复绑定的最终防线(极罕见,openid wechat-login 刚查过为空)
"""
user = _build_wechat_user(
db, phone=phone, openid=openid,
wechat_nickname=wechat_nickname, wechat_avatar_url=wechat_avatar_url,
)
db.commit()
db.refresh(user)
return user
def upsert_user_for_login(
db: Session,
*,
@@ -245,44 +154,3 @@ def soft_delete_account(db: Session, user: User) -> None:
# 释放邀请码唯一槽
user.invite_code = None
db.commit()
def rebind_account(
db: Session,
*,
phone: str,
openid: str,
wechat_nickname: str | None,
wechat_avatar_url: str | None,
source: str = "wechat_conflict",
) -> User:
"""换绑:**单事务内**注销老账号 X(腾出手机号)+ 用该号建全新微信账号 Y + 写换绑台账。
- 老账号可能已不存在(P 被腾空) old_user_id=None,直接建 Y(幂等更稳)
- 手机号唯一约束靠时序:先把 X.phone 改名并 flush 腾号,再插 Y
- 全程不中途 commit,任一步失败整体回滚,绝不出现"X 删了 Y 没建"
X 的字段变更等价 soft_delete_account(软删 + 匿名化 + 释放 openid/邀请码唯一槽),但不在此 commit
"""
old = get_user_by_phone(db, phone)
old_id = old.id if old is not None else None
if old is not None:
old.status = "deleted"
old.phone = f"deleted_{old.id}"
old.nickname = None
old.avatar_url = None
old.wechat_openid = None
old.wechat_nickname = None
old.wechat_avatar_url = None
old.invite_code = None
db.flush() # 先落 phone 改名,腾出手机号唯一约束,才能给 Y 用
new_user = _build_wechat_user(
db, phone=phone, openid=openid,
wechat_nickname=wechat_nickname, wechat_avatar_url=wechat_avatar_url,
)
db.flush() # 拿 new_user.id
phone_rebind.add_rebind_log(
db, phone=phone, old_user_id=old_id, new_user_id=new_user.id, source=source
)
db.commit()
db.refresh(new_user)
return new_user
+1 -109
View File
@@ -11,7 +11,7 @@ import unicodedata
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select, update
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -20,7 +20,6 @@ from app.core.config import settings
from app.core.rewards import COIN_PER_CENT, coins_to_cents
from app.integrations import wxpay
from app.models.user import User
from app.repositories.user import apply_wechat_display_identity
from app.models.wallet import (
CashTransaction,
CoinAccount,
@@ -35,10 +34,6 @@ _WX_STATE_SUCCESS = "SUCCESS"
_WX_STATE_FAILED = {"FAIL", "CANCELLED", "CLOSED"}
_WX_STATE_WAIT_CONFIRM = "WAIT_USER_CONFIRM" # 用户还没在微信确认页确认
_WITHDRAW_ACTIVE_STATUSES = {"reviewing", "pending"}
# 占用新人档「一次性」资格的提现状态:进行中(reviewing/pending)或成功打款(success)。
# 被拒/转账失败/解绑退回(rejected/failed,均已退款、钱没到手)不在此列 → 新人档恢复可提
# (2026-07-16 修正:此前判定不看状态,解绑微信退回后 0.1 被误判已用、资格永久锁死)。
_NEWBIE_TIER_HELD_STATUSES = {"reviewing", "pending", "success"}
# 免确认收款授权状态
_WX_AUTH_ACTIVE = "TAKING_EFFECT" # 已生效,可免确认转账
_WX_AUTH_CLOSED = "CLOSED" # 已关闭(用户/商户/风控),需重新开启
@@ -72,10 +67,6 @@ class WithdrawTooFrequentError(Exception):
"""提现申请过于频繁,或已有未完成提现单。"""
class WithdrawTierUnavailableError(Exception):
"""该档位今日不可提:次数已满,或今天已选了其他额度(7-9 福利页档位规则)。"""
class WithdrawTransferError(Exception):
"""调用微信转账失败(已退回余额)。"""
@@ -379,7 +370,6 @@ def bind_wechat_openid(db: Session, user_id: int, code: str) -> dict:
user.wechat_openid = info["openid"]
user.wechat_nickname = info["nickname"]
user.wechat_avatar_url = info["avatar_url"]
apply_wechat_display_identity(user, wechat_nickname=info["nickname"], wechat_avatar_url=info["avatar_url"])
db.commit()
return info
@@ -615,91 +605,6 @@ def _settle_after_ambiguous(db: Session, order: WithdrawOrder, reason: str) -> N
db.commit()
def _beijing_today_start_utc() -> datetime:
"""北京时今日 0 点(转 UTC)。WithdrawOrder.created_at 是 func.now()(UTC)存储,
比较时统一转 UTC, admin 看板 today_start 同口径(admin/repositories/queries.py)"""
return (
datetime.now(rewards.CN_TZ)
.replace(hour=0, minute=0, second=0, microsecond=0)
.astimezone(timezone.utc)
)
def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") -> list[dict]:
"""福利页(coin_cash)提现档位的可提现状态。withdraw-info 下发与 create_withdraw 校验共用此口径。
规则(2026-07-09 拍板,7-9提现ui对齐;新人档判定 2026-07-16 修正):
- 新人档(0.1/0.3):账号历史一次性进行中(reviewing/pending)或成功打款(success)即视为
已用,直接**从返回列表消失**;被拒/转账失败/解绑退回(均已退款钱没到手)则恢复可提,不永久
占用资格两档各自独立互不影响,不参与"每日选一个额度"互斥
- 常规档(0.5×3 / 10×1 / 20×1):按北京日计次,"发起就算占用"(当天创建的单不论最终状态
都计入,被拒/失败不退当天名额);三档每天只能选一个,选定后其余两档当天 other_tier_selected
- invite_cash 本轮无档位概念 返回空列表(邀请页客户端仍用本地写死档位,行为不变)
余额是否足够由客户端本地判断(余额随兑换实时变化,不在此快照)
"""
if source != "coin_cash":
return []
tiers = rewards.WITHDRAW_TIERS_COIN_CASH
amounts = [t.amount_cents for t in tiers]
newbie_amounts = [t.amount_cents for t in tiers if t.is_newbie]
# 新人档历史是否用过:进行中或已成功打款的单占用资格;被拒/失败/解绑退回(已退款)不算(恢复可提)
used_newbie: set[int] = set(
db.execute(
select(WithdrawOrder.amount_cents)
.distinct()
.where(
WithdrawOrder.user_id == user_id,
WithdrawOrder.source == "coin_cash",
WithdrawOrder.amount_cents.in_(newbie_amounts),
WithdrawOrder.status.in_(_NEWBIE_TIER_HELD_STATUSES),
)
).scalars()
) if newbie_amounts else set()
# 今日(北京日)每档已发起次数(任意状态)
today_counts: dict[int, int] = {
int(amount): int(cnt)
for amount, cnt in db.execute(
select(WithdrawOrder.amount_cents, func.count(WithdrawOrder.id))
.where(
WithdrawOrder.user_id == user_id,
WithdrawOrder.source == "coin_cash",
WithdrawOrder.amount_cents.in_(amounts),
WithdrawOrder.created_at >= _beijing_today_start_utc(),
)
.group_by(WithdrawOrder.amount_cents)
)
}
# "每日选一个额度":今天发起过的常规档(新人档不算)
selected_regular = next(
(t.amount_cents for t in tiers if not t.is_newbie and today_counts.get(t.amount_cents, 0) > 0),
None,
)
out: list[dict] = []
for t in tiers:
if t.is_newbie:
if t.amount_cents in used_newbie:
continue # 用过即消失,不再下发
out.append({
"amount_cents": t.amount_cents, "label": t.label, "badge": t.badge,
"is_newbie": True, "available": True, "disabled_reason": None,
"remaining_today": 1,
})
continue
used = today_counts.get(t.amount_cents, 0)
if selected_regular is not None and selected_regular != t.amount_cents:
available, reason, remaining = False, "other_tier_selected", 0
elif used >= t.daily_limit:
available, reason, remaining = False, "quota_exhausted", 0
else:
available, reason, remaining = True, None, t.daily_limit - used
out.append({
"amount_cents": t.amount_cents, "label": t.label, "badge": t.badge,
"is_newbie": False, "available": available, "disabled_reason": reason,
"remaining_today": remaining,
})
return out
def create_withdraw(
db: Session,
user_id: int,
@@ -755,19 +660,6 @@ def create_withdraw(
if active_order_id is not None:
raise WithdrawTooFrequentError
# 福利页档位闸(7-9):coin_cash 只能提预设档位,且该档今日可提(服务端权威口径,防绕过
# 客户端刷)。放在幂等返回/在途互斥之后:同号重试仍原样返回旧单,不被档位闸误杀。
# allow_sub_min(0.01 调试直发)保持原样放行,不受档位约束;invite_cash 本轮无档位概念不校验。
if source == "coin_cash" and not allow_sub_min:
tier_state = next(
(t for t in withdraw_tier_states(db, user_id, source) if t["amount_cents"] == amount_cents),
None,
)
if tier_state is None: # 非预设档位金额,或新人档已用过(已从列表消失)
raise InvalidWithdrawAmountError
if not tier_state["available"]:
raise WithdrawTierUnavailableError
# 账户须存在(原子扣款的 UPDATE 不会建账户)
get_or_create_account(db, user_id, commit=True)
-6
View File
@@ -63,12 +63,6 @@ class EcpmReportIn(BaseModel):
description="点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页);"
"比价与领券共用同一 Draw 代码位,需客户端在各调用点显式标注,供收益报表区分比价/领券;激励视频为空",
)
trace_id: str | None = Field(
None,
max_length=64,
description="本次比价/领券 trace_id(信息流场景带上):把这条展示收益归属到对应比价/领券,"
"供领券数据/比价记录看板聚合本场广告收益;激励视频/福利为空",
)
app_env: str | None = Field(
None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)"
)
-34
View File
@@ -1,34 +0,0 @@
"""自报计数上报 schema。字段名对齐客户端 payload(snake_case),累计值语义。"""
from __future__ import annotations
from pydantic import BaseModel, Field
class SelfStatEventIn(BaseModel):
event: str = Field(max_length=64)
attempted: int = 0
drop_capture: int = 0
delivered: int = 0
drop_undelivered: int = 0
class SelfStatBatchIn(BaseModel):
device_id: str = Field(max_length=64)
epoch_id: str = Field(max_length=64)
sent_at: int | None = None
app_ver: str | None = Field(default=None, max_length=32)
oem: str | None = Field(default=None, max_length=32)
os: str | None = Field(default=None, max_length=32)
batches_attempted: int = 0
batches_ok: int = 0
batches_fail: int = 0
retries: int = 0
queue_depth: int = 0
# 允许空列表:某次快照只上报设备级计数(batches_*/retries/queue_depth)、无 event 细分时也合法
# (与 AnalyticsBatchIn 的 min_length=1 有意不同——那是行为事件、必须至少一条)。
events: list[SelfStatEventIn] = Field(default_factory=list, max_length=200)
class SelfStatIngestOut(BaseModel):
ok: bool = True
snapshot_id: int
-61
View File
@@ -102,64 +102,3 @@ class RefreshRequest(BaseModel):
class LogoutResponse(BaseModel):
ok: bool = True
# ===== 微信登录 =====
class WechatLoginRequest(BaseModel):
code: str = Field(..., min_length=1, description="微信 App 授权拿到的 code(单次有效)")
device_id: str = Field(
"", max_length=64,
description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理",
)
class WechatLoginResponse(BaseModel):
# status="logged_in" → openid 命中,token 有值;"need_bind_phone" → 未命中,bind_ticket 有值
status: str
token: TokenWithUser | None = None
bind_ticket: str | None = None
wechat_nickname: str | None = None
wechat_avatar_url: str | None = None
class OccupiedAccountInfo(BaseModel):
"""手机号被占用时返回的原账号脱敏展示信息(供冲突页)。"""
nickname: str | None = None
avatar_url: str | None = None
created_at: datetime
has_wechat: bool = False
class WechatBindResultResponse(BaseModel):
# status="logged_in" → 未占用,已建号登入,token 有值;
# "phone_occupied" → 手机号被占用,occupied_account + conflict_ticket 有值,token 为 None
status: str
token: TokenWithUser | None = None
occupied_account: OccupiedAccountInfo | None = None
conflict_ticket: str | None = None # 占用时签发,换绑/继续绑定只认它
rebind_available: bool | None = None # 该手机号 30 天内是否还能换绑(给换绑按钮预置禁用态)
rebind_blocked_days: int | None = None # 被限时剩余天数(rebind_available=False 时>0)
class WechatBindPhoneSmsRequest(BaseModel):
bind_ticket: str = Field(..., min_length=1)
phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$")
code: str = Field(..., min_length=4, max_length=8)
device_id: str = Field("", max_length=64)
class WechatBindPhoneJverifyRequest(BaseModel):
bind_ticket: str = Field(..., min_length=1)
login_token: str = Field(..., min_length=1, description="客户端 loginAuth 拿到的 loginToken")
device_id: str = Field("", max_length=64)
class WechatConflictContinueRequest(BaseModel):
conflict_ticket: str = Field(..., min_length=1)
device_id: str = Field("", max_length=64)
class WechatConflictRebindRequest(BaseModel):
conflict_ticket: str = Field(..., min_length=1)
device_id: str = Field("", max_length=64)
-11
View File
@@ -46,17 +46,6 @@ class AdConfigPublicOut(BaseModel):
withdrawal_ad_enabled: bool # 提现激励视频开关(关=客户端直接放行提现)
class HuaweiReviewOut(BaseModel):
"""华为审核开关下发给客户端(不鉴权,引导页在登录前就要展示)。
客户端只需读 onboarding_closable 决策;mode 仅供排查问题时看后台切成了哪态
只有华为 ROM(HarmonyOS/EMUI,不含荣耀 MagicOS)的客户端才会来拉这个端点
"""
mode: str = "default" # default | review
onboarding_closable: bool = False # 快速设置权限步是否允许用户退出(mode == review)
class AppVersionOut(BaseModel):
"""最新 App 版本信息(OTA 检查更新,不鉴权)。
-19
View File
@@ -75,21 +75,6 @@ class ExchangeResultOut(BaseModel):
# ===== 提现(现金 → 微信零钱) =====
class WithdrawTierOut(BaseModel):
"""提现档位(福利页 coin_cash;7-9 对齐原型)。served by rewards.WITHDRAW_TIERS_COIN_CASH。"""
amount_cents: int = Field(..., description="档位金额(分)")
label: str = Field(..., description="档位方块展示文案,如 0.1 / 10")
badge: str | None = Field(None, description="角标文案(如 新人福利);无则空")
is_newbie: bool = Field(False, description="新人档:历史一次性,用过后不再下发;免广告直提")
available: bool = Field(True, description="当前是否可提(次数/选一额度口径;余额由客户端自判)")
disabled_reason: str | None = Field(
None,
description="不可提原因:quota_exhausted(今日次数满) / other_tier_selected(今日已选其他额度)",
)
remaining_today: int = Field(0, description="今日剩余可提次数")
class WithdrawInfoOut(BaseModel):
min_cents: int = Field(..., description="单次最低提现(分)")
max_cents: int = Field(..., description="单次最高提现(分)")
@@ -99,10 +84,6 @@ class WithdrawInfoOut(BaseModel):
transfer_auth_enabled: bool = Field(
False, description="是否已开启免确认到账(开启后提现免跳微信确认,直接到账)"
)
tiers: list[WithdrawTierOut] = Field(
default_factory=list,
description="提现档位(source=coin_cash 下发;invite_cash 为空,客户端走旧逻辑)",
)
# ===== 免确认收款授权(用户授权免确认模式)=====
-53
View File
@@ -1,53 +0,0 @@
"""LLM 调用成本计算(纯逻辑,无 DB):按 model 分桶累加 token × 单价,返回总成本(元)+ 价格快照。
用量取自 comparison_record.llm_calls[].usage(pricebot 已归一为 prompt/completion_tokens);
error / usage 的调用跳过price_cfg = {per_model:{model:{input_per_1m,output_per_1m}}, default:{...}}
成本单位单次亚分级, float(不用 *_cents);snapshot 只含本次用到的模型的价(审计用,
不存整张价表)用到但没配价(既无 per_model 又无 default)的模型 快照标 unpriced,成本按 0
"""
from __future__ import annotations
_PRICE_KEY = "llm_token_price"
def get_llm_prices(db) -> dict:
"""读 LLM 单价配置(app_config;表内无则回退 CONFIG_DEFS 默认)。返回 compute_llm_cost 的 price_cfg。"""
from app.repositories import app_config # 延迟 import:compute_llm_cost 纯逻辑不牵连 DB 层
return app_config.get_value(db, _PRICE_KEY)
def compute_llm_cost(calls: list[dict], price_cfg: dict) -> tuple[float | None, dict | None]:
"""遍历 calls 按 model 分桶,cost = Σ(入/1e6*入价 + 出/1e6*出价);无有效调用 → (None, None)。"""
if not calls:
return None, None
per_model = price_cfg.get("per_model") or {}
default = price_cfg.get("default")
buckets: dict[str, list[int]] = {} # model -> [Σprompt_tokens, Σcompletion_tokens]
for c in calls:
if c.get("error"):
continue
usage = c.get("usage") or {}
model = c.get("model") or "unknown"
b = buckets.setdefault(model, [0, 0])
b[0] += usage.get("prompt_tokens") or 0
b[1] += usage.get("completion_tokens") or 0
if not buckets: # 全是 error / 无 usage
return None, None
total = 0.0
prices: dict[str, dict] = {}
for model, (tin, tout) in buckets.items():
price = per_model.get(model, default)
in_p = price.get("input_per_1m") if isinstance(price, dict) else None
out_p = price.get("output_per_1m") if isinstance(price, dict) else None
# 没配价 / 无 default / 单价残缺或非法(配置页手改 JSON 可能存出脏数据)→ 标记待补价、
# 不计入成本;绝不抛异常,以免连累同一回填里的 token/llm_calls 落库。
if not isinstance(in_p, (int, float)) or not isinstance(out_p, (int, float)):
prices[model] = {"input_per_1m": in_p, "output_per_1m": out_p, "unpriced": True}
continue
total += tin / 1e6 * in_p + tout / 1e6 * out_p
prices[model] = {
"input_per_1m": in_p,
"output_per_1m": out_p,
"_source": "per_model" if model in per_model else "default",
}
return round(total, 6), {"mode": "per_model", "prices": prices}
+1 -5
View File
@@ -19,11 +19,7 @@ server {
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m;
# 上传接口(反馈/上报截图、头像)业务上限 = 最多 6 张 × 每张 5MB
# (见 app _MAX_IMAGES / AVATAR_MAX_BYTES)≈ 30MB,留余量设 32m。
# 低于此值时带截图的反馈会在到达 uvicorn 前就被 nginx 413,表现为「提交经常失败」
# (纯文字反馈体积小、不受影响 → 呈现为「时好时坏」)。根治仍需客户端上传前压缩。
client_max_body_size 32m;
client_max_body_size 4m;
location / {
proxy_pass http://127.0.0.1:8770;
-57
View File
@@ -1,57 +0,0 @@
# OpenObserve 监控台反代(observe.shaguabijia.com)。证书走 Certbot/Let's Encrypt,与 admin-web 一致。
#
# 前置(一次性):
# 1) DNS: observe.shaguabijia.com A 记录 → 本服务器公网 IP
# 2) 证书: sudo certbot certonly --nginx -d observe.shaguabijia.com
# (options-ssl-nginx.conf / ssl-dhparams.pem 首次跑 certbot 时已生成,admin-web 在用即已存在)
# 3) OpenObserve 只绑 127.0.0.1:5080(见 docker-compose.prod.yml),本文件把它反代出公网
# 4) nginx -t 通过后 systemctl reload nginx
#
# 安全:OO 有自身登录。监控台不必对全网裸开——本机办公网无固定出口 IP,故在 nginx 层加 Basic Auth 兜底;
# 将来有固定 IP 可改用【IP 白名单】块(更省事,可去掉 Basic Auth)。
server {
server_name observe.shaguabijia.com;
client_max_body_size 10m;
# —— IP 白名单:办公网无固定出口 IP,暂不用;将来有固定 IP 可改用这块(比 Basic Auth 省事)——
# allow 1.2.3.4; # ← 换成你的真实出口 IP,可多行
# deny all;
# —— Basic Auth:无固定 IP 的兜底密码(生成 .htpasswd_observe 的命令见 README/下方)——
auth_basic "OpenObserve";
auth_basic_user_file /etc/nginx/conf.d/.htpasswd_observe;
location / {
proxy_pass http://127.0.0.1:5080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# OpenObserve 有实时/流式面板,需透传 WebSocket
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
}
# IPv6 这行不带 ipv6only=on:该选项对 [::]:443 全局只能设一次,admin-web 那个 server 块已设(否则 nginx 报 duplicate listen options)
listen [::]:443 ssl; # managed by Certbot
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/observe.shaguabijia.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/observe.shaguabijia.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = observe.shaguabijia.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
listen [::]:80;
server_name observe.shaguabijia.com;
return 404; # managed by Certbot
}
-4
View File
@@ -1,4 +0,0 @@
# OpenObserve 落盘数据(parquet/索引/元数据),运行时产生,不入库。
data/
# 生产 compose 的密码文件(OO_ROOT_PASSWORD),含机密,不入库。
.env
-126
View File
@@ -1,126 +0,0 @@
# OpenObserve 本地部署(接口 QPS / 耗时可观测)
app-server 通过中间件采集每个接口的 QPS + 耗时 + 错误率,批量上报到这里。
设计见 [../../docs/superpowers/specs/2026-07-06-openobserve-api-metrics-design.md](../../docs/superpowers/specs/2026-07-06-openobserve-api-metrics-design.md)。
## 启动
```bash
cd deploy/openobserve
docker compose up -d
```
- Web UIhttp://localhost:5080
- 登录:`admin@shaguabijia.local` / `Complexpass#123`(见 `docker-compose.yml`
- 数据落 `deploy/openobserve/data/`(已挂卷持久化;该目录已 gitignore)
## 让 app-server 上报
在项目根的 `.env` 打开观测(`OBSERVE_*`,账号密码与 compose 里 root 一致):
```dotenv
OBSERVE_ENABLED=true
OBSERVE_ENDPOINT=http://localhost:5080
OBSERVE_ORG=default
OBSERVE_STREAM=app_requests
OBSERVE_USER=admin@shaguabijia.local
OBSERVE_PASSWORD=Complexpass#123
```
重启 app-server,随便打几个接口。stream `app_requests` **首次上报自动创建**
在 UI 的 Logs → 选 `app_requests` 就能看到逐条请求事件(字段:`method` / `route` /
`status` / `duration_ms` / `service` / `env`)。
> 未开 `OBSERVE_ENABLED` 或缺账号密码时,中间件透传、worker 不启动,整套 no-op,不影响业务。
## 查询(Logs 页 SQL,或建 Dashboard 面板)
各接口 QPS(1 分钟分桶,面板里再除 60 得每秒):
```sql
SELECT route, histogram(_timestamp, '1 minute') AS ts, count(*) AS cnt
FROM app_requests GROUP BY route, ts ORDER BY ts
```
各接口 P95 耗时(毫秒):
```sql
SELECT route, approx_percentile_cont(duration_ms, 0.95) AS p95_ms
FROM app_requests GROUP BY route ORDER BY p95_ms DESC
```
各接口错误率(5xx 占比):
```sql
SELECT route,
count(*) FILTER (WHERE status >= 500) * 100.0 / count(*) AS err_pct
FROM app_requests GROUP BY route ORDER BY err_pct DESC
```
## 一键导入现成仪表盘(QPS / P95 / 分位 / 错误率)
备好了 [dashboard-api-metrics.json](dashboard-api-metrics.json),4 个面板:各接口每分钟请求数(QPS 源)、
P95 耗时折线、P50/P95/P99 分位表、5xx 错误率表。
- **UI 导入**Dashboards → 右上 **Import** → 选该 JSON 文件 → Import(每次导入新建,不覆盖)。
- **或 API 导入**
```bash
curl -u admin@shaguabijia.local:Complexpass#123 -H 'Content-Type: application/json' \
-X POST 'http://localhost:5080/api/default/dashboards?folder=default' \
--data-binary @deploy/openobserve/dashboard-api-metrics.json
```
导入后进仪表盘,右上角时间调到「最近 15 分钟 / 1 小时」、开自动刷新即可。低流量下 QPS 面板看「每分钟请求数」比「每秒」直观。
## 停止 / 清数据
```bash
docker compose down # 停止(保留数据)
docker compose down -v && rm -rf data # 停止并清空数据
```
## 生产部署(单机)+ UI 访问
前提:app-server 与 OpenObserve **同机**,app→OO 走 localhost(`127.0.0.1:5080`)、不出网、无需 TLS。
唯一要防的是**别把 :5080 裸暴露公网**。硬化版编排见 [docker-compose.prod.yml](docker-compose.prod.yml)。
### 部署步骤
```bash
# 1) 密码文件(本目录,已 gitignore)
echo "OO_ROOT_PASSWORD=$(python -c 'import secrets;print(secrets.token_urlsafe(24))')" > deploy/openobserve/.env
# 2) 起 OpenObserve(只绑 127.0.0.1、命名卷持久化、mem 1g)
cd deploy/openobserve && docker compose -f docker-compose.prod.yml up -d
sudo systemctl enable docker # 开机自起
```
3) app-server 的 `.env` 打开观测并**重启**(用非 root 的专用 ingest 账号):
```dotenv
OBSERVE_ENABLED=true
OBSERVE_ENDPOINT=http://127.0.0.1:5080
OBSERVE_ORG=default
OBSERVE_STREAM=app_requests
OBSERVE_USER=ingest@shaguabijia.com # UI → Users 建的非 root 账号
OBSERVE_PASSWORD=<该账号密码>
```
```bash
sudo systemctl restart shaguabijia-app-server # 日志出现 "observe worker started" 即生效
```
4) 两个必做收口(磁盘/安全):
- **保留期**:UI → Streams → `app_requests` → Data Retention 设 14/30 天(一请求一行,不封顶迟早撑爆盘)。
- **专用账号**:UI → Users 建非 root 账号给 app 上报,root 只留人工登 UI。
### UI 访问(二选一)
**A. SSH 隧道(推荐,零暴露、不用域名/证书):**
```bash
ssh -L 5080:127.0.0.1:5080 用户@服务器IP
# 然后本机浏览器开 http://localhost:5080
```
**B. nginx 子域名反代(要固定 URL / 团队常看):** 见 [../nginx/observe.shaguabijia.com.conf](../nginx/observe.shaguabijia.com.conf)。
需 DNS `observe.shaguabijia.com` → 本机 + 证书放 `/etc/nginx/ssl/`;含 IP 白名单 + TLS + WebSocket 透传。
> ⚠️ prod compose 必须保持 `127.0.0.1:5080:5080`;写成 `5080:5080`(绑 0.0.0.0)= 裸暴露公网,这是唯一真正的坑。
@@ -1,302 +0,0 @@
{
"version": 8,
"dashboardId": "api-metrics",
"title": "接口监控 (QPS / 耗时 / 错误率)",
"description": "app-server 接口 QPS、P50/P95/P99 耗时、5xx 错误率。数据流 app_requests。",
"role": "",
"tabs": [
{
"tabId": "default",
"name": "Default",
"panels": [
{
"id": "panel_qps",
"type": "line",
"title": "各接口 每分钟请求数 (QPS 源)",
"description": "",
"config": {
"show_legends": true,
"legends_position": null,
"decimals": 2.0,
"axis_border_show": false,
"base_map": null,
"map_view": null
},
"queryType": "sql",
"queries": [
{
"query": "SELECT histogram(_timestamp, '1 minute') as ts, route, count(*) as reqs FROM app_requests GROUP BY ts, route ORDER BY ts",
"vrlFunctionQuery": "",
"customQuery": true,
"fields": {
"stream": "app_requests",
"stream_type": "logs",
"x": [
{
"label": "ts",
"alias": "ts",
"column": "ts",
"color": null,
"sortBy": "ASC"
}
],
"y": [
{
"label": "reqs",
"alias": "reqs",
"column": "reqs",
"color": null
}
],
"z": [],
"breakdown": [
{
"label": "route",
"alias": "route",
"column": "route",
"color": null
}
],
"filter": {
"filterType": "group",
"logicalOperator": "AND",
"conditions": []
}
},
"config": {
"promql_legend": "",
"layer_type": "scatter",
"weight_fixed": 1.0
}
}
],
"layout": {
"x": 0,
"y": 0,
"w": 24,
"h": 9,
"i": 1
}
},
{
"id": "panel_p95",
"type": "line",
"title": "各接口 P95 耗时 (ms)",
"description": "",
"config": {
"show_legends": true,
"legends_position": null,
"decimals": 2.0,
"axis_border_show": false,
"base_map": null,
"map_view": null
},
"queryType": "sql",
"queries": [
{
"query": "SELECT histogram(_timestamp, '1 minute') as ts, route, approx_percentile_cont(duration_ms, 0.95) as p95_ms FROM app_requests GROUP BY ts, route ORDER BY ts",
"vrlFunctionQuery": "",
"customQuery": true,
"fields": {
"stream": "app_requests",
"stream_type": "logs",
"x": [
{
"label": "ts",
"alias": "ts",
"column": "ts",
"color": null,
"sortBy": "ASC"
}
],
"y": [
{
"label": "p95_ms",
"alias": "p95_ms",
"column": "p95_ms",
"color": null
}
],
"z": [],
"breakdown": [
{
"label": "route",
"alias": "route",
"column": "route",
"color": null
}
],
"filter": {
"filterType": "group",
"logicalOperator": "AND",
"conditions": []
}
},
"config": {
"promql_legend": "",
"layer_type": "scatter",
"weight_fixed": 1.0
}
}
],
"layout": {
"x": 24,
"y": 0,
"w": 24,
"h": 9,
"i": 2
}
},
{
"id": "panel_pctl",
"type": "table",
"title": "各接口 耗时分位 P50/P95/P99 (ms)",
"description": "",
"config": {
"show_legends": true,
"legends_position": null,
"decimals": 2.0,
"axis_border_show": false,
"base_map": null,
"map_view": null
},
"queryType": "sql",
"queries": [
{
"query": "SELECT route, approx_percentile_cont(duration_ms,0.5) as p50, approx_percentile_cont(duration_ms,0.95) as p95, approx_percentile_cont(duration_ms,0.99) as p99, count(*) as cnt FROM app_requests GROUP BY route ORDER BY p95 DESC",
"vrlFunctionQuery": "",
"customQuery": true,
"fields": {
"stream": "app_requests",
"stream_type": "logs",
"x": [
{
"label": "route",
"alias": "route",
"column": "route",
"color": null
}
],
"y": [
{
"label": "p50",
"alias": "p50",
"column": "p50",
"color": null
},
{
"label": "p95",
"alias": "p95",
"column": "p95",
"color": null
},
{
"label": "p99",
"alias": "p99",
"column": "p99",
"color": null
},
{
"label": "cnt",
"alias": "cnt",
"column": "cnt",
"color": null
}
],
"z": [],
"breakdown": [],
"filter": {
"filterType": "group",
"logicalOperator": "AND",
"conditions": []
}
},
"config": {
"promql_legend": "",
"layer_type": "scatter",
"weight_fixed": 1.0
}
}
],
"layout": {
"x": 0,
"y": 9,
"w": 24,
"h": 9,
"i": 3
}
},
{
"id": "panel_err",
"type": "table",
"title": "各接口 错误率 (5xx %)",
"description": "",
"config": {
"show_legends": true,
"legends_position": null,
"decimals": 2.0,
"axis_border_show": false,
"base_map": null,
"map_view": null
},
"queryType": "sql",
"queries": [
{
"query": "SELECT route, count(*) FILTER (WHERE status >= 500) * 100.0 / count(*) as err_pct, count(*) as cnt FROM app_requests GROUP BY route ORDER BY err_pct DESC",
"vrlFunctionQuery": "",
"customQuery": true,
"fields": {
"stream": "app_requests",
"stream_type": "logs",
"x": [
{
"label": "route",
"alias": "route",
"column": "route",
"color": null
}
],
"y": [
{
"label": "err_pct",
"alias": "err_pct",
"column": "err_pct",
"color": null
},
{
"label": "cnt",
"alias": "cnt",
"column": "cnt",
"color": null
}
],
"z": [],
"breakdown": [],
"filter": {
"filterType": "group",
"logicalOperator": "AND",
"conditions": []
}
},
"config": {
"promql_legend": "",
"layer_type": "scatter",
"weight_fixed": 1.0
}
}
],
"layout": {
"x": 24,
"y": 9,
"w": 24,
"h": 9,
"i": 4
}
}
]
}
],
"variables": {
"list": []
}
}
@@ -1,33 +0,0 @@
# 生产用 OpenObserve(单机)。相对本地版 docker-compose.yml 的区别:
# - 端口只绑 127.0.0.1 → 公网/外网都到不了(UI 访问走 SSH 隧道或 nginx 反代,见 README)
# - root 密码走环境变量(放同目录 .env,已 gitignore,勿提交)
# - 数据 bind-mount 到宿主 /data 分区(需预建目录 + 确认容器可写)+ CPU/内存上限(与 app/PG 共存防抢内存)
#
# 用法:
# 1) 本目录建 .env(已 gitignore):
# OO_ROOT_PASSWORD=<强随机串> # 生成: python -c "import secrets;print(secrets.token_urlsafe(24))"
# 2) docker compose -f docker-compose.prod.yml up -d
# 3) 开机自起: sudo systemctl enable docker
services:
openobserve:
image: public.ecr.aws/zinclabs/openobserve:v0.91.2
container_name: openobserve
ports:
- "127.0.0.1:5080:5080" # 只绑本机,安全
environment:
ZO_ROOT_USER_EMAIL: "admin@shaguabijia.com"
ZO_ROOT_USER_PASSWORD: "${OO_ROOT_PASSWORD:?请先在 deploy/openobserve/.env 里设 OO_ROOT_PASSWORD}"
ZO_DATA_DIR: "/data"
ZO_COMPACT_DATA_RETENTION_DAYS: "30" # 超 30 天自动删,防爆盘(默认 3650 天=10年)
ZO_TELEMETRY: "false" # 关匿名遥测(内网自用);变量名是 ZO_TELEMETRY,不是 *_ENABLED
volumes:
- /data/openobserve/data:/data # 绑定挂载到宿主机的 /data/openobserve/data 目录(建议该目录所在分区有 20G+ 空间)
restart: unless-stopped
deploy:
resources:
limits: # 硬上限:防 OO 查询/ingest 抢爆 CPU/内存,拖垮同机 PG+app
cpus: '2.0'
memory: 3G
logging: # 容器 stdout 日志上限,防爆盘
driver: json-file
options: { max-size: "10m", max-file: "3" }
-16
View File
@@ -1,16 +0,0 @@
# 本地开发用 OpenObserve(单容器 = local 模式)。用于接收 app-server 的接口指标(QPS/耗时/错误率)。
# 启动: cd deploy/openobserve && docker compose up -d
# Web UI: http://localhost:5080 (账号见下方 env)
services:
openobserve:
image: public.ecr.aws/zinclabs/openobserve:latest
container_name: openobserve
ports:
- "5080:5080"
environment:
ZO_ROOT_USER_EMAIL: "admin@shaguabijia.local"
ZO_ROOT_USER_PASSWORD: "Complexpass#123"
ZO_DATA_DIR: "/data"
volumes:
- ./data:/data
restart: unless-stopped
-2
View File
@@ -115,7 +115,6 @@
| 40a | `GET /api/v1/platform/flags` | 无 | [详情](./platform/platform-flags.md)(客户端运营 feature flag,比价/领券期广告开关等,拉取后缓存) |
| 40b | `GET /api/v1/platform/ad-config` | 无 | [详情](./platform/platform-ad-config.md)(客户端拉广告配置:穿山甲 app_id+各位ID+各场景开关;不含验签密钥) |
| 40c | `GET /api/v1/platform/app-version` | 无 | [详情](./platform/platform-app-version.md)(最新 App 版本,OTA 检查更新;与本机 versionCode 比) |
| 40d | `GET /api/v1/platform/huawei-review` | 无 | [详情](./platform/platform-huawei-review.md)(华为审核开关:快速设置权限步能否被用户关闭;仅华为 ROM 客户端拉) |
| **微信支付回调**(前缀 `/api/v1/wxpay` |||
| W1 | `POST /api/v1/wxpay/transfer-auth-notify` | 无 | 免确认收款授权结果通知(一期 stub:仅应答 200 不验签不改账,授权状态靠主动查询兜底)(无单独文档) |
| **CPS 群发短链落地**(**无前缀**,挂域名根;公网不鉴权) |||
@@ -154,7 +153,6 @@
| A12 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin/ad/admin-ad-revenue-report.md)(广告收益报表:分页/场景/`app_env` 筛 + **DAU/ARPU** #120;真实收益侧接穿山甲日表 #92) |
| A13 | `GET / PATCH /admin/api/ad-config` | operator/finance | 广告配置(穿山甲 ID/验签密钥/各场景开关;C 端只读版见 40b)(无单独文档,见 `app/admin/routers/ad_config.py`) |
| A14 | `GET /admin/api/config``PATCH /config/{key}` | operator/finance | 运营可配置项([app_config](../database/app_config.md):奖励常量/提现地板价等;#117 修系统配置下发)(无单独文档,见 `app/admin/routers/config.py`) |
| A16 | `GET / PATCH /admin/api/huawei-review` | operator/tech | 华为审核开关(快速设置权限步能否被用户关闭,落 `app_config.huawei_review`;C 端只读版见 40d)(无单独文档,见 `app/admin/routers/huawei_review.py`) |
| **A·管理员与角色**(super_admin):`GET`/`POST` `/admins``PATCH`/`DELETE` `/admins/{id}`(#126 删除+`pages_override`)、`GET`/`POST` `/roles``GET /roles/catalog``PATCH`/`DELETE` `/roles/{id}`(#117/#126 自定义角色) ||| [列表](./admin/admins/admin-admins-list.md) / [](./admin/admins/admin-admin-create.md) / [改+删](./admin/admins/admin-admin-update.md) / [角色](./admin/admin-roles.md) |
| A15 | `GET /admin/api/audit-logs` | admin | [详情](./admin/admin-audit-logs.md) |
| **A·CPS 运营台**:群/活动 CRUD、`POST /referral-links``POST /orders/reconcile`(美团+京东 #90)、`GET /orders``/stats`、群 `timeseries`/`daily`/`wx-users`/`day-users`(#79) ||| [详情](./admin/admin-cps.md) |
@@ -1,36 +0,0 @@
# GET /api/v1/platform/huawei-review — 华为审核开关
> 所属:Platform 组(前缀 `/api/v1/platform` | 鉴权:无 | [← 返回 API 索引](../README.md)
华为应用市场审核要求:新手引导的**「快速设置」权限步必须可被用户关闭**(引导视频页不在要求内)。本端点把运营后台配的开关下发给客户端,决定该步左上角是否出现退出按钮。
**不鉴权**:引导页在登录之前就展示,此时客户端必然没有 token。
值来自 `app_config` 表的 `huawei_review` 行(admin 页 `GET / PATCH /admin/api/huawei-review` 可改),空库回退 `default`
## 入参
无。
## 出参
响应 `200`:`HuaweiReviewOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `mode` | string | `default`(强制展示,不可关闭 = 上线至今的现状)/ `review`(可关闭,过审用)。仅供排查时看后台切成了哪态 |
| `onboarding_closable` | bool | 快速设置权限步是否允许用户退出(= `mode == "review"`)。**客户端只读这一个字段决策** |
Mock 出参:
```json
{
"mode": "review",
"onboarding_closable": true
}
```
## 说明
- **只有华为 ROM 客户端会来拉**HarmonyOS / EMUI;荣耀 MagicOS 不拉)。机型判断在客户端做(`OemDetector`),服务端不看 UA,也就不用维护机型名单。
- 客户端在进新手引导前拉一次并本地缓存;请求失败/超时用上次缓存值,从未拉到过则按 `onboarding_closable=false`(宁可不给退出按钮,也不误放开)。
- 脏值兜底:DB 里 `mode` 不在枚举内时服务端一律回退 `default`
- 切回 `default` 即可一键收回退出按钮(审核通过后无需发版)。
-2
View File
@@ -42,8 +42,6 @@
| `ad_ecpm_record` | 广告展示 eCPM 上报(收益对账) | `models/ad_ecpm.py` | [详情](./ad_ecpm_record.md) |
| `ad_feed_reward_record` | 信息流/Draw 广告结算记录(10 秒一份,client_event_id 幂等;`ad_type`+`feed_scene` 分形态/场景) | `models/ad_feed_reward.py` | [详情](./ad_feed_reward_record.md) |
| `ad_pangle_daily_revenue` | 穿山甲 GroMore 后台收益日表(定时拉取,收益报表/大盘真实收益源,#92) | `models/ad_pangle_revenue.py` | [详情](./ad_pangle_daily_revenue.md) |
| `inactivity_reset_log` | 15 天不活跃清零审计(每次清零一行;清零前三桶余额快照+原因+不活跃天数;只清金币+现金,邀请金仅快照) | `models/inactivity.py` | [详情](./inactivity_reset_log.md) |
| `inactivity_notification_log` | 不活跃清零前预警记录(余额快照+档位+通道+状态;streak 去重依据 + 占位 outbox) | `models/inactivity.py` | [详情](./inactivity_notification_log.md) |
### 比价 / 省钱
| 表 | 用途 | 模型 | 文档 |
-9
View File
@@ -29,12 +29,3 @@
## 注意
- 不缓存:配置读频率低(每次福利操作读一次,主键查极快),admin 改了立即生效、跨进程一致(多 worker 也对)。
- 新增可配项 = 在 `CONFIG_DEFS` 加一条 + 业务处改用 `app_config.get_value(db, key)` 读;不需要建迁移(行是动态插的,表结构不变)。
## 专用 key(借表不进 CONFIG_DEFS)
有自己的语义与专用管理页的配置,复用本表但**不注册进 `CONFIG_DEFS`**——混进通用「系统配置」页只会显示成一个没头没尾的 on/off。它们各有一对 `get_*` / `set_*` 函数(仍在 `repositories/app_config.py`),`value` 存 dict,空行回退各自的模块内默认值。
| key | 管理页 / admin 端点 | C 端读取 | 说明 |
|---|---|---|---|
| `ad_config` | `GET/PATCH /admin/api/ad-config` | `GET /api/v1/platform/ad-config`(去密钥) | 穿山甲 app_id / 各代码位 / 各场景开关 |
| `app_version` | 内部写入(`X-Internal-Secret`) | `GET /api/v1/platform/app-version` | OTA 最新版本信息 |
| `huawei_review` | `GET/PATCH /admin/api/huawei-review` | `GET /api/v1/platform/huawei-review` | 华为审核开关:`{"mode": "default""review"}`,决定新手引导「快速设置」权限步能否被用户关闭。脏值/空行一律回退 `default`(不给退出按钮) |
-2
View File
@@ -40,8 +40,6 @@
| `raw_payload` | JSON(PG: JSONB) | nullable | 客户端原始上报全量(calibration + done.params),取数兜底 |
| `input_tokens` | Integer | nullable | 本次 LLM 累计输入 token = Σ `llm_calls[].usage.prompt_tokens`(server 收上报后从 `llm_calls` 累加;旧记录/未采集为 null) |
| `output_tokens` | Integer | nullable | 本次 LLM 累计输出 token = Σ `llm_calls[].usage.completion_tokens`(同上) |
| `llm_cost_yuan` | Float | nullable | 本次比价 LLM 总成本(元),回填时按「当时价」逐模型算好冻结(见 `services/llm_cost.py`);旧记录/未回填为 null → 前端回退「估算成本」 |
| `llm_price_snapshot` | JSON(PG: JSONB) | nullable | 算成本所用单价快照 `{mode, prices:{model:{input_per_1m,output_per_1m,_source}}}`;`app_config` 只存当前价、不留历史,故冻结当时价供审计/复算 |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
> `ordered`(已下单)是**瞬态字段**,不在表里:`list_records` 读取时按 `store_name ∈ 该用户 source='compare' 的 savings_record.shop_name 集合` 现挂到实例上供出参用。
+4 -48
View File
@@ -1,4 +1,4 @@
# coupon_state — 领券状态表(今日状态三张 + 领券流水 coupon_session)
# coupon_state — 领券今日状态三张表(弹窗频控 / 首页置灰 / 领券记录)
> 模型 `app/models/coupon_state.py` · 仓库 `app/repositories/coupon_state.py` · 接口 `app/api/v1/coupon.py`(prefix `/api/v1/coupon`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 同模型文件里还有第四张表 [`coupon_session`](./coupon_session.md)(一次领券任务一行的全程流水,admin「领券数据」看板数据源,#99)——维度与本文三张「设备×日」状态表不同,单独成文。
@@ -8,9 +8,8 @@
- **`coupon_prompt_engagement`** — 弹窗频控源。按 `(device, App 包名, 自然日)` 记「今天**这个 App** 是否对领券引导窗表达过**意向**」(弹出即记 `shown` / 点「一键领取」=`claim_started` / 点拒绝关闭=`dismissed` 都算)。切到外卖 App 时据此决定弹不弹:今天**该 App** engage 过就不再弹该 App。频控维度自 2026-06-14 起含 `package`,美团/淘宝/京东各自独立、互不压制。
- **`coupon_daily_completion`** — 首页置灰源。按 `(device, 自然日)` 记「今天是否已**跑完整轮**领券(到 done 帧)」。首页「去领取」卡据此置灰:今天跑完了就不能再领。
- **`coupon_claim_record`** — 资产沉淀层。按 `(device, 券, 自然日)` 记每张券的领取结果(success/already_claimed/failed/skipped),**纯沉淀**(资产/画像/排查/CPS 归因),当前**不参与**「要不要领 / 弹不弹」的判断。
- **`coupon_session`** — admin「领券数据」看板数据源(**独立流水表,不是「今日状态」表**)。按 `trace_id` 一次领券一行,走 `POST /api/v1/coupon/session` 两段上报(发起/收尾),记全程耗时 + 各平台耗时 + `platform_success`(成功平台,算整单②/点位③成功率)。详见下方专节。
前三张「今日状态」表的共同口径:
三表共同口径:
- **判断维度是 `device_id`,不是 `user_id`**:券发到的是设备上登录的那个外卖账号,device 比 user 更贴近「哪个登录环境」,且 `device_id` 全链路现成、不依赖领券鉴权(领券 MVP 阶段 `/coupon/step` 不鉴权)。客户端 `getOrCreateDeviceId` 生成存 SP,**卸载重装会变 → 当新设备重新弹一次**(产品预期)。
- **日期 = `Asia/Shanghai` 自然日**`claim_date` / `engage_date` / `complete_date``repositories/coupon_state.today_cn()`)。每日可领的券(签到/天天红包)靠这天然每天一条。
- **`user_id` 可空**:领券登录态有就记(资产/画像),可空、**不进唯一键、不阻塞判断**。
@@ -92,7 +91,7 @@
### 用在哪 / 增删改查
- **C / U(幂等 upsert**`record_claims`,由 `POST /api/v1/coupon/step` 写入。一帧的券结果来自 pricebot 的 `last_coupon_result`(最后一张)+ `action.params.coupon_results`(全量)——**会重复带同一张券**,端点 `_extract_coupon_results` 先**按 `coupon_id` 去重**(全量覆盖单张),仓库再靠唯一键幂等:已有则更新 `status`/`reason`/`claimed_count`/`extra`(以最后一次为准),否则插入。
- **U / D**:无业务删除。
- **R**`GET /admin/api/coupon-data/coupons``coupon_slot_report`)—— admin「按券成功率」表,按 `coupon_id` 聚合 成功/(成功+失败)`skipped` 排除,设备-天口径,按 `app_env` 过滤)。见设计 §13
- **R****当前无读取端点**(纯写入沉淀,未来做去重/归因/画像时再用)
### 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
@@ -103,7 +102,6 @@
| `coupon_id` | String(64) | NOT NULL | 券标识(取自 pricebot 结果) |
| `claim_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`);每日可领的券靠它天然每天一条 |
| `status` | String(24) | NOT NULL | `success` / `already_claimed` / `failed` / `skipped`(原样取 pricebot coupon 结果) |
| `app_env` | String(16) | index, 可空 | 领券所属 session 环境 `prod`/`dev``/step``trace_id``coupon_session.app_env` 打标);旧行 NULL(不回填)。admin「按券成功率」表按它过滤。见设计 §13 |
| `vendor` | String(48) | 可空 | 券提供方 |
| `coupon_name` | String(128) | 可空 | 取 pricebot `name` |
| `claimed_count` | Integer | 可空 | 这张领到几张(pricebot `display_count`,给不出时 None;兼容 `claimed_count` |
@@ -123,49 +121,7 @@
---
## coupon_session — 领券任务流水(一次领券一行,admin「领券数据」看板数据源)
`trace_id` 唯一,一次领券一行。与上面三张「今日状态」表不同:本表走 `POST /api/v1/coupon/session`(客户端**两段上报**:发起 `started` 建行、收尾 `completed`/`failed`/`abandoned``trace_id` 更新同一行),记从发起到收尾的全程耗时 + 各平台耗时 + 机型/ROM。发起即落库 → admin 可算「发起数」与中途流失(started 无终态 = 未完成)。
### 用在哪 / 增删改查
- **C / U(幂等 upsert**`upsert_coupon_session`,由 `POST /api/v1/coupon/session` 两段上报。**状态只前进**(started 帧重复到不覆盖已有终态);终态补 `finished_at`
- **U(并集写)**`merge_session_platform_success`,由 `POST /api/v1/coupon/step` 每逢**带券结果的帧**调用——把本帧「成功平台」(`status∈{success,already_claimed}` 的券 → `coupon_id` 前缀映射平台)**并入** `platform_success`(并集幂等,无新平台不写;读不到该 trace 行则跳过)。复用 `record_claims` 的同一 `SessionLocal`,不新增连接。
- **R**admin `GET /admin/api/coupon-data``coupon_data_report`)—— 发起/完成数、耗时分位、**整单成功率②/点位成功率③**、按天/小时趋势、逐条明细;`GET /admin/api/coupon-data/user-records` 某用户全部领券。
### 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `trace_id` | String(64) | NOT NULL, UNIQUE | 一次领券唯一 id(客户端 UUID,全程贯穿),upsert 键 |
| `device_id` | String(64) | NOT NULL | |
| `user_id` | Integer | index, 可空 | 登录态才带(admin join 用户表出手机号/昵称);匿名领券为空 |
| `status` | String(16) | NOT NULL | `started` / `completed` / `failed` / `abandoned`;started 无终态 = 中途流失 |
| `app_env` | String(16) | index, 可空 | `prod` / `dev`;admin 报表默认只看 prod(防测试串台) |
| `platforms` | JSON | 可空 | 发起勾选平台 `["meituan-waimai",…]`(空 = 全领三档);**③点位成功率的分母来源** |
| `origin_package` | String(64) | 可空 | 发起来源外卖 App 包名;null = App 内(傻瓜比价首页)发起,非空 = 外卖侧弹券 |
| `device_model` | String(128) | 可空 | Build.MANUFACTURER + MODEL |
| `rom` | String(64) | 可空 | OemDetector,如 "ColorOS 14" |
| `started_at` | DateTime(tz) | NOT NULL | 发起时刻(客户端墙钟);明细「时间」列、趋势 X 轴 |
| `started_date` | **Date** | NOT NULL | 发起的**北京**自然日;admin 按天聚合/筛选(索引) |
| `finished_at` | DateTime(tz) | 可空 | 收尾时刻(服务端 now);未收尾(流失)为空 |
| `elapsed_ms` | Integer | 可空 | 全程耗时(ms,客户端点发起→收尾);均值/分位只统计 completed |
| `platform_elapsed` | JSON | 可空 | 各平台领券耗时 `{"meituan-waimai":3200,…}`ms |
| `claimed_count` | Integer | 可空 | 领到总张数(收尾帧带) |
| `platform_success` | JSONPG JSONB | 可空 | **本次至少领到一张(`status∈{success,already_claimed}`)的平台 id 列表**,如 `["meituan-waimai","jd-waimai"]``/step` 逐帧按 `trace_id` **并集**写入(`merge_session_platform_success`;旧行 NULL 视作空集。admin 据此算整单成功率②(`platforms``platform_success`)/点位成功率③(Σ交集/Σ勾选)。设计:[领券成功率指标](../guides/领券成功率指标-设计与埋点.md) |
| `trace_url` | String(512) | 可空 | pricebot done 帧回传的公网 trace 链接;未到 donefailed/abandoned)为空 |
| `created_at` | DateTime(tz) | server_default now() | |
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | |
### 索引与约束
- PK `id`index `user_id``app_env`UNIQUE(`trace_id`) = `uq_coupon_session_trace`Index(`started_date`, `app_env`) = `ix_coupon_session_date_env`admin 主聚合/筛选)。
### 注意
- `platform_success` 是**布尔性质**的平台集,跨帧**并集**天然幂等 → `/step` 每帧并入不重复计;失败/中途退出的 session 也能拿到崩溃前已成的平台。写放大 ≈ 领券券数(仅带券结果的帧写)。
- 成功率**基数 = 区间全部 session**(含 abandoned/failed,与「发起数」同基数(设计 §3)。`coupon_id → 平台` 用前缀(`mt_`/`tb_`·`ele_`·`elm_`/`jd_`,与客户端 `couponIdToPlatform` 同词表。
---
## 今日状态三表共性小结
## 三表共性小结
- 数据流向:客户端 → `POST /api/v1/coupon/step`(透传给 pricebot)→ 结果回写这三张表(best-effort,写库失败不影响领券)。
- 唯一键都含 `device_id` + 某个北京自然日列(engagement 还含 `package`,按 App 频控);`user_id` 永远是可空旁路(资产留痕,不进唯一键、不阻塞判断)。
- 无硬外键:`user_id` 软指 `user.id``trace_id` 软指 pricebot work_logs(详见 [OVERVIEW → 表间关系 & Join Key](./OVERVIEW.md))。
@@ -1,36 +0,0 @@
# inactivity_notification_log — 不活跃清零前预警记录
> 模型 `app/models/inactivity.py` · 仓库 `app/repositories/inactivity.py` · 通知器 `app/integrations/notifier.py` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
清零前按可配置节奏(`INACTIVITY_WARN_DAYS_BEFORE`,默认清零前 7 天、2 天各一次)向用户预警"账户里的 xx 金币和 xx 现金将被清零"。每发一次预警写一行,记推送时的余额快照 + 提前天数档 + 通道 + 状态。兼作两用:**预警去重**依据(同 streak 内 `stage==k 且 created_at > last_active` 即已推过、不重推)与**占位 outbox**(v1 通道=`log`,只打日志不真推;后续接 JPush/短信同层扩展)。append-only,不更新。**预警只涉及会被清的金币 + 折算现金;邀请奖励金不清、不预警**(`invite_cash_balance_cents` 仅作账户状态快照)。
## 用在哪 / 增删改查
- **C(插入)**:`inactivity.run_warn_once` 命中预警档、且本 streak 未推过时,调 `notifier.warn` 后写一行(`status` = 通知器返回,占位实现为 `placeholder`)。
- **U / D**:无(append-only)。
- **R**:预警去重查询(`user_id + stage + created_at > last_active`);未来接真实推送时作待推送 outbox。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | **PK**, autoincrement | 主键 |
| `user_id` | Integer | NOT NULL, index | 预警对象;只索引不设外键(同 `analytics_event`) |
| `stage` | Integer | NOT NULL | 提前天数档(如 `7` / `2`,即清零前第几天推) |
| `inactive_days` | Integer | NOT NULL | 推送时的不活跃天数(北京自然日) |
| `coin_balance` | Integer | NOT NULL | 推送时金币余额快照(将被清) |
| `cash_balance_cents` | Integer | NOT NULL | 推送时折算现金余额快照(分,将被清) |
| `invite_cash_balance_cents` | Integer | NOT NULL | 推送时**邀请奖励金**余额快照(分,**不清、不在预警额度内**) |
| `channel` | String(16) | NOT NULL | 通道:`log`(占位) / `jpush` / `sms` |
| `status` | String(16) | NOT NULL | 状态:`placeholder`(占位未真推) / `sent` / `failed` |
| `created_at` | DateTime(tz) | server_default now(), index | 推送时刻;去重比 `created_at > last_active`(用户回归后 `last_active` 前移 → 旧行自然失效、开启新 streak) |
## 关系 / Join Key
- `user_id``user.id`(无外键直连,靠 `user_id` 关联)。
- 与 `inactivity_reset_log` 无直接外键;同一 streak 内先有若干预警行,到期后有一行清零。
## 索引与约束
- PK `id`;`ix_inactivity_notification_log_user_id``ix_inactivity_notification_log_created_at`
## 注意
- **预警去重按 streak**:判据是 `created_at > last_active`;用户一有活跃(`home_view`/比价/领券),`last_active` 前移,旧预警行"失效",回归后可重新进入预警。
- **占位实现**:v1 `LogNotifier``logger.warning("[inactivity-warn] ...")`、返回 `placeholder`,不真推(参照心跳告警"本期先不接推送"先例)。
- **漏跑补发**:worker 漏跑数天后某用户可能同时满足多档,只补发**最紧急的未推档**(最小提前天数),避免刷屏。
-35
View File
@@ -1,35 +0,0 @@
# inactivity_reset_log — 15 天不活跃清零审计
> 模型 `app/models/inactivity.py` · 仓库 `app/repositories/inactivity.py` · worker `app/core/inactivity_reset_worker.py` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
连续 15 天不活跃(北京自然日,活跃口径见 `app/repositories/activity.py`:`home_view` + 发起比价 + 发起领券,**不含登录**)的用户,worker 每日自动清零其**金币 + 折算现金**。每清一个用户写一行,记清零前三桶余额快照 + 原因 + 判定时的活跃时间/不活跃天数,供纠纷排查。清零同时另写 2 条钱包流水(`coin_transaction` / `cash_transaction`,`biz_type=inactivity_reset`,`ref_id=` 本表 `id`),资金流可逐笔回溯、人工恢复。**邀请奖励金(`invite_cash_balance_cents`)是产品红线、不清零**,本表 `invite_cash_balance_cents_before` 仅为清零时仍保留的邀请金快照(非被清金额)。append-only,不更新。
## 用在哪 / 增删改查
- **C(插入)**:`inactivity.clear_user` 逐用户清零(独立事务、行锁)时写一行,`db.flush()``id` 作流水 `ref_id` 交叉链接。
- **U / D**:无(append-only 审计)。
- **R**:纠纷排查 / 对账(与 `coin_transaction` / `cash_transaction``ref_id` 交叉核对)。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | **PK**, autoincrement | 主键;作 `ref_id` 写入两条清零流水 |
| `user_id` | Integer | NOT NULL, index | 被清零用户;只索引不设外键(同 `analytics_event`,避免删用户级联 / 留历史) |
| `coin_balance_before` | Integer | NOT NULL | 清零前金币余额(个数);= 对应 `coin_transaction.amount` 绝对值 |
| `cash_balance_cents_before` | Integer | NOT NULL | 清零前折算现金余额(分);= 对应 `cash_transaction.amount_cents` 绝对值 |
| `invite_cash_balance_cents_before` | Integer | NOT NULL | 清零时的**邀请奖励金**余额快照(分)——**不清、原封保留**,仅记录以证明"未动邀请金" |
| `last_active_at` | DateTime(tz) | nullable | 判定时的最近活跃时刻(UTC);无任何活跃信号时兜底为 `user.created_at` |
| `inactive_days` | Integer | NOT NULL | 判定时的不活跃天数(北京自然日) |
| `reason` | String(32) | NOT NULL | 清零原因,如 `inactive_15d` |
| `reset_at` | DateTime(tz) | server_default now(), index | 清零时刻 |
## 关系 / Join Key
- `user_id``user.id`(无外键直连,靠 `user_id` 关联)。
- `id``coin_transaction.ref_id` / `cash_transaction.ref_id`(`biz_type=inactivity_reset`):审计行 ↔ 资金流水交叉对账。
## 索引与约束
- PK `id`;`ix_inactivity_reset_log_user_id`(按用户查)、`ix_inactivity_reset_log_reset_at`(按时间查)。
## 注意
- **只清 2 桶**:金币 + 折算现金;**邀请现金不清**(两本账物理隔离,见 [`coin_account`](./coin_account.md) / `wallet.CoinAccount` 注释)。
- **天然幂等**:清完余额=0,次日不再匹配;worker 重启 / 多次唤醒 / 补跑都不会重复清零或重复流水。
- **总闸默认关**(`INACTIVITY_RESET_ENABLED=false`),灰度验证清零名单后再开。
@@ -1,294 +0,0 @@
# 领券「整单成功率」与「点位成功率」指标 — 设计与埋点
- 日期:2026-07-07
- 状态:待评审
- 涉及仓库:`shaguabijia-app-server`**纯服务端**;客户端零改动)
- 数据源表:`coupon_session`admin「领券数据」看板)
## 1. 背景与目标
admin「领券数据」看板(数据源 `coupon_session`,见 `app/admin/repositories/coupon_data.py`)当前能算:**领券发起数、完成数、全程耗时均值/分位**。产品还想要两个成功率指标,现有埋点算不出来:
- **② 整单成功率** = 一次发起里勾选的平台**全部**领到券的次数 / 领券发起数
- **③ 点位成功率** = 平台维度的领取成功率(每个平台「点位」成没成功)
> 口径决定(2026-07-07):「点位」= **平台粒度**(美团 / 淘宝闪购 / 京东),不是「每张券」。
耗时中位数、发起数已由 `coupon_session.elapsed_ms` / `status=started` 计数满足,本设计只补 ②③。
## 2. 关键结论:per-slot 信号已在库,缺的是「按 session 可靠归因 + 看板可过滤」
服务端 `/api/v1/coupon/step``app/api/v1/coupon.py`)每帧都调 `record_claims`,把**每张券**的结果(`status ∈ success / already_claimed / failed / skipped`)写进 `coupon_claim_record`,还带 `trace_id`。原始成败信号**已经落库**。
但该表**不能**直接支撑本指标:
1. 唯一键是 `(device_id, coupon_id, claim_date)`**不含 trace_id**;且 `record_claims` 冲突更新时**不更新 trace_id**`app/repositories/coupon_state.py``record_claims`,仅 INSERT 时写 trace_id)。→ 同一张券当天跨多次 session 会折叠成一行、只归属**最早**那次 → **按 session 归因不可靠**(直接砸 ②「整单全成功」)。
2. `coupon_claim_record``app_env` / `origin_package` → 无法像看板那样只看 prod、也无法拆 Path AApp 内发起)/ Path B(外卖侧弹券)。
因此采用 **route B**:在 `/step` 里服务端推导「本次 session 哪些平台成功」,直接写到 `coupon_session` 行——该表按 `trace_id` 唯一、已带 `app_env` / `origin_package` / `platforms`,指标干净可过滤、可拆路径。
## 3. 指标口径(平台粒度,已定)
记一次 session 为 `s`
- `sel(s)` = **勾选平台集** = `coupon_session.platforms`;为空表示「全领」→ 取默认 `{meituan-waimai, taobao-shanguang, jd-waimai}``|sel(s)|` = 该次「点位数」。
- `succ(s)` = **成功平台集** = 本次 session 里**至少领到一张**`status ∈ {success, already_claimed}`)的平台集合。
指标:
- **③ 点位成功率** = `Σ_s |succ(s) ∩ sel(s)|` / `Σ_s |sel(s)|`
(分母即「发起数 × 各自点位数」;全部全领时等于 发起数 × 3)
- **② 整单成功率** = `#{ s : sel(s) ⊆ succ(s) 且 sel(s) ≠ ∅ }` / `发起数`
已定边界:
1. **③ 分母用「勾选平台」`sel(s)`**。勾了淘宝但淘宝没领到 = 该点位未成功(不特判「平台没券」)。若日后要「真没券的平台不计入分母」,再引入 `platform_attempted`(见 §8)。
2. **②③ 基数 = 区间内全部 session**(含 `started` / `failed` / `abandoned`),与看板「发起数」同基数。**失败 / 中途退出的 session 的 `succ(s)` 取它崩溃前真领到的平台**(不一律算 0)。
3. **成功语义**`status ∈ {success, already_claimed}` = 成功;`failed` / `skipped` = 未成功。与 `sum_claimed_count``app/repositories/coupon_state.py`)一致。
> pricebot 协议文档把 `status` enum 写作 `success|failed|skipped`,但**代码实际还会 emit `already_claimed`**pricebot `app/services/coupon_provider.py` 等多处)——以代码为准,含 already_claimed 是对的。`skipped` 目前 MVP 阶段基本不出现。
## 4. 数据模型改动
`coupon_session``app/models/coupon_state.py::CouponSession`)新增一列:
| 列 | 类型 | 说明 |
|----|------|------|
| `platform_success` | `_JSON`PG→JSONB / SQLite→JSON),nullable | 本次 session **至少领到一张**的平台 id 列表,如 `["meituan-waimai","jd-waimai"]`。旧行 = `NULL` → 视作空集。 |
- alembic 新迁移:`add_column coupon_session.platform_success`nullable、无 server_default。
- **分母 `sel(s)` 复用已有 `platforms` 列,不新增字段。** 净新增仅此一列。
- 同步更新表字典 `docs/database/coupon_state.md`
## 5. 服务端推导逻辑(`/step`
`app/api/v1/coupon.py::coupon_step` 内(已有 `results = _extract_coupon_results(resp_json)`)新增:
1. 对每条 result 求平台:**按 `coupon_id` 前缀映射**(与客户端 `CouponForegroundService.couponIdToPlatform` 对齐,且与 `platforms` / `platform_elapsed` 用同一套平台 id 词表):
- `mt_``meituan-waimai`
- `tb_` / `ele_` / `elm_``taobao-shanguang`
- `jd_``jd-waimai`
- 其余 → 跳过(无法识别)
> 不用券的 `vendor` 字段做映射:vendor 词表未必等于这三档平台 id,而 `sel(s)` 用的就是这三档,`succ(s)` 必须同词表。
2. 收集 `status ∈ {success, already_claimed}` 的平台集合 `ok_platforms`
3. 若 `device_id``ok_platforms` 非空:调用新 repo 函数把 `ok_platforms` **并入** `coupon_session.platform_success`(按 `trace_id`)。
新增 `app/repositories/coupon_state.py::merge_session_platform_success(db, trace_id, ok_platforms)`
- 读现有行 → `platform_success = 现有 ok_platforms`(去重、保序)→ 写回、commit`IntegrityError` 回滚忽略(同现有 upsert 兜底)。
- **并集幂等**:跨帧多次并入同一平台不会重复;done 帧的全量 `coupon_results` 保证完整;失败 / 中途退出的 session 靠中间帧 `last_coupon_result` 已并入的平台拿到「部分成功」。
- **fire-and-forget**:包 `run_in_threadpool` + 整段 try/except 只 log,绝不连累 `/step` 返回(与现有 `record_claims` / `mark_completed` 同规格)。
**建行 / 时序(已定:行不存在则跳过本次并入)**:
- 客户端 `/session started``start()`(任务发起那刻)就发,而首个带券的 `/step` 要等领券循环跑起来、晚几秒;到 `/step``ok_platforms` 时,`coupon_session` 行几乎必然已存在。故 `merge_session_platform_success` **读不到行就跳过**,不建兜底行——实现最简,也不引入 `started_at` 不精确的脏行。
- 残留丢数窗口:仅当「`started` 上报丢失」**且**「`/step` done 先于 `/session` terminal 落库」两者同时成立,该 session 的 `platform_success` 才会缺(terminal 帧会建行但那之后没有 `/step` 再并入)。两条件叠加概率极低,且指标是聚合口径、可容忍个别缺失。
- 若上线后观测到该缺失不可忽略,再降级为「读不到行则 upsert 建最小兜底行(`started_at=now()`)」——届时改 `merge_session_platform_success` 一处即可,不影响其余设计。
**写放大(重要,非每 step)**:`merge_session_platform_success` **只在「本帧带券结果」时触发**——即 pricebot 在**单券完成帧**给 `last_coupon_result`、**最终 done 帧**给全量 `coupon_results` 的那些帧;领单张券途中的导航/点击帧(占 step 大头)`_extract_coupon_results` 返回空 → **不写**。所以写频次 ≈ **本次领的券数**(通常个位数),且落在**服务端今天已有的** `record_claims` 写的**同一批帧**上,不新增写的帧。
实现:把 merge 放进**现有 `_record_claims_blocking` 的同一个 `SessionLocal`**(紧接 `record_claims`),边际成本 = 每张券完成时多一条 `UPDATE coupon_session`(按 `trace_id` 唯一索引),不新增连接 / 不新增 `run_in_threadpool` 调用。
可选降级(若要「一次 session 只写一次」):只在 done 帧写 `platform_success`(全量 `coupon_results` 一次算完)。代价:`failed` / `abandoned`(没 done 帧)拿不到「崩溃前已成平台」→ 失败单部分成功丢失,与 §3「失败单取实际成的平台」相悖。**默认取每券帧并入**(失败单也如实统计),此降级留作观测到写压力后再启用。
## 6. admin 聚合与呈现
`app/admin/repositories/coupon_data.py::coupon_data_report``summary` 增加(沿用「全量拉区间 → Python 聚合」风格,与分位一致):
- `full_success_rate`(②)、`point_success_rate`(③)
- 可选 `per_platform``{platform: rate}`(各平台点位成功率,拆美团/淘宝/京东)
计算:对区间内 sessions`sel = platforms or 默认三档``succ = set(platform_success) ∩ sel`;按 §3 公式汇总。
- 过滤:默认 `app_env == 'prod'`(同现有分位口径,防测试串台)。
- 拆路径:`origin_package` 已在表上 → 看板后续可加「Path A / Path B」筛选项(本设计不含前端图表细节)。
- schema`app/admin/schemas/coupon_data.py` 的 summary 加对应字段(+ 可选 `per_platform`)。
## 7. 改动清单
- [x] `app/models/coupon_state.py``CouponSession``platform_success`
- [x] `alembic/versions/`:新迁移 add column `coupon_session.platform_success`
- [x] `app/repositories/coupon_state.py`:新增 `merge_session_platform_success`
- [x] `app/api/v1/coupon.py``/step` 推导 `ok_platforms` 并 union(前缀映射 + fire-and-forget
- [x] `app/admin/repositories/coupon_data.py``summary` 加 ②③(+ 可选 `per_platform`
- [x] `app/admin/schemas/coupon_data.py``summary` schema 加字段
- [x] `docs/database/coupon_state.md`:补 `platform_success` 列说明(并补 coupon_session 整节)
- [x] `tests/``tests/test_coupon_platform_success.py`10 测试,见 §9
## 8. 不做(YAGNI / 边界)
- **客户端不改、历史不回填**`platform_success` 只对新 session 生效(route B 的固有取舍,用户已接受)。
- **不引入 `platform_attempted`**:③ 分母用勾选平台。若日后要「排除真没券的平台」,注意 `platform_elapsed.keys()` 已近似「被处理过的平台」,可作 attempted 的现成来源,多半仍不必加列。
- **不动 `coupon_claim_record`** 的去重 / 归因:本指标绕开它,避免牵动频控 / 资产 / CPS 语义。
- **不做券级(每张券)成功率**:已选平台粒度。
## 9. 测试口径要点
- **union 幂等**:同一 `trace_id` 多帧并入同一平台,`platform_success` 不重复、保序。
- **失败单部分成功**session `failed`,但美团已成 → `succ = {meituan-waimai}`,计入 ③ 分子;② 仅当 `sel ⊆ succ` 才算整单成功。
- **空 `platforms` → `sel` 取默认三档**(全领)。
- **成功语义**`already_claimed` 计成功;`skipped` 不计。
- **基数**`abandoned` 计入 ②③ 基数,`succ` 取实际成的平台。
- **prod 过滤**`dev` 环境 session 不进指标。
## 10. 对 pricebot-backend 的影响
**结论:不需要改 pricebot,也不改发往 pricebot 的请求 / 不加调用 / 不加负载。**
- `/step` 仍原样透传请求 bytes 给 pricebot;本设计只**多解析 pricebot 的响应**`coupon_results` / `last_coupon_result`),而这两个字段服务端**今天已在** `_extract_coupon_results` / `record_claims` 里解析。零新增字段需求、零额外上游调用、pricebot 负载不变。
- **只读依赖(既有耦合,非新引入)**:平台映射靠 pricebot 的 `coupon_id` 前缀约定(`mt_` / `tb_` / `ele_` / `elm_` / `jd_`)。客户端 `couponIdToPlatform` 早就依赖同一套;本设计只是加了这份映射的第二个消费者。维护耦合:pricebot 若改 `coupon_id` 前缀,客户端与本指标会**一起**失效——但这是既有风险,依赖方向不变。无法识别前缀的券按「跳过」处理(与客户端一致)。
- 认账的 pricebot 事实(源:`app/models/response.py` + `docs/projects/领券-客户端对接协议.md`):
- `CouponResult = {coupon_id, name, vendor, status, reason?, duration_ms?}``coupon_results` 仅最终 done 帧全量,中间帧走 `last_coupon_result`(单张)。
- `vendor` 是来源标签(`meituan_internal` / `dianping_cps` …),**不等于**三档平台 id;且 `mt_dianping_xxx`(大众点评 CPS)也带 `mt_` 前缀归美团 → 印证「用 `coupon_id` 前缀、不用 `vendor`」正确。
---
## 11. 实现状态(交付记录 · 2026-07-07
**状态:实现完成、TDD 全绿、未提交、迁移未应用。** 纯服务端(shaguabijia-app-server),客户端 / pricebot 未动。
### 已交付改动
| 文件 | 改动 |
|---|---|
| `app/models/coupon_state.py` | `CouponSession``platform_success``_JSON`, nullable |
| `app/repositories/coupon_state.py` | `coupon_id_to_platform` / `succeeded_platforms` / `merge_session_platform_success` + 常量 `DEFAULT_PLATFORMS` / `_SUCCESS_STATUSES` |
| `app/api/v1/coupon.py` | `_record_claims_blocking` 内、`record_claims` 之后并入本帧成功平台(同一 `SessionLocal` |
| `app/admin/repositories/coupon_data.py` | `_success_rates(rows)` → summary 加 `full_success_rate②` / `point_success_rate③` + 3 个支撑计数 |
| `app/admin/schemas/coupon_data.py` | `CouponDataSummary` 加 5 字段 |
| `alembic/versions/coupon_session_platform_success.py` | add column;revision=`coupon_session_platform_success`down=`admin_user_plain_password`(当前 head |
| `docs/database/coupon_state.md` | 补 `coupon_session` 整节(表原本无文档)+ 新列 |
| `tests/test_coupon_platform_success.py` | 10 个测试(TDD |
> §7 里「可选 `per_platform`」原标 YAGNI 延后;**已在 §122026-07-08)补做**(随 admin 前端看板卡一并接入,见下)。
### 测试与验证
- 本特性:`Set-Location e:\project\shaguabijia-app-server; & .\.venv\Scripts\python.exe -m pytest tests\test_coupon_platform_success.py -q`**10 passed**
- 全量 `pytest -q`:**306 passed / 5 failed**。5 个失败**全部预存、与本次无关**:
- `test_coupon_proxy.py::test_coupon_step_passes_body_through`(测试断言 `json=` 但 handler 用 `content=` 转发;写代码前 sanity run 就红)。
- `test_invite.py` + `test_invite_compare_reward.py` 共 4 个(单独跑也红;本次改动集零 invite 文件)。
- 迁移:临时库 `alembic upgrade head` 通过、列已建、单一 head。
- lint:新增代码 `ruff` 全清;`coupon_state.py` 剩 3 处 pre-existing UP017`upsert_coupon_session``timezone.utc`)未动。
### 待办(在后端项目里继续)
1. **应用迁移**:`alembic upgrade head`(DDL 已验;线上只前进统计、历史不回填)。
2. **提交**:尚未提交;建议先开分支再提交。
3. **admin 前端图表**:后端指标已就绪(summary 的 `full_success_rate` / `point_success_rate` 等),看板卡 / 趋势展示待接前端。
4. (可选)修预存 `test_coupon_step_passes_body_through`(一行:capture `content` 而非 `json`)。
### 续开发须知
- `platform_success` 只在**带券结果的帧**写(`/step``succeeded_platforms(results)` 非空才 merge),≈ 领券券数量级、非每 step;复用 `record_claims` 同一 `SessionLocal`、不新增连接。
- merge **读不到 session 行则跳过**(不建兜底行);并集幂等、无新平台不写。
- 口径:成功=`status∈{success,already_claimed}`;基数含 `abandoned`/`failed`;③ 分母=`platforms`(空→全领三档);`coupon_id`→平台走前缀,与客户端 `couponIdToPlatform` 同词表。
---
## 12. 续做:admin 前端看板卡 + 分平台点位成功率(设计 · 2026-07-08
承 §11 待办 #3(前端图表)与 §7「可选 `per_platform`」。本轮把 ②③ 接入 admin「领券数据」页,并把 ③ 按平台拆(`per_platform`)。**改前端 + 后端 + 测试**;客户端 / pricebot 仍零改动。
- 状态:设计已评审通过(2026-07-08),待实现。
- 涉及仓库:`shaguabijia-app-server`(后端)+ `shaguabijia-admin-web`(admin 前端,Next.js + antd)。
### 12.1 后端:`per_platform` 分平台点位成功率
`app/admin/repositories/coupon_data.py::_success_rates(rows)` 在现有合计基础上,对每个 `p ∈ DEFAULT_PLATFORMS`(美团/淘宝/京东)累加:
- 分母 `per_total[p]` = 勾选了 p 的 session 数(`p ∈ sel`,空勾选 `sel` 按全领三档);
- 分子 `per_succ[p]` = 其中 `p ∈ succ`(该平台至少领到一张)的 session 数;
- `per_platform[p]` = `round(per_succ[p] / per_total[p], 4)`;分母 0 → `None`
**不变量**:`Σ_p per_succ[p] == point_success_count``Σ_p per_total[p] == point_total_count`(三档词表下恒成立;非三档平台 id 不计入 `per_platform`,由 `if p in per_total` 守卫)。用 §9 / `test_coupon_data_success_rates` 数据自检:美团 3/4=0.75、淘宝 2/3=0.6667、京东 1/2=0.5;合计仍 6/9=0.6667。
- summary 加一项 `per_platform`:**恒含三档键**,如 `{"meituan-waimai":0.75,"taobao-shanguang":0.6667,"jd-waimai":0.5}`(区间内无人勾选的平台 → 值 `None`)。
- schema `app/admin/schemas/coupon_data.py::CouponDataSummary``per_platform: dict[str, float | None]`
### 12.2 前端:admin-web「领券数据」汇总卡补一段
`shaguabijia-admin-web/src/app/(main)/coupon-data/page.tsx`(单文件,`CouponDataSummary` 为该页内联类型):
- 内联 `CouponDataSummary` 补:`full_success_count` / `full_success_rate` / `point_success_count` / `point_total_count` / `point_success_rate` + `per_platform: Record<string, number | null>`
- 新 helper `fmtPct(v) = v == null ? '-' : ${(v*100).toFixed(1)}%`(沿用本页 `-` 空值风格,数学同大盘 `pct`);antd 导入补 `Tooltip`,新增 `import { InfoCircleOutlined } from '@ant-design/icons'`
- 汇总卡「耗时分位」行之后,`Divider` + 两行 `Statistic`(各 `Col flex="1 1 0"`):
- 行1:**整单成功率** `fmtPct(full_success_rate)` · **点位成功率(合计)** `fmtPct(point_success_rate)`;标题各带 ⓘ `Tooltip`(口径说明;合计率注明「平台粒度、= 三档之和,与『数据大盘』券粒度口径不同」)。
- 行2:**美团 / 淘宝 / 京东 点位成功率** `fmtPct(per_platform['meituan-waimai' | 'taobao-shanguang' | 'jd-waimai'])`(平台名同「美团耗时」列既有叫法)。
- 不显示支撑数(合计与分平台均纯百分比);tooltip 不带分母。
### 12.3 测试
- 扩 `tests/test_coupon_platform_success.py::test_coupon_data_success_rates`:断言 `per_platform == {"meituan-waimai":0.75,"taobao-shanguang":round(2/3,4),"jd-waimai":0.5}`,并断言和不变量(`Σ 分子 == point_success_count == 6``Σ 分母 == point_total_count == 9`)。
- 前端 `shaguabijia-admin-web` lint / type-check 通过。
- 端到端:随下一步「真实 /step 实测」在跑起来的 admin-web + 后端页面上核对卡片渲染。
### 12.4 不做(YAGNI)
- 成功率**趋势线**:后端 `daily` / `hourly` 不含率字段,加趋势要另改聚合,超出本轮范围。
- 合计 / 分平台的**支撑数副文本**、tooltip 带分母。
- 客户端 / pricebot 改动;历史回填。
---
## 13. 续做:每券(coupon_id)成功率明细表(设计 · 2026-07-08)
产品要更细粒度:到**具体领券点位**(如「美团外卖红包天天领」「美团甄选好店」),即按 `coupon_id` 算成功/失败率,比 §12 的平台粒度再细一层。**改前端 + 后端 + 测试**;客户端 / pricebot 仍零改动。
- 状态:设计已评审通过(2026-07-08),待实现。
- 关键取舍(已定):数据源用 **`coupon_claim_record`**(它本就是「单张券一天一条」的系统记录,已带 `coupon_name` / `status` / `vendor` / `trace_id`),唯一缺 `app_env` → 补一列即可。§2 当初绕开它是因为**平台指标要按 session 归因**;而**每券成功率是全局聚合、不需要 session 归因**,`(device,券,天)` 折叠反而天然去重防刷,故这里用它是对的。
### 13.1 指标口径(已定)
- **成功** = `status ∈ {success, already_claimed}`;**尝试(分母)** = `status ∈ {success, already_claimed, failed}`;**`skipped` 排除**(无券可领/不适用,不算尝试、不进分母、不展示)。
- **成功率** = 成功 / 尝试(某券区间内无 tried 行 → 不出现在表里,无除零)。
- **粒度 = 「设备-天」**(非「每次点击」):`coupon_claim_record` 唯一键 `(device, coupon_id, claim_date)`,同设备当天同券只留最后状态。所以「尝试」= 有多少**设备-天**尝试过该券,「成功」= 其中最终领到的。要每次点击级须换源(route B / 原始事件),本轮不做。
- **环境**:跟随页面「环境」(prod/dev/全部),按 `app_env` 过滤;旧行 `app_env=NULL` **不回填** → 仅「全部」视图可见。
- **日期**:按 `claim_date`(Asia/Shanghai 自然日),与页面日期范围一致。
### 13.2 数据模型
`CouponClaimRecord`(`app/models/coupon_state.py`)加一列:
| 列 | 类型 | 说明 |
|----|------|------|
| `app_env` | `String(16)`,index,nullable | prod / dev。`/step` 落库时按 session 的 app_env 打标;旧行 NULL(不回填)。 |
- alembic 新迁移:`add_column coupon_claim_record.app_env`,nullable + index,无 server_default。
- 同步更新 `docs/database/coupon_state.md` 的 coupon_claim_record 节。
### 13.3 写路径(`/step`)
- `app/repositories/coupon_state.py::record_claims` 加参数 `app_env: str | None = None`;INSERT 时写入,UPDATE 时 `if app_env is not None: row.app_env = app_env`(不用 None 覆盖已有)。
- 新增轻量 repo 助手 `session_app_env(db, trace_id) -> str | None`(按 trace_id 取 `coupon_session.app_env`,查不到返回 None)。
- `app/api/v1/coupon.py::_record_claims_blocking`:`trace_id` 存在则先 `app_env = coupon_repo.session_app_env(db, trace_id)`,传给 `record_claims(..., app_env=app_env)`;`merge_session_platform_success` 保持原样(其自身 select 不变)。查不到 session / 无 trace_id → `app_env=None`(行为同旧)。
> 写放大:仅「带券结果的帧」触发(同 §5),每帧多一次 `session_app_env` 小查询(按 trace_id 唯一索引),可忽略。
### 13.4 admin 聚合(新 repo 函数)
`app/admin/repositories/coupon_data.py` 新增 `coupon_slot_report(db, *, date_from, date_to, app_env)`(与 `coupon_user_records` 同居本文件,同属「领券数据」看板;更新模块 docstring 注明本文件现读 `coupon_session` + `coupon_claim_record` 两源):
- `SELECT coupon_id, MAX(coupon_name) AS coupon_name, COUNT(*) AS tried, SUM(CASE WHEN status IN (success,already_claimed) THEN 1 ELSE 0 END) AS succeeded`
`WHERE claim_date ∈ [from,to] AND status IN (success,already_claimed,failed)` (+ `AND app_env = :env` 当 env 非「全部」) `GROUP BY coupon_id`
- 每行:`platform = coupon_id_to_platform(coupon_id)`(复用前缀映射,无法识别→None),`success_rate = round(succeeded/tried, 4)`
- 按 `tried` 倒序、`coupon_id` 次序返回 `{"items": [...]}`
### 13.5 接口 + schema(新子端点)
- 路由 `app/admin/routers/coupon_data.py`:加 `@router.get("/coupons")``get_coupon_slots`,参数 `date_from` / `date_to` / `app_env`(同主 report 的解析与 `_MAX_RANGE_DAYS` 校验,`app_env="all"→None`),调 `coupon_slot_report`。路径全称 `/admin/api/coupon-data/coupons`(与现有 `/coupon-data/user-records` 同款子端点)。
- schema `app/admin/schemas/coupon_data.py`:
- `CouponSlotRow{coupon_id: str, coupon_name: str|None, platform: str|None, tried: int, succeeded: int, success_rate: float|None}`
- `CouponSlotsOut{date_from: str, date_to: str, items: list[CouponSlotRow]}`
### 13.6 前端
`shaguabijia-admin-web/src/app/(main)/coupon-data/page.tsx`:
- 汇总卡下方(或趋势图下方)新增一张「按券成功率」表:列 **券名**(`coupon_name || coupon_id`)/ **平台**(美团/淘宝/京东/其他)/ **尝试** / **成功** / **成功率**(`fmtPct`)。默认按尝试倒序;支持 antd 列排序。
- 点「查询」时,除主 report 外并行 `api.get('/admin/api/coupon-data/coupons', {params:{date_from,date_to,app_env}})`;空则不显示表。
- 新增内联类型 `CouponSlotRow`;平台名复用映射(`meituan-waimai→美团` 等,null→其他)。
### 13.7 测试
- 后端:`record_claims` stamp `app_env`(INSERT/UPDATE 两路);`session_app_env` 助手;`/step` 集成写入 `app_env`;`coupon_slot_report` 聚合(多券 × success/already_claimed/failed/skipped × 两 env:断言 tried/succeeded/rate、**skipped 排除**、env 过滤、排序);schema 契约。
- 前端:`tsc --noEmit`
### 13.8 不做(YAGNI)
- 每次点击级成功率(需换数据源);成功率趋势线;`app_env` 历史回填(可选一次性 `join trace_id→session.app_env`,默认不做);券级 tooltip / 明细下钻。
- 券表为**全局聚合**,**不随页面「用户」搜索框过滤**(用户维度非本需求;`coupon_claim_record` 虽有 `user_id`,YAGNI)。与主明细表按用户过滤的行为不同,属有意为之。
@@ -1,897 +0,0 @@
# 接口 QPS + 耗时可观测(OpenObserve)实现计划
> **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 每个接口采集 QPS + 耗时 + 错误率,经轻量 ASGI 中间件 + 后台 worker 批量直采到本地 Docker 的 OpenObserve。
**Architecture:** 纯 ASGI 中间件测每请求耗时/抓路由模板+状态码 → 非阻塞入有界队列(满则丢、绝不阻塞)→ 后台 asyncio worker 批量 POST 到 OpenObserve `_json` ingest 端点。请求路径零 I/O;未配置观测则整套 no-op;上报失败丢批不重试。
**Tech Stack:** FastAPI / Starlette ASGI 中间件、`asyncio.Queue``httpx.AsyncClient`(已有依赖)、pydantic-settings、OpenObserveDocker)。
参考 spec[docs/superpowers/specs/2026-07-06-openobserve-api-metrics-design.md](2026-07-06-openobserve-api-metrics-design.md)
---
## 文件结构
| 文件 | 职责 |
|---|---|
| `app/core/config.py`(改) | 新增 `OBSERVE_*` 配置 + `observe_configured` 门槛属性 |
| `app/core/observe.py`(新) | 有界事件队列 + `record_event` + 路由模板解析 + `RequestMetricsMiddleware` |
| `app/core/observe_worker.py`(新) | 后台批量上报 worker:`_collect_batch` / `_post_batch` / `start_*` / `stop_*` |
| `app/main.py`(改) | 挂中间件(最外层)+ lifespan 启停 worker |
| `.env.example`(改) | 新增 `OBSERVE_*` 注释段 |
| `deploy/openobserve/docker-compose.yml`(新) | 本地 OpenObserve 容器 |
| `deploy/openobserve/README.md`(新) | 部署步骤 + 查询/仪表盘 SQL |
| `tests/test_observe.py`(新) | 配置门槛 / 队列 / 中间件 / worker 单测 |
**关键接口契约(跨任务一致,勿改名):**
- `app.core.observe.get_queue() -> asyncio.Queue[dict]`
- `app.core.observe.record_event(event: dict) -> None`
- `app.core.observe.take_dropped() -> int`
- `app.core.observe.RequestMetricsMiddleware`ASGI class`__init__(self, app)`
- 事件字段:`_timestamp`(µs int) / `service` / `env` / `method` / `route` / `status` / `duration_ms`(float)
- `app.core.observe_worker.start_observe_worker() -> asyncio.Task | None`
- `app.core.observe_worker.stop_observe_worker(task) -> None`
- `settings.observe_configured -> bool`
---
## Task 1: 配置项 `OBSERVE_*` + `observe_configured`
**Files:**
- Modify: `app/core/config.py`(在 `cors_origins_list` property 之后、`is_prod` property 之前插入)
- Test: `tests/test_observe.py`(新建)
- [ ] **Step 1: 写失败测试**
新建 `tests/test_observe.py`
```python
"""接口指标可观测(observe)单测:配置门槛 / 队列 / 中间件 / worker。
沿用仓库约定:TestClient + monkeypatch,绝不打真网络。observe 默认关(conftest 未设
OBSERVE_*),需要开启的用例用 monkeypatch 改 settings 单例属性。
"""
from __future__ import annotations
from app.core.config import settings
def test_observe_configured_requires_switch_and_creds(monkeypatch):
# 开关开 + endpoint(默认 localhost)+ user + password 齐全 → True
monkeypatch.setattr(settings, "OBSERVE_ENABLED", True)
monkeypatch.setattr(settings, "OBSERVE_USER", "u")
monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "p")
assert settings.observe_configured is True
# 缺密码 → False
monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "")
assert settings.observe_configured is False
# 开关关 → False(即便凭证齐全)
monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "p")
monkeypatch.setattr(settings, "OBSERVE_ENABLED", False)
assert settings.observe_configured is False
```
- [ ] **Step 2: 运行,确认失败**
Run: `pytest tests/test_observe.py::test_observe_configured_requires_switch_and_creds -q`
Expected: FAIL —— `AttributeError``settings``OBSERVE_ENABLED` / 无 `observe_configured`
- [ ] **Step 3: 实现配置**
`app/core/config.py``cors_origins_list` property 之后、`is_prod` property 之前插入:
```python
# ===== 可观测(OpenObserve 接口指标)=====
# 采集每个接口的 QPS + 耗时 + 错误率,批量直采到 OpenObserve(本地 Docker)。
# 默认关(prod 安全):未开启 → 中间件透传、worker 不启动,整套 no-op。
# 开启需 ENABLED=true 且 ENDPOINT/USER/PASSWORD 齐全(见 observe_configured)。
OBSERVE_ENABLED: bool = False
OBSERVE_ENDPOINT: str = "http://localhost:5080" # OpenObserve base URL
OBSERVE_ORG: str = "default" # 组织名
OBSERVE_STREAM: str = "app_requests" # stream 名(首次上报自动建)
OBSERVE_USER: str = "" # Basic auth 邮箱
OBSERVE_PASSWORD: str = "" # Basic auth 密码/token
OBSERVE_FLUSH_INTERVAL_SEC: float = 5.0 # worker 最长攒批间隔
OBSERVE_BATCH_MAX: int = 200 # 单批最大事件数
OBSERVE_QUEUE_MAX: int = 10000 # 有界队列上限,满则丢
OBSERVE_TIMEOUT_SEC: float = 5.0 # 上报 HTTP 超时
@property
def observe_configured(self) -> bool:
"""观测上报可用 = 总开关开 且 endpoint/账号/密码齐全(缺则整套 no-op)。"""
return bool(
self.OBSERVE_ENABLED
and self.OBSERVE_ENDPOINT
and self.OBSERVE_USER
and self.OBSERVE_PASSWORD
)
```
- [ ] **Step 4: 运行,确认通过**
Run: `pytest tests/test_observe.py::test_observe_configured_requires_switch_and_creds -q`
Expected: PASS
- [ ] **Step 5: 提交**
```bash
git add app/core/config.py tests/test_observe.py
git commit -m "feat(observe): 加 OBSERVE_* 配置与 observe_configured 门槛"
```
---
## Task 2: 事件队列 + `record_event` + `take_dropped`
**Files:**
- Create: `app/core/observe.py`
- Test: `tests/test_observe.py`(追加)
- [ ] **Step 1: 写失败测试**
`tests/test_observe.py` 顶部 import 区补 `import asyncio``from app.core import observe`,并追加:
```python
def test_record_event_enqueues(monkeypatch):
q = asyncio.Queue(maxsize=10)
monkeypatch.setattr(observe, "_queue", q)
observe.record_event({"route": "/x"})
assert q.get_nowait() == {"route": "/x"}
def test_record_event_drops_when_full(monkeypatch):
q = asyncio.Queue(maxsize=1)
monkeypatch.setattr(observe, "_queue", q)
monkeypatch.setattr(observe, "_dropped", 0)
observe.record_event({"n": 1}) # 占满
observe.record_event({"n": 2}) # 满 → 丢弃当前,不抛异常
assert observe.take_dropped() == 1
assert observe.take_dropped() == 0 # 取出后清零
assert q.get_nowait() == {"n": 1} # 保留的是先到的
```
- [ ] **Step 2: 运行,确认失败**
Run: `pytest tests/test_observe.py -q -k record_event`
Expected: FAIL —— `ModuleNotFoundError: app.core.observe` 或无 `record_event`
- [ ] **Step 3: 实现 `app/core/observe.py`(先只放队列部分)**
> 注意:本步只放队列相关代码。中间件用到的 `os`/`time`/`Match``_SKIP_PATHS`/`_UNMATCHED`/`_SERVICE` 常量放到 Task 3 一并加入——否则本步提交时 ruff 会报 F401 未用导入。
新建 `app/core/observe.py`
```python
"""接口指标埋点:有界事件队列 + 纯 ASGI 中间件。
每个 HTTP 请求测总耗时、抓路由模板 + 状态码,非阻塞塞进有界队列;由 observe_worker
后台批量上报到 OpenObserve。请求路径上无任何 I/O。未配置观测时中间件直接透传。
"""
from __future__ import annotations
import asyncio
from app.core.config import settings
# 有界事件队列(懒创建,见 get_queue):首次取用时在运行中的 loop 里建,避免 import 期
# 无 loop 的边角问题;put_nowait/get_nowait 不需运行中的 loop → 可在无 loop 下测试。
_queue: asyncio.Queue[dict] | None = None
# 队列满时的丢弃计数,worker 定期取出打日志。
_dropped = 0
def get_queue() -> asyncio.Queue[dict]:
"""返回全局有界事件队列(懒创建)。测试可 monkeypatch 模块级 _queue 换成小队列。"""
global _queue
if _queue is None:
_queue = asyncio.Queue(maxsize=settings.OBSERVE_QUEUE_MAX)
return _queue
def take_dropped() -> int:
"""取出并清零累计丢弃数(供 worker 打点)。"""
global _dropped
n, _dropped = _dropped, 0
return n
def record_event(event: dict) -> None:
"""非阻塞入队;队列满则丢弃当前事件并计数。永不抛异常、永不阻塞请求。"""
global _dropped
try:
get_queue().put_nowait(event)
except asyncio.QueueFull:
_dropped += 1
```
- [ ] **Step 4: 运行,确认通过**
Run: `pytest tests/test_observe.py -q -k record_event`
Expected: PASS
- [ ] **Step 5: 提交**
```bash
git add app/core/observe.py tests/test_observe.py
git commit -m "feat(observe): 加有界事件队列与 record_event(满则丢)"
```
---
## Task 3: `RequestMetricsMiddleware`(路由模板 + 状态码 + 耗时)
**Files:**
- Modify: `app/core/observe.py`(追加 `_resolve_route` 和中间件 class
- Test: `tests/test_observe.py`(追加)
- [ ] **Step 1: 写失败测试**
`tests/test_observe.py` 顶部 import 区补:
```python
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
```
并追加:
```python
def _make_probe_app() -> FastAPI:
"""独立最小 app:只挂中间件 + 两个无鉴权路由,不碰真业务 DB/auth。"""
app = FastAPI()
app.add_middleware(observe.RequestMetricsMiddleware)
@app.get("/things/{tid}")
def get_thing(tid: str):
return {"tid": tid}
@app.get("/health")
def health():
return {"ok": True}
return app
@pytest.fixture
def observe_on(monkeypatch):
"""开启观测 + 换一个干净小队列,返回该队列供断言。"""
monkeypatch.setattr(settings, "OBSERVE_ENABLED", True)
monkeypatch.setattr(settings, "OBSERVE_USER", "u")
monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "p")
q = asyncio.Queue(maxsize=100)
monkeypatch.setattr(observe, "_queue", q)
return q
def test_middleware_records_route_template(observe_on):
client = TestClient(_make_probe_app())
r = client.get("/things/42")
assert r.status_code == 200
evt = observe_on.get_nowait()
assert evt["route"] == "/things/{tid}" # 模板,不是 /things/42
assert evt["method"] == "GET"
assert evt["status"] == 200
assert evt["duration_ms"] >= 0
assert evt["service"] and "env" in evt and isinstance(evt["_timestamp"], int)
def test_middleware_skips_health(observe_on):
client = TestClient(_make_probe_app())
client.get("/health")
assert observe_on.empty()
def test_middleware_unmatched_route_is_normalized(observe_on):
client = TestClient(_make_probe_app())
r = client.get("/definitely-not-a-route")
assert r.status_code == 404
evt = observe_on.get_nowait()
assert evt["route"] == "__unmatched__"
assert evt["status"] == 404
def test_middleware_noop_when_disabled(monkeypatch):
monkeypatch.setattr(settings, "OBSERVE_ENABLED", False)
q = asyncio.Queue(maxsize=100)
monkeypatch.setattr(observe, "_queue", q)
client = TestClient(_make_probe_app())
client.get("/things/1")
assert q.empty() # 未配置观测 → 零入队
```
- [ ] **Step 2: 运行,确认失败**
Run: `pytest tests/test_observe.py -q -k middleware`
Expected: FAIL —— `AttributeError: module 'app.core.observe' has no attribute 'RequestMetricsMiddleware'`
- [ ] **Step 3a: 给 `app/core/observe.py` 补中间件用的导入与常量**
把顶部 import 段从
```python
from __future__ import annotations
import asyncio
from app.core.config import settings
```
改成
```python
from __future__ import annotations
import asyncio
import os
import time
from starlette.routing import Match
from app.core.config import settings
# 不采集的路径(纯噪音):健康检查。
_SKIP_PATHS = frozenset({"/health"})
# 未匹配路由(404/扫描器)归一到此,防维度爆炸。
_UNMATCHED = "__unmatched__"
# service 字段:与 logging.py 同源(LOG_SERVICE_NAME),默认 app-server。
_SERVICE = os.getenv("LOG_SERVICE_NAME", "app-server")
```
`_queue` / `_dropped` / `get_queue` / `take_dropped` / `record_event` 保持不动。)
- [ ] **Step 3b: 实现中间件(追加到 `app/core/observe.py` 末尾)**
```python
def _resolve_route(scope) -> str:
"""从 scope 取路由模板(如 /things/{tid})。优先 scope['route'](现代 Starlette
路由后写入);取不到则手动匹配一次(老版本兜底);仍无 → __unmatched__(404/扫描器)。"""
route = scope.get("route")
path = getattr(route, "path", None)
if path:
return path
app_ = scope.get("app")
router = getattr(app_, "router", None)
for candidate in getattr(router, "routes", []):
try:
match, _ = candidate.matches(scope)
except Exception: # noqa: BLE001 - 匹配兜底,任一路由异常不影响整体
continue
if match == Match.FULL and getattr(candidate, "path", None):
return candidate.path
return _UNMATCHED
class RequestMetricsMiddleware:
"""纯 ASGI 中间件:测每个 http 请求耗时,记 method/route/status/duration。
放在最外层(main.py 里 CORS 之后 add),测到含 CORS 的完整耗时。未配置观测 → 透传。
"""
def __init__(self, app) -> None:
self.app = app
async def __call__(self, scope, receive, send) -> None:
if scope["type"] != "http" or not settings.observe_configured:
await self.app(scope, receive, send)
return
if scope.get("path") in _SKIP_PATHS:
await self.app(scope, receive, send)
return
start = time.perf_counter()
status_holder = {"status": 500} # 下游异常未产出 response 时兜底 500
async def send_wrapper(message) -> None:
if message["type"] == "http.response.start":
status_holder["status"] = message["status"]
await send(message)
try:
await self.app(scope, receive, send_wrapper)
finally:
duration_ms = (time.perf_counter() - start) * 1000.0
record_event({
"_timestamp": int(time.time() * 1_000_000), # µs,OpenObserve 时间列
"service": _SERVICE,
"env": settings.APP_ENV,
"method": scope.get("method", ""),
"route": _resolve_route(scope),
"status": status_holder["status"],
"duration_ms": round(duration_ms, 3),
})
```
- [ ] **Step 4: 运行,确认通过**
Run: `pytest tests/test_observe.py -q -k middleware`
Expected: PASS4 个中间件用例全过)
> 若 `test_middleware_records_route_template` 拿到的是 `/things/42` 而非模板,说明该 Starlette 版本未在 `scope["route"]` 写模板——此时 `_resolve_route` 的手动匹配兜底应已生效并返回模板;若仍不对,检查兜底分支是否被 import 顺序影响。
- [ ] **Step 5: 提交**
```bash
git add app/core/observe.py tests/test_observe.py
git commit -m "feat(observe): 加 RequestMetricsMiddleware(路由模板+状态码+耗时)"
```
---
## Task 4: 后台上报 worker
**Files:**
- Create: `app/core/observe_worker.py`
- Test: `tests/test_observe.py`(追加)
- [ ] **Step 1: 写失败测试**
`tests/test_observe.py` 顶部 import 区补:
```python
import httpx
from app.core import observe_worker
```
并追加:
```python
async def test_collect_batch_drains_up_to_batch_max(monkeypatch):
q = asyncio.Queue(maxsize=100)
monkeypatch.setattr(observe, "_queue", q)
monkeypatch.setattr(settings, "OBSERVE_FLUSH_INTERVAL_SEC", 0.1)
monkeypatch.setattr(settings, "OBSERVE_BATCH_MAX", 200)
for i in range(3):
q.put_nowait({"n": i})
batch = await observe_worker._collect_batch()
assert [e["n"] for e in batch] == [0, 1, 2]
async def test_collect_batch_timeout_returns_empty(monkeypatch):
q = asyncio.Queue(maxsize=100)
monkeypatch.setattr(observe, "_queue", q)
monkeypatch.setattr(settings, "OBSERVE_FLUSH_INTERVAL_SEC", 0.05)
batch = await observe_worker._collect_batch()
assert batch == []
async def test_post_batch_hits_json_ingest_url(monkeypatch):
monkeypatch.setattr(settings, "OBSERVE_ORG", "default")
monkeypatch.setattr(settings, "OBSERVE_STREAM", "app_requests")
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["json"] = request.content
return httpx.Response(200, json={"code": 200})
client = httpx.AsyncClient(
base_url="http://oo", transport=httpx.MockTransport(handler)
)
await observe_worker._post_batch(client, [{"route": "/x", "status": 200}])
await client.aclose()
assert captured["url"] == "http://oo/api/default/app_requests/_json"
assert b"/x" in captured["json"]
def test_start_observe_worker_noop_when_not_configured(monkeypatch):
monkeypatch.setattr(settings, "OBSERVE_ENABLED", False)
assert observe_worker.start_observe_worker() is None
```
- [ ] **Step 2: 运行,确认失败**
Run: `pytest tests/test_observe.py -q -k "collect_batch or post_batch or start_observe"`
Expected: FAIL —— `ModuleNotFoundError: app.core.observe_worker`
- [ ] **Step 3: 实现 `app/core/observe_worker.py`**
新建 `app/core/observe_worker.py`
```python
"""接口指标后台上报 worker:批量 drain 事件队列 → POST 到 OpenObserve。
对齐 heartbeat_monitor_worker 等的 start_*/stop_* 形态。best-effort 遥测:catch 全部
异常,上报失败直接丢批不重试。未配置观测 → start 返回 None(不启动),整套 no-op。
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import httpx
from app.core.config import settings
from app.core.observe import get_queue, take_dropped
logger = logging.getLogger("shagua.observe")
# 上报用的 httpx client,start 时建、stop 时关。
_client: httpx.AsyncClient | None = None
async def _collect_batch() -> list[dict]:
"""等到 ≥1 条(或到 flush 间隔)后,连抽到 BATCH_MAX 条或抽空。超时且空 → 返回 []。"""
queue = get_queue()
batch: list[dict] = []
try:
first = await asyncio.wait_for(
queue.get(), timeout=settings.OBSERVE_FLUSH_INTERVAL_SEC
)
except asyncio.TimeoutError:
return batch
batch.append(first)
while len(batch) < settings.OBSERVE_BATCH_MAX:
try:
batch.append(queue.get_nowait())
except asyncio.QueueEmpty:
break
return batch
async def _post_batch(client: httpx.AsyncClient, batch: list[dict]) -> None:
"""POST 一批事件到 OpenObserve 的 _json ingest 端点。非 2xx 仅告警。"""
url = f"/api/{settings.OBSERVE_ORG}/{settings.OBSERVE_STREAM}/_json"
resp = await client.post(url, json=batch)
if resp.status_code >= 300:
logger.warning(
"observe ingest failed status=%s body=%s",
resp.status_code,
resp.text[:200],
)
async def _run_loop(client: httpx.AsyncClient) -> None:
try:
while True:
batch = await _collect_batch()
dropped = take_dropped()
if dropped:
logger.warning("observe dropped %d events (queue full)", dropped)
if not batch:
continue
try:
await _post_batch(client, batch)
except Exception: # noqa: BLE001 - best-effort 遥测,失败丢批不重试、不退出
logger.warning(
"observe post batch failed, dropped %d events",
len(batch),
exc_info=True,
)
except asyncio.CancelledError:
logger.info("observe worker stopped")
raise
def start_observe_worker() -> asyncio.Task | None:
"""启动上报 worker。未配置观测 → 返回 None(no-op)。"""
global _client
if not settings.observe_configured:
return None
_client = httpx.AsyncClient(
base_url=settings.OBSERVE_ENDPOINT,
auth=(settings.OBSERVE_USER, settings.OBSERVE_PASSWORD),
timeout=settings.OBSERVE_TIMEOUT_SEC,
)
logger.info(
"observe worker started endpoint=%s org=%s stream=%s",
settings.OBSERVE_ENDPOINT,
settings.OBSERVE_ORG,
settings.OBSERVE_STREAM,
)
return asyncio.create_task(_run_loop(_client), name="observe-worker")
async def stop_observe_worker(task: asyncio.Task | None) -> None:
"""收尾:cancel worker → best-effort 发最后一批 → 关 client。"""
global _client
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
if _client is not None:
# worker 已停,安全 drain 剩余并 best-effort 发最后一批(短超时,不卡关停)。
try:
queue = get_queue()
final: list[dict] = []
while len(final) < settings.OBSERVE_BATCH_MAX:
try:
final.append(queue.get_nowait())
except asyncio.QueueEmpty:
break
if final:
await asyncio.wait_for(
_post_batch(_client, final), timeout=settings.OBSERVE_TIMEOUT_SEC
)
except Exception: # noqa: BLE001 - 关停期尽力而为,失败忽略
pass
await _client.aclose()
_client = None
```
- [ ] **Step 4: 运行,确认通过**
Run: `pytest tests/test_observe.py -q -k "collect_batch or post_batch or start_observe"`
Expected: PASS
- [ ] **Step 5: 提交**
```bash
git add app/core/observe_worker.py tests/test_observe.py
git commit -m "feat(observe): 加后台批量上报 worker(失败丢批不重试)"
```
---
## Task 5: 接线到 `app/main.py`(挂中间件 + lifespan 启停)
**Files:**
- Modify: `app/main.py`import 区、lifespan、CORS 之后)
- Test: `tests/test_observe.py`(追加)
- [ ] **Step 1: 写失败测试**
`tests/test_observe.py` 追加:
```python
def test_app_has_metrics_middleware():
from app.main import app
names = [m.cls.__name__ for m in app.user_middleware]
assert "RequestMetricsMiddleware" in names
```
- [ ] **Step 2: 运行,确认失败**
Run: `pytest tests/test_observe.py::test_app_has_metrics_middleware -q`
Expected: FAIL —— 断言失败(中间件尚未挂载)
- [ ] **Step 3: 实现接线**
3a. 在 `app/main.py` import 区(`withdraw_reconcile_worker` import 块之后)加:
```python
from app.core.observe import RequestMetricsMiddleware
from app.core.observe_worker import (
start_observe_worker,
stop_observe_worker,
)
```
3b. lifespan 里加启停(现有 `daily_exchange_task = start_daily_exchange_worker()` 之后、`try:` 之前加一行;`finally` 里在 `stop_daily_exchange_worker` 之后加一行):
```python
daily_exchange_task = start_daily_exchange_worker()
observe_task = start_observe_worker()
try:
yield
finally:
await stop_heartbeat_monitor(heartbeat_task)
await stop_withdraw_reconcile_worker(reconcile_task)
await stop_daily_exchange_worker(daily_exchange_task)
await stop_observe_worker(observe_task)
await aclose_pricebot_client()
logger.info("shutting down")
```
3c. 挂中间件——在 CORS 的 `if settings.cors_origins_list:` 整块之后加(使其成为最外层,测到含 CORS 的完整耗时):
```python
# 接口指标埋点(最外层:测含 CORS 的完整耗时)。未配置观测时中间件自 no-op。
app.add_middleware(RequestMetricsMiddleware)
```
- [ ] **Step 4: 运行,确认通过**
Run: `pytest tests/test_observe.py::test_app_has_metrics_middleware -q`
Expected: PASS
- [ ] **Step 5: 跑整套 observe 测试 + 全量回归,确认无破坏**
Run: `pytest tests/test_observe.py -q && pytest -q`
Expected: 全 PASS(现有用例不受影响:conftest 未设 `OBSERVE_*` → 观测关 → worker no-op、中间件透传)
- [ ] **Step 6: 提交**
```bash
git add app/main.py tests/test_observe.py
git commit -m "feat(observe): main.py 挂中间件 + lifespan 启停上报 worker"
```
---
## Task 6: OpenObserve 本地部署(compose + README + .env.example
**Files:**
- Create: `deploy/openobserve/docker-compose.yml`
- Create: `deploy/openobserve/README.md`
- Modify: `.env.example`(追加 `OBSERVE_*` 段)
- [ ] **Step 1: 写 docker-compose**
新建 `deploy/openobserve/docker-compose.yml`
```yaml
# 本地开发用 OpenObserve(单容器 = local 模式)。用于接收 app-server 的接口指标。
# 启动: cd deploy/openobserve && docker compose up -d
# Web UI: http://localhost:5080 (账号见下方 env)
services:
openobserve:
image: public.ecr.aws/zinclabs/openobserve:latest
container_name: openobserve
ports:
- "5080:5080"
environment:
ZO_ROOT_USER_EMAIL: "admin@shaguabijia.local"
ZO_ROOT_USER_PASSWORD: "Complexpass#123"
ZO_DATA_DIR: "/data"
volumes:
- ./data:/data
restart: unless-stopped
```
- [ ] **Step 2: 写 README**
新建 `deploy/openobserve/README.md`
````markdown
# OpenObserve 本地部署(接口 QPS / 耗时可观测)
app-server 通过中间件采集每个接口的 QPS + 耗时 + 错误率,批量上报到这里。
## 启动
```bash
cd deploy/openobserve
docker compose up -d
```
- Web UIhttp://localhost:5080
- 登录:`admin@shaguabijia.local` / `Complexpass#123`(见 `docker-compose.yml`
- 数据落 `deploy/openobserve/data/`(已挂卷持久化;`data/` 建议 gitignore
## 让 app-server 上报
在项目根的 `.env` 打开观测(`OBSERVE_*`,账号密码与 compose 里 root 一致):
```dotenv
OBSERVE_ENABLED=true
OBSERVE_ENDPOINT=http://localhost:5080
OBSERVE_ORG=default
OBSERVE_STREAM=app_requests
OBSERVE_USER=admin@shaguabijia.local
OBSERVE_PASSWORD=Complexpass#123
```
重启 app-server,随便打几个接口。stream `app_requests` **首次上报自动创建**
在 UI 的 Logs → 选 `app_requests` 就能看到逐条请求事件。
## 查询(Logs 页 SQL,或建 Dashboard 面板)
各接口 QPS(1 分钟分桶,面板里再除 60 得每秒):
```sql
SELECT route, histogram(_timestamp, '1 minute') AS ts, count(*) AS cnt
FROM app_requests GROUP BY route, ts ORDER BY ts
```
各接口 P95 耗时(毫秒):
```sql
SELECT route, approx_percentile_cont(duration_ms, 0.95) AS p95_ms
FROM app_requests GROUP BY route ORDER BY p95_ms DESC
```
各接口错误率(5xx 占比):
```sql
SELECT route,
count(*) FILTER (WHERE status >= 500) * 100.0 / count(*) AS err_pct
FROM app_requests GROUP BY route ORDER BY err_pct DESC
```
## 停止 / 清数据
```bash
docker compose down # 停止(保留数据)
docker compose down -v && rm -rf data # 停止并清空数据
```
> 生产部署(持久化规格、独立 ingest 账号、鉴权收紧)见 spec 第 9 节,本期不做。
````
- [ ] **Step 3: 追加 `.env.example`**
`.env.example` 末尾追加:
```dotenv
# ===== 可观测(OpenObserve 接口指标)=====
# 采集每个接口 QPS + 耗时 + 错误率,批量直采到 OpenObserve(本地 Docker,见 deploy/openobserve/)。
# 默认关;开启需 ENABLED=true 且填 USER/PASSWORD(与 docker-compose 里 root 账号一致)。
OBSERVE_ENABLED=false
OBSERVE_ENDPOINT=http://localhost:5080
OBSERVE_ORG=default
OBSERVE_STREAM=app_requests
OBSERVE_USER=admin@shaguabijia.local
OBSERVE_PASSWORD=Complexpass#123
# 进阶(一般不用改):攒批间隔秒 / 单批最大条数 / 有界队列上限(满则丢) / 上报超时秒
OBSERVE_FLUSH_INTERVAL_SEC=5
OBSERVE_BATCH_MAX=200
OBSERVE_QUEUE_MAX=10000
OBSERVE_TIMEOUT_SEC=5
```
- [ ] **Step 4: 校验 compose 语法(不需真拉镜像)**
Run: `docker compose -f deploy/openobserve/docker-compose.yml config`
Expected: 打印规整后的配置、无报错(若本机无 docker,可跳过,标注为手动验证项)
- [ ] **Step 5: 提交**
```bash
git add deploy/openobserve/docker-compose.yml deploy/openobserve/README.md .env.example
git commit -m "feat(observe): 加 OpenObserve 本地 compose + README + .env.example"
```
---
## Task 7: 端到端手动验证 + 全量 lint/test 收尾
**Files:** 无(验证 + 收尾)
- [ ] **Step 1: 起 OpenObserve**
Run: `cd deploy/openobserve && docker compose up -d`
Expected: 容器起来,浏览器打开 http://localhost:5080 能登录
- [ ] **Step 2: 本地开观测起 app-server**
在根 `.env``OBSERVE_ENABLED=true` + `OBSERVE_USER/PASSWORD`(同 compose),然后:
Run: `./run.sh`Windows 用 `python -m uvicorn app.main:app --port 8770 --reload --reload-dir app`
Expected: 启动日志出现 `observe worker started endpoint=http://localhost:5080 ...`
- [ ] **Step 3: 打几个接口产生数据**
Run: `curl http://localhost:8770/health && curl http://localhost:8770/things-does-not-exist -i`(或正常业务接口若干)
Expected: 稍等 ≤5sflush 间隔),OpenObserve UI 的 Logs → `app_requests` 出现事件;`/health` 不应出现;不存在的路径 route 为 `__unmatched__`
- [ ] **Step 4: 验证三条查询**
在 OpenObserve UI 分别粘贴 README 里的 QPS / P95 / 错误率 SQL,确认能出数。
- [ ] **Step 5: lint(仅本改动涉及文件)+ 全量测试**
> 说明:仓库基线有 ~558 个既有 ruff 错误、且未强制 ruff 通过。不要去清历史欠债(范围蔓延)。只要求**本次新增/改动的文件**零 ruff 错误。
Run: `ruff check app/core/observe.py app/core/observe_worker.py tests/test_observe.py && python -m pytest -q`
Expected: 上述三个新文件 ruff 无错;测试里 `tests/test_observe.py` 全 PASS,且**全量失败数不超过基线的 4 个**(test_compare_proxy ×2 / test_coupon_proxy ×1 / test_invite ×1,均与本功能无关)。
额外确认我对既有文件的改动没有引入**新的** ruff 错误:`ruff check app/core/config.py app/main.py`(数量应与基线一致,不因本改动增加)。
- [ ] **Step 6: 关观测复跑一次,确认降级**
`.env``OBSERVE_ENABLED` 改回 `false``ruff check .` 不涉及,直接 `pytest -q`
Expected: 全 PASS(验证 observe 关闭时零副作用)
- [ ] **Step 7: 收尾提交(如有 .env 之外的改动)**
```bash
git add -A
git commit -m "chore(observe): 端到端验证与收尾" --allow-empty
```
> `.env` 不入 git(已 gitignore);本任务只验证,不提交 `.env`
---
## Self-Review(写完计划后自查)
- **Spec 覆盖**Docker 部署→Task 6/7;事件 schema→Task 3`record_event` 事件字段);中间件→Task 3worker→Task 4;配置→Task 1main 接线→Task 5;查询/仪表盘→Task 6 README;测试→Task 1-5;决策(a)队列满丢→Task 2(b)失败不重试→Task 4(c)跳过 /health→Task 3。全覆盖。
- **占位符**:无 TBD/TODO;每个代码步骤含完整代码。
- **类型/命名一致**`get_queue` / `record_event` / `take_dropped` / `RequestMetricsMiddleware` / `start_observe_worker` / `stop_observe_worker` / `observe_configured` / 事件字段名,跨 Task 1-5 与文件结构表一致。
@@ -1,830 +0,0 @@
# 逐次比价/领券广告收益 Implementation Plan
> **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:** 让 admin「领券数据」和「比价记录」两个看板的 table 每一行显示这一次领券/比价产生的广告收益(预估元)。
**Architecture:** 客户端在信息流(Draw)展示上报 eCPM 时带上本场 `trace_id`,后端落到 `ad_ecpm_record.trace_id`(新列 + 索引)。两个看板在分页后,对**当前页**的 trace_id 批量聚合一次 `ad_ecpm_record` 的展示收益(单条收益 = min(eCPM元,¥500)/1000,与广告收益报表同口径),挂到每行。查询是按 trace_id 索引的单条聚合,与已上线的 `_ad_coins_by_trace`(比价记录页「比价赚N金币」)同一性能剖面。
**Tech Stack:** 后端 FastAPI + SQLAlchemy 2.0 + Alembic;客户端 Android(Kotlin/OkHttp);admin 前端 Next.js + React + Ant Design(shaguabijia-admin-web)。
---
## 背景与约束(执行前必读)
- **收益 ≠ 金币**。本功能查的是「我们赚的广告收益」(数据源 `ad_ecpm_record`,客户端自报 eCPM 折算的预估),不是发给用户的金币(那是 `ad_feed_reward_record`,已有 `trace_id`)。
- **只能到「场景 + 单次」粒度**`trace_id` 由客户端在比价(comparisonTraceId)/领券(sessionTraceId)全流程保持不变。激励视频、福利页、旧客户端不带 trace_id → 该列为 NULL,历史数据无法回填,只对升级后新数据生效。
- **收益口径**(与 `app/admin/repositories/ad_revenue.py:165-167` 完全一致):
单条展示收益(元) = `min(parse_ecpm_yuan(ecpm_raw), AD_ECPM_MAX_FEN/100) / 1000`
其中 `AD_ECPM_MAX_FEN = 50000`(分)= ¥500 CPM 封顶,`parse_ecpm_yuan(x) = parse_ecpm_fen(x)/100`
- **性能前提**:`ad_ecpm_record` 是全库写入量最大的表。必须有 `ix_ad_ecpm_record_trace_id` 索引(本计划 Task A2 建),且只对**当前页**的 trace_id 聚合,绝不对整个日期区间聚合。
- **跨仓库**:本计划涉及三个仓库,路径前缀:
- 后端 `e:\project\shaguabijia-app-server`(相对路径即以此为根)
- 客户端 `E:\project\shaguabijia-app-android`
- admin 前端 `e:\project\shaguabijia-admin-web`
---
## File Structure
### 后端(shaguabijia-app-server)
- Modify `app/models/ad_ecpm.py``AdEcpmRecord``trace_id` 列(索引)
- Create `alembic/versions/ad_ecpm_trace_id.py` — 加列 + 索引,并收敛当前双 head
- Modify `app/schemas/ad.py``EcpmReportIn``trace_id` 字段
- Modify `app/repositories/ad_ecpm.py``create_ecpm_record` 持久化 `trace_id`;新增 `revenue_yuan_by_trace` 聚合器
- Modify `app/api/v1/ad.py``ecpm_report` 透传 `trace_id`
- Modify `app/admin/schemas/coupon_data.py``CouponDataRow``ad_revenue_yuan`
- Modify `app/admin/repositories/coupon_data.py` — 逐页补 `ad_revenue_yuan`
- Modify `app/admin/schemas/comparison.py``AdminComparisonListItem``ad_revenue_yuan`
- Modify `app/admin/repositories/queries.py``list_comparison_records` 逐页补 `ad_revenue_yuan`
- Create `tests/test_ad_ecpm_trace_revenue.py` — 聚合器 + 落库单测
- Create `tests/test_board_ad_revenue.py` — 两个看板收益列单测
> `app/models/__init__.py` **不需改**:`AdEcpmRecord` 已注册,只是加列。
### 客户端(shaguabijia-app-android)
- Modify `app/src/main/java/com/jishisongfu/shaguabijia/agent/network/ApiClient.kt``reportAdImpression``traceId` 参数
- Modify `app/src/main/java/com/jishisongfu/shaguabijia/agent/service/ad/CompareAdController.kt` — 比价展示上报带 `traceId`
- Modify `app/src/main/java/com/jishisongfu/shaguabijia/service/CouponForegroundService.kt` — 领券展示上报带 `traceId`
### admin 前端(shaguabijia-admin-web)
- Modify `src/lib/types.ts``ComparisonRecordListItem``ad_revenue_yuan`
- Modify `src/app/(main)/comparison-records/page.tsx` — 加「广告收益」列
- Modify `src/app/(main)/coupon-data/page.tsx``CouponDataRow` 加字段 + 加「广告收益」列
---
## Phase A — 后端数据打通(落 trace_id + 收益聚合器)
### Task A1: `AdEcpmRecord``trace_id`
**Files:**
- Modify: `app/models/ad_ecpm.py`
- [ ] **Step 1: 加列**
`app/models/ad_ecpm.py` 中,找到 `feed_scene` 这一行:
```python
feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True)
```
在其**下方**插入:
```python
# 本次比价/领券 trace_id(信息流场景客户端带上):把这条展示收益归属到对应比价/领券记录。
# 领券数据 / 比价记录看板按 trace_id 聚合"本次广告收益"。激励视频/福利/旧客户端 = NULL。
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
```
- [ ] **Step 2: 提交**
```bash
git add app/models/ad_ecpm.py
git commit -m "feat(ad-ecpm): add trace_id column to AdEcpmRecord model"
```
---
### Task A2: 迁移 — 加列 + 索引,并收敛双 head
**Files:**
- Create: `alembic/versions/ad_ecpm_trace_id.py`
> ⚠️ 当前 `alembic heads` 有**两个 head**:`11c44afbea58`(selfstat 表)与 `merge_pages_override_coupon_slot`(#126+领券合并)。本迁移用元组 `down_revision` 把二者收敛成单 head,同时加列,让 `alembic upgrade head`(单数,run.sh 用)恢复正常。
- [ ] **Step 1: 确认当前 heads 未漂移**
Run: `alembic heads`
Expected: 恰好两行 —
```
11c44afbea58 (head)
merge_pages_override_coupon_slot (head)
```
若不同(他人已合并/新增),把下面 `down_revision` 改成此刻实际的 head 列表。
- [ ] **Step 2: 建迁移文件**
Create `alembic/versions/ad_ecpm_trace_id.py`:
```python
"""ad_ecpm_record.trace_id(展示收益归属到比价/领券 trace)+ 收敛双 head
信息流(Draw)展示 eCPM 上报时带上本场比价/领券 trace_id,落此列;领券数据 / 比价记录看板
按 trace_id 聚合"本次广告收益"。激励视频/福利/旧客户端为 NULL。
顺带把当前两个 head(11c44afbea58 selfstat 表 + merge_pages_override_coupon_slot)收敛成
单 head,让 `alembic upgrade head`(单数,部署/run.sh 用)恢复正常。
Revision ID: ad_ecpm_trace_id
Revises: 11c44afbea58, merge_pages_override_coupon_slot
Create Date: 2026-07-10
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "ad_ecpm_trace_id"
down_revision: Union[str, Sequence[str], None] = (
"11c44afbea58",
"merge_pages_override_coupon_slot",
)
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# SQLite 下 ADD COLUMN(可空)与 CREATE INDEX 均原生支持,无需 batch_alter_table
# (同 ad_feed_reward_trace_id 迁移)。
op.add_column(
"ad_ecpm_record",
sa.Column("trace_id", sa.String(length=64), nullable=True),
)
op.create_index(
op.f("ix_ad_ecpm_record_trace_id"),
"ad_ecpm_record",
["trace_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index(op.f("ix_ad_ecpm_record_trace_id"), table_name="ad_ecpm_record")
op.drop_column("ad_ecpm_record", "trace_id")
```
- [ ] **Step 3: 应用迁移**
Run: `alembic upgrade head`
Expected: 无报错(不再报 "multiple heads")。
- [ ] **Step 4: 验证单 head + 列存在**
Run: `alembic heads`
Expected: 只有一行 `ad_ecpm_trace_id (head)`
Run: `python -c "from sqlalchemy import inspect; from app.db.session import engine; print([c['name'] for c in inspect(engine).get_columns('ad_ecpm_record')])"`
Expected: 输出的列名列表包含 `trace_id`
- [ ] **Step 5: 提交**
```bash
git add alembic/versions/ad_ecpm_trace_id.py
git commit -m "feat(migration): add ad_ecpm_record.trace_id + index, converge heads"
```
---
### Task A3: `EcpmReportIn``trace_id` 字段
**Files:**
- Modify: `app/schemas/ad.py`
- [ ] **Step 1: 加字段**
`app/schemas/ad.py``EcpmReportIn` 里,找到 `feed_scene` 字段定义(以 `feed_scene: str | None = Field(` 开头的那段)。在该字段**之后**插入:
```python
trace_id: str | None = Field(
None,
max_length=64,
description="本次比价/领券 trace_id(信息流场景带上):把这条展示收益归属到对应比价/领券,"
"供领券数据/比价记录看板聚合本场广告收益;激励视频/福利为空",
)
```
- [ ] **Step 2: 提交**
```bash
git add app/schemas/ad.py
git commit -m "feat(ad-schema): EcpmReportIn accepts trace_id"
```
---
### Task A4: `create_ecpm_record` 持久化 trace_id + 新增 `revenue_yuan_by_trace`
**Files:**
- Modify: `app/repositories/ad_ecpm.py`
- Test: `tests/test_ad_ecpm_trace_revenue.py`
- [ ] **Step 1: 写失败测试**
Create `tests/test_ad_ecpm_trace_revenue.py`:
```python
"""ad_ecpm_record.trace_id 落库 + 按 trace 聚合广告收益(元)。"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import delete
from app.db.session import SessionLocal
from app.models.ad_ecpm import AdEcpmRecord
from app.repositories import ad_ecpm as crud_ecpm
def _ecpm(trace_id: str, ecpm_raw: str, session_id: str) -> AdEcpmRecord:
"""构造一条 Draw 展示 eCPM(不 commit;ad_session_id 全局唯一,须各不相同)。"""
return AdEcpmRecord(
user_id=1,
ad_type="draw",
feed_scene="comparison",
ad_session_id=session_id,
ecpm_raw=ecpm_raw,
trace_id=trace_id,
report_date="2020-01-02",
created_at=datetime(2020, 1, 2, tzinfo=UTC),
)
def test_revenue_yuan_by_trace_sums_and_clamps() -> None:
"""同一 trace 多条展示求和;收益=min(eCPM元,¥500)/1000;无展示的 trace 不出现。
ecpm 200 分→2.0 元/千次→0.002 元/次;300 分→0.003;合计 0.005。
"""
db = SessionLocal()
try:
db.add_all([
_ecpm("t1", "200", "sess-t1-a"),
_ecpm("t1", "300", "sess-t1-b"),
_ecpm("t2", "0", "sess-t2-a"),
])
db.flush()
rev = crud_ecpm.revenue_yuan_by_trace(db, ["t1", "t2", "t3"])
assert rev["t1"] == 0.005
assert rev.get("t2", 0.0) == 0.0
assert "t3" not in rev # 无展示的 trace 不出现在结果里
finally:
db.rollback()
db.close()
def test_revenue_yuan_by_trace_empty() -> None:
"""空 trace 列表直接返回 {}(避免 IN () 非法)。"""
db = SessionLocal()
try:
assert crud_ecpm.revenue_yuan_by_trace(db, []) == {}
finally:
db.close()
def test_create_ecpm_record_persists_trace_id() -> None:
"""create_ecpm_record 落 trace_id。"""
db = SessionLocal()
try:
rec = crud_ecpm.create_ecpm_record(
db, 1, ad_type="draw", ecpm_raw="150",
ad_session_id="sess-trace-persist", feed_scene="coupon",
trace_id="trace-xyz",
)
assert rec.trace_id == "trace-xyz"
finally:
db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.ad_session_id == "sess-trace-persist"))
db.commit()
db.close()
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pytest tests/test_ad_ecpm_trace_revenue.py -q`
Expected: FAIL — `create_ecpm_record``trace_id` 参数(TypeError)/ `revenue_yuan_by_trace` 不存在(AttributeError)。
- [ ] **Step 3: 实现**
`app/repositories/ad_ecpm.py`:
(a) 顶部 import 区加(与现有 `from app.core.rewards import cn_today` 并列):
```python
from app.core import rewards
```
(b) `create_ecpm_record` 的签名里,在 `feed_scene: str | None = None,` 之后加一行参数:
```python
trace_id: str | None = None,
```
(c) 同函数体内构造 `AdEcpmRecord(...)` 处,在 `feed_scene=feed_scene,` 之后加一行:
```python
trace_id=trace_id,
```
(d) 文件末尾新增聚合器:
```python
def revenue_yuan_by_trace(db: Session, trace_ids: list[str]) -> dict[str, float]:
"""各 trace_id 的广告预估收益(元):按 trace_id 聚合 ad_ecpm_record 的展示收益。
单条展示收益 = min(eCPM元, AD_ECPM_MAX_FEN/100) / 1000(与 admin 广告收益报表同口径)。
ecpm_raw 是字符串且需逐条钳顶,故取回后 Python 求和(行数=本页各 trace 的展示条数,很小)。
trace_id 仅信息流(比价/领券)场景客户端带,激励视频/旧数据为 NULL,按 trace_id 过滤天然只算对应场景。
只喂**当前页**的 trace_id(≤ 一页条数);空集合直接返回(避免 IN () 非法)。
"""
if not trace_ids:
return {}
rows = db.execute(
select(AdEcpmRecord.trace_id, AdEcpmRecord.ecpm_raw).where(
AdEcpmRecord.trace_id.in_(trace_ids),
)
).all()
cap_yuan = rewards.AD_ECPM_MAX_FEN / 100.0
out: dict[str, float] = {}
for tid, ecpm_raw in rows:
if not tid:
continue
out[tid] = out.get(tid, 0.0) + min(rewards.parse_ecpm_yuan(ecpm_raw), cap_yuan) / 1000.0
return {tid: round(v, 6) for tid, v in out.items()}
```
- [ ] **Step 4: 跑测试确认通过**
Run: `pytest tests/test_ad_ecpm_trace_revenue.py -q`
Expected: PASS(3 passed)。
- [ ] **Step 5: 提交**
```bash
git add app/repositories/ad_ecpm.py tests/test_ad_ecpm_trace_revenue.py
git commit -m "feat(ad-ecpm): persist trace_id + revenue_yuan_by_trace aggregator"
```
---
### Task A5: `ecpm_report` 端点透传 trace_id
**Files:**
- Modify: `app/api/v1/ad.py`
- [ ] **Step 1: 透传字段**
`app/api/v1/ad.py``ecpm_report` 函数里,找到 `crud_ecpm.create_ecpm_record(` 调用,在 `feed_scene=payload.feed_scene,` 之后加一行:
```python
trace_id=payload.trace_id,
```
- [ ] **Step 2: 冒烟验证(手动,可选)**
启动后端(`./run.sh`),用一个有效用户 JWT 调:
Run:
```bash
curl -s -X POST http://127.0.0.1:8770/api/v1/ad/ecpm-report \
-H "Authorization: Bearer <USER_JWT>" -H "Content-Type: application/json" \
-d '{"ad_type":"draw","ecpm":"200","ad_session_id":"smoke-sess-1","feed_scene":"comparison","trace_id":"smoke-trace-1"}'
```
Expected: `{"ok":true}`;库里 `ad_ecpm_record` 出现一条 `trace_id='smoke-trace-1'` 的记录。
> 端点逻辑是纯透传,已由 A4 的 schema/repo 测试覆盖;此步仅人工确认接线。
- [ ] **Step 3: 提交**
```bash
git add app/api/v1/ad.py
git commit -m "feat(ad-api): ecpm-report forwards trace_id to record"
```
---
## Phase B — 后端两个看板补收益列
### Task B1: 领券数据看板逐行补 `ad_revenue_yuan`
**Files:**
- Modify: `app/admin/schemas/coupon_data.py`
- Modify: `app/admin/repositories/coupon_data.py`
- Test: `tests/test_board_ad_revenue.py`
- [ ] **Step 1: 写失败测试**
Create `tests/test_board_ad_revenue.py`:
```python
"""两个看板逐行「本次广告收益」(元):按 trace_id 聚合 ad_ecpm_record。"""
from __future__ import annotations
from datetime import UTC, date, datetime
from sqlalchemy import delete
from app.admin.repositories import queries
from app.admin.repositories.coupon_data import coupon_data_report
from app.db.session import SessionLocal
from app.models.ad_ecpm import AdEcpmRecord
from app.models.comparison import ComparisonRecord
from app.models.coupon_state import CouponSession
def _ecpm(trace_id: str, ecpm_raw: str, session_id: str, scene: str) -> AdEcpmRecord:
return AdEcpmRecord(
user_id=1, ad_type="draw", feed_scene=scene, ad_session_id=session_id,
ecpm_raw=ecpm_raw, trace_id=trace_id, report_date="2020-01-02",
created_at=datetime(2020, 1, 2, tzinfo=UTC),
)
def test_coupon_data_report_includes_ad_revenue() -> None:
"""领券看板明细行带本次广告收益;200+300 分 → 0.005 元。"""
db = SessionLocal()
try:
db.add(CouponSession(
trace_id="rev-cp-1", device_id="d1", status="completed", app_env="prod",
platforms=["meituan-waimai"], platform_success=["meituan-waimai"],
started_at=datetime(2020, 1, 2, tzinfo=UTC), started_date=date(2020, 1, 2),
))
db.add_all([
_ecpm("rev-cp-1", "200", "cp-sess-a", "coupon"),
_ecpm("rev-cp-1", "300", "cp-sess-b", "coupon"),
])
db.flush()
res = coupon_data_report(db, date_from="2020-01-02", date_to="2020-01-02", app_env="prod")
row = next(r for r in res["items"] if r["trace_id"] == "rev-cp-1")
assert row["ad_revenue_yuan"] == 0.005
finally:
db.rollback()
db.close()
def test_comparison_list_includes_ad_revenue() -> None:
"""比价记录列表项带本次广告收益;200 分 → 0.002 元。
用独有 user_id 过滤,确保本行必落在第一页(避免共享测试库里同 user 记录多、分页把它挤掉)。
"""
db = SessionLocal()
try:
db.add(ComparisonRecord(
trace_id="rev-cmp-1", user_id=987654, status="success", business_type="food",
created_at=datetime(2020, 1, 2, tzinfo=UTC),
))
db.add(_ecpm("rev-cmp-1", "200", "cmp-sess-a", "comparison"))
db.commit()
items, _next, _total = queries.list_comparison_records(db, user_id=987654)
row = next(it for it in items if it.trace_id == "rev-cmp-1")
assert row.ad_revenue_yuan == 0.002
finally:
db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.trace_id.in_(["rev-cmp-1"])))
db.execute(delete(ComparisonRecord).where(ComparisonRecord.trace_id == "rev-cmp-1"))
db.commit()
db.close()
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pytest tests/test_board_ad_revenue.py -q`
Expected: FAIL — `coupon_data_report` 明细行无 `ad_revenue_yuan` 键(KeyError);`ComparisonRecord``ad_revenue_yuan`(AttributeError)。
- [ ] **Step 3: 领券 schema 加字段**
`app/admin/schemas/coupon_data.py``CouponDataRow` 里,`trace_url` 字段**之后**加:
```python
ad_revenue_yuan: float = Field(
0.0, description="本次领券看的信息流广告预估收益(元);按 trace_id 聚合 ad_ecpm_record"
)
```
- [ ] **Step 4: 领券 repo 逐页补收益**
`app/admin/repositories/coupon_data.py`:
(a) import 区(现有 `from app.models.user import User` 附近)加:
```python
from app.repositories import ad_ecpm as crud_ecpm
```
(b) `_session_to_row` 签名改为(加末位参数):
```python
def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad_revenue_yuan: float = 0.0) -> dict:
```
并在其返回的 dict 里,`"trace_url": r.trace_url,` 之后加一行:
```python
"ad_revenue_yuan": ad_revenue_yuan,
```
(c) 在 `coupon_data_report` 里,找到构造明细的这段:
```python
items = []
for r in page:
phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None)
items.append(_session_to_row(r, phone, nickname))
```
替换为(新增 `rev_map` + 传入):
```python
rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in page])
items = []
for r in page:
phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None)
items.append(_session_to_row(r, phone, nickname, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)))
```
> `coupon_user_records`(手机号抽屉)仍走 `_session_to_row(r)`,`ad_revenue_yuan` 取默认 0.0——抽屉不展示收益列,无需补;字段有默认值故 schema 校验不受影响。
- [ ] **Step 5: 跑领券用例确认通过**
Run: `pytest tests/test_board_ad_revenue.py::test_coupon_data_report_includes_ad_revenue -q`
Expected: PASS。
- [ ] **Step 6: 提交**
```bash
git add app/admin/schemas/coupon_data.py app/admin/repositories/coupon_data.py tests/test_board_ad_revenue.py
git commit -m "feat(admin-coupon-data): per-session ad revenue column"
```
---
### Task B2: 比价记录看板逐行补 `ad_revenue_yuan`
**Files:**
- Modify: `app/admin/schemas/comparison.py`
- Modify: `app/admin/repositories/queries.py`
- [ ] **Step 1: 比价 schema 加字段**
`app/admin/schemas/comparison.py``AdminComparisonListItem` 里,`created_at: datetime` **之前**加:
```python
ad_revenue_yuan: float = 0.0 # 本次比价看的信息流广告预估收益(元),queries 瞬态挂 ORM 实例上
```
- [ ] **Step 2: 比价 repo 逐页补收益**
`app/admin/repositories/queries.py`:
(a) import 区加:
```python
from app.repositories import ad_ecpm
```
(b) 在 `list_comparison_records` 里,找到:
```python
_attach_user_info(db, items)
return items, next_cursor, total
```
替换为:
```python
_attach_user_info(db, items)
# 「本次比价看广告的预估收益」:按本页 trace_id 一次性聚合(同 _attach_user_info 逐页范式)。
# ad_revenue_yuan 非 ORM 列,仅瞬态挂实例上供 AdminComparisonListItem(from_attributes)读出。
rev = ad_ecpm.revenue_yuan_by_trace(db, [it.trace_id for it in items])
for it in items:
it.ad_revenue_yuan = rev.get(it.trace_id, 0.0)
return items, next_cursor, total
```
- [ ] **Step 3: 跑比价用例确认通过**
Run: `pytest tests/test_board_ad_revenue.py::test_comparison_list_includes_ad_revenue -q`
Expected: PASS。
- [ ] **Step 4: 跑全量后端测试(确认无回归)**
Run: `pytest -q`
Expected: 全绿(新增用例通过,原有用例不受影响)。
- [ ] **Step 5: Lint**
Run: `ruff check app/ tests/`
Expected: 无新增告警。
- [ ] **Step 6: 提交**
```bash
git add app/admin/schemas/comparison.py app/admin/repositories/queries.py
git commit -m "feat(admin-comparison): per-comparison ad revenue column"
```
---
## Phase C — 客户端上报带 trace_id(Android)
> 三处都在 `E:\project\shaguabijia-app-android`。改动极小:eCPM 上报点的 trace_id 已在作用域内(比价是 `showAd(traceId)` 参数,领券是 `sessionTraceId` 类字段),只是当前没往上带。发奖(`reportFeedReward`)已在带 traceId,可作参照。
### Task C1: `ApiClient.reportAdImpression``traceId` 参数
**Files:**
- Modify: `app/src/main/java/com/jishisongfu/shaguabijia/agent/network/ApiClient.kt`
- [ ] **Step 1: 加参数**
找到 `reportAdImpression` 的参数列表,末尾 `feedScene: String? = null,` 之后加一行:
```kotlin
traceId: String? = null,
```
- [ ] **Step 2: 写进 payload**
同函数内,找到:
```kotlin
if (!feedScene.isNullOrBlank()) payload.put("feed_scene", feedScene)
```
在其**下方**加一行:
```kotlin
if (!traceId.isNullOrBlank()) payload.put("trace_id", traceId)
```
- [ ] **Step 3: 提交**
```bash
git add app/src/main/java/com/jishisongfu/shaguabijia/agent/network/ApiClient.kt
git commit -m "feat(ad-report): reportAdImpression carries trace_id"
```
---
### Task C2: 比价展示上报带 traceId
**Files:**
- Modify: `app/src/main/java/com/jishisongfu/shaguabijia/agent/service/ad/CompareAdController.kt`
- [ ] **Step 1: 传 traceId**
`showAd(traceId: String)` 内的 `onAdImpression` 回调里,找到 `apiClient.reportAdImpression(` 调用,其中 `feedScene = "comparison",` 之后加一行(`traceId``showAd` 的入参):
```kotlin
traceId = traceId, // 本场比价 trace → 展示收益归属到该次比价
```
- [ ] **Step 2: 提交**
```bash
git add app/src/main/java/com/jishisongfu/shaguabijia/agent/service/ad/CompareAdController.kt
git commit -m "feat(compare-ad): report comparison impression with trace_id"
```
---
### Task C3: 领券展示上报带 traceId
**Files:**
- Modify: `app/src/main/java/com/jishisongfu/shaguabijia/service/CouponForegroundService.kt`
- [ ] **Step 1: 传 traceId**
`onAdImpression` 回调里,找到 `apiClient.reportAdImpression(` 调用,其中 `feedScene = "coupon",` 之后加一行(`sessionTraceId` 为本类字段,整场领券不变):
```kotlin
traceId = sessionTraceId, // 本场领券 trace → 展示收益归属到该次领券
```
- [ ] **Step 2: 编译验证**
Run(在 `E:\project\shaguabijia-app-android`): `./gradlew :app:compileDebugKotlin`
Expected: BUILD SUCCESSFUL。
- [ ] **Step 3: 提交**
```bash
git add app/src/main/java/com/jishisongfu/shaguabijia/service/CouponForegroundService.kt
git commit -m "feat(coupon-ad): report coupon impression with trace_id"
```
---
## Phase D — admin 前端加「广告收益」列
> 两个页面都在 `e:\project\shaguabijia-admin-web`。收益值单位是**元**(小数,单次很小如 ¥0.0050),用 `.toFixed(4)` 展示。
### Task D1: 比价记录页加列
**Files:**
- Modify: `src/lib/types.ts`
- Modify: `src/app/(main)/comparison-records/page.tsx`
- [ ] **Step 1: 类型加字段**
`src/lib/types.ts``ComparisonRecordListItem` 接口里,`created_at: string;` **之前**加:
```typescript
ad_revenue_yuan: number; // 本次比价看的信息流广告预估收益(元)
```
- [ ] **Step 2: 表格加列**
`src/app/(main)/comparison-records/page.tsx``columns` 数组里,找到「省」这一列(以 `title: '省',` 开头的对象),在其**之后**插入一列:
```tsx
{
title: '广告收益',
key: 'ad_revenue',
width: 96,
align: 'right',
render: (_, r) =>
r.ad_revenue_yuan > 0 ? (
<span style={{ color: '#3f8600' }}>¥{r.ad_revenue_yuan.toFixed(4)}</span>
) : (
<span style={{ color: '#ccc' }}>-</span>
),
},
```
- [ ] **Step 3: 加宽横向滚动**
同文件找到比价记录主表的 `scroll={{ x: 1820 }}`,改为:
```tsx
scroll={{ x: 1920 }}
```
- [ ] **Step 4: 提交**
```bash
git add src/lib/types.ts "src/app/(main)/comparison-records/page.tsx"
git commit -m "feat(admin-web): ad revenue column in comparison records table"
```
---
### Task D2: 领券数据页加列
**Files:**
- Modify: `src/app/(main)/coupon-data/page.tsx`
- [ ] **Step 1: 接口加字段**
`src/app/(main)/coupon-data/page.tsx``interface CouponDataRow` 里,`trace_url: string | null;` **之后**加:
```typescript
ad_revenue_yuan: number; // 本次领券看的信息流广告预估收益(元)
```
- [ ] **Step 2: 加金额格式化辅助**
`fmtPct` 定义(以 `const fmtPct =` 开头)之后加:
```typescript
// 元(小数)→ "¥0.0050"(空/≤0 显示 -)。单次广告收益很小,保留 4 位。
const fmtYuan = (v: number | null | undefined): string =>
v == null || v <= 0 ? '-' : `¥${v.toFixed(4)}`;
```
- [ ] **Step 3: 主表加列**
找到主表 `columns`(`CouponDataPage` 组件内的 `const columns: ColumnsType<CouponDataRow> = [`)。在「耗时」列(`dataIndex: 'elapsed_ms'` 的对象)**之后**插入:
```tsx
{
title: '广告收益',
dataIndex: 'ad_revenue_yuan',
width: 100,
align: 'right',
render: (v: number) => fmtYuan(v),
},
```
- [ ] **Step 4: 加宽横向滚动**
找到主表 `scroll={{ x: 1450 }}`,改为:
```tsx
scroll={{ x: 1560 }}
```
- [ ] **Step 5: 前端类型检查 / 构建**
Run(在 `e:\project\shaguabijia-admin-web`): `npm run build`
Expected: 构建成功、无 TS 类型错误。
- [ ] **Step 6: 提交**
```bash
git add "src/app/(main)/coupon-data/page.tsx"
git commit -m "feat(admin-web): ad revenue column in coupon data table"
```
---
## 端到端验证(全部任务完成后)
- [ ] **后端**:`pytest -q` 全绿;`alembic heads` 只有 `ad_ecpm_trace_id` 单 head。
- [ ] **客户端**:装 debug 包,跑一次比价 + 一次领券(要有信息流广告展示);后端库 `ad_ecpm_record` 出现带 `trace_id``feed_scene in (comparison, coupon)` 的记录。
- [ ] **看板**:admin 打开「比价记录」「领券数据」两页,新「广告收益」列对刚才那两次显示 > ¥0 的金额;旧数据(无 trace_id 上报)显示 `-`
- [ ] **口径核对**:任取一行,手动核对 `ad_ecpm_record` 中该 `trace_id` 的各条 `ecpm_raw`,按 `Σ min(ecpm/100, 500)/1000` 算出的值与页面一致。
---
## 回滚
- 前端/客户端:回退对应 commit 即可(纯展示/上报,无副作用)。
- 后端:`alembic downgrade -1``ad_ecpm_trace_id`(会拆回两个 head——与本计划实施前状态一致);看板收益列在无 trace_id 列时会因查询报错,故 downgrade 迁移前需先回退 Phase A/B 的 commit。正常情况不需回滚。
File diff suppressed because it is too large Load Diff
@@ -1,236 +0,0 @@
# 接口 QPS + 耗时可观测(OpenObserve)设计
- **日期**2026-07-06
- **状态**:已评审通过,待写实现计划
- **范围**:仅 app-server8770);admin8771)暂不接入
- **方案**:A —— 轻量自研 ASGI 中间件 + 后台 worker 批量直采到 OpenObserve
## 1. 背景与目标
app-server 目前除 CORS 外无任何中间件,也无接口级可观测。需要按**每个接口**采集:
- **QPS**(每秒请求数,可按接口/时间分桶)
- **耗时**P50/P95/P99 等分位)
顺带低成本拿到**错误率**`status >= 500` 占比)。落地目标是:本地 Docker 跑一个 OpenObserve 实例接收数据,服务侧加埋点上报,在 OpenObserve 仪表盘上看各接口 QPS + 耗时。
### 非目标(YAGNI
- 不做分布式 trace / span 关联(只要接口聚合指标)。
- 不引入 OpenTelemetry / Prometheus 客户端等重依赖。
- 不采集请求体 / query / 用户身份等,任何 PII 都不进上报。
- admin(8771)本期不接(中间件写成可复用,未来一行挂载即可)。
- 上报失败不做持久化重试 / 落盘补偿(best-effort)。
## 2. 方案选型
对比过三条路(详见评审记录):
- **A 轻量自研中间件 + JSON 直采**(选中):零新依赖(`httpx` 已在依赖里),完全贴合本仓库「后台 worker + JSON 事件 + `*_configured` 优雅降级」的既有习惯,恰好满足「每接口 QPS + 耗时 + 错误率」并保留原始事件下钻能力。
- B OpenTelemetry 自动埋点 + OTLP:行业标准、顺带 trace,但多 5–6 个依赖、概念多、数据量/成本高于需求,与精简代码库风格相悖。
- C Prometheus 进程内聚合 + remote_write/抓取:数据量最小,但 remote_write 编码复杂或需额外抓取进程,丢失单请求下钻,最不贴合 OpenObserve 的 log-first 强项。
**结论:A。**
## 3. 架构与数据流
```
每个 HTTP 请求
→ RequestMetricsMiddleware(最外层:测总耗时 / 抓路由模板 + 状态码)
→ record_event() 非阻塞入队(有界队列,满则丢最旧,绝不阻塞、绝不 OOM)
→ observe_worker(后台 asyncio.Task,随 lifespan 启停)批量 drain
→ httpx POST {ENDPOINT}/api/{ORG}/{STREAM}/_json → OpenObserve
→ 仪表盘 SQL 聚合出 QPS / 分位耗时 / 错误率
```
**核心不变量**
1. 请求路径上只做「测时 + 构建一个小 dict + `put_nowait`」,**无任何网络/磁盘 I/O**。
2. 所有上报 I/O 在后台 worker;worker 捕获全部异常,绝不让埋点影响请求。
3. 未配置观测(`observe_configured=False`)→ 中间件透传、worker 不启动,整套 no-op。
4. OpenObserve 不可用 → 队列填满后丢弃事件 + 限流告警,业务零影响。
## 4. 组件设计
### 4.1 OpenObserve 本地部署 —— `deploy/openobserve/docker-compose.yml`(新增)
```yaml
services:
openobserve:
image: public.ecr.aws/zinclabs/openobserve:latest
container_name: openobserve
ports: ["5080:5080"]
environment:
ZO_ROOT_USER_EMAIL: "admin@shaguabijia.local"
ZO_ROOT_USER_PASSWORD: "Complexpass#123"
ZO_DATA_DIR: "/data"
volumes: ["./data:/data"]
restart: unless-stopped
```
- `docker compose up -d` 启动;Web UI `http://localhost:5080`,用上面邮箱/密码登录。
- 单容器 = local 模式,数据落 `./data`(已挂卷持久化)。
- **stream 首次上报自动创建**,无需预建 `app_requests`
- 上报鉴权:HTTP Basic auth`email:password`),本地直接用 root 账号;生产应另建仅具 ingest 权限的用户/服务账号(本期不涉及)。
### 4.2 事件 schema(一请求一行 JSON
```json
{
"_timestamp": 1720000000000000, // 微秒(µs)整数,请求完成时刻。OpenObserve 默认时间列 _timestamp 以微秒计
"service": "app-server", // 取 LOG_SERVICE_NAME / 固定值
"env": "dev", // settings.APP_ENV
"method": "POST",
"route": "/api/v1/coupon/step", // 路由模板(非实际 path)
"status": 200,
"duration_ms": 42.7 // float 毫秒
}
```
- **只存路由模板**(如 `/c/{code}``/media` 静态归一),避免 path 参数把维度打爆。
- 未匹配路由(404 / 扫描器)归一到常量 `__unmatched__`
- 只采 method / route / status / duration —— 无 body、无 query、无 PII。
### 4.3 埋点中间件 —— `app/core/observe.py`(新增)
**纯 ASGI 中间件**(比 `BaseHTTPMiddleware` 开销低;能可靠读到路由与最终状态码;scope 按引用透传,内层 router 的 `scope["route"]` 外层可见)。
职责:
1. 非 `http` 请求、或 `not settings.observe_configured` → 直接透传,不测。
2. `perf_counter()` 记起点;包一层 `send``http.response.start``status`(默认兜底 500,覆盖下游抛异常未产出 response 的情况)。
3. `finally` 里算 `duration_ms`,从 `scope` 取路由模板(见下),构建事件,调 `record_event()`
4. 跳过路径集合 `_SKIP_PATHS = {"/health"}`(纯噪音)。
**路由模板解析(跨 Starlette 版本稳健)**
```python
route = scope.get("route")
template = getattr(route, "path", None)
if template is None: # 未匹配 / 老版本未写 scope["route"]
template = "__unmatched__"
```
(若实测某 Starlette 版本不写 `scope["route"]`,回退用 `request.app.router.routes` 逐个 `route.matches(scope)==Match.FULL` 找模板;实现时以实际版本为准,优先 `scope["route"]`。)
**入队(`record_event`**:模块级 `asyncio.Queue(maxsize=OBSERVE_QUEUE_MAX)`。用 `put_nowait``QueueFull` 则丢弃并累加一个 `_dropped` 计数(每累计 N 条限流打一条 WARNING)。**永不 `await put()`、永不阻塞请求**。
> 决策(a):队列满 → **丢弃**(不阻塞请求)。
### 4.4 上报 worker —— `app/core/observe_worker.py`(新增)
对齐现有 `heartbeat_monitor_worker.py` / `daily_exchange_worker.py` / `withdraw_reconcile_worker.py``start_*` / `stop_*` 形态。
- `start_observe_worker() -> asyncio.Task | None`
- `not settings.observe_configured` → 返回 `None`no-op)。
- 否则建专用 `httpx.AsyncClient``base_url=ENDPOINT``auth=(USER, PASSWORD)``timeout=OBSERVE_TIMEOUT_SEC`),起 `_run_loop` task。
- `_run_loop()`:循环
1. `_collect_batch()``await asyncio.wait_for(queue.get(), timeout=FLUSH_INTERVAL)` 拿到首条(超时且空 → 返回空,continue);再 `get_nowait()` 连抽到 `BATCH_MAX` 条或抽空。
2. `POST /api/{ORG}/{STREAM}/_json`body 为事件数组。
3. **catch 所有异常**:失败限流打 WARNING,**直接丢弃该批,不重试**。
- `stop_observe_worker(task)`best-effort 收尾 flush(短超时)→ `task.cancel()``await`(吞 `CancelledError`)→ 关 client。
> 决策(b):上报失败 → **直接丢弃,不重试**best-effort 遥测)。
### 4.5 配置 —— `app/core/config.py`(改)
新增一段 `# ===== 可观测(OpenObserve 接口指标)=====`,默认全关(prod 安全):
| 配置 | 默认 | 说明 |
|---|---|---|
| `OBSERVE_ENABLED` | `False` | 总开关;默认关,opt-in |
| `OBSERVE_ENDPOINT` | `http://localhost:5080` | OpenObserve base URL |
| `OBSERVE_ORG` | `default` | 组织名 |
| `OBSERVE_STREAM` | `app_requests` | stream 名 |
| `OBSERVE_USER` | `""` | Basic auth 邮箱 |
| `OBSERVE_PASSWORD` | `""` | Basic auth 密码/token |
| `OBSERVE_FLUSH_INTERVAL_SEC` | `5.0` | worker 最长攒批间隔 |
| `OBSERVE_BATCH_MAX` | `200` | 单批最大事件数 |
| `OBSERVE_QUEUE_MAX` | `10000` | 有界队列上限,满则丢 |
| `OBSERVE_TIMEOUT_SEC` | `5.0` | 上报 HTTP 超时 |
```python
@property
def observe_configured(self) -> bool:
return bool(self.OBSERVE_ENABLED and self.OBSERVE_ENDPOINT
and self.OBSERVE_USER and self.OBSERVE_PASSWORD)
```
`.env.example` 同步补一段带注释的 `OBSERVE_*`(沿用该文件重注释风格),`OBSERVE_ENABLED=false`
### 4.6 接线 —— `app/main.py`(改)
- import `RequestMetricsMiddleware``start_observe_worker` / `stop_observe_worker`
- `app.add_middleware(RequestMetricsMiddleware)`:放在 CORS `add_middleware` **之后** → 成为最外层,测到含 CORS 的完整耗时。无条件挂载(内部自 no-op)。
- `lifespan`:启动 `observe_task = start_observe_worker()``finally``await stop_observe_worker(observe_task)`,与现有 worker 并列。
### 4.7 OpenObserve 查询 / 仪表盘 —— `deploy/openobserve/README.md`(新增)
含:compose 启停、登录、stream 自动创建说明、`.env` 接线,以及可直接粘的示例 SQL
- **各接口 QPS**1 分钟分桶):
```sql
SELECT route, histogram(_timestamp, '1 minute') AS ts, count(*) AS cnt
FROM app_requests GROUP BY route, ts ORDER BY ts
```
(面板按 `cnt/60` 展示每秒;或用 OpenObserve 图表的 rate 能力。)
- **各接口 P95 耗时**
```sql
SELECT route, approx_percentile_cont(duration_ms, 0.95) AS p95_ms
FROM app_requests GROUP BY route ORDER BY p95_ms DESC
```
- **各接口错误率**
```sql
SELECT route,
count(*) FILTER (WHERE status >= 500) * 100.0 / count(*) AS err_pct
FROM app_requests GROUP BY route ORDER BY err_pct DESC
```
## 5. 关键设计决策汇总
- **(a) 队列满 → 丢弃**(不阻塞请求):遥测让路于业务可用性。
- **(b) 上报失败 → 不重试**best-effort;避免 poison batch 堆积与队列无限增长。
- **(c) 跳过 `/health`**:健康检查是纯噪音,硬编码在 `_SKIP_PATHS`
- **只存路由模板 + `__unmatched__`**:防维度爆炸。
- **默认 OFF、opt-in**:prod 安全默认;开启后仍全异步 + 有界。
- **纯 ASGI 中间件 + `perf_counter`**:请求路径开销微秒级,无 I/O。
## 6. 安全 / 性能保证
- 请求路径新增开销 ≈ 一次 `perf_counter` 差 + 一个小 dict + 一次 `put_nowait`(微秒级),无锁竞争的显著热点。
- 失败隔离:入队丢弃 + worker 全异常捕获;OpenObserve 宕机不影响任何请求。
- 有界内存:队列 `maxsize` 封顶,最坏丢事件不涨内存。
- 无 PII:仅 method / route / status / duration。
## 7. 测试策略 —— `tests/test_observe.py`(新增)
沿用仓库约定(`TestClient` + `monkeypatch`,绝不打真网络;`conftest` 在 import 前设 env):
1. 埋点入队字段正确:模板路由、`status``duration_ms > 0`
2. 参数化路由 → 取到**模板**而非实际 path。
3. 未匹配路径(404)→ `route == "__unmatched__"`
4. `OBSERVE_ENABLED=false` → 零入队、零 HTTP(现有测试不受影响)。
5. 队列满 → `record_event` 不抛异常(走丢弃分支)。
6. worker 批量 POST 的 URL / payload 正确(monkeypatch httpx client / `_post`,不打网络)。
7. `/health` 被跳过 → 不入队。
> `settings``lru_cache` 单例;需要开启观测的用例通过 monkeypatch `settings` 属性或直接调 `record_event` / 中间件并 patch `observe_configured` 实现,避免全局 env 改动波及他用例。
## 8. 文件清单
| 文件 | 动作 |
|---|---|
| `deploy/openobserve/docker-compose.yml` | 新增(OpenObserve 容器)|
| `deploy/openobserve/README.md` | 新增(部署步骤 + 查询/仪表盘)|
| `app/core/observe.py` | 新增(中间件 + 有界队列 + `record_event` + 路由解析)|
| `app/core/observe_worker.py` | 新增(后台批量上报 worker)|
| `app/core/config.py` | 改(`OBSERVE_*` + `observe_configured`|
| `app/main.py` | 改(挂中间件 + lifespan 启停 worker|
| `.env.example` | 改(新增 `OBSERVE_*` 注释段)|
| `tests/test_observe.py` | 新增 |
## 9. 未来工作(本期不做)
- admin(8771)接入同一套中间件(`service` 字段区分)。
- 生产部署 OpenObserve(持久化、独立 ingest 账号、资源规格、鉴权收紧)。
- 上报字段扩展(如按 user/设备维度、上游 pricebot 透传耗时拆分)。
@@ -1,296 +0,0 @@
# 15 天不活跃自动清零(金币 + 现金)设计
- **日期**2026-07-16
- **状态**Draft — 待评审
- **所属**app-server`app/`),含一处 admin 侧重构 + 一项 Android 端埋点依赖
- **一句话**:连续 15 天不活跃的用户,自动清零其金币与现金;清零前按可配置节奏预警;全过程留审计以备纠纷排查。
---
## 1. 背景与目标
运营需要对**长期不活跃**用户的钱包余额做清理。两条硬性要求:
1. **可审计**:记录清零原因与**清零前的三桶余额**,便于后续排查与处理客户纠纷。
2. **临清预警**:在临近清零前推送信息告知用户"因账号不活跃,账户里的 xx 金币和 xx 现金将被清零"。
### 非目标(本期不做)
- 不做真实推送通道(极光 JPush / 短信)的对接 —— 仅做**可插拔通知器 + 日志占位**,接口预留、后续无缝替换。
- 不改动提现(`WithdrawOrder`)流程。
- 不新增 `User.last_active_at` 列、不改鉴权热路径。
---
## 2. 需求
| # | 需求 | 落地 |
|---|---|---|
| R1 | 连续 15 天不活跃 → 清零金币 + 现金 | 每日 worker 扫描 + 逐用户事务清零(§6) |
| R2 | 记录清零原因 + 清零前余额 | `inactivity_reset_log` 审计表 + 3 条钱包流水(§5、§7) |
| R3 | 临清前预警"xx 金币 xx 现金将清零" | 阶段 A 预警 + `inactivity_notification_log`(§6、§7 |
| R4 | 活跃口径与"用户管理"一致 | 抽共享模块 `activity.py`admin 与 worker 共用(§4、§12 |
| R5 | 预警时机完全可配置 | `INACTIVITY_*` 配置项(§8 |
---
## 3. 决策记录(来自评审问答)
| 决策点 | 结论 | 理由 |
|---|---|---|
| **活跃口径** | 与"用户管理"一致:`max(首页可见 show/home, 比价, 领券)`**不含 last_login_at**;无任何信号时以 `created_at` 为非空基线 | 比价可从**浮窗**触发、不进首页;`last_login_at` 只在登录/换绑动作更新(re-login 也算),代表不了"在用 App",故彻底排除 |
| **"进首页"信号落地** | **方案 A:前端上报 `home_view` 埋点**(复用 `/analytics/events`),非新接口 | 三个活跃信号统一为同类埋点事件;零新接口零新列;与 admin 口径天然一致。B(鉴权接口 + 列)"更权威"的优势是假的——比价/领券仍是端上报事件,最弱环决定整体可信度 |
| **清零范围** | **金币 + 折算现金**(**邀请现金不清**——产品红线,仅快照入审计) | 对应"账户里的金币和现金";邀请奖励金与金币现金物理隔离、不可累加,见 `wallet.CoinAccount` 注释 |
| **预警推送** | **可插拔通知器 + 日志占位**v1),后续接 JPush/短信 | 现状无真实推送能力;先把清零主流程 + 审计做扎实,不阻塞 |
| **预警时机** | **完全可配置**(提前天数列表 + 次数 + 执行点 + 通道) | R5 |
| **触发方式** | **进程内每日 worker**,仿 `daily_exchange_worker` | 与项目最新模式一致,无需外部 cron |
| **admin 共享口径** | 共享模块 + **重构 admin 改用它** | 单一真源,永不漂移(R4 |
### 已知取舍(可接受)
- analytics 的 `user_id` 是**端上报、未鉴权**(可伪造)。但伪造只能"保自己活跃、避免被清",无收益,且正是本功能要防的行为,风险良性。活跃时间的非空基线由服务端权威的 `User.created_at` 提供(见 §4),不再依赖 `last_login_at`。与"用户管理"口径一致。
---
## 4. 活跃口径与共享模块 `app/repositories/activity.py`(新建)
活跃口径的**唯一真源**。app 侧模块,admin 可 import`app.main` 不 import `app.admin`,反向允许)。
### 口径
```
last_active = max(
User.created_at, # 注册基线(恒非空;re-login 不推进,只有真实使用才推进)
max AnalyticsEvent.created_at WHERE event IN ACTIVE_EVENTS,
max CouponPromptEngagement.created_at WHERE engage_type == "claim_started",
)
不活跃判定:按北京自然日、0 点对齐(非从末次活跃时刻滚动 15×24h)
last_active_date = 北京(last_active).date() # 末次活跃的北京日,记为「第 1 日」
清零边界 = 北京 00:00 of (last_active_date + RESET_DAYS 天) =「第 (RESET_DAYS+1) 日 0 点」 # 15 → 第16日0点
应清零 ⟺ (cn_today() last_active_date).days ≥ RESET_DAYS
⟺ last_active < cutoff cutoff = 北京 00:00 of (cn_today() (RESET_DAYS1)) # 供 SQL 比较
inactive_days = (cn_today() last_active_date).days # 清零当日恰 = RESET_DAYS
例:末次活跃 1/1 → 1/16 00:00(第16日0点)清零,当日 inactive_days=151/15 及之前不清
```
### 模块内容
- 常量:
- **首页可见活跃信号已定名:`event=show` + `page=home`**(前端确认,原占位 `home_view`;下文出现的 `home_view` 均指此信号)。活跃行为过滤见 `activity.active_event_condition()`:首页可见 比价 `real_compare_start` 领券 `real_coupon_start``ACTIVE_EVENTS` 仅含后两个纯 event 名(首页可见是 event+page 组合、单列)。
- `ACTIVE_ENGAGE_TYPE = "claim_started"`
- `last_active_subqueries(db)` —— 复刻现 admin `queries._last_active_parts()`:两个按 `user_id``GROUP BY max(created_at)` 聚合子查询。
- `last_active_expr(base_col, ev_sub, eng_sub, dialect)` —— 生成 `greatest`/`max`PG `func.greatest`SQLite `func.max`);子聚合缺失时 `coalesce(子聚合, User.created_at)` 兜底(注册基线恒非空,**替代原 last_login_at**)。
- `_norm_utc()` —— 沿用现 admin 的 naive→UTC 归一(SQLite naive / PG aware 混算保护)。
- `reset_cutoff(reset_days)` / `warn_cutoff(reset_days, k)` —— 生成**北京 0 点对齐**的边界 datetime(见口径):`reset_cutoff = 北京 00:00 of (cn_today() (reset_days1))`,供下面查询按 `last_active < cutoff` 比较。
- `select_inactive_users(db, *, cutoff, with_balance=True)` —— **worker 专用**join `CoinAccount`,筛 `last_active < cutoff`(cutoff = 北京 0 点对齐边界,见口径)且(`coin_balance>0 OR cash_balance_cents>0`;**邀请现金不清、不计入候选**),返回 `(user, account, last_active, inactive_days)`
- `select_warn_targets(db, *, reset_days, warn_days_before)` —— **worker 专用**:返回 `(user, account, last_active, inactive_days, stage)` 元组——各"提前天数"窗口内、有余额、本 streak 未推过档 `stage` 的用户(去重结合 `notification_log`,逻辑见 §9)。
> **参考现状**:现口径散落在 `app/admin/repositories/queries.py:38,91-124,199-204``_ACTIVE_EVENTS`/`_last_active_parts`/`greatest`)与 `app/admin/repositories/stats.py:51-52,138-146``COMPARE_START_EVENT`/`COUPON_START_EVENT`/活跃用户集)。这些改为从 `activity.py` 导入(§12)。
---
## 5. 数据模型(2 张新表,不动 `User`
两表均登记进 `app/models/__init__.py`;一个 Alembic 迁移建两表(`render_as_batch`SQLite 兼容)。
### ① `inactivity_reset_log` —— 清零审计(R2
仿 `app/models/phone_rebind_log.py` 的简单审计表风格。
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int PK autoincrement | |
| `user_id` | int, index, not null | |
| `coin_balance_before` | int, not null | 清零前金币 |
| `cash_balance_cents_before` | int, not null | 清零前折算现金(分) |
| `invite_cash_balance_cents_before` | int, not null | 清零前邀请现金(分) |
| `last_active_at` | DateTime(tz), nullable | 判定时的最近活跃时间 |
| `inactive_days` | int, not null | 判定时不活跃天数 |
| `reason` | String(32), not null | 如 `"inactive_15d"` |
| `reset_at` | DateTime(tz), server_default now(), index, not null | 清零时刻 |
### ② `inactivity_notification_log` —— 预警记录 + 去重 + 占位 outboxR3
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int PK autoincrement | |
| `user_id` | int, index, not null | |
| `stage` | int, not null | 提前天数档(如 7 / 2 |
| `inactive_days` | int, not null | 推送时不活跃天数 |
| `coin_balance` | int, not null | 推送快照:告知用户的金币数 |
| `cash_balance_cents` | int, not null | 推送快照:折算现金 |
| `invite_cash_balance_cents` | int, not null | 推送快照:邀请现金 |
| `channel` | String(16), not null | `"log"` / `"jpush"` / `"sms"` |
| `status` | String(16), not null | `"placeholder"` / `"sent"` / `"failed"` |
| `created_at` | DateTime(tz), server_default now(), index, not null | 去重锚点(见 §9 |
> 备注:不新增 `User.last_active_at` 列,不改 `get_current_user`。活跃时间由 §4 口径**实时计算**。
---
## 6. 清零 worker `app/core/inactivity_reset_worker.py`(新建)
**完全仿 [`app/core/daily_exchange_worker.py`](../../../app/core/daily_exchange_worker.py)**App 启动自带 asyncio 任务,文件锁(`data/inactivity_reset.lock`)防同机多进程并发。**worker 常驻**;`INACTIVITY_RESET_ENABLED` 只决定是否**真清**:false(默认)= 只记审计名单、不动钱(dry-run),true = 真清。
### 调度
- 每 `INACTIVITY_RESET_CHECK_INTERVAL_SEC` 秒醒一次;`last_run: date` 守卫**北京日**,保证每日只跑一轮。
- 仅当 `cn_today() != last_run` 且当前北京小时 `>= INACTIVITY_RESET_RUN_HOUR` 时执行(启动补跑同 daily_exchange 语义)。
- **清零资格边界 = 第 16 日 0 点(北京,见 §4),与 worker 执行点解耦**worker 于当日 `RUN_HOUR`(默认 3 点)跑,把已过边界者一并清;若要严格 0 点触发可置 `RUN_HOUR=0`,但注意与 `daily_auto_exchange` 的 0 点任务错峰。
- lifespan 里 `start_inactivity_reset_worker()` / `stop_...`(仿 `start_daily_exchange_worker``app/main.py` 的接线)。
### 一轮 `run_once(db)` 两阶段(同一次运行、各自逐用户独立 commit)
**阶段 A — 预警**
```
for user, acc, last_active, inactive_days, stage in activity.select_warn_targets(...):
notifier.send_inactivity_warning(user, balances=snapshot(acc), stage=stage, days_until_reset=RESET_DAYS-inactive_days)
db.add(InactivityNotificationLog(..., channel=notifier.channel, status=notifier.last_status))
db.commit() # 逐条独立
```
**阶段 B — 清零**`biz_type="inactivity_reset"`
```
for user, acc, last_active, inactive_days in activity.select_inactive_users(db, cutoff=activity.reset_cutoff(RESET_DAYS)): # 北京 00:00 of (今天−(RESET_DAYS1))
try:
acc = wallet.get_or_create_account(db, user.id, commit=False, lock=True) # 行锁
before = (acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents)
if acc.coin_balance == 0 and acc.cash_balance_cents == 0: continue # 邀请现金不清,不算可清余额
log = InactivityResetLog(user_id=user.id, coin_balance_before=before[0],
cash_balance_cents_before=before[1], invite_cash_balance_cents_before=before[2], # 邀请现金仅快照
last_active_at=last_active, inactive_days=inactive_days, reason=f"inactive_{RESET_DAYS}d")
db.add(log); db.flush() # 拿 log.id 作 ref_id 交叉链接
if acc.coin_balance: wallet.grant_coins(db, user.id, -acc.coin_balance, biz_type="inactivity_reset", ref_id=str(log.id), remark="15天不活跃清零")
if acc.cash_balance_cents: wallet.grant_cash(db, user.id, -acc.cash_balance_cents, biz_type="inactivity_reset", ref_id=str(log.id), remark="15天不活跃清零")
# 邀请现金(invite_cash_balance_cents)不清:产品红线、两本账物理隔离,仅快照记入审计。
db.commit()
except SQLAlchemyError:
db.rollback(); stats["failed"] += 1
```
- `grant_*` 负数出账、`balance_after=0`、写**两条**流水(金币 + 折算现金;**邀请现金不清**);`grant_coins` 负数**不**动 `total_coin_earned`(历史累计保留)。
- 逐用户独立 commit:一个失败不影响其余。返回 `stats = {warned, warn_skipped, warn_failed, scanned, cleared, failed}``logger.info`。**预警逐用户 try/except 隔离、且预警整段异常也绝不阻塞清零**(清零是不可逆资金操作,不能被通知故障拖住)。
---
## 7. 预警与可插拔通知器
`app/integrations/notifier.py` 定义协议(外部投递属 integrations 层):
```python
class InactivityNotifier(Protocol):
channel: str # "log" / "jpush" / "sms"
last_status: str # "placeholder" / "sent" / "failed"
def send_inactivity_warning(self, user, *, balances, stage, days_until_reset) -> None: ...
```
- **v1 `LogNotifier`**`channel="log"`):`logger.warning("[inactivity-warn] user=%s coin=%s cash=%s invite=%s T-%s", ...)``last_status="placeholder"`。参照 `heartbeat_monitor_worker` 先例("本期先不接推送,用终端打印代替")。
- 未来 `JPushNotifier` / `SmsNotifier`:实现同协议即可替换,worker 不改。
- 选择:`INACTIVITY_NOTIFY_CHANNEL` → 工厂返回对应实现(未配到真实实现时回退 `LogNotifier`)。
- 预警文案数据来自快照 `balances`,满足 R3"告知 xx 金币 xx 现金"。
---
## 8. 配置项(`app/core/config.py`
```
INACTIVITY_RESET_ENABLED = False # false(默认)=只记审计名单(dry-run,不动钱);true=真清
INACTIVITY_RESET_DAYS = 15 # 不活跃阈值(天)
INACTIVITY_WARN_DAYS_BEFORE = "7,2" # 清零前几天各推一次;空串=不推。逗号分隔,降序解析
INACTIVITY_RESET_RUN_HOUR = 3 # 北京时间每日执行点(0-23)
INACTIVITY_NOTIFY_CHANNEL = "log" # log(占位) / jpush / sms
INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800 # worker 唤醒间隔(可复用现有间隔常量)
```
- 清零范围(三桶)固定为常量,不做配置。
- `INACTIVITY_WARN_DAYS_BEFORE` 语义(`inactive_days` 为北京自然日,见 §4):档位 `k` ⟹ 当 `inactive_days >= RESET_DAYS-k``< RESET_DAYS` 且本 streak 未推过档 `k` 时预警,即在北京日 `last_active_date + (RESET_DAYSk)` 触发(漏跑某天时补发最紧急未推档,§9)。
- `INACTIVITY_RESET_RUN_HOUR` 只决定 worker 每日执行点,**不改变**"第 16 日 0 点"这一资格边界(§4/§6)。
---
## 9. 幂等与重新活跃
- **重新活跃自动退出**`inactive_days` 由 §4 口径**实时算**。用户一有 `home_view`/比价/领券(**登录本身不算**),`last_active` 前移,自动移出预警与清零队列。**无需**显式"重置标记"。
- **预警去重**`inactivity_notification_log` 中存在 `stage==k 且 created_at > last_active` 的行 ⟹ 本 streak 已推过档 `k`,不重推。用户回归后 `last_active` 前移,旧预警行自然"失效",开启新 streak。
- **清零幂等**:阶段 B 只处理三桶非全 0 者;清完 = 0,次日不再匹配。worker 重启 / 多次唤醒 / 补跑均安全,不产生重复清零或重复流水。
- **稳健补发**:worker 漏跑数天后,某用户可能同时满足多档;只补发**最紧急的未推档**(最小 `k`),避免一次刷屏。
---
## 10. 边界与安全
| 场景 | 处理 |
|---|---|
| 新用户 | `created_at` 作活跃基线(恒非空)→ 注册即"第 1 日活跃";注册后连续 15 天无 home_view/比价/领券 才清 |
| 在途提现 | 提现申请时现金已扣入 `WithdrawOrder`,当前余额已不含在途;只清当前余额、不动提现单。提现失败退款到已清账户 = 用户的钱,正常 |
| 与 `daily_auto_exchange` 并存 | 各自逐用户幂等;金币多已日结折现金,三桶全清正好覆盖 |
| 时区/日界 | 统一北京(`rewards.cn_today()`/`CN_TZ`);**清零/预警按北京自然日 0 点对齐**(末次活跃记为第 1 日 → 第 16 日 0 点清零,见 §4),非滚动 24h;流水 `created_at` 沿用北京 wall-clock naive |
| 误清防护 | worker 常驻默认 **dry-run**`ENABLED=false` 只记审计名单、不动钱);看准名单再置 `true` 真清(§13 |
---
## 11. 前端依赖:`home_view` 埋点(跨仓 — Android
- **Android 端**`shaguabijia-app-android`)需在**首页可见**`onResume`/Tab 切入)时,向现有 `POST /api/v1/analytics/events` 批量上报里加一条 `event=<首页可见事件名>`(名称明天加埋点时定,暂记 `"home_view"` 的事件,**携带登录后的 `user_id`**。
- 客户端按会话/前台去重即可(服务端只取 `max(created_at)`,多报无害)。
- **上线顺序依赖**`home_view` 全量覆盖前,"进首页"信号缺失,只有比价/领券能推进活跃、其余落到 `created_at` 基线("只开首页不操作"且注册满 15 天的用户会被误清)—— 故**开真清(`ENABLED=true`)必须待 `home_view` 铺满后再开**(§13);dry-run 只记名单不动钱、可先开着看。
---
## 12. admin 重构范围与影响(R4
- `app/admin/repositories/queries.py`:删本地 `_ACTIVE_EVENTS`/`_last_active_parts()`,改用 `activity.py` 的常量与子查询构造;`list_users``greatest(...)` 排序/筛选、`_attach_last_active` 均改走共享构造器。
- `app/admin/repositories/stats.py``COMPARE_START_EVENT`/`COUPON_START_EVENT`/活跃用户集(`:138-146`)改用共享常量与口径。
- **行为变化(预期内、需产品知会)**admin 的"最近活跃 / DAU"口径变化——**移除 `last_login_at`(登录不再计为活跃)、以 `created_at` 为基线、纳入 `home_view`**。net`home_view` 铺满后更准(真正把"开首页"算进活跃);铺满前"只登录不操作"的用户活跃度会下降。
- **回归底线**:现有 admin 用户列表 / stats 测试按新口径**更新预期**last_login_at 移除 + created_at 基线 + home_view 纳入);非活跃口径部分行为不变。
---
## 13. 灰度与上线顺序(安全优先)
1. **后端先行**:合入共享模块 + 两表 + worker + 通知器,`INACTIVITY_RESET_ENABLED=False`;活跃口径以 `created_at` 为非空基线、**不含 last_login_at**。
2. **Android 发版**:上报 `home_view`;观察 analytics 覆盖率。
3. **dry-run 灰度(默认即是)**`INACTIVITY_RESET_ENABLED=False` 时 worker 常驻只写审计名单(`reason=inactive_Nd_dryrun`)、不动钱、不预警;核对名单准确。
4. **开真清**:确认无误后置 `INACTIVITY_RESET_ENABLED=True`(转为真清 + 预警)。
5. **收尾/监控**:持续观察 `home_view` 覆盖率与预警/清零名单;发现"活跃却被判不活跃"的漏报即回查埋点覆盖(口径已不含 last_login_at,登录不再兜底)。
---
## 14. 测试计划
- **活跃口径(共享模块)**`home_view`/比价/领券 各单独命中都算活跃;**纯登录不算**;无信号用户以 `created_at` 计;`max` 取最新;naive/aware 混算不崩。
- **admin 回归**:用户列表 / stats 按新口径更新预期(移除 last_login_at + created_at 基线 + home_view)。
- **不活跃判定**`last_active` 分别 `<15d / =15d / >15d` × 有/无余额 的命中矩阵。
- **清零**:三桶归零;`inactivity_reset_log` 清前值正确;三条流水 `biz_type=inactivity_reset``balance_after=0``ref_id=log.id``total_coin_earned` 不变。
- **预警**:命中窗口调 notifier + 写 `notification_log`;同 streak 不重推;回归后 `last_active` 前移可再次预警;漏跑补发最紧急档。
- **worker**:常驻;`ENABLED=false` 走 dry-run(只记审计名单、不清、不预警);文件锁互斥;逐用户失败隔离(一个抛错不影响其余,`failed` 计数);重复跑幂等。
- **配置**`INACTIVITY_WARN_DAYS_BEFORE` 解析(含空串=不推);`RESET_DAYS`/`RUN_HOUR` 生效。
- 沿用 `tests/conftest.py`(临时 SQLite、`RATE_LIMIT_ENABLED=false`);外部通知 monkeypatch。
---
## 15. 未来工作
- 接真实 `JPushNotifier`(需用户级 `registration_id` 覆盖 + JPush push API/ `SmsNotifier`
- 如需 admin 后台可视化:不活跃/预警/清零名单与历史查询接口。
- 如量级增长导致每日 join 扫描变慢:再考虑物化 `last_active_at`(当前每日一次可接受)。
---
## 附:涉及文件清单
**新增**
- `app/repositories/activity.py` — 活跃口径唯一真源
- `app/models/inactivity_reset_log.py` — 审计表
- `app/models/inactivity_notification_log.py` — 预警/占位表
- `app/core/inactivity_reset_worker.py` — 每日 worker(仿 daily_exchange_worker
- `app/integrations/notifier.py` — 通知器协议 + `LogNotifier`(真实 JPush/短信后续同层扩展)
- `alembic/versions/<...>_add_inactivity_tables.py` — 建两表迁移
- `docs/database/inactivity_reset_log.md` / `inactivity_notification_log.md` — 表字典(随实现补)
- 对应 `tests/test_inactivity_reset.py`
**改动**
- `app/models/__init__.py` — 注册两模型
- `app/core/config.py``INACTIVITY_*` 配置
- `app/main.py` — lifespan 接线 start/stop worker
- `app/admin/repositories/queries.py``stats.py` — 改用 `activity.py`(§12
-166
View File
@@ -1,166 +0,0 @@
"""收益明细「金币记录」文案验证脚手架(2026-07 文案改版验收用)。
问题:客户端按 bizType 显示固定文案(路线B,强制覆盖后端 remark),但账号若没有对应
bizType 的流水,收益明细页就空着,无从验证本脚本往指定测试用户塞每种 bizType 各一条
流水,让你在手机收益明细页一屏核对全部新文案;验收完 --clean 一键删除,不污染数据
dev 库用(APP_ENV=dev 时才允许 --seed/--clean)所有测试流水 ref_id 前缀 TESTDOC,
按前缀精确清理,不会误删真实流水
用法(pricebot env 直调,见项目 CLAUDE.md):
D:/miniconda/envs/pricebot/python.exe scripts/seed_coinhistory_labels_test.py --show
D:/miniconda/envs/pricebot/python.exe scripts/seed_coinhistory_labels_test.py --seed
D:/miniconda/envs/pricebot/python.exe scripts/seed_coinhistory_labels_test.py --clean
"""
from __future__ import annotations
import argparse
import sys
from app.core.config import settings
from app.core.rewards import CN_TZ
from datetime import datetime
from app.db.session import SessionLocal
from app.models.user import User
from app.models.wallet import CoinAccount, CoinTransaction
TEST_PHONE = "11111111111"
REF_PREFIX = "TESTDOC" # 所有本脚本造的流水都带这个 ref_id 前缀,便于精确清理
# 每条 = (bizType, 故意写错/留空的 remark, 金币数)。
# remark 故意填「错的」→ 若客户端仍显示新文案 = 证明路线B强制覆盖生效(无视后端 remark)。
# task_ 一条留空 remark → 走客户端兜底映射。顺序即手机上从新到旧的展示顺序(后塞的在最上)。
CASES: list[tuple[str, str, int]] = [
("signin", "每日签到 第99天(旧文案,应被覆盖)", 220),
("signin_boost", "签到膨胀 第99天", 3000),
("reward_video", "看视频奖励金币(旧文案,应被覆盖)", 200),
("feed_ad_reward_comparison", "", 50), # 后端新拆:比价场景 → 比价奖励
("feed_ad_reward_coupon", "", 50), # 后端新拆:领券场景 → 领券奖励
("feed_ad_reward", "信息流广告奖励(welfare/旧数据兜底)", 50),
("price_report_reward", "上报更低价审核通过(旧文案,应被覆盖)", 1000),
("feedback_reward", "意见反馈被采纳(旧文案,应被覆盖)", 10000),
("task_enable_notification", "", 750), # remark 留空 → 客户端「打开消息提醒奖励」
# 下两条现实中不会进金币记录(invite 发现金进邀请钱包 / compare_milestone 后端死代码不发钱),
# 仅用于验证「杀掉好友比价奖励 + 兜底改任务奖励」:两条都应显示「任务奖励」(remark 被强制无视)。
("invite", "好友比价奖励(旧文案,应被杀→任务奖励)", 200),
("compare_milestone", "", 120),
]
def _client_coin_title(biz_type: str, remark: str | None) -> str:
"""复刻 CoinHistoryViewModel.coinTitle 的最新逻辑(路线B),用于 --show 预览。
必须与客户端保持一致;客户端改了这里也要同步,否则预览会骗人
"""
fixed = {
"exchange_out": "金币兑换现金",
"signin": "每日签到奖励",
"signin_boost": "签到膨胀奖励",
"reward_video": "看视频赚金币",
"ad_reward": "看视频赚金币",
"feed_ad_reward_comparison": "比价奖励",
"feed_ad_reward_coupon": "领券奖励",
"feed_ad_reward": "信息流广告奖励",
"price_report_reward": "爆料奖励",
"feedback_reward": "反馈奖励",
"invite": "任务奖励", # 杀掉"好友比价奖励",归兜底
}
if biz_type in fixed:
return fixed[biz_type]
if remark:
return remark
if biz_type == "task_enable_notification":
return "打开消息提醒奖励"
return "任务奖励"
def _get_user(db) -> User:
u = db.query(User).filter(User.phone == TEST_PHONE).first()
if not u:
print(f"✗ 库里没有测试号 {TEST_PHONE} —— 先在手机上用这个号登录一次再跑本脚本。")
sys.exit(1)
return u
def cmd_show() -> None:
"""只打印:每种 bizType 经客户端映射后会显示成什么(不写库)。"""
print("bizType 造流水后,收益明细页预期显示的文案:\n")
print(f" {'bizType':32} {'后端remark(故意填的)':32} → 手机显示")
print(" " + "-" * 90)
for biz, remark, _coin in CASES:
shown = _client_coin_title(biz, remark or None)
rk = (remark or "(空)")
print(f" {biz:32} {rk:32}{shown}")
print("\n注:remark 列是故意填的『旧/错』文案;'手机显示'若为新文案 = 强制覆盖生效。")
print("invite / compare_milestone 已从客户端映射删除:invite 有 remark 故显示原样,")
print("compare_milestone remark 空故落兜底『奖励』—— 两者都不再有专属新文案(符合『去掉』)。")
def cmd_seed() -> None:
if settings.APP_ENV != "dev":
print(f"✗ 拒绝:APP_ENV={settings.APP_ENV},本脚本只在 dev 库造测试数据。")
sys.exit(1)
db = SessionLocal()
u = _get_user(db)
acc = db.query(CoinAccount).filter(CoinAccount.user_id == u.id).first()
if acc is None:
acc = CoinAccount(user_id=u.id, coin_balance=0, cash_balance_cents=0)
db.add(acc)
db.flush()
now = datetime.now(CN_TZ).replace(tzinfo=None)
made = 0
for i, (biz, remark, coin) in enumerate(CASES):
ref = f"{REF_PREFIX}:{biz}:{i}"
exists = db.query(CoinTransaction).filter(CoinTransaction.ref_id == ref).first()
if exists:
continue
acc.coin_balance += coin
db.add(CoinTransaction(
user_id=u.id, amount=coin, balance_after=acc.coin_balance,
biz_type=biz, ref_id=ref, remark=remark or None, created_at=now,
))
made += 1
db.commit()
print(f"✓ 已给 user_id={u.id}({TEST_PHONE})造 {made} 条测试流水,当前金币余额 {acc.coin_balance}")
print(" → 打开手机 App「收益明细 / 金币记录」下拉刷新,逐条核对文案。")
print(" → 验收完跑 --clean 删除这些测试流水。")
def cmd_clean() -> None:
if settings.APP_ENV != "dev":
print(f"✗ 拒绝:APP_ENV={settings.APP_ENV}")
sys.exit(1)
db = SessionLocal()
u = _get_user(db)
rows = db.query(CoinTransaction).filter(
CoinTransaction.user_id == u.id,
CoinTransaction.ref_id.like(f"{REF_PREFIX}:%"),
).all()
total = sum(r.amount for r in rows)
for r in rows:
db.delete(r)
acc = db.query(CoinAccount).filter(CoinAccount.user_id == u.id).first()
if acc is not None:
acc.coin_balance -= total # 把造流水时加的余额扣回,还原
db.commit()
print(f"✓ 已删除 {len(rows)} 条 TESTDOC 测试流水,余额回扣 {total},当前 {acc.coin_balance if acc else 0}")
def main() -> None:
ap = argparse.ArgumentParser(description="收益明细金币文案验证脚手架")
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--show", action="store_true", help="只打印每种 bizType 的预期显示文案,不写库")
g.add_argument("--seed", action="store_true", help="往测试号造每种 bizType 各一条流水")
g.add_argument("--clean", action="store_true", help="删除本脚本造的所有测试流水")
args = ap.parse_args()
if args.show:
cmd_show()
elif args.seed:
cmd_seed()
elif args.clean:
cmd_clean()
if __name__ == "__main__":
main()
-139
View File
@@ -1,139 +0,0 @@
"""人工验证用:按「金币/现金/邀请」排列组合 + 活跃/新用户对照,造一批账号。
用法(仓库根目录,venv 解释器):
.venv/Scripts/python.exe scripts/seed_inactivity_cases.py # 造号(会先清掉上次 vcase*)
.venv/Scripts/python.exe scripts/seed_inactivity_cases.py --clean # 只清理,不造
配合默认配置 INACTIVITY_RESET_DAYS=15 / INACTIVITY_WARN_DAYS_BEFORE=7,2 验证
造完把 worker 打开( README/对话里的 .env),启动服务即会在 RUN_HOUR 后跑一轮
worker 清零针对**库里所有**符合条件的用户,不止 vcase*dev 库里若有其它"老且有余额、
无近期活跃事件"的用户,也会被一起清。要干净验证建议用一个空/副本 dev 库。
"""
from __future__ import annotations
import os
import sys
from datetime import UTC, datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import delete, select # noqa: E402
from app.db.session import SessionLocal # noqa: E402
from app.models.analytics_event import AnalyticsEvent # noqa: E402
from app.models.inactivity import ( # noqa: E402
InactivityNotificationLog,
InactivityResetLog,
)
from app.models.user import User # noqa: E402
from app.models.wallet import ( # noqa: E402
CashTransaction,
CoinAccount,
CoinTransaction,
InviteCashTransaction,
)
from app.repositories import wallet as wallet_repo # noqa: E402
MARK = "vcase" # username 前缀,用于清理
# label, 创建于N天前, coin, cash, invite, 近期事件(N天前)or None, 预期
CASES = [
("1 三桶全有", 30, 100, 200, 300, None, "清 coin+cash;invite=300 保留;审计1行+2流水"),
("2 金币+现金", 30, 100, 200, 0, None, "清 coin+cash;审计1行+2流水"),
("3 金币+邀请", 30, 100, 0, 300, None, "清 coin;invite=300 保留;审计1行+1流水"),
("4 现金+邀请", 30, 0, 200, 300, None, "清 cash;invite=300 保留;审计1行+1流水"),
("5 只有金币", 30, 100, 0, 0, None, "清 coin;审计1行+1流水"),
("6 只有现金", 30, 0, 200, 0, None, "清 cash;审计1行+1流水"),
("7 只有邀请(红线)", 30, 0, 0, 300, None, "不选中/不清/无审计/无流水;invite=300 原封"),
("8 预警窗(10天)", 10, 50, 60, 70, None, "不清;发 T-7 预警;notification_log 1行;余额不动"),
("9 活跃兜底", 30, 100, 200, 300, 1, "昨日 home_view→last_active 近→不清不警"),
("10 新用户(3天)", 3, 100, 200, 0, None, "created_at 近→不清不警"),
]
def _mark_uids(db) -> list[int]:
return list(db.execute(select(User.id).where(User.username.like(f"{MARK}%"))).scalars())
def clean(db) -> int:
uids = _mark_uids(db)
if uids:
for model in (
InactivityResetLog, InactivityNotificationLog,
CoinTransaction, CashTransaction, InviteCashTransaction,
AnalyticsEvent, CoinAccount,
):
db.execute(delete(model).where(model.user_id.in_(uids)))
db.execute(delete(User).where(User.id.in_(uids)))
db.commit()
return len(uids)
def seed(db) -> None:
now = datetime.now(UTC)
print(f"{'#':>3} {'uid':>5} {'案例':<16} {'coin/cash/invite':<18} {'创建':<7} 预期")
for i, (label, days_ago, coin, cash, invite, ev_days, expected) in enumerate(CASES, 1):
u = User(
phone=f"seed_tmp_{i}", username=f"{MARK}{i}", status="active",
created_at=now - timedelta(days=days_ago),
last_login_at=now, # 登录很新——但登录不算活跃,清零该发生照发生
)
db.add(u)
db.flush() # 拿自增 id
u.phone = f"1{u.id:010d}" # 用全局唯一 id 拼 "100…" 段手机号,dev 库里绝不撞
acc = wallet_repo.get_or_create_account(db, u.id, commit=False)
acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents = coin, cash, invite
acc.total_coin_earned = coin
if ev_days is not None:
db.add(AnalyticsEvent( # 首页可见 = event=show + page=home
event="show", page="home", device_id=MARK, user_id=u.id,
client_ts=0, created_at=now - timedelta(days=ev_days),
))
db.flush()
print(f"{i:>3} {u.id:>5} {label:<16} {f'{coin}/{cash}/{invite}':<18} {f'{days_ago}天前':<7} {expected}")
db.commit()
def check(db) -> None:
"""worker 跑完后:打印每个 vcase 账号的当前三桶余额 + 是否有审计/预警行。"""
rows = db.execute(
select(User.id, User.username).where(User.username.like(f"{MARK}%")).order_by(User.id)
).all()
if not rows:
print("没有 vcase* 账号(先跑一次不带参数造号)")
return
print(f"{'uid':>5} {'账号':<8} {'coin/cash/invite(现在)':<24} {'审计':<5} 预警")
for uid, uname in rows:
acc = db.get(CoinAccount, uid)
bal = f"{acc.coin_balance}/{acc.cash_balance_cents}/{acc.invite_cash_balance_cents}" if acc else ""
has_reset = db.execute(
select(InactivityResetLog.id).where(InactivityResetLog.user_id == uid).limit(1)
).first()
stages = db.execute(
select(InactivityNotificationLog.stage).where(InactivityNotificationLog.user_id == uid)
).scalars().all()
warn = ",".join(f"T-{s}" for s in stages) if stages else ""
print(f"{uid:>5} {uname:<8} {bal:<24} {'' if has_reset else '':<5} {warn}")
def main() -> None:
db = SessionLocal()
try:
if "--check" in sys.argv:
check(db)
return
removed = clean(db)
if removed:
print(f"已清理上次 {removed}{MARK}* 账号")
if "--clean" in sys.argv:
return
seed(db)
print("\n造号完成。打开 worker(INACTIVITY_RESET_ENABLED=true, RUN_HOUR=17)后启动服务,"
"≥17:00 首个 tick 即跑一轮。验完 `--clean` 清理。")
finally:
db.close()
if __name__ == "__main__":
main()
-248
View File
@@ -1,248 +0,0 @@
"""一次性 mock:造带 LLM token 成本的比价记录 + 配好 app_config 模型单价,用于测「管理后端」LLM 成本展示。
覆盖 admin比价记录详情抽屉的LLM 成本展示分支:
app_config.llm_token_price 写一条多模型单价(= 配置页LLM 成本卡片已改,get_llm_prices 读它)
comparison_record 5 ,逐条**复用生产的 compute_llm_cost + _backfill_llm_calls 同款派生**
(llm_call_count/retry_count/input_tokens/output_tokens/llm_cost_yuan/llm_price_snapshot),
确保 mock = 真实回填产出5 条刻意覆盖:
单模型真实样本(qwen3.5-flash ×4) ¥0.006184(核对精确值)
多模型(flash + plus) 快照含两个模型各自 _source=per_model
未登记模型(deepseek-v3) default,快照 _source=default
旧记录( token cost) llm_cost_yuan=NULL 前端回退估算成本
error 调用 error 那次跳过计费retry_count+1
记录挂到库里第一个真实用户(admin 列表能显示手机号);无用户则 user_id=NULL(孤儿行,admin 照样全看)
created_at 用北京 naive最近几分钟内错开,详情列表倒序即 置顶
幂等:重跑先按 trace_id 前缀MOCKLLM-清旧再建app_config 单价是 upsert(不随 --clean-only ,
因该 key 本就是本需求新增无历史真实值;要改价直接去配置页或重跑本脚本)
python -m scripts.seed_mock_llm_cost # 造价格 + 5 条记录
python -m scripts.seed_mock_llm_cost --clean-only # 只清 MOCKLLM- 记录(保留单价)
验收:admin比价记录 traceMOCKLLM- 5 点开详情看LLM 成本:
显示实际·当时价+ 价格快照; 显示估算
"""
from __future__ import annotations
import argparse
import sys
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, select
from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
from app.models.user import User
from app.repositories import app_config
from app.services.llm_cost import compute_llm_cost
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8") # Windows 控制台输出中文/¥
_BJ = timezone(timedelta(hours=8))
ID_PREFIX = "MOCKLLM-"
# ── 写进 app_config 的模型单价(get_llm_prices 读它;配置页「LLM 成本」卡片可再改)──
PRICE_CFG = {
"per_model": {
"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0},
"qwen3.5-plus": {"input_per_1m": 4.0, "output_per_1m": 12.0},
},
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0},
"currency": "CNY",
"unit": "per_1m_tokens",
}
def _c(scene: str, model: str, pin: int, cout: int, error: str | None = None) -> dict:
"""一条 llm_calls 明细,结构对齐真实 pricebot 归一后契约:
{scene, model, input_messages:[{role,content}], output, usage:{prompt/completion/total_tokens},
latency_ms, error}(详情抽屉会遍历 input_messages,缺了会崩)error 的调用无 usage/output"""
return {
"scene": scene,
"model": model,
"error": error,
"input_messages": [
{"role": "system", "content": f"你是比价助手,负责 {scene} 环节。"},
{"role": "user", "content": f"[mock] 请处理本次比价的 {scene} 任务。"},
],
"output": None if error else f"[mock] {scene} 环节完成。",
"usage": None if error else {
"prompt_tokens": pin, "completion_tokens": cout, "total_tokens": pin + cout,
},
"latency_ms": 780,
}
# ── 5 条记录蓝本:calls 决定成本;freeze=False 模拟旧记录(有 token 无 cost)──
RECORDS = [
{
"label": "①单模型·真实样本",
"source": ("美团外卖", 4280), "best": ("京东秒送", 3680),
"store": "肯德基(建国路店)", "product": "疯狂星期四全家桶",
"info": "在京东秒送找到同款,到手价 ¥36.80,省 ¥6.00",
"freeze": True,
"calls": [
_c("store_match", "qwen3.5-flash", 1512, 22),
_c("dish_match", "qwen3.5-flash", 2111, 160),
_c("dish_match", "qwen3.5-flash", 1940, 142),
_c("summary", "qwen3.5-flash", 1325, 13),
],
},
{
"label": "②多模型·flash+plus",
"source": ("淘宝闪购", 5900), "best": ("美团外卖", 5200),
"store": "瑞幸咖啡(国贸店)", "product": "生椰拿铁×2、丝绒拿铁",
"info": "在美团外卖找到同款,到手价 ¥52.00,省 ¥7.00",
"freeze": True,
"calls": [
_c("store_match", "qwen3.5-flash", 2000, 50),
_c("dish_match", "qwen3.5-flash", 1800, 40),
_c("reasoning", "qwen3.5-plus", 3000, 500),
],
},
{
"label": "③未登记模型走 default",
"source": ("京东秒送", 3100), "best": ("美团外卖", 2650),
"store": "麦当劳(soho店)", "product": "麦辣鸡腿堡套餐",
"info": "在美团外卖找到同款,到手价 ¥26.50,省 ¥4.50",
"freeze": True,
"calls": [
_c("store_match", "deepseek-v3", 5000, 800),
],
},
{
"label": "④旧记录·有token无成本(回退估算)",
"source": ("美团外卖", 3600), "best": ("淘宝闪购", 3200),
"store": "华莱士(双井店)", "product": "全鸡汉堡套餐",
"info": "在淘宝闪购找到同款,到手价 ¥32.00,省 ¥4.00",
"freeze": False, # 模拟本需求上线前的老记录:llm_cost_yuan=NULL → 前端回退估算
"calls": [
_c("store_match", "qwen3.5-flash", 2000, 100),
],
},
{
"label": "⑤含 error 调用(跳过计费)",
"source": ("淘宝闪购", 4100), "best": ("京东秒送", 3750),
"store": "海底捞(合生汇店)", "product": "番茄锅底、肥牛卷",
"info": "在京东秒送找到同款,到手价 ¥37.50,省 ¥3.50",
"freeze": True,
"calls": [
_c("store_match", "qwen3.5-flash", 0, 0, error="timeout"),
_c("store_match", "qwen3.5-flash", 1500, 30),
],
},
]
_PLATFORM_ID = { # 展示名 → 平台代号(comparison_results / source/best 列用)
"美团外卖": "meituan", "京东秒送": "jd", "淘宝闪购": "taobao",
}
def _naive_bj_now() -> datetime:
return datetime.now(_BJ).replace(tzinfo=None)
def clean(db) -> int:
n = db.execute(
delete(ComparisonRecord).where(ComparisonRecord.trace_id.like(f"{ID_PREFIX}%"))
).rowcount or 0
db.commit()
return n
def _build_record(spec: dict, owner_id: int | None, created_at: datetime) -> tuple[ComparisonRecord, float | None]:
"""按蓝本造一条记录,LLM 派生完全对齐 _backfill_llm_calls;返回 (记录, 冻结成本或 None)。"""
calls = spec["calls"]
src_name, src_cents = spec["source"]
best_name, best_cents = spec["best"]
# —— 与 _backfill_llm_calls 同款派生 ——
llm_call_count = len(calls)
retry_count = sum(1 for c in calls if c.get("error"))
input_tokens = sum((c.get("usage") or {}).get("prompt_tokens") or 0 for c in calls)
output_tokens = sum((c.get("usage") or {}).get("completion_tokens") or 0 for c in calls)
if spec["freeze"]:
cost, snapshot = compute_llm_cost(calls, PRICE_CFG) # 复用生产纯函数
else:
cost, snapshot = None, None # 旧记录:回填这段代码上线前就有,只有 token 没成本
rec = ComparisonRecord(
user_id=owner_id,
device_id=f"{ID_PREFIX.lower()}dev",
business_type="food",
trace_id=f"{ID_PREFIX}{spec['label'][0]}", # ①..⑤ 各一,唯一
status="success",
source_platform_id=_PLATFORM_ID.get(src_name), source_platform_name=src_name,
source_price_cents=src_cents,
best_platform_id=_PLATFORM_ID.get(best_name), best_platform_name=best_name,
best_price_cents=best_cents,
saved_amount_cents=src_cents - best_cents,
is_source_best=False,
store_name=spec["store"],
product_names=spec["product"],
information=spec["info"],
items=[{"name": spec["product"], "qty": 1}],
comparison_results=[
{"platform_id": _PLATFORM_ID.get(src_name), "platform_name": src_name,
"price": src_cents / 100, "is_source": True, "rank": 2},
{"platform_id": _PLATFORM_ID.get(best_name), "platform_name": best_name,
"price": best_cents / 100, "is_source": False, "rank": 1},
],
total_ms=90_000 + llm_call_count * 1000,
step_count=llm_call_count * 3,
llm_call_count=llm_call_count,
retry_count=retry_count,
input_tokens=input_tokens,
output_tokens=output_tokens,
llm_calls=calls,
llm_cost_yuan=cost,
llm_price_snapshot=snapshot,
created_at=created_at,
)
return rec, cost
def seed(db) -> list[tuple[str, float | None]]:
app_config.set_value(db, "llm_token_price", PRICE_CFG, admin_id=None) # upsert 单价
owner_id = db.execute(select(User.id).order_by(User.id).limit(1)).scalar()
base = _naive_bj_now()
out: list[tuple[str, float | None]] = []
for i, spec in enumerate(RECORDS):
rec, cost = _build_record(spec, owner_id, base - timedelta(minutes=i * 3))
db.add(rec)
out.append((spec["label"], cost))
db.commit()
return out, owner_id
def main() -> None:
parser = argparse.ArgumentParser(description="造带 LLM 成本的比价记录 + app_config 模型单价(测管理后端)")
parser.add_argument("--clean-only", action="store_true", help="只清 MOCKLLM- 记录,不重建(保留单价)")
args = parser.parse_args()
db = SessionLocal()
try:
removed = clean(db)
if removed:
print(f"🧹 已清理旧 mock 记录 {removed}")
if args.clean_only:
print("✅ 仅清理,已完成(app_config 单价保留)。")
return
results, owner_id = seed(db)
print(f"\n✅ 已写入 app_config.llm_token_price(单价)+ {len(results)} 条比价记录"
f"(挂 user_id={owner_id or 'NULL(孤儿行)'})")
print("\n📋 每条冻结成本(admin 详情「LLM 成本」应显示):")
for label, cost in results:
shown = "NULL → 前端回退「估算」" if cost is None else f"¥{cost}"
print(f" {label:<20} {shown}")
print("\n👉 验收:admin「比价记录」→ trace 搜「MOCKLLM-」→ 点开详情核对 LLM 成本 + 价格快照。")
print(" 配置页「系统配置」→「福利页」Tab →「LLM 成本」卡片,单价应为「已改」态。")
finally:
db.close()
if __name__ == "__main__":
main()
-107
View File
@@ -1,107 +0,0 @@
"""ad_ecpm_record.trace_id 落库 + 按 trace 聚合广告收益(元)。"""
from __future__ import annotations
from datetime import UTC, date, datetime
from sqlalchemy import delete
from app.db.session import SessionLocal
from app.models.ad_ecpm import AdEcpmRecord
from app.models.coupon_state import CouponSession
from app.repositories import ad_ecpm as crud_ecpm
def _ecpm(trace_id: str, ecpm_raw: str, session_id: str) -> AdEcpmRecord:
"""构造一条 Draw 展示 eCPM(不 commit;ad_session_id 全局唯一,须各不相同)。"""
return AdEcpmRecord(
user_id=1,
ad_type="draw",
feed_scene="comparison",
ad_session_id=session_id,
ecpm_raw=ecpm_raw,
trace_id=trace_id,
report_date="2020-01-02",
created_at=datetime(2020, 1, 2, tzinfo=UTC),
)
def test_revenue_yuan_by_trace_sums_and_clamps() -> None:
"""同一 trace 多条展示求和;收益=min(eCPM元,¥500)/1000;无展示的 trace 不出现。
ecpm 200 2.0 /千次0.002 /;300 0.003;合计 0.005
"""
db = SessionLocal()
try:
db.add_all([
_ecpm("t1", "200", "sess-t1-a"),
_ecpm("t1", "300", "sess-t1-b"),
_ecpm("t2", "0", "sess-t2-a"),
_ecpm("t_cap", "60000", "sess-cap-a"), # 600 元 CPM > ¥500 钳顶
])
db.flush()
rev = crud_ecpm.revenue_yuan_by_trace(db, ["t1", "t2", "t3", "t_cap"])
assert rev["t1"] == 0.005
assert rev["t2"] == 0.0 # 有展示但 eCPM=0 → 0 元(仍在结果里)
assert "t3" not in rev # 无展示的 trace 不出现在结果里
assert rev["t_cap"] == 0.5 # min(¥600, ¥500)/1000,证明钳顶生效(非 0.6)
finally:
db.rollback()
db.close()
def test_revenue_yuan_by_trace_empty() -> None:
"""空 trace 列表直接返回 {}(避免 IN () 非法)。"""
db = SessionLocal()
try:
assert crud_ecpm.revenue_yuan_by_trace(db, []) == {}
finally:
db.close()
def test_terminal_coupon_trace_is_not_attributable_to_late_impression() -> None:
"""领券失败或被放弃后才到达的广告展示保留收益记录,但不再关联死亡 trace。"""
db = SessionLocal()
try:
db.add_all([
CouponSession(
trace_id="failed-before-ad", device_id="d-late-ad", status="failed", app_env="prod",
started_at=datetime(2020, 1, 2, tzinfo=UTC), started_date=date(2020, 1, 2),
),
CouponSession(
trace_id="abandoned-before-ad", device_id="d-late-ad", status="abandoned", app_env="prod",
started_at=datetime(2020, 1, 2, tzinfo=UTC), started_date=date(2020, 1, 2),
),
])
db.flush()
assert crud_ecpm.attributable_trace_id(
db, feed_scene="coupon", trace_id="failed-before-ad"
) is None
assert crud_ecpm.attributable_trace_id(
db, feed_scene="coupon", trace_id="abandoned-before-ad"
) is None
assert crud_ecpm.attributable_trace_id(
db, feed_scene="comparison", trace_id="failed-before-ad"
) == "failed-before-ad"
assert crud_ecpm.attributable_trace_id(
db, feed_scene="coupon", trace_id="unknown-trace"
) == "unknown-trace"
finally:
db.rollback()
db.close()
def test_create_ecpm_record_persists_trace_id() -> None:
"""create_ecpm_record 落 trace_id。"""
db = SessionLocal()
try:
rec = crud_ecpm.create_ecpm_record(
db, 1, ad_type="draw", ecpm_raw="150",
ad_session_id="sess-trace-persist", feed_scene="coupon",
trace_id="trace-xyz",
)
assert rec.trace_id == "trace-xyz"
finally:
db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.ad_session_id == "sess-trace-persist"))
db.commit()
db.close()
-46
View File
@@ -1,15 +1,12 @@
"""Admin M2 读接口测试:大盘聚合 + 用户/流水/提现/反馈列表 + 鉴权拦截。"""
from __future__ import annotations
from datetime import datetime
import pytest
from fastapi.testclient import TestClient
from app.admin.main import admin_app
from app.admin.repositories import admin_user as admin_repo
from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
from app.models.feedback import Feedback
from app.models.wallet import CashTransaction, WithdrawOrder
from app.repositories import user as user_repo
@@ -72,49 +69,6 @@ def test_dashboard_overview(admin_client: TestClient, admin_token: str) -> None:
assert "jd_order_count" in data["cps"]
def test_dashboard_period_comparison_is_aggregated_by_backend(
admin_client: TestClient, admin_token: str
) -> None:
created_at = datetime(2037, 1, 15, 12)
rows = [
("dashboard-aggregate-success", "success", 101, 0.1),
("dashboard-aggregate-failed", "failed", 200, 0.2),
("dashboard-aggregate-cancelled", "cancelled", 300, 0.3),
("dashboard-aggregate-running", "running", 400, 0.4),
]
db = SessionLocal()
try:
for trace_id, status, total_ms, llm_cost_yuan in rows:
db.add(
ComparisonRecord(
trace_id=trace_id,
status=status,
total_ms=total_ms,
llm_cost_yuan=llm_cost_yuan,
created_at=created_at,
)
)
db.commit()
finally:
db.close()
response = admin_client.get(
"/admin/api/stats/overview",
params={"date_from": "2037-01-15", "date_to": "2037-01-15"},
headers=_auth(admin_token),
)
assert response.status_code == 200, response.text
comparison = response.json()["period"]["comparison"]
assert comparison["total"] == 4
assert comparison["completed"] == 2
assert comparison["cancelled"] == 1
assert comparison["success"] == 1
assert comparison["success_rate"] == 0.3333
assert comparison["median_duration_ms"] == 151
assert comparison["p95_duration_ms"] == 195
assert comparison["token_cost_total_yuan"] == pytest.approx(1.0)
def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> None:
uid = _seed_user_with_data("13800000002")
r = admin_client.get("/admin/api/users", headers=_auth(admin_token))
+1 -2
View File
@@ -123,8 +123,7 @@ def test_builtin_roles_labels_and_pages(admin_client, super_token) -> None:
# 页集对齐 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", "huawei-review",
"event-logs", "audit-logs",
"dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs",
}
-160
View File
@@ -1,160 +0,0 @@
"""埋点健康度聚合:纯差分函数 + admin 端点。"""
from __future__ import annotations
from datetime import datetime, timezone
from app.admin.repositories.analytics_health import _cn_day, _rates, diff_snapshots
def _row(device, epoch, event, ts, **cum) -> dict:
base = {"attempted": 0, "drop_capture": 0, "delivered": 0, "drop_undelivered": 0}
base.update(cum)
return {
"device_id": device, "epoch_id": epoch, "event": event,
"created_at": datetime(2026, 7, 1, ts, 0, tzinfo=timezone.utc),
"app_ver": "0.2.12(62)", "oem": "ColorOS", "os": "Android 14",
**base,
}
def test_diff_first_row_is_full_cumulative() -> None:
rows = [_row("d", "e", "video_play", 1, attempted=100, delivered=90)]
out = diff_snapshots(rows)
assert len(out) == 1
assert out[0]["d_attempted"] == 100
assert out[0]["d_delivered"] == 90
def test_diff_consecutive_delta() -> None:
rows = [
_row("d", "e", "video_play", 1, attempted=100, delivered=90),
_row("d", "e", "video_play", 2, attempted=150, delivered=140),
]
out = sorted(diff_snapshots(rows), key=lambda r: r["created_at"])
assert out[1]["d_attempted"] == 50
assert out[1]["d_delivered"] == 50
def test_diff_epoch_reset_new_partition() -> None:
rows = [
_row("d", "e1", "video_play", 1, attempted=100),
_row("d", "e2", "video_play", 2, attempted=5),
]
out = {(r["epoch_id"]): r["d_attempted"] for r in diff_snapshots(rows)}
assert out["e1"] == 100
assert out["e2"] == 5
def test_diff_clamps_negative_on_reorder() -> None:
rows = [
_row("d", "e", "video_play", 1, attempted=100),
_row("d", "e", "video_play", 2, attempted=80),
]
out = sorted(diff_snapshots(rows), key=lambda r: r["created_at"])
assert out[1]["d_attempted"] == 0
def test_rates_computes_both_formulas() -> None:
out = _rates({"attempted": 150, "drop_capture": 0, "delivered": 140, "drop_undelivered": 10})
assert out["track_success_rate"] == 1.0
assert out["report_success_rate"] == 140 / 150
def test_rates_zero_denominator_yields_none() -> None:
out = _rates({"attempted": 0, "drop_capture": 0, "delivered": 0, "drop_undelivered": 0})
assert out["track_success_rate"] is None
assert out["report_success_rate"] is None
def test_cn_day_beijing_boundary() -> None:
# UTC 15:59 → 北京 23:59 同日;UTC 16:00 → 北京 次日 00:00
assert _cn_day(datetime(2026, 7, 1, 15, 59, tzinfo=timezone.utc)) == "2026-07-01"
assert _cn_day(datetime(2026, 7, 1, 16, 0, tzinfo=timezone.utc)) == "2026-07-02"
def test_diff_same_timestamp_ordered_by_id() -> None:
ts = datetime(2026, 7, 1, 1, 0, tzinfo=timezone.utc)
# 相同 created_at,乱序传入(id=2 在前);应按 id 排序 → id=1(cum100) 在前
rows = [
{"id": 2, "device_id": "d", "epoch_id": "e", "event": "vp", "created_at": ts,
"app_ver": "v", "oem": "o", "os": "s",
"attempted": 150, "drop_capture": 0, "delivered": 0, "drop_undelivered": 0},
{"id": 1, "device_id": "d", "epoch_id": "e", "event": "vp", "created_at": ts,
"app_ver": "v", "oem": "o", "os": "s",
"attempted": 100, "drop_capture": 0, "delivered": 0, "drop_undelivered": 0},
]
out = diff_snapshots(rows)
assert out[0]["d_attempted"] == 100 # id=1 sorts first → its cumulative
assert out[1]["d_attempted"] == 50 # id=2 second → 150-100
import pytest
from fastapi.testclient import TestClient
from app.admin.main import admin_app
from app.admin.repositories import admin_user as admin_repo
from app.db.session import SessionLocal
@pytest.fixture()
def admin_client() -> TestClient:
return TestClient(admin_app)
@pytest.fixture()
def admin_token() -> str:
db = SessionLocal()
try:
if admin_repo.get_by_username(db, "health_admin") is None:
admin_repo.create_admin(db, username="health_admin", password="pw", role="super_admin")
finally:
db.close()
c = TestClient(admin_app)
r = c.post("/admin/api/auth/login", json={"username": "health_admin", "password": "pw"})
return r.json()["access_token"]
def _auth(t: str) -> dict:
return {"Authorization": f"Bearer {t}"}
def _seed_two_snapshots(client: TestClient) -> None:
for attempted, delivered in ((100, 90), (150, 140)):
client.post("/api/v1/analytics/selfstat", json={
"device_id": "d-health", "epoch_id": "e-health",
"app_ver": "0.2.12(62)", "oem": "ColorOS", "os": "Android 14",
"events": [{"event": "video_play", "attempted": attempted,
"drop_capture": 0, "delivered": delivered, "drop_undelivered": 0}],
})
def test_health_overview_requires_auth(admin_client: TestClient) -> None:
r = admin_client.get("/admin/api/analytics-health/overview",
params={"date_from": "2026-07-01T00:00:00Z", "date_to": "2030-01-01T00:00:00Z"})
assert r.status_code == 401
def test_health_overview(client: TestClient, admin_client: TestClient, admin_token: str) -> None:
_seed_two_snapshots(client) # 播种走公开 app(ingest 端点在 app,不在 admin_app)
r = admin_client.get(
"/admin/api/analytics-health/overview",
params={"date_from": "2026-07-01T00:00:00Z", "date_to": "2030-01-01T00:00:00Z"},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
data = r.json()
# 两快照累计 100→150,差分后 attempted 总 150(首快照 100 + 增量 50)
assert data["attempted"] == 150
assert data["track_success_rate"] == 1.0
def test_health_breakdown(client: TestClient, admin_client: TestClient, admin_token: str) -> None:
_seed_two_snapshots(client) # 播种走公开 app
r = admin_client.get(
"/admin/api/analytics-health/breakdown",
params={"date_from": "2026-07-01T00:00:00Z", "date_to": "2030-01-01T00:00:00Z", "dim": "event"},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
rows = r.json()
assert any(row["key"] == "video_play" for row in rows)
-43
View File
@@ -1,43 +0,0 @@
"""自报计数上报端点测试。"""
from __future__ import annotations
from fastapi.testclient import TestClient
def _payload(**over) -> dict:
base = {
"device_id": "dev-1", "epoch_id": "ep-1", "sent_at": 1700000000000,
"app_ver": "0.2.12(62)", "oem": "ColorOS", "os": "Android 14",
"batches_attempted": 10, "batches_ok": 9, "batches_fail": 1,
"retries": 1, "queue_depth": 2,
"events": [
{"event": "video_play", "attempted": 100, "drop_capture": 1,
"delivered": 95, "drop_undelivered": 2},
],
}
base.update(over)
return base
def test_selfstat_ingest_ok(client: TestClient) -> None:
r = client.post("/api/v1/analytics/selfstat", json=_payload())
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True
assert isinstance(body["snapshot_id"], int)
def test_selfstat_ingest_empty_events(client: TestClient) -> None:
r = client.post("/api/v1/analytics/selfstat", json=_payload(events=[]))
assert r.status_code == 200, r.text
assert r.json()["ok"] is True
def test_selfstat_ingest_db_error_returns_503(client: TestClient, monkeypatch) -> None:
"""落库异常时端点回 503(计数链路稳定性守卫),不裸奔 500。"""
def _boom(*_args, **_kwargs):
raise RuntimeError("db gone")
monkeypatch.setattr("app.repositories.analytics_selfstat.record_selfstat", _boom)
r = client.post("/api/v1/analytics/selfstat", json=_payload())
assert r.status_code == 503, r.text
-61
View File
@@ -107,67 +107,6 @@ def test_sms_send_device_ip_rate_limit(client, monkeypatch) -> None:
assert r.status_code == 200, r.text
def test_sms_send_cooldown_reject_not_counted(client, monkeypatch) -> None:
"""发码额度只算「成功发码」:被单号 60s 冷却挡下的重发(429)不占设备额度。
做法:同号狂发只成功 1 其余被冷却挡下;把小时额度设 2,证明换号后仍能再成功发 1
若冷却重发也计数,额度早被那几次耗尽"""
from app.api.v1 import auth
from app.core import ratelimit
monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_HOUR_PER_DEVICE", 2)
ratelimit._buckets.clear()
device = "dev-cooldown"
phone_a = "13710137000"
# 首发成功(小时闸计 1/2)
assert client.post(
"/api/v1/auth/sms/send", json={"phone": phone_a, "device_id": device}
).status_code == 200
# 同号连发 3 次:都被单号 60s 冷却挡下 → 429,且**不占**设备额度
for _ in range(3):
r = client.post(
"/api/v1/auth/sms/send", json={"phone": phone_a, "device_id": device}
)
assert r.status_code == 429, r.text
# 换号再发:设备额度只用了 1/2(冷却那几次没算)→ 仍放行(计到 2/2)
assert client.post(
"/api/v1/auth/sms/send", json={"phone": "13710137001", "device_id": device}
).status_code == 200
# 又换号:此时小时闸已 2/2 → 429(反证成功发码确实各计了 1)
r = client.post(
"/api/v1/auth/sms/send", json={"phone": "13710137002", "device_id": device}
)
assert r.status_code == 429, r.text
def test_sms_send_daily_cap(client, monkeypatch) -> None:
"""每天发码上限(设备 + IP):成功发码累计到日上限即 429(用不同手机号绕开单号冷却)。
抬高小时闸单独测日闸;超限文案含今日以便前端提示明天再来"""
from app.api.v1 import auth
from app.core import ratelimit
monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_HOUR_PER_DEVICE", 100) # 抬高小时闸,不干扰
monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_DAY_PER_DEVICE", 3)
ratelimit._buckets.clear()
device = "dev-daily"
for i in range(3):
r = client.post(
"/api/v1/auth/sms/send",
json={"phone": f"13720137{i:03d}", "device_id": device},
)
assert r.status_code == 200, f"{i + 1} 次应放行: {r.text}"
# 第 4 次:同设备同 IP 当日超限 → 429
r = client.post(
"/api/v1/auth/sms/send",
json={"phone": "13720137999", "device_id": device},
)
assert r.status_code == 429, r.text
assert "今日" in r.json()["detail"]
def test_sms_login_device_ip_rate_limit(client, monkeypatch) -> None:
"""防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试,超出 429。
conftest 默认 RATE_LIMIT_ENABLED=false(内存计数跨用例累加),本用例临时打开并清空计数隔离"""
-87
View File
@@ -1,87 +0,0 @@
"""两个看板逐行「本次广告收益」(元):按 trace_id 聚合 ad_ecpm_record。"""
from __future__ import annotations
from datetime import UTC, date, datetime
from sqlalchemy import delete
from app.admin.repositories import queries
from app.admin.repositories.coupon_data import coupon_data_report, coupon_user_records
from app.db.session import SessionLocal
from app.models.ad_ecpm import AdEcpmRecord
from app.models.comparison import ComparisonRecord
from app.models.coupon_state import CouponSession
def _ecpm(trace_id: str, ecpm_raw: str, session_id: str, scene: str) -> AdEcpmRecord:
return AdEcpmRecord(
user_id=1, ad_type="draw", feed_scene=scene, ad_session_id=session_id,
ecpm_raw=ecpm_raw, trace_id=trace_id, report_date="2020-01-02",
created_at=datetime(2020, 1, 2, tzinfo=UTC),
)
def test_coupon_data_report_includes_ad_revenue() -> None:
"""领券看板明细行带本次广告收益;200+300 分 → 0.005 元。"""
db = SessionLocal()
try:
db.add(CouponSession(
trace_id="rev-cp-1", device_id="d1", status="completed", app_env="prod",
platforms=["meituan-waimai"], platform_success=["meituan-waimai"],
started_at=datetime(2020, 1, 2, tzinfo=UTC), started_date=date(2020, 1, 2),
))
db.add_all([
_ecpm("rev-cp-1", "200", "cp-sess-a", "coupon"),
_ecpm("rev-cp-1", "300", "cp-sess-b", "coupon"),
])
db.flush()
res = coupon_data_report(db, date_from="2020-01-02", date_to="2020-01-02", app_env="prod")
row = next(r for r in res["items"] if r["trace_id"] == "rev-cp-1")
assert row["ad_revenue_yuan"] == 0.005
finally:
db.rollback()
db.close()
def test_comparison_list_includes_ad_revenue() -> None:
"""比价记录列表项带本次广告收益;200 分 → 0.002 元。
用独有 user_id 过滤,确保本行必落在第一页(避免共享测试库里同 user 记录多分页把它挤掉)
"""
db = SessionLocal()
try:
db.add(ComparisonRecord(
trace_id="rev-cmp-1", user_id=987654, status="success", business_type="food",
created_at=datetime(2020, 1, 2, tzinfo=UTC),
))
db.add(_ecpm("rev-cmp-1", "200", "cmp-sess-a", "comparison"))
db.commit()
items, _next, _total = queries.list_comparison_records(db, user_id=987654)
row = next(it for it in items if it.trace_id == "rev-cmp-1")
assert row.ad_revenue_yuan == 0.002
finally:
db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.trace_id.in_(["rev-cmp-1"])))
db.execute(delete(ComparisonRecord).where(ComparisonRecord.trace_id == "rev-cmp-1"))
db.commit()
db.close()
def test_coupon_user_records_includes_ad_revenue() -> None:
"""手机号抽屉的用户领券记录也带本次广告收益(400 分 → 0.004 元)。"""
db = SessionLocal()
try:
db.add(CouponSession(
trace_id="rev-drawer-1", device_id="d2", status="completed", app_env="prod",
user_id=987655, platforms=["meituan-waimai"], platform_success=["meituan-waimai"],
started_at=datetime(2020, 1, 2, tzinfo=UTC), started_date=date(2020, 1, 2),
))
db.add(_ecpm("rev-drawer-1", "400", "drawer-sess-a", "coupon"))
db.commit()
res = coupon_user_records(db, user_id=987655)
row = next(r for r in res["items"] if r["trace_id"] == "rev-drawer-1")
assert row["ad_revenue_yuan"] == 0.004
finally:
db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.trace_id == "rev-drawer-1"))
db.execute(delete(CouponSession).where(CouponSession.trace_id == "rev-drawer-1"))
db.commit()
db.close()
-299
View File
@@ -1,299 +0,0 @@
"""领券「平台成功率」埋点:coupon_id→平台映射 / 成功平台推导 / session platform_success 并集。
设计:docs/guides/领券成功率指标-设计与埋点.md
"""
from __future__ import annotations
from datetime import UTC, date, datetime
from sqlalchemy import delete, func, select
from app.admin.repositories.coupon_data import coupon_data_report
from app.db.session import SessionLocal
from app.models.coupon_state import CouponSession
from app.repositories.coupon_state import (
coupon_id_to_platform,
merge_session_platform_success,
succeeded_platforms,
)
def _agg_session(
trace: str, platforms, platform_success, *, status: str = "completed"
) -> CouponSession:
"""构造一条聚合测试用 session(started_date 固定 2020-01-02、app_env=prod,不 commit)。"""
return CouponSession(
trace_id=trace,
device_id="d-agg",
status=status,
app_env="prod",
platforms=platforms,
platform_success=platform_success,
started_at=datetime(2020, 1, 2, tzinfo=UTC),
started_date=date(2020, 1, 2),
)
def _make_session(db, trace_id: str, **kw) -> CouponSession:
row = CouponSession(
trace_id=trace_id,
device_id="dev-merge",
status=kw.pop("status", "started"),
started_at=datetime(2020, 1, 1, tzinfo=UTC),
started_date=date(2020, 1, 1),
**kw,
)
db.add(row)
db.commit()
return row
def test_coupon_id_to_platform_prefix_mapping() -> None:
"""coupon_id 前缀 → 三档平台 id(与客户端 couponIdToPlatform 同词表)。"""
assert coupon_id_to_platform("mt_banjia_zhoumo") == "meituan-waimai"
assert coupon_id_to_platform("mt_cps_waimai_redpacket") == "meituan-waimai" # 大众点评CPS 也挂 mt_ → 归美团
assert coupon_id_to_platform("tb_vip_shangou_voucher") == "taobao-shanguang"
assert coupon_id_to_platform("ele_hongbao") == "taobao-shanguang"
assert coupon_id_to_platform("elm_hongbao") == "taobao-shanguang"
assert coupon_id_to_platform("jd_redpacket") == "jd-waimai"
def test_coupon_id_to_platform_unknown_returns_none() -> None:
"""无法识别的前缀 / 空 → None(调用方跳过,不计入平台)。"""
assert coupon_id_to_platform("weird_xxx") is None
assert coupon_id_to_platform("") is None
assert coupon_id_to_platform(None) is None # type: ignore[arg-type]
def test_succeeded_platforms_filters_status_and_dedups() -> None:
"""只取 status∈{success,already_claimed} 的券,映射平台后去重;失败/跳过/无法识别的不计。"""
results = [
{"coupon_id": "mt_a", "status": "success"},
{"coupon_id": "mt_b", "status": "already_claimed"}, # 也算成功 → 仍是美团
{"coupon_id": "mt_c", "status": "failed"}, # 不算
{"coupon_id": "tb_a", "status": "success"},
{"coupon_id": "jd_a", "status": "skipped"}, # 不算
{"coupon_id": "weird", "status": "success"}, # 无法识别平台 → 跳过
]
assert set(succeeded_platforms(results)) == {"meituan-waimai", "taobao-shanguang"}
def test_succeeded_platforms_empty() -> None:
assert succeeded_platforms([]) == []
def test_merge_platform_success_union_idempotent() -> None:
"""按 trace_id 把成功平台并入 platform_success:并集去重 + 按 DEFAULT_PLATFORMS 保序 + 重复并入幂等。"""
db = SessionLocal()
trace = "merge-union-1"
try:
_make_session(db, trace)
merge_session_platform_success(db, trace, ["meituan-waimai"])
merge_session_platform_success(db, trace, ["jd-waimai", "meituan-waimai"]) # 并集 + 已有幂等
db.expire_all()
row = db.execute(
select(CouponSession).where(CouponSession.trace_id == trace)
).scalar_one()
# 美团→淘宝→京东固定序;只含实际成功的美团/京东
assert row.platform_success == ["meituan-waimai", "jd-waimai"]
finally:
db.execute(delete(CouponSession).where(CouponSession.trace_id == trace))
db.commit()
db.close()
def test_merge_platform_success_missing_row_skips() -> None:
"""trace_id 无对应行 → 静默跳过:不建兜底行、不抛异常(设计 §5)。"""
db = SessionLocal()
try:
merge_session_platform_success(db, "no-such-trace", ["meituan-waimai"])
n = db.execute(
select(func.count()).select_from(CouponSession).where(
CouponSession.trace_id == "no-such-trace"
)
).scalar_one()
assert n == 0
finally:
db.close()
def test_coupon_data_success_rates() -> None:
"""admin 聚合 ②整单成功率 / ③点位成功率:平台粒度,含空 platforms→全领三档、abandoned 入基数。
A 勾美团+淘宝两个都成 整单成功;点位 2/2
B 勾三档只成美团 非整单;点位 1/3
C 全领(platforms空3)三档全成 整单成功;点位 3/3
D abandoned勾美团无成功平台 非整单;点位 0/1(仍进基数)
发起数=4;整单成功=2 0.5;点位 6/9 0.6667
"""
db = SessionLocal()
try:
db.add_all([
_agg_session("agg-A", ["meituan-waimai", "taobao-shanguang"],
["meituan-waimai", "taobao-shanguang"]),
_agg_session("agg-B", ["meituan-waimai", "taobao-shanguang", "jd-waimai"],
["meituan-waimai"]),
_agg_session("agg-C", [], ["meituan-waimai", "taobao-shanguang", "jd-waimai"]),
_agg_session("agg-D", ["meituan-waimai"], None, status="abandoned"),
])
db.flush() # 同会话可见,不 commit(finally 回滚保持隔离)
s = coupon_data_report(
db, date_from="2020-01-02", date_to="2020-01-02", app_env="prod"
)["summary"]
assert s["started_count"] == 4
assert s["full_success_count"] == 2
assert s["full_success_rate"] == 0.5
assert s["point_success_count"] == 6
assert s["point_total_count"] == 9
assert s["point_success_rate"] == round(6 / 9, 4)
# ③ 分平台点位成功率(§12):美团 3/4、淘宝 2/3、京东 1/2。
# 分平台(成功,总)= 美团(3,4)+淘宝(2,3)+京东(1,2) = (6,9),
# 其和正好等于上面已断言的 point_success_count=6 / point_total_count=9(和不变量)。
assert s["per_platform"] == {
"meituan-waimai": 0.75,
"taobao-shanguang": round(2 / 3, 4),
"jd-waimai": 0.5,
}
finally:
db.rollback()
db.close()
def test_coupon_data_success_rates_empty_range() -> None:
"""区间无 session → 发起数 0、两个率为 None(不除零)。"""
db = SessionLocal()
try:
s = coupon_data_report(
db, date_from="2019-01-01", date_to="2019-01-01", app_env="prod"
)["summary"]
assert s["started_count"] == 0
assert s["full_success_rate"] is None
assert s["point_success_rate"] is None
finally:
db.close()
def test_coupon_data_summary_schema_exposes_rates() -> None:
"""schema 契约:CouponDataSummary 暴露 ②③ 字段,键名与 repo 输出一致(router 直接 **summary 构造)。"""
from app.admin.schemas.coupon_data import CouponDataSummary
db = SessionLocal()
try:
db.add(_agg_session("agg-schema-1", ["meituan-waimai"], ["meituan-waimai"]))
db.flush()
summary = coupon_data_report(
db, date_from="2020-01-02", date_to="2020-01-02", app_env="prod"
)["summary"]
dumped = CouponDataSummary(**summary).model_dump()
assert dumped["full_success_rate"] == summary["full_success_rate"]
assert dumped["point_success_rate"] == summary["point_success_rate"]
assert dumped["full_success_count"] == summary["full_success_count"]
assert dumped["point_success_count"] == summary["point_success_count"]
assert dumped["point_total_count"] == summary["point_total_count"]
assert dumped["per_platform"] == summary["per_platform"]
finally:
db.rollback()
db.close()
def test_step_writes_platform_success_to_session(client) -> None:
"""/step 集成:pricebot 返 coupon_results → 本 trace 的 coupon_session.platform_success 落库(仅成功平台)。"""
from unittest.mock import MagicMock, patch
import httpx
from app.models.coupon_state import CouponClaimRecord, CouponDailyCompletion
trace = "int-trace-1"
device = "dev-int-1"
# 预置一条 started session(模拟 /session started 已先落库)
db = SessionLocal()
try:
db.add(CouponSession(
trace_id=trace, device_id=device, status="started", app_env="dev",
platforms=["meituan-waimai", "taobao-shanguang"],
started_at=datetime(2020, 1, 5, tzinfo=UTC),
started_date=date(2020, 1, 5),
))
db.commit()
finally:
db.close()
fake_resp = {
"success": True,
"action": {"command": "done", "params": {
"information": "已领 1 张",
"coupon_results": [
{"coupon_id": "mt_x", "name": "美团券", "vendor": "meituan_internal", "status": "success"},
{"coupon_id": "tb_y", "name": "淘宝券", "vendor": "taobao", "status": "failed"},
],
}},
"continue": False,
}
async def fake_post(self, url, **kw):
m = MagicMock()
m.status_code = 200
m.json = lambda: fake_resp
return m
body = {
"device_id": device, "trace_id": trace, "step": 5,
"screen_state": {"screen": {"width": 1080, "height": 2340, "density": 3.0},
"foreground": {"package": "x", "activity": ""}, "windows": []},
}
try:
with patch.object(httpx.AsyncClient, "post", fake_post):
r = client.post("/api/v1/coupon/step", json=body)
assert r.status_code == 200, r.text
db = SessionLocal()
try:
row = db.execute(
select(CouponSession).where(CouponSession.trace_id == trace)
).scalar_one()
assert row.platform_success == ["meituan-waimai"] # tb 失败不计入
claims = db.execute(
select(CouponClaimRecord).where(CouponClaimRecord.device_id == device)
).scalars().all()
assert claims and all(c.app_env == "dev" for c in claims) # session app_env 打标
finally:
db.close()
finally:
db = SessionLocal()
try:
db.execute(delete(CouponSession).where(CouponSession.trace_id == trace))
db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.device_id == device))
db.execute(delete(CouponDailyCompletion).where(CouponDailyCompletion.device_id == device))
db.commit()
finally:
db.close()
def test_coupon_data_status_filter() -> None:
"""状态多选过滤(方案 A):整个视图按选中状态算;None/空=全部。"""
db = SessionLocal()
try:
db.add_all([
_agg_session("st-A", ["meituan-waimai"], ["meituan-waimai"], status="completed"),
_agg_session("st-B", ["meituan-waimai"], ["meituan-waimai"], status="started"),
_agg_session("st-C", ["meituan-waimai"], None, status="failed"),
_agg_session("st-D", ["meituan-waimai"], ["meituan-waimai"], status="abandoned"),
])
db.flush()
base = dict(date_from="2020-01-02", date_to="2020-01-02", app_env="prod")
# None = 全部 4 发起
assert coupon_data_report(db, **base)["summary"]["started_count"] == 4
# 排除 started → 3 发起(整个视图,发起数也随之变)
sub = coupon_data_report(db, **base, statuses=["completed", "failed", "abandoned"])["summary"]
assert sub["started_count"] == 3
assert sub["completed_count"] == 1
# 只 completed → 发起数=1、整单成功率基数=1(该 completed 是整单成功)
comp = coupon_data_report(db, **base, statuses=["completed"])["summary"]
assert comp["started_count"] == 1
assert comp["full_success_rate"] == 1.0
finally:
db.rollback()
db.close()

Some files were not shown because too many files have changed in this diff Show More