Compare commits

..

2 Commits

Author SHA1 Message Date
no_gen_mu e2ecd3db9e feat(invite): 发奖口径改为「比价并下单」+ DEV dev-reset 出厂重置接口
- 发奖触发点从「比价成功上报」(compare/record)挪到「实际下单上报」
  (order/report):被邀请人完成比价并真实下单才给邀请人发 2 元邀请奖励金
  (冰 2026-07-02 拍板);仅完成比价不再发奖。幂等闸 compare_reward_granted 不变。
- 新增 POST /api/v1/invite/dev-reset:把固定测试号(TEST_ACCOUNT_PHONE)打回
  出厂态(清邀请关系/奖励金/savings/流水 + 刷 created_at 骗过 72h 新用户闸),
  便于反复当新用户测发奖。双门控:生产 404 + 只认配置的测试号,无鉴权。
- 测试:8 例改为下单驱动 + 新增 2 例覆盖 dev-reset(可反复邀请 / 开关关闭 400)。
2026-07-02 22:15:36 +08:00
no_gen_mu 5cdde8f6c6 feat(invite): 好友列表已比价状态(is_compared) + DEV 造虚拟好友接口; fix(deploy): 每日兑换定时器锁北京时区
邀请:
- get_invitees 返回增加 is_compared 字段(= compare_reward_granted),
  客户端据此区分好友列表"去提醒/邀请成功"、在途列表只取未比价、算在途好友数与收益。
- 新增 POST/DELETE /invite/seed-fake 开发接口(仅非生产, is_prod 挡):造未比价+已比价虚拟好友,
  已比价的走真实入账发 2 元邀请奖励金, 便于真机联调在途/好友列表 UI; 清理原路退回不留脏账。

兑换时区:
- daily-exchange.service/.timer 锁定 Asia/Shanghai, 避免按服务器本地时区在错误时刻触发每日兑换。
- 新增 test_daily_exchange_timezone 覆盖同一北京日内幂等。

测试: 邀请 + 兑换时区相关用例全过(28 passed)。
(仓库预存的 3 个 proxy 转发测试失败与本改动无关, 已在干净 HEAD 上复现确认。)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:24:40 +08:00
39 changed files with 860 additions and 494 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ JG_REQUEST_TIMEOUT_SEC=15
# ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)=====
HEARTBEAT_MONITOR_ENABLED=true
HEARTBEAT_TIMEOUT_MINUTES=60
HEARTBEAT_TIMEOUT_MINUTES=10
HEARTBEAT_SCAN_INTERVAL_SEC=60
# ===== 短信 (mock 模式) =====
@@ -1,26 +0,0 @@
"""merge jd_cps_order_fields and coupon_session_origin_package heads
Revision ID: 761ef181ce7c
Revises: coupon_session_origin_package, jd_cps_order_fields
Create Date: 2026-07-01 13:52:16.068808
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '761ef181ce7c'
down_revision: Union[str, Sequence[str], None] = ('coupon_session_origin_package', 'jd_cps_order_fields')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
-48
View File
@@ -1,48 +0,0 @@
"""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")
+1 -7
View File
@@ -136,16 +136,12 @@ def _feed_scene_matches(rec: AdFeedRewardRecord, scene: str | None) -> bool:
"""该信息流记录是否落入请求的展示筛选 scene。
- scene=="feed":ad_type in ("feed", NULL)(旧数据 NULL 视为 feed,向后兼容)
- scene=="draw":ad_type=="draw"
- scene=="feed_all":所有信息流(feed/draw/NULL 都要)——业务已全切 Draw 信息流,收益报表把「Draw 信息流」
当作整个信息流口径(含历史误标 feed/NULL),用它避免筛选漏历史。
- scene 为 None:不筛(两类都要)。
"""
if scene == "feed":
return rec.ad_type in (None, "feed")
if scene == "draw":
return rec.ad_type == "draw"
if scene == "feed_all":
return True
return True
@@ -188,7 +184,6 @@ def _feed_rows(
"record_id": rec.id,
"user_id": rec.user_id,
"ad_session_id": rec.ad_session_id,
"trace_id": rec.trace_id,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at,
@@ -214,7 +209,6 @@ def _feed_rows(
"record_id": rec.id,
"user_id": rec.user_id,
"ad_session_id": rec.ad_session_id,
"trace_id": rec.trace_id,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at,
@@ -247,7 +241,7 @@ def audit_rows(
rows: list[dict] = []
if scene in (None, "reward_video"):
rows.extend(_reward_video_rows(db, date=date, user_id=user_id))
if scene in (None, "feed", "draw", "feed_all"):
if scene in (None, "feed", "draw"):
rows.extend(_feed_rows(db, date=date, user_id=user_id, scene=scene))
return rows
+35 -105
View File
@@ -3,11 +3,9 @@
只读。每行 = 一次广告事件(不再按用户聚合):
- **激励视频**:一次观看 = 1 条展示(ad_ecpm)+ 1 条发奖(ad_reward),按 ad_session_id 合并成一行,
直接给出 eCPM / 收益 + 状态 / 应发 / 实发 / 一致;点开看该条金币复算因子。
- **信息流(比价/领券)**:一次比价 / 一次领券 = 一条整场发奖(ad_feed_reward)一行,给出 eCPM /
发奖金币 + 应发 / 实发 / 一致;点开看金币复算因子。⚠️ draw 的逐条展示(ad_ecpm,impressionId 各自
独立、与整场发奖无公共键、无法归到「哪一次」)**不再单独占行**(2026-07 按「一次比价/领券放一块」调整)——
其展示数 / eCPM / 预估收益仍进全量统计(合计 / 趋势 / 分类大盘 / 穿山甲对照),只是主表不逐条铺开。
- 兜底:激励视频有展示无发奖(中途关 / 未达发奖)、有发奖无展示(未上报 eCPM)仍各自成行。
- **信息流**:轮播每条展示各一行(impressionId 各自独立);整场发奖(ad_feed_reward,client_event_id)
与逐条展示无法对应,单独成「纯发奖」行。
- 兜底:有展示无发奖(中途关 / 未达发奖)、有发奖无展示(未上报 eCPM)都各自成行。
展示与收益来自 ad_ecpm_record(收益 = eCPM元 ÷ 1000);应发 / 实发金币复用金币审计逐条复算
(ad_audit.audit_rows,与正式发奖同一公式口径,不另写公式)。合计与对账在全量上统计,
@@ -60,6 +58,14 @@ def _date_range(date_from: str, date_to: str) -> list[str]:
_AUDIT_SCENES = {"reward_video", "feed", "draw"}
def _event_ad_type(row: dict) -> str:
"""纯发奖事件行的 ad_type:信息流行用 audit 带回的真实 ad_type(feed/draw),回退 feed;
激励视频行恒 reward_video。不再用 scene 硬映射,避免把 draw 丢成 feed。"""
if row["scene"] == "reward_video":
return "reward_video"
return row.get("ad_type") or "feed"
# 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。
_REWARD_DETAIL_KEYS = (
"record_id", "created_at", "status", "ecpm", "ecpm_factor", "units",
@@ -102,14 +108,9 @@ def ad_revenue_report(
# 同时保留全量列表,未被展示合并的成「纯发奖」事件。
reward_by_session: dict[tuple[int, str], list[dict]] = {}
all_reward_rows: list[dict] = []
# 报表 ad_type audit scene:reward_video/feed 直传;**draw(前端「Draw 信息流」)映射成 feed_all**
# ——业务已全切 Draw,把「Draw 信息流」当作整个信息流口径(含历史误标 feed/NULL),否则筛选会漏历史
if ad_type == "draw":
audit_scene = "feed_all"
elif ad_type in _AUDIT_SCENES:
audit_scene = ad_type
else:
audit_scene = None
# 报表 ad_type 直接当 audit scene 用(取值一致);未知/无效 ad_type 不取发奖行。draw 在此被
# 正确传成 scene="draw",audit 会按 ad_type 筛出 Draw 发奖,不再丢成 feed
audit_scene = ad_type if ad_type in _AUDIT_SCENES else None
if ad_type is None or audit_scene is not None:
for d in _date_range(date_from, date_to):
for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene):
@@ -139,10 +140,7 @@ def ad_revenue_report(
)
if user_id is not None:
stmt = stmt.where(AdEcpmRecord.user_id == user_id)
if ad_type == "draw":
# draw = 所有信息流展示(业务已全 Draw,含历史误标 feed);展示行只进统计,不占主表行
stmt = stmt.where(AdEcpmRecord.ad_type.in_(["draw", "feed"]))
elif ad_type is not None:
if ad_type is not None:
stmt = stmt.where(AdEcpmRecord.ad_type == ad_type)
for rec in db.execute(stmt).scalars():
rwd = _pop_reward(rec.user_id, rec.ad_session_id)
@@ -167,8 +165,6 @@ def ad_revenue_report(
),
"adn": rec.adn,
"slot_id": rec.slot_id,
"sub_rewards": [],
"sub_count": 1,
}
if rwd is not None:
ev.update({
@@ -188,88 +184,33 @@ def ad_revenue_report(
})
events.append(ev)
# 3) 未被展示合并的发奖行 → 事件:
# - 激励视频(reward_video):逐条成「纯发奖」事件(每次一个 ad_session_id;有发奖无展示等)。
# - 信息流(feed/draw):同一次比价/领券的多条广告共享**整场 ad_session_id**(客户端整场复用),
# 按 (user_id, ad_session_id) 聚成**一次比价 / 一次领券**父事件;sub_rewards 为组内逐条明细,
# 应发/实发取组内合计;业务已全 Draw → 类型统一 "draw"。session 缺失(极少旧数据)各自单独成组。
feed_groups: dict[tuple[int, str], list[dict]] = {}
# 3) 未被展示合并的发奖行 → 「纯发奖」事件(信息流整场发奖 / 有发奖无展示)。
# 收益恒 0(收益只算展示侧,避免与展示行重复计)。
for row in all_reward_rows:
if row["record_id"] in used_reward_ids:
continue
if row["scene"] == "reward_video":
events.append({
"event_key": f"rwd-{row['record_id']}",
"report_date": row["_report_date"],
"user_id": row["user_id"],
"ad_type": "reward_video",
"feed_scene": row.get("feed_scene"),
"app_env": row.get("app_env"),
"our_code_id": row.get("our_code_id"),
"created_at": row["created_at"],
"hour": _cn_hour(row["created_at"]) if by_hour else None,
"has_impression": False,
"impressions": 0,
"ecpm": row["ecpm"],
"revenue_yuan": 0.0,
"adn": None,
"slot_id": None,
"has_reward": True,
"status": row["status"],
"expected_coin": int(row["expected_coin"]),
"actual_coin": int(row["actual_coin"]),
"matched": bool(row["matched"]),
"reward_detail": _reward_detail(row),
"sub_rewards": [],
"sub_count": 1,
})
else:
# 聚合单位 = 一次完整比价/领券流程:优先用 trace_id(比价带 comparisonTraceId、领券带 sessionTraceId,
# 整个流程不变;即使中途点广告致浮层关闭重弹、ad_session_id 变了,trace_id 仍不变 → 全流程聚成一行)。
# 无 trace_id(历史领券未上报 / 旧数据)回退整场 ad_session_id;再无则 record_id 各自成组、不误并。
grp_key = row.get("trace_id") or row.get("ad_session_id") or f"_rid-{row['record_id']}"
feed_groups.setdefault((row["user_id"], grp_key), []).append(row)
# 信息流分组 → 「一次比价 / 一次领券」父事件(收益恒 0:收益只算展示侧,避免与展示行重复计)。
for (uid, grp_key), group in feed_groups.items():
group.sort(key=lambda r: (r["created_at"], r["record_id"]))
rep = group[-1] # 代表条(最新一条):时间/场景/应用/代码位取它
expected_sum = sum(int(g["expected_coin"]) for g in group)
actual_sum = sum(int(g["actual_coin"]) for g in group)
# 父行 eCPM:组内各条 eCPM(分)均值(展示用,各条不同);无有效值则取代表条
ecpm_fens = [rewards.parse_ecpm_fen(g["ecpm"]) for g in group if g.get("ecpm")]
avg_ecpm = str(round(sum(ecpm_fens) / len(ecpm_fens))) if ecpm_fens else rep.get("ecpm")
# 主表逐行显示用:这次发奖广告的预估收益之和(发奖侧 eCPM 折算,钳顶同展示侧)。只放进
# row_revenue_yuan 给主表逐行展示,不进 revenue_yuan/合计/趋势——避免与展示侧 total 重复计。
row_revenue = round(sum(
min(rewards.parse_ecpm_yuan(g["ecpm"]), rewards.AD_ECPM_MAX_FEN / 100.0) / 1000.0
for g in group if g.get("ecpm")
), 6)
events.append({
"event_key": f"feedgrp-{uid}-{grp_key}",
"report_date": rep["_report_date"],
"user_id": uid,
"ad_type": "draw", # 业务已全切 Draw 信息流,聚合行统一 draw
"feed_scene": rep.get("feed_scene"),
"app_env": rep.get("app_env"),
"our_code_id": rep.get("our_code_id"),
"created_at": rep["created_at"],
"hour": _cn_hour(rep["created_at"]) if by_hour else None,
"event_key": f"rwd-{row['record_id']}",
"report_date": row["_report_date"],
"user_id": row["user_id"],
"ad_type": _event_ad_type(row),
"feed_scene": row.get("feed_scene"),
"app_env": row.get("app_env"),
"our_code_id": row.get("our_code_id"),
"created_at": row["created_at"],
"hour": _cn_hour(row["created_at"]) if by_hour else None,
"has_impression": False,
"impressions": 0,
"ecpm": avg_ecpm,
"ecpm": row["ecpm"],
"revenue_yuan": 0.0,
"row_revenue_yuan": row_revenue,
"adn": None,
"slot_id": None,
"has_reward": True,
"status": rep["status"], # 代表状态(逐条见展开)
"expected_coin": expected_sum,
"actual_coin": actual_sum,
"matched": all(bool(g["matched"]) for g in group),
"reward_detail": None,
"sub_rewards": [_reward_detail(g) for g in group],
"sub_count": len(group),
"status": row["status"],
"expected_coin": int(row["expected_coin"]),
"actual_coin": int(row["actual_coin"]),
"matched": bool(row["matched"]),
"reward_detail": _reward_detail(row),
})
# 「场景」作为全局筛选(与 user_id/ad_type 一致):同时作用于明细、合计与 daily/hourly 趋势。
@@ -390,20 +331,9 @@ def ad_revenue_report(
is_today = date_from == date_to == rewards.cn_today().isoformat()
dau = admin_stats.today_dau(db) if is_today else None
# 主表「逐行」= 单次广告行为(2026-07 按「一次比价/领券放一块」聚合):激励视频 = 一次观看一行(展示+发奖
# 按 ad_session_id 合并);一次比价 / 一次领券 = 该次整场多条广告按 ad_session_id 聚成一行(展开看逐条)。
# 信息流(draw/feed)的逐条展示(ad_ecpm,impressionId 各自独立、与整场发奖无公共键)不再单独占行
# ——其展示数 / eCPM / 预估收益已计入上面的全量统计(total_*、daily / hourly、type_stats、穿山甲对照),
# 只是主表不逐条铺开;逐条明细在父行展开里看(sub_rewards)。合计 / 趋势 / 分类大盘均基于全量 events,
# 不受此过滤影响;total / 分页只作用于主表行。
main_rows = [
e for e in events
if not (e["ad_type"] in ("draw", "feed") and e["has_impression"] and not e["has_reward"])
]
return {
"total": len(main_rows),
"truncated": len(main_rows) > offset + limit,
"total": len(events),
"truncated": len(events) > offset + limit,
"total_impressions": total_impressions,
"total_revenue_yuan": total_revenue_yuan,
# 穿山甲后台收益合计(元):预估 revenue + 收益Api;非全量视图(带 user/类型/场景过滤)或无数据为 None。
@@ -417,5 +347,5 @@ def ad_revenue_report(
"hourly": hourly,
"type_stats": type_stats,
"dau": dau,
"items": main_rows[offset:offset + limit],
"items": events[offset:offset + limit],
}
+1 -4
View File
@@ -65,19 +65,16 @@ def review_feedback(
reward_coins: int | None = None,
reject_reason: str | None = None,
review_note: str | None = None,
admin_reply: str | None = None,
commit: bool = True,
) -> Feedback:
"""审核反馈:置 adopted/rejected + 记录奖励/原因/回复留言/审核人/审核时间。
"""审核反馈:置 adopted/rejected + 记录奖励/原因/审核人/审核时间。
发金币(wallet.grant_coins)由 router 在同一事务里调,确保状态、金币流水、审计一起提交。
admin_reply=给用户的回复留言(用户端可见),与 review_note(内部备注)区分。
"""
feedback.status = status
feedback.reward_coins = reward_coins
feedback.reject_reason = reject_reason
feedback.review_note = review_note
feedback.admin_reply = admin_reply
feedback.reviewed_by_admin_id = reviewed_by_admin_id
feedback.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None)
if commit:
+7 -31
View File
@@ -390,7 +390,6 @@ def list_all_withdraw_orders(
*,
user_id: int | None = None,
status: str | None = None,
source: str | None = None,
keyword: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
@@ -410,9 +409,6 @@ def list_all_withdraw_orders(
stmt = stmt.where(WithdrawOrder.user_id == user_id)
if status:
stmt = stmt.where(WithdrawOrder.status == status)
# 提现类型:coin_cash(福利页提现)/ invite_cash(邀请提现);None=全部
if source:
stmt = stmt.where(WithdrawOrder.source == source)
kw = (keyword or "").strip()
if kw:
@@ -538,7 +534,6 @@ def list_feedbacks(
db: Session,
*,
status: str | None = None,
source: str | None = None,
user_id: int | None = None,
content: str | None = None,
created_from: datetime | None = None,
@@ -548,15 +543,13 @@ def list_feedbacks(
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[Feedback], int | None, int]:
"""反馈工单列表。支持 状态 / 反馈类型(source) / 用户ID / 内容模糊 / 提交时间范围 筛选,
按 id·提交时间排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受。返回 (items, next_cursor, total),
total 供页码分页。created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。"""
"""反馈工单列表。支持 状态 / 用户ID / 内容模糊 / 提交时间范围 筛选,按 id·提交时间排序。
**offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),代价是翻页期间
数据变动可能错位一条——admin 低频场景可接受。返回 (items, next_cursor, total),total 供页码分页。
created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。"""
stmt = select(Feedback)
if status:
stmt = stmt.where(Feedback.status == status)
if source:
stmt = stmt.where(Feedback.source == source)
if user_id is not None:
stmt = stmt.where(Feedback.user_id == user_id)
if content and content.strip():
@@ -813,12 +806,6 @@ def get_user_overview(db: Session, user_id: int) -> dict | None:
}
def _as_utc_naive(value: datetime) -> datetime:
"""窗口入参 → UTC naive(= _as_utc 去时区),与库里按 naive UTC 存取的 created_at 同口径比较。
历史遗留:_window_conds 一直引用本函数却未定义(自定义区间会 NameError),此处补上。"""
return _as_utc(value).replace(tzinfo=None)
def _window_conds(col, date_from: datetime | None, date_to: datetime | None) -> list:
"""把 [date_from, date_to] 转成对 col(created_at)的过滤条件;都为 None = 全量(注册至今)。"""
conds = []
@@ -908,13 +895,6 @@ def user_reward_stats(
}
def _cn_wall_to_utc(dt: datetime) -> datetime:
"""coin_transaction 存的是北京 wall-clock(naive,见 wallet.grant_coins「存北京 wall-clock」),转成 UTC naive,
与广告表(func.now() UTC)统一 —— 让本函数按同一绝对时刻排序、且前端 apiTime(把无时区时间当 UTC 再 +8 展示)
口径一致;否则签到会比实际多显示 8 小时(北京时间又被 +8)。"""
return dt.replace(tzinfo=rewards.CN_TZ).astimezone(timezone.utc).replace(tzinfo=None)
def user_coin_records(
db: Session,
user_id: int,
@@ -935,9 +915,6 @@ def user_coin_records(
offset = max(cursor or 0, 0)
fetch = offset + limit + 1
rows: list[dict] = []
# coin_transaction 存北京 wall-clock(其余表存 UTC);签到窗口边界 +8h 对齐北京,过滤/计数才不偏移 8 小时
signin_from = date_from + timedelta(hours=8) if date_from is not None else None
signin_to = date_to + timedelta(hours=8) if date_to is not None else None
for rec in db.execute(
select(AdRewardRecord)
@@ -981,7 +958,7 @@ def user_coin_records(
.where(
CoinTransaction.user_id == user_id,
CoinTransaction.biz_type == "signin",
*_window_conds(CoinTransaction.created_at, signin_from, signin_to),
*_window_conds(CoinTransaction.created_at, date_from, date_to),
)
.order_by(CoinTransaction.created_at.desc())
.limit(fetch)
@@ -989,8 +966,7 @@ def user_coin_records(
rows.append({
"source": "signin",
"source_label": "签到",
# 北京 wall-clock → UTC,与广告记录统一(前端 apiTime 会 +8 回北京展示,不然签到会多 8 小时)
"created_at": _cn_wall_to_utc(rec.created_at),
"created_at": rec.created_at,
"ecpm": None,
"coin": rec.amount,
})
@@ -1016,7 +992,7 @@ def user_coin_records(
+ _count(
CoinTransaction, CoinTransaction.user_id == user_id,
CoinTransaction.biz_type == "signin",
*_window_conds(CoinTransaction.created_at, signin_from, signin_to),
*_window_conds(CoinTransaction.created_at, date_from, date_to),
)
)
return rows[offset:offset + limit], (offset + limit if has_more else None), total
-7
View File
@@ -36,8 +36,6 @@ def _ensure_pending(fb: Feedback) -> None:
def list_feedbacks(
db: AdminDb,
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,
content: Annotated[str | None, Query(max_length=100)] = None,
created_from: Annotated[datetime | None, Query()] = None,
@@ -50,7 +48,6 @@ def list_feedbacks(
items, next_cursor, total = queries.list_feedbacks(
db,
status=status,
source=source,
user_id=user_id,
content=content,
created_from=created_from,
@@ -104,7 +101,6 @@ def approve_feedback(
status="adopted",
reward_coins=payload.reward_coins,
review_note=payload.note,
admin_reply=payload.reply,
reviewed_by_admin_id=admin.id,
commit=False,
)
@@ -127,7 +123,6 @@ def approve_feedback(
"after": "adopted",
"reward_coins": payload.reward_coins,
"note": payload.note,
"reply": payload.reply,
},
ip=get_client_ip(request),
commit=False,
@@ -157,7 +152,6 @@ def reject_feedback(
status="rejected",
reject_reason=payload.reason,
review_note=payload.note,
admin_reply=payload.reply,
reviewed_by_admin_id=admin.id,
commit=False,
)
@@ -172,7 +166,6 @@ def reject_feedback(
"after": "rejected",
"reason": payload.reason,
"note": payload.note,
"reply": payload.reply,
},
ip=get_client_ip(request),
commit=False,
-3
View File
@@ -50,8 +50,6 @@ def list_withdraws(
db: AdminDb,
user_id: Annotated[int | 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,
date_from: Annotated[datetime | None, Query()] = None,
date_to: Annotated[datetime | None, Query()] = None,
@@ -71,7 +69,6 @@ def list_withdraws(
db,
user_id=user_id,
status=status,
source=source,
keyword=keyword,
date_from=date_from,
date_to=date_to,
-14
View File
@@ -94,11 +94,6 @@ class AdRevenueRow(BaseModel):
impressions: int = Field(..., description="本行展示条数:有展示=1 / 纯发奖=0(供日汇总、趋势图复用)")
ecpm: str | None = Field(None, description="eCPM 原始值(分/千次);展示行取展示值,纯发奖行取发奖采用值")
revenue_yuan: float = Field(..., description="本次展示预估收益(元)= eCPM元 ÷ 1000;纯发奖行=0")
row_revenue_yuan: float | None = Field(
None,
description="主表逐行展示用的预估收益(元):一次比价/领券聚合行=该次发奖广告 eCPM 折算之和;"
"其它行为空(前端回退取 revenue_yuan)。不进合计/趋势,避免与展示侧重复计",
)
adn: str | None = Field(None, description="实际填充 ADN 子渠道(pangle/gdt…);纯发奖行为空")
slot_id: str | None = Field(None, description="底层 mediation rit(非我们配置的广告位 ID);纯发奖行为空")
# ── 发奖侧 ──
@@ -111,15 +106,6 @@ class AdRevenueRow(BaseModel):
None,
description="发奖复算明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致);点行展开下钻用,纯展示为空",
)
sub_rewards: list[AdRevenueRecord] = Field(
default_factory=list,
description="一次比价/领券聚合行的组内逐条发奖明细(同一整场 ad_session_id 的多条广告);"
"点行展开渲染多行。激励视频/纯展示行为空(单条看 reward_detail)",
)
sub_count: int = Field(
1,
description="本行聚合的发奖条数:一次比价/领券=该次广告条数(≥1);激励视频/纯展示=1",
)
class AdRevenueReportOut(BaseModel):
+1 -9
View File
@@ -15,17 +15,11 @@ class FeedbackOut(BaseModel):
user_id: int
content: str
contact: str
# 反馈来源入口:profile(「我的」页)/ comparison(比价结果页)——admin 展示/筛选「反馈类型」
source: str = "profile"
# 比价反馈的问题场景(找错商品/优惠不对…);普通反馈为 None
scene: str | None = None
images: list[str] | None = None
status: str
reject_reason: str | None = None
reward_coins: int | None = None
review_note: str | None = None
# 运营给用户的回复留言(用户端可见,采纳/未采纳都可填)
admin_reply: str | None = None
reviewed_by_admin_id: int | None = None
reviewed_at: datetime | None = None
created_at: datetime
@@ -45,14 +39,12 @@ class FeedbackApproveRequest(BaseModel):
le=FEEDBACK_REWARD_MAX_COINS,
description="采纳后发放金币数",
)
note: str | None = Field(default=None, max_length=256, description="采纳要点/审核备注(内部)")
reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见")
note: str | None = Field(default=None, max_length=256, description="采纳要点/审核备注")
class FeedbackRejectRequest(BaseModel):
reason: str = Field(min_length=1, 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):
-1
View File
@@ -17,7 +17,6 @@ class AdminUserListItem(BaseModel):
status: str
debug_trace_enabled: bool = False
wechat_openid: str | None = None
wechat_nickname: str | None = None
created_at: datetime
last_login_at: datetime
-2
View File
@@ -41,8 +41,6 @@ class WithdrawOrderOut(BaseModel):
user_id: int
out_bill_no: str
amount_cents: int
# 提现类型:coin_cash(福利页提现,金币兑换的现金)/ invite_cash(邀请提现,邀请奖励金)
source: str = "coin_cash"
user_name: str | None = None # 提现实名(审核核对 + 打款用)
status: str
wechat_state: str | None = None
+7 -6
View File
@@ -12,11 +12,11 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
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 enforce_rate_limit, rate_limit
from app.core.security import TokenError, decode_token, issue_token_pair
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
from app.integrations.sms import SmsError, send_code, verify_code
@@ -41,7 +41,7 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
# 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。
SMS_LOGIN_MAX_PER_HOUR = 5
# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。
# 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。
# 堵「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞 —— 那两道是单号维度,一机换号能绕开。
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5
@@ -100,8 +100,8 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
return SmsSendResponse(sent=True, mock=True, cooldown_sec=0)
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码。
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,
# 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。
# 补「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞(那两道是单号维度,一机换号能绕);设备维度按机器封顶,
# 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。与路由上 IP 维度(10次/分钟)互补。
enforce_rate_limit(
request,
scope="sms-send-device",
@@ -125,6 +125,7 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
"/sms/login",
response_model=TokenWithUser,
summary="手机号+验证码登录",
dependencies=[Depends(rate_limit(20, 60, "sms-login"))], # 防撞库爆破(另有单码失败次数上限)
)
def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWithUser:
# 测试账号:免验证码登录 + 每日上限 + 每次都走新手引导(详见 app/core/test_account.py)。
@@ -142,7 +143,7 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
return _login_response(user, onboarding_completed=False, force_onboarding=True)
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试。放在验证码校验
# **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破(另有单码失败 SMS_MAX_VERIFY_ATTEMPTS 次即作废兜底)
# **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破。与路由上 IP 维度的 sms-login 限流(同 IP)互补
# ⚠️ 按设备而非手机号 → 一台机器换不同手机号刷登录也受限(防一机狂登多号);device_id 空(老客户端)时
# 退化为该 IP 下所有空设备聚一桶,仍受限。
enforce_rate_limit(
+4 -15
View File
@@ -21,16 +21,15 @@ from app.api.deps import CurrentUser, DbSession
from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
from app.repositories import comparison as crud_compare
from app.repositories import invite as crud_invite
from app.services.pricebot_llm_calls import fetch_llm_calls
from app.schemas.compare_record import (
CompareStatsOut,
ComparisonRecordCreatedOut,
ComparisonRecordDetailOut,
ComparisonRecordIn,
ComparisonRecordPage,
ComparisonRecordOut,
ComparisonRecordPage,
)
from app.services.pricebot_llm_calls import fetch_llm_calls
logger = logging.getLogger("shagua.compare_record")
@@ -53,18 +52,8 @@ def report_record(
# 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用
# 独立 session 按 record id 回填 llm_calls + 派生 llm_call_count/retry_count。
background_tasks.add_task(_backfill_llm_calls, rec.id, rec.trace_id)
# 邀请 v2 发奖:被邀请人完成一次【成功】比价 → 给邀请人发邀请奖励金(幂等,只发一次)。
# best-effort:发奖异常不影响比价上报本身(rec 已 commit),只 log;邀请人补偿靠后续对账
if rec.status == "success":
try:
reward = crud_invite.try_reward_on_compare(db, user.id)
if reward.status == "granted":
logger.info(
"invite compare reward granted inviter=%s invitee=%s cents=%s",
reward.inviter_user_id, user.id, reward.reward_cents,
)
except Exception as e: # noqa: BLE001 best-effort,发奖失败不阻塞上报
logger.warning("invite compare reward failed invitee=%s: %s", user.id, e)
# 注:邀请发奖已从"比价成功"挪到"实际下单"(见 api/v1/order.py report_order)——
# 冰拍板:被邀请人完成比价并实际下单才算邀请成功,仅完成比价不再发奖
logger.info(
"compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)",
user.id,
+1 -24
View File
@@ -31,18 +31,7 @@ router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
_MAX_IMAGES = 6
_CONTENT_MAX = 200
_CONTACT_MAX = 128
_SCENE_MAX = 32
_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:
@@ -57,12 +46,10 @@ def _record_out(fb) -> FeedbackRecordOut:
return FeedbackRecordOut(
id=fb.id,
content=fb.content,
scene=getattr(fb, "scene", None),
images=fb.images or [],
status=_app_status(fb.status),
reject_reason=getattr(fb, "reject_reason", None),
reward_coins=getattr(fb, "reward_coins", None),
admin_reply=getattr(fb, "admin_reply", None),
created_at=fb.created_at,
)
@@ -74,11 +61,6 @@ async def submit_feedback(
content: str = Form(...),
# 原型改版后客户端不再采集联系方式;保留字段以兼容旧端 + 后续可能复用,默认空串。
contact: str = Form(default=""),
# 反馈来源入口:profile(「我的」页)/ comparison(比价结果页)。新端显式传;旧端不带 →
# 空串 → 按 scene 有无派生。admin 据此展示/筛选「反馈类型」。
source: str = Form(default=""),
# 比价结果页反馈的「问题场景」(找错商品/优惠不对…);普通反馈不带 → 空串 → 存 NULL。
scene: str = Form(default=""),
# 提交端环境快照(admin 反馈页展示「提交版本号」「机型OS版本」);旧端不带 → 空串 → 存 NULL。
app_version: str = Form(default=""),
device_model: str = Form(default=""),
@@ -94,8 +76,6 @@ async def submit_feedback(
raise HTTPException(status_code=400, detail="反馈内容过长")
if len(contact) > _CONTACT_MAX:
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]
if len(files) > _MAX_IMAGES:
@@ -111,13 +91,10 @@ async def submit_feedback(
fb = feedback_repo.create_feedback(
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(),
rom_name=rom_name.strip(), android_version=android_version.strip(),
)
logger.info(
"feedback id=%d user_id=%d source=%s images=%d", fb.id, user.id, resolved_source, len(urls)
)
logger.info("feedback id=%d user_id=%d images=%d", fb.id, user.id, len(urls))
return FeedbackOut.model_validate(fb)
+51
View File
@@ -122,6 +122,57 @@ def my_invitees(
)
@router.post("/seed-fake", summary="[DEV] 给自己塞虚拟好友(测在途/好友列表 UI,仅非生产)")
def seed_fake_invitees(
user: CurrentUser, db: DbSession, pending: int = 3, compared: int = 2
) -> dict:
"""造 pending 个未比价 + compared 个已比价的虚拟好友。生产环境(is_prod)直接 404。
未比价 → 上"在途列表 / 好友列表去提醒";已比价 → 好友列表"邀请成功"、并发真实邀请奖励金
(可提现余额/累计提现也有数)。清理见 DELETE /seed-fake。
"""
if settings.is_prod:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Not Found")
n = invite_repo.seed_fake_invitees(db, user.id, pending=max(0, pending), compared=max(0, compared))
return {"status": "ok", "created": n, "pending": pending, "compared": compared}
@router.delete("/seed-fake", summary="[DEV] 清掉自己的虚拟好友(仅非生产)")
def clear_fake_invitees(user: CurrentUser, db: DbSession) -> dict:
if settings.is_prod:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Not Found")
n = invite_repo.clear_fake_invitees(db, user.id)
return {"status": "ok", "removed": n}
@router.post("/dev-reset", summary="[DEV] 把固定测试号打回出厂态,便于反复当新用户测发奖(仅非生产)")
def dev_reset(db: DbSession) -> dict:
"""把 TEST_ACCOUNT_PHONE 这个固定测试号清成"从没被邀请/没下过单"的新用户态。
⚠️ 无需鉴权(它只按配置里的测试号操作,不依赖登录态,方便测试脚本第一步直接调)。
双重门控:生产环境(is_prod)404;测试号总开关(TEST_ACCOUNT_PHONE)没配置 → 400。
故它永远只能动那一个配置好的测试号,动不了任意真实用户。
清什么见 repositories/invite.dev_reset_test_account(全清 + 刷 created_at 骗过 72h 闸)。
调完该测试号即可再次绑定 + 下单上报,重新触发一次邀请发奖。
"""
from fastapi import HTTPException
if settings.is_prod:
raise HTTPException(status_code=404, detail="Not Found")
phone = settings.test_account_phone
if not phone:
raise HTTPException(
status_code=400,
detail="测试号未启用:请在 .env 配 TEST_ACCOUNT_PHONE=11111111111 并重启服务",
)
cleared = invite_repo.dev_reset_test_account(db, phone)
logger.info("invite dev-reset phone=%s cleared=%s", phone, cleared)
return {"status": "ok", "phone": phone, "cleared": cleared}
@router.post(
"/landing-track",
response_model=LandingTrackOut,
+19
View File
@@ -1,9 +1,14 @@
import logging
from fastapi import APIRouter
from app.api.deps import CurrentUser, DbSession
from app.repositories import invite as crud_invite
from app.repositories import savings as crud_savings
from app.schemas.order import OrderReportOut, OrderReportRequest
logger = logging.getLogger("shagua.order")
router = APIRouter(prefix="/api/v1/order", tags=["order"])
@@ -15,6 +20,20 @@ router = APIRouter(prefix="/api/v1/order", tags=["order"])
def report_order(req: OrderReportRequest, user: CurrentUser, db: DbSession) -> OrderReportOut:
# 记账唯一真相表是 savings_record(source='compare')。
rec, duplicated = crud_savings.create_from_report(db, user.id, req)
# 邀请 v2 发奖:被邀请人【实际下单】(而非仅完成比价)才给邀请人发邀请奖励金(幂等,只发一次)。
# 触发点从"比价成功上报"挪到这里(冰:完成比价并实际下单才算成功)。仅首次真实上报触发,
# 重复上报(duplicated)跳过。best-effort:发奖异常不影响订单上报本身(rec 已 commit),只 log;
# try_reward_on_compare 自带 compare_reward_granted 幂等闸,漏发靠后续对账补。
if not duplicated:
try:
reward = crud_invite.try_reward_on_compare(db, user.id)
if reward.status == "granted":
logger.info(
"invite order reward granted inviter=%s invitee=%s cents=%s",
reward.inviter_user_id, user.id, reward.reward_cents,
)
except Exception as e: # noqa: BLE001 best-effort,发奖失败不阻塞订单上报
logger.warning("invite order reward failed invitee=%s: %s", user.id, e)
return OrderReportOut(
id=rec.id,
platform=rec.platform or req.platform,
+2 -1
View File
@@ -68,7 +68,7 @@ class Settings(BaseSettings):
# 无障碍保护存活监控后台任务(pull 后置检测;本期不接推送)
HEARTBEAT_MONITOR_ENABLED: bool = True # 总开关
HEARTBEAT_TIMEOUT_MINUTES: int = 60 # 多久没心跳算掉线(1 小时,避免短暂离线误判被杀)
HEARTBEAT_TIMEOUT_MINUTES: int = 10 # 多久没心跳算掉线(≈3 个客户端心跳周期)
HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期
# ===== 短信 =====
@@ -81,6 +81,7 @@ class Settings(BaseSettings):
SMS_SIGN_ID: int = 31729 # 极光短信签名 ID(非机密,可被 .env 覆盖)
SMS_TEMPLATE_ID: int = 1 # 极光短信模板 ID(变量名 code,有效期 5 分钟)
SMS_CODE_LENGTH: int = 6 # 验证码位数(本服务生成;前端 code 字段 4-8 位兼容)
SMS_DAILY_LIMIT_PER_PHONE: int = 10 # 单手机号每日发送上限(防刷 + 控费)
SMS_MAX_VERIFY_ATTEMPTS: int = 5 # 单个验证码最多校验失败次数,超过即作废(防爆破)
# ===== 测试账号(release 包全流程联调用)=====
+25 -8
View File
@@ -8,16 +8,15 @@
校验) 鉴权复用极光一键登录的 `JG_APP_KEY`/`JG_MASTER_SECRET`(同一极光应用)
验证码存储:**进程内存**( worker uvicorn 够用)重启丢失(用户重发即可)
worker / 多机时内存不共享 冷却校验都会失效,届时迁移到 DB/Redis
worker / 多机时内存不共享 冷却每日上限校验都会失效,届时迁移到 DB/Redis
docs/待办与技术债.md
防刷(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
防刷(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)
2. 设备(device_id)每小时频控(api auth.sms_send enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)
2. 号每日 `SMS_DAILY_LIMIT_PER_PHONE` 条上限(本文件)
3. 单设备(device_id)每小时频控(api auth.sms_send enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)
IP 频控(rate_limit 依赖)2026-06-26 按产品要求删除改设备维度; device_id 客户端可伪造/轮换,
脚本轮换 id 能绕过本层 挡脚本狂发主要靠极光控制台侧(+ 可选 nginx 限流)
单号每日上限2026-07-03 按精简要求删除(mentor :登录风控只留单号冷却 + 单设备频控);
单号维度现仅剩 60s 冷却,换号轰炸由单设备频控封顶
:单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废(防爆破),验过即作废(一次性)
"""
from __future__ import annotations
@@ -27,6 +26,7 @@ import logging
import secrets
import time
from dataclasses import dataclass
from datetime import datetime
from threading import Lock
import httpx
@@ -56,17 +56,22 @@ class _CodeRecord:
# 进程内存(单 worker 有效;多 worker 不共享,见模块 docstring)
_codes: dict[str, _CodeRecord] = {} # phone -> 当前有效验证码
_last_sent: dict[str, float] = {} # phone -> 上次发送 epoch(冷却)
_daily_count: dict[str, tuple[str, int]] = {} # phone -> (date_str, 当日发送数)
_lock = Lock()
_GC_THRESHOLD = 10000 # 任一内存 dict 超此阈值,send 时顺手清过期项(防无限增长,仿 ratelimit)
def _today() -> str:
return datetime.now().strftime("%Y-%m-%d")
def _gen_code() -> str:
"""生成 N 位数字验证码(用 secrets 而非 random;允许前导 0)。"""
return "".join(secrets.choice("0123456789") for _ in range(settings.SMS_CODE_LENGTH))
def _gc(now: float) -> None:
"""顺手清理过期内存项,防个 dict 无限增长。仅在持锁时调用,且某 dict 超
"""顺手清理过期内存项,防个 dict 无限增长。仅在持锁时调用,且某 dict 超
_GC_THRESHOLD 才扫它(低频,开销可忽略)"""
if len(_codes) > _GC_THRESHOLD:
for p in [p for p, r in _codes.items() if now > r.expires_at]:
@@ -75,6 +80,10 @@ def _gc(now: float) -> None:
cutoff = now - settings.SMS_SEND_INTERVAL_SEC
for p in [p for p, ts in _last_sent.items() if ts < cutoff]:
_last_sent.pop(p, None)
if len(_daily_count) > _GC_THRESHOLD:
today = _today()
for p in [p for p, (d, _c) in _daily_count.items() if d != today]:
_daily_count.pop(p, None)
def send_code(phone: str) -> int:
@@ -93,9 +102,17 @@ def send_code(phone: str) -> int:
remain = int(settings.SMS_SEND_INTERVAL_SEC - elapsed)
raise SmsError(f"发送过于频繁,请 {remain}s 后再试")
today = _today()
day, cnt = _daily_count.get(phone, ("", 0))
if day != today:
cnt = 0
if cnt >= settings.SMS_DAILY_LIMIT_PER_PHONE:
raise SmsError("今日验证码发送次数已达上限,请明天再试")
code = _gen_code()
# 预占:先记冷却/存码,释放锁后再发网络(发失败保留冷却,见下)
# 预占:先记冷却/计数/存码,释放锁后再发网络(发失败保留冷却+计数,见下)
_last_sent[phone] = now
_daily_count[phone] = (today, cnt + 1)
_codes[phone] = _CodeRecord(code=code, expires_at=now + settings.SMS_CODE_TTL_SEC)
# --- lock 外:真正发送(网络 IO 不持锁)---
@@ -106,7 +123,7 @@ def send_code(phone: str) -> int:
_send_via_jiguang(phone, code)
logger.info("[SMS] sent to %s****", phone[:3])
except Exception as e:
# 发送失败:**保留冷却**(失败也限速,挡住余额不足/签名失效时
# 发送失败:**保留冷却 + 每日计数**(失败也限速,挡住余额不足/签名失效时
# 前端重试狂打极光),只清掉没发出去的码(用户收不到,留着无意义且占内存)。
with _lock:
_codes.pop(phone, None)
-11
View File
@@ -23,14 +23,6 @@ class Feedback(Base):
)
content: Mapped[str] = mapped_column(Text, 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
images: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
# 提交时的端环境快照(admin 排查用;客户端改版带上后的新反馈才有,历史数据为 NULL)
@@ -44,9 +36,6 @@ class Feedback(Base):
reward_coins: Mapped[int | None] = mapped_column(Integer, 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(
Integer, ForeignKey("admin_user.id"), nullable=True
)
-4
View File
@@ -17,8 +17,6 @@ def create_feedback(
content: str,
contact: str,
images: list[str] | None,
source: str = "profile",
scene: str | None = None,
app_version: str | None = None,
device_model: str | None = None,
rom_name: str | None = None,
@@ -28,8 +26,6 @@ def create_feedback(
user_id=user_id,
content=content,
contact=contact,
source=source,
scene=scene or None,
images=images or None,
app_version=app_version or None,
device_model=device_model or None,
+175
View File
@@ -304,11 +304,186 @@ def get_invitees(
"avatar_url": u.avatar_url or u.wechat_avatar_url or None,
"coins": rel.inviter_coin, # v3 起恒 0(邀请人收益改走邀请奖励金)
"invited_at": rel.created_at,
# 是否已完成过一次比价(= 已发过邀请奖励金)。客户端据此:好友列表分"去提醒/邀请成功"、
# 在途列表只取未比价(is_compared=False)、算在途好友数与在途收益(未比价数×2元)。
"is_compared": bool(rel.compare_reward_granted),
})
has_more = offset + len(rows) < int(total)
return items, int(total), has_more
# ===== 开发用:塞虚拟好友(仅非生产,方便真机测在途/好友列表 UI)=====
def seed_fake_invitees(db: Session, inviter_id: int, *, pending: int = 3, compared: int = 2) -> int:
"""给 inviter 造 pending 个"未比价"+ compared 个"已比价"的虚拟被邀请好友(仅测试用)。
造真实 User(随机手机号/昵称/头像)+ InviteRelation;已比价的顺带发邀请奖励金(走真实入账,
这样"可提现余额/累计"也有数)仅非生产环境开放(endpoint 层用 is_prod )返回新建好友数
"""
from datetime import datetime, timedelta, timezone
from app.repositories import user as user_repo
# 造点像样的中文昵称 + 打包进 APK 的真人头像(asset://,客户端转 file:///android_asset/ 本地加载,
# 离线必显、不依赖外网,避免 dicebear 这类国外图在国内加载失败成空白)。头像图见客户端
# app/src/main/assets/avatars/fake/p0..p7.jpg,按序号轮询分配。
names = ["小满同学", "一罐可乐Colour", "草原上感受宁静者", "晴天小豆", "momo", "隔壁老王",
"爱吃辣的猫", "月半子", "阿柴", "橘子汽水"]
FAKE_AVATAR_COUNT = 8 # 与客户端 assets/avatars/fake/ 图数一致
created = 0
total = pending + compared
now = datetime.now(timezone.utc)
for i in range(total):
is_compared = i >= pending
# 唯一手机号:99 开头 + 9 位随机(避开真实号段,不与真人撞)
phone = "99" + "".join(secrets.choice("0123456789") for _ in range(9))
nickname = names[i % len(names)] + secrets.choice(["", "", "_", str(secrets.randbelow(99))])
u = User(
phone=phone,
username=user_repo._gen_unique_username(db),
nickname=nickname,
register_channel="fake_seed",
avatar_url=f"asset://avatars/fake/p{i % FAKE_AVATAR_COUNT}.jpg", # 打包头像,轮询分配
status="active",
)
db.add(u)
db.flush() # 拿 u.id
rel = InviteRelation(
inviter_user_id=inviter_id,
invitee_user_id=u.id,
channel="fake_seed",
status="effective",
inviter_coin=0,
invitee_coin=0,
# 邀请时间错开,便于看"时间近→远"排序
created_at=(now - timedelta(hours=i * 6)).replace(tzinfo=None),
)
if is_compared:
# 已比价 → 走真实"好友比价发奖"链路:标记 + 真发 ¥2 进邀请奖励金账户(invite_cash),
# 让"可提现余额/累计/人数/流水/提现记录"全链路真实变化,可验证功能正确。
# (钱只进 invite_cash,不碰金币现金账户;清理 DELETE /seed-fake 会原路退回。)
rel.compare_reward_granted = True
rel.compare_reward_cents = rewards.INVITE_COMPARE_REWARD_CENTS
rel.compare_rewarded_at = now
db.add(rel)
db.flush()
if is_compared:
crud_wallet.grant_invite_cash(
db, inviter_id, rewards.INVITE_COMPARE_REWARD_CENTS,
biz_type="invite_reward", ref_id=str(u.id), remark="虚拟好友比价奖励(测试)",
)
created += 1
db.commit()
return created
def clear_fake_invitees(db: Session, inviter_id: int) -> int:
"""删掉该 inviter 名下所有 channel=fake_seed 的虚拟好友关系 + 对应虚拟 User(测试收尾)。
已发过邀请奖励金的假关系(compare_reward_granted),删除时必须把当初 grant 的钱**原路退回**
(负数 grant_invite_cash),否则假好友清掉了发进钱包的奖励金却残留 invite_cash 余额脏账
"""
rels = db.execute(
select(InviteRelation).where(
InviteRelation.inviter_user_id == inviter_id,
InviteRelation.channel == "fake_seed",
)
).scalars().all()
n = 0
for rel in rels:
# 先退钱:该关系当初发过奖励金 → 反向扣回同额,钱包不留残
if rel.compare_reward_granted and rel.compare_reward_cents:
crud_wallet.grant_invite_cash(
db, inviter_id, -rel.compare_reward_cents,
biz_type="invite_reward", ref_id=str(rel.invitee_user_id),
remark="虚拟好友清理·奖励金退回(测试)",
)
u = db.get(User, rel.invitee_user_id)
db.delete(rel)
if u is not None and u.register_channel == "fake_seed":
db.delete(u)
n += 1
db.commit()
return n
def dev_reset_test_account(db: Session, phone: str) -> dict:
"""[DEV] 把固定测试号打回"从没被邀请/没下过单"的出厂态,以便反复当新用户测发奖。
清理范围(全清, 2026-07-02 拍板): user
- invite_relation(作为被邀请人的那条 = already_bound;作为邀请人的那些)
- invite_cash_transaction / coin_account 三个余额归零(invite_cash / coin / cash)
- savings_record(= order/report duplicated 幂等)
- invite_fingerprint(作为邀请人落的指纹)
- coin_transaction / cash_transaction / withdraw_order(彻底出厂)
并把 user.created_at 刷成 now(UTC) 骗过 bind "新用户 72h 窗口"(_is_new_user),
否则该号第一次跑之后 created_at 固定 72h 即使清了关系也会 not_eligible
仅供 dev-reset 端点调用(端点已 is_prod404 + 只认配置的测试号)返回各表清理条数
user 不存在(还没登录过) 返回全 0,不报错(下一步登录会新建,天然新号)
"""
from datetime import datetime, timezone
from app.models.invite_fingerprint import InviteFingerprint
from app.models.savings import SavingsRecord
from app.models.wallet import (
CashTransaction,
CoinAccount,
CoinTransaction,
InviteCashTransaction,
WithdrawOrder,
)
user = db.execute(select(User).where(User.phone == phone)).scalar_one_or_none()
if user is None:
return {"user_found": False}
uid = user.id
def _del(model, whereclause) -> int:
rows = db.execute(select(model).where(whereclause)).scalars().all()
for r in rows:
db.delete(r)
return len(rows)
cleared = {
"user_found": True,
"user_id": uid,
"invite_relation_as_invitee": _del(
InviteRelation, InviteRelation.invitee_user_id == uid
),
"invite_relation_as_inviter": _del(
InviteRelation, InviteRelation.inviter_user_id == uid
),
"invite_cash_transaction": _del(
InviteCashTransaction, InviteCashTransaction.user_id == uid
),
"coin_transaction": _del(CoinTransaction, CoinTransaction.user_id == uid),
"cash_transaction": _del(CashTransaction, CashTransaction.user_id == uid),
"withdraw_order": _del(WithdrawOrder, WithdrawOrder.user_id == uid),
"invite_fingerprint": _del(
InviteFingerprint, InviteFingerprint.inviter_user_id == uid
),
"savings_record": _del(SavingsRecord, SavingsRecord.user_id == uid),
}
# 余额账户归零(保留行,只清三个余额;没有账户行则跳过)
acc = db.get(CoinAccount, uid)
if acc is not None:
acc.invite_cash_balance_cents = 0
acc.coin_balance = 0
acc.cash_balance_cents = 0
cleared["coin_account_zeroed"] = True
else:
cleared["coin_account_zeroed"] = False
# 刷 created_at 骗过 72h 新用户闸(存 naive UTC,与登录新建 user 的写法一致)
user.created_at = datetime.now(timezone.utc).replace(tzinfo=None)
cleared["created_at_refreshed"] = True
db.commit()
return cleared
# ===== 指纹归因(剪贴板兜底,见 [[invite-three-tasks]] 任务 3)=====
def record_fingerprint(
+3
View File
@@ -296,6 +296,9 @@ def daily_auto_exchange(db: Session) -> dict:
- **逐用户独立事务**:单个用户异常 rollback 不影响其他人;exchange_coins_to_cash 内部按用户 commit
返回统计 dict(scanned/converted/skipped_done/skipped_dust/failed/total_cents)
"""
# ⚠️「今天」写死北京时(rewards.cn_today() = datetime.now(CN_TZ).date(), CN_TZ=+8),与服务器/用户
# 时区无关(用户 2026-07-01 硬约束:兑换一律北京 0 点日切)。**禁止**改成 date.today() / 裸
# datetime.now().date()——那会跟随进程本地时区,服务器非 CST 时会在错误的"天"兑/重复兑。
today = rewards.cn_today()
stats = {
"scanned": 0, "converted": 0, "skipped_done": 0,
-4
View File
@@ -30,14 +30,10 @@ class FeedbackQrConfigOut(BaseModel):
class FeedbackRecordOut(BaseModel):
id: int
content: str
# 比价反馈的问题场景(找错商品/优惠不对…);普通反馈为 None
scene: str | None = None
images: list[str] = Field(default_factory=list)
status: str
reject_reason: str | None = None
reward_coins: int | None = None
# 运营给用户的回复留言(用户端可见,采纳/未采纳都可能有)
admin_reply: str | None = None
created_at: datetime
+1
View File
@@ -69,6 +69,7 @@ class InviteeItem(BaseModel):
avatar_url: str | None = None # 头像 URL;null = 前端画默认色块
coins: int # 这次邀请给我(邀请人)发的金币
invited_at: datetime # 邀请绑定时间(前端转"今天/3天前")
is_compared: bool = False # 该好友是否已完成过一次比价(好友列表分"去提醒/邀请成功";在途列表只取 False)
class InviteeListOut(BaseModel):
+3
View File
@@ -19,6 +19,9 @@ Type=oneshot
User=root
WorkingDirectory=/opt/shaguabijia-app-server
Environment="PATH=/opt/shaguabijia-app-server/.venv/bin:/usr/bin:/bin"
# 写死北京时:兑换的"当天/0 点"一律按北京时,不随服务器 OS 时区漂(用户 2026-07-01 硬约束)。
# 脚本内的日期判断本就走 rewards.cn_today()(CN_TZ=+8),这里再把进程 TZ 也钉成北京,双保险。
Environment="TZ=Asia/Shanghai"
EnvironmentFile=/opt/shaguabijia-app-server/.env
ExecStart=/opt/shaguabijia-app-server/.venv/bin/python -m scripts.daily_auto_exchange --once
SyslogIdentifier=daily-exchange
+4 -2
View File
@@ -4,8 +4,10 @@
Description=Run daily auto-exchange coins->cash at midnight
[Timer]
# 每天 0 点跑。客户端文案已注明「可能存在延迟」,可按需改 00:05 错开整点扎堆
OnCalendar=*-*-* 00:00:00
# 每天【北京时】0 点跑,写死时区(用户 2026-07-01 硬约束:兑换一律北京 0 点,不随服务器本地时区漂)
# systemd OnCalendar 支持尾缀时区;不写时区会按服务器 OS 本地时区触发 → 服务器非 CST 时会在错误时刻兑。
# 客户端文案已注明「可能存在延迟」,可按需改 00:05 错开整点扎堆。
OnCalendar=*-*-* 00:00:00 Asia/Shanghai
# 服务器宕机/重启后,补跑错过的那一轮(而不是干等次日)。
Persistent=true
AccuracySec=1min
+9 -8
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 比对存码,匹配即作废,失败累计到上限作废 |
| `SmsError(msg, status_code)` | `status_code` 决定 HTTP 码:过频 **429**、供应商失败 **503**、号码无效 **400** |
| `SmsError(msg, status_code)` | `status_code` 决定 HTTP 码:过频/每日超限 **429**、供应商失败 **503**、号码无效 **400** |
## 配置
| 配置项 | 默认 | 说明 |
@@ -30,16 +30,17 @@
| `SMS_SIGN_ID` | 31729 | 极光签名 ID |
| `SMS_TEMPLATE_ID` | 1 | 极光模板 ID(变量名 `code`) |
| `SMS_CODE_LENGTH` | 6 | 验证码位数(本服务生成;前端 code 4-8 位兼容) |
| `SMS_DAILY_LIMIT_PER_PHONE` | 10 | 单号每日发送上限(防刷 + 控费) |
| `SMS_MAX_VERIFY_ATTEMPTS` | 5 | 单码最多校验失败次数,超过作废(防爆破) |
> **鉴权复用极光一键登录**:`/v1/messages``base64(JG_APP_KEY:JG_MASTER_SECRET)` 做 HTTP Basic Auth——短信与一键登录是**同一个极光应用**(同 AppKey)。**上线不需要额外凭证,只需 `SMS_MOCK=false`**(`JG_*` 一键登录已配)。
## 防刷(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权)
> 2026-07-03 精简:登录风控只留「单号冷却 + 单设备频控」两道主策略(删单号每日上限、删登录纯 IP `rate_limit`);单码失败上限属验证码安全底线,保留。
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(单号维度)
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`
3. 单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废 + 验过即作废(一次性)
4. 运维侧建议在极光控制台叠加:**IP 白名单**(只许服务器 IP)+ **防轰炸设置**
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却
2. 单号每日 `SMS_DAILY_LIMIT_PER_PHONE` 条上限
3. 单 IP 频控:`/sms/send` `rate_limit(10,60)``/sms/login` `rate_limit(20,60)`
4. 单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废
5. 运维侧建议在极光控制台叠加:**IP 白名单**(只许服务器 IP)+ **防轰炸设置**
## 极光错误码(节选,映射在 `_send_via_jiguang`)
| code | 含义 | 处理 |
@@ -55,4 +56,4 @@
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/重启即失效)。
**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)。
**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)。
### 4.3 Token 与刷新
+141
View File
@@ -0,0 +1,141 @@
# 邀请发奖真环境验证脚本(方案①:纯后端)—— PowerShell 版 ·【192 局域网 debug 环境】
# ==============================================================================
# 证明:好友 B 实际下单(POST /api/v1/order/report)→ 邀请人 A 到账 2 元(invite_cash),
# 且「只绑定不下单不发」「重复不重发」「无关系不发」;并证明固定测试号 B 经
# dev-reset 后可【反复】当新用户,再次触发发奖。
# 不用真下单 / 不用付钱 / 不用无障碍 / 不用第二台设备。
#
# 打的是本地局域网 debug 环境(默认 http://192.168.0.144:8770 = 本机 LAN IP)。
# 两个前提:① SMS_MOCK=true(默认) → 任意 6 位验证码通过;② .env 配了
# TEST_ACCOUNT_PHONE=11111111111 并【重启过】app-server(B 靠它免验证码 + dev-reset)。
#
# 用法(PowerShell):
# .\invite_reward_e2e.ps1 # 默认 192.168.0.144:8770
# .\invite_reward_e2e.ps1 -BaseUrl http://127.0.0.1:8770
# ==============================================================================
param(
# 目标环境:本地局域网 debug :8770(默认本机 LAN IP)
[string]$BaseUrl = "http://192.168.0.144:8770"
)
$ErrorActionPreference = "Stop"
$BaseUrl = $BaseUrl.TrimEnd('/')
# B 固定用测试号(免验证码 + 可 dev-reset 反复当新用户)。必须与 .env 的 TEST_ACCOUNT_PHONE 一致。
$phoneB = "11111111111"
# A(邀请人)/ X(无关系)用带时间戳的新号。
$stamp = [int][double]::Parse((Get-Date -UFormat %s))
$suffix = ($stamp % 100000000)
$phoneX = "139" + ("{0:D8}" -f $suffix)
$expectCents = 200 # = INVITE_COMPARE_REWARD_CENTS(2 元)
Write-Host "======================================================================"
Write-Host " 邀请发奖·192 局域网 debug 验证 BASE_URL=$BaseUrl"
Write-Host " B(固定测试号·被邀请人)=$phoneB X(无关系·下单)=$phoneX"
Write-Host "======================================================================"
# ---- HTTP 小工具(Invoke-RestMethod 自动解析 JSON)---------------------------
function Invoke-Api {
param([string]$Method, [string]$Path, $Body, [string]$Token)
$headers = @{ "Content-Type" = "application/json; charset=utf-8" }
if ($Token) { $headers["Authorization"] = "Bearer $Token" }
$req = @{ Method = $Method; Uri = "$BaseUrl$Path"; Headers = $headers }
if ($null -ne $Body) {
# 用 UTF8 字节发,保证「美团」等中文不乱码
$json = ($Body | ConvertTo-Json -Compress)
$req["Body"] = [System.Text.Encoding]::UTF8.GetBytes($json)
}
return Invoke-RestMethod @req
}
# 登录:发码(mock 不真发)→ 用 123456 登录 → 回 access_token
# 唯一 device_id 避开「device_id+IP 每小时 5 次」限流(测试号 B 本不受限,统一带上无害)。
function Login {
param([string]$Phone)
$dev = "e2e-$Phone-$stamp"
Invoke-Api POST "/api/v1/auth/sms/send" @{ phone = $Phone; device_id = $dev } | Out-Null
$r = Invoke-Api POST "/api/v1/auth/sms/login" @{ phone = $Phone; code = "123456"; device_id = $dev }
if (-not $r.access_token) { throw "登录失败 phone=$Phone" }
return $r.access_token
}
# 把固定测试号 B 打回出厂态(清绑定/发奖/savings + 刷 created_at 骗过 72h 闸)。无需鉴权。
function Reset-TestAccount {
try {
$r = Invoke-Api POST "/api/v1/invite/dev-reset" @{}
} catch {
throw "dev-reset 失败:$($_.Exception.Message) (检查 app-server 是否用最新代码重启?.env 是否配了 TEST_ACCOUNT_PHONE 并重启?)"
}
if ($r.status -ne "ok") { throw "dev-reset 返回非 ok:$($r | ConvertTo-Json -Compress)" }
}
function Get-Reward { param([string]$Tok) (Invoke-Api GET "/api/v1/invite/me" $null $Tok).reward_balance_cents }
function Get-MyCode { param([string]$Tok) (Invoke-Api GET "/api/v1/invite/me" $null $Tok).invite_code }
function Bind-To { param([string]$Tok,[string]$Code) Invoke-Api POST "/api/v1/invite/bind" @{ invite_code = $Code } $Tok }
function Report-Order {
param([string]$Tok,[string]$EventId)
Invoke-Api POST "/api/v1/order/report" @{
client_event_id = $EventId
platform = "美团"
pay_channel = "wechat"
compared_price_cents = 2016
paid_amount_cents = 2016
} $Tok | Out-Null
}
# ---- 断言 -------------------------------------------------------------------
$script:pass = 0; $script:fail = 0
function Assert-Eq {
param($Expected, $Actual, [string]$Desc)
if ("$Expected" -eq "$Actual") { Write-Host "$Desc (=$Actual)"; $script:pass++ }
else { Write-Host "$Desc 期望=$Expected 实得=$Actual" -ForegroundColor Red; $script:fail++ }
}
# ---- 一轮完整发奖验证:reset B → 新 A 绑定 → B 下单 → A 到账 -----------------
function Invoke-RewardCycle {
param([int]$Tag)
$phoneA = "138" + ("{0:D8}" -f (($suffix + $Tag) % 100000000))
Write-Host "`n===== 第 $Tag 轮:A=$phoneA 邀请固定号 B=$phoneB ====="
Reset-TestAccount # 把 B 打回新用户态
$tokA = Login $phoneA
$tokB = Login $phoneB
$codeA = Get-MyCode $tokA
Write-Host " A 邀请码=$codeA;B 已 dev-reset 回新用户"
Assert-Eq 0 (Get-Reward $tokA) "[$Tag] 发奖前 A.invite_cash"
$bind = Bind-To $tokB $codeA
Write-Host " bind → status=$($bind.status)(期望 success)"
Assert-Eq 0 (Get-Reward $tokA) "[$Tag] 仅绑定后 A.invite_cash(绑定不发)"
Report-Order $tokB "evt-$stamp-$Tag-1"
Assert-Eq $expectCents (Get-Reward $tokA) "[$Tag] B 首单后 A.invite_cash(下单才发)"
Report-Order $tokB "evt-$stamp-$Tag-1" # 重复同 event
Report-Order $tokB "evt-$stamp-$Tag-2" # 不同 event 第二笔
Assert-Eq $expectCents (Get-Reward $tokA) "[$Tag] B 重复/多次上报后 A.invite_cash(只发一次)"
}
# ============================== 开跑 =========================================
# 第 1、2 轮:同一个固定号 B 反复当新用户各拿一次发奖(证明 dev-reset 让它可反复用)。
Invoke-RewardCycle 1
Invoke-RewardCycle 2
# 无关系用户下单 → 不发奖
Write-Host "`n===== 无关系校验:X 没绑定任何人,下单不应发奖 ====="
$tokX = Login $phoneX
Report-Order $tokX "evt-$stamp-x"
Assert-Eq 0 (Get-Reward $tokX) "无邀请关系 X 下单后 X.invite_cash(不发)"
# ============================== 结果 =========================================
Write-Host "`n======================================================================"
if ($script:fail -eq 0) {
Write-Host " ✅ 全部通过($script:pass 项):下单才发奖 / 绑定不发 / 重复不重发 / 无关系不发" -ForegroundColor Green
Write-Host " 且固定号 B 经 dev-reset 后【反复】当新用户两轮均发奖成功。" -ForegroundColor Green
exit 0
} else {
Write-Host " ❌ 有 $($script:fail) 项未通过(通过 $($script:pass) 项)。看上面 ✗ 行。" -ForegroundColor Red
exit 1
}
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
# 邀请发奖真环境验证脚本(方案①:纯后端 curl)——【192 局域网 debug 包环境】
# ------------------------------------------------------------------------------
# 证明:好友 B 实际下单(POST /api/v1/order/report)→ 邀请人 A 到账 2 元(invite_cash),
# 且「只绑定不下单不发」「重复上报不重发」「无邀请关系不发」;
# 并证明固定测试号 B 经 dev-reset 后可【反复】当新用户,再次触发发奖。
# 不用真下单 / 不用付钱 / 不用无障碍 / 不用第二台设备。
#
# 打的是本地局域网 debug 环境(默认 http://192.168.0.144:8770 = 本机 LAN IP)。
# 两个前提(不满足脚本白跑):
# ① SMS_MOCK=true(默认即是)→ /sms/login 放行任意 6 位验证码,故用 123456 拿 token。
# ② .env 里配了 TEST_ACCOUNT_PHONE=11111111111 并【重启过】app-server —— B 用它免验证码
# 登录,且脚本靠 POST /invite/dev-reset 每轮把它打回新用户态。
#
# 用法:
# ./invite_reward_e2e.sh # 用默认 192.168.0.144:8770
# ./invite_reward_e2e.sh http://192.168.0.144:8770 # 位置参数传 base
# BASE_URL=http://127.0.0.1:8770 ./invite_reward_e2e.sh
# 依赖:仅 curl(JSON 用 grep/sed 抽字段,不依赖 python/jq —— Windows Git Bash 里
# python3 常是 WindowsApps 假 stub,故意不用)。
# ==============================================================================
set -euo pipefail
# ---- 目标环境:本地局域网 debug :8770(改成你的开发机内网 IP)------------------
BASE_URL="${1:-${BASE_URL:-http://192.168.0.144:8770}}"
BASE_URL="${BASE_URL%/}" # 去掉结尾多余的 /
# B 固定用测试号(免验证码 + 可 dev-reset 反复当新用户)。必须与 .env 的 TEST_ACCOUNT_PHONE 一致。
PHONE_B="11111111111"
# A(邀请人)/ X(无关系)每轮用带时间戳的新号:A 需从 0 余额验起、X 只要没关系即可。
STAMP="$(date +%s)"
SUFFIX="${STAMP: -8}"
PHONE_X="139${SUFFIX}"
EXPECT_CENTS=200 # = INVITE_COMPARE_REWARD_CENTS(2 元)
echo "======================================================================"
echo " 邀请发奖·192 局域网 debug 验证 BASE_URL=$BASE_URL"
echo " B(固定测试号·被邀请人)=$PHONE_B X(无关系·下单)=$PHONE_X"
echo "======================================================================"
# ---- 小工具:从 JSON 取字段(纯 grep/sed,不依赖 python/jq)------------------
# 用法: echo "$json" | jget access_token
# 支持字符串值("key":"val")与数字值("key":123);只取顶层首个匹配,够本脚本用。
jget() {
local key="$1" body; body="$(cat)"
local v
v="$(printf '%s' "$body" | grep -o "\"$key\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed -E "s/.*:[[:space:]]*\"([^\"]*)\"/\1/")"
if [ -n "$v" ]; then printf '%s' "$v"; return; fi
printf '%s' "$body" | grep -o "\"$key\"[[:space:]]*:[[:space:]]*-\?[0-9]\+" | head -1 | sed -E "s/.*:[[:space:]]*(-?[0-9]+)/\1/"
}
# ---- POST JSON:body 走 stdin + --data-binary,不内联 -d --------------------
# ⚠️ Windows Git Bash 里内联 -d "{...美团...}" 会把中文 UTF-8 字节搞乱 → 后端
# "error parsing the body"。把 body 从 stdin 以原始字节喂进去可避开。
post_json() {
local path="$1" token="$2"
local auth=()
[ -n "$token" ] && auth=(-H "Authorization: Bearer $token")
curl -sS -X POST "$BASE_URL$path" \
-H 'Content-Type: application/json' "${auth[@]}" \
--data-binary @-
}
# ---- 登录:发码(mock 不真发)→ 用 123456 登录 → 回 access_token -----------
# 每个号带唯一 device_id(=phone+stamp):登录/发码限流是「device_id+IP 每小时 5 次」,
# 空 device_id 会让本机所有请求挤同一桶,自己把自己限流掉。给唯一 id 各走各的桶。
# (测试号 B 本就不受该限流约束,但带上 device_id 无害、统一处理。)
login() {
local phone="$1"
local dev="e2e-${phone}-${STAMP}"
post_json /api/v1/auth/sms/send "" <<< "{\"phone\":\"$phone\",\"device_id\":\"$dev\"}" >/dev/null
local resp
resp="$(post_json /api/v1/auth/sms/login "" <<< "{\"phone\":\"$phone\",\"code\":\"123456\",\"device_id\":\"$dev\"}")"
local tok; tok="$(printf '%s' "$resp" | jget access_token)"
if [ -z "$tok" ]; then
echo "✗ 登录失败 phone=$phone,响应:$resp" >&2
exit 1
fi
printf '%s' "$tok"
}
# 把固定测试号 B 打回出厂态(清绑定/发奖/savings + 刷 created_at 骗过 72h 闸)。无需鉴权。
dev_reset() {
local resp; resp="$(post_json /api/v1/invite/dev-reset "" <<< '{}')"
if [ "$(printf '%s' "$resp" | jget status)" != "ok" ]; then
echo "✗ dev-reset 失败,响应:$resp" >&2
echo " (检查:app-server 是否已用最新代码重启?.env 是否配了 TEST_ACCOUNT_PHONE 并重启?)" >&2
exit 1
fi
}
# GET /invite/me 的某个字段(邀请码 / 奖励金余额都从这里读)
invite_me() { curl -sS "$BASE_URL/api/v1/invite/me" -H "Authorization: Bearer $1"; }
my_code() { invite_me "$1" | jget invite_code; }
reward() { invite_me "$1" | jget reward_balance_cents; }
bind_to() { post_json /api/v1/invite/bind "$1" <<< "{\"invite_code\":\"$2\"}"; }
report_order() {
post_json /api/v1/order/report "$1" <<< "{\"client_event_id\":\"$2\",\"platform\":\"美团\",\"pay_channel\":\"wechat\",\"compared_price_cents\":2016,\"paid_amount_cents\":2016}"
}
# ---- 断言 -------------------------------------------------------------------
PASS=0; FAIL=0
assert_eq() { # 期望 实得 说明
if [ "$1" = "$2" ]; then echo "$3 (=$2)"; PASS=$((PASS+1));
else echo "$3 期望=$1 实得=$2"; FAIL=$((FAIL+1)); fi
}
# ---- 一轮完整发奖验证:reset B → 新 A 绑定 → B 下单 → A 到账 -----------------
# $1=轮次标签(用于 event_id 唯一 + 展示)。A 每轮用不同新号(从 0 验起)。
run_reward_cycle() {
local tag="$1"
local phone_a="138$(printf '%08d' $(( (10#$SUFFIX + tag) % 100000000 )) )"
echo; echo "===== 第 $tag 轮:A=$phone_a 邀请固定号 B=$PHONE_B ====="
dev_reset # 把 B 打回新用户态
local tok_a tok_b code_a
tok_a="$(login "$phone_a")"
tok_b="$(login "$PHONE_B")"
code_a="$(my_code "$tok_a")"
echo " A 邀请码=$code_a;B 已 dev-reset 回新用户"
assert_eq 0 "$(reward "$tok_a")" "[$tag] 发奖前 A.invite_cash"
local bind_resp; bind_resp="$(bind_to "$tok_b" "$code_a")"
echo " bind → status=$(printf '%s' "$bind_resp" | jget status)(期望 success)"
assert_eq 0 "$(reward "$tok_a")" "[$tag] 仅绑定后 A.invite_cash(绑定不发)"
report_order "$tok_b" "evt-$STAMP-$tag-1" >/dev/null
assert_eq "$EXPECT_CENTS" "$(reward "$tok_a")" "[$tag] B 首单后 A.invite_cash(下单才发)"
report_order "$tok_b" "evt-$STAMP-$tag-1" >/dev/null # 重复同 event
report_order "$tok_b" "evt-$STAMP-$tag-2" >/dev/null # 不同 event 的第二笔
assert_eq "$EXPECT_CENTS" "$(reward "$tok_a")" "[$tag] B 重复/多次上报后 A.invite_cash(只发一次)"
}
# ============================== 开跑 =========================================
# 第 1、2 轮:同一个固定号 B 反复当新用户各拿一次发奖(证明 dev-reset 让它可反复用)。
run_reward_cycle 1
run_reward_cycle 2
# 无关系用户下单 → 不发奖
echo; echo "===== 无关系校验:X 没绑定任何人,下单不应发奖 ====="
TOK_X="$(login "$PHONE_X")"
report_order "$TOK_X" "evt-$STAMP-x" >/dev/null
assert_eq 0 "$(reward "$TOK_X")" "无邀请关系 X 下单后 X.invite_cash(不发)"
# ============================== 结果 =========================================
echo
echo "======================================================================"
if [ "$FAIL" -eq 0 ]; then
echo " ✅ 全部通过($PASS 项):下单才发奖 / 绑定不发 / 重复不重发 / 无关系不发"
echo " 且固定号 B 经 dev-reset 后【反复】当新用户两轮均发奖成功。"
exit 0
else
echo " ❌ 有 $FAIL 项未通过(通过 $PASS 项)。看上面 ✗ 行。"
exit 1
fi
-61
View File
@@ -190,67 +190,6 @@ def test_feedback_list(admin_client: TestClient, admin_token: str) -> None:
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(
admin_client: TestClient, admin_token: str
) -> None:
-29
View File
@@ -368,35 +368,6 @@ def test_reject_feedback_records_reason_and_no_coin(
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) =====
def test_create_and_update_admin(admin_client: TestClient, super_token: str) -> None:
+22 -2
View File
@@ -17,6 +17,7 @@ def _reset(phone: str) -> None:
"""清该号的进程内存状态,隔离 real 模式用例。"""
sms._codes.pop(phone, None)
sms._last_sent.pop(phone, None)
sms._daily_count.pop(phone, None)
class _OkResp:
@@ -75,7 +76,7 @@ def test_sms_send_too_frequent(client) -> None:
def test_sms_send_device_ip_rate_limit(client, monkeypatch) -> None:
"""发码防刷:同一设备(device_id) + 同一 IP 每小时最多 N 次发码,超出 429。
用不同手机号(绕开单号 60s 冷却)证明限流按设备封顶 换号绕开单号冷却的洞
用不同手机号(绕开单号 60s 冷却)证明限流按设备封顶 换号绕开单号冷却/每日上限的洞
conftest 默认关限流;本用例临时打开 + 调小阈值便于测 + 清计数隔离"""
from app.api.v1 import auth
from app.core import ratelimit
@@ -154,6 +155,21 @@ def test_phone_format_validation(client) -> None:
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 路径(不真发)============================
def test_sms_real_send_calls_jiguang(monkeypatch) -> None:
@@ -239,20 +255,24 @@ def test_sms_real_balance_error_keeps_cooldown(monkeypatch) -> None:
def test_sms_gc_purges_stale_only(monkeypatch) -> None:
"""GC 清过期码 / 旧冷却,但不动今天有效的(阈值设 0 强制每次扫)。"""
"""GC 清过期码 / 旧冷却 / 隔日计数,但不动今天有效的(阈值设 0 强制每次扫)。"""
monkeypatch.setattr(sms, "_GC_THRESHOLD", 0)
sms._codes.clear()
sms._last_sent.clear()
sms._daily_count.clear()
now = time.time()
sms._codes["stale"] = sms._CodeRecord(code="111111", expires_at=now - 1)
sms._codes["fresh"] = sms._CodeRecord(code="222222", expires_at=now + 999)
sms._last_sent["old"] = now - 99999
sms._last_sent["recent"] = now
sms._daily_count["yesterday"] = ("2000-01-01", 3)
sms._daily_count["today"] = (sms._today(), 1)
sms._gc(now)
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 "yesterday" not in sms._daily_count and "today" in sms._daily_count
# ============================ 用户名 / 默认昵称 ============================
+82
View File
@@ -0,0 +1,82 @@
"""金币→现金「每日自动兑」时区回归测试。
用户 2026-07-01 硬约束:不管服务器 / 用户设成什么时区,兑换一律按北京时 0 日切
本测试锁死这个行为,防以后有人把 rewards.cn_today() 改回 date.today() / datetime.now()
(那会跟随进程本地时区,服务器非 CST 时在错误的""兑或重复兑)
"""
from __future__ import annotations
from datetime import datetime, timezone
from app.core import rewards
from app.core.rewards import COIN_PER_CENT
from app.db.session import SessionLocal
from app.repositories import wallet as crud_wallet
from app.repositories.user import get_user_by_phone
def _login(client, phone: str) -> str:
client.post("/api/v1/auth/sms/send", json={"phone": phone})
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
assert r.status_code == 200, r.text
return r.json()["access_token"]
def test_cn_today_is_beijing_date_regardless_of_utc_offset() -> None:
"""cn_today() 恒等于北京时的日期,与 UTC / 进程本地时区无关。
取一个 UTC 与北京跨天的时刻(北京已是次日 0 点后UTC 还在前一天)验证:
2026-07-01 15:30 UTC = 2026-07-01 23:30 北京(同一天);
2026-07-01 17:30 UTC = 2026-07-02 01:30 北京(北京已跨天)
"""
cst = timezone(rewards.CN_TZ.utcoffset(None))
# UTC 17:30 → 北京次日 01:30:北京日期应是 07-02
moment_utc = datetime(2026, 7, 1, 17, 30, tzinfo=timezone.utc)
assert moment_utc.astimezone(cst).date().isoformat() == "2026-07-02"
# 反过来 UTC 23:30 → 北京 07:30 次日:北京日期 07-02(证明 +8 偏移生效)
moment_utc2 = datetime(2026, 7, 1, 23, 30, tzinfo=timezone.utc)
assert moment_utc2.astimezone(cst).date().isoformat() == "2026-07-02"
# cn_today() 的实现就是 datetime.now(CN_TZ).date():与 UTC now 的北京投影一致
assert rewards.cn_today() == datetime.now(timezone.utc).astimezone(cst).date()
def test_daily_auto_exchange_idempotent_within_same_beijing_day(client) -> None:
"""同一北京日内多次触发只兑一次(幂等键 = 当天是否已有 exchange_in 北京日流水)。"""
token = _login(client, "13800007701")
assert token
with SessionLocal() as db:
user = get_user_by_phone(db, "13800007701")
assert user is not None
# 给 200 金币(= 2 分),够到分兑换
crud_wallet.grant_coins(db, user.id, 200, biz_type="test_grant")
db.commit()
first = crud_wallet.daily_auto_exchange(db)
assert first["converted"] == 1, first
assert first["total_cents"] == 2, first
# 当天再进账 200 金币,再触发一轮:因当天已有 exchange_in(北京日幂等键)→ 跳过,
# 不重复兑(这 200 留到次日北京 0 点)。若幂等键被改成本地时区,服务器非 CST 时这里会误兑。
crud_wallet.grant_coins(db, user.id, 200, biz_type="test_grant")
db.commit()
second = crud_wallet.daily_auto_exchange(db)
assert second["converted"] == 0, second
assert second["skipped_done"] >= 1, second
def test_has_exchange_in_on_keyed_on_given_beijing_day(client) -> None:
"""_has_exchange_in_on 以传入的北京日为界比较 [当天0点, 次日0点)。"""
token = _login(client, "13800007702")
assert token
with SessionLocal() as db:
user = get_user_by_phone(db, "13800007702")
assert user is not None
crud_wallet.grant_coins(db, user.id, 200, biz_type="test_grant")
db.commit()
crud_wallet.daily_auto_exchange(db)
today = rewards.cn_today()
assert crud_wallet._has_exchange_in_on(db, user.id, today) is True
# 昨天(北京日)不该命中今天兑的流水
yesterday = today.fromordinal(today.toordinal() - 1)
assert crud_wallet._has_exchange_in_on(db, user.id, yesterday) is False
-37
View File
@@ -36,43 +36,6 @@ def test_feedback_config_returns_default(client) -> None:
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:
phone = "13622000003"
token = _login(client, phone)
+6 -2
View File
@@ -73,9 +73,11 @@ def test_bind_flow_no_coins_either_side(client) -> None:
assert _coin_balance(client, b) == 0
assert _coin_balance(client, a) == 0
# A 的战绩:已邀 1 人;金币口径收益恒 0(邀请人收益走邀请奖励金,见 try_reward_on_compare)
# A 的战绩:此时 invited_count=0 —— 口径 = "完成过一次比价的好友数"(compare_reward_granted=True),
# 仅绑定未比价不计入(否则会出现"已邀 1、可提现余额 0",与产品口径"已邀×2=累计提现+余额"对不上;
# 见 get_stats 注释 + 需求 spec"邀请好友人数=邀请的完成一次比价的新用户数量")。金币口径收益恒 0。
r = client.get("/api/v1/invite/me", headers=_auth(a))
assert r.json()["invited_count"] == 1
assert r.json()["invited_count"] == 0
assert r.json()["coins_earned"] == 0
@@ -322,6 +324,8 @@ def test_invitees_basic(client) -> None:
assert all(it["avatar_url"] is None for it in body["items"])
# v2:绑定时邀请人那份为 0(收益改"好友比价才发奖"),故每条 coins 字段=0
assert all(it["coins"] == 0 for it in body["items"])
# 刚绑定还没比价 → is_compared 全 False(客户端据此:在途列表取这些、好友列表按钮"去提醒")
assert all(it["is_compared"] is False for it in body["items"])
def test_invitees_order_desc(client) -> None:
+98 -21
View File
@@ -1,5 +1,8 @@
"""邀请 v3 比价发奖测试:好友完成成功比价 → 邀请人得邀请奖励金(独立账户),幂等只发一次,
"""邀请 v3 发奖测试:好友完成比价【并实际下单】→ 邀请人得邀请奖励金(独立账户),幂等只发一次,
账户隔离不串金币/现金被邀请人绑定不得任何奖励由 test_invite.py 覆盖
触发口径(冰拍板):被邀请人仅完成比价发奖,须实际下单(客户端归因后 POST
/api/v1/order/report)才发故本文件用下单上报驱动发奖,而非比价上报
"""
from __future__ import annotations
@@ -30,10 +33,17 @@ def _bind(client, token: str, code: str):
return client.post("/api/v1/invite/bind", json={"invite_code": code}, headers=_auth(token))
def _report_compare(client, token: str, trace_id: str, status: str = "success"):
def _report_order(client, token: str, event_id: str):
"""上报一笔归因订单(视为实际下单)→ 触发邀请发奖。event_id 为幂等键。"""
return client.post(
"/api/v1/compare/record",
json={"trace_id": trace_id, "status": status},
"/api/v1/order/report",
json={
"client_event_id": event_id,
"platform": "美团",
"pay_channel": "wechat",
"compared_price_cents": 2016,
"paid_amount_cents": 2016,
},
headers=_auth(token),
)
@@ -46,58 +56,80 @@ def _invite_cash(phone: str) -> int:
return acc.invite_cash_balance_cents if acc else 0
def test_compare_reward_granted_to_inviter(client) -> None:
"""B 被 A 邀请后完成一次成功比价 → A 得邀请奖励金 2 元(独立账户),B 不得该奖。"""
def test_order_reward_granted_to_inviter(client) -> None:
"""B 被 A 邀请后实际下单一笔 → A 得邀请奖励金 2 元(独立账户),B 不得该奖。"""
a = _login(client, "13800003001")
b = _login(client, "13800003002")
_bind(client, b, _my_code(client, a))
assert _invite_cash("13800003001") == 0 # 发奖前
r = _report_compare(client, b, "trace-reward-1")
r = _report_order(client, b, "evt-reward-1")
assert r.status_code == 200, r.text
assert _invite_cash("13800003001") == INVITE_COMPARE_REWARD_CENTS # 邀请人到账
assert _invite_cash("13800003002") == 0 # 被邀请人不得此奖
def test_compare_reward_idempotent(client) -> None:
"""好友比价多次 → 只发一次(compare_reward_granted 幂等)。"""
def test_order_reward_idempotent(client) -> None:
"""好友下单多次 → 只发一次(compare_reward_granted 幂等)。"""
a = _login(client, "13800003003")
b = _login(client, "13800003004")
_bind(client, b, _my_code(client, a))
_report_compare(client, b, "trace-idem-1")
_report_compare(client, b, "trace-idem-2") # 第二次比价(不同 trace)
_report_compare(client, b, "trace-idem-1") # 重复上报同 trace
_report_order(client, b, "evt-idem-1")
_report_order(client, b, "evt-idem-2") # 第二笔下单(不同 event)
_report_order(client, b, "evt-idem-1") # 重复上报同 event
assert _invite_cash("13800003003") == INVITE_COMPARE_REWARD_CENTS # 仍只发一次
def test_compare_no_relation_no_reward(client) -> None:
"""没有邀请关系的人比价 → 不发奖(没人是他的邀请人)。"""
def test_invitees_is_compared_flag_flips_after_order(client) -> None:
"""好友列表 is_compared:好友下单前 False(在途/去提醒)、下单后 True(邀请成功)。"""
a = _login(client, "13800003021")
b = _login(client, "13800003022")
_bind(client, b, _my_code(client, a))
with SessionLocal() as db:
b_name = get_user_by_phone(db, "13800003022").nickname
def _b_item():
items = client.get("/api/v1/invite/invitees", headers=_auth(a)).json()["items"]
return next(it for it in items if it["display_name"] == b_name)
assert _b_item()["is_compared"] is False # 下单前:未完成(在途 / 去提醒)
_report_order(client, b, "evt-flag-1") # B 实际下单一笔
assert _b_item()["is_compared"] is True # 下单后:已完成(邀请成功)
def test_order_no_relation_no_reward(client) -> None:
"""没有邀请关系的人下单 → 不发奖(没人是他的邀请人)。"""
x = _login(client, "13800003005")
r = _report_compare(client, x, "trace-norel-1")
r = _report_order(client, x, "evt-norel-1")
assert r.status_code == 200, r.text
assert _invite_cash("13800003005") == 0
def test_compare_failed_no_reward(client) -> None:
"""失败的比价(status=failed)不触发发奖"""
def test_compare_alone_no_reward(client) -> None:
"""仅完成比价、未下单 → 不触发发奖(新口径:比价不再发奖)"""
a = _login(client, "13800003006")
b = _login(client, "13800003007")
_bind(client, b, _my_code(client, a))
_report_compare(client, b, "trace-fail-1", status="failed")
assert _invite_cash("13800003006") == 0
r = client.post(
"/api/v1/compare/record",
json={"trace_id": "trace-noorder-1", "status": "success"},
headers=_auth(b),
)
assert r.status_code == 200, r.text
assert _invite_cash("13800003006") == 0 # 只比价没下单:不发奖
def test_compare_reward_isolated_from_coin_cash(client) -> None:
def test_order_reward_isolated_from_coin_cash(client) -> None:
"""账户隔离:邀请奖励金进 invite_cash,不串 cash_balance_cents;流水落 invite_cash_transaction。"""
a = _login(client, "13800003008")
b = _login(client, "13800003009")
_bind(client, b, _my_code(client, a))
_report_compare(client, b, "trace-iso-1")
_report_order(client, b, "evt-iso-1")
with SessionLocal() as db:
ua = get_user_by_phone(db, "13800003008")
@@ -112,3 +144,48 @@ def test_compare_reward_isolated_from_coin_cash(client) -> None:
).scalars().all()
assert len(txns) == 1
assert txns[0].amount_cents == INVITE_COMPARE_REWARD_CENTS
def test_dev_reset_makes_test_account_fresh_again(client, monkeypatch) -> None:
"""dev-reset 让固定测试号 B 可【反复】当新用户:发奖 → reset → 换个邀请人再发奖成功。
验证 POST /api/v1/invite/dev-reset(方案 B) B invite_relation / invite_cash /
savings 清掉并刷 created_at,于是它能再次绑定 + 下单触发第二次发奖(第一次的 already_bound /
savings 幂等 / 72h 闸都被 reset 解除)
"""
from app.core.config import settings
test_phone = "11111111111"
# 端点按 settings.test_account_phone 认号 → 用 monkeypatch 在本用例内启用该测试号
monkeypatch.setattr(settings, "TEST_ACCOUNT_PHONE", test_phone)
a1 = _login(client, "13800003101")
b = _login(client, test_phone)
# 第一轮:B 绑 A1 → 下单 → A1 得奖
assert _bind(client, b, _my_code(client, a1)).json()["status"] == "success"
_report_order(client, b, "evt-reset-r1")
assert _invite_cash("13800003101") == INVITE_COMPARE_REWARD_CENTS
assert _invite_cash(test_phone) == 0 # 被邀请人 B 不得此奖
# dev-reset:把 B 打回出厂态(无需鉴权)
r = client.post("/api/v1/invite/dev-reset")
assert r.status_code == 200, r.text
body = r.json()
assert body["status"] == "ok" and body["phone"] == test_phone
assert body["cleared"]["invite_relation_as_invitee"] == 1 # 上一轮那条被邀请关系被清
# 第二轮:同一个 B 绑【另一个】邀请人 A2 → 下单 → A2 也得奖(证明 B 又是新用户了)
a2 = _login(client, "13800003102")
assert _bind(client, b, _my_code(client, a2)).json()["status"] == "success" # 不再 already_bound
_report_order(client, b, "evt-reset-r2")
assert _invite_cash("13800003102") == INVITE_COMPARE_REWARD_CENTS # 第二个邀请人到账
def test_dev_reset_blocked_when_test_account_disabled(client, monkeypatch) -> None:
"""测试号总开关没配(TEST_ACCOUNT_PHONE 空)→ dev-reset 返回 400(功能未启用)。"""
from app.core.config import settings
monkeypatch.setattr(settings, "TEST_ACCOUNT_PHONE", "")
r = client.post("/api/v1/invite/dev-reset")
assert r.status_code == 400, r.text