Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3c3a9e582 | |||
| ee132aa93b | |||
| 4512b6ecac |
+1
-1
@@ -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 模式) =====
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 与刷新
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
|
|
||||||
|
|
||||||
# ============================ 用户名 / 默认昵称 ============================
|
# ============================ 用户名 / 默认昵称 ============================
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user