Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3eb439b5ae | |||
| 6b10e866e9 | |||
| 13b143cd97 | |||
| 93f052535e | |||
| a563c1ca4b |
+1
-49
@@ -27,55 +27,7 @@ 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
|
||||
|
||||
# ===== 厂商直推(无障碍保护存活告警 + 消息中心 13 类通知)=====
|
||||
# 敏感密钥只放 .env / 服务器环境变量,不要提交到 git。
|
||||
# 各厂商配置状态可随时 GET /api/v1/push/vendors 查看(缺哪些键一目了然)。
|
||||
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
|
||||
|
||||
# 华为 Push Kit:AGC 控制台 → 项目设置 → 常规 → 应用,AppId + AppSecret
|
||||
HUAWEI_PUSH_APP_ID=
|
||||
HUAWEI_PUSH_APP_SECRET=
|
||||
HUAWEI_PUSH_TOKEN_ENDPOINT=https://oauth-login.cloud.huawei.com/oauth2/v3/token
|
||||
HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE=https://push-api.cloud.huawei.com/v1/{app_id}/messages:send
|
||||
|
||||
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
|
||||
# OPPO 新消息分类(2024-11-20 后创建的应用必须携带 category;channel_id 为后台「通道ID」;
|
||||
# notify_level 0=不传走默认,内容营销类仅支持 1/2)
|
||||
OPPO_PUSH_CHANNEL_ID=
|
||||
OPPO_PUSH_CATEGORY=
|
||||
OPPO_PUSH_NOTIFY_LEVEL=0
|
||||
|
||||
# ===== 无障碍保护存活监控(推送 + pull 后置兜底)=====
|
||||
# ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)=====
|
||||
HEARTBEAT_MONITOR_ENABLED=true
|
||||
HEARTBEAT_TIMEOUT_MINUTES=60
|
||||
HEARTBEAT_SCAN_INTERVAL_SEC=60
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
"""merge direct_vendor_push and feedback_type_reply heads
|
||||
|
||||
Revision ID: 1a924c274fce
|
||||
Revises: direct_vendor_push_fields, feedback_type_reply
|
||||
Create Date: 2026-07-14 18:53:02.856979
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '1a924c274fce'
|
||||
down_revision: Union[str, Sequence[str], None] = ('direct_vendor_push_fields', 'feedback_type_reply')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,49 @@
|
||||
"""comparison_record: 唯一键 (user_id,trace_id) → trace_id 单列 + user_id 可空
|
||||
|
||||
Revision ID: comparison_record_trace_unique
|
||||
Revises: feedback_type_reply
|
||||
Create Date: 2026-07-03 12:00:00.000000
|
||||
|
||||
比价记录改「后端 harvest」:app-server 在帧0(pricebot 出 trace_id)即建行,随 done/finalize
|
||||
逐步补全,客户端不再 POST 记录。故本表要两处调整:
|
||||
1. user_id 改**可空**——harvest 建行那刻(软鉴权 / 老客户端匿名)可能还没有 user_id。
|
||||
2. 唯一键 (user_id, trace_id) → **trace_id 单列**——trace_id 由 app-server 签发、全局唯一,
|
||||
一次比价一行;harvest 建行时 user_id 还没有,不能再用复合键去重。
|
||||
|
||||
⚠️ 上线前(QA)务必确认 comparison_record 无重复 trace_id:旧复合唯一键**允许**同 trace_id
|
||||
跨不同 user_id(实际上客户端 UUID 从不重复,但约束没拦),若真有重复,建 trace 单列唯一会失败。
|
||||
查: SELECT trace_id, COUNT(*) c FROM comparison_record GROUP BY trace_id HAVING c > 1;
|
||||
|
||||
SQLite 不支持直接 drop/alter 约束/列,用 batch_alter_table(建临时表+拷数据+换名),
|
||||
与 coupon_engage_per_package / store_mapping_* 同款;Postgres 直接执行原生 ALTER。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'comparison_record_trace_unique'
|
||||
down_revision: Union[str, Sequence[str], None] = 'feedback_type_reply'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
|
||||
# harvest 帧0 建行时 user_id 可能暂缺 → 放开非空。
|
||||
batch_op.alter_column('user_id', existing_type=sa.Integer(), nullable=True)
|
||||
# 复合唯一 → trace_id 单列唯一。
|
||||
batch_op.drop_constraint('uq_comparison_user_trace', type_='unique')
|
||||
batch_op.create_unique_constraint('uq_comparison_trace', ['trace_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
|
||||
batch_op.drop_constraint('uq_comparison_trace', type_='unique')
|
||||
batch_op.create_unique_constraint(
|
||||
'uq_comparison_user_trace', ['user_id', 'trace_id']
|
||||
)
|
||||
# 回退非空前提是当时无 null user_id 行(harvest 期可能有孤儿行,回滚需先清理)。
|
||||
batch_op.alter_column('user_id', existing_type=sa.Integer(), nullable=False)
|
||||
@@ -1,30 +0,0 @@
|
||||
"""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")
|
||||
@@ -24,9 +24,7 @@ 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 # 旧极光字段,仅兼容历史数据
|
||||
push_vendor: str | None = None
|
||||
push_token: str | None = None
|
||||
registration_id: str | None = None # 非空 = 拿到极光 token、可推送
|
||||
|
||||
ever_protected: bool # 是否开过无障碍(=该设备对功能有意义)
|
||||
first_protected_at: datetime | None = None # 首次开无障碍时刻(老设备为 null)
|
||||
|
||||
@@ -54,5 +54,29 @@ def get_current_user(
|
||||
return user
|
||||
|
||||
|
||||
def get_current_user_optional(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> User | None:
|
||||
"""软鉴权:有合法 Bearer 就返回 user,否则(无 header / 无效 token / 用户禁用)一律返回
|
||||
None,**不 raise**。
|
||||
|
||||
比价 step / finalize 灰度期用:新客户端带 JWT → 拿到 user_id 绑定 harvest 行;
|
||||
老客户端(不带 JWT)→ None,harvest 行 user_id 暂空,由其后续 /compare/record 上报补齐。
|
||||
等新版覆盖率够高,再把这几条端点从软鉴权收紧成硬 get_current_user。
|
||||
"""
|
||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||
return None
|
||||
try:
|
||||
payload = decode_token(credentials.credentials, expected_type="access")
|
||||
user = db.get(User, int(payload["sub"]))
|
||||
except (TokenError, KeyError, ValueError, TypeError):
|
||||
return None
|
||||
if user is None or user.status != "active":
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
OptionalUser = Annotated[User | None, Depends(get_current_user_optional)]
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
+6
-7
@@ -12,11 +12,11 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import test_account
|
||||
from app.core.ratelimit import enforce_rate_limit, rate_limit
|
||||
from app.core.ratelimit import enforce_rate_limit
|
||||
from app.core.security import TokenError, decode_token, issue_token_pair
|
||||
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
|
||||
from app.integrations.sms import SmsError, send_code, verify_code
|
||||
@@ -41,7 +41,7 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
# 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。
|
||||
SMS_LOGIN_MAX_PER_HOUR = 5
|
||||
# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。
|
||||
# 堵「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞 —— 那两道是单号维度,一机换号能绕开。
|
||||
# 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。
|
||||
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5
|
||||
|
||||
|
||||
@@ -100,8 +100,8 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
|
||||
return SmsSendResponse(sent=True, mock=True, cooldown_sec=0)
|
||||
|
||||
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码。
|
||||
# 补「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞(那两道是单号维度,一机换号能绕);设备维度按机器封顶,
|
||||
# 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。与路由上 IP 维度(10次/分钟)互补。
|
||||
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,
|
||||
# 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。
|
||||
enforce_rate_limit(
|
||||
request,
|
||||
scope="sms-send-device",
|
||||
@@ -125,7 +125,6 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
|
||||
"/sms/login",
|
||||
response_model=TokenWithUser,
|
||||
summary="手机号+验证码登录",
|
||||
dependencies=[Depends(rate_limit(20, 60, "sms-login"))], # 防撞库爆破(另有单码失败次数上限)
|
||||
)
|
||||
def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWithUser:
|
||||
# 测试账号:免验证码登录 + 每日上限 + 每次都走新手引导(详见 app/core/test_account.py)。
|
||||
@@ -143,7 +142,7 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
|
||||
return _login_response(user, onboarding_completed=False, force_onboarding=True)
|
||||
|
||||
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试。放在验证码校验
|
||||
# **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破。与路由上 IP 维度的 sms-login 限流(同 IP)互补。
|
||||
# **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破(另有单码失败 SMS_MAX_VERIFY_ATTEMPTS 次即作废兜底)。
|
||||
# ⚠️ 按设备而非手机号 → 一台机器换不同手机号刷登录也受限(防一机狂登多号);device_id 空(老客户端)时
|
||||
# 退化为该 IP 下所有空设备聚一桶,仍受限。
|
||||
enforce_rate_limit(
|
||||
|
||||
+203
-60
@@ -1,49 +1,114 @@
|
||||
"""外卖比价业务透传端点。
|
||||
"""外卖比价业务透传端点 + 后端 harvest 落库。
|
||||
|
||||
把客户端的 POST /api/v1/intent/recognize 和 /api/v1/price/step 请求原样转发给
|
||||
pricebot-backend 的 /api/intent/recognize 和 /api/price/step。MVP 阶段**不鉴权**
|
||||
(同 coupon/step,见 docs/待办与技术债.md P1:device_id 透传区分设备,待补 JWT +
|
||||
device_id↔user_id 绑定后才能做用户级画像)。
|
||||
把客户端 POST /api/v1/intent/* 和 /api/v1/price/step 转发给 pricebot,同时**由 app-server
|
||||
在透传里直接落库比价记录**(不再靠客户端 POST /compare/record):
|
||||
- 帧0(客户端首帧不带 trace_id)→ app-server 用 uuid 签发 trace_id、注入转发 body、回给
|
||||
客户端;并按 trace_id 建 running 行(harvest_running)。
|
||||
- 最终 done 帧 → 更新成 success/failed + 结果(harvest_done)。发奖不在这里:#113 已把
|
||||
邀请奖口径从「比价」移到「实际下单」(order.py)。
|
||||
- /trace/finalize(用户终止/Phase1 未识别,无 done)→ 更新成 cancelled/failed
|
||||
(harvest_abort,**不降级 success**)。
|
||||
trace_url 从 pricebot 响应**顶层 trace_url** 取(pricebot 每帧都带,见其 goal_engine.process_step)。
|
||||
|
||||
- Phase 1 /intent/recognize:从源平台(淘宝闪购/美团/京东外卖)购物车页识别店名+菜品+
|
||||
价格,一次性。
|
||||
- Phase 2 /price/step:多轮循环,在目标平台复现订单读到手价,直到 done。
|
||||
鉴权:软鉴权(OptionalUser)——新客户端带 JWT → 绑 user_id;老客户端不带 → user_id 暂空,
|
||||
由其后续 /compare/record 上报补(灰度期两条写路径按 trace_id reconcile,success 不被降级)。
|
||||
|
||||
真正的目标驱动比价逻辑在 pricebot-backend(GoalEngine,另一个 repo),本接口只是透传壳。
|
||||
电商(ecom)那两个端点 food MVP 暂不需要,以后放开 scene 时再加 /ecom/intent/recognize
|
||||
和 /ecom/step 两行即可。
|
||||
|
||||
pricebot 协议文档:
|
||||
pricebot-backend/docs/main/02_api_protocol.md
|
||||
真正的目标驱动比价逻辑在 pricebot-backend(GoalEngine,另一个 repo),本接口是透传壳 + 落库。
|
||||
pricebot 协议文档: pricebot-backend/docs/main/02_api_protocol.md
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
from app.api.deps import OptionalUser
|
||||
from app.core.config import settings
|
||||
from app.core.logging import trace_id_ctx
|
||||
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 comparison as crud_compare
|
||||
|
||||
logger = logging.getLogger("shagua.compare")
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["compare"])
|
||||
|
||||
|
||||
async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
|
||||
"""把请求体原样转发给 pricebot-backend 的 {upstream_path}。
|
||||
# ============================================================
|
||||
# harvest 落库(阻塞 SQLAlchemy → run_in_threadpool,独立 SessionLocal,不阻塞事件循环;
|
||||
# 任何写库异常都吞掉、绝不连累比价透传返回 —— 同 coupon.py 现有 best-effort 写法)。
|
||||
# ============================================================
|
||||
|
||||
跟 coupon_step 同款透传壳:不鉴权、不做 schema 校验,仅读 device_id/trace_id/step
|
||||
打日志。比价单帧是大上下文 / 逐帧 LLM,超时用 PRICEBOT_COMPARE_TIMEOUT_SEC(60s,
|
||||
比领券的 30s 长)。
|
||||
|
||||
def _harvest_running_blocking(
|
||||
trace_id: str, user_id: int | None, business_type: str,
|
||||
device_id: str | None, device_info: dict | None, trace_url: str | None,
|
||||
) -> None:
|
||||
with SessionLocal() as db:
|
||||
crud_compare.harvest_running(
|
||||
db, trace_id=trace_id, user_id=user_id, business_type=business_type,
|
||||
device_id=device_id, device_info=device_info, trace_url=trace_url,
|
||||
)
|
||||
logger.info(
|
||||
"harvest running row (user=%s)", user_id,
|
||||
extra={"phase": "harvest_running", "status": "running", "user_id": user_id},
|
||||
)
|
||||
|
||||
|
||||
def _harvest_done_blocking(
|
||||
trace_id: str, user_id: int | None, done_params: dict, business_type: str,
|
||||
device_id: str | None, device_info: dict | None, trace_url: str | None,
|
||||
) -> None:
|
||||
with SessionLocal() as db:
|
||||
rec, newly_success = crud_compare.harvest_done(
|
||||
db, trace_id=trace_id, user_id=user_id, done_params=done_params,
|
||||
business_type=business_type, device_id=device_id,
|
||||
device_info=device_info, trace_url=trace_url,
|
||||
)
|
||||
logger.info(
|
||||
"harvest done → %s saved=%s newly=%s", rec.status,
|
||||
rec.saved_amount_cents, newly_success,
|
||||
extra={"phase": "harvest_done", "status": rec.status,
|
||||
"saved_cents": rec.saved_amount_cents,
|
||||
"best_platform": rec.best_platform_id, "newly_success": newly_success,
|
||||
"user_id": user_id},
|
||||
)
|
||||
# 不在此处发邀请奖:#113 已把发奖口径从「比价」移到「实际下单」(order.py),harvest
|
||||
# 只记录比价、不发奖。否则比价先于下单 + try_reward 幂等闸会让奖落在「比价」这步,
|
||||
# 架空 #113 的「下单才发奖」防刷意图(newly_success 仅留作日志观测)。
|
||||
|
||||
|
||||
def _harvest_abort_blocking(
|
||||
trace_id: str, status_hint: str, reason: str | None, trace_url: str | None,
|
||||
) -> None:
|
||||
with SessionLocal() as db:
|
||||
rec = crud_compare.harvest_abort(
|
||||
db, trace_id=trace_id, status=status_hint, reason=reason, trace_url=trace_url,
|
||||
)
|
||||
logger.info(
|
||||
"harvest abort → %s", (rec.status if rec else "no-row"),
|
||||
extra={"phase": "harvest_abort",
|
||||
"status": (rec.status if rec else None), "reason": reason},
|
||||
)
|
||||
|
||||
|
||||
async def _forward(
|
||||
request: Request, upstream_path: str, user, *, harvest_first_frame: bool = True,
|
||||
) -> tuple[dict[str, Any], str, dict[str, Any]]:
|
||||
"""透传给 pricebot 的 {upstream_path},并 mint trace_id + 首帧建 running 行。
|
||||
返回 (resp_json, trace_id, meta)。
|
||||
|
||||
- 客户端首帧不带 trace_id → app-server 用 uuid 签发、写进转发 body、回填进响应顶层
|
||||
`trace_id`(客户端据此拿到后续帧都带上)。带了(老客户端 / 后续帧)→ 原样用、走原始 bytes 快路。
|
||||
- 仅**本次 mint(=首帧)**建 running 行(idempotent);后续帧不再写库。
|
||||
"""
|
||||
# 读原始字节,避免"反序列化→再序列化"的双重 JSON(省 ~一半透传 CPU,让 app-server
|
||||
# 单 worker 也扛得住高并发)。只 json.loads 一次拿 trace_id 做亲和 + 打日志,转发时
|
||||
# 直接发原始 bytes(content=raw),不重新 dumps。
|
||||
raw = await request.body()
|
||||
try:
|
||||
meta = json.loads(raw)
|
||||
@@ -52,26 +117,41 @@ async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
|
||||
# 按 trace_id 一致性 hash 选 pricebot 实例(同一比价的所有帧落同一进程,内存维护 state)
|
||||
base = pick_pricebot(meta.get("trace_id"))
|
||||
trace_id = meta.get("trace_id")
|
||||
minted = False
|
||||
if not trace_id:
|
||||
trace_id = str(uuid.uuid4())
|
||||
meta["trace_id"] = trace_id
|
||||
raw = json.dumps(meta).encode() # 仅首帧重新序列化(注入 trace_id);后续帧走原始 bytes
|
||||
minted = True
|
||||
|
||||
# 请求级 trace_id 贯穿:此后本请求(含 run_in_threadpool 里的 harvest)每行日志自动带 trace_id
|
||||
trace_id_ctx.set(trace_id)
|
||||
|
||||
base = pick_pricebot(trace_id)
|
||||
url = f"{base.rstrip('/')}{upstream_path}"
|
||||
timeout = settings.PRICEBOT_COMPARE_TIMEOUT_SEC
|
||||
step = meta.get("step")
|
||||
|
||||
logger.info(
|
||||
"compare %s device_id=%s trace_id=%s step=%s",
|
||||
upstream_path,
|
||||
meta.get("device_id"),
|
||||
meta.get("trace_id"),
|
||||
meta.get("step"),
|
||||
"→ pricebot %s step=%s%s", upstream_path, step, " [mint]" if minted else "",
|
||||
extra={"phase": "forward", "endpoint": upstream_path, "step": step,
|
||||
"device_id": meta.get("device_id"), "minted": minted,
|
||||
"query": meta.get("query"), "user_id": (user.id if user else None)},
|
||||
)
|
||||
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
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)
|
||||
logger.error(
|
||||
"← pricebot %s 不可达: %s", upstream_path, e,
|
||||
extra={"phase": "resp", "endpoint": upstream_path, "step": step,
|
||||
"error": "upstream_unreachable"},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"pricebot upstream unreachable: {e}",
|
||||
@@ -79,49 +159,112 @@ async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
|
||||
|
||||
if resp.status_code >= 500:
|
||||
logger.error(
|
||||
"[pricebot] 5xx status=%d body=%s",
|
||||
resp.status_code,
|
||||
resp.text[:500],
|
||||
"← pricebot %s 5xx=%d", upstream_path, resp.status_code,
|
||||
extra={"phase": "resp", "endpoint": upstream_path, "step": step,
|
||||
"http_status": resp.status_code, "error": "upstream_5xx"},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"pricebot upstream returned {resp.status_code}",
|
||||
)
|
||||
|
||||
return resp.json()
|
||||
resp_json = resp.json()
|
||||
cost_ms = int((time.monotonic() - t0) * 1000)
|
||||
if not isinstance(resp_json, dict):
|
||||
return {}, trace_id, meta
|
||||
# 回传 app-server 签发的 trace_id(新客户端首帧据此拿到,后续帧都带上它)
|
||||
resp_json.setdefault("trace_id", trace_id)
|
||||
_action = resp_json.get("action") or {}
|
||||
logger.info(
|
||||
"← pricebot %s cmd=%s cont=%s %dms", upstream_path,
|
||||
_action.get("command"), resp_json.get("continue"), cost_ms,
|
||||
extra={"phase": "resp", "endpoint": upstream_path, "step": step,
|
||||
"command": _action.get("command"), "continue": resp_json.get("continue"),
|
||||
"cost_ms": cost_ms},
|
||||
)
|
||||
|
||||
# 首帧建 running 行(仅本次 mint;老客户端自带 trace_id → 不 mint → 由 done / 其 POST 建行)
|
||||
if minted and harvest_first_frame:
|
||||
try:
|
||||
await run_in_threadpool(
|
||||
_harvest_running_blocking, trace_id,
|
||||
(user.id if user else None), "food",
|
||||
meta.get("device_id"), meta.get("device_info"),
|
||||
resp_json.get("trace_url"),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("harvest_running failed trace=%s: %s", trace_id, e)
|
||||
|
||||
return resp_json, trace_id, meta
|
||||
|
||||
|
||||
@router.post("/intent/recognize", summary="外卖比价 Phase 1 意图识别 (透传到 pricebot)")
|
||||
async def intent_recognize(request: Request) -> dict[str, Any]:
|
||||
return await _passthrough(request, "/api/intent/recognize")
|
||||
@router.post("/intent/recognize", summary="外卖比价 Phase 1 意图识别 (透传 + 建 running 行)")
|
||||
async def intent_recognize(request: Request, user: OptionalUser) -> dict[str, Any]:
|
||||
resp, _, _ = await _forward(request, "/api/intent/recognize", user)
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/intent/step", summary="外卖比价 Phase 1 多帧意图识别 (透传到 pricebot, 仅淘宝源)")
|
||||
async def intent_step(request: Request) -> dict[str, Any]:
|
||||
# 多帧版意图识别(展开+滚动采集→提取): 循环调用直到 done(done 帧顶层带
|
||||
# result+calibration)。目前仅淘宝源走这条, 其它源走上面单次 /intent/recognize。
|
||||
return await _passthrough(request, "/api/intent/step")
|
||||
@router.post("/intent/step", summary="外卖比价 Phase 1 多帧意图识别 (透传, 仅淘宝源)")
|
||||
async def intent_step(request: Request, user: OptionalUser) -> dict[str, Any]:
|
||||
resp, _, _ = await _forward(request, "/api/intent/step", user)
|
||||
return resp
|
||||
|
||||
|
||||
@router.post(
|
||||
"/intent/precoupon/step",
|
||||
summary="外卖比价 Phase 0 意图识别前先用券 (透传到 pricebot, 仅美团源)",
|
||||
)
|
||||
async def intent_precoupon_step(request: Request) -> dict[str, Any]:
|
||||
# 美团源平台『意图识别前先用券』多帧循环: 客户端在调 /intent/recognize 之前先循环
|
||||
# 调本端点到 done(continue=false)。订单页底部有『点击使用X红包』就自动选最大免费券
|
||||
# 用上, 已用券/无券则首帧秒过。与 /intent/step 同属 intent 域, 复用同一透传壳。
|
||||
return await _passthrough(request, "/api/intent/precoupon/step")
|
||||
@router.post("/intent/precoupon/step", summary="外卖比价 Phase 0 识别前先用券 (透传, 仅美团源)")
|
||||
async def intent_precoupon_step(request: Request, user: OptionalUser) -> dict[str, Any]:
|
||||
resp, _, _ = await _forward(request, "/api/intent/precoupon/step", user)
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传到 pricebot)")
|
||||
async def price_step(request: Request) -> dict[str, Any]:
|
||||
return await _passthrough(request, "/api/price/step")
|
||||
@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传 + done 落库)")
|
||||
async def price_step(request: Request, user: OptionalUser) -> dict[str, Any]:
|
||||
resp, trace_id, meta = await _forward(request, "/api/price/step", user)
|
||||
# 最终 done 帧(command=done 且 continue=false)→ harvest 更新成终态。
|
||||
# (单平台中途 done 被 pricebot 改写成 wait+continue=true,不会命中这里,同 coupon 语义。)
|
||||
action = resp.get("action") or {}
|
||||
if action.get("command") == "done" and not resp.get("continue", True):
|
||||
done_params = action.get("params") or {}
|
||||
try:
|
||||
await run_in_threadpool(
|
||||
_harvest_done_blocking, trace_id, (user.id if user else None),
|
||||
done_params, "food",
|
||||
meta.get("device_id"), meta.get("device_info"),
|
||||
resp.get("trace_url") or done_params.get("trace_url"),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("harvest_done failed trace=%s: %s", trace_id, e)
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传到 pricebot, 终止/未识别拿 trace_url)")
|
||||
async def trace_finalize(request: Request) -> dict[str, Any]:
|
||||
# 用户终止 / Phase1 未识别没走到 done 帧, pricebot 没上云也没回传 trace_url。客户端收尾时
|
||||
# 打这个, _passthrough 按 trace_id 一致性 hash 落到处理这条 trace 的同一 pricebot 进程
|
||||
# (dir_cache 在那, 才能算对 trace 目录), 由后者打包上云返回 {trace_url}。
|
||||
return await _passthrough(request, "/api/trace/finalize")
|
||||
@router.post("/trace/epilogue", summary="比价结果页尾声帧 (透传到 pricebot, 用户视角截图入 trace)")
|
||||
async def trace_epilogue(request: Request, user: OptionalUser) -> dict[str, Any]:
|
||||
# App 收到 done、渲染完结果页后,把自己页面的截图(base64)传给 pricebot 存进 trace
|
||||
# 目录并触发重传 —— trace 里补上"用户实际看到的汇总页"(步骤帧只有目标 App 画面)。
|
||||
# body ~几百 KB(截图 base64), 纯透传壳(harvest_first_frame=False:不建行/不落库,
|
||||
# 该 trace 的记录已由 done/finalize 落终态)。#112 原走 _passthrough,合并到 harvest
|
||||
# 分支后统一走 _forward(_passthrough 已并入它)。
|
||||
resp, _, _ = await _forward(
|
||||
request, "/api/trace/epilogue", user, harvest_first_frame=False,
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传 + 夭折落库)")
|
||||
async def trace_finalize(request: Request, user: OptionalUser) -> dict[str, Any]:
|
||||
# 用户终止 / Phase1 未识别没到 done 帧: pricebot 打包半截上云返回 {trace_url};
|
||||
# app-server 顺手把该 trace 的 running 行更新成夭折终态(**不降级 success**)。
|
||||
# 客户端 finalize body 带 status(cancelled/failed)+ reason;老客户端只带 trace_id →
|
||||
# 默认 cancelled,其后续 /compare/record 上报再补精确态。
|
||||
resp, trace_id, meta = await _forward(
|
||||
request, "/api/trace/finalize", user, harvest_first_frame=False,
|
||||
)
|
||||
try:
|
||||
await run_in_threadpool(
|
||||
_harvest_abort_blocking, trace_id,
|
||||
(meta.get("status") or "cancelled"),
|
||||
(meta.get("reason") or meta.get("information")),
|
||||
(resp.get("trace_url") if isinstance(resp, dict) else None),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("harvest_abort failed trace=%s: %s", trace_id, e)
|
||||
return resp
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
"""比价记录 endpoint(「我的比价记录」数据源)。
|
||||
"""比价记录 endpoint(「我的比价记录」+ admin 数据源)。
|
||||
|
||||
路由前缀 `/api/v1/compare`:
|
||||
POST /record 上报一次比价结果(幂等:同 user+trace_id 覆盖)
|
||||
POST /record (灰度期兼容)老客户端上报,按 **trace_id** 幂等 + 不降级 success
|
||||
GET /records 比价记录列表(游标分页)
|
||||
GET /records/{id} 单条详情(含 raw_payload 全量)
|
||||
|
||||
**均需鉴权**(CurrentUser)——与同文件无关的不鉴权透传 `compare.py` 分开:那个是
|
||||
转发壳(MVP 不鉴权),这里是按用户维度落库的业务接口,必须有 user_id。
|
||||
|
||||
注:本轮只做 server 端,客户端(android 仓)在 done 帧后调 POST /record 上报的改动
|
||||
另起一轮(见 app-server docs/待办与技术债.md P1)。
|
||||
**均需鉴权**(CurrentUser)。⚠️ 写路径现以 `compare.py` 透传壳的**后端 harvest** 为主
|
||||
(帧0 建 running 行 → done/finalize 落终态,新客户端不再 POST);本 POST /record 仅灰度期
|
||||
给老客户端用,与 harvest 按 trace_id reconcile。新版覆盖够高后可下线本 POST(阶段3)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,16 +19,15 @@ from app.api.deps import CurrentUser, DbSession
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.repositories import comparison as crud_compare
|
||||
from app.repositories import invite as crud_invite
|
||||
from app.services.pricebot_llm_calls import fetch_llm_calls
|
||||
from app.schemas.compare_record import (
|
||||
CompareStatsOut,
|
||||
ComparisonRecordCreatedOut,
|
||||
ComparisonRecordDetailOut,
|
||||
ComparisonRecordIn,
|
||||
ComparisonRecordPage,
|
||||
ComparisonRecordOut,
|
||||
ComparisonRecordPage,
|
||||
)
|
||||
from app.services.pricebot_llm_calls import fetch_llm_calls
|
||||
|
||||
logger = logging.getLogger("shagua.compare_record")
|
||||
|
||||
@@ -53,18 +50,8 @@ def report_record(
|
||||
# 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用
|
||||
# 独立 session 按 record id 回填 llm_calls + 派生 llm_call_count/retry_count。
|
||||
background_tasks.add_task(_backfill_llm_calls, rec.id, rec.trace_id)
|
||||
# 邀请 v2 发奖:被邀请人完成一次【成功】比价 → 给邀请人发邀请奖励金(幂等,只发一次)。
|
||||
# best-effort:发奖异常不影响比价上报本身(rec 已 commit),只 log;邀请人补偿靠后续对账。
|
||||
if rec.status == "success":
|
||||
try:
|
||||
reward = crud_invite.try_reward_on_compare(db, user.id)
|
||||
if reward.status == "granted":
|
||||
logger.info(
|
||||
"invite compare reward granted inviter=%s invitee=%s cents=%s",
|
||||
reward.inviter_user_id, user.id, reward.reward_cents,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 best-effort,发奖失败不阻塞上报
|
||||
logger.warning("invite compare reward failed invitee=%s: %s", user.id, e)
|
||||
# 注:邀请发奖已从"比价成功"挪到"实际下单"(见 api/v1/order.py report_order)——
|
||||
# 冰拍板:被邀请人完成比价并实际下单才算邀请成功,仅完成比价不再发奖。
|
||||
logger.info(
|
||||
"compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)",
|
||||
user.id,
|
||||
|
||||
+4
-93
@@ -1,22 +1,19 @@
|
||||
"""设备注册 / 心跳 endpoint(无障碍保护存活检测)。
|
||||
|
||||
路由前缀 /api/v1/device,需 Bearer 鉴权(设备绑登录用户)。
|
||||
POST /register 注册设备 / 更新厂商 push token(App 前台、拿到 push token 时调)
|
||||
POST /register 注册设备 / 更新 registration_id(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, BackgroundTasks, HTTPException, status
|
||||
from fastapi import APIRouter
|
||||
|
||||
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,
|
||||
@@ -25,8 +22,6 @@ from app.schemas.device import (
|
||||
LivenessAckRequest,
|
||||
LivenessOut,
|
||||
OkResponse,
|
||||
PushTestOut,
|
||||
PushTestRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.device")
|
||||
@@ -34,37 +29,6 @@ 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,
|
||||
@@ -76,17 +40,13 @@ 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 vendor=%s token=%s legacy_reg=%s",
|
||||
"device register user_id=%d device_id=%s reg=%s",
|
||||
user.id,
|
||||
req.device_id,
|
||||
req.push_vendor,
|
||||
bool(req.push_token),
|
||||
bool(req.registration_id),
|
||||
)
|
||||
return DeviceOut.model_validate(device)
|
||||
@@ -104,59 +64,10 @@ 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,
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
"""消息通知中心 endpoint(PRD《消息通知中心》)。
|
||||
|
||||
路由前缀 `/api/v1/notifications`,需 Bearer 鉴权(消息按用户隔离)。
|
||||
GET / 消息列表(分页;全列表时间倒序,不分组——PRD 原文的分组已取消)
|
||||
GET /unread-count 未读总数(首页铃铛角标)
|
||||
POST /read 标记已读({ids:[...]} 单条/多条 或 {all:true} 全量清零)
|
||||
|
||||
⚠️ 虚拟数据阶段:数据来自 repositories/notification_mock.py 内存 mock(重启复位,
|
||||
每个用户一套覆盖全部 13 种类型的固定样例)。接真实数据时只换 repository 实现。
|
||||
|
||||
⚠️ 字段命名:本组接口对外为 **camelCase**(sentAt / isRead / pageSize…,PRD 前端契约),
|
||||
详见 schemas/notification.py 顶部说明。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from app.api.deps import CurrentUser
|
||||
from app.core import notification_catalog as catalog
|
||||
from app.repositories import notification_mock as mock_repo
|
||||
from app.schemas.notification import (
|
||||
InfoRow,
|
||||
MarkReadOut,
|
||||
MarkReadRequest,
|
||||
NotificationItem,
|
||||
NotificationListOut,
|
||||
UnreadCountOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.notifications")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
def _to_item(n: mock_repo.MockNotification) -> NotificationItem:
|
||||
"""mock 记录 + 类型静态目录 → 接口出参。"""
|
||||
ntype = catalog.get_type(n.type_key)
|
||||
return NotificationItem(
|
||||
id=n.id,
|
||||
category=ntype.category,
|
||||
category_label=catalog.category_label(ntype.category),
|
||||
type=ntype.key,
|
||||
card_style=ntype.card_style,
|
||||
title=ntype.card_title,
|
||||
coins=n.coins,
|
||||
cash_cents=n.cash_cents,
|
||||
cash_yuan=mock_repo.cash_yuan(n.cash_cents),
|
||||
info_rows=[InfoRow(**row) for row in n.info_rows],
|
||||
action_text=ntype.action_text,
|
||||
extra=n.extra,
|
||||
sent_at=n.sent_at,
|
||||
is_read=n.is_read,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=NotificationListOut, summary="消息列表(分页)")
|
||||
def list_notifications(
|
||||
user: CurrentUser,
|
||||
page: int = Query(default=1, ge=1, description="页码,1 起"),
|
||||
page_size: int = Query(
|
||||
default=20, ge=1, le=100, alias="pageSize", description="每页条数,默认 20,最大 100"
|
||||
),
|
||||
) -> NotificationListOut:
|
||||
"""通知中心消息列表。
|
||||
|
||||
- 排序服务端已做好:**全列表按时间倒序**(最新在前,不做分类分组;PRD §1 的
|
||||
"按分类分组"为笔误,已与需求方确认取消),前端按返回顺序渲染即可。
|
||||
- 每条的字段构成与各版式说明见 NotificationItem schema。
|
||||
- 响应同时带 unreadCount,进页面时可顺手刷新角标。
|
||||
- 虚拟数据阶段:首次请求生成一套固定样例(13 类型全覆盖 + 今天/昨天/当年/跨年
|
||||
四种时间 + 已读未读混合),已读状态在服务端内存维护,重启服务复位。
|
||||
"""
|
||||
items, total, unread = mock_repo.list_notifications(user.id, page=page, page_size=page_size)
|
||||
return NotificationListOut(
|
||||
items=[_to_item(n) for n in items],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
has_more=page * page_size < total,
|
||||
unread_count=unread,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/unread-count", response_model=UnreadCountOut, summary="未读总数(铃铛角标)")
|
||||
def get_unread_count(user: CurrentUser) -> UnreadCountOut:
|
||||
"""首页铃铛角标数据源。刷新时机(PRD §4):进入首页时、从通知中心/其他页面返回首页时。
|
||||
|
||||
- count:精确未读条数;
|
||||
- badgeText:直接可展示的角标文案——超过 99 返回 "99+",等于 0 返回 null(隐藏整个角标)。
|
||||
"""
|
||||
count = mock_repo.unread_count(user.id)
|
||||
badge = None if count == 0 else ("99+" if count > 99 else str(count))
|
||||
return UnreadCountOut(count=count, badge_text=badge)
|
||||
|
||||
|
||||
@router.post("/read", response_model=MarkReadOut, summary="标记已读(单条/多条/全量)")
|
||||
def mark_read(req: MarkReadRequest, user: CurrentUser) -> MarkReadOut:
|
||||
"""红点消除(PRD §4),两种调用模式:
|
||||
|
||||
1. `{"ids": [90001]}` —— 点击某张消息卡片(无论点击后是跳转/弹窗/无动作都算已读);
|
||||
用户点击 push 直达落地页时,客户端也用它把对应站内消息同步置读(push extras 里带
|
||||
notificationId);
|
||||
2. `{"all": true}` —— 进入通知中心自动清零(只是浏览列表就消红点,无需逐条点击)。
|
||||
|
||||
幂等:不存在或已读的 id 忽略;重复调用 markedCount 为 0、不报错。
|
||||
响应带 unreadCount(处理后剩余未读),可直接刷新铃铛角标。
|
||||
"""
|
||||
if not req.all and not req.ids:
|
||||
raise HTTPException(status_code=400, detail="ids 与 all 至少传一个:{ids:[...]} 或 {all:true}")
|
||||
marked, unread = mock_repo.mark_read(user.id, ids=req.ids, mark_all=req.all)
|
||||
logger.info(
|
||||
"notifications read user_id=%d mode=%s marked=%d unread_left=%d",
|
||||
user.id,
|
||||
"all" if req.all else f"ids×{len(req.ids or [])}",
|
||||
marked,
|
||||
unread,
|
||||
)
|
||||
return MarkReadOut(ok=True, marked_count=marked, unread_count=unread)
|
||||
@@ -1,9 +1,14 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.repositories import invite as crud_invite
|
||||
from app.repositories import savings as crud_savings
|
||||
from app.schemas.order import OrderReportOut, OrderReportRequest
|
||||
|
||||
logger = logging.getLogger("shagua.order")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/order", tags=["order"])
|
||||
|
||||
|
||||
@@ -15,6 +20,20 @@ router = APIRouter(prefix="/api/v1/order", tags=["order"])
|
||||
def report_order(req: OrderReportRequest, user: CurrentUser, db: DbSession) -> OrderReportOut:
|
||||
# 记账唯一真相表是 savings_record(source='compare')。
|
||||
rec, duplicated = crud_savings.create_from_report(db, user.id, req)
|
||||
# 邀请 v2 发奖:被邀请人【实际下单】(而非仅完成比价)才给邀请人发邀请奖励金(幂等,只发一次)。
|
||||
# 触发点从"比价成功上报"挪到这里(冰:完成比价并实际下单才算成功)。仅首次真实上报触发,
|
||||
# 重复上报(duplicated)跳过。best-effort:发奖异常不影响订单上报本身(rec 已 commit),只 log;
|
||||
# try_reward_on_compare 自带 compare_reward_granted 幂等闸,漏发靠后续对账补。
|
||||
if not duplicated:
|
||||
try:
|
||||
reward = crud_invite.try_reward_on_compare(db, user.id)
|
||||
if reward.status == "granted":
|
||||
logger.info(
|
||||
"invite order reward granted inviter=%s invitee=%s cents=%s",
|
||||
reward.inviter_user_id, user.id, reward.reward_cents,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 best-effort,发奖失败不阻塞订单上报
|
||||
logger.warning("invite order reward failed invitee=%s: %s", user.id, e)
|
||||
return OrderReportOut(
|
||||
id=rec.id,
|
||||
platform=rec.platform or req.platform,
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
"""厂商推送 测试/联调 endpoint。
|
||||
|
||||
路由前缀 `/api/v1/push`,需 Bearer 鉴权。围绕「消息中心 13 类通知的厂商直推」提供三件套:
|
||||
GET /vendors 5 个厂商(荣耀/华为/小米/OPPO/vivo)服务端凭据配置状态,缺哪些键一目了然
|
||||
GET /templates 13 种通知类型的 push 标题/正文模板 + PRD 示例渲染效果
|
||||
POST /test 测试发送:默认 mock(不真调厂商 API,回显渲染结果);mock=false 真发到手机
|
||||
|
||||
与 `/api/v1/device/push-test`(无障碍召回通道的延迟自测)互补:本组面向消息中心 13 类
|
||||
push 的文案/参数/厂商通道联调。真实业务触发(奖励过期、提现回执等)接入后走
|
||||
integrations.vendor_push.send_notification,与本测试端点同一条发送链路。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import notification_catalog as catalog
|
||||
from app.integrations import vendor_push
|
||||
from app.repositories import device as device_repo
|
||||
from app.repositories import notification_mock as mock_repo
|
||||
from app.schemas.push import (
|
||||
PushTemplateOut,
|
||||
PushTemplatesOut,
|
||||
PushTestOut,
|
||||
PushTestRequest,
|
||||
PushVendorsOut,
|
||||
PushVendorStatus,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.push")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/push", tags=["push"])
|
||||
|
||||
# /vendors 的展示顺序(荣耀/华为/小米/OPPO/vivo)
|
||||
_VENDOR_ORDER = ("honor", "huawei", "xiaomi", "oppo", "vivo")
|
||||
|
||||
_GENERIC_TEST_TITLE = "傻瓜比价测试推送"
|
||||
_GENERIC_TEST_BODY = "这是一条{label}通道的测试推送,收到说明服务端 → {label}厂商通道已打通。"
|
||||
|
||||
|
||||
@router.get("/vendors", response_model=PushVendorsOut, summary="厂商推送配置状态")
|
||||
def vendor_status(user: CurrentUser) -> PushVendorsOut:
|
||||
"""检查 5 个厂商的服务端推送凭据是否配齐(读 .env,不打厂商接口)。
|
||||
|
||||
missingKeys 列出的即还需要在 .env 里补的配置键;全空说明该厂商随时可真发。
|
||||
mock 测试(POST /test 默认模式)不依赖任何凭据。
|
||||
"""
|
||||
return PushVendorsOut(
|
||||
vendors=[
|
||||
PushVendorStatus(
|
||||
vendor=v,
|
||||
label=vendor_push.VENDOR_LABELS[v],
|
||||
configured=not vendor_push.missing_settings(v),
|
||||
missing_keys=vendor_push.missing_settings(v),
|
||||
)
|
||||
for v in _VENDOR_ORDER
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/templates", response_model=PushTemplatesOut, summary="13 类通知的 push 模板预览")
|
||||
def push_templates(user: CurrentUser) -> PushTemplatesOut:
|
||||
"""PRD §5 的 13 条 push 文案模板 + 用示例值渲染后的效果,联调对文案用。
|
||||
|
||||
标题固定(≤11 字不带变量);正文里 {var} 为变量,POST /test 的 vars 字段可覆盖。
|
||||
"""
|
||||
templates: list[PushTemplateOut] = []
|
||||
for key, ntype in catalog.TYPES.items():
|
||||
title, body_sample = catalog.render_push(key)
|
||||
templates.append(
|
||||
PushTemplateOut(
|
||||
type=key,
|
||||
category=ntype.category,
|
||||
category_label=catalog.category_label(ntype.category),
|
||||
card_style=ntype.card_style,
|
||||
push_title=title,
|
||||
push_body_sample=body_sample,
|
||||
push_body_template=ntype.push_body_template,
|
||||
variables=catalog.push_variable_names(key),
|
||||
sample_vars=ntype.sample_vars,
|
||||
)
|
||||
)
|
||||
return PushTemplatesOut(templates=templates)
|
||||
|
||||
|
||||
@router.post("/test", response_model=PushTestOut, summary="测试发送厂商推送(默认 mock)")
|
||||
def send_test_push(req: PushTestRequest, user: CurrentUser, db: DbSession) -> PushTestOut:
|
||||
"""向指定厂商 token(或本用户已注册设备)发一条测试 push。
|
||||
|
||||
- **mock=true(默认)**:不真调厂商 API——校验参数、渲染文案后原样返回,并在
|
||||
missingKeys 里提示真发前还缺哪些配置。虚拟数据阶段随便打,不会骚扰真机。
|
||||
- **mock=false**:真发。要求该厂商凭据已配置(缺则 400 报缺失键);厂商 API 报错回 502。
|
||||
注意 vivo 未上架前是测试推送模式(VIVO_PUSH_MODE=1),目标手机要先在 vivo 后台加为测试设备。
|
||||
- **createNotification=true**:同时往消息中心 mock 列表插一条同类型未读通知并把
|
||||
notificationId 放进 push extras → 客户端点击 push 后调 POST /notifications/read
|
||||
{ids:[notificationId]} 即可闭环验证 PRD §4 的 push 已读联动。
|
||||
"""
|
||||
# ---- 1. 解析推送目标(vendor + token):直填优先,缺则按 deviceId 反查已注册设备 ----
|
||||
vendor_raw = req.vendor.strip()
|
||||
push_token = req.push_token.strip()
|
||||
if (not vendor_raw or not push_token) and req.device_id.strip():
|
||||
device = device_repo.get_device(db, user_id=user.id, device_id=req.device_id.strip())
|
||||
if device is not None:
|
||||
vendor_raw = vendor_raw or (device.push_vendor or "")
|
||||
push_token = push_token or (device.push_token or "")
|
||||
|
||||
vendor = vendor_push.normalize_vendor(vendor_raw)
|
||||
if not vendor or vendor not in vendor_push.SUPPORTED_VENDORS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"vendor 无效或无法从设备推断,支持: {', '.join(_VENDOR_ORDER)}",
|
||||
)
|
||||
if not push_token:
|
||||
# mock 模式给个占位 token,让「只想看看渲染结果」的调用免造数据;真发必须给真 token。
|
||||
if req.mock:
|
||||
push_token = "mock-token"
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="push token 未知:请直传 pushToken,或先用该设备调 /api/v1/device/register 上报",
|
||||
)
|
||||
|
||||
# ---- 2. 组装文案与 extras:直填 > type 模板 > 通用测试文案 ----
|
||||
extras: dict[str, str] = {}
|
||||
notification_id: int | None = None
|
||||
if req.type:
|
||||
try:
|
||||
title, body = catalog.render_push(req.type, req.vars or None)
|
||||
except catalog.UnknownNotificationType as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
extras["type"] = req.type
|
||||
if req.create_notification:
|
||||
item = mock_repo.insert_sample(user.id, req.type)
|
||||
notification_id = item.id
|
||||
extras.update({str(k): str(v) for k, v in item.extra.items()})
|
||||
extras["notificationId"] = str(item.id)
|
||||
else:
|
||||
label = vendor_push.VENDOR_LABELS[vendor]
|
||||
title = _GENERIC_TEST_TITLE
|
||||
body = _GENERIC_TEST_BODY.format(label=label)
|
||||
extras["type"] = "push_test"
|
||||
if req.title.strip():
|
||||
title = req.title.strip()
|
||||
if req.content.strip():
|
||||
body = req.content.strip()
|
||||
|
||||
# ---- 3. 发送(mock / 真发) ----
|
||||
missing = vendor_push.missing_settings(vendor)
|
||||
vendor_response = None
|
||||
if req.mock:
|
||||
vendor_push.send_notification(
|
||||
vendor, push_token, title=title, body=body, extras=extras, mock=True
|
||||
)
|
||||
else:
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{vendor_push.VENDOR_LABELS[vendor]}推送凭据未配置,先在 .env 补上: "
|
||||
f"{', '.join(missing)}",
|
||||
)
|
||||
try:
|
||||
vendor_response = vendor_push.send_notification(
|
||||
vendor, push_token, title=title, body=body, extras=extras
|
||||
)
|
||||
except vendor_push.VendorPushError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"厂商推送失败: {e}"
|
||||
) from e
|
||||
|
||||
logger.info(
|
||||
"push test user_id=%d vendor=%s type=%s mock=%s notification_id=%s",
|
||||
user.id, vendor, req.type or "generic", req.mock, notification_id,
|
||||
)
|
||||
return PushTestOut(
|
||||
ok=True,
|
||||
mock=req.mock,
|
||||
vendor=vendor,
|
||||
title=title,
|
||||
body=body,
|
||||
extras=extras,
|
||||
notification_id=notification_id,
|
||||
missing_keys=missing,
|
||||
vendor_response=vendor_response,
|
||||
)
|
||||
@@ -69,6 +69,18 @@ def complete_onboarding(
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.post("/onboarding/reset", response_model=OkResponse, summary="重置新手引导(删该 设备+账号 完成标记,下次登录重走)")
|
||||
def reset_onboarding(
|
||||
req: OnboardingCompleteRequest, user: CurrentUser, db: DbSession
|
||||
) -> OkResponse:
|
||||
"""删该 (当前账号, device_id) 的引导完成标记 → 下次登录 onboarding_completed=false,客户端重走。
|
||||
与 /onboarding/complete 互逆。device_id 取客户端硬件级 ANDROID_ID(与登录/complete 一致)。
|
||||
幂等:无记录也返回 ok。仅删自己(当前 JWT 用户)这台设备的记录,不影响别的账号/设备。"""
|
||||
deleted = onboarding_repo.delete_completion(db, user_id=user.id, device_id=req.device_id)
|
||||
logger.info("onboarding reset user_id=%d device_len=%d deleted=%d", user.id, len(req.device_id), deleted)
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/onboarding/status",
|
||||
response_model=OnboardingStatusResponse,
|
||||
|
||||
+1
-54
@@ -66,59 +66,7 @@ class Settings(BaseSettings):
|
||||
JG_VERIFY_ENDPOINT: str = "https://api.verification.jpush.cn/v1/web/loginTokenVerify"
|
||||
JG_REQUEST_TIMEOUT_SEC: int = 15
|
||||
|
||||
# ===== 厂商直推(无障碍保护存活告警)=====
|
||||
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"
|
||||
)
|
||||
|
||||
# 华为 Push Kit:AGC 控制台 → 项目设置 → 常规 → 应用,取 AppId + AppSecret
|
||||
# (OAuth 换 token 时 client_id 即 AppId)。发送走 v1 messages:send,成功码 80000000。
|
||||
HUAWEI_PUSH_APP_ID: str = ""
|
||||
HUAWEI_PUSH_APP_SECRET: str = ""
|
||||
HUAWEI_PUSH_TOKEN_ENDPOINT: str = "https://oauth-login.cloud.huawei.com/oauth2/v3/token"
|
||||
HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE: str = (
|
||||
"https://push-api.cloud.huawei.com/v1/{app_id}/messages:send"
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
# OPPO 新消息分类(2024-11-20 后创建的应用必须携带,否则可能被拒/限):
|
||||
# channel_id=通知栏通道(OPPO 后台「通道ID」),category=消息分类 code(如 MARKETING 内容营销)。
|
||||
# notify_level=提醒方式(0=不传走 OPPO 默认;内容营销类仅支持 1 通知栏/2 通知栏+锁屏)。
|
||||
OPPO_PUSH_CHANNEL_ID: str = ""
|
||||
OPPO_PUSH_CATEGORY: str = ""
|
||||
OPPO_PUSH_NOTIFY_LEVEL: int = 0
|
||||
|
||||
# 无障碍保护存活监控后台任务(推送 + pull 后置兜底)
|
||||
# 无障碍保护存活监控后台任务(pull 后置检测;本期不接推送)
|
||||
HEARTBEAT_MONITOR_ENABLED: bool = True # 总开关
|
||||
HEARTBEAT_TIMEOUT_MINUTES: int = 60 # 多久没心跳算掉线(1 小时,避免短暂离线误判被杀)
|
||||
HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期
|
||||
@@ -133,7 +81,6 @@ class Settings(BaseSettings):
|
||||
SMS_SIGN_ID: int = 31729 # 极光短信签名 ID(非机密,可被 .env 覆盖)
|
||||
SMS_TEMPLATE_ID: int = 1 # 极光短信模板 ID(变量名 code,有效期 5 分钟)
|
||||
SMS_CODE_LENGTH: int = 6 # 验证码位数(本服务生成;前端 code 字段 4-8 位兼容)
|
||||
SMS_DAILY_LIMIT_PER_PHONE: int = 10 # 单手机号每日发送上限(防刷 + 控费)
|
||||
SMS_MAX_VERIFY_ATTEMPTS: int = 5 # 单个验证码最多校验失败次数,超过即作废(防爆破)
|
||||
|
||||
# ===== 测试账号(release 包全流程联调用)=====
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""无障碍保护存活监控后台任务。
|
||||
|
||||
周期扫描「曾经保护过、当前 alive、心跳超时」的设备 = App 被彻底杀掉/无障碍已停(心跳断了),
|
||||
**命中即在服务器终端打印告警并尝试厂商直推**;并把状态机
|
||||
**命中即在服务器终端打印告警**(本期先不接推送,工程量大,用终端打印代替真实通知);并把状态机
|
||||
推进到 notified 防每轮重复打印(心跳恢复时由 repositories.device.touch_heartbeat 重置回 alive)。
|
||||
结构仿 withdraw_reconcile_worker(单实例锁 + asyncio 轮询 + 优雅退出)。
|
||||
|
||||
@@ -22,7 +22,6 @@ 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")
|
||||
@@ -72,66 +71,32 @@ 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() # noqa: UP017
|
||||
ref = datetime.now(timezone.utc) if last.tzinfo is not None else datetime.utcnow()
|
||||
return int((ref - last).total_seconds())
|
||||
|
||||
|
||||
def _scan_once(timeout_minutes: int) -> dict:
|
||||
"""扫描一轮:找出心跳超时(App 被彻底杀掉/无障碍已停)的设备并召回。
|
||||
"""扫描一轮:找出心跳超时(App 被彻底杀掉/无障碍已停)的设备,在**服务器终端打印**告警代替真实推送。
|
||||
|
||||
有 push_vendor + push_token 时先发厂商直推,无 token 或推送失败时仍置
|
||||
kill_alert_pending,客户端下次进 App 继续走后置提醒兜底。
|
||||
本期不接推送(极光/厂商通道工程量大),只做服务端掉线检测:命中即 logger.warning 打印到终端,
|
||||
并把状态机推进到 notified 防每轮重复打印(心跳恢复时 touch_heartbeat 会重置回 alive)。
|
||||
"""
|
||||
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 已被杀/无障碍已停。",
|
||||
" → 判定 App 已被杀/无障碍已停。【已置 kill_alert_pending: 用户下次进 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,
|
||||
"pushed": pushed,
|
||||
"push_failed": push_failed,
|
||||
}
|
||||
return {"checked": len(overdue), "notified": notified}
|
||||
|
||||
|
||||
async def _run_loop() -> None:
|
||||
|
||||
+57
-8
@@ -2,9 +2,15 @@
|
||||
|
||||
业务代码用 `logger = logging.getLogger("shagua.xxx")` 即可, 本模块在 main.py 启动时调一次。
|
||||
|
||||
- 控制台(stdout): 人类可读文本, 给 systemd / 本地看。
|
||||
- 文件 `logs/app-server.log`: 单行 JSON, 供阿里云 SLS/Logtail 采集(JSON 模式零正则);
|
||||
异常栈作为字段内嵌不换行 → 每条日志一行。
|
||||
- 控制台(stdout): 人类可读文本, 给 systemd / 本地看; 有 trace 时行尾附 `trace=xxx`。
|
||||
- 文件 `logs/app-server.log`: 单行 JSON, 供**阿里云 SLS / Logtail** 采集(JSON 模式零正则);
|
||||
异常栈内嵌为字段不换行 → 每条日志一行。
|
||||
- **结构化字段(SLS 可直接查/聚合)**:
|
||||
- `trace_id`: 请求级贯穿——在入口 `trace_id_ctx.set(...)` 后, 本请求内**每一行日志**(含
|
||||
run_in_threadpool 里的 harvest, contextvars 自动拷进线程)都自动带上, 无需手写。
|
||||
SLS 里 `trace_id: "xxx"` 一查即得整条比价链路, 按 time 升序即请求顺序。
|
||||
- 任意 `logger.info(msg, extra={"phase": ..., "step": ..., "command": ...})` 的 extra
|
||||
键都会平铺进 JSON 顶层 → SLS 可按 phase/step/command/cost_ms 等过滤聚合。
|
||||
- 环境变量:
|
||||
- LOG_JSON_CONSOLE=1 控制台也输出 JSON
|
||||
- LOG_DIR / LOG_FILE 改落盘路径(默认 logs/app-server.log)
|
||||
@@ -17,13 +23,36 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# 请求级 trace_id:入口(如 compare.py 透传壳)set 之后, 本请求上下文(含 run_in_threadpool
|
||||
# 拷贝出去的线程)内所有日志自动带上。默认空串 = 非请求上下文(启动/后台 worker)。
|
||||
trace_id_ctx: ContextVar[str] = ContextVar("trace_id", default="")
|
||||
|
||||
|
||||
# 标准 LogRecord 属性 + 格式化期附加项:凡不在此集合的 record 属性都视为业务 extra, 平铺进 JSON。
|
||||
_RESERVED = set(
|
||||
logging.LogRecord("", 0, "", 0, "", (), None).__dict__
|
||||
) | {"message", "asctime", "trace_id", "taskName"}
|
||||
|
||||
|
||||
class _ContextFilter(logging.Filter):
|
||||
"""把 trace_id_ctx 注入每条 record(供两个 formatter 取用)。挂在 handler 上,
|
||||
命中每条(含 propagate 上来的)记录, 在 format 之前置好 record.trace_id。"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
if not hasattr(record, "trace_id"):
|
||||
record.trace_id = trace_id_ctx.get()
|
||||
return True
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
"""把 LogRecord 序列化成单行 JSON(SLS/Logtail 友好)。异常栈内嵌为字段, 整条仍是一行。"""
|
||||
"""LogRecord → 单行 JSON(SLS/Logtail 友好)。trace_id 提到顶层、extra 键平铺, 异常栈内嵌。"""
|
||||
|
||||
def __init__(self, service: str = "app-server"):
|
||||
super().__init__()
|
||||
self.service = service
|
||||
@@ -35,10 +64,17 @@ class JsonFormatter(logging.Formatter):
|
||||
"level": record.levelname,
|
||||
"service": self.service,
|
||||
"logger": record.name,
|
||||
"func": record.funcName,
|
||||
"line": record.lineno,
|
||||
"message": record.getMessage(),
|
||||
}
|
||||
tid = getattr(record, "trace_id", "") or trace_id_ctx.get()
|
||||
if tid:
|
||||
data["trace_id"] = tid
|
||||
# 业务 extra 字段(phase / step / endpoint / command / cost_ms / status ...)平铺进顶层
|
||||
for k, v in record.__dict__.items():
|
||||
if k not in _RESERVED and not k.startswith("_"):
|
||||
data[k] = v
|
||||
data["func"] = record.funcName
|
||||
data["line"] = record.lineno
|
||||
data["message"] = record.getMessage()
|
||||
if record.exc_info:
|
||||
data["exception"] = self.formatException(record.exc_info)
|
||||
if record.stack_info:
|
||||
@@ -46,6 +82,15 @@ class JsonFormatter(logging.Formatter):
|
||||
return json.dumps(data, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
class TextFormatter(logging.Formatter):
|
||||
"""控制台文本:标准行 + 有 trace_id 时行尾附 `trace=xxx`(无 trace 的启动/后台日志不加噪)。"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
base = super().format(record)
|
||||
tid = getattr(record, "trace_id", "") or trace_id_ctx.get()
|
||||
return f"{base} trace={tid}" if tid else base
|
||||
|
||||
|
||||
_CONFIGURED = False
|
||||
|
||||
|
||||
@@ -63,14 +108,17 @@ def setup_logging(debug: bool = False) -> None:
|
||||
for h in list(root.handlers):
|
||||
root.removeHandler(h)
|
||||
|
||||
ctx_filter = _ContextFilter()
|
||||
|
||||
# 控制台: 默认文本(systemd/本地看); LOG_JSON_CONSOLE=1 时输出 JSON
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
if os.getenv("LOG_JSON_CONSOLE") == "1":
|
||||
console.setFormatter(JsonFormatter(service))
|
||||
else:
|
||||
console.setFormatter(
|
||||
logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
TextFormatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
)
|
||||
console.addFilter(ctx_filter)
|
||||
root.addHandler(console)
|
||||
|
||||
# 文件: 单行 JSON, 供 Logtail 采集(自动轮转, 单文件 10MB, 保留 5 个)
|
||||
@@ -82,6 +130,7 @@ def setup_logging(debug: bool = False) -> None:
|
||||
log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8",
|
||||
)
|
||||
file_handler.setFormatter(JsonFormatter(service))
|
||||
file_handler.addFilter(ctx_filter)
|
||||
root.addHandler(file_handler)
|
||||
|
||||
# 第三方库降噪
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
"""消息通知中心:13 种通知类型的静态目录 + Push 文案模板。
|
||||
|
||||
对应 PRD《消息通知中心》:§1 类型清单 / §3 字段元素 / §5 Push 文案。
|
||||
这里只放**静态定义**(分类、版式、标题、操作行、Push 模板),供两处消费:
|
||||
- repositories/notification_mock.py 生成消息中心 mock 列表(虚拟数据阶段)
|
||||
- api/v1/push.py 渲染 13 类 push 标题/文案(厂商推送 + 测试端点)
|
||||
|
||||
PRD 文案规范(§5):push 标题 ≤11 字、固定文案不带变量;变量只出现在正文里且尽量前置。
|
||||
模板变量用 `{name}` 占位,渲染时缺省回退 sample_vars(PRD 示例值),保证 mock 阶段随时可发。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 分类(仅作卡片头部的分类标签展示;列表不按分类分组——PRD §1 的分组已确认取消,全表时间倒序)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CATEGORY_WITHDRAW = "withdraw_assistant"
|
||||
CATEGORY_SYSTEM = "system"
|
||||
CATEGORY_FEEDBACK = "feedback"
|
||||
CATEGORY_REPORT = "report"
|
||||
CATEGORY_INVITE = "invite"
|
||||
|
||||
# key → 中文标签
|
||||
CATEGORIES: dict[str, str] = {
|
||||
CATEGORY_WITHDRAW: "提现助手",
|
||||
CATEGORY_SYSTEM: "系统通知",
|
||||
CATEGORY_FEEDBACK: "我的反馈",
|
||||
CATEGORY_REPORT: "我的爆料",
|
||||
CATEGORY_INVITE: "好友邀请",
|
||||
}
|
||||
|
||||
|
||||
def category_label(key: str) -> str:
|
||||
return CATEGORIES[key]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 卡片版式(PRD §3「版式」列;前端按此渲染五种卡)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CARD_DUAL_AMOUNT = "dual_amount" # 双金额卡(金币数 + 现金数)
|
||||
CARD_WITHDRAW = "withdraw" # 提现卡(¥金额)
|
||||
CARD_PLAIN_TEXT = "plain_text" # 纯文本卡(无数值)
|
||||
CARD_COIN_REWARD = "coin_reward" # 金币奖励卡(金币数 + 单位「金币」)
|
||||
CARD_FRIEND_CASH = "friend_cash" # 好友现金卡(¥金额)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NotificationType:
|
||||
"""一种通知类型的静态定义(卡片元数据 + Push 模板)。"""
|
||||
|
||||
key: str # 类型 key(接口 type 字段;前端按它决定点击跳转,见 PRD §2)
|
||||
category: str # 分类 key(CATEGORIES 之一)
|
||||
card_style: str # 卡片版式(CARD_* 之一)
|
||||
card_title: str # 卡片标题(PRD §3「标题」列)
|
||||
action_text: str | None # 操作行文案;None = 无操作行(如「提现成功」)
|
||||
push_title: str # push 标题(≤11 字固定文案,PRD §5)
|
||||
push_body_template: str # push 正文模板,`{var}` 为变量
|
||||
sample_vars: dict[str, str] = field(default_factory=dict) # PRD 示例值,渲染缺省回退
|
||||
|
||||
|
||||
# 13 种类型,编号/文案与 PRD §1/§3/§5 一一对应(插入顺序 = PRD 编号顺序)。
|
||||
TYPES: dict[str, NotificationType] = {
|
||||
t.key: t
|
||||
for t in [
|
||||
# -- 提现助手 -------------------------------------------------------
|
||||
NotificationType(
|
||||
key="reward_expiring",
|
||||
category=CATEGORY_WITHDRAW,
|
||||
card_style=CARD_DUAL_AMOUNT,
|
||||
card_title="金币现金奖励即将失效",
|
||||
action_text="立即激活您的收益",
|
||||
push_title="您的奖励即将失效",
|
||||
push_body_template="{coins}金币和{cash}元现金{days}天后失效,完成快来激活收益",
|
||||
sample_vars={"coins": "86", "cash": "12.80", "days": "3"},
|
||||
),
|
||||
NotificationType(
|
||||
key="reward_expired",
|
||||
category=CATEGORY_WITHDRAW,
|
||||
card_style=CARD_DUAL_AMOUNT,
|
||||
card_title="金币现金奖励已失效",
|
||||
action_text="立即赚取新收益",
|
||||
push_title="您的奖励已失效",
|
||||
push_body_template="{coins}金币和{cash}元现金已过期,完成一次一键领券或一键比价可赚取新收益",
|
||||
sample_vars={"coins": "35", "cash": "0.60"},
|
||||
),
|
||||
NotificationType(
|
||||
key="withdraw_success",
|
||||
category=CATEGORY_WITHDRAW,
|
||||
card_style=CARD_WITHDRAW,
|
||||
card_title="提现成功",
|
||||
action_text=None, # PRD §3:提现成功卡无操作行,点击也无跳转、仅消红点
|
||||
push_title="提现到账提醒",
|
||||
push_body_template="¥{amount}已存入您的微信钱包,点击查看到账详情",
|
||||
sample_vars={"amount": "0.50"},
|
||||
),
|
||||
NotificationType(
|
||||
key="withdraw_failed",
|
||||
category=CATEGORY_WITHDRAW,
|
||||
card_style=CARD_WITHDRAW,
|
||||
card_title="提现失败,款项已退回",
|
||||
action_text="重新提现",
|
||||
push_title="提现失败,款项已退回",
|
||||
push_body_template="¥{amount}因{reason}退回现金余额,点击重新提现",
|
||||
sample_vars={"amount": "3.50", "reason": "微信零钱未实名"},
|
||||
),
|
||||
# -- 系统通知(权限异常 ×4;标题里的功能名按类型写死,见 PRD §1/§3)----
|
||||
NotificationType(
|
||||
key="perm_accessibility",
|
||||
category=CATEGORY_SYSTEM,
|
||||
card_style=CARD_PLAIN_TEXT,
|
||||
card_title="检测到您的比价功能已失效",
|
||||
action_text="去开启",
|
||||
push_title="检测到您的比价功能已失效",
|
||||
push_body_template="未开启将导致核心功能不可用,请尽快来傻瓜比价开启",
|
||||
),
|
||||
NotificationType(
|
||||
key="perm_battery",
|
||||
category=CATEGORY_SYSTEM,
|
||||
card_style=CARD_PLAIN_TEXT,
|
||||
card_title="检测到您的比价续航保护已失效",
|
||||
action_text="去开启",
|
||||
push_title="检测到您的比价续航保护已失效",
|
||||
push_body_template="未开启将导致核心功能不可用,请尽快来傻瓜比价开启",
|
||||
),
|
||||
NotificationType(
|
||||
key="perm_autostart",
|
||||
category=CATEGORY_SYSTEM,
|
||||
card_style=CARD_PLAIN_TEXT,
|
||||
card_title="检测到您的比价启动保护已失效",
|
||||
action_text="去开启",
|
||||
push_title="检测到您的比价启动保护已失效",
|
||||
push_body_template="未开启将导致核心功能不可用,请尽快来傻瓜比价开启",
|
||||
),
|
||||
NotificationType(
|
||||
key="perm_overlay",
|
||||
category=CATEGORY_SYSTEM,
|
||||
card_style=CARD_PLAIN_TEXT,
|
||||
card_title="检测到您的比价按钮已失效",
|
||||
action_text="去开启",
|
||||
push_title="检测到您的比价按钮已失效",
|
||||
push_body_template="未开启将导致核心功能不可用,请尽快来傻瓜比价开启",
|
||||
),
|
||||
# -- 我的反馈 -------------------------------------------------------
|
||||
NotificationType(
|
||||
key="feedback_reply",
|
||||
category=CATEGORY_FEEDBACK,
|
||||
card_style=CARD_PLAIN_TEXT,
|
||||
card_title="傻瓜比价官方回复了您的反馈",
|
||||
action_text="查看详情",
|
||||
push_title="您的反馈有回复啦",
|
||||
push_body_template="您提的建议我们认真看过了,来看看我们的回复吧~",
|
||||
),
|
||||
NotificationType(
|
||||
key="feedback_reward",
|
||||
category=CATEGORY_FEEDBACK,
|
||||
card_style=CARD_COIN_REWARD,
|
||||
card_title="反馈奖励",
|
||||
action_text="查看反馈详情",
|
||||
push_title="反馈奖励已到账",
|
||||
push_body_template="谢谢您帮傻瓜比价变得更好,{coins}金币已到账,还有一条给您的留言~",
|
||||
sample_vars={"coins": "300"},
|
||||
),
|
||||
# -- 我的爆料 -------------------------------------------------------
|
||||
NotificationType(
|
||||
key="report_approved",
|
||||
category=CATEGORY_REPORT,
|
||||
card_style=CARD_COIN_REWARD,
|
||||
card_title="爆料审核通过",
|
||||
action_text="查看爆料详情",
|
||||
push_title="爆料审核通过",
|
||||
push_body_template="您爆料的「{store}」更低价审核通过,{coins}金币已到账,感谢您的分享",
|
||||
sample_vars={"store": "蜀大侠火锅", "coins": "1000"},
|
||||
),
|
||||
# -- 好友邀请 -------------------------------------------------------
|
||||
NotificationType(
|
||||
key="invite_order_reward",
|
||||
category=CATEGORY_INVITE,
|
||||
card_style=CARD_FRIEND_CASH,
|
||||
card_title="好友比价成功,现金已到账",
|
||||
action_text="邀请更多好友赚现金",
|
||||
push_title="您的邀请奖励已到账",
|
||||
push_body_template="您的好友「{nickname}」完成首次下单,{amount}元现金已到账",
|
||||
sample_vars={"nickname": "柚子", "amount": "2"},
|
||||
),
|
||||
NotificationType(
|
||||
key="invite_remind",
|
||||
category=CATEGORY_INVITE,
|
||||
card_style=CARD_PLAIN_TEXT,
|
||||
card_title="你邀请的好友还差一步",
|
||||
action_text="去提醒 TA",
|
||||
push_title="提醒好友完成比价的奖励",
|
||||
push_body_template="您的好友「{nickname}」还没完成比价下单,提醒TA完成,您可得{amount}元现金",
|
||||
sample_vars={"nickname": "阿泽", "amount": "2"},
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class UnknownNotificationType(ValueError):
|
||||
"""type key 不在 13 种类型之内。"""
|
||||
|
||||
|
||||
def get_type(type_key: str) -> NotificationType:
|
||||
ntype = TYPES.get(type_key)
|
||||
if ntype is None:
|
||||
raise UnknownNotificationType(
|
||||
f"unknown notification type: {type_key!r} (可选: {', '.join(TYPES)})"
|
||||
)
|
||||
return ntype
|
||||
|
||||
|
||||
def render_push(type_key: str, variables: dict[str, str] | None = None) -> tuple[str, str]:
|
||||
"""渲染某类型的 push (标题, 正文)。
|
||||
|
||||
variables 覆盖模板变量;缺的变量回退 sample_vars(PRD 示例值)——保证虚拟数据
|
||||
阶段不传变量也能发出完整文案。多余的变量忽略。
|
||||
"""
|
||||
ntype = get_type(type_key)
|
||||
merged = {**ntype.sample_vars, **(variables or {})}
|
||||
|
||||
class _Fallback(dict):
|
||||
def __missing__(self, key: str) -> str: # 模板变量既没传也没示例值 → 保留 {key} 原样
|
||||
return "{" + key + "}"
|
||||
|
||||
body = ntype.push_body_template.format_map(_Fallback(merged))
|
||||
return ntype.push_title, body
|
||||
|
||||
|
||||
def push_variable_names(type_key: str) -> list[str]:
|
||||
"""列出模板里出现的变量名(给 /push/templates 预览用)。"""
|
||||
import string
|
||||
|
||||
ntype = get_type(type_key)
|
||||
return [
|
||||
fname
|
||||
for _, fname, _, _ in string.Formatter().parse(ntype.push_body_template)
|
||||
if fname
|
||||
]
|
||||
+8
-25
@@ -8,15 +8,16 @@
|
||||
校验)→ 鉴权复用极光一键登录的 `JG_APP_KEY`/`JG_MASTER_SECRET`(同一极光应用)。
|
||||
|
||||
验证码存储:**进程内存**(单 worker uvicorn 够用)。重启丢失(用户重发即可)。多
|
||||
worker / 多机时内存不共享 → 冷却、每日上限、校验都会失效,届时迁移到 DB/Redis。
|
||||
worker / 多机时内存不共享 → 冷却、校验都会失效,届时迁移到 DB/Redis。
|
||||
见 docs/待办与技术债.md。
|
||||
|
||||
防刷三层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
|
||||
防刷两层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
|
||||
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)
|
||||
2. 单号每日 `SMS_DAILY_LIMIT_PER_PHONE` 条上限(本文件)
|
||||
3. 单设备(device_id)每小时频控(api 层 auth.sms_send 内 enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)。
|
||||
2. 单设备(device_id)每小时频控(api 层 auth.sms_send 内 enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)。
|
||||
⚠️ 原「单 IP 频控(rate_limit 依赖)」2026-06-26 按产品要求删除、改设备维度;但 device_id 客户端可伪造/轮换,
|
||||
脚本轮换 id 能绕过本层 → 挡脚本狂发主要靠极光控制台侧(+ 可选 nginx 限流)。
|
||||
⚠️ 原「单号每日上限」2026-07-03 按精简要求删除(mentor 定:登录风控只留单号冷却 + 单设备频控);
|
||||
单号维度现仅剩 60s 冷却,「换号轰炸」由单设备频控封顶。
|
||||
另:单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废(防爆破),验过即作废(一次性)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -26,7 +27,6 @@ import logging
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from threading import Lock
|
||||
|
||||
import httpx
|
||||
@@ -56,22 +56,17 @@ class _CodeRecord:
|
||||
# 进程内存(单 worker 有效;多 worker 不共享,见模块 docstring)
|
||||
_codes: dict[str, _CodeRecord] = {} # phone -> 当前有效验证码
|
||||
_last_sent: dict[str, float] = {} # phone -> 上次发送 epoch(冷却)
|
||||
_daily_count: dict[str, tuple[str, int]] = {} # phone -> (date_str, 当日发送数)
|
||||
_lock = Lock()
|
||||
_GC_THRESHOLD = 10000 # 任一内存 dict 超此阈值,send 时顺手清过期项(防无限增长,仿 ratelimit)
|
||||
|
||||
|
||||
def _today() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _gen_code() -> str:
|
||||
"""生成 N 位数字验证码(用 secrets 而非 random;允许前导 0)。"""
|
||||
return "".join(secrets.choice("0123456789") for _ in range(settings.SMS_CODE_LENGTH))
|
||||
|
||||
|
||||
def _gc(now: float) -> None:
|
||||
"""顺手清理过期内存项,防三个 dict 无限增长。仅在持锁时调用,且某 dict 超
|
||||
"""顺手清理过期内存项,防两个 dict 无限增长。仅在持锁时调用,且某 dict 超
|
||||
_GC_THRESHOLD 才扫它(低频,开销可忽略)。"""
|
||||
if len(_codes) > _GC_THRESHOLD:
|
||||
for p in [p for p, r in _codes.items() if now > r.expires_at]:
|
||||
@@ -80,10 +75,6 @@ def _gc(now: float) -> None:
|
||||
cutoff = now - settings.SMS_SEND_INTERVAL_SEC
|
||||
for p in [p for p, ts in _last_sent.items() if ts < cutoff]:
|
||||
_last_sent.pop(p, None)
|
||||
if len(_daily_count) > _GC_THRESHOLD:
|
||||
today = _today()
|
||||
for p in [p for p, (d, _c) in _daily_count.items() if d != today]:
|
||||
_daily_count.pop(p, None)
|
||||
|
||||
|
||||
def send_code(phone: str) -> int:
|
||||
@@ -102,17 +93,9 @@ def send_code(phone: str) -> int:
|
||||
remain = int(settings.SMS_SEND_INTERVAL_SEC - elapsed)
|
||||
raise SmsError(f"发送过于频繁,请 {remain}s 后再试")
|
||||
|
||||
today = _today()
|
||||
day, cnt = _daily_count.get(phone, ("", 0))
|
||||
if day != today:
|
||||
cnt = 0
|
||||
if cnt >= settings.SMS_DAILY_LIMIT_PER_PHONE:
|
||||
raise SmsError("今日验证码发送次数已达上限,请明天再试")
|
||||
|
||||
code = _gen_code()
|
||||
# 预占:先记冷却/计数/存码,释放锁后再发网络(发失败保留冷却+计数,见下)
|
||||
# 预占:先记冷却/存码,释放锁后再发网络(发失败保留冷却,见下)
|
||||
_last_sent[phone] = now
|
||||
_daily_count[phone] = (today, cnt + 1)
|
||||
_codes[phone] = _CodeRecord(code=code, expires_at=now + settings.SMS_CODE_TTL_SEC)
|
||||
|
||||
# --- lock 外:真正发送(网络 IO 不持锁)---
|
||||
@@ -123,7 +106,7 @@ def send_code(phone: str) -> int:
|
||||
_send_via_jiguang(phone, code)
|
||||
logger.info("[SMS] sent to %s****", phone[:3])
|
||||
except Exception as e:
|
||||
# 发送失败:**保留冷却 + 每日计数**(失败也限速,挡住余额不足/签名失效时
|
||||
# 发送失败:**保留冷却**(失败也限速,挡住余额不足/签名失效时
|
||||
# 前端重试狂打极光),只清掉没发出去的码(用户收不到,留着无意义且占内存)。
|
||||
with _lock:
|
||||
_codes.pop(phone, None)
|
||||
|
||||
@@ -1,519 +0,0 @@
|
||||
"""厂商直推集成(荣耀 / 华为 / 小米 / OPPO / vivo)。
|
||||
|
||||
服务端不经由 JPush Push API,而是按客户端上报的 push_vendor + push_token
|
||||
分发到各手机厂商的服务端 API。
|
||||
|
||||
对外两个入口:
|
||||
- send_notification() 通用:任意标题/正文/extras(消息中心 13 类推送走这里),
|
||||
mock=True 时不真调厂商、返回渲染结果(虚拟数据联调用)
|
||||
- send_accessibility_disabled() 旧:无障碍掉线召回(heartbeat_monitor_worker 在用),
|
||||
已改为 send_notification 的薄封装,行为不变
|
||||
|
||||
各厂商鉴权方式:荣耀/华为 OAuth client_credentials 换 access_token(进程内缓存);
|
||||
vivo/OPPO 签名换 authToken(缓存 24h);小米直接 AppSecret 走 Authorization 头。
|
||||
"""
|
||||
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", "huawei", "vivo", "xiaomi", "oppo"})
|
||||
|
||||
# vendor key → 中文名(测试/配置状态接口展示用)
|
||||
VENDOR_LABELS: dict[str, str] = {
|
||||
"honor": "荣耀",
|
||||
"huawei": "华为",
|
||||
"xiaomi": "小米",
|
||||
"oppo": "OPPO",
|
||||
"vivo": "vivo",
|
||||
}
|
||||
|
||||
# 各厂商真发推送所需的 settings 键(缺任一即视为未配置;/api/v1/push/vendors 据此报缺)
|
||||
REQUIRED_SETTINGS: dict[str, tuple[str, ...]] = {
|
||||
"honor": ("HONOR_PUSH_APP_ID", "HONOR_PUSH_CLIENT_ID", "HONOR_PUSH_CLIENT_SECRET"),
|
||||
"huawei": ("HUAWEI_PUSH_APP_ID", "HUAWEI_PUSH_APP_SECRET"),
|
||||
"xiaomi": ("XIAOMI_PUSH_APP_SECRET",),
|
||||
"oppo": ("OPPO_PUSH_APP_KEY", "OPPO_PUSH_MASTER_SECRET"),
|
||||
"vivo": ("VIVO_PUSH_APP_ID", "VIVO_PUSH_APP_KEY", "VIVO_PUSH_APP_SECRET"),
|
||||
}
|
||||
|
||||
|
||||
def missing_settings(vendor: str) -> list[str]:
|
||||
"""该厂商还缺哪些配置键(全配齐返回空列表)。vendor 需已 normalize。"""
|
||||
return [key for key in REQUIRED_SETTINGS.get(vendor, ()) if not getattr(settings, key, "")]
|
||||
|
||||
|
||||
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",
|
||||
"hms": "huawei",
|
||||
"华为": "huawei",
|
||||
"harmony": "huawei",
|
||||
"harmonyos": "huawei",
|
||||
"mi": "xiaomi",
|
||||
"小米": "xiaomi",
|
||||
"oneplus": "oppo",
|
||||
"realme": "oppo",
|
||||
}
|
||||
return aliases.get(vendor, vendor)
|
||||
|
||||
|
||||
def send_notification(
|
||||
push_vendor: str,
|
||||
push_token: str,
|
||||
*,
|
||||
title: str,
|
||||
body: str,
|
||||
extras: dict[str, str] | None = None,
|
||||
mock: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""按厂商 token 向单台设备发送一条通知(通用入口)。
|
||||
|
||||
- extras:透传给客户端的自定义键值(值统一 string,兼容各厂商限制)。消息中心推送约定
|
||||
至少带 {"type": <13 种类型 key>, "notificationId": <站内消息 id>},客户端据此
|
||||
深链落地 + 调 /notifications/read 同步置读(PRD §4 push 联动)。
|
||||
- mock=True:不真调厂商 API,校验参数后原样返回渲染结果(虚拟数据阶段联调/自动化测试用)。
|
||||
"""
|
||||
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")
|
||||
extras = {str(k): str(v) for k, v in (extras or {}).items()}
|
||||
|
||||
if mock:
|
||||
logger.info(
|
||||
"[mock push] vendor=%s token=%s... title=%s body=%s extras=%s",
|
||||
vendor, token[:12], title, body, extras,
|
||||
)
|
||||
return {
|
||||
"mock": True,
|
||||
"vendor": vendor,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"extras": extras,
|
||||
}
|
||||
|
||||
dispatch: dict[str, Callable[[str, str, str, dict[str, str]], dict[str, Any]]] = {
|
||||
"honor": _send_honor,
|
||||
"huawei": _send_huawei,
|
||||
"vivo": _send_vivo,
|
||||
"xiaomi": _send_xiaomi,
|
||||
"oppo": _send_oppo,
|
||||
}
|
||||
return dispatch[vendor](token, title, body, extras)
|
||||
|
||||
|
||||
def send_accessibility_disabled(
|
||||
push_vendor: str,
|
||||
push_token: str,
|
||||
*,
|
||||
title: str = "保护已关闭",
|
||||
alert: str = "傻瓜比价的无障碍保护被关了,点此重新开启,继续帮你自动比价省钱。",
|
||||
) -> dict[str, Any]:
|
||||
"""按厂商 token 向单台设备发送无障碍掉线通知(heartbeat_monitor_worker 在用,行为不变)。"""
|
||||
return send_notification(
|
||||
push_vendor,
|
||||
push_token,
|
||||
title=title,
|
||||
body=alert,
|
||||
extras={"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, body: str, extras: dict[str, 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": body},
|
||||
"android": {
|
||||
"ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s",
|
||||
"targetUserType": 1,
|
||||
"notification": {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"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 _huawei_access_token() -> str:
|
||||
"""华为 OAuth2 client_credentials 换 access_token(client_id 即 AGC 应用的 AppId)。"""
|
||||
cache_key = "huawei"
|
||||
cached = _cache_get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID")
|
||||
app_secret = _require(settings.HUAWEI_PUSH_APP_SECRET, "HUAWEI_PUSH_APP_SECRET")
|
||||
data = _request_form(
|
||||
"POST",
|
||||
settings.HUAWEI_PUSH_TOKEN_ENDPOINT,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": app_id,
|
||||
"client_secret": app_secret,
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
token = data.get("access_token")
|
||||
if not token:
|
||||
raise VendorPushError(f"huawei auth failed: {data}")
|
||||
return _cache_put(cache_key, str(token), data.get("expires_in"))
|
||||
|
||||
|
||||
def _send_huawei(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
|
||||
"""华为 Push Kit 下行消息(v1 messages:send)。成功码 '80000000';
|
||||
'80100000' 为部分成功(单 token 场景仍视为失败,错误里带原始响应便于排障)。"""
|
||||
app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID")
|
||||
access_token = _huawei_access_token()
|
||||
payload = {
|
||||
"validate_only": False,
|
||||
"message": {
|
||||
"data": json.dumps(extras, ensure_ascii=False),
|
||||
"android": {
|
||||
"ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s",
|
||||
"notification": {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"click_action": {"type": 3},
|
||||
"importance": "NORMAL",
|
||||
},
|
||||
},
|
||||
"token": [token],
|
||||
},
|
||||
}
|
||||
data = _request_json(
|
||||
"POST",
|
||||
settings.HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
|
||||
json=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json; charset=UTF-8",
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
},
|
||||
)
|
||||
if str(data.get("code", "")) != "80000000":
|
||||
raise VendorPushError(f"huawei 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, body: str, extras: dict[str, str]) -> dict[str, Any]:
|
||||
app_id = _require(settings.VIVO_PUSH_APP_ID, "VIVO_PUSH_APP_ID")
|
||||
auth_token = _vivo_auth_token()
|
||||
payload: dict[str, Any] = {
|
||||
"appId": app_id,
|
||||
"regId": token,
|
||||
"notifyType": settings.VIVO_PUSH_NOTIFY_TYPE,
|
||||
"title": title,
|
||||
"content": body,
|
||||
"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:
|
||||
payload["category"] = settings.VIVO_PUSH_CATEGORY
|
||||
data = _request_json(
|
||||
"POST",
|
||||
settings.VIVO_PUSH_SEND_ENDPOINT,
|
||||
json=payload,
|
||||
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, body: str, extras: dict[str, 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 body
|
||||
form = {
|
||||
"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:
|
||||
form["extra.channel_id"] = settings.XIAOMI_PUSH_CHANNEL_ID.strip()
|
||||
if settings.XIAOMI_PUSH_TEMPLATE_ID:
|
||||
form["extra.template_id"] = settings.XIAOMI_PUSH_TEMPLATE_ID.strip()
|
||||
if settings.XIAOMI_PUSH_TEMPLATE_PARAM_JSON:
|
||||
form["extra.template_param"] = _xiaomi_template_param(title, body)
|
||||
data = _request_form(
|
||||
"POST",
|
||||
settings.XIAOMI_PUSH_SEND_ENDPOINT,
|
||||
data=form,
|
||||
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, body: str, extras: dict[str, str]) -> dict[str, Any]:
|
||||
auth_token = _oppo_auth_token()
|
||||
ttl_hours = max(1, min(72, settings.PUSH_TIME_TO_LIVE_SEC // 3600))
|
||||
notification: dict[str, Any] = {
|
||||
"app_message_id": f"{extras.get('type', 'notify')}_{uuid.uuid4().hex}",
|
||||
"title": title,
|
||||
"content": body,
|
||||
"click_action_type": 0,
|
||||
"off_line": True,
|
||||
"off_line_ttl": ttl_hours,
|
||||
"action_parameters": json.dumps(extras, ensure_ascii=False),
|
||||
}
|
||||
# 新消息分类(2024-11-20 后创建的 OPPO 应用必须带 category,否则可能被拒收/降级)
|
||||
if settings.OPPO_PUSH_CHANNEL_ID.strip():
|
||||
notification["channel_id"] = settings.OPPO_PUSH_CHANNEL_ID.strip()
|
||||
if settings.OPPO_PUSH_CATEGORY.strip():
|
||||
notification["category"] = settings.OPPO_PUSH_CATEGORY.strip()
|
||||
if settings.OPPO_PUSH_NOTIFY_LEVEL:
|
||||
notification["notify_level"] = settings.OPPO_PUSH_NOTIFY_LEVEL
|
||||
message = {
|
||||
"target_type": 2,
|
||||
"target_value": token,
|
||||
"notification": notification,
|
||||
}
|
||||
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
|
||||
@@ -31,10 +31,8 @@ from app.api.v1.device import router as device_router
|
||||
from app.api.v1.feedback import router as feedback_router
|
||||
from app.api.v1.invite import router as invite_router
|
||||
from app.api.v1.meituan import router as meituan_router
|
||||
from app.api.v1.notifications import router as notifications_router
|
||||
from app.api.v1.order import router as order_router
|
||||
from app.api.v1.platform import router as platform_router
|
||||
from app.api.v1.push import router as push_router
|
||||
from app.api.v1.report import router as report_router
|
||||
from app.api.v1.savings import router as savings_router
|
||||
from app.api.v1.signin import router as signin_router
|
||||
@@ -128,10 +126,6 @@ app.include_router(savings_router)
|
||||
app.include_router(ad_router)
|
||||
app.include_router(order_router)
|
||||
app.include_router(report_router)
|
||||
# 消息通知中心(PRD;虚拟数据阶段:mock 数据在内存,见 repositories/notification_mock.py)
|
||||
app.include_router(notifications_router)
|
||||
# 厂商推送测试三件套(配置状态/模板预览/测试发送,支持 mock 与真发)
|
||||
app.include_router(push_router)
|
||||
# 内部(server→server)端点:pricebot 上报价格观测 / 店铺映射,靠共享密钥头校验,不对客户端开放。
|
||||
app.include_router(internal_price_router)
|
||||
app.include_router(internal_store_router)
|
||||
|
||||
@@ -39,16 +39,19 @@ _JSON = JSON().with_variant(JSONB(), "postgresql")
|
||||
class ComparisonRecord(Base):
|
||||
__tablename__ = "comparison_record"
|
||||
__table_args__ = (
|
||||
# 同一用户同一次比价(trace_id)只存一条:客户端重试/误点重复上报时幂等覆盖。
|
||||
UniqueConstraint("user_id", "trace_id", name="uq_comparison_user_trace"),
|
||||
# trace_id 由 app-server 签发、全局唯一 → 一次比价一行,后端 harvest 按它 upsert。
|
||||
# (原 (user_id,trace_id) 复合唯一改为 trace_id 单列:harvest 帧0 建行时 user_id 可能暂缺。)
|
||||
UniqueConstraint("trace_id", name="uq_comparison_trace"),
|
||||
# 首页轮播 / 省钱战绩聚合都按 status='success' 过滤 + created_at 近期排序;
|
||||
# 复合索引避免随数据量增大退化成全表扫(单列 created_at 索引不含 status)。
|
||||
Index("ix_comparison_status_created", "status", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
# 后端 harvest 在帧0(pricebot 出 trace_id)即建行,软鉴权下 user_id 可能暂缺(老客户端/匿名)→ 可空。
|
||||
# C 端「我的比价记录」按 user_id 过滤天然排除 null-user 行;admin 全看(含孤儿行)。
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=True
|
||||
)
|
||||
# 仍记录设备号(同一用户多设备的行为区分 / 与不鉴权期 device_id 数据对账)
|
||||
device_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""设备表(无障碍保护存活检测 + 厂商直推)。
|
||||
"""设备表(无障碍保护存活检测 + 极光推送)。
|
||||
|
||||
每条 = 一个用户的一台设备(per-install,device_id 由客户端 DeviceId.get() 生成)。
|
||||
客户端的无障碍服务存活时周期上报心跳刷新 last_heartbeat_at;App 前台/登录时上报
|
||||
push_vendor + push_token(厂商推送目标)。后端 heartbeat_monitor_worker 扫描「曾经保护过、
|
||||
现在心跳超时」的设备,通过厂商直推提醒用户重开无障碍。
|
||||
registration_id(极光推送目标)。后端 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 + 厂商推送目标),故名 device_liveness。
|
||||
# (心跳 last_heartbeat_at + liveness_state + kill_alert_pending + 推送目标 registration_id),故名 device_liveness。
|
||||
__tablename__ = "device_liveness"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "device_id", name="uq_device_liveness_user_device"),
|
||||
@@ -42,12 +42,8 @@ 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,仅为兼容历史客户端/数据保留;新链路使用 push_vendor + push_token。
|
||||
# 极光推送 registration id;拿到才填(JCollectionAuth 同意后才下发)
|
||||
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)
|
||||
|
||||
|
||||
@@ -74,7 +74,12 @@ def _derive(payload: ComparisonRecordIn) -> dict:
|
||||
def upsert_record(
|
||||
db: Session, *, user_id: int, payload: ComparisonRecordIn
|
||||
) -> ComparisonRecord:
|
||||
"""按 (user_id, trace_id) 幂等写入:已存在则覆盖(更完整的重试上报胜出),否则新建。"""
|
||||
"""按 **trace_id** 幂等写入(唯一键已从 user_id+trace_id 改为 trace_id):已存在则合并
|
||||
(回填 null user_id + 覆盖字段,但**不降级 success**),否则新建。
|
||||
|
||||
灰度期老客户端 POST /compare/record 走这条,与后端 harvest 按 trace_id reconcile;
|
||||
新客户端不再 POST(改由 compare.py 透传壳 harvest 落库)。
|
||||
"""
|
||||
derived = _derive(payload)
|
||||
fields = dict(
|
||||
device_id=payload.device_id,
|
||||
@@ -110,14 +115,29 @@ def upsert_record(
|
||||
**derived,
|
||||
)
|
||||
|
||||
# 按 trace_id 定位(唯一键已改 trace_id):后端 harvest 可能已建行,这里的客户端上报
|
||||
# (灰度期老客户端 / 新客户端不再走这条)与之 reconcile。
|
||||
existing = db.execute(
|
||||
select(ComparisonRecord).where(
|
||||
ComparisonRecord.user_id == user_id,
|
||||
ComparisonRecord.trace_id == payload.trace_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if existing is not None:
|
||||
# 不降级:harvest 或更早上报已落成 success,收尾期 fromFailure 的 cancelled/failed
|
||||
# 不许把它盖回去(老 comparisonReported bug 的服务端兜底)——只补 user_id / trace_url。
|
||||
if existing.status == "success" and fields.get("status") != "success":
|
||||
if existing.user_id is None and user_id is not None:
|
||||
existing.user_id = user_id
|
||||
if fields.get("trace_url"):
|
||||
existing.trace_url = fields["trace_url"]
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return existing
|
||||
# 只**填**空缺 user_id、不 reassign:trace_id 全局唯一,一条 trace 只属一个用户;
|
||||
# 绝不把已有归属的记录改判给另一个上报者(仅 harvest 建的 null-user 行在此绑上)。
|
||||
if existing.user_id is None:
|
||||
existing.user_id = user_id
|
||||
for k, v in fields.items():
|
||||
setattr(existing, k, v)
|
||||
db.commit()
|
||||
@@ -139,6 +159,201 @@ def upsert_record(
|
||||
return rec
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 后端 harvest:app-server 从 pricebot 透传响应里直接落库(不靠客户端上报)。
|
||||
# 帧0 建行(running) → 最终 done 更新(success/failed) → finalize 更新(aborted)。
|
||||
# 全按 trace_id upsert;success 行永不被后到的 failed/aborted 降级。
|
||||
# ============================================================
|
||||
|
||||
|
||||
def _derive_from_results(results: list[dict]) -> dict:
|
||||
"""从 done 帧 comparison_results(pricebot 原始 dict 列表)派生结构化列。
|
||||
等价 _derive,但吃原始字段(is_source/price/rank/platform_id/store_name...)而非 pydantic 对象。"""
|
||||
priced = [r for r in results if r.get("price") is not None]
|
||||
best = None
|
||||
if priced:
|
||||
best = min(
|
||||
priced,
|
||||
key=lambda r: (r.get("rank") if r.get("rank") is not None else 10**9, r["price"]),
|
||||
)
|
||||
src_row = next((r for r in results if r.get("is_source")), None)
|
||||
|
||||
source_price_cents = None
|
||||
if src_row is not None and src_row.get("price") is not None:
|
||||
source_price_cents = _yuan_to_cents(src_row["price"])
|
||||
best_price_cents = _yuan_to_cents(best["price"]) if best else None
|
||||
saved_amount_cents = None
|
||||
if source_price_cents is not None and best_price_cents is not None:
|
||||
saved_amount_cents = source_price_cents - best_price_cents
|
||||
|
||||
has_valid_target = any(
|
||||
(not r.get("is_source")) and r.get("price") is not None for r in results
|
||||
)
|
||||
return {
|
||||
"source_platform_id": (src_row or {}).get("platform_id"),
|
||||
"source_platform_name": (src_row or {}).get("platform_name"),
|
||||
"source_package": (src_row or {}).get("package"),
|
||||
"source_price_cents": source_price_cents,
|
||||
"best_platform_id": best.get("platform_id") if best else None,
|
||||
"best_platform_name": best.get("platform_name") if best else None,
|
||||
"best_price_cents": best_price_cents,
|
||||
"saved_amount_cents": saved_amount_cents,
|
||||
"is_source_best": best.get("is_source") if best else None,
|
||||
"store_name": (src_row or {}).get("store_name") or None,
|
||||
"status": "success" if has_valid_target else "failed",
|
||||
}
|
||||
|
||||
|
||||
def _device_cols_from_info(device_info: dict | None) -> dict:
|
||||
"""step 帧 device_info({locale,brand,model,android_version,rom_version})→ 表列。
|
||||
step 帧只带这几项;rom_name / android_sdk / app_version / 耗时步数 harvest 拿不到 → 留 None
|
||||
(灰度期老客户端 fromComparison 会补齐;要新客户端也全带需扩 collectDeviceInfo,后续)。"""
|
||||
d = device_info or {}
|
||||
rv = str(d.get("rom_version") or "")
|
||||
return {
|
||||
"device_model": d.get("model") or None,
|
||||
"device_manufacturer": d.get("brand") or None,
|
||||
"android_version": d.get("android_version") or None,
|
||||
"rom_version": int(rv) if rv.isdigit() else None,
|
||||
}
|
||||
|
||||
|
||||
def _get_by_trace(db: Session, trace_id: str) -> ComparisonRecord | None:
|
||||
return db.execute(
|
||||
select(ComparisonRecord).where(ComparisonRecord.trace_id == trace_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def harvest_running(
|
||||
db: Session,
|
||||
*,
|
||||
trace_id: str,
|
||||
user_id: int | None,
|
||||
business_type: str = "food",
|
||||
device_id: str | None = None,
|
||||
device_info: dict | None = None,
|
||||
trace_url: str | None = None,
|
||||
) -> ComparisonRecord:
|
||||
"""帧0(或任一尚未建行的帧)建/补 running 行。幂等:已存在只补空缺(user_id/trace_url/
|
||||
device_id/机型),绝不动已落定的 status / 结果。"""
|
||||
rec = _get_by_trace(db, trace_id)
|
||||
if rec is None:
|
||||
rec = ComparisonRecord(
|
||||
trace_id=trace_id,
|
||||
user_id=user_id,
|
||||
business_type=business_type or "food",
|
||||
device_id=device_id,
|
||||
status="running",
|
||||
trace_url=trace_url,
|
||||
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
|
||||
**_device_cols_from_info(device_info),
|
||||
)
|
||||
db.add(rec)
|
||||
db.commit()
|
||||
db.refresh(rec)
|
||||
return rec
|
||||
changed = False
|
||||
if rec.user_id is None and user_id is not None:
|
||||
rec.user_id = user_id
|
||||
changed = True
|
||||
if not rec.trace_url and trace_url:
|
||||
rec.trace_url = trace_url
|
||||
changed = True
|
||||
if rec.device_id is None and device_id:
|
||||
rec.device_id = device_id
|
||||
changed = True
|
||||
for k, v in _device_cols_from_info(device_info).items():
|
||||
if getattr(rec, k) is None and v is not None:
|
||||
setattr(rec, k, v)
|
||||
changed = True
|
||||
if changed:
|
||||
db.commit()
|
||||
db.refresh(rec)
|
||||
return rec
|
||||
|
||||
|
||||
def harvest_done(
|
||||
db: Session,
|
||||
*,
|
||||
trace_id: str,
|
||||
user_id: int | None,
|
||||
done_params: dict,
|
||||
business_type: str = "food",
|
||||
device_id: str | None = None,
|
||||
device_info: dict | None = None,
|
||||
trace_url: str | None = None,
|
||||
) -> tuple[ComparisonRecord, bool]:
|
||||
"""最终 done 帧:running 行 → 终态(success/failed)+结果+trace_url。
|
||||
返回 (记录, 是否本次**新**落成 success)——供调用方据此幂等发一次邀请奖。
|
||||
行不存在(理论上帧0已建;防御)则新建。"""
|
||||
results = done_params.get("comparison_results") or []
|
||||
derived = _derive_from_results(results)
|
||||
fields = dict(
|
||||
business_type=business_type or "food",
|
||||
information=done_params.get("information") or None,
|
||||
# best_deeplink 来自客户端剪贴板采集,harvest 拿不到 → 留空(灰度期 fromComparison 会补;
|
||||
# 纯 harvest 行「再次比价」退化为按 package 拉起 App。要精确深链需客户端另传,后续)。
|
||||
trace_url=trace_url or done_params.get("trace_url"),
|
||||
total_dish_count=done_params.get("total_dish_count"),
|
||||
skipped_dish_count=done_params.get("skipped_dish_count"),
|
||||
skipped_dish_names=list(done_params.get("skipped_dish_names") or []),
|
||||
comparison_results=results,
|
||||
# 菜品:pricebot 已把源单菜品塞进 comparison_results[源行].items
|
||||
items=next((r.get("items") or [] for r in results if r.get("is_source")), []),
|
||||
raw_payload=done_params,
|
||||
**derived,
|
||||
**_device_cols_from_info(device_info),
|
||||
)
|
||||
|
||||
rec = _get_by_trace(db, trace_id)
|
||||
was_success = rec is not None and rec.status == "success"
|
||||
if rec is None:
|
||||
rec = ComparisonRecord(
|
||||
trace_id=trace_id,
|
||||
user_id=user_id,
|
||||
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
|
||||
device_id=device_id,
|
||||
**fields,
|
||||
)
|
||||
db.add(rec)
|
||||
else:
|
||||
if user_id is not None and rec.user_id is None:
|
||||
rec.user_id = user_id
|
||||
if device_id and rec.device_id is None:
|
||||
rec.device_id = device_id
|
||||
for k, v in fields.items():
|
||||
setattr(rec, k, v)
|
||||
db.commit()
|
||||
db.refresh(rec)
|
||||
newly_success = (rec.status == "success") and not was_success
|
||||
return rec, newly_success
|
||||
|
||||
|
||||
def harvest_abort(
|
||||
db: Session,
|
||||
*,
|
||||
trace_id: str,
|
||||
status: str,
|
||||
reason: str | None,
|
||||
trace_url: str | None = None,
|
||||
) -> ComparisonRecord | None:
|
||||
"""finalize(用户终止/超时/异常,无 done 帧):running 行 → aborted/failed + trace_url。
|
||||
**不降级 success**:行已 success(收尾取消那种 finalize 后到)只 refresh trace_url。
|
||||
行不存在(极少:帧0没建成)→ 返回 None,不凭空造。"""
|
||||
rec = _get_by_trace(db, trace_id)
|
||||
if rec is None:
|
||||
return None
|
||||
if trace_url and not rec.trace_url:
|
||||
rec.trace_url = trace_url
|
||||
if rec.status != "success":
|
||||
rec.status = status or "cancelled"
|
||||
if reason:
|
||||
rec.information = reason
|
||||
db.commit()
|
||||
db.refresh(rec)
|
||||
return rec
|
||||
|
||||
|
||||
def _ordered_shop_names(db: Session, user_id: int) -> set[str]:
|
||||
"""该用户「真实下单」(source='compare')覆盖到的店名集合,用来给比价记录打「已下单」。
|
||||
|
||||
|
||||
@@ -21,23 +21,17 @@ def register_or_update(
|
||||
*,
|
||||
user_id: int,
|
||||
device_id: str,
|
||||
registration_id: str | None = None,
|
||||
push_vendor: str | None = None,
|
||||
push_token: str | None = None,
|
||||
registration_id: str | None,
|
||||
platform: str = "android",
|
||||
app_version: str | None = None,
|
||||
) -> DeviceLiveness:
|
||||
"""注册设备或更新其厂商 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
|
||||
"""注册设备或更新其 registration_id / 元信息。upsert by (user_id, device_id)。"""
|
||||
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,
|
||||
)
|
||||
@@ -45,10 +39,6 @@ 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:
|
||||
@@ -64,9 +54,7 @@ def touch_heartbeat(
|
||||
user_id: int,
|
||||
device_id: str,
|
||||
accessibility_enabled: bool,
|
||||
registration_id: str | None = None,
|
||||
push_vendor: str | None = None,
|
||||
push_token: str | None = None,
|
||||
registration_id: str | None,
|
||||
) -> DeviceLiveness:
|
||||
"""处理一次心跳(心跳也能自注册)。
|
||||
|
||||
@@ -81,12 +69,6 @@ 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:
|
||||
@@ -105,7 +87,7 @@ def touch_heartbeat(
|
||||
def list_overdue(db: Session, *, timeout_minutes: int) -> list[DeviceLiveness]:
|
||||
"""掉线设备:曾经保护过、当前 alive、心跳超时。
|
||||
|
||||
即使没有厂商 token 也要检出,后续由 kill_alert_pending 走客户端进 App 后兜底提醒。
|
||||
本期只做终端打印检测、不推送 → 不再要求有 registration_id(没接极光 token 的设备也要检出)。
|
||||
"""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes)
|
||||
stmt = select(DeviceLiveness).where(
|
||||
@@ -142,27 +124,3 @@ 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)
|
||||
|
||||
@@ -304,6 +304,9 @@ def get_invitees(
|
||||
"avatar_url": u.avatar_url or u.wechat_avatar_url or None,
|
||||
"coins": rel.inviter_coin, # v3 起恒 0(邀请人收益改走邀请奖励金)
|
||||
"invited_at": rel.created_at,
|
||||
# 是否已完成过一次比价(= 已发过邀请奖励金)。客户端据此:好友列表分"去提醒/邀请成功"、
|
||||
# 在途列表只取未比价(is_compared=False)、算在途好友数与在途收益(未比价数×2元)。
|
||||
"is_compared": bool(rel.compare_reward_granted),
|
||||
})
|
||||
has_more = offset + len(rows) < int(total)
|
||||
return items, int(total), has_more
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
"""消息通知中心 mock 数据仓库(内存版,不落库)。
|
||||
|
||||
⚠️ 虚拟数据阶段专用:
|
||||
- 每个用户**首次访问时**生成一套固定样例(覆盖全部 13 种类型 + 「今天/昨天/当年/跨年」
|
||||
四种时间显示分支 + 已读/未读混合),供前端联调列表渲染、角标、已读消除全流程。
|
||||
- 已读状态维护在进程内存 → **重启服务即复位**;多 worker 部署时各 worker 各一份
|
||||
(联调期单 worker 跑即可,与 core/ratelimit.py 同款局限)。
|
||||
- 后续接真实数据:建 notification 表 + 本文件同名函数改为查库,API 层无需改动。
|
||||
|
||||
排序规则:**全列表按时间倒序**(最新在前),不做分类分组(需求方 2026-07-14 确认
|
||||
PRD §1 的"按分类分组"写错了,已取消);分页作用在排序后的整表上。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.core import notification_catalog as catalog
|
||||
|
||||
# 北京时间:sentAt 统一带 +08:00 偏移下发,前端直接按本地时区渲染「今天/昨天/M月D日」。
|
||||
_CST = timezone(timedelta(hours=8))
|
||||
|
||||
# mock id 从 90001 起步,一眼可辨"这是虚拟数据"(真实表主键自增不会从这里开始)。
|
||||
_id_counter = itertools.count(90001)
|
||||
_lock = threading.Lock()
|
||||
# user_id → 该用户的 mock 通知列表(生成后仅 is_read 会变化)
|
||||
_stores: dict[int, list[MockNotification]] = {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockNotification:
|
||||
"""一条 mock 通知的动态部分;静态部分(标题/分类/版式/操作行)看 catalog.TYPES。"""
|
||||
|
||||
id: int
|
||||
type_key: str
|
||||
sent_at: datetime
|
||||
is_read: bool
|
||||
coins: int | None = None # 金币数(整数;dual_amount / coin_reward 卡)
|
||||
cash_cents: int | None = None # 现金金额,单位分(dual_amount / withdraw / friend_cash 卡)
|
||||
info_rows: list[dict[str, str]] = field(default_factory=list) # [{label, value}]
|
||||
extra: dict[str, str] = field(default_factory=dict) # 点击跳转所需业务参数
|
||||
|
||||
|
||||
def cash_yuan(cents: int | None) -> str | None:
|
||||
"""分 → 保留两位小数的元字符串(PRD §3:现金/提现金额保留两位小数)。"""
|
||||
if cents is None:
|
||||
return None
|
||||
return f"{cents // 100}.{cents % 100:02d}"
|
||||
|
||||
|
||||
def _fmt_time(dt: datetime) -> str:
|
||||
"""信息行里「到账时间」等 value 的展示格式。"""
|
||||
return dt.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 各类型的样例卡片内容(数值 + 信息行 + extra),文案对齐 PRD §3
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _card_reward_expiring(sent_at: datetime, coins: int = 86, cash: int = 1280, days: int = 3) -> dict:
|
||||
return {
|
||||
"coins": coins,
|
||||
"cash_cents": cash,
|
||||
"info_rows": [
|
||||
{
|
||||
"label": "过期说明",
|
||||
"value": f"您有{coins}金币和{cash_yuan(cash)}元现金即将失效,"
|
||||
"完成一次一键领券或一键比价即可激活收益",
|
||||
},
|
||||
{"label": "过期时间", "value": f"{days}天后失效"},
|
||||
],
|
||||
# batchId:同一批次激活成功后不再重复推送(PRD §2 激活逻辑)
|
||||
"extra": {"batchId": f"batch_{sent_at:%Y%m%d}"},
|
||||
}
|
||||
|
||||
|
||||
def _card_reward_expired(sent_at: datetime, coins: int = 35, cash: int = 60) -> dict:
|
||||
return {
|
||||
"coins": coins,
|
||||
"cash_cents": cash,
|
||||
"info_rows": [
|
||||
{
|
||||
"label": "过期说明",
|
||||
"value": f"您的{coins}金币和{cash_yuan(cash)}元现金已失效,"
|
||||
"完成一次一键领券或一键比价可赚取新收益",
|
||||
},
|
||||
{"label": "过期时间", "value": f"已过期 {sent_at.month}月{sent_at.day}日失效"},
|
||||
],
|
||||
"extra": {}, # 点击跳赚钱页(tab),无需参数
|
||||
}
|
||||
|
||||
|
||||
def _card_withdraw_success(sent_at: datetime, cash: int = 50) -> dict:
|
||||
return {
|
||||
"cash_cents": cash,
|
||||
"info_rows": [
|
||||
{"label": "到账账户", "value": "微信钱包"},
|
||||
{"label": "到账时间", "value": _fmt_time(sent_at)},
|
||||
],
|
||||
"extra": {}, # 无跳转,仅消红点
|
||||
}
|
||||
|
||||
|
||||
def _card_withdraw_failed(sent_at: datetime, cash: int = 350, reason: str = "微信零钱未实名") -> dict:
|
||||
return {
|
||||
"cash_cents": cash,
|
||||
"info_rows": [
|
||||
{"label": "失败原因", "value": reason},
|
||||
{"label": "退回说明", "value": "款项已原路退回现金余额"},
|
||||
],
|
||||
"extra": {"withdrawId": "88001"}, # 点击跳提现页
|
||||
}
|
||||
|
||||
|
||||
def _card_permission(permission: str) -> dict:
|
||||
# permission ∈ accessibility(无障碍)/ battery(省电策略)/ autostart(自启动)/ overlay(悬浮窗)
|
||||
# 客户端点击时按此 key 实时检测该权限并弹对应开启弹窗(PRD §2 权限逻辑)。
|
||||
return {
|
||||
"info_rows": [
|
||||
{"label": "说明文案", "value": "未开启将导致核心功能不可用,请尽快开启"},
|
||||
],
|
||||
"extra": {"permission": permission},
|
||||
}
|
||||
|
||||
|
||||
def _card_feedback_reply(feedback_id: str) -> dict:
|
||||
return {
|
||||
"info_rows": [
|
||||
{"label": "说明文案", "value": "快去看看官方给您的回复吧~"},
|
||||
],
|
||||
"extra": {"feedbackId": feedback_id}, # 跳反馈历史页并滚动高亮该条(PRD §2)
|
||||
}
|
||||
|
||||
|
||||
def _card_feedback_reward(sent_at: datetime, coins: int = 300,
|
||||
reply: str = "感谢反馈,您说的问题已经修复上线,送您的金币请查收~") -> dict:
|
||||
return {
|
||||
"coins": coins,
|
||||
"info_rows": [
|
||||
{"label": "奖励说明", "value": "感谢您的反馈,您的金币奖励已到账"},
|
||||
{"label": "官方留言", "value": reply}, # PRD §3:官方留言必填(发奖励必带留言)
|
||||
{"label": "到账时间", "value": _fmt_time(sent_at)},
|
||||
],
|
||||
"extra": {"feedbackId": "3002"},
|
||||
}
|
||||
|
||||
|
||||
def _card_report_approved(sent_at: datetime, coins: int = 1000, store: str = "蜀大侠火锅") -> dict:
|
||||
return {
|
||||
"coins": coins,
|
||||
"info_rows": [
|
||||
{"label": "奖励说明", "value": f"您爆料的「{store}」更低价已通过审核,金币奖励已到账"},
|
||||
{"label": "到账时间", "value": _fmt_time(sent_at)},
|
||||
],
|
||||
"extra": {"reportId": "5001"}, # 跳爆料记录页并滚动高亮该条
|
||||
}
|
||||
|
||||
|
||||
def _card_invite_order_reward(sent_at: datetime, cash: int = 200, nickname: str = "柚子") -> dict:
|
||||
return {
|
||||
"cash_cents": cash,
|
||||
"info_rows": [
|
||||
{"label": "奖励说明", "value": f"好友「{nickname}」完成首次下单"},
|
||||
{"label": "到账时间", "value": _fmt_time(sent_at)},
|
||||
],
|
||||
"extra": {"inviteeNickname": nickname}, # 跳邀请页(welfare/invite.html?from=notifications)
|
||||
}
|
||||
|
||||
|
||||
def _card_invite_remind(nickname: str = "阿泽") -> dict:
|
||||
return {
|
||||
"info_rows": [
|
||||
{
|
||||
"label": "说明文案",
|
||||
"value": f"好友「{nickname}」已注册,还没完成比价下单,提醒TA完成后你可得2元现金",
|
||||
},
|
||||
],
|
||||
# scrollTo=remind:跳邀请页并自动滚动到底部「提醒好友」模块(PRD §2 #13)
|
||||
"extra": {"inviteeNickname": nickname, "scrollTo": "remind"},
|
||||
}
|
||||
|
||||
|
||||
def build_sample(type_key: str, sent_at: datetime | None = None) -> MockNotification:
|
||||
"""按类型生成一条默认样例(push 测试联动等场景插入新 mock 通知用)。"""
|
||||
catalog.get_type(type_key) # 校验 type 合法
|
||||
now = sent_at or datetime.now(_CST)
|
||||
default_builders = {
|
||||
"reward_expiring": lambda: _card_reward_expiring(now),
|
||||
"reward_expired": lambda: _card_reward_expired(now),
|
||||
"withdraw_success": lambda: _card_withdraw_success(now),
|
||||
"withdraw_failed": lambda: _card_withdraw_failed(now),
|
||||
"perm_accessibility": lambda: _card_permission("accessibility"),
|
||||
"perm_battery": lambda: _card_permission("battery"),
|
||||
"perm_autostart": lambda: _card_permission("autostart"),
|
||||
"perm_overlay": lambda: _card_permission("overlay"),
|
||||
"feedback_reply": lambda: _card_feedback_reply("3001"),
|
||||
"feedback_reward": lambda: _card_feedback_reward(now),
|
||||
"report_approved": lambda: _card_report_approved(now),
|
||||
"invite_order_reward": lambda: _card_invite_order_reward(now),
|
||||
"invite_remind": lambda: _card_invite_remind(),
|
||||
}
|
||||
return MockNotification(
|
||||
id=next(_id_counter),
|
||||
type_key=type_key,
|
||||
sent_at=now,
|
||||
is_read=False,
|
||||
**default_builders[type_key](),
|
||||
)
|
||||
|
||||
|
||||
def _seed_user(user_id: int) -> list[MockNotification]:
|
||||
"""给一个用户生成整套 mock 样例:13 类型全覆盖 + 时间显示四分支 + 已读/未读混合。"""
|
||||
now = datetime.now(_CST)
|
||||
today = now - timedelta(hours=2) # 今天
|
||||
today_recent = now - timedelta(minutes=10) # 今天(更近,验证组内时间倒序)
|
||||
yesterday = now - timedelta(days=1) # 昨天
|
||||
this_year = now - timedelta(days=40) # 当年 → 「M月D日」
|
||||
last_year = now - timedelta(days=370) # 跨年 → 「YYYY年M月D日」
|
||||
|
||||
def one(type_key: str, sent_at: datetime, card: dict, *, read: bool = False) -> MockNotification:
|
||||
return MockNotification(
|
||||
id=next(_id_counter), type_key=type_key, sent_at=sent_at, is_read=read, **card
|
||||
)
|
||||
|
||||
return [
|
||||
# ---- 提现助手(4 类 + 2 条补充,验证组内倒序与跨年展示)----
|
||||
one("reward_expiring", today, _card_reward_expiring(today)),
|
||||
one("reward_expired", yesterday, _card_reward_expired(yesterday)),
|
||||
one("withdraw_success", today_recent, _card_withdraw_success(today_recent)),
|
||||
one("withdraw_failed", yesterday, _card_withdraw_failed(yesterday)),
|
||||
one("withdraw_success", now - timedelta(days=3),
|
||||
_card_withdraw_success(now - timedelta(days=3), cash=3000)),
|
||||
one("withdraw_success", last_year, _card_withdraw_success(last_year, cash=120), read=True),
|
||||
# ---- 系统通知(权限异常 ×4;同一权限未读期间只更新时间不新增,故各一条)----
|
||||
one("perm_accessibility", now - timedelta(hours=1), _card_permission("accessibility")),
|
||||
one("perm_battery", now - timedelta(days=3), _card_permission("battery"), read=True),
|
||||
one("perm_autostart", now - timedelta(days=5), _card_permission("autostart")),
|
||||
one("perm_overlay", now - timedelta(days=6), _card_permission("overlay"), read=True),
|
||||
# ---- 我的反馈 ----
|
||||
one("feedback_reply", now - timedelta(hours=4), _card_feedback_reply("3001")),
|
||||
one("feedback_reward", yesterday, _card_feedback_reward(yesterday)),
|
||||
one("feedback_reply", now - timedelta(days=60), _card_feedback_reply("2001"), read=True),
|
||||
# ---- 我的爆料 ----
|
||||
one("report_approved", this_year, _card_report_approved(this_year)),
|
||||
# ---- 好友邀请 ----
|
||||
one("invite_order_reward", today_recent, _card_invite_order_reward(today_recent)),
|
||||
one("invite_remind", now - timedelta(days=2), _card_invite_remind()),
|
||||
]
|
||||
|
||||
|
||||
def _get_store(user_id: int) -> list[MockNotification]:
|
||||
"""取该用户的 mock 列表,首次访问时生成。调用方须持有 _lock。"""
|
||||
store = _stores.get(user_id)
|
||||
if store is None:
|
||||
store = _seed_user(user_id)
|
||||
_stores[user_id] = store
|
||||
return store
|
||||
|
||||
|
||||
def _sorted(store: list[MockNotification]) -> list[MockNotification]:
|
||||
"""排序:全列表时间倒序(同秒再按 id 倒序稳定化)。不分组(PRD 原文写错,已取消)。"""
|
||||
return sorted(store, key=lambda n: (-n.sent_at.timestamp(), -n.id))
|
||||
|
||||
|
||||
def list_notifications(
|
||||
user_id: int, *, page: int, page_size: int
|
||||
) -> tuple[list[MockNotification], int, int]:
|
||||
"""分页取通知列表。返回 (当前页条目, 总条数, 未读条数)。"""
|
||||
with _lock:
|
||||
store = _get_store(user_id)
|
||||
ordered = _sorted(store)
|
||||
total = len(ordered)
|
||||
unread = sum(1 for n in store if not n.is_read)
|
||||
start = (page - 1) * page_size
|
||||
return ordered[start : start + page_size], total, unread
|
||||
|
||||
|
||||
def unread_count(user_id: int) -> int:
|
||||
"""未读总数(首页铃铛角标)。"""
|
||||
with _lock:
|
||||
return sum(1 for n in _get_store(user_id) if not n.is_read)
|
||||
|
||||
|
||||
def mark_read(
|
||||
user_id: int, *, ids: list[int] | None = None, mark_all: bool = False
|
||||
) -> tuple[int, int]:
|
||||
"""标记已读。mark_all=True 全量清零,否则按 ids 逐条置读(不存在的 id 忽略,幂等)。
|
||||
|
||||
返回 (本次实际由未读→已读的条数, 剩余未读数)。
|
||||
"""
|
||||
with _lock:
|
||||
store = _get_store(user_id)
|
||||
marked = 0
|
||||
if mark_all:
|
||||
for n in store:
|
||||
if not n.is_read:
|
||||
n.is_read = True
|
||||
marked += 1
|
||||
else:
|
||||
wanted = set(ids or [])
|
||||
for n in store:
|
||||
if n.id in wanted and not n.is_read:
|
||||
n.is_read = True
|
||||
marked += 1
|
||||
unread = sum(1 for n in store if not n.is_read)
|
||||
return marked, unread
|
||||
|
||||
|
||||
def insert_sample(user_id: int, type_key: str) -> MockNotification:
|
||||
"""插入一条该类型的新 mock 通知(未读)。push 测试联动用:push extras 带上它的 id,
|
||||
客户端点击 push 后调 POST /notifications/read {ids:[id]} 即可闭环验证已读联动。"""
|
||||
with _lock:
|
||||
store = _get_store(user_id)
|
||||
item = build_sample(type_key)
|
||||
store.append(item)
|
||||
return item
|
||||
|
||||
|
||||
def reset_store(user_id: int | None = None) -> None:
|
||||
"""清空 mock 状态(测试/联调用;user_id=None 清全部)。下次访问重新生成。"""
|
||||
with _lock:
|
||||
if user_id is None:
|
||||
_stores.clear()
|
||||
else:
|
||||
_stores.pop(user_id, None)
|
||||
@@ -5,7 +5,7 @@ device_id 为空(老客户端 / 取不到 ANDROID_ID)一律按"未完成"处理,
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -33,3 +33,18 @@ def mark_completed(db: Session, *, user_id: int, device_id: str) -> None:
|
||||
except IntegrityError:
|
||||
# 并发 / 重复提交撞唯一约束:已有行即视为成功。
|
||||
db.rollback()
|
||||
|
||||
|
||||
def delete_completion(db: Session, *, user_id: int, device_id: str) -> int:
|
||||
"""删该 (账号, 设备) 的引导完成标记 → 下次登录 is_completed=False → 客户端重走。
|
||||
与 [mark_completed] 互逆。device_id 为空忽略(返 0);无记录也幂等(返 0)。返回删除行数。"""
|
||||
if not device_id:
|
||||
return 0
|
||||
result = db.execute(
|
||||
delete(OnboardingCompletion).where(
|
||||
OnboardingCompletion.user_id == user_id,
|
||||
OnboardingCompletion.device_id == device_id,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return result.rowcount
|
||||
|
||||
@@ -296,6 +296,9 @@ def daily_auto_exchange(db: Session) -> dict:
|
||||
- **逐用户独立事务**:单个用户异常 rollback 不影响其他人;exchange_coins_to_cash 内部按用户 commit。
|
||||
返回统计 dict(scanned/converted/skipped_done/skipped_dust/failed/total_cents)。
|
||||
"""
|
||||
# ⚠️「今天」写死北京时(rewards.cn_today() = datetime.now(CN_TZ).date(), CN_TZ=+8),与服务器/用户
|
||||
# 时区无关(用户 2026-07-01 硬约束:兑换一律北京 0 点日切)。**禁止**改成 date.today() / 裸
|
||||
# datetime.now().date()——那会跟随进程本地时区,服务器非 CST 时会在错误的"天"兑/重复兑。
|
||||
today = rewards.cn_today()
|
||||
stats = {
|
||||
"scanned": 0, "converted": 0, "skipped_done": 0,
|
||||
|
||||
+1
-22
@@ -3,15 +3,12 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -21,8 +18,6 @@ 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):
|
||||
@@ -31,8 +26,6 @@ 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
|
||||
@@ -53,17 +46,3 @@ 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
|
||||
|
||||
@@ -69,6 +69,7 @@ class InviteeItem(BaseModel):
|
||||
avatar_url: str | None = None # 头像 URL;null = 前端画默认色块
|
||||
coins: int # 这次邀请给我(邀请人)发的金币
|
||||
invited_at: datetime # 邀请绑定时间(前端转"今天/3天前")
|
||||
is_compared: bool = False # 该好友是否已完成过一次比价(好友列表分"去提醒/邀请成功";在途列表只取 False)
|
||||
|
||||
|
||||
class InviteeListOut(BaseModel):
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
"""消息通知中心 请求/响应契约。
|
||||
|
||||
⚠️ 命名约定:本组接口按 PRD 前端契约使用 **camelCase**(sentAt / isRead / pageSize …),
|
||||
与库内其他 snake_case 接口不同——PRD 与前端原型(notifications.html)按 camelCase 对接,
|
||||
需求方接口清单亦明确写作 sentAt / isRead,故整组遵循之。响应序列化走 pydantic alias。
|
||||
|
||||
字段说明都写在 Field(description=...) 里,起服务后打开 /docs 即是给前端的在线文档。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
|
||||
class _CamelModel(BaseModel):
|
||||
"""出参统一 camelCase(alias);populate_by_name 允许服务端代码仍用 snake_case 构造。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
||||
|
||||
|
||||
class InfoRow(_CamelModel):
|
||||
"""卡片信息行(PRD §3「信息行」列),前端按 label: value 逐行渲染。"""
|
||||
|
||||
label: str = Field(description="行标签,如「过期说明」「到账账户」「失败原因」")
|
||||
value: str = Field(description="行内容(已按 PRD 文案拼好变量,前端直接展示)")
|
||||
|
||||
|
||||
class NotificationItem(_CamelModel):
|
||||
"""一条通知卡片。
|
||||
|
||||
卡片头部三要素:categoryLabel(分类标签)+ 未读红点(isRead=false 时展示)+ 时间(sentAt)。
|
||||
时间显示规则(前端处理):今天→「今天」;昨天→「昨天」;当年→「M月D日」;跨年→「YYYY年M月D日」。
|
||||
"""
|
||||
|
||||
id: int = Field(description="通知 id(未读消除、push 联动都用它)")
|
||||
category: str = Field(
|
||||
description="分类 key:withdraw_assistant=提现助手 / system=系统通知 / "
|
||||
"feedback=我的反馈 / report=我的爆料 / invite=好友邀请"
|
||||
)
|
||||
category_label: str = Field(description="分类中文标签(卡片头部直接展示)")
|
||||
type: str = Field(
|
||||
description="类型 key(13 种,决定点击行为,见 PRD §2):reward_expiring 即将失效 / "
|
||||
"reward_expired 已失效 / withdraw_success 提现成功 / withdraw_failed 提现失败 / "
|
||||
"perm_accessibility 无障碍异常 / perm_battery 省电策略异常 / "
|
||||
"perm_autostart 自启动异常 / perm_overlay 悬浮窗异常 / "
|
||||
"feedback_reply 官方回复 / feedback_reward 反馈奖励 / "
|
||||
"report_approved 爆料审核通过 / invite_order_reward 好友下单奖励 / "
|
||||
"invite_remind 好友催单提醒"
|
||||
)
|
||||
card_style: str = Field(
|
||||
description="卡片版式:dual_amount 双金额卡 / withdraw 提现卡 / plain_text 纯文本卡 / "
|
||||
"coin_reward 金币奖励卡 / friend_cash 好友现金卡"
|
||||
)
|
||||
title: str = Field(description="卡片标题(双金额/提现/金币奖励/好友现金卡标题居中)")
|
||||
coins: int | None = Field(
|
||||
default=None,
|
||||
description="金币数(整数,不带小数)。dual_amount / coin_reward 卡有值,其余 null",
|
||||
)
|
||||
cash_cents: int | None = Field(
|
||||
default=None,
|
||||
description="现金金额,单位【分】。dual_amount / withdraw / friend_cash 卡有值,其余 null",
|
||||
)
|
||||
cash_yuan: str | None = Field(
|
||||
default=None,
|
||||
description="现金金额展示串(元,保留两位小数,如 \"12.80\"),与 cashCents 同源,可直接展示",
|
||||
)
|
||||
info_rows: list[InfoRow] = Field(
|
||||
description="信息行列表(label: value),内容已按 PRD §3 拼好,前端逐行渲染即可"
|
||||
)
|
||||
action_text: str | None = Field(
|
||||
default=None,
|
||||
description="操作行文案(如「立即激活您的收益」「去开启」);null=无操作行(提现成功卡)。"
|
||||
"注意:点击目标是整张卡片,不区分卡片主体和操作行",
|
||||
)
|
||||
extra: dict[str, Any] = Field(
|
||||
description="点击跳转所需业务参数,按 type 取用:perm_* → {permission: accessibility|battery|"
|
||||
"autostart|overlay}(点击时实时检测该权限);feedback_* → {feedbackId};"
|
||||
"report_approved → {reportId};withdraw_failed → {withdrawId};"
|
||||
"invite_order_reward → {inviteeNickname};invite_remind → "
|
||||
"{inviteeNickname, scrollTo:\"remind\"};reward_expiring → {batchId}"
|
||||
)
|
||||
sent_at: datetime = Field(description="下发时间(ISO8601 带 +08:00 时区),前端按显示规则格式化")
|
||||
is_read: bool = Field(description="是否已读;false 时分类标签右侧展示 6px 红点(#E53935)")
|
||||
|
||||
|
||||
class NotificationListOut(_CamelModel):
|
||||
"""GET /api/v1/notifications 出参。列表已按时间倒序排好(最新在前,**不分组**;
|
||||
PRD §1 的"按分类分组"为笔误,已确认取消),前端无需再排。"""
|
||||
|
||||
items: list[NotificationItem] = Field(description="当前页通知卡片")
|
||||
page: int = Field(description="当前页码(1 起)")
|
||||
page_size: int = Field(description="每页条数")
|
||||
total: int = Field(description="全部通知总条数(含已读)")
|
||||
has_more: bool = Field(description="是否还有下一页")
|
||||
unread_count: int = Field(description="当前未读总数(与 /notifications/unread-count 同口径,省一次请求)")
|
||||
|
||||
|
||||
class UnreadCountOut(_CamelModel):
|
||||
"""GET /api/v1/notifications/unread-count 出参(首页铃铛角标)。"""
|
||||
|
||||
count: int = Field(description="未读总条数(精确值)")
|
||||
badge_text: str | None = Field(
|
||||
description="角标展示文案:超过 99 返回 \"99+\";等于 0 返回 null(整个角标隐藏,不展示空红点)"
|
||||
)
|
||||
|
||||
|
||||
class MarkReadRequest(_CamelModel):
|
||||
"""POST /api/v1/notifications/read 入参,两种模式二选一:
|
||||
|
||||
- `{"ids": [90001, 90002]}` 单条/多条置读——点击某张卡片、点击 push 落地后同步置读;
|
||||
- `{"all": true}` 全量清零——进入通知中心(或退出时)自动清零(PRD §4)。
|
||||
|
||||
同时传时 all=true 优先;不存在/已读的 id 自动忽略(幂等,可放心重试)。
|
||||
"""
|
||||
|
||||
ids: list[int] | None = Field(default=None, description="要置为已读的通知 id 列表")
|
||||
all: bool = Field(default=False, description="true=清空该用户全部未读")
|
||||
|
||||
|
||||
class MarkReadOut(_CamelModel):
|
||||
"""POST /api/v1/notifications/read 出参。"""
|
||||
|
||||
ok: bool = Field(description="固定 true(参数非法时走 400,不会到这里)")
|
||||
marked_count: int = Field(description="本次实际由未读变为已读的条数(重复请求会是 0)")
|
||||
unread_count: int = Field(description="处理后的剩余未读总数,可直接刷新铃铛角标")
|
||||
@@ -1,99 +0,0 @@
|
||||
"""厂商推送(测试/联调)接口契约。与消息中心同族,出参统一 camelCase。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
|
||||
class _CamelModel(BaseModel):
|
||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
||||
|
||||
|
||||
class PushVendorStatus(_CamelModel):
|
||||
vendor: str = Field(description="厂商 key:honor / huawei / xiaomi / oppo / vivo")
|
||||
label: str = Field(description="厂商中文名")
|
||||
configured: bool = Field(description="服务端凭据是否齐全(齐全才能真发,mock 不受影响)")
|
||||
missing_keys: list[str] = Field(description="缺失的 .env 配置键;configured=true 时为空")
|
||||
|
||||
|
||||
class PushVendorsOut(_CamelModel):
|
||||
vendors: list[PushVendorStatus] = Field(description="5 个厂商的配置状态")
|
||||
|
||||
|
||||
class PushTemplateOut(_CamelModel):
|
||||
type: str = Field(description="通知类型 key(13 种,与消息中心 type 一致)")
|
||||
category: str = Field(description="分类 key")
|
||||
category_label: str = Field(description="分类中文标签")
|
||||
card_style: str = Field(description="站内卡片版式")
|
||||
push_title: str = Field(description="push 标题(≤11 字固定文案,PRD §5)")
|
||||
push_body_sample: str = Field(description="push 正文示例(模板用 PRD 示例值渲染后的效果)")
|
||||
push_body_template: str = Field(description="push 正文模板原文,{var} 为变量占位")
|
||||
variables: list[str] = Field(description="模板变量名列表(调 /push/test 时可在 vars 里覆盖)")
|
||||
sample_vars: dict[str, str] = Field(description="各变量的 PRD 示例值(vars 未覆盖时的缺省)")
|
||||
|
||||
|
||||
class PushTemplatesOut(_CamelModel):
|
||||
templates: list[PushTemplateOut] = Field(description="13 种通知类型的 push 模板(PRD 编号顺序)")
|
||||
|
||||
|
||||
class PushTestRequest(_CamelModel):
|
||||
"""POST /api/v1/push/test 入参。三种发送内容来源(优先级从高到低):
|
||||
|
||||
1. 直接指定 title + content;
|
||||
2. 指定 type(13 种之一)→ 按 PRD §5 模板渲染,vars 可覆盖模板变量;
|
||||
3. 都不传 → 发一条通用测试文案。
|
||||
|
||||
推送目标:pushToken 直填,或 deviceId 反查该用户已注册设备(/api/v1/device/register 上报过的)。
|
||||
"""
|
||||
|
||||
vendor: str = Field(
|
||||
default="",
|
||||
description="厂商:honor/huawei/xiaomi/oppo/vivo(中文「华为」「小米」等别名也识别)。"
|
||||
"留空时用 deviceId 对应设备上报的 push_vendor",
|
||||
)
|
||||
push_token: str = Field(default="", description="厂商 push token / regId;留空则走 deviceId 反查")
|
||||
device_id: str = Field(default="", description="设备 id(客户端 DeviceId.get());用于反查 token")
|
||||
type: str = Field(
|
||||
default="",
|
||||
description="通知类型 key(13 种,见 GET /push/templates);留空且未直接给 title/content 时发通用测试文案",
|
||||
)
|
||||
vars: dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="覆盖 push 模板变量,如 {\"coins\":\"520\",\"cash\":\"6.66\"};缺省用 PRD 示例值",
|
||||
)
|
||||
title: str = Field(default="", description="直接指定标题(优先于 type 模板)")
|
||||
content: str = Field(default="", description="直接指定正文(优先于 type 模板)")
|
||||
create_notification: bool = Field(
|
||||
default=False,
|
||||
description="true=同时往该用户的消息中心 mock 列表插入一条同类型未读通知,push extras 带上它的"
|
||||
" notificationId → 可闭环验证「点 push → 落地 → 调 /notifications/read 消红点」联动"
|
||||
"(仅 type 为 13 种类型之一时生效)",
|
||||
)
|
||||
mock: bool = Field(
|
||||
default=True,
|
||||
description="true(默认)=不真调厂商 API,返回渲染结果(联调安全);false=真发,要求该厂商凭据已配置",
|
||||
)
|
||||
|
||||
|
||||
class PushTestOut(_CamelModel):
|
||||
ok: bool = Field(description="发送(或 mock 渲染)成功")
|
||||
mock: bool = Field(description="本次是否 mock(未真调厂商 API)")
|
||||
vendor: str = Field(description="实际使用的厂商 key(已归一化)")
|
||||
title: str = Field(description="实际下发的 push 标题")
|
||||
body: str = Field(description="实际下发的 push 正文")
|
||||
extras: dict[str, str] = Field(
|
||||
description="随 push 下发的自定义键值(客户端深链用):type 必有;createNotification=true 时带"
|
||||
" notificationId 及该通知的业务参数(feedbackId / permission / …)"
|
||||
)
|
||||
notification_id: int | None = Field(
|
||||
default=None, description="createNotification=true 时新插入的站内 mock 通知 id"
|
||||
)
|
||||
missing_keys: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="该厂商仍缺失的配置键(mock 发送时提示「真发前还需配什么」;真发时必为空)",
|
||||
)
|
||||
vendor_response: dict[str, Any] | None = Field(
|
||||
default=None, description="真发时厂商 API 的原始响应(mock 时为 null)"
|
||||
)
|
||||
@@ -19,6 +19,9 @@ Type=oneshot
|
||||
User=root
|
||||
WorkingDirectory=/opt/shaguabijia-app-server
|
||||
Environment="PATH=/opt/shaguabijia-app-server/.venv/bin:/usr/bin:/bin"
|
||||
# 写死北京时:兑换的"当天/0 点"一律按北京时,不随服务器 OS 时区漂(用户 2026-07-01 硬约束)。
|
||||
# 脚本内的日期判断本就走 rewards.cn_today()(CN_TZ=+8),这里再把进程 TZ 也钉成北京,双保险。
|
||||
Environment="TZ=Asia/Shanghai"
|
||||
EnvironmentFile=/opt/shaguabijia-app-server/.env
|
||||
ExecStart=/opt/shaguabijia-app-server/.venv/bin/python -m scripts.daily_auto_exchange --once
|
||||
SyslogIdentifier=daily-exchange
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
Description=Run daily auto-exchange coins->cash at midnight
|
||||
|
||||
[Timer]
|
||||
# 每天 0 点跑。客户端文案已注明「可能存在延迟」,可按需改 00:05 错开整点扎堆。
|
||||
OnCalendar=*-*-* 00:00:00
|
||||
# 每天【北京时】0 点跑,写死时区(用户 2026-07-01 硬约束:兑换一律北京 0 点,不随服务器本地时区漂)。
|
||||
# systemd OnCalendar 支持尾缀时区;不写时区会按服务器 OS 本地时区触发 → 服务器非 CST 时会在错误时刻兑。
|
||||
# 客户端文案已注明「可能存在延迟」,可按需改 00:05 错开整点扎堆。
|
||||
OnCalendar=*-*-* 00:00:00 Asia/Shanghai
|
||||
# 服务器宕机/重启后,补跑错过的那一轮(而不是干等次日)。
|
||||
Persistent=true
|
||||
AccuracySec=1min
|
||||
|
||||
+73
-22
@@ -1,32 +1,83 @@
|
||||
# 后端文档库(docs/)
|
||||
# 文档索引
|
||||
|
||||
`shaguabijia-app-server` 的文档都在这里。结构:**根目录放总览,其余按领域/用途分目录**。
|
||||
项目文档结构说明。后续大模型增补/更新文档时,按此分类找到对应目录。
|
||||
|
||||
## 根目录
|
||||
---
|
||||
|
||||
| 文档 | 作用 |
|
||||
|---|---|
|
||||
| [后端技术实现.md](./后端技术实现.md) | **后端技术方案总览**:业务概览、分层架构与目录、登录链路、美团 CPS、领券/比价透传、数据模型、配置与部署、已知问题。想了解"整个后端怎么回事"先看这份。 |
|
||||
| README.md | 本文件:文档库索引/传送门。 |
|
||||
## API 接口文档 (`api/`)
|
||||
|
||||
## 子目录
|
||||
按业务领域分类,每个子目录对应一类接口。
|
||||
|
||||
| 目录 | 作用 |
|
||||
|---|---|
|
||||
| [api/](./api/) | **API 接口文档**:"索引 + 一接口一文件"(组织方式见下)。想查"某个接口的协议"看这里。 |
|
||||
| [database/](./database/) | **数据库文档**:每张表一个文件(表结构/字段/索引/约定) + 两份迁移指南——[数据库迁移.md](./database/数据库迁移.md)(Alembic 操作:clone 后建表/日常升级/新增迁移/多 head 排查) + [postgres-migration.md](./database/postgres-migration.md)(SQLite → PostgreSQL 切换步骤,配套 `scripts/init_postgres.py`)。想"把库跑起来 / 改表结构 / 查某张表"看这里。 |
|
||||
| [integrations/](./integrations/) | **集成层实现文档**:穿山甲验签 / 微信支付 / 极光 / 短信 / 美团 CPS 等 SDK 集成的签名、加解密、协议细节与踩坑。想知道"接外部服务那块到底怎么实现"看这里。 |
|
||||
| [guides/](./guides/) | **开发 / 上线 / 功能 指南**:[待办与技术债.md](./guides/待办与技术债.md)(跨前后端 backlog,P1 鉴权/用户绑定、引擎移植待办等,"还欠什么、以后要补什么"看这份) + [看广告赚金币上线清单.md](./guides/看广告赚金币上线清单.md)(看广告发奖上线 checklist) + [邀请功能-实现原理与本地测试.md](./guides/邀请功能-实现原理与本地测试.md)(invite-mvp 实现原理 + 本地内网全链路测试) + [CPS发券分发与微信授权.md](./guides/CPS发券分发与微信授权.md)(**CPS 发券系统交接文档**:设群/建活动/生成落地页短链/美团对账/统计 + 微信网页授权拿 openid 做用户级统计;含数据流/表/平台差异/端点/配置/代码地图/排障/技术债)。 |
|
||||
### 分类目录
|
||||
|
||||
## api/ 目录是怎么组织的(传送门式)
|
||||
| 目录 | 分类 | URL 前缀 | 说明 |
|
||||
|------|------|----------|------|
|
||||
| [api/auth/](api/auth/) | 认证 | `/api/v1/auth` | 用户登录(极光一键登录/短信验证码)、Token 刷新(access + refresh)、登出、当前用户信息查询 |
|
||||
| [api/ad/](api/ad/) | 广告 | `/api/v1/ad` | 穿山甲 S2S 回调验签发奖、激励视频/信息流广告奖励结算、eCPM 上报、发奖状态查询、测试发奖(仅本地) |
|
||||
| [api/wallet/](api/wallet/) | 钱包 | `/api/v1/wallet` | 账户资产查询(金币/现金余额)、金币与现金流水、兑换规则与执行、绑定/解绑微信、提现申请/状态/记录、免确认收款授权 |
|
||||
| [api/coupon/](api/coupon/) | 领券 | `/api/v1/coupon` | CPS 领券透传(step/session)、引导窗频控(should-show/shown/dismiss/reset)、每日完成状态、累计领券统计 |
|
||||
| [api/compare/](api/compare/) | 比价记录 | `/api/v1/compare` | 比价记录上报/列表/详情/统计、比价战绩里程碑查询及奖励领取(按成功比价数解锁金币) |
|
||||
| [api/savings/](api/savings/) | 省钱 | `/api/v1/savings` + `/api/v1/platform/savings-feed` | 省钱大作战(battle)、省钱明细/汇总、平台省钱动态 Feed |
|
||||
| [api/signin/](api/signin/) | 签到 | `/api/v1/signin` | 每日签到执行、签到加速(看广告多领)、签到状态查询 |
|
||||
| [api/tasks/](api/tasks/) | 任务 | `/api/v1/tasks` | 任务列表查询、任务奖励领取(按 task_key) |
|
||||
| [api/invite/](api/invite/) | 邀请 | `/api/v1/invite` | 邀请码/分享链接生成、已邀请列表、邀请绑定(支持 clipboard/manual/fingerprint 三种归因) |
|
||||
| [api/user/](api/user/) | 用户 | `/api/v1/user` | 个人资料编辑(昵称)、头像上传、新手引导状态/完成标记、注销账号 |
|
||||
| [api/device/](api/device/) | 设备 | `/api/v1/device` | 设备注册/推送 token 更新、无障碍存活心跳、掉线检测(后置 pull)、告警确认 |
|
||||
| [api/platform/](api/platform/) | 平台配置 | `/api/v1/platform` | 平台统计数据、Feature Flag 开关、广告配置(穿山甲 ID)、App 版本更新检查(OTA);全部不鉴权 |
|
||||
| [api/intent/](api/intent/) | 意图/电商 | `/api/v1/intent` + `/api/v1/price` + `/api/v1/ecom` | 比价意图识别(Phase 1 单次/多帧/预券)、电商意图识别、比价步进(Phase 2) |
|
||||
| [api/meituan/](api/meituan/) | 美团 CPS | `/api/v1/meituan` | 美团券列表/信息流/推广链接/销量榜;全部无鉴权 |
|
||||
| [api/other/](api/other/) | 其它 | 分散 | 健康检查、CPS 短链重定向(含微信 OAuth)、反馈提交/配置/记录、埋点上报、订单上报、更低价上报、Trace 收尾 |
|
||||
| [api/admin/](api/admin/) | 管理后台 | `/admin/api` | Admin 独立子应用,二级目录按子资源拆分:`auth/`(登录)、`users/`(用户管理/财务操作)、`wallet/`(流水查询)、`withdraws/`(提现审核/对账)、`feedbacks/`(反馈处理)、`admins/`(管理员管理)、`ad/`(广告对账/收益);单文件留根:审计日志、数据大盘、跑马灯种子、统计概览 |
|
||||
| [api/internal/](api/internal/) | 内部接口 | `/internal` | 服务间调用(pricebot→app-server):价格事实回写、店铺映射、启动确认样本、App 版本写入;X-Internal-Secret 鉴权 |
|
||||
|
||||
接口多了之后,单个大文件会臃肿、难维护,所以拆成 **一个索引 + 一个接口一个文件**:
|
||||
### 接口索引入口
|
||||
|
||||
- **[api/README.md](./api/README.md) — 入口/传送门**
|
||||
一张总览表列出**全部接口**(方法、路径、鉴权),每行链接到该接口的独立文档;另含**通用约定**(错误码、时间格式等)和**复用数据结构**(`TokenPair` / `UserOut` / `CouponCard` 等)。它本身不展开每个接口的细节,只负责"指路"。
|
||||
- **api/<模块>-<接口>.md — 单接口文档**
|
||||
每个接口一个文件,只写自己的入参 / 出参 / 错误码 / 说明。命名按 `<模块>-<接口>`,如 `auth-jverify-login.md`、`coupon-step.md`、`meituan-feed.md`。
|
||||
完整接口列表(含路径、方法、鉴权方式)见 [api/README.md](api/README.md)。
|
||||
|
||||
**查某个接口的协议**:先打开 [api/README.md](./api/README.md) 的总览表 → 找到接口 → 点链接进对应文档。
|
||||
### 文档命名规则
|
||||
|
||||
> **新增接口时**:在 `api/` 下加一个 `<模块>-<接口>.md`,并到 `api/README.md` 总览表里补一行链接。这样索引始终是唯一的"传送门",细节各自独立、互不干扰。
|
||||
单端点文档按 URL 路径命名:`{prefix}-{resource}.md`(如 `wallet-withdraw.md`、`auth-sms-send.md`)。
|
||||
含子资源的合并文档按族命名(如 `device-liveness.md` 覆盖 register/heartbeat/liveness/liveness-ack 四个端点)。
|
||||
文件名唯一确定文档位置:大类目录 + 文件名前缀 → 直接匹配。
|
||||
|
||||
---
|
||||
|
||||
## 数据库文档 (`database/`)
|
||||
|
||||
数据库表结构说明,每表一个文件,[database/OVERVIEW.md](database/OVERVIEW.md) 为索引入口。
|
||||
|
||||
---
|
||||
|
||||
## 第三方集成 (`integrations/`)
|
||||
|
||||
外部 SDK/API 集成架构与实现说明:[integrations/README.md](integrations/README.md)。
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [jiguang.md](integrations/jiguang.md) | 极光一键登录(REST 验证 + RSA 解密) |
|
||||
| [pangle.md](integrations/pangle.md) | 穿山甲广告 S2S 回调 |
|
||||
| [wxpay.md](integrations/wxpay.md) | 微信支付 V3(提现/授权) |
|
||||
| [meituan.md](integrations/meituan.md) | 美团 CPS 网关 |
|
||||
|
||||
---
|
||||
|
||||
## 开发指南 (`guides/`)
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [邀请功能-实现原理与本地测试.md](guides/邀请功能-实现原理与本地测试.md) | 邀请系统实现细节 |
|
||||
| [CPS发券分发与微信授权.md](guides/CPS发券分发与微信授权.md) | CPS 发券 + 微信网页授权流程 |
|
||||
| [看广告赚金币上线清单.md](guides/看广告赚金币上线清单.md) | 广告功能上线检查清单 |
|
||||
| [待办与技术债.md](guides/待办与技术债.md) | 待办事项与技术债务 |
|
||||
|
||||
---
|
||||
|
||||
## 架构与设计 (`superpowers/`)
|
||||
|
||||
需求规格与设计文档,按 `YYYY-MM-DD-<topic>-design.md` 命名。
|
||||
|
||||
---
|
||||
|
||||
## 后端技术实现 (`后端技术实现.md`)
|
||||
|
||||
后端整体技术架构说明。
|
||||
|
||||
+130
-130
@@ -1,9 +1,9 @@
|
||||
# 傻瓜比价 App 后端 — API 接口文档(索引)
|
||||
|
||||
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
|
||||
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**(⚠️ 例外:消息通知中心 `notifications` 族与厂商推送 `push` 族按 PRD 前端契约用 **camelCase**,见各自文档)
|
||||
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
|
||||
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
|
||||
> 最后更新:2026-07-14(新增 **消息通知中心** 3 端点(M1-M3,虚拟数据阶段)与 **厂商推送测试** 3 端点(P1-P3,荣耀/华为/小米/OPPO/vivo);上一次 2026-06-23 补全 device/internal/CPS 短链等整族端点)
|
||||
> 最后更新:2026-07-03(补全缺失文档:ad/watch-report, wallet/transfer-auth 族, coupon/session+stats+completed-today+prompt 族, invite 族, user/onboarding, platform/flags+ad-config+app-version, intent/step+precoupon/step, analytics/events, order/report, report 族, feedback/config+records, trace/finalize。文档移至分类子目录,新增 mock 入参/出参示例)
|
||||
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
|
||||
|
||||
---
|
||||
@@ -12,154 +12,154 @@
|
||||
|
||||
| # | 方法 + 路径 | 鉴权 | 详情 |
|
||||
|---|---|---|---|
|
||||
| 1 | `GET /health` | 无 | [详情](./health.md) |
|
||||
| 2 | `POST /api/v1/auth/jverify-login` | 无 | [详情](./auth-jverify-login.md) |
|
||||
| 3 | `POST /api/v1/auth/sms/send` | 无 | [详情](./auth-sms-send.md) |
|
||||
| 4 | `POST /api/v1/auth/sms/login` | 无 | [详情](./auth-sms-login.md) |
|
||||
| 5 | `POST /api/v1/auth/refresh` | 无 | [详情](./auth-refresh.md) |
|
||||
| 6 | `GET /api/v1/auth/me` | Bearer | [详情](./auth-me.md) |
|
||||
| 7 | `POST /api/v1/auth/logout` | Bearer | [详情](./auth-logout.md) |
|
||||
| 8 | `POST /api/v1/coupon/step` | 无 | [详情](./coupon-step.md)(透传 pricebot + best-effort 写 `coupon_*` 三表) |
|
||||
| 8a | `POST /api/v1/coupon/prompt/shown` | 无 | 引导窗弹出即上报(按 device+package+日记 `shown`,今天这个 App 不再自动弹)(无单独文档) |
|
||||
| 8b | `POST /api/v1/coupon/prompt/dismiss` | 无 | 用户拒绝/关闭引导窗(透传链路看不到拒绝,客户端通知)(无单独文档) |
|
||||
| 8c | `GET /api/v1/coupon/prompt/should-show` | 无 | 切到外卖 App 时是否还应弹引导窗(`device_id`+`package`)(无单独文档) |
|
||||
| 8d | `POST /api/v1/coupon/prompt/reset` | 无 | 重置今日引导窗 engagement(开发测频控用)(无单独文档) |
|
||||
| 8e | `GET /api/v1/coupon/completed-today` | 无 | 这台设备今天是否已跑完整轮领券(首页「去领取」卡置灰源)(无单独文档) |
|
||||
| 8f | `POST /api/v1/coupon/completed-today/reset` | 无 | 重置今日已完成(开发用)(无单独文档) |
|
||||
| 8g | `GET /api/v1/coupon/stats` | Bearer | 累计领券数(「我的」页战绩卡;按 user_id 聚合,**鉴权**)(无单独文档) |
|
||||
| 9 | `POST /api/v1/meituan/coupons` | 无 | [详情](./meituan-coupons.md) |
|
||||
| 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan-feed.md) |
|
||||
| 11 | `POST /api/v1/meituan/referral-link` | 无 | [详情](./meituan-referral-link.md) |
|
||||
| 11a | `POST /api/v1/meituan/top-sales` | 无 | [详情](./meituan-top-sales.md)(销量榜:离线库 `meituan_coupon` 按销量降序 + 跨源去重,不实时打美团) |
|
||||
| 1 | `GET /health` | 无 | [详情](./other/health.md) |
|
||||
| 2 | `POST /api/v1/auth/jverify-login` | 无 | [详情](./auth/auth-jverify-login.md) |
|
||||
| 3 | `POST /api/v1/auth/sms/send` | 无 | [详情](./auth/auth-sms-send.md) |
|
||||
| 4 | `POST /api/v1/auth/sms/login` | 无 | [详情](./auth/auth-sms-login.md) |
|
||||
| 5 | `POST /api/v1/auth/refresh` | 无 | [详情](./auth/auth-refresh.md) |
|
||||
| 6 | `GET /api/v1/auth/me` | Bearer | [详情](./auth/auth-me.md) |
|
||||
| 7 | `POST /api/v1/auth/logout` | Bearer | [详情](./auth/auth-logout.md) |
|
||||
| 8 | `POST /api/v1/coupon/step` | 无 | [详情](./coupon/coupon-step.md)(透传 pricebot + best-effort 写 `coupon_*` 三表) |
|
||||
| 8a | `POST /api/v1/coupon/prompt/shown` | 无 | [详情](./coupon/coupon-prompt.md)(引导窗弹出即上报) |
|
||||
| 8b | `POST /api/v1/coupon/prompt/dismiss` | 无 | [详情](./coupon/coupon-prompt.md)(用户拒绝/关闭引导窗) |
|
||||
| 8c | `GET /api/v1/coupon/prompt/should-show` | 无 | [详情](./coupon/coupon-prompt.md)(切到外卖 App 时是否还应弹引导窗) |
|
||||
| 8d | `POST /api/v1/coupon/prompt/reset` | 无 | [详情](./coupon/coupon-prompt.md)(重置今日引导窗 engagement,开发测频控用) |
|
||||
| 8e | `GET /api/v1/coupon/completed-today` | 无 | [详情](./coupon/coupon-completed-today.md)(这台设备今天是否已跑完整轮领券) |
|
||||
| 8f | `POST /api/v1/coupon/completed-today/reset` | 无 | [详情](./coupon/coupon-completed-today.md)(重置今日已完成,开发用) |
|
||||
| 8g | `GET /api/v1/coupon/stats` | Bearer | [详情](./coupon/coupon-stats.md)(累计领券数,「我的」页战绩卡) |
|
||||
| 8h | `POST /api/v1/coupon/session` | 无 | [详情](./coupon/coupon/coupon-session.md)(领券流水上报,admin 看板数据源) |
|
||||
| 9 | `POST /api/v1/meituan/coupons` | 无 | [详情](./meituan/meituan-coupons.md) |
|
||||
| 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan/meituan-feed.md) |
|
||||
| 11 | `POST /api/v1/meituan/referral-link` | 无 | [详情](./meituan/meituan-referral-link.md) |
|
||||
| 11a | `POST /api/v1/meituan/top-sales` | 无 | [详情](./meituan/meituan-top-sales.md)(销量榜:离线库 `meituan_coupon` 按销量降序 + 跨源去重,不实时打美团) |
|
||||
| **比价透传**(前缀 `/api/v1`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend;下按 Phase 流程列,均不鉴权) |||
|
||||
| 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./compare-intent-recognize.md)(Phase 1 意图识别,单次,多数源) |
|
||||
| 12a | `POST /api/v1/intent/precoupon/step` | 无 | Phase 0 意图识别前先用券,仅美团源(透传,无单独文档) |
|
||||
| 12b | `POST /api/v1/intent/step` | 无 | Phase 1 多帧意图识别,仅淘宝源,循环到 done(透传,无单独文档) |
|
||||
| 13 | `POST /api/v1/price/step` | 无 | [详情](./compare-price-step.md)(Phase 2 步进) |
|
||||
| 13a | `POST /api/v1/trace/finalize` | 无 | 比价 trace 收尾上云,终止/未识别拿 trace_url(透传,无单独文档) |
|
||||
| 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./intent/compare-intent-recognize.md)(Phase 1 意图识别,单次,多数源) |
|
||||
| 12a | `POST /api/v1/intent/precoupon/step` | 无 | [详情](./intent/intent-step.md)(Phase 0 意图识别前先用券,仅美团源) |
|
||||
| 12b | `POST /api/v1/intent/step` | 无 | [详情](./intent/intent-step.md)(Phase 1 多帧意图识别,仅淘宝源,循环到 done) |
|
||||
| 13 | `POST /api/v1/price/step` | 无 | [详情](./intent/compare-price-step.md)(Phase 2 步进) |
|
||||
| 13a | `POST /api/v1/trace/finalize` | 无 | [详情](./other/trace-finalize.md)(比价 trace 收尾上云,终止/未识别拿 trace_url) |
|
||||
| **比价记录**(前缀 `/api/v1/compare`;按用户落库,**鉴权**,区别于上面不鉴权的透传) |||
|
||||
| 12a | `POST /api/v1/compare/record` | Bearer | [详情](./compare-record-report.md) |
|
||||
| 12b | `GET /api/v1/compare/records` | Bearer | [详情](./compare-records.md) |
|
||||
| 12c | `GET /api/v1/compare/records/{id}` | Bearer | [详情](./compare-record-detail.md) |
|
||||
| 12e | `GET /api/v1/compare/stats` | Bearer | [详情](./compare-stats.md)(「我的」页省钱战绩卡:完成比价数 + 累计发现可省) |
|
||||
| 12a | `POST /api/v1/compare/record` | Bearer | [详情](./compare/compare-record-report.md) |
|
||||
| 12b | `GET /api/v1/compare/records` | Bearer | [详情](./compare/compare-records.md) |
|
||||
| 12c | `GET /api/v1/compare/records/{id}` | Bearer | [详情](./compare/compare-record-detail.md) |
|
||||
| 12e | `GET /api/v1/compare/stats` | Bearer | [详情](./compare/compare-stats.md)(「我的」页省钱战绩卡:完成比价数 + 累计发现可省) |
|
||||
| **比价战绩里程碑**(前缀 `/api/v1/compare`;福利页「记录比价战绩」,按成功比价数解锁逐档发金币) |||
|
||||
| 12d | `GET /api/v1/compare/milestones` | Bearer | [详情](./compare-milestones.md) |
|
||||
| 12e | `POST /api/v1/compare/milestones/{milestone}/claim` | Bearer | [详情](./compare-milestone-claim.md) |
|
||||
| 12d | `GET /api/v1/compare/milestones` | Bearer | [详情](./compare/compare-milestones.md) |
|
||||
| 12e | `POST /api/v1/compare/milestones/{milestone}/claim` | Bearer | [详情](./compare/compare-milestone-claim.md) |
|
||||
| **设备 / 无障碍存活监控**(前缀 `/api/v1/device`;心跳超时检出 + 掉线召回,#65) |||
|
||||
| D1 | `POST /api/v1/device/register` | Bearer | [详情](./device-liveness.md)(注册设备/更新极光 push token) |
|
||||
| D2 | `POST /api/v1/device/heartbeat` | Bearer | [详情](./device-liveness.md)(无障碍服务存活心跳,心跳也能自注册) |
|
||||
| D3 | `GET /api/v1/device/liveness` | Bearer | [详情](./device-liveness.md)(进 App 查本机是否被判掉线过) |
|
||||
| D4 | `POST /api/v1/device/liveness/ack` | Bearer | [详情](./device-liveness.md)(确认已弹引导,清掉线告警) |
|
||||
| D1 | `POST /api/v1/device/register` | Bearer | [详情](./device/device-liveness.md)(注册设备/更新极光 push token) |
|
||||
| D2 | `POST /api/v1/device/heartbeat` | Bearer | [详情](./device/device-liveness.md)(无障碍服务存活心跳,心跳也能自注册) |
|
||||
| D3 | `GET /api/v1/device/liveness` | Bearer | [详情](./device/device-liveness.md)(进 App 查本机是否被判掉线过) |
|
||||
| D4 | `POST /api/v1/device/liveness/ack` | Bearer | [详情](./device/device-liveness.md)(确认已弹引导,清掉线告警) |
|
||||
| **上报更低价**(前缀 `/api/v1/report`;众包纠偏,人工审核发奖) |||
|
||||
| R1 | `POST /api/v1/report` | Bearer | 提交上报(multipart:`comparison_record_id`/`reported_platform_id`/`reported_price`(元) + 1~4 张截图;原最低价反查 `comparison_record.best_*` 校验须更低)(无单独文档) |
|
||||
| R2 | `GET /api/v1/report/records` | Bearer | 上报记录列表(`?status=` pending/approved/rejected 可选筛选)(无单独文档) |
|
||||
| R1 | `POST /api/v1/report` | Bearer | [详情](./other/report-submit.md)(提交上报更低价,multipart:比价记录ID+平台+价格+截图1-4张) |
|
||||
| R2 | `GET /api/v1/report/records` | Bearer | [详情](./other/report-records.md)(上报记录列表,?status=pending/approved/rejected 可选筛选) |
|
||||
| **好友邀请**(前缀 `/api/v1/invite`;注册即生效,双方各发 1 万金币) |||
|
||||
| I1 | `GET /api/v1/invite/me` | Bearer | 我的邀请码 + 分享链接 + 已邀人数/已得金币(无单独文档) |
|
||||
| I2 | `GET /api/v1/invite/invitees` | Bearer | 我邀请的人列表(`limit`/`offset` 分页)(无单独文档) |
|
||||
| I3 | `POST /api/v1/invite/landing-track` | 无 | 落地页 `dl.html` 访问上报指纹(剪贴板归因兜底;浏览器无 token)(无单独文档) |
|
||||
| I4 | `POST /api/v1/invite/bind` | Bearer | 绑定邀请人;支持 clipboard/manual 邀请码 + fingerprint 指纹反查三种归因(无单独文档) |
|
||||
| I1 | `GET /api/v1/invite/me` | Bearer | [详情](./invite/invite-me.md)(我的邀请码+分享链接+已邀人数/已得金币) |
|
||||
| I2 | `GET /api/v1/invite/invitees` | Bearer | [详情](./invite/invite-invitees.md)(我邀请的人列表,limit/offset 分页) |
|
||||
| I3 | `POST /api/v1/invite/landing-track` | 无 | [详情](./invite/invite-bind.md)(落地页 dl.html 访问上报指纹,剪贴板归因兜底;浏览器无 token) |
|
||||
| I4 | `POST /api/v1/invite/bind` | Bearer | [详情](./invite/invite-bind.md)(绑定邀请人;支持 clipboard/manual 邀请码+fingerprint 指纹反查三种归因) |
|
||||
| **钱包 / 我的资产**(前缀 `/api/v1/wallet`) |||
|
||||
| 14 | `GET /api/v1/wallet/account` | Bearer | [详情](./wallet-account.md) |
|
||||
| 15 | `GET /api/v1/wallet/coin-transactions` | Bearer | [详情](./wallet-coin-transactions.md) |
|
||||
| 16 | `GET /api/v1/wallet/cash-transactions` | Bearer | [详情](./wallet-cash-transactions.md) |
|
||||
| 17 | `GET /api/v1/wallet/exchange-info` | 无 | [详情](./wallet-exchange-info.md) |
|
||||
| 18 | `POST /api/v1/wallet/exchange` | Bearer | [详情](./wallet-exchange.md) |
|
||||
| 19 | `POST /api/v1/wallet/bind-wechat` | Bearer | [详情](./wallet-bind-wechat.md) |
|
||||
| 20 | `POST /api/v1/wallet/unbind-wechat` | Bearer | [详情](./wallet-unbind-wechat.md) |
|
||||
| 21 | `GET /api/v1/wallet/withdraw-info` | Bearer | [详情](./wallet-withdraw-info.md) |
|
||||
| 22 | `POST /api/v1/wallet/withdraw` | Bearer | [详情](./wallet-withdraw.md) |
|
||||
| 23 | `GET /api/v1/wallet/withdraw/status` | Bearer | [详情](./wallet-withdraw-status.md) |
|
||||
| 24 | `GET /api/v1/wallet/withdraw-orders` | Bearer | [详情](./wallet-withdraw-orders.md) |
|
||||
| 14 | `GET /api/v1/wallet/account` | Bearer | [详情](./wallet/wallet-account.md) |
|
||||
| 15 | `GET /api/v1/wallet/coin-transactions` | Bearer | [详情](./wallet/wallet-coin-transactions.md) |
|
||||
| 16 | `GET /api/v1/wallet/cash-transactions` | Bearer | [详情](./wallet/wallet-cash-transactions.md) |
|
||||
| 17 | `GET /api/v1/wallet/exchange-info` | 无 | [详情](./wallet/wallet-exchange-info.md) |
|
||||
| 18 | `POST /api/v1/wallet/exchange` | Bearer | [详情](./wallet/wallet-exchange.md) |
|
||||
| 19 | `POST /api/v1/wallet/bind-wechat` | Bearer | [详情](./wallet/wallet-bind-wechat.md) |
|
||||
| 20 | `POST /api/v1/wallet/unbind-wechat` | Bearer | [详情](./wallet/wallet-unbind-wechat.md) |
|
||||
| 21 | `GET /api/v1/wallet/withdraw-info` | Bearer | [详情](./wallet/wallet-withdraw-info.md) |
|
||||
| 22 | `POST /api/v1/wallet/withdraw` | Bearer | [详情](./wallet/wallet-withdraw.md) |
|
||||
| 23 | `GET /api/v1/wallet/withdraw/status` | Bearer | [详情](./wallet/wallet-withdraw-status.md) |
|
||||
| 24 | `GET /api/v1/wallet/withdraw-orders` | Bearer | [详情](./wallet/wallet-withdraw-orders.md) |
|
||||
| 24a | `POST /api/v1/wallet/transfer-auth` | Bearer | [详情](./wallet/wallet-transfer-auth.md)(开启免确认到账,申请授权,返回拉起微信授权页的 package) |
|
||||
| 24b | `GET /api/v1/wallet/transfer-auth/status` | Bearer | [详情](./wallet/wallet-transfer-auth.md)(查免确认授权状态,从微信授权页返回后轮询) |
|
||||
| 24c | `POST /api/v1/wallet/transfer-auth/close` | Bearer | [详情](./wallet/wallet-transfer-auth.md)(关闭免确认到账,解除授权) |
|
||||
| **签到**(前缀 `/api/v1/signin`) |||
|
||||
| 25 | `GET /api/v1/signin/status` | Bearer | [详情](./signin-status.md) |
|
||||
| 26 | `POST /api/v1/signin` | Bearer | [详情](./signin-do.md) |
|
||||
| 26a | `POST /api/v1/signin/boost` | Bearer | [详情](./signin-boost.md) |
|
||||
| 25 | `GET /api/v1/signin/status` | Bearer | [详情](./signin/signin-status.md) |
|
||||
| 26 | `POST /api/v1/signin` | Bearer | [详情](./signin/signin-do.md) |
|
||||
| 26a | `POST /api/v1/signin/boost` | Bearer | [详情](./signin/signin-boost.md) |
|
||||
| **任务**(前缀 `/api/v1/tasks`) |||
|
||||
| 27 | `GET /api/v1/tasks` | Bearer | [详情](./tasks-list.md) |
|
||||
| 28 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks-claim.md) |
|
||||
| 27 | `GET /api/v1/tasks` | Bearer | [详情](./tasks/tasks-list.md) |
|
||||
| 28 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks/tasks-claim.md) |
|
||||
| **省钱**(前缀 `/api/v1/savings`) |||
|
||||
| 29 | `GET /api/v1/savings/summary` | Bearer | [详情](./savings-summary.md) |
|
||||
| 30 | `GET /api/v1/savings/battle` | Bearer | [详情](./savings-battle.md) |
|
||||
| 31 | `GET /api/v1/savings/records` | Bearer | [详情](./savings-records.md) |
|
||||
| 29 | `GET /api/v1/savings/summary` | Bearer | [详情](./savings/savings-summary.md) |
|
||||
| 30 | `GET /api/v1/savings/battle` | Bearer | [详情](./savings/savings-battle.md) |
|
||||
| 31 | `GET /api/v1/savings/records` | Bearer | [详情](./savings/savings-records.md) |
|
||||
| **看广告发奖**(前缀 `/api/v1/ad`) |||
|
||||
| 32 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad-pangle-callback.md) |
|
||||
| 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad-reward-status.md) |
|
||||
| 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) |
|
||||
| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad-ecpm-report.md) |
|
||||
| 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad-feed-reward.md) |
|
||||
| 35b | `POST /api/v1/ad/reward-noshow` | Bearer | [详情](./ad-reward-noshow.md)(激励视频提前关闭/未发奖留痕,只记原因不发币) |
|
||||
| 35c | `GET /api/v1/ad/feed-reward/units` | Bearer | 信息流广告今日已发份数/上限(配合 `feed-reward` 看进度)(无单独文档) |
|
||||
| 32 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad/ad-pangle-callback.md) |
|
||||
| 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad/ad-reward-status.md) |
|
||||
| 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad/ad-test-grant.md) |
|
||||
| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad/ad-ecpm-report.md) |
|
||||
| 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad/ad-feed-reward.md) |
|
||||
| 35b | `POST /api/v1/ad/reward-noshow` | Bearer | [详情](./ad/ad-reward-noshow.md)(激励视频提前关闭/未发奖留痕,只记原因不发币) |
|
||||
| 35c | `GET /api/v1/ad/feed-reward/units` | Bearer | [详情](./ad/ad-feed-reward.md)(信息流广告今日已发份数/上限,配合 feed-reward 看进度) |
|
||||
| 35d | `POST /api/v1/ad/watch-report` | Bearer | [详情](./ad/ad-watch-report.md)(上报激励视频观看时长,旧客户端兼容) |
|
||||
| **用户资料**(前缀 `/api/v1/user`) |||
|
||||
| 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) |
|
||||
| 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user-avatar.md) |
|
||||
| 36a | `POST /api/v1/user/onboarding/complete` | Bearer | 标记新手引导完成(按 账号+device_id 幂等,跨卸载重装持久)(无单独文档) |
|
||||
| 36b | `GET /api/v1/user/onboarding/status` | Bearer | 查该 (账号,设备) 是否走过引导(运营在 admin 删记录即触发重走)(无单独文档) |
|
||||
| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user-delete.md) |
|
||||
| 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user/user-profile.md) |
|
||||
| 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user/user-avatar.md) |
|
||||
| 36a | `POST /api/v1/user/onboarding/complete` | Bearer | [详情](./user/user-onboarding.md)(标记新手引导完成,按 账号+device_id 幂等,跨卸载重装持久) |
|
||||
| 36b | `GET /api/v1/user/onboarding/status` | Bearer | [详情](./user/user-onboarding.md)(查该 账号+设备 是否走过引导,运营在 admin 删记录即触发重走) |
|
||||
| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user/user-delete.md) |
|
||||
| **帮助与反馈**(前缀 `/api/v1/feedback`) |||
|
||||
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./feedback.md) |
|
||||
| 38a | `GET /api/v1/feedback/config` | Bearer | 反馈页「加群二维码」卡配置(开关 + 二维码图 + 三行文案)(无单独文档) |
|
||||
| 38b | `GET /api/v1/feedback/records` | Bearer | 我的反馈历史(pending/adopted/rejected)(无单独文档) |
|
||||
| **消息通知中心**(前缀 `/api/v1/notifications`;⚠️ 本族对外 **camelCase**;虚拟数据阶段:内存 mock,重启复位) |||
|
||||
| M1 | `GET /api/v1/notifications` | Bearer | [详情](./notifications.md)(消息列表,分页;13 类型卡片字段 + sentAt/isRead;服务端已按时间倒序排好,不分组) |
|
||||
| M2 | `GET /api/v1/notifications/unread-count` | Bearer | [详情](./notifications.md)(未读总数,首页铃铛角标;>99 → "99+",0 → null 隐藏) |
|
||||
| M3 | `POST /api/v1/notifications/read` | Bearer | [详情](./notifications.md)(标记已读:`{ids:[...]}` 单条/多条 或 `{all:true}` 进通知中心全量清零;幂等) |
|
||||
| **厂商推送测试**(前缀 `/api/v1/push`;荣耀/华为/小米/OPPO/vivo 五通道联调三件套,同为 camelCase) |||
|
||||
| P1 | `GET /api/v1/push/vendors` | Bearer | [详情](./push-vendor-test.md)(5 厂商服务端凭据配置状态,缺哪些 .env 键一目了然) |
|
||||
| P2 | `GET /api/v1/push/templates` | Bearer | [详情](./push-vendor-test.md)(13 类通知的 push 标题/正文模板 + PRD 示例渲染效果) |
|
||||
| P3 | `POST /api/v1/push/test` | Bearer | [详情](./push-vendor-test.md)(测试发送:默认 mock 不真发;mock=false 真发;可联动插一条站内 mock 通知闭环验证已读) |
|
||||
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./other/feedback.md) |
|
||||
| 38a | `GET /api/v1/feedback/config` | Bearer | [详情](./other/feedback-config.md)(反馈页「加群二维码」卡配置:开关+二维码图+三行文案) |
|
||||
| 38b | `GET /api/v1/feedback/records` | Bearer | [详情](./other/feedback-records.md)(我的反馈历史,pending/adopted/rejected) |
|
||||
| **埋点 & 订单上报**(前缀分散;全部 Bearer 除 analytics/events 不强制登录) |||
|
||||
| E1 | `POST /api/v1/analytics/events` | 无 | [详情](./other/analytics-events.md)(批量上报埋点事件,不强制登录,每批最多200条) |
|
||||
| E2 | `POST /api/v1/order/report` | Bearer | [详情](./other/order-report.md)(上报归因订单,比价后5分钟内点链接+支付金额与比价价相差≤1元) |
|
||||
| **首页门面数据 / 客户端配置**(前缀 `/api/v1/platform`;全平台展示数字 + 运营开关,**全部不鉴权**,登录前可读) |||
|
||||
| 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform-stats.md) |
|
||||
| 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./platform-savings-feed.md) |
|
||||
| 40a | `GET /api/v1/platform/flags` | 无 | 客户端运营 feature flag(比价/领券期广告开关等),拉取后缓存(无单独文档) |
|
||||
| 40b | `GET /api/v1/platform/ad-config` | 无 | 客户端拉广告配置(穿山甲 app_id + 各位 ID + 各场景开关;不含验签密钥)(无单独文档) |
|
||||
| 40c | `GET /api/v1/platform/app-version` | 无 | 最新 App 版本(OTA 检查更新;与本机 versionCode 比)(无单独文档) |
|
||||
| 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform/platform-stats.md) |
|
||||
| 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./savings/platform-savings-feed.md) |
|
||||
| 40a | `GET /api/v1/platform/flags` | 无 | [详情](./platform/platform-flags.md)(客户端运营 feature flag,比价/领券期广告开关等,拉取后缓存) |
|
||||
| 40b | `GET /api/v1/platform/ad-config` | 无 | [详情](./platform/platform-ad-config.md)(客户端拉广告配置:穿山甲 app_id+各位ID+各场景开关;不含验签密钥) |
|
||||
| 40c | `GET /api/v1/platform/app-version` | 无 | [详情](./platform/platform-app-version.md)(最新 App 版本,OTA 检查更新;与本机 versionCode 比) |
|
||||
| **微信支付回调**(前缀 `/api/v1/wxpay`) |||
|
||||
| W1 | `POST /api/v1/wxpay/transfer-auth-notify` | 无 | 免确认收款授权结果通知(一期 stub:仅应答 200 不验签不改账,授权状态靠主动查询兜底)(无单独文档) |
|
||||
| **CPS 群发短链落地**(**无前缀**,挂域名根;公网不鉴权) |||
|
||||
| C1 | `GET /c/{code}` | 无 | [详情](./cps-redirect.md)(短链落地:微信授权拿 openid + 记点击 + 302 跳/淘宝 H5 落地页) |
|
||||
| C2 | `POST /c/{code}/copy` | 无 | [详情](./cps-redirect.md)(淘宝落地页点「复制口令」记 `copy`) |
|
||||
| C3 | `GET /wx/oauth/cb` | 无 | [详情](./cps-redirect.md)(微信网页授权回调;upsert `cps_wx_user` + 种 cookie,`include_in_schema=False`) |
|
||||
| C4 | `GET /MP_verify_*.txt` | 无 | [详情](./cps-redirect.md)(微信「网页授权域名」归属校验文件,`include_in_schema=False`) |
|
||||
| C1 | `GET /c/{code}` | 无 | [详情](./other/cps-redirect.md)(短链落地:微信授权拿 openid + 记点击 + 302 跳/淘宝 H5 落地页) |
|
||||
| C2 | `POST /c/{code}/copy` | 无 | [详情](./other/cps-redirect.md)(淘宝落地页点「复制口令」记 `copy`) |
|
||||
| C3 | `GET /wx/oauth/cb` | 无 | [详情](./other/cps-redirect.md)(微信网页授权回调;upsert `cps_wx_user` + 种 cookie,`include_in_schema=False`) |
|
||||
| C4 | `GET /MP_verify_*.txt` | 无 | [详情](./other/cps-redirect.md)(微信「网页授权域名」归属校验文件,`include_in_schema=False`) |
|
||||
| **内部回写端点**(前缀 `/internal`;pricebot/发布流程→app-server,**`X-Internal-Secret` 头**,非客户端接口) |||
|
||||
| N1 | `POST /internal/price-observation` | 内部密钥 | [详情](./internal.md)(比价价格事实批量落 `price_observation`) |
|
||||
| N2 | `GET /internal/store-mapping/lookup` | 内部密钥 | [详情](./internal.md)(按源平台店名反查目标平台已沉淀店铺 id/deeplink) |
|
||||
| N3 | `POST /internal/store-mapping` | 内部密钥 | [详情](./internal.md)(跨平台店铺身份映射落 `store_mapping`) |
|
||||
| N4 | `POST /internal/store-mapping/invalidate` | 内部密钥 | [详情](./internal.md)(标记某平台 shopId 缓存 deeplink 失效) |
|
||||
| N5 | `POST /internal/launch-confirm-sample` | 内部密钥 | [详情](./internal.md)(启动确认窗兜底样本落 `launch_confirm_sample`) |
|
||||
| N6 | `POST /internal/app-version` | 内部密钥 | [详情](./internal.md)(发布流程写最新 App 版本,落 `app_config`) |
|
||||
| N1 | `POST /internal/price-observation` | 内部密钥 | [详情](./internal/internal.md)(比价价格事实批量落 `price_observation`) |
|
||||
| N2 | `GET /internal/store-mapping/lookup` | 内部密钥 | [详情](./internal/internal.md)(按源平台店名反查目标平台已沉淀店铺 id/deeplink) |
|
||||
| N3 | `POST /internal/store-mapping` | 内部密钥 | [详情](./internal/internal.md)(跨平台店铺身份映射落 `store_mapping`) |
|
||||
| N4 | `POST /internal/store-mapping/invalidate` | 内部密钥 | [详情](./internal/internal.md)(标记某平台 shopId 缓存 deeplink 失效) |
|
||||
| N5 | `POST /internal/launch-confirm-sample` | 内部密钥 | [详情](./internal/internal.md)(启动确认窗兜底样本落 `launch_confirm_sample`) |
|
||||
| N6 | `POST /internal/app-version` | 内部密钥 | [详情](./internal/internal.md)(发布流程写最新 App 版本,落 `app_config`) |
|
||||
| **静态资源**(StaticFiles 挂载,见下方 `/media` 静态服务) |||
|
||||
| - | `GET /media/avatars/<file>` | 无 | 用户头像;返回二进制图片 |
|
||||
| - | `GET /media/feedback/<file>` | 无 | 反馈截图;返回二进制图片 |
|
||||
| **运营后台 Admin**(独立子应用 `app/admin/`,前缀 `/admin/api`,独立进程 + 独立 admin JWT。鉴权列:`admin`=任意已登录管理员,`operator`/`finance`/`super_admin`=需对应角色(`super_admin` 恒通过)) |||
|
||||
| A1 | `POST /admin/api/auth/login` | 无 | [详情](./admin-auth-login.md) |
|
||||
| A2 | `GET /admin/api/auth/me` | admin | [详情](./admin-auth-me.md) |
|
||||
| A3 | `GET /admin/api/stats/overview` | admin | [详情](./admin-stats-overview.md) |
|
||||
| A4 | `GET /admin/api/users` | admin | [详情](./admin-users-list.md) |
|
||||
| A5 | `GET /admin/api/users/{user_id}` | admin | [详情](./admin-user-detail.md) |
|
||||
| A6 | `POST /admin/api/users/{user_id}/status` | operator | [详情](./admin-user-status.md) |
|
||||
| A7 | `POST /admin/api/users/{user_id}/coins` | finance | [详情](./admin-user-coins.md) |
|
||||
| A8 | `POST /admin/api/users/{user_id}/cash` | finance | [详情](./admin-user-cash.md) |
|
||||
| A9 | `GET /admin/api/wallet/coin-transactions` | admin | [详情](./admin-wallet-coin-transactions.md) |
|
||||
| A10 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin-wallet-cash-transactions.md) |
|
||||
| A11 | `GET /admin/api/withdraws` | admin | [详情](./admin-withdraws-list.md) |
|
||||
| A12 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin-withdraw-reconcile.md) |
|
||||
| A13 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin-withdraw-refresh.md) |
|
||||
| A14 | `GET /admin/api/feedbacks` | admin | [详情](./admin-feedbacks-list.md) |
|
||||
| A15 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin-feedback-handle.md) |
|
||||
| A16 | `GET /admin/api/admins` | super_admin | [详情](./admin-admins-list.md) |
|
||||
| A17 | `POST /admin/api/admins` | super_admin | [详情](./admin-admin-create.md) |
|
||||
| A18 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin-admin-update.md) |
|
||||
| A19 | `GET /admin/api/audit-logs` | admin | [详情](./admin-audit-logs.md) |
|
||||
| A20 | `GET /admin/api/dashboard-display` | admin | [详情](./admin-dashboard-display.md) |
|
||||
| A21 | `PATCH /admin/api/dashboard-display/{metric}` | operator | [详情](./admin-dashboard-display.md) |
|
||||
| A22 | `GET /admin/api/marquee-seeds` | admin | [详情](./admin-marquee-seeds.md) |
|
||||
| A23 | `POST /admin/api/marquee-seeds` | operator | [详情](./admin-marquee-seeds.md) |
|
||||
| A24 | `PATCH /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) |
|
||||
| A25 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) |
|
||||
| A26 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) |
|
||||
| A27 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) |
|
||||
| A28 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) |
|
||||
| A29 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin-ad-revenue-report.md)(广告收益报表:按用户/日期/类型/应用/代码位 聚合 条数/收益/金币,只读) |
|
||||
| A1 | `POST /admin/api/auth/login` | 无 | [详情](./admin/auth/admin-auth-login.md) |
|
||||
| A2 | `GET /admin/api/auth/me` | admin | [详情](./admin/auth/admin-auth-me.md) |
|
||||
| A3 | `GET /admin/api/stats/overview` | admin | [详情](./admin/admin-stats-overview.md) |
|
||||
| A4 | `GET /admin/api/users` | admin | [详情](./admin/users/admin-users-list.md) |
|
||||
| A5 | `GET /admin/api/users/{user_id}` | admin | [详情](./admin/users/admin-user-detail.md) |
|
||||
| A6 | `POST /admin/api/users/{user_id}/status` | operator | [详情](./admin/users/admin-user-status.md) |
|
||||
| A7 | `POST /admin/api/users/{user_id}/coins` | finance | [详情](./admin/users/admin-user-coins.md) |
|
||||
| A8 | `POST /admin/api/users/{user_id}/cash` | finance | [详情](./admin/users/admin-user-cash.md) |
|
||||
| A9 | `GET /admin/api/wallet/coin-transactions` | admin | [详情](./admin/wallet/admin-wallet-coin-transactions.md) |
|
||||
| A10 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin/wallet/admin-wallet-cash-transactions.md) |
|
||||
| A11 | `GET /admin/api/withdraws` | admin | [详情](./admin/withdraws/admin-withdraws-list.md) |
|
||||
| A12 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin/withdraws/admin-withdraw-reconcile.md) |
|
||||
| A13 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin/withdraws/admin-withdraw-refresh.md) |
|
||||
| A14 | `GET /admin/api/feedbacks` | admin | [详情](./admin/feedbacks/admin-feedbacks-list.md) |
|
||||
| A15 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin/feedbacks/admin-feedback-handle.md) |
|
||||
| A16 | `GET /admin/api/admins` | super_admin | [详情](./admin/admins/admin-admins-list.md) |
|
||||
| A17 | `POST /admin/api/admins` | super_admin | [详情](./admin/admins/admin-admin-create.md) |
|
||||
| A18 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin/admins/admin-admin-update.md) |
|
||||
| A19 | `GET /admin/api/audit-logs` | admin | [详情](./admin/admin-audit-logs.md) |
|
||||
| A20 | `GET /admin/api/dashboard-display` | admin | [详情](./admin/admin-dashboard-display.md) |
|
||||
| A21 | `PATCH /admin/api/dashboard-display/{metric}` | operator | [详情](./admin/admin-dashboard-display.md) |
|
||||
| A22 | `GET /admin/api/marquee-seeds` | admin | [详情](./admin/admin-marquee-seeds.md) |
|
||||
| A23 | `POST /admin/api/marquee-seeds` | operator | [详情](./admin/admin-marquee-seeds.md) |
|
||||
| A24 | `PATCH /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin/admin-marquee-seeds.md) |
|
||||
| A25 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin/admin-marquee-seeds.md) |
|
||||
| A26 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin/admin-marquee-seeds.md) |
|
||||
| A27 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin/admin-marquee-seeds.md) |
|
||||
| A28 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin/ad/admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) |
|
||||
| A29 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin/ad/admin-ad-revenue-report.md)(广告收益报表:按用户/日期/类型/应用/代码位 聚合 条数/收益/金币,只读) |
|
||||
| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
|
||||
|
||||
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
|
||||
@@ -209,8 +209,8 @@
|
||||
|---|---|---|
|
||||
| `id` | int | 用户主键 |
|
||||
| `phone` | string | 手机号(注销账号后变 `deleted_<id>` 占位释放唯一约束) |
|
||||
| `nickname` | string \| null | 昵称,经 [`PATCH /api/v1/user/profile`](./user-profile.md) 修改 |
|
||||
| `avatar_url` | string \| null | 头像相对 URL(`/media/avatars/...`),经 [`POST /api/v1/user/avatar`](./user-avatar.md) 上传 |
|
||||
| `nickname` | string \| null | 昵称,经 [`PATCH /api/v1/user/profile`](./user/user-profile.md) 修改 |
|
||||
| `avatar_url` | string \| null | 头像相对 URL(`/media/avatars/...`),经 [`POST /api/v1/user/avatar`](./user/user-avatar.md) 上传 |
|
||||
| `register_channel` | string | 注册渠道:`jverify` / `sms` |
|
||||
| `status` | string | `active` / `disabled` / `deleted` |
|
||||
| `created_at` | datetime | 注册时间 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# POST /api/v1/ad/ecpm-report — 上报本次广告展示的 eCPM(内部收益统计)
|
||||
|
||||
> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | [← 返回 API 索引](../README.md)
|
||||
|
||||
## 入参
|
||||
请求体:`EcpmReportIn`
|
||||
@@ -1,6 +1,6 @@
|
||||
# GET /api/v1/ad/pangle-callback — 穿山甲 GroMore 激励视频发奖回调(S2S)
|
||||
|
||||
> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:**无 JWT,靠验签**(穿山甲 GroMore 服务器调用) | 限流:同 IP ≤300 次/分 | [← 返回 API 索引](./README.md)
|
||||
> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:**无 JWT,靠验签**(穿山甲 GroMore 服务器调用) | 限流:同 IP ≤300 次/分 | [← 返回 API 索引](../README.md)
|
||||
>
|
||||
> ⚠️ 我们客户端用 `useMediation(true)`(GroMore 融合),回调走 **GroMore 广告位层级**(规范见 supportcenter/26240),**不是**联盟代码位层级(5416)。两者密钥、响应格式都不同,别混。后台配置入口:**GroMore 聚合管理 → 搜广告位ID → 编辑 → 勾选「服务端激励回调」**(广告位层级配了就别再在代码位层级重复配,会冲突)。
|
||||
>
|
||||
@@ -1,6 +1,6 @@
|
||||
# GET /api/v1/ad/reward-status — 今日看广告发奖进度
|
||||
|
||||
> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | [← 返回 API 索引](../README.md)
|
||||
|
||||
## 入参
|
||||
无(用户由 token 确定)。
|
||||
@@ -1,6 +1,6 @@
|
||||
# POST /api/v1/ad/test-grant — [仅本地联调]模拟穿山甲回调发奖
|
||||
|
||||
> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | 限流:同 IP ≤60 次/分 | [← 返回 API 索引](./README.md)
|
||||
> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | 限流:同 IP ≤60 次/分 | [← 返回 API 索引](../README.md)
|
||||
>
|
||||
> ⚠️ **仅本地联调**,受 `AD_REWARD_TEST_GRANT_ENABLED` 开关控制,**生产必须关闭**(默认 False → 一律 404)。
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# POST /api/v1/ad/watch-report — 上报激励视频观看时长
|
||||
|
||||
> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | 限流:同 IP ≤120 次/分 | [← 返回 API 索引](../README.md)
|
||||
|
||||
客户端在激励视频关闭(onAdClose)后上报本次实际观看秒数,服务端累计到当日总时长。当前产品只保留每日 500 次上限,DAILY_AD_WATCH_SECONDS_LIMIT=0 表示时长闸不启用;该接口仍保留用于旧客户端兼容和排查观看时长。
|
||||
|
||||
## 入参
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `seconds` | int | ✅ (≥0) | 本次观看秒数,服务端夹 [0, MAX_SINGLE_WATCH_SECONDS] |
|
||||
|
||||
Mock 入参:
|
||||
```json
|
||||
{
|
||||
"seconds": 28
|
||||
}
|
||||
```
|
||||
|
||||
## 出参
|
||||
|
||||
响应 `200`:`WatchReportOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `watched_seconds_today` | int | 今日累计观看秒数 |
|
||||
| `watch_seconds_limit` | int | 每日上限(秒);0 表示当前未启用时长闸 |
|
||||
| `watch_seconds_remaining` | int | 今日剩余可观看秒数 |
|
||||
|
||||
Mock 出参:
|
||||
```json
|
||||
{
|
||||
"watched_seconds_today": 128,
|
||||
"watch_seconds_limit": 0,
|
||||
"watch_seconds_remaining": 0
|
||||
}
|
||||
```
|
||||
|
||||
## 错误码
|
||||
- `401` 未鉴权 / token 失效
|
||||
- `422` `seconds` 缺或为负
|
||||
|
||||
## 说明
|
||||
- 鉴权靠 Bearer,`user_id` 取自 JWT(不信 body)
|
||||
- 后端只做累计 + 上限裁切,不据此发奖(发奖靠 S2S 回调)
|
||||
- `watch_seconds_limit=0` 时客户端不据此拦截,按每日 500 次上限走
|
||||
@@ -1,6 +1,6 @@
|
||||
# Admin 看广告金币审计
|
||||
|
||||
> 所属:Admin 组(前缀 `/admin/api/ad-coin-audit`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](./README.md)
|
||||
> 所属:Admin 组(前缀 `/admin/api/ad-coin-audit`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](../../README.md)
|
||||
|
||||
把「看视频赚金币」(`ad_reward_record`)和「比价信息流广告」(`ad_feed_reward_record`)两类发奖记录,用与**正式发奖完全相同**的公式 [`app/core/rewards.py` `calculate_ad_reward_coin`](../../app/core/rewards.py) 复算一遍 `expected_coin`,与实际入账的 `actual_coin` 对比,核对金币公式是否生效。**纯只读对账**,不发币、不改任何数据。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Admin 广告收益报表
|
||||
|
||||
> 所属:Admin 组(前缀 `/admin/api/ad-revenue-report`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](./README.md)
|
||||
> 所属:Admin 组(前缀 `/admin/api/ad-revenue-report`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](../../README.md)
|
||||
|
||||
按 **用户 × 日期 × 广告类型 × 我们的应用 × 我们的代码位** 聚合,回答「每个用户某天、每类广告(激励视频 / 信息流 / 历史 Draw)分别**看了多少条**、**收益多少**、按现算法**发了多少金币**、广告来自**哪个应用的哪个代码位**」。**纯只读**,不发币、不改数据,也**不改发奖逻辑**。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# GET /admin/api/audit-logs — 审计日志(谁改了什么,游标分页)
|
||||
|
||||
> 所属:Admin·Audit 组(前缀 `/admin/api/audit-logs`) | 鉴权:Bearer admin_token(角色:任意已登录 admin) | [← 返回 API 索引](./README.md)
|
||||
> 所属:Admin·Audit 组(前缀 `/admin/api/audit-logs`) | 鉴权:Bearer admin_token(角色:任意已登录 admin) | [← 返回 API 索引](../README.md)
|
||||
|
||||
## 入参(query)
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
@@ -1,6 +1,6 @@
|
||||
# Admin 首页数据配置 — 三统计展示模式
|
||||
|
||||
> 所属:Admin 组(前缀 `/admin/api/dashboard-display`) | 鉴权:Admin Bearer(改需 operator/super) | [← 返回 API 索引](./README.md)
|
||||
> 所属:Admin 组(前缀 `/admin/api/dashboard-display`) | 鉴权:Admin Bearer(改需 operator/super) | [← 返回 API 索引](../README.md)
|
||||
|
||||
配置客户端首页三个门面数字(帮助用户 / 完成比价 / 累计节省)的展示模式。每个指标可独立选 real/manual/random。用户侧读取见 [platform-stats](./platform-stats.md);表见 [ops_stat_config](../database/ops_stat_config.md)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Admin 首页轮播种子管理
|
||||
|
||||
> 所属:Admin 组(前缀 `/admin/api/marquee-seeds`) | 鉴权:Admin Bearer(改需 operator/super) | [← 返回 API 索引](./README.md)
|
||||
> 所属:Admin 组(前缀 `/admin/api/marquee-seeds`) | 鉴权:Admin Bearer(改需 operator/super) | [← 返回 API 索引](../README.md)
|
||||
|
||||
管理首页轮播「真实+种子混播」的兜底种子。种子是「生成规则」:`masked_user` 可空(空→feed 随机合成名)、金额是 `[min_cents, max_cents]` 区间(feed 每次随机取值)。用户侧 feed 见 [platform-savings-feed](./platform-savings-feed.md);表见 [ops_marquee_seed](../database/ops_marquee_seed.md)。金额单位:分(前端 ÷100 显示元)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# GET /admin/api/stats/overview — 大盘核心指标
|
||||
|
||||
> 所属:Admin·数据大盘 组(前缀 `/admin/api/stats`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](./README.md)
|
||||
> 所属:Admin·数据大盘 组(前缀 `/admin/api/stats`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](../README.md)
|
||||
|
||||
## 入参
|
||||
无
|
||||
@@ -1,6 +1,6 @@
|
||||
# POST /admin/api/admins — 创建管理员
|
||||
|
||||
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins`) | 鉴权:Bearer admin_token(角色:super_admin) | [← 返回 API 索引](./README.md)
|
||||
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins`) | 鉴权:Bearer admin_token(角色:super_admin) | [← 返回 API 索引](../../README.md)
|
||||
|
||||
## 入参
|
||||
**application/json**:
|
||||
@@ -1,6 +1,6 @@
|
||||
# PATCH /admin/api/admins/{admin_id} — 改角色/启停/重置密码
|
||||
|
||||
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins`) | 鉴权:Bearer admin_token(角色:super_admin) | [← 返回 API 索引](./README.md)
|
||||
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins`) | 鉴权:Bearer admin_token(角色:super_admin) | [← 返回 API 索引](../../README.md)
|
||||
|
||||
## 入参
|
||||
**路径参数**:
|
||||
@@ -1,6 +1,6 @@
|
||||
# GET /admin/api/admins — 管理员列表
|
||||
|
||||
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins`) | 鉴权:Bearer admin_token(角色:super_admin) | [← 返回 API 索引](./README.md)
|
||||
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins`) | 鉴权:Bearer admin_token(角色:super_admin) | [← 返回 API 索引](../../README.md)
|
||||
|
||||
## 入参
|
||||
无(按 `id` 升序返回全部,无分页)
|
||||
@@ -1,6 +1,6 @@
|
||||
# POST /admin/api/auth/login — 管理员登录
|
||||
|
||||
> 所属:Admin·Auth 组(前缀 `/admin/api/auth`) | 鉴权:无 | [← 返回 API 索引](./README.md)
|
||||
> 所属:Admin·Auth 组(前缀 `/admin/api/auth`) | 鉴权:无 | [← 返回 API 索引](../../README.md)
|
||||
|
||||
## 入参
|
||||
**application/json**:
|
||||
@@ -1,6 +1,6 @@
|
||||
# GET /admin/api/auth/me — 当前管理员
|
||||
|
||||
> 所属:Admin·Auth 组(前缀 `/admin/api/auth`) | 鉴权:Bearer admin_token(角色:任意已登录 admin) | [← 返回 API 索引](./README.md)
|
||||
> 所属:Admin·Auth 组(前缀 `/admin/api/auth`) | 鉴权:Bearer admin_token(角色:任意已登录 admin) | [← 返回 API 索引](../../README.md)
|
||||
|
||||
## 入参
|
||||
无(身份取自 Header token)
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# POST /admin/api/feedbacks/{feedback_id}/handle — 标记反馈已处理
|
||||
|
||||
> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks`) | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过,`require_role("operator")`) | [← 返回 API 索引](./README.md)
|
||||
> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks`) | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过,`require_role("operator")`) | [← 返回 API 索引](../../README.md)
|
||||
|
||||
## 入参
|
||||
- 路径:`feedback_id`(int)
|
||||
@@ -1,6 +1,6 @@
|
||||
# GET /admin/api/feedbacks — 反馈工单列表(offset 分页 + 筛选/排序)
|
||||
|
||||
> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 `require_role`,仅 `get_current_admin`) | [← 返回 API 索引](./README.md)
|
||||
> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 `require_role`,仅 `get_current_admin`) | [← 返回 API 索引](../../README.md)
|
||||
|
||||
## 入参(query)
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
@@ -1,6 +1,6 @@
|
||||
# POST /admin/api/users/{user_id}/cash — 手动增减/设值现金(带审计)
|
||||
|
||||
> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md)
|
||||
> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](../../README.md)
|
||||
|
||||
主要用于给无现金用户直接发钱、好让其测试提现链路。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# POST /admin/api/users/{user_id}/coins — 手动增减/设值金币(带审计)
|
||||
|
||||
> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md)
|
||||
> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](../../README.md)
|
||||
|
||||
## 入参
|
||||
- 路径:`user_id`(int)
|
||||
@@ -1,6 +1,6 @@
|
||||
# GET /admin/api/users/{user_id} — 用户 360 详情
|
||||
|
||||
> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](./README.md)
|
||||
> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](../../README.md)
|
||||
|
||||
## 入参
|
||||
- 路径:`user_id`(int)
|
||||
@@ -1,6 +1,6 @@
|
||||
# POST /admin/api/users/{user_id}/status — 封禁/解封用户
|
||||
|
||||
> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md)
|
||||
> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过) | [← 返回 API 索引](../../README.md)
|
||||
|
||||
## 入参
|
||||
- 路径:`user_id`(int)
|
||||
@@ -1,6 +1,6 @@
|
||||
# GET /admin/api/users — 用户列表(筛选+排序+分页)
|
||||
|
||||
> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](./README.md)
|
||||
> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](../../README.md)
|
||||
|
||||
## 入参(query)
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# GET /admin/api/wallet/cash-transactions — 现金流水(游标分页)
|
||||
|
||||
> 所属:Admin·钱包 组(前缀 `/admin/api/wallet`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,仅需 `get_current_admin`,无 `require_role`) | [← 返回 API 索引](./README.md)
|
||||
> 所属:Admin·钱包 组(前缀 `/admin/api/wallet`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,仅需 `get_current_admin`,无 `require_role`) | [← 返回 API 索引](../../README.md)
|
||||
|
||||
跨用户查询全量现金流水,可按 `user_id` / `biz_type` 过滤。游标分页(`id` 倒序)。金额单位一律为**分**。
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# GET /admin/api/wallet/coin-transactions — 金币流水(游标分页)
|
||||
|
||||
> 所属:Admin·钱包 组(前缀 `/admin/api/wallet`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,仅需 `get_current_admin`,无 `require_role`) | [← 返回 API 索引](./README.md)
|
||||
> 所属:Admin·钱包 组(前缀 `/admin/api/wallet`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,仅需 `get_current_admin`,无 `require_role`) | [← 返回 API 索引](../../README.md)
|
||||
|
||||
跨用户查询全量金币流水,可按 `user_id` / `biz_type` 过滤。游标分页(`id` 倒序)。
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# POST /admin/api/withdraws/reconcile — 批量对账(扫超时 pending 单)
|
||||
|
||||
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md)
|
||||
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](../../README.md)
|
||||
|
||||
扫描创建时间超过 `older_than_minutes` 分钟、仍为 `pending` 的提现单,逐单调微信查单并归一化(成功落 `success`;失败/已撤销则退款落 `failed`;查到 `WAIT_USER_CONFIRM` 视为用户放弃,撤单+退款)。用于解开"扣了款但转账没发起/没确认"的孤儿单。单笔失败不影响其余(内部 rollback 后继续,下轮再试)。
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# POST /admin/api/withdraws/{out_bill_no}/refresh — 单笔提现重试查单
|
||||
|
||||
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md)
|
||||
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](../../README.md)
|
||||
|
||||
对单笔提现单调微信查单并归一化:`SUCCESS`→`success`;`FAIL`/`CANCELLED`/`CLOSED`→退款+`failed`;查到 `WAIT_USER_CONFIRM` 视为用户放弃(`cancel_if_unconfirmed=True`),撤单+退款;`ACCEPTED`/`PROCESSING` 等仍在途则保持 `pending`。已是终态的单直接返回、不再查。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# GET /admin/api/withdraws — 提现单列表(游标分页)
|
||||
|
||||
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,列表为只读,仅需 `get_current_admin`,无 `require_role`) | [← 返回 API 索引](./README.md)
|
||||
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,列表为只读,仅需 `get_current_admin`,无 `require_role`) | [← 返回 API 索引](../../README.md)
|
||||
|
||||
跨用户查询全量提现单,可按 `user_id` / `status` 过滤。游标分页(`id` 倒序)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# POST /api/v1/auth/jverify-login — 极光一键登录
|
||||
|
||||
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无 | [← 返回 API 索引](./README.md)
|
||||
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无 | [← 返回 API 索引](../README.md)
|
||||
>
|
||||
> 集成实现:见 [integrations/jiguang](../integrations/jiguang.md)(极光核验链路、RSA 解密策略、私钥配对踩坑)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# POST /api/v1/auth/logout — 登出
|
||||
|
||||
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md)
|
||||
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:Bearer access_token | [← 返回 API 索引](../README.md)
|
||||
|
||||
## 入参
|
||||
无
|
||||
@@ -1,6 +1,6 @@
|
||||
# GET /api/v1/auth/me — 获取当前登录用户
|
||||
|
||||
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md)
|
||||
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:Bearer access_token | [← 返回 API 索引](../README.md)
|
||||
|
||||
## 入参
|
||||
无(身份取自 Header token)
|
||||
@@ -1,6 +1,6 @@
|
||||
# POST /api/v1/auth/refresh — 刷新 token
|
||||
|
||||
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无(凭 body 里的 refresh_token) | [← 返回 API 索引](./README.md)
|
||||
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无(凭 body 里的 refresh_token) | [← 返回 API 索引](../README.md)
|
||||
|
||||
## 入参
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# POST /api/v1/auth/sms/login — 手机号 + 验证码登录
|
||||
|
||||
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无 | [← 返回 API 索引](./README.md)
|
||||
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无 | [← 返回 API 索引](../README.md)
|
||||
>
|
||||
> 集成实现:见 [integrations/sms](../integrations/sms.md)(验证码校验逻辑)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# POST /api/v1/auth/sms/send — 发送短信验证码
|
||||
|
||||
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无 | [← 返回 API 索引](./README.md)
|
||||
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无 | [← 返回 API 索引](../README.md)
|
||||
>
|
||||
> 集成实现:见 [integrations/sms](../integrations/sms.md)(mock 模式、频控、接真供应商 TODO)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# POST /api/v1/compare/milestones/{milestone}/claim — 领取比价战绩里程碑奖励
|
||||
|
||||
> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](../README.md)
|
||||
|
||||
领取某一档(第 `milestone` 次)。⚠️ **当前不真发金币**(产品定,后续整体删除该功能):仍写
|
||||
`comparison_milestone_claim`((user_id, milestone) 唯一)标记该档已领、**每档只能领一次**,但不调
|
||||
@@ -1,6 +1,6 @@
|
||||
# GET /api/v1/compare/milestones — 比价战绩里程碑进度
|
||||
|
||||
> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](../README.md)
|
||||
|
||||
福利页「记录比价战绩」的数据源。返回各档(第 1~6 次)解锁/领取状态。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# GET /api/v1/compare/records/{record_id} — 比价记录详情
|
||||
|
||||
> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](../README.md)
|
||||
|
||||
单条比价记录详情,在列表项基础上额外带 `raw_payload`(客户端上报的原始全量),供未来 UI 展示任意细节。
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# POST /api/v1/compare/record — 上报一次比价结果(幂等)
|
||||
|
||||
> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](../README.md)
|
||||
|
||||
比价 `done` 帧后,客户端用**带 JWT 的通道**上报一条比价结果,落 `comparison_record` 表,作为「我的比价记录」数据源 + 用户级行为画像。
|
||||
比价 `done` 帧后上报一条比价结果,落 `comparison_record` 表,作为「我的比价记录」数据源 + 用户级行为画像。
|
||||
|
||||
> ⚠️ 与不鉴权的透传端点 [`/api/v1/price/step`](./compare-price-step.md) 不同:那是转发壳,本接口按用户维度落库,**必须鉴权**。
|
||||
> 本轮只做 server 端;客户端在 done 帧后调本接口的改动另起一轮(见 [待办与技术债.md](../guides/待办与技术债.md) P1)。
|
||||
> ⚠️ **灰度定位(2026-07)**:写 `comparison_record` 现以透传壳 `compare.py` 的**后端 harvest** 为主(帧0 建 `running` 行 → done/finalize 落终态,新客户端不再 POST)。本接口降级为**老客户端兼容**通道,与 harvest 按 `trace_id` reconcile(**success 不被降级**);新版覆盖够高后可下线本 POST。
|
||||
> 与软鉴权透传端点 [`/api/v1/price/step`](./compare-price-step.md) 不同:那是转发壳(顺带 harvest 落库),本接口按用户维度显式上报,**必须鉴权**。
|
||||
|
||||
## 入参(JSON body)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `trace_id` | string | ✅ | — | pricebot 侧 trace_id。**幂等键**:同用户同 trace_id 重复上报覆盖、返回同一 id |
|
||||
| `trace_id` | string | ✅ | — | 一次比价的唯一标识(app-server 帧0 签发)。**幂等键(trace_id 单列唯一)**:同 trace_id 重复上报覆盖、返回同一 id;与 harvest 行按它 reconcile |
|
||||
| `business_type` | string | ❌ | `food` | `food`(外卖,当前唯一接通) / `ecom`(电商) / `coupon`(领券) |
|
||||
| `device_id` | string \| null | ❌ | null | 设备号 |
|
||||
| `store_name` | string \| null | ❌ | null | 店铺名(外卖,来自 calibration.result) |
|
||||
@@ -1,6 +1,6 @@
|
||||
# GET /api/v1/compare/records — 比价记录列表(游标分页)
|
||||
|
||||
> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](../README.md)
|
||||
|
||||
「我的比价记录」列表页数据源。按 `id` 倒序(最新在前)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# GET /api/v1/compare/stats — 比价口径战绩(「我的」页省钱战绩卡)
|
||||
|
||||
> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](../README.md)
|
||||
|
||||
## 入参
|
||||
无(用户由 token 确定)。
|
||||
@@ -0,0 +1,67 @@
|
||||
# 今日领券完成状态(completed-today 族)
|
||||
|
||||
> 所属:Coupon 组(前缀 `/api/v1/coupon`) | 鉴权:无(按 device_id 判断) | [← 返回 API 索引](../README.md)
|
||||
|
||||
---
|
||||
|
||||
## GET /completed-today — 今天是否已完成整轮领券
|
||||
|
||||
判断这台设备今天是否跑到 done 帧。已完成 → 首页「去领取」卡置灰。
|
||||
|
||||
### 入参(query)
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `device_id` | string | ✅ | 设备 ID(需与领券循环上报的一致) |
|
||||
|
||||
Mock 请求:
|
||||
```
|
||||
GET /api/v1/coupon/completed-today?device_id=android_abc123def456
|
||||
```
|
||||
|
||||
### 出参
|
||||
|
||||
响应 `200`:`CouponCompletedTodayOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `completed` | bool | 今天是否已跑完整轮 |
|
||||
|
||||
Mock 出参:
|
||||
```json
|
||||
{"completed": true}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /completed-today/reset — 重置今日已完成
|
||||
|
||||
删这台设备今天的 completion → `has_completed_today` 变 false,首页「去领取」卡恢复可点。开发设置用。
|
||||
|
||||
### 入参
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `device_id` | string | ✅ | 设备 ID |
|
||||
| `package` | string | ❌ | App 包名(默认 "") |
|
||||
| `user_id` | int \| null | ❌ | 登录用户 ID |
|
||||
|
||||
Mock 入参:
|
||||
```json
|
||||
{
|
||||
"device_id": "android_abc123def456"
|
||||
}
|
||||
```
|
||||
|
||||
### 出参
|
||||
|
||||
```json
|
||||
{"ok": true}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 说明
|
||||
- 判断维度 `device_id`(客户端两端都用 ANDROID_ID)
|
||||
- 用户决策 A 方案:到 done 即算完成,不管单券成败
|
||||
- MVP 不鉴权
|
||||
@@ -0,0 +1,128 @@
|
||||
# 领券引导窗频控(prompt 族)
|
||||
|
||||
> 所属:Coupon 组(前缀 `/api/v1/coupon`) | 鉴权:无(按 device_id 判断,MVP 阶段) | [← 返回 API 索引](../README.md)
|
||||
|
||||
领券引导窗频控:今天这台设备**这个 App** 已弹过/领过/拒过 → 不再弹。各 App 独立(美团弹过不压淘宝/京东)。
|
||||
|
||||
---
|
||||
|
||||
## GET /prompt/should-show — 是否还应弹引导窗
|
||||
|
||||
客户端切到外卖 App 时查。
|
||||
|
||||
### 入参(query)
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `device_id` | string | ✅ | 设备 ID |
|
||||
| `package` | string | ❌ | App 包名(默认 "",老客户端兼容全局态) |
|
||||
|
||||
Mock 请求:
|
||||
```
|
||||
GET /api/v1/coupon/prompt/should-show?device_id=android_abc&package=com.sankuai.meituan
|
||||
```
|
||||
|
||||
### 出参
|
||||
|
||||
响应 `200`:`CouponPromptShouldShowOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `should_show` | bool | 今天是否还应弹引导窗 |
|
||||
|
||||
Mock 出参:
|
||||
```json
|
||||
{"should_show": true}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /prompt/shown — 引导窗弹出即上报
|
||||
|
||||
客户端弹出引导窗那刻调 → 记一条今日 engagement(shown),今天这个 App 不再自动弹。频控主判据管跨重装。
|
||||
|
||||
### 入参
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `device_id` | string | ✅ | 设备 ID |
|
||||
| `package` | string | ✅ | App 包名 |
|
||||
| `user_id` | int \| null | ❌ | 登录用户 ID |
|
||||
|
||||
Mock 入参:
|
||||
```json
|
||||
{
|
||||
"device_id": "android_abc123def456",
|
||||
"package": "com.sankuai.meituan",
|
||||
"user_id": 42
|
||||
}
|
||||
```
|
||||
|
||||
### 出参
|
||||
|
||||
```json
|
||||
{"ok": true}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /prompt/dismiss — 用户拒绝/关闭引导窗
|
||||
|
||||
客户端点关闭时调 → 记 dismissed,今天这个 App 不再弹。
|
||||
|
||||
### 入参
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `device_id` | string | ✅ | 设备 ID |
|
||||
| `package` | string | ❌ | App 包名(默认 "") |
|
||||
| `user_id` | int \| null | ❌ | 登录用户 ID |
|
||||
|
||||
Mock 入参:
|
||||
```json
|
||||
{
|
||||
"device_id": "android_abc123def456",
|
||||
"package": "com.sankuai.meituan"
|
||||
}
|
||||
```
|
||||
|
||||
### 出参
|
||||
|
||||
```json
|
||||
{"ok": true}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /prompt/reset — 重置今日引导窗状态
|
||||
|
||||
删这台设备今天的 engagement → 今天又能弹。开发测频控用。
|
||||
|
||||
### 入参
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `device_id` | string | ✅ | 设备 ID |
|
||||
| `package` | string | ❌ | App 包名(默认 "") |
|
||||
| `user_id` | int \| null | ❌ | 登录用户 ID |
|
||||
|
||||
Mock 入参:
|
||||
```json
|
||||
{
|
||||
"device_id": "android_abc123def456"
|
||||
}
|
||||
```
|
||||
|
||||
### 出参
|
||||
|
||||
```json
|
||||
{"ok": true}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 说明
|
||||
- 频控按 `(device, package, 日)`,各 App 独立
|
||||
- 弹出即占用今天一次(管跨重装),后续领取/拒绝再升级 type
|
||||
- user_id 可选,登录态带上就一并记(资产留痕)
|
||||
- MVP 不鉴权
|
||||
@@ -0,0 +1,75 @@
|
||||
# POST /api/v1/coupon/session — 领券任务流水上报
|
||||
|
||||
> 所属:Coupon 组(前缀 `/api/v1/coupon`) | 鉴权:无(按 device_id/trace_id 区分) | [← 返回 API 索引](../README.md)
|
||||
|
||||
客户端两段上报一次领券流水(发起 / 收尾),按 `trace_id` upsert 到 `coupon_session`。供 admin「领券数据」看板算发起/完成数、耗时分位、机型维度。
|
||||
|
||||
## 入参
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `trace_id` | string | ✅ | 领券 trace 标识(同领券循环 step 的 trace_id) |
|
||||
| `device_id` | string | ✅ | 设备 ID(与领券循环一致) |
|
||||
| `status` | string | ✅ | `started` / `completed` / `failed` / `abandoned` |
|
||||
| `started_at_ms` | int | ✅ | 发起墙钟毫秒(客户端 `System.currentTimeMillis`) |
|
||||
| `user_id` | int \| null | ❌ | 登录用户 ID(未登录可空) |
|
||||
| `platforms` | list[string] \| null | ❌ | 勾选的平台列表 |
|
||||
| `origin_package` | string \| null | ❌ | 发起领券的 App 包名 |
|
||||
| `device_model` | string \| null | ❌ | 机型(如 `24115RA8EC`) |
|
||||
| `rom` | string \| null | ❌ | ROM 信息(如 `MIUI 14.0.8`) |
|
||||
| `app_env` | string \| null | ❌ | 应用环境(`prod` / `test`) |
|
||||
| `elapsed_ms` | int \| null | ❌ | 全程耗时(ms),收尾帧必带 |
|
||||
| `platform_elapsed` | dict \| null | ❌ | 各平台耗时(如 `{"meituan": 3200, "jd": 2800}`),收尾帧带 |
|
||||
| `claimed_count` | int \| null | ❌ | 本场实际领到的券张数 |
|
||||
| `trace_url` | string \| null | ❌ | trace 云端 URL(done 帧带) |
|
||||
|
||||
Mock 入参(started):
|
||||
```json
|
||||
{
|
||||
"trace_id": "tr_20260703_a1b2c3d4",
|
||||
"device_id": "android_abc123def456",
|
||||
"status": "started",
|
||||
"started_at_ms": 1719993600000,
|
||||
"user_id": 42,
|
||||
"platforms": ["meituan", "jd"],
|
||||
"origin_package": "com.sankuai.meituan",
|
||||
"device_model": "24115RA8EC",
|
||||
"rom": "MIUI 14.0.8",
|
||||
"app_env": "prod"
|
||||
}
|
||||
```
|
||||
|
||||
Mock 入参(completed):
|
||||
```json
|
||||
{
|
||||
"trace_id": "tr_20260703_a1b2c3d4",
|
||||
"device_id": "android_abc123def456",
|
||||
"status": "completed",
|
||||
"started_at_ms": 1719993600000,
|
||||
"user_id": 42,
|
||||
"platforms": ["meituan", "jd"],
|
||||
"origin_package": "com.sankuai.meituan",
|
||||
"device_model": "24115RA8EC",
|
||||
"rom": "MIUI 14.0.8",
|
||||
"app_env": "prod",
|
||||
"elapsed_ms": 12500,
|
||||
"platform_elapsed": {"meituan": 5200, "jd": 4800},
|
||||
"claimed_count": 3,
|
||||
"trace_url": "https://trace.shaguabijia.com/tr_20260703_a1b2c3d4"
|
||||
}
|
||||
```
|
||||
|
||||
## 出参
|
||||
|
||||
响应 `200`:
|
||||
```json
|
||||
{"ok": true}
|
||||
```
|
||||
|
||||
## 错误码
|
||||
- `422` 必填字段缺失或类型不符
|
||||
|
||||
## 说明
|
||||
- 不鉴权(同领券循环 MVP,按 `device_id`/`trace_id`)
|
||||
- 写库失败不连累客户端(fire-and-forget,吞掉返回 ok)
|
||||
- 一次领券两段上报:发起(started)→ 收尾(completed/failed/abandoned)
|
||||
@@ -0,0 +1,33 @@
|
||||
# GET /api/v1/coupon/stats — 累计领券数
|
||||
|
||||
> 所属:Coupon 组(前缀 `/api/v1/coupon`) | 鉴权:Bearer | [← 返回 API 索引](../README.md)
|
||||
|
||||
「我的」页战绩卡「领取优惠券 X 张」数据源。该登录用户累计领到的券数(`SUM(claimed_count)`,与领券完成时给用户看的「本次领了 N 张」同源)。
|
||||
|
||||
**鉴权(CurrentUser)** — 区别于同文件不鉴权的 `/step` 透传与 `/prompt` 频控:个人战绩按 `user_id` 聚合,必须有登录态。
|
||||
|
||||
## 入参
|
||||
|
||||
无(`user_id` 从 JWT 取)。
|
||||
|
||||
## 出参
|
||||
|
||||
响应 `200`:`CouponStatsOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `coupon_count` | int | 累计领到的券张数 |
|
||||
|
||||
Mock 出参:
|
||||
```json
|
||||
{
|
||||
"coupon_count": 42
|
||||
}
|
||||
```
|
||||
|
||||
## 错误码
|
||||
- `401` 未鉴权 / token 失效
|
||||
|
||||
## 说明
|
||||
- 口径:`SUM(claimed_count)` — 各成功领券记录 pricebot 展示张数之和
|
||||
- 只算登录用户,按 `user_id` 聚合(非 device_id)
|
||||
@@ -1,6 +1,6 @@
|
||||
# POST /api/v1/coupon/step — 一键领券任务步进(透传到 pricebot)
|
||||
|
||||
> 所属:Coupon 组(前缀 `/api/v1/coupon`) | 鉴权:Bearer access_token(客户端契约;Server MVP 阶段不强校验) | [← 返回 API 索引](./README.md)
|
||||
> 所属:Coupon 组(前缀 `/api/v1/coupon`) | 鉴权:Bearer access_token(客户端契约;Server MVP 阶段不强校验) | [← 返回 API 索引](../README.md)
|
||||
|
||||
## 入参
|
||||
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `device_id`、`trace_id`、`step` 用于日志。
|
||||
@@ -1,6 +1,6 @@
|
||||
# 设备 / 无障碍存活监控(device 族)
|
||||
|
||||
> 所属:device 组(前缀 `/api/v1/device`,源 `app/api/v1/device.py`) | 鉴权:**全部 Bearer**(设备绑登录用户) | [← 返回 API 索引](./README.md)
|
||||
> 所属:device 组(前缀 `/api/v1/device`,源 `app/api/v1/device.py`) | 鉴权:**全部 Bearer**(设备绑登录用户) | [← 返回 API 索引](../README.md)
|
||||
>
|
||||
> 落库:[`device_liveness`](../database/device_liveness.md) 表;后台 `heartbeat_monitor_worker` 据此检出心跳超时的设备并召回。设计见 spec `accessibility-liveness-push.md`(推送版)+ `accessibility-liveness-pull-prompt.md`(后置 pull 提醒版)。#65 新增。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# POST /api/v1/intent/recognize — 外卖比价 Phase 1 意图识别(透传到 pricebot)
|
||||
|
||||
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](./README.md)
|
||||
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](../README.md)
|
||||
|
||||
## 入参
|
||||
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `device_id`、`trace_id`、`step` 用于日志。
|
||||
@@ -1,6 +1,6 @@
|
||||
# POST /api/v1/price/step — 外卖比价 Phase 2 步进(透传到 pricebot)
|
||||
|
||||
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](./README.md)
|
||||
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](../README.md)
|
||||
|
||||
## 入参
|
||||
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `device_id`、`trace_id`、`step` 用于日志。
|
||||
@@ -0,0 +1,113 @@
|
||||
# 比价意图多帧步进(intent/step + intent/precoupon/step)
|
||||
|
||||
> 所属:Intent 组(前缀 `/api/v1`,外卖比价透传) | 鉴权:无(MVP 阶段不鉴权) | [← 返回 API 索引](../README.md)
|
||||
|
||||
透传到 pricebot-backend。请求体原样转发、不做 schema 校验。
|
||||
|
||||
---
|
||||
|
||||
## POST /intent/step — Phase 1 多帧意图识别(淘宝源)
|
||||
|
||||
淘宝源走多帧版意图识别(展开+滚动采集→提取):循环调用直到 done(done 帧顶层带 `result` + `calibration`)。其它源走单次 `/intent/recognize`。
|
||||
|
||||
### 入参
|
||||
|
||||
透传 pricebot,客户端按 pricebot 协议组装。关键字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `device_id` | string | 设备 ID |
|
||||
| `trace_id` | string | 比价 trace 标识 |
|
||||
| `frame` | int | 帧序号(0 起始) |
|
||||
| `continue` | bool | 是否继续(pricebot 回传,客户端 next 调用时带上帧) |
|
||||
| `image` | string | 当前帧截图 base64 |
|
||||
| `platform` | string | 源平台 |
|
||||
| `package` | string | 目标平台包名 |
|
||||
| *(透传)* | | 其余字段由 pricebot 定义,本端点不做校验 |
|
||||
|
||||
Mock 入参(首帧):
|
||||
```json
|
||||
{
|
||||
"device_id": "android_abc123def456",
|
||||
"trace_id": "tr_20260703_e5f6g7h8",
|
||||
"frame": 0,
|
||||
"continue": true,
|
||||
"image": "/9j/4AAQSkZJRgABAQEAYABgAAD/...(base64)",
|
||||
"platform": "taobao",
|
||||
"package": "com.sankuai.meituan"
|
||||
}
|
||||
```
|
||||
|
||||
### 出参
|
||||
|
||||
透传 pricebot 原始响应。通常包含 `action.command`(`continue` / `done`)、`action.params` 等。
|
||||
|
||||
Mock 出参:
|
||||
```json
|
||||
{
|
||||
"trace_id": "tr_20260703_e5f6g7h8",
|
||||
"frame": 0,
|
||||
"continue": true,
|
||||
"action": {
|
||||
"command": "continue",
|
||||
"params": {
|
||||
"next_frame": 1,
|
||||
"scroll_distance": 300
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /intent/precoupon/step — Phase 0 意图识别前先用券(美团源)
|
||||
|
||||
美团源平台「意图识别前先用券」多帧循环:客户端在调 `/intent/recognize` 之前先循环调本端点到 done(`continue=false`)。订单页底部有「点击使用X红包」就自动选最大免费券用上,已用券/无券则首帧秒过。与 `/intent/step` 同属 intent 域,复用同一透传壳。
|
||||
|
||||
### 入参
|
||||
|
||||
同 `/intent/step`,透传 pricebot。
|
||||
|
||||
Mock 入参(首帧):
|
||||
```json
|
||||
{
|
||||
"device_id": "android_abc123def456",
|
||||
"trace_id": "tr_20260703_i9j0k1l2",
|
||||
"frame": 0,
|
||||
"continue": true,
|
||||
"image": "/9j/4AAQSkZJRgABAQEAYABgAAD/...(base64)",
|
||||
"platform": "meituan",
|
||||
"package": "com.sankuai.meituan"
|
||||
}
|
||||
```
|
||||
|
||||
### 出参
|
||||
|
||||
透传 pricebot 原始响应。
|
||||
|
||||
Mock 出参(无券首帧秒过):
|
||||
```json
|
||||
{
|
||||
"trace_id": "tr_20260703_i9j0k1l2",
|
||||
"frame": 0,
|
||||
"continue": false,
|
||||
"action": {
|
||||
"command": "done",
|
||||
"params": {
|
||||
"coupon_used": false,
|
||||
"reason": "no_coupon_available"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误码
|
||||
- `502` pricebot 不可达或返回 5xx
|
||||
- `400` 请求体不是合法 JSON
|
||||
|
||||
## 说明
|
||||
- 两个端点是同域透传,区别是 pricebot 后端路由不同(`/api/intent/step` vs `/api/intent/precoupon/step`)
|
||||
- 一致性 hash 按 `trace_id` 路由到同一 pricebot 实例
|
||||
- MVP 阶段不鉴权(待补 JWT)
|
||||
@@ -1,6 +1,6 @@
|
||||
# 内部回写端点(internal 族,pricebot / 发布流程 → app-server)
|
||||
|
||||
> 所属:internal 组(前缀 `/internal`,源 `app/api/internal/`) | 鉴权:**`X-Internal-Secret` 头**(== `settings.INTERNAL_API_SECRET`) | [← 返回 API 索引](./README.md)
|
||||
> 所属:internal 组(前缀 `/internal`,源 `app/api/internal/`) | 鉴权:**`X-Internal-Secret` 头**(== `settings.INTERNAL_API_SECRET`) | [← 返回 API 索引](../README.md)
|
||||
|
||||
**不是给客户端的接口**:不走用户 JWT。`§6` 是 app-server 透传给 pricebot,这里反过来——pricebot(及发布流程)把少量数据 server→server 回写 app-server 落库。
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# 邀请绑定(bind + landing-track)
|
||||
|
||||
> 所属:Invite 组(前缀 `/api/v1/invite`) | 鉴权:bind 需 Bearer / landing-track 无需鉴权 | [← 返回 API 索引](../README.md)
|
||||
|
||||
---
|
||||
|
||||
## POST /bind — 绑定邀请人
|
||||
|
||||
把当前登录用户(被邀请人)绑定到某邀请码。支持三种归因路径:clipboard(首启读剪贴板)、manual(手动输入邀请码)、fingerprint(指纹兜底反查)。绑定成功双方各发 1 万金币。
|
||||
|
||||
### 入参
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `invite_code` | string \| null | ❌ | 邀请码;走指纹兜底时为空 |
|
||||
| `channel` | string | ✅ | 归因来源:`clipboard` / `manual` / `fingerprint` |
|
||||
| `fingerprint` | object \| null | ❌ | 指纹兜底时必传 |
|
||||
| `fingerprint.screen` | string | - | 屏幕分辨率(如 `1080x2400`) |
|
||||
| `fingerprint.device_model` | string | - | 设备型号(如 `24115RA8EC`) |
|
||||
|
||||
Mock 入参(clipboard 归因):
|
||||
```json
|
||||
{
|
||||
"invite_code": "A3F8K2",
|
||||
"channel": "clipboard",
|
||||
"fingerprint": null
|
||||
}
|
||||
```
|
||||
|
||||
Mock 入参(指纹兜底):
|
||||
```json
|
||||
{
|
||||
"invite_code": null,
|
||||
"channel": "fingerprint",
|
||||
"fingerprint": {
|
||||
"screen": "1080x2400",
|
||||
"device_model": "24115RA8EC"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 出参
|
||||
|
||||
响应 `200`:`BindInviteOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `status` | string | `success` / `already_bound` / `invalid_code` / `self_invite` / `not_eligible` / `fp_not_found` |
|
||||
| `coins_awarded` | int | 本次给当前用户(被邀请人)发的金币 |
|
||||
| `message` | string | 给前端直接展示的文案 |
|
||||
|
||||
Mock 出参:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"coins_awarded": 10000,
|
||||
"message": "邀请绑定成功"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /landing-track — 落地页指纹采集
|
||||
|
||||
B 浏览器打开 `dl.html?ref=xxx` 时上报指纹(剪贴板归因失败时兜底)。**无需鉴权**(浏览器没 token)。
|
||||
|
||||
### 入参
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `ref` | string | ✅(4-16 位) | 邀请码(落地页 `?ref=`) |
|
||||
| `screen` | string | ❌ | 屏幕分辨率(如 `1080x2400`) |
|
||||
|
||||
Mock 入参:
|
||||
```json
|
||||
{
|
||||
"ref": "A3F8K2",
|
||||
"screen": "1080x2400"
|
||||
}
|
||||
```
|
||||
|
||||
### 出参
|
||||
|
||||
响应 `200`:`LandingTrackOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `status` | string | `ok` / `invalid_code` / `no_ip` |
|
||||
|
||||
Mock 出参:
|
||||
```json
|
||||
{"status": "ok"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 说明
|
||||
- `bind`: 绑定只在注册后首次有效(`not_eligible` = 已过新人期),自邀屏蔽
|
||||
- `landing-track`: IP/UA 服务端从 HTTP 头自动拿,JS 无需上报
|
||||
- 指纹反查窗口期由 `INVITE_FP_WINDOW_DAYS` 控制
|
||||
@@ -0,0 +1,60 @@
|
||||
# GET /api/v1/invite/invitees — 我邀请的人列表
|
||||
|
||||
> 所属:Invite 组(前缀 `/api/v1/invite`) | 鉴权:Bearer | [← 返回 API 索引](../README.md)
|
||||
|
||||
分页查询当前用户成功邀请的人列表。邀请页小窗(取前几条)+ 完整列表页(分页加载)共用。
|
||||
|
||||
## 入参(query)
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `limit` | int | ❌ | 每页条数(1–50,默认 20) |
|
||||
| `offset` | int | ❌ | 偏移量(≥0,默认 0) |
|
||||
|
||||
Mock 请求:
|
||||
```
|
||||
GET /api/v1/invite/invitees?limit=5&offset=0
|
||||
```
|
||||
|
||||
## 出参
|
||||
|
||||
响应 `200`:`InviteeListOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `items` | list[InviteeItem] | 被邀请人列表 |
|
||||
| `items[].display_name` | string | 显示名(昵称 → 微信昵称 → 脱敏手机号,后端已兜底) |
|
||||
| `items[].avatar_url` | string \| null | 头像 URL;null = 前端画默认色块 |
|
||||
| `items[].coins` | int | 这次邀请给邀请人发的金币 |
|
||||
| `items[].invited_at` | datetime | 邀请绑定时间(ISO 8601 UTC) |
|
||||
| `total` | int | 我邀请的总人数 |
|
||||
| `has_more` | bool | 还有下一页吗 |
|
||||
|
||||
Mock 出参:
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"display_name": "省钱小王",
|
||||
"avatar_url": "/media/avatars/u2_f1e2d3c4b5a60708.jpg",
|
||||
"coins": 10000,
|
||||
"invited_at": "2026-06-28T14:30:00Z"
|
||||
},
|
||||
{
|
||||
"display_name": "138****1234",
|
||||
"avatar_url": null,
|
||||
"coins": 10000,
|
||||
"invited_at": "2026-07-01T09:15:00Z"
|
||||
}
|
||||
],
|
||||
"total": 5,
|
||||
"has_more": false
|
||||
}
|
||||
```
|
||||
|
||||
## 错误码
|
||||
- `401` 未鉴权 / token 失效
|
||||
|
||||
## 说明
|
||||
- 名字/头像降级兜底在后端算好:昵称 → 微信昵称 → 脱敏手机号
|
||||
- `limit` 钳到 [1, 50],`offset` 钳到 ≥0
|
||||
@@ -0,0 +1,48 @@
|
||||
# GET /api/v1/invite/me — 我的邀请信息
|
||||
|
||||
> 所属:Invite 组(前缀 `/api/v1/invite`) | 鉴权:Bearer | [← 返回 API 索引](../README.md)
|
||||
|
||||
获取当前用户的邀请码、分享链接、已邀人数、累计获得金币/奖励金,以及 7 天一轮的倒计时信息。
|
||||
|
||||
## 入参
|
||||
|
||||
无(`user_id` 从 JWT 取)。
|
||||
|
||||
## 出参
|
||||
|
||||
响应 `200`:`InviteInfoOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `invite_code` | string | 我的邀请码(6-8 位) |
|
||||
| `share_url` | string | 落地页链接(含 `?ref=`),前端据此生成二维码 + 复制分享 |
|
||||
| `invited_count` | int | 已成功邀请人数 |
|
||||
| `coins_earned` | int | 累计从邀请获得的金币(v1 口径) |
|
||||
| `reward_balance_cents` | int | v2 可提现邀请奖励金(分) |
|
||||
| `reward_withdrawn_cents` | int | v2 累计提现成功的邀请奖励金(分) |
|
||||
| `countdown_days_left` | int | v2 本轮剩余天数(7 天 1 轮) |
|
||||
| `countdown_is_fresh_round` | bool | 是否刚进入新一轮(非首轮第 1 天) |
|
||||
| `countdown_text` | string | 倒计时展示文案(前端直接显示,新轮含换行) |
|
||||
|
||||
Mock 出参:
|
||||
```json
|
||||
{
|
||||
"invite_code": "A3F8K2",
|
||||
"share_url": "https://app.shaguabijia.com/dl.html?ref=A3F8K2",
|
||||
"invited_count": 5,
|
||||
"coins_earned": 50000,
|
||||
"reward_balance_cents": 3200,
|
||||
"reward_withdrawn_cents": 1800,
|
||||
"countdown_days_left": 4,
|
||||
"countdown_is_fresh_round": false,
|
||||
"countdown_text": "还剩 4 天"
|
||||
}
|
||||
```
|
||||
|
||||
## 错误码
|
||||
- `401` 未鉴权 / token 失效
|
||||
|
||||
## 说明
|
||||
- 首次调用自动生成邀请码(幂等)
|
||||
- v2 奖励金与现金隔离(`reward_balance_cents` 独立于 `cash_balance_cents`)
|
||||
- 7 天 1 轮倒计时:新用户从注册日起算
|
||||
@@ -1,6 +1,6 @@
|
||||
# POST /api/v1/meituan/coupons — 券列表 / 搜索
|
||||
|
||||
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md)
|
||||
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](../README.md)
|
||||
>
|
||||
> 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# POST /api/v1/meituan/feed — 首页推荐流(多 tab)
|
||||
|
||||
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md)
|
||||
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](../README.md)
|
||||
>
|
||||
> 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑);离线库见 [database/meituan_coupon](../database/meituan_coupon.md)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# POST /api/v1/meituan/referral-link — 换取推广链接
|
||||
|
||||
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md)
|
||||
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](../README.md)
|
||||
>
|
||||
> 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# POST /api/v1/meituan/top-sales — 销量最高(离线库)
|
||||
|
||||
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md)
|
||||
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](../README.md)
|
||||
>
|
||||
> 数据来自离线库 [database/meituan_coupon](../database/meituan_coupon.md);**不实时打美团**(美团搜索对销量排序支持差、且有 402 限流)。
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
# 消息通知中心(notifications 族)
|
||||
|
||||
> 所属:notifications 组(前缀 `/api/v1/notifications`,源 `app/api/v1/notifications.py`) | 鉴权:**全部 Bearer**(消息按用户隔离) | [← 返回 API 索引](./README.md)
|
||||
>
|
||||
> 对应 PRD《消息通知中心》(通知类型清单 / 点击跳转 / 字段元素 / 未读红点 / Push 文案)。
|
||||
> Push 侧(厂商直推 + 测试)见 [push-vendor-test.md](./push-vendor-test.md)。
|
||||
>
|
||||
> ⚠️ **虚拟数据阶段**:数据来自内存 mock(`app/repositories/notification_mock.py`),每个用户首次访问生成一套固定样例(13 种类型全覆盖 + 今天/昨天/当年/跨年四种时间 + 已读未读混合,共 16 条,id 从 90001 起);已读状态在服务端内存维护,**重启服务复位**。后续接真实数据只换 repository 实现,接口契约不变。
|
||||
>
|
||||
> ⚠️ **字段命名**:本组接口(含 push 测试组)对外为 **camelCase**(`sentAt` / `isRead` / `pageSize`…),与库内其他 snake_case 接口不同——按 PRD 前端契约对接,勿混用。
|
||||
|
||||
## 通知类型速查(13 种)
|
||||
|
||||
列表**服务端已排好序:全列表按时间倒序**(最新在前,**不做分类分组**——PRD §1 的"按分类分组"为笔误,2026-07-14 需求方确认取消),前端按返回顺序渲染即可。category 仅用于卡片头部的分类标签展示。
|
||||
|
||||
| category | 分类标签 | type | 类型 | cardStyle 版式 | actionText 操作行 | extra 里带什么 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| withdraw_assistant | 提现助手 | `reward_expiring` | 金币现金奖励即将失效 | dual_amount 双金额卡 | 立即激活您的收益 | `batchId` |
|
||||
| withdraw_assistant | 提现助手 | `reward_expired` | 金币现金奖励已失效 | dual_amount 双金额卡 | 立即赚取新收益 | — |
|
||||
| withdraw_assistant | 提现助手 | `withdraw_success` | 提现成功 | withdraw 提现卡 | **null(无操作行,点击仅消红点)** | — |
|
||||
| withdraw_assistant | 提现助手 | `withdraw_failed` | 提现失败,款项已退回 | withdraw 提现卡 | 重新提现 | `withdrawId` |
|
||||
| system | 系统通知 | `perm_accessibility` | 比价功能异常(无障碍) | plain_text 纯文本卡 | 去开启 | `permission:"accessibility"` |
|
||||
| system | 系统通知 | `perm_battery` | 比价续航保护异常(省电策略) | plain_text 纯文本卡 | 去开启 | `permission:"battery"` |
|
||||
| system | 系统通知 | `perm_autostart` | 比价启动保护异常(自启动) | plain_text 纯文本卡 | 去开启 | `permission:"autostart"` |
|
||||
| system | 系统通知 | `perm_overlay` | 比价按钮异常(悬浮窗) | plain_text 纯文本卡 | 去开启 | `permission:"overlay"` |
|
||||
| feedback | 我的反馈 | `feedback_reply` | 官方回复 | plain_text 纯文本卡 | 查看详情 | `feedbackId` |
|
||||
| feedback | 我的反馈 | `feedback_reward` | 反馈奖励(必带官方留言行) | coin_reward 金币奖励卡 | 查看反馈详情 | `feedbackId` |
|
||||
| report | 我的爆料 | `report_approved` | 爆料审核通过 | coin_reward 金币奖励卡 | 查看爆料详情 | `reportId` |
|
||||
| invite | 好友邀请 | `invite_order_reward` | 好友下单奖励到账 | friend_cash 好友现金卡 | 邀请更多好友赚现金 | `inviteeNickname` |
|
||||
| invite | 好友邀请 | `invite_remind` | 好友催单提醒 | plain_text 纯文本卡 | 去提醒 TA | `inviteeNickname`, `scrollTo:"remind"` |
|
||||
|
||||
点击跳转逻辑按 PRD §2 由客户端按 `type` 分发;点击目标 = 整张卡片(不区分主体和操作行),任何点击都先调 `POST /read` 消该条红点。
|
||||
|
||||
## GET /api/v1/notifications — 消息列表(分页)
|
||||
|
||||
**入参(query)**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `page` | int | ❌ | 页码,1 起,默认 1 |
|
||||
| `pageSize` | int | ❌ | 每页条数,默认 20,最大 100 |
|
||||
|
||||
**出参**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": 90001,
|
||||
"category": "withdraw_assistant", // 分类 key(5 种,见上表)
|
||||
"categoryLabel": "提现助手", // 卡片头部左上角分类标签
|
||||
"type": "reward_expiring", // 类型 key(13 种,决定点击行为)
|
||||
"cardStyle": "dual_amount", // 版式:dual_amount/withdraw/plain_text/coin_reward/friend_cash
|
||||
"title": "金币现金奖励即将失效", // 卡片标题
|
||||
"coins": 86, // 金币数,整数;无金币的版式为 null
|
||||
"cashCents": 1280, // 现金金额(分);无现金的版式为 null
|
||||
"cashYuan": "12.80", // 现金展示串(元,两位小数),与 cashCents 同源
|
||||
"infoRows": [ // 信息行,已按 PRD 拼好文案,逐行 label: value 渲染
|
||||
{ "label": "过期说明", "value": "您有86金币和12.80元现金即将失效,完成一次一键领券或一键比价即可激活收益" },
|
||||
{ "label": "过期时间", "value": "3天后失效" }
|
||||
],
|
||||
"actionText": "立即激活您的收益", // 操作行;null = 无操作行(提现成功卡)
|
||||
"extra": { "batchId": "batch_20260714" }, // 跳转/联动参数,按 type 取用(见上表)
|
||||
"sentAt": "2026-07-14T14:59:58+08:00", // ISO8601 带时区
|
||||
"isRead": false // false → 分类标签右侧显示 6px 红点(#E53935)
|
||||
}
|
||||
],
|
||||
"page": 1,
|
||||
"pageSize": 20,
|
||||
"total": 16,
|
||||
"hasMore": false,
|
||||
"unreadCount": 12 // 与 /unread-count 同口径,进页面可顺手刷角标
|
||||
}
|
||||
```
|
||||
|
||||
**时间显示规则(前端处理 `sentAt`)**:今天 →「今天」;昨天 →「昨天」;当年 →「M月D日」(不补零);跨年 →「YYYY年M月D日」。mock 样例特意覆盖了这四种情况。
|
||||
|
||||
**数值约束(PRD §3)**:金币整数不带小数;现金/提现金额两位小数(直接用 `cashYuan`)。
|
||||
|
||||
## GET /api/v1/notifications/unread-count — 未读总数(首页铃铛角标)
|
||||
|
||||
无入参。**出参**:
|
||||
|
||||
```jsonc
|
||||
{ "count": 12, "badgeText": "12" } // count>99 时 badgeText="99+";count=0 时 badgeText=null → 整个角标隐藏
|
||||
```
|
||||
|
||||
刷新时机(PRD §4):进入首页时、从通知中心/其他页面返回首页时(原型监听 `pageshow`)。
|
||||
|
||||
## POST /api/v1/notifications/read — 标记已读
|
||||
|
||||
**入参(JSON),两种模式二选一(同时传时 `all` 优先)**
|
||||
|
||||
| 模式 | body | 使用场景 |
|
||||
|---|---|---|
|
||||
| 单条/多条 | `{ "ids": [90001, 90003] }` | ① 点击某张消息卡片(点击后无论跳转/弹窗/无动作都算已读);② 用户点击 push 直达落地页后,客户端拿 push extras 里的 `notificationId` 同步置读 |
|
||||
| 全量清零 | `{ "all": true }` | 进入通知中心自动清零(只浏览列表就消红点,无需逐条点击;退出通知中心时也可再调一次兜底) |
|
||||
|
||||
**出参**
|
||||
|
||||
```jsonc
|
||||
{ "ok": true, "markedCount": 2, "unreadCount": 10 } // unreadCount = 处理后剩余未读,可直接刷新角标
|
||||
```
|
||||
|
||||
幂等:不存在/已读的 id 忽略,重复调用 `markedCount=0` 不报错。
|
||||
|
||||
**错误**:`400` ids 与 all 都没传(或 ids 为空数组);`401` 未鉴权。
|
||||
|
||||
## 联调小抄
|
||||
|
||||
```bash
|
||||
# 1. 登录拿 token(SMS mock:任意手机号 + 任意 6 位验证码)
|
||||
curl -X POST :8770/api/v1/auth/sms/send -d '{"phone":"13800001234"}'
|
||||
curl -X POST :8770/api/v1/auth/sms/login -d '{"phone":"13800001234","code":"123456"}'
|
||||
# 2. 列表 / 角标 / 置读
|
||||
curl ":8770/api/v1/notifications?page=1&pageSize=20" -H "Authorization: Bearer $TOKEN"
|
||||
curl ":8770/api/v1/notifications/unread-count" -H "Authorization: Bearer $TOKEN"
|
||||
curl -X POST ":8770/api/v1/notifications/read" -d '{"all":true}' -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
要重置 mock 数据(重新生成整套样例):重启服务即可。想造「push → 站内联动」数据,用 [POST /api/v1/push/test](./push-vendor-test.md) 的 `createNotification:true`。
|
||||
@@ -0,0 +1,82 @@
|
||||
# POST /api/v1/analytics/events — 批量上报埋点事件
|
||||
|
||||
> 所属:Analytics 组(前缀 `/api/v1/analytics`) | 鉴权:无(不强制登录,未登录态也要采集行为) | [← 返回 API 索引](../README.md)
|
||||
|
||||
批量接收新手引导(及后续)埋点,append 落 `analytics_event` 表。`user_id` 由客户端在 body 可选带上,不靠 Bearer。服务端补 `client_ip`(X-Forwarded-For)与 `server_at`(接收时间)。
|
||||
|
||||
## 入参
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `device_id` | string | ✅(≤64) | 设备 ID |
|
||||
| `user_id` | int \| null | ❌ | 登录用户 ID(未登录可空) |
|
||||
| `sent_at` | int \| null | ❌ | 批次发送时间(epoch ms) |
|
||||
| `oem` | string \| null | ❌ | 厂商(如 `Xiaomi`) |
|
||||
| `os` | string \| null | ❌ | 操作系统(如 `Android 14`) |
|
||||
| `model` | string \| null | ❌ | 机型(如 `24115RA8EC`) |
|
||||
| `app_ver` | string \| null | ❌ | App 版本 |
|
||||
| `channel` | string \| null | ❌ | 渠道 |
|
||||
| `events` | list[object] | ✅(1-200 条) | 事件数组 |
|
||||
| `events[].event` | string | ✅(≤64) | 事件名(如 `onboarding_start`) |
|
||||
| `events[].client_ts` | int | ✅ | 端事件发生时间(epoch ms) |
|
||||
| `events[].session_id` | string \| null | ❌ | 会话 ID |
|
||||
| `events[].page` | string \| null | ❌ | 页面标识 |
|
||||
| `events[].network` | string \| null | ❌ | 网络类型(如 `wifi`) |
|
||||
| `events[].props` | dict | ❌ | 事件属性(key-value) |
|
||||
|
||||
Mock 入参:
|
||||
```json
|
||||
{
|
||||
"device_id": "android_abc123def456",
|
||||
"user_id": 42,
|
||||
"sent_at": 1719993700000,
|
||||
"oem": "Xiaomi",
|
||||
"os": "Android 14",
|
||||
"model": "24115RA8EC",
|
||||
"app_ver": "0.1.5",
|
||||
"channel": "official",
|
||||
"events": [
|
||||
{
|
||||
"event": "onboarding_start",
|
||||
"client_ts": 1719993600000,
|
||||
"session_id": "sess_a1b2c3",
|
||||
"page": "onboarding",
|
||||
"network": "wifi",
|
||||
"props": {"step": "1", "source": "fresh_install"}
|
||||
},
|
||||
{
|
||||
"event": "onboarding_step_complete",
|
||||
"client_ts": 1719993615000,
|
||||
"session_id": "sess_a1b2c3",
|
||||
"page": "onboarding",
|
||||
"network": "wifi",
|
||||
"props": {"step": "1", "duration_ms": "15000"}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 出参
|
||||
|
||||
响应 `200`:`AnalyticsIngestOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `ok` | bool | 固定 `true` |
|
||||
| `received` | int | 成功写入的条数 |
|
||||
|
||||
Mock 出参:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"received": 2
|
||||
}
|
||||
```
|
||||
|
||||
## 错误码
|
||||
- `422` `events` 为空或超过 200 条 / 字段类型不符
|
||||
|
||||
## 说明
|
||||
- `user_id` 不靠 JWT:未登录态也要采集行为(新手引导可能在登录前)
|
||||
- 每批最多 200 条,建议客户端攒到一定量再批量上报
|
||||
- `client_ts` 是端侧时间(客户端时钟),`server_at` 由服务端补(可靠时间轴)
|
||||
@@ -1,6 +1,6 @@
|
||||
# CPS 群发短链落地(cps-redirect 族)
|
||||
|
||||
> 所属:cps-redirect 组(**无前缀**,挂域名根;源 `app/api/v1/cps_redirect.py`) | 鉴权:**公网无鉴权**(群里任何人点都要能跳/能领) | [← 返回 API 索引](./README.md)
|
||||
> 所属:cps-redirect 组(**无前缀**,挂域名根;源 `app/api/v1/cps_redirect.py`) | 鉴权:**公网无鉴权**(群里任何人点都要能跳/能领) | [← 返回 API 索引](../README.md)
|
||||
>
|
||||
> 落库:点击落 [`cps_click`](../database/cps_click.md)、微信落地页用户落 [`cps_wx_user`](../database/cps_wx_user.md);短链由 [`cps_link`](../database/cps_link.md) 解析。
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# GET /api/v1/feedback/config — 反馈页二维码卡配置
|
||||
|
||||
> 所属:Feedback 组(前缀 `/api/v1/feedback`) | 鉴权:Bearer | [← 返回 API 索引](../README.md)
|
||||
|
||||
运营后台配的反馈页「加群二维码」卡配置(开关 + 二维码图 + 三行文案)。客户端进反馈页时拉取,据此渲染整张「加群二维码」卡。
|
||||
|
||||
## 入参
|
||||
|
||||
无(`user_id` 从 JWT 取)。
|
||||
|
||||
## 出参
|
||||
|
||||
响应 `200`:`FeedbackQrConfigOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `enabled` | bool | 是否展示加群二维码卡 |
|
||||
| `image_url` | string \| null | 二维码图片 URL(相对 `/media` 路径);空 → 客户端走本地兜底 |
|
||||
| `title` | string | 卡片标题(如 `加入用户反馈群`) |
|
||||
| `group_name` | string | 群名称(如 `傻瓜比价用户群`) |
|
||||
| `subtitle` | string | 副标题/说明文案(如 `扫码加入,你的声音我们听得见`) |
|
||||
|
||||
Mock 出参:
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"image_url": "/media/feedback_qr/qr_default.png",
|
||||
"title": "加入用户反馈群",
|
||||
"group_name": "傻瓜比价用户群",
|
||||
"subtitle": "扫码加入,你的声音我们听得见"
|
||||
}
|
||||
```
|
||||
|
||||
## 错误码
|
||||
- `401` 未鉴权 / token 失效
|
||||
|
||||
## 说明
|
||||
- `image_url` 是相对路径,客户端按自己的 `BASE_URL` 拼绝对地址
|
||||
- `enabled=false` 时客户端隐藏整张卡
|
||||
- 配置在 admin 后台 `feedback_qr` 表维护
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user