Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51ee9f738e | |||
| 21fc930c0a | |||
| a7d8562ddd | |||
| ad6a579ff8 | |||
| 08a16c7632 | |||
| 2ed62c789f | |||
| 1548406f29 | |||
| b7b958ed58 | |||
| f3cd97a190 | |||
| b4c27f4d88 | |||
| e38120ad49 | |||
| 3d67749101 |
+48
-4
@@ -18,7 +18,7 @@ JWT_ACCESS_TOKEN_EXPIRE_MINUTES=120
|
||||
# refresh token 有效期(天),默认 30 天
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
|
||||
# ===== 极光一键登录 =====
|
||||
# ===== 极光一键登录 / 短信 =====
|
||||
# 控制台 → 应用设置 → 应用信息 拿到
|
||||
JG_APP_KEY=
|
||||
JG_MASTER_SECRET=
|
||||
@@ -27,7 +27,43 @@ JG_PRIVATE_KEY_PATH=./secrets/jverify_rsa_private.pem
|
||||
JG_VERIFY_ENDPOINT=https://api.verification.jpush.cn/v1/web/loginTokenVerify
|
||||
JG_REQUEST_TIMEOUT_SEC=15
|
||||
|
||||
# ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)=====
|
||||
# ===== 厂商直推(无障碍保护存活告警)=====
|
||||
# 敏感密钥只放 .env / 服务器环境变量,不要提交到 git。
|
||||
ANDROID_PACKAGE_NAME=com.jishisongfu.shaguabijia
|
||||
PUSH_REQUEST_TIMEOUT_SEC=15
|
||||
PUSH_TIME_TO_LIVE_SEC=86400
|
||||
|
||||
HONOR_PUSH_APP_ID=
|
||||
HONOR_PUSH_CLIENT_ID=
|
||||
HONOR_PUSH_CLIENT_SECRET=
|
||||
HONOR_PUSH_TOKEN_ENDPOINT=https://iam.developer.honor.com/auth/token
|
||||
HONOR_PUSH_SEND_ENDPOINT_TEMPLATE=https://push-api.cloud.honor.com/api/v1/{app_id}/sendMessage
|
||||
|
||||
VIVO_PUSH_APP_ID=
|
||||
VIVO_PUSH_APP_KEY=
|
||||
VIVO_PUSH_APP_SECRET=
|
||||
VIVO_PUSH_AUTH_ENDPOINT=https://api-push.vivo.com.cn/message/auth
|
||||
VIVO_PUSH_SEND_ENDPOINT=https://api-push.vivo.com.cn/message/send
|
||||
# vivo 未上架测试时可用 push_mode=1; 上架正式推送改为 0。
|
||||
VIVO_PUSH_MODE=1
|
||||
VIVO_PUSH_NOTIFY_TYPE=4
|
||||
VIVO_PUSH_CATEGORY=DEVICE_REMINDER
|
||||
|
||||
XIAOMI_PUSH_APP_SECRET=
|
||||
XIAOMI_PUSH_SEND_ENDPOINT=https://api.xmpush.xiaomi.com/v3/message/regid
|
||||
XIAOMI_PUSH_CHANNEL_ID=
|
||||
XIAOMI_PUSH_TEMPLATE_ID=
|
||||
XIAOMI_PUSH_TEMPLATE_TITLE=
|
||||
XIAOMI_PUSH_TEMPLATE_DESCRIPTION=
|
||||
# 可选: JSON 字符串,支持 {title}/{alert} 占位符,例如 {"title":"{title}","content":"{alert}"}
|
||||
XIAOMI_PUSH_TEMPLATE_PARAM_JSON=
|
||||
|
||||
OPPO_PUSH_APP_KEY=
|
||||
OPPO_PUSH_MASTER_SECRET=
|
||||
OPPO_PUSH_AUTH_ENDPOINT=https://api.push.oppomobile.com/server/v1/auth
|
||||
OPPO_PUSH_SEND_ENDPOINT=https://api.push.oppomobile.com/server/v1/message/notification/unicast
|
||||
|
||||
# ===== 无障碍保护存活监控(推送 + pull 后置兜底)=====
|
||||
HEARTBEAT_MONITOR_ENABLED=true
|
||||
HEARTBEAT_TIMEOUT_MINUTES=10
|
||||
HEARTBEAT_SCAN_INTERVAL_SEC=60
|
||||
@@ -58,6 +94,13 @@ MT_CPS_DEFAULT_SID=sgbjia
|
||||
# 线上国内服务器留空(=直连)。留空且本机直连失败时 /feed、/coupons、/top-sales 会返回空。
|
||||
MT_CPS_PROXY=
|
||||
|
||||
# ===== 京东联盟 CPS =====
|
||||
# 京东联盟/京东宙斯开放平台创建应用后填写。AUTH_KEY 是工具商授权 key,自有应用可留空。
|
||||
JD_UNION_APP_KEY=
|
||||
JD_UNION_APP_SECRET=
|
||||
JD_UNION_SITE_ID=
|
||||
JD_UNION_AUTH_KEY=
|
||||
|
||||
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
|
||||
# 客户端调本服务的 /api/v1/coupon/step 等,我们透传到 pricebot-backend。
|
||||
# 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。
|
||||
@@ -82,8 +125,9 @@ INTERNAL_API_SECRET=
|
||||
# 逗号分隔,生产留空(只让 app 调,不开放 web)。本地开发可加 http://localhost:5173 之类
|
||||
CORS_ALLOW_ORIGINS=
|
||||
|
||||
# ===== 微信支付(商家转账到零钱 / 提现)=====
|
||||
# appid/secret 来自微信开放平台移动应用;mch/序列号/公钥ID 来自微信支付商户平台。
|
||||
# ===== 微信开放平台 App + 微信支付(登录 / 商家转账到零钱 / 提现)=====
|
||||
# WECHAT_APP_ID/WECHAT_APP_SECRET 来自微信开放平台移动应用,用于 App 微信一键登录(code 换 openid)。
|
||||
# mch/序列号/公钥ID 来自微信支付商户平台,仅提现/转账需要。
|
||||
# 证书 .pem 放 secrets/(已 gitignore)。
|
||||
WECHAT_APP_ID=wxxxxxxxxxxxxxxxxx
|
||||
WECHAT_APP_SECRET=your_app_secret
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""add direct vendor push fields
|
||||
|
||||
Revision ID: direct_vendor_push_fields
|
||||
Revises: jd_cps_order_fields
|
||||
Create Date: 2026-07-01 16:30:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "direct_vendor_push_fields"
|
||||
down_revision = "jd_cps_order_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("device_liveness") as batch_op:
|
||||
batch_op.add_column(sa.Column("push_vendor", sa.String(length=32), nullable=True))
|
||||
batch_op.add_column(sa.Column("push_token", sa.String(length=256), nullable=True))
|
||||
batch_op.create_index("ix_device_liveness_push_vendor", ["push_vendor"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("device_liveness") as batch_op:
|
||||
batch_op.drop_index("ix_device_liveness_push_vendor")
|
||||
batch_op.drop_column("push_token")
|
||||
batch_op.drop_column("push_vendor")
|
||||
@@ -0,0 +1,56 @@
|
||||
"""add jd cps order fields
|
||||
|
||||
Revision ID: jd_cps_order_fields
|
||||
Revises: 7db22acee504
|
||||
Create Date: 2026-06-28 20:30:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "jd_cps_order_fields"
|
||||
down_revision = "7db22acee504"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("cps_order") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("platform", sa.String(length=20), nullable=False, server_default="meituan")
|
||||
)
|
||||
batch_op.add_column(sa.Column("external_order_id", sa.String(length=128), nullable=True))
|
||||
batch_op.add_column(sa.Column("external_row_id", sa.String(length=128), nullable=True))
|
||||
batch_op.add_column(sa.Column("estimated_commission_cents", sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column("actual_commission_cents", sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column("jd_valid_code", sa.String(length=16), nullable=True))
|
||||
batch_op.add_column(sa.Column("settle_month", sa.String(length=16), nullable=True))
|
||||
batch_op.add_column(sa.Column("site_id", sa.String(length=128), nullable=True))
|
||||
batch_op.add_column(sa.Column("position_id", sa.String(length=128), nullable=True))
|
||||
batch_op.add_column(sa.Column("pid", sa.String(length=128), nullable=True))
|
||||
batch_op.add_column(sa.Column("sub_union_id", sa.String(length=128), nullable=True))
|
||||
batch_op.create_index("ix_cps_order_platform", ["platform"])
|
||||
batch_op.create_index("ix_cps_order_external_order_id", ["external_order_id"])
|
||||
batch_op.create_index("ix_cps_order_external_row_id", ["external_row_id"])
|
||||
batch_op.create_index("ix_cps_order_jd_valid_code", ["jd_valid_code"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("cps_order") as batch_op:
|
||||
batch_op.drop_index("ix_cps_order_jd_valid_code")
|
||||
batch_op.drop_index("ix_cps_order_external_row_id")
|
||||
batch_op.drop_index("ix_cps_order_external_order_id")
|
||||
batch_op.drop_index("ix_cps_order_platform")
|
||||
batch_op.drop_column("sub_union_id")
|
||||
batch_op.drop_column("pid")
|
||||
batch_op.drop_column("position_id")
|
||||
batch_op.drop_column("site_id")
|
||||
batch_op.drop_column("settle_month")
|
||||
batch_op.drop_column("jd_valid_code")
|
||||
batch_op.drop_column("actual_commission_cents")
|
||||
batch_op.drop_column("estimated_commission_cents")
|
||||
batch_op.drop_column("external_row_id")
|
||||
batch_op.drop_column("external_order_id")
|
||||
batch_op.drop_column("platform")
|
||||
@@ -4,9 +4,9 @@
|
||||
- 看视频:每条 granted = 1 份,第 N 份 = 该用户 granted 的 reward_video **账号累计**顺序号
|
||||
(与 ad_reward.grant_ad_reward 里 `_granted_cumulative + 1` 一致;LT 因子不按天重置,
|
||||
故复算时要把当日序号叠加上该用户在本日**之前**的累计已发份数)。
|
||||
- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 该用户 granted 份数**账号累计**
|
||||
(与 ad_feed_reward._unit_reward_total 的 existing_units 一致;同样不按天重置,
|
||||
复算需叠加本日之前的累计份数)。
|
||||
- 信息流:**每条 granted = 1 份**(与 ad_feed_reward.grant_feed_reward 同口径:看满一份即发该条
|
||||
满额,**不按 unit_count 逐份累加**),LT 序号 = 该用户 granted **条数**账号累计
|
||||
(与 ad_feed_reward.granted_unit_total 的 COUNT 一致;不按天重置,复算需叠加本日之前的累计条数)。
|
||||
|
||||
非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。
|
||||
"""
|
||||
@@ -108,14 +108,18 @@ def _reward_video_rows(
|
||||
return rows
|
||||
|
||||
|
||||
def _feed_prior_granted_units(
|
||||
def _feed_prior_granted_count(
|
||||
db: Session, *, date: str, user_id: int | None
|
||||
) -> dict[int, int]:
|
||||
"""各用户在 date **之前** granted 的信息流份数累计,作为当日复算的 LT 序号起点。"""
|
||||
"""各用户在 date **之前** granted 的信息流**条数**累计,作为当日复算的 LT 序号起点。
|
||||
|
||||
与发奖侧 ad_feed_reward.granted_unit_total(COUNT status=granted)对齐:一条广告 = 1 份,
|
||||
LT 按账号累计**条数**递进。**不再用 SUM(unit_count)**——那是「一条按时长折多份」的过时口径,
|
||||
与现行发奖(每条 1 份)漂移,会让 unit_count>1 的记录复算虚高、对账恒「不符」。"""
|
||||
stmt = (
|
||||
select(
|
||||
AdFeedRewardRecord.user_id,
|
||||
func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0),
|
||||
func.count(),
|
||||
)
|
||||
.where(
|
||||
AdFeedRewardRecord.reward_date < date,
|
||||
@@ -144,10 +148,11 @@ def _feed_scene_matches(rec: AdFeedRewardRecord, scene: str | None) -> bool:
|
||||
def _feed_rows(
|
||||
db: Session, *, date: str, user_id: int | None, scene: str | None = None
|
||||
) -> list[dict]:
|
||||
"""信息流记录复算。granted 记录逐份累加,LT 序号沿用账号累计份数(含本日之前)。
|
||||
"""信息流记录复算。**每条 granted = 1 份**(与发奖同口径,不按 unit_count 累加),
|
||||
LT 序号沿用账号累计**条数**(含本日之前)。
|
||||
|
||||
**关键:LT 因子账号累计按全表 unit 累计(feed+draw 共享同一发奖池/上限),不按 ad_type 拆分**——
|
||||
故无论 scene 怎么筛展示,这里都遍历当日**全部**信息流记录维持 granted_units 累加;scene 只决定
|
||||
**关键:LT 因子账号累计按全表 granted 条数累计(feed+draw 共享同一发奖池/上限),不按 ad_type 拆分**——
|
||||
故无论 scene 怎么筛展示,这里都遍历当日**全部**信息流记录维持 granted_count 累加;scene 只决定
|
||||
哪些行被**留下展示**(由 _feed_scene_matches 判断),不影响累计基线,保证复算序号与正式发奖一致。
|
||||
"""
|
||||
stmt = (
|
||||
@@ -158,23 +163,20 @@ def _feed_rows(
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
|
||||
|
||||
# 本日之前的累计份数做起点,与 _unit_reward_total 的 existing_units(累计)对齐
|
||||
granted_units: dict[int, int] = _feed_prior_granted_units(db, date=date, user_id=user_id)
|
||||
# 本日之前的累计**条数**做起点,与发奖侧 granted_unit_total(COUNT granted)对齐
|
||||
granted_count: dict[int, int] = _feed_prior_granted_count(db, date=date, user_id=user_id)
|
||||
rows: list[dict] = []
|
||||
for rec in db.execute(stmt).scalars():
|
||||
keep = _feed_scene_matches(rec, scene) # 累计照常推进,这里只决定是否展示本行
|
||||
if rec.status == "granted":
|
||||
existing = granted_units.get(rec.user_id, 0)
|
||||
units = rec.unit_count
|
||||
granted_units[rec.user_id] = existing + units
|
||||
# 一条广告 = 1 份(与 grant_feed_reward 同口径:看满一份即发该条满额,不按 unit_count 累加)。
|
||||
# nth = 账号累计第几**条**(含本日之前),与发奖侧 granted_unit_total+1 对齐;累计照常推进
|
||||
# (即便 scene 不匹配不展示也要 +1,保证序号与正式发奖一致)。
|
||||
nth = granted_count.get(rec.user_id, 0) + 1
|
||||
granted_count[rec.user_id] = nth
|
||||
if not keep:
|
||||
continue
|
||||
expected = sum(
|
||||
rewards.calculate_ad_reward_coin(rec.ecpm_raw, existing + offset)
|
||||
for offset in range(1, units + 1)
|
||||
)
|
||||
start = existing + 1 if units > 0 else None
|
||||
end = existing + units if units > 0 else None
|
||||
expected = rewards.calculate_ad_reward_coin(rec.ecpm_raw, nth)
|
||||
rows.append({
|
||||
"scene": "feed",
|
||||
"ad_type": rec.ad_type or "feed",
|
||||
@@ -188,11 +190,11 @@ def _feed_rows(
|
||||
"status": rec.status,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
|
||||
"units": units,
|
||||
"lt_index_start": start,
|
||||
"lt_index_end": end,
|
||||
"lt_factor_start": rewards.ad_lt_factor(start) if start else None,
|
||||
"lt_factor_end": rewards.ad_lt_factor(end) if end else None,
|
||||
"units": 1,
|
||||
"lt_index_start": nth,
|
||||
"lt_index_end": nth,
|
||||
"lt_factor_start": rewards.ad_lt_factor(nth),
|
||||
"lt_factor_end": rewards.ad_lt_factor(nth),
|
||||
"expected_coin": expected,
|
||||
"actual_coin": rec.coin,
|
||||
"matched": expected == rec.coin,
|
||||
|
||||
@@ -20,13 +20,14 @@ report_date / reward_date 归日。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import date as _date
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.repositories import ad_audit
|
||||
from app.admin.repositories import stats as admin_stats
|
||||
from app.core import rewards
|
||||
from app.models.ad_ecpm import AdEcpmRecord
|
||||
from app.models.user import User
|
||||
@@ -35,7 +36,7 @@ from app.models.user import User
|
||||
def _cn_hour(dt: datetime) -> int:
|
||||
"""created_at(UTC 口径)→ 北京时间小时(0–23)。naive 当 UTC 处理(sqlite),tz-aware 直接换算(pg)。"""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(rewards.CN_TZ).hour
|
||||
|
||||
|
||||
@@ -84,14 +85,20 @@ def ad_revenue_report(
|
||||
date_to: str,
|
||||
user_id: int | None = None,
|
||||
ad_type: str | None = None,
|
||||
feed_scene: str | None = None,
|
||||
granularity: str = "day",
|
||||
limit: int = 500,
|
||||
offset: int = 0,
|
||||
sort: str = "time",
|
||||
) -> dict:
|
||||
"""日期区间(北京时间,闭区间)**逐条广告事件**列表 + 发奖对账。单日时 date_from==date_to。
|
||||
|
||||
每个 item = 一次广告事件(展示与发奖按 ad_session_id 合并;信息流展示 / 发奖各自成行)。
|
||||
ad_type: None=全部 / reward_video / feed / draw。granularity=hour 时每行带北京小时(由各自时间算)。
|
||||
limit 只截断 items(事件明细),total 与 total_* / daily 在全量上统计,数字始终可信。
|
||||
ad_type: None=全部 / reward_video / feed / draw。feed_scene: None=全部 /
|
||||
comparison / coupon / welfare,作为全局筛选(同时作用于明细、合计与 daily/hourly 趋势)。
|
||||
granularity=hour 时每行带北京小时(由各自时间算),并额外返回全量 hourly 序列。
|
||||
事件按时间倒序(新→旧)排列;limit/offset 对排序后的全量做分页切片(items 为当前页),
|
||||
total 与 total_* / daily / hourly 在全量上统计,不受分页影响。
|
||||
"""
|
||||
by_hour = granularity == "hour"
|
||||
|
||||
@@ -200,7 +207,17 @@ def ad_revenue_report(
|
||||
"reward_detail": _reward_detail(row),
|
||||
})
|
||||
|
||||
events.sort(key=lambda e: (e["report_date"], e["user_id"], e["created_at"]))
|
||||
# 「场景」作为全局筛选(与 user_id/ad_type 一致):同时作用于明细、合计与 daily/hourly 趋势。
|
||||
# feed_scene 仅信息流 / Draw 有值,激励视频与旧数据为 None;选中后只保留该场景事件。
|
||||
if feed_scene is not None:
|
||||
events = [e for e in events if e.get("feed_scene") == feed_scene]
|
||||
|
||||
# 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排;
|
||||
# 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。
|
||||
if sort == "ecpm":
|
||||
events.sort(key=lambda e: rewards.parse_ecpm_fen(e["ecpm"]), reverse=True)
|
||||
else:
|
||||
events.sort(key=lambda e: (e["report_date"], e["created_at"]), reverse=True)
|
||||
|
||||
# 补手机号(admin 展示用,完整不脱敏,与用户 / 钱包 / 比价记录页一致):批量一次查,避免 N+1。
|
||||
uids = {e["user_id"] for e in events}
|
||||
@@ -238,14 +255,60 @@ def ad_revenue_report(
|
||||
for d in sorted(daily_map.values(), key=lambda x: x["date"])
|
||||
]
|
||||
|
||||
# 按小时汇总(全量,不受分页 limit/offset 影响):供前端按小时趋势图(单日 granularity=hour 时用)。
|
||||
# 只在 by_hour 下聚合(此时每个 event 带 hour);否则空。前端按天趋势仍用 daily。
|
||||
hourly: list[dict] = []
|
||||
if by_hour:
|
||||
hour_map: dict[int, dict] = {}
|
||||
for e in events:
|
||||
h = e["hour"]
|
||||
if h is None:
|
||||
continue
|
||||
hd = hour_map.get(h)
|
||||
if hd is None:
|
||||
hd = {"hour": h, "impressions": 0, "revenue_yuan": 0.0,
|
||||
"expected_coin": 0, "actual_coin": 0}
|
||||
hour_map[h] = hd
|
||||
hd["impressions"] += e["impressions"]
|
||||
hd["revenue_yuan"] += e["revenue_yuan"]
|
||||
hd["expected_coin"] += e["expected_coin"]
|
||||
hd["actual_coin"] += e["actual_coin"]
|
||||
hourly = [
|
||||
{**hd, "revenue_yuan": round(hd["revenue_yuan"], 6)}
|
||||
for hd in sorted(hour_map.values(), key=lambda x: x["hour"])
|
||||
]
|
||||
|
||||
# 分广告类型小计(按 ad_type:展示条数 + 预估收益;eCPM 由前端用 收益÷展示×1000 算)。
|
||||
# 基于全量(已按 feed_scene 过滤)events;前端只取 draw / reward_video 两类展示。
|
||||
type_map: dict[str, dict] = {}
|
||||
for e in events:
|
||||
t = type_map.get(e["ad_type"])
|
||||
if t is None:
|
||||
t = {"impressions": 0, "revenue_yuan": 0.0}
|
||||
type_map[e["ad_type"]] = t
|
||||
t["impressions"] += e["impressions"]
|
||||
t["revenue_yuan"] += e["revenue_yuan"]
|
||||
type_stats = {
|
||||
k: {"impressions": v["impressions"], "revenue_yuan": round(v["revenue_yuan"], 6)}
|
||||
for k, v in type_map.items()
|
||||
}
|
||||
|
||||
# DAU:复用大盘「今日活跃」口径(stats.today_dau,last_login_at)。该口径只能算今日,
|
||||
# 故仅当查询=今日单天时给值;历史 / 多天区间返回 None,前端显示「-」。
|
||||
is_today = date_from == date_to == rewards.cn_today().isoformat()
|
||||
dau = admin_stats.today_dau(db) if is_today else None
|
||||
|
||||
return {
|
||||
"total": len(events),
|
||||
"truncated": len(events) > limit,
|
||||
"truncated": len(events) > offset + limit,
|
||||
"total_impressions": total_impressions,
|
||||
"total_revenue_yuan": total_revenue_yuan,
|
||||
"total_expected_coin": total_expected_coin,
|
||||
"total_actual_coin": total_actual_coin,
|
||||
"mismatch_count": mismatch_count,
|
||||
"daily": daily,
|
||||
"items": events[:limit],
|
||||
"hourly": hourly,
|
||||
"type_stats": type_stats,
|
||||
"dau": dau,
|
||||
"items": events[offset:offset + limit],
|
||||
}
|
||||
|
||||
@@ -7,13 +7,14 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.repositories.queries import _as_utc, offset_paginate
|
||||
from app.integrations import meituan
|
||||
from app.integrations import jd_union, meituan
|
||||
from app.repositories import cps_link as cps_link_repo
|
||||
from app.models.cps_activity import CpsActivity
|
||||
from app.models.cps_group import CpsGroup
|
||||
@@ -24,6 +25,11 @@ from app.models.cps_wx_user import CpsWxUser
|
||||
# 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账
|
||||
_INVALID_STATUS = {"4", "5"}
|
||||
_SETTLED_STATUS = "6"
|
||||
_JD_INVALID_CODES = {
|
||||
"2", "3", "4", "5", "6", "7", "8", "9", "11", "13", "14", "19", "20", "21",
|
||||
"22", "23", "25", "26", "27", "28", "29", "30", "31", "34", "35", "36",
|
||||
}
|
||||
_JD_UNPAID_CODES = {"15"}
|
||||
|
||||
# CPS 点击时序按北京时区分桶(运营看的是北京时间)
|
||||
_BJ_TZ = timezone(timedelta(hours=8))
|
||||
@@ -45,8 +51,58 @@ def _yuan_to_cents(v: object) -> int | None:
|
||||
|
||||
def _ts_to_dt(ts: object) -> datetime | None:
|
||||
"""秒级时间戳 → tz-aware UTC datetime(绝对时刻,前端按北京展示)。"""
|
||||
if not ts:
|
||||
if ts is None:
|
||||
return None
|
||||
if isinstance(ts, datetime):
|
||||
return ts if ts.tzinfo else ts.replace(tzinfo=_BJ_TZ).astimezone(timezone.utc)
|
||||
s = str(ts).strip()
|
||||
if not s or s.lower() == "null":
|
||||
return None
|
||||
try:
|
||||
seconds = float(Decimal(s))
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
if seconds == 0:
|
||||
return None
|
||||
# 美团文档是秒级时间戳,这里顺手兼容毫秒/微秒,避免上游格式变化导致时间再次落空。
|
||||
if abs(seconds) > 10_000_000_000_000:
|
||||
seconds /= 1_000_000
|
||||
elif abs(seconds) > 10_000_000_000:
|
||||
seconds /= 1_000
|
||||
try:
|
||||
return datetime.fromtimestamp(seconds, tz=timezone.utc)
|
||||
except (OverflowError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _jd_dt_to_utc(value: object) -> datetime | None:
|
||||
"""京东时间字符串(北京时间) → UTC aware datetime。"""
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
|
||||
try:
|
||||
dt = datetime.strptime(s, fmt)
|
||||
return dt.replace(tzinfo=_BJ_TZ).astimezone(timezone.utc)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _text(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
return s or None
|
||||
|
||||
|
||||
def _pick(row: dict[str, Any], *keys: str) -> Any:
|
||||
for key in keys:
|
||||
if key in row and row[key] is not None:
|
||||
return row[key]
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(int(ts), tz=timezone.utc)
|
||||
except (ValueError, OSError, TypeError):
|
||||
@@ -249,6 +305,80 @@ def _map_order_fields(r: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _jd_order_key(r: dict[str, Any]) -> str | None:
|
||||
row_id = _text(_pick(r, "id", "rowId", "orderRowId"))
|
||||
if row_id:
|
||||
return f"jd:{row_id}"
|
||||
order_id = _text(_pick(r, "orderId", "parentOrderId"))
|
||||
sku_id = _text(_pick(r, "skuId"))
|
||||
if order_id and sku_id:
|
||||
return f"jd:{order_id}:{sku_id}"
|
||||
if order_id:
|
||||
return f"jd:{order_id}"
|
||||
return None
|
||||
|
||||
|
||||
def _map_jd_order_fields(r: dict[str, Any]) -> dict:
|
||||
"""京东 order.row.query 单条订单行 → CpsOrder 字段。"""
|
||||
sku_name = _text(_pick(r, "skuName", "goodsName", "productName"))
|
||||
if sku_name and len(sku_name) > 500:
|
||||
sku_name = sku_name[:500]
|
||||
valid_code = _text(_pick(r, "validCode", "valid_code"))
|
||||
actual_fee = _yuan_to_cents(_pick(r, "actualFee", "actual_fee"))
|
||||
estimate_fee = _yuan_to_cents(_pick(r, "estimateFee", "estimate_fee"))
|
||||
commission = actual_fee if actual_fee not in (None, 0) else estimate_fee
|
||||
order_time = _jd_dt_to_utc(_pick(r, "orderTime", "order_time"))
|
||||
return {
|
||||
"platform": "jd",
|
||||
"external_order_id": _text(_pick(r, "orderId", "parentOrderId")),
|
||||
"external_row_id": _text(_pick(r, "id", "rowId", "orderRowId")),
|
||||
"sid": _text(_pick(r, "subUnionId", "sub_union_id")),
|
||||
"act_id": None,
|
||||
"biz_line": None,
|
||||
"trade_type": None,
|
||||
"pay_price_cents": _yuan_to_cents(
|
||||
_pick(r, "actualCosPrice", "estimateCosPrice", "price")
|
||||
),
|
||||
"commission_cents": commission,
|
||||
"commission_rate": _text(_pick(r, "commissionRate", "commission_rate")),
|
||||
"refund_price_cents": None,
|
||||
"refund_profit_cents": None,
|
||||
"estimated_commission_cents": estimate_fee,
|
||||
"actual_commission_cents": actual_fee,
|
||||
"mt_status": None,
|
||||
"jd_valid_code": valid_code,
|
||||
"invalid_reason": None if _is_jd_valid_code(valid_code) else f"validCode={valid_code}",
|
||||
"product_name": sku_name,
|
||||
"settle_month": _text(_pick(r, "payMonth", "settleMonth", "pay_month")),
|
||||
"site_id": _text(_pick(r, "siteId", "site_id")),
|
||||
"position_id": _text(_pick(r, "positionId", "position_id")),
|
||||
"pid": _text(_pick(r, "pid")),
|
||||
"sub_union_id": _text(_pick(r, "subUnionId", "sub_union_id")),
|
||||
"pay_time": order_time,
|
||||
"mt_update_time": _jd_dt_to_utc(_pick(r, "modifyTime", "updateTime", "modify_time"))
|
||||
or order_time,
|
||||
"raw": r,
|
||||
}
|
||||
|
||||
|
||||
def _is_jd_valid_code(valid_code: str | None) -> bool:
|
||||
code = str(valid_code).strip() if valid_code is not None else ""
|
||||
return bool(code and code not in _JD_INVALID_CODES and code not in _JD_UNPAID_CODES)
|
||||
|
||||
|
||||
def is_jd_order_valid(order: CpsOrder) -> bool:
|
||||
return _is_jd_valid_code(order.jd_valid_code)
|
||||
|
||||
|
||||
def effective_commission_cents(order: CpsOrder) -> int:
|
||||
if order.platform == "jd":
|
||||
if order.actual_commission_cents not in (None, 0):
|
||||
return order.actual_commission_cents or 0
|
||||
if order.estimated_commission_cents is not None:
|
||||
return order.estimated_commission_cents or 0
|
||||
return order.commission_cents or 0
|
||||
|
||||
|
||||
def reconcile_orders(
|
||||
db: Session, *, start_time: int, end_time: int,
|
||||
query_time_type: int = 1, sid: str | None = None, max_pages: int = 200,
|
||||
@@ -274,6 +404,11 @@ def reconcile_orders(
|
||||
continue
|
||||
fetched += 1
|
||||
fields = _map_order_fields(r)
|
||||
fields.setdefault("platform", "meituan")
|
||||
fields.setdefault("external_order_id", order_id)
|
||||
fields.setdefault("external_row_id", None)
|
||||
fields.setdefault("estimated_commission_cents", fields.get("commission_cents"))
|
||||
fields.setdefault("actual_commission_cents", None)
|
||||
existing = db.execute(
|
||||
select(CpsOrder).where(CpsOrder.order_id == order_id)
|
||||
).scalar_one_or_none()
|
||||
@@ -291,6 +426,56 @@ def reconcile_orders(
|
||||
return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages}
|
||||
|
||||
|
||||
def reconcile_jd_orders(
|
||||
db: Session, *, start_time: datetime, end_time: datetime,
|
||||
query_time_type: int = 3, max_pages: int = 100,
|
||||
) -> dict:
|
||||
"""调京东 order.row.query 拉单 → 按订单行 upsert。
|
||||
|
||||
京东单次查询窗口最多 1 小时,这里按北京自然时间切窗并逐页拉取。
|
||||
"""
|
||||
fetched = inserted = updated = pages = 0
|
||||
cur = start_time
|
||||
while cur < end_time:
|
||||
win_end = min(cur + timedelta(hours=1), end_time)
|
||||
page = 1
|
||||
while page <= max_pages:
|
||||
resp = jd_union.query_order_rows(
|
||||
start_time=cur,
|
||||
end_time=win_end,
|
||||
query_time_type=query_time_type,
|
||||
page_index=page,
|
||||
page_size=200,
|
||||
)
|
||||
rows = resp.get("rows") or []
|
||||
has_more = bool(resp.get("has_more"))
|
||||
if not rows:
|
||||
break
|
||||
pages += 1
|
||||
for r in rows:
|
||||
order_id = _jd_order_key(r)
|
||||
if not order_id:
|
||||
continue
|
||||
fetched += 1
|
||||
fields = _map_jd_order_fields(r)
|
||||
existing = db.execute(
|
||||
select(CpsOrder).where(CpsOrder.order_id == order_id)
|
||||
).scalar_one_or_none()
|
||||
if existing is None:
|
||||
db.add(CpsOrder(order_id=order_id, **fields))
|
||||
inserted += 1
|
||||
else:
|
||||
for k, v in fields.items():
|
||||
setattr(existing, k, v)
|
||||
updated += 1
|
||||
if not has_more or len(rows) < 200:
|
||||
break
|
||||
page += 1
|
||||
cur = win_end
|
||||
db.commit()
|
||||
return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages}
|
||||
|
||||
|
||||
def list_orders(
|
||||
db: Session, *, sid: str | None = None, mt_status: str | None = None,
|
||||
limit: int = 20, cursor: int | None = None,
|
||||
|
||||
+490
-11
@@ -5,20 +5,46 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
from app.models.ad_reward import AdRewardRecord
|
||||
from app.models.analytics_event import AnalyticsEvent
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.coupon_state import CouponPromptEngagement
|
||||
from app.models.cps_order import CpsOrder
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.savings import SavingsRecord
|
||||
from app.models.signin import SigninBoostRecord, SigninRecord
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CoinTransaction, WithdrawOrder
|
||||
|
||||
_BEIJING = timezone(timedelta(hours=8))
|
||||
REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward")
|
||||
COUPON_REWARD_BIZ_TYPES = ("coupon", "coupon_reward")
|
||||
COMPARISON_REWARD_BIZ_TYPES = ("comparison", "compare_reward", "comparison_reward")
|
||||
EXCLUDED_REWARD_BIZ_TYPES = ("invite_inviter", "invite_invitee", "admin_grant")
|
||||
UNCLASSIFIED_FEED_BIZ_TYPES = ("feed_ad_reward",)
|
||||
REGULAR_TASK_EXCLUDED_BIZ_TYPES = (
|
||||
*REWARD_VIDEO_BIZ_TYPES,
|
||||
*COUPON_REWARD_BIZ_TYPES,
|
||||
*COMPARISON_REWARD_BIZ_TYPES,
|
||||
*EXCLUDED_REWARD_BIZ_TYPES,
|
||||
*UNCLASSIFIED_FEED_BIZ_TYPES,
|
||||
)
|
||||
MEITUAN_CPS_INVALID_STATUSES = ("4", "5")
|
||||
MEITUAN_CPS_SETTLED_STATUS = "6"
|
||||
COMPARE_START_EVENT = "real_compare_start"
|
||||
COUPON_START_EVENT = "real_coupon_start"
|
||||
JD_CPS_INVALID_CODES = {
|
||||
"2", "3", "4", "5", "6", "7", "8", "9", "11", "13", "14", "19", "20", "21",
|
||||
"22", "23", "25", "26", "27", "28", "29", "30", "31", "34", "35", "36",
|
||||
}
|
||||
JD_CPS_UNPAID_CODES = {"15"}
|
||||
|
||||
|
||||
def _beijing_today_start_utc() -> datetime:
|
||||
@@ -28,20 +54,130 @@ def _beijing_today_start_utc() -> datetime:
|
||||
return start_bj.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def dashboard_overview(db: Session) -> dict:
|
||||
def today_dau(db: Session) -> int:
|
||||
"""今日活跃用户数(DAU):登录 + 开始比价 + 开始领券,按用户去重。
|
||||
|
||||
广告收益报表复用这个函数;历史窗口 DAU 由 dashboard_overview 的 period 口径另算。
|
||||
"""
|
||||
today_bj = datetime.now(_BEIJING).date()
|
||||
today_start = _beijing_today_start_utc()
|
||||
tomorrow_start = today_start + timedelta(days=1)
|
||||
login_user_ids = _id_set(
|
||||
db,
|
||||
select(User.id).where(User.last_login_at >= today_start, User.last_login_at < tomorrow_start),
|
||||
)
|
||||
compare_start_user_ids = _event_user_ids(
|
||||
db, (COMPARE_START_EVENT,), today_start, tomorrow_start
|
||||
)
|
||||
coupon_event_user_ids = _event_user_ids(
|
||||
db, (COUPON_START_EVENT,), today_start, tomorrow_start
|
||||
)
|
||||
coupon_claim_user_ids = _id_set(
|
||||
db,
|
||||
select(CouponPromptEngagement.user_id).where(
|
||||
CouponPromptEngagement.engage_date == today_bj,
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
),
|
||||
)
|
||||
return len(login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids)
|
||||
|
||||
|
||||
def _default_period_end() -> date:
|
||||
"""新版大盘不含今日,默认窗口结束日=北京时间昨天。"""
|
||||
return datetime.now(_BEIJING).date() - timedelta(days=1)
|
||||
|
||||
|
||||
def _normalize_period(date_from: date | None, date_to: date | None) -> tuple[date, date]:
|
||||
end = date_to or _default_period_end()
|
||||
start = date_from or end
|
||||
if start > end:
|
||||
start, end = end, start
|
||||
return start, end
|
||||
|
||||
|
||||
def _period_bounds(date_from: date, date_to: date) -> tuple[datetime, datetime, datetime, datetime]:
|
||||
"""返回同一北京自然日窗口的 UTC aware 边界和北京 naive 边界。
|
||||
|
||||
user.created_at / last_login_at 是 UTC aware 口径;比较/金币等历史上有北京 naive
|
||||
写入,所以两套边界同时保留。
|
||||
"""
|
||||
start_bj = datetime.combine(date_from, time.min, tzinfo=_BEIJING)
|
||||
end_bj = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=_BEIJING)
|
||||
start_utc = start_bj.astimezone(timezone.utc)
|
||||
end_utc = end_bj.astimezone(timezone.utc)
|
||||
return (
|
||||
start_utc,
|
||||
end_utc,
|
||||
start_bj.replace(tzinfo=None),
|
||||
end_bj.replace(tzinfo=None),
|
||||
)
|
||||
|
||||
|
||||
def _date_range(date_from: date, date_to: date) -> list[date]:
|
||||
days = (date_to - date_from).days
|
||||
return [date_from + timedelta(days=i) for i in range(days + 1)]
|
||||
|
||||
|
||||
def _id_set(db: Session, stmt) -> set[int]:
|
||||
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
|
||||
|
||||
|
||||
def _event_user_ids(
|
||||
db: Session, event_names: tuple[str, ...], start_utc: datetime, end_utc: datetime
|
||||
) -> set[int]:
|
||||
return _id_set(
|
||||
db,
|
||||
select(AnalyticsEvent.user_id).where(
|
||||
AnalyticsEvent.user_id.is_not(None),
|
||||
AnalyticsEvent.event.in_(event_names),
|
||||
AnalyticsEvent.created_at >= start_utc,
|
||||
AnalyticsEvent.created_at < end_utc,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _commission_rate_percent(raw: str | None) -> Decimal | None:
|
||||
"""美团 commissionRate 原值: "300"=3%, "10"=0.1%;也兼容 "3%"。"""
|
||||
if raw is None:
|
||||
return None
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
if s.endswith("%"):
|
||||
return Decimal(s[:-1])
|
||||
val = Decimal(s)
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
return val / Decimal("100")
|
||||
|
||||
|
||||
def _jd_valid_order(order: CpsOrder) -> bool:
|
||||
code = str(order.jd_valid_code).strip() if order.jd_valid_code is not None else ""
|
||||
return bool(code and code not in JD_CPS_INVALID_CODES and code not in JD_CPS_UNPAID_CODES)
|
||||
|
||||
|
||||
def dashboard_overview(
|
||||
db: Session, *, date_from: date | None = None, date_to: date | None = None
|
||||
) -> dict:
|
||||
today_start = _beijing_today_start_utc()
|
||||
period_from, period_to = _normalize_period(date_from, date_to)
|
||||
start_utc, end_utc, start_local, end_local = _period_bounds(period_from, period_to)
|
||||
|
||||
def _count(model, *conds) -> int:
|
||||
stmt = select(func.count(model.id))
|
||||
if conds:
|
||||
stmt = stmt.where(*conds)
|
||||
return db.execute(stmt).scalar_one()
|
||||
return int(db.execute(stmt).scalar_one())
|
||||
|
||||
def _sum(col, *conds) -> int:
|
||||
stmt = select(func.coalesce(func.sum(col), 0))
|
||||
if conds:
|
||||
stmt = stmt.where(*conds)
|
||||
return db.execute(stmt).scalar_one()
|
||||
return int(db.execute(stmt).scalar_one())
|
||||
|
||||
def _user_id_set(stmt) -> set[int]:
|
||||
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
|
||||
|
||||
# ===== 用户 =====
|
||||
by_status = dict(
|
||||
@@ -61,6 +197,279 @@ def dashboard_overview(db: Session) -> dict:
|
||||
comparison_total = _count(ComparisonRecord)
|
||||
comparison_success = _count(ComparisonRecord, ComparisonRecord.status == "success")
|
||||
success_rate = round(comparison_success / comparison_total, 4) if comparison_total else 0.0
|
||||
period_comparison_conds = (
|
||||
ComparisonRecord.created_at >= start_local,
|
||||
ComparisonRecord.created_at < end_local,
|
||||
)
|
||||
period_comparison_total = _count(ComparisonRecord, *period_comparison_conds)
|
||||
period_comparison_success = _count(
|
||||
ComparisonRecord,
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.status == "success",
|
||||
)
|
||||
period_comparison_success_rate = (
|
||||
round(period_comparison_success / period_comparison_total, 4)
|
||||
if period_comparison_total
|
||||
else 0.0
|
||||
)
|
||||
period_saved_positive_count = _count(
|
||||
ComparisonRecord,
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.status == "success",
|
||||
ComparisonRecord.saved_amount_cents > 0,
|
||||
)
|
||||
period_saved_positive_sum = _sum(
|
||||
ComparisonRecord.saved_amount_cents,
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.status == "success",
|
||||
ComparisonRecord.saved_amount_cents > 0,
|
||||
)
|
||||
period_avg_saved_cents = (
|
||||
round(period_saved_positive_sum / period_saved_positive_count)
|
||||
if period_saved_positive_count
|
||||
else None
|
||||
)
|
||||
period_avg_duration_ms = db.execute(
|
||||
select(func.avg(ComparisonRecord.total_ms)).where(
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.total_ms.is_not(None),
|
||||
ComparisonRecord.total_ms > 0,
|
||||
)
|
||||
).scalar_one()
|
||||
period_avg_duration_ms = (
|
||||
round(float(period_avg_duration_ms))
|
||||
if period_avg_duration_ms is not None
|
||||
else None
|
||||
)
|
||||
|
||||
ordered_exists = (
|
||||
select(SavingsRecord.id)
|
||||
.where(
|
||||
SavingsRecord.user_id == ComparisonRecord.user_id,
|
||||
SavingsRecord.source == "compare",
|
||||
SavingsRecord.shop_name.is_not(None),
|
||||
SavingsRecord.shop_name == ComparisonRecord.store_name,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
period_ordered_count = _count(
|
||||
ComparisonRecord,
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.store_name.is_not(None),
|
||||
ordered_exists,
|
||||
)
|
||||
|
||||
# ===== 日期窗口用户 =====
|
||||
period_new_user_ids = _user_id_set(
|
||||
select(User.id).where(User.created_at >= start_utc, User.created_at < end_utc)
|
||||
)
|
||||
login_user_ids = _user_id_set(
|
||||
select(User.id).where(User.last_login_at >= start_utc, User.last_login_at < end_utc)
|
||||
)
|
||||
compare_start_user_ids = _event_user_ids(
|
||||
db, (COMPARE_START_EVENT,), start_utc, end_utc
|
||||
)
|
||||
coupon_event_user_ids = _event_user_ids(
|
||||
db, (COUPON_START_EVENT,), start_utc, end_utc
|
||||
)
|
||||
coupon_claim_user_ids = _user_id_set(
|
||||
select(CouponPromptEngagement.user_id).where(
|
||||
CouponPromptEngagement.engage_date >= period_from,
|
||||
CouponPromptEngagement.engage_date <= period_to,
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
)
|
||||
)
|
||||
period_active_user_ids = (
|
||||
login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids
|
||||
)
|
||||
period_retained_new_user_ids = period_new_user_ids & period_active_user_ids
|
||||
period_retention_rate = (
|
||||
round(len(period_retained_new_user_ids) / len(period_new_user_ids), 4)
|
||||
if period_new_user_ids
|
||||
else None
|
||||
)
|
||||
trend_points: list[dict] = []
|
||||
for cur_date in _date_range(period_from, period_to):
|
||||
day_start_utc, day_end_utc, day_start_local, day_end_local = _period_bounds(
|
||||
cur_date, cur_date
|
||||
)
|
||||
daily_comparison_conds = (
|
||||
ComparisonRecord.created_at >= day_start_local,
|
||||
ComparisonRecord.created_at < day_end_local,
|
||||
)
|
||||
daily_login_user_ids = _user_id_set(
|
||||
select(User.id).where(
|
||||
User.last_login_at >= day_start_utc,
|
||||
User.last_login_at < day_end_utc,
|
||||
)
|
||||
)
|
||||
daily_compare_start_user_ids = _event_user_ids(
|
||||
db, (COMPARE_START_EVENT,), day_start_utc, day_end_utc
|
||||
)
|
||||
daily_coupon_event_user_ids = _event_user_ids(
|
||||
db, (COUPON_START_EVENT,), day_start_utc, day_end_utc
|
||||
)
|
||||
daily_coupon_claim_user_ids = _user_id_set(
|
||||
select(CouponPromptEngagement.user_id).where(
|
||||
CouponPromptEngagement.engage_date == cur_date,
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
)
|
||||
)
|
||||
trend_points.append(
|
||||
{
|
||||
"date": cur_date,
|
||||
"active_users": len(
|
||||
daily_login_user_ids
|
||||
| daily_compare_start_user_ids
|
||||
| daily_coupon_event_user_ids
|
||||
| daily_coupon_claim_user_ids
|
||||
),
|
||||
"new_users": _count(
|
||||
User,
|
||||
User.created_at >= day_start_utc,
|
||||
User.created_at < day_end_utc,
|
||||
),
|
||||
"comparisons": _count(ComparisonRecord, *daily_comparison_conds),
|
||||
}
|
||||
)
|
||||
|
||||
period_coin_conds = (
|
||||
CoinTransaction.created_at >= start_local,
|
||||
CoinTransaction.created_at < end_local,
|
||||
CoinTransaction.amount > 0,
|
||||
)
|
||||
period_reward_video_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES),
|
||||
)
|
||||
period_feed_ad_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type == "feed_ad_reward",
|
||||
)
|
||||
period_feed_ad_conds = (
|
||||
AdFeedRewardRecord.created_at >= start_utc,
|
||||
AdFeedRewardRecord.created_at < end_utc,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
)
|
||||
period_coupon_feed_coin_total = _sum(
|
||||
AdFeedRewardRecord.coin,
|
||||
*period_feed_ad_conds,
|
||||
AdFeedRewardRecord.feed_scene == "coupon",
|
||||
)
|
||||
period_comparison_feed_coin_total = _sum(
|
||||
AdFeedRewardRecord.coin,
|
||||
*period_feed_ad_conds,
|
||||
AdFeedRewardRecord.feed_scene == "comparison",
|
||||
)
|
||||
period_signin_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type == "signin",
|
||||
)
|
||||
period_signin_boost_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type == "signin_boost",
|
||||
)
|
||||
period_task_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.like("task_%"),
|
||||
)
|
||||
period_coupon_reward_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.in_(COUPON_REWARD_BIZ_TYPES),
|
||||
) + period_coupon_feed_coin_total
|
||||
period_comparison_reward_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.in_(COMPARISON_REWARD_BIZ_TYPES),
|
||||
) + period_comparison_feed_coin_total
|
||||
period_regular_task_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.notin_(REGULAR_TASK_EXCLUDED_BIZ_TYPES),
|
||||
)
|
||||
period_classified_coin_total = (
|
||||
period_coupon_reward_coin_total
|
||||
+ period_comparison_reward_coin_total
|
||||
+ period_reward_video_coin_total
|
||||
+ period_regular_task_coin_total
|
||||
)
|
||||
period_cps_orders = list(
|
||||
db.execute(
|
||||
select(CpsOrder).where(
|
||||
CpsOrder.pay_time >= start_utc,
|
||||
CpsOrder.pay_time < end_utc,
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
period_meituan_orders = [
|
||||
o for o in period_cps_orders if (o.platform or "meituan") == "meituan"
|
||||
]
|
||||
period_meituan_valid_orders = [
|
||||
o for o in period_meituan_orders if o.mt_status not in MEITUAN_CPS_INVALID_STATUSES
|
||||
]
|
||||
period_jd_orders = [o for o in period_cps_orders if o.platform == "jd"]
|
||||
period_jd_valid_orders = [o for o in period_jd_orders if _jd_valid_order(o)]
|
||||
period_jd_invalid_orders = [
|
||||
o for o in period_jd_orders if o.jd_valid_code and not _jd_valid_order(o)
|
||||
]
|
||||
period_meituan_hit_count = 0
|
||||
period_meituan_miss_count = 0
|
||||
period_meituan_unknown_rate_count = 0
|
||||
for order in period_meituan_valid_orders:
|
||||
rate = _commission_rate_percent(order.commission_rate)
|
||||
if rate is None:
|
||||
period_meituan_unknown_rate_count += 1
|
||||
elif rate < Decimal("1"):
|
||||
period_meituan_miss_count += 1
|
||||
else:
|
||||
period_meituan_hit_count += 1
|
||||
period_meituan_hit_denominator = period_meituan_hit_count + period_meituan_miss_count
|
||||
period_meituan_hit_rate = (
|
||||
round(period_meituan_hit_count / period_meituan_hit_denominator, 4)
|
||||
if period_meituan_hit_denominator
|
||||
else None
|
||||
)
|
||||
|
||||
total_reward_video_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
CoinTransaction.amount > 0,
|
||||
CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES),
|
||||
)
|
||||
total_coupon_reward_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
CoinTransaction.amount > 0,
|
||||
CoinTransaction.biz_type.in_(COUPON_REWARD_BIZ_TYPES),
|
||||
) + _sum(
|
||||
AdFeedRewardRecord.coin,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
AdFeedRewardRecord.feed_scene == "coupon",
|
||||
)
|
||||
total_comparison_reward_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
CoinTransaction.amount > 0,
|
||||
CoinTransaction.biz_type.in_(COMPARISON_REWARD_BIZ_TYPES),
|
||||
) + _sum(
|
||||
AdFeedRewardRecord.coin,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
AdFeedRewardRecord.feed_scene == "comparison",
|
||||
)
|
||||
total_regular_task_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
CoinTransaction.amount > 0,
|
||||
CoinTransaction.biz_type.notin_(REGULAR_TASK_EXCLUDED_BIZ_TYPES),
|
||||
)
|
||||
total_classified_coin_total = (
|
||||
total_coupon_reward_coin_total
|
||||
+ total_comparison_reward_coin_total
|
||||
+ total_reward_video_coin_total
|
||||
+ total_regular_task_coin_total
|
||||
)
|
||||
|
||||
return {
|
||||
"users": {
|
||||
@@ -69,15 +478,15 @@ def dashboard_overview(db: Session) -> dict:
|
||||
"disabled": by_status.get("disabled", 0),
|
||||
"deleted": by_status.get("deleted", 0),
|
||||
"new_today": _count(User, User.created_at >= today_start),
|
||||
"dau": _count(User, User.last_login_at >= today_start),
|
||||
"dau": today_dau(db),
|
||||
},
|
||||
"coins": {
|
||||
# 累计发放金币(coin_transaction 里所有 amount>0 之和;负数是兑换/扣减不计)
|
||||
"granted_total": _sum(CoinTransaction.amount, CoinTransaction.amount > 0),
|
||||
# 累计发放金币按大盘 4 个展示分类加总;未分类/运营手动/邀请奖励不进入本指标。
|
||||
"granted_total": total_classified_coin_total,
|
||||
"reward_video_coin_total": _sum(
|
||||
CoinTransaction.amount,
|
||||
CoinTransaction.amount > 0,
|
||||
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
|
||||
CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES),
|
||||
),
|
||||
"reward_video_watch_count": _count(
|
||||
AdRewardRecord,
|
||||
@@ -105,6 +514,10 @@ def dashboard_overview(db: Session) -> dict:
|
||||
CoinTransaction.biz_type == "signin_boost",
|
||||
),
|
||||
"signin_boost_watch_count": _count(SigninBoostRecord),
|
||||
"task_coin_total": total_regular_task_coin_total,
|
||||
"coupon_reward_coin_total": total_coupon_reward_coin_total,
|
||||
"comparison_reward_coin_total": total_comparison_reward_coin_total,
|
||||
"regular_task_coin_total": total_regular_task_coin_total,
|
||||
},
|
||||
"cash": {
|
||||
"withdraw_success_cents": _sum(
|
||||
@@ -119,7 +532,73 @@ def dashboard_overview(db: Session) -> dict:
|
||||
"success": comparison_success,
|
||||
"success_rate": success_rate,
|
||||
},
|
||||
"feedback": {"new": _count(Feedback, Feedback.status.in_(("pending", "new")))},
|
||||
# CPS 收入数据源未接(referral-link 只换链接,转化/佣金未回收)→ 前端显示"待接入"。
|
||||
"cps": {"available": False, "note": "CPS 转化数据未接入(P2)"},
|
||||
"period": {
|
||||
"date_from": period_from,
|
||||
"date_to": period_to,
|
||||
"users": {
|
||||
"new": len(period_new_user_ids),
|
||||
"active": len(period_active_user_ids),
|
||||
"retained_new_users": len(period_retained_new_user_ids),
|
||||
"retention_rate": period_retention_rate,
|
||||
"retention_note": (
|
||||
"口径:登录(last_login_at)+开始比价(real_compare_start)+"
|
||||
"开始领券(real_coupon_start/claim_started),按用户去重"
|
||||
),
|
||||
},
|
||||
"comparison": {
|
||||
"total": period_comparison_total,
|
||||
"success": period_comparison_success,
|
||||
"success_rate": period_comparison_success_rate,
|
||||
"ordered": period_ordered_count,
|
||||
"average_duration_ms": period_avg_duration_ms,
|
||||
"average_saved_cents": period_avg_saved_cents,
|
||||
},
|
||||
"coins": {
|
||||
"granted_total": period_classified_coin_total,
|
||||
"reward_video_coin_total": period_reward_video_coin_total,
|
||||
"feed_ad_coin_total": period_feed_ad_coin_total,
|
||||
"signin_coin_total": period_signin_coin_total,
|
||||
"signin_boost_coin_total": period_signin_boost_coin_total,
|
||||
"task_coin_total": period_task_coin_total,
|
||||
"coupon_reward_coin_total": period_coupon_reward_coin_total,
|
||||
"comparison_reward_coin_total": period_comparison_reward_coin_total,
|
||||
"regular_task_coin_total": period_regular_task_coin_total,
|
||||
},
|
||||
"cash": {
|
||||
"withdraw_success_cents": _sum(
|
||||
WithdrawOrder.amount_cents,
|
||||
WithdrawOrder.status == "success",
|
||||
WithdrawOrder.created_at >= start_local,
|
||||
WithdrawOrder.created_at < end_local,
|
||||
),
|
||||
},
|
||||
"trend": trend_points,
|
||||
},
|
||||
"feedback": {
|
||||
"new": _count(Feedback, Feedback.status.in_(("pending", "new"))),
|
||||
},
|
||||
"cps": {
|
||||
"available": True,
|
||||
"note": "美团/JD CPS 读 cps_order 对账订单;淘宝佣金暂空",
|
||||
"meituan_order_count": len(period_meituan_valid_orders),
|
||||
"meituan_commission_cents": sum(
|
||||
o.commission_cents or 0 for o in period_meituan_valid_orders
|
||||
),
|
||||
"meituan_hit_count": period_meituan_hit_count,
|
||||
"meituan_miss_count": period_meituan_miss_count,
|
||||
"meituan_unknown_rate_count": period_meituan_unknown_rate_count,
|
||||
"meituan_hit_rate": period_meituan_hit_rate,
|
||||
"jd_order_count": len(period_jd_valid_orders),
|
||||
# 数据大盘京东 CPS 只看实际佣金,不再用预估佣金兜底。
|
||||
"jd_commission_cents": sum(
|
||||
o.actual_commission_cents or 0 for o in period_jd_valid_orders
|
||||
),
|
||||
"jd_actual_commission_cents": sum(
|
||||
o.actual_commission_cents or 0 for o in period_jd_valid_orders
|
||||
),
|
||||
"jd_estimated_commission_cents": sum(
|
||||
o.estimated_commission_cents or 0 for o in period_jd_valid_orders
|
||||
),
|
||||
"jd_invalid_count": len(period_jd_invalid_orders),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -11,7 +11,13 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import ad_revenue
|
||||
from app.admin.schemas.ad_revenue import AdRevenueDaily, AdRevenueReportOut, AdRevenueRow
|
||||
from app.admin.schemas.ad_revenue import (
|
||||
AdRevenueDaily,
|
||||
AdRevenueHourly,
|
||||
AdRevenueReportOut,
|
||||
AdRevenueRow,
|
||||
AdRevenueTypeStat,
|
||||
)
|
||||
from app.core.rewards import cn_today
|
||||
|
||||
router = APIRouter(
|
||||
@@ -43,10 +49,21 @@ def get_ad_revenue_report(
|
||||
str | None,
|
||||
Query(description="reward_video / feed / draw;不传=全部类型"),
|
||||
] = None,
|
||||
feed_scene: Annotated[
|
||||
str | None,
|
||||
Query(
|
||||
description="comparison(比价) / coupon(领券) / welfare(福利);不传=全部场景。"
|
||||
"全局筛选,同时影响明细 / 合计 / 趋势"
|
||||
),
|
||||
] = None,
|
||||
granularity: Annotated[
|
||||
str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day")
|
||||
] = "day",
|
||||
limit: Annotated[int, Query(ge=1, le=1000)] = 500,
|
||||
limit: Annotated[int, Query(ge=1, le=1000, description="每页条数(分页大小)")] = 500,
|
||||
offset: Annotated[int, Query(ge=0, description="分页偏移(已跳过的条数)=(页码-1)×每页条数")] = 0,
|
||||
sort: Annotated[
|
||||
str, Query(description="排序:time=时间倒序(默认) / ecpm=按 eCPM 数值倒序")
|
||||
] = "time",
|
||||
) -> AdRevenueReportOut:
|
||||
today = cn_today()
|
||||
d_from = _parse_day(date_from, field="date_from", default=today)
|
||||
@@ -58,12 +75,16 @@ def get_ad_revenue_report(
|
||||
|
||||
result = ad_revenue.ad_revenue_report(
|
||||
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
|
||||
user_id=user_id, ad_type=ad_type, granularity=granularity, limit=limit,
|
||||
user_id=user_id, ad_type=ad_type, feed_scene=feed_scene,
|
||||
granularity=granularity, limit=limit, offset=offset, sort=sort,
|
||||
)
|
||||
return AdRevenueReportOut(
|
||||
date_from=d_from.isoformat(),
|
||||
date_to=d_to.isoformat(),
|
||||
daily=[AdRevenueDaily(**d) for d in result["daily"]],
|
||||
hourly=[AdRevenueHourly(**h) for h in result["hourly"]],
|
||||
type_stats={k: AdRevenueTypeStat(**v) for k, v in result["type_stats"].items()},
|
||||
dau=result["dau"],
|
||||
total=result["total"],
|
||||
truncated=result["truncated"],
|
||||
total_impressions=result["total_impressions"],
|
||||
|
||||
+95
-10
@@ -1,13 +1,13 @@
|
||||
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 美团订单对账 + 统计。
|
||||
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 联盟订单对账 + 统计。
|
||||
|
||||
平台:meituan(actId+sid 转链 + query_order 对账) / taobao(整段淘口令) / jd(链接)。
|
||||
淘宝/京东无 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"。
|
||||
淘宝暂未接 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"。
|
||||
群/活动管理 = operator;订单对账(涉佣金) = finance;只读列表/统计 = 登录即可。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import date as _date, datetime, time as _dt_time, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
|
||||
@@ -34,6 +34,7 @@ from app.admin.schemas.cps import (
|
||||
from app.core import media
|
||||
from app.core.config import settings
|
||||
from app.integrations import meituan
|
||||
from app.integrations.jd_union import JdUnionError
|
||||
from app.integrations.meituan import MeituanCpsError
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.cps_activity import CpsActivity
|
||||
@@ -340,24 +341,108 @@ def generate_referral_links(
|
||||
|
||||
|
||||
# ───────────── 订单对账 ─────────────
|
||||
@router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取美团订单对账")
|
||||
_BEIJING = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def _parse_day(value: str | None, *, field: str) -> _date | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return _date.fromisoformat(value)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e
|
||||
|
||||
|
||||
def _reconcile_range_to_ts(
|
||||
date_from: _date | None, date_to: _date | None, days: int
|
||||
) -> tuple[int, int]:
|
||||
if date_from is None and date_to is None:
|
||||
now = int(time.time())
|
||||
return now - days * 86400, now
|
||||
|
||||
start_day = date_from or date_to
|
||||
end_day = date_to or date_from
|
||||
if start_day is None or end_day is None:
|
||||
raise HTTPException(status_code=422, detail="日期参数不完整")
|
||||
if start_day > end_day:
|
||||
start_day, end_day = end_day, start_day
|
||||
if (end_day - start_day).days + 1 > 90:
|
||||
raise HTTPException(status_code=422, detail="美团订单查询最长 90 天")
|
||||
|
||||
start_dt = datetime.combine(start_day, _dt_time.min, tzinfo=_BEIJING)
|
||||
end_dt = datetime.combine(end_day + timedelta(days=1), _dt_time.min, tzinfo=_BEIJING)
|
||||
return int(start_dt.timestamp()), int(end_dt.timestamp())
|
||||
|
||||
|
||||
def _reconcile_range_to_bj_dt(
|
||||
date_from: _date | None, date_to: _date | None, days: int
|
||||
) -> tuple[datetime, datetime]:
|
||||
start_ts, end_ts = _reconcile_range_to_ts(date_from, date_to, days)
|
||||
return (
|
||||
datetime.fromtimestamp(start_ts, tz=_BEIJING),
|
||||
datetime.fromtimestamp(end_ts, tz=_BEIJING),
|
||||
)
|
||||
|
||||
|
||||
def _merge_reconcile_result(total: dict, current: dict) -> None:
|
||||
for key in ("fetched", "inserted", "updated", "pages"):
|
||||
total[key] = int(total.get(key, 0)) + int(current.get(key, 0))
|
||||
|
||||
|
||||
@router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取联盟订单对账")
|
||||
def reconcile_orders(
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("finance"))],
|
||||
db: AdminDb,
|
||||
days: Annotated[int, Query(ge=1, le=90)] = 7,
|
||||
date_from: Annotated[str | None, Query(description="起始日 YYYY-MM-DD")] = None,
|
||||
date_to: Annotated[str | None, Query(description="结束日 YYYY-MM-DD")] = None,
|
||||
days: Annotated[int, Query(ge=1, le=90, description="未传日期时默认拉近 N 天")] = 7,
|
||||
sid: Annotated[str | None, Query(max_length=64)] = None,
|
||||
query_time_type: Annotated[int, Query(ge=1, le=3)] = 1,
|
||||
platform: Annotated[str, Query(pattern="^(all|meituan|jd)$")] = "all",
|
||||
) -> CpsReconcileResult:
|
||||
now = int(time.time())
|
||||
parsed_from = _parse_day(date_from, field="date_from")
|
||||
parsed_to = _parse_day(date_to, field="date_to")
|
||||
result = {"fetched": 0, "inserted": 0, "updated": 0, "pages": 0}
|
||||
try:
|
||||
result = cps_repo.reconcile_orders(
|
||||
db, start_time=now - days * 86400, end_time=now, sid=sid,
|
||||
)
|
||||
if platform in {"all", "meituan"}:
|
||||
start_ts, end_ts = _reconcile_range_to_ts(parsed_from, parsed_to, days)
|
||||
mt_result = cps_repo.reconcile_orders(
|
||||
db,
|
||||
start_time=start_ts,
|
||||
end_time=end_ts,
|
||||
query_time_type=query_time_type if query_time_type in (1, 2) else 2,
|
||||
sid=sid,
|
||||
)
|
||||
_merge_reconcile_result(result, mt_result)
|
||||
if platform in {"all", "jd"}:
|
||||
if sid:
|
||||
raise HTTPException(status_code=422, detail="京东订单刷新不支持 sid 筛选")
|
||||
start_dt, end_dt = _reconcile_range_to_bj_dt(parsed_from, parsed_to, days)
|
||||
jd_result = cps_repo.reconcile_jd_orders(
|
||||
db,
|
||||
start_time=start_dt,
|
||||
end_time=end_dt,
|
||||
query_time_type=query_time_type,
|
||||
)
|
||||
_merge_reconcile_result(result, jd_result)
|
||||
except MeituanCpsError as e:
|
||||
raise HTTPException(status_code=502, detail=f"美团拉单失败: {e}") from e
|
||||
except JdUnionError as e:
|
||||
raise HTTPException(status_code=502, detail=f"京东拉单失败: {e}") from e
|
||||
write_audit(
|
||||
db, admin, action="cps.order.reconcile", target_type="cps_order", target_id=None,
|
||||
detail={"days": days, "sid": sid, **result}, ip=get_client_ip(request), commit=True,
|
||||
detail={
|
||||
"platform": platform,
|
||||
"date_from": date_from,
|
||||
"date_to": date_to,
|
||||
"days": days,
|
||||
"sid": sid,
|
||||
"query_time_type": query_time_type,
|
||||
**result,
|
||||
},
|
||||
ip=get_client_ip(request),
|
||||
commit=True,
|
||||
)
|
||||
return CpsReconcileResult(**result)
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""admin 数据大盘(只读聚合)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import stats
|
||||
@@ -15,5 +17,11 @@ router = APIRouter(
|
||||
|
||||
|
||||
@router.get("/overview", response_model=DashboardOverview, summary="大盘核心指标")
|
||||
def overview(db: AdminDb) -> DashboardOverview:
|
||||
return DashboardOverview.model_validate(stats.dashboard_overview(db))
|
||||
def overview(
|
||||
db: AdminDb,
|
||||
date_from: date | None = Query(None, description="北京时间自然日起始日 YYYY-MM-DD"),
|
||||
date_to: date | None = Query(None, description="北京时间自然日结束日 YYYY-MM-DD"),
|
||||
) -> DashboardOverview:
|
||||
return DashboardOverview.model_validate(
|
||||
stats.dashboard_overview(db, date_from=date_from, date_to=date_to)
|
||||
)
|
||||
|
||||
@@ -40,7 +40,7 @@ class AdRevenueRecord(BaseModel):
|
||||
|
||||
|
||||
class AdRevenueDaily(BaseModel):
|
||||
"""按日期汇总的一天(供前端按天趋势图;全量,不受 limit 影响)。"""
|
||||
"""按日期汇总的一天(供前端按天趋势图;全量,不受分页影响)。"""
|
||||
|
||||
date: str = Field(..., description="北京时间 YYYY-MM-DD")
|
||||
impressions: int = Field(..., description="当天展示条数合计")
|
||||
@@ -49,6 +49,23 @@ class AdRevenueDaily(BaseModel):
|
||||
actual_coin: int = Field(..., description="当天实发金币合计")
|
||||
|
||||
|
||||
class AdRevenueHourly(BaseModel):
|
||||
"""按北京小时(0–23)汇总的一小时(供前端按小时趋势图;全量,不受分页影响,单日 granularity=hour 时非空)。"""
|
||||
|
||||
hour: int = Field(..., description="北京时间小时 0–23")
|
||||
impressions: int = Field(..., description="该小时展示条数合计")
|
||||
revenue_yuan: float = Field(..., description="该小时预估收益合计(元)")
|
||||
expected_coin: int = Field(..., description="该小时应发金币合计")
|
||||
actual_coin: int = Field(..., description="该小时实发金币合计")
|
||||
|
||||
|
||||
class AdRevenueTypeStat(BaseModel):
|
||||
"""按广告类型(ad_type)的小计:展示条数 + 预估收益(eCPM 由前端用 收益÷展示×1000 算)。"""
|
||||
|
||||
impressions: int = Field(..., description="该类型展示条数合计")
|
||||
revenue_yuan: float = Field(..., description="该类型预估收益合计(元)")
|
||||
|
||||
|
||||
class AdRevenueRow(BaseModel):
|
||||
"""一次广告事件(逐条一行):激励视频展示与发奖按 ad_session_id 合并;信息流展示 / 发奖各自成行。"""
|
||||
|
||||
@@ -91,8 +108,20 @@ class AdRevenueReportOut(BaseModel):
|
||||
date_from: str = Field(..., description="报表起始日期(北京时间 YYYY-MM-DD)")
|
||||
date_to: str = Field(..., description="报表结束日期(北京时间 YYYY-MM-DD,闭区间;单日时与 date_from 相同)")
|
||||
daily: list[AdRevenueDaily] = Field(..., description="按日期汇总序列(全量,供按天趋势图)")
|
||||
total: int = Field(..., description="广告事件总数(全量,不受 limit 影响)")
|
||||
truncated: bool = Field(..., description="明细是否被 limit 截断")
|
||||
hourly: list[AdRevenueHourly] = Field(
|
||||
default_factory=list,
|
||||
description="按小时汇总序列(全量,供按小时趋势图;按天查询时为空)",
|
||||
)
|
||||
type_stats: dict[str, AdRevenueTypeStat] = Field(
|
||||
default_factory=dict,
|
||||
description="按广告类型(ad_type)小计 {ad_type: {impressions, revenue_yuan}};前端取 draw / reward_video 做分类大盘",
|
||||
)
|
||||
dau: int | None = Field(
|
||||
None,
|
||||
description="今日活跃用户数(复用大盘口径,last_login_at);**仅查询=今日单天时有值**,历史/多天为 null",
|
||||
)
|
||||
total: int = Field(..., description="广告事件总数(全量,不受分页影响;= 当前筛选下的分页总条数)")
|
||||
truncated: bool = Field(..., description="当前页之后是否还有更多事件(len(events) > offset + limit)")
|
||||
total_impressions: int = Field(..., description="全量展示条数合计")
|
||||
total_revenue_yuan: float = Field(..., description="全量收益合计(元)")
|
||||
total_expected_coin: int = Field(..., description="全量应发金币合计")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。
|
||||
|
||||
平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接,只统计点击)。
|
||||
对账类字段对淘宝/京东为 None → 前端显示 "-"(无法对账)。
|
||||
平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接 + 订单 API 对账)。
|
||||
对账类字段对淘宝为 None → 前端显示 "-"(暂未对账)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -116,15 +116,22 @@ class CpsOrderOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
platform: str = "meituan"
|
||||
order_id: str
|
||||
external_order_id: str | None = None
|
||||
external_row_id: str | None = None
|
||||
sid: str | None = None
|
||||
act_id: str | None = None
|
||||
pay_price_cents: int | None = None
|
||||
commission_cents: int | None = None
|
||||
estimated_commission_cents: int | None = None
|
||||
actual_commission_cents: int | None = None
|
||||
commission_rate: str | None = None
|
||||
mt_status: str | None = None
|
||||
jd_valid_code: str | None = None
|
||||
invalid_reason: str | None = None
|
||||
product_name: str | None = None
|
||||
settle_month: str | None = None
|
||||
pay_time: datetime | None = None
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""admin 大盘 schemas(对应 stats.dashboard_overview 的嵌套结构)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
@@ -38,6 +40,56 @@ class DashboardComparison(BaseModel):
|
||||
success_rate: float
|
||||
|
||||
|
||||
class DashboardPeriodUsers(BaseModel):
|
||||
new: int
|
||||
active: int
|
||||
retained_new_users: int
|
||||
retention_rate: float | None = None
|
||||
retention_note: str
|
||||
|
||||
|
||||
class DashboardPeriodComparison(BaseModel):
|
||||
total: int
|
||||
success: int
|
||||
success_rate: float
|
||||
ordered: int
|
||||
average_duration_ms: int | None = None
|
||||
average_saved_cents: int | None = None
|
||||
|
||||
|
||||
class DashboardPeriodCoins(BaseModel):
|
||||
granted_total: int
|
||||
reward_video_coin_total: int = 0
|
||||
feed_ad_coin_total: int = 0
|
||||
signin_coin_total: int = 0
|
||||
signin_boost_coin_total: int = 0
|
||||
task_coin_total: int = 0
|
||||
coupon_reward_coin_total: int = 0
|
||||
comparison_reward_coin_total: int = 0
|
||||
regular_task_coin_total: int = 0
|
||||
|
||||
|
||||
class DashboardPeriodCash(BaseModel):
|
||||
withdraw_success_cents: int
|
||||
|
||||
|
||||
class DashboardTrendPoint(BaseModel):
|
||||
date: date
|
||||
active_users: int
|
||||
new_users: int
|
||||
comparisons: int
|
||||
|
||||
|
||||
class DashboardPeriod(BaseModel):
|
||||
date_from: date
|
||||
date_to: date
|
||||
users: DashboardPeriodUsers
|
||||
comparison: DashboardPeriodComparison
|
||||
coins: DashboardPeriodCoins
|
||||
cash: DashboardPeriodCash
|
||||
trend: list[DashboardTrendPoint] = []
|
||||
|
||||
|
||||
class DashboardFeedback(BaseModel):
|
||||
new: int
|
||||
|
||||
@@ -45,6 +97,17 @@ class DashboardFeedback(BaseModel):
|
||||
class DashboardCps(BaseModel):
|
||||
available: bool
|
||||
note: str
|
||||
meituan_order_count: int = 0
|
||||
meituan_commission_cents: int = 0
|
||||
meituan_hit_count: int = 0
|
||||
meituan_miss_count: int = 0
|
||||
meituan_unknown_rate_count: int = 0
|
||||
meituan_hit_rate: float | None = None
|
||||
jd_order_count: int = 0
|
||||
jd_commission_cents: int = 0
|
||||
jd_actual_commission_cents: int = 0
|
||||
jd_estimated_commission_cents: int = 0
|
||||
jd_invalid_count: int = 0
|
||||
|
||||
|
||||
class DashboardOverview(BaseModel):
|
||||
@@ -52,5 +115,6 @@ class DashboardOverview(BaseModel):
|
||||
coins: DashboardCoins
|
||||
cash: DashboardCash
|
||||
comparison: DashboardComparison
|
||||
period: DashboardPeriod
|
||||
feedback: DashboardFeedback
|
||||
cps: DashboardCps
|
||||
|
||||
@@ -24,7 +24,9 @@ class DeviceLivenessItem(BaseModel):
|
||||
device_model: str | None = None # 由 device_id 解析(device_<机型>_<hash>);非 DB 列
|
||||
platform: str
|
||||
app_version: str | None = None
|
||||
registration_id: str | None = None # 非空 = 拿到极光 token、可推送
|
||||
registration_id: str | None = None # 旧极光字段,仅兼容历史数据
|
||||
push_vendor: str | None = None
|
||||
push_token: str | None = None
|
||||
|
||||
ever_protected: bool # 是否开过无障碍(=该设备对功能有意义)
|
||||
first_protected_at: datetime | None = None # 首次开无障碍时刻(老设备为 null)
|
||||
|
||||
@@ -411,6 +411,7 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
|
||||
app_env=payload.app_env,
|
||||
our_code_id=payload.our_code_id,
|
||||
aborted=payload.aborted,
|
||||
display_coin=payload.display_coin,
|
||||
)
|
||||
logger.info(
|
||||
"feed ad reward user_id=%d event=%s status=%s units=%d coin=%d",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
路由前缀 `/api/v1/auth`,包含:
|
||||
POST /jverify-login 极光一键登录(loginToken → 手机号 → 注册即登录 → 签 JWT)
|
||||
POST /wechat-login 微信 App 授权登录(code → openid → 注册即登录 → 签 JWT)
|
||||
POST /sms/send 发短信验证码(mock 阶段任意 6 位通过)
|
||||
POST /sms/login 手机号 + 验证码登录
|
||||
POST /refresh 用 refresh_token 换新的 token 对
|
||||
@@ -20,6 +21,7 @@ from app.core.ratelimit import enforce_rate_limit, rate_limit
|
||||
from app.core.security import TokenError, decode_token, issue_token_pair
|
||||
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
|
||||
from app.integrations.sms import SmsError, send_code, verify_code
|
||||
from app.integrations.wechat_login import WechatLoginError, code_to_userinfo
|
||||
from app.repositories import onboarding as onboarding_repo
|
||||
from app.repositories import user as user_repo
|
||||
from app.schemas.auth import (
|
||||
@@ -32,6 +34,7 @@ from app.schemas.auth import (
|
||||
TokenPair,
|
||||
TokenWithUser,
|
||||
UserOut,
|
||||
WechatLoginRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.auth")
|
||||
@@ -84,6 +87,40 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
|
||||
return _login_response(user, onboarding_completed=completed)
|
||||
|
||||
|
||||
# ===================== 微信一键登录 =====================
|
||||
|
||||
@router.post(
|
||||
"/wechat-login",
|
||||
response_model=TokenWithUser,
|
||||
summary="微信一键登录",
|
||||
dependencies=[Depends(rate_limit(20, 60, "wechat-login"))],
|
||||
)
|
||||
def wechat_login(req: WechatLoginRequest, db: DbSession) -> TokenWithUser:
|
||||
try:
|
||||
info = code_to_userinfo(req.code)
|
||||
except WechatLoginError as e:
|
||||
logger.warning("wechat_login failed: %s", e)
|
||||
raise HTTPException(status_code=502, detail=str(e)) from e
|
||||
|
||||
user = user_repo.upsert_user_for_wechat_login(
|
||||
db,
|
||||
openid=info["openid"],
|
||||
nickname=info.get("nickname"),
|
||||
avatar_url=info.get("avatar_url"),
|
||||
)
|
||||
if user.status != "active":
|
||||
raise HTTPException(status_code=403, detail="account disabled")
|
||||
|
||||
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
|
||||
logger.info(
|
||||
"wechat_login ok user_id=%d openid=%s*** onboarded=%s",
|
||||
user.id,
|
||||
info["openid"][:6],
|
||||
completed,
|
||||
)
|
||||
return _login_response(user, onboarding_completed=completed)
|
||||
|
||||
|
||||
# ===================== 短信登录 =====================
|
||||
|
||||
@router.post(
|
||||
|
||||
@@ -26,6 +26,7 @@ import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.pricebot_client import get_pricebot_client
|
||||
from app.core.pricebot_router import pick_pricebot
|
||||
|
||||
logger = logging.getLogger("shagua.compare")
|
||||
@@ -65,10 +66,10 @@ async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(
|
||||
url, content=raw, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
client = get_pricebot_client()
|
||||
resp = await client.post(
|
||||
url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.error("[pricebot] request failed: %s", e)
|
||||
raise HTTPException(
|
||||
|
||||
@@ -20,6 +20,7 @@ from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core.config import settings
|
||||
from app.core.pricebot_client import get_pricebot_client
|
||||
from app.core.pricebot_router import pick_pricebot
|
||||
from app.db.session import SessionLocal
|
||||
from app.repositories import coupon_state as coupon_repo
|
||||
@@ -141,10 +142,10 @@ async def coupon_step(
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(
|
||||
url, content=raw, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
client = get_pricebot_client()
|
||||
resp = await client.post(
|
||||
url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.error("[pricebot] request failed: %s", e)
|
||||
raise HTTPException(
|
||||
|
||||
+93
-4
@@ -1,19 +1,22 @@
|
||||
"""设备注册 / 心跳 endpoint(无障碍保护存活检测)。
|
||||
|
||||
路由前缀 /api/v1/device,需 Bearer 鉴权(设备绑登录用户)。
|
||||
POST /register 注册设备 / 更新 registration_id(App 前台、拿到 push token 时调)
|
||||
POST /register 注册设备 / 更新厂商 push token(App 前台、拿到 push token 时调)
|
||||
POST /heartbeat 上报心跳(无障碍服务存活时周期调,刷新存活)
|
||||
POST /push-test 开发验收:延迟发送厂商通道测试推送
|
||||
|
||||
后端 heartbeat_monitor_worker 据此发现心跳超时的设备并极光推送告警。
|
||||
后端 heartbeat_monitor_worker 据此发现心跳超时的设备并厂商直推告警。
|
||||
见 spec: spec/accessibility-liveness-push.md。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.integrations import vendor_push
|
||||
from app.repositories import device as device_repo
|
||||
from app.schemas.device import (
|
||||
DeviceOut,
|
||||
@@ -22,6 +25,8 @@ from app.schemas.device import (
|
||||
LivenessAckRequest,
|
||||
LivenessOut,
|
||||
OkResponse,
|
||||
PushTestOut,
|
||||
PushTestRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.device")
|
||||
@@ -29,6 +34,37 @@ logger = logging.getLogger("shagua.device")
|
||||
router = APIRouter(prefix="/api/v1/device", tags=["device"])
|
||||
|
||||
|
||||
def _send_push_test_after_delay(
|
||||
push_vendor: str,
|
||||
push_token: str,
|
||||
delay_seconds: int,
|
||||
user_id: int,
|
||||
device_id: str,
|
||||
) -> None:
|
||||
if delay_seconds > 0:
|
||||
time.sleep(delay_seconds)
|
||||
try:
|
||||
vendor_push.send_accessibility_disabled(
|
||||
push_vendor,
|
||||
push_token,
|
||||
title="测试推送",
|
||||
alert="这是一条厂商通道测试推送。收到它说明 App 被划掉后仍可通过系统通知栏触达。",
|
||||
)
|
||||
logger.info(
|
||||
"push test sent user_id=%d device_id=%s delay=%ds",
|
||||
user_id,
|
||||
device_id,
|
||||
delay_seconds,
|
||||
)
|
||||
except vendor_push.VendorPushError as e:
|
||||
logger.warning(
|
||||
"push test failed user_id=%d device_id=%s error=%s",
|
||||
user_id,
|
||||
device_id,
|
||||
e,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register", response_model=DeviceOut, summary="注册设备/更新推送token")
|
||||
def register_device(
|
||||
req: DeviceRegisterRequest,
|
||||
@@ -40,13 +76,17 @@ def register_device(
|
||||
user_id=user.id,
|
||||
device_id=req.device_id,
|
||||
registration_id=req.registration_id,
|
||||
push_vendor=req.push_vendor,
|
||||
push_token=req.push_token,
|
||||
platform=req.platform,
|
||||
app_version=req.app_version,
|
||||
)
|
||||
logger.info(
|
||||
"device register user_id=%d device_id=%s reg=%s",
|
||||
"device register user_id=%d device_id=%s vendor=%s token=%s legacy_reg=%s",
|
||||
user.id,
|
||||
req.device_id,
|
||||
req.push_vendor,
|
||||
bool(req.push_token),
|
||||
bool(req.registration_id),
|
||||
)
|
||||
return DeviceOut.model_validate(device)
|
||||
@@ -64,10 +104,59 @@ def report_heartbeat(
|
||||
device_id=req.device_id,
|
||||
accessibility_enabled=req.accessibility_enabled,
|
||||
registration_id=req.registration_id,
|
||||
push_vendor=req.push_vendor,
|
||||
push_token=req.push_token,
|
||||
)
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.post("/push-test", response_model=PushTestOut, summary="延迟发送厂商通道测试推送")
|
||||
def request_push_test(
|
||||
req: PushTestRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
) -> PushTestOut:
|
||||
"""开发验收用:App 内点一次,服务端延迟发厂商直推,验证离线通道。"""
|
||||
push_vendor = req.push_vendor.strip() if req.push_vendor else None
|
||||
push_token = req.push_token.strip() if req.push_token else None
|
||||
if push_vendor and push_token:
|
||||
device_repo.register_or_update(
|
||||
db,
|
||||
user_id=user.id,
|
||||
device_id=req.device_id,
|
||||
registration_id=req.registration_id,
|
||||
push_vendor=push_vendor,
|
||||
push_token=push_token,
|
||||
)
|
||||
else:
|
||||
device = device_repo.get_device(db, user_id=user.id, device_id=req.device_id)
|
||||
push_vendor = device.push_vendor if device is not None else None
|
||||
push_token = device.push_token if device is not None else None
|
||||
|
||||
if not push_vendor or not push_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="push vendor token not ready",
|
||||
)
|
||||
|
||||
background_tasks.add_task(
|
||||
_send_push_test_after_delay,
|
||||
push_vendor,
|
||||
push_token,
|
||||
req.delay_seconds,
|
||||
user.id,
|
||||
req.device_id,
|
||||
)
|
||||
logger.info(
|
||||
"push test scheduled user_id=%d device_id=%s delay=%ds",
|
||||
user.id,
|
||||
req.device_id,
|
||||
req.delay_seconds,
|
||||
)
|
||||
return PushTestOut(delay_seconds=req.delay_seconds, has_push_token=True)
|
||||
|
||||
|
||||
@router.get("/liveness", response_model=LivenessOut, summary="查询本机掉线告警(后置检测)")
|
||||
def get_liveness(
|
||||
device_id: str,
|
||||
|
||||
+56
-4
@@ -9,7 +9,7 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic import model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
@@ -59,14 +59,51 @@ class Settings(BaseSettings):
|
||||
return []
|
||||
return [ip.strip() for ip in self.ADMIN_IP_ALLOWLIST.split(",") if ip.strip()]
|
||||
|
||||
# ===== 极光 =====
|
||||
# ===== 极光一键登录 / 短信 =====
|
||||
JG_APP_KEY: str = ""
|
||||
JG_MASTER_SECRET: str = ""
|
||||
JG_PRIVATE_KEY_PATH: str = "./secrets/jverify_rsa_private.pem"
|
||||
JG_VERIFY_ENDPOINT: str = "https://api.verification.jpush.cn/v1/web/loginTokenVerify"
|
||||
JG_REQUEST_TIMEOUT_SEC: int = 15
|
||||
|
||||
# 无障碍保护存活监控后台任务(pull 后置检测;本期不接推送)
|
||||
# ===== 厂商直推(无障碍保护存活告警)=====
|
||||
ANDROID_PACKAGE_NAME: str = "com.jishisongfu.shaguabijia"
|
||||
PUSH_REQUEST_TIMEOUT_SEC: int = 15
|
||||
PUSH_TIME_TO_LIVE_SEC: int = 86400
|
||||
|
||||
HONOR_PUSH_APP_ID: str = ""
|
||||
HONOR_PUSH_CLIENT_ID: str = ""
|
||||
HONOR_PUSH_CLIENT_SECRET: str = ""
|
||||
HONOR_PUSH_TOKEN_ENDPOINT: str = "https://iam.developer.honor.com/auth/token"
|
||||
HONOR_PUSH_SEND_ENDPOINT_TEMPLATE: str = (
|
||||
"https://push-api.cloud.honor.com/api/v1/{app_id}/sendMessage"
|
||||
)
|
||||
|
||||
VIVO_PUSH_APP_ID: str = ""
|
||||
VIVO_PUSH_APP_KEY: str = ""
|
||||
VIVO_PUSH_APP_SECRET: str = ""
|
||||
VIVO_PUSH_AUTH_ENDPOINT: str = "https://api-push.vivo.com.cn/message/auth"
|
||||
VIVO_PUSH_SEND_ENDPOINT: str = "https://api-push.vivo.com.cn/message/send"
|
||||
VIVO_PUSH_MODE: int = 1 # 0=正式推送,1=测试推送(未上架 vivo 时用)
|
||||
VIVO_PUSH_NOTIFY_TYPE: int = 4 # 1=无,2=响铃,3=振动,4=响铃+振动
|
||||
VIVO_PUSH_CATEGORY: str = "DEVICE_REMINDER"
|
||||
|
||||
XIAOMI_PUSH_APP_SECRET: str = ""
|
||||
XIAOMI_PUSH_SEND_ENDPOINT: str = "https://api.xmpush.xiaomi.com/v3/message/regid"
|
||||
XIAOMI_PUSH_CHANNEL_ID: str = ""
|
||||
XIAOMI_PUSH_TEMPLATE_ID: str = ""
|
||||
XIAOMI_PUSH_TEMPLATE_TITLE: str = ""
|
||||
XIAOMI_PUSH_TEMPLATE_DESCRIPTION: str = ""
|
||||
XIAOMI_PUSH_TEMPLATE_PARAM_JSON: str = ""
|
||||
|
||||
OPPO_PUSH_APP_KEY: str = ""
|
||||
OPPO_PUSH_MASTER_SECRET: str = ""
|
||||
OPPO_PUSH_AUTH_ENDPOINT: str = "https://api.push.oppomobile.com/server/v1/auth"
|
||||
OPPO_PUSH_SEND_ENDPOINT: str = (
|
||||
"https://api.push.oppomobile.com/server/v1/message/notification/unicast"
|
||||
)
|
||||
|
||||
# 无障碍保护存活监控后台任务(推送 + pull 后置兜底)
|
||||
HEARTBEAT_MONITOR_ENABLED: bool = True # 总开关
|
||||
HEARTBEAT_TIMEOUT_MINUTES: int = 10 # 多久没心跳算掉线(≈3 个客户端心跳周期)
|
||||
HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期
|
||||
@@ -113,6 +150,21 @@ class Settings(BaseSettings):
|
||||
"""美团 CPS 凭证齐全(缺则接口返空,而非 502)。"""
|
||||
return bool(self.MT_CPS_APP_KEY and self.MT_CPS_APP_SECRET)
|
||||
|
||||
# ===== 京东联盟 CPS =====
|
||||
# app_key/app_secret 来自京东联盟应用;site_id 是推广管理里的 APP/网站 ID;
|
||||
# auth_key 是工具商授权 key,自有应用查询可留空。
|
||||
JD_UNION_APP_KEY: str = ""
|
||||
JD_UNION_APP_SECRET: str = ""
|
||||
JD_UNION_SITE_ID: str = ""
|
||||
JD_UNION_AUTH_KEY: str = ""
|
||||
JD_UNION_GATEWAY: str = "https://api.jd.com/routerjson"
|
||||
JD_UNION_TIMEOUT_SEC: int = 15
|
||||
|
||||
@property
|
||||
def jd_union_configured(self) -> bool:
|
||||
"""京东联盟订单查询凭证齐全。"""
|
||||
return bool(self.JD_UNION_APP_KEY and self.JD_UNION_APP_SECRET)
|
||||
|
||||
# ===== 微信服务号(网页授权) =====
|
||||
# CPS 落地页在微信内拿用户 openid(base 静默)/昵称头像(userinfo),做用户级群统计。
|
||||
# ⚠️ 区别于 WECHAT_APP_ID(那是 App 移动应用,用于微信支付);这是【已认证服务号】。
|
||||
@@ -272,7 +324,7 @@ class Settings(BaseSettings):
|
||||
return self.APP_ENV == "prod"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _enforce_prod_secrets(self) -> "Settings":
|
||||
def _enforce_prod_secrets(self) -> Settings:
|
||||
"""prod 下强校验 JWT secret,弱/默认/空即启动报错(fail-fast,挡住 token 被伪造)。
|
||||
|
||||
只校验两个签发凭证:App 用户的 JWT_SECRET_KEY、后台的 ADMIN_JWT_SECRET——它们沿用默认值
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""无障碍保护存活监控后台任务。
|
||||
|
||||
周期扫描「曾经保护过、当前 alive、心跳超时」的设备 = App 被彻底杀掉/无障碍已停(心跳断了),
|
||||
**命中即在服务器终端打印告警**(本期先不接推送,工程量大,用终端打印代替真实通知);并把状态机
|
||||
**命中即在服务器终端打印告警并尝试厂商直推**;并把状态机
|
||||
推进到 notified 防每轮重复打印(心跳恢复时由 repositories.device.touch_heartbeat 重置回 alive)。
|
||||
结构仿 withdraw_reconcile_worker(单实例锁 + asyncio 轮询 + 优雅退出)。
|
||||
|
||||
@@ -22,6 +22,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal
|
||||
from app.integrations import vendor_push
|
||||
from app.repositories import device as device_repo
|
||||
|
||||
logger = logging.getLogger("shagua.heartbeat_monitor")
|
||||
@@ -71,32 +72,66 @@ def _silent_seconds(last: datetime | None) -> int | None:
|
||||
"""距上次心跳的秒数(兼容 sqlite 取回的 naive datetime)。"""
|
||||
if last is None:
|
||||
return None
|
||||
ref = datetime.now(timezone.utc) if last.tzinfo is not None else datetime.utcnow()
|
||||
ref = datetime.now(timezone.utc) if last.tzinfo is not None else datetime.utcnow() # noqa: UP017
|
||||
return int((ref - last).total_seconds())
|
||||
|
||||
|
||||
def _scan_once(timeout_minutes: int) -> dict:
|
||||
"""扫描一轮:找出心跳超时(App 被彻底杀掉/无障碍已停)的设备,在**服务器终端打印**告警代替真实推送。
|
||||
"""扫描一轮:找出心跳超时(App 被彻底杀掉/无障碍已停)的设备并召回。
|
||||
|
||||
本期不接推送(极光/厂商通道工程量大),只做服务端掉线检测:命中即 logger.warning 打印到终端,
|
||||
并把状态机推进到 notified 防每轮重复打印(心跳恢复时 touch_heartbeat 会重置回 alive)。
|
||||
有 push_vendor + push_token 时先发厂商直推,无 token 或推送失败时仍置
|
||||
kill_alert_pending,客户端下次进 App 继续走后置提醒兜底。
|
||||
"""
|
||||
notified = 0
|
||||
pushed = 0
|
||||
push_failed = 0
|
||||
with SessionLocal() as db:
|
||||
overdue = device_repo.list_overdue(db, timeout_minutes=timeout_minutes)
|
||||
for device in overdue:
|
||||
silent = _silent_seconds(device.last_heartbeat_at)
|
||||
logger.warning(
|
||||
"[掉线检测] user_id=%s device_id=%s 已 %s 秒无心跳(阈值 %d 分钟)"
|
||||
" → 判定 App 已被杀/无障碍已停。【已置 kill_alert_pending: 用户下次进 App 将弹「开启自启动」引导(后置检测);推送本期未接】",
|
||||
" → 判定 App 已被杀/无障碍已停。",
|
||||
device.user_id,
|
||||
device.device_id,
|
||||
silent if silent is not None else "?",
|
||||
timeout_minutes,
|
||||
)
|
||||
if device.push_vendor and device.push_token:
|
||||
try:
|
||||
vendor_push.send_accessibility_disabled(
|
||||
device.push_vendor,
|
||||
device.push_token,
|
||||
)
|
||||
pushed += 1
|
||||
logger.info(
|
||||
"[掉线检测] push sent user_id=%s device_id=%s vendor=%s",
|
||||
device.user_id,
|
||||
device.device_id,
|
||||
device.push_vendor,
|
||||
)
|
||||
except vendor_push.VendorPushError as e:
|
||||
push_failed += 1
|
||||
logger.warning(
|
||||
"[掉线检测] push failed user_id=%s device_id=%s error=%s",
|
||||
device.user_id,
|
||||
device.device_id,
|
||||
e,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"[掉线检测] device has no push vendor/token, skip push user_id=%s device_id=%s",
|
||||
device.user_id,
|
||||
device.device_id,
|
||||
)
|
||||
device_repo.mark_notified(db, device_id_pk=device.id)
|
||||
notified += 1
|
||||
return {"checked": len(overdue), "notified": notified}
|
||||
return {
|
||||
"checked": len(overdue),
|
||||
"notified": notified,
|
||||
"pushed": pushed,
|
||||
"push_failed": push_failed,
|
||||
}
|
||||
|
||||
|
||||
async def _run_loop() -> None:
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""透传到 pricebot 的共享 httpx.AsyncClient 单例。
|
||||
|
||||
为什么不能每请求新建(coupon.py / compare.py 老写法 async with httpx.AsyncClient(...)):
|
||||
① 每次构造都重建一套 SSL 上下文(httpx.create_ssl_context 加载 certifi CA),实测
|
||||
~1s+/次;而 pricebot 是纯 http 透传,根本用不到 TLS → 纯浪费,且每帧重交一次。
|
||||
② trust_env 默认 True 会读进程 HTTP_PROXY,把 http://localhost:8000 这条本地透传整个
|
||||
塞进本机代理(如 Clash 7897),恒定再多几秒。
|
||||
单例:启动只建一次(SSL/连接池一次性),keep-alive 复用 TCP,每帧降到个位数 ms。
|
||||
trust_env=False:对齐 integrations/meituan.py 的既有约定,不被进程代理误导,直连 pricebot。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
def get_pricebot_client() -> httpx.AsyncClient:
|
||||
"""取透传单例。lifespan 启动会预热;未预热(如测试态)懒建兜底。
|
||||
|
||||
超时不在此固化(coupon 30s / compare 60s 不同),由调用点 client.post(timeout=...) 传。
|
||||
懒建无 await,asyncio 单线程下不会有并发竞态。
|
||||
"""
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = httpx.AsyncClient(trust_env=False)
|
||||
return _client
|
||||
|
||||
|
||||
async def aclose_pricebot_client() -> None:
|
||||
"""lifespan 关停时调,优雅关连接池。"""
|
||||
global _client
|
||||
if _client is not None:
|
||||
await _client.aclose()
|
||||
_client = None
|
||||
@@ -0,0 +1,163 @@
|
||||
"""京东联盟 OpenAPI 客户端。
|
||||
|
||||
当前只接数据大盘需要的订单明细接口:
|
||||
`jd.union.open.order.row.query`。京东要求订单查询时间窗最长 1 小时,
|
||||
调用方负责切窗分页。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BEIJING = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
class JdUnionError(RuntimeError):
|
||||
"""京东联盟 API 调用失败。"""
|
||||
|
||||
|
||||
def _parse_json_maybe(value: Any) -> Any:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return value
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
|
||||
|
||||
def _sign(params: dict[str, Any], secret: str) -> str:
|
||||
pieces = [secret]
|
||||
for key in sorted(k for k in params if k != "sign"):
|
||||
value = params[key]
|
||||
if value is None:
|
||||
continue
|
||||
pieces.append(f"{key}{value}")
|
||||
pieces.append(secret)
|
||||
raw = "".join(pieces)
|
||||
return hashlib.md5(raw.encode("utf-8")).hexdigest().upper()
|
||||
|
||||
|
||||
def _unwrap_response(data: dict[str, Any]) -> dict[str, Any]:
|
||||
if "error_response" in data:
|
||||
err = data["error_response"] or {}
|
||||
msg = err.get("zh_desc") or err.get("en_desc") or err.get("msg") or err
|
||||
raise JdUnionError(f"京东 API 错误: {msg}")
|
||||
|
||||
body: Any = data
|
||||
for key, value in data.items():
|
||||
if key.endswith("_responce") or key.endswith("_response"):
|
||||
body = value
|
||||
break
|
||||
|
||||
body = _parse_json_maybe(body)
|
||||
if not isinstance(body, dict):
|
||||
raise JdUnionError("京东 API 返回格式异常")
|
||||
|
||||
result = body.get("queryResult", body.get("result", body))
|
||||
result = _parse_json_maybe(result)
|
||||
if not isinstance(result, dict):
|
||||
raise JdUnionError("京东 API 业务结果格式异常")
|
||||
|
||||
code = str(result.get("code", result.get("resultCode", "200")))
|
||||
if code not in {"0", "200"}:
|
||||
msg = result.get("message") or result.get("msg") or result.get("resultMsg") or result
|
||||
raise JdUnionError(f"京东 API 业务错误: {msg}")
|
||||
return result
|
||||
|
||||
|
||||
def call(method: str, payload: dict[str, Any], *, version: str = "1.0") -> dict[str, Any]:
|
||||
if not settings.jd_union_configured:
|
||||
raise JdUnionError("京东联盟凭证未配置")
|
||||
|
||||
biz_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
params: dict[str, Any] = {
|
||||
"method": method,
|
||||
"app_key": settings.JD_UNION_APP_KEY,
|
||||
"timestamp": datetime.now(_BEIJING).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"format": "json",
|
||||
"v": version,
|
||||
"sign_method": "md5",
|
||||
"360buy_param_json": biz_json,
|
||||
}
|
||||
params["sign"] = _sign(params, settings.JD_UNION_APP_SECRET)
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=settings.JD_UNION_TIMEOUT_SEC, trust_env=False) as client:
|
||||
resp = client.post(settings.JD_UNION_GATEWAY, data=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.HTTPError as e:
|
||||
raise JdUnionError(f"京东 API 网络错误: {e}") from e
|
||||
except json.JSONDecodeError as e:
|
||||
raise JdUnionError("京东 API 返回非 JSON") from e
|
||||
|
||||
return _unwrap_response(data)
|
||||
|
||||
|
||||
def _extract_rows(result: dict[str, Any]) -> tuple[list[dict[str, Any]], bool]:
|
||||
payload = _parse_json_maybe(result.get("data", result.get("result", result)))
|
||||
has_more = bool(result.get("hasMore") or result.get("has_more"))
|
||||
|
||||
if isinstance(payload, dict):
|
||||
for key in ("orderRowResp", "orderRows", "orderList", "orders", "list", "rows"):
|
||||
rows = _parse_json_maybe(payload.get(key))
|
||||
if isinstance(rows, list):
|
||||
return [r for r in rows if isinstance(r, dict)], bool(
|
||||
payload.get("hasMore") or payload.get("has_more") or has_more
|
||||
)
|
||||
return [], bool(payload.get("hasMore") or payload.get("has_more") or has_more)
|
||||
if isinstance(payload, list):
|
||||
return [r for r in payload if isinstance(r, dict)], has_more
|
||||
return [], has_more
|
||||
|
||||
|
||||
def query_order_rows(
|
||||
*,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
query_time_type: int = 3,
|
||||
page_index: int = 1,
|
||||
page_size: int = 200,
|
||||
) -> dict[str, Any]:
|
||||
"""查询京东 CPS 订单行。
|
||||
|
||||
query_time_type: 1 下单时间, 2 完成时间, 3 更新时间。
|
||||
start_time/end_time 用北京时间展示给京东;调用方需保证窗口不超过 1 小时。
|
||||
"""
|
||||
start_bj = start_time.astimezone(_BEIJING)
|
||||
end_bj = end_time.astimezone(_BEIJING)
|
||||
if end_bj <= start_bj:
|
||||
return {"rows": [], "has_more": False}
|
||||
if end_bj - start_bj > timedelta(hours=1):
|
||||
raise JdUnionError("京东订单查询单次时间窗不能超过 1 小时")
|
||||
|
||||
order_req: dict[str, Any] = {
|
||||
"pageIndex": page_index,
|
||||
"pageSize": min(max(page_size, 1), 200),
|
||||
"type": query_time_type,
|
||||
"startTime": start_bj.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"endTime": end_bj.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
if settings.JD_UNION_AUTH_KEY:
|
||||
order_req["key"] = settings.JD_UNION_AUTH_KEY
|
||||
result = call("jd.union.open.order.row.query", {"orderReq": order_req})
|
||||
rows, has_more = _extract_rows(result)
|
||||
logger.info(
|
||||
"jd.union.open.order.row.query fetched rows=%s page=%s has_more=%s",
|
||||
len(rows),
|
||||
page_index,
|
||||
has_more,
|
||||
)
|
||||
return {"rows": rows, "has_more": has_more, "raw": result}
|
||||
@@ -0,0 +1,380 @@
|
||||
"""厂商直推集成。
|
||||
|
||||
服务端不再经由 JPush Push API 发送无障碍召回通知,而是按客户端上报的
|
||||
push_vendor + push_token 分发到各手机厂商的服务端 API。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger("shagua.vendor_push")
|
||||
|
||||
TYPE_ACCESSIBILITY_DISABLED = "accessibility_disabled"
|
||||
SUPPORTED_VENDORS = frozenset({"honor", "vivo", "xiaomi", "oppo"})
|
||||
|
||||
|
||||
class VendorPushError(Exception):
|
||||
"""厂商推送调用失败。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CachedToken:
|
||||
value: str
|
||||
expires_at: float
|
||||
|
||||
|
||||
_token_cache: dict[str, _CachedToken] = {}
|
||||
|
||||
|
||||
def normalize_vendor(push_vendor: str | None) -> str | None:
|
||||
if not push_vendor:
|
||||
return None
|
||||
vendor = push_vendor.strip().lower()
|
||||
aliases = {
|
||||
"hihonor": "honor",
|
||||
"荣耀": "honor",
|
||||
"mi": "xiaomi",
|
||||
"小米": "xiaomi",
|
||||
"oneplus": "oppo",
|
||||
"realme": "oppo",
|
||||
}
|
||||
return aliases.get(vendor, vendor)
|
||||
|
||||
|
||||
def send_accessibility_disabled(
|
||||
push_vendor: str,
|
||||
push_token: str,
|
||||
*,
|
||||
title: str = "保护已关闭",
|
||||
alert: str = "傻瓜比价的无障碍保护被关了,点此重新开启,继续帮你自动比价省钱。",
|
||||
) -> dict[str, Any]:
|
||||
"""按厂商 token 向单台设备发送无障碍掉线通知。"""
|
||||
vendor = normalize_vendor(push_vendor)
|
||||
token = push_token.strip() if push_token else ""
|
||||
if not vendor or vendor not in SUPPORTED_VENDORS:
|
||||
raise VendorPushError(f"unsupported push vendor: {push_vendor}")
|
||||
if not token:
|
||||
raise VendorPushError("push token is empty")
|
||||
|
||||
dispatch: dict[str, Callable[[str, str, str], dict[str, Any]]] = {
|
||||
"honor": _send_honor,
|
||||
"vivo": _send_vivo,
|
||||
"xiaomi": _send_xiaomi,
|
||||
"oppo": _send_oppo,
|
||||
}
|
||||
return dispatch[vendor](token, title, alert)
|
||||
|
||||
|
||||
def _extras() -> dict[str, str]:
|
||||
return {"type": TYPE_ACCESSIBILITY_DISABLED}
|
||||
|
||||
|
||||
def _require(value: str, name: str) -> str:
|
||||
if not value:
|
||||
raise VendorPushError(f"{name} not configured")
|
||||
return value
|
||||
|
||||
|
||||
def _request_json(
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
expected_status: tuple[int, ...] = (200,),
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
resp = httpx.request(
|
||||
method,
|
||||
url,
|
||||
timeout=settings.PUSH_REQUEST_TIMEOUT_SEC,
|
||||
**kwargs,
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise VendorPushError(f"push http error: {e}") from e
|
||||
|
||||
if resp.status_code not in expected_status:
|
||||
logger.error("vendor push http failed url=%s http=%s body=%s", url, resp.status_code, resp.text[:500])
|
||||
raise VendorPushError(f"push http {resp.status_code}")
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError as e:
|
||||
raise VendorPushError(f"push invalid json: {resp.text[:200]}") from e
|
||||
|
||||
|
||||
def _request_form(
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
expected_status: tuple[int, ...] = (200,),
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
resp = httpx.request(
|
||||
method,
|
||||
url,
|
||||
timeout=settings.PUSH_REQUEST_TIMEOUT_SEC,
|
||||
**kwargs,
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise VendorPushError(f"push http error: {e}") from e
|
||||
|
||||
if resp.status_code not in expected_status:
|
||||
logger.error("vendor push http failed url=%s http=%s body=%s", url, resp.status_code, resp.text[:500])
|
||||
raise VendorPushError(f"push http {resp.status_code}")
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError as e:
|
||||
raise VendorPushError(f"push invalid json: {resp.text[:200]}") from e
|
||||
|
||||
|
||||
def _cache_get(key: str) -> str | None:
|
||||
cached = _token_cache.get(key)
|
||||
if cached and cached.expires_at > time.time() + 60:
|
||||
return cached.value
|
||||
return None
|
||||
|
||||
|
||||
def _cache_put(key: str, value: str, expires_in: int | float | None) -> str:
|
||||
ttl = int(expires_in or 3600)
|
||||
_token_cache[key] = _CachedToken(value=value, expires_at=time.time() + max(60, ttl - 60))
|
||||
return value
|
||||
|
||||
|
||||
def _honor_access_token() -> str:
|
||||
cache_key = "honor"
|
||||
cached = _cache_get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
client_id = _require(settings.HONOR_PUSH_CLIENT_ID, "HONOR_PUSH_CLIENT_ID")
|
||||
client_secret = _require(settings.HONOR_PUSH_CLIENT_SECRET, "HONOR_PUSH_CLIENT_SECRET")
|
||||
data = _request_form(
|
||||
"POST",
|
||||
settings.HONOR_PUSH_TOKEN_ENDPOINT,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
token = data.get("access_token")
|
||||
if not token:
|
||||
raise VendorPushError(f"honor auth failed: {data}")
|
||||
return _cache_put(cache_key, str(token), data.get("expires_in"))
|
||||
|
||||
|
||||
def _send_honor(token: str, title: str, alert: str) -> dict[str, Any]:
|
||||
app_id = _require(settings.HONOR_PUSH_APP_ID, "HONOR_PUSH_APP_ID")
|
||||
access_token = _honor_access_token()
|
||||
payload = {
|
||||
"data": json.dumps(_extras(), ensure_ascii=False),
|
||||
"notification": {"title": title, "body": alert},
|
||||
"android": {
|
||||
"ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s",
|
||||
"targetUserType": 1,
|
||||
"notification": {
|
||||
"title": title,
|
||||
"body": alert,
|
||||
"clickAction": {"type": 3},
|
||||
"importance": "NORMAL",
|
||||
},
|
||||
},
|
||||
"token": [token],
|
||||
}
|
||||
data = _request_json(
|
||||
"POST",
|
||||
settings.HONOR_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
|
||||
json=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json; charset=UTF-8",
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"timestamp": str(int(time.time() * 1000)),
|
||||
},
|
||||
)
|
||||
code = data.get("code")
|
||||
if code is not None and int(code) != 200:
|
||||
raise VendorPushError(f"honor push failed: {data}")
|
||||
return data
|
||||
|
||||
|
||||
def _vivo_auth_token() -> str:
|
||||
cache_key = "vivo"
|
||||
cached = _cache_get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
app_id = _require(settings.VIVO_PUSH_APP_ID, "VIVO_PUSH_APP_ID")
|
||||
app_key = _require(settings.VIVO_PUSH_APP_KEY, "VIVO_PUSH_APP_KEY")
|
||||
app_secret = _require(settings.VIVO_PUSH_APP_SECRET, "VIVO_PUSH_APP_SECRET")
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
sign = hashlib.md5(f"{app_id}{app_key}{timestamp}{app_secret}".encode()).hexdigest() # noqa: S324
|
||||
data = _request_json(
|
||||
"POST",
|
||||
settings.VIVO_PUSH_AUTH_ENDPOINT,
|
||||
json={
|
||||
"appId": app_id,
|
||||
"appKey": app_key,
|
||||
"timestamp": timestamp,
|
||||
"sign": sign,
|
||||
},
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
if int(data.get("result", -1)) != 0:
|
||||
raise VendorPushError(f"vivo auth failed: {data}")
|
||||
token = data.get("authToken")
|
||||
if not token:
|
||||
raise VendorPushError(f"vivo auth missing authToken: {data}")
|
||||
return _cache_put(cache_key, str(token), 24 * 3600)
|
||||
|
||||
|
||||
def _send_vivo(token: str, title: str, alert: str) -> dict[str, Any]:
|
||||
app_id = _require(settings.VIVO_PUSH_APP_ID, "VIVO_PUSH_APP_ID")
|
||||
auth_token = _vivo_auth_token()
|
||||
body: dict[str, Any] = {
|
||||
"appId": app_id,
|
||||
"regId": token,
|
||||
"notifyType": settings.VIVO_PUSH_NOTIFY_TYPE,
|
||||
"title": title,
|
||||
"content": alert,
|
||||
"timeToLive": settings.PUSH_TIME_TO_LIVE_SEC,
|
||||
"skipType": 1,
|
||||
"requestId": uuid.uuid4().hex,
|
||||
"pushMode": settings.VIVO_PUSH_MODE,
|
||||
"clientCustomMap": _extras(),
|
||||
}
|
||||
if settings.VIVO_PUSH_CATEGORY:
|
||||
body["category"] = settings.VIVO_PUSH_CATEGORY
|
||||
data = _request_json(
|
||||
"POST",
|
||||
settings.VIVO_PUSH_SEND_ENDPOINT,
|
||||
json=body,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"authToken": auth_token,
|
||||
},
|
||||
)
|
||||
if int(data.get("result", -1)) != 0:
|
||||
raise VendorPushError(f"vivo push failed: {data}")
|
||||
return data
|
||||
|
||||
|
||||
def _send_xiaomi(token: str, title: str, alert: str) -> dict[str, Any]:
|
||||
app_secret = _require(settings.XIAOMI_PUSH_APP_SECRET, "XIAOMI_PUSH_APP_SECRET")
|
||||
message_title = settings.XIAOMI_PUSH_TEMPLATE_TITLE.strip() or title
|
||||
message_description = settings.XIAOMI_PUSH_TEMPLATE_DESCRIPTION.strip() or alert
|
||||
body = {
|
||||
"registration_id": token,
|
||||
"restricted_package_name": settings.ANDROID_PACKAGE_NAME,
|
||||
"title": message_title,
|
||||
"description": message_description,
|
||||
"payload": json.dumps(_extras(), ensure_ascii=False),
|
||||
"pass_through": "0",
|
||||
"notify_type": "-1",
|
||||
"time_to_live": str(settings.PUSH_TIME_TO_LIVE_SEC * 1000),
|
||||
"extra.notify_effect": "1",
|
||||
}
|
||||
if settings.XIAOMI_PUSH_CHANNEL_ID:
|
||||
body["extra.channel_id"] = settings.XIAOMI_PUSH_CHANNEL_ID.strip()
|
||||
if settings.XIAOMI_PUSH_TEMPLATE_ID:
|
||||
body["extra.template_id"] = settings.XIAOMI_PUSH_TEMPLATE_ID.strip()
|
||||
if settings.XIAOMI_PUSH_TEMPLATE_PARAM_JSON:
|
||||
body["extra.template_param"] = _xiaomi_template_param(title, alert)
|
||||
data = _request_form(
|
||||
"POST",
|
||||
settings.XIAOMI_PUSH_SEND_ENDPOINT,
|
||||
data=body,
|
||||
headers={"Authorization": f"key={app_secret}"},
|
||||
)
|
||||
code = data.get("code")
|
||||
if code not in (0, "0", None):
|
||||
raise VendorPushError(f"xiaomi push failed: {data}")
|
||||
if str(data.get("result", "ok")).lower() not in ("ok", "success"):
|
||||
raise VendorPushError(f"xiaomi push failed: {data}")
|
||||
return data
|
||||
|
||||
|
||||
def _xiaomi_template_param(title: str, alert: str) -> str:
|
||||
rendered = (
|
||||
settings.XIAOMI_PUSH_TEMPLATE_PARAM_JSON
|
||||
.replace("{title}", title)
|
||||
.replace("{alert}", alert)
|
||||
)
|
||||
try:
|
||||
payload = json.loads(rendered)
|
||||
except ValueError as e:
|
||||
raise VendorPushError("XIAOMI_PUSH_TEMPLATE_PARAM_JSON invalid json") from e
|
||||
if not isinstance(payload, dict):
|
||||
raise VendorPushError("XIAOMI_PUSH_TEMPLATE_PARAM_JSON must be a json object")
|
||||
for key, value in payload.items():
|
||||
if not isinstance(key, str) or not isinstance(value, str):
|
||||
raise VendorPushError("xiaomi template params must be string key-value pairs")
|
||||
if not value.strip() or len(value) > 128:
|
||||
raise VendorPushError("xiaomi template param value length must be 1-128")
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _oppo_auth_token() -> str:
|
||||
cache_key = "oppo"
|
||||
cached = _cache_get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
app_key = _require(settings.OPPO_PUSH_APP_KEY, "OPPO_PUSH_APP_KEY")
|
||||
master_secret = _require(settings.OPPO_PUSH_MASTER_SECRET, "OPPO_PUSH_MASTER_SECRET")
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
sign = hashlib.sha256(f"{app_key}{timestamp}{master_secret}".encode()).hexdigest()
|
||||
data = _request_form(
|
||||
"POST",
|
||||
settings.OPPO_PUSH_AUTH_ENDPOINT,
|
||||
data={
|
||||
"app_key": app_key,
|
||||
"timestamp": timestamp,
|
||||
"sign": sign,
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
if int(data.get("code", -1)) != 0:
|
||||
raise VendorPushError(f"oppo auth failed: {data}")
|
||||
token = (data.get("data") or {}).get("auth_token") or data.get("auth_token")
|
||||
if not token:
|
||||
raise VendorPushError(f"oppo auth missing auth_token: {data}")
|
||||
return _cache_put(cache_key, str(token), 24 * 3600)
|
||||
|
||||
|
||||
def _send_oppo(token: str, title: str, alert: str) -> dict[str, Any]:
|
||||
auth_token = _oppo_auth_token()
|
||||
ttl_hours = max(1, min(72, settings.PUSH_TIME_TO_LIVE_SEC // 3600))
|
||||
message = {
|
||||
"target_type": 2,
|
||||
"target_value": token,
|
||||
"notification": {
|
||||
"app_message_id": f"accessibility_disabled_{uuid.uuid4().hex}",
|
||||
"title": title,
|
||||
"content": alert,
|
||||
"click_action_type": 0,
|
||||
"off_line": True,
|
||||
"off_line_ttl": ttl_hours,
|
||||
"action_parameters": json.dumps(_extras(), ensure_ascii=False),
|
||||
},
|
||||
}
|
||||
data = _request_form(
|
||||
"POST",
|
||||
settings.OPPO_PUSH_SEND_ENDPOINT,
|
||||
data={
|
||||
"auth_token": auth_token,
|
||||
"message": json.dumps(message, ensure_ascii=False),
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
if int(data.get("code", -1)) != 0:
|
||||
raise VendorPushError(f"oppo push failed: {data}")
|
||||
return data
|
||||
@@ -0,0 +1,89 @@
|
||||
"""微信开放平台 App 登录集成。
|
||||
|
||||
客户端只把微信 SDK 回调拿到的临时 code 传给服务端;AppSecret、access_token
|
||||
与用户 openid 都留在服务端处理,避免敏感凭证落到 APK 里。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import certifi
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
_API_BASE = "https://api.weixin.qq.com"
|
||||
|
||||
|
||||
class WechatLoginError(Exception):
|
||||
"""微信授权换取用户信息失败。"""
|
||||
|
||||
|
||||
def _ensure_configured() -> None:
|
||||
if not settings.WECHAT_APP_ID or not settings.WECHAT_APP_SECRET:
|
||||
raise WechatLoginError("wechat app not configured")
|
||||
|
||||
|
||||
def _http_client() -> httpx.Client:
|
||||
return httpx.Client(verify=certifi.where())
|
||||
|
||||
|
||||
def code_to_userinfo(code: str) -> dict:
|
||||
"""用授权 code 换 openid/unionid,尽力补昵称头像。
|
||||
|
||||
返回字段:
|
||||
- openid: 同一开放平台移动应用下的用户唯一标识
|
||||
- unionid: 同一开放平台主体下的用户唯一标识,微信可能不返回
|
||||
- nickname/avatar_url: sns/userinfo 可用时返回,失败不阻断登录
|
||||
- raw: userinfo 原始响应,便于排障
|
||||
"""
|
||||
_ensure_configured()
|
||||
try:
|
||||
with _http_client() as client:
|
||||
token_resp = client.get(
|
||||
f"{_API_BASE}/sns/oauth2/access_token",
|
||||
params={
|
||||
"appid": settings.WECHAT_APP_ID,
|
||||
"secret": settings.WECHAT_APP_SECRET,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
|
||||
)
|
||||
token_data = token_resp.json()
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise WechatLoginError("微信授权请求失败,请稍后重试") from e
|
||||
if "openid" not in token_data or "access_token" not in token_data:
|
||||
raise WechatLoginError(f"微信授权失败: {token_data.get('errmsg', token_data)}")
|
||||
|
||||
openid = token_data["openid"]
|
||||
unionid = token_data.get("unionid")
|
||||
nickname = None
|
||||
avatar_url = None
|
||||
raw: dict = {}
|
||||
|
||||
try:
|
||||
with _http_client() as client:
|
||||
info_resp = client.get(
|
||||
f"{_API_BASE}/sns/userinfo",
|
||||
params={
|
||||
"access_token": token_data["access_token"],
|
||||
"openid": openid,
|
||||
"lang": "zh_CN",
|
||||
},
|
||||
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
|
||||
)
|
||||
raw = info_resp.json()
|
||||
if "errcode" not in raw:
|
||||
nickname = raw.get("nickname") or None
|
||||
avatar_url = raw.get("headimgurl") or None
|
||||
unionid = unionid or raw.get("unionid")
|
||||
except Exception: # noqa: BLE001
|
||||
# openid 已拿到即可登录;昵称头像只是展示增强。
|
||||
pass
|
||||
|
||||
return {
|
||||
"openid": openid,
|
||||
"unionid": unionid,
|
||||
"nickname": nickname,
|
||||
"avatar_url": avatar_url,
|
||||
"raw": raw,
|
||||
}
|
||||
@@ -49,6 +49,7 @@ from app.core.daily_exchange_worker import (
|
||||
stop_daily_exchange_worker,
|
||||
)
|
||||
from app.core.logging import setup_logging
|
||||
from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client
|
||||
from app.core.withdraw_reconcile_worker import (
|
||||
start_withdraw_reconcile_worker,
|
||||
stop_withdraw_reconcile_worker,
|
||||
@@ -68,6 +69,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
settings.APP_DEBUG,
|
||||
settings.DATABASE_URL.split("://", 1)[0],
|
||||
)
|
||||
get_pricebot_client() # 预热透传 client:把建 SSL 上下文的一次性成本付在启动,首个领券请求即热
|
||||
reconcile_task = start_withdraw_reconcile_worker()
|
||||
heartbeat_task = start_heartbeat_monitor()
|
||||
daily_exchange_task = start_daily_exchange_worker()
|
||||
@@ -77,6 +79,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
await stop_heartbeat_monitor(heartbeat_task)
|
||||
await stop_withdraw_reconcile_worker(reconcile_task)
|
||||
await stop_daily_exchange_worker(daily_exchange_task)
|
||||
await aclose_pricebot_client()
|
||||
logger.info("shutting down")
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.models.coupon_state import ( # noqa: F401
|
||||
CouponDailyCompletion,
|
||||
CouponPromptEngagement,
|
||||
)
|
||||
from app.models.cps_order import CpsOrder # noqa: F401
|
||||
from app.models.feedback import Feedback # noqa: F401
|
||||
from app.models.invite import InviteRelation # noqa: F401
|
||||
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
|
||||
|
||||
+22
-4
@@ -1,11 +1,13 @@
|
||||
"""CPS 对账订单(cps_order)。
|
||||
|
||||
从美团联盟 query_order 按时间窗拉回、按 sid 归群的订单明细。字段对齐 query_order
|
||||
从联盟 API 按时间窗拉回、按平台落库的 CPS 订单明细。字段最初对齐美团 query_order,
|
||||
后续兼容京东订单报表:
|
||||
实测返回:
|
||||
- payPrice / profit 是「元」字符串 → 入库统一转「分」(与全站口径一致)
|
||||
- payTime / updateTime 是秒级时间戳 → 入库转 tz-aware datetime
|
||||
- status: 2付款 3完成 4取消 5风控 6结算(取消/风控不计佣金)
|
||||
order_id 全局唯一,reconcile 按它 upsert(订单状态会变,重复拉则更新)。
|
||||
order_id 全局唯一,reconcile 按它 upsert(订单状态会变,重复拉则更新)。京东订单用
|
||||
`jd:<row_id>` 前缀避免与美团订单号碰撞。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -22,8 +24,13 @@ class CpsOrder(Base):
|
||||
__tablename__ = "cps_order"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# 美团订单号(加密串),全局唯一,upsert 幂等键。
|
||||
# 平台:meituan / jd。历史数据迁移默认 meituan。
|
||||
platform: Mapped[str] = mapped_column(String(20), default="meituan", index=True, nullable=False)
|
||||
# 平台订单号/行号包装后的全局唯一键,upsert 幂等。
|
||||
order_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
# 平台原始订单号/行号。京东一笔订单多 SKU 时可按行号区分。
|
||||
external_order_id: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
|
||||
external_row_id: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
|
||||
# 渠道追踪位 = 群 sid(历史无 sid 订单为空)。按它归群聚合。
|
||||
sid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
act_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
@@ -35,11 +42,21 @@ class CpsOrder(Base):
|
||||
commission_rate: Mapped[str | None] = mapped_column(String(16), nullable=True) # "300"=3% "10"=0.1%
|
||||
refund_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
refund_profit_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# 通用佣金拆分。美团只有预估 profit;京东有预估/实际佣金。
|
||||
estimated_commission_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
actual_commission_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# 美团订单状态: 2付款 3完成 4取消 5风控 6结算
|
||||
mt_status: Mapped[str | None] = mapped_column(String(8), index=True, nullable=True)
|
||||
# 京东订单有效码(validCode),用于判断是否有效/已完成。
|
||||
jd_valid_code: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
|
||||
invalid_reason: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
product_name: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
settle_month: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
site_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
position_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
pid: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
sub_union_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
|
||||
pay_time: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), index=True, nullable=True
|
||||
@@ -59,5 +76,6 @@ class CpsOrder(Base):
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
f"<CpsOrder id={self.id} order_id={self.order_id!r} "
|
||||
f"sid={self.sid!r} status={self.mt_status} profit_cents={self.commission_cents}>"
|
||||
f"platform={self.platform!r} sid={self.sid!r} "
|
||||
f"status={self.mt_status or self.jd_valid_code} profit_cents={self.commission_cents}>"
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""设备表(无障碍保护存活检测 + 极光推送)。
|
||||
"""设备表(无障碍保护存活检测 + 厂商直推)。
|
||||
|
||||
每条 = 一个用户的一台设备(per-install,device_id 由客户端 DeviceId.get() 生成)。
|
||||
客户端的无障碍服务存活时周期上报心跳刷新 last_heartbeat_at;App 前台/登录时上报
|
||||
registration_id(极光推送目标)。后端 heartbeat_monitor_worker 扫描「曾经保护过、
|
||||
现在心跳超时」的设备,通过极光推送提醒用户重开无障碍。
|
||||
push_vendor + push_token(厂商推送目标)。后端 heartbeat_monitor_worker 扫描「曾经保护过、
|
||||
现在心跳超时」的设备,通过厂商直推提醒用户重开无障碍。
|
||||
|
||||
liveness_state 状态机(防刷屏,一次掉线只推一条):
|
||||
unknown → alive(收到 service 心跳)→ silent/notified(扫描发现超时并已推送)
|
||||
@@ -30,7 +30,7 @@ from app.db.base import Base
|
||||
|
||||
class DeviceLiveness(Base):
|
||||
# 表名不叫 device:device 易被当成「设备信息(品牌/型号/系统)」表;本表实为**无障碍存活监控状态**
|
||||
# (心跳 last_heartbeat_at + liveness_state + kill_alert_pending + 推送目标 registration_id),故名 device_liveness。
|
||||
# (心跳 last_heartbeat_at + liveness_state + kill_alert_pending + 厂商推送目标),故名 device_liveness。
|
||||
__tablename__ = "device_liveness"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "device_id", name="uq_device_liveness_user_device"),
|
||||
@@ -42,8 +42,12 @@ class DeviceLiveness(Base):
|
||||
)
|
||||
# 客户端 DeviceId.get() 生成的 per-install id(如 device_Pixel_ab12cd34)
|
||||
device_id: Mapped[str] = mapped_column(String(128), index=True, nullable=False)
|
||||
# 极光推送 registration id;拿到才填(JCollectionAuth 同意后才下发)
|
||||
# 旧极光推送 registration id,仅为兼容历史客户端/数据保留;新链路使用 push_vendor + push_token。
|
||||
registration_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 厂商推送类型:honor/vivo/xiaomi/oppo 等;客户端按实际 SDK token 来源上报。
|
||||
push_vendor: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# 厂商 push token / regId / registration_id;不同厂商命名不同,后端统一存这里。
|
||||
push_token: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
platform: Mapped[str] = mapped_column(String(16), nullable=False, default="android")
|
||||
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
|
||||
|
||||
@@ -53,11 +53,16 @@ def create_ecpm_record(
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
if ad_session_id:
|
||||
existing = find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
raise
|
||||
# 撞唯一约束 uq_ad_ecpm_record_session(全局按 ad_session_id、不含 user_id):并发同会话重复上报,
|
||||
# 或同一 ad_session_id 已被先到的上报占用。本接口 fire-and-forget、best-effort —— 丢一条不影响业务
|
||||
# (穿山甲后台才是结算权威),绝不向客户端抛 500。兜底查找须与唯一约束**同口径**(只按 ad_session_id、
|
||||
# 不带 user_id):否则不同 user 上报了同一 ad_session_id 时,带 user_id 的查找会漏掉那条别人的记录 →
|
||||
# 旧逻辑在此 raise 成 500(本应静默吞掉)。
|
||||
existing = _find_by_session_global(db, ad_session_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
# 极少:rollback 后既存记录又查不到(并发删除 / 竞态)。吞掉、返回未入库的内存对象(调用方不读返回值)。
|
||||
return rec
|
||||
db.refresh(rec)
|
||||
return rec
|
||||
|
||||
@@ -76,6 +81,20 @@ def find_by_session(
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _find_by_session_global(db: Session, ad_session_id: str | None) -> AdEcpmRecord | None:
|
||||
"""按 ad_session_id **全局**查找(与唯一约束 uq_ad_ecpm_record_session 同口径,不含 user_id)。
|
||||
|
||||
仅 create_ecpm_record 撞约束后兜底用:此时撞的是全局会话约束,既存记录可能属于**另一个 user**,
|
||||
带 user_id 的 find_by_session 会漏掉它、导致误判「查无 → raise 500」。其它业务查「某 user 的某次
|
||||
展示 eCPM」仍用 find_by_session(带 user_id,语义更准),不走这里。
|
||||
"""
|
||||
if not ad_session_id:
|
||||
return None
|
||||
return db.execute(
|
||||
select(AdEcpmRecord).where(AdEcpmRecord.ad_session_id == ad_session_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def count_today(db: Session, user_id: int) -> int:
|
||||
"""该用户今日(北京时间)上报的 eCPM 条数,排查/对账辅助用。"""
|
||||
return db.execute(
|
||||
|
||||
@@ -73,16 +73,19 @@ def grant_feed_reward(
|
||||
app_env: str | None = None,
|
||||
our_code_id: str | None = None,
|
||||
aborted: bool = False,
|
||||
display_coin: int = 0,
|
||||
) -> AdFeedRewardRecord:
|
||||
"""**每条**信息流广告(客户端每条各上报一次)结算奖励。client_event_id 幂等,同号重试不重复发。
|
||||
|
||||
发奖规则:**一条广告 = 一个单次公式值**(rewards.calculate_ad_reward_coin),因子2(LT)按账号累计
|
||||
**条**数递进;看满一份时长(unit_count>=1, 即 ≥10 秒)才发,**不逐份累加**。
|
||||
发奖规则(所见即所得, 2026-06-27 用户拍板「显示多少给多少」):优先**直接发客户端小球显示的金币
|
||||
display_coin**;防刷钳到本条「1 份满额」(eCPM 已钳 AD_ECPM_MAX_FEN, 因子2 按账号累计已发条数取档),
|
||||
合法显示(实际因子2 × 进度 p ≤ 1 份)不被砍, 只挡伪造天价值。旧客户端不传 display_coin 时退回
|
||||
「看满 10 秒发整份」(兼容不断币)。因子2(LT)由**客户端**按 granted 行 COUNT(拉自 /feed-reward/units)
|
||||
算进 display_coin, 后端只记 granted 行让该计数自增, 不再服务端重算份值。
|
||||
- aborted=True(用户中途 ✕ 关闭这条):本条不发,记 status='closed_early'。
|
||||
- 时长不足 10 秒(unit_count==0):记 status='too_short' 不发。
|
||||
- display_coin 为 0 且时长不足一份:记 status='too_short' 不发(不计 LT / 当日上限)。
|
||||
- 命中当日条数上限:记 status='capped' 不发。
|
||||
duration_seconds 是**这一条**的观看秒数。服务端两道硬闸防刷:时长钳到 FEED_MAX_DURATION_SECONDS、
|
||||
eCPM 在 calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN;叠加每日 get_ad_daily_limit 条数上限。
|
||||
duration_seconds 落库留痕(unit_count 字段), 旧端兼容路径据它判是否满 1 份。
|
||||
feed_scene:点位场景(comparison/coupon/welfare),仅归类落库,不参与计算。
|
||||
ad_type:广告形态(feed 信息流 / draw Draw 信息流),仅归类落库;**每日上限与因子2(LT)仍按本表
|
||||
全表 unit 累计(feed+draw 共享同一发奖池/上限),不按 ad_type 拆分**。
|
||||
@@ -139,14 +142,26 @@ def grant_feed_reward(
|
||||
)
|
||||
return _commit_record(db, rec, client_event_id)
|
||||
|
||||
# 整场总时长不足 10 秒,凑不满一份 → 不发,记 too_short 留痕。
|
||||
if unit_count == 0:
|
||||
# 所见即所得(用户 2026-06-27「显示多少给多少」): 优先发**客户端小球显示**的金币 display_coin,
|
||||
# 钳到本条「1 份满额」防刷(eCPM 已钳 AD_ECPM_MAX_FEN; 合法显示=因子2×p≤1份, 不会被砍)。
|
||||
# 因子2(LT)按账号累计已发条数(granted 行 COUNT), 第 existing_ads+1 条。
|
||||
existing_ads = granted_unit_total(db, user_id)
|
||||
unit_cap = rewards.calculate_ad_reward_coin(ecpm, existing_ads + 1)
|
||||
if display_coin > 0:
|
||||
coin = min(display_coin, unit_cap) # 新端: 所见即所得(直接发小球显示金币)
|
||||
elif unit_count >= 1:
|
||||
coin = unit_cap # 旧端没传 display_coin: 退回「看满 1 份发整份」(兼容)
|
||||
else:
|
||||
coin = 0
|
||||
|
||||
# 显示金币为 0 且没满一份 → 不发, 记 too_short 留痕(不写 granted 行 → 不计 LT / 当日上限)。
|
||||
if coin <= 0:
|
||||
rec = AdFeedRewardRecord(
|
||||
client_event_id=client_event_id,
|
||||
user_id=user_id,
|
||||
reward_date=today,
|
||||
duration_seconds=safe_duration,
|
||||
unit_count=0,
|
||||
unit_count=unit_count,
|
||||
ad_session_id=ad_session_id,
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
@@ -161,15 +176,11 @@ def grant_feed_reward(
|
||||
)
|
||||
return _commit_record(db, rec, client_event_id)
|
||||
|
||||
# 一条广告 = 一个「单次公式值」(因子2 按账号累计**条**数, 即第 existing_ads+1 条);看满一份(unit_count>=1)即发,不逐份累加。
|
||||
existing_ads = granted_unit_total(db, user_id)
|
||||
coin = rewards.calculate_ad_reward_coin(ecpm, existing_ads + 1)
|
||||
if coin > 0:
|
||||
crud_wallet.grant_coins(
|
||||
db, user_id, coin,
|
||||
biz_type="feed_ad_reward", ref_id=client_event_id,
|
||||
remark="信息流广告奖励",
|
||||
)
|
||||
crud_wallet.grant_coins(
|
||||
db, user_id, coin,
|
||||
biz_type="feed_ad_reward", ref_id=client_event_id,
|
||||
remark="信息流广告奖励",
|
||||
)
|
||||
rec = AdFeedRewardRecord(
|
||||
client_event_id=client_event_id,
|
||||
user_id=user_id,
|
||||
|
||||
@@ -21,17 +21,23 @@ def register_or_update(
|
||||
*,
|
||||
user_id: int,
|
||||
device_id: str,
|
||||
registration_id: str | None,
|
||||
registration_id: str | None = None,
|
||||
push_vendor: str | None = None,
|
||||
push_token: str | None = None,
|
||||
platform: str = "android",
|
||||
app_version: str | None = None,
|
||||
) -> DeviceLiveness:
|
||||
"""注册设备或更新其 registration_id / 元信息。upsert by (user_id, device_id)。"""
|
||||
"""注册设备或更新其厂商 push token / 元信息。upsert by (user_id, device_id)。"""
|
||||
normalized_vendor = _normalize_push_vendor(push_vendor)
|
||||
normalized_token = push_token.strip() if push_token else None
|
||||
device = _get(db, user_id=user_id, device_id=device_id)
|
||||
if device is None:
|
||||
device = DeviceLiveness(
|
||||
user_id=user_id,
|
||||
device_id=device_id,
|
||||
registration_id=registration_id,
|
||||
push_vendor=normalized_vendor,
|
||||
push_token=normalized_token,
|
||||
platform=platform or "android",
|
||||
app_version=app_version,
|
||||
)
|
||||
@@ -39,6 +45,10 @@ def register_or_update(
|
||||
else:
|
||||
if registration_id:
|
||||
device.registration_id = registration_id
|
||||
if normalized_vendor:
|
||||
device.push_vendor = normalized_vendor
|
||||
if normalized_token:
|
||||
device.push_token = normalized_token
|
||||
if platform:
|
||||
device.platform = platform
|
||||
if app_version:
|
||||
@@ -54,7 +64,9 @@ def touch_heartbeat(
|
||||
user_id: int,
|
||||
device_id: str,
|
||||
accessibility_enabled: bool,
|
||||
registration_id: str | None,
|
||||
registration_id: str | None = None,
|
||||
push_vendor: str | None = None,
|
||||
push_token: str | None = None,
|
||||
) -> DeviceLiveness:
|
||||
"""处理一次心跳(心跳也能自注册)。
|
||||
|
||||
@@ -69,6 +81,12 @@ def touch_heartbeat(
|
||||
|
||||
if registration_id:
|
||||
device.registration_id = registration_id
|
||||
normalized_vendor = _normalize_push_vendor(push_vendor)
|
||||
normalized_token = push_token.strip() if push_token else None
|
||||
if normalized_vendor:
|
||||
device.push_vendor = normalized_vendor
|
||||
if normalized_token:
|
||||
device.push_token = normalized_token
|
||||
device.last_report_protection_on = accessibility_enabled
|
||||
|
||||
if accessibility_enabled:
|
||||
@@ -87,7 +105,7 @@ def touch_heartbeat(
|
||||
def list_overdue(db: Session, *, timeout_minutes: int) -> list[DeviceLiveness]:
|
||||
"""掉线设备:曾经保护过、当前 alive、心跳超时。
|
||||
|
||||
本期只做终端打印检测、不推送 → 不再要求有 registration_id(没接极光 token 的设备也要检出)。
|
||||
即使没有厂商 token 也要检出,后续由 kill_alert_pending 走客户端进 App 后兜底提醒。
|
||||
"""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes)
|
||||
stmt = select(DeviceLiveness).where(
|
||||
@@ -124,3 +142,27 @@ def ack_kill_alert(db: Session, *, user_id: int, device_id: str) -> None:
|
||||
if device is not None and device.kill_alert_pending:
|
||||
device.kill_alert_pending = False
|
||||
db.commit()
|
||||
|
||||
|
||||
def has_push_target(device: DeviceLiveness | None) -> bool:
|
||||
"""是否已有厂商直推所需的 vendor + token。"""
|
||||
return bool(device and device.push_vendor and device.push_token)
|
||||
|
||||
|
||||
def _normalize_push_vendor(push_vendor: str | None) -> str | None:
|
||||
if not push_vendor:
|
||||
return None
|
||||
vendor = push_vendor.strip().lower()
|
||||
aliases = {
|
||||
"honor": "honor",
|
||||
"hihonor": "honor",
|
||||
"荣耀": "honor",
|
||||
"vivo": "vivo",
|
||||
"xiaomi": "xiaomi",
|
||||
"mi": "xiaomi",
|
||||
"小米": "xiaomi",
|
||||
"oppo": "oppo",
|
||||
"oneplus": "oppo",
|
||||
"realme": "oppo",
|
||||
}
|
||||
return aliases.get(vendor, vendor)
|
||||
|
||||
@@ -6,6 +6,7 @@ from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import string
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -70,6 +71,21 @@ def get_user_by_phone(db: Session, phone: str) -> User | None:
|
||||
return db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_user_by_wechat_openid(db: Session, openid: str) -> User | None:
|
||||
stmt = select(User).where(User.wechat_openid == openid)
|
||||
return db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def _wechat_placeholder_phone(openid: str) -> str:
|
||||
"""给纯微信注册用户生成内部登录号。
|
||||
|
||||
当前 user.phone 非空唯一,而微信 App 授权不会返回手机号。这里生成一个不可能
|
||||
命中手机号正则的内部值,只用于满足既有表结构和客户端登录态判断。
|
||||
"""
|
||||
digest = hashlib.sha256(openid.encode("utf-8")).hexdigest()[:16]
|
||||
return f"wx_{digest}"
|
||||
|
||||
|
||||
def upsert_user_for_login(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -99,6 +115,52 @@ def upsert_user_for_login(
|
||||
return user
|
||||
|
||||
|
||||
def upsert_user_for_wechat_login(
|
||||
db: Session,
|
||||
*,
|
||||
openid: str,
|
||||
nickname: str | None = None,
|
||||
avatar_url: str | None = None,
|
||||
) -> User:
|
||||
"""微信 App 登录:openid 已存在则回到原账号,不存在则注册新账号。
|
||||
|
||||
如果用户之前在提现页绑定过微信,该 openid 已在 user.wechat_openid 上,因此会
|
||||
直接登录到同一账号;新微信用户则创建 register_channel=wechat 的账号。
|
||||
"""
|
||||
user = get_user_by_wechat_openid(db, openid)
|
||||
now = datetime.now(timezone.utc)
|
||||
if user is None:
|
||||
phone = _wechat_placeholder_phone(openid)
|
||||
# 极小概率 hash 前缀碰撞时,后缀追加随机段兜底。
|
||||
if get_user_by_phone(db, phone) is not None:
|
||||
phone = f"wx_{secrets.token_hex(8)}"
|
||||
user = User(
|
||||
phone=phone,
|
||||
username=_gen_unique_username(db),
|
||||
nickname=nickname or _gen_nickname(),
|
||||
avatar_url=avatar_url,
|
||||
register_channel="wechat",
|
||||
wechat_openid=openid,
|
||||
wechat_nickname=nickname,
|
||||
wechat_avatar_url=avatar_url,
|
||||
last_login_at=now,
|
||||
)
|
||||
db.add(user)
|
||||
else:
|
||||
user.last_login_at = now
|
||||
if nickname:
|
||||
user.wechat_nickname = nickname
|
||||
if not user.nickname:
|
||||
user.nickname = nickname
|
||||
if avatar_url:
|
||||
user.wechat_avatar_url = avatar_url
|
||||
if not user.avatar_url:
|
||||
user.avatar_url = avatar_url
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def update_nickname(db: Session, user: User, *, nickname: str) -> User:
|
||||
user.nickname = nickname
|
||||
db.commit()
|
||||
|
||||
@@ -167,6 +167,11 @@ class FeedRewardIn(BaseModel):
|
||||
aborted: bool = Field(
|
||||
False, description="用户中途 ✕ 关闭广告(未走完比价):整场不发,记 closed_early"
|
||||
)
|
||||
display_coin: int = Field(
|
||||
0, ge=0,
|
||||
description="客户端金币小球**本条显示**的金币(所见即所得):后端直接发这个数,钳到本条最大 1 份"
|
||||
"满额防刷。缺省 0 = 旧客户端不传,退回服务端「看满 10 秒发整份」",
|
||||
)
|
||||
|
||||
|
||||
class FeedRewardOut(BaseModel):
|
||||
|
||||
@@ -67,6 +67,14 @@ class JverifyLoginRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class WechatLoginRequest(BaseModel):
|
||||
code: str = Field(..., min_length=1, description="微信 SDK SendAuth.Resp 返回的一次性授权 code")
|
||||
device_id: str = Field(
|
||||
"", max_length=64,
|
||||
description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理",
|
||||
)
|
||||
|
||||
|
||||
# ===== 短信验证码 =====
|
||||
|
||||
class SmsSendRequest(BaseModel):
|
||||
|
||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
# ===== 上报请求 =====
|
||||
@@ -21,8 +21,36 @@ class ComparisonItemIn(BaseModel):
|
||||
|
||||
name: str
|
||||
qty: int = 1
|
||||
# specs 仅供比价记录展示(admin-web 详情 / app 记录页都按字符串数组渲染并 join)。
|
||||
# pricebot(2026-06-27 嵌套规格统一)起 calibration 的 specs 可能是规格对象
|
||||
# [{name, qty, sub_specs}] 而非字符串 → 下面的 before-validator 统一拍平成可读字符串,
|
||||
# 兼容新旧两种输入、保持 list[str] 契约不变(下游零改动)。
|
||||
# ⚠️ 两个坑都踩过, 必须"拍平"而非别的: ① 直接声明 list[str] 不拍平 → 对象 specs 整条
|
||||
# 422 被拒、不入库(同下方 platform_results list→dict 同类事故); ② 仅放宽成裸 list 又会让
|
||||
# 下游 join 出 "[object Object]"/对象 toString 的乱码。
|
||||
specs: list[str] | None = None
|
||||
|
||||
@field_validator("specs", mode="before")
|
||||
@classmethod
|
||||
def _flatten_specs(cls, v: object) -> object:
|
||||
"""pricebot 规格对象 [{name, qty, sub_specs}] → 可读字符串数组; 字符串元素原样保留;
|
||||
非 list 原样交还(让 pydantic 照常报类型错)。嵌套规格拼成 '主项(子1,子2)'。"""
|
||||
if not isinstance(v, list):
|
||||
return v
|
||||
out: list[str] = []
|
||||
for it in v:
|
||||
if isinstance(it, str):
|
||||
s = it
|
||||
elif isinstance(it, dict):
|
||||
name = str(it.get("name") or "").strip()
|
||||
subs = [str(x).strip() for x in (it.get("sub_specs") or []) if str(x).strip()]
|
||||
s = f"{name}({','.join(subs)})" if name and subs else (name or ",".join(subs))
|
||||
else:
|
||||
continue
|
||||
if s:
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
class AppliedCouponIn(BaseModel):
|
||||
"""单笔已用优惠(来自 comparison_results[].applied_coupons)。amount 单位:元、正数。"""
|
||||
@@ -95,6 +123,7 @@ class ComparisonRecordIn(BaseModel):
|
||||
# pricebot done.params.trace_url 原样上报,落库供记录页「复制调试链接」(dir 名含落盘
|
||||
# 时分秒前端拼不出,必须由后端透传)。
|
||||
trace_url: str | None = Field(None, description="本次比价公网调试链接")
|
||||
total_ms: int | None = Field(None, description="整场比价墙钟耗时(ms)")
|
||||
|
||||
# ===== debug 维度(客户端采集上报;旧客户端不带 → None。仅 admin 比价记录页用)=====
|
||||
# 必须显式声明,否则 model_dump() 落 raw_payload 时被 pydantic 静默丢弃(同上面 coupon_saved 的坑)。
|
||||
@@ -144,6 +173,7 @@ class ComparisonRecordOut(BaseModel):
|
||||
items: list = []
|
||||
comparison_results: list = []
|
||||
skipped_dish_names: list = []
|
||||
total_ms: int | None = None
|
||||
# 「已下单」(店级):该店名在该用户真实下单(source='compare')里出现过即 True。
|
||||
# 由 list_records 动态算出挂在 ORM 实例上(非 DB 列),from_attributes 读出;缺省 False。
|
||||
ordered: bool = False
|
||||
|
||||
+22
-1
@@ -3,12 +3,15 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class DeviceRegisterRequest(BaseModel):
|
||||
device_id: str
|
||||
# registration_id 为旧极光字段,新推送链路统一使用 push_vendor + push_token。
|
||||
registration_id: str | None = None
|
||||
push_vendor: str | None = None
|
||||
push_token: str | None = None
|
||||
platform: str = "android"
|
||||
app_version: str | None = None
|
||||
|
||||
@@ -18,6 +21,8 @@ class HeartbeatRequest(BaseModel):
|
||||
source: str = "service" # service | app
|
||||
accessibility_enabled: bool = True
|
||||
registration_id: str | None = None
|
||||
push_vendor: str | None = None
|
||||
push_token: str | None = None
|
||||
|
||||
|
||||
class DeviceOut(BaseModel):
|
||||
@@ -26,6 +31,8 @@ class DeviceOut(BaseModel):
|
||||
id: int
|
||||
device_id: str
|
||||
registration_id: str | None
|
||||
push_vendor: str | None
|
||||
push_token: str | None
|
||||
ever_protected: bool
|
||||
liveness_state: str
|
||||
last_heartbeat_at: datetime | None
|
||||
@@ -46,3 +53,17 @@ class LivenessOut(BaseModel):
|
||||
|
||||
class LivenessAckRequest(BaseModel):
|
||||
device_id: str
|
||||
|
||||
|
||||
class PushTestRequest(BaseModel):
|
||||
device_id: str
|
||||
delay_seconds: int = Field(default=10, ge=0, le=60)
|
||||
push_vendor: str | None = None
|
||||
push_token: str | None = None
|
||||
registration_id: str | None = None
|
||||
|
||||
|
||||
class PushTestOut(BaseModel):
|
||||
ok: bool = True
|
||||
delay_seconds: int
|
||||
has_push_token: bool
|
||||
|
||||
@@ -27,8 +27,11 @@
|
||||
| `date_to` | string | =`date_from` | 结束日 北京时间 `YYYY-MM-DD`,**闭区间**;单日时与 `date_from` 相同 |
|
||||
| `user_id` | int | 全部 | 只看某用户;不传=所有用户 |
|
||||
| `ad_type` | string | 全部 | `reward_video` / `feed` / `draw`;不传=全部类型 |
|
||||
| `feed_scene` | string | 全部 | `comparison`(比价)/ `coupon`(领券)/ `welfare`(福利);**全局筛选**,同时作用于明细 / 合计 / `daily`·`hourly` 趋势;不传=全部场景 |
|
||||
| `granularity` | string | `day` | `day`=按天 / `hour`=按小时(聚合键再加北京时间小时 0–23);**区间>1 天建议用 day** |
|
||||
| `limit` | int(1~1000) | 500 | **展示**明细组数(截断;`total`/`total_*`/`daily` 按全量统计不受影响) |
|
||||
| `limit` | int(1~1000) | 500 | **每页条数**(分页大小);`total`/`total_*`/`daily`/`hourly` 按全量统计不受分页影响 |
|
||||
| `offset` | int(≥0) | 0 | 分页偏移(已跳过条数)=(页码−1)×`limit` |
|
||||
| `sort` | string | `time` | 明细排序:`time`=按时间倒序(新→旧) / `ecpm`=按 eCPM 数值倒序 |
|
||||
|
||||
约束:`date_to` 不早于 `date_from`、区间最长 **92 天**、日期须 `YYYY-MM-DD`,否则 `422`。
|
||||
|
||||
@@ -36,15 +39,18 @@
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `date_from` / `date_to` | string | 报表起止日期(闭区间) |
|
||||
| `daily` | `AdRevenueDaily[]` | 按日期汇总序列(全量,供按天趋势图;不受 `limit` 影响) |
|
||||
| `total` | int | 聚合组**总数**(全量,不受 `limit` 影响) |
|
||||
| `truncated` | bool | 明细是否被 `limit` 截断 |
|
||||
| `daily` | `AdRevenueDaily[]` | 按日期汇总序列(全量,供按天趋势图;不受分页影响) |
|
||||
| `hourly` | `AdRevenueHourly[]` | 按小时汇总序列(全量,供按小时趋势图;**仅 `granularity=hour` 时非空**;不受分页影响) |
|
||||
| `type_stats` | `{[ad_type]: AdRevenueTypeStat}` | 按广告类型(`ad_type`)小计(全量);前端取 `draw` / `reward_video` 做分类大盘 |
|
||||
| `dau` | int \| null | 今日活跃用户数(复用大盘口径 `last_login_at`,今日登录过);**仅查询=今日单天时有值**,历史/多天为 `null` |
|
||||
| `total` | int | 当前筛选下的**分页总条数**(全量,不受分页影响;= 前端分页器 total) |
|
||||
| `truncated` | bool | 当前页之后是否还有更多事件(`len(events) > offset + limit`) |
|
||||
| `total_impressions` | int | 全量展示条数合计 |
|
||||
| `total_revenue_yuan` | float | 全量收益合计(元) |
|
||||
| `total_expected_coin` | int | 全量应发金币合计 |
|
||||
| `total_actual_coin` | int | 全量实发金币合计 |
|
||||
| `mismatch_count` | int | 应发≠实发的组数(=0 说明全部按公式发放) |
|
||||
| `items` | `AdRevenueRow[]` | 聚合明细(按 日期→用户→类型→代码位 排序) |
|
||||
| `items` | `AdRevenueRow[]` | 逐条广告事件(**按时间倒序:新→旧**);`limit`/`offset` 对全量做分页切片,返回当前页 |
|
||||
|
||||
### AdRevenueDaily(`daily[]` — 按天趋势)
|
||||
| 字段 | 类型 | 说明 |
|
||||
@@ -55,6 +61,21 @@
|
||||
| `expected_coin` | int | 当天应发金币合计 |
|
||||
| `actual_coin` | int | 当天实发金币合计 |
|
||||
|
||||
### AdRevenueHourly(`hourly[]` — 按小时趋势,仅 `granularity=hour` 时非空)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `hour` | int | 北京时间小时 0–23 |
|
||||
| `impressions` | int | 该小时展示条数合计 |
|
||||
| `revenue_yuan` | float | 该小时预估收益合计(元) |
|
||||
| `expected_coin` | int | 该小时应发金币合计 |
|
||||
| `actual_coin` | int | 该小时实发金币合计 |
|
||||
|
||||
### AdRevenueTypeStat(`type_stats[ad_type]` — 分广告类型小计,供大盘第二行)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `impressions` | int | 该类型展示条数合计 |
|
||||
| `revenue_yuan` | float | 该类型预估收益合计(元);eCPM 由前端用 收益÷展示×1000 算 |
|
||||
|
||||
### AdRevenueRow(`items[]`)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -37,7 +37,7 @@ B 安装并首启 App
|
||||
└─ POST /api/v1/invite/bind { invite_code, channel="clipboard" }
|
||||
↓
|
||||
后端 repositories/invite.py bind()
|
||||
└─ 过四道防线 → 建 invite_relation + 给 A、B 各发金币(同事务原子提交)
|
||||
└─ 过四道防线 → 建 invite_relation(邀请金币已下线,不写金币流水)
|
||||
```
|
||||
|
||||
手动填码这条:B 在邀请页输码 → `InviteRepository.bindManual()` → `POST /bind { channel="manual" }` → 同一个 `bind()`。
|
||||
@@ -52,20 +52,20 @@ B 安装并首启 App
|
||||
|---|---|
|
||||
| 端点 | `app/api/v1/invite.py`:`GET /api/v1/invite/me`(返回 `invite_code` + `share_url` + 战绩)、`POST /api/v1/invite/bind`(绑定,`channel` = `clipboard` / `manual`)。**均需 Bearer 鉴权**。 |
|
||||
| share_url 构造 | `invite.py` 的 `my_invite`:`settings.INVITE_LANDING_URL + "?ref=" + code`。`INVITE_LANDING_URL` 在 `app/core/config.py`(默认 `https://app-api.shaguabijia.com/media/dl.html`)。 |
|
||||
| 业务逻辑 | `app/repositories/invite.py`:`ensure_code`(懒生成 6 位邀请码,去混淆字符集,唯一约束碰撞则换码)/ `resolve_inviter`(邀请码→邀请人,大小写不敏感)/ `bind`(下面详述)/ `get_stats`(已邀人数 + 累计金币)。 |
|
||||
| 业务逻辑 | `app/repositories/invite.py`:`ensure_code`(懒生成 6 位邀请码,去混淆字符集,唯一约束碰撞则换码)/ `resolve_inviter`(邀请码→邀请人,大小写不敏感)/ `bind`(下面详述)/ `get_stats`(已邀人数 + 兼容累计金币字段,当前恒为 0)。 |
|
||||
| 数据模型 | `app/models/invite.py` 的 `InviteRelation`(`inviter_user_id` / `invitee_user_id` / `channel` / `status` / `inviter_coin` / `invitee_coin` / `created_at`)+ `app/models/user.py` 的 `User.invite_code` 列。 |
|
||||
| 迁移 | `alembic/versions/invite_code_and_relation.py`:给 `user` 加 `invite_code`(唯一索引)+ 建 `invite_relation` 表。`down_revision = 11a1d08c6f55`。 |
|
||||
| 收发模型 | `app/schemas/invite.py`:`InviteInfoOut` / `BindInviteIn` / `BindInviteOut`。 |
|
||||
| 奖励常量 | `app/core/rewards.py`:`INVITE_INVITER_COINS` / `INVITE_INVITEE_COINS`(各 10000 = 1 元)、`INVITE_NEW_USER_WINDOW_HOURS`(72)。 |
|
||||
| 新人窗口 | `app/core/rewards.py`:`INVITE_NEW_USER_WINDOW_HOURS`(72)。邀请金币已下线,不再配置邀请金币常量。 |
|
||||
|
||||
**`bind()` 的四道防线(防重复 / 防刷,看 `repositories/invite.py`):**
|
||||
|
||||
1. **被邀请人唯一**:`invitee_user_id` 唯一约束 → 一个 B 只能被绑一次(幂等键,重复返回 `already_bound`,不重复发奖)。
|
||||
1. **被邀请人唯一**:`invitee_user_id` 唯一约束 → 一个 B 只能被绑一次(幂等键,重复返回 `already_bound`,不重复绑定)。
|
||||
2. **自邀屏蔽**:`inviter == invitee` → `self_invite`。
|
||||
3. **新人闸**:`_is_new_user`(B 的 `created_at` 在 `INVITE_NEW_USER_WINDOW_HOURS` = 72h 内)才发奖,挡存量老用户互相填码薅羊毛 → 否则 `not_eligible`。
|
||||
3. **新人闸**:`_is_new_user`(B 的 `created_at` 在 `INVITE_NEW_USER_WINDOW_HOURS` = 72h 内)才生效,挡存量老用户互相填码刷关系 → 否则 `not_eligible`。
|
||||
4. **手机号唯一**(天然限量):每个 B = 一个真实手机号账号。
|
||||
|
||||
发金币复用 `repositories/wallet.py` 的 `grant_coins`,与建关系记录在**同一事务**提交,保证"建关系 + 双方加金币"原子。
|
||||
邀请金币已下线:`bind()` 只记录绑定关系,不再写 `coin_transaction`;响应里的金币字段保留兼容旧客户端,当前恒为 0。
|
||||
|
||||
### 3.2 前端(shaguabijia-app-android)
|
||||
|
||||
@@ -120,7 +120,7 @@ B 安装并首启 App
|
||||
### 4.4 测试硬约束 / 坑(都是机制,不是 bug)
|
||||
|
||||
- **B 必须用新手机号**:`invitee_user_id` 唯一,一个 B 只能绑一次;反复测要换号(或手删 `invite_relation` 那行 + 回滚金币)。
|
||||
- **72h 新人闸**:B 注册后 72 小时内绑才发奖(刚注册肯定满足)。
|
||||
- **72h 新人闸**:B 注册后 72 小时内绑定才生效(刚注册肯定满足)。
|
||||
- **A ≠ B**:自邀被屏蔽。
|
||||
- **B 从点下载到首启 App 之间别复制别的东西**:剪贴板会被覆盖 → 归因丢(剪贴板 deferred deeplink 的固有脆弱性)。
|
||||
- **笔记本 IP 别变**:debug 包把 `BASE_URL` 的 IP 烧死在编译期,DHCP 一换就连不上 → 给笔记本固定个 LAN IP。
|
||||
|
||||
+7771
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* SGApi —— H5 调 app-server 后端的薄封装。
|
||||
*
|
||||
* 同源:H5 由 app-server 的 /media/h5/ 托管,后端在 /api/v1,同 host:port → 用相对路径、无 CORS。
|
||||
* 鉴权:JWT Bearer。token 经 SGBridge.getToken() 从原生取(原生持登录态);浏览器调试走 bridge 的 mock token。
|
||||
* 401:交原生拉登录(requestLogin)兜底,本次请求按失败 reject;正式的 refresh 重试策略阶段2 再补。
|
||||
*
|
||||
* 依赖 shared/bridge.js 先加载(取 token)。
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var BASE = '/api/v1';
|
||||
|
||||
function authHeaders() {
|
||||
var t = (global.SGBridge && global.SGBridge.getToken()) || '';
|
||||
var h = { 'Content-Type': 'application/json' };
|
||||
if (t) h['Authorization'] = 'Bearer ' + t;
|
||||
return h;
|
||||
}
|
||||
|
||||
function handle(res) {
|
||||
if (res.status === 401) {
|
||||
// 未授权:拉原生登录(异步),本次请求按失败处理,调用方自行决定是否重试
|
||||
if (global.SGBridge) global.SGBridge.requestLogin();
|
||||
return Promise.reject(new Error('unauthorized'));
|
||||
}
|
||||
if (!res.ok) {
|
||||
return res.text().then(function (t) {
|
||||
return Promise.reject(new Error('http ' + res.status + ' ' + t));
|
||||
});
|
||||
}
|
||||
// 204 / 空体兜底
|
||||
return res.text().then(function (t) { return t ? JSON.parse(t) : null; });
|
||||
}
|
||||
|
||||
/** GET /api/v1<path>。path 以 / 开头,如 '/savings/battle'。 */
|
||||
function apiGet(path) {
|
||||
return fetch(BASE + path, { method: 'GET', headers: authHeaders() }).then(handle);
|
||||
}
|
||||
|
||||
/** POST /api/v1<path>,body 自动 JSON 序列化。 */
|
||||
function apiPost(path, body) {
|
||||
return fetch(BASE + path, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(body || {}),
|
||||
}).then(handle);
|
||||
}
|
||||
|
||||
global.SGApi = { base: BASE, get: apiGet, post: apiPost };
|
||||
})(window);
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* SGBridge —— 傻瓜比价 H5 ↔ Android 原生 的桥。
|
||||
*
|
||||
* 背景:四个主 tab(首页/福利/记录/我的)由原生 Compose 改造为 WebView 加载本工程 H5。
|
||||
* H5 只负责"画 + 取后端数据";凡需要原生能力(登录态 / 跳转 / 跳外卖 App / 比价领券 /
|
||||
* 权限 / 定位 / Toast / 激励视频),一律经本桥调用原生。
|
||||
*
|
||||
* 协议两个方向:
|
||||
* ① H5 → 原生:Android 端 WebView.addJavascriptInterface(obj, "SGBridgeNative")。
|
||||
* obj 方法都是【同步】:查询类返回 String(JSON 或纯串),动作类无返回。
|
||||
* ② 原生 → H5:原生执行 evaluateJavascript("window.SGBridge._emit('<event>', '<json>')")。
|
||||
* 事件:onAuthChange(登录态变) / onBalanceChange(余额变) / onSigninChange(签到态变) / onResume(回前台刷新)。
|
||||
*
|
||||
* 离线兜底:浏览器里(无 SGBridgeNative)走 MOCK,便于不装 App 直接在本地 server 调样式 / 渲染。
|
||||
* 与原生实现对应:见 shaguabijia-app-android 的 SGBridge.kt(方法名逐个对齐本文件)。
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var native = global.SGBridgeNative || null;
|
||||
var hasNative = !!native;
|
||||
|
||||
// ---- 离线 MOCK(仅无原生时生效,便于浏览器调试渲染;真机一律走 native) ----
|
||||
var MOCK = {
|
||||
authState: { loggedIn: true, userId: 1, nickname: '冰', avatarUrl: '', phone: '188****8888' },
|
||||
token: 'mock-token-for-browser-debug',
|
||||
deviceId: 'browser-debug-device',
|
||||
appVersion: '0.0.0-debug',
|
||||
};
|
||||
|
||||
function safeParse(s, fallback) {
|
||||
try { return s ? JSON.parse(s) : fallback; } catch (e) { return fallback; }
|
||||
}
|
||||
|
||||
// ====== 查询类(同步返回) ======
|
||||
|
||||
/** 当前登录态 + 用户基本信息 → {loggedIn, userId, nickname, avatarUrl, phone}。 */
|
||||
function getAuthState() {
|
||||
if (hasNative && native.getAuthState) return safeParse(native.getAuthState(), { loggedIn: false });
|
||||
return MOCK.authState;
|
||||
}
|
||||
|
||||
/** 后端鉴权用的 access token(空串=未登录)。原生持登录态,H5 调后端前取它拼 Bearer。 */
|
||||
function getToken() {
|
||||
if (hasNative && native.getToken) return native.getToken() || '';
|
||||
return MOCK.token;
|
||||
}
|
||||
|
||||
/** 设备唯一标识(心跳 / 领券状态查询等用)。 */
|
||||
function getDeviceId() {
|
||||
if (hasNative && native.getDeviceId) return native.getDeviceId() || '';
|
||||
return MOCK.deviceId;
|
||||
}
|
||||
|
||||
/** App 版本号。 */
|
||||
function getAppVersion() {
|
||||
if (hasNative && native.getAppVersion) return native.getAppVersion() || '';
|
||||
return MOCK.appVersion;
|
||||
}
|
||||
|
||||
/** 已安装的目标电商/外卖 App 包名数组(原生 InstalledApps 探测)。H5 选平台弹窗据此判真实装机态。 */
|
||||
function getInstalledApps() {
|
||||
if (hasNative && native.getInstalledApps) return safeParse(native.getInstalledApps(), []);
|
||||
// MOCK(浏览器无原生):给主流已装,便于本地预览选平台弹窗正常显示"有"。
|
||||
return ['com.sankuai.meituan', 'com.taobao.taobao', 'com.jingdong.app.mall', 'me.ele'];
|
||||
}
|
||||
|
||||
/** 今日是否已领券(置灰「去领取」→「去查看」)。原生读 CompareButtonState(SP 按天);无桥默认 false。 */
|
||||
function getCouponClaimedToday() {
|
||||
if (hasNative && native.getCouponClaimedToday) return !!native.getCouponClaimedToday();
|
||||
return false;
|
||||
}
|
||||
|
||||
// ====== 动作类(无返回;异步结果走事件) ======
|
||||
|
||||
/** 跳原生页。route 取值对齐安卓 Routes(invite / settings / feedback / withdrawal / compareRecords / reportFlow / guideVideo / compareResult / coinHistory / cashHistory / welfareRules ...)。 */
|
||||
function navigate(route) {
|
||||
if (hasNative && native.navigate) native.navigate(route);
|
||||
else console.log('[SGBridge mock] navigate →', route);
|
||||
}
|
||||
|
||||
/** 拉起极光一键登录。结果异步经 onAuthChange 事件回来(不在此函数返回)。 */
|
||||
function requestLogin() {
|
||||
if (hasNative && native.requestLogin) native.requestLogin();
|
||||
else console.log('[SGBridge mock] requestLogin');
|
||||
}
|
||||
|
||||
/** 原生居中 Toast。 */
|
||||
function toast(msg) {
|
||||
if (hasNative && native.toast) native.toast(String(msg));
|
||||
else console.log('[SGBridge mock] toast →', msg);
|
||||
}
|
||||
|
||||
/** 跳美团/外卖 App(deeplink 优先;空则原生按包名启动,未装可跳应用商店)。 */
|
||||
function openMeituan(deeplink) {
|
||||
if (hasNative && native.openMeituan) native.openMeituan(deeplink || '');
|
||||
else console.log('[SGBridge mock] openMeituan →', deeplink);
|
||||
}
|
||||
|
||||
/** 触发 agent 比价流程(原生起无障碍引擎)。 */
|
||||
function startCompare() {
|
||||
if (hasNative && native.startCompare) native.startCompare();
|
||||
else console.log('[SGBridge mock] startCompare');
|
||||
}
|
||||
|
||||
/** 触发一键领券(原生先校验悬浮窗/无障碍权限,再起前台服务)。platforms: string[]。 */
|
||||
function startCouponClaim(platforms) {
|
||||
var json = JSON.stringify(platforms || []);
|
||||
if (hasNative && native.startCouponClaim) native.startCouponClaim(json);
|
||||
else console.log('[SGBridge mock] startCouponClaim →', json);
|
||||
}
|
||||
|
||||
/** 按候选包名拉起对应平台 App。照原生 HomePicker 点击跳转:原生在该平台一组候选包里取首个可
|
||||
* getLaunchIntentForPackage 的拉起(NEW_TASK|CLEAR_TASK 冷启到平台首页)。packages: string[](一个平台一组候选包,任一可拉即拉)。 */
|
||||
function launchApp(packages) {
|
||||
var json = JSON.stringify(packages || []);
|
||||
if (hasNative && native.launchApp) native.launchApp(json);
|
||||
else console.log('[SGBridge mock] launchApp →', json);
|
||||
}
|
||||
|
||||
// ====== 原生 → H5 事件总线 ======
|
||||
|
||||
var listeners = {}; // event → [fn]
|
||||
|
||||
/** 订阅原生事件。返回取消订阅函数。 */
|
||||
function on(event, fn) {
|
||||
(listeners[event] || (listeners[event] = [])).push(fn);
|
||||
return function off() {
|
||||
listeners[event] = (listeners[event] || []).filter(function (f) { return f !== fn; });
|
||||
};
|
||||
}
|
||||
|
||||
/** 供原生回调:window.SGBridge._emit('onAuthChange', '{...}')。payload 可为 JSON 串或对象。 */
|
||||
function _emit(event, payload) {
|
||||
var data = typeof payload === 'string' ? safeParse(payload, payload) : payload;
|
||||
(listeners[event] || []).forEach(function (fn) {
|
||||
try { fn(data); } catch (e) { console.error('[SGBridge] listener error', event, e); }
|
||||
});
|
||||
}
|
||||
|
||||
global.SGBridge = {
|
||||
hasNative: hasNative,
|
||||
// 查询
|
||||
getAuthState: getAuthState,
|
||||
getToken: getToken,
|
||||
getDeviceId: getDeviceId,
|
||||
getAppVersion: getAppVersion,
|
||||
getInstalledApps: getInstalledApps,
|
||||
getCouponClaimedToday: getCouponClaimedToday,
|
||||
// 动作
|
||||
navigate: navigate,
|
||||
requestLogin: requestLogin,
|
||||
toast: toast,
|
||||
openMeituan: openMeituan,
|
||||
startCompare: startCompare,
|
||||
startCouponClaim: startCouponClaim,
|
||||
launchApp: launchApp,
|
||||
// 事件
|
||||
on: on,
|
||||
_emit: _emit,
|
||||
};
|
||||
})(window);
|
||||
@@ -305,11 +305,12 @@ def test_feed_reward_grants_by_10_second_units(client) -> None:
|
||||
"duration_seconds": 30,
|
||||
"adn": "pangle",
|
||||
"slot_id": "slot_feed",
|
||||
"display_coin": 4,
|
||||
}
|
||||
r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
expected = sum(calculate_ad_reward_coin("200", i) for i in range(1, 4))
|
||||
expected = 4
|
||||
assert body["granted"] is True
|
||||
assert body["status"] == "granted"
|
||||
assert body["unit_count"] == 3
|
||||
|
||||
@@ -64,7 +64,9 @@ def test_dashboard_overview(admin_client: TestClient, admin_token: str) -> None:
|
||||
assert data["users"]["total"] >= 1
|
||||
assert data["coins"]["granted_total"] >= 5000
|
||||
assert "success_rate" in data["comparison"]
|
||||
assert data["cps"]["available"] is False
|
||||
assert data["cps"]["available"] is True
|
||||
assert "meituan_order_count" in data["cps"]
|
||||
assert "jd_order_count" in data["cps"]
|
||||
|
||||
|
||||
def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> None:
|
||||
|
||||
@@ -68,6 +68,64 @@ def test_sms_login_and_me_flow(client) -> None:
|
||||
assert r.json()["ok"] is True
|
||||
|
||||
|
||||
def test_wechat_login_creates_and_reuses_user(client, monkeypatch) -> None:
|
||||
"""微信 code 登录:新 openid 注册,同 openid 重登回同一账号。"""
|
||||
monkeypatch.setattr(
|
||||
"app.api.v1.auth.code_to_userinfo",
|
||||
lambda code: {
|
||||
"openid": "openid_login_1",
|
||||
"unionid": "union_1",
|
||||
"nickname": "微信用户A",
|
||||
"avatar_url": "https://wx/avatar-a.png",
|
||||
"raw": {},
|
||||
},
|
||||
)
|
||||
|
||||
r = client.post("/api/v1/auth/wechat-login", json={"code": "code-a", "device_id": "dev-wx"})
|
||||
assert r.status_code == 200, r.text
|
||||
first = r.json()
|
||||
assert first["user"]["register_channel"] == "wechat"
|
||||
assert first["user"]["phone"].startswith("wx_")
|
||||
assert first["user"]["nickname"] == "微信用户A"
|
||||
|
||||
r = client.post("/api/v1/auth/wechat-login", json={"code": "code-b", "device_id": "dev-wx"})
|
||||
assert r.status_code == 200, r.text
|
||||
second = r.json()
|
||||
assert second["user"]["id"] == first["user"]["id"]
|
||||
|
||||
|
||||
def test_wechat_login_uses_existing_bound_user(client, monkeypatch) -> None:
|
||||
"""如果该微信已在提现页绑定过,微信登录应回到原手机号账号。"""
|
||||
from app.db.session import SessionLocal
|
||||
from app.repositories import user as user_repo
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = user_repo.upsert_user_for_login(db, phone="13600136010", register_channel="sms")
|
||||
user.wechat_openid = "openid_bound_1"
|
||||
db.commit()
|
||||
uid = user.id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.api.v1.auth.code_to_userinfo",
|
||||
lambda code: {
|
||||
"openid": "openid_bound_1",
|
||||
"unionid": None,
|
||||
"nickname": "绑定微信",
|
||||
"avatar_url": None,
|
||||
"raw": {},
|
||||
},
|
||||
)
|
||||
|
||||
r = client.post("/api/v1/auth/wechat-login", json={"code": "code-bound", "device_id": "dev-wx"})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["user"]["id"] == uid
|
||||
assert body["user"]["phone"] == "13600136010"
|
||||
|
||||
|
||||
def test_sms_send_too_frequent(client) -> None:
|
||||
phone = "13900139000"
|
||||
assert client.post("/api/v1/auth/sms/send", json={"phone": phone}).status_code == 200
|
||||
|
||||
@@ -44,6 +44,7 @@ def _food_payload(trace_id: str) -> dict:
|
||||
"skipped_dish_names": ["黑牛肉卷"],
|
||||
"total_dish_count": 3,
|
||||
"information": "在美团找到同店,到手价 ¥123.50",
|
||||
"total_ms": 12345,
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +72,8 @@ def test_report_and_derive(client) -> None:
|
||||
assert d["information"] == "在美团找到同店,到手价 ¥123.50"
|
||||
assert d["store_name"] == "海底捞(朝阳店)"
|
||||
assert d["total_dish_count"] == 3
|
||||
assert d["total_ms"] == 12345
|
||||
assert d["raw_payload"]["total_ms"] == 12345
|
||||
assert d["skipped_dish_count"] == 1
|
||||
assert d["skipped_dish_names"] == ["黑牛肉卷"]
|
||||
assert len(d["comparison_results"]) == 3
|
||||
|
||||
@@ -11,6 +11,7 @@ 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_order import CpsOrder
|
||||
from app.models.cps_wx_user import CpsWxUser
|
||||
from app.repositories import cps_link as cps_link_repo
|
||||
|
||||
@@ -180,3 +181,73 @@ def test_day_users_cross_year(admin_client: TestClient, admin_token: str) -> Non
|
||||
assert len(users) == 1
|
||||
assert users[0]["openid"] == openid
|
||||
assert users[0]["visit_count"] == 5 # 次年 01-01 那条被时间窗排除
|
||||
|
||||
|
||||
def test_jd_reconcile_updates_dashboard(
|
||||
admin_client: TestClient, admin_token: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""京东拉单:按订单行入库;大盘只统计有效 validCode 的佣金。"""
|
||||
|
||||
def fake_query_order_rows(**kwargs):
|
||||
start = kwargs["start_time"].astimezone(_BJ)
|
||||
if start.hour != 10 or kwargs["page_index"] != 1:
|
||||
return {"rows": [], "has_more": False}
|
||||
return {
|
||||
"rows": [
|
||||
{
|
||||
"id": "pytest-jd-row-valid",
|
||||
"orderId": "pytest-jd-order-1",
|
||||
"skuId": "sku-1",
|
||||
"skuName": "京东测试商品",
|
||||
"orderTime": "2026-06-25 10:10:00",
|
||||
"modifyTime": "2026-06-25 10:20:00",
|
||||
"validCode": "16",
|
||||
"estimateCosPrice": "19.90",
|
||||
"estimateFee": "1.23",
|
||||
"actualFee": "2.34",
|
||||
"commissionRate": "10.00",
|
||||
},
|
||||
{
|
||||
"id": "pytest-jd-row-invalid",
|
||||
"orderId": "pytest-jd-order-2",
|
||||
"skuId": "sku-2",
|
||||
"skuName": "京东无效订单",
|
||||
"orderTime": "2026-06-25 10:15:00",
|
||||
"modifyTime": "2026-06-25 10:25:00",
|
||||
"validCode": "4",
|
||||
"estimateCosPrice": "9.90",
|
||||
"estimateFee": "0.50",
|
||||
"commissionRate": "5.00",
|
||||
},
|
||||
],
|
||||
"has_more": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr("app.integrations.jd_union.query_order_rows", fake_query_order_rows)
|
||||
r = admin_client.post(
|
||||
"/admin/api/cps/orders/reconcile",
|
||||
params={
|
||||
"platform": "jd",
|
||||
"date_from": "2026-06-25",
|
||||
"date_to": "2026-06-25",
|
||||
"query_time_type": 1,
|
||||
},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["fetched"] == 2
|
||||
|
||||
with SessionLocal() as db:
|
||||
rows = db.query(CpsOrder).filter(CpsOrder.platform == "jd").all()
|
||||
assert len([o for o in rows if o.order_id.startswith("jd:pytest-jd-row")]) == 2
|
||||
|
||||
overview = admin_client.get(
|
||||
"/admin/api/stats/overview",
|
||||
params={"date_from": "2026-06-25", "date_to": "2026-06-25"},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert overview.status_code == 200, overview.text
|
||||
cps = overview.json()["cps"]
|
||||
assert cps["jd_order_count"] >= 1
|
||||
assert cps["jd_commission_cents"] >= 234
|
||||
assert cps["jd_invalid_count"] >= 1
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1 import device as device_api
|
||||
from app.core import heartbeat_monitor_worker
|
||||
from app.db.session import SessionLocal
|
||||
from app.integrations import vendor_push
|
||||
from app.models.device import DeviceLiveness
|
||||
from app.repositories import user as user_repo
|
||||
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
text = "{}"
|
||||
|
||||
def __init__(self, data: dict) -> None:
|
||||
self._data = data
|
||||
|
||||
def json(self) -> dict:
|
||||
return self._data
|
||||
|
||||
|
||||
def test_xiaomi_accessibility_payload(monkeypatch) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
def _fake_request(method, url, **kwargs): # noqa: ANN001
|
||||
captured.update(method=method, url=url, **kwargs)
|
||||
return _Resp({"code": 0, "result": "ok", "data": {"id": "xm-msg"}})
|
||||
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret")
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_CHANNEL_ID", "")
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_ID", "")
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_TITLE", "")
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_DESCRIPTION", "")
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_PARAM_JSON", "")
|
||||
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
|
||||
|
||||
data = vendor_push.send_accessibility_disabled("xiaomi", "xm-regid")
|
||||
|
||||
assert data["data"]["id"] == "xm-msg"
|
||||
assert captured["method"] == "POST"
|
||||
assert captured["url"] == vendor_push.settings.XIAOMI_PUSH_SEND_ENDPOINT
|
||||
assert captured["headers"]["Authorization"] == "key=xiaomi-secret"
|
||||
body = captured["data"]
|
||||
assert body["registration_id"] == "xm-regid"
|
||||
assert body["restricted_package_name"] == "com.jishisongfu.shaguabijia"
|
||||
assert json.loads(body["payload"]) == {"type": "accessibility_disabled"}
|
||||
assert "extra.channel_id" not in body
|
||||
|
||||
|
||||
def test_xiaomi_payload_with_channel_and_template(monkeypatch) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
def _fake_request(method, url, **kwargs): # noqa: ANN001
|
||||
captured.update(method=method, url=url, **kwargs)
|
||||
return _Resp({"code": 0, "result": "ok", "data": {"id": "xm-msg"}})
|
||||
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret")
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_CHANNEL_ID", "130")
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_ID", "1001")
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_TITLE", "{$app_name$}提醒")
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_DESCRIPTION", "{$content$}")
|
||||
monkeypatch.setattr(
|
||||
vendor_push.settings,
|
||||
"XIAOMI_PUSH_TEMPLATE_PARAM_JSON",
|
||||
'{"app_name":"傻瓜比价","content":"{alert}"}',
|
||||
)
|
||||
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
|
||||
|
||||
vendor_push.send_accessibility_disabled(
|
||||
"xiaomi",
|
||||
"xm-regid",
|
||||
title="测试标题",
|
||||
alert="测试内容",
|
||||
)
|
||||
|
||||
body = captured["data"]
|
||||
assert body["title"] == "{$app_name$}提醒"
|
||||
assert body["description"] == "{$content$}"
|
||||
assert body["extra.channel_id"] == "130"
|
||||
assert body["extra.template_id"] == "1001"
|
||||
assert body["extra.template_param"] == '{"app_name":"傻瓜比价","content":"测试内容"}'
|
||||
|
||||
|
||||
def test_vivo_auth_and_send_payload(monkeypatch) -> None:
|
||||
vendor_push._token_cache.clear()
|
||||
calls: list[dict] = []
|
||||
|
||||
def _fake_request(method, url, **kwargs): # noqa: ANN001
|
||||
calls.append({"method": method, "url": url, **kwargs})
|
||||
if url == vendor_push.settings.VIVO_PUSH_AUTH_ENDPOINT:
|
||||
return _Resp({"result": 0, "authToken": "vivo-auth"})
|
||||
return _Resp({"result": 0, "taskId": "vivo-task"})
|
||||
|
||||
monkeypatch.setattr(vendor_push.settings, "VIVO_PUSH_APP_ID", "106072775")
|
||||
monkeypatch.setattr(vendor_push.settings, "VIVO_PUSH_APP_KEY", "vivo-key")
|
||||
monkeypatch.setattr(vendor_push.settings, "VIVO_PUSH_APP_SECRET", "vivo-secret")
|
||||
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
|
||||
|
||||
data = vendor_push.send_accessibility_disabled("vivo", "vivo-regid")
|
||||
|
||||
assert data["taskId"] == "vivo-task"
|
||||
assert calls[0]["url"] == vendor_push.settings.VIVO_PUSH_AUTH_ENDPOINT
|
||||
assert calls[0]["json"]["appId"] == "106072775"
|
||||
assert calls[0]["json"]["sign"]
|
||||
assert calls[1]["url"] == vendor_push.settings.VIVO_PUSH_SEND_ENDPOINT
|
||||
assert calls[1]["headers"]["authToken"] == "vivo-auth"
|
||||
body = calls[1]["json"]
|
||||
assert body["regId"] == "vivo-regid"
|
||||
assert body["pushMode"] == vendor_push.settings.VIVO_PUSH_MODE
|
||||
assert body["clientCustomMap"] == {"type": "accessibility_disabled"}
|
||||
|
||||
|
||||
def test_oppo_auth_and_send_payload(monkeypatch) -> None:
|
||||
vendor_push._token_cache.clear()
|
||||
calls: list[dict] = []
|
||||
|
||||
def _fake_request(method, url, **kwargs): # noqa: ANN001
|
||||
calls.append({"method": method, "url": url, **kwargs})
|
||||
if url == vendor_push.settings.OPPO_PUSH_AUTH_ENDPOINT:
|
||||
return _Resp({"code": 0, "data": {"auth_token": "oppo-auth"}})
|
||||
return _Resp({"code": 0, "data": {"message_id": "oppo-msg"}})
|
||||
|
||||
monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_APP_KEY", "oppo-key")
|
||||
monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_MASTER_SECRET", "oppo-master")
|
||||
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
|
||||
|
||||
data = vendor_push.send_accessibility_disabled("oppo", "oppo-regid")
|
||||
|
||||
assert data["data"]["message_id"] == "oppo-msg"
|
||||
assert calls[0]["data"]["app_key"] == "oppo-key"
|
||||
assert calls[0]["data"]["sign"]
|
||||
message = json.loads(calls[1]["data"]["message"])
|
||||
assert calls[1]["data"]["auth_token"] == "oppo-auth"
|
||||
assert message["target_type"] == 2
|
||||
assert message["target_value"] == "oppo-regid"
|
||||
assert json.loads(message["notification"]["action_parameters"]) == {
|
||||
"type": "accessibility_disabled"
|
||||
}
|
||||
|
||||
|
||||
def test_honor_auth_and_send_payload(monkeypatch) -> None:
|
||||
vendor_push._token_cache.clear()
|
||||
calls: list[dict] = []
|
||||
|
||||
def _fake_request(method, url, **kwargs): # noqa: ANN001
|
||||
calls.append({"method": method, "url": url, **kwargs})
|
||||
if url == vendor_push.settings.HONOR_PUSH_TOKEN_ENDPOINT:
|
||||
return _Resp({"access_token": "honor-access", "expires_in": 3600})
|
||||
return _Resp({"code": 200, "message": "successful!", "data": {"sendResult": True}})
|
||||
|
||||
monkeypatch.setattr(vendor_push.settings, "HONOR_PUSH_APP_ID", "104559789")
|
||||
monkeypatch.setattr(vendor_push.settings, "HONOR_PUSH_CLIENT_ID", "honor-client")
|
||||
monkeypatch.setattr(vendor_push.settings, "HONOR_PUSH_CLIENT_SECRET", "honor-secret")
|
||||
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
|
||||
|
||||
data = vendor_push.send_accessibility_disabled("honor", "honor-token")
|
||||
|
||||
assert data["code"] == 200
|
||||
assert calls[0]["data"]["client_id"] == "honor-client"
|
||||
assert calls[1]["headers"]["Authorization"] == "Bearer honor-access"
|
||||
assert calls[1]["headers"]["timestamp"]
|
||||
assert calls[1]["url"].endswith("/api/v1/104559789/sendMessage")
|
||||
body = calls[1]["json"]
|
||||
assert body["token"] == ["honor-token"]
|
||||
assert body["android"]["targetUserType"] == 1
|
||||
assert body["android"]["notification"]["clickAction"] == {"type": 3}
|
||||
assert json.loads(body["data"]) == {"type": "accessibility_disabled"}
|
||||
|
||||
|
||||
def _seed_overdue_device(
|
||||
*,
|
||||
phone: str,
|
||||
device_id: str,
|
||||
push_vendor: str | None,
|
||||
push_token: str | None,
|
||||
) -> int:
|
||||
with SessionLocal() as db:
|
||||
user = user_repo.upsert_user_for_login(db, phone=phone, register_channel="sms")
|
||||
device = DeviceLiveness(
|
||||
user_id=user.id,
|
||||
device_id=device_id,
|
||||
push_vendor=push_vendor,
|
||||
push_token=push_token,
|
||||
platform="android",
|
||||
ever_protected=True,
|
||||
last_heartbeat_at=datetime.now(timezone.utc) - timedelta(minutes=30), # noqa: UP017
|
||||
last_report_protection_on=True,
|
||||
liveness_state="alive",
|
||||
kill_alert_pending=False,
|
||||
)
|
||||
db.add(device)
|
||||
db.commit()
|
||||
db.refresh(device)
|
||||
return device.id
|
||||
|
||||
|
||||
def _login(client: TestClient, 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 test_heartbeat_monitor_pushes_overdue_device(monkeypatch) -> None:
|
||||
device_pk = _seed_overdue_device(
|
||||
phone="13900009001",
|
||||
device_id="dev-push-honor",
|
||||
push_vendor="honor",
|
||||
push_token="honor-token-1",
|
||||
)
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
def _fake_send(push_vendor: str, push_token: str) -> dict:
|
||||
calls.append((push_vendor, push_token))
|
||||
return {"msg_id": "m1"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
heartbeat_monitor_worker.vendor_push,
|
||||
"send_accessibility_disabled",
|
||||
_fake_send,
|
||||
)
|
||||
|
||||
result = heartbeat_monitor_worker._scan_once(timeout_minutes=10)
|
||||
|
||||
assert result["pushed"] >= 1
|
||||
assert ("honor", "honor-token-1") in calls
|
||||
with SessionLocal() as db:
|
||||
device = db.get(DeviceLiveness, device_pk)
|
||||
assert device is not None
|
||||
assert device.liveness_state == "notified"
|
||||
assert device.kill_alert_pending is True
|
||||
|
||||
|
||||
def test_heartbeat_monitor_skips_push_without_vendor_token(monkeypatch) -> None:
|
||||
device_pk = _seed_overdue_device(
|
||||
phone="13900009002",
|
||||
device_id="dev-push-no-token",
|
||||
push_vendor=None,
|
||||
push_token=None,
|
||||
)
|
||||
|
||||
def _fake_send(push_vendor: str, push_token: str) -> dict:
|
||||
raise AssertionError(f"should not push without token: {push_vendor}/{push_token}")
|
||||
|
||||
monkeypatch.setattr(
|
||||
heartbeat_monitor_worker.vendor_push,
|
||||
"send_accessibility_disabled",
|
||||
_fake_send,
|
||||
)
|
||||
|
||||
result = heartbeat_monitor_worker._scan_once(timeout_minutes=10)
|
||||
|
||||
assert result["checked"] >= 1
|
||||
with SessionLocal() as db:
|
||||
device = db.get(DeviceLiveness, device_pk)
|
||||
assert device is not None
|
||||
assert device.liveness_state == "notified"
|
||||
assert device.kill_alert_pending is True
|
||||
|
||||
|
||||
def test_push_test_endpoint_schedules_vendor_push(client: TestClient, monkeypatch) -> None:
|
||||
token = _login(client, "13900009003")
|
||||
calls: list[tuple[str, str, str, str]] = []
|
||||
|
||||
def _fake_send(push_vendor: str, push_token: str, *, title: str, alert: str) -> dict:
|
||||
calls.append((push_vendor, push_token, title, alert))
|
||||
return {"msg_id": "m-test"}
|
||||
|
||||
monkeypatch.setattr(device_api.vendor_push, "send_accessibility_disabled", _fake_send)
|
||||
|
||||
r = client.post(
|
||||
"/api/v1/device/push-test",
|
||||
json={
|
||||
"device_id": "dev-push-test",
|
||||
"push_vendor": "honor",
|
||||
"push_token": "honor-test-token",
|
||||
"delay_seconds": 0,
|
||||
},
|
||||
headers=_auth(token),
|
||||
)
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {
|
||||
"ok": True,
|
||||
"delay_seconds": 0,
|
||||
"has_push_token": True,
|
||||
}
|
||||
assert calls == [
|
||||
(
|
||||
"honor",
|
||||
"honor-test-token",
|
||||
"测试推送",
|
||||
"这是一条厂商通道测试推送。收到它说明 App 被划掉后仍可通过系统通知栏触达。",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_push_test_endpoint_requires_vendor_token(client: TestClient) -> None:
|
||||
token = _login(client, "13900009004")
|
||||
|
||||
r = client.post(
|
||||
"/api/v1/device/push-test",
|
||||
json={"device_id": "dev-push-test-no-token", "delay_seconds": 0},
|
||||
headers=_auth(token),
|
||||
)
|
||||
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == "push vendor token not ready"
|
||||
@@ -14,6 +14,7 @@ def test_openapi_loads(client) -> None:
|
||||
paths = resp.json()["paths"]
|
||||
# auth endpoints 都注册了
|
||||
assert "/api/v1/auth/jverify-login" in paths
|
||||
assert "/api/v1/auth/wechat-login" in paths
|
||||
assert "/api/v1/auth/sms/send" in paths
|
||||
assert "/api/v1/auth/sms/login" in paths
|
||||
assert "/api/v1/auth/refresh" in paths
|
||||
|
||||
@@ -52,7 +52,8 @@ def call_raw(path: str, body_obj: dict) -> dict:
|
||||
}
|
||||
url = f"{settings.MT_CPS_HOST}{path}"
|
||||
t0 = time.time()
|
||||
resp = httpx.post(url, content=body, headers=headers, timeout=settings.MT_CPS_TIMEOUT_SEC)
|
||||
# trust_env=False: 美团是国内域名,强制直连绕开本机代理(代理会掐断 TLS 握手,报 SSL EOF)
|
||||
resp = httpx.post(url, content=body, headers=headers, timeout=settings.MT_CPS_TIMEOUT_SEC, trust_env=False)
|
||||
ms = int((time.time() - t0) * 1000)
|
||||
try:
|
||||
j = resp.json()
|
||||
|
||||
Reference in New Issue
Block a user