Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9babc0ba3 |
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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 改动。"""
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]},
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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
@@ -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 对")
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 兜底未登记的模型。改价只影响之后回填的新记录,历史记录用当时价格快照。"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 登录。
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}>"
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}>"
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=15、today=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),
|
||||
)
|
||||
@@ -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()}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
@@ -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))
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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(测试应用)"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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 检查更新,不鉴权)。
|
||||
|
||||
|
||||
@@ -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 为空,客户端走旧逻辑)",
|
||||
)
|
||||
|
||||
|
||||
# ===== 免确认收款授权(用户授权免确认模式)=====
|
||||
|
||||
@@ -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}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
# OpenObserve 落盘数据(parquet/索引/元数据),运行时产生,不入库。
|
||||
data/
|
||||
# 生产 compose 的密码文件(OO_ROOT_PASSWORD),含机密,不入库。
|
||||
.env
|
||||
@@ -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 UI:http://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" }
|
||||
@@ -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
|
||||
@@ -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` 即可一键收回退出按钮(审核通过后无需发版)。
|
||||
@@ -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) |
|
||||
|
||||
### 比价 / 省钱
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|
||||
@@ -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`(不给退出按钮) |
|
||||
|
||||
@@ -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 集合` 现挂到实例上供出参用。
|
||||
|
||||
@@ -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` | JSON(PG 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 链接;未到 done(failed/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 漏跑数天后某用户可能同时满足多档,只补发**最紧急的未推档**(最小提前天数),避免刷屏。
|
||||
@@ -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 A(App 内发起)/ 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 延后;**已在 §12(2026-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、OpenObserve(Docker)。
|
||||
|
||||
参考 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: PASS(4 个中间件用例全过)
|
||||
|
||||
> 若 `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 UI:http://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: 稍等 ≤5s(flush 间隔),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 3;worker→Task 4;配置→Task 1;main 接线→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-server(8770);admin(8771)暂不接入
|
||||
- **方案**: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_DAYS−1)) # 供 SQL 比较
|
||||
inactive_days = (cn_today() − last_active_date).days # 清零当日恰 = RESET_DAYS
|
||||
例:末次活跃 1/1 → 1/16 00:00(第16日0点)清零,当日 inactive_days=15;1/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_days−1))`,供下面查询按 `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` —— 预警记录 + 去重 + 占位 outbox(R3)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `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_DAYS−1))
|
||||
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_DAYS−k)` 触发(漏跑某天时补发最紧急未推档,§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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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「比价记录」→ 找 trace「MOCKLLM-」的 5 条 → 点开详情看「LLM 成本」:
|
||||
①②③⑤ 显示「实际·当时价」+ 价格快照;④ 显示「估算」。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.user import User
|
||||
from app.repositories import app_config
|
||||
from app.services.llm_cost import compute_llm_cost
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8") # Windows 控制台输出中文/¥
|
||||
|
||||
_BJ = timezone(timedelta(hours=8))
|
||||
ID_PREFIX = "MOCKLLM-"
|
||||
|
||||
# ── 写进 app_config 的模型单价(get_llm_prices 读它;配置页「LLM 成本」卡片可再改)──
|
||||
PRICE_CFG = {
|
||||
"per_model": {
|
||||
"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0},
|
||||
"qwen3.5-plus": {"input_per_1m": 4.0, "output_per_1m": 12.0},
|
||||
},
|
||||
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0},
|
||||
"currency": "CNY",
|
||||
"unit": "per_1m_tokens",
|
||||
}
|
||||
|
||||
|
||||
def _c(scene: str, model: str, pin: int, cout: int, error: str | None = None) -> dict:
|
||||
"""一条 llm_calls 明细,结构对齐真实 pricebot 归一后契约:
|
||||
{scene, model, input_messages:[{role,content}], output, usage:{prompt/completion/total_tokens},
|
||||
latency_ms, error}(详情抽屉会遍历 input_messages,缺了会崩)。error 的调用无 usage/output。"""
|
||||
return {
|
||||
"scene": scene,
|
||||
"model": model,
|
||||
"error": error,
|
||||
"input_messages": [
|
||||
{"role": "system", "content": f"你是比价助手,负责 {scene} 环节。"},
|
||||
{"role": "user", "content": f"[mock] 请处理本次比价的 {scene} 任务。"},
|
||||
],
|
||||
"output": None if error else f"[mock] {scene} 环节完成。",
|
||||
"usage": None if error else {
|
||||
"prompt_tokens": pin, "completion_tokens": cout, "total_tokens": pin + cout,
|
||||
},
|
||||
"latency_ms": 780,
|
||||
}
|
||||
|
||||
|
||||
# ── 5 条记录蓝本:calls 决定成本;freeze=False 模拟旧记录(有 token 无 cost)──
|
||||
RECORDS = [
|
||||
{
|
||||
"label": "①单模型·真实样本",
|
||||
"source": ("美团外卖", 4280), "best": ("京东秒送", 3680),
|
||||
"store": "肯德基(建国路店)", "product": "疯狂星期四全家桶",
|
||||
"info": "在京东秒送找到同款,到手价 ¥36.80,省 ¥6.00",
|
||||
"freeze": True,
|
||||
"calls": [
|
||||
_c("store_match", "qwen3.5-flash", 1512, 22),
|
||||
_c("dish_match", "qwen3.5-flash", 2111, 160),
|
||||
_c("dish_match", "qwen3.5-flash", 1940, 142),
|
||||
_c("summary", "qwen3.5-flash", 1325, 13),
|
||||
],
|
||||
},
|
||||
{
|
||||
"label": "②多模型·flash+plus",
|
||||
"source": ("淘宝闪购", 5900), "best": ("美团外卖", 5200),
|
||||
"store": "瑞幸咖啡(国贸店)", "product": "生椰拿铁×2、丝绒拿铁",
|
||||
"info": "在美团外卖找到同款,到手价 ¥52.00,省 ¥7.00",
|
||||
"freeze": True,
|
||||
"calls": [
|
||||
_c("store_match", "qwen3.5-flash", 2000, 50),
|
||||
_c("dish_match", "qwen3.5-flash", 1800, 40),
|
||||
_c("reasoning", "qwen3.5-plus", 3000, 500),
|
||||
],
|
||||
},
|
||||
{
|
||||
"label": "③未登记模型走 default",
|
||||
"source": ("京东秒送", 3100), "best": ("美团外卖", 2650),
|
||||
"store": "麦当劳(soho店)", "product": "麦辣鸡腿堡套餐",
|
||||
"info": "在美团外卖找到同款,到手价 ¥26.50,省 ¥4.50",
|
||||
"freeze": True,
|
||||
"calls": [
|
||||
_c("store_match", "deepseek-v3", 5000, 800),
|
||||
],
|
||||
},
|
||||
{
|
||||
"label": "④旧记录·有token无成本(回退估算)",
|
||||
"source": ("美团外卖", 3600), "best": ("淘宝闪购", 3200),
|
||||
"store": "华莱士(双井店)", "product": "全鸡汉堡套餐",
|
||||
"info": "在淘宝闪购找到同款,到手价 ¥32.00,省 ¥4.00",
|
||||
"freeze": False, # 模拟本需求上线前的老记录:llm_cost_yuan=NULL → 前端回退估算
|
||||
"calls": [
|
||||
_c("store_match", "qwen3.5-flash", 2000, 100),
|
||||
],
|
||||
},
|
||||
{
|
||||
"label": "⑤含 error 调用(跳过计费)",
|
||||
"source": ("淘宝闪购", 4100), "best": ("京东秒送", 3750),
|
||||
"store": "海底捞(合生汇店)", "product": "番茄锅底、肥牛卷",
|
||||
"info": "在京东秒送找到同款,到手价 ¥37.50,省 ¥3.50",
|
||||
"freeze": True,
|
||||
"calls": [
|
||||
_c("store_match", "qwen3.5-flash", 0, 0, error="timeout"),
|
||||
_c("store_match", "qwen3.5-flash", 1500, 30),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
_PLATFORM_ID = { # 展示名 → 平台代号(comparison_results / source/best 列用)
|
||||
"美团外卖": "meituan", "京东秒送": "jd", "淘宝闪购": "taobao",
|
||||
}
|
||||
|
||||
|
||||
def _naive_bj_now() -> datetime:
|
||||
return datetime.now(_BJ).replace(tzinfo=None)
|
||||
|
||||
|
||||
def clean(db) -> int:
|
||||
n = db.execute(
|
||||
delete(ComparisonRecord).where(ComparisonRecord.trace_id.like(f"{ID_PREFIX}%"))
|
||||
).rowcount or 0
|
||||
db.commit()
|
||||
return n
|
||||
|
||||
|
||||
def _build_record(spec: dict, owner_id: int | None, created_at: datetime) -> tuple[ComparisonRecord, float | None]:
|
||||
"""按蓝本造一条记录,LLM 派生完全对齐 _backfill_llm_calls;返回 (记录, 冻结成本或 None)。"""
|
||||
calls = spec["calls"]
|
||||
src_name, src_cents = spec["source"]
|
||||
best_name, best_cents = spec["best"]
|
||||
|
||||
# —— 与 _backfill_llm_calls 同款派生 ——
|
||||
llm_call_count = len(calls)
|
||||
retry_count = sum(1 for c in calls if c.get("error"))
|
||||
input_tokens = sum((c.get("usage") or {}).get("prompt_tokens") or 0 for c in calls)
|
||||
output_tokens = sum((c.get("usage") or {}).get("completion_tokens") or 0 for c in calls)
|
||||
if spec["freeze"]:
|
||||
cost, snapshot = compute_llm_cost(calls, PRICE_CFG) # 复用生产纯函数
|
||||
else:
|
||||
cost, snapshot = None, None # 旧记录:回填这段代码上线前就有,只有 token 没成本
|
||||
|
||||
rec = ComparisonRecord(
|
||||
user_id=owner_id,
|
||||
device_id=f"{ID_PREFIX.lower()}dev",
|
||||
business_type="food",
|
||||
trace_id=f"{ID_PREFIX}{spec['label'][0]}", # ①..⑤ 各一,唯一
|
||||
status="success",
|
||||
source_platform_id=_PLATFORM_ID.get(src_name), source_platform_name=src_name,
|
||||
source_price_cents=src_cents,
|
||||
best_platform_id=_PLATFORM_ID.get(best_name), best_platform_name=best_name,
|
||||
best_price_cents=best_cents,
|
||||
saved_amount_cents=src_cents - best_cents,
|
||||
is_source_best=False,
|
||||
store_name=spec["store"],
|
||||
product_names=spec["product"],
|
||||
information=spec["info"],
|
||||
items=[{"name": spec["product"], "qty": 1}],
|
||||
comparison_results=[
|
||||
{"platform_id": _PLATFORM_ID.get(src_name), "platform_name": src_name,
|
||||
"price": src_cents / 100, "is_source": True, "rank": 2},
|
||||
{"platform_id": _PLATFORM_ID.get(best_name), "platform_name": best_name,
|
||||
"price": best_cents / 100, "is_source": False, "rank": 1},
|
||||
],
|
||||
total_ms=90_000 + llm_call_count * 1000,
|
||||
step_count=llm_call_count * 3,
|
||||
llm_call_count=llm_call_count,
|
||||
retry_count=retry_count,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
llm_calls=calls,
|
||||
llm_cost_yuan=cost,
|
||||
llm_price_snapshot=snapshot,
|
||||
created_at=created_at,
|
||||
)
|
||||
return rec, cost
|
||||
|
||||
|
||||
def seed(db) -> list[tuple[str, float | None]]:
|
||||
app_config.set_value(db, "llm_token_price", PRICE_CFG, admin_id=None) # upsert 单价
|
||||
owner_id = db.execute(select(User.id).order_by(User.id).limit(1)).scalar()
|
||||
base = _naive_bj_now()
|
||||
out: list[tuple[str, float | None]] = []
|
||||
for i, spec in enumerate(RECORDS):
|
||||
rec, cost = _build_record(spec, owner_id, base - timedelta(minutes=i * 3))
|
||||
db.add(rec)
|
||||
out.append((spec["label"], cost))
|
||||
db.commit()
|
||||
return out, owner_id
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="造带 LLM 成本的比价记录 + app_config 模型单价(测管理后端)")
|
||||
parser.add_argument("--clean-only", action="store_true", help="只清 MOCKLLM- 记录,不重建(保留单价)")
|
||||
args = parser.parse_args()
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
removed = clean(db)
|
||||
if removed:
|
||||
print(f"🧹 已清理旧 mock 记录 {removed} 条")
|
||||
if args.clean_only:
|
||||
print("✅ 仅清理,已完成(app_config 单价保留)。")
|
||||
return
|
||||
|
||||
results, owner_id = seed(db)
|
||||
print(f"\n✅ 已写入 app_config.llm_token_price(单价)+ {len(results)} 条比价记录"
|
||||
f"(挂 user_id={owner_id or 'NULL(孤儿行)'})")
|
||||
print("\n📋 每条冻结成本(admin 详情「LLM 成本」应显示):")
|
||||
for label, cost in results:
|
||||
shown = "NULL → 前端回退「估算」" if cost is None else f"¥{cost}"
|
||||
print(f" {label:<20} {shown}")
|
||||
print("\n👉 验收:admin「比价记录」→ trace 搜「MOCKLLM-」→ 点开详情核对 LLM 成本 + 价格快照。")
|
||||
print(" 配置页「系统配置」→「福利页」Tab →「LLM 成本」卡片,单价应为「已改」态。")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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(内存计数跨用例累加),本用例临时打开并清空计数隔离。"""
|
||||
|
||||
@@ -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()
|
||||
@@ -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
Reference in New Issue
Block a user