Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb15018322 | |||
| a6cfef64a8 | |||
| 7802aeb163 | |||
| f843e68d0f | |||
| 108c175142 | |||
| e6f467a935 | |||
| 708e454959 | |||
| 0ec6b2bc56 | |||
| 23e4671312 | |||
| b3ed66d4eb | |||
| c9c35a4350 | |||
| 5f1d1a5e5f | |||
| e9fd51d119 |
@@ -0,0 +1,68 @@
|
||||
"""add inactivity tables
|
||||
|
||||
Revision ID: 135e79414fd0
|
||||
Revises: comparison_llm_cost
|
||||
Create Date: 2026-07-16 18:31:02.105929
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '135e79414fd0'
|
||||
down_revision: Union[str, Sequence[str], None] = 'comparison_llm_cost'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"inactivity_reset_log",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("coin_balance_before", sa.Integer(), nullable=False),
|
||||
sa.Column("cash_balance_cents_before", sa.Integer(), nullable=False),
|
||||
sa.Column("invite_cash_balance_cents_before", sa.Integer(), nullable=False),
|
||||
sa.Column("last_active_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("inactive_days", sa.Integer(), nullable=False),
|
||||
sa.Column("reason", sa.String(length=32), nullable=False),
|
||||
sa.Column("reset_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
with op.batch_alter_table("inactivity_reset_log", schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f("ix_inactivity_reset_log_user_id"), ["user_id"], unique=False)
|
||||
batch_op.create_index(batch_op.f("ix_inactivity_reset_log_reset_at"), ["reset_at"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"inactivity_notification_log",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("stage", sa.Integer(), nullable=False),
|
||||
sa.Column("inactive_days", sa.Integer(), nullable=False),
|
||||
sa.Column("coin_balance", sa.Integer(), nullable=False),
|
||||
sa.Column("cash_balance_cents", sa.Integer(), nullable=False),
|
||||
sa.Column("invite_cash_balance_cents", sa.Integer(), nullable=False),
|
||||
sa.Column("channel", sa.String(length=16), nullable=False),
|
||||
sa.Column("status", sa.String(length=16), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
with op.batch_alter_table("inactivity_notification_log", schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f("ix_inactivity_notification_log_user_id"), ["user_id"], unique=False)
|
||||
batch_op.create_index(batch_op.f("ix_inactivity_notification_log_created_at"), ["created_at"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("inactivity_notification_log", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_inactivity_notification_log_created_at"))
|
||||
batch_op.drop_index(batch_op.f("ix_inactivity_notification_log_user_id"))
|
||||
op.drop_table("inactivity_notification_log")
|
||||
with op.batch_alter_table("inactivity_reset_log", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_inactivity_reset_log_reset_at"))
|
||||
batch_op.drop_index(batch_op.f("ix_inactivity_reset_log_user_id"))
|
||||
op.drop_table("inactivity_reset_log")
|
||||
+24
-15
@@ -12,11 +12,16 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import test_account
|
||||
from app.core.ratelimit import enforce_rate_limit
|
||||
from app.core.ratelimit import (
|
||||
RateLimitRule,
|
||||
check_rate_limits,
|
||||
enforce_rate_limit,
|
||||
record_rate_limits,
|
||||
)
|
||||
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
|
||||
@@ -40,9 +45,10 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
# 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。
|
||||
SMS_LOGIN_MAX_PER_HOUR = 5
|
||||
# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。
|
||||
# 发码防刷(同一设备 device_id + 同一 IP,**只按成功发码计数**;被单号 60s 冷却挡下的重发不占额度):
|
||||
# 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。
|
||||
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5
|
||||
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5 # 每小时上限
|
||||
SMS_SEND_MAX_PER_DAY_PER_DEVICE = 20 # 每天上限(再叠一层日封顶,挡低频长时间轰炸)
|
||||
|
||||
|
||||
def _login_response(
|
||||
@@ -99,23 +105,26 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
|
||||
logger.info("test_account sms_send short-circuit (不真发)")
|
||||
return SmsSendResponse(sent=True, mock=True, cooldown_sec=0)
|
||||
|
||||
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码。
|
||||
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,
|
||||
# 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。
|
||||
enforce_rate_limit(
|
||||
request,
|
||||
scope="sms-send-device",
|
||||
subject=req.device_id,
|
||||
limit=SMS_SEND_MAX_PER_HOUR_PER_DEVICE,
|
||||
window_sec=3600,
|
||||
detail="操作过于频繁,请稍后再试",
|
||||
)
|
||||
# 发码防刷:同一设备(device_id) + 同一 IP,每小时 / 每天两道闸,**均只按成功发码计数**。
|
||||
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,挡短信轰炸/烧钱。
|
||||
# 关键:被单号 60s 冷却挡下的重发是「没真发、没烧钱」→ 不该占额度。故 check(先判)放在真发之前
|
||||
# (超限直接 429、不真发),record(计数)只在 send_code 成功后调 —— 冷却/供应商失败抛 429 时直接返回、不计数。
|
||||
send_rules = [
|
||||
RateLimitRule("sms-send-device", SMS_SEND_MAX_PER_HOUR_PER_DEVICE, 3600,
|
||||
"操作过于频繁,请稍后再试"),
|
||||
RateLimitRule("sms-send-device-daily", SMS_SEND_MAX_PER_DAY_PER_DEVICE, 86400,
|
||||
"今日验证码发送次数过多,请明天再试"),
|
||||
]
|
||||
check_rate_limits(request, subject=req.device_id, rules=send_rules)
|
||||
|
||||
try:
|
||||
cooldown = send_code(req.phone)
|
||||
except SmsError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
|
||||
|
||||
# 发码成功 → 两道闸各 +1(被单号冷却挡下的重发走不到这里,故不占额度)
|
||||
record_rate_limits(request, subject=req.device_id, rules=send_rules)
|
||||
|
||||
from app.core.config import settings # 局部 import 避免循环
|
||||
|
||||
return SmsSendResponse(sent=True, mock=settings.SMS_MOCK, cooldown_sec=cooldown)
|
||||
|
||||
+3
-18
@@ -14,24 +14,18 @@ import logging
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import wx_poc_signal
|
||||
from app.repositories import device as device_repo
|
||||
from app.schemas.device import (
|
||||
DeviceOut,
|
||||
DeviceRegisterRequest,
|
||||
HeartbeatRequest,
|
||||
HeartbeatResponse,
|
||||
LivenessAckRequest,
|
||||
LivenessOut,
|
||||
OkResponse,
|
||||
PendingCompare,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.device")
|
||||
|
||||
# PoC:源平台代号 → Android 包名(前端 launch 用)。当前只用美团。
|
||||
_SOURCE_PACKAGES = {"meituan": "com.sankuai.meituan"}
|
||||
|
||||
router = APIRouter(prefix="/api/v1/device", tags=["device"])
|
||||
|
||||
|
||||
@@ -58,12 +52,12 @@ def register_device(
|
||||
return DeviceOut.model_validate(device)
|
||||
|
||||
|
||||
@router.post("/heartbeat", response_model=HeartbeatResponse, summary="上报心跳")
|
||||
@router.post("/heartbeat", response_model=OkResponse, summary="上报心跳")
|
||||
def report_heartbeat(
|
||||
req: HeartbeatRequest,
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
) -> HeartbeatResponse:
|
||||
) -> OkResponse:
|
||||
device_repo.touch_heartbeat(
|
||||
db,
|
||||
user_id=user.id,
|
||||
@@ -71,16 +65,7 @@ def report_heartbeat(
|
||||
accessibility_enabled=req.accessibility_enabled,
|
||||
registration_id=req.registration_id,
|
||||
)
|
||||
# PoC:该设备有"待比价"信号则带回(只对写死的测试设备生效, 取走即清、只弹一次)
|
||||
source = wx_poc_signal.pop_pending(req.device_id)
|
||||
pending = None
|
||||
if source:
|
||||
pending = PendingCompare(
|
||||
source_platform=source,
|
||||
source_package=_SOURCE_PACKAGES.get(source, ""),
|
||||
)
|
||||
logger.info("heartbeat 下发比价信号 device=%s source=%s", req.device_id, source)
|
||||
return HeartbeatResponse(pending_compare=pending)
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.get("/liveness", response_model=LivenessOut, summary="查询本机掉线告警(后置检测)")
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
"""微信客服(企业微信)消息接收回调 /wx/kf/callback(裸路径, 非 /api/v1)。
|
||||
|
||||
对应企业微信「微信客服」→ 客服账号 → 接收消息回调里填的 URL。是服务号版(wx_mp.py)的
|
||||
平行实现:下游(set_pending → 心跳下发 → 前端弹窗)完全一致, 只有上游"如何拿到用户消息"不同 ——
|
||||
服务号是微信把消息直接 POST 给你;微信客服是 POST 一个事件通知, 你再用 <Token> 主动 sync_msg 拉。
|
||||
|
||||
GET : URL 接入验证 → msg_signature 验签(含密文 echostr) → 解密 echostr → 返回明文。
|
||||
⚠️ 与公众号明文模式不同:企业微信 echostr 是密文, 必须解密后返回。
|
||||
POST : 收事件(密文) → msg_signature 验签 → AES 解密 → 解析 XML。
|
||||
Event=kf_msg_or_event → 取 <Token>/<OpenKfId> → sync_msg 增量拉消息 →
|
||||
客户(origin=3)发来的图片(image) → set_pending(测试设备, "meituan") + 下载落盘。
|
||||
其余消息/事件记日志忽略。一律返回 "success"(异常也吞掉, 不让企业微信重试轰炸)。
|
||||
|
||||
回调加密与公众号「安全模式」同源, 直接复用 integrations/wx_mp_crypto(验签 + AES-256-CBC 解密),
|
||||
差别只在 receiveid 用 corpid(公众号是 appid)。MVP 只做"收图打信号 + 落盘证明链路通"。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from app.core import wx_poc_signal
|
||||
from app.core.config import settings
|
||||
from app.integrations import wx_kf_client, wx_mp_crypto
|
||||
|
||||
logger = logging.getLogger("shagua.wx_kf")
|
||||
|
||||
router = APIRouter(prefix="/wx/kf", tags=["wx-kf"])
|
||||
|
||||
# 收到的截图暂存目录(MVP 验证用;接上识别后可改为不落盘或定期清理)
|
||||
_INBOX = Path(settings.MEDIA_ROOT) / "wx_kf_inbox"
|
||||
|
||||
|
||||
@router.get("/callback", include_in_schema=False)
|
||||
async def verify(
|
||||
msg_signature: str = "", timestamp: str = "", nonce: str = "", echostr: str = ""
|
||||
) -> PlainTextResponse:
|
||||
"""企业微信 URL 接入验证:验签(密文 echostr 参与)→ 解密 echostr → 返回明文。"""
|
||||
if not settings.wx_kf_callback_configured:
|
||||
logger.warning("wx_kf verify: 回调凭证未配齐(CorpId/Token/AESKey/Secret)")
|
||||
return PlainTextResponse("", status_code=503)
|
||||
if not wx_mp_crypto.verify_msg_signature(
|
||||
settings.WX_KF_TOKEN, timestamp, nonce, echostr, msg_signature
|
||||
):
|
||||
logger.warning("wx_kf verify: signature 校验失败 ts=%s nonce=%s", timestamp, nonce)
|
||||
return PlainTextResponse("invalid signature", status_code=403)
|
||||
try:
|
||||
plain = wx_mp_crypto.decrypt_message(
|
||||
settings.WX_KF_AES_KEY, settings.WX_KF_CORP_ID, echostr
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("wx_kf verify: echostr 解密失败")
|
||||
return PlainTextResponse("decrypt failed", status_code=403)
|
||||
return PlainTextResponse(plain)
|
||||
|
||||
|
||||
@router.post("/callback", include_in_schema=False)
|
||||
async def receive(request: Request) -> PlainTextResponse:
|
||||
"""收事件通知(安全模式)。任何异常都吞掉返回 success, 不让企业微信重试轰炸, 靠日志排查。"""
|
||||
if not settings.wx_kf_callback_configured:
|
||||
logger.warning("wx_kf receive: 回调凭证未配齐")
|
||||
return PlainTextResponse("success")
|
||||
try:
|
||||
body = (await request.body()).decode("utf-8")
|
||||
qp = request.query_params
|
||||
# 安全模式外层 XML 只有 ToUserName + Encrypt。来源=企业微信服务器(HTTPS)+ 下面验签,
|
||||
# 且 stdlib ET 不扩展外部实体, XXE 不适用。
|
||||
encrypt = ET.fromstring(body).findtext("Encrypt") or ""
|
||||
if not wx_mp_crypto.verify_msg_signature(
|
||||
settings.WX_KF_TOKEN,
|
||||
qp.get("timestamp", ""),
|
||||
qp.get("nonce", ""),
|
||||
encrypt,
|
||||
qp.get("msg_signature", ""),
|
||||
):
|
||||
logger.warning("wx_kf receive: msg_signature 校验失败")
|
||||
return PlainTextResponse("success")
|
||||
xml = wx_mp_crypto.decrypt_message(
|
||||
settings.WX_KF_AES_KEY, settings.WX_KF_CORP_ID, encrypt
|
||||
)
|
||||
await _handle_event(ET.fromstring(xml))
|
||||
except Exception:
|
||||
logger.exception("wx_kf receive: 处理异常")
|
||||
return PlainTextResponse("success")
|
||||
|
||||
|
||||
async def _handle_event(root: ET.Element) -> None:
|
||||
"""客服事件:MsgType=event 且 Event=kf_msg_or_event → 用 Token 增量拉消息。"""
|
||||
event = root.findtext("Event") or ""
|
||||
if event != "kf_msg_or_event":
|
||||
logger.info("wx_kf 收到事件(暂忽略): event=%s", event)
|
||||
return
|
||||
token = root.findtext("Token") or ""
|
||||
open_kfid = root.findtext("OpenKfId") or ""
|
||||
msgs = await wx_kf_client.sync_messages(token, open_kfid)
|
||||
logger.info("wx_kf sync_msg 拉到 %d 条 open_kfid=%s", len(msgs), open_kfid)
|
||||
for msg in msgs:
|
||||
await _handle_message(msg)
|
||||
|
||||
|
||||
async def _handle_message(msg: dict[str, Any]) -> None:
|
||||
"""处理单条客服消息。只关心【客户发来的】(origin=3)图片 / 小程序卡片:打比价信号(+ 落盘)。
|
||||
|
||||
origin:3=客户发的 / 4=系统推送 / 5=接待人员发的。接待人员或系统消息一律不触发比价。
|
||||
两种触发物对应场景:image=用户发结算页截图;miniprogram=用户分享美团小程序卡片(见需求截图)。
|
||||
"""
|
||||
if msg.get("origin") != 3:
|
||||
return
|
||||
msgtype = msg.get("msgtype") or ""
|
||||
external_userid = msg.get("external_userid") or ""
|
||||
if msgtype == "image":
|
||||
media_id = (msg.get("image") or {}).get("media_id", "")
|
||||
logger.info(
|
||||
"wx_kf 收到图片: external_userid=%s media_id=%s", external_userid, media_id
|
||||
)
|
||||
_fire_pending("image")
|
||||
await _save_media(media_id, external_userid)
|
||||
elif msgtype == "miniprogram":
|
||||
mp = msg.get("miniprogram") or {}
|
||||
logger.info(
|
||||
"wx_kf 收到小程序卡片: external_userid=%s title=%s appid=%s",
|
||||
external_userid,
|
||||
mp.get("title"),
|
||||
mp.get("appid"),
|
||||
)
|
||||
# 卡片无可下载媒体, 只打信号;源平台/订单识别(可据 appid 判平台)留后续, 现写死美团。
|
||||
_fire_pending("miniprogram")
|
||||
else:
|
||||
logger.info(
|
||||
"wx_kf 收到消息(暂忽略): external_userid=%s type=%s", external_userid, msgtype
|
||||
)
|
||||
|
||||
|
||||
def _fire_pending(kind: str) -> None:
|
||||
"""PoC:给写死的测试设备打"从美团比价"信号(下次心跳带回前端弹窗, 与服务号版完全一致)。"""
|
||||
poc_dev = settings.WX_POC_TEST_DEVICE_ID
|
||||
if not poc_dev:
|
||||
return
|
||||
wx_poc_signal.set_pending(poc_dev, "meituan")
|
||||
logger.info("wx_kf PoC: 已给测试设备 %s 打比价信号 source=meituan (触发=%s)", poc_dev, kind)
|
||||
|
||||
|
||||
async def _save_media(media_id: str, external_userid: str) -> None:
|
||||
"""图片消息只给 media_id, 走临时素材下载接口拉回落盘(PoC 验证用)。"""
|
||||
content = await wx_kf_client.download_media(media_id)
|
||||
if not content:
|
||||
return
|
||||
try:
|
||||
_INBOX.mkdir(parents=True, exist_ok=True)
|
||||
dest = _INBOX / f"{external_userid[:16]}_{media_id[:16]}.jpg"
|
||||
dest.write_bytes(content)
|
||||
logger.info("wx_kf 图片已落盘: %s (%d bytes)", dest, len(content))
|
||||
except Exception:
|
||||
logger.exception("wx_kf 图片落盘失败 media_id=%s", media_id)
|
||||
@@ -1,112 +0,0 @@
|
||||
"""微信服务号消息接收回调 /wx/mp/callback(裸路径, 非 /api/v1)。
|
||||
|
||||
对应公众平台"设置与开发 → 基本配置 → 服务器配置"里填的 URL。
|
||||
GET : URL 接入验证 → 校验 signature → 原样返回 echostr(明文)
|
||||
POST : 收用户消息(安全模式密文) → msg_signature 验签 → AES 解密 → 解析 XML
|
||||
图片消息(MsgType=image): 打日志(openid/PicUrl/MediaId) + 用 PicUrl 下载落盘
|
||||
其余消息/事件: 记一条日志忽略。一律返回 "success"(微信据此不再重试)。
|
||||
|
||||
MVP 只做"收到图片并落盘"证明链路通;后续接:识别平台+订单 → 打 pending_compare 标记
|
||||
→ 心跳带回前端弹窗,以及 openid↔device 绑定。任何异常都返回 "success"、不让微信重试轰炸。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from app.core import wx_poc_signal
|
||||
from app.core.config import settings
|
||||
from app.integrations import wx_mp_crypto
|
||||
|
||||
logger = logging.getLogger("shagua.wx_mp")
|
||||
|
||||
router = APIRouter(prefix="/wx/mp", tags=["wx-mp"])
|
||||
|
||||
# 收到的截图暂存目录(MVP 验证用;接上识别后可改为不落盘或定期清理)
|
||||
_INBOX = Path(settings.MEDIA_ROOT) / "wx_inbox"
|
||||
|
||||
|
||||
@router.get("/callback", include_in_schema=False)
|
||||
async def verify(
|
||||
signature: str = "", timestamp: str = "", nonce: str = "", echostr: str = ""
|
||||
) -> PlainTextResponse:
|
||||
"""微信 URL 接入验证:校验 signature 通过则原样返回 echostr。"""
|
||||
if not settings.WX_MP_TOKEN:
|
||||
logger.warning("wx_mp verify: WX_MP_TOKEN 未配置")
|
||||
return PlainTextResponse("", status_code=503)
|
||||
if wx_mp_crypto.verify_url_signature(
|
||||
settings.WX_MP_TOKEN, timestamp, nonce, signature
|
||||
):
|
||||
return PlainTextResponse(echostr)
|
||||
logger.warning("wx_mp verify: signature 校验失败 ts=%s nonce=%s", timestamp, nonce)
|
||||
return PlainTextResponse("invalid signature", status_code=403)
|
||||
|
||||
|
||||
@router.post("/callback", include_in_schema=False)
|
||||
async def receive(request: Request) -> PlainTextResponse:
|
||||
"""收用户消息(安全模式)。任何异常都吞掉返回 success,不让微信重试轰炸,靠日志排查。"""
|
||||
if not settings.wx_mp_callback_configured:
|
||||
logger.warning("wx_mp receive: 回调凭证未配齐(Token/AESKey/AppID)")
|
||||
return PlainTextResponse("success")
|
||||
try:
|
||||
body = (await request.body()).decode("utf-8")
|
||||
qp = request.query_params
|
||||
# 安全模式外层 XML 只有 ToUserName + Encrypt。来源=微信服务器(HTTPS)+ 下面验签,
|
||||
# 且 stdlib ET 不扩展外部实体, XXE 不适用。
|
||||
encrypt = ET.fromstring(body).findtext("Encrypt") or ""
|
||||
if not wx_mp_crypto.verify_msg_signature(
|
||||
settings.WX_MP_TOKEN,
|
||||
qp.get("timestamp", ""),
|
||||
qp.get("nonce", ""),
|
||||
encrypt,
|
||||
qp.get("msg_signature", ""),
|
||||
):
|
||||
logger.warning("wx_mp receive: msg_signature 校验失败")
|
||||
return PlainTextResponse("success")
|
||||
xml = wx_mp_crypto.decrypt_message(
|
||||
settings.WX_MP_AES_KEY, settings.WX_MP_APPID, encrypt
|
||||
)
|
||||
await _handle_message(ET.fromstring(xml))
|
||||
except Exception:
|
||||
logger.exception("wx_mp receive: 处理异常")
|
||||
return PlainTextResponse("success")
|
||||
|
||||
|
||||
async def _handle_message(root: ET.Element) -> None:
|
||||
openid = root.findtext("FromUserName") or ""
|
||||
msg_type = root.findtext("MsgType") or ""
|
||||
if msg_type == "image":
|
||||
pic_url = root.findtext("PicUrl") or ""
|
||||
media_id = root.findtext("MediaId") or ""
|
||||
logger.info(
|
||||
"wx_mp 收到图片: openid=%s media_id=%s pic_url=%s", openid, media_id, pic_url
|
||||
)
|
||||
# PoC: 写死"该测试设备要从美团比价", 下次心跳带回前端弹选平台窗
|
||||
poc_dev = settings.WX_POC_TEST_DEVICE_ID
|
||||
if poc_dev:
|
||||
wx_poc_signal.set_pending(poc_dev, "meituan")
|
||||
logger.info("wx_mp PoC: 已给测试设备 %s 打比价信号 source=meituan", poc_dev)
|
||||
await _download(pic_url, openid, media_id)
|
||||
else:
|
||||
logger.info("wx_mp 收到消息(暂忽略): openid=%s type=%s", openid, msg_type)
|
||||
|
||||
|
||||
async def _download(pic_url: str, openid: str, media_id: str) -> None:
|
||||
"""用 PicUrl 直接下载图片落盘。PicUrl 是临时公网链接,无需 access_token / IP 白名单。"""
|
||||
if not pic_url:
|
||||
return
|
||||
try:
|
||||
_INBOX.mkdir(parents=True, exist_ok=True)
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(pic_url)
|
||||
resp.raise_for_status()
|
||||
dest = _INBOX / f"{openid[:12]}_{media_id[:16]}.jpg"
|
||||
dest.write_bytes(resp.content)
|
||||
logger.info("wx_mp 图片已落盘: %s (%d bytes)", dest, len(resp.content))
|
||||
except Exception:
|
||||
logger.exception("wx_mp 图片下载失败 pic_url=%s", pic_url)
|
||||
+20
-35
@@ -136,26 +136,6 @@ class Settings(BaseSettings):
|
||||
# (不跳授权、不拿 openid),整套微信代码保留。审核通过后 .env 置 true + 重启即启用,无需改代码。
|
||||
WX_MP_OAUTH_ENABLED: bool = False
|
||||
|
||||
# ===== 微信服务号(消息接收回调) =====
|
||||
# 用户发消息给服务号 → 微信 POST 到 /wx/mp/callback(安全模式:Token 验签 + AESKey 解密)。
|
||||
# 区别于上面的网页授权(那是落地页拿 openid);这里是被动收用户主动发来的消息(截图比价入口)。
|
||||
WX_MP_TOKEN: str = "" # 公众平台"服务器配置"的 Token(URL 验签 + 消息验签)
|
||||
WX_MP_AES_KEY: str = "" # EncodingAESKey(43 位, 消息 AES-256-CBC 加解密)
|
||||
|
||||
# ===== 微信截图比价 PoC(临时验证链路, 验证后删) =====
|
||||
# 只对这台写死的测试设备下发"从美团比价"信号;空=不触发任何设备(部署上线零风险)。
|
||||
# 收图 → 给此 device_id 打 pending → 该设备下次心跳带回 → 前端弹选平台窗。
|
||||
WX_POC_TEST_DEVICE_ID: str = ""
|
||||
|
||||
# ===== 微信客服(企业微信;截图比价上游入口 —— 服务号版的平行实现) =====
|
||||
# 用户在「微信客服」会话发消息 → 企业微信 POST 事件到 /wx/kf/callback → 后端 sync_msg 拉消息。
|
||||
# 回调加密与公众号「安全模式」同源(复用 wx_mp_crypto), 但 receiveid 用 corpid 且 echostr 需解密。
|
||||
# 拉消息 / 下载图片走 qyapi, 需 access_token(corpid + secret 换取)。全空=回调端点拒收, 零风险。
|
||||
WX_KF_CORP_ID: str = "" # 企业微信 corpid(回调解密 receiveid + 换 access_token)
|
||||
WX_KF_SECRET: str = "" # 「微信客服」Secret(换 access_token)
|
||||
WX_KF_TOKEN: str = "" # 客服「接收消息」回调的 Token(URL 验签 + 消息验签)
|
||||
WX_KF_AES_KEY: str = "" # 客服「接收消息」回调的 EncodingAESKey(AES-256-CBC 解密)
|
||||
|
||||
@property
|
||||
def wx_mp_configured(self) -> bool:
|
||||
"""服务号网页授权凭证齐全(缺则落地页不发起授权,降级为无 openid)。"""
|
||||
@@ -166,21 +146,6 @@ class Settings(BaseSettings):
|
||||
"""落地页是否真正发起微信授权 = 凭证齐全 且 总开关开。"""
|
||||
return self.wx_mp_configured and self.WX_MP_OAUTH_ENABLED
|
||||
|
||||
@property
|
||||
def wx_mp_callback_configured(self) -> bool:
|
||||
"""消息接收回调凭证齐全(Token + AESKey + AppID)。缺则回调端点拒绝处理消息。"""
|
||||
return bool(self.WX_MP_TOKEN and self.WX_MP_AES_KEY and self.WX_MP_APPID)
|
||||
|
||||
@property
|
||||
def wx_kf_callback_configured(self) -> bool:
|
||||
"""微信客服回调凭证齐全(CorpId + Token + AESKey + Secret)。缺则回调端点拒绝处理。"""
|
||||
return bool(
|
||||
self.WX_KF_CORP_ID
|
||||
and self.WX_KF_TOKEN
|
||||
and self.WX_KF_AES_KEY
|
||||
and self.WX_KF_SECRET
|
||||
)
|
||||
|
||||
# ===== 微信支付(商家转账到零钱 / 提现)=====
|
||||
# 真实凭证放 .env(已 gitignore),证书 .pem 放 secrets/。WECHAT_APP_ID 同时用于
|
||||
# 微信登录(code 换 openid)与转账,必须与 App 端开放平台 appid 一致。
|
||||
@@ -204,6 +169,13 @@ class Settings(BaseSettings):
|
||||
# 进程内自动兑换 worker 的检查间隔(秒):每隔这么久醒一次,跨过北京 0 点就跑一轮。
|
||||
# 默认 600s=10min,即 0 点后最多 10 分钟内兑完(客户端文案已注明「可能存在延迟」)。
|
||||
AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600
|
||||
# === 15 天不活跃清零(app.core.inactivity_reset_worker)===
|
||||
INACTIVITY_RESET_ENABLED: bool = False # 总闸,默认关;灰度验证后再开
|
||||
INACTIVITY_RESET_DAYS: int = 15 # 不活跃阈值(天),第 (N+1) 日 0 点清
|
||||
INACTIVITY_WARN_DAYS_BEFORE: str = "7,2" # 清零前几天各推一次;""=不推。逗号分隔
|
||||
INACTIVITY_RESET_RUN_HOUR: int = 3 # 北京时间每日执行点(0-23)
|
||||
INACTIVITY_NOTIFY_CHANNEL: str = "log" # log(占位) / jpush / sms
|
||||
INACTIVITY_RESET_CHECK_INTERVAL_SEC: int = 1800 # worker 唤醒间隔(秒)
|
||||
# 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。
|
||||
# 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容
|
||||
# (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
|
||||
@@ -224,6 +196,19 @@ class Settings(BaseSettings):
|
||||
"""免确认收款授权可用 = 微信支付凭证齐全 + 授权回调地址已配。"""
|
||||
return bool(self.wxpay_configured and self.WXPAY_AUTH_NOTIFY_URL)
|
||||
|
||||
@property
|
||||
def inactivity_warn_stages(self) -> list[int]:
|
||||
"""解析 INACTIVITY_WARN_DAYS_BEFORE → 降序去重的提前天数列表。
|
||||
丢弃非数字 / <=0 / >=RESET_DAYS 的项(空串 → 空列表 = 不推)。"""
|
||||
out: list[int] = []
|
||||
for part in (self.INACTIVITY_WARN_DAYS_BEFORE or "").split(","):
|
||||
part = part.strip()
|
||||
if part.isdigit():
|
||||
v = int(part)
|
||||
if 0 < v < self.INACTIVITY_RESET_DAYS and v not in out:
|
||||
out.append(v)
|
||||
return sorted(out, reverse=True)
|
||||
|
||||
# ===== 穿山甲激励视频(服务端发奖回调)=====
|
||||
# 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。
|
||||
# 穿山甲后台配置的"奖励校验密钥"(m-key),验签用。每个 GroMore 广告位 m-key 不同(后台各自
|
||||
|
||||
+93
-8
@@ -9,29 +9,41 @@ from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import NamedTuple
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# key -> (window_start_ts, count)
|
||||
_buckets: dict[str, tuple[float, int]] = {}
|
||||
# key -> (window_start_ts, count, window_sec)
|
||||
# 存每个 key 自己的 window_sec:_buckets 混着不同窗口(60s 广告 / 3600s 登录 / 86400s 日闸)的 key,
|
||||
# GC 必须按各 key 自己的窗口判过期(见 [_purge_expired]),否则短窗口调用触发的 GC 会误删长窗口 key。
|
||||
_buckets: dict[str, tuple[float, int, float]] = {}
|
||||
_lock = threading.Lock()
|
||||
_GC_THRESHOLD = 10000 # _buckets 超此阈值才顺手清过期 key(仿 sms.py;测试可 monkeypatch 调小强制每次扫)
|
||||
|
||||
|
||||
def _purge_expired(now: float) -> None:
|
||||
"""清过期 key(**仅在持有 _lock 时调用**)。按每个 key 自己存的 window_sec 判过期,而非调用方的窗口
|
||||
—— _buckets 是全局共享、混着 60s(广告)/3600s(登录)/86400s(日闸)不同窗口的 key;若用调用方窗口,
|
||||
高频的 60s 广告端点触发 GC 时会把本该活 3600s/86400s 的登录/日闸计数一并删掉,使其在规模上(超阈值才
|
||||
触发本清理)被反复清零而失效。仅在超阈值时扫,低频、开销可忽略。"""
|
||||
if len(_buckets) <= _GC_THRESHOLD:
|
||||
return
|
||||
for k in [k for k, (s, _, w) in _buckets.items() if now - s >= w]:
|
||||
_buckets.pop(k, None)
|
||||
|
||||
|
||||
def _hit(key: str, limit: int, window_sec: float) -> bool:
|
||||
"""记一次访问。返回 True=放行,False=超限。"""
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
start, count = _buckets.get(key, (now, 0))
|
||||
start, count, _ = _buckets.get(key, (now, 0, window_sec))
|
||||
if now - start >= window_sec: # 窗口过期,重置
|
||||
start, count = now, 0
|
||||
count += 1
|
||||
_buckets[key] = (start, count)
|
||||
# 顺手清理过期 key,防内存无限涨(低频访问足够)
|
||||
if len(_buckets) > 10000:
|
||||
for k in [k for k, (s, _) in _buckets.items() if now - s >= window_sec]:
|
||||
_buckets.pop(k, None)
|
||||
_buckets[key] = (start, count, window_sec)
|
||||
_purge_expired(now) # 顺手清过期 key(按各自窗口),防内存无限涨
|
||||
return count <= limit
|
||||
|
||||
|
||||
@@ -83,3 +95,76 @@ def enforce_rate_limit(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
# ===================== 先判 / 后记(只按「成功」计数)=====================
|
||||
# _hit 是原子「判+记」:一调用就 +1,适合登录爆破(失败尝试也要计)。但对「短信发码」这类
|
||||
# **只想给成功动作计数**的场景不合适 —— 被单号冷却挡下的重发没真发、没烧钱,不该占额度。
|
||||
# 故拆成 _peek(只判不记)+ _commit(只记):check_rate_limits 先判 → 动作 → 成功后 record。
|
||||
|
||||
|
||||
class RateLimitRule(NamedTuple):
|
||||
"""一条限流规则。scope 区分不同闸(不同 key 前缀);同一 (subject, IP) 在 window_sec
|
||||
内最多 limit 次,超限抛 429 用 detail 文案。
|
||||
|
||||
(scope, window_sec) 成对绑在一条规则里 —— check(先判)与 record(计数)复用同一条,
|
||||
避免两处把窗口/scope 写歪导致 key 对不上。
|
||||
"""
|
||||
|
||||
scope: str
|
||||
limit: int
|
||||
window_sec: float
|
||||
detail: str = "操作过于频繁,请稍后再试"
|
||||
|
||||
|
||||
def _peek(key: str, limit: int, window_sec: float) -> bool:
|
||||
"""只读:当前窗口内是否还没到上限(count < limit)。**不改计数**。
|
||||
与 [_commit] 配对实现「先判后记」——只在动作成功后才 _commit。"""
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
start, count, _ = _buckets.get(key, (now, 0, window_sec))
|
||||
if now - start >= window_sec: # 窗口已过期 → 视作已重置(count 归零)
|
||||
count = 0
|
||||
return count < limit
|
||||
|
||||
|
||||
def _commit(key: str, window_sec: float) -> None:
|
||||
"""记一次访问(+1)。窗口过期则以本次为起点重置。仅在动作成功后调用。"""
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
start, count, _ = _buckets.get(key, (now, 0, window_sec))
|
||||
if now - start >= window_sec: # 窗口过期,重置
|
||||
start, count = now, 0
|
||||
_buckets[key] = (start, count + 1, window_sec)
|
||||
_purge_expired(now) # 顺手清过期 key(按各自窗口,同 [_hit])
|
||||
|
||||
|
||||
def check_rate_limits(request: Request, subject: str, rules: list[RateLimitRule]) -> None:
|
||||
"""【先判】一组限流:任一规则已达上限即抛 429,且**不改计数**。
|
||||
|
||||
配合 [record_rate_limits] 实现「只按成功计数」:先 check 所有闸(全未超才继续)→ 执行动作
|
||||
→ 动作**成功后**再 record。动作被下游挡下(如短信单号冷却)、没真正发生时不 record → 不占额度。
|
||||
key = `scope:subject:client_ip`(与 [enforce_rate_limit] 同款)。
|
||||
"""
|
||||
if not settings.RATE_LIMIT_ENABLED:
|
||||
return
|
||||
ip = _client_ip(request)
|
||||
for rule in rules:
|
||||
if not _peek(f"{rule.scope}:{subject}:{ip}", rule.limit, rule.window_sec):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=rule.detail,
|
||||
)
|
||||
|
||||
|
||||
def record_rate_limits(request: Request, subject: str, rules: list[RateLimitRule]) -> None:
|
||||
"""【记一次】一组限流(每条规则 +1)。仅在动作成功后调用,与 [check_rate_limits] 配对。
|
||||
|
||||
⚠️ check→动作→record 非原子:并发突发下计数可能略超 limit(每个在途请求各 +1)。对
|
||||
「防脚本/防轰炸」的安全网定位可接受;要精确配额需迁 Redis(见模块 docstring)。
|
||||
"""
|
||||
if not settings.RATE_LIMIT_ENABLED:
|
||||
return
|
||||
ip = _client_ip(request)
|
||||
for rule in rules:
|
||||
_commit(f"{rule.scope}:{subject}:{ip}", rule.window_sec)
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
"""微信截图比价 PoC:进程内存的"待比价"信号(单 worker 够用, 重启即失效, PoC 可接受)。
|
||||
|
||||
收图端点 set_pending(device_id, source) → 该设备下次心跳 pop_pending 取走并清除 →
|
||||
心跳响应带回 → 前端弹选平台窗。只对 settings.WX_POC_TEST_DEVICE_ID 写入, 不碰其他设备。
|
||||
后续接真识别 / openid↔device 绑定时整体替换本模块。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
_lock = threading.Lock()
|
||||
_pending: dict[str, str] = {} # device_id -> source_platform(如 "meituan")
|
||||
|
||||
|
||||
def set_pending(device_id: str, source_platform: str) -> None:
|
||||
if not device_id:
|
||||
return
|
||||
with _lock:
|
||||
_pending[device_id] = source_platform
|
||||
|
||||
|
||||
def pop_pending(device_id: str) -> str | None:
|
||||
"""取出并清除该设备的待比价信号;无则 None。心跳每帧调, 取到即消费(只弹一次)。"""
|
||||
if not device_id:
|
||||
return None
|
||||
with _lock:
|
||||
return _pending.pop(device_id, None)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""不活跃预警通知器(可插拔)。
|
||||
|
||||
v1 仅日志占位(LogNotifier):现状无真实推送能力(极光只用于一键登录解密 + 设备心跳告警,
|
||||
心跳 worker 也只打印),先把清零主流程 + 审计做扎实。后续实现同协议的 JPushNotifier /
|
||||
SmsNotifier 即可替换,worker/repo 不改。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Protocol
|
||||
|
||||
logger = logging.getLogger("shagua.inactivity")
|
||||
|
||||
|
||||
class InactivityNotifier(Protocol):
|
||||
channel: str
|
||||
|
||||
def warn(self, *, user_id: int, coin: int, cash_cents: int, invite_cash_cents: int,
|
||||
stage: int, days_until_reset: int) -> str:
|
||||
"""发预警,返回状态:'sent' / 'failed' / 'placeholder'。"""
|
||||
...
|
||||
|
||||
|
||||
class LogNotifier:
|
||||
"""占位实现:只打印,不真推。参照 heartbeat_monitor_worker「本期先不接推送」先例。"""
|
||||
|
||||
channel = "log"
|
||||
|
||||
def warn(self, *, user_id: int, coin: int, cash_cents: int, invite_cash_cents: int,
|
||||
stage: int, days_until_reset: int) -> str:
|
||||
logger.warning(
|
||||
"[inactivity-warn] user=%s coin=%s cash_cents=%s invite_cash_cents=%s "
|
||||
"stage=T-%s days_until_reset=%s",
|
||||
user_id, coin, cash_cents, invite_cash_cents, stage, days_until_reset,
|
||||
)
|
||||
return "placeholder"
|
||||
|
||||
|
||||
def get_notifier(channel: str) -> InactivityNotifier:
|
||||
"""按配置返回通知器。未实现的通道(jpush/sms)暂回退 LogNotifier 占位。"""
|
||||
# 后续:if channel == "jpush": return JPushNotifier()
|
||||
# if channel == "sms": return SmsNotifier()
|
||||
return LogNotifier()
|
||||
@@ -13,7 +13,8 @@ worker / 多机时内存不共享 → 冷却、校验都会失效,届时迁移
|
||||
|
||||
防刷两层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
|
||||
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)
|
||||
2. 单设备(device_id)每小时频控(api 层 auth.sms_send 内 enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)。
|
||||
2. 单设备(device_id)+ IP 每小时 / 每天频控(api 层 auth.sms_send 的 check/record_rate_limits,
|
||||
**只按成功发码计数** —— 被本文件单号冷却挡下的重发不占额度)+ 极光控制台 IP 白名单/防轰炸(运维侧)。
|
||||
⚠️ 原「单 IP 频控(rate_limit 依赖)」2026-06-26 按产品要求删除、改设备维度;但 device_id 客户端可伪造/轮换,
|
||||
脚本轮换 id 能绕过本层 → 挡脚本狂发主要靠极光控制台侧(+ 可选 nginx 限流)。
|
||||
⚠️ 原「单号每日上限」2026-07-03 按精简要求删除(mentor 定:登录风控只留单号冷却 + 单设备频控);
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
"""微信客服(企业微信)API 客户端:access_token 缓存 + sync_msg 拉消息 + 临时素材下载。
|
||||
|
||||
对应 api/v1/wx_kf.py:收到 `kf_msg_or_event` 回调后, 用这里的函数增量拉取用户消息。
|
||||
- access_token(7200s)进程内缓存, 提前 300s 刷新(单 worker 够用, 重启即失效)。
|
||||
- sync_msg 游标 next_cursor 按 open_kfid 进程内存续(PoC 重启后从最近 3 天重拉, 可接受)。
|
||||
凭证来自 settings(WX_KF_CORP_ID / WX_KF_SECRET);纯外部 HTTP, 不含业务逻辑。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger("shagua.wx_kf")
|
||||
|
||||
_QYAPI = "https://qyapi.weixin.qq.com/cgi-bin"
|
||||
|
||||
# access_token 进程内缓存(单 worker)。token 空 / 未到刷新点直接用。
|
||||
_token_lock = asyncio.Lock()
|
||||
_token_cache: dict[str, Any] = {"token": "", "expire_at": 0.0}
|
||||
|
||||
# sync_msg 游标:open_kfid -> next_cursor(PoC 进程内存;重启从最近 3 天重拉)
|
||||
_cursors: dict[str, str] = {}
|
||||
|
||||
|
||||
async def _get_access_token() -> str:
|
||||
"""取企业微信 access_token, 进程内缓存, 提前 300s 过期刷新。失败返回空串(调用方降级)。"""
|
||||
now = time.time()
|
||||
if _token_cache["token"] and _token_cache["expire_at"] - 300 > now:
|
||||
return _token_cache["token"]
|
||||
async with _token_lock:
|
||||
now = time.time() # 拿锁后复检, 避免并发重复刷新
|
||||
if _token_cache["token"] and _token_cache["expire_at"] - 300 > now:
|
||||
return _token_cache["token"]
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{_QYAPI}/gettoken",
|
||||
params={
|
||||
"corpid": settings.WX_KF_CORP_ID,
|
||||
"corpsecret": settings.WX_KF_SECRET,
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
logger.exception("wx_kf gettoken 请求失败")
|
||||
return ""
|
||||
if data.get("errcode"):
|
||||
logger.warning(
|
||||
"wx_kf gettoken errcode=%s errmsg=%s", data.get("errcode"), data.get("errmsg")
|
||||
)
|
||||
return ""
|
||||
_token_cache["token"] = data.get("access_token", "")
|
||||
_token_cache["expire_at"] = now + int(data.get("expires_in", 7200))
|
||||
return _token_cache["token"]
|
||||
|
||||
|
||||
async def sync_messages(token: str, open_kfid: str) -> list[dict[str, Any]]:
|
||||
"""收到 kf_msg_or_event 回调后调用:用回调带的一次性 token 增量拉消息, has_more 循环拉完。
|
||||
|
||||
token: 回调事件里的 <Token>(消息拉取凭证, 非配置 Token;不传会有严格频控)。
|
||||
open_kfid:客服账号 id(回调里的 <OpenKfId>)。
|
||||
返回本次新拉到的 msg_list(已合并多页);游标按 open_kfid 进程内存续。
|
||||
"""
|
||||
access_token = await _get_access_token()
|
||||
if not access_token:
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
cursor = _cursors.get(open_kfid, "")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
for _ in range(20): # 最多 20 页护栏, 防异常时死循环
|
||||
body: dict[str, Any] = {"cursor": cursor, "token": token, "limit": 1000}
|
||||
if open_kfid:
|
||||
body["open_kfid"] = open_kfid
|
||||
resp = await client.post(
|
||||
f"{_QYAPI}/kf/sync_msg",
|
||||
params={"access_token": access_token},
|
||||
json=body,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("errcode"):
|
||||
logger.warning(
|
||||
"wx_kf sync_msg errcode=%s errmsg=%s",
|
||||
data.get("errcode"),
|
||||
data.get("errmsg"),
|
||||
)
|
||||
break
|
||||
out.extend(data.get("msg_list", []))
|
||||
cursor = data.get("next_cursor", cursor)
|
||||
_cursors[open_kfid] = cursor
|
||||
if not data.get("has_more"):
|
||||
break
|
||||
except Exception:
|
||||
logger.exception("wx_kf sync_msg 请求失败 open_kfid=%s", open_kfid)
|
||||
return out
|
||||
|
||||
|
||||
async def download_media(media_id: str) -> bytes | None:
|
||||
"""临时素材下载(图片消息只给 media_id)。成功返回二进制, 失败返回 None。"""
|
||||
if not media_id:
|
||||
return None
|
||||
access_token = await _get_access_token()
|
||||
if not access_token:
|
||||
return None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
resp = await client.get(
|
||||
f"{_QYAPI}/media/get",
|
||||
params={"access_token": access_token, "media_id": media_id},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
# 成功=二进制文件流;失败=JSON(errcode)。据 Content-Type 区分。
|
||||
ctype = resp.headers.get("Content-Type", "")
|
||||
if "application/json" in ctype or "text/plain" in ctype:
|
||||
logger.warning("wx_kf media/get 非文件响应: %s", resp.text[:200])
|
||||
return None
|
||||
return resp.content
|
||||
except Exception:
|
||||
logger.exception("wx_kf media/get 下载失败 media_id=%s", media_id)
|
||||
return None
|
||||
@@ -1,69 +0,0 @@
|
||||
"""微信服务号消息接收回调的验签与解密(安全模式)。
|
||||
|
||||
服务号"服务器配置"选安全模式后:
|
||||
- URL 接入验证(GET): sha1(sort(token, timestamp, nonce)) == signature → 原样返回 echostr
|
||||
- 消息(POST): body 里 <Encrypt> 是密文;
|
||||
msg_signature = sha1(sort(token, timestamp, nonce, encrypt))
|
||||
密文 AES-256-CBC 解出: random(16B) + msg_len(4B big-endian) + msg + appid, 去 PKCS7(块=32) padding
|
||||
|
||||
只做验签 + 解密(收消息)。被动回复(加密)MVP 不需要 —— 收到后走客服消息异步回执。
|
||||
密钥/口令来自 settings(WX_MP_TOKEN / WX_MP_AES_KEY / WX_MP_APPID),本模块只做纯算法、不读配置。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
|
||||
def verify_url_signature(token: str, timestamp: str, nonce: str, signature: str) -> bool:
|
||||
"""GET 接入验证:token/timestamp/nonce 三者字典序排序拼接后 sha1。"""
|
||||
return _consteq(_sha1(token, timestamp, nonce), signature)
|
||||
|
||||
|
||||
def verify_msg_signature(
|
||||
token: str, timestamp: str, nonce: str, encrypt: str, msg_signature: str
|
||||
) -> bool:
|
||||
"""POST 消息验签:四者(含密文 encrypt)字典序排序拼接后 sha1。"""
|
||||
return _consteq(_sha1(token, timestamp, nonce, encrypt), msg_signature)
|
||||
|
||||
|
||||
def decrypt_message(aes_key_b64: str, expected_appid: str, encrypt_b64: str) -> str:
|
||||
"""解密 <Encrypt> 密文, 返回明文消息 XML。appid 不符抛 ValueError。
|
||||
|
||||
aes_key_b64: EncodingAESKey(43 位, 不含结尾 '='), 补 '=' 后 base64 解出 32 字节 AES-256 key。
|
||||
"""
|
||||
aes_key = base64.b64decode(aes_key_b64 + "=") # 43 → 32 bytes
|
||||
iv = aes_key[:16]
|
||||
decryptor = Cipher(algorithms.AES(aes_key), modes.CBC(iv)).decryptor()
|
||||
plain = decryptor.update(base64.b64decode(encrypt_b64)) + decryptor.finalize()
|
||||
plain = _pkcs7_unpad(plain)
|
||||
|
||||
# random(16) + msg_len(4, big-endian) + msg(msg_len) + from_appid
|
||||
content = plain[16:]
|
||||
msg_len = int.from_bytes(content[:4], "big")
|
||||
msg = content[4 : 4 + msg_len]
|
||||
from_appid = content[4 + msg_len :].decode("utf-8")
|
||||
if expected_appid and from_appid != expected_appid:
|
||||
raise ValueError(f"appid mismatch: {from_appid!r} != {expected_appid!r}")
|
||||
return msg.decode("utf-8")
|
||||
|
||||
|
||||
def _sha1(*parts: str) -> str:
|
||||
return hashlib.sha1("".join(sorted(parts)).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _consteq(a: str, b: str) -> bool:
|
||||
return hmac.compare_digest(a, b)
|
||||
|
||||
|
||||
def _pkcs7_unpad(data: bytes) -> bytes:
|
||||
"""微信用块大小 32 的 PKCS7,末字节即 padding 长度(1..32)。越界则原样返回(容错)。"""
|
||||
if not data:
|
||||
return data
|
||||
pad = data[-1]
|
||||
if pad < 1 or pad > 32:
|
||||
return data
|
||||
return data[:-pad]
|
||||
@@ -39,8 +39,6 @@ from app.api.v1.signin import router as signin_router
|
||||
from app.api.v1.tasks import router as tasks_router
|
||||
from app.api.v1.user import router as user_router
|
||||
from app.api.v1.wallet import router as wallet_router
|
||||
from app.api.v1.wx_kf import router as wx_kf_router
|
||||
from app.api.v1.wx_mp import router as wx_mp_router
|
||||
from app.api.v1.wxpay import router as wxpay_router
|
||||
from app.core.config import settings
|
||||
from app.core.daily_exchange_worker import (
|
||||
@@ -142,10 +140,6 @@ app.include_router(internal_launch_confirm_router)
|
||||
app.include_router(platform_router)
|
||||
# CPS 群发短链跳转 /c/{code}(公网无鉴权:记点击 → 302 跳美团)
|
||||
app.include_router(cps_redirect_router)
|
||||
# 微信服务号消息接收回调 /wx/mp/callback(公网无鉴权:微信服务器验签, 截图比价入口)
|
||||
app.include_router(wx_mp_router)
|
||||
# 微信客服(企业微信)消息接收回调 /wx/kf/callback(服务号版的平行实现, 收事件→sync_msg 拉图)
|
||||
app.include_router(wx_kf_router)
|
||||
|
||||
# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。
|
||||
_media_root = Path(settings.MEDIA_ROOT)
|
||||
|
||||
@@ -27,6 +27,10 @@ from app.models.coupon_state import ( # noqa: F401
|
||||
CouponSession,
|
||||
)
|
||||
from app.models.feedback import Feedback # noqa: F401
|
||||
from app.models.inactivity import ( # noqa: F401
|
||||
InactivityNotificationLog,
|
||||
InactivityResetLog,
|
||||
)
|
||||
from app.models.invite import InviteRelation # noqa: F401
|
||||
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
|
||||
from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""15 天不活跃清零相关表。
|
||||
|
||||
- inactivity_reset_log:每次清零一行,记清零前三桶余额 + 原因 + 判定时活跃时间/不活跃天数,
|
||||
供纠纷排查(需求①)。清零同时另写 3 条钱包流水(biz_type=inactivity_reset),资金流可逐笔回溯。
|
||||
- inactivity_notification_log:每次预警一行,记推送时余额快照 + 档位 + 通道 + 状态,
|
||||
兼作"预警去重"依据(created_at > last_active)与"待推送"占位 outbox(v1 通道=log)。
|
||||
|
||||
append-only,不更新。user_id 只索引、不设外键(同 analytics_event,避免删用户级联/历史留痕)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class InactivityResetLog(Base):
|
||||
__tablename__ = "inactivity_reset_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
|
||||
coin_balance_before: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
cash_balance_cents_before: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
invite_cash_balance_cents_before: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
last_active_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
inactive_days: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
reason: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
reset_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<InactivityResetLog id={self.id} user_id={self.user_id} coin={self.coin_balance_before}>"
|
||||
|
||||
|
||||
class InactivityNotificationLog(Base):
|
||||
__tablename__ = "inactivity_notification_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
|
||||
stage: Mapped[int] = mapped_column(Integer, nullable=False) # 提前天数档(如 7 / 2)
|
||||
inactive_days: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
coin_balance: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
invite_cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
channel: Mapped[str] = mapped_column(String(16), nullable=False) # log / jpush / sms
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False) # placeholder / sent / failed
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<InactivityNotificationLog id={self.id} user_id={self.user_id} stage={self.stage}>"
|
||||
@@ -0,0 +1,89 @@
|
||||
"""活跃口径唯一真源:worker(不活跃清零)与 admin(最近活跃/DAU)共用,防两处漂移。
|
||||
|
||||
口径 = max(User.created_at, AnalyticsEvent[ACTIVE_EVENTS], CouponPromptEngagement[claim_started])。
|
||||
**不含 last_login_at**(登录/re-login 不代表在用 App);created_at 为恒非空基线。
|
||||
清零/预警按北京自然日 0 点对齐(见 reset_cutoff)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import CN_TZ, cn_today
|
||||
from app.models.analytics_event import AnalyticsEvent
|
||||
from app.models.coupon_state import CouponPromptEngagement
|
||||
|
||||
# —— 活跃事件名(与"用户管理"口径一致)——
|
||||
# home_view:进首页(前端埋点,名称前端明天敲定,此处占位;定名后仅改这一常量)。
|
||||
HOME_VIEW_EVENT = "home_view"
|
||||
COMPARE_START_EVENT = "real_compare_start" # 发起比价(含浮窗触发)
|
||||
COUPON_START_EVENT = "real_coupon_start" # 发起领券
|
||||
ACTIVE_EVENTS = (HOME_VIEW_EVENT, COMPARE_START_EVENT, COUPON_START_EVENT)
|
||||
ACTIVE_ENGAGE_TYPE = "claim_started" # coupon_prompt_engagement 一键领取
|
||||
|
||||
|
||||
def as_utc(value: datetime) -> datetime:
|
||||
"""任意 datetime → tz-aware UTC(无时区按 UTC 解释)。用于与 DateTime(timezone=True) 列比较,
|
||||
比较绝对时刻、与会话时区无关(口径同 admin queries._as_utc)。"""
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def norm_utc(dt: datetime | None) -> datetime | None:
|
||||
"""naive 视为 UTC 补 tzinfo(SQLite 读回 naive、PG 读回 aware,混着 max() 会 TypeError)。"""
|
||||
if dt is None:
|
||||
return None
|
||||
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def cn_midnight_utc(d: date) -> datetime:
|
||||
"""北京 d 日 00:00 → tz-aware UTC datetime。"""
|
||||
return as_utc(datetime(d.year, d.month, d.day, tzinfo=CN_TZ))
|
||||
|
||||
|
||||
def reset_cutoff(reset_days: int, today: date | None = None) -> datetime:
|
||||
"""应清零边界(tz-aware UTC):last_active < 此值 ⟺ 距末次活跃已满 reset_days 天(北京 0 点对齐)。
|
||||
= 北京 00:00 of (today − (reset_days − 1))。例:reset_days=15、today=1/20 → 北京 1/6 00:00。"""
|
||||
today = today or cn_today()
|
||||
return cn_midnight_utc(today - timedelta(days=reset_days - 1))
|
||||
|
||||
|
||||
def last_active_subqueries(db: Session):
|
||||
"""两个按 user_id 预聚合的派生表:最近活跃事件(ACTIVE_EVENTS)、最近领券发起(claim_started)。
|
||||
返回 (ev_sub, eng_sub)。口径同 admin,LEFT JOIN 用,借事件索引只扫活跃事件。"""
|
||||
ev_sub = (
|
||||
select(
|
||||
AnalyticsEvent.user_id.label("user_id"),
|
||||
func.max(AnalyticsEvent.created_at).label("last_at"),
|
||||
)
|
||||
.where(AnalyticsEvent.user_id.is_not(None), AnalyticsEvent.event.in_(ACTIVE_EVENTS))
|
||||
.group_by(AnalyticsEvent.user_id)
|
||||
.subquery()
|
||||
)
|
||||
eng_sub = (
|
||||
select(
|
||||
CouponPromptEngagement.user_id.label("user_id"),
|
||||
func.max(CouponPromptEngagement.created_at).label("last_at"),
|
||||
)
|
||||
.where(
|
||||
CouponPromptEngagement.user_id.is_not(None),
|
||||
CouponPromptEngagement.engage_type == ACTIVE_ENGAGE_TYPE,
|
||||
)
|
||||
.group_by(CouponPromptEngagement.user_id)
|
||||
.subquery()
|
||||
)
|
||||
return ev_sub, eng_sub
|
||||
|
||||
|
||||
def last_active_expr(base_col, ev_sub, eng_sub, dialect: str):
|
||||
"""max(base_col, 最近活跃事件, 最近领券) 的 SQL 表达式。PG 用 greatest、SQLite 用 max。
|
||||
子聚合缺失(未命中)时 coalesce 到 base_col(= User.created_at,恒非空基线)。"""
|
||||
greatest = func.greatest if dialect == "postgresql" else func.max
|
||||
return greatest(
|
||||
base_col,
|
||||
func.coalesce(ev_sub.c.last_at, base_col),
|
||||
func.coalesce(eng_sub.c.last_at, base_col),
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""15 天不活跃清零业务逻辑(纯同步,可单测)。worker 只是它的 asyncio 外壳。
|
||||
|
||||
活跃口径复用 app.repositories.activity;清零走 wallet.grant_*(负数出账、写流水、不 commit)。
|
||||
逐用户独立事务,一个失败不影响其余。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import CN_TZ
|
||||
from app.models.inactivity import InactivityResetLog
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CoinAccount
|
||||
from app.repositories import activity
|
||||
from app.repositories import wallet as wallet_repo
|
||||
|
||||
RESET_BIZ_TYPE = "inactivity_reset"
|
||||
RESET_REMARK = "15天不活跃清零"
|
||||
|
||||
_ANY_BALANCE = or_(
|
||||
CoinAccount.coin_balance > 0,
|
||||
CoinAccount.cash_balance_cents > 0,
|
||||
CoinAccount.invite_cash_balance_cents > 0,
|
||||
)
|
||||
|
||||
|
||||
def _base_query(db: Session):
|
||||
"""select(user_id, last_active, 三桶余额),join CoinAccount + 两活跃子查询。"""
|
||||
ev_sub, eng_sub = activity.last_active_subqueries(db)
|
||||
dialect = db.get_bind().dialect.name
|
||||
last_active = activity.last_active_expr(User.created_at, ev_sub, eng_sub, dialect)
|
||||
stmt = (
|
||||
select(
|
||||
User.id.label("user_id"),
|
||||
last_active.label("last_active"),
|
||||
CoinAccount.coin_balance,
|
||||
CoinAccount.cash_balance_cents,
|
||||
CoinAccount.invite_cash_balance_cents,
|
||||
)
|
||||
.join(CoinAccount, CoinAccount.user_id == User.id)
|
||||
.outerjoin(ev_sub, ev_sub.c.user_id == User.id)
|
||||
.outerjoin(eng_sub, eng_sub.c.user_id == User.id)
|
||||
)
|
||||
return stmt, last_active
|
||||
|
||||
|
||||
def _cn_date(dt: datetime) -> date:
|
||||
"""datetime → 北京自然日(naive 视为 UTC)。"""
|
||||
return activity.norm_utc(dt).astimezone(CN_TZ).date()
|
||||
|
||||
|
||||
def _inactive_days(last_active: datetime, today: date) -> int:
|
||||
return (today - _cn_date(last_active)).days
|
||||
|
||||
|
||||
def select_inactive_users(db: Session, *, cutoff: datetime):
|
||||
"""应清零用户:last_active < cutoff 且三桶有余额。返回 Row 列表(值已快照,可跨 commit)。"""
|
||||
stmt, last_active = _base_query(db)
|
||||
stmt = stmt.where(_ANY_BALANCE, last_active < activity.as_utc(cutoff))
|
||||
return db.execute(stmt).all()
|
||||
|
||||
|
||||
def clear_user(db: Session, *, user_id: int, last_active: datetime, inactive_days: int, reason: str) -> bool:
|
||||
"""单用户清零(独立事务、行锁)。三桶归零 + 写审计 + 3 条流水。返回是否真清了(有余额)。"""
|
||||
acc = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
|
||||
coin, cash, invite = acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents
|
||||
if coin == 0 and cash == 0 and invite == 0:
|
||||
return False
|
||||
log = InactivityResetLog(
|
||||
user_id=user_id, coin_balance_before=coin, cash_balance_cents_before=cash,
|
||||
invite_cash_balance_cents_before=invite, last_active_at=activity.norm_utc(last_active),
|
||||
inactive_days=inactive_days, reason=reason,
|
||||
)
|
||||
db.add(log)
|
||||
db.flush() # 拿 log.id 作 ref_id 交叉链接审计↔流水
|
||||
ref = str(log.id)
|
||||
if coin:
|
||||
wallet_repo.grant_coins(db, user_id, -coin, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK)
|
||||
if cash:
|
||||
wallet_repo.grant_cash(db, user_id, -cash, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK)
|
||||
if invite:
|
||||
wallet_repo.grant_invite_cash(db, user_id, -invite, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def run_reset_once(db: Session, *, reset_days: int, today: date) -> dict:
|
||||
"""扫一轮清零。逐用户独立 commit,失败隔离。"""
|
||||
stats = {"scanned": 0, "cleared": 0, "failed": 0}
|
||||
cutoff = activity.reset_cutoff(reset_days, today)
|
||||
reason = f"inactive_{reset_days}d"
|
||||
rows = select_inactive_users(db, cutoff=cutoff) # 先物化,避免边遍历边 commit
|
||||
for row in rows:
|
||||
stats["scanned"] += 1
|
||||
idays = _inactive_days(row.last_active, today)
|
||||
try:
|
||||
if clear_user(db, user_id=row.user_id, last_active=row.last_active,
|
||||
inactive_days=idays, reason=reason):
|
||||
stats["cleared"] += 1
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
stats["failed"] += 1
|
||||
return stats
|
||||
@@ -36,20 +36,6 @@ class OkResponse(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
|
||||
class PendingCompare(BaseModel):
|
||||
"""PoC:后端通过心跳下发的"用户要从某源平台比价"信号。前端据此弹选平台窗 + launch 源平台。"""
|
||||
source_platform: str # pricebot 源平台代号(PoC 写死 "meituan")
|
||||
source_package: str # 源平台 Android 包名(前端 launch 用)
|
||||
|
||||
|
||||
class HeartbeatResponse(BaseModel):
|
||||
"""心跳响应。常规只回 ok;PoC 期带回 pending_compare 触发比价(exclude_none 省略 null)。"""
|
||||
model_config = ConfigDict()
|
||||
|
||||
ok: bool = True
|
||||
pending_compare: PendingCompare | None = None
|
||||
|
||||
|
||||
class LivenessOut(BaseModel):
|
||||
"""本机掉线告警状态(后置检测 pull 版)。客户端只需这一个布尔判断要不要弹「开启自启动」引导,
|
||||
故只返回 kill_alert_pending(不暴露设备详情 / 内部 liveness_state 等)。从未注册过 → 默认 False(无告警)。"""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,296 @@
|
||||
# 15 天不活跃自动清零(金币 + 现金)设计
|
||||
|
||||
- **日期**:2026-07-16
|
||||
- **状态**:Draft — 待评审
|
||||
- **所属**:app-server(`app/`),含一处 admin 侧重构 + 一项 Android 端埋点依赖
|
||||
- **一句话**:连续 15 天不活跃的用户,自动清零其金币与现金;清零前按可配置节奏预警;全过程留审计以备纠纷排查。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
运营需要对**长期不活跃**用户的钱包余额做清理。两条硬性要求:
|
||||
|
||||
1. **可审计**:记录清零原因与**清零前的三桶余额**,便于后续排查与处理客户纠纷。
|
||||
2. **临清预警**:在临近清零前推送信息告知用户"因账号不活跃,账户里的 xx 金币和 xx 现金将被清零"。
|
||||
|
||||
### 非目标(本期不做)
|
||||
|
||||
- 不做真实推送通道(极光 JPush / 短信)的对接 —— 仅做**可插拔通知器 + 日志占位**,接口预留、后续无缝替换。
|
||||
- 不改动提现(`WithdrawOrder`)流程。
|
||||
- 不新增 `User.last_active_at` 列、不改鉴权热路径。
|
||||
|
||||
---
|
||||
|
||||
## 2. 需求
|
||||
|
||||
| # | 需求 | 落地 |
|
||||
|---|---|---|
|
||||
| R1 | 连续 15 天不活跃 → 清零金币 + 现金 | 每日 worker 扫描 + 逐用户事务清零(§6) |
|
||||
| R2 | 记录清零原因 + 清零前余额 | `inactivity_reset_log` 审计表 + 3 条钱包流水(§5、§7) |
|
||||
| R3 | 临清前预警"xx 金币 xx 现金将清零" | 阶段 A 预警 + `inactivity_notification_log`(§6、§7) |
|
||||
| R4 | 活跃口径与"用户管理"一致 | 抽共享模块 `activity.py`,admin 与 worker 共用(§4、§12) |
|
||||
| R5 | 预警时机完全可配置 | `INACTIVITY_*` 配置项(§8) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 决策记录(来自评审问答)
|
||||
|
||||
| 决策点 | 结论 | 理由 |
|
||||
|---|---|---|
|
||||
| **活跃口径** | 与"用户管理"一致:`max(home_view, 比价, 领券)`,**不含 last_login_at**;无任何信号时以 `created_at` 为非空基线 | 比价可从**浮窗**触发、不进首页;`last_login_at` 只在登录/换绑动作更新(re-login 也算),代表不了"在用 App",故彻底排除 |
|
||||
| **"进首页"信号落地** | **方案 A:前端上报 `home_view` 埋点**(复用 `/analytics/events`),非新接口 | 三个活跃信号统一为同类埋点事件;零新接口零新列;与 admin 口径天然一致。B(鉴权接口 + 列)"更权威"的优势是假的——比价/领券仍是端上报事件,最弱环决定整体可信度 |
|
||||
| **清零范围** | **金币 + 折算现金 + 邀请现金** 三桶全清 | 对应"账户里的金币和现金" |
|
||||
| **预警推送** | **可插拔通知器 + 日志占位**(v1),后续接 JPush/短信 | 现状无真实推送能力;先把清零主流程 + 审计做扎实,不阻塞 |
|
||||
| **预警时机** | **完全可配置**(提前天数列表 + 次数 + 执行点 + 通道) | R5 |
|
||||
| **触发方式** | **进程内每日 worker**,仿 `daily_exchange_worker` | 与项目最新模式一致,无需外部 cron |
|
||||
| **admin 共享口径** | 共享模块 + **重构 admin 改用它** | 单一真源,永不漂移(R4) |
|
||||
|
||||
### 已知取舍(可接受)
|
||||
|
||||
- analytics 的 `user_id` 是**端上报、未鉴权**(可伪造)。但伪造只能"保自己活跃、避免被清",无收益,且正是本功能要防的行为,风险良性。活跃时间的非空基线由服务端权威的 `User.created_at` 提供(见 §4),不再依赖 `last_login_at`。与"用户管理"口径一致。
|
||||
|
||||
---
|
||||
|
||||
## 4. 活跃口径与共享模块 `app/repositories/activity.py`(新建)
|
||||
|
||||
活跃口径的**唯一真源**。app 侧模块,admin 可 import(`app.main` 不 import `app.admin`,反向允许)。
|
||||
|
||||
### 口径
|
||||
|
||||
```
|
||||
last_active = max(
|
||||
User.created_at, # 注册基线(恒非空;re-login 不推进,只有真实使用才推进)
|
||||
max AnalyticsEvent.created_at WHERE event IN ACTIVE_EVENTS,
|
||||
max CouponPromptEngagement.created_at WHERE engage_type == "claim_started",
|
||||
)
|
||||
不活跃判定:按北京自然日、0 点对齐(非从末次活跃时刻滚动 15×24h)
|
||||
last_active_date = 北京(last_active).date() # 末次活跃的北京日,记为「第 1 日」
|
||||
清零边界 = 北京 00:00 of (last_active_date + RESET_DAYS 天) =「第 (RESET_DAYS+1) 日 0 点」 # 15 → 第16日0点
|
||||
应清零 ⟺ (cn_today() − last_active_date).days ≥ RESET_DAYS
|
||||
⟺ last_active < cutoff, cutoff = 北京 00:00 of (cn_today() − (RESET_DAYS−1)) # 供 SQL 比较
|
||||
inactive_days = (cn_today() − last_active_date).days # 清零当日恰 = RESET_DAYS
|
||||
例:末次活跃 1/1 → 1/16 00:00(第16日0点)清零,当日 inactive_days=15;1/15 及之前不清
|
||||
```
|
||||
|
||||
### 模块内容
|
||||
|
||||
- 常量:
|
||||
- `ACTIVE_EVENTS = (HOME_VIEW_EVENT, "real_compare_start", "real_coupon_start")` —— 进首页 + 比价 + 领券。**`HOME_VIEW_EVENT` 事件名待前端确认**(明天加埋点时定;暂记 `"home_view"`)。
|
||||
- `ACTIVE_ENGAGE_TYPE = "claim_started"`
|
||||
- `last_active_subqueries(db)` —— 复刻现 admin `queries._last_active_parts()`:两个按 `user_id` 的 `GROUP BY max(created_at)` 聚合子查询。
|
||||
- `last_active_expr(base_col, ev_sub, eng_sub, dialect)` —— 生成 `greatest`/`max`(PG `func.greatest`/SQLite `func.max`);子聚合缺失时 `coalesce(子聚合, User.created_at)` 兜底(注册基线恒非空,**替代原 last_login_at**)。
|
||||
- `_norm_utc()` —— 沿用现 admin 的 naive→UTC 归一(SQLite naive / PG aware 混算保护)。
|
||||
- `reset_cutoff(reset_days)` / `warn_cutoff(reset_days, k)` —— 生成**北京 0 点对齐**的边界 datetime(见口径):`reset_cutoff = 北京 00:00 of (cn_today() − (reset_days−1))`,供下面查询按 `last_active < cutoff` 比较。
|
||||
- `select_inactive_users(db, *, cutoff, with_balance=True)` —— **worker 专用**:join `CoinAccount`,筛 `last_active < cutoff`(cutoff = 北京 0 点对齐边界,见口径)且(`coin_balance>0 OR cash_balance_cents>0 OR invite_cash_balance_cents>0`),返回 `(user, account, last_active, inactive_days)`。
|
||||
- `select_warn_targets(db, *, reset_days, warn_days_before)` —— **worker 专用**:返回 `(user, account, last_active, inactive_days, stage)` 元组——各"提前天数"窗口内、有余额、本 streak 未推过档 `stage` 的用户(去重结合 `notification_log`,逻辑见 §9)。
|
||||
|
||||
> **参考现状**:现口径散落在 `app/admin/repositories/queries.py:38,91-124,199-204`(`_ACTIVE_EVENTS`/`_last_active_parts`/`greatest`)与 `app/admin/repositories/stats.py:51-52,138-146`(`COMPARE_START_EVENT`/`COUPON_START_EVENT`/活跃用户集)。这些改为从 `activity.py` 导入(§12)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据模型(2 张新表,不动 `User`)
|
||||
|
||||
两表均登记进 `app/models/__init__.py`;一个 Alembic 迁移建两表(`render_as_batch`,SQLite 兼容)。
|
||||
|
||||
### ① `inactivity_reset_log` —— 清零审计(R2)
|
||||
|
||||
仿 `app/models/phone_rebind_log.py` 的简单审计表风格。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int PK autoincrement | |
|
||||
| `user_id` | int, index, not null | |
|
||||
| `coin_balance_before` | int, not null | 清零前金币 |
|
||||
| `cash_balance_cents_before` | int, not null | 清零前折算现金(分) |
|
||||
| `invite_cash_balance_cents_before` | int, not null | 清零前邀请现金(分) |
|
||||
| `last_active_at` | DateTime(tz), nullable | 判定时的最近活跃时间 |
|
||||
| `inactive_days` | int, not null | 判定时不活跃天数 |
|
||||
| `reason` | String(32), not null | 如 `"inactive_15d"` |
|
||||
| `reset_at` | DateTime(tz), server_default now(), index, not null | 清零时刻 |
|
||||
|
||||
### ② `inactivity_notification_log` —— 预警记录 + 去重 + 占位 outbox(R3)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int PK autoincrement | |
|
||||
| `user_id` | int, index, not null | |
|
||||
| `stage` | int, not null | 提前天数档(如 7 / 2) |
|
||||
| `inactive_days` | int, not null | 推送时不活跃天数 |
|
||||
| `coin_balance` | int, not null | 推送快照:告知用户的金币数 |
|
||||
| `cash_balance_cents` | int, not null | 推送快照:折算现金 |
|
||||
| `invite_cash_balance_cents` | int, not null | 推送快照:邀请现金 |
|
||||
| `channel` | String(16), not null | `"log"` / `"jpush"` / `"sms"` |
|
||||
| `status` | String(16), not null | `"placeholder"` / `"sent"` / `"failed"` |
|
||||
| `created_at` | DateTime(tz), server_default now(), index, not null | 去重锚点(见 §9) |
|
||||
|
||||
> 备注:不新增 `User.last_active_at` 列,不改 `get_current_user`。活跃时间由 §4 口径**实时计算**。
|
||||
|
||||
---
|
||||
|
||||
## 6. 清零 worker `app/core/inactivity_reset_worker.py`(新建)
|
||||
|
||||
**完全仿 [`app/core/daily_exchange_worker.py`](../../../app/core/daily_exchange_worker.py)**:App 启动自带 asyncio 任务,文件锁(`data/inactivity_reset.lock`)防同机多进程并发,总闸 `INACTIVITY_RESET_ENABLED`(默认关)。
|
||||
|
||||
### 调度
|
||||
|
||||
- 每 `INACTIVITY_RESET_CHECK_INTERVAL_SEC` 秒醒一次;`last_run: date` 守卫**北京日**,保证每日只跑一轮。
|
||||
- 仅当 `cn_today() != last_run` 且当前北京小时 `>= INACTIVITY_RESET_RUN_HOUR` 时执行(启动补跑同 daily_exchange 语义)。
|
||||
- **清零资格边界 = 第 16 日 0 点(北京,见 §4),与 worker 执行点解耦**:worker 于当日 `RUN_HOUR`(默认 3 点)跑,把已过边界者一并清;若要严格 0 点触发可置 `RUN_HOUR=0`,但注意与 `daily_auto_exchange` 的 0 点任务错峰。
|
||||
- lifespan 里 `start_inactivity_reset_worker()` / `stop_...`(仿 `start_daily_exchange_worker` 在 `app/main.py` 的接线)。
|
||||
|
||||
### 一轮 `run_once(db)` 两阶段(同一次运行、各自逐用户独立 commit)
|
||||
|
||||
**阶段 A — 预警**
|
||||
```
|
||||
for user, acc, last_active, inactive_days, stage in activity.select_warn_targets(...):
|
||||
notifier.send_inactivity_warning(user, balances=snapshot(acc), stage=stage, days_until_reset=RESET_DAYS-inactive_days)
|
||||
db.add(InactivityNotificationLog(..., channel=notifier.channel, status=notifier.last_status))
|
||||
db.commit() # 逐条独立
|
||||
```
|
||||
|
||||
**阶段 B — 清零**(`biz_type="inactivity_reset"`)
|
||||
```
|
||||
for user, acc, last_active, inactive_days in activity.select_inactive_users(db, cutoff=activity.reset_cutoff(RESET_DAYS)): # 北京 00:00 of (今天−(RESET_DAYS−1))
|
||||
try:
|
||||
acc = wallet.get_or_create_account(db, user.id, commit=False, lock=True) # 行锁
|
||||
before = (acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents)
|
||||
if before == (0,0,0): continue
|
||||
log = InactivityResetLog(user_id=user.id, coin_balance_before=before[0],
|
||||
cash_balance_cents_before=before[1], invite_cash_balance_cents_before=before[2],
|
||||
last_active_at=last_active, inactive_days=inactive_days, reason=f"inactive_{RESET_DAYS}d")
|
||||
db.add(log); db.flush() # 拿 log.id 作 ref_id 交叉链接
|
||||
if acc.coin_balance: wallet.grant_coins(db, user.id, -acc.coin_balance, biz_type="inactivity_reset", ref_id=str(log.id), remark="15天不活跃清零")
|
||||
if acc.cash_balance_cents: wallet.grant_cash(db, user.id, -acc.cash_balance_cents, biz_type="inactivity_reset", ref_id=str(log.id), remark="15天不活跃清零")
|
||||
if acc.invite_cash_balance_cents: wallet.grant_invite_cash(db, user.id, -acc.invite_cash_balance_cents, biz_type="inactivity_reset", ref_id=str(log.id), remark="15天不活跃清零")
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback(); stats["failed"] += 1
|
||||
```
|
||||
|
||||
- `grant_*` 负数出账、`balance_after=0`、写三条流水;`grant_coins` 负数**不**动 `total_coin_earned`(历史累计保留)。
|
||||
- 逐用户独立 commit:一个失败不影响其余。返回 `stats = {scanned, warned, cleared, failed}` 并 `logger.info`。
|
||||
|
||||
---
|
||||
|
||||
## 7. 预警与可插拔通知器
|
||||
|
||||
`app/integrations/notifier.py` 定义协议(外部投递属 integrations 层):
|
||||
|
||||
```python
|
||||
class InactivityNotifier(Protocol):
|
||||
channel: str # "log" / "jpush" / "sms"
|
||||
last_status: str # "placeholder" / "sent" / "failed"
|
||||
def send_inactivity_warning(self, user, *, balances, stage, days_until_reset) -> None: ...
|
||||
```
|
||||
|
||||
- **v1 `LogNotifier`**(`channel="log"`):`logger.warning("[inactivity-warn] user=%s coin=%s cash=%s invite=%s T-%s", ...)`,`last_status="placeholder"`。参照 `heartbeat_monitor_worker` 先例("本期先不接推送,用终端打印代替")。
|
||||
- 未来 `JPushNotifier` / `SmsNotifier`:实现同协议即可替换,worker 不改。
|
||||
- 选择:`INACTIVITY_NOTIFY_CHANNEL` → 工厂返回对应实现(未配到真实实现时回退 `LogNotifier`)。
|
||||
- 预警文案数据来自快照 `balances`,满足 R3"告知 xx 金币 xx 现金"。
|
||||
|
||||
---
|
||||
|
||||
## 8. 配置项(`app/core/config.py`)
|
||||
|
||||
```
|
||||
INACTIVITY_RESET_ENABLED = False # 总闸,默认关;灰度验证后再开
|
||||
INACTIVITY_RESET_DAYS = 15 # 不活跃阈值(天)
|
||||
INACTIVITY_WARN_DAYS_BEFORE = "7,2" # 清零前几天各推一次;空串=不推。逗号分隔,降序解析
|
||||
INACTIVITY_RESET_RUN_HOUR = 3 # 北京时间每日执行点(0-23)
|
||||
INACTIVITY_NOTIFY_CHANNEL = "log" # log(占位) / jpush / sms
|
||||
INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800 # worker 唤醒间隔(可复用现有间隔常量)
|
||||
```
|
||||
|
||||
- 清零范围(三桶)固定为常量,不做配置。
|
||||
- `INACTIVITY_WARN_DAYS_BEFORE` 语义(`inactive_days` 为北京自然日,见 §4):档位 `k` ⟹ 当 `inactive_days >= RESET_DAYS-k` 且 `< RESET_DAYS` 且本 streak 未推过档 `k` 时预警,即在北京日 `last_active_date + (RESET_DAYS−k)` 触发(漏跑某天时补发最紧急未推档,§9)。
|
||||
- `INACTIVITY_RESET_RUN_HOUR` 只决定 worker 每日执行点,**不改变**"第 16 日 0 点"这一资格边界(§4/§6)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 幂等与重新活跃
|
||||
|
||||
- **重新活跃自动退出**:`inactive_days` 由 §4 口径**实时算**。用户一有 `home_view`/比价/领券(**登录本身不算**),`last_active` 前移,自动移出预警与清零队列。**无需**显式"重置标记"。
|
||||
- **预警去重**:`inactivity_notification_log` 中存在 `stage==k 且 created_at > last_active` 的行 ⟹ 本 streak 已推过档 `k`,不重推。用户回归后 `last_active` 前移,旧预警行自然"失效",开启新 streak。
|
||||
- **清零幂等**:阶段 B 只处理三桶非全 0 者;清完 = 0,次日不再匹配。worker 重启 / 多次唤醒 / 补跑均安全,不产生重复清零或重复流水。
|
||||
- **稳健补发**:worker 漏跑数天后,某用户可能同时满足多档;只补发**最紧急的未推档**(最小 `k`),避免一次刷屏。
|
||||
|
||||
---
|
||||
|
||||
## 10. 边界与安全
|
||||
|
||||
| 场景 | 处理 |
|
||||
|---|---|
|
||||
| 新用户 | `created_at` 作活跃基线(恒非空)→ 注册即"第 1 日活跃";注册后连续 15 天无 home_view/比价/领券 才清 |
|
||||
| 在途提现 | 提现申请时现金已扣入 `WithdrawOrder`,当前余额已不含在途;只清当前余额、不动提现单。提现失败退款到已清账户 = 用户的钱,正常 |
|
||||
| 与 `daily_auto_exchange` 并存 | 各自逐用户幂等;金币多已日结折现金,三桶全清正好覆盖 |
|
||||
| 时区/日界 | 统一北京(`rewards.cn_today()`/`CN_TZ`);**清零/预警按北京自然日 0 点对齐**(末次活跃记为第 1 日 → 第 16 日 0 点清零,见 §4),非滚动 24h;流水 `created_at` 沿用北京 wall-clock naive |
|
||||
| 误清防护 | 总闸默认关;先灰度只看预警/清零**名单**对不对,再开清零(§13) |
|
||||
|
||||
---
|
||||
|
||||
## 11. 前端依赖:`home_view` 埋点(跨仓 — Android)
|
||||
|
||||
- **Android 端**(`shaguabijia-app-android`)需在**首页可见**(`onResume`/Tab 切入)时,向现有 `POST /api/v1/analytics/events` 批量上报里加一条 `event=<首页可见事件名>`(名称明天加埋点时定,暂记 `"home_view"`) 的事件,**携带登录后的 `user_id`**。
|
||||
- 客户端按会话/前台去重即可(服务端只取 `max(created_at)`,多报无害)。
|
||||
- **上线顺序依赖**:`home_view` 全量覆盖前,"进首页"信号缺失,只有比价/领券能推进活跃、其余落到 `created_at` 基线("只开首页不操作"且注册满 15 天的用户会被误清)—— 故清零总闸必须待 `home_view` 铺满后再开(§13)。
|
||||
|
||||
---
|
||||
|
||||
## 12. admin 重构范围与影响(R4)
|
||||
|
||||
- `app/admin/repositories/queries.py`:删本地 `_ACTIVE_EVENTS`/`_last_active_parts()`,改用 `activity.py` 的常量与子查询构造;`list_users` 的 `greatest(...)` 排序/筛选、`_attach_last_active` 均改走共享构造器。
|
||||
- `app/admin/repositories/stats.py`:`COMPARE_START_EVENT`/`COUPON_START_EVENT`/活跃用户集(`:138-146`)改用共享常量与口径。
|
||||
- **行为变化(预期内、需产品知会)**:admin 的"最近活跃 / DAU"口径变化——**移除 `last_login_at`(登录不再计为活跃)、以 `created_at` 为基线、纳入 `home_view`**。net:`home_view` 铺满后更准(真正把"开首页"算进活跃);铺满前"只登录不操作"的用户活跃度会下降。
|
||||
- **回归底线**:现有 admin 用户列表 / stats 测试按新口径**更新预期**(last_login_at 移除 + created_at 基线 + home_view 纳入);非活跃口径部分行为不变。
|
||||
|
||||
---
|
||||
|
||||
## 13. 灰度与上线顺序(安全优先)
|
||||
|
||||
1. **后端先行**:合入共享模块 + 两表 + worker + 通知器,`INACTIVITY_RESET_ENABLED=False`;活跃口径以 `created_at` 为非空基线、**不含 last_login_at**。
|
||||
2. **Android 发版**:上报 `home_view`;观察 analytics 覆盖率。
|
||||
3. **只读灰度**:临时开一个"dry-run/只出名单不清零"路径或看阶段 A 预警日志,核对预警/清零**名单**准确。
|
||||
4. **开清零**:确认无误后置 `INACTIVITY_RESET_ENABLED=True`。
|
||||
5. **收尾/监控**:持续观察 `home_view` 覆盖率与预警/清零名单;发现"活跃却被判不活跃"的漏报即回查埋点覆盖(口径已不含 last_login_at,登录不再兜底)。
|
||||
|
||||
---
|
||||
|
||||
## 14. 测试计划
|
||||
|
||||
- **活跃口径(共享模块)**:`home_view`/比价/领券 各单独命中都算活跃;**纯登录不算**;无信号用户以 `created_at` 计;`max` 取最新;naive/aware 混算不崩。
|
||||
- **admin 回归**:用户列表 / stats 按新口径更新预期(移除 last_login_at + created_at 基线 + home_view)。
|
||||
- **不活跃判定**:`last_active` 分别 `<15d / =15d / >15d` × 有/无余额 的命中矩阵。
|
||||
- **清零**:三桶归零;`inactivity_reset_log` 清前值正确;三条流水 `biz_type=inactivity_reset`、`balance_after=0`、`ref_id=log.id`;`total_coin_earned` 不变。
|
||||
- **预警**:命中窗口调 notifier + 写 `notification_log`;同 streak 不重推;回归后 `last_active` 前移可再次预警;漏跑补发最紧急档。
|
||||
- **worker**:总闸关不启动;文件锁互斥;逐用户失败隔离(一个抛错不影响其余,`failed` 计数);重复跑幂等。
|
||||
- **配置**:`INACTIVITY_WARN_DAYS_BEFORE` 解析(含空串=不推);`RESET_DAYS`/`RUN_HOUR` 生效。
|
||||
- 沿用 `tests/conftest.py`(临时 SQLite、`RATE_LIMIT_ENABLED=false`);外部通知 monkeypatch。
|
||||
|
||||
---
|
||||
|
||||
## 15. 未来工作
|
||||
|
||||
- 接真实 `JPushNotifier`(需用户级 `registration_id` 覆盖 + JPush push API)/ `SmsNotifier`。
|
||||
- 如需 admin 后台可视化:不活跃/预警/清零名单与历史查询接口。
|
||||
- 如量级增长导致每日 join 扫描变慢:再考虑物化 `last_active_at`(当前每日一次可接受)。
|
||||
|
||||
---
|
||||
|
||||
## 附:涉及文件清单
|
||||
|
||||
**新增**
|
||||
- `app/repositories/activity.py` — 活跃口径唯一真源
|
||||
- `app/models/inactivity_reset_log.py` — 审计表
|
||||
- `app/models/inactivity_notification_log.py` — 预警/占位表
|
||||
- `app/core/inactivity_reset_worker.py` — 每日 worker(仿 daily_exchange_worker)
|
||||
- `app/integrations/notifier.py` — 通知器协议 + `LogNotifier`(真实 JPush/短信后续同层扩展)
|
||||
- `alembic/versions/<...>_add_inactivity_tables.py` — 建两表迁移
|
||||
- `docs/database/inactivity_reset_log.md` / `inactivity_notification_log.md` — 表字典(随实现补)
|
||||
- 对应 `tests/test_inactivity_reset.py`
|
||||
|
||||
**改动**
|
||||
- `app/models/__init__.py` — 注册两模型
|
||||
- `app/core/config.py` — `INACTIVITY_*` 配置
|
||||
- `app/main.py` — lifespan 接线 start/stop worker
|
||||
- `app/admin/repositories/queries.py`、`stats.py` — 改用 `activity.py`(§12)
|
||||
@@ -107,6 +107,67 @@ def test_sms_send_device_ip_rate_limit(client, monkeypatch) -> None:
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_sms_send_cooldown_reject_not_counted(client, monkeypatch) -> None:
|
||||
"""发码额度只算「成功发码」:被单号 60s 冷却挡下的重发(429)不占设备额度。
|
||||
做法:同号狂发只成功 1 次、其余被冷却挡下;把小时额度设 2,证明换号后仍能再成功发 1 次
|
||||
—— 若冷却重发也计数,额度早被那几次耗尽。"""
|
||||
from app.api.v1 import auth
|
||||
from app.core import ratelimit
|
||||
|
||||
monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
|
||||
monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_HOUR_PER_DEVICE", 2)
|
||||
ratelimit._buckets.clear()
|
||||
|
||||
device = "dev-cooldown"
|
||||
phone_a = "13710137000"
|
||||
# 首发成功(小时闸计 1/2)
|
||||
assert client.post(
|
||||
"/api/v1/auth/sms/send", json={"phone": phone_a, "device_id": device}
|
||||
).status_code == 200
|
||||
# 同号连发 3 次:都被单号 60s 冷却挡下 → 429,且**不占**设备额度
|
||||
for _ in range(3):
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/send", json={"phone": phone_a, "device_id": device}
|
||||
)
|
||||
assert r.status_code == 429, r.text
|
||||
# 换号再发:设备额度只用了 1/2(冷却那几次没算)→ 仍放行(计到 2/2)
|
||||
assert client.post(
|
||||
"/api/v1/auth/sms/send", json={"phone": "13710137001", "device_id": device}
|
||||
).status_code == 200
|
||||
# 又换号:此时小时闸已 2/2 → 429(反证成功发码确实各计了 1)
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/send", json={"phone": "13710137002", "device_id": device}
|
||||
)
|
||||
assert r.status_code == 429, r.text
|
||||
|
||||
|
||||
def test_sms_send_daily_cap(client, monkeypatch) -> None:
|
||||
"""每天发码上限(设备 + IP):成功发码累计到日上限即 429(用不同手机号绕开单号冷却)。
|
||||
抬高小时闸单独测日闸;超限文案含「今日」以便前端提示明天再来。"""
|
||||
from app.api.v1 import auth
|
||||
from app.core import ratelimit
|
||||
|
||||
monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
|
||||
monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_HOUR_PER_DEVICE", 100) # 抬高小时闸,不干扰
|
||||
monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_DAY_PER_DEVICE", 3)
|
||||
ratelimit._buckets.clear()
|
||||
|
||||
device = "dev-daily"
|
||||
for i in range(3):
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/send",
|
||||
json={"phone": f"13720137{i:03d}", "device_id": device},
|
||||
)
|
||||
assert r.status_code == 200, f"第 {i + 1} 次应放行: {r.text}"
|
||||
# 第 4 次:同设备同 IP 当日超限 → 429
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/send",
|
||||
json={"phone": "13720137999", "device_id": device},
|
||||
)
|
||||
assert r.status_code == 429, r.text
|
||||
assert "今日" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_sms_login_device_ip_rate_limit(client, monkeypatch) -> None:
|
||||
"""防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试,超出 429。
|
||||
conftest 默认 RATE_LIMIT_ENABLED=false(内存计数跨用例累加),本用例临时打开并清空计数隔离。"""
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""15 天不活跃清零:模型 / 活跃口径 / 清零 / 预警 / 配置 / worker。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, select, update
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.inactivity import InactivityNotificationLog, InactivityResetLog
|
||||
from app.repositories import activity
|
||||
|
||||
|
||||
def test_reset_and_notification_models_persist() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(InactivityResetLog(
|
||||
user_id=1, coin_balance_before=10, cash_balance_cents_before=20,
|
||||
invite_cash_balance_cents_before=30,
|
||||
last_active_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
inactive_days=15, reason="inactive_15d",
|
||||
))
|
||||
db.add(InactivityNotificationLog(
|
||||
user_id=1, stage=7, inactive_days=8, coin_balance=10,
|
||||
cash_balance_cents=20, invite_cash_balance_cents=30,
|
||||
channel="log", status="placeholder",
|
||||
))
|
||||
db.commit()
|
||||
r = db.execute(select(InactivityResetLog).where(InactivityResetLog.user_id == 1)).scalar_one()
|
||||
assert r.reason == "inactive_15d" and r.reset_at is not None
|
||||
n = db.execute(select(InactivityNotificationLog).where(InactivityNotificationLog.user_id == 1)).scalar_one()
|
||||
assert n.stage == 7 and n.created_at is not None
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_reset_cutoff_is_cn_midnight_of_today_minus_days_minus_1() -> None:
|
||||
# RESET_DAYS=15, today=1/20 → cutoff = 北京 00:00 of 1/6 = 1/5 16:00 UTC
|
||||
cutoff = activity.reset_cutoff(15, today=date(2026, 1, 20))
|
||||
assert cutoff == datetime(2026, 1, 5, 16, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_active_event_constants() -> None:
|
||||
assert activity.HOME_VIEW_EVENT in activity.ACTIVE_EVENTS
|
||||
assert "real_compare_start" in activity.ACTIVE_EVENTS
|
||||
assert "real_coupon_start" in activity.ACTIVE_EVENTS
|
||||
assert activity.ACTIVE_ENGAGE_TYPE == "claim_started"
|
||||
|
||||
|
||||
def test_as_utc_normalizes() -> None:
|
||||
assert activity.as_utc(datetime(2026, 1, 1)) == datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
cn = datetime(2026, 1, 1, tzinfo=activity.CN_TZ) # 北京 0 点 = 前一天 16:00 UTC
|
||||
assert activity.as_utc(cn) == datetime(2025, 12, 31, 16, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
from app.core.rewards import CN_TZ
|
||||
from app.models.analytics_event import AnalyticsEvent
|
||||
from app.models.coupon_state import CouponPromptEngagement
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CoinAccount
|
||||
from app.repositories import wallet as wallet_repo
|
||||
|
||||
_PHONE_SEQ = [0]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_inactivity_state():
|
||||
"""本文件的测试都做全表扫描 + 全局计数,而 SQLite 测试库 session 级共享、无逐用例回滚
|
||||
(commit 后的 rollback 是 no-op),故先把可能泄漏的余额清零 + 清掉活跃事件/审计行,
|
||||
保证每个用例干净起步。不删 User(零余额用户不会被扫描选中,避免跨文件/外键影响)。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.execute(update(CoinAccount).values(
|
||||
coin_balance=0, cash_balance_cents=0, invite_cash_balance_cents=0))
|
||||
for model in (AnalyticsEvent, CouponPromptEngagement,
|
||||
InactivityResetLog, InactivityNotificationLog):
|
||||
db.execute(delete(model))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
yield
|
||||
|
||||
|
||||
def _new_user(db, *, created_at, coin=0, cash=0, invite=0) -> int:
|
||||
"""直接建一个 User + CoinAccount,created_at 可控。返回 user_id。"""
|
||||
_PHONE_SEQ[0] += 1
|
||||
u = User(phone=f"139{_PHONE_SEQ[0]:08d}", created_at=created_at,
|
||||
last_login_at=created_at, status="active",
|
||||
username=f"2{_PHONE_SEQ[0]:010d}")
|
||||
db.add(u)
|
||||
db.flush()
|
||||
acc = wallet_repo.get_or_create_account(db, u.id, commit=False)
|
||||
acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents = coin, cash, invite
|
||||
acc.total_coin_earned = coin
|
||||
db.flush()
|
||||
return u.id
|
||||
|
||||
|
||||
def _add_event(db, user_id, event, when: datetime) -> None:
|
||||
db.add(AnalyticsEvent(event=event, device_id="d", user_id=user_id, client_ts=0, created_at=when))
|
||||
|
||||
|
||||
def _add_engage(db, user_id, when: datetime, engage_type="claim_started") -> None:
|
||||
db.add(CouponPromptEngagement(device_id=f"dev{user_id}", package="p", user_id=user_id,
|
||||
engage_date=when.date(), engage_type=engage_type, created_at=when))
|
||||
|
||||
|
||||
def test_last_active_expr_takes_max_of_baseline_and_events() -> None:
|
||||
from sqlalchemy import select
|
||||
db = SessionLocal()
|
||||
try:
|
||||
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
uid = _new_user(db, created_at=base, coin=5)
|
||||
_add_event(db, uid, "real_compare_start", datetime(2026, 1, 10, tzinfo=timezone.utc))
|
||||
db.commit()
|
||||
ev_sub, eng_sub = activity.last_active_subqueries(db)
|
||||
dialect = db.get_bind().dialect.name
|
||||
expr = activity.last_active_expr(User.created_at, ev_sub, eng_sub, dialect)
|
||||
stmt = (select(expr).select_from(User)
|
||||
.outerjoin(ev_sub, ev_sub.c.user_id == User.id)
|
||||
.outerjoin(eng_sub, eng_sub.c.user_id == User.id)
|
||||
.where(User.id == uid))
|
||||
got = activity.norm_utc(db.execute(stmt).scalar_one())
|
||||
assert got == datetime(2026, 1, 10, tzinfo=timezone.utc) # 事件 > 基线
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_inactivity_warn_stages_parsing() -> None:
|
||||
from app.core.config import Settings
|
||||
s = Settings(INACTIVITY_WARN_DAYS_BEFORE="7,2", INACTIVITY_RESET_DAYS=15)
|
||||
assert s.inactivity_warn_stages == [7, 2] # 降序去重
|
||||
s2 = Settings(INACTIVITY_WARN_DAYS_BEFORE="", INACTIVITY_RESET_DAYS=15)
|
||||
assert s2.inactivity_warn_stages == [] # 空=不推
|
||||
s3 = Settings(INACTIVITY_WARN_DAYS_BEFORE="2,20,7,2", INACTIVITY_RESET_DAYS=15)
|
||||
assert s3.inactivity_warn_stages == [7, 2] # 去重 + 丢弃 >=RESET_DAYS(20)
|
||||
|
||||
|
||||
def test_log_notifier_returns_placeholder(caplog) -> None:
|
||||
from app.integrations.notifier import LogNotifier, get_notifier
|
||||
n = get_notifier("log")
|
||||
assert isinstance(n, LogNotifier) and n.channel == "log"
|
||||
status = n.warn(user_id=1, coin=10, cash_cents=20, invite_cash_cents=30, stage=7, days_until_reset=8)
|
||||
assert status == "placeholder"
|
||||
# 未实现通道回退 LogNotifier(占位)
|
||||
assert get_notifier("jpush").channel == "log"
|
||||
|
||||
|
||||
def test_run_reset_clears_three_buckets_and_writes_audit_and_flows() -> None:
|
||||
from sqlalchemy import select
|
||||
from app.models.wallet import CoinAccount, CoinTransaction, CashTransaction, InviteCashTransaction
|
||||
from app.repositories import inactivity
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
today = date(2026, 2, 1)
|
||||
# 末次活跃 = created_at 基线 = 1/10(距 today 22 天 → 应清)
|
||||
old = _new_user(db, created_at=datetime(2026, 1, 10, tzinfo=timezone.utc),
|
||||
coin=100, cash=200, invite=300)
|
||||
# 活跃用户:昨天有 home_view → 不清
|
||||
fresh = _new_user(db, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), coin=50)
|
||||
_add_event(db, fresh, "home_view", datetime(2026, 1, 31, tzinfo=timezone.utc))
|
||||
db.commit()
|
||||
|
||||
stats = inactivity.run_reset_once(db, reset_days=15, today=today)
|
||||
assert stats["cleared"] == 1 and stats["failed"] == 0
|
||||
|
||||
acc = db.get(CoinAccount, old)
|
||||
assert (acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents) == (0, 0, 0)
|
||||
assert acc.total_coin_earned == 100 # 历史累计不动
|
||||
|
||||
log = db.execute(select(InactivityResetLog).where(InactivityResetLog.user_id == old)).scalar_one()
|
||||
assert (log.coin_balance_before, log.cash_balance_cents_before,
|
||||
log.invite_cash_balance_cents_before) == (100, 200, 300)
|
||||
assert log.inactive_days == 22 and log.reason == "inactive_15d"
|
||||
|
||||
ct = db.execute(select(CoinTransaction).where(
|
||||
CoinTransaction.user_id == old, CoinTransaction.biz_type == "inactivity_reset")).scalar_one()
|
||||
assert ct.amount == -100 and ct.balance_after == 0 and ct.ref_id == str(log.id)
|
||||
assert db.execute(select(CashTransaction).where(
|
||||
CashTransaction.user_id == old, CashTransaction.biz_type == "inactivity_reset")).scalar_one().amount_cents == -200
|
||||
assert db.execute(select(InviteCashTransaction).where(
|
||||
InviteCashTransaction.user_id == old, InviteCashTransaction.biz_type == "inactivity_reset")).scalar_one().amount_cents == -300
|
||||
|
||||
# 活跃用户不动;再跑一次幂等(已清零 → 不再匹配)
|
||||
assert db.get(CoinAccount, fresh).coin_balance == 50
|
||||
assert inactivity.run_reset_once(db, reset_days=15, today=today)["cleared"] == 0
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
@@ -0,0 +1,57 @@
|
||||
"""ratelimit 内存桶过期清理(GC)测试。
|
||||
|
||||
回归重点:_buckets 是**全局共享**、混着不同窗口(60s 广告 / 3600s 登录 / 86400s 日闸)的 key。
|
||||
GC 必须按【每个 key 自己存的 window_sec】判过期,而不是当前调用方的窗口 —— 否则高频的 60s 端点
|
||||
触发 GC 时会把本该存活更久的 3600s/86400s 计数(如短信日闸)一并删掉,使其被反复清零、限流失效。
|
||||
用 monkeypatch 把 _GC_THRESHOLD 调 0 强制每次都扫,免造上万条(仿 test_auth 里对 sms._GC_THRESHOLD 的做法)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core import ratelimit
|
||||
|
||||
|
||||
def test_purge_expired_respects_each_key_own_window(monkeypatch) -> None:
|
||||
"""短窗口(60s)触发的 GC 只删真正过期的 key,不得删掉仍在自身窗口内的长窗口 key。"""
|
||||
monkeypatch.setattr(ratelimit, "_GC_THRESHOLD", 0) # 强制每次都扫
|
||||
ratelimit._buckets.clear()
|
||||
|
||||
now = 1_000_000.0
|
||||
# 日闸:100s 前开窗、window=86400 → 远未过期,必须保留
|
||||
ratelimit._buckets["sms-send-device-daily:D:IP"] = (now - 100, 7, 86400.0)
|
||||
# 登录:1800s、window=3600 → 未过期,保留
|
||||
ratelimit._buckets["sms-login-device:D:IP"] = (now - 1800, 2, 3600.0)
|
||||
# 广告:120s、window=60 → 已过期,应删
|
||||
ratelimit._buckets["ad-watch-report:IP2"] = (now - 120, 3, 60.0)
|
||||
|
||||
ratelimit._purge_expired(now)
|
||||
|
||||
assert "sms-send-device-daily:D:IP" in ratelimit._buckets
|
||||
assert "sms-login-device:D:IP" in ratelimit._buckets
|
||||
assert "ad-watch-report:IP2" not in ratelimit._buckets
|
||||
|
||||
|
||||
def test_purge_expired_keeps_long_window_key_older_than_short_window(monkeypatch) -> None:
|
||||
"""反证旧 bug:日闸 key 已老于 3600s,旧代码在 60s/3600s 端点触发 GC 时会误删它;
|
||||
现在按自身 86400s 窗口判 → 未过期 → 必须保留。"""
|
||||
monkeypatch.setattr(ratelimit, "_GC_THRESHOLD", 0)
|
||||
ratelimit._buckets.clear()
|
||||
|
||||
now = 2_000_000.0
|
||||
# 3700s 前开窗(> 1 小时),但 window=86400 → 未过期
|
||||
ratelimit._buckets["sms-send-device-daily:D:IP"] = (now - 3700, 20, 86400.0)
|
||||
|
||||
ratelimit._purge_expired(now)
|
||||
|
||||
assert "sms-send-device-daily:D:IP" in ratelimit._buckets
|
||||
|
||||
|
||||
def test_purge_expired_noop_below_threshold(monkeypatch) -> None:
|
||||
"""未超阈值时不扫(即便有过期 key 也不动),避免每次请求都 O(n) 扫全表。"""
|
||||
monkeypatch.setattr(ratelimit, "_GC_THRESHOLD", 10)
|
||||
ratelimit._buckets.clear()
|
||||
|
||||
now = 3_000_000.0
|
||||
ratelimit._buckets["stale:IP"] = (now - 999, 1, 60.0) # 早过期,但没超阈值
|
||||
ratelimit._purge_expired(now)
|
||||
|
||||
assert "stale:IP" in ratelimit._buckets # 桶数没超阈值 → 不清理
|
||||
Reference in New Issue
Block a user