Compare commits

...

8 Commits

Author SHA1 Message Date
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
55 changed files with 2133 additions and 245 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.config import router as config_router
from app.admin.routers.cps import router as cps_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.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.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 import router as feedback_router
from app.admin.routers.feedback_qr import router as feedback_qr_router from app.admin.routers.feedback_qr import router as feedback_qr_router
from app.admin.routers.onboarding import router as onboarding_router from app.admin.routers.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(auth_router)
admin_app.include_router(dashboard_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_stat_config_router)
admin_app.include_router(ops_marquee_seed_router) admin_app.include_router(ops_marquee_seed_router)
admin_app.include_router(users_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(withdraw_router)
admin_app.include_router(price_report_router) admin_app.include_router(price_report_router)
admin_app.include_router(feedback_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(feedback_qr_router)
admin_app.include_router(admins_router) admin_app.include_router(admins_router)
admin_app.include_router(audit_router) admin_app.include_router(audit_router)
+38 -6
View File
@@ -128,8 +128,28 @@ def _feed_prior_granted_units(
return {uid: int(n) for uid, n in db.execute(stmt).all()} 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]: def _feed_scene_matches(rec: AdFeedRewardRecord, scene: str | None) -> bool:
"""信息流记录复算。granted 记录逐份累加,LT 序号沿用账号累计份数(含本日之前)。""" """信息流记录是否落入请求的展示筛选 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 记录逐份累加,LT 序号沿用账号累计份数(含本日之前)。
**关键:LT 因子账号累计按全表 unit 累计(feed+draw 共享同一发奖池/上限),不按 ad_type 拆分**——
故无论 scene 怎么筛展示,这里都遍历当日**全部**信息流记录维持 granted_units 累加;scene 只决定
哪些行被**留下展示**(由 _feed_scene_matches 判断),不影响累计基线,保证复算序号与正式发奖一致。
"""
stmt = ( stmt = (
select(AdFeedRewardRecord) select(AdFeedRewardRecord)
.where(AdFeedRewardRecord.reward_date == date) .where(AdFeedRewardRecord.reward_date == date)
@@ -142,18 +162,23 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
granted_units: dict[int, int] = _feed_prior_granted_units(db, date=date, user_id=user_id) granted_units: dict[int, int] = _feed_prior_granted_units(db, date=date, user_id=user_id)
rows: list[dict] = [] rows: list[dict] = []
for rec in db.execute(stmt).scalars(): for rec in db.execute(stmt).scalars():
keep = _feed_scene_matches(rec, scene) # 累计照常推进,这里只决定是否展示本行
if rec.status == "granted": if rec.status == "granted":
existing = granted_units.get(rec.user_id, 0) existing = granted_units.get(rec.user_id, 0)
units = rec.unit_count units = rec.unit_count
granted_units[rec.user_id] = existing + units
if not keep:
continue
expected = sum( expected = sum(
rewards.calculate_ad_reward_coin(rec.ecpm_raw, existing + offset) rewards.calculate_ad_reward_coin(rec.ecpm_raw, existing + offset)
for offset in range(1, units + 1) for offset in range(1, units + 1)
) )
granted_units[rec.user_id] = existing + units
start = existing + 1 if units > 0 else None start = existing + 1 if units > 0 else None
end = existing + units if units > 0 else None end = existing + units if units > 0 else None
rows.append({ rows.append({
"scene": "feed", "scene": "feed",
"ad_type": rec.ad_type or "feed",
"feed_scene": rec.feed_scene,
"record_id": rec.id, "record_id": rec.id,
"user_id": rec.user_id, "user_id": rec.user_id,
"ad_session_id": rec.ad_session_id, "ad_session_id": rec.ad_session_id,
@@ -173,8 +198,12 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
"matched": expected == rec.coin, "matched": expected == rec.coin,
}) })
else: else:
if not keep:
continue
rows.append({ rows.append({
"scene": "feed", "scene": "feed",
"ad_type": rec.ad_type or "feed",
"feed_scene": rec.feed_scene,
"record_id": rec.id, "record_id": rec.id,
"user_id": rec.user_id, "user_id": rec.user_id,
"ad_session_id": rec.ad_session_id, "ad_session_id": rec.ad_session_id,
@@ -199,16 +228,19 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
def audit_rows( def audit_rows(
db: Session, *, date: str, user_id: int | None, scene: str | None = None db: Session, *, date: str, user_id: int | None, scene: str | None = None
) -> list[dict]: ) -> 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` 等,供金币审计逐条对账, 每行含 `app_env`/`our_code_id`/`expected_coin`/`actual_coin` 等,供金币审计逐条对账,
也供广告收益报表把「应发/实发」按 用户×类型×应用×代码位 聚合(见 ad_revenue,复用同一复算口径)。 也供广告收益报表把「应发/实发」按 用户×类型×应用×代码位 聚合(见 ad_revenue,复用同一复算口径)。
**LT 因子账号累计仍按全表 unit 累计(feed+draw 共享),scene 只筛展示,不拆累计。**
""" """
rows: list[dict] = [] rows: list[dict] = []
if scene in (None, "reward_video"): if scene in (None, "reward_video"):
rows.extend(_reward_video_rows(db, date=date, user_id=user_id)) rows.extend(_reward_video_rows(db, date=date, user_id=user_id))
if scene in (None, "feed"): if scene in (None, "feed", "draw"):
rows.extend(_feed_rows(db, date=date, user_id=user_id)) rows.extend(_feed_rows(db, date=date, user_id=user_id, scene=scene))
return rows return rows
+27 -8
View File
@@ -11,12 +11,17 @@
(ad_audit.audit_rows,与正式发奖同一公式口径,不另写公式)。合计与对账在全量上统计, (ad_audit.audit_rows,与正式发奖同一公式口径,不另写公式)。合计与对账在全量上统计,
不受 limit(只截断 items)影响。 不受 limit(只截断 items)影响。
⚠️ 局限:① 历史 Draw 发奖混在 ad_feed_reward_record 无类型标记,金币侧统一记 feed。 每行带 ad_type(reward_video/feed/draw)与 feed_scene(comparison/coupon/welfare),供前端区分
② 跨天 S2S 回调:同一次广告的展示与发奖偶尔落相邻日,各自按 report_date / reward_date 归日 「比价 Draw 收益」与「领券 Draw 收益」(比价/领券共用同一代码位,只能靠 feed_scene 分)
⚠️ 局限:① 历史信息流/Draw 发奖 ad_type 为 NULL 的旧记录统一视为 feed(向后兼容);Draw 仅
ad_type=="draw" 的新记录单独成类。② 跨天 S2S 回调:同一次广告的展示与发奖偶尔落相邻日,各自按
report_date / reward_date 归日。
""" """
from __future__ import annotations 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 import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -30,7 +35,7 @@ from app.models.user import User
def _cn_hour(dt: datetime) -> int: def _cn_hour(dt: datetime) -> int:
"""created_at(UTC 口径)→ 北京时间小时(023)。naive 当 UTC 处理(sqlite),tz-aware 直接换算(pg)。""" """created_at(UTC 口径)→ 北京时间小时(023)。naive 当 UTC 处理(sqlite),tz-aware 直接换算(pg)。"""
if dt.tzinfo is None: if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc) dt = dt.replace(tzinfo=UTC)
return dt.astimezone(rewards.CN_TZ).hour return dt.astimezone(rewards.CN_TZ).hour
@@ -46,8 +51,18 @@ def _date_range(date_from: str, date_to: str) -> list[str]:
return out return out
# 审计行的 scene 与报表 ad_type 一一对应 # 报表 ad_type 与审计 scene 取值一致(reward_video / feed / draw):feed 与 draw 同查发奖表
_SCENE_TO_AD_TYPE = {"reward_video": "reward_video", "feed": "feed"} # 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。 # 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。
_REWARD_DETAIL_KEYS = ( _REWARD_DETAIL_KEYS = (
@@ -84,7 +99,9 @@ def ad_revenue_report(
# 同时保留全量列表,未被展示合并的成「纯发奖」事件。 # 同时保留全量列表,未被展示合并的成「纯发奖」事件。
reward_by_session: dict[tuple[int, str], list[dict]] = {} reward_by_session: dict[tuple[int, str], list[dict]] = {}
all_reward_rows: 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: if ad_type is None or audit_scene is not None:
for d in _date_range(date_from, date_to): 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): for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene):
@@ -123,6 +140,7 @@ def ad_revenue_report(
"report_date": rec.report_date, "report_date": rec.report_date,
"user_id": rec.user_id, "user_id": rec.user_id,
"ad_type": rec.ad_type, "ad_type": rec.ad_type,
"feed_scene": rec.feed_scene,
"app_env": rec.app_env, "app_env": rec.app_env,
"our_code_id": rec.our_code_id, "our_code_id": rec.our_code_id,
"created_at": rec.created_at, "created_at": rec.created_at,
@@ -162,7 +180,8 @@ def ad_revenue_report(
"event_key": f"rwd-{row['record_id']}", "event_key": f"rwd-{row['record_id']}",
"report_date": row["_report_date"], "report_date": row["_report_date"],
"user_id": row["user_id"], "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"), "app_env": row.get("app_env"),
"our_code_id": row.get("our_code_id"), "our_code_id": row.get("our_code_id"),
"created_at": row["created_at"], "created_at": row["created_at"],
+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.repositories import cps_link as cps_link_repo
from app.models.cps_activity import CpsActivity from app.models.cps_activity import CpsActivity
from app.models.cps_group import CpsGroup 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_order import CpsOrder
from app.models.cps_wx_user import CpsWxUser 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) result.sort(key=lambda x: x["first_seen"], reverse=True)
return result[:limit] 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 datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo 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 sqlalchemy.orm import Session
from app.core import rewards from app.core import rewards
from app.core.config import settings
from app.models.ad_feed_reward import AdFeedRewardRecord from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord from app.models.ad_reward import AdRewardRecord
from app.models.admin import AdminAuditLog from app.models.admin import AdminAuditLog
from app.models.analytics_event import AnalyticsEvent
from app.models.comparison import ComparisonRecord from app.models.comparison import ComparisonRecord
from app.models.device import DeviceLiveness
from app.models.feedback import Feedback from app.models.feedback import Feedback
from app.models.onboarding import OnboardingCompletion from app.models.onboarding import OnboardingCompletion
from app.models.price_report import PriceReport 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( def list_all_coin_transactions(
db: Session, db: Session,
*, *,
@@ -412,6 +565,44 @@ def list_feedbacks(
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor) 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: def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder | None:
"""按商户单号查提现单(admin 重试打款先拿 user_id 用,M3)。""" """按商户单号查提现单(admin 重试打款先拿 user_id 用,M3)。"""
return db.execute( return db.execute(
+1 -1
View File
@@ -26,7 +26,7 @@ def get_ad_coin_audit(
date: Annotated[str | None, Query(description="北京时间 YYYY-MM-DD,默认今天")] = None, date: Annotated[str | None, Query(description="北京时间 YYYY-MM-DD,默认今天")] = None,
user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None, user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None,
scene: Annotated[ scene: Annotated[
str | None, Query(description="reward_video / feed;不传=两类都要") str | None, Query(description="reward_video / feed / draw;不传=全部")
] = None, ] = None,
limit: Annotated[int, Query(ge=1, le=500)] = 100, limit: Annotated[int, Query(ge=1, le=500)] = 100,
only_mismatch: Annotated[ only_mismatch: Annotated[
+26 -1
View File
@@ -468,7 +468,7 @@ def group_daily(
while cur <= last: while cur <= last:
cp = click_points[idx] if idx < len(click_points) else None cp = click_points[idx] if idx < len(click_points) else None
row = { row = {
"date": cur.strftime("%m-%d"), "date": cur.strftime("%Y-%m-%d"),
"click_pv": cp["click_pv"] if cp else 0, "click_pv": cp["click_pv"] if cp else 0,
"click_uv": cp["click_uv"] if cp else 0, "click_uv": cp["click_uv"] if cp else 0,
"copy_pv": cp["copy_pv"] if cp else 0, "copy_pv": cp["copy_pv"] if cp else 0,
@@ -496,3 +496,28 @@ def group_wx_users(group_id: int, db: AdminDb) -> dict:
if group is None: if group is None:
raise HTTPException(status_code=404, detail="群不存在") raise HTTPException(status_code=404, detail="群不存在")
return {"users": cps_repo.group_wx_users(db, group_id=group_id)} 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,
}
+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 app_id: str
reward_code_id: str reward_code_id: str
compare_feed_code_id: str compare_draw_code_id: str
coupon_feed_code_id: str coupon_draw_code_id: str
reward_mkey: str reward_mkey: str
reward_enabled: bool reward_enabled: bool
compare_ad_enabled: bool compare_ad_enabled: bool
@@ -23,8 +23,8 @@ class AdConfigUpdate(BaseModel):
app_id: str | None = None app_id: str | None = None
reward_code_id: str | None = None reward_code_id: str | None = None
compare_feed_code_id: str | None = None compare_draw_code_id: str | None = None
coupon_feed_code_id: str | None = None coupon_draw_code_id: str | None = None
reward_mkey: str | None = None reward_mkey: str | None = None
reward_enabled: bool | None = None reward_enabled: bool | None = None
compare_ad_enabled: bool | None = None compare_ad_enabled: bool | None = None
+6 -1
View File
@@ -56,7 +56,12 @@ class AdRevenueRow(BaseModel):
report_date: str = Field(..., description="该事件所属日期(北京时间 YYYY-MM-DD)") report_date: str = Field(..., description="该事件所属日期(北京时间 YYYY-MM-DD)")
user_id: int user_id: int
user_phone: str | None = Field(None, description="用户手机号(admin 展示用,完整;用户已删 / 查不到为空)") 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(测试应用);旧数据为空") app_env: str | None = Field(None, description="我们的应用:prod(傻瓜比价正式) / test(测试应用);旧数据为空")
our_code_id: str | None = Field(None, description="我们后台配置的代码位 ID(104xxx);旧数据为空") our_code_id: str | None = Field(None, description="我们后台配置的代码位 ID(104xxx);旧数据为空")
hour: int | None = Field(None, description="北京时间小时 023(granularity=hour 时有值;按天为 null)") hour: int | None = Field(None, description="北京时间小时 023(granularity=hour 时有值;按天为 null)")
+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
+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
+7 -5
View File
@@ -9,8 +9,8 @@
""" """
from __future__ import annotations from __future__ import annotations
import logging
import json import json
import logging
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status 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.api.deps import CurrentUser, DbSession
from app.core import rewards from app.core import rewards
from app.core.config import settings from app.core.config import settings
from app.integrations import pangle
from app.core.ratelimit import rate_limit 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_ecpm as crud_ecpm
from app.repositories import ad_feed_reward as crud_feed from app.repositories import ad_feed_reward as crud_feed
from app.repositories import ad_reward as crud_ad 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_type=payload.ad_type, ecpm_raw=payload.ecpm,
ad_session_id=payload.ad_session_id, ad_session_id=payload.ad_session_id,
adn=payload.adn, slot_id=payload.slot_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, app_env=payload.app_env, our_code_id=payload.our_code_id,
) )
logger.info( logger.info(
"ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s app=%s code=%s", "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.ad_session_id, payload.ecpm, payload.adn, payload.slot_id, user.id, payload.ad_type, payload.feed_scene, payload.ad_session_id, payload.ecpm,
payload.app_env, payload.our_code_id, payload.adn, payload.slot_id, payload.app_env, payload.our_code_id,
) )
return EcpmReportOut(ok=True) 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, client_event_id=payload.client_event_id,
ecpm=payload.ecpm, ecpm=payload.ecpm,
duration_seconds=payload.duration_seconds, duration_seconds=payload.duration_seconds,
ad_type=payload.ad_type,
ad_session_id=payload.ad_session_id, ad_session_id=payload.ad_session_id,
adn=payload.adn, adn=payload.adn,
slot_id=payload.slot_id, slot_id=payload.slot_id,
+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 每小时最多的登录尝试次数(成功/失败都计)。 # 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。
SMS_LOGIN_MAX_PER_HOUR = 5 SMS_LOGIN_MAX_PER_HOUR = 5
# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。比登录略宽(发码含正常重发); # 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。
# 堵「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞 —— 那两道是单号维度,一机换号能绕开。 # 堵「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞 —— 那两道是单号维度,一机换号能绕开。
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 10 SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5
def _login_response( def _login_response(
@@ -90,7 +90,6 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
"/sms/send", "/sms/send",
response_model=SmsSendResponse, response_model=SmsSendResponse,
summary="发送短信验证码", summary="发送短信验证码",
dependencies=[Depends(rate_limit(10, 60, "sms-send"))], # 同 IP 每分钟≤10 次(防一 IP 刷不同号)
) )
def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse: def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
# 测试账号:不真发短信(号码非真实手机号,真发会失败/浪费),直接放行让客户端进入填码界面。 # 测试账号:不真发短信(号码非真实手机号,真发会失败/浪费),直接放行让客户端进入填码界面。
+13
View File
@@ -21,6 +21,7 @@ from app.api.deps import CurrentUser, DbSession
from app.db.session import SessionLocal from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord from app.models.comparison import ComparisonRecord
from app.repositories import comparison as crud_compare 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.services.pricebot_llm_calls import fetch_llm_calls
from app.schemas.compare_record import ( from app.schemas.compare_record import (
CompareStatsOut, CompareStatsOut,
@@ -52,6 +53,18 @@ def report_record(
# 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用 # 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用
# 独立 session 按 record id 回填 llm_calls + 派生 llm_call_count/retry_count。 # 独立 session 按 record id 回填 llm_calls + 派生 llm_call_count/retry_count。
background_tasks.add_task(_backfill_llm_calls, rec.id, rec.trace_id) 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( logger.info(
"compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)", "compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)",
user.id, user.id,
+9 -2
View File
@@ -38,7 +38,7 @@ logger = logging.getLogger("shagua.invite")
router = APIRouter(prefix="/api/v1/invite", tags=["invite"]) router = APIRouter(prefix="/api/v1/invite", tags=["invite"])
_BIND_MESSAGES = { _BIND_MESSAGES = {
"success": "邀请绑定成功,金币已到账", "success": "邀请绑定成功",
"already_bound": "你已绑定过邀请人", "already_bound": "你已绑定过邀请人",
"invalid_code": "邀请码无效", "invalid_code": "邀请码无效",
"self_invite": "不能填写自己的邀请码", "self_invite": "不能填写自己的邀请码",
@@ -84,6 +84,8 @@ def _parse_device_model(ua: str) -> str:
def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut: def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
code = invite_repo.ensure_code(db, user) code = invite_repo.ensure_code(db, user)
invited, coins = invite_repo.get_stats(db, user.id) 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 "?" sep = "&" if "?" in settings.INVITE_LANDING_URL else "?"
share_url = f"{settings.INVITE_LANDING_URL}{sep}ref={code}" share_url = f"{settings.INVITE_LANDING_URL}{sep}ref={code}"
return InviteInfoOut( return InviteInfoOut(
@@ -91,6 +93,11 @@ def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
share_url=share_url, share_url=share_url,
invited_count=invited, invited_count=invited,
coins_earned=coins, 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") return LandingTrackOut(status="ok")
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,双方发金币)") @router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,绑定不发奖)")
def bind_invite( def bind_invite(
req: BindInviteIn, user: CurrentUser, db: DbSession, request: Request req: BindInviteIn, user: CurrentUser, db: DbSession, request: Request
) -> BindInviteOut: ) -> BindInviteOut:
+2 -2
View File
@@ -63,8 +63,8 @@ def ad_config(db: DbSession) -> AdConfigPublicOut:
return AdConfigPublicOut( return AdConfigPublicOut(
app_id=c["app_id"], app_id=c["app_id"],
reward_code_id=c["reward_code_id"], reward_code_id=c["reward_code_id"],
compare_feed_code_id=c["compare_feed_code_id"], compare_draw_code_id=c["compare_draw_code_id"],
coupon_feed_code_id=c["coupon_feed_code_id"], coupon_draw_code_id=c["coupon_draw_code_id"],
reward_enabled=c["reward_enabled"], reward_enabled=c["reward_enabled"],
compare_ad_enabled=c["compare_ad_enabled"], compare_ad_enabled=c["compare_ad_enabled"],
coupon_ad_enabled=c["coupon_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") raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="wechat pay not configured")
try: try:
order = crud_wallet.create_withdraw( 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: except crud_wallet.InvalidWithdrawAmountError as e:
raise HTTPException( raise HTTPException(
@@ -274,10 +275,11 @@ def withdraw_status(
def withdraw_orders( def withdraw_orders(
user: CurrentUser, user: CurrentUser,
db: DbSession, db: DbSession,
source: str | None = Query(None, description="按账户来源过滤:coin_cash / invite_cash;不传=全部"),
limit: int = Query(20, ge=1, le=100), limit: int = Query(20, ge=1, le=100),
cursor: int | None = Query(None, description="上一页末条 id"), cursor: int | None = Query(None, description="上一页末条 id"),
) -> WithdrawOrderPage: ) -> 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( return WithdrawOrderPage(
items=[WithdrawOrderOut.model_validate(it) for it in items], items=[WithdrawOrderOut.model_validate(it) for it in items],
next_cursor=next_cursor, next_cursor=next_cursor,
+10 -4
View File
@@ -113,10 +113,9 @@ PRICE_REPORT_REWARD_COINS: int = 1000
FEEDBACK_REWARD_MAX_COINS: int = 10000 FEEDBACK_REWARD_MAX_COINS: int = 10000
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)===== # ===== 邀请好友(绑定即生效,绑定只建归因关系、双方都不发钱)=====
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。 # v3(冰 2026-06-26):废除 v1 的"绑定双方各发金币"——被邀请人无奖励、邀请人改比价后发现金
INVITE_INVITER_COINS: int = 10000 # (见下方 INVITE_COMPARE_REWARD_CENTS)。原 INVITE_INVITER_COINS / INVITE_INVITEE_COINS 已删。
INVITE_INVITEE_COINS: int = 10000
# "新用户闸":被邀请人必须在注册后此窗口内绑定才发奖(挡存量老用户互相填码薅羊毛)。 # "新用户闸":被邀请人必须在注册后此窗口内绑定才发奖(挡存量老用户互相填码薅羊毛)。
# 自动绑(剪贴板)在首次注册登录后几秒内发生;留 72h 给手动填码兜底。 # 自动绑(剪贴板)在首次注册登录后几秒内发生;留 72h 给手动填码兜底。
INVITE_NEW_USER_WINDOW_HOURS: int = 72 INVITE_NEW_USER_WINDOW_HOURS: int = 72
@@ -127,6 +126,13 @@ INVITE_NEW_USER_WINDOW_HOURS: int = 72
INVITE_FP_WINDOW_DAYS: int = 7 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(),官方口径单位是【分/千次展示】(不是元! # eCPM 取自穿山甲 SDK getShowEcpm().getEcpm(),官方口径单位是【分/千次展示】(不是元!
# csjplatform 文档原文"通过 getEcpm 获取的单位是分")。计算时先 ÷100 转成元; # csjplatform 文档原文"通过 getEcpm 获取的单位是分")。计算时先 ÷100 转成元;
+3 -1
View File
@@ -14,7 +14,9 @@ worker / 多机时内存不共享 → 冷却、每日上限、校验都会失效
防刷三层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权): 防刷三层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件) 1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)
2. 单号每日 `SMS_DAILY_LIMIT_PER_PHONE` 条上限(本文件) 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` 次即作废(防爆破),验过即作废(一次性) :单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废(防爆破),验过即作废(一次性)
""" """
from __future__ import annotations from __future__ import annotations
+2
View File
@@ -15,6 +15,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from app.api.v1.ad import router as ad_router 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.auth import router as auth_router
from app.api.v1.compare import router as compare_router from app.api.v1.compare import router as compare_router
from app.api.v1.compare_milestone import router as compare_milestone_router from app.api.v1.compare_milestone import router as compare_milestone_router
@@ -105,6 +106,7 @@ def health() -> dict[str, str]:
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(user_router) app.include_router(user_router)
app.include_router(feedback_router) app.include_router(feedback_router)
app.include_router(analytics_router)
app.include_router(invite_router) app.include_router(invite_router)
app.include_router(coupon_router) app.include_router(coupon_router)
app.include_router(device_router) app.include_router(device_router)
+2
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_reward import AdRewardRecord # noqa: F401
from app.models.ad_watch_log import AdWatchLog # noqa: F401 from app.models.ad_watch_log import AdWatchLog # noqa: F401
from app.models.admin import AdminAuditLog, AdminUser # noqa: F401 from app.models.admin 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.app_config import AppConfig # noqa: F401
from app.models.comparison import ComparisonRecord # noqa: F401 from app.models.comparison import ComparisonRecord # noqa: F401
from app.models.cps_activity import CpsActivity # noqa: F401 from app.models.cps_activity import CpsActivity # noqa: F401
@@ -37,5 +38,6 @@ from app.models.wallet import ( # noqa: F401
CashTransaction, CashTransaction,
CoinAccount, CoinAccount,
CoinTransaction, CoinTransaction,
InviteCashTransaction,
WithdrawOrder, WithdrawOrder,
) )
+3
View File
@@ -29,6 +29,9 @@ class AdEcpmRecord(Base):
) )
# 广告类型:reward_video(激励视频) / draw(Draw 信息流) 等;不强行统一代码位,各类型各自上报 # 广告类型:reward_video(激励视频) / draw(Draw 信息流) 等;不强行统一代码位,各类型各自上报
ad_type: Mapped[str] = mapped_column(String(32), nullable=False) 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 会透传同值 # 客户端生成的一次广告会话 id;激励视频 S2S 回调 extra 会透传同值
ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# 实际投放的 ADN(穿山甲 getShowEcpm().getSdkName(),如 pangle / gdt) # 实际投放的 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) ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
adn: Mapped[str | None] = mapped_column(String(32), nullable=True) adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
slot_id: Mapped[str | None] = mapped_column(String(64), 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(福利页)。比价与领券共用同一信息流 # 点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页)。比价与领券共用同一信息流
# 代码位,slot_id/our_code_id 分不出,只能客户端各调用点显式打标;NULL=历史/未升级客户端=未分类。 # 代码位,slot_id/our_code_id 分不出,只能客户端各调用点显式打标;NULL=历史/未升级客户端=未分类。
feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True) 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( ever_protected: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False 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 心跳时间(存活证明);超时即视为保护掉线 # 最近一次 service 心跳时间(存活证明);超时即视为保护掉线
last_heartbeat_at: Mapped[datetime | None] = mapped_column( last_heartbeat_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), index=True, nullable=True DateTime(timezone=True), index=True, nullable=True
+15 -1
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
from datetime import datetime 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 sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base from app.db.base import Base
@@ -35,6 +35,20 @@ class InviteRelation(Base):
inviter_coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0) inviter_coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
invitee_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( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False 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) 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: 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) 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),唯一
out_bill_no: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) out_bill_no: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
amount_cents: Mapped[int] = mapped_column(Integer, 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) user_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 归一化状态:reviewing(待审核) / pending(打款在途) / success / failed(打款失败已退) / rejected(审核拒绝已退) # 归一化状态:reviewing(待审核) / pending(打款在途) / success / failed(打款失败已退) / rejected(审核拒绝已退)
@@ -200,3 +210,41 @@ class CashTransaction(Base):
def __repr__(self) -> str: # pragma: no cover def __repr__(self) -> str: # pragma: no cover
return f"<CashTransaction id={self.id} user_id={self.user_id} cents={self.amount_cents}>" 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}>"
+2
View File
@@ -23,6 +23,7 @@ def create_ecpm_record(
ad_session_id: str | None = None, ad_session_id: str | None = None,
adn: str | None = None, adn: str | None = None,
slot_id: str | None = None, slot_id: str | None = None,
feed_scene: str | None = None,
app_env: str | None = None, app_env: str | None = None,
our_code_id: str | None = None, our_code_id: str | None = None,
) -> AdEcpmRecord: ) -> AdEcpmRecord:
@@ -41,6 +42,7 @@ def create_ecpm_record(
ad_session_id=ad_session_id, ad_session_id=ad_session_id,
adn=adn, adn=adn,
slot_id=slot_id, slot_id=slot_id,
feed_scene=feed_scene,
app_env=app_env, app_env=app_env,
our_code_id=our_code_id, our_code_id=our_code_id,
ecpm_raw=ecpm_raw, ecpm_raw=ecpm_raw,
+6 -1
View File
@@ -14,7 +14,6 @@ from app.core.rewards import cn_today
from app.models.ad_feed_reward import AdFeedRewardRecord from app.models.ad_feed_reward import AdFeedRewardRecord
from app.repositories import wallet as crud_wallet from app.repositories import wallet as crud_wallet
FEED_REWARD_UNIT_SECONDS = 10 FEED_REWARD_UNIT_SECONDS = 10
# 单个 feed 事件的时长上限(秒):一期 duration_seconds 由客户端上报,伪造超长时长会刷份数 # 单个 feed 事件的时长上限(秒):一期 duration_seconds 由客户端上报,伪造超长时长会刷份数
# (每 10 秒 1 份)。真实单条信息流视频远小于此;取 120s=12 份封顶,挡刷量、不影响正规单。 # (每 10 秒 1 份)。真实单条信息流视频远小于此;取 120s=12 份封顶,挡刷量、不影响正规单。
@@ -65,6 +64,7 @@ def grant_feed_reward(
client_event_id: str, client_event_id: str,
ecpm: str, ecpm: str,
duration_seconds: int, duration_seconds: int,
ad_type: str = "feed",
ad_session_id: str | None = None, ad_session_id: str | None = None,
adn: str | None = None, adn: str | None = None,
slot_id: str | None = None, slot_id: str | None = None,
@@ -84,6 +84,8 @@ def grant_feed_reward(
duration_seconds **这一条**的观看秒数服务端两道硬闸防刷:时长钳到 FEED_MAX_DURATION_SECONDS duration_seconds **这一条**的观看秒数服务端两道硬闸防刷:时长钳到 FEED_MAX_DURATION_SECONDS
eCPM calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN;叠加每日 get_ad_daily_limit 条数上限 eCPM calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN;叠加每日 get_ad_daily_limit 条数上限
feed_scene:点位场景(comparison/coupon/welfare),仅归类落库,不参与计算 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) existing = _find_by_event(db, client_event_id)
if existing is not None: if existing is not None:
@@ -106,6 +108,7 @@ def grant_feed_reward(
ecpm_raw=ecpm, ecpm_raw=ecpm,
adn=adn, adn=adn,
slot_id=slot_id, slot_id=slot_id,
ad_type=ad_type,
feed_scene=feed_scene, feed_scene=feed_scene,
trace_id=trace_id, trace_id=trace_id,
app_env=app_env, app_env=app_env,
@@ -126,6 +129,7 @@ def grant_feed_reward(
ecpm_raw=ecpm, ecpm_raw=ecpm,
adn=adn, adn=adn,
slot_id=slot_id, slot_id=slot_id,
ad_type=ad_type,
feed_scene=feed_scene, feed_scene=feed_scene,
trace_id=trace_id, trace_id=trace_id,
app_env=app_env, app_env=app_env,
@@ -147,6 +151,7 @@ def grant_feed_reward(
ecpm_raw=ecpm, ecpm_raw=ecpm,
adn=adn, adn=adn,
slot_id=slot_id, slot_id=slot_id,
ad_type=ad_type,
feed_scene=feed_scene, feed_scene=feed_scene,
trace_id=trace_id, trace_id=trace_id,
app_env=app_env, app_env=app_env,
+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] = { _AD_CONFIG_DEFAULTS: dict[str, Any] = {
"app_id": "5830519", # 穿山甲应用ID(正式) "app_id": "5830519", # 穿山甲应用ID(正式)
"reward_code_id": "104099389", # 福利页激励视频位 "reward_code_id": "104099389", # 福利页激励视频位
# ⚠️ 2026-06-21 真机核对穿山甲后台:5830519 名下信息流真实位是 104142227「信息流 1」; # 比价与领券共用同一穿山甲 Draw 代码位(默认 104098712 = 后台「Draw信息流」位),靠 feed_scene
# 旧值 104090333 不在该应用名下(请求会报 44406/配置 null、出不了广告)。客户端接入下发后以本值为准 # (comparison/coupon)区分收益;运营可在后台把两者拆成不同代码位。字段从旧 *_feed_code_id 改名为 *_draw_code_id
"compare_feed_code_id": "104142227", # 比价信息流位 "compare_draw_code_id": "104098712", # 比价 Draw 代码位(104098712 = Draw 信息流位)
"coupon_feed_code_id": "104142227", # 领券信息流位(初始同比价,运营可拆) "coupon_draw_code_id": "104098712", # 领券 Draw 代码位(初始同比价,运营可拆)
"reward_mkey": "", # 激励位 GroMore 验签密钥(空则回退 .env PANGLE_REWARD_SECRET*) "reward_mkey": "", # 激励位 GroMore 验签密钥(空则回退 .env PANGLE_REWARD_SECRET*)
"reward_enabled": True, # 福利激励视频开关 "reward_enabled": True, # 福利激励视频开关
"compare_ad_enabled": True, # 比价广告开关 "compare_ad_enabled": True, # 比价广告开关
+2
View File
@@ -72,6 +72,8 @@ def touch_heartbeat(
device.last_report_protection_on = accessibility_enabled device.last_report_protection_on = accessibility_enabled
if accessibility_enabled: if accessibility_enabled:
if not device.ever_protected:
device.first_protected_at = now # 首次开无障碍记一次,后续心跳不覆盖
device.last_heartbeat_at = now device.last_heartbeat_at = now
device.ever_protected = True device.ever_protected = True
device.liveness_state = "alive" 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 / 提现的资金安全思路): 防重复发奖三道(仿 ad_reward / 提现的资金安全思路):
1. invitee_user_id 唯一 一个被邀请人只能被绑定一次(幂等键) 1. invitee_user_id 唯一 一个被邀请人只能被绑定一次(幂等键)
2. 自邀屏蔽 inviter == invitee 直接拒 2. 自邀屏蔽 inviter == invitee 直接拒
3. 现成的手机号唯一(每个被邀请人 = 一个真实手机号账号)= 天然限制刷量规模 3. 现成的手机号唯一(每个被邀请人 = 一个真实手机号账号)= 天然限制刷量规模
发金币复用 wallet.grant_coins(grant flush commit),与建关系记录在**同一事务**
commit,保证"建关系 + 双方加金币"原子奖励额 = rewards.INVITE_INVITER_COINS /
INVITE_INVITEE_COINS
""" """
from __future__ import annotations from __future__ import annotations
@@ -34,6 +35,30 @@ def _gen_code() -> str:
return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LEN)) 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: def ensure_code(db: Session, user: User) -> str:
"""保证 user 有邀请码(懒生成),返回它。唯一约束碰撞则换码重试。 """保证 user 有邀请码(懒生成),返回它。唯一约束碰撞则换码重试。
@@ -90,15 +115,17 @@ def _is_new_user(user: User) -> bool:
class BindResult: class BindResult:
status: str # success / already_bound / invalid_code / self_invite / not_eligible status: str # success / already_bound / invalid_code / self_invite / not_eligible
relation: InviteRelation | None = None relation: InviteRelation | None = None
invitee_coin: int = 0 # 本次给被邀请人发的金币(success 时 >0) invitee_coin: int = 0 # v3 起恒 0(绑定不再发金币);保留字段兼容响应
def bind( def bind(
db: Session, *, invitee: User, invite_code: str, channel: str = "clipboard" db: Session, *, invitee: User, invite_code: str, channel: str = "clipboard"
) -> BindResult: ) -> 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) existing = _relation_of_invitee(db, invitee.id)
@@ -114,27 +141,18 @@ def bind(
if not _is_new_user(invitee): if not _is_new_user(invitee):
return BindResult("not_eligible") return BindResult("not_eligible")
inviter_coin = rewards.INVITE_INVITER_COINS # v3(冰 2026-06-26 拍板):绑定双方都不发钱。被邀请人不再发新人金币(去掉拉新即时激励);
invitee_coin = rewards.INVITE_INVITEE_COINS # 邀请人的钱由 try_reward_on_compare 在好友成功比价后发 2 元邀请奖励金(防刷)。
rel = InviteRelation( rel = InviteRelation(
inviter_user_id=inviter.id, inviter_user_id=inviter.id,
invitee_user_id=invitee.id, invitee_user_id=invitee.id,
channel=(channel or "clipboard")[:16], channel=(channel or "clipboard")[:16],
status="effective", status="effective",
inviter_coin=inviter_coin, inviter_coin=0, # v1 金币线已停用,列保留恒 0(待清)
invitee_coin=invitee_coin, invitee_coin=0, # v3:被邀请人绑定不再发金币
) )
db.add(rel) 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: try:
db.commit() db.commit()
except IntegrityError: except IntegrityError:
@@ -145,16 +163,62 @@ def bind(
return BindResult("already_bound", existing) return BindResult("already_bound", existing)
raise raise
except Exception: except Exception:
# 其它 commit 失败(DB 故障 / PG 序列化冲突等):显式回滚,保证"建关系 + 双方发币" # 其它 commit 失败(DB 故障 / PG 序列化冲突等):显式回滚,保证建关系原子,
# 原子(要么全成要么全无),不依赖 get_db 关闭时的隐式回滚,语义更硬。 # 不依赖 get_db 关闭时的隐式回滚,语义更硬。
db.rollback() db.rollback()
raise raise
db.refresh(rel) 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]: 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( count = db.execute(
select(func.count()) select(func.count())
.select_from(InviteRelation) .select_from(InviteRelation)
@@ -167,6 +231,26 @@ def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
return int(count), int(coins) 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: def _mask_phone(phone: str) -> str:
"""手机号脱敏:138****8888。前端拿不到完整号,展示被邀请人时在此兜底名字。 """手机号脱敏:138****8888。前端拿不到完整号,展示被邀请人时在此兜底名字。
@@ -210,7 +294,7 @@ def get_invitees(
items.append({ items.append({
"display_name": u.nickname or u.wechat_nickname or _mask_phone(u.phone), "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, "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, "invited_at": rel.created_at,
}) })
has_more = offset + len(rows) < int(total) has_more = offset + len(rows) < int(total)
+84 -32
View File
@@ -24,6 +24,7 @@ from app.models.wallet import (
CashTransaction, CashTransaction,
CoinAccount, CoinAccount,
CoinTransaction, CoinTransaction,
InviteCashTransaction,
WechatTransferAuthorization, WechatTransferAuthorization,
WithdrawOrder, WithdrawOrder,
) )
@@ -166,6 +167,36 @@ def grant_cash(
return acc, txn 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( def list_coin_transactions(
db: Session, db: Session,
user_id: int, 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}$") _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( res = db.execute(
update(CoinAccount) update(CoinAccount)
.where( .where(CoinAccount.user_id == user_id, col >= amount_cents)
CoinAccount.user_id == user_id, .values({col: col - amount_cents})
CoinAccount.cash_balance_cents >= amount_cents,
)
.values(cash_balance_cents=CoinAccount.cash_balance_cents - amount_cents)
) )
return res.rowcount == 1 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( db.execute(
update(CoinAccount) update(CoinAccount)
.where(CoinAccount.user_id == user_id) .where(CoinAccount.user_id == user_id)
.values(cash_balance_cents=CoinAccount.cash_balance_cents + amount_cents) .values({col: col + amount_cents})
) )
db.flush() db.flush()
bal = db.execute( bal = db.execute(
select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id) select(col).where(CoinAccount.user_id == user_id)
).scalar_one() ).scalar_one()
return bal return bal
@@ -456,11 +497,15 @@ def _refund_withdraw(
""" """
if order.status in ("failed", "rejected"): if order.status in ("failed", "rejected"):
return # 防重复退款(并发/对账与查单/重复拒绝同时触发) 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( refunded_txn_id = db.execute(
select(CashTransaction.id).where( select(txn_model.id).where(
CashTransaction.user_id == order.user_id, txn_model.user_id == order.user_id,
CashTransaction.biz_type == "withdraw_refund", txn_model.biz_type == refund_biz,
CashTransaction.ref_id == order.out_bill_no, txn_model.ref_id == order.out_bill_no,
).limit(1) ).limit(1)
).scalar_one_or_none() ).scalar_one_or_none()
if refunded_txn_id is not None: if refunded_txn_id is not None:
@@ -468,13 +513,13 @@ def _refund_withdraw(
order.fail_reason = reason[:256] order.fail_reason = reason[:256]
db.commit() db.commit()
return 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( db.add(
CashTransaction( txn_model(
user_id=order.user_id, user_id=order.user_id,
amount_cents=order.amount_cents, amount_cents=order.amount_cents,
balance_after_cents=bal, balance_after_cents=bal,
biz_type="withdraw_refund", biz_type=refund_biz,
ref_id=order.out_bill_no, ref_id=order.out_bill_no,
# 用户可见文案区分"未成功(自动退)"vs"审核未通过";技术原因记在 order.fail_reason # 用户可见文案区分"未成功(自动退)"vs"审核未通过";技术原因记在 order.fail_reason
remark=( remark=(
@@ -492,13 +537,13 @@ def _refund_withdraw(
db.commit() db.commit()
except IntegrityError: except IntegrityError:
# 并发退款兜底:唯一退款流水已被另一事务写入时,回滚本事务的加钱和流水, # 并发退款兜底:唯一退款流水已被另一事务写入时,回滚本事务的加钱和流水,
# 再把订单状态补到终态。这样无论拒绝/查单/对账怎么并发,金最多退一次。 # 再把订单状态补到终态。这样无论拒绝/查单/对账怎么并发,金最多退一次。
db.rollback() db.rollback()
refunded_txn_id = db.execute( refunded_txn_id = db.execute(
select(CashTransaction.id).where( select(txn_model.id).where(
CashTransaction.user_id == user_id, txn_model.user_id == user_id,
CashTransaction.biz_type == "withdraw_refund", txn_model.biz_type == refund_biz,
CashTransaction.ref_id == out_bill_no, txn_model.ref_id == out_bill_no,
).limit(1) ).limit(1)
).scalar_one_or_none() ).scalar_one_or_none()
if refunded_txn_id is None: if refunded_txn_id is None:
@@ -562,6 +607,7 @@ def create_withdraw(
user_id: int, user_id: int,
amount_cents: int, amount_cents: int,
*, *,
source: str = "coin_cash",
user_name: str | None = None, user_name: str | None = None,
out_bill_no: str | None = None, out_bill_no: str | None = None,
) -> WithdrawOrder: ) -> WithdrawOrder:
@@ -608,20 +654,23 @@ def create_withdraw(
# 账户须存在(原子扣款的 UPDATE 不会建账户) # 账户须存在(原子扣款的 UPDATE 不会建账户)
get_or_create_account(db, user_id, commit=True) get_or_create_account(db, user_id, commit=True)
# #1 原子扣款:余额不足时影响行数为 0 # #1 原子扣款:余额不足时影响行数为 0(按 source 扣对应账户)
if not _try_deduct_cash(db, user_id, amount_cents): if not _try_deduct_cash(db, user_id, amount_cents, source):
db.rollback() db.rollback()
raise InsufficientCashError 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( 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() ).scalar_one()
db.add( db.add(
CashTransaction( txn_model(
user_id=user_id, user_id=user_id,
amount_cents=-amount_cents, amount_cents=-amount_cents,
balance_after_cents=bal, balance_after_cents=bal,
biz_type="withdraw", biz_type=withdraw_biz,
ref_id=out_bill_no, ref_id=out_bill_no,
remark="提现到微信零钱(待审核)", remark="提现到微信零钱(待审核)",
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None), created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
@@ -631,6 +680,7 @@ def create_withdraw(
user_id=user_id, user_id=user_id,
out_bill_no=out_bill_no, out_bill_no=out_bill_no,
amount_cents=amount_cents, amount_cents=amount_cents,
source=source,
user_name=user_name, user_name=user_name,
status="reviewing", status="reviewing",
) )
@@ -1007,10 +1057,12 @@ def reconcile_pending_withdraws(db: Session, *, older_than_minutes: int = 15) ->
def list_withdraw_orders( 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]: ) -> tuple[list[WithdrawOrder], int | None]:
"""提现单分页(按 id 倒序,游标式)。""" """提现单分页(按 id 倒序,游标式)。source 非空时只返回该账户来源的单(coin_cash / invite_cash)。"""
stmt = select(WithdrawOrder).where(WithdrawOrder.user_id == user_id) 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: if cursor is not None:
stmt = stmt.where(WithdrawOrder.id < cursor) stmt = stmt.where(WithdrawOrder.id < cursor)
stmt = stmt.order_by(WithdrawOrder.id.desc()).limit(limit) stmt = stmt.order_by(WithdrawOrder.id.desc()).limit(limit)
+11
View File
@@ -57,6 +57,12 @@ class EcpmReportIn(BaseModel):
) )
adn: str | None = Field(None, description="实际投放 ADN(getSdkName),如 pangle") adn: str | None = Field(None, description="实际投放 ADN(getSdkName),如 pangle")
slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)") 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( app_env: str | None = Field(
None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)" 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") 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( ad_session_id: str | None = Field(
None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id" None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id"
) )
+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
+29 -1
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
from datetime import datetime 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 name: str
qty: int = 1 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 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): class AppliedCouponIn(BaseModel):
"""单笔已用优惠(来自 comparison_results[].applied_coupons)。amount 单位:元、正数。""" """单笔已用优惠(来自 comparison_results[].applied_coupons)。amount 单位:元、正数。"""
+6 -1
View File
@@ -10,7 +10,12 @@ class InviteInfoOut(BaseModel):
invite_code: str # 我的邀请码 invite_code: str # 我的邀请码
share_url: str # 落地页链接(含 ?ref=),前端据此生成二维码 + 复制分享 share_url: str # 落地页链接(含 ?ref=),前端据此生成二维码 + 复制分享
invited_count: int # 已成功邀请人数 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): class LandingTrackIn(BaseModel):
+2 -2
View File
@@ -38,8 +38,8 @@ class AdConfigPublicOut(BaseModel):
app_id: str # 穿山甲应用ID(改了客户端需冷启才生效,SDK init 一次性读) app_id: str # 穿山甲应用ID(改了客户端需冷启才生效,SDK init 一次性读)
reward_code_id: str # 福利页激励视频位 reward_code_id: str # 福利页激励视频位
compare_feed_code_id: str # 比价信息流 compare_draw_code_id: str # 比价 Draw 代码
coupon_feed_code_id: str # 领券信息流位 coupon_draw_code_id: str # 领券 Draw 代码位(比价/领券共用同一位,靠 feed_scene 区分收益)
reward_enabled: bool # 福利激励视频开关 reward_enabled: bool # 福利激励视频开关
compare_ad_enabled: bool # 比价广告开关 compare_ad_enabled: bool # 比价广告开关
coupon_ad_enabled: bool # 领券广告开关 coupon_ad_enabled: bool # 领券广告开关
+3
View File
@@ -16,6 +16,7 @@ class CoinAccountOut(BaseModel):
coin_balance: int = Field(..., description="当前金币余额") coin_balance: int = Field(..., description="当前金币余额")
cash_balance_cents: int = Field(..., description="当前现金余额(分)") cash_balance_cents: int = Field(..., description="当前现金余额(分)")
invite_cash_balance_cents: int = Field(0, description="邀请奖励金余额(分,与现金隔离)")
total_coin_earned: int = Field(..., description="累计赚取金币") total_coin_earned: int = Field(..., description="累计赚取金币")
@@ -123,6 +124,7 @@ class UnbindWechatResultOut(BaseModel):
class WithdrawRequest(BaseModel): class WithdrawRequest(BaseModel):
amount_cents: int = Field(..., gt=0, description="提现金额(分)") amount_cents: int = Field(..., gt=0, description="提现金额(分)")
source: str = Field("coin_cash", description="提现账户:coin_cash(金币现金) / invite_cash(邀请奖励金)")
user_name: str | None = Field(None, description="实名(达额时微信要求,可空)") user_name: str | None = Field(None, description="实名(达额时微信要求,可空)")
out_bill_no: str | None = Field( out_bill_no: str | None = Field(
None, description="客户端幂等键(商户单号):同号重试不重复转账。不传则服务端生成" None, description="客户端幂等键(商户单号):同号重试不重复转账。不传则服务端生成"
@@ -166,6 +168,7 @@ class WithdrawOrderOut(BaseModel):
id: int id: int
out_bill_no: str out_bill_no: str
amount_cents: int amount_cents: int
source: str = Field("coin_cash", description="提现账户:coin_cash / invite_cash")
status: str = Field(..., description="reviewing(待审核) / pending / success / failed / rejected") status: str = Field(..., description="reviewing(待审核) / pending / success / failed / rejected")
wechat_state: str | None = None wechat_state: str | None = None
fail_reason: str | None = None fail_reason: str | None = None
+224 -117
View File
@@ -8,122 +8,231 @@
<style> <style>
* { margin:0; padding:0; box-sizing:border-box; -webkit-tap-highlight-color:transparent; } * { margin:0; padding:0; box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
html,body { height:100%; } html,body { height:100%; }
button { border:0; background:none; color:inherit; font:inherit; cursor:pointer; }
body { body {
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif; min-height:100vh;
background:linear-gradient(165deg,#FF7A3D 0%,#FF3B30 52%,#E0245E 100%); display:flex; align-items:center; justify-content:center;
color:#fff; min-height:100%; display:flex; flex-direction:column; background:#000;
align-items:center; justify-content:center; padding:40px 26px; text-align:center; font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",sans-serif;
overflow-x:hidden;
} }
.logo { /* 设备框:桌面预览成 375×667 卡片;真机(≤430)铺满全屏 */
width:104px; height:104px; border-radius:26px; background:#fff; .device {
display:flex; align-items:center; justify-content:center; font-size:52px; position:relative; flex:0 0 auto;
box-shadow:0 14px 34px rgba(0,0,0,.22); margin-bottom:24px; 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; } .download-landing {
.slogan { margin-top:12px; font-size:16px; line-height:1.7; opacity:.95; max-width:300px; } position:absolute; inset:0; z-index:0; overflow:hidden;
.feats { margin-top:26px; display:flex; flex-direction:column; gap:12px; width:100%; max-width:320px; } background:
.feat { background:rgba(255,255,255,.16); border-radius:14px; padding:13px 16px; font-size:15px; display:flex; align-items:center; gap:10px; } url('coupon-page-bg.png') center center / cover no-repeat,
.feat b { font-weight:700; } #FFF4CD;
.btn { color:#1A1A1A; text-align:center;
margin-top:34px; width:100%; max-width:320px; border:none; cursor:pointer; }
background:#fff; color:#FF3B30; font-size:19px; font-weight:800; .download-content {
padding:17px 0; border-radius:999px; box-shadow:0 10px 26px rgba(0,0,0,.22); position:relative; z-index:1; height:100%;
display:flex; align-items:center; justify-content:center; gap:9px; 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 { .download-guide-layer {
display:none; position:fixed; inset:0; z-index:9999; position:absolute; inset:0; z-index:30; display:none;
background:rgba(0,0,0,.86); padding:18px; 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> </style>
</head> </head>
<body> <body>
<div class="logo">🛒</div> <main class="device" aria-label="傻瓜比价下载页">
<h1>傻瓜比价</h1> <section class="download-landing" aria-label="傻瓜比价下载页">
<div class="slogan">买什么都先比一比<br>自动帮你找全网最低价</div> <div class="download-content">
<div class="brand-lockup">
<div class="feats"> <img class="ad-logo" src="sb-brand.png" alt="傻瓜比价">
<div class="feat">🍔 <span>点外卖前一键比价,<b>美团/京东/淘宝</b>到手价一目了然</span></div> <h1 class="ad-title">傻瓜比价</h1>
<div class="feat">🎟️ <span>自动领遍各平台<b>红包券</b>,能省的一分不漏</span></div> </div>
<div class="feat">💰 <span>省下的钱看得见,还能<b>赚金币提现</b></span></div> <p class="ad-subtitle">跨平台比价,用傻瓜</p>
</div> <div class="bottom-area" aria-label="下载入口">
<button class="download-btn primary" id="dlbtn" type="button">应用商店下载</button>
<button class="btn" id="dlbtn">🏪 打开应用商店下载</button> <button class="download-btn secondary" id="dlbtn2" type="button">官网下载</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>
</div> </div>
</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>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
</main>
<script> <script>
// 应用商店跳转:用包名(App 唯一标识 = 身份证号)定位到傻瓜比价的商店下载页 // ===== 应用商店跳转用包名App 唯一标识定位到傻瓜比价的商店下载页 =====
// 主:market:// 唤起手机自带应用市场(华为/小米/OV);兜底:应用宝网页(任何浏览器都能开) // 主market:// 唤起手机自带应用市场华为/小米/OV);兜底应用宝网页任何浏览器都能开
var PKG = "com.jishisongfu.shaguabijia"; var PKG = "com.jishisongfu.shaguabijia";
var MARKET_URL = "market://details?id=" + PKG; var MARKET_URL = "market://details?id=" + PKG;
var YYB_URL = "https://a.app.qq.com/o/simple.jsp?pkgname=" + PKG; var YYB_URL = "https://a.app.qq.com/o/simple.jsp?pkgname=" + PKG;
var ua = navigator.userAgent || ""; var ua = navigator.userAgent || "";
var isWeChat = /MicroMessenger/i.test(ua); var isWeChat = /MicroMessenger/i.test(ua);
var isIOS = /iPhone|iPad|iPod/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 表 // ===== 指纹归因兜底页面加载即上报访问者指纹后端存 invite_fingerprint 表 =====
// 当 APK 首启读剪贴板失败(被覆盖)时,客户端用 (IP+屏幕+UA 解析的手机型) 反查 // 当 APK 首启读剪贴板失败被覆盖)时,客户端用 (IP+屏幕+UA 机型) 反查 7 天内最近一条 → 撞出原邀请人。
// 本表 7 天内最近一条匹配 → 撞库出原邀请人 → 走原 bind 流程。 // 任何失败都 silent,不影响下载主流程。
// 任何失败都 silent(不影响下载主流程);后端 invalid_code/no_ip 也只返 200。
if (ref) { if (ref) {
// screen 报【物理像素】= CSS 像素 × devicePixelRatio,跟 Android dm.widthPixels(物理像素)对齐 // screen 报【物理像素】= CSS 像素 × devicePixelRatio跟 Android dm.widthPixels 对齐,否则撞不上库
// 不同设备 DPR 不同(常见 2/2.5/3/3.5),CSS 像素直接报会跟客户端不对齐 → 撞不上库。
var _dpr = window.devicePixelRatio || 1; var _dpr = window.devicePixelRatio || 1;
var _sw = Math.round(screen.width * _dpr); var _sw = Math.round(screen.width * _dpr);
var _sh = Math.round(screen.height * _dpr); var _sh = Math.round(screen.height * _dpr);
fetch("/api/v1/invite/landing-track", { fetch("/api/v1/invite/landing-track", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({ ref: ref, screen: _sw + "x" + _sh }),
ref: ref, }).catch(function () {}); // silent,绝不阻断下载
screen: _sw + "x" + _sh,
// IP / UA 服务端从 HTTP 头自动拿,无需 JS 上报
}),
}).catch(function () {}); // silent,绝不阻断下载
} }
// 把邀请码写进剪贴板,APK 首启读出完成归因(deferred deeplink 的关键一步)。 // ===== 把邀请码写进剪贴板APK 首启读出完成归因deferred deeplink=====
// 浏览器要求:必须在用户点击手势里调用 + HTTPS 下才允许写。 // 浏览器要求必须在用户点击手势里调用 + HTTPS 下才允许写。
function legacyCopy(payload) { // 老 webview / 无 clipboard API 兜底 function legacyCopy(payload) { // 老 webview / 无 clipboard API 兜底
try { try {
var ta = document.createElement("textarea"); var ta = document.createElement("textarea");
ta.value = payload; ta.style.position = "fixed"; ta.style.top = "-1000px"; ta.style.opacity = "0"; 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); document.execCommand("copy"); document.body.removeChild(ta);
} catch (e) {} } catch (e) {}
} }
function copyInviteCode() { // 返回 Promise(完成后才下载,避免异步写入被打断) function copyInviteCode() { // 返回 Promise完成后才下载避免异步写入被打断
if (!ref) return Promise.resolve(); if (!ref) return Promise.resolve();
var payload = "SGBJ_INVITE:" + ref; var payload = "SGBJ_INVITE:" + ref;
legacyCopy(payload); // 同步兜底:在用户手势内立刻 execCommand 写一次(最可靠) legacyCopy(payload); // 同步兜底在用户手势内立刻 execCommand 写一次最可靠
if (navigator.clipboard && navigator.clipboard.writeText) { if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(payload).catch(function () {}); // 现代 API 锦上添花,失败无妨 return navigator.clipboard.writeText(payload).catch(function () {});
} }
return Promise.resolve(); return Promise.resolve();
} }
// 跳应用商店:先尝试 market:// 唤起手机自带商店;若 2.5s 内页面没切后台 // 跳应用商店:先 market:// 唤起自带商店2.5s 内页面没切后台 → 兜底跳应用宝网页,不让用户卡死。
//(= 没有商店接管 market://),兜底跳应用宝网页下载页,不让用户卡死。
function openStore() { function openStore() {
var jumped = false; var jumped = false;
function markJumped() { jumped = true; } // 页面切到后台 = 商店已唤起 document.addEventListener("visibilitychange", function () { if (document.hidden) jumped = true; });
document.addEventListener("visibilitychange", function () { window.addEventListener("pagehide", function () { jumped = true; });
if (document.hidden) jumped = true; window.addEventListener("blur", function () { jumped = true; });
}); window.location.href = MARKET_URL;
window.addEventListener("pagehide", markJumped); setTimeout(function () { if (!jumped) window.location.href = YYB_URL; }, 2500);
window.addEventListener("blur", markJumped);
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 showToast(text) {
function showMask(){ mask.classList.add("show"); } var toast = document.getElementById("toast");
function hideMask(){ mask.classList.remove("show"); } toast.textContent = text; toast.classList.add("show");
document.getElementById("wxclose").addEventListener("click", hideMask); clearTimeout(showToast.timer);
showToast.timer = setTimeout(function () { toast.classList.remove("show"); }, 1400);
}
// 微信里一进页面就提示去浏览器打开(下载在微信内必被拦) // ===== 下载按钮:微信内引导去浏览器;iOS 提示;安卓写邀请码 + 跳应用商店 =====
if (isWeChat) showMask(); function handleDownload() {
if (isWeChat) { showWxGuide(); return; }
document.getElementById("dlbtn").addEventListener("click", function(){ if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
if (isWeChat) { showMask(); return; } // 微信内:引导去浏览器 copyInviteCode(); // 先把邀请码写进剪贴板(供 App 首启归因),链路丢了还有 landing-track 指纹兜底
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
// 安卓浏览器:先把邀请码写进剪贴板(legacyCopy 同步写、最可靠),再跳应用商店。
// 剪贴板供 App 首启归因;链路长易丢时由指纹兜底(已上报 landing-track)接住。
copyInviteCode();
openStore(); openStore();
}); }
document.getElementById("dlbtn").addEventListener("click", handleDownload);
document.getElementById("dlbtn2").addEventListener("click", handleDownload);
</script> </script>
</body> </body>
</html> </html>
+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: def test_sms_send_device_ip_rate_limit(client, monkeypatch) -> None:
"""发码防刷:同一设备(device_id) + 同一 IP 每小时最多 N 次发码,超出 429。 """发码防刷:同一设备(device_id) + 同一 IP 每小时最多 N 次发码,超出 429。
用不同手机号(绕开单号 60s 冷却)证明限流按设备封顶 换号绕开单号冷却/每日上限的洞 用不同手机号(绕开单号 60s 冷却)证明限流按设备封顶 换号绕开单号冷却/每日上限的洞
conftest 默认关限流;本用例临时打开 + 调小阈值(避开同 IP 10/分钟那道)+ 清计数隔离""" conftest 默认关限流;本用例临时打开 + 调小阈值便于测 + 清计数隔离"""
from app.api.v1 import auth from app.api.v1 import auth
from app.core import ratelimit from app.core import ratelimit
+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),再跑邀请闭环 sms mock 登录拿 token( test_welfare),再跑邀请闭环
""" """
@@ -10,8 +10,6 @@ from sqlalchemy import select
from app.core.rewards import ( from app.core.rewards import (
INVITE_FP_WINDOW_DAYS, INVITE_FP_WINDOW_DAYS,
INVITE_INVITEE_COINS,
INVITE_INVITER_COINS,
INVITE_NEW_USER_WINDOW_HOURS, INVITE_NEW_USER_WINDOW_HOURS,
) )
from app.db.session import SessionLocal 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"] assert _my_code(client, token) == body["invite_code"]
def test_bind_flow_both_get_coins(client) -> None: def test_bind_flow_no_coins_either_side(client) -> None:
"""B 用 A 的码绑定 → 双方各得 1 万金币;A 战绩 +1""" """v3:B 用 A 的码绑定 → 双方都不发钱(被邀请人无奖励、邀请人改比价后发现金)"""
a = _login(client, "13800002002") a = _login(client, "13800002002")
b = _login(client, "13800002003") b = _login(client, "13800002003")
a_code = _my_code(client, a) 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 assert r.status_code == 200, r.text
res = r.json() res = r.json()
assert res["status"] == "success" assert res["status"] == "success"
assert res["coins_awarded"] == INVITE_INVITEE_COINS assert res["coins_awarded"] == 0
# 双方金币到账 # 绑定后双方金币都不增(邀请人收益改走"好友比价发 2 元邀请奖励金")
assert _coin_balance(client, b) == INVITE_INVITEE_COINS assert _coin_balance(client, b) == 0
assert _coin_balance(client, a) == INVITE_INVITER_COINS 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)) r = client.get("/api/v1/invite/me", headers=_auth(a))
assert r.json()["invited_count"] == 1 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: 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.status_code == 200, r2.text
assert r2.json()["status"] == "success" assert r2.json()["status"] == "success"
# 双方各发金币 # v3:绑定双方都不发钱(被邀请人无奖励、邀请人改比价发现金)
assert _coin_balance(client, a) == INVITE_INVITER_COINS assert _coin_balance(client, a) == 0
assert _coin_balance(client, b) == INVITE_INVITEE_COINS assert _coin_balance(client, b) == 0
def test_bind_by_fingerprint_not_found(client) -> None: 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"]} names = {it["display_name"] for it in body["items"]}
assert names == expected_names assert names == expected_names
assert all(it["avatar_url"] is None for it in body["items"]) 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: 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)) r = client.get("/api/v1/wallet/account", headers=_auth(token))
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
body = r.json() 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: def test_signin_flow(client) -> None: