Compare commits

...

14 Commits

Author SHA1 Message Date
xiebing 2ed62c789f feat(h5): 我的页改 H5(mine 页 + shared bridge/api) (#89)
- h5/mine/index.html: 个人中心「我的页」H5 实现
- h5/shared/bridge.js, api.js: H5↔客户端桥与 API 封装(mine 依赖)

Reviewed-on: #89
Co-authored-by: xiebing <xiebing@wonderable.ai>
Co-committed-by: xiebing <xiebing@wonderable.ai>
2026-06-28 14:16:32 +08:00
chenshuobo 1548406f29 美团 feed 改用离线库 + 三 tab 降级兜底(替换 main 的实时版 feed) (#88)
Co-authored-by: lowmaster-chen <1119780489@qq.com>
Co-authored-by: chenshuobo <1119780489@qq.com>
Reviewed-on: #88
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
2026-06-28 10:38:31 +08:00
guke b7b958ed58 perf(pricebot): 透传复用共享 httpx 单例,免每请求重建 SSL 上下文 + 绕过进程代理 (#87)
coupon/compare 透传原先每请求 async with httpx.AsyncClient(...) 新建:
每次重建 SSL 上下文(加载 certifi CA)实测 ~1s+,而 pricebot 是纯 HTTP
透传根本用不到 TLS;且 trust_env 默认 True 会把 http://localhost:8000
经进程代理(Clash)再绕几秒。

抽 app/core/pricebot_client.py 共享单例:trust_env=False 直连,lifespan
启动预热(SSL 一次性成本付在启动)、关停 aclose;timeout 下放到 .post()
保留 coupon 30s / compare 60s 差异。keep-alive 复用 TCP,每帧降到个位数 ms。

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #87
Co-authored-by: guke <guke@wonderable.ai>
Co-committed-by: guke <guke@wonderable.ai>
2026-06-28 09:36:31 +08:00
wuqi f3cd97a190 feat: 信息流广告改"所见即所得"发奖——直接发客户端小球显示金币 (#86)
Reviewed-on: #86
Co-authored-by: wuqi <wuqi@wonderable.ai>
Co-committed-by: wuqi <wuqi@wonderable.ai>
2026-06-27 23:49:51 +08:00
zhuzihao b4c27f4d88 fix(ad): eCPM 上报撞会话唯一约束不再抛 500,按全局口径兜底去重 (#85)
create_ecpm_record 撞 uq_ad_ecpm_record_session(只含 ad_session_id、全局唯一)
时,改用与约束同口径的全局查找 _find_by_session_global(仅按 ad_session_id)兜底,
并去掉末尾的 raise。原逻辑兜底用带 user_id 的 find_by_session:不同 user 上报了
同一 ad_session_id 时二次查找仍为 None → raise → 接口 500,违背该接口
best-effort / fire-and-forget 约定(丢一两条不影响业务,穿山甲后台才是结算权威)。
find_by_session 签名与行为不变,另两个调用方(ad_reward、ad.py)不受影响。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #85
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-27 22:42:58 +08:00
zhuzihao e38120ad49 feat(ad-revenue): 收益报表分页/场景筛选/倒序,并修复信息流金币审计复算口径+ 大盘改版 (#84)
收益报表(/admin/api/ad-revenue-report):
- 明细改按时间倒序;新增 offset 真分页(limit 作每页大小、total 为全量),可翻页看当前筛选下全部数据,突破原 1000 条上限。
- 「场景」(feed_scene)下推后端做全局筛选,同时作用于明细/合计/daily·hourly 趋势(原为前端仅过滤明细)。
- 新增全量 hourly 序列,按小时趋势改用它,不再受分页截断影响。

修复信息流金币审计复算口径漂移(ad_audit):
- 发奖侧 grant_feed_reward 早已是「一条广告=1份、LT 按账号累计条数(COUNT)」,但审计仍按 unit_count 逐份累加 + SUM(unit_count) 做 LT 基线,导致单条停留>20s(份数>1)时应发虚高、必然「✗ 不符」。
- 审计改为每条 granted 按 1 份复算、LT 基线用 COUNT,与发奖对齐;金币审计页与收益报表(复用同一复算)一并恢复正确。纯复算口径修正,不改实际发奖、不动钱。

文档同步更新 admin-ad-revenue-report.md。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #84
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-27 22:42:54 +08:00
marco 3d67749101 fix(compare-record): specs 兼容 pricebot 嵌套规格对象
pricebot 嵌套规格统一(2026-06-27)后 calibration.specs 可能是规格对象
[{name, qty, sub_specs}] 而非字符串。ComparisonItemIn.specs 加 before-validator
把对象拍平成可读字符串"主项(子1,子2)",兼容新旧输入、保持 list[str] 契约(下游零改动)。
防两坑:① 直接声明 list[str] → 对象 specs 整条 422 被拒、不入库;
② 仅放宽成裸 list → 下游 join 出"[object Object]"乱码。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 09:26:00 +08:00
marco f1c2ea662b fix(migration): merge alembic 3-way 分叉为单 head(修本地 upgrade head 报 Multiple head revisions)
#82(invite_cash)/埋点(c2874d2bf705)/device_first_protected_at 三个 PR 并行合 main 留下 3 个 head,
`alembic upgrade head`(单数)报 Multiple head revisions。加一个空 merge 迁移把三支并成单 head,
deploy 的 `upgrade heads`(复数)和本地 `upgrade head` 都干净。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 03:06:53 +08:00
marco be15a119a7 fix(migration): 缩短 revision id 至 26 字符(修 alembic_version varchar(32) 截断致 0.2.1 部署失败)
invite_cash_account_and_compare_reward(38字符) > alembic_version.version_num varchar(32),
INSERT 时 psycopg StringDataRightTruncation → 部署 alembic 迁移失败。改为 invite_cash_compare_reward(26)。
该迁移是链末端(无后续 down_revision 依赖)、首次部署(任何环境都没应用过、PG DDL 事务失败已回滚无残留), 改 id 安全。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 02:58:29 +08:00
zhuzihao 59fe715245 feat(analytics): 埋点事件接收接口 + admin 查询接口 + 埋点表 (#83)
Reviewed-on: #83
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-26 23:56:29 +08:00
zhuzihao 667cda566f feat(analytics): 埋点事件接收接口 + admin 查询接口 + 埋点表 (#81)
新手引导埋点的服务端:
- 表 analytics_event(五维硬性列 + props JSON 扩展字段;event/device_id/user_id/session_id/created_at 带索引)
- POST /api/v1/analytics/events:客户端批量上报(不鉴权、body 读可选 user_id、补 client_ip + server_at)
- admin GET /admin/api/event-logs:列表 + 按 事件/设备/用户/会话/时间 筛选(offset 分页,照 list_feedbacks)
- alembic migration 建表(autogenerate 顺带检出的 ad/cps 历史索引漂移已手动剔除)

app 主后端 :8770 与 admin :8771 共用同一 SQLite,admin 同库直接查、无需跨库。
配套客户端五维上报 + admin 日志页(另两仓库 PR)。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #81
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-26 23:33:31 +08:00
xiebing 19f5987436 feat(invite): 邀请奖励金账户(与金币隔离) + 比价发奖 + 奖励金提现 (#82)
Reviewed-on: #82
Co-authored-by: xiebing <xiebing@wonderable.ai>
Co-committed-by: xiebing <xiebing@wonderable.ai>
2026-06-26 21:10:21 +08:00
chenshirui 1d6432d8bb feat(admin): 设备存活监控接口 + 首次无障碍开启时间(first_protected_at) (#80)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------

Co-authored-by: 陈世睿 <2839904623@qq.com>
Reviewed-on: #80
Co-authored-by: chenshirui <chenshirui@wonderable.ai>
Co-committed-by: chenshirui <chenshirui@wonderable.ai>
2026-06-26 15:28:16 +08:00
guke 2eb44fe947 feat(cps): 每日明细按天按用户领券下钻接口 + /daily date 改全日期 (#79)
- 新增 GET /admin/api/cps/groups/{id}/day-users?date=YYYY-MM-DD:
  按 openid 聚合当天领券(copy)/点击(visit) + 每人 visit 过的券(券×次数)

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #79
2026-06-26 15:17:37 +08:00
71 changed files with 10890 additions and 338 deletions
@@ -0,0 +1,65 @@
"""add analytics_event table
Revision ID: 1699fc2c069f
Revises: bcfcaf07152b
Create Date: 2026-06-26 16:35:16.133975
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '1699fc2c069f'
down_revision: str | Sequence[str] | None = 'bcfcaf07152b'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('analytics_event',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('event', sa.String(length=64), nullable=False),
sa.Column('props', sa.JSON(), nullable=True),
sa.Column('device_id', sa.String(length=64), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('session_id', sa.String(length=64), nullable=True),
sa.Column('client_ts', sa.BigInteger(), nullable=False),
sa.Column('sent_at', sa.BigInteger(), nullable=True),
sa.Column('page', sa.String(length=64), nullable=True),
sa.Column('client_ip', sa.String(length=64), nullable=True),
sa.Column('oem', sa.String(length=32), nullable=True),
sa.Column('os', sa.String(length=32), nullable=True),
sa.Column('model', sa.String(length=64), nullable=True),
sa.Column('app_ver', sa.String(length=32), nullable=True),
sa.Column('network', sa.String(length=16), nullable=True),
sa.Column('channel', sa.String(length=32), 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_event', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_analytics_event_created_at'), ['created_at'], unique=False)
batch_op.create_index(batch_op.f('ix_analytics_event_device_id'), ['device_id'], unique=False)
batch_op.create_index(batch_op.f('ix_analytics_event_event'), ['event'], unique=False)
batch_op.create_index(batch_op.f('ix_analytics_event_session_id'), ['session_id'], unique=False)
batch_op.create_index(batch_op.f('ix_analytics_event_user_id'), ['user_id'], unique=False)
# 注:autogenerate 顺带检出 ad_ecpm/cps_*/invite_fingerprint 的历史索引漂移,
# 与本次「新增埋点表」无关,已手动移除,避免本迁移误改他人表。
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('analytics_event', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_analytics_event_user_id'))
batch_op.drop_index(batch_op.f('ix_analytics_event_session_id'))
batch_op.drop_index(batch_op.f('ix_analytics_event_event'))
batch_op.drop_index(batch_op.f('ix_analytics_event_device_id'))
batch_op.drop_index(batch_op.f('ix_analytics_event_created_at'))
op.drop_table('analytics_event')
# ### end Alembic commands ###
@@ -0,0 +1,26 @@
"""merge invite_cash + analytics + device heads
Revision ID: 7db22acee504
Revises: c2874d2bf705, device_first_protected_at, invite_cash_compare_reward
Create Date: 2026-06-27 03:06:52.594401
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7db22acee504'
down_revision: Union[str, Sequence[str], None] = ('c2874d2bf705', 'device_first_protected_at', 'invite_cash_compare_reward')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,46 @@
"""add ad_type to ad_feed_reward and feed_scene to ad_ecpm
把信息流广告全面改造成 Draw 信息流(draw):
- ad_feed_reward_record.ad_type:广告形态 feed(信息流) / draw(Draw 信息流)。可空,旧数据 NULL
一律视为 feed(向后兼容);每日上限与因子2(LT)仍按本表全表 unit 累计,不按 ad_type 拆。
- ad_ecpm_record.feed_scene:点位场景 comparison(比价) / coupon(领券) / welfare(福利),供广告
收益报表区分比价/领券 Draw 收益;仅信息流/Draw 上报,激励视频为 NULL。
注:autogenerate 会顺带探测到 analytics_event / cps_* / invite_fingerprint 等无关索引差异(本地库与
metadata 漂移、analytics 模型尚未并入 __init__),与本次改动无关,已手工剔除——本迁移只 add 两列。
SQLite 经 env.py 的 render_as_batch 自动走 batch_alter_table 重建表。
Revision ID: c2874d2bf705
Revises: 1699fc2c069f
Create Date: 2026-06-26 16:48:52.158609
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c2874d2bf705"
down_revision: str | Sequence[str] | None = "1699fc2c069f"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# 信息流发奖记录:新增广告形态 feed/draw(可空,旧数据 NULL=feed)
op.add_column(
"ad_feed_reward_record",
sa.Column("ad_type", sa.String(length=16), nullable=True),
)
# eCPM 展示上报:新增点位场景 comparison/coupon/welfare(可空,激励视频/旧数据 NULL)
op.add_column(
"ad_ecpm_record",
sa.Column("feed_scene", sa.String(length=16), nullable=True),
)
def downgrade() -> None:
op.drop_column("ad_ecpm_record", "feed_scene")
op.drop_column("ad_feed_reward_record", "ad_type")
@@ -0,0 +1,31 @@
"""device_liveness 加 first_protected_at(首次开无障碍时刻)
Revision ID: device_first_protected_at
Revises: bcfcaf07152b
Create Date: 2026-06-25 00:00:00.000000
admin 设备存活页要展示「首次无障碍开启时间」。touch_heartbeat 在 ever_protected 首次翻 true
时记一次(后续心跳不覆盖)。仅新增可空列,SQLite 原生支持 add_column、不用 batch;downgrade
的 drop_column 在 SQLite 走 batch_alter_table 兜底。老设备无此时刻 → 留 NULL(无法准确回填)。
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "device_first_protected_at"
down_revision: Union[str, Sequence[str], None] = "bcfcaf07152b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"device_liveness",
sa.Column("first_protected_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
with op.batch_alter_table("device_liveness") as batch_op:
batch_op.drop_column("first_protected_at")
@@ -0,0 +1,88 @@
"""invite cash account isolation + compare reward tracking (🅱-1)
Revision ID: invite_cash_compare_reward
Revises: feedback_review_fields
Create Date: 2026-06-23 00:00:00.000000
邀请功能 v2 账户隔离 + 比价发奖追踪:
- coin_account 加 invite_cash_balance_cents(邀请奖励金独立余额,与金币兑换的 cash 物理隔离)
- withdraw_order 加 source(标记提现扣哪个账户,退款退回对应账户;旧单默认 coin_cash)
- invite_relation 加比价发奖追踪三列(好友比价多次只发一次)
- 新增 invite_cash_transaction 表(邀请奖励金独立流水账本)
加列均 NOT NULL + server_default,存量行自动填默认值,安全。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'invite_cash_compare_reward'
down_revision: Union[str, Sequence[str], None] = 'feedback_review_fields'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. 邀请奖励金独立余额(与金币兑换的 cash_balance_cents 物理隔离;红线:两本账不可累加)
op.add_column(
'coin_account',
sa.Column('invite_cash_balance_cents', sa.Integer(), nullable=False, server_default='0'),
)
# 2. 提现单标记账户来源:coin_cash / invite_cash,退款退回对应账户(旧单默认 coin_cash)
op.add_column(
'withdraw_order',
sa.Column('source', sa.String(length=16), nullable=False, server_default='coin_cash'),
)
# 3. 邀请关系加比价发奖追踪(好友"下载+登录+比价一次"→ 给邀请人发奖,只发一次)
op.add_column(
'invite_relation',
sa.Column('compare_reward_granted', sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.add_column(
'invite_relation',
sa.Column('compare_reward_cents', sa.Integer(), nullable=False, server_default='0'),
)
op.add_column(
'invite_relation',
sa.Column('compare_rewarded_at', sa.DateTime(timezone=True), nullable=True),
)
# 4. 邀请奖励金独立流水表(结构同 cash_transaction;balance_after 记 invite_cash_balance_cents)
op.create_table(
'invite_cash_transaction',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('amount_cents', sa.Integer(), nullable=False),
sa.Column('balance_after_cents', sa.Integer(), nullable=False),
sa.Column('biz_type', sa.String(length=32), nullable=False),
sa.Column('ref_id', sa.String(length=64), nullable=True),
sa.Column('remark', sa.String(length=128), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id']),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_invite_cash_transaction_user_id', 'invite_cash_transaction', ['user_id'])
op.create_index('ix_invite_cash_transaction_created_at', 'invite_cash_transaction', ['created_at'])
# 提现退款幂等:一个提现单只退一次(partial unique,对齐 cash_transaction 的 withdraw_refund 去重)
op.create_index(
'ux_invite_cash_txn_refund_ref',
'invite_cash_transaction',
['ref_id'],
unique=True,
sqlite_where=sa.text("biz_type = 'invite_withdraw_refund' AND ref_id IS NOT NULL"),
postgresql_where=sa.text("biz_type = 'invite_withdraw_refund' AND ref_id IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index('ux_invite_cash_txn_refund_ref', table_name='invite_cash_transaction')
op.drop_index('ix_invite_cash_transaction_created_at', table_name='invite_cash_transaction')
op.drop_index('ix_invite_cash_transaction_user_id', table_name='invite_cash_transaction')
op.drop_table('invite_cash_transaction')
op.drop_column('invite_relation', 'compare_rewarded_at')
op.drop_column('invite_relation', 'compare_reward_cents')
op.drop_column('invite_relation', 'compare_reward_granted')
op.drop_column('withdraw_order', 'source')
op.drop_column('coin_account', 'invite_cash_balance_cents')
+4
View File
@@ -23,7 +23,9 @@ from app.admin.routers.comparison import router as comparison_router
from app.admin.routers.config import router as config_router
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.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.onboarding import router as onboarding_router
@@ -83,6 +85,7 @@ def health() -> dict[str, str]:
admin_app.include_router(auth_router)
admin_app.include_router(dashboard_router)
admin_app.include_router(device_liveness_router)
admin_app.include_router(ops_stat_config_router)
admin_app.include_router(ops_marquee_seed_router)
admin_app.include_router(users_router)
@@ -91,6 +94,7 @@ admin_app.include_router(wallet_router)
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(feedback_qr_router)
admin_app.include_router(admins_router)
admin_app.include_router(audit_router)
+61 -27
View File
@@ -4,9 +4,9 @@
- 看视频:每条 granted = 1 份,第 N 份 = 该用户 granted 的 reward_video **账号累计**顺序号
(与 ad_reward.grant_ad_reward 里 `_granted_cumulative + 1` 一致;LT 因子不按天重置,
故复算时要把当日序号叠加上该用户在本日**之前**的累计已发份数)。
- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 该用户 granted 份数**账号累计**
(与 ad_feed_reward._unit_reward_total 的 existing_units 一致;同样不按天重置,
复算需叠加本日之前的累计数)。
- 信息流:**每条 granted = 1 份**(与 ad_feed_reward.grant_feed_reward 同口径:看满一份即发该条
满额,**不按 unit_count 逐份累加**),LT 序号 = 该用户 granted **条数**账号累计
(与 ad_feed_reward.granted_unit_total 的 COUNT 一致;不按天重置,复算需叠加本日之前的累计数)。
非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。
"""
@@ -108,14 +108,18 @@ def _reward_video_rows(
return rows
def _feed_prior_granted_units(
def _feed_prior_granted_count(
db: Session, *, date: str, user_id: int | None
) -> dict[int, int]:
"""各用户在 date **之前** granted 的信息流份数累计,作为当日复算的 LT 序号起点。"""
"""各用户在 date **之前** granted 的信息流**条数**累计,作为当日复算的 LT 序号起点。
与发奖侧 ad_feed_reward.granted_unit_total(COUNT status=granted)对齐:一条广告 = 1 份,
LT 按账号累计**条数**递进。**不再用 SUM(unit_count)**——那是「一条按时长折多份」的过时口径,
与现行发奖(每条 1 份)漂移,会让 unit_count>1 的记录复算虚高、对账恒「不符」。"""
stmt = (
select(
AdFeedRewardRecord.user_id,
func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0),
func.count(),
)
.where(
AdFeedRewardRecord.reward_date < date,
@@ -128,8 +132,29 @@ def _feed_prior_granted_units(
return {uid: int(n) for uid, n in db.execute(stmt).all()}
def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
"""信息流记录复算。granted 记录逐份累加,LT 序号沿用账号累计份数(含本日之前)。"""
def _feed_scene_matches(rec: AdFeedRewardRecord, scene: str | None) -> bool:
"""信息流记录是否落入请求的展示筛选 scene。
- scene=="feed":ad_type in ("feed", NULL)(旧数据 NULL 视为 feed,向后兼容)
- scene=="draw":ad_type=="draw"
- scene 为 None:不筛(两类都要)。
"""
if scene == "feed":
return rec.ad_type in (None, "feed")
if scene == "draw":
return rec.ad_type == "draw"
return True
def _feed_rows(
db: Session, *, date: str, user_id: int | None, scene: str | None = None
) -> list[dict]:
"""信息流记录复算。**每条 granted = 1 份**(与发奖同口径,不按 unit_count 累加),
LT 序号沿用账号累计**条数**(含本日之前)。
**关键:LT 因子账号累计按全表 granted 条数累计(feed+draw 共享同一发奖池/上限),不按 ad_type 拆分**——
故无论 scene 怎么筛展示,这里都遍历当日**全部**信息流记录维持 granted_count 累加;scene 只决定
哪些行被**留下展示**(由 _feed_scene_matches 判断),不影响累计基线,保证复算序号与正式发奖一致。
"""
stmt = (
select(AdFeedRewardRecord)
.where(AdFeedRewardRecord.reward_date == date)
@@ -138,22 +163,24 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
if user_id is not None:
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
# 本日之前的累计份数做起点,与 _unit_reward_total 的 existing_units(累计)对齐
granted_units: dict[int, int] = _feed_prior_granted_units(db, date=date, user_id=user_id)
# 本日之前的累计**条数**做起点,与发奖侧 granted_unit_total(COUNT granted)对齐
granted_count: dict[int, int] = _feed_prior_granted_count(db, date=date, user_id=user_id)
rows: list[dict] = []
for rec in db.execute(stmt).scalars():
keep = _feed_scene_matches(rec, scene) # 累计照常推进,这里只决定是否展示本行
if rec.status == "granted":
existing = granted_units.get(rec.user_id, 0)
units = rec.unit_count
expected = sum(
rewards.calculate_ad_reward_coin(rec.ecpm_raw, existing + offset)
for offset in range(1, units + 1)
)
granted_units[rec.user_id] = existing + units
start = existing + 1 if units > 0 else None
end = existing + units if units > 0 else None
# 一条广告 = 1 份(与 grant_feed_reward 同口径:看满一份即发该条满额,不按 unit_count 累加)。
# nth = 账号累计第几**条**(含本日之前),与发奖侧 granted_unit_total+1 对齐;累计照常推进
# (即便 scene 不匹配不展示也要 +1,保证序号与正式发奖一致)。
nth = granted_count.get(rec.user_id, 0) + 1
granted_count[rec.user_id] = nth
if not keep:
continue
expected = rewards.calculate_ad_reward_coin(rec.ecpm_raw, nth)
rows.append({
"scene": "feed",
"ad_type": rec.ad_type or "feed",
"feed_scene": rec.feed_scene,
"record_id": rec.id,
"user_id": rec.user_id,
"ad_session_id": rec.ad_session_id,
@@ -163,18 +190,22 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
"status": rec.status,
"ecpm": rec.ecpm_raw,
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
"units": units,
"lt_index_start": start,
"lt_index_end": end,
"lt_factor_start": rewards.ad_lt_factor(start) if start else None,
"lt_factor_end": rewards.ad_lt_factor(end) if end else None,
"units": 1,
"lt_index_start": nth,
"lt_index_end": nth,
"lt_factor_start": rewards.ad_lt_factor(nth),
"lt_factor_end": rewards.ad_lt_factor(nth),
"expected_coin": expected,
"actual_coin": rec.coin,
"matched": expected == rec.coin,
})
else:
if not keep:
continue
rows.append({
"scene": "feed",
"ad_type": rec.ad_type or "feed",
"feed_scene": rec.feed_scene,
"record_id": rec.id,
"user_id": rec.user_id,
"ad_session_id": rec.ad_session_id,
@@ -199,16 +230,19 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
def audit_rows(
db: Session, *, date: str, user_id: int | None, scene: str | None = None
) -> list[dict]:
"""当日逐条发奖复算行(未排序)。scene: None=两类 / "reward_video" / "feed"
"""当日逐条发奖复算行(未排序)。scene: None=两类 / "reward_video" / "feed" / "draw"
"feed""draw" 都查 ad_feed_reward_record(同一发奖表),按 ad_type 区分:feed 含历史 NULL,
draw 仅 ad_type=="draw"。信息流行额外带 `ad_type`/`feed_scene`,供收益报表区分比价/领券 Draw 收益。
每行含 `app_env`/`our_code_id`/`expected_coin`/`actual_coin` 等,供金币审计逐条对账,
也供广告收益报表把「应发/实发」按 用户×类型×应用×代码位 聚合(见 ad_revenue,复用同一复算口径)。
**LT 因子账号累计仍按全表 unit 累计(feed+draw 共享),scene 只筛展示,不拆累计。**
"""
rows: list[dict] = []
if scene in (None, "reward_video"):
rows.extend(_reward_video_rows(db, date=date, user_id=user_id))
if scene in (None, "feed"):
rows.extend(_feed_rows(db, date=date, user_id=user_id))
if scene in (None, "feed", "draw"):
rows.extend(_feed_rows(db, date=date, user_id=user_id, scene=scene))
return rows
+95 -13
View File
@@ -11,17 +11,23 @@
(ad_audit.audit_rows,与正式发奖同一公式口径,不另写公式)。合计与对账在全量上统计,
不受 limit(只截断 items)影响。
⚠️ 局限:① 历史 Draw 发奖混在 ad_feed_reward_record 无类型标记,金币侧统一记 feed。
② 跨天 S2S 回调:同一次广告的展示与发奖偶尔落相邻日,各自按 report_date / reward_date 归日
每行带 ad_type(reward_video/feed/draw)与 feed_scene(comparison/coupon/welfare),供前端区分
「比价 Draw 收益」与「领券 Draw 收益」(比价/领券共用同一代码位,只能靠 feed_scene 分)
⚠️ 局限:① 历史信息流/Draw 发奖 ad_type 为 NULL 的旧记录统一视为 feed(向后兼容);Draw 仅
ad_type=="draw" 的新记录单独成类。② 跨天 S2S 回调:同一次广告的展示与发奖偶尔落相邻日,各自按
report_date / reward_date 归日。
"""
from __future__ import annotations
from datetime import date as _date, datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from datetime import date as _date
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.admin.repositories import ad_audit
from app.admin.repositories import stats as admin_stats
from app.core import rewards
from app.models.ad_ecpm import AdEcpmRecord
from app.models.user import User
@@ -30,7 +36,7 @@ from app.models.user import User
def _cn_hour(dt: datetime) -> int:
"""created_at(UTC 口径)→ 北京时间小时(023)。naive 当 UTC 处理(sqlite),tz-aware 直接换算(pg)。"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
dt = dt.replace(tzinfo=UTC)
return dt.astimezone(rewards.CN_TZ).hour
@@ -46,8 +52,18 @@ def _date_range(date_from: str, date_to: str) -> list[str]:
return out
# 审计行的 scene 与报表 ad_type 一一对应
_SCENE_TO_AD_TYPE = {"reward_video": "reward_video", "feed": "feed"}
# 报表 ad_type 与审计 scene 取值一致(reward_video / feed / draw):feed 与 draw 同查发奖表
# ad_feed_reward_record,由 audit 内部按 ad_type 区分(feed 含历史 NULL,draw 仅 ad_type=="draw")。
_AUDIT_SCENES = {"reward_video", "feed", "draw"}
def _event_ad_type(row: dict) -> str:
"""纯发奖事件行的 ad_type:信息流行用 audit 带回的真实 ad_type(feed/draw),回退 feed;
激励视频行恒 reward_video。不再用 scene 硬映射,避免把 draw 丢成 feed。"""
if row["scene"] == "reward_video":
return "reward_video"
return row.get("ad_type") or "feed"
# 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。
_REWARD_DETAIL_KEYS = (
@@ -69,14 +85,20 @@ def ad_revenue_report(
date_to: str,
user_id: int | None = None,
ad_type: str | None = None,
feed_scene: str | None = None,
granularity: str = "day",
limit: int = 500,
offset: int = 0,
sort: str = "time",
) -> dict:
"""日期区间(北京时间,闭区间)**逐条广告事件**列表 + 发奖对账。单日时 date_from==date_to。
每个 item = 一次广告事件(展示与发奖按 ad_session_id 合并;信息流展示 / 发奖各自成行)。
ad_type: None=全部 / reward_video / feed / draw。granularity=hour 时每行带北京小时(由各自时间算)。
limit 只截断 items(事件明细),total 与 total_* / daily 在全量上统计,数字始终可信
ad_type: None=全部 / reward_video / feed / draw。feed_scene: None=全部 /
comparison / coupon / welfare,作为全局筛选(同时作用于明细、合计与 daily/hourly 趋势)
granularity=hour 时每行带北京小时(由各自时间算),并额外返回全量 hourly 序列。
事件按时间倒序(新→旧)排列;limit/offset 对排序后的全量做分页切片(items 为当前页),
total 与 total_* / daily / hourly 在全量上统计,不受分页影响。
"""
by_hour = granularity == "hour"
@@ -84,7 +106,9 @@ def ad_revenue_report(
# 同时保留全量列表,未被展示合并的成「纯发奖」事件。
reward_by_session: dict[tuple[int, str], list[dict]] = {}
all_reward_rows: list[dict] = []
audit_scene = _SCENE_TO_AD_TYPE.get(ad_type) if ad_type is not None else None
# 报表 ad_type 直接当 audit scene 用(取值一致);未知/无效 ad_type 不取发奖行。draw 在此被
# 正确传成 scene="draw",audit 会按 ad_type 筛出 Draw 发奖,不再丢成 feed。
audit_scene = ad_type if ad_type in _AUDIT_SCENES else None
if ad_type is None or audit_scene is not None:
for d in _date_range(date_from, date_to):
for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene):
@@ -123,6 +147,7 @@ def ad_revenue_report(
"report_date": rec.report_date,
"user_id": rec.user_id,
"ad_type": rec.ad_type,
"feed_scene": rec.feed_scene,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at,
@@ -162,7 +187,8 @@ def ad_revenue_report(
"event_key": f"rwd-{row['record_id']}",
"report_date": row["_report_date"],
"user_id": row["user_id"],
"ad_type": _SCENE_TO_AD_TYPE.get(row["scene"], row["scene"]),
"ad_type": _event_ad_type(row),
"feed_scene": row.get("feed_scene"),
"app_env": row.get("app_env"),
"our_code_id": row.get("our_code_id"),
"created_at": row["created_at"],
@@ -181,7 +207,17 @@ def ad_revenue_report(
"reward_detail": _reward_detail(row),
})
events.sort(key=lambda e: (e["report_date"], e["user_id"], e["created_at"]))
# 「场景」作为全局筛选(与 user_id/ad_type 一致):同时作用于明细、合计与 daily/hourly 趋势。
# feed_scene 仅信息流 / Draw 有值,激励视频与旧数据为 None;选中后只保留该场景事件。
if feed_scene is not None:
events = [e for e in events if e.get("feed_scene") == feed_scene]
# 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排;
# 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。
if sort == "ecpm":
events.sort(key=lambda e: rewards.parse_ecpm_fen(e["ecpm"]), reverse=True)
else:
events.sort(key=lambda e: (e["report_date"], e["created_at"]), reverse=True)
# 补手机号(admin 展示用,完整不脱敏,与用户 / 钱包 / 比价记录页一致):批量一次查,避免 N+1。
uids = {e["user_id"] for e in events}
@@ -219,14 +255,60 @@ def ad_revenue_report(
for d in sorted(daily_map.values(), key=lambda x: x["date"])
]
# 按小时汇总(全量,不受分页 limit/offset 影响):供前端按小时趋势图(单日 granularity=hour 时用)。
# 只在 by_hour 下聚合(此时每个 event 带 hour);否则空。前端按天趋势仍用 daily。
hourly: list[dict] = []
if by_hour:
hour_map: dict[int, dict] = {}
for e in events:
h = e["hour"]
if h is None:
continue
hd = hour_map.get(h)
if hd is None:
hd = {"hour": h, "impressions": 0, "revenue_yuan": 0.0,
"expected_coin": 0, "actual_coin": 0}
hour_map[h] = hd
hd["impressions"] += e["impressions"]
hd["revenue_yuan"] += e["revenue_yuan"]
hd["expected_coin"] += e["expected_coin"]
hd["actual_coin"] += e["actual_coin"]
hourly = [
{**hd, "revenue_yuan": round(hd["revenue_yuan"], 6)}
for hd in sorted(hour_map.values(), key=lambda x: x["hour"])
]
# 分广告类型小计(按 ad_type:展示条数 + 预估收益;eCPM 由前端用 收益÷展示×1000 算)。
# 基于全量(已按 feed_scene 过滤)events;前端只取 draw / reward_video 两类展示。
type_map: dict[str, dict] = {}
for e in events:
t = type_map.get(e["ad_type"])
if t is None:
t = {"impressions": 0, "revenue_yuan": 0.0}
type_map[e["ad_type"]] = t
t["impressions"] += e["impressions"]
t["revenue_yuan"] += e["revenue_yuan"]
type_stats = {
k: {"impressions": v["impressions"], "revenue_yuan": round(v["revenue_yuan"], 6)}
for k, v in type_map.items()
}
# DAU:复用大盘「今日活跃」口径(stats.today_dau,last_login_at)。该口径只能算今日,
# 故仅当查询=今日单天时给值;历史 / 多天区间返回 None,前端显示「-」。
is_today = date_from == date_to == rewards.cn_today().isoformat()
dau = admin_stats.today_dau(db) if is_today else None
return {
"total": len(events),
"truncated": len(events) > limit,
"truncated": len(events) > offset + limit,
"total_impressions": total_impressions,
"total_revenue_yuan": total_revenue_yuan,
"total_expected_coin": total_expected_coin,
"total_actual_coin": total_actual_coin,
"mismatch_count": mismatch_count,
"daily": daily,
"items": events[:limit],
"hourly": hourly,
"type_stats": type_stats,
"dau": dau,
"items": events[offset:offset + limit],
}
+88 -1
View File
@@ -17,7 +17,7 @@ from app.integrations import meituan
from app.repositories import cps_link as cps_link_repo
from app.models.cps_activity import CpsActivity
from app.models.cps_group import CpsGroup
from app.models.cps_link import CpsClick
from app.models.cps_link import CpsClick, CpsLink
from app.models.cps_order import CpsOrder
from app.models.cps_wx_user import CpsWxUser
@@ -524,3 +524,90 @@ def group_wx_users(db: Session, *, group_id: int, limit: int = 200) -> list[dict
]
result.sort(key=lambda x: x["first_seen"], reverse=True)
return result[:limit]
def group_day_users(
db: Session, *, group_id: int, start: datetime, end: datetime, limit: int = 200,
) -> list[dict]:
"""该群某天(北京)以用户为单位的领券/点击 + 每人 visit 过的券。
时间窗为半开区间 [start, end)(end=次日 00:00),避免午夜双计。只统计 openid 非空
(可归属到人)的点击 —— 匿名点击(美团/京东 302 多为匿名)不计入。券名 = 该点击 link
对应活动名;活动被硬删则兜底 活动#{id}。copy=领券次数、visit=点击次数;coupons 仅
取 visit 事件按活动分组、按次数倒序(合计 = visit_count)。排序:领券 desc、再点击 desc。
与 group_wx_users 同风格(Python 侧聚合,跨 PG/SQLite 无方言坑)。
注:每日明细行的 click_pv/copy_pv 计全部点击(含匿名、UV 按 ip,ua);本函数只计 openid
用户,故各用户求和 <= 当天行总数,二者口径不同、不必相等。
"""
rows = db.execute(
select(CpsClick.openid, CpsClick.event_type, CpsClick.link_id)
.where(CpsClick.group_id == group_id)
.where(CpsClick.clicked_at >= _as_utc(start))
.where(CpsClick.clicked_at < _as_utc(end))
.where(CpsClick.openid.is_not(None))
).all()
if not rows:
return []
# link_id -> activity_id -> 券名(活动名)
link_ids = {r.link_id for r in rows}
link_to_act = dict(
db.execute(
select(CpsLink.id, CpsLink.activity_id).where(CpsLink.id.in_(link_ids))
).all()
)
act_ids = {aid for aid in link_to_act.values() if aid is not None}
act_name = (
dict(
db.execute(
select(CpsActivity.id, CpsActivity.name).where(CpsActivity.id.in_(act_ids))
).all()
)
if act_ids
else {}
)
def _coupon_name(link_id: int) -> str:
aid = link_to_act.get(link_id)
if aid is None:
return f"链接#{link_id}"
return act_name.get(aid) or f"活动#{aid}"
stat: dict[str, dict] = {}
for openid, event_type, link_id in rows:
s = stat.setdefault(openid, {"copy": 0, "visit": 0, "coupons": {}})
if event_type == "copy":
s["copy"] += 1
else:
s["visit"] += 1
name = _coupon_name(link_id)
s["coupons"][name] = s["coupons"].get(name, 0) + 1
openids = list(stat.keys())
users = {
u.openid: u
for u in db.execute(
select(CpsWxUser).where(CpsWxUser.openid.in_(openids))
).scalars().all()
}
result = [
{
"openid": openid,
"nickname": users[openid].nickname if openid in users else None,
"headimgurl": users[openid].headimgurl if openid in users else None,
"copy_count": s["copy"],
"visit_count": s["visit"],
"coupons": [
{"name": name, "count": cnt}
# 次数倒序;同次数按券名升序兜底,保证 PG 无 ORDER BY 行序下输出稳定
for name, cnt in sorted(
s["coupons"].items(), key=lambda kv: (-kv[1], kv[0])
)
],
}
for openid, s in stat.items()
]
result.sort(key=lambda x: (x["copy_count"], x["visit_count"]), reverse=True)
return result[:limit]
+192 -1
View File
@@ -8,14 +8,17 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from sqlalchemy import Select, asc, desc, func, or_, select
from sqlalchemy import Select, asc, case, desc, func, or_, select
from sqlalchemy.orm import Session
from app.core import rewards
from app.core.config import settings
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
from app.models.admin import AdminAuditLog
from app.models.analytics_event import AnalyticsEvent
from app.models.comparison import ComparisonRecord
from app.models.device import DeviceLiveness
from app.models.feedback import Feedback
from app.models.onboarding import OnboardingCompletion
from app.models.price_report import PriceReport
@@ -199,6 +202,156 @@ def list_onboarding_devices(db: Session, *, limit: int = 500) -> list[dict]:
]
def _heartbeat_seconds_ago(last: datetime | None) -> int | None:
"""距上次心跳的秒数(兼容 SQLite 取回的 naive datetime,按 UTC 处理)。None = 从没心跳。"""
if last is None:
return None
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
return int((datetime.now(timezone.utc) - last).total_seconds())
def _device_model_from_id(device_id: str) -> str:
"""从 device_id(格式 device_<机型>_<hash>)解析机型;不规范则回退原 id。"""
parts = device_id.split("_")
if len(parts) >= 3 and parts[0] == "device":
return "_".join(parts[1:-1]).replace("_", " ")
return device_id
def _attach_device_derived(devices: list[DeviceLiveness]) -> None:
"""给每台设备瞬态挂 device_model / online / display_state / offline_seconds(供 schema 读)。
在线判定:开过无障碍(ever_protected)且距上次心跳 ≤ HEARTBEAT_TIMEOUT_MINUTES。
从没开过无障碍(ever_protected=False)= never(不算掉线,避免把装了没用的设备误报掉线)。
offline_seconds 仅 offline 时给值(=掉线时长),在线/从未启用为 None。"""
timeout_sec = max(1, int(settings.HEARTBEAT_TIMEOUT_MINUTES)) * 60
for d in devices:
d.device_model = _device_model_from_id(d.device_id)
secs = _heartbeat_seconds_ago(d.last_heartbeat_at)
if not d.ever_protected:
d.online = False
d.display_state = "never"
d.offline_seconds = None
elif secs is not None and secs <= timeout_sec:
d.online = True
d.display_state = "online"
d.offline_seconds = None
else:
d.online = False
d.display_state = "offline"
d.offline_seconds = secs
def _attach_device_user_info(db: Session, devices: list[DeviceLiveness]) -> None:
"""给每台设备瞬态挂归属用户 phone/nickname(同 _attach_user_info,供 admin schema 读)。"""
uids = {d.user_id for d in devices}
if not uids:
return
rows = db.execute(
select(User.id, User.phone, User.nickname).where(User.id.in_(uids))
).all()
umap = {uid: (phone, nick) for uid, phone, nick in rows}
for d in devices:
phone, nick = umap.get(d.user_id, (None, None))
d.phone = phone
d.nickname = nick
def _liveness_cutoff() -> datetime:
"""掉线判定分界:此刻 - HEARTBEAT_TIMEOUT_MINUTES。心跳早于它 = 掉线(同 list_overdue 口径)。"""
timeout_min = max(1, int(settings.HEARTBEAT_TIMEOUT_MINUTES))
return datetime.now(timezone.utc) - timedelta(minutes=timeout_min)
def list_device_liveness(
db: Session,
*,
status: str | None = None,
device_id: str | None = None,
phone: str | None = None,
user_id: int | None = None,
sort_by: str = "status",
sort_order: str = "desc",
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[DeviceLiveness], int | None, int]:
"""设备存活列表(admin 全量)。按 在线情况(online/offline/never)/ 设备id(包含)/
归属用户(手机号前缀 或 user_id)筛,offset 分页。join user 取 phone/nickname,派生
device_model/online/display_state/offline_seconds 挂行上。
默认排序 status=掉线置顶(offline → online → never;掉线组内掉得最久在前)。"""
cutoff = _liveness_cutoff()
stmt = select(DeviceLiveness)
# 在线情况派生筛选(口径同 _attach_device_derived:ever_protected + 心跳是否过阈值)
if status == "online":
stmt = stmt.where(
DeviceLiveness.ever_protected.is_(True),
DeviceLiveness.last_heartbeat_at.is_not(None),
DeviceLiveness.last_heartbeat_at >= cutoff,
)
elif status == "offline":
stmt = stmt.where(
DeviceLiveness.ever_protected.is_(True),
DeviceLiveness.last_heartbeat_at.is_not(None),
DeviceLiveness.last_heartbeat_at < cutoff,
)
elif status == "never":
stmt = stmt.where(DeviceLiveness.ever_protected.is_(False))
if device_id and device_id.strip():
stmt = stmt.where(DeviceLiveness.device_id.like(f"%{device_id.strip()}%"))
if user_id is not None:
stmt = stmt.where(DeviceLiveness.user_id == user_id)
if phone:
stmt = stmt.where(
DeviceLiveness.user_id.in_(select(User.id).where(User.phone.like(f"{phone}%")))
)
if sort_by in ("last_heartbeat_at", "created_at"):
col = (
DeviceLiveness.last_heartbeat_at
if sort_by == "last_heartbeat_at"
else DeviceLiveness.created_at
)
order_fn = asc if sort_order == "asc" else desc
id_order = asc(DeviceLiveness.id) if sort_order == "asc" else desc(DeviceLiveness.id)
sort_clause: tuple = (order_fn(col), id_order)
else:
# 默认「掉线置顶」:offline(0) → online(1) → never(2);掉线组内按心跳最旧(掉得最久)在前
rank = case(
(DeviceLiveness.ever_protected.is_(False), 2),
(DeviceLiveness.last_heartbeat_at < cutoff, 0),
else_=1,
)
sort_clause = (asc(rank), asc(DeviceLiveness.last_heartbeat_at), desc(DeviceLiveness.id))
items, next_cursor, total = offset_paginate(db, stmt, sort_clause, limit=limit, cursor=cursor)
_attach_device_user_info(db, items)
_attach_device_derived(items)
return items, next_cursor, total
def device_liveness_stats(db: Session) -> dict:
"""顶部卡片:总设备数 + 在线 / 已掉线 / 未启用(按心跳阈值派生,口径同列表)。"""
cutoff = _liveness_cutoff()
def _count(*conds) -> int:
return int(db.execute(select(func.count(DeviceLiveness.id)).where(*conds)).scalar_one())
total = int(db.execute(select(func.count(DeviceLiveness.id))).scalar_one())
never = _count(DeviceLiveness.ever_protected.is_(False))
online = _count(
DeviceLiveness.ever_protected.is_(True),
DeviceLiveness.last_heartbeat_at.is_not(None),
DeviceLiveness.last_heartbeat_at >= cutoff,
)
offline = _count(
DeviceLiveness.ever_protected.is_(True),
DeviceLiveness.last_heartbeat_at.is_not(None),
DeviceLiveness.last_heartbeat_at < cutoff,
)
return {"total": total, "online": online, "offline": offline, "never": never}
def list_all_coin_transactions(
db: Session,
*,
@@ -412,6 +565,44 @@ def list_feedbacks(
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
def list_analytics_events(
db: Session,
*,
event: str | None = None,
device_id: str | None = None,
user_id: int | None = None,
session_id: str | None = None,
created_from: datetime | None = None,
created_to: datetime | None = None,
sort_by: str = "id",
sort_order: str = "desc",
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[AnalyticsEvent], int | None, int]:
"""埋点日志列表(admin 全量)。按 事件名 / 设备ID前缀 / 用户ID / 会话ID / 接收时间范围 筛选,
按 id·接收时间排序。offset 分页(同 [list_feedbacks])。created_at 为 timestamptz,
日期入参统一转 tz-aware UTC 比较。"""
stmt = select(AnalyticsEvent)
if event:
stmt = stmt.where(AnalyticsEvent.event == event)
if device_id and device_id.strip():
stmt = stmt.where(AnalyticsEvent.device_id.like(f"{device_id.strip()}%"))
if user_id is not None:
stmt = stmt.where(AnalyticsEvent.user_id == user_id)
if session_id and session_id.strip():
stmt = stmt.where(AnalyticsEvent.session_id == session_id.strip())
if created_from is not None:
stmt = stmt.where(AnalyticsEvent.created_at >= _as_utc(created_from))
if created_to is not None:
stmt = stmt.where(AnalyticsEvent.created_at <= _as_utc(created_to))
sort_cols = {"id": AnalyticsEvent.id, "created_at": AnalyticsEvent.created_at}
sort_col = sort_cols.get(sort_by, AnalyticsEvent.id)
order_fn = asc if sort_order == "asc" else desc
id_order = asc(AnalyticsEvent.id) if sort_order == "asc" else desc(AnalyticsEvent.id)
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder | None:
"""按商户单号查提现单(admin 重试打款先拿 user_id 用,M3)。"""
return db.execute(
+346 -8
View File
@@ -5,7 +5,8 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, time, timedelta, timezone
from decimal import Decimal, InvalidOperation
from sqlalchemy import func, select
from sqlalchemy.orm import Session
@@ -13,12 +14,27 @@ from sqlalchemy.orm import Session
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
from app.models.comparison import ComparisonRecord
from app.models.coupon_state import CouponPromptEngagement
from app.models.cps_order import CpsOrder
from app.models.feedback import Feedback
from app.models.savings import SavingsRecord
from app.models.signin import SigninBoostRecord, SigninRecord
from app.models.user import User
from app.models.wallet import CoinTransaction, WithdrawOrder
_BEIJING = timezone(timedelta(hours=8))
COUPON_REWARD_BIZ_TYPES = ("reward_video", "ad_reward", "coupon", "coupon_reward")
COMPARISON_REWARD_BIZ_TYPES = ("comparison", "compare_reward", "comparison_reward")
EXCLUDED_REWARD_BIZ_TYPES = ("invite_inviter", "invite_invitee", "admin_grant")
UNCLASSIFIED_FEED_BIZ_TYPES = ("feed_ad_reward",)
REGULAR_TASK_EXCLUDED_BIZ_TYPES = (
*COUPON_REWARD_BIZ_TYPES,
*COMPARISON_REWARD_BIZ_TYPES,
*EXCLUDED_REWARD_BIZ_TYPES,
*UNCLASSIFIED_FEED_BIZ_TYPES,
)
MEITUAN_CPS_INVALID_STATUSES = ("4", "5")
MEITUAN_CPS_SETTLED_STATUS = "6"
def _beijing_today_start_utc() -> datetime:
@@ -28,20 +44,90 @@ def _beijing_today_start_utc() -> datetime:
return start_bj.astimezone(timezone.utc)
def dashboard_overview(db: Session) -> dict:
def today_dau(db: Session) -> int:
"""今日活跃用户数(DAU):北京时区今天 0 点后登录过(last_login_at)。
广告收益报表复用这个函数;历史窗口 DAU 由 dashboard_overview 的 period 口径另算。
"""
today_start = _beijing_today_start_utc()
return int(
db.execute(select(func.count(User.id)).where(User.last_login_at >= today_start)).scalar_one()
)
def _default_period_end() -> date:
"""新版大盘不含今日,默认窗口结束日=北京时间昨天。"""
return datetime.now(_BEIJING).date() - timedelta(days=1)
def _normalize_period(date_from: date | None, date_to: date | None) -> tuple[date, date]:
end = date_to or _default_period_end()
start = date_from or end
if start > end:
start, end = end, start
return start, end
def _period_bounds(date_from: date, date_to: date) -> tuple[datetime, datetime, datetime, datetime]:
"""返回同一北京自然日窗口的 UTC aware 边界和北京 naive 边界。
user.created_at / last_login_at 是 UTC aware 口径;比较/金币等历史上有北京 naive
写入,所以两套边界同时保留。
"""
start_bj = datetime.combine(date_from, time.min, tzinfo=_BEIJING)
end_bj = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=_BEIJING)
start_utc = start_bj.astimezone(timezone.utc)
end_utc = end_bj.astimezone(timezone.utc)
return (
start_utc,
end_utc,
start_bj.replace(tzinfo=None),
end_bj.replace(tzinfo=None),
)
def _date_range(date_from: date, date_to: date) -> list[date]:
days = (date_to - date_from).days
return [date_from + timedelta(days=i) for i in range(days + 1)]
def _commission_rate_percent(raw: str | None) -> Decimal | None:
"""美团 commissionRate 原值: "300"=3%, "10"=0.1%;也兼容 "3%""""
if raw is None:
return None
s = str(raw).strip()
if not s:
return None
try:
if s.endswith("%"):
return Decimal(s[:-1])
val = Decimal(s)
except (InvalidOperation, ValueError):
return None
return val / Decimal("100")
def dashboard_overview(
db: Session, *, date_from: date | None = None, date_to: date | None = None
) -> dict:
today_start = _beijing_today_start_utc()
period_from, period_to = _normalize_period(date_from, date_to)
start_utc, end_utc, start_local, end_local = _period_bounds(period_from, period_to)
def _count(model, *conds) -> int:
stmt = select(func.count(model.id))
if conds:
stmt = stmt.where(*conds)
return db.execute(stmt).scalar_one()
return int(db.execute(stmt).scalar_one())
def _sum(col, *conds) -> int:
stmt = select(func.coalesce(func.sum(col), 0))
if conds:
stmt = stmt.where(*conds)
return db.execute(stmt).scalar_one()
return int(db.execute(stmt).scalar_one())
def _user_id_set(stmt) -> set[int]:
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
# ===== 用户 =====
by_status = dict(
@@ -61,6 +147,204 @@ def dashboard_overview(db: Session) -> dict:
comparison_total = _count(ComparisonRecord)
comparison_success = _count(ComparisonRecord, ComparisonRecord.status == "success")
success_rate = round(comparison_success / comparison_total, 4) if comparison_total else 0.0
period_comparison_conds = (
ComparisonRecord.created_at >= start_local,
ComparisonRecord.created_at < end_local,
)
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_total, 4)
if period_comparison_total
else 0.0
)
period_saved_positive_count = _count(
ComparisonRecord,
*period_comparison_conds,
ComparisonRecord.status == "success",
ComparisonRecord.saved_amount_cents > 0,
)
period_saved_positive_sum = _sum(
ComparisonRecord.saved_amount_cents,
*period_comparison_conds,
ComparisonRecord.status == "success",
ComparisonRecord.saved_amount_cents > 0,
)
period_avg_saved_cents = (
round(period_saved_positive_sum / period_saved_positive_count)
if period_saved_positive_count
else None
)
period_avg_duration_ms = db.execute(
select(func.avg(ComparisonRecord.total_ms)).where(
*period_comparison_conds,
ComparisonRecord.total_ms.is_not(None),
ComparisonRecord.total_ms > 0,
)
).scalar_one()
period_avg_duration_ms = (
round(float(period_avg_duration_ms))
if period_avg_duration_ms is not None
else None
)
ordered_exists = (
select(SavingsRecord.id)
.where(
SavingsRecord.user_id == ComparisonRecord.user_id,
SavingsRecord.source == "compare",
SavingsRecord.shop_name.is_not(None),
SavingsRecord.shop_name == ComparisonRecord.store_name,
)
.exists()
)
period_ordered_count = _count(
ComparisonRecord,
*period_comparison_conds,
ComparisonRecord.store_name.is_not(None),
ordered_exists,
)
# ===== 日期窗口用户 =====
period_new_user_ids = _user_id_set(
select(User.id).where(User.created_at >= start_utc, User.created_at < end_utc)
)
login_user_ids = _user_id_set(
select(User.id).where(User.last_login_at >= start_utc, User.last_login_at < end_utc)
)
compare_user_ids = _user_id_set(
select(ComparisonRecord.user_id).where(*period_comparison_conds)
)
coupon_user_ids = _user_id_set(
select(CouponPromptEngagement.user_id).where(
CouponPromptEngagement.engage_date >= period_from,
CouponPromptEngagement.engage_date <= period_to,
CouponPromptEngagement.engage_type == "claim_started",
)
)
period_active_user_ids = login_user_ids | compare_user_ids | coupon_user_ids
period_retained_new_user_ids = period_new_user_ids & period_active_user_ids
period_retention_rate = (
round(len(period_retained_new_user_ids) / len(period_new_user_ids), 4)
if period_new_user_ids
else None
)
trend_points: list[dict] = []
for cur_date in _date_range(period_from, period_to):
day_start_utc, day_end_utc, day_start_local, day_end_local = _period_bounds(
cur_date, cur_date
)
daily_comparison_conds = (
ComparisonRecord.created_at >= day_start_local,
ComparisonRecord.created_at < day_end_local,
)
daily_login_user_ids = _user_id_set(
select(User.id).where(
User.last_login_at >= day_start_utc,
User.last_login_at < day_end_utc,
)
)
daily_compare_user_ids = _user_id_set(
select(ComparisonRecord.user_id).where(*daily_comparison_conds)
)
daily_coupon_user_ids = _user_id_set(
select(CouponPromptEngagement.user_id).where(
CouponPromptEngagement.engage_date == cur_date,
CouponPromptEngagement.engage_type == "claim_started",
)
)
trend_points.append(
{
"date": cur_date,
"active_users": len(
daily_login_user_ids | daily_compare_user_ids | daily_coupon_user_ids
),
"new_users": _count(
User,
User.created_at >= day_start_utc,
User.created_at < day_end_utc,
),
"comparisons": _count(ComparisonRecord, *daily_comparison_conds),
}
)
period_coin_conds = (
CoinTransaction.created_at >= start_local,
CoinTransaction.created_at < end_local,
CoinTransaction.amount > 0,
)
period_reward_video_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
)
period_feed_ad_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type == "feed_ad_reward",
)
period_signin_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type == "signin",
)
period_signin_boost_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type == "signin_boost",
)
period_task_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.like("task_%"),
)
period_coupon_reward_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.in_(COUPON_REWARD_BIZ_TYPES),
)
period_comparison_reward_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.in_(COMPARISON_REWARD_BIZ_TYPES),
)
period_regular_task_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.notin_(REGULAR_TASK_EXCLUDED_BIZ_TYPES),
)
period_meituan_orders = list(
db.execute(
select(CpsOrder).where(
CpsOrder.pay_time >= start_utc,
CpsOrder.pay_time < end_utc,
)
).scalars()
)
period_meituan_valid_orders = [
o for o in period_meituan_orders if o.mt_status not in MEITUAN_CPS_INVALID_STATUSES
]
period_meituan_hit_count = 0
period_meituan_miss_count = 0
period_meituan_unknown_rate_count = 0
for order in period_meituan_valid_orders:
rate = _commission_rate_percent(order.commission_rate)
if rate is None:
period_meituan_unknown_rate_count += 1
elif rate < Decimal("1"):
period_meituan_miss_count += 1
else:
period_meituan_hit_count += 1
period_meituan_hit_denominator = period_meituan_hit_count + period_meituan_miss_count
period_meituan_hit_rate = (
round(period_meituan_hit_count / period_meituan_hit_denominator, 4)
if period_meituan_hit_denominator
else None
)
return {
"users": {
@@ -69,7 +353,7 @@ def dashboard_overview(db: Session) -> dict:
"disabled": by_status.get("disabled", 0),
"deleted": by_status.get("deleted", 0),
"new_today": _count(User, User.created_at >= today_start),
"dau": _count(User, User.last_login_at >= today_start),
"dau": today_dau(db),
},
"coins": {
# 累计发放金币(coin_transaction 里所有 amount>0 之和;负数是兑换/扣减不计)
@@ -119,7 +403,61 @@ def dashboard_overview(db: Session) -> dict:
"success": comparison_success,
"success_rate": success_rate,
},
"feedback": {"new": _count(Feedback, Feedback.status.in_(("pending", "new")))},
# CPS 收入数据源未接(referral-link 只换链接,转化/佣金未回收)→ 前端显示"待接入"。
"cps": {"available": False, "note": "CPS 转化数据未接入(P2)"},
"period": {
"date_from": period_from,
"date_to": period_to,
"users": {
"new": len(period_new_user_ids),
"active": len(period_active_user_ids),
"retained_new_users": len(period_retained_new_user_ids),
"retention_rate": period_retention_rate,
"retention_note": (
"近似口径:登录(last_login_at)+已上报比价记录+领券claim_started;"
"尚不包含未完成上报的比价开始事件"
),
},
"comparison": {
"total": period_comparison_total,
"success": period_comparison_success,
"success_rate": period_comparison_success_rate,
"ordered": period_ordered_count,
"average_duration_ms": period_avg_duration_ms,
"average_saved_cents": period_avg_saved_cents,
},
"coins": {
"granted_total": _sum(CoinTransaction.amount, *period_coin_conds),
"reward_video_coin_total": period_reward_video_coin_total,
"feed_ad_coin_total": period_feed_ad_coin_total,
"signin_coin_total": period_signin_coin_total,
"signin_boost_coin_total": period_signin_boost_coin_total,
"task_coin_total": period_task_coin_total,
"coupon_reward_coin_total": period_coupon_reward_coin_total,
"comparison_reward_coin_total": period_comparison_reward_coin_total,
"regular_task_coin_total": period_regular_task_coin_total,
},
"cash": {
"withdraw_success_cents": _sum(
WithdrawOrder.amount_cents,
WithdrawOrder.status == "success",
WithdrawOrder.created_at >= start_local,
WithdrawOrder.created_at < end_local,
),
},
"trend": trend_points,
},
"feedback": {
"new": _count(Feedback, Feedback.status.in_(("pending", "new"))),
},
"cps": {
"available": True,
"note": "美团 CPS 读 cps_order 对账订单;淘宝/京东佣金暂空",
"meituan_order_count": len(period_meituan_valid_orders),
"meituan_commission_cents": sum(
o.commission_cents or 0 for o in period_meituan_valid_orders
),
"meituan_hit_count": period_meituan_hit_count,
"meituan_miss_count": period_meituan_miss_count,
"meituan_unknown_rate_count": period_meituan_unknown_rate_count,
"meituan_hit_rate": period_meituan_hit_rate,
},
}
+1 -1
View File
@@ -26,7 +26,7 @@ def get_ad_coin_audit(
date: Annotated[str | None, Query(description="北京时间 YYYY-MM-DD,默认今天")] = None,
user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None,
scene: Annotated[
str | None, Query(description="reward_video / feed;不传=两类都要")
str | None, Query(description="reward_video / feed / draw;不传=全部")
] = None,
limit: Annotated[int, Query(ge=1, le=500)] = 100,
only_mismatch: Annotated[
+24 -3
View File
@@ -11,7 +11,13 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import ad_revenue
from app.admin.schemas.ad_revenue import AdRevenueDaily, AdRevenueReportOut, AdRevenueRow
from app.admin.schemas.ad_revenue import (
AdRevenueDaily,
AdRevenueHourly,
AdRevenueReportOut,
AdRevenueRow,
AdRevenueTypeStat,
)
from app.core.rewards import cn_today
router = APIRouter(
@@ -43,10 +49,21 @@ def get_ad_revenue_report(
str | None,
Query(description="reward_video / feed / draw;不传=全部类型"),
] = None,
feed_scene: Annotated[
str | None,
Query(
description="comparison(比价) / coupon(领券) / welfare(福利);不传=全部场景。"
"全局筛选,同时影响明细 / 合计 / 趋势"
),
] = None,
granularity: Annotated[
str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day")
] = "day",
limit: Annotated[int, Query(ge=1, le=1000)] = 500,
limit: Annotated[int, Query(ge=1, le=1000, description="每页条数(分页大小)")] = 500,
offset: Annotated[int, Query(ge=0, description="分页偏移(已跳过的条数)=(页码-1)×每页条数")] = 0,
sort: Annotated[
str, Query(description="排序:time=时间倒序(默认) / ecpm=按 eCPM 数值倒序")
] = "time",
) -> AdRevenueReportOut:
today = cn_today()
d_from = _parse_day(date_from, field="date_from", default=today)
@@ -58,12 +75,16 @@ def get_ad_revenue_report(
result = ad_revenue.ad_revenue_report(
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
user_id=user_id, ad_type=ad_type, granularity=granularity, limit=limit,
user_id=user_id, ad_type=ad_type, feed_scene=feed_scene,
granularity=granularity, limit=limit, offset=offset, sort=sort,
)
return AdRevenueReportOut(
date_from=d_from.isoformat(),
date_to=d_to.isoformat(),
daily=[AdRevenueDaily(**d) for d in result["daily"]],
hourly=[AdRevenueHourly(**h) for h in result["hourly"]],
type_stats={k: AdRevenueTypeStat(**v) for k, v in result["type_stats"].items()},
dau=result["dau"],
total=result["total"],
truncated=result["truncated"],
total_impressions=result["total_impressions"],
+84 -6
View File
@@ -7,7 +7,7 @@
from __future__ import annotations
import time
from datetime import datetime, timedelta, timezone
from datetime import date as _date, datetime, time as _dt_time, timedelta, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
@@ -340,24 +340,77 @@ def generate_referral_links(
# ───────────── 订单对账 ─────────────
_BEIJING = timezone(timedelta(hours=8))
def _parse_day(value: str | None, *, field: str) -> _date | None:
if value is None:
return None
try:
return _date.fromisoformat(value)
except ValueError as e:
raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e
def _reconcile_range_to_ts(
date_from: _date | None, date_to: _date | None, days: int
) -> tuple[int, int]:
if date_from is None and date_to is None:
now = int(time.time())
return now - days * 86400, now
start_day = date_from or date_to
end_day = date_to or date_from
if start_day is None or end_day is None:
raise HTTPException(status_code=422, detail="日期参数不完整")
if start_day > end_day:
start_day, end_day = end_day, start_day
if (end_day - start_day).days + 1 > 90:
raise HTTPException(status_code=422, detail="美团订单查询最长 90 天")
start_dt = datetime.combine(start_day, _dt_time.min, tzinfo=_BEIJING)
end_dt = datetime.combine(end_day + timedelta(days=1), _dt_time.min, tzinfo=_BEIJING)
return int(start_dt.timestamp()), int(end_dt.timestamp())
@router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取美团订单对账")
def reconcile_orders(
request: Request,
admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb,
days: Annotated[int, Query(ge=1, le=90)] = 7,
date_from: Annotated[str | None, Query(description="起始日 YYYY-MM-DD")] = None,
date_to: Annotated[str | None, Query(description="结束日 YYYY-MM-DD")] = None,
days: Annotated[int, Query(ge=1, le=90, description="未传日期时默认拉近 N 天")] = 7,
sid: Annotated[str | None, Query(max_length=64)] = None,
query_time_type: Annotated[int, Query(ge=1, le=2)] = 1,
) -> CpsReconcileResult:
now = int(time.time())
start_ts, end_ts = _reconcile_range_to_ts(
_parse_day(date_from, field="date_from"),
_parse_day(date_to, field="date_to"),
days,
)
try:
result = cps_repo.reconcile_orders(
db, start_time=now - days * 86400, end_time=now, sid=sid,
db,
start_time=start_ts,
end_time=end_ts,
query_time_type=query_time_type,
sid=sid,
)
except MeituanCpsError as e:
raise HTTPException(status_code=502, detail=f"美团拉单失败: {e}") from e
write_audit(
db, admin, action="cps.order.reconcile", target_type="cps_order", target_id=None,
detail={"days": days, "sid": sid, **result}, ip=get_client_ip(request), commit=True,
detail={
"date_from": date_from,
"date_to": date_to,
"days": days,
"sid": sid,
"query_time_type": query_time_type,
**result,
},
ip=get_client_ip(request),
commit=True,
)
return CpsReconcileResult(**result)
@@ -468,7 +521,7 @@ def group_daily(
while cur <= last:
cp = click_points[idx] if idx < len(click_points) else None
row = {
"date": cur.strftime("%m-%d"),
"date": cur.strftime("%Y-%m-%d"),
"click_pv": cp["click_pv"] if cp else 0,
"click_uv": cp["click_uv"] if cp else 0,
"copy_pv": cp["copy_pv"] if cp else 0,
@@ -496,3 +549,28 @@ def group_wx_users(group_id: int, db: AdminDb) -> dict:
if group is None:
raise HTTPException(status_code=404, detail="群不存在")
return {"users": cps_repo.group_wx_users(db, group_id=group_id)}
@router.get("/groups/{group_id}/day-users", summary="某天该群按用户的领券/点击 + 每人点过的券")
def group_day_users(
group_id: int,
db: AdminDb,
date: Annotated[str, Query(description="北京日期 YYYY-MM-DD")],
) -> dict:
group = cps_repo.get_group(db, group_id)
if group is None:
raise HTTPException(status_code=404, detail="群不存在")
bj = timezone(timedelta(hours=8))
try:
day0 = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=bj)
except ValueError as e:
raise HTTPException(status_code=400, detail="date 格式应为 YYYY-MM-DD") from e
start = day0.replace(hour=0, minute=0, second=0, microsecond=0)
end = start + timedelta(days=1)
users = cps_repo.group_day_users(db, group_id=group_id, start=start, end=end)
return {
"group_id": group.id,
"group_name": group.name,
"date": date,
"users": users,
}
+11 -3
View File
@@ -1,7 +1,9 @@
"""admin 数据大盘(只读聚合)。"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from datetime import date
from fastapi import APIRouter, Depends, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import stats
@@ -15,5 +17,11 @@ router = APIRouter(
@router.get("/overview", response_model=DashboardOverview, summary="大盘核心指标")
def overview(db: AdminDb) -> DashboardOverview:
return DashboardOverview.model_validate(stats.dashboard_overview(db))
def overview(
db: AdminDb,
date_from: date | None = Query(None, description="北京时间自然日起始日 YYYY-MM-DD"),
date_to: date | None = Query(None, description="北京时间自然日结束日 YYYY-MM-DD"),
) -> DashboardOverview:
return DashboardOverview.model_validate(
stats.dashboard_overview(db, date_from=date_from, date_to=date_to)
)
+63
View File
@@ -0,0 +1,63 @@
"""admin 设备存活监控(只读):列设备心跳/在线掉线 + 顶部统计卡片。
数据源 device_liveness 表(心跳 last_heartbeat_at + liveness_state + kill_alert_pending,
见 app/models/device.py)。在线/掉线、掉线时长由 repo 按 HEARTBEAT_TIMEOUT_MINUTES 阈值派生。
纯读:无写、无审计。任意登录管理员可看(同大盘/设备管理,无角色门)。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import queries
from app.admin.schemas.common import CursorPage
from app.admin.schemas.device import DeviceLivenessItem, DeviceLivenessStats
router = APIRouter(
prefix="/admin/api/device-liveness",
tags=["admin-device-liveness"],
dependencies=[Depends(get_current_admin)],
)
@router.get("/stats", response_model=DeviceLivenessStats, summary="设备存活统计(顶部卡片)")
def device_stats(db: AdminDb) -> DeviceLivenessStats:
return DeviceLivenessStats(**queries.device_liveness_stats(db))
@router.get(
"",
response_model=CursorPage[DeviceLivenessItem],
summary="设备存活列表(在线情况/设备id/归属用户 筛选 + 排序 + 分页,默认掉线置顶)",
)
def list_devices(
db: AdminDb,
status: Annotated[str | None, Query(pattern="^(online|offline|never)$")] = None,
device_id: Annotated[str | None, Query(max_length=128)] = None,
phone: Annotated[str | None, Query(max_length=20)] = None,
user_id: Annotated[int | None, Query()] = None,
sort_by: Annotated[
str, Query(pattern="^(status|last_heartbeat_at|created_at)$")
] = "status",
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[DeviceLivenessItem]:
items, next_cursor, total = queries.list_device_liveness(
db,
status=status,
device_id=device_id,
phone=phone,
user_id=user_id,
sort_by=sort_by,
sort_order=sort_order,
limit=limit,
cursor=cursor,
)
return CursorPage(
items=[DeviceLivenessItem.model_validate(d) for d in items],
next_cursor=next_cursor,
total=total,
)
+52
View File
@@ -0,0 +1,52 @@
"""admin 埋点日志:列表 + 按事件 / 设备 / 用户 / 会话 / 时间筛选(只读,同库直接查 analytics_event)。"""
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 queries
from app.admin.schemas.analytics import AnalyticsEventOut
from app.admin.schemas.common import CursorPage
router = APIRouter(
prefix="/admin/api/event-logs",
tags=["admin-event-logs"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=CursorPage[AnalyticsEventOut], summary="埋点日志列表")
def list_event_logs(
db: AdminDb,
event: Annotated[str | None, Query(max_length=64)] = None,
device_id: Annotated[str | None, Query(max_length=64)] = None,
user_id: Annotated[int | None, Query()] = None,
session_id: Annotated[str | None, Query(max_length=64)] = None,
created_from: Annotated[datetime | None, Query()] = None,
created_to: Annotated[datetime | None, Query()] = None,
sort_by: Annotated[str, Query(pattern="^(id|created_at)$")] = "id",
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[AnalyticsEventOut]:
items, next_cursor, total = queries.list_analytics_events(
db,
event=event,
device_id=device_id,
user_id=user_id,
session_id=session_id,
created_from=created_from,
created_to=created_to,
sort_by=sort_by,
sort_order=sort_order,
limit=limit,
cursor=cursor,
)
return CursorPage(
items=[AnalyticsEventOut.model_validate(e) for e in items],
next_cursor=next_cursor,
total=total,
)
+4 -4
View File
@@ -9,8 +9,8 @@ class AdConfigOut(BaseModel):
app_id: str
reward_code_id: str
compare_feed_code_id: str
coupon_feed_code_id: str
compare_draw_code_id: str
coupon_draw_code_id: str
reward_mkey: str
reward_enabled: bool
compare_ad_enabled: bool
@@ -23,8 +23,8 @@ class AdConfigUpdate(BaseModel):
app_id: str | None = None
reward_code_id: str | None = None
compare_feed_code_id: str | None = None
coupon_feed_code_id: str | None = None
compare_draw_code_id: str | None = None
coupon_draw_code_id: str | None = None
reward_mkey: str | None = None
reward_enabled: bool | None = None
compare_ad_enabled: bool | None = None
+38 -4
View File
@@ -40,7 +40,7 @@ class AdRevenueRecord(BaseModel):
class AdRevenueDaily(BaseModel):
"""按日期汇总的一天(供前端按天趋势图;全量,不受 limit 影响)。"""
"""按日期汇总的一天(供前端按天趋势图;全量,不受分页影响)。"""
date: str = Field(..., description="北京时间 YYYY-MM-DD")
impressions: int = Field(..., description="当天展示条数合计")
@@ -49,6 +49,23 @@ class AdRevenueDaily(BaseModel):
actual_coin: int = Field(..., description="当天实发金币合计")
class AdRevenueHourly(BaseModel):
"""按北京小时(0–23)汇总的一小时(供前端按小时趋势图;全量,不受分页影响,单日 granularity=hour 时非空)。"""
hour: int = Field(..., description="北京时间小时 023")
impressions: int = Field(..., description="该小时展示条数合计")
revenue_yuan: float = Field(..., description="该小时预估收益合计(元)")
expected_coin: int = Field(..., description="该小时应发金币合计")
actual_coin: int = Field(..., description="该小时实发金币合计")
class AdRevenueTypeStat(BaseModel):
"""按广告类型(ad_type)的小计:展示条数 + 预估收益(eCPM 由前端用 收益÷展示×1000 算)。"""
impressions: int = Field(..., description="该类型展示条数合计")
revenue_yuan: float = Field(..., description="该类型预估收益合计(元)")
class AdRevenueRow(BaseModel):
"""一次广告事件(逐条一行):激励视频展示与发奖按 ad_session_id 合并;信息流展示 / 发奖各自成行。"""
@@ -56,7 +73,12 @@ class AdRevenueRow(BaseModel):
report_date: str = Field(..., description="该事件所属日期(北京时间 YYYY-MM-DD)")
user_id: int
user_phone: str | None = Field(None, description="用户手机号(admin 展示用,完整;用户已删 / 查不到为空)")
ad_type: str = Field(..., description="reward_video(激励视频) / feed(信息流) / draw(历史 Draw 信息流)")
ad_type: str = Field(..., description="reward_video(激励视频) / feed(信息流) / draw(Draw 信息流);历史 NULL 视为 feed")
feed_scene: str | None = Field(
None,
description="点位场景:comparison(比价) / coupon(领券) / welfare(福利);供区分比价/领券 Draw 收益;"
"激励视频与旧数据为空",
)
app_env: str | None = Field(None, description="我们的应用:prod(傻瓜比价正式) / test(测试应用);旧数据为空")
our_code_id: str | None = Field(None, description="我们后台配置的代码位 ID(104xxx);旧数据为空")
hour: int | None = Field(None, description="北京时间小时 023(granularity=hour 时有值;按天为 null)")
@@ -86,8 +108,20 @@ class AdRevenueReportOut(BaseModel):
date_from: str = Field(..., description="报表起始日期(北京时间 YYYY-MM-DD)")
date_to: str = Field(..., description="报表结束日期(北京时间 YYYY-MM-DD,闭区间;单日时与 date_from 相同)")
daily: list[AdRevenueDaily] = Field(..., description="按日期汇总序列(全量,供按天趋势图)")
total: int = Field(..., description="广告事件总数(全量,不受 limit 影响)")
truncated: bool = Field(..., description="明细是否被 limit 截断")
hourly: list[AdRevenueHourly] = Field(
default_factory=list,
description="按小时汇总序列(全量,供按小时趋势图;按天查询时为空)",
)
type_stats: dict[str, AdRevenueTypeStat] = Field(
default_factory=dict,
description="按广告类型(ad_type)小计 {ad_type: {impressions, revenue_yuan}};前端取 draw / reward_video 做分类大盘",
)
dau: int | None = Field(
None,
description="今日活跃用户数(复用大盘口径,last_login_at);**仅查询=今日单天时有值**,历史/多天为 null",
)
total: int = Field(..., description="广告事件总数(全量,不受分页影响;= 当前筛选下的分页总条数)")
truncated: bool = Field(..., description="当前页之后是否还有更多事件(len(events) > offset + limit)")
total_impressions: int = Field(..., description="全量展示条数合计")
total_revenue_yuan: float = Field(..., description="全量收益合计(元)")
total_expected_coin: int = Field(..., description="全量应发金币合计")
+33
View File
@@ -0,0 +1,33 @@
"""admin 埋点日志列表响应。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class AnalyticsEventOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
event: str
# Who
device_id: str
user_id: int | None
# When
session_id: str | None
client_ts: int
sent_at: int | None
created_at: datetime # 服务端接收时间(server_at)
# Where
page: str | None
client_ip: str | None
# How
oem: str | None
os: str | None
model: str | None
app_ver: str | None
network: str | None
channel: str | None
# What 专属
props: dict | None
+59
View File
@@ -1,6 +1,8 @@
"""admin 大盘 schemas(对应 stats.dashboard_overview 的嵌套结构)。"""
from __future__ import annotations
from datetime import date
from pydantic import BaseModel
@@ -38,6 +40,56 @@ class DashboardComparison(BaseModel):
success_rate: float
class DashboardPeriodUsers(BaseModel):
new: int
active: int
retained_new_users: int
retention_rate: float | None = None
retention_note: str
class DashboardPeriodComparison(BaseModel):
total: int
success: int
success_rate: float
ordered: int
average_duration_ms: int | None = None
average_saved_cents: int | None = None
class DashboardPeriodCoins(BaseModel):
granted_total: int
reward_video_coin_total: int = 0
feed_ad_coin_total: int = 0
signin_coin_total: int = 0
signin_boost_coin_total: int = 0
task_coin_total: int = 0
coupon_reward_coin_total: int = 0
comparison_reward_coin_total: int = 0
regular_task_coin_total: int = 0
class DashboardPeriodCash(BaseModel):
withdraw_success_cents: int
class DashboardTrendPoint(BaseModel):
date: date
active_users: int
new_users: int
comparisons: int
class DashboardPeriod(BaseModel):
date_from: date
date_to: date
users: DashboardPeriodUsers
comparison: DashboardPeriodComparison
coins: DashboardPeriodCoins
cash: DashboardPeriodCash
trend: list[DashboardTrendPoint] = []
class DashboardFeedback(BaseModel):
new: int
@@ -45,6 +97,12 @@ class DashboardFeedback(BaseModel):
class DashboardCps(BaseModel):
available: bool
note: str
meituan_order_count: int = 0
meituan_commission_cents: int = 0
meituan_hit_count: int = 0
meituan_miss_count: int = 0
meituan_unknown_rate_count: int = 0
meituan_hit_rate: float | None = None
class DashboardOverview(BaseModel):
@@ -52,5 +110,6 @@ class DashboardOverview(BaseModel):
coins: DashboardCoins
cash: DashboardCash
comparison: DashboardComparison
period: DashboardPeriod
feedback: DashboardFeedback
cps: DashboardCps
+54
View File
@@ -0,0 +1,54 @@
"""admin 设备存活监控 schemas(只读)。
数据源 device_liveness 表(见 app/models/device.py)。online / display_state /
offline_seconds 为后端按 HEARTBEAT_TIMEOUT_MINUTES 阈值派生的瞬态字段(非 DB 列),
在 repo 里算好挂到行上,供 from_attributes 读出。
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class DeviceLivenessItem(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int
# join user 取(瞬态;理论上 user 恒存在,留空仅防脏数据)
phone: str | None = None
nickname: str | None = None
device_id: str
device_model: str | None = None # 由 device_id 解析(device_<机型>_<hash>);非 DB 列
platform: str
app_version: str | None = None
registration_id: str | None = None # 非空 = 拿到极光 token、可推送
ever_protected: bool # 是否开过无障碍(=该设备对功能有意义)
first_protected_at: datetime | None = None # 首次开无障碍时刻(老设备为 null)
last_heartbeat_at: datetime | None = None
last_report_protection_on: bool # 心跳里上报的无障碍开关(恒 true,仅观测)
liveness_state: str # unknown / alive / silent(当前未用) / notified
notified_at: datetime | None = None
kill_alert_pending: bool # 掉线召回待客户端 ack(与 state 解耦)
created_at: datetime
updated_at: datetime
# ===== 后端派生(非 DB 列)=====
online: bool = False # ever_protected 且距上次心跳 ≤ 阈值
# 显示态:online=在线 / offline=已掉线 / never=从未启用(没开过无障碍)
display_state: str = "never"
# 掉线时长(秒):仅 offline 时有值;在线 / 从未启用为 None
offline_seconds: int | None = None
class DeviceLivenessStats(BaseModel):
"""顶部卡片:总设备数 + 在线 / 已掉线 / 未启用(按心跳阈值派生的 operator 口径,
不暴露内部状态机 unknown/alive/silent/notified)。"""
total: int
online: int
offline: int
never: int
+8 -5
View File
@@ -9,8 +9,8 @@
"""
from __future__ import annotations
import logging
import json
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status
@@ -18,8 +18,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from app.api.deps import CurrentUser, DbSession
from app.core import rewards
from app.core.config import settings
from app.integrations import pangle
from app.core.ratelimit import rate_limit
from app.integrations import pangle
from app.repositories import ad_ecpm as crud_ecpm
from app.repositories import ad_feed_reward as crud_feed
from app.repositories import ad_reward as crud_ad
@@ -285,12 +285,13 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
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,
app_env=payload.app_env, our_code_id=payload.our_code_id,
)
logger.info(
"ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s app=%s code=%s",
user.id, payload.ad_type, payload.ad_session_id, payload.ecpm, payload.adn, payload.slot_id,
payload.app_env, payload.our_code_id,
"ad ecpm report user_id=%d type=%s scene=%s session=%s ecpm=%s adn=%s slot=%s app=%s code=%s",
user.id, payload.ad_type, payload.feed_scene, payload.ad_session_id, payload.ecpm,
payload.adn, payload.slot_id, payload.app_env, payload.our_code_id,
)
return EcpmReportOut(ok=True)
@@ -401,6 +402,7 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
client_event_id=payload.client_event_id,
ecpm=payload.ecpm,
duration_seconds=payload.duration_seconds,
ad_type=payload.ad_type,
ad_session_id=payload.ad_session_id,
adn=payload.adn,
slot_id=payload.slot_id,
@@ -409,6 +411,7 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
app_env=payload.app_env,
our_code_id=payload.our_code_id,
aborted=payload.aborted,
display_coin=payload.display_coin,
)
logger.info(
"feed ad reward user_id=%d event=%s status=%s units=%d coin=%d",
+31
View File
@@ -0,0 +1,31 @@
"""客户端埋点上报接口。
POST /api/v1/analytics/events 批量接收新手引导(及后续)埋点,append analytics_event
**不强制登录**(未登录态也要采集行为):user_id 由客户端在 body 里可选带上,不靠 Bearer
服务端补 client_ip(X-Forwarded-For) created_at(接收时间 = server_at)
"""
from __future__ import annotations
from fastapi import APIRouter, Request
from app.api.deps import DbSession
from app.repositories import analytics as analytics_repo
from app.schemas.analytics import AnalyticsBatchIn, AnalyticsIngestOut
router = APIRouter(prefix="/api/v1/analytics", tags=["analytics"])
def _client_ip(request: Request) -> str:
"""取客户端 IP:生产经 nginx 反代优先 X-Forwarded-For 第一段,否则直连 IP(同 admin get_client_ip)。"""
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else ""
@router.post("/events", response_model=AnalyticsIngestOut, summary="批量上报埋点事件")
def ingest_events(
batch: AnalyticsBatchIn, request: Request, db: DbSession
) -> AnalyticsIngestOut:
n = analytics_repo.record_batch(db, batch, client_ip=_client_ip(request))
return AnalyticsIngestOut(received=n)
+2 -3
View File
@@ -40,9 +40,9 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
# 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。
SMS_LOGIN_MAX_PER_HOUR = 5
# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。比登录略宽(发码含正常重发);
# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。
# 堵「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞 —— 那两道是单号维度,一机换号能绕开。
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 10
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5
def _login_response(
@@ -90,7 +90,6 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
"/sms/send",
response_model=SmsSendResponse,
summary="发送短信验证码",
dependencies=[Depends(rate_limit(10, 60, "sms-send"))], # 同 IP 每分钟≤10 次(防一 IP 刷不同号)
)
def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
# 测试账号:不真发短信(号码非真实手机号,真发会失败/浪费),直接放行让客户端进入填码界面。
+5 -4
View File
@@ -26,6 +26,7 @@ import httpx
from fastapi import APIRouter, HTTPException, Request, status
from app.core.config import settings
from app.core.pricebot_client import get_pricebot_client
from app.core.pricebot_router import pick_pricebot
logger = logging.getLogger("shagua.compare")
@@ -65,10 +66,10 @@ async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}
)
client = get_pricebot_client()
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout
)
except httpx.RequestError as e:
logger.error("[pricebot] request failed: %s", e)
raise HTTPException(
+13
View File
@@ -21,6 +21,7 @@ from app.api.deps import CurrentUser, DbSession
from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
from app.repositories import comparison as crud_compare
from app.repositories import invite as crud_invite
from app.services.pricebot_llm_calls import fetch_llm_calls
from app.schemas.compare_record import (
CompareStatsOut,
@@ -52,6 +53,18 @@ def report_record(
# 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用
# 独立 session 按 record id 回填 llm_calls + 派生 llm_call_count/retry_count。
background_tasks.add_task(_backfill_llm_calls, rec.id, rec.trace_id)
# 邀请 v2 发奖:被邀请人完成一次【成功】比价 → 给邀请人发邀请奖励金(幂等,只发一次)。
# best-effort:发奖异常不影响比价上报本身(rec 已 commit),只 log;邀请人补偿靠后续对账。
if rec.status == "success":
try:
reward = crud_invite.try_reward_on_compare(db, user.id)
if reward.status == "granted":
logger.info(
"invite compare reward granted inviter=%s invitee=%s cents=%s",
reward.inviter_user_id, user.id, reward.reward_cents,
)
except Exception as e: # noqa: BLE001 best-effort,发奖失败不阻塞上报
logger.warning("invite compare reward failed invitee=%s: %s", user.id, e)
logger.info(
"compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)",
user.id,
+5 -4
View File
@@ -20,6 +20,7 @@ from fastapi.concurrency import run_in_threadpool
from app.api.deps import CurrentUser, DbSession
from app.core.config import settings
from app.core.pricebot_client import get_pricebot_client
from app.core.pricebot_router import pick_pricebot
from app.db.session import SessionLocal
from app.repositories import coupon_state as coupon_repo
@@ -141,10 +142,10 @@ async def coupon_step(
)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}
)
client = get_pricebot_client()
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout
)
except httpx.RequestError as e:
logger.error("[pricebot] request failed: %s", e)
raise HTTPException(
+9 -2
View File
@@ -38,7 +38,7 @@ logger = logging.getLogger("shagua.invite")
router = APIRouter(prefix="/api/v1/invite", tags=["invite"])
_BIND_MESSAGES = {
"success": "邀请绑定成功,金币已到账",
"success": "邀请绑定成功",
"already_bound": "你已绑定过邀请人",
"invalid_code": "邀请码无效",
"self_invite": "不能填写自己的邀请码",
@@ -84,6 +84,8 @@ def _parse_device_model(ua: str) -> str:
def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
code = invite_repo.ensure_code(db, user)
invited, coins = invite_repo.get_stats(db, user.id)
reward_balance, reward_withdrawn = invite_repo.get_reward_stats(db, user.id)
days_left, is_fresh_round, countdown_text = invite_repo.compute_invite_countdown(user.created_at)
sep = "&" if "?" in settings.INVITE_LANDING_URL else "?"
share_url = f"{settings.INVITE_LANDING_URL}{sep}ref={code}"
return InviteInfoOut(
@@ -91,6 +93,11 @@ def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
share_url=share_url,
invited_count=invited,
coins_earned=coins,
reward_balance_cents=reward_balance,
reward_withdrawn_cents=reward_withdrawn,
countdown_days_left=days_left,
countdown_is_fresh_round=is_fresh_round,
countdown_text=countdown_text,
)
@@ -154,7 +161,7 @@ def landing_track(
return LandingTrackOut(status="ok")
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,双方发金币)")
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,绑定不发奖)")
def bind_invite(
req: BindInviteIn, user: CurrentUser, db: DbSession, request: Request
) -> BindInviteOut:
+2 -2
View File
@@ -63,8 +63,8 @@ def ad_config(db: DbSession) -> AdConfigPublicOut:
return AdConfigPublicOut(
app_id=c["app_id"],
reward_code_id=c["reward_code_id"],
compare_feed_code_id=c["compare_feed_code_id"],
coupon_feed_code_id=c["coupon_feed_code_id"],
compare_draw_code_id=c["compare_draw_code_id"],
coupon_draw_code_id=c["coupon_draw_code_id"],
reward_enabled=c["reward_enabled"],
compare_ad_enabled=c["compare_ad_enabled"],
coupon_ad_enabled=c["coupon_ad_enabled"],
+4 -2
View File
@@ -200,7 +200,8 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="wechat pay not configured")
try:
order = crud_wallet.create_withdraw(
db, user.id, req.amount_cents, user_name=req.user_name, out_bill_no=req.out_bill_no
db, user.id, req.amount_cents, source=req.source,
user_name=req.user_name, out_bill_no=req.out_bill_no,
)
except crud_wallet.InvalidWithdrawAmountError as e:
raise HTTPException(
@@ -274,10 +275,11 @@ def withdraw_status(
def withdraw_orders(
user: CurrentUser,
db: DbSession,
source: str | None = Query(None, description="按账户来源过滤:coin_cash / invite_cash;不传=全部"),
limit: int = Query(20, ge=1, le=100),
cursor: int | None = Query(None, description="上一页末条 id"),
) -> WithdrawOrderPage:
items, next_cursor = crud_wallet.list_withdraw_orders(db, user.id, limit=limit, cursor=cursor)
items, next_cursor = crud_wallet.list_withdraw_orders(db, user.id, source=source, limit=limit, cursor=cursor)
return WithdrawOrderPage(
items=[WithdrawOrderOut.model_validate(it) for it in items],
next_cursor=next_cursor,
+35
View File
@@ -0,0 +1,35 @@
"""透传到 pricebot 的共享 httpx.AsyncClient 单例。
为什么不能每请求新建(coupon.py / compare.py 老写法 async with httpx.AsyncClient(...)):
每次构造都重建一套 SSL 上下文(httpx.create_ssl_context 加载 certifi CA),实测
~1s+/; pricebot 是纯 http 透传,根本用不到 TLS 纯浪费,且每帧重交一次
trust_env 默认 True 会读进程 HTTP_PROXY, http://localhost:8000 这条本地透传整个
塞进本机代理( Clash 7897),恒定再多几秒
单例:启动只建一次(SSL/连接池一次性),keep-alive 复用 TCP,每帧降到个位数 ms
trust_env=False:对齐 integrations/meituan.py 的既有约定,不被进程代理误导,直连 pricebot
"""
from __future__ import annotations
import httpx
_client: httpx.AsyncClient | None = None
def get_pricebot_client() -> httpx.AsyncClient:
"""取透传单例。lifespan 启动会预热;未预热(如测试态)懒建兜底。
超时不在此固化(coupon 30s / compare 60s 不同),由调用点 client.post(timeout=...)
懒建无 await,asyncio 单线程下不会有并发竞态
"""
global _client
if _client is None:
_client = httpx.AsyncClient(trust_env=False)
return _client
async def aclose_pricebot_client() -> None:
"""lifespan 关停时调,优雅关连接池。"""
global _client
if _client is not None:
await _client.aclose()
_client = None
+10 -4
View File
@@ -113,10 +113,9 @@ PRICE_REPORT_REWARD_COINS: int = 1000
FEEDBACK_REWARD_MAX_COINS: int = 10000
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。
INVITE_INVITER_COINS: int = 10000
INVITE_INVITEE_COINS: int = 10000
# ===== 邀请好友(绑定即生效,绑定只建归因关系、双方都不发钱)=====
# v3(冰 2026-06-26):废除 v1 的"绑定双方各发金币"——被邀请人无奖励、邀请人改比价后发现金
# (见下方 INVITE_COMPARE_REWARD_CENTS)。原 INVITE_INVITER_COINS / INVITE_INVITEE_COINS 已删。
# "新用户闸":被邀请人必须在注册后此窗口内绑定才发奖(挡存量老用户互相填码薅羊毛)。
# 自动绑(剪贴板)在首次注册登录后几秒内发生;留 72h 给手动填码兜底。
INVITE_NEW_USER_WINDOW_HOURS: int = 72
@@ -127,6 +126,13 @@ INVITE_NEW_USER_WINDOW_HOURS: int = 72
INVITE_FP_WINDOW_DAYS: int = 7
# ===== 邀请好友 v2(好友"下载+登录+比价一次"→ 给邀请人发邀请奖励金·现金)=====
# v2 新规则:不再注册即发金币,改"好友完成首次成功比价"才给【邀请人】发奖,发的是【现金·分】
# 进独立的邀请奖励金账户(coin_account.invite_cash_balance_cents),与金币体系物理隔离。
# 200 分 = 2 元。⚠️ 金额待产品定准:邀请主页=2元 / 福利入口=3.5元 不一致,定后改此处。
INVITE_COMPARE_REWARD_CENTS: int = 200
# ===== 看激励视频 / 信息流广告发金币 =====
# eCPM 取自穿山甲 SDK getShowEcpm().getEcpm(),官方口径单位是【分/千次展示】(不是元!
# csjplatform 文档原文"通过 getEcpm 获取的单位是分")。计算时先 ÷100 转成元;
+3 -1
View File
@@ -14,7 +14,9 @@ worker / 多机时内存不共享 → 冷却、每日上限、校验都会失效
防刷三层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)
2. 单号每日 `SMS_DAILY_LIMIT_PER_PHONE` 条上限(本文件)
3. IP 频控(api rate_limit 依赖)+ 极光控制台 IP 白名单/防轰炸(运维侧)
3. 设备(device_id)每小时频控(api auth.sms_send enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)
IP 频控(rate_limit 依赖)2026-06-26 按产品要求删除改设备维度; device_id 客户端可伪造/轮换,
脚本轮换 id 能绕过本层 挡脚本狂发主要靠极光控制台侧(+ 可选 nginx 限流)
:单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废(防爆破),验过即作废(一次性)
"""
from __future__ import annotations
+5
View File
@@ -15,6 +15,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.api.v1.ad import router as ad_router
from app.api.v1.analytics import router as analytics_router
from app.api.v1.auth import router as auth_router
from app.api.v1.compare import router as compare_router
from app.api.v1.compare_milestone import router as compare_milestone_router
@@ -48,6 +49,7 @@ from app.core.daily_exchange_worker import (
stop_daily_exchange_worker,
)
from app.core.logging import setup_logging
from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client
from app.core.withdraw_reconcile_worker import (
start_withdraw_reconcile_worker,
stop_withdraw_reconcile_worker,
@@ -67,6 +69,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings.APP_DEBUG,
settings.DATABASE_URL.split("://", 1)[0],
)
get_pricebot_client() # 预热透传 client:把建 SSL 上下文的一次性成本付在启动,首个领券请求即热
reconcile_task = start_withdraw_reconcile_worker()
heartbeat_task = start_heartbeat_monitor()
daily_exchange_task = start_daily_exchange_worker()
@@ -76,6 +79,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
await stop_heartbeat_monitor(heartbeat_task)
await stop_withdraw_reconcile_worker(reconcile_task)
await stop_daily_exchange_worker(daily_exchange_task)
await aclose_pricebot_client()
logger.info("shutting down")
@@ -105,6 +109,7 @@ def health() -> dict[str, str]:
app.include_router(auth_router)
app.include_router(user_router)
app.include_router(feedback_router)
app.include_router(analytics_router)
app.include_router(invite_router)
app.include_router(coupon_router)
app.include_router(device_router)
+3
View File
@@ -4,6 +4,7 @@ from app.models.ad_feed_reward import AdFeedRewardRecord # noqa: F401
from app.models.ad_reward import AdRewardRecord # noqa: F401
from app.models.ad_watch_log import AdWatchLog # noqa: F401
from app.models.admin import AdminAuditLog, AdminUser # noqa: F401
from app.models.analytics_event import AnalyticsEvent # noqa: F401
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
@@ -18,6 +19,7 @@ from app.models.coupon_state import ( # noqa: F401
CouponDailyCompletion,
CouponPromptEngagement,
)
from app.models.cps_order import CpsOrder # noqa: F401
from app.models.feedback import Feedback # noqa: F401
from app.models.invite import InviteRelation # noqa: F401
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
@@ -37,5 +39,6 @@ from app.models.wallet import ( # noqa: F401
CashTransaction,
CoinAccount,
CoinTransaction,
InviteCashTransaction,
WithdrawOrder,
)
+3
View File
@@ -29,6 +29,9 @@ class AdEcpmRecord(Base):
)
# 广告类型:reward_video(激励视频) / draw(Draw 信息流) 等;不强行统一代码位,各类型各自上报
ad_type: Mapped[str] = mapped_column(String(32), nullable=False)
# 点位场景:comparison(比价) / coupon(领券) / welfare(福利),供收益报表区分比价/领券 Draw 收益;
# 仅信息流/Draw 上报(比价与领券共用同一代码位,只能客户端各调用点显式打标),激励视频为 NULL。
feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True)
# 客户端生成的一次广告会话 id;激励视频 S2S 回调 extra 会透传同值
ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# 实际投放的 ADN(穿山甲 getShowEcpm().getSdkName(),如 pangle / gdt)
+3
View File
@@ -30,6 +30,9 @@ class AdFeedRewardRecord(Base):
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 广告类型:feed(信息流) / draw(Draw 信息流)。比价与领券共用同一 Draw 代码位,靠 feed_scene
# 区分收益;ad_type 区分广告形态。旧数据(未升级客户端)为 NULL,一律视为 feed,保持向后兼容。
ad_type: Mapped[str | None] = mapped_column(String(16), nullable=True, default="feed")
# 点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页)。比价与领券共用同一信息流
# 代码位,slot_id/our_code_id 分不出,只能客户端各调用点显式打标;NULL=历史/未升级客户端=未分类。
feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True)
+60
View File
@@ -0,0 +1,60 @@
"""新手引导(及后续)埋点事件表。
每行 = 客户端上报的一条行为埋点,Who / When / Where / What / How五维组织:
- What :event(事件名, video_play)+ props(事件专属属性,JSON)
- Who :device_id(硬件级设备标识)+ user_id(登录后才有,可空)
- When :client_ts(端事件时间 epoch ms)+ session_id(本次引导会话)+ sent_at(端上报时间)
+ created_at(服务端接收时间 = server_at)
- Where:page(引导步/页面)+ client_ip(服务端从 X-Forwarded-For )
- How :oem / os / model / app_ver / network / channel(设备与环境)
append-only,不更新;客户端批量上报( app/api/v1/analytics.py),admin 同库直接查(
app/admin/routers/event_logs.py)未登录态也允许上报(user_id 为空), user_id 不设外键只索引
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, BigInteger, DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class AnalyticsEvent(Base):
__tablename__ = "analytics_event"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# ---- What:做了什么 ----
event: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
props: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# ---- Who:谁 ----
device_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
# ---- When:何时 ----
session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
client_ts: Mapped[int] = mapped_column(BigInteger, nullable=False) # 端事件时间 epoch ms
sent_at: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # 端上报时间 epoch ms
# ---- Where:何地 ----
page: Mapped[str | None] = mapped_column(String(64), nullable=True)
client_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
# ---- How:用什么环境 ----
oem: Mapped[str | None] = mapped_column(String(32), nullable=True)
os: Mapped[str | None] = mapped_column(String(32), nullable=True)
model: Mapped[str | None] = mapped_column(String(64), nullable=True)
app_ver: Mapped[str | None] = mapped_column(String(32), nullable=True)
network: Mapped[str | None] = mapped_column(String(16), nullable=True)
channel: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 服务端接收时间(= When.server_at);客户端时间不可信,以此为权威落库时刻。
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"<AnalyticsEvent id={self.id} event={self.event} device={self.device_id}>"
+5
View File
@@ -51,6 +51,11 @@ class DeviceLiveness(Base):
ever_protected: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
# 首次开无障碍(首次收到 accessibility_enabled 心跳)的时刻;ever_protected 第一次翻 true 时记一次,
# 后续心跳不覆盖。老设备(迁移前已 protected)无此值 → NULL。
first_protected_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# 最近一次 service 心跳时间(存活证明);超时即视为保护掉线
last_heartbeat_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), index=True, nullable=True
+15 -1
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, false, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
@@ -35,6 +35,20 @@ class InviteRelation(Base):
inviter_coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
invitee_coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# ===== v2 比价发奖追踪(好友"下载+登录+比价一次"→ 给邀请人发邀请奖励金)=====
# 是否已因"好友完成比价"发过奖:好友比价多次只发一次(防重复发,与 invitee 唯一约束双保险)
compare_reward_granted: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=false()
)
# 实发给邀请人的邀请奖励金(分);未发为 0
compare_reward_cents: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
# 发奖时间(未发为 None)
compare_rewarded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
+48
View File
@@ -25,6 +25,11 @@ class CoinAccount(Base):
)
coin_balance: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 邀请奖励金余额(分)——与金币兑换来的 cash_balance_cents **物理隔离**(产品红线:
# 邀请奖励金 ≠ 看广告/金币现金,两本账不可累加)。好友比价发奖入账、提现出账走它。
invite_cash_balance_cents: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
# 累计赚取的金币(只增不减),用于"历史总收益"类展示
total_coin_earned: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
@@ -108,6 +113,11 @@ class WithdrawOrder(Base):
# 商户单号(我们生成,微信查单的 out_bill_no),唯一
out_bill_no: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
# 这笔提现扣的是哪个账户:coin_cash(金币兑换的现金) / invite_cash(邀请奖励金)。
# 退款时据此退回**对应**账户,两本账不串。旧单默认 coin_cash。
source: Mapped[str] = mapped_column(
String(16), nullable=False, default="coin_cash", server_default="coin_cash"
)
# 提现实名(微信达额转账要求):审核后异步打款时要用,发起提现时存下,可空
user_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 归一化状态:reviewing(待审核) / pending(打款在途) / success / failed(打款失败已退) / rejected(审核拒绝已退)
@@ -200,3 +210,41 @@ class CashTransaction(Base):
def __repr__(self) -> str: # pragma: no cover
return f"<CashTransaction id={self.id} user_id={self.user_id} cents={self.amount_cents}>"
class InviteCashTransaction(Base):
"""邀请奖励金流水(单位:分)。与 cash_transaction(金币兑换现金)**物理隔离**——
产品红线:邀请奖励金 看广告/金币现金,两本账不可累加各自提现
入账=好友比价发奖(invite_reward),出账=提现(invite_withdraw)/退款(invite_withdraw_refund)
结构与 cash_transaction 同构,balance_after_cents 记的是 coin_account.invite_cash_balance_cents"""
__tablename__ = "invite_cash_transaction"
__table_args__ = (
# 提现退款幂等:一个提现单只退一次(同 cash_transaction 的 withdraw_refund 去重)
Index(
"ux_invite_cash_txn_refund_ref",
"ref_id",
unique=True,
sqlite_where=text("biz_type = 'invite_withdraw_refund' AND ref_id IS NOT NULL"),
postgresql_where=text("biz_type = 'invite_withdraw_refund' AND ref_id IS NOT NULL"),
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 正数=入账(发奖),负数=出账(提现)
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
balance_after_cents: Mapped[int] = mapped_column(Integer, nullable=False)
# 业务类型:invite_reward / invite_withdraw / invite_withdraw_refund
biz_type: Mapped[str] = mapped_column(String(32), nullable=False)
ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
remark: Mapped[str | None] = mapped_column(String(128), nullable=True)
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"<InviteCashTransaction id={self.id} user_id={self.user_id} cents={self.amount_cents}>"
+26 -5
View File
@@ -23,6 +23,7 @@ def create_ecpm_record(
ad_session_id: str | None = None,
adn: str | None = None,
slot_id: str | None = None,
feed_scene: str | None = None,
app_env: str | None = None,
our_code_id: str | None = None,
) -> AdEcpmRecord:
@@ -41,6 +42,7 @@ def create_ecpm_record(
ad_session_id=ad_session_id,
adn=adn,
slot_id=slot_id,
feed_scene=feed_scene,
app_env=app_env,
our_code_id=our_code_id,
ecpm_raw=ecpm_raw,
@@ -51,11 +53,16 @@ def create_ecpm_record(
db.commit()
except IntegrityError:
db.rollback()
if ad_session_id:
existing = find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
if existing is not None:
return existing
raise
# 撞唯一约束 uq_ad_ecpm_record_session(全局按 ad_session_id、不含 user_id):并发同会话重复上报,
# 或同一 ad_session_id 已被先到的上报占用。本接口 fire-and-forget、best-effort —— 丢一条不影响业务
# (穿山甲后台才是结算权威),绝不向客户端抛 500。兜底查找须与唯一约束**同口径**(只按 ad_session_id、
# 不带 user_id):否则不同 user 上报了同一 ad_session_id 时,带 user_id 的查找会漏掉那条别人的记录 →
# 旧逻辑在此 raise 成 500(本应静默吞掉)。
existing = _find_by_session_global(db, ad_session_id)
if existing is not None:
return existing
# 极少:rollback 后既存记录又查不到(并发删除 / 竞态)。吞掉、返回未入库的内存对象(调用方不读返回值)。
return rec
db.refresh(rec)
return rec
@@ -74,6 +81,20 @@ def find_by_session(
).scalar_one_or_none()
def _find_by_session_global(db: Session, ad_session_id: str | None) -> AdEcpmRecord | None:
"""按 ad_session_id **全局**查找(与唯一约束 uq_ad_ecpm_record_session 同口径,不含 user_id)。
create_ecpm_record 撞约束后兜底用:此时撞的是全局会话约束,既存记录可能属于**另一个 user**,
user_id find_by_session 会漏掉它导致误判查无 raise 500其它业务查 user 的某次
展示 eCPM仍用 find_by_session( user_id,语义更准),不走这里
"""
if not ad_session_id:
return None
return db.execute(
select(AdEcpmRecord).where(AdEcpmRecord.ad_session_id == ad_session_id)
).scalar_one_or_none()
def count_today(db: Session, user_id: int) -> int:
"""该用户今日(北京时间)上报的 eCPM 条数,排查/对账辅助用。"""
return db.execute(
+34 -18
View File
@@ -14,7 +14,6 @@ from app.core.rewards import cn_today
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.repositories import wallet as crud_wallet
FEED_REWARD_UNIT_SECONDS = 10
# 单个 feed 事件的时长上限(秒):一期 duration_seconds 由客户端上报,伪造超长时长会刷份数
# (每 10 秒 1 份)。真实单条信息流视频远小于此;取 120s=12 份封顶,挡刷量、不影响正规单。
@@ -65,6 +64,7 @@ def grant_feed_reward(
client_event_id: str,
ecpm: str,
duration_seconds: int,
ad_type: str = "feed",
ad_session_id: str | None = None,
adn: str | None = None,
slot_id: str | None = None,
@@ -73,17 +73,22 @@ def grant_feed_reward(
app_env: str | None = None,
our_code_id: str | None = None,
aborted: bool = False,
display_coin: int = 0,
) -> AdFeedRewardRecord:
"""**每条**信息流广告(客户端每条各上报一次)结算奖励。client_event_id 幂等,同号重试不重复发。
发奖规则:**一条广告 = 一个单次公式值**(rewards.calculate_ad_reward_coin),因子2(LT)按账号累计
****数递进;看满一份时长(unit_count>=1, 10 )才发,**不逐份累加**
发奖规则(所见即所得, 2026-06-27 用户拍板显示多少给多少):优先**直接发客户端小球显示的金币
display_coin**;防刷钳到本条1 份满额(eCPM 已钳 AD_ECPM_MAX_FEN, 因子2 按账号累计已发条数取档),
合法显示(实际因子2 × 进度 p 1 )不被砍, 只挡伪造天价值旧客户端不传 display_coin 时退回
看满 10 秒发整份(兼容不断币)因子2(LT)**客户端** granted COUNT(拉自 /feed-reward/units)
算进 display_coin, 后端只记 granted 行让该计数自增, 不再服务端重算份值
- aborted=True(用户中途 关闭这条):本条不发, status='closed_early'
- 时长不足 10 (unit_count==0): status='too_short' 不发
- display_coin 0 且时长不足一份: status='too_short' 不发(不计 LT / 当日上限)
- 命中当日条数上限: status='capped' 不发
duration_seconds **这一条**的观看秒数服务端两道硬闸防刷:时长钳到 FEED_MAX_DURATION_SECONDS
eCPM calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN;叠加每日 get_ad_daily_limit 条数上限
duration_seconds 落库留痕(unit_count 字段), 旧端兼容路径据它判是否满 1
feed_scene:点位场景(comparison/coupon/welfare),仅归类落库,不参与计算
ad_type:广告形态(feed 信息流 / draw Draw 信息流),仅归类落库;**每日上限与因子2(LT)仍按本表
全表 unit 累计(feed+draw 共享同一发奖池/上限),不按 ad_type 拆分**
"""
existing = _find_by_event(db, client_event_id)
if existing is not None:
@@ -106,6 +111,7 @@ def grant_feed_reward(
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
ad_type=ad_type,
feed_scene=feed_scene,
trace_id=trace_id,
app_env=app_env,
@@ -126,6 +132,7 @@ def grant_feed_reward(
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
ad_type=ad_type,
feed_scene=feed_scene,
trace_id=trace_id,
app_env=app_env,
@@ -135,18 +142,31 @@ def grant_feed_reward(
)
return _commit_record(db, rec, client_event_id)
# 整场总时长不足 10 秒,凑不满一份 → 不发,记 too_short 留痕。
if unit_count == 0:
# 所见即所得(用户 2026-06-27「显示多少给多少」): 优先发**客户端小球显示**的金币 display_coin,
# 钳到本条「1 份满额」防刷(eCPM 已钳 AD_ECPM_MAX_FEN; 合法显示=因子2×p≤1份, 不会被砍)。
# 因子2(LT)按账号累计已发条数(granted 行 COUNT), 第 existing_ads+1 条。
existing_ads = granted_unit_total(db, user_id)
unit_cap = rewards.calculate_ad_reward_coin(ecpm, existing_ads + 1)
if display_coin > 0:
coin = min(display_coin, unit_cap) # 新端: 所见即所得(直接发小球显示金币)
elif unit_count >= 1:
coin = unit_cap # 旧端没传 display_coin: 退回「看满 1 份发整份」(兼容)
else:
coin = 0
# 显示金币为 0 且没满一份 → 不发, 记 too_short 留痕(不写 granted 行 → 不计 LT / 当日上限)。
if coin <= 0:
rec = AdFeedRewardRecord(
client_event_id=client_event_id,
user_id=user_id,
reward_date=today,
duration_seconds=safe_duration,
unit_count=0,
unit_count=unit_count,
ad_session_id=ad_session_id,
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
ad_type=ad_type,
feed_scene=feed_scene,
trace_id=trace_id,
app_env=app_env,
@@ -156,15 +176,11 @@ def grant_feed_reward(
)
return _commit_record(db, rec, client_event_id)
# 一条广告 = 一个「单次公式值」(因子2 按账号累计**条**数, 即第 existing_ads+1 条);看满一份(unit_count>=1)即发,不逐份累加。
existing_ads = granted_unit_total(db, user_id)
coin = rewards.calculate_ad_reward_coin(ecpm, existing_ads + 1)
if coin > 0:
crud_wallet.grant_coins(
db, user_id, coin,
biz_type="feed_ad_reward", ref_id=client_event_id,
remark="信息流广告奖励",
)
crud_wallet.grant_coins(
db, user_id, coin,
biz_type="feed_ad_reward", ref_id=client_event_id,
remark="信息流广告奖励",
)
rec = AdFeedRewardRecord(
client_event_id=client_event_id,
user_id=user_id,
+34
View File
@@ -0,0 +1,34 @@
"""埋点事件批量落库。append-only,一次 commit 提交整批。"""
from __future__ import annotations
from sqlalchemy.orm import Session
from app.models.analytics_event import AnalyticsEvent
from app.schemas.analytics import AnalyticsBatchIn
def record_batch(db: Session, batch: AnalyticsBatchIn, *, client_ip: str | None) -> int:
"""把一批上报事件展开成多行落库(公共维度复制到每行),返回写入条数。"""
rows = [
AnalyticsEvent(
event=e.event,
props=e.props or None,
device_id=batch.device_id,
user_id=batch.user_id,
session_id=e.session_id,
client_ts=e.client_ts,
sent_at=batch.sent_at,
page=e.page,
client_ip=client_ip or None,
oem=batch.oem,
os=batch.os,
model=batch.model,
app_ver=batch.app_ver,
network=e.network,
channel=batch.channel,
)
for e in batch.events
]
db.add_all(rows)
db.commit()
return len(rows)
+4 -4
View File
@@ -101,10 +101,10 @@ AD_CONFIG_KEY = "ad_config"
_AD_CONFIG_DEFAULTS: dict[str, Any] = {
"app_id": "5830519", # 穿山甲应用ID(正式)
"reward_code_id": "104099389", # 福利页激励视频位
# ⚠️ 2026-06-21 真机核对穿山甲后台:5830519 名下信息流真实位是 104142227「信息流 1」;
# 旧值 104090333 不在该应用名下(请求会报 44406/配置 null、出不了广告)。客户端接入下发后以本值为准
"compare_feed_code_id": "104142227", # 比价信息流位
"coupon_feed_code_id": "104142227", # 领券信息流位(初始同比价,运营可拆)
# 比价与领券共用同一穿山甲 Draw 代码位(默认 104098712 = 后台「Draw信息流」位),靠 feed_scene
# (comparison/coupon)区分收益;运营可在后台把两者拆成不同代码位。字段从旧 *_feed_code_id 改名为 *_draw_code_id
"compare_draw_code_id": "104098712", # 比价 Draw 代码位(104098712 = Draw 信息流位)
"coupon_draw_code_id": "104098712", # 领券 Draw 代码位(初始同比价,运营可拆)
"reward_mkey": "", # 激励位 GroMore 验签密钥(空则回退 .env PANGLE_REWARD_SECRET*)
"reward_enabled": True, # 福利激励视频开关
"compare_ad_enabled": True, # 比价广告开关
+2
View File
@@ -72,6 +72,8 @@ def touch_heartbeat(
device.last_report_protection_on = accessibility_enabled
if accessibility_enabled:
if not device.ever_protected:
device.first_protected_at = now # 首次开无障碍记一次,后续心跳不覆盖
device.last_heartbeat_at = now
device.ever_protected = True
device.liveness_state = "alive"
+111 -27
View File
@@ -1,13 +1,14 @@
"""好友邀请 CRUD(注册即生效,邀请人 + 被邀请人各发金币)。
"""好友邀请 CRUD(注册即生效,绑定只建归因关系、双方都不发钱)。
发奖规则(v3, 2026-06-26 拍板):
- 绑定时双方都不发钱(被邀请人无奖励邀请人不发金币)
- 邀请人的钱由 try_reward_on_compare 在好友"成功比价一次"后发 2 元邀请奖励金(防刷)
- v1 "绑定双方各发金币"已废;invite_relation inviter_coin/invitee_coin 列保留恒 0(待清)
防重复发奖三道(仿 ad_reward / 提现的资金安全思路):
1. invitee_user_id 唯一 一个被邀请人只能被绑定一次(幂等键)
2. 自邀屏蔽 inviter == invitee 直接拒
3. 现成的手机号唯一(每个被邀请人 = 一个真实手机号账号)= 天然限制刷量规模
发金币复用 wallet.grant_coins(grant flush commit),与建关系记录在**同一事务**
commit,保证"建关系 + 双方加金币"原子奖励额 = rewards.INVITE_INVITER_COINS /
INVITE_INVITEE_COINS
"""
from __future__ import annotations
@@ -34,6 +35,30 @@ def _gen_code() -> str:
return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LEN))
# ===== v2 邀请倒计时(7 天 1 轮,锚点=注册日,自然日差,东八区)=====
# 中国不用夏令时,固定 +8 偏移即可(不依赖 tzdata,Windows 本地联调也稳)。
_CST = timezone(timedelta(hours=8))
def compute_invite_countdown(register_at: datetime) -> tuple[int, bool, str]:
"""按 7 天 1 轮算邀请页倒计时。
锚点 = 用户注册日(user.created_at),按东八区自然日差算
(跨自然日才减同日多次登录不减,天然满足)返回:
(本轮剩余天数 1..7, 是否刚进入新一轮[非首轮第1天], 展示文案)
"""
reg = register_at if register_at.tzinfo else register_at.replace(tzinfo=timezone.utc)
days_since = max(0, (datetime.now(_CST).date() - reg.astimezone(_CST).date()).days)
day_in_cycle = days_since % 7 # 0..6(本轮第几天,0-based)
days_left = 7 - day_in_cycle # 7..1(第1天=7、第7天=1)
is_fresh_round = days_since >= 7 and day_in_cycle == 0 # 非首轮的第1天
if is_fresh_round:
text = "恭喜您进入新一轮邀请!\n距离本轮结束还有7天"
else:
text = f"距本轮结束还有{days_left}"
return days_left, is_fresh_round, text
def ensure_code(db: Session, user: User) -> str:
"""保证 user 有邀请码(懒生成),返回它。唯一约束碰撞则换码重试。
@@ -90,15 +115,17 @@ def _is_new_user(user: User) -> bool:
class BindResult:
status: str # success / already_bound / invalid_code / self_invite / not_eligible
relation: InviteRelation | None = None
invitee_coin: int = 0 # 本次给被邀请人发的金币(success 时 >0)
invitee_coin: int = 0 # v3 起恒 0(绑定不再发金币);保留字段兼容响应
def bind(
db: Session, *, invitee: User, invite_code: str, channel: str = "clipboard"
) -> BindResult:
"""把 invitee 绑定到 invite_code 对应的邀请人,注册即生效 + 双方发金币
"""把 invitee 绑定到 invite_code 对应的邀请人,注册即生效。
幂等:invitee 已被绑过 already_bound(不重复发奖)
v3 发奖( 2026-06-26 拍板):绑定双方都不发钱被邀请人无奖励,邀请人改"好友成功比价一次
才发 2 元邀请奖励金"(见 try_reward_on_compare,防刷)。绑定只建归因关系 + 跑防刷闸。
幂等:已绑过 already_bound
"""
# 幂等:已绑过直接返回(不重复发奖)
existing = _relation_of_invitee(db, invitee.id)
@@ -114,27 +141,18 @@ def bind(
if not _is_new_user(invitee):
return BindResult("not_eligible")
inviter_coin = rewards.INVITE_INVITER_COINS
invitee_coin = rewards.INVITE_INVITEE_COINS
# v3(冰 2026-06-26 拍板):绑定双方都不发钱。被邀请人不再发新人金币(去掉拉新即时激励);
# 邀请人的钱由 try_reward_on_compare 在好友成功比价后发 2 元邀请奖励金(防刷)。
rel = InviteRelation(
inviter_user_id=inviter.id,
invitee_user_id=invitee.id,
channel=(channel or "clipboard")[:16],
status="effective",
inviter_coin=inviter_coin,
invitee_coin=invitee_coin,
inviter_coin=0, # v1 金币线已停用,列保留恒 0(待清)
invitee_coin=0, # v3:被邀请人绑定不再发金币
)
db.add(rel)
# 双方发金币(同事务,与建关系一起 commit)。ref_id 互指对方便于对账
crud_wallet.grant_coins(
db, inviter.id, inviter_coin,
biz_type="invite_inviter", ref_id=str(invitee.id), remark="邀请好友奖励",
)
crud_wallet.grant_coins(
db, invitee.id, invitee_coin,
biz_type="invite_invitee", ref_id=str(inviter.id), remark="新人受邀奖励",
)
# 绑定不发任何金币(邀请人改比价发现金、被邀请人无奖励),仅建归因关系
try:
db.commit()
except IntegrityError:
@@ -145,16 +163,62 @@ def bind(
return BindResult("already_bound", existing)
raise
except Exception:
# 其它 commit 失败(DB 故障 / PG 序列化冲突等):显式回滚,保证"建关系 + 双方发币"
# 原子(要么全成要么全无),不依赖 get_db 关闭时的隐式回滚,语义更硬。
# 其它 commit 失败(DB 故障 / PG 序列化冲突等):显式回滚,保证建关系原子,
# 不依赖 get_db 关闭时的隐式回滚,语义更硬。
db.rollback()
raise
db.refresh(rel)
return BindResult("success", rel, invitee_coin)
return BindResult("success", rel)
@dataclass
class CompareRewardResult:
status: str # granted / no_relation / already_granted / inviter_inactive
inviter_user_id: int | None = None
reward_cents: int = 0
def try_reward_on_compare(db: Session, invitee_user_id: int) -> CompareRewardResult:
"""被邀请人完成一次成功比价时调用:若其有邀请关系且尚未发过比价奖,给【邀请人】发邀请奖励金。
v2 发奖规则核心(替代 v1 "注册即发金币"):好友"下载+登录+比价一次" 邀请人得 2 元现金
幂等:compare_reward_granted 标记保证好友比价多次只发一次无邀请关系 / 已发过 / 邀请人失效
空操作发奖(grant_invite_cash 入账独立账户)+ 置标记同事务 commit,保证原子
"""
rel = _relation_of_invitee(db, invitee_user_id)
if rel is None:
return CompareRewardResult("no_relation")
if rel.compare_reward_granted:
return CompareRewardResult("already_granted", rel.inviter_user_id)
inviter = db.get(User, rel.inviter_user_id)
if inviter is None or inviter.status != "active":
# 邀请人注销 / 封禁:本次不发、不置标记,待其恢复后下次比价再试(保守,不吞奖励)
return CompareRewardResult("inviter_inactive", rel.inviter_user_id)
reward = rewards.INVITE_COMPARE_REWARD_CENTS
rel.compare_reward_granted = True
rel.compare_reward_cents = reward
rel.compare_rewarded_at = datetime.now(timezone.utc)
# 发邀请奖励金到邀请人的独立账户(与金币隔离),ref_id 指向被邀请人便于对账
crud_wallet.grant_invite_cash(
db, inviter.id, reward,
biz_type="invite_reward", ref_id=str(invitee_user_id), remark="好友比价奖励",
)
try:
db.commit()
except Exception:
db.rollback()
raise
return CompareRewardResult("granted", inviter.id, reward)
def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
"""返回 (已成功邀请人数, 累计从邀请获得的金币)。"""
"""返回 (已成功邀请人数, 累计从邀请获得的金币)。
金币口径(inviter_coin 之和) v3 起恒 0(邀请人收益改走邀请奖励金, get_reward_stats /
try_reward_on_compare);保留返回位兼容旧响应字段 coins_earned
"""
count = db.execute(
select(func.count())
.select_from(InviteRelation)
@@ -167,6 +231,26 @@ def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
return int(count), int(coins)
def get_reward_stats(db: Session, inviter_id: int) -> tuple[int, int]:
"""v2 邀请奖励金战绩:(可提现余额/分, 累计提现成功/分)。
余额 = coin_account.invite_cash_balance_cents;累计提现 = 该用户 source=invite_cash
status=success 的提现单金额之和(成功打款才算) /invite/me 提现板块展示
"""
from app.models.wallet import CoinAccount, WithdrawOrder
balance = db.execute(
select(CoinAccount.invite_cash_balance_cents).where(CoinAccount.user_id == inviter_id)
).scalar_one_or_none()
withdrawn = db.execute(
select(func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)).where(
WithdrawOrder.user_id == inviter_id,
WithdrawOrder.source == "invite_cash",
WithdrawOrder.status == "success",
)
).scalar_one()
return int(balance or 0), int(withdrawn)
def _mask_phone(phone: str) -> str:
"""手机号脱敏:138****8888。前端拿不到完整号,展示被邀请人时在此兜底名字。
@@ -210,7 +294,7 @@ def get_invitees(
items.append({
"display_name": u.nickname or u.wechat_nickname or _mask_phone(u.phone),
"avatar_url": u.avatar_url or u.wechat_avatar_url or None,
"coins": rel.inviter_coin,
"coins": rel.inviter_coin, # v3 起恒 0(邀请人收益改走邀请奖励金)
"invited_at": rel.created_at,
})
has_more = offset + len(rows) < int(total)
+84 -32
View File
@@ -24,6 +24,7 @@ from app.models.wallet import (
CashTransaction,
CoinAccount,
CoinTransaction,
InviteCashTransaction,
WechatTransferAuthorization,
WithdrawOrder,
)
@@ -166,6 +167,36 @@ def grant_cash(
return acc, txn
def grant_invite_cash(
db: Session,
user_id: int,
amount_cents: int,
*,
biz_type: str,
ref_id: str | None = None,
remark: str | None = None,
) -> tuple[CoinAccount, InviteCashTransaction]:
"""邀请奖励金变动入口(正数入账 / 负数出账)。更新 invite_cash_balance_cents + 写
invite_cash_transaction, commit与金币兑换的 cash_balance_cents **物理隔离**
(产品红线:邀请奖励金 金币现金,两本账不可累加)返回 (account, transaction),
调用方负责 commit不在此校验扣成负由调用方按业务保护"""
acc = get_or_create_account(db, user_id, commit=False)
acc.invite_cash_balance_cents += amount_cents
txn = InviteCashTransaction(
user_id=user_id,
amount_cents=amount_cents,
balance_after_cents=acc.invite_cash_balance_cents,
biz_type=biz_type,
ref_id=ref_id,
remark=remark,
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
)
db.add(txn)
db.flush()
return acc, txn
def list_coin_transactions(
db: Session,
user_id: int,
@@ -411,33 +442,43 @@ def refund_reviewing_withdraws_on_unbind(db: Session, user_id: int) -> int:
_OUT_BILL_NO_RE = re.compile(r"^[0-9A-Za-z_-]{8,32}$")
def _try_deduct_cash(db: Session, user_id: int, amount_cents: int) -> bool:
"""原子扣减现金:仅当余额足够时扣,返回是否成功
def _balance_col(source: str):
"""提现账户来源 → CoinAccount 余额列。invite_cash=邀请奖励金;否则金币兑换的现金
两账户物理隔离,提现扣款/退款都按 source 走对应列,互不串"""
return (
CoinAccount.invite_cash_balance_cents
if source == "invite_cash"
else CoinAccount.cash_balance_cents
)
用带条件的 UPDATE(`WHERE cash_balance_cents >= amount`)避免"读-判断-写"竞态
并发/重试时不会两次都通过余额检查导致超额扣款(SQLite 串行写Postgres 行级,均安全)
def _try_deduct_cash(db: Session, user_id: int, amount_cents: int, source: str = "coin_cash") -> bool:
"""原子扣减指定账户余额:仅当余额足够时扣,返回是否成功。
用带条件的 UPDATE(`WHERE <col> >= amount`)避免"读-判断-写"竞态并发/重试时不会两次
都通过余额检查导致超额扣款(SQLite 串行写Postgres 行级,均安全)source 决定扣
cash_balance_cents(coin_cash) 还是 invite_cash_balance_cents(invite_cash)
"""
col = _balance_col(source)
res = db.execute(
update(CoinAccount)
.where(
CoinAccount.user_id == user_id,
CoinAccount.cash_balance_cents >= amount_cents,
)
.values(cash_balance_cents=CoinAccount.cash_balance_cents - amount_cents)
.where(CoinAccount.user_id == user_id, col >= amount_cents)
.values({col: col - amount_cents})
)
return res.rowcount == 1
def _add_cash(db: Session, user_id: int, amount_cents: int) -> int:
"""原子增加现金(退款用),返回加后余额。"""
def _add_cash(db: Session, user_id: int, amount_cents: int, source: str = "coin_cash") -> int:
"""原子增加指定账户余额(退款用),返回加后余额。source 决定退回哪个账户(两账户隔离)。"""
col = _balance_col(source)
db.execute(
update(CoinAccount)
.where(CoinAccount.user_id == user_id)
.values(cash_balance_cents=CoinAccount.cash_balance_cents + amount_cents)
.values({col: col + amount_cents})
)
db.flush()
bal = db.execute(
select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id)
select(col).where(CoinAccount.user_id == user_id)
).scalar_one()
return bal
@@ -456,11 +497,15 @@ def _refund_withdraw(
"""
if order.status in ("failed", "rejected"):
return # 防重复退款(并发/对账与查单/重复拒绝同时触发)
# 账户隔离:按 order.source 退回对应账户 + 写对应退款流水表
is_invite = order.source == "invite_cash"
txn_model = InviteCashTransaction if is_invite else CashTransaction
refund_biz = "invite_withdraw_refund" if is_invite else "withdraw_refund"
refunded_txn_id = db.execute(
select(CashTransaction.id).where(
CashTransaction.user_id == order.user_id,
CashTransaction.biz_type == "withdraw_refund",
CashTransaction.ref_id == order.out_bill_no,
select(txn_model.id).where(
txn_model.user_id == order.user_id,
txn_model.biz_type == refund_biz,
txn_model.ref_id == order.out_bill_no,
).limit(1)
).scalar_one_or_none()
if refunded_txn_id is not None:
@@ -468,13 +513,13 @@ def _refund_withdraw(
order.fail_reason = reason[:256]
db.commit()
return
bal = _add_cash(db, order.user_id, order.amount_cents)
bal = _add_cash(db, order.user_id, order.amount_cents, order.source)
db.add(
CashTransaction(
txn_model(
user_id=order.user_id,
amount_cents=order.amount_cents,
balance_after_cents=bal,
biz_type="withdraw_refund",
biz_type=refund_biz,
ref_id=order.out_bill_no,
# 用户可见文案区分"未成功(自动退)"vs"审核未通过";技术原因记在 order.fail_reason
remark=(
@@ -492,13 +537,13 @@ def _refund_withdraw(
db.commit()
except IntegrityError:
# 并发退款兜底:唯一退款流水已被另一事务写入时,回滚本事务的加钱和流水,
# 再把订单状态补到终态。这样无论拒绝/查单/对账怎么并发,金最多退一次。
# 再把订单状态补到终态。这样无论拒绝/查单/对账怎么并发,金最多退一次。
db.rollback()
refunded_txn_id = db.execute(
select(CashTransaction.id).where(
CashTransaction.user_id == user_id,
CashTransaction.biz_type == "withdraw_refund",
CashTransaction.ref_id == out_bill_no,
select(txn_model.id).where(
txn_model.user_id == user_id,
txn_model.biz_type == refund_biz,
txn_model.ref_id == out_bill_no,
).limit(1)
).scalar_one_or_none()
if refunded_txn_id is None:
@@ -562,6 +607,7 @@ def create_withdraw(
user_id: int,
amount_cents: int,
*,
source: str = "coin_cash",
user_name: str | None = None,
out_bill_no: str | None = None,
) -> WithdrawOrder:
@@ -608,20 +654,23 @@ def create_withdraw(
# 账户须存在(原子扣款的 UPDATE 不会建账户)
get_or_create_account(db, user_id, commit=True)
# #1 原子扣款:余额不足时影响行数为 0
if not _try_deduct_cash(db, user_id, amount_cents):
# #1 原子扣款:余额不足时影响行数为 0(按 source 扣对应账户)
if not _try_deduct_cash(db, user_id, amount_cents, source):
db.rollback()
raise InsufficientCashError
is_invite = source == "invite_cash"
txn_model = InviteCashTransaction if is_invite else CashTransaction
withdraw_biz = "invite_withdraw" if is_invite else "withdraw"
bal = db.execute(
select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id)
select(_balance_col(source)).where(CoinAccount.user_id == user_id)
).scalar_one()
db.add(
CashTransaction(
txn_model(
user_id=user_id,
amount_cents=-amount_cents,
balance_after_cents=bal,
biz_type="withdraw",
biz_type=withdraw_biz,
ref_id=out_bill_no,
remark="提现到微信零钱(待审核)",
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
@@ -631,6 +680,7 @@ def create_withdraw(
user_id=user_id,
out_bill_no=out_bill_no,
amount_cents=amount_cents,
source=source,
user_name=user_name,
status="reviewing",
)
@@ -1007,10 +1057,12 @@ def reconcile_pending_withdraws(db: Session, *, older_than_minutes: int = 15) ->
def list_withdraw_orders(
db: Session, user_id: int, *, limit: int = 20, cursor: int | None = None
db: Session, user_id: int, *, source: str | None = None, limit: int = 20, cursor: int | None = None
) -> tuple[list[WithdrawOrder], int | None]:
"""提现单分页(按 id 倒序,游标式)。"""
"""提现单分页(按 id 倒序,游标式)。source 非空时只返回该账户来源的单(coin_cash / invite_cash)。"""
stmt = select(WithdrawOrder).where(WithdrawOrder.user_id == user_id)
if source is not None:
stmt = stmt.where(WithdrawOrder.source == source)
if cursor is not None:
stmt = stmt.where(WithdrawOrder.id < cursor)
stmt = stmt.order_by(WithdrawOrder.id.desc()).limit(limit)
+16
View File
@@ -57,6 +57,12 @@ class EcpmReportIn(BaseModel):
)
adn: str | None = Field(None, description="实际投放 ADN(getSdkName),如 pangle")
slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)")
feed_scene: str | None = Field(
None,
max_length=16,
description="点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页);"
"比价与领券共用同一 Draw 代码位,需客户端在各调用点显式标注,供收益报表区分比价/领券;激励视频为空",
)
app_env: str | None = Field(
None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)"
)
@@ -128,6 +134,11 @@ class FeedRewardIn(BaseModel):
"""
client_event_id: str = Field(..., min_length=8, max_length=64, description="客户端生成的幂等事件 id")
ad_type: str = Field(
"feed",
max_length=16,
description="广告类型:feed(信息流) / draw(Draw 信息流);默认 feed 兼容旧客户端",
)
ad_session_id: str | None = Field(
None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id"
)
@@ -156,6 +167,11 @@ class FeedRewardIn(BaseModel):
aborted: bool = Field(
False, description="用户中途 ✕ 关闭广告(未走完比价):整场不发,记 closed_early"
)
display_coin: int = Field(
0, ge=0,
description="客户端金币小球**本条显示**的金币(所见即所得):后端直接发这个数,钳到本条最大 1 份"
"满额防刷。缺省 0 = 旧客户端不传,退回服务端「看满 10 秒发整份」",
)
class FeedRewardOut(BaseModel):
+42
View File
@@ -0,0 +1,42 @@
"""客户端埋点上报 schema(批量)。
客户端把设备固定维度(device_id / user_id / oem / os / model / app_ver / channel / sent_at)
放批次外层只传一次,events 列表里每条只带事件维度(event / client_ts / session_id / page /
network / props);服务端展开成多行 AnalyticsEvent 落库( app/repositories/analytics.py)
"""
from __future__ import annotations
from pydantic import BaseModel, Field
class AnalyticsEventIn(BaseModel):
"""单条事件维度。"""
event: str = Field(max_length=64)
client_ts: int = Field(description="端事件发生时间 epoch ms")
session_id: str | None = Field(default=None, max_length=64)
page: str | None = Field(default=None, max_length=64)
network: str | None = Field(default=None, max_length=16)
props: dict[str, str] = Field(default_factory=dict)
class AnalyticsBatchIn(BaseModel):
"""一批上报:公共维度 + 事件列表。"""
# Who(整批共享)
device_id: str = Field(max_length=64)
user_id: int | None = None
# When(本批上报时刻 epoch ms)
sent_at: int | None = None
# How(设备固定维度,整批共享)
oem: str | None = Field(default=None, max_length=32)
os: str | None = Field(default=None, max_length=32)
model: str | None = Field(default=None, max_length=64)
app_ver: str | None = Field(default=None, max_length=32)
channel: str | None = Field(default=None, max_length=32)
events: list[AnalyticsEventIn] = Field(min_length=1, max_length=200)
class AnalyticsIngestOut(BaseModel):
ok: bool = True
received: int
+31 -1
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
# ===== 上报请求 =====
@@ -21,8 +21,36 @@ class ComparisonItemIn(BaseModel):
name: str
qty: int = 1
# specs 仅供比价记录展示(admin-web 详情 / app 记录页都按字符串数组渲染并 join)。
# pricebot(2026-06-27 嵌套规格统一)起 calibration 的 specs 可能是规格对象
# [{name, qty, sub_specs}] 而非字符串 → 下面的 before-validator 统一拍平成可读字符串,
# 兼容新旧两种输入、保持 list[str] 契约不变(下游零改动)。
# ⚠️ 两个坑都踩过, 必须"拍平"而非别的: ① 直接声明 list[str] 不拍平 → 对象 specs 整条
# 422 被拒、不入库(同下方 platform_results list→dict 同类事故); ② 仅放宽成裸 list 又会让
# 下游 join 出 "[object Object]"/对象 toString 的乱码。
specs: list[str] | None = None
@field_validator("specs", mode="before")
@classmethod
def _flatten_specs(cls, v: object) -> object:
"""pricebot 规格对象 [{name, qty, sub_specs}] → 可读字符串数组; 字符串元素原样保留;
list 原样交还( pydantic 照常报类型错)嵌套规格拼成 '主项(子1,子2)'"""
if not isinstance(v, list):
return v
out: list[str] = []
for it in v:
if isinstance(it, str):
s = it
elif isinstance(it, dict):
name = str(it.get("name") or "").strip()
subs = [str(x).strip() for x in (it.get("sub_specs") or []) if str(x).strip()]
s = f"{name}({','.join(subs)})" if name and subs else (name or ",".join(subs))
else:
continue
if s:
out.append(s)
return out
class AppliedCouponIn(BaseModel):
"""单笔已用优惠(来自 comparison_results[].applied_coupons)。amount 单位:元、正数。"""
@@ -95,6 +123,7 @@ class ComparisonRecordIn(BaseModel):
# pricebot done.params.trace_url 原样上报,落库供记录页「复制调试链接」(dir 名含落盘
# 时分秒前端拼不出,必须由后端透传)。
trace_url: str | None = Field(None, description="本次比价公网调试链接")
total_ms: int | None = Field(None, description="整场比价墙钟耗时(ms)")
# ===== debug 维度(客户端采集上报;旧客户端不带 → None。仅 admin 比价记录页用)=====
# 必须显式声明,否则 model_dump() 落 raw_payload 时被 pydantic 静默丢弃(同上面 coupon_saved 的坑)。
@@ -144,6 +173,7 @@ class ComparisonRecordOut(BaseModel):
items: list = []
comparison_results: list = []
skipped_dish_names: list = []
total_ms: int | None = None
# 「已下单」(店级):该店名在该用户真实下单(source='compare')里出现过即 True。
# 由 list_records 动态算出挂在 ORM 实例上(非 DB 列),from_attributes 读出;缺省 False。
ordered: bool = False
+6 -1
View File
@@ -10,7 +10,12 @@ class InviteInfoOut(BaseModel):
invite_code: str # 我的邀请码
share_url: str # 落地页链接(含 ?ref=),前端据此生成二维码 + 复制分享
invited_count: int # 已成功邀请人数
coins_earned: int # 累计从邀请获得的金币
coins_earned: int # 累计从邀请获得的金币(v1 口径;v2 邀请人改发奖励金)
reward_balance_cents: int = 0 # v2 可提现邀请奖励金(分)
reward_withdrawn_cents: int = 0 # v2 累计提现成功的邀请奖励金(分)
countdown_days_left: int = 7 # v2 本轮剩余天数(7 天 1 轮)
countdown_is_fresh_round: bool = False # 是否刚进入新一轮(非首轮第1天)
countdown_text: str = "" # 倒计时展示文案(前端直接显示,新轮含换行)
class LandingTrackIn(BaseModel):
+2 -2
View File
@@ -38,8 +38,8 @@ class AdConfigPublicOut(BaseModel):
app_id: str # 穿山甲应用ID(改了客户端需冷启才生效,SDK init 一次性读)
reward_code_id: str # 福利页激励视频位
compare_feed_code_id: str # 比价信息流
coupon_feed_code_id: str # 领券信息流位
compare_draw_code_id: str # 比价 Draw 代码
coupon_draw_code_id: str # 领券 Draw 代码位(比价/领券共用同一位,靠 feed_scene 区分收益)
reward_enabled: bool # 福利激励视频开关
compare_ad_enabled: bool # 比价广告开关
coupon_ad_enabled: bool # 领券广告开关
+3
View File
@@ -16,6 +16,7 @@ class CoinAccountOut(BaseModel):
coin_balance: int = Field(..., description="当前金币余额")
cash_balance_cents: int = Field(..., description="当前现金余额(分)")
invite_cash_balance_cents: int = Field(0, description="邀请奖励金余额(分,与现金隔离)")
total_coin_earned: int = Field(..., description="累计赚取金币")
@@ -123,6 +124,7 @@ class UnbindWechatResultOut(BaseModel):
class WithdrawRequest(BaseModel):
amount_cents: int = Field(..., gt=0, description="提现金额(分)")
source: str = Field("coin_cash", description="提现账户:coin_cash(金币现金) / invite_cash(邀请奖励金)")
user_name: str | None = Field(None, description="实名(达额时微信要求,可空)")
out_bill_no: str | None = Field(
None, description="客户端幂等键(商户单号):同号重试不重复转账。不传则服务端生成"
@@ -166,6 +168,7 @@ class WithdrawOrderOut(BaseModel):
id: int
out_bill_no: str
amount_cents: int
source: str = Field("coin_cash", description="提现账户:coin_cash / invite_cash")
status: str = Field(..., description="reviewing(待审核) / pending / success / failed / rejected")
wechat_state: str | None = None
fail_reason: str | None = None
+224 -117
View File
@@ -8,122 +8,231 @@
<style>
* { margin:0; padding:0; box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
html,body { height:100%; }
button { border:0; background:none; color:inherit; font:inherit; cursor:pointer; }
body {
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
background:linear-gradient(165deg,#FF7A3D 0%,#FF3B30 52%,#E0245E 100%);
color:#fff; min-height:100%; display:flex; flex-direction:column;
align-items:center; justify-content:center; padding:40px 26px; text-align:center;
overflow-x:hidden;
min-height:100vh;
display:flex; align-items:center; justify-content:center;
background:#000;
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",sans-serif;
}
.logo {
width:104px; height:104px; border-radius:26px; background:#fff;
display:flex; align-items:center; justify-content:center; font-size:52px;
box-shadow:0 14px 34px rgba(0,0,0,.22); margin-bottom:24px;
/* 设备框:桌面预览成 375×667 卡片;真机(≤430)铺满全屏 */
.device {
position:relative; flex:0 0 auto;
width:375px; height:667px;
overflow:hidden; border-radius:32px;
background:#FFF4CD;
box-shadow:0 20px 60px rgba(0,0,0,.5);
color:#fff;
}
h1 { font-size:30px; font-weight:800; letter-spacing:1px; }
.slogan { margin-top:12px; font-size:16px; line-height:1.7; opacity:.95; max-width:300px; }
.feats { margin-top:26px; display:flex; flex-direction:column; gap:12px; width:100%; max-width:320px; }
.feat { background:rgba(255,255,255,.16); border-radius:14px; padding:13px 16px; font-size:15px; display:flex; align-items:center; gap:10px; }
.feat b { font-weight:700; }
.btn {
margin-top:34px; width:100%; max-width:320px; border:none; cursor:pointer;
background:#fff; color:#FF3B30; font-size:19px; font-weight:800;
padding:17px 0; border-radius:999px; box-shadow:0 10px 26px rgba(0,0,0,.22);
display:flex; align-items:center; justify-content:center; gap:9px;
.download-landing {
position:absolute; inset:0; z-index:0; overflow:hidden;
background:
url('coupon-page-bg.png') center center / cover no-repeat,
#FFF4CD;
color:#1A1A1A; text-align:center;
}
.download-content {
position:relative; z-index:1; height:100%;
padding:82px 20px 0;
display:flex; flex-direction:column; align-items:center; overflow:hidden;
}
.brand-lockup {
display:flex; align-items:center; justify-content:center; gap:16px;
}
.ad-logo {
width:53px; height:53px; border-radius:13px; display:block;
box-shadow:0 10px 24px rgba(255,179,0,.24);
}
.ad-title {
color:#1A1A1A; font-size:32px; font-weight:800; line-height:1.15;
letter-spacing:0; white-space:nowrap;
}
.ad-subtitle {
margin-top:22px; max-width:100%;
color:#000; font-size:18px; font-weight:400; line-height:1.25;
display:flex; align-items:center; justify-content:center; gap:10px; white-space:nowrap;
}
.ad-subtitle::before, .ad-subtitle::after {
content:""; width:5px; height:5px; border-radius:50%; background:#000; flex:0 0 auto;
}
.bottom-area {
position:relative; z-index:2; width:100%; max-width:266px;
margin-top:11px;
display:grid; grid-template-columns:1fr; justify-items:stretch; align-content:center; gap:10px;
}
.download-btn {
width:100%; height:39px; border-radius:22px; color:#1A1A1A;
font-family:inherit; font-size:14px; line-height:1; font-weight:700; letter-spacing:0;
display:flex; align-items:center; justify-content:center;
}
.download-btn.primary {
background:linear-gradient(180deg,#FFE066 0%,#FFC400 100%);
box-shadow:inset 0 1px 0 rgba(255,255,255,.82), 0 7px 18px rgba(255,179,0,.24);
}
.download-btn.secondary {
background:#fff; border:1px solid #DDD; color:#1A1A1A; font-size:13px;
box-shadow:0 2px 8px rgba(122,79,0,.08);
}
.download-btn:active { transform:translateY(1px); }
/* 底部两条卖点文案,压在背景插画两张卡片下方 */
/* 卖点卡:CSS 实体卡片(白底+图标),不再靠底图死框,字自适应(对齐 WeChat.html PR siyi 改版)*/
.download-feature-card {
position:absolute; z-index:2; pointer-events:none;
height:62px; padding:0 10px; border-radius:22px;
background:#FFFAEE;
box-shadow:inset 0 1px 0 rgba(255,255,255,.86), 0 6px 14px rgba(122,79,0,.08);
display:flex; align-items:center; gap:8px; color:#1A1A1A;
}
.download-feature-card.left { left:24px; top:576px; width:146px; }
.download-feature-card.right { left:199px; top:576px; width:156px; }
.download-feature-icon {
width:27px; height:27px; flex:0 0 auto; display:block; color:#FFAE00;
}
.download-feature-icon svg {
display:block; width:100%; height:100%; filter:drop-shadow(0 1px 0 rgba(255,255,255,.7));
}
.download-feature-copy {
min-width:0; flex:1 1 auto; text-align:left; white-space:nowrap; letter-spacing:0;
}
.download-feature-title {
display:block; font-size:13px; font-weight:800; line-height:1.12; letter-spacing:0;
}
.download-feature-desc {
display:block; margin-top:5px; color:#5A3A00; font-size:10px; font-weight:400; line-height:1.1;
}
.btn:active { transform:translateY(1px); opacity:.92; }
.hint { margin-top:16px; font-size:13px; opacity:.85; }
.foot { margin-top:30px; font-size:12px; opacity:.6; line-height:1.6; max-width:320px; }
/* 微信内"去浏览器打开"引导蒙层 */
#wxmask {
display:none; position:fixed; inset:0; z-index:9999;
background:rgba(0,0,0,.86); padding:18px;
.download-guide-layer {
position:absolute; inset:0; z-index:30; display:none;
background:rgba(0,0,0,.85); color:#fff; /* 半透明黑:透出底层(新版)下载页,隐约可见 */
}
.download-guide-layer.show { display:block; }
.download-guide-arrow {
position:absolute; top:17px; right:9px; width:80px; height:60px;
}
.download-guide-arrow-svg {
display:block; width:100%; height:100%; overflow:hidden; shape-rendering:geometricPrecision;
}
.download-guide-title {
position:absolute; top:129px; right:16px; width:218px; margin:0;
color:#fff; font-size:17px; font-weight:800; line-height:1.32; letter-spacing:0;
text-align:right; text-shadow:0 2px 8px rgba(0,0,0,.36);
}
.download-guide-title .guide-dots { color:#FFD95A; letter-spacing:4px; }
.download-guide-title .guide-highlight { color:#FFD95A; white-space:nowrap; }
.download-guide-title .guide-final {
display:block; margin-top:10px; color:rgba(255,255,255,.94);
font-size:13px; font-weight:700; line-height:1.38;
}
.download-guide-title .guide-target { color:#FFD95A; white-space:nowrap; }
.guide-dismiss {
position:absolute; bottom:30px; left:0; right:0; text-align:center;
font-size:14px; color:#fff; opacity:.75;
}
.toast {
position:absolute; left:50%; bottom:118px; z-index:20;
transform:translateX(-50%) translateY(12px);
padding:9px 14px; border-radius:12px; background:rgba(0,0,0,.78);
color:#fff; font-size:14px; opacity:0; pointer-events:none;
transition:opacity .2s ease, transform .2s ease;
}
.toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
@media (max-width:430px) {
/* 真机:宽满屏、高按 375:667 锁比例(不拉伸变形),顶对齐、底部留白用底色填。
这样底图(含价格卡)与卖点卡片同处 667 坐标基准,卡片用回 top:576,不再相互错位/遮挡。 */
body { align-items:flex-start; background:#FFF4CD; }
.device { width:100vw; height:calc(100vw * 667 / 375); border-radius:0; box-shadow:none; }
}
#wxmask.show { display:block; }
.arrow { position:absolute; top:8px; right:14px; width:120px; }
.wxtip { position:absolute; top:150px; right:18px; left:18px; text-align:right; }
.wxtip .big { font-size:21px; font-weight:800; line-height:1.5; }
.wxtip .big em { color:#FFD24D; font-style:normal; }
.wxtip .sub { margin-top:14px; font-size:15px; line-height:1.8; opacity:.9; }
.wxsteps { margin-top:26px; text-align:left; background:rgba(255,255,255,.1); border-radius:14px; padding:18px 18px; font-size:15px; line-height:2; }
.wxsteps .n { display:inline-block; width:22px; height:22px; line-height:22px; text-align:center; border-radius:50%; background:#FFD24D; color:#333; font-weight:800; font-size:13px; margin-right:8px; }
.closebar { position:absolute; bottom:30px; left:0; right:0; text-align:center; font-size:14px; opacity:.7; }
</style>
</head>
<body>
<div class="logo">🛒</div>
<h1>傻瓜比价</h1>
<div class="slogan">买什么都先比一比<br>自动帮你找全网最低价</div>
<div class="feats">
<div class="feat">🍔 <span>点外卖前一键比价,<b>美团/京东/淘宝</b>到手价一目了然</span></div>
<div class="feat">🎟️ <span>自动领遍各平台<b>红包券</b>,能省的一分不漏</span></div>
<div class="feat">💰 <span>省下的钱看得见,还能<b>赚金币提现</b></span></div>
</div>
<button class="btn" id="dlbtn">🏪 打开应用商店下载</button>
<div class="hint" id="hint">Android 安卓版 · 应用商店安全下载</div>
<div class="foot">
将前往应用商店下载,安全放心。<br>
本页为内部测试页。
</div>
<!-- 微信内引导:跳出微信去浏览器 -->
<div id="wxmask">
<svg class="arrow" viewBox="0 0 120 130" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M30 120 C 30 70, 55 40, 95 28" stroke="#FFD24D" stroke-width="6" stroke-linecap="round" fill="none" stroke-dasharray="2 13"/>
<path d="M95 28 L 78 30 M95 28 L 92 46" stroke="#FFD24D" stroke-width="6" stroke-linecap="round"/>
</svg>
<div class="wxtip">
<div class="big">点击右上角 <em>···</em><br>选择「<em>在浏览器打开</em></div>
<div class="sub">微信里无法直接下载安装包<br>需在系统浏览器中完成下载</div>
<div class="wxsteps">
<div><span class="n">1</span>点右上角的 ··· 菜单</div>
<div><span class="n">2</span>选择「在浏览器打开」</div>
<div><span class="n">3</span>在浏览器里按提示去应用商店下载</div>
<main class="device" aria-label="傻瓜比价下载页">
<section class="download-landing" aria-label="傻瓜比价下载页">
<div class="download-content">
<div class="brand-lockup">
<img class="ad-logo" src="sb-brand.png" alt="傻瓜比价">
<h1 class="ad-title">傻瓜比价</h1>
</div>
<p class="ad-subtitle">跨平台比价,用傻瓜</p>
<div class="bottom-area" aria-label="下载入口">
<button class="download-btn primary" id="dlbtn" type="button">应用商店下载</button>
<button class="download-btn secondary" id="dlbtn2" type="button">官网下载</button>
</div>
</div>
<div class="closebar" id="wxclose">我知道了 ✕</div>
<div class="download-feature-card left" aria-label="优惠券轻松领,羊毛全都不错过">
<span class="download-feature-icon" aria-hidden="true">
<svg viewBox="0 0 40 32" focusable="false">
<path d="M5 5h30a3 3 0 0 1 3 3v5.2a4.8 4.8 0 0 0 0 9.6V24a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3v-1.2a4.8 4.8 0 0 0 0-9.6V8a3 3 0 0 1 3-3Z" fill="currentColor"/>
<path d="M20 10v12" fill="none" stroke="#FFF7CF" stroke-width="3" stroke-linecap="round"/>
</svg>
</span>
<span class="download-feature-copy">
<span class="download-feature-title">优惠券轻松领</span>
<span class="download-feature-desc">羊毛全都不错过</span>
</span>
</div>
<div class="download-feature-card right" aria-label="一键全网找底价,再也不用费力切屏">
<span class="download-feature-icon" aria-hidden="true">
<svg viewBox="0 0 40 32" focusable="false">
<rect x="5" y="16" width="8" height="11" rx="2" fill="currentColor"/>
<rect x="16" y="9" width="8" height="18" rx="2" fill="currentColor"/>
<rect x="27" y="3" width="8" height="24" rx="2" fill="currentColor"/>
</svg>
</span>
<span class="download-feature-copy">
<span class="download-feature-title">一键全网找底价</span>
<span class="download-feature-desc">再也不用费力切屏</span>
</span>
</div>
</section>
<!-- 微信内引导:跳出微信去浏览器 -->
<div class="download-guide-layer" id="wxGuide" role="dialog" aria-modal="true" aria-labelledby="wxGuideTitle">
<div class="download-guide-arrow" aria-hidden="true">
<svg class="download-guide-arrow-svg" viewBox="0 0 80 60" focusable="false">
<path d="M0 60 C16 32 39 15 66 15" fill="none" stroke="#FFD95A" stroke-width="5.5" stroke-linecap="round"/>
<path d="M59 5 L77 14 L63 31" fill="none" stroke="#FFD95A" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h2 class="download-guide-title" id="wxGuideTitle">
点击右上角 <span class="guide-dots">···</span><br>
选择「<span class="guide-highlight">在浏览器打开</span>
<span class="guide-final">在浏览器里按提示<span class="guide-target">去应用商店下载</span></span>
</h2>
<div class="guide-dismiss" id="wxGuideDismiss">我知道了 ✕</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
</main>
<script>
// 应用商店跳转:用包名(App 唯一标识 = 身份证号)定位到傻瓜比价的商店下载页
// 主:market:// 唤起手机自带应用市场(华为/小米/OV);兜底:应用宝网页(任何浏览器都能开)
// ===== 应用商店跳转用包名App 唯一标识定位到傻瓜比价的商店下载页 =====
// 主market:// 唤起手机自带应用市场华为/小米/OV);兜底应用宝网页任何浏览器都能开
var PKG = "com.jishisongfu.shaguabijia";
var MARKET_URL = "market://details?id=" + PKG;
var YYB_URL = "https://a.app.qq.com/o/simple.jsp?pkgname=" + PKG;
var ua = navigator.userAgent || "";
var isWeChat = /MicroMessenger/i.test(ua);
var isIOS = /iPhone|iPad|iPod/i.test(ua);
var isAndroid = /Android/i.test(ua);
var ref = new URLSearchParams(location.search).get("ref"); // 邀请码(来自二维码 URL ?ref=)
var ref = new URLSearchParams(location.search).get("ref"); // 邀请码(来自二维码 URL ?ref=)
// 【任务 3】指纹归因兜底:页面加载即上报访问者指纹,后端存 invite_fingerprint 表
// 当 APK 首启读剪贴板失败(被覆盖)时,客户端用 (IP+屏幕+UA 解析的手机型) 反查
// 本表 7 天内最近一条匹配 → 撞库出原邀请人 → 走原 bind 流程。
// 任何失败都 silent(不影响下载主流程);后端 invalid_code/no_ip 也只返 200。
// ===== 指纹归因兜底页面加载即上报访问者指纹后端存 invite_fingerprint 表 =====
// 当 APK 首启读剪贴板失败被覆盖)时,客户端用 (IP+屏幕+UA 机型) 反查 7 天内最近一条 → 撞出原邀请人。
// 任何失败都 silent,不影响下载主流程。
if (ref) {
// screen 报【物理像素】= CSS 像素 × devicePixelRatio,跟 Android dm.widthPixels(物理像素)对齐
// 不同设备 DPR 不同(常见 2/2.5/3/3.5),CSS 像素直接报会跟客户端不对齐 → 撞不上库。
// screen 报【物理像素】= CSS 像素 × devicePixelRatio跟 Android dm.widthPixels 对齐,否则撞不上库
var _dpr = window.devicePixelRatio || 1;
var _sw = Math.round(screen.width * _dpr);
var _sh = Math.round(screen.height * _dpr);
fetch("/api/v1/invite/landing-track", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ref: ref,
screen: _sw + "x" + _sh,
// IP / UA 服务端从 HTTP 头自动拿,无需 JS 上报
}),
}).catch(function () {}); // silent,绝不阻断下载
body: JSON.stringify({ ref: ref, screen: _sw + "x" + _sh }),
}).catch(function () {}); // silent,绝不阻断下载
}
// 把邀请码写进剪贴板,APK 首启读出完成归因(deferred deeplink 的关键一步)。
// 浏览器要求:必须在用户点击手势里调用 + HTTPS 下才允许写。
function legacyCopy(payload) { // 老 webview / 无 clipboard API 兜底
// ===== 把邀请码写进剪贴板APK 首启读出完成归因deferred deeplink=====
// 浏览器要求必须在用户点击手势里调用 + HTTPS 下才允许写。
function legacyCopy(payload) { // 老 webview / 无 clipboard API 兜底
try {
var ta = document.createElement("textarea");
ta.value = payload; ta.style.position = "fixed"; ta.style.top = "-1000px"; ta.style.opacity = "0";
@@ -131,50 +240,48 @@
document.execCommand("copy"); document.body.removeChild(ta);
} catch (e) {}
}
function copyInviteCode() { // 返回 Promise(完成后才下载,避免异步写入被打断)
function copyInviteCode() { // 返回 Promise完成后才下载避免异步写入被打断
if (!ref) return Promise.resolve();
var payload = "SGBJ_INVITE:" + ref;
legacyCopy(payload); // 同步兜底:在用户手势内立刻 execCommand 写一次(最可靠)
legacyCopy(payload); // 同步兜底在用户手势内立刻 execCommand 写一次最可靠
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(payload).catch(function () {}); // 现代 API 锦上添花,失败无妨
return navigator.clipboard.writeText(payload).catch(function () {});
}
return Promise.resolve();
}
// 跳应用商店:先尝试 market:// 唤起手机自带商店;若 2.5s 内页面没切后台
//(= 没有商店接管 market://),兜底跳应用宝网页下载页,不让用户卡死。
// 跳应用商店:先 market:// 唤起自带商店2.5s 内页面没切后台 → 兜底跳应用宝网页,不让用户卡死。
function openStore() {
var jumped = false;
function markJumped() { jumped = true; } // 页面切到后台 = 商店已唤起
document.addEventListener("visibilitychange", function () {
if (document.hidden) jumped = true;
});
window.addEventListener("pagehide", markJumped);
window.addEventListener("blur", markJumped);
window.location.href = MARKET_URL; // 唤起自带应用市场
setTimeout(function () {
if (!jumped) window.location.href = YYB_URL; // 没唤起 → 应用宝网页兜底
}, 2500);
document.addEventListener("visibilitychange", function () { if (document.hidden) jumped = true; });
window.addEventListener("pagehide", function () { jumped = true; });
window.addEventListener("blur", function () { jumped = true; });
window.location.href = MARKET_URL;
setTimeout(function () { if (!jumped) window.location.href = YYB_URL; }, 2500);
}
var hint = document.getElementById("hint");
if (isIOS) hint.textContent = "检测到 iPhone · iOS 版请前往 App Store";
// ===== 微信内引导蒙层 =====
var wxGuide = document.getElementById("wxGuide");
function showWxGuide() { wxGuide.classList.add("show"); }
function hideWxGuide() { wxGuide.classList.remove("show"); }
document.getElementById("wxGuideDismiss").addEventListener("click", hideWxGuide);
if (isWeChat) showWxGuide(); // 微信里一进页面就提示去浏览器(微信内下载必被拦)
var mask = document.getElementById("wxmask");
function showMask(){ mask.classList.add("show"); }
function hideMask(){ mask.classList.remove("show"); }
document.getElementById("wxclose").addEventListener("click", hideMask);
function showToast(text) {
var toast = document.getElementById("toast");
toast.textContent = text; toast.classList.add("show");
clearTimeout(showToast.timer);
showToast.timer = setTimeout(function () { toast.classList.remove("show"); }, 1400);
}
// 微信里一进页面就提示去浏览器打开(下载在微信内必被拦)
if (isWeChat) showMask();
document.getElementById("dlbtn").addEventListener("click", function(){
if (isWeChat) { showMask(); return; } // 微信内:引导去浏览器
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
// 安卓浏览器:先把邀请码写进剪贴板(legacyCopy 同步写、最可靠),再跳应用商店。
// 剪贴板供 App 首启归因;链路长易丢时由指纹兜底(已上报 landing-track)接住。
copyInviteCode();
// ===== 下载按钮:微信内引导去浏览器;iOS 提示;安卓写邀请码 + 跳应用商店 =====
function handleDownload() {
if (isWeChat) { showWxGuide(); return; }
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
copyInviteCode(); // 先把邀请码写进剪贴板(供 App 首启归因),链路丢了还有 landing-track 指纹兜底
openStore();
});
}
document.getElementById("dlbtn").addEventListener("click", handleDownload);
document.getElementById("dlbtn2").addEventListener("click", handleDownload);
</script>
</body>
</html>
+26 -5
View File
@@ -27,8 +27,11 @@
| `date_to` | string | =`date_from` | 结束日 北京时间 `YYYY-MM-DD`,**闭区间**;单日时与 `date_from` 相同 |
| `user_id` | int | 全部 | 只看某用户;不传=所有用户 |
| `ad_type` | string | 全部 | `reward_video` / `feed` / `draw`;不传=全部类型 |
| `feed_scene` | string | 全部 | `comparison`(比价)/ `coupon`(领券)/ `welfare`(福利);**全局筛选**,同时作用于明细 / 合计 / `daily`·`hourly` 趋势;不传=全部场景 |
| `granularity` | string | `day` | `day`=按天 / `hour`=按小时(聚合键再加北京时间小时 0–23);**区间>1 天建议用 day** |
| `limit` | int(1~1000) | 500 | **展示**明细组数(截断;`total`/`total_*`/`daily` 按全量统计不受影响) |
| `limit` | int(1~1000) | 500 | **每页条数**(分页大小);`total`/`total_*`/`daily`/`hourly` 按全量统计不受分页影响 |
| `offset` | int(≥0) | 0 | 分页偏移(已跳过条数)=(页码−1)×`limit` |
| `sort` | string | `time` | 明细排序:`time`=按时间倒序(新→旧) / `ecpm`=按 eCPM 数值倒序 |
约束:`date_to` 不早于 `date_from`、区间最长 **92 天**、日期须 `YYYY-MM-DD`,否则 `422`
@@ -36,15 +39,18 @@
| 字段 | 类型 | 说明 |
|---|---|---|
| `date_from` / `date_to` | string | 报表起止日期(闭区间) |
| `daily` | `AdRevenueDaily[]` | 按日期汇总序列(全量,供按天趋势图;不受 `limit` 影响) |
| `total` | int | 聚合组**总数**(全量,不受 `limit` 影响) |
| `truncated` | bool | 明细是否被 `limit` 截断 |
| `daily` | `AdRevenueDaily[]` | 按日期汇总序列(全量,供按天趋势图;不受分页影响) |
| `hourly` | `AdRevenueHourly[]` | 按小时汇总序列(全量,供按小时趋势图;**仅 `granularity=hour` 时非空**;不受分页影响) |
| `type_stats` | `{[ad_type]: AdRevenueTypeStat}` | 按广告类型(`ad_type`)小计(全量);前端取 `draw` / `reward_video` 做分类大盘 |
| `dau` | int \| null | 今日活跃用户数(复用大盘口径 `last_login_at`,今日登录过);**仅查询=今日单天时有值**,历史/多天为 `null` |
| `total` | int | 当前筛选下的**分页总条数**(全量,不受分页影响;= 前端分页器 total) |
| `truncated` | bool | 当前页之后是否还有更多事件(`len(events) > offset + limit`) |
| `total_impressions` | int | 全量展示条数合计 |
| `total_revenue_yuan` | float | 全量收益合计(元) |
| `total_expected_coin` | int | 全量应发金币合计 |
| `total_actual_coin` | int | 全量实发金币合计 |
| `mismatch_count` | int | 应发≠实发的组数(=0 说明全部按公式发放) |
| `items` | `AdRevenueRow[]` | 聚合明细(按 日期→用户→类型→代码位 排序) |
| `items` | `AdRevenueRow[]` | 逐条广告事件(**按时间倒序:新→旧**);`limit`/`offset` 对全量做分页切片,返回当前页 |
### AdRevenueDaily(`daily[]` — 按天趋势)
| 字段 | 类型 | 说明 |
@@ -55,6 +61,21 @@
| `expected_coin` | int | 当天应发金币合计 |
| `actual_coin` | int | 当天实发金币合计 |
### AdRevenueHourly(`hourly[]` — 按小时趋势,仅 `granularity=hour` 时非空)
| 字段 | 类型 | 说明 |
|---|---|---|
| `hour` | int | 北京时间小时 023 |
| `impressions` | int | 该小时展示条数合计 |
| `revenue_yuan` | float | 该小时预估收益合计(元) |
| `expected_coin` | int | 该小时应发金币合计 |
| `actual_coin` | int | 该小时实发金币合计 |
### AdRevenueTypeStat(`type_stats[ad_type]` — 分广告类型小计,供大盘第二行)
| 字段 | 类型 | 说明 |
|---|---|---|
| `impressions` | int | 该类型展示条数合计 |
| `revenue_yuan` | float | 该类型预估收益合计(元);eCPM 由前端用 收益÷展示×1000 算 |
### AdRevenueRow(`items[]`)
| 字段 | 类型 | 说明 |
|---|---|---|
@@ -37,7 +37,7 @@ B 安装并首启 App
└─ POST /api/v1/invite/bind { invite_code, channel="clipboard" }
后端 repositories/invite.py bind()
└─ 过四道防线 → 建 invite_relation + 给 A、B 各发金币(同事务原子提交)
└─ 过四道防线 → 建 invite_relation(邀请金币已下线,不写金币流水)
```
手动填码这条:B 在邀请页输码 → `InviteRepository.bindManual()``POST /bind { channel="manual" }` → 同一个 `bind()`
@@ -52,20 +52,20 @@ B 安装并首启 App
|---|---|
| 端点 | `app/api/v1/invite.py`:`GET /api/v1/invite/me`(返回 `invite_code` + `share_url` + 战绩)、`POST /api/v1/invite/bind`(绑定,`channel` = `clipboard` / `manual`)。**均需 Bearer 鉴权**。 |
| share_url 构造 | `invite.py``my_invite`:`settings.INVITE_LANDING_URL + "?ref=" + code``INVITE_LANDING_URL``app/core/config.py`(默认 `https://app-api.shaguabijia.com/media/dl.html`)。 |
| 业务逻辑 | `app/repositories/invite.py`:`ensure_code`(懒生成 6 位邀请码,去混淆字符集,唯一约束碰撞则换码)/ `resolve_inviter`(邀请码→邀请人,大小写不敏感)/ `bind`(下面详述)/ `get_stats`(已邀人数 + 累计金币)。 |
| 业务逻辑 | `app/repositories/invite.py`:`ensure_code`(懒生成 6 位邀请码,去混淆字符集,唯一约束碰撞则换码)/ `resolve_inviter`(邀请码→邀请人,大小写不敏感)/ `bind`(下面详述)/ `get_stats`(已邀人数 + 兼容累计金币字段,当前恒为 0)。 |
| 数据模型 | `app/models/invite.py``InviteRelation`(`inviter_user_id` / `invitee_user_id` / `channel` / `status` / `inviter_coin` / `invitee_coin` / `created_at`)+ `app/models/user.py``User.invite_code` 列。 |
| 迁移 | `alembic/versions/invite_code_and_relation.py`:给 `user``invite_code`(唯一索引)+ 建 `invite_relation` 表。`down_revision = 11a1d08c6f55`。 |
| 收发模型 | `app/schemas/invite.py`:`InviteInfoOut` / `BindInviteIn` / `BindInviteOut`。 |
| 奖励常量 | `app/core/rewards.py`:`INVITE_INVITER_COINS` / `INVITE_INVITEE_COINS`(各 10000 = 1 元)、`INVITE_NEW_USER_WINDOW_HOURS`(72)。 |
| 新人窗口 | `app/core/rewards.py`:`INVITE_NEW_USER_WINDOW_HOURS`(72)。邀请金币已下线,不再配置邀请金币常量。 |
**`bind()` 的四道防线(防重复 / 防刷,看 `repositories/invite.py`):**
1. **被邀请人唯一**:`invitee_user_id` 唯一约束 → 一个 B 只能被绑一次(幂等键,重复返回 `already_bound`,不重复发奖)。
1. **被邀请人唯一**:`invitee_user_id` 唯一约束 → 一个 B 只能被绑一次(幂等键,重复返回 `already_bound`,不重复绑定)。
2. **自邀屏蔽**:`inviter == invitee``self_invite`
3. **新人闸**:`_is_new_user`(B 的 `created_at``INVITE_NEW_USER_WINDOW_HOURS` = 72h 内)才发奖,挡存量老用户互相填码薅羊毛 → 否则 `not_eligible`
3. **新人闸**:`_is_new_user`(B 的 `created_at``INVITE_NEW_USER_WINDOW_HOURS` = 72h 内)才生效,挡存量老用户互相填码刷关系 → 否则 `not_eligible`
4. **手机号唯一**(天然限量):每个 B = 一个真实手机号账号。
发金币复用 `repositories/wallet.py``grant_coins`,与建关系记录在**同一事务**提交,保证"建关系 + 双方加金币"原子
邀请金币已下线:`bind()` 只记录绑定关系,不再写 `coin_transaction`;响应里的金币字段保留兼容旧客户端,当前恒为 0
### 3.2 前端(shaguabijia-app-android)
@@ -120,7 +120,7 @@ B 安装并首启 App
### 4.4 测试硬约束 / 坑(都是机制,不是 bug)
- **B 必须用新手机号**:`invitee_user_id` 唯一,一个 B 只能绑一次;反复测要换号(或手删 `invite_relation` 那行 + 回滚金币)。
- **72h 新人闸**:B 注册后 72 小时内绑才发奖(刚注册肯定满足)。
- **72h 新人闸**:B 注册后 72 小时内绑定才生效(刚注册肯定满足)。
- **A ≠ B**:自邀被屏蔽。
- **B 从点下载到首启 App 之间别复制别的东西**:剪贴板会被覆盖 → 归因丢(剪贴板 deferred deeplink 的固有脆弱性)。
- **笔记本 IP 别变**:debug 包把 `BASE_URL` 的 IP 烧死在编译期,DHCP 一换就连不上 → 给笔记本固定个 LAN IP。
+7771
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
/**
* SGApi H5 app-server 后端的薄封装
*
* 同源H5 app-server /media/h5/ 托管后端在 /api/v1 host:port 用相对路径 CORS
* 鉴权JWT Bearertoken SGBridge.getToken() 从原生取(原生持登录态)浏览器调试走 bridge mock token
* 401交原生拉登录(requestLogin)兜底本次请求按失败 reject正式的 refresh 重试策略阶段2 再补
*
* 依赖 shared/bridge.js 先加载( token)
*/
(function (global) {
'use strict';
var BASE = '/api/v1';
function authHeaders() {
var t = (global.SGBridge && global.SGBridge.getToken()) || '';
var h = { 'Content-Type': 'application/json' };
if (t) h['Authorization'] = 'Bearer ' + t;
return h;
}
function handle(res) {
if (res.status === 401) {
// 未授权:拉原生登录(异步),本次请求按失败处理,调用方自行决定是否重试
if (global.SGBridge) global.SGBridge.requestLogin();
return Promise.reject(new Error('unauthorized'));
}
if (!res.ok) {
return res.text().then(function (t) {
return Promise.reject(new Error('http ' + res.status + ' ' + t));
});
}
// 204 / 空体兜底
return res.text().then(function (t) { return t ? JSON.parse(t) : null; });
}
/** GET /api/v1<path>。path 以 / 开头,如 '/savings/battle'。 */
function apiGet(path) {
return fetch(BASE + path, { method: 'GET', headers: authHeaders() }).then(handle);
}
/** POST /api/v1<path>body 自动 JSON 序列化。 */
function apiPost(path, body) {
return fetch(BASE + path, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify(body || {}),
}).then(handle);
}
global.SGApi = { base: BASE, get: apiGet, post: apiPost };
})(window);
+162
View File
@@ -0,0 +1,162 @@
/**
* SGBridge 傻瓜比价 H5 Android 原生 的桥
*
* 背景四个主 tab(首页/福利/记录/我的)由原生 Compose 改造为 WebView 加载本工程 H5
* H5 只负责"画 + 取后端数据"凡需要原生能力(登录态 / 跳转 / 跳外卖 App / 比价领券 /
* 权限 / 定位 / Toast / 激励视频)一律经本桥调用原生
*
* 协议两个方向
* H5 原生Android WebView.addJavascriptInterface(obj, "SGBridgeNative")
* obj 方法都是同步查询类返回 String(JSON 或纯串)动作类无返回
* 原生 H5原生执行 evaluateJavascript("window.SGBridge._emit('<event>', '<json>')")
* 事件onAuthChange(登录态变) / onBalanceChange(余额变) / onSigninChange(签到态变) / onResume(回前台刷新)
*
* 离线兜底浏览器里( SGBridgeNative) MOCK便于不装 App 直接在本地 server 调样式 / 渲染
* 与原生实现对应 shaguabijia-app-android SGBridge.kt(方法名逐个对齐本文件)
*/
(function (global) {
'use strict';
var native = global.SGBridgeNative || null;
var hasNative = !!native;
// ---- 离线 MOCK(仅无原生时生效,便于浏览器调试渲染;真机一律走 native) ----
var MOCK = {
authState: { loggedIn: true, userId: 1, nickname: '冰', avatarUrl: '', phone: '188****8888' },
token: 'mock-token-for-browser-debug',
deviceId: 'browser-debug-device',
appVersion: '0.0.0-debug',
};
function safeParse(s, fallback) {
try { return s ? JSON.parse(s) : fallback; } catch (e) { return fallback; }
}
// ====== 查询类(同步返回) ======
/** 当前登录态 + 用户基本信息 → {loggedIn, userId, nickname, avatarUrl, phone}。 */
function getAuthState() {
if (hasNative && native.getAuthState) return safeParse(native.getAuthState(), { loggedIn: false });
return MOCK.authState;
}
/** 后端鉴权用的 access token(空串=未登录)。原生持登录态,H5 调后端前取它拼 Bearer。 */
function getToken() {
if (hasNative && native.getToken) return native.getToken() || '';
return MOCK.token;
}
/** 设备唯一标识(心跳 / 领券状态查询等用)。 */
function getDeviceId() {
if (hasNative && native.getDeviceId) return native.getDeviceId() || '';
return MOCK.deviceId;
}
/** App 版本号。 */
function getAppVersion() {
if (hasNative && native.getAppVersion) return native.getAppVersion() || '';
return MOCK.appVersion;
}
/** 已安装的目标电商/外卖 App 包名数组(原生 InstalledApps 探测)。H5 选平台弹窗据此判真实装机态。 */
function getInstalledApps() {
if (hasNative && native.getInstalledApps) return safeParse(native.getInstalledApps(), []);
// MOCK(浏览器无原生):给主流已装,便于本地预览选平台弹窗正常显示"有"。
return ['com.sankuai.meituan', 'com.taobao.taobao', 'com.jingdong.app.mall', 'me.ele'];
}
/** 今日是否已领券(置灰「去领取」→「去查看」)。原生读 CompareButtonState(SP 按天);无桥默认 false。 */
function getCouponClaimedToday() {
if (hasNative && native.getCouponClaimedToday) return !!native.getCouponClaimedToday();
return false;
}
// ====== 动作类(无返回;异步结果走事件) ======
/** 跳原生页。route 取值对齐安卓 Routes(invite / settings / feedback / withdrawal / compareRecords / reportFlow / guideVideo / compareResult / coinHistory / cashHistory / welfareRules ...)。 */
function navigate(route) {
if (hasNative && native.navigate) native.navigate(route);
else console.log('[SGBridge mock] navigate →', route);
}
/** 拉起极光一键登录。结果异步经 onAuthChange 事件回来(不在此函数返回)。 */
function requestLogin() {
if (hasNative && native.requestLogin) native.requestLogin();
else console.log('[SGBridge mock] requestLogin');
}
/** 原生居中 Toast。 */
function toast(msg) {
if (hasNative && native.toast) native.toast(String(msg));
else console.log('[SGBridge mock] toast →', msg);
}
/** 跳美团/外卖 App(deeplink 优先;空则原生按包名启动,未装可跳应用商店)。 */
function openMeituan(deeplink) {
if (hasNative && native.openMeituan) native.openMeituan(deeplink || '');
else console.log('[SGBridge mock] openMeituan →', deeplink);
}
/** 触发 agent 比价流程(原生起无障碍引擎)。 */
function startCompare() {
if (hasNative && native.startCompare) native.startCompare();
else console.log('[SGBridge mock] startCompare');
}
/** 触发一键领券(原生先校验悬浮窗/无障碍权限,再起前台服务)。platforms: string[]。 */
function startCouponClaim(platforms) {
var json = JSON.stringify(platforms || []);
if (hasNative && native.startCouponClaim) native.startCouponClaim(json);
else console.log('[SGBridge mock] startCouponClaim →', json);
}
/** App HomePicker :
* getLaunchIntentForPackage 的拉起(NEW_TASK|CLEAR_TASK 冷启到平台首页)packages: string[](一个平台一组候选包,任一可拉即拉) */
function launchApp(packages) {
var json = JSON.stringify(packages || []);
if (hasNative && native.launchApp) native.launchApp(json);
else console.log('[SGBridge mock] launchApp →', json);
}
// ====== 原生 → H5 事件总线 ======
var listeners = {}; // event → [fn]
/** 订阅原生事件。返回取消订阅函数。 */
function on(event, fn) {
(listeners[event] || (listeners[event] = [])).push(fn);
return function off() {
listeners[event] = (listeners[event] || []).filter(function (f) { return f !== fn; });
};
}
/** 供原生回调:window.SGBridge._emit('onAuthChange', '{...}')。payload 可为 JSON 串或对象。 */
function _emit(event, payload) {
var data = typeof payload === 'string' ? safeParse(payload, payload) : payload;
(listeners[event] || []).forEach(function (fn) {
try { fn(data); } catch (e) { console.error('[SGBridge] listener error', event, e); }
});
}
global.SGBridge = {
hasNative: hasNative,
// 查询
getAuthState: getAuthState,
getToken: getToken,
getDeviceId: getDeviceId,
getAppVersion: getAppVersion,
getInstalledApps: getInstalledApps,
getCouponClaimedToday: getCouponClaimedToday,
// 动作
navigate: navigate,
requestLogin: requestLogin,
toast: toast,
openMeituan: openMeituan,
startCompare: startCompare,
startCouponClaim: startCouponClaim,
launchApp: launchApp,
// 事件
on: on,
_emit: _emit,
};
})(window);
+2 -1
View File
@@ -305,11 +305,12 @@ def test_feed_reward_grants_by_10_second_units(client) -> None:
"duration_seconds": 30,
"adn": "pangle",
"slot_id": "slot_feed",
"display_coin": 4,
}
r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token))
assert r.status_code == 200, r.text
body = r.json()
expected = sum(calculate_ad_reward_coin("200", i) for i in range(1, 4))
expected = 4
assert body["granted"] is True
assert body["status"] == "granted"
assert body["unit_count"] == 3
+2 -1
View File
@@ -64,7 +64,8 @@ def test_dashboard_overview(admin_client: TestClient, admin_token: str) -> None:
assert data["users"]["total"] >= 1
assert data["coins"]["granted_total"] >= 5000
assert "success_rate" in data["comparison"]
assert data["cps"]["available"] is False
assert data["cps"]["available"] is True
assert "meituan_order_count" in data["cps"]
def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> None:
+1 -1
View File
@@ -77,7 +77,7 @@ def test_sms_send_too_frequent(client) -> None:
def test_sms_send_device_ip_rate_limit(client, monkeypatch) -> None:
"""发码防刷:同一设备(device_id) + 同一 IP 每小时最多 N 次发码,超出 429。
用不同手机号(绕开单号 60s 冷却)证明限流按设备封顶 换号绕开单号冷却/每日上限的洞
conftest 默认关限流;本用例临时打开 + 调小阈值(避开同 IP 10/分钟那道)+ 清计数隔离"""
conftest 默认关限流;本用例临时打开 + 调小阈值便于测 + 清计数隔离"""
from app.api.v1 import auth
from app.core import ratelimit
+3
View File
@@ -44,6 +44,7 @@ def _food_payload(trace_id: str) -> dict:
"skipped_dish_names": ["黑牛肉卷"],
"total_dish_count": 3,
"information": "在美团找到同店,到手价 ¥123.50",
"total_ms": 12345,
}
@@ -71,6 +72,8 @@ def test_report_and_derive(client) -> None:
assert d["information"] == "在美团找到同店,到手价 ¥123.50"
assert d["store_name"] == "海底捞(朝阳店)"
assert d["total_dish_count"] == 3
assert d["total_ms"] == 12345
assert d["raw_payload"]["total_ms"] == 12345
assert d["skipped_dish_count"] == 1
assert d["skipped_dish_names"] == ["黑牛肉卷"]
assert len(d["comparison_results"]) == 3
+182
View File
@@ -0,0 +1,182 @@
"""admin CPS 端点测试:每日明细 date 格式 + 按天按用户领券下钻(/day-users)。"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
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.admin.repositories import cps as cps_repo
from app.db.session import SessionLocal
from app.models.cps_link import CpsClick
from app.models.cps_wx_user import CpsWxUser
from app.repositories import cps_link as cps_link_repo
_BJ = timezone(timedelta(hours=8))
@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, "cps_test_admin") is None:
admin_repo.create_admin(
db, username="cps_test_admin", password="cps-pass", role="super_admin"
)
finally:
db.close()
c = TestClient(admin_app)
r = c.post("/admin/api/auth/login", json={"username": "cps_test_admin", "password": "cps-pass"})
return r.json()["access_token"]
def _auth(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
def _seed_cps_day(day_bj: date) -> tuple[int, str]:
"""造淘宝群 + 2 个活动(券) + 2 条 link + day_bj 当天点击(含匿名 + 次日各 1) + 授权画像。
o_user(本群唯一):券A visit×3券B visit×2copy×2 visit_count=5copy_count=2
另造 1 条匿名 visit(openid=None) 1 条次日 visit,均不应计入当天该用户聚合
clicked_at 统一存 tz-aware UTC(SQLite DateTime 按字段渲染忽略 tzinfo;
UTC 后字段即 UTC 墙钟, repo _as_utc 比较口径一致)返回 (group_id, openid)
"""
db = SessionLocal()
try:
gid = cps_repo.create_group(db, name="下钻测试群", platforms=["taobao"]).id
openid = f"od_user_{gid}" # 按群唯一,规避 CpsWxUser.openid 唯一约束跨用例冲突
a1 = cps_repo.create_activity(db, name="618神券", platform="taobao", payload="tkl-1")
a2 = cps_repo.create_activity(db, name="买一送一", platform="taobao", payload="tkl-2")
link1 = cps_link_repo.create_link(
db, group_id=gid, activity_id=a1.id, sid=None, target_url="t1", platform="taobao"
)
link2 = cps_link_repo.create_link(
db, group_id=gid, activity_id=a2.id, sid=None, target_url="t2", platform="taobao"
)
db.add(CpsWxUser(openid=openid, nickname="张三", headimgurl="https://h/1"))
db.commit()
def at(hour: int) -> datetime:
return datetime(day_bj.year, day_bj.month, day_bj.day, hour, tzinfo=_BJ).astimezone(
timezone.utc
)
for _ in range(3):
db.add(CpsClick(link_id=link1.id, group_id=gid, sid=None,
event_type="visit", openid=openid, clicked_at=at(10)))
for _ in range(2):
db.add(CpsClick(link_id=link2.id, group_id=gid, sid=None,
event_type="visit", openid=openid, clicked_at=at(11)))
for _ in range(2):
db.add(CpsClick(link_id=link1.id, group_id=gid, sid=None,
event_type="copy", openid=openid, clicked_at=at(12)))
# 匿名点击 — 不计入
db.add(CpsClick(link_id=link1.id, group_id=gid, sid=None,
event_type="visit", openid=None, clicked_at=at(13)))
# 次日点击 — 验证时间窗,不计入当天
nxt = (datetime(day_bj.year, day_bj.month, day_bj.day, 10, tzinfo=_BJ)
+ timedelta(days=1)).astimezone(timezone.utc)
db.add(CpsClick(link_id=link1.id, group_id=gid, sid=None,
event_type="visit", openid=openid, clicked_at=nxt))
db.commit()
return gid, openid
finally:
db.close()
def test_daily_date_is_full_iso(admin_client: TestClient, admin_token: str) -> None:
"""/daily 每行 date 改为 YYYY-MM-DD(10 字符、两个连字符),不再是 MM-DD。"""
gid, _ = _seed_cps_day(date(2026, 6, 25))
r = admin_client.get(
f"/admin/api/cps/groups/{gid}/daily", params={"days": 3}, headers=_auth(admin_token)
)
assert r.status_code == 200, r.text
rows = r.json()["rows"]
assert rows, "应有按天补零行"
for row in rows:
assert len(row["date"]) == 10 and row["date"].count("-") == 2, row["date"]
def test_day_users_aggregates(admin_client: TestClient, admin_token: str) -> None:
"""当天该群:仅授权用户;领券=copy、点击=visit;coupons=visit 券×次数倒序、合计=点击次数。"""
gid, openid = _seed_cps_day(date(2026, 6, 25))
r = admin_client.get(
f"/admin/api/cps/groups/{gid}/day-users",
params={"date": "2026-06-25"},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
body = r.json()
assert body["group_id"] == gid
assert body["date"] == "2026-06-25"
users = body["users"]
assert len(users) == 1 # 匿名不计、次日不计
u = users[0]
assert u["openid"] == openid
assert u["nickname"] == "张三"
assert u["headimgurl"] == "https://h/1"
assert u["copy_count"] == 2
assert u["visit_count"] == 5
assert [c["name"] for c in u["coupons"]] == ["618神券", "买一送一"]
assert [c["count"] for c in u["coupons"]] == [3, 2]
assert sum(c["count"] for c in u["coupons"]) == u["visit_count"]
def test_day_users_group_not_found(admin_client: TestClient, admin_token: str) -> None:
r = admin_client.get(
"/admin/api/cps/groups/999999/day-users",
params={"date": "2026-06-25"},
headers=_auth(admin_token),
)
assert r.status_code == 404
def test_day_users_bad_date(admin_client: TestClient, admin_token: str) -> None:
gid, _ = _seed_cps_day(date(2026, 6, 25))
r = admin_client.get(
f"/admin/api/cps/groups/{gid}/day-users",
params={"date": "2026/06/25"}, # 非 YYYY-MM-DD
headers=_auth(admin_token),
)
assert r.status_code == 400
def test_day_users_requires_auth(admin_client: TestClient) -> None:
r = admin_client.get(
"/admin/api/cps/groups/1/day-users", params={"date": "2026-06-25"}
)
assert r.status_code == 401
def test_day_users_empty_when_no_clicks(admin_client: TestClient, admin_token: str) -> None:
gid, _ = _seed_cps_day(date(2026, 6, 25))
r = admin_client.get(
f"/admin/api/cps/groups/{gid}/day-users",
params={"date": "2026-06-20"}, # 该群当天无任何点击
headers=_auth(admin_token),
)
assert r.status_code == 200
assert r.json()["users"] == []
def test_day_users_cross_year(admin_client: TestClient, admin_token: str) -> None:
"""跨年:YYYY-MM-DD 才能精确定位 12-31(MM-DD 会丢年份);次日(次年 01-01)不计入。"""
gid, openid = _seed_cps_day(date(2025, 12, 31))
r = admin_client.get(
f"/admin/api/cps/groups/{gid}/day-users",
params={"date": "2025-12-31"},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
users = r.json()["users"]
assert len(users) == 1
assert users[0]["openid"] == openid
assert users[0]["visit_count"] == 5 # 次年 01-01 那条被时间窗排除
+14 -15
View File
@@ -1,4 +1,4 @@
"""好友邀请测试:邀请码、绑定双方发金币、幂等、自邀/无效码屏蔽、指纹兜底归因。
"""好友邀请测试:邀请码、绑定(双方不发钱)、幂等、自邀/无效码屏蔽、指纹兜底归因。
sms mock 登录拿 token( test_welfare),再跑邀请闭环
"""
@@ -10,8 +10,6 @@ from sqlalchemy import select
from app.core.rewards import (
INVITE_FP_WINDOW_DAYS,
INVITE_INVITEE_COINS,
INVITE_INVITER_COINS,
INVITE_NEW_USER_WINDOW_HOURS,
)
from app.db.session import SessionLocal
@@ -56,8 +54,8 @@ def test_invite_me_returns_stable_code(client) -> None:
assert _my_code(client, token) == body["invite_code"]
def test_bind_flow_both_get_coins(client) -> None:
"""B 用 A 的码绑定 → 双方各得 1 万金币;A 战绩 +1"""
def test_bind_flow_no_coins_either_side(client) -> None:
"""v3:B 用 A 的码绑定 → 双方都不发钱(被邀请人无奖励、邀请人改比价后发现金)"""
a = _login(client, "13800002002")
b = _login(client, "13800002003")
a_code = _my_code(client, a)
@@ -69,16 +67,16 @@ def test_bind_flow_both_get_coins(client) -> None:
assert r.status_code == 200, r.text
res = r.json()
assert res["status"] == "success"
assert res["coins_awarded"] == INVITE_INVITEE_COINS
assert res["coins_awarded"] == 0
# 双方金币到账
assert _coin_balance(client, b) == INVITE_INVITEE_COINS
assert _coin_balance(client, a) == INVITE_INVITER_COINS
# 绑定后双方金币都不增(邀请人收益改走"好友比价发 2 元邀请奖励金")
assert _coin_balance(client, b) == 0
assert _coin_balance(client, a) == 0
# A 的战绩:已邀 1 人,累计获得 = 邀请人那份
# A 的战绩:已邀 1 人;金币口径收益恒 0(邀请人收益走邀请奖励金,见 try_reward_on_compare)
r = client.get("/api/v1/invite/me", headers=_auth(a))
assert r.json()["invited_count"] == 1
assert r.json()["coins_earned"] == INVITE_INVITER_COINS
assert r.json()["coins_earned"] == 0
def test_bind_idempotent_no_double_reward(client) -> None:
@@ -217,9 +215,9 @@ def test_bind_by_fingerprint_success(client) -> None:
)
assert r2.status_code == 200, r2.text
assert r2.json()["status"] == "success"
# 双方各发金币
assert _coin_balance(client, a) == INVITE_INVITER_COINS
assert _coin_balance(client, b) == INVITE_INVITEE_COINS
# v3:绑定双方都不发钱(被邀请人无奖励、邀请人改比价发现金)
assert _coin_balance(client, a) == 0
assert _coin_balance(client, b) == 0
def test_bind_by_fingerprint_not_found(client) -> None:
@@ -322,7 +320,8 @@ def test_invitees_basic(client) -> None:
names = {it["display_name"] for it in body["items"]}
assert names == expected_names
assert all(it["avatar_url"] is None for it in body["items"])
assert all(it["coins"] == INVITE_INVITER_COINS for it in body["items"])
# v2:绑定时邀请人那份为 0(收益改"好友比价才发奖"),故每条 coins 字段=0
assert all(it["coins"] == 0 for it in body["items"])
def test_invitees_order_desc(client) -> None:
+193
View File
@@ -0,0 +1,193 @@
"""邀请奖励金提现账户隔离测试:提现扣 invite_cash 账户、退款退回 invite_cash、与金币现金不串、
/invite/me 返回奖励金战绩提现单 source 过滤复用 test_withdraw wxpay monkeypatch 模式
"""
from __future__ import annotations
from sqlalchemy import select
from app.db.session import SessionLocal
from app.models.user import User
from app.models.wallet import CoinAccount, InviteCashTransaction
from app.repositories import wallet as crud_wallet
def _login(client, phone: str) -> str:
client.post("/api/v1/auth/sms/send", json={"phone": phone})
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
assert r.status_code == 200, r.text
return r.json()["access_token"]
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _patch_userinfo(monkeypatch, openid: str) -> None:
monkeypatch.setattr(
"app.integrations.wxpay.code_to_userinfo",
lambda code: {"openid": openid, "nickname": None, "avatar_url": None, "raw": {}},
)
def _seed_balances(client, token: str, phone: str, *, cash: int = 0, invite_cash: int = 0) -> None:
"""访问 /account 触发建账户,再 DB 直接灌两个账户余额(没有"加钱"接口,正常靠兑换/发奖)。"""
client.get("/api/v1/wallet/account", headers=_auth(token))
db = SessionLocal()
try:
user = db.execute(select(User).where(User.phone == phone)).scalar_one()
acc = db.get(CoinAccount, user.id)
acc.cash_balance_cents = cash
acc.invite_cash_balance_cents = invite_cash
db.commit()
finally:
db.close()
def _balances(client, token: str) -> tuple[int, int]:
j = client.get("/api/v1/wallet/account", headers=_auth(token)).json()
return j["cash_balance_cents"], j["invite_cash_balance_cents"]
def _reject(bill: str, reason: str = "测试拒绝") -> None:
db = SessionLocal()
try:
crud_wallet.reject_withdraw(db, bill, reason)
finally:
db.close()
def test_invite_cash_withdraw_deducts_invite_account(client, monkeypatch) -> None:
"""source=invite_cash 提现 → 扣 invite_cash_balance_cents,不动 cash;流水落 invite_cash_transaction。"""
_patch_userinfo(monkeypatch, "openid_ic_1")
token = _login(client, "13800004001")
_seed_balances(client, token, "13800004001", cash=300, invite_cash=500)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 200, "source": "invite_cash"},
headers=_auth(token),
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "reviewing"
cash, invite_cash = _balances(client, token)
assert invite_cash == 300 # 扣了邀请奖励金
assert cash == 300 # 金币现金没动
db = SessionLocal()
try:
user = db.execute(select(User).where(User.phone == "13800004001")).scalar_one()
txns = db.execute(
select(InviteCashTransaction).where(
InviteCashTransaction.user_id == user.id,
InviteCashTransaction.biz_type == "invite_withdraw",
)
).scalars().all()
assert len(txns) == 1 and txns[0].amount_cents == -200
finally:
db.close()
orders = client.get("/api/v1/wallet/withdraw-orders", headers=_auth(token)).json()["items"]
assert orders[0]["source"] == "invite_cash"
def test_invite_cash_reject_refunds_invite_account(client, monkeypatch) -> None:
"""拒绝 invite_cash 提现 → 退回 invite_cash,不串金币现金;退款流水落 invite_cash_transaction。"""
_patch_userinfo(monkeypatch, "openid_ic_2")
token = _login(client, "13800004002")
_seed_balances(client, token, "13800004002", cash=0, invite_cash=500)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 200, "source": "invite_cash"},
headers=_auth(token),
)
bill = r.json()["out_bill_no"]
cash, invite_cash = _balances(client, token)
assert invite_cash == 300 and cash == 0 # 扣后
_reject(bill)
cash, invite_cash = _balances(client, token)
assert invite_cash == 500 # 退回邀请奖励金
assert cash == 0 # 没串进现金
db = SessionLocal()
try:
user = db.execute(select(User).where(User.phone == "13800004002")).scalar_one()
refunds = db.execute(
select(InviteCashTransaction).where(
InviteCashTransaction.user_id == user.id,
InviteCashTransaction.biz_type == "invite_withdraw_refund",
)
).scalars().all()
assert len(refunds) == 1 and refunds[0].amount_cents == 200
finally:
db.close()
def test_two_accounts_withdraw_independent(client, monkeypatch) -> None:
"""两账户各提各的不串:先提 invite_cash(拒绝结清),再提 cash,各扣各账户。
:一个用户同一时间只能一个活跃提现单(跨账户),故第二笔需先结清第一笔"""
_patch_userinfo(monkeypatch, "openid_ic_3")
token = _login(client, "13800004003")
_seed_balances(client, token, "13800004003", cash=400, invite_cash=500)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r1 = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 200, "source": "invite_cash"},
headers=_auth(token),
)
_reject(r1.json()["out_bill_no"]) # 退回 invite_cash + 结清活跃单
r2 = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 100, "source": "coin_cash"},
headers=_auth(token),
)
assert r2.json()["status"] == "reviewing"
cash, invite_cash = _balances(client, token)
assert invite_cash == 500 # 已退回
assert cash == 300 # 扣了 cash 100
def test_invite_me_returns_reward_stats(client) -> None:
"""/invite/me 返回 reward_balance_cents(可提现奖励金)。"""
token = _login(client, "13800004004")
_seed_balances(client, token, "13800004004", invite_cash=350)
j = client.get("/api/v1/invite/me", headers=_auth(token)).json()
assert j["reward_balance_cents"] == 350
assert j["reward_withdrawn_cents"] == 0
def test_withdraw_orders_source_filter(client, monkeypatch) -> None:
"""/wallet/withdraw-orders?source=invite_cash 只返回邀请奖励金提现单。"""
_patch_userinfo(monkeypatch, "openid_ic_5")
token = _login(client, "13800004005")
_seed_balances(client, token, "13800004005", cash=400, invite_cash=500)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r1 = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 200, "source": "invite_cash"},
headers=_auth(token),
)
_reject(r1.json()["out_bill_no"]) # 结清,才能提第二笔
client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 100, "source": "coin_cash"},
headers=_auth(token),
)
all_orders = client.get("/api/v1/wallet/withdraw-orders", headers=_auth(token)).json()["items"]
invite_orders = client.get(
"/api/v1/wallet/withdraw-orders", params={"source": "invite_cash"}, headers=_auth(token)
).json()["items"]
coin_orders = client.get(
"/api/v1/wallet/withdraw-orders", params={"source": "coin_cash"}, headers=_auth(token)
).json()["items"]
assert len(all_orders) == 2
assert len(invite_orders) == 1 and invite_orders[0]["source"] == "invite_cash"
assert len(coin_orders) == 1 and coin_orders[0]["source"] == "coin_cash"
+114
View File
@@ -0,0 +1,114 @@
"""邀请 v3 比价发奖测试:好友完成成功比价 → 邀请人得邀请奖励金(独立账户),幂等只发一次,
账户隔离不串金币/现金被邀请人绑定不得任何奖励由 test_invite.py 覆盖
"""
from __future__ import annotations
from sqlalchemy import select
from app.core.rewards import INVITE_COMPARE_REWARD_CENTS
from app.db.session import SessionLocal
from app.models.wallet import CoinAccount, InviteCashTransaction
from app.repositories.user import get_user_by_phone
def _login(client, phone: str) -> str:
client.post("/api/v1/auth/sms/send", json={"phone": phone})
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
assert r.status_code == 200, r.text
return r.json()["access_token"]
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _my_code(client, token: str) -> str:
return client.get("/api/v1/invite/me", headers=_auth(token)).json()["invite_code"]
def _bind(client, token: str, code: str):
return client.post("/api/v1/invite/bind", json={"invite_code": code}, headers=_auth(token))
def _report_compare(client, token: str, trace_id: str, status: str = "success"):
return client.post(
"/api/v1/compare/record",
json={"trace_id": trace_id, "status": status},
headers=_auth(token),
)
def _invite_cash(phone: str) -> int:
"""被试用户的邀请奖励金余额(分)。专用查询端点见小步3,这里直接查 DB。"""
with SessionLocal() as db:
u = get_user_by_phone(db, phone)
acc = db.get(CoinAccount, u.id) if u else None
return acc.invite_cash_balance_cents if acc else 0
def test_compare_reward_granted_to_inviter(client) -> None:
"""B 被 A 邀请后完成一次成功比价 → A 得邀请奖励金 2 元(独立账户),B 不得该奖。"""
a = _login(client, "13800003001")
b = _login(client, "13800003002")
_bind(client, b, _my_code(client, a))
assert _invite_cash("13800003001") == 0 # 发奖前
r = _report_compare(client, b, "trace-reward-1")
assert r.status_code == 200, r.text
assert _invite_cash("13800003001") == INVITE_COMPARE_REWARD_CENTS # 邀请人到账
assert _invite_cash("13800003002") == 0 # 被邀请人不得此奖
def test_compare_reward_idempotent(client) -> None:
"""好友比价多次 → 只发一次(compare_reward_granted 幂等)。"""
a = _login(client, "13800003003")
b = _login(client, "13800003004")
_bind(client, b, _my_code(client, a))
_report_compare(client, b, "trace-idem-1")
_report_compare(client, b, "trace-idem-2") # 第二次比价(不同 trace)
_report_compare(client, b, "trace-idem-1") # 重复上报同 trace
assert _invite_cash("13800003003") == INVITE_COMPARE_REWARD_CENTS # 仍只发一次
def test_compare_no_relation_no_reward(client) -> None:
"""没有邀请关系的人比价 → 不发奖(没人是他的邀请人)。"""
x = _login(client, "13800003005")
r = _report_compare(client, x, "trace-norel-1")
assert r.status_code == 200, r.text
assert _invite_cash("13800003005") == 0
def test_compare_failed_no_reward(client) -> None:
"""失败的比价(status=failed)不触发发奖。"""
a = _login(client, "13800003006")
b = _login(client, "13800003007")
_bind(client, b, _my_code(client, a))
_report_compare(client, b, "trace-fail-1", status="failed")
assert _invite_cash("13800003006") == 0
def test_compare_reward_isolated_from_coin_cash(client) -> None:
"""账户隔离:邀请奖励金进 invite_cash,不串 cash_balance_cents;流水落 invite_cash_transaction。"""
a = _login(client, "13800003008")
b = _login(client, "13800003009")
_bind(client, b, _my_code(client, a))
_report_compare(client, b, "trace-iso-1")
with SessionLocal() as db:
ua = get_user_by_phone(db, "13800003008")
acc = db.get(CoinAccount, ua.id)
assert acc.invite_cash_balance_cents == INVITE_COMPARE_REWARD_CENTS # 奖励金到账
assert acc.cash_balance_cents == 0 # 没串进金币现金
txns = db.execute(
select(InviteCashTransaction).where(
InviteCashTransaction.user_id == ua.id,
InviteCashTransaction.biz_type == "invite_reward",
)
).scalars().all()
assert len(txns) == 1
assert txns[0].amount_cents == INVITE_COMPARE_REWARD_CENTS
+6 -1
View File
@@ -46,7 +46,12 @@ def test_account_auto_created_empty(client) -> None:
r = client.get("/api/v1/wallet/account", headers=_auth(token))
assert r.status_code == 200, r.text
body = r.json()
assert body == {"coin_balance": 0, "cash_balance_cents": 0, "total_coin_earned": 0}
assert body == {
"coin_balance": 0,
"cash_balance_cents": 0,
"invite_cash_balance_cents": 0, # v2 账户隔离新增(邀请奖励金,与现金隔离)
"total_coin_earned": 0,
}
def test_signin_flow(client) -> None:
+2 -1
View File
@@ -52,7 +52,8 @@ def call_raw(path: str, body_obj: dict) -> dict:
}
url = f"{settings.MT_CPS_HOST}{path}"
t0 = time.time()
resp = httpx.post(url, content=body, headers=headers, timeout=settings.MT_CPS_TIMEOUT_SEC)
# trust_env=False: 美团是国内域名,强制直连绕开本机代理(代理会掐断 TLS 握手,报 SSL EOF)
resp = httpx.post(url, content=body, headers=headers, timeout=settings.MT_CPS_TIMEOUT_SEC, trust_env=False)
ms = int((time.time() - t0) * 1000)
try:
j = resp.json()