Compare commits

...

3 Commits

Author SHA1 Message Date
zzhyyyyy b3c3a9e582 精简短信登录风控:保留单号冷却 + 单设备频控两道
按 mentor 要求精简 SMS 登录链路的防刷策略,只保留两道主策略:
- 单号 60s 冷却(发码,SMS_SEND_INTERVAL_SEC)
- 单设备(device_id + IP)每小时 5 次频控(发码 + 登录,enforce_rate_limit)

删除两道:
- 单号每日发送上限(SMS_DAILY_LIMIT_PER_PHONE)
- 登录接口纯 IP 20 次/分钟限流(rate_limit 依赖)

验证码安全底线保留(一次性使用 + 输错 SMS_MAX_VERIFY_ATTEMPTS 次即作废),
属验证码正确性、不算限流策略,不在精简范围。

顺带清理:sms.py 移除 _daily_count/_today 及 datetime 导入;config.py 删常量;
auth.py 清理变为无用的 Depends/rate_limit 导入;同步 docs/integrations/sms.md 与
docs/后端技术实现.md 的防刷说明(并修正 sms.md 里已过时的 /sms/send rate_limit 描述)。

影响:单号发码上限由 10 条/天放宽为仅受 60s 冷却约束;登录撞库防护改由设备频控
+ 验证码错误次数上限兜底。test_auth.py 相应用例已删除/更新,auth 测试全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:55:11 +08:00
chenshirui ee132aa93b 心跳掉线判定超时从10分钟调成1小时 (#107)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------

Co-authored-by: 陈世睿 <2839904623@qq.com>
Reviewed-on: #107
Co-authored-by: chenshirui <chenshirui@wonderable.ai>
Co-committed-by: chenshirui <chenshirui@wonderable.ai>
2026-07-02 22:09:54 +08:00
zhuzihao 4512b6ecac feat: 反馈加来源/场景/运营回复 + 提现类型筛选 (#105)
用户反馈后台:区分反馈类型(比价反馈/普通反馈)+ 审核可给用户留言;提现后台:按提现类型筛选。

- feedback 表加 source(profile/comparison)/scene/admin_reply + 迁移;提交接口 /api/v1/feedback
  接收 source/scene,来源判定显式 source 优先、否则据 scene 有无派生(有=comparison);
  /records 带回 scene + admin_reply。
- admin 反馈:列表加「反馈类型」筛选;采纳/拒绝支持存 admin_reply(给用户的回复,用户端可见);
  FeedbackOut 带出 source/scene/admin_reply。
- admin 提现:WithdrawOut 暴露 source,列表加「提现类型」筛选(coin_cash=福利页提现 / invite_cash=邀请提现)。
- 补 4 项测试:来源派生、回复存取、反馈按类型筛选、提现按类型筛选。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #105
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-02 19:50:14 +08:00
21 changed files with 281 additions and 74 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ JG_REQUEST_TIMEOUT_SEC=15
# ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)===== # ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)=====
HEARTBEAT_MONITOR_ENABLED=true HEARTBEAT_MONITOR_ENABLED=true
HEARTBEAT_TIMEOUT_MINUTES=10 HEARTBEAT_TIMEOUT_MINUTES=60
HEARTBEAT_SCAN_INTERVAL_SEC=60 HEARTBEAT_SCAN_INTERVAL_SEC=60
# ===== 短信 (mock 模式) ===== # ===== 短信 (mock 模式) =====
+48
View File
@@ -0,0 +1,48 @@
"""feedback 加反馈来源/场景/运营回复(source / scene / admin_reply)
admin「用户反馈」页要展示并筛选「反馈类型」(比价反馈 / 普通反馈),并支持审核时给用户留言:
- source: 反馈来源入口(profile=「我的」页 / comparison=比价结果页)。NOT NULL,
旧数据 + 普通反馈默认 profile(server_default)。加索引供 admin 按类型筛选。
- scene: 比价反馈的问题场景(找错商品/优惠不对…),普通反馈为 NULL。
- admin_reply: 运营给用户的回复留言(用户端可见),随「我的反馈」历史下发。
均为新增列(SQLite 原生支持 add_column);downgrade 的 drop_column 在 SQLite 走 batch 兜底。
Revision ID: feedback_type_reply
Revises: 761ef181ce7c
Create Date: 2026-07-02 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "feedback_type_reply"
down_revision: str | Sequence[str] | None = "761ef181ce7c"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"feedback",
sa.Column(
"source",
sa.String(length=16),
nullable=False,
server_default="profile",
),
)
op.add_column("feedback", sa.Column("scene", sa.String(length=32), nullable=True))
op.add_column("feedback", sa.Column("admin_reply", sa.String(length=256), nullable=True))
op.create_index("ix_feedback_source", "feedback", ["source"])
def downgrade() -> None:
with op.batch_alter_table("feedback") as batch_op:
batch_op.drop_index("ix_feedback_source")
batch_op.drop_column("admin_reply")
batch_op.drop_column("scene")
batch_op.drop_column("source")
+4 -1
View File
@@ -65,16 +65,19 @@ def review_feedback(
reward_coins: int | None = None, reward_coins: int | None = None,
reject_reason: str | None = None, reject_reason: str | None = None,
review_note: str | None = None, review_note: str | None = None,
admin_reply: str | None = None,
commit: bool = True, commit: bool = True,
) -> Feedback: ) -> Feedback:
"""审核反馈:置 adopted/rejected + 记录奖励/原因/审核人/审核时间。 """审核反馈:置 adopted/rejected + 记录奖励/原因/回复留言/审核人/审核时间。
发金币(wallet.grant_coins)由 router 在同一事务里调,确保状态、金币流水、审计一起提交。 发金币(wallet.grant_coins)由 router 在同一事务里调,确保状态、金币流水、审计一起提交。
admin_reply=给用户的回复留言(用户端可见),与 review_note(内部备注)区分。
""" """
feedback.status = status feedback.status = status
feedback.reward_coins = reward_coins feedback.reward_coins = reward_coins
feedback.reject_reason = reject_reason feedback.reject_reason = reject_reason
feedback.review_note = review_note feedback.review_note = review_note
feedback.admin_reply = admin_reply
feedback.reviewed_by_admin_id = reviewed_by_admin_id feedback.reviewed_by_admin_id = reviewed_by_admin_id
feedback.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None) feedback.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None)
if commit: if commit:
+11 -4
View File
@@ -390,6 +390,7 @@ def list_all_withdraw_orders(
*, *,
user_id: int | None = None, user_id: int | None = None,
status: str | None = None, status: str | None = None,
source: str | None = None,
keyword: str | None = None, keyword: str | None = None,
date_from: datetime | None = None, date_from: datetime | None = None,
date_to: datetime | None = None, date_to: datetime | None = None,
@@ -409,6 +410,9 @@ def list_all_withdraw_orders(
stmt = stmt.where(WithdrawOrder.user_id == user_id) stmt = stmt.where(WithdrawOrder.user_id == user_id)
if status: if status:
stmt = stmt.where(WithdrawOrder.status == status) stmt = stmt.where(WithdrawOrder.status == status)
# 提现类型:coin_cash(福利页提现)/ invite_cash(邀请提现);None=全部
if source:
stmt = stmt.where(WithdrawOrder.source == source)
kw = (keyword or "").strip() kw = (keyword or "").strip()
if kw: if kw:
@@ -534,6 +538,7 @@ def list_feedbacks(
db: Session, db: Session,
*, *,
status: str | None = None, status: str | None = None,
source: str | None = None,
user_id: int | None = None, user_id: int | None = None,
content: str | None = None, content: str | None = None,
created_from: datetime | None = None, created_from: datetime | None = None,
@@ -543,13 +548,15 @@ def list_feedbacks(
limit: int = 20, limit: int = 20,
cursor: int | None = None, cursor: int | None = None,
) -> tuple[list[Feedback], int | None, int]: ) -> tuple[list[Feedback], int | None, int]:
"""反馈工单列表。支持 状态 / 用户ID / 内容模糊 / 提交时间范围 筛选,按 id·提交时间排序。 """反馈工单列表。支持 状态 / 反馈类型(source) / 用户ID / 内容模糊 / 提交时间范围 筛选,
**offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),代价是翻页期间 按 id·提交时间排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),
数据变动可能错位一条——admin 低频场景可接受。返回 (items, next_cursor, total),total 供页码分页。 代价是翻页期间数据变动可能错位一条——admin 低频场景可接受。返回 (items, next_cursor, total),
created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。""" total 供页码分页。created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。"""
stmt = select(Feedback) stmt = select(Feedback)
if status: if status:
stmt = stmt.where(Feedback.status == status) stmt = stmt.where(Feedback.status == status)
if source:
stmt = stmt.where(Feedback.source == source)
if user_id is not None: if user_id is not None:
stmt = stmt.where(Feedback.user_id == user_id) stmt = stmt.where(Feedback.user_id == user_id)
if content and content.strip(): if content and content.strip():
+7
View File
@@ -36,6 +36,8 @@ def _ensure_pending(fb: Feedback) -> None:
def list_feedbacks( def list_feedbacks(
db: AdminDb, db: AdminDb,
status: Annotated[str | None, Query()] = None, status: Annotated[str | None, Query()] = None,
# 反馈类型筛选:profile(普通反馈)/ comparison(比价反馈);None=全部
source: Annotated[str | None, Query(pattern="^(profile|comparison)$")] = None,
user_id: Annotated[int | None, Query()] = None, user_id: Annotated[int | None, Query()] = None,
content: Annotated[str | None, Query(max_length=100)] = None, content: Annotated[str | None, Query(max_length=100)] = None,
created_from: Annotated[datetime | None, Query()] = None, created_from: Annotated[datetime | None, Query()] = None,
@@ -48,6 +50,7 @@ def list_feedbacks(
items, next_cursor, total = queries.list_feedbacks( items, next_cursor, total = queries.list_feedbacks(
db, db,
status=status, status=status,
source=source,
user_id=user_id, user_id=user_id,
content=content, content=content,
created_from=created_from, created_from=created_from,
@@ -101,6 +104,7 @@ def approve_feedback(
status="adopted", status="adopted",
reward_coins=payload.reward_coins, reward_coins=payload.reward_coins,
review_note=payload.note, review_note=payload.note,
admin_reply=payload.reply,
reviewed_by_admin_id=admin.id, reviewed_by_admin_id=admin.id,
commit=False, commit=False,
) )
@@ -123,6 +127,7 @@ def approve_feedback(
"after": "adopted", "after": "adopted",
"reward_coins": payload.reward_coins, "reward_coins": payload.reward_coins,
"note": payload.note, "note": payload.note,
"reply": payload.reply,
}, },
ip=get_client_ip(request), ip=get_client_ip(request),
commit=False, commit=False,
@@ -152,6 +157,7 @@ def reject_feedback(
status="rejected", status="rejected",
reject_reason=payload.reason, reject_reason=payload.reason,
review_note=payload.note, review_note=payload.note,
admin_reply=payload.reply,
reviewed_by_admin_id=admin.id, reviewed_by_admin_id=admin.id,
commit=False, commit=False,
) )
@@ -166,6 +172,7 @@ def reject_feedback(
"after": "rejected", "after": "rejected",
"reason": payload.reason, "reason": payload.reason,
"note": payload.note, "note": payload.note,
"reply": payload.reply,
}, },
ip=get_client_ip(request), ip=get_client_ip(request),
commit=False, commit=False,
+3
View File
@@ -50,6 +50,8 @@ def list_withdraws(
db: AdminDb, db: AdminDb,
user_id: Annotated[int | None, Query()] = None, user_id: Annotated[int | None, Query()] = None,
status: Annotated[str | None, Query()] = None, status: Annotated[str | None, Query()] = None,
# 提现类型筛选:coin_cash(福利页提现)/ invite_cash(邀请提现);None=全部
source: Annotated[str | None, Query(pattern="^(coin_cash|invite_cash)$")] = None,
keyword: Annotated[str | None, Query(max_length=100)] = None, keyword: Annotated[str | None, Query(max_length=100)] = None,
date_from: Annotated[datetime | None, Query()] = None, date_from: Annotated[datetime | None, Query()] = None,
date_to: Annotated[datetime | None, Query()] = None, date_to: Annotated[datetime | None, Query()] = None,
@@ -69,6 +71,7 @@ def list_withdraws(
db, db,
user_id=user_id, user_id=user_id,
status=status, status=status,
source=source,
keyword=keyword, keyword=keyword,
date_from=date_from, date_from=date_from,
date_to=date_to, date_to=date_to,
+9 -1
View File
@@ -15,11 +15,17 @@ class FeedbackOut(BaseModel):
user_id: int user_id: int
content: str content: str
contact: str contact: str
# 反馈来源入口:profile(「我的」页)/ comparison(比价结果页)——admin 展示/筛选「反馈类型」
source: str = "profile"
# 比价反馈的问题场景(找错商品/优惠不对…);普通反馈为 None
scene: str | None = None
images: list[str] | None = None images: list[str] | None = None
status: str status: str
reject_reason: str | None = None reject_reason: str | None = None
reward_coins: int | None = None reward_coins: int | None = None
review_note: str | None = None review_note: str | None = None
# 运营给用户的回复留言(用户端可见,采纳/未采纳都可填)
admin_reply: str | None = None
reviewed_by_admin_id: int | None = None reviewed_by_admin_id: int | None = None
reviewed_at: datetime | None = None reviewed_at: datetime | None = None
created_at: datetime created_at: datetime
@@ -39,12 +45,14 @@ class FeedbackApproveRequest(BaseModel):
le=FEEDBACK_REWARD_MAX_COINS, le=FEEDBACK_REWARD_MAX_COINS,
description="采纳后发放金币数", description="采纳后发放金币数",
) )
note: str | None = Field(default=None, max_length=256, description="采纳要点/审核备注") note: str | None = Field(default=None, max_length=256, description="采纳要点/审核备注(内部)")
reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见")
class FeedbackRejectRequest(BaseModel): class FeedbackRejectRequest(BaseModel):
reason: str = Field(min_length=1, max_length=256, description="未采纳原因,用户端可见") reason: str = Field(min_length=1, max_length=256, description="未采纳原因,用户端可见")
note: str | None = Field(default=None, max_length=256, description="运营内部审核备注") note: str | None = Field(default=None, max_length=256, description="运营内部审核备注")
reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见")
class FeedbackSummary(BaseModel): class FeedbackSummary(BaseModel):
+2
View File
@@ -41,6 +41,8 @@ class WithdrawOrderOut(BaseModel):
user_id: int user_id: int
out_bill_no: str out_bill_no: str
amount_cents: int amount_cents: int
# 提现类型:coin_cash(福利页提现,金币兑换的现金)/ invite_cash(邀请提现,邀请奖励金)
source: str = "coin_cash"
user_name: str | None = None # 提现实名(审核核对 + 打款用) user_name: str | None = None # 提现实名(审核核对 + 打款用)
status: str status: str
wechat_state: str | None = None wechat_state: str | None = None
+6 -7
View File
@@ -12,11 +12,11 @@ from __future__ import annotations
import logging 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.api.deps import CurrentUser, DbSession
from app.core import test_account 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.core.security import TokenError, decode_token, issue_token_pair
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
from app.integrations.sms import SmsError, send_code, verify_code from app.integrations.sms import SmsError, send_code, verify_code
@@ -41,7 +41,7 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
# 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。 # 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。
SMS_LOGIN_MAX_PER_HOUR = 5 SMS_LOGIN_MAX_PER_HOUR = 5
# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。 # 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。
# 堵「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞 —— 那两道是单号维度,一机换号能绕开。 # 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5 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) return SmsSendResponse(sent=True, mock=True, cooldown_sec=0)
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码。 # 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码。
# 补「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞(那两道是单号维度,一机换号能绕);设备维度按机器封顶, # 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,
# 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。与路由上 IP 维度(10次/分钟)互补。 # 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。
enforce_rate_limit( enforce_rate_limit(
request, request,
scope="sms-send-device", scope="sms-send-device",
@@ -125,7 +125,6 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
"/sms/login", "/sms/login",
response_model=TokenWithUser, response_model=TokenWithUser,
summary="手机号+验证码登录", summary="手机号+验证码登录",
dependencies=[Depends(rate_limit(20, 60, "sms-login"))], # 防撞库爆破(另有单码失败次数上限)
) )
def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWithUser: def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWithUser:
# 测试账号:免验证码登录 + 每日上限 + 每次都走新手引导(详见 app/core/test_account.py)。 # 测试账号:免验证码登录 + 每日上限 + 每次都走新手引导(详见 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) return _login_response(user, onboarding_completed=False, force_onboarding=True)
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试。放在验证码校验 # 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试。放在验证码校验
# **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破。与路由上 IP 维度的 sms-login 限流(同 IP)互补 # **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破(另有单码失败 SMS_MAX_VERIFY_ATTEMPTS 次即作废兜底)
# ⚠️ 按设备而非手机号 → 一台机器换不同手机号刷登录也受限(防一机狂登多号);device_id 空(老客户端)时 # ⚠️ 按设备而非手机号 → 一台机器换不同手机号刷登录也受限(防一机狂登多号);device_id 空(老客户端)时
# 退化为该 IP 下所有空设备聚一桶,仍受限。 # 退化为该 IP 下所有空设备聚一桶,仍受限。
enforce_rate_limit( enforce_rate_limit(
+24 -1
View File
@@ -31,7 +31,18 @@ router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
_MAX_IMAGES = 6 _MAX_IMAGES = 6
_CONTENT_MAX = 200 _CONTENT_MAX = 200
_CONTACT_MAX = 128 _CONTACT_MAX = 128
_SCENE_MAX = 32
_VALID_RECORD_STATUS = {"pending", "adopted", "rejected"} _VALID_RECORD_STATUS = {"pending", "adopted", "rejected"}
_VALID_SOURCES = {"profile", "comparison"}
def _resolve_source(source: str, scene: str) -> str:
"""反馈来源:客户端显式传的 source 优先(profile / comparison);未传或非法时,
scene 有无派生比价结果页反馈才带 scene, scene 非空即视作 comparison"""
src = source.strip().lower()
if src in _VALID_SOURCES:
return src
return "comparison" if scene.strip() else "profile"
def _app_status(db_status: str) -> str: def _app_status(db_status: str) -> str:
@@ -46,10 +57,12 @@ def _record_out(fb) -> FeedbackRecordOut:
return FeedbackRecordOut( return FeedbackRecordOut(
id=fb.id, id=fb.id,
content=fb.content, content=fb.content,
scene=getattr(fb, "scene", None),
images=fb.images or [], images=fb.images or [],
status=_app_status(fb.status), status=_app_status(fb.status),
reject_reason=getattr(fb, "reject_reason", None), reject_reason=getattr(fb, "reject_reason", None),
reward_coins=getattr(fb, "reward_coins", None), reward_coins=getattr(fb, "reward_coins", None),
admin_reply=getattr(fb, "admin_reply", None),
created_at=fb.created_at, created_at=fb.created_at,
) )
@@ -61,6 +74,11 @@ async def submit_feedback(
content: str = Form(...), content: str = Form(...),
# 原型改版后客户端不再采集联系方式;保留字段以兼容旧端 + 后续可能复用,默认空串。 # 原型改版后客户端不再采集联系方式;保留字段以兼容旧端 + 后续可能复用,默认空串。
contact: str = Form(default=""), contact: str = Form(default=""),
# 反馈来源入口:profile(「我的」页)/ comparison(比价结果页)。新端显式传;旧端不带 →
# 空串 → 按 scene 有无派生。admin 据此展示/筛选「反馈类型」。
source: str = Form(default=""),
# 比价结果页反馈的「问题场景」(找错商品/优惠不对…);普通反馈不带 → 空串 → 存 NULL。
scene: str = Form(default=""),
# 提交端环境快照(admin 反馈页展示「提交版本号」「机型OS版本」);旧端不带 → 空串 → 存 NULL。 # 提交端环境快照(admin 反馈页展示「提交版本号」「机型OS版本」);旧端不带 → 空串 → 存 NULL。
app_version: str = Form(default=""), app_version: str = Form(default=""),
device_model: str = Form(default=""), device_model: str = Form(default=""),
@@ -76,6 +94,8 @@ async def submit_feedback(
raise HTTPException(status_code=400, detail="反馈内容过长") raise HTTPException(status_code=400, detail="反馈内容过长")
if len(contact) > _CONTACT_MAX: if len(contact) > _CONTACT_MAX:
raise HTTPException(status_code=400, detail="联系方式过长") raise HTTPException(status_code=400, detail="联系方式过长")
scene = scene.strip()[:_SCENE_MAX]
resolved_source = _resolve_source(source, scene)
files = [f for f in (images or []) if f is not None and f.filename] files = [f for f in (images or []) if f is not None and f.filename]
if len(files) > _MAX_IMAGES: if len(files) > _MAX_IMAGES:
@@ -91,10 +111,13 @@ async def submit_feedback(
fb = feedback_repo.create_feedback( fb = feedback_repo.create_feedback(
db, user_id=user.id, content=content, contact=contact, images=urls, db, user_id=user.id, content=content, contact=contact, images=urls,
source=resolved_source, scene=scene or None,
app_version=app_version.strip(), device_model=device_model.strip(), app_version=app_version.strip(), device_model=device_model.strip(),
rom_name=rom_name.strip(), android_version=android_version.strip(), rom_name=rom_name.strip(), android_version=android_version.strip(),
) )
logger.info("feedback id=%d user_id=%d images=%d", fb.id, user.id, len(urls)) logger.info(
"feedback id=%d user_id=%d source=%s images=%d", fb.id, user.id, resolved_source, len(urls)
)
return FeedbackOut.model_validate(fb) return FeedbackOut.model_validate(fb)
+1 -2
View File
@@ -68,7 +68,7 @@ class Settings(BaseSettings):
# 无障碍保护存活监控后台任务(pull 后置检测;本期不接推送) # 无障碍保护存活监控后台任务(pull 后置检测;本期不接推送)
HEARTBEAT_MONITOR_ENABLED: bool = True # 总开关 HEARTBEAT_MONITOR_ENABLED: bool = True # 总开关
HEARTBEAT_TIMEOUT_MINUTES: int = 10 # 多久没心跳算掉线(≈3 个客户端心跳周期) HEARTBEAT_TIMEOUT_MINUTES: int = 60 # 多久没心跳算掉线(1 小时,避免短暂离线误判被杀)
HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期 HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期
# ===== 短信 ===== # ===== 短信 =====
@@ -81,7 +81,6 @@ class Settings(BaseSettings):
SMS_SIGN_ID: int = 31729 # 极光短信签名 ID(非机密,可被 .env 覆盖) SMS_SIGN_ID: int = 31729 # 极光短信签名 ID(非机密,可被 .env 覆盖)
SMS_TEMPLATE_ID: int = 1 # 极光短信模板 ID(变量名 code,有效期 5 分钟) SMS_TEMPLATE_ID: int = 1 # 极光短信模板 ID(变量名 code,有效期 5 分钟)
SMS_CODE_LENGTH: int = 6 # 验证码位数(本服务生成;前端 code 字段 4-8 位兼容) SMS_CODE_LENGTH: int = 6 # 验证码位数(本服务生成;前端 code 字段 4-8 位兼容)
SMS_DAILY_LIMIT_PER_PHONE: int = 10 # 单手机号每日发送上限(防刷 + 控费)
SMS_MAX_VERIFY_ATTEMPTS: int = 5 # 单个验证码最多校验失败次数,超过即作废(防爆破) SMS_MAX_VERIFY_ATTEMPTS: int = 5 # 单个验证码最多校验失败次数,超过即作废(防爆破)
# ===== 测试账号(release 包全流程联调用)===== # ===== 测试账号(release 包全流程联调用)=====
+8 -25
View File
@@ -8,15 +8,16 @@
校验) 鉴权复用极光一键登录的 `JG_APP_KEY`/`JG_MASTER_SECRET`(同一极光应用) 校验) 鉴权复用极光一键登录的 `JG_APP_KEY`/`JG_MASTER_SECRET`(同一极光应用)
验证码存储:**进程内存**( worker uvicorn 够用)重启丢失(用户重发即可) 验证码存储:**进程内存**( worker uvicorn 够用)重启丢失(用户重发即可)
worker / 多机时内存不共享 冷却每日上限校验都会失效,届时迁移到 DB/Redis worker / 多机时内存不共享 冷却校验都会失效,届时迁移到 DB/Redis
docs/待办与技术债.md docs/待办与技术债.md
防刷(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权): 防刷(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件) 1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)
2. 号每日 `SMS_DAILY_LIMIT_PER_PHONE` 条上限(本文件) 2. 设备(device_id)每小时频控(api auth.sms_send enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)
3. 单设备(device_id)每小时频控(api auth.sms_send enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)
IP 频控(rate_limit 依赖)2026-06-26 按产品要求删除改设备维度; device_id 客户端可伪造/轮换, IP 频控(rate_limit 依赖)2026-06-26 按产品要求删除改设备维度; device_id 客户端可伪造/轮换,
脚本轮换 id 能绕过本层 挡脚本狂发主要靠极光控制台侧(+ 可选 nginx 限流) 脚本轮换 id 能绕过本层 挡脚本狂发主要靠极光控制台侧(+ 可选 nginx 限流)
单号每日上限2026-07-03 按精简要求删除(mentor :登录风控只留单号冷却 + 单设备频控);
单号维度现仅剩 60s 冷却,换号轰炸由单设备频控封顶
:单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废(防爆破),验过即作废(一次性) :单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废(防爆破),验过即作废(一次性)
""" """
from __future__ import annotations from __future__ import annotations
@@ -26,7 +27,6 @@ import logging
import secrets import secrets
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from threading import Lock from threading import Lock
import httpx import httpx
@@ -56,22 +56,17 @@ class _CodeRecord:
# 进程内存(单 worker 有效;多 worker 不共享,见模块 docstring) # 进程内存(单 worker 有效;多 worker 不共享,见模块 docstring)
_codes: dict[str, _CodeRecord] = {} # phone -> 当前有效验证码 _codes: dict[str, _CodeRecord] = {} # phone -> 当前有效验证码
_last_sent: dict[str, float] = {} # phone -> 上次发送 epoch(冷却) _last_sent: dict[str, float] = {} # phone -> 上次发送 epoch(冷却)
_daily_count: dict[str, tuple[str, int]] = {} # phone -> (date_str, 当日发送数)
_lock = Lock() _lock = Lock()
_GC_THRESHOLD = 10000 # 任一内存 dict 超此阈值,send 时顺手清过期项(防无限增长,仿 ratelimit) _GC_THRESHOLD = 10000 # 任一内存 dict 超此阈值,send 时顺手清过期项(防无限增长,仿 ratelimit)
def _today() -> str:
return datetime.now().strftime("%Y-%m-%d")
def _gen_code() -> str: def _gen_code() -> str:
"""生成 N 位数字验证码(用 secrets 而非 random;允许前导 0)。""" """生成 N 位数字验证码(用 secrets 而非 random;允许前导 0)。"""
return "".join(secrets.choice("0123456789") for _ in range(settings.SMS_CODE_LENGTH)) return "".join(secrets.choice("0123456789") for _ in range(settings.SMS_CODE_LENGTH))
def _gc(now: float) -> None: def _gc(now: float) -> None:
"""顺手清理过期内存项,防个 dict 无限增长。仅在持锁时调用,且某 dict 超 """顺手清理过期内存项,防个 dict 无限增长。仅在持锁时调用,且某 dict 超
_GC_THRESHOLD 才扫它(低频,开销可忽略)""" _GC_THRESHOLD 才扫它(低频,开销可忽略)"""
if len(_codes) > _GC_THRESHOLD: if len(_codes) > _GC_THRESHOLD:
for p in [p for p, r in _codes.items() if now > r.expires_at]: 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 cutoff = now - settings.SMS_SEND_INTERVAL_SEC
for p in [p for p, ts in _last_sent.items() if ts < cutoff]: for p in [p for p, ts in _last_sent.items() if ts < cutoff]:
_last_sent.pop(p, None) _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: def send_code(phone: str) -> int:
@@ -102,17 +93,9 @@ def send_code(phone: str) -> int:
remain = int(settings.SMS_SEND_INTERVAL_SEC - elapsed) remain = int(settings.SMS_SEND_INTERVAL_SEC - elapsed)
raise SmsError(f"发送过于频繁,请 {remain}s 后再试") 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() code = _gen_code()
# 预占:先记冷却/计数/存码,释放锁后再发网络(发失败保留冷却+计数,见下) # 预占:先记冷却/存码,释放锁后再发网络(发失败保留冷却,见下)
_last_sent[phone] = now _last_sent[phone] = now
_daily_count[phone] = (today, cnt + 1)
_codes[phone] = _CodeRecord(code=code, expires_at=now + settings.SMS_CODE_TTL_SEC) _codes[phone] = _CodeRecord(code=code, expires_at=now + settings.SMS_CODE_TTL_SEC)
# --- lock 外:真正发送(网络 IO 不持锁)--- # --- lock 外:真正发送(网络 IO 不持锁)---
@@ -123,7 +106,7 @@ def send_code(phone: str) -> int:
_send_via_jiguang(phone, code) _send_via_jiguang(phone, code)
logger.info("[SMS] sent to %s****", phone[:3]) logger.info("[SMS] sent to %s****", phone[:3])
except Exception as e: except Exception as e:
# 发送失败:**保留冷却 + 每日计数**(失败也限速,挡住余额不足/签名失效时 # 发送失败:**保留冷却**(失败也限速,挡住余额不足/签名失效时
# 前端重试狂打极光),只清掉没发出去的码(用户收不到,留着无意义且占内存)。 # 前端重试狂打极光),只清掉没发出去的码(用户收不到,留着无意义且占内存)。
with _lock: with _lock:
_codes.pop(phone, None) _codes.pop(phone, None)
+11
View File
@@ -23,6 +23,14 @@ class Feedback(Base):
) )
content: Mapped[str] = mapped_column(Text, nullable=False) content: Mapped[str] = mapped_column(Text, nullable=False)
contact: Mapped[str] = mapped_column(String(128), nullable=False) contact: Mapped[str] = mapped_column(String(128), nullable=False)
# 反馈来源入口:profile(「我的」页反馈入口)/ comparison(比价结果页反馈入口)。
# admin 据此展示/筛选「反馈类型」。旧数据与「我的」页反馈均为 profile(server_default)。
# 客户端显式传 source 优先;未传时由 scene 有无派生(见 api/v1/feedback.submit_feedback)。
source: Mapped[str] = mapped_column(
String(16), nullable=False, default="profile", server_default="profile", index=True
)
# 比价反馈的「问题场景」(找错商品/优惠不对/比价太慢…),比价结果页反馈才有;普通反馈为 NULL。
scene: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 截图 URL 列表(相对路径,如 ["/media/feedback/u1_ab12.jpg"]);无图为 None # 截图 URL 列表(相对路径,如 ["/media/feedback/u1_ab12.jpg"]);无图为 None
images: Mapped[list[str] | None] = mapped_column(JSON, nullable=True) images: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
# 提交时的端环境快照(admin 排查用;客户端改版带上后的新反馈才有,历史数据为 NULL) # 提交时的端环境快照(admin 排查用;客户端改版带上后的新反馈才有,历史数据为 NULL)
@@ -36,6 +44,9 @@ class Feedback(Base):
reward_coins: Mapped[int | None] = mapped_column(Integer, nullable=True) reward_coins: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 审核批注:采纳时可写采纳要点,未采纳时也可保留运营侧备注 # 审核批注:采纳时可写采纳要点,未采纳时也可保留运营侧备注
review_note: Mapped[str | None] = mapped_column(String(256), nullable=True) review_note: Mapped[str | None] = mapped_column(String(256), nullable=True)
# 运营给用户的回复留言(**用户端可见**,采纳/未采纳都可填,随「我的反馈」历史下发)。
# 与 review_note(内部备注,用户不可见)、reject_reason(未采纳原因)区分开。
admin_reply: Mapped[str | None] = mapped_column(String(256), nullable=True)
reviewed_by_admin_id: Mapped[int | None] = mapped_column( reviewed_by_admin_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("admin_user.id"), nullable=True Integer, ForeignKey("admin_user.id"), nullable=True
) )
+4
View File
@@ -17,6 +17,8 @@ def create_feedback(
content: str, content: str,
contact: str, contact: str,
images: list[str] | None, images: list[str] | None,
source: str = "profile",
scene: str | None = None,
app_version: str | None = None, app_version: str | None = None,
device_model: str | None = None, device_model: str | None = None,
rom_name: str | None = None, rom_name: str | None = None,
@@ -26,6 +28,8 @@ def create_feedback(
user_id=user_id, user_id=user_id,
content=content, content=content,
contact=contact, contact=contact,
source=source,
scene=scene or None,
images=images or None, images=images or None,
app_version=app_version or None, app_version=app_version or None,
device_model=device_model or None, device_model=device_model or None,
+4
View File
@@ -30,10 +30,14 @@ class FeedbackQrConfigOut(BaseModel):
class FeedbackRecordOut(BaseModel): class FeedbackRecordOut(BaseModel):
id: int id: int
content: str content: str
# 比价反馈的问题场景(找错商品/优惠不对…);普通反馈为 None
scene: str | None = None
images: list[str] = Field(default_factory=list) images: list[str] = Field(default_factory=list)
status: str status: str
reject_reason: str | None = None reject_reason: str | None = None
reward_coins: int | None = None reward_coins: int | None = None
# 运营给用户的回复留言(用户端可见,采纳/未采纳都可能有)
admin_reply: str | None = None
created_at: datetime created_at: datetime
+8 -9
View File
@@ -16,9 +16,9 @@
## 函数 / 异常 ## 函数 / 异常
| 函数 | 行为 | | 函数 | 行为 |
|---|---| |---|---|
| `send_code(phone) -> int` | 防刷检查(冷却 + 每日上限)→ `secrets` 生成 N 位码 → **预占**(冷却/计数/存码)→ mock 打日志 / real 调极光 → 失败**回滚预占**。返回距下次可发秒数 | | `send_code(phone) -> int` | 防刷检查(冷却)→ `secrets` 生成 N 位码 → **预占**(冷却/存码)→ mock 打日志 / real 调极光 → 失败**回滚预占**。返回距下次可发秒数 |
| `verify_code(phone, code) -> bool` | mock 放行任意 6 位;real 比对存码,匹配即作废,失败累计到上限作废 | | `verify_code(phone, code) -> bool` | mock 放行任意 6 位;real 比对存码,匹配即作废,失败累计到上限作废 |
| `SmsError(msg, status_code)` | `status_code` 决定 HTTP 码:过频/每日超限 **429**、供应商失败 **503**、号码无效 **400** | | `SmsError(msg, status_code)` | `status_code` 决定 HTTP 码:过频 **429**、供应商失败 **503**、号码无效 **400** |
## 配置 ## 配置
| 配置项 | 默认 | 说明 | | 配置项 | 默认 | 说明 |
@@ -30,17 +30,16 @@
| `SMS_SIGN_ID` | 31729 | 极光签名 ID | | `SMS_SIGN_ID` | 31729 | 极光签名 ID |
| `SMS_TEMPLATE_ID` | 1 | 极光模板 ID(变量名 `code`) | | `SMS_TEMPLATE_ID` | 1 | 极光模板 ID(变量名 `code`) |
| `SMS_CODE_LENGTH` | 6 | 验证码位数(本服务生成;前端 code 4-8 位兼容) | | `SMS_CODE_LENGTH` | 6 | 验证码位数(本服务生成;前端 code 4-8 位兼容) |
| `SMS_DAILY_LIMIT_PER_PHONE` | 10 | 单号每日发送上限(防刷 + 控费) |
| `SMS_MAX_VERIFY_ATTEMPTS` | 5 | 单码最多校验失败次数,超过作废(防爆破) | | `SMS_MAX_VERIFY_ATTEMPTS` | 5 | 单码最多校验失败次数,超过作废(防爆破) |
> **鉴权复用极光一键登录**:`/v1/messages``base64(JG_APP_KEY:JG_MASTER_SECRET)` 做 HTTP Basic Auth——短信与一键登录是**同一个极光应用**(同 AppKey)。**上线不需要额外凭证,只需 `SMS_MOCK=false`**(`JG_*` 一键登录已配)。 > **鉴权复用极光一键登录**:`/v1/messages``base64(JG_APP_KEY:JG_MASTER_SECRET)` 做 HTTP Basic Auth——短信与一键登录是**同一个极光应用**(同 AppKey)。**上线不需要额外凭证,只需 `SMS_MOCK=false`**(`JG_*` 一键登录已配)。
## 防刷(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权) ## 防刷(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权)
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却 > 2026-07-03 精简:登录风控只留「单号冷却 + 单设备频控」两道主策略(删单号每日上限、删登录纯 IP `rate_limit`);单码失败上限属验证码安全底线,保留。
2. 单号每日 `SMS_DAILY_LIMIT_PER_PHONE` 条上限 1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(单号维度)
3. 单 IP 频控:`/sms/send` `rate_limit(10,60)``/sms/login``rate_limit(20,60)` 2. 单设备(`device_id` + IP)每小时频控:`/sms/send` `SMS_SEND_MAX_PER_HOUR_PER_DEVICE`(5)、`/sms/login``SMS_LOGIN_MAX_PER_HOUR`(5)——堵「换号绕开单号冷却」+ 挡登录撞库,在 `app/api/v1/auth.py``enforce_rate_limit`
4. 单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废 3. 单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废 + 验过即作废(一次性)
5. 运维侧建议在极光控制台叠加:**IP 白名单**(只许服务器 IP)+ **防轰炸设置** 4. 运维侧建议在极光控制台叠加:**IP 白名单**(只许服务器 IP)+ **防轰炸设置**
## 极光错误码(节选,映射在 `_send_via_jiguang`) ## 极光错误码(节选,映射在 `_send_via_jiguang`)
| code | 含义 | 处理 | | code | 含义 | 处理 |
@@ -56,4 +55,4 @@
3. 真机发一条验证:收到短信 + 能登录 3. 真机发一条验证:收到短信 + 能登录
## 已知局限 ## 已知局限
**验证码存进程内存**:单 worker uvicorn 够用;重启丢码(用户重发即可);**多 worker / 多机不共享 → 冷却 / 每日上限 / 校验失效**,扩 worker 前迁移到 DB/Redis。见 [待办与技术债](../guides/待办与技术债.md)。 **验证码存进程内存**:单 worker uvicorn 够用;重启丢码(用户重发即可);**多 worker / 多机不共享 → 冷却 / 校验失效**,扩 worker 前迁移到 DB/Redis。见 [待办与技术债](../guides/待办与技术债.md)。
+1 -1
View File
@@ -169,7 +169,7 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
短信冷却表存进程内存(`--workers 1` 下够用,多 worker/重启即失效)。 短信冷却表存进程内存(`--workers 1` 下够用,多 worker/重启即失效)。
**real 模式(`SMS_MOCK=false`,生产)**:自定义验证码——本服务 `secrets` 生成 6 位码 → 极光 `/v1/messages` 只负责发 → 本地校验(一次性 / 防爆破),鉴权**复用 `JG_APP_KEY`/`JG_MASTER_SECRET`**(短信与一键登录同一极光应用,**上线只需 `SMS_MOCK=false`**)。防刷四层(单号冷却 + 单号每日上限 + 单 IP `rate_limit` + 单码失败次数)+ 错误码 429/503/400。详见 [integrations/sms](./integrations/sms.md)。 **real 模式(`SMS_MOCK=false`,生产)**:自定义验证码——本服务 `secrets` 生成 6 位码 → 极光 `/v1/messages` 只负责发 → 本地校验(一次性 / 防爆破),鉴权**复用 `JG_APP_KEY`/`JG_MASTER_SECRET`**(短信与一键登录同一极光应用,**上线只需 `SMS_MOCK=false`**)。防刷(单号冷却 + 单设备频控 + 单码失败次数;2026-07-03 精简删单号每日上限与登录纯 IP 限流)+ 错误码 429/503/400。详见 [integrations/sms](./integrations/sms.md)。
### 4.3 Token 与刷新 ### 4.3 Token 与刷新
+61
View File
@@ -190,6 +190,67 @@ def test_feedback_list(admin_client: TestClient, admin_token: str) -> None:
assert all(f["status"] == "pending" for f in r.json()["items"]) assert all(f["status"] == "pending" for f in r.json()["items"])
def test_feedback_list_filter_by_source(admin_client: TestClient, admin_token: str) -> None:
"""反馈列表按反馈类型(source)筛选;返回项带 source/scene 字段。"""
db = SessionLocal()
try:
uid = user_repo.upsert_user_for_login(db, phone="13800009001", register_channel="sms").id
db.add(Feedback(user_id=uid, content="比价反馈", contact="", status="pending",
source="comparison", scene="找错商品"))
db.add(Feedback(user_id=uid, content="普通反馈", contact="", status="pending",
source="profile"))
db.commit()
finally:
db.close()
r = admin_client.get(
"/admin/api/feedbacks", params={"user_id": uid, "source": "comparison"},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
items = r.json()["items"]
assert items and all(it["source"] == "comparison" for it in items)
assert any(it["scene"] == "找错商品" for it in items)
r = admin_client.get(
"/admin/api/feedbacks", params={"user_id": uid, "source": "profile"},
headers=_auth(admin_token),
)
assert all(it["source"] == "profile" for it in r.json()["items"])
# 非法 source 值 → 422
assert admin_client.get(
"/admin/api/feedbacks", params={"source": "bogus"}, headers=_auth(admin_token)
).status_code == 422
def test_withdraw_list_exposes_source_and_filters(
admin_client: TestClient, admin_token: str
) -> None:
"""提现列表带 source 字段(coin_cash/invite_cash),并可按提现类型筛选。"""
db = SessionLocal()
try:
uid = user_repo.upsert_user_for_login(db, phone="13800009002", register_channel="sms").id
db.add(WithdrawOrder(user_id=uid, out_bill_no=f"src{uid}coin0001",
amount_cents=100, status="success", source="coin_cash"))
db.add(WithdrawOrder(user_id=uid, out_bill_no=f"src{uid}invite001",
amount_cents=200, status="success", source="invite_cash"))
db.commit()
finally:
db.close()
r = admin_client.get("/admin/api/withdraws", params={"user_id": uid}, headers=_auth(admin_token))
assert r.status_code == 200, r.text
assert {it["source"] for it in r.json()["items"]} == {"coin_cash", "invite_cash"}
r = admin_client.get(
"/admin/api/withdraws", params={"user_id": uid, "source": "invite_cash"},
headers=_auth(admin_token),
)
items = r.json()["items"]
assert items and all(it["source"] == "invite_cash" for it in items)
def test_ad_coin_audit_full_count_truncate_and_only_mismatch( def test_ad_coin_audit_full_count_truncate_and_only_mismatch(
admin_client: TestClient, admin_token: str admin_client: TestClient, admin_token: str
) -> None: ) -> None:
+29
View File
@@ -368,6 +368,35 @@ def test_reject_feedback_records_reason_and_no_coin(
db.close() db.close()
def test_feedback_review_stores_admin_reply(
admin_client: TestClient, operator_token: str
) -> None:
"""审核可给用户留言(admin_reply,用户端可见),采纳/拒绝都能带,并回显在响应里。"""
fid = _seed_feedback("13900000021")
r = admin_client.post(
f"/admin/api/feedbacks/{fid}/approve",
json={"reward_coins": 100, "note": "内部备注", "reply": "感谢反馈,已采纳~"},
headers=_auth(operator_token),
)
assert r.status_code == 200, r.text
assert r.json()["admin_reply"] == "感谢反馈,已采纳~"
db = SessionLocal()
try:
fb = db.get(Feedback, fid)
assert fb.admin_reply == "感谢反馈,已采纳~" and fb.review_note == "内部备注"
finally:
db.close()
fid2 = _seed_feedback("13900000022")
r = admin_client.post(
f"/admin/api/feedbacks/{fid2}/reject",
json={"reason": "无法复现", "reply": "已收到,后续跟进"},
headers=_auth(operator_token),
)
assert r.status_code == 200, r.text
assert r.json()["admin_reply"] == "已收到,后续跟进"
# ===== admin 账号管理(super_admin) ===== # ===== admin 账号管理(super_admin) =====
def test_create_and_update_admin(admin_client: TestClient, super_token: str) -> None: def test_create_and_update_admin(admin_client: TestClient, super_token: str) -> None:
+2 -22
View File
@@ -17,7 +17,6 @@ def _reset(phone: str) -> None:
"""清该号的进程内存状态,隔离 real 模式用例。""" """清该号的进程内存状态,隔离 real 模式用例。"""
sms._codes.pop(phone, None) sms._codes.pop(phone, None)
sms._last_sent.pop(phone, None) sms._last_sent.pop(phone, None)
sms._daily_count.pop(phone, None)
class _OkResp: class _OkResp:
@@ -76,7 +75,7 @@ def test_sms_send_too_frequent(client) -> None:
def test_sms_send_device_ip_rate_limit(client, monkeypatch) -> None: def test_sms_send_device_ip_rate_limit(client, monkeypatch) -> None:
"""发码防刷:同一设备(device_id) + 同一 IP 每小时最多 N 次发码,超出 429。 """发码防刷:同一设备(device_id) + 同一 IP 每小时最多 N 次发码,超出 429。
用不同手机号(绕开单号 60s 冷却)证明限流按设备封顶 换号绕开单号冷却/每日上限的洞 用不同手机号(绕开单号 60s 冷却)证明限流按设备封顶 换号绕开单号冷却的洞
conftest 默认关限流;本用例临时打开 + 调小阈值便于测 + 清计数隔离""" conftest 默认关限流;本用例临时打开 + 调小阈值便于测 + 清计数隔离"""
from app.api.v1 import auth from app.api.v1 import auth
from app.core import ratelimit from app.core import ratelimit
@@ -155,21 +154,6 @@ def test_phone_format_validation(client) -> None:
assert r.status_code == 422 assert r.status_code == 422
def test_sms_daily_limit(monkeypatch) -> None:
"""单号每日上限(send 的防刷逻辑 mock/real 都跑;函数级绕开 60s 冷却)。"""
phone = "13511135000"
_reset(phone)
limit = 3
monkeypatch.setattr(sms.settings, "SMS_DAILY_LIMIT_PER_PHONE", limit)
for _ in range(limit):
sms.send_code(phone)
sms._last_sent.pop(phone, None) # 绕开冷却,只测每日上限
with pytest.raises(sms.SmsError, match="上限"):
sms.send_code(phone)
# ============================ real 路径(不真发)============================ # ============================ real 路径(不真发)============================
def test_sms_real_send_calls_jiguang(monkeypatch) -> None: def test_sms_real_send_calls_jiguang(monkeypatch) -> None:
@@ -255,24 +239,20 @@ def test_sms_real_balance_error_keeps_cooldown(monkeypatch) -> None:
def test_sms_gc_purges_stale_only(monkeypatch) -> None: def test_sms_gc_purges_stale_only(monkeypatch) -> None:
"""GC 清过期码 / 旧冷却 / 隔日计数,但不动今天有效的(阈值设 0 强制每次扫)。""" """GC 清过期码 / 旧冷却,但不动今天有效的(阈值设 0 强制每次扫)。"""
monkeypatch.setattr(sms, "_GC_THRESHOLD", 0) monkeypatch.setattr(sms, "_GC_THRESHOLD", 0)
sms._codes.clear() sms._codes.clear()
sms._last_sent.clear() sms._last_sent.clear()
sms._daily_count.clear()
now = time.time() now = time.time()
sms._codes["stale"] = sms._CodeRecord(code="111111", expires_at=now - 1) sms._codes["stale"] = sms._CodeRecord(code="111111", expires_at=now - 1)
sms._codes["fresh"] = sms._CodeRecord(code="222222", expires_at=now + 999) sms._codes["fresh"] = sms._CodeRecord(code="222222", expires_at=now + 999)
sms._last_sent["old"] = now - 99999 sms._last_sent["old"] = now - 99999
sms._last_sent["recent"] = now sms._last_sent["recent"] = now
sms._daily_count["yesterday"] = ("2000-01-01", 3)
sms._daily_count["today"] = (sms._today(), 1)
sms._gc(now) sms._gc(now)
assert "stale" not in sms._codes and "fresh" in sms._codes assert "stale" not in sms._codes and "fresh" in sms._codes
assert "old" not in sms._last_sent and "recent" in sms._last_sent assert "old" not in sms._last_sent and "recent" in sms._last_sent
assert "yesterday" not in sms._daily_count and "today" in sms._daily_count
# ============================ 用户名 / 默认昵称 ============================ # ============================ 用户名 / 默认昵称 ============================
+37
View File
@@ -36,6 +36,43 @@ def test_feedback_config_returns_default(client) -> None:
assert body["group_name"] == "傻瓜比价官方群" assert body["group_name"] == "傻瓜比价官方群"
def test_submit_feedback_derives_source_from_scene(client) -> None:
"""比价结果页反馈带 scene → source=comparison 并存 scene;「我的」页不带 → profile;
客户端显式传 source 优先(即便没 scene 也判 comparison)历史接口带回 scene"""
token = _login(client, "13622000010")
h = {"Authorization": f"Bearer {token}"}
r = client.post("/api/v1/feedback", data={"content": "比价太慢了", "scene": "比价太慢"}, headers=h)
assert r.status_code == 200, r.text
fid_cmp = r.json()["id"]
r = client.post("/api/v1/feedback", data={"content": "普通建议"}, headers=h)
assert r.status_code == 200, r.text
fid_prof = r.json()["id"]
r = client.post(
"/api/v1/feedback", data={"content": "只填了描述", "source": "comparison"}, headers=h
)
assert r.status_code == 200, r.text
fid_explicit = r.json()["id"]
db = SessionLocal()
try:
cmp_fb = db.get(Feedback, fid_cmp)
assert cmp_fb.source == "comparison" and cmp_fb.scene == "比价太慢"
prof_fb = db.get(Feedback, fid_prof)
assert prof_fb.source == "profile" and prof_fb.scene is None
assert db.get(Feedback, fid_explicit).source == "comparison"
finally:
db.close()
r = client.get("/api/v1/feedback/records", headers=h)
assert r.status_code == 200, r.text
scenes = {item["id"]: item["scene"] for item in r.json()["records"]}
assert scenes[fid_cmp] == "比价太慢"
assert scenes[fid_prof] is None
def test_feedback_records_maps_existing_statuses(client) -> None: def test_feedback_records_maps_existing_statuses(client) -> None:
phone = "13622000003" phone = "13622000003"
token = _login(client, phone) token = _login(client, phone)