Compare commits

...

12 Commits

Author SHA1 Message Date
zzhyyyyy 7250682dc5 feat(auth): 测试号下发 force_onboarding,驱动应用内重登也重走引导
新增 TokenWithUser.force_onboarding(仅测试号置 true)。配合客户端:让测试号在
「退出后应用内重新登录」也重走新手引导;普通号 force_onboarding=false、按原逻辑回原页。
与 onboarding_completed 分工:后者管 App 启动路由,本字段管应用内登录后的去向。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:07:23 +08:00
zzhyyyyy cc37905509 feat(auth): 测试账号免验证码登录 + 每次走引导 + 每日上限
为打通 release 包全流程(无 SIM 卡 / 不走极光一键登录)新增一个固定测试手机号:
- 该号 /sms/send 不真发、/sms/login 跳过验证码校验(real 模式同样生效),填任意码即可登入;
- 登录响应 onboarding_completed 恒为 false,每次都重走新手引导(不读/不写完成表);
- 每日登录次数上限(默认 500),超过当天拒绝(429),防被人猜到号后脚本刷。

手机号与上限都在 .env 配(TEST_ACCOUNT_PHONE / TEST_ACCOUNT_DAILY_LIMIT),
留空 = 整功能关闭(生产默认安全)。逻辑集中在 app/core/test_account.py,与其他业务解耦。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:03:32 +08:00
chenshuobo f868414966 feat(feedback): 补齐反馈历史审核发奖闭环 (#66)
- 新增反馈审核字段和迁移,支持 pending/adopted/rejected、未采纳原因和采纳金币

- 增加用户端反馈历史 records 接口和 admin 采纳/拒绝接口

- 采纳时同事务写状态、金币流水和审计日志,拦截重复审核

- 验证:pytest tests/test_feedback.py tests/test_admin_write.py tests/test_admin_read.py

---------

Co-authored-by: lowmaster-chen <1119780489@qq.com>
Reviewed-on: #66
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
2026-06-22 23:18:42 +08:00
zhuzihao 900cc83d38 fix(ad): 广告配置默认信息流位 104090333 → 104142227 (#67)
_AD_CONFIG_DEFAULTS 里 compare/coupon_feed_code_id 的旧默认 104090333
不在穿山甲应用 5830519 名下(请求报 44406「配置为 null」、出不了广告);
2026-06-21 真机核对后台,5830519 名下真实信息流位是 104142227「信息流 1」。

客户端接入 /api/v1/platform/ad-config 下发后会以此默认为准(空库回退),
故更正默认值。注:若 DB 里已存过 104090333,仍需在 admin 广告管理页改存为 104142227。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #67
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-22 23:00:27 +08:00
zhuzihao 3cab75b6ac feat(admin-withdraw): 提现详情金币记录分页 + 风险标签拆分 (#64)
- user_coin_records 增返 total(三源 granted 计数和),支持前端页码分页
- 风险标签「历史异常提现」拆成「提现拒绝N笔」「提现失败N笔」(拒绝=人工驳回退款,失败=打款失败退款)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #64
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-21 23:41:40 +08:00
marco 08bbb5775e fix(compare-record): platform_results 类型 list→dict,修部分失败比价上报 422 丢记录
ComparisonRecordIn.platform_results 误声明为 list,但 pricebot/客户端透传的是 dict
({platform_id: {status,reason,...}})。该字段仅「失败/部分失败」比价 done 帧才带,故
成功比价一直正常入库、部分失败比价上报被 pydantic 422 拒导致不入库、记录页查不到。
改 dict 后用线上真实 body 验证通过;字段仅随 raw_payload 落库、无结构化列、零副作用。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:11:29 +08:00
zhuzihao 8a2f72d366 feat(user): 昵称上限放宽到 20 字(与客户端/原型一致) (#63)
Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #63
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-21 00:16:40 +08:00
marco f7a3ef2e0b feat(internal): 启动确认窗 agent 兜底样本落盘(pricebot 回写)
- 新增内部端点 POST /internal/launch-confirm-sample(X-Internal-Secret 鉴权):
  pricebot 对没见过文案的启动确认窗用 LLM 兜底放行后,把样本(host 包 / 弹窗树 /
  LLM plan / 设备 locale 机型)回写落 launch_confirm_sample 表,供研发人工沉淀回
  pricebot 规则 yaml
- 配套: model LaunchConfirmSample + schema + repo(都上报不去重) + alembic 迁移
  launch_confirm_sample_table(down_revision=cps_wx_user) + main 注册 router +
  models/__init__ 登记
- docs: 后端技术实现.md 加 §6.5 pricebot 回写内部端点说明

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:39:45 +08:00
marco d8358a6816 fix(user): 注销账号前置校验资金余额 + 释放微信/邀请码唯一约束
- delete_account 端点: 有可提现现金余额 / 在审提现单(reviewing)时拒绝注销(409), 防余额锁死在 deleted 行无法退出; pending 提现已在途不拦(对账 worker 不依赖 user.status)
- soft_delete_account: 注销连带清 wechat_openid/nickname/avatar(释放微信绑定槽, 否则该微信永久无法再被任何账号绑定→提现通道锁死) + invite_code(释放邀请码唯一槽)
- wallet 新增 get_cash_balance_cents(只读, 不建账户) 供注销前置校验; 复用 has_reviewing_withdraw
- 新增 tests/test_delete_account.py

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 02:03:33 +08:00
marco 95a07f75b7 feat(ad): 穿山甲广告配置后台化(app_id/各位ID/验签密钥/各场景开关)
- app_config 存 ad_config(默认=客户端当前内置 ID,空库维持现状)
- GET /platform/ad-config 下发客户端(不含验签密钥 reward_mkey)
- admin GET/PATCH /admin/api/ad-config 读写(operator/finance + 审计,mkey 不记明文)
- pangle 回调验签 m-key 优先读后台配置、.env 兜底兼容
- 比价/领券信息流位拆分(compare/coupon_feed_code_id)
- 客户端需发版接入 /platform/ad-config 才生效;本次后端就绪、不影响现状

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 01:35:17 +08:00
marco 733a51260f feat(cps): 微信网页授权加总开关 WX_MP_OAUTH_ENABLED(默认关)
服务号认证审核中,先关掉落地页授权(走原逻辑不跳授权/不拿 openid),整套微信代码保留。
审核通过后 .env 置 WX_MP_OAUTH_ENABLED=true + 重启即启用,无需改代码。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:03:58 +08:00
marco 7fd70a264d feat(cps): 微信网页授权接入——落地页拿 openid/昵称头像,用户级群统计
- 服务号网页授权(WX_MP_APPID/SECRET,区别于 App 的 WECHAT_APP_ID):
  落地页 base 静默拿 openid;点「领券」补 userinfo 拿昵称头像(交互触发避快照页)
- 新表 cps_wx_user + cps_click 加 openid + 迁移;integrations/wx_oauth
- /c/{code} 授权链路 + 回调 /wx/oauth/cb + cookie 免重复授权;click 带 openid
- admin 群详情加「群内微信用户」(领券画像)端点
- 下单归因到人需 user-level sid,本期只做身份+领券侧

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:44:11 +08:00
66 changed files with 3115 additions and 71 deletions
+9
View File
@@ -34,6 +34,15 @@ SMS_MOCK=true
SMS_CODE_TTL_SEC=300
SMS_SEND_INTERVAL_SEC=60
# ===== 测试账号(release 包全流程联调用)=====
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
# (real 模式下也跳过校验)、每次登录【都重走新手引导】,并有【每日登录上限】防被人猜到号后脚本刷。
# 逻辑见 app/core/test_account.py,与其他业务解耦。
# ⚠️ 留空 = 关闭整功能(生产默认);要启用才填号(如 11111111111)。改完重启生效,随时可清空停用。
TEST_ACCOUNT_PHONE=
# 该测试号每日最多登录次数,当日超过即拒绝(429),次日归零。
TEST_ACCOUNT_DAILY_LIMIT=500
# ===== 美团联盟 CPS =====
# 美团联盟后台 → 媒体管理 拿到的 appkey 和密钥
MT_CPS_APP_KEY=
+34
View File
@@ -0,0 +1,34 @@
"""add feed_scene to ad_feed_reward_record
信息流广告点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页)。
比价与领券共用同一个信息流代码位(AdConfig.feedCodeId),slot_id / our_code_id 都分不出来,
只能由客户端在各调用点(PriceBotService / CouponForegroundService / WelfareScreen)显式打标。
可空:历史记录与未升级客户端为 NULL = 未分类。
Revision ID: add_feed_scene
Revises: drop_force_onboarding
Create Date: 2026-06-20 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "add_feed_scene"
down_revision: Union[str, Sequence[str], None] = "drop_force_onboarding"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"ad_feed_reward_record",
sa.Column("feed_scene", sa.String(length=16), nullable=True),
)
def downgrade() -> None:
op.drop_column("ad_feed_reward_record", "feed_scene")
@@ -0,0 +1,26 @@
"""merge add_feed_scene and launch_confirm_sample heads
Revision ID: ceb286289426
Revises: add_feed_scene, launch_confirm_sample_table
Create Date: 2026-06-20 23:52:57.853400
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ceb286289426'
down_revision: Union[str, Sequence[str], None] = ('add_feed_scene', 'launch_confirm_sample_table')
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
@@ -0,0 +1,48 @@
"""cps 微信用户表 cps_wx_user + cps_click 加 openid
CPS 落地页微信网页授权拿 openid/昵称头像,做用户级群统计。
Revision ID: cps_wx_user
Revises: comparison_debug_fields
Create Date: 2026-06-19 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "cps_wx_user"
down_revision: Union[str, Sequence[str], None] = "comparison_debug_fields"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"cps_wx_user",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("openid", sa.String(64), nullable=False),
sa.Column("unionid", sa.String(64), nullable=True),
sa.Column("nickname", sa.String(128), nullable=True),
sa.Column("headimgurl", sa.String(512), nullable=True),
sa.Column("first_code", sa.String(16), nullable=True),
sa.Column("first_group_id", sa.Integer(), nullable=True),
sa.Column("first_seen", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("last_seen", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_cps_wx_user_openid", "cps_wx_user", ["openid"], unique=True)
op.create_index("ix_cps_wx_user_unionid", "cps_wx_user", ["unionid"])
op.create_index("ix_cps_wx_user_first_group_id", "cps_wx_user", ["first_group_id"])
op.add_column("cps_click", sa.Column("openid", sa.String(64), nullable=True))
op.create_index("ix_cps_click_openid", "cps_click", ["openid"])
def downgrade() -> None:
op.drop_index("ix_cps_click_openid", table_name="cps_click")
op.drop_column("cps_click", "openid")
op.drop_index("ix_cps_wx_user_first_group_id", table_name="cps_wx_user")
op.drop_index("ix_cps_wx_user_unionid", table_name="cps_wx_user")
op.drop_index("ix_cps_wx_user_openid", table_name="cps_wx_user")
op.drop_table("cps_wx_user")
@@ -0,0 +1,78 @@
"""feedback review fields
Revision ID: feedback_review_fields
Revises: ceb286289426
Create Date: 2026-06-22 20:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "feedback_review_fields"
down_revision: str | Sequence[str] | None = "ceb286289426"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("feedback", schema=None) as batch_op:
batch_op.add_column(sa.Column("reject_reason", sa.String(length=256), nullable=True))
batch_op.add_column(sa.Column("reward_coins", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("review_note", sa.String(length=256), nullable=True))
batch_op.add_column(sa.Column("reviewed_by_admin_id", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True))
batch_op.create_index(batch_op.f("ix_feedback_status"), ["status"], unique=False)
batch_op.create_foreign_key(
batch_op.f("fk_feedback_reviewed_by_admin_id_admin_user"),
"admin_user",
["reviewed_by_admin_id"],
["id"],
)
feedback = sa.table(
"feedback",
sa.column("status", sa.String(length=16)),
)
op.execute(
feedback.update()
.where(feedback.c.status == "new")
.values(status="pending")
)
op.execute(
feedback.update()
.where(feedback.c.status == "handled")
.values(status="adopted")
)
def downgrade() -> None:
feedback = sa.table(
"feedback",
sa.column("status", sa.String(length=16)),
)
op.execute(
feedback.update()
.where(feedback.c.status == "pending")
.values(status="new")
)
op.execute(
feedback.update()
.where(feedback.c.status == "adopted")
.values(status="handled")
)
with op.batch_alter_table("feedback", schema=None) as batch_op:
batch_op.drop_constraint(
batch_op.f("fk_feedback_reviewed_by_admin_id_admin_user"),
type_="foreignkey",
)
batch_op.drop_index(batch_op.f("ix_feedback_status"))
batch_op.drop_column("reviewed_at")
batch_op.drop_column("reviewed_by_admin_id")
batch_op.drop_column("review_note")
batch_op.drop_column("reward_coins")
batch_op.drop_column("reject_reason")
@@ -0,0 +1,53 @@
"""launch_confirm_sample table (启动确认窗 agent 兜底样本: LLM 放行的弹窗样本沉淀)
Revision ID: launch_confirm_sample_table
Revises: cps_wx_user
Create Date: 2026-06-20 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "launch_confirm_sample_table"
down_revision: Union[str, Sequence[str], None] = "cps_wx_user"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"launch_confirm_sample",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
sa.Column("trace_id", sa.String(length=64), nullable=True),
sa.Column("device_id", sa.String(length=64), nullable=True),
sa.Column("host_package", sa.String(length=128), nullable=True),
sa.Column("target_app", sa.String(length=128), nullable=True),
sa.Column("system_locale", sa.String(length=32), nullable=True),
sa.Column("exec_success", sa.Boolean(), nullable=False),
sa.Column("dialog_title", sa.String(length=256), nullable=True),
# PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐
sa.Column("payload", sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), "postgresql"), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("launch_confirm_sample", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_created_at"), ["created_at"], unique=False)
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_trace_id"), ["trace_id"], unique=False)
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_device_id"), ["device_id"], unique=False)
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_host_package"), ["host_package"], unique=False)
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_system_locale"), ["system_locale"], unique=False)
def downgrade() -> None:
with op.batch_alter_table("launch_confirm_sample", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_system_locale"))
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_host_package"))
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_device_id"))
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_trace_id"))
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_created_at"))
op.drop_table("launch_confirm_sample")
+4
View File
@@ -14,6 +14,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.admin.routers.ad_audit import router as ad_audit_router
from app.admin.routers.ad_config import router as ad_config_router
from app.admin.routers.ad_revenue import router as ad_revenue_router
from app.admin.routers.admins import router as admins_router
from app.admin.routers.audit import router as audit_router
@@ -24,6 +25,7 @@ from app.admin.routers.cps import router as cps_router
from app.admin.routers.dashboard import router as dashboard_router
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
from app.admin.routers.feedback import router as feedback_router
from app.admin.routers.feedback_qr import router as feedback_qr_router
from app.admin.routers.onboarding import router as onboarding_router
from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router
from app.admin.routers.price_report import router as price_report_router
@@ -89,10 +91,12 @@ admin_app.include_router(wallet_router)
admin_app.include_router(withdraw_router)
admin_app.include_router(price_report_router)
admin_app.include_router(feedback_router)
admin_app.include_router(feedback_qr_router)
admin_app.include_router(admins_router)
admin_app.include_router(audit_router)
admin_app.include_router(config_router)
admin_app.include_router(comparison_router)
admin_app.include_router(cps_router)
admin_app.include_router(ad_audit_router)
admin_app.include_router(ad_config_router)
admin_app.include_router(ad_revenue_router)
+46 -1
View File
@@ -9,7 +9,7 @@ from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
from uuid import uuid4
from sqlalchemy import desc, select
from sqlalchemy import desc, func, select
from sqlalchemy.orm import Session
from app.admin.repositories.queries import _as_utc, offset_paginate
@@ -19,6 +19,7 @@ from app.models.cps_activity import CpsActivity
from app.models.cps_group import CpsGroup
from app.models.cps_link import CpsClick
from app.models.cps_order import CpsOrder
from app.models.cps_wx_user import CpsWxUser
# 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账
_INVALID_STATUS = {"4", "5"}
@@ -470,3 +471,47 @@ def group_order_daily(
"settled_commission_cents": sum(o.commission_cents or 0 for o in settled),
}
return out
def group_wx_users(db: Session, *, group_id: int, limit: int = 200) -> list[dict]:
"""群内微信用户(首次来源 first_group_id=group_id)+ 每人在该群的 visit/copy 次数(领券画像)。
按首次进入倒序。copy 次数 = 该用户在该群点了几次「复制口令」(领券意向),visit = 进落地页次数。
"""
users = (
db.execute(
select(CpsWxUser)
.where(CpsWxUser.first_group_id == group_id)
.order_by(desc(CpsWxUser.first_seen))
.limit(limit)
)
.scalars()
.all()
)
if not users:
return []
openids = [u.openid for u in users]
stat: dict[str, dict] = {}
rows = db.execute(
select(CpsClick.openid, CpsClick.event_type, func.count())
.where(CpsClick.group_id == group_id)
.where(CpsClick.openid.in_(openids))
.group_by(CpsClick.openid, CpsClick.event_type)
).all()
for openid, event_type, cnt in rows:
s = stat.setdefault(openid, {"visit": 0, "copy": 0})
if event_type == "copy":
s["copy"] = cnt
else:
s["visit"] = cnt
return [
{
"openid": u.openid,
"nickname": u.nickname,
"headimgurl": u.headimgurl,
"first_seen": u.first_seen,
"visit_count": stat.get(u.openid, {}).get("visit", 0),
"copy_count": stat.get(u.openid, {}).get("copy", 0),
}
for u in users
]
+29
View File
@@ -56,6 +56,35 @@ def update_feedback_status(
return feedback
def review_feedback(
db: Session,
feedback: Feedback,
*,
status: str,
reviewed_by_admin_id: int,
reward_coins: int | None = None,
reject_reason: str | None = None,
review_note: str | None = None,
commit: bool = True,
) -> Feedback:
"""审核反馈:置 adopted/rejected + 记录奖励/原因/审核人/审核时间。
发金币(wallet.grant_coins)由 router 在同一事务里调,确保状态、金币流水、审计一起提交。
"""
feedback.status = status
feedback.reward_coins = reward_coins
feedback.reject_reason = reject_reason
feedback.review_note = review_note
feedback.reviewed_by_admin_id = reviewed_by_admin_id
feedback.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None)
if commit:
db.commit()
db.refresh(feedback)
else:
db.flush()
return feedback
def review_price_report(
db: Session,
report: PriceReport,
+245 -4
View File
@@ -11,6 +11,9 @@ from zoneinfo import ZoneInfo
from sqlalchemy import Select, asc, desc, func, or_, select
from sqlalchemy.orm import Session
from app.core import rewards
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
from app.models.admin import AdminAuditLog
from app.models.comparison import ComparisonRecord
from app.models.feedback import Feedback
@@ -19,6 +22,16 @@ from app.models.price_report import PriceReport
from app.models.user import User
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
# 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取")
_NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct")
# 信息流点位场景 → 金币记录「赚取途径」展示名;NULL/未知 = 历史未分类。
_FEED_SCENE_LABEL = {
"comparison": "比价信息流",
"coupon": "领券信息流",
"welfare": "福利信息流",
}
def cursor_paginate(
db: Session, stmt: Select, id_col, *, limit: int, cursor: int | None
@@ -331,6 +344,38 @@ def _as_utc(value: datetime) -> datetime:
return value.astimezone(timezone.utc)
def withdraw_list_enrichment(db: Session, user_ids: list[int]) -> dict[int, dict]:
"""批量富化提现单列表:按本页 user_id 取 手机号/昵称 + 各自累计成功提现金额(分)。
两条聚合查询搞定(避免逐行 N+1)。累计口径与 get_user_overview 的 withdraw_success_cents
完全一致:SUM(amount_cents) WHERE status='success'。返回 {user_id: {phone, nickname,
cumulative_success_cents}};某 user_id 查不到用户则不在 dict 里(路由侧回退到默认值)。
"""
uniq = list(set(user_ids))
if not uniq:
return {}
success_rows = db.execute(
select(
WithdrawOrder.user_id,
func.coalesce(func.sum(WithdrawOrder.amount_cents), 0),
)
.where(WithdrawOrder.user_id.in_(uniq), WithdrawOrder.status == "success")
.group_by(WithdrawOrder.user_id)
).all()
success_map = {uid: int(total) for uid, total in success_rows}
users = db.execute(
select(User.id, User.phone, User.nickname).where(User.id.in_(uniq))
).all()
return {
uid: {
"phone": phone,
"nickname": nickname,
"cumulative_success_cents": success_map.get(uid, 0),
}
for uid, phone, nickname in users
}
def list_feedbacks(
db: Session,
*,
@@ -448,15 +493,19 @@ def withdraw_risk_flags(
created_at = user.created_at.replace(tzinfo=timezone.utc) if user.created_at.tzinfo is None else user.created_at
if datetime.now(timezone.utc) - created_at < timedelta(hours=24):
flags.append("新注册用户")
failed_or_rejected = sum(1 for item in recent_withdraws if item.status in {"failed", "rejected"})
if failed_or_rejected:
flags.append(f"历史异常提现{failed_or_rejected}")
# 历史异常提现拆「拒绝」「失败」两类(口径不同:拒绝=人工驳回退款,失败=打款失败退款)
rejected_n = sum(1 for item in recent_withdraws if item.status == "rejected")
failed_n = sum(1 for item in recent_withdraws if item.status == "failed")
if rejected_n:
flags.append(f"提现拒绝{rejected_n}")
if failed_n:
flags.append(f"提现失败{failed_n}")
recent_reviewing = sum(1 for item in recent_withdraws if item.status == "reviewing")
if recent_reviewing >= 3:
flags.append(f"待审核提现偏多:{recent_reviewing}")
if cash_balance_cents < 0:
flags.append("现金余额为负")
score = min(100, len(flags) * 20 + failed_or_rejected * 10)
score = min(100, len(flags) * 20 + (rejected_n + failed_n) * 10)
return flags, score
@@ -546,6 +595,198 @@ def get_user_overview(db: Session, user_id: int) -> dict | None:
}
def _window_conds(col, date_from: datetime | None, date_to: datetime | None) -> list:
"""把 [date_from, date_to] 转成对 col(created_at)的过滤条件;都为 None = 全量(注册至今)。"""
conds = []
if date_from is not None:
conds.append(col >= _as_utc_naive(date_from))
if date_to is not None:
conds.append(col <= _as_utc_naive(date_to))
return conds
def _coins_to_cents(coins: int) -> int:
"""累计金币折算成可提现现金(分)。COIN_PER_YUAN=10000 → 100 金币 = 1 分。"""
return round(coins / (rewards.COIN_PER_YUAN / 100))
def user_reward_stats(
db: Session,
user_id: int,
*,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> dict:
"""提现详情「用户统计区」10 项。窗口作用于除「现金余额」外的所有项(余额是当前快照)。
口径:激励视频/信息流只统计 granted;数量——视频按条数、信息流按份数(unit_count 累加);
平均 eCPM 用原始分值(分/千次)按记录取算术平均;各「提现」= 该来源累计金币折现。
传统任务 = 窗口内正向金币中,排除广告(reward_video/feed_ad_reward)与人工调整后的折现。
"""
wd_success = db.execute(
select(func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)).where(
WithdrawOrder.user_id == user_id,
WithdrawOrder.status == "success",
*_window_conds(WithdrawOrder.created_at, date_from, date_to),
)
).scalar_one()
wd_total = db.execute(
select(func.count(WithdrawOrder.id)).where(
WithdrawOrder.user_id == user_id,
*_window_conds(WithdrawOrder.created_at, date_from, date_to),
)
).scalar_one()
acc = db.get(CoinAccount, user_id) # 现金余额:当前快照,不随窗口
cash_balance = acc.cash_balance_cents if acc else 0
rv = list(db.execute(
select(AdRewardRecord).where(
AdRewardRecord.user_id == user_id,
AdRewardRecord.reward_scene == "reward_video",
AdRewardRecord.status == "granted",
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
)
).scalars())
rv_ecpms = [rewards.parse_ecpm_fen(r.ecpm_raw) for r in rv if r.ecpm_raw]
rv_coins = sum(r.coin for r in rv)
feed = list(db.execute(
select(AdFeedRewardRecord).where(
AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.status == "granted",
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
)
).scalars())
feed_ecpms = [rewards.parse_ecpm_fen(f.ecpm_raw) for f in feed if f.ecpm_raw]
feed_coins = sum(f.coin for f in feed)
trad_coins = db.execute(
select(func.coalesce(func.sum(CoinTransaction.amount), 0)).where(
CoinTransaction.user_id == user_id,
CoinTransaction.amount > 0,
CoinTransaction.biz_type.not_in(_NON_TASK_BIZ_TYPES),
*_window_conds(CoinTransaction.created_at, date_from, date_to),
)
).scalar_one()
return {
"withdraw_success_cents": int(wd_success),
"cash_balance_cents": int(cash_balance),
"withdraw_total": int(wd_total),
"traditional_task_cash_cents": _coins_to_cents(int(trad_coins)),
"reward_video_count": len(rv),
"reward_video_avg_ecpm": round(sum(rv_ecpms) / len(rv_ecpms), 2) if rv_ecpms else 0.0,
"reward_video_cash_cents": _coins_to_cents(rv_coins),
"feed_count": int(sum(f.unit_count for f in feed)),
"feed_avg_ecpm": round(sum(feed_ecpms) / len(feed_ecpms), 2) if feed_ecpms else 0.0,
"feed_cash_cents": _coins_to_cents(feed_coins),
}
def user_coin_records(
db: Session,
user_id: int,
*,
date_from: datetime | None = None,
date_to: datetime | None = None,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[dict], int | None, int]:
"""金币发放记录(提现详情底部表),按时间倒序、offset 游标分页 + 总数。
合并三类来源(Phase1 信息流不分比价/领券):
- 激励视频 / 签到膨胀 = ad_reward_record(granted)
- 信息流广告 = ad_feed_reward_record(granted)
- 签到 = coin_transaction(biz_type='signin')
每来源各取 offset+limit+1 条(倒序)再归并切片,避免 union 全量拉取。
"""
offset = max(cursor or 0, 0)
fetch = offset + limit + 1
rows: list[dict] = []
for rec in db.execute(
select(AdRewardRecord)
.where(
AdRewardRecord.user_id == user_id,
AdRewardRecord.status == "granted",
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
)
.order_by(AdRewardRecord.created_at.desc())
.limit(fetch)
).scalars():
is_video = rec.reward_scene == "reward_video"
rows.append({
"source": rec.reward_scene,
"source_label": "激励视频" if is_video else "签到膨胀",
"created_at": rec.created_at,
"ecpm": rec.ecpm_raw,
"coin": rec.coin,
})
for rec in db.execute(
select(AdFeedRewardRecord)
.where(
AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.status == "granted",
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
)
.order_by(AdFeedRewardRecord.created_at.desc())
.limit(fetch)
).scalars():
rows.append({
"source": "feed",
"source_label": _FEED_SCENE_LABEL.get(rec.feed_scene, "信息流广告"),
"created_at": rec.created_at,
"ecpm": rec.ecpm_raw,
"coin": rec.coin,
})
for rec in db.execute(
select(CoinTransaction)
.where(
CoinTransaction.user_id == user_id,
CoinTransaction.biz_type == "signin",
*_window_conds(CoinTransaction.created_at, date_from, date_to),
)
.order_by(CoinTransaction.created_at.desc())
.limit(fetch)
).scalars():
rows.append({
"source": "signin",
"source_label": "签到",
"created_at": rec.created_at,
"ecpm": None,
"coin": rec.amount,
})
rows.sort(key=lambda r: r["created_at"], reverse=True)
has_more = len(rows) > offset + limit
# 总数 = 三源在窗口内 granted 计数之和(供前端页码分页渲染页码/共 N 条)
def _count(model, *conds) -> int:
return int(db.execute(select(func.count()).select_from(model).where(*conds)).scalar_one())
total = (
_count(
AdRewardRecord, AdRewardRecord.user_id == user_id,
AdRewardRecord.status == "granted",
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
)
+ _count(
AdFeedRewardRecord, AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.status == "granted",
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
)
+ _count(
CoinTransaction, CoinTransaction.user_id == user_id,
CoinTransaction.biz_type == "signin",
*_window_conds(CoinTransaction.created_at, date_from, date_to),
)
)
return rows[offset:offset + limit], (offset + limit if has_more else None), total
def list_price_reports(
db: Session,
*,
+1 -1
View File
@@ -119,7 +119,7 @@ def dashboard_overview(db: Session) -> dict:
"success": comparison_success,
"success_rate": success_rate,
},
"feedback": {"new": _count(Feedback, Feedback.status == "new")},
"feedback": {"new": _count(Feedback, Feedback.status.in_(("pending", "new")))},
# CPS 收入数据源未接(referral-link 只换链接,转化/佣金未回收)→ 前端显示"待接入"。
"cps": {"available": False, "note": "CPS 转化数据未接入(P2)"},
}
+45
View File
@@ -0,0 +1,45 @@
"""admin 广告配置:读/写穿山甲 app_id / 各场景广告位ID / 验签密钥 / 各场景开关。
存在 app_config 表的 ad_config dict(见 repositories/app_config.get_ad_config/set_ad_config)。
客户端经 /api/v1/platform/ad-config 拉取(不含 reward_mkey)。权限 operator/finance + 审计。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.schemas.ad_config import AdConfigOut, AdConfigUpdate
from app.models.admin import AdminUser
from app.repositories import app_config
router = APIRouter(
prefix="/admin/api/ad-config",
tags=["admin-ad-config"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=AdConfigOut, summary="广告配置(穿山甲ID/验签密钥/各场景开关)")
def get_ad_config(db: AdminDb) -> AdConfigOut:
return AdConfigOut(**app_config.get_ad_config(db))
@router.patch("", response_model=AdConfigOut, summary="改广告配置(部分更新,带审计)")
def update_ad_config(
body: AdConfigUpdate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator", "finance"))],
db: AdminDb,
) -> AdConfigOut:
data = body.model_dump(exclude_none=True)
app_config.set_ad_config(db, data, admin_id=admin.id, commit=False)
# 审计只记改了哪些字段,不记 mkey 明文(机密)
write_audit(
db, admin, action="ad_config.set", target_type="ad_config", target_id="ad_config",
detail={"changed": sorted(data.keys())}, ip=get_client_ip(request), commit=False,
)
db.commit()
return AdConfigOut(**app_config.get_ad_config(db))
+8
View File
@@ -488,3 +488,11 @@ def group_daily(
"days": days,
"rows": rows,
}
@router.get("/groups/{group_id}/wx-users", summary="群内微信用户(领券画像:头像/昵称/领券次数)")
def group_wx_users(group_id: int, db: AdminDb) -> dict:
group = cps_repo.get_group(db, group_id)
if group is None:
raise HTTPException(status_code=404, detail="群不存在")
return {"users": cps_repo.group_wx_users(db, group_id=group_id)}
+100 -8
View File
@@ -1,4 +1,4 @@
"""admin 反馈工单:列表(读,支持 状态/用户ID/内容/时间 筛选 + 排序)+ 标记已处理(写,带审计)。"""
"""admin 反馈工单:列表筛选 + 审核采纳/拒绝(带金币发放与审计)。"""
from __future__ import annotations
from datetime import datetime
@@ -10,9 +10,10 @@ from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.repositories import mutations, queries
from app.admin.schemas.common import CursorPage, OkResponse
from app.admin.schemas.feedback import FeedbackOut
from app.admin.schemas.feedback import FeedbackApproveRequest, FeedbackOut, FeedbackRejectRequest
from app.models.admin import AdminUser
from app.models.feedback import Feedback
from app.repositories import wallet as wallet_repo
router = APIRouter(
prefix="/admin/api/feedbacks",
@@ -21,6 +22,11 @@ router = APIRouter(
)
def _ensure_pending(fb: Feedback) -> None:
if fb.status not in {"pending", "new"}:
raise HTTPException(status_code=400, detail="反馈已审核")
@router.get("", response_model=CursorPage[FeedbackOut], summary="反馈工单列表")
def list_feedbacks(
db: AdminDb,
@@ -56,18 +62,104 @@ def list_feedbacks(
@router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理")
def handle_feedback(
feedback_id: int,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
_admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
fb = db.get(Feedback, feedback_id)
if fb is None:
raise HTTPException(status_code=404, detail="反馈不存在")
raise HTTPException(status_code=400, detail="请使用采纳或拒绝接口审核反馈")
@router.post("/{feedback_id}/approve", response_model=FeedbackOut, summary="采纳反馈并发金币")
def approve_feedback(
feedback_id: int,
payload: FeedbackApproveRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> FeedbackOut:
fb = db.get(Feedback, feedback_id)
if fb is None:
raise HTTPException(status_code=404, detail="反馈不存在")
_ensure_pending(fb)
before = fb.status
mutations.update_feedback_status(db, fb, status="handled", commit=False)
mutations.review_feedback(
db,
fb,
status="adopted",
reward_coins=payload.reward_coins,
review_note=payload.note,
reviewed_by_admin_id=admin.id,
commit=False,
)
wallet_repo.grant_coins(
db,
fb.user_id,
payload.reward_coins,
biz_type="feedback_reward",
ref_id=str(fb.id),
remark="意见反馈被采纳",
)
write_audit(
db, admin, action="feedback.handle", target_type="feedback", target_id=feedback_id,
detail={"before": before, "after": "handled"}, ip=get_client_ip(request), commit=False,
db,
admin,
action="feedback.approve",
target_type="feedback",
target_id=feedback_id,
detail={
"before": before,
"after": "adopted",
"reward_coins": payload.reward_coins,
"note": payload.note,
},
ip=get_client_ip(request),
commit=False,
)
db.commit()
return OkResponse()
db.refresh(fb)
return FeedbackOut.model_validate(fb)
@router.post("/{feedback_id}/reject", response_model=FeedbackOut, summary="拒绝采纳反馈")
def reject_feedback(
feedback_id: int,
payload: FeedbackRejectRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> FeedbackOut:
fb = db.get(Feedback, feedback_id)
if fb is None:
raise HTTPException(status_code=404, detail="反馈不存在")
_ensure_pending(fb)
before = fb.status
mutations.review_feedback(
db,
fb,
status="rejected",
reject_reason=payload.reason,
review_note=payload.note,
reviewed_by_admin_id=admin.id,
commit=False,
)
write_audit(
db,
admin,
action="feedback.reject",
target_type="feedback",
target_id=feedback_id,
detail={
"before": before,
"after": "rejected",
"reason": payload.reason,
"note": payload.note,
},
ip=get_client_ip(request),
commit=False,
)
db.commit()
db.refresh(fb)
return FeedbackOut.model_validate(fb)
+92
View File
@@ -0,0 +1,92 @@
"""admin 反馈页二维码卡配置:读 / 改文案开关 / 上传二维码 / 删二维码(带审计)。
整张卡存通用 app_config 表(见 app/repositories/feedback_qr.py),App 反馈页拉
GET /api/v1/feedback/config 同步。权限:operator 可改(运营维护),super 恒可;
读为只读(任意已登录 admin)。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.schemas.feedback_qr import FeedbackQrOut, FeedbackQrUpdate
from app.core import media
from app.models.admin import AdminUser
from app.repositories import feedback_qr
router = APIRouter(
prefix="/admin/api/feedback-config",
tags=["admin-feedback-config"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=FeedbackQrOut, summary="反馈页二维码卡配置")
def get_config(db: AdminDb) -> FeedbackQrOut:
return FeedbackQrOut(**feedback_qr.get_config(db))
@router.patch("", response_model=FeedbackQrOut, summary="改反馈页文案/开关(带审计)")
def update_config(
body: FeedbackQrUpdate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> FeedbackQrOut:
before, after = feedback_qr.update_config(
db,
enabled=body.enabled,
title=body.title,
group_name=body.group_name,
subtitle=body.subtitle,
admin_id=admin.id,
commit=False,
)
write_audit(
db, admin, action="feedback_qr.update", target_type="feedback_qr", target_id=None,
detail={"before": before, "after": after}, ip=get_client_ip(request), commit=False,
)
db.commit()
return FeedbackQrOut(**after)
@router.post("/image", response_model=FeedbackQrOut, summary="上传反馈页二维码(带审计)")
async def upload_image(
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
file: UploadFile = File(...),
) -> FeedbackQrOut:
data = await file.read()
try:
url = media.save_feedback_qr(data)
except media.MediaError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
before, after = feedback_qr.set_image(db, url, admin_id=admin.id, commit=False)
write_audit(
db, admin, action="feedback_qr.set_image", target_type="feedback_qr", target_id=None,
detail={"before": before.get("image_url"), "after": url},
ip=get_client_ip(request), commit=False,
)
db.commit()
media.delete_feedback_qr(before.get("image_url")) # 提交成功后再删旧图,避免新图未落库就丢旧图
return FeedbackQrOut(**after)
@router.delete("/image", response_model=FeedbackQrOut, summary="移除反馈页二维码(带审计)")
def delete_image(
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> FeedbackQrOut:
before, after = feedback_qr.set_image(db, None, admin_id=admin.id, commit=False)
write_audit(
db, admin, action="feedback_qr.delete_image", target_type="feedback_qr", target_id=None,
detail={"before": before.get("image_url")}, ip=get_client_ip(request), commit=False,
)
db.commit()
media.delete_feedback_qr(before.get("image_url"))
return FeedbackQrOut(**after)
+42
View File
@@ -17,6 +17,8 @@ from app.admin.schemas.user import (
GrantCoinsRequest,
SetDebugTraceRequest,
SetUserStatusRequest,
UserCoinRecord,
UserRewardStats,
)
from app.models.admin import AdminUser
from app.repositories import user as user_repo
@@ -66,6 +68,46 @@ def get_user(user_id: int, db: AdminDb) -> AdminUserOverview:
return AdminUserOverview.model_validate(overview)
@router.get(
"/{user_id}/reward-stats",
response_model=UserRewardStats,
summary="用户提现/看广告统计(提现详情用,按时间窗口)",
)
def get_user_reward_stats(
user_id: int,
db: AdminDb,
date_from: Annotated[datetime | None, Query()] = None,
date_to: Annotated[datetime | None, Query()] = None,
) -> UserRewardStats:
"""提现详情抽屉「用户统计区」。date_from/date_to 都不传 = 注册至今(全量)。"""
if user_repo.get_user_by_id(db, user_id) is None:
raise HTTPException(status_code=404, detail="用户不存在")
return UserRewardStats(
**queries.user_reward_stats(db, user_id, date_from=date_from, date_to=date_to)
)
@router.get(
"/{user_id}/coin-records",
response_model=CursorPage[UserCoinRecord],
summary="用户金币发放记录(提现详情底部表,按时间窗口分页)",
)
def get_user_coin_records(
user_id: int,
db: AdminDb,
date_from: Annotated[datetime | None, Query()] = None,
date_to: Annotated[datetime | None, Query()] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[UserCoinRecord]:
items, next_cursor, total = queries.user_coin_records(
db, user_id, date_from=date_from, date_to=date_to, limit=limit, cursor=cursor,
)
return CursorPage(
items=[UserCoinRecord(**r) for r in items], next_cursor=next_cursor, total=total,
)
@router.post("/{user_id}/status", response_model=OkResponse, summary="封禁/解封用户")
def set_user_status(
user_id: int,
+13 -7
View File
@@ -25,6 +25,7 @@ from app.admin.schemas.wallet import (
WithdrawBulkItemResult,
WithdrawDetailOut,
WithdrawLedgerCheckOut,
WithdrawListItemOut,
WithdrawOrderOut,
WithdrawRejectRequest,
WithdrawSummaryOut,
@@ -44,7 +45,7 @@ router = APIRouter(
)
@router.get("", response_model=CursorPage[WithdrawOrderOut], summary="提现单列表")
@router.get("", response_model=CursorPage[WithdrawListItemOut], summary="提现单列表")
def list_withdraws(
db: AdminDb,
user_id: Annotated[int | None, Query()] = None,
@@ -63,7 +64,7 @@ def list_withdraws(
] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[WithdrawOrderOut]:
) -> CursorPage[WithdrawListItemOut]:
items, next_cursor, total = queries.list_all_withdraw_orders(
db,
user_id=user_id,
@@ -78,11 +79,16 @@ def list_withdraws(
limit=limit,
cursor=cursor,
)
return CursorPage(
items=[WithdrawOrderOut.model_validate(o) for o in items],
next_cursor=next_cursor,
total=total,
)
# 联表带出手机号/昵称 + 累计成功提现(本页 user_id 批量富化,2 条聚合查询,无 N+1)。
enrichment = queries.withdraw_list_enrichment(db, [o.user_id for o in items])
_empty = {"phone": None, "nickname": None, "cumulative_success_cents": 0}
out_items = [
WithdrawListItemOut.model_validate(o).model_copy(
update=enrichment.get(o.user_id, _empty)
)
for o in items
]
return CursorPage(items=out_items, next_cursor=next_cursor, total=total)
@router.get("/summary", response_model=WithdrawSummaryOut, summary="提现审核台统计")
+30
View File
@@ -0,0 +1,30 @@
"""admin 广告配置 schemas(穿山甲 app_id/各位ID/验签密钥/各场景开关)。"""
from __future__ import annotations
from pydantic import BaseModel
class AdConfigOut(BaseModel):
"""admin 读到的完整广告配置(含 reward_mkey;admin 有权见,但绝不经 /platform 下发客户端)。"""
app_id: str
reward_code_id: str
compare_feed_code_id: str
coupon_feed_code_id: str
reward_mkey: str
reward_enabled: bool
compare_ad_enabled: bool
coupon_ad_enabled: bool
class AdConfigUpdate(BaseModel):
"""部分更新:只改传入(非 None)字段。空串是合法值(如清空 mkey 回退 .env)。"""
app_id: str | None = None
reward_code_id: str | None = None
compare_feed_code_id: str | None = None
coupon_feed_code_id: str | None = None
reward_mkey: str | None = None
reward_enabled: bool | None = None
compare_ad_enabled: bool | None = None
coupon_ad_enabled: bool | None = None
+22 -1
View File
@@ -3,7 +3,9 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from app.core.rewards import FEEDBACK_REWARD_MAX_COINS
class FeedbackOut(BaseModel):
@@ -15,4 +17,23 @@ class FeedbackOut(BaseModel):
contact: str
images: list[str] | None = None
status: str
reject_reason: str | None = None
reward_coins: int | None = None
review_note: str | None = None
reviewed_by_admin_id: int | None = None
reviewed_at: datetime | None = None
created_at: datetime
class FeedbackApproveRequest(BaseModel):
reward_coins: int = Field(
ge=1,
le=FEEDBACK_REWARD_MAX_COINS,
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="运营内部审核备注")
+22
View File
@@ -0,0 +1,22 @@
"""admin 反馈页二维码卡配置 schemas。"""
from __future__ import annotations
from pydantic import BaseModel, Field
class FeedbackQrOut(BaseModel):
enabled: bool
image_url: str | None = None
title: str
group_name: str
subtitle: str
updated_at: str | None = None
class FeedbackQrUpdate(BaseModel):
"""改文案/开关(均可选,只改传了的字段;图片走单独的上传/删除接口)。"""
enabled: bool | None = None
title: str | None = Field(default=None, max_length=40)
group_name: str | None = Field(default=None, max_length=40)
subtitle: str | None = Field(default=None, max_length=60)
+30
View File
@@ -37,6 +37,36 @@ class AdminUserOverview(BaseModel):
feedback_total: int
class UserRewardStats(BaseModel):
"""提现详情抽屉「用户统计区」:按时间窗口算的提现 + 看广告行为统计。
窗口由 date_from/date_to 决定(都不传 = 注册至今 = 全量);现金余额除外——它是当前快照。
各「*_cash_cents」是把该来源累计发放金币折算成可提现现金(分):100 金币 = 1 分(COIN_PER_YUAN=10000)。
eCPM 单位沿用穿山甲原值「分/千次」。
"""
withdraw_success_cents: int # 累计提现(窗口内 success 金额)
cash_balance_cents: int # 现金余额(当前快照,不随窗口)
withdraw_total: int # 提现总次数(窗口内全部状态)
traditional_task_cash_cents: int # 传统任务提现(非广告非人工的金币折现)
reward_video_count: int # 累计激励视频数(granted 条数)
reward_video_avg_ecpm: float # 平均激励视频 eCPM(分/千次)
reward_video_cash_cents: int # 激励视频提现(金币折现)
feed_count: int # 累计信息流广告数(granted 份数,unit_count 累加)
feed_avg_ecpm: float # 平均信息流广告 eCPM(分/千次)
feed_cash_cents: int # 信息流广告提现(金币折现)
class UserCoinRecord(BaseModel):
"""金币发放记录(提现详情底部表)。source 见 source_label;非广告来源 ecpm 为 None。"""
source: str # reward_video / signin_boost / feed / signin
source_label: str # 激励视频 / 签到膨胀 / 信息流广告 / 签到
created_at: datetime
ecpm: str | None = None # 原始 eCPM(分/千次),非广告为 None
coin: int # 发放金币数
def _strip_reason(v: str) -> str:
# min_length=1 放过纯空白(" "),trim 后再校验非空,避免审计记到空原因
if not v.strip():
+11
View File
@@ -50,6 +50,17 @@ class WithdrawOrderOut(BaseModel):
updated_at: datetime
class WithdrawListItemOut(WithdrawOrderOut):
"""提现单列表项:在提现单字段基础上,补本页用户的手机号/昵称 + 累计成功提现金额(分)。
仅列表接口用(需联表 User + 聚合);详情/批量仍用 WithdrawOrderOut(不带这些字段)。
"""
phone: str | None = None
nickname: str | None = None
cumulative_success_cents: int = 0 # 累计成功提现 = SUM(amount_cents) WHERE status='success'
class WithdrawSummaryOut(BaseModel):
reviewing_count: int
reviewing_amount_cents: int
+63
View File
@@ -0,0 +1,63 @@
"""启动确认窗兜底样本内部上报端点(pricebot → app-server)。
pricebot 的 launch_confirm_agent 每次靠 LLM 兜底放行一个"静态 PROFILES 没认出"的跨 App
启动确认窗时,把完整样本 POST 到这里落库。**不是给客户端的接口**:不走用户 JWT,靠
server 间共享密钥头 `X-Internal-Secret` 校验(== settings.INTERNAL_API_SECRET)。密钥未配置
(默认空)时直接 503,避免裸奔写端点。
研发定期人工把 exec_success=true 的样本沉淀进 pricebot 的 launch_confirm.PROFILES。
"""
from __future__ import annotations
import hmac
import logging
from typing import Annotated
from fastapi import APIRouter, Header, HTTPException, status
from app.api.deps import DbSession
from app.core.config import settings
from app.repositories import launch_confirm_sample as repo
from app.schemas.launch_confirm_sample import (
LaunchConfirmSampleIn,
LaunchConfirmSampleOut,
)
logger = logging.getLogger("shagua.internal.launch_confirm")
router = APIRouter(prefix="/internal", tags=["internal"])
def _check_secret(x_internal_secret: str | None) -> None:
"""共享密钥校验。未配置 → 503(挡裸奔写端点);不匹配 → 401(常量时间比较)。"""
configured = settings.INTERNAL_API_SECRET
if not configured:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="internal api not configured",
)
if not x_internal_secret or not hmac.compare_digest(x_internal_secret, configured):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid internal secret",
)
@router.post(
"/launch-confirm-sample",
response_model=LaunchConfirmSampleOut,
summary="启动确认窗兜底样本上报(pricebot→app-server,落 launch_confirm_sample)",
)
def report_launch_confirm_sample(
payload: LaunchConfirmSampleIn,
db: DbSession,
x_internal_secret: Annotated[str | None, Header()] = None,
) -> LaunchConfirmSampleOut:
_check_secret(x_internal_secret)
sid = repo.insert_sample(db, payload)
logger.info(
"launch_confirm_sample id=%d host=%s locale=%s success=%s trace=%s",
sid, payload.host_package, payload.system_locale,
payload.exec_success, payload.trace_id,
)
return LaunchConfirmSampleOut(id=sid)
+12 -2
View File
@@ -24,6 +24,7 @@ from app.repositories import ad_ecpm as crud_ecpm
from app.repositories import ad_feed_reward as crud_feed
from app.repositories import ad_reward as crud_ad
from app.repositories import ad_watch as crud_watch
from app.repositories import app_config
from app.repositories import signin as crud_signin
from app.schemas.ad import (
AdRewardStatusOut,
@@ -80,14 +81,22 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
验签失败 403(留给真请求重试);参数缺/坏或 user 不存在 is_verify=false + reason(不发,不重试)
granted / capped is_verify=true + reason=0
"""
if not settings.pangle_callback_configured:
if not settings.PANGLE_CALLBACK_ENABLED:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
)
params = dict(request.query_params)
if not pangle.verify_callback_sign_any(params, settings.pangle_reward_secrets):
# 验签密钥:admin 后台配的 reward_mkey 优先,.env 的 PANGLE_REWARD_SECRET* 兜底(兼容/过渡)。
# 换激励位时后台同步换 mkey 即可,无需改 .env。两者都空才视为未配置。
ad_mkey = app_config.get_ad_config(db).get("reward_mkey") or ""
secrets = ([ad_mkey] if ad_mkey else []) + settings.pangle_reward_secrets
if not secrets:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
)
if not pangle.verify_callback_sign_any(params, secrets):
logger.warning("pangle callback bad sign trans_id=%s", params.get("trans_id"))
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="bad sign")
@@ -363,6 +372,7 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
ad_session_id=payload.ad_session_id,
adn=payload.adn,
slot_id=payload.slot_id,
feed_scene=payload.feed_scene,
app_env=payload.app_env,
our_code_id=payload.our_code_id,
aborted=payload.aborted,
+26 -2
View File
@@ -15,6 +15,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, status
from app.api.deps import CurrentUser, DbSession
from app.core import test_account
from app.core.ratelimit import 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
@@ -38,13 +39,17 @@ logger = logging.getLogger("shagua.auth")
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
def _login_response(user, *, onboarding_completed: bool) -> TokenWithUser:
"""登录类接口共用的响应组装。onboarding_completed 据登录请求的 device_id 算好后传入。"""
def _login_response(
user, *, onboarding_completed: bool, force_onboarding: bool = False
) -> TokenWithUser:
"""登录类接口共用的响应组装。onboarding_completed 据登录请求的 device_id 算好后传入;
force_onboarding 仅测试号置 True(让应用内重新登录也重走引导)"""
tokens = issue_token_pair(user.id)
return TokenWithUser(
**tokens,
user=UserOut.model_validate(user),
onboarding_completed=onboarding_completed,
force_onboarding=force_onboarding,
)
@@ -82,6 +87,12 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
dependencies=[Depends(rate_limit(10, 60, "sms-send"))], # 同 IP 每分钟≤10 次(防一 IP 刷不同号)
)
def sms_send(req: SmsSendRequest) -> SmsSendResponse:
# 测试账号:不真发短信(号码非真实手机号,真发会失败/浪费),直接放行让客户端进入填码界面。
# 真正的"免验证码"在 /sms/login 跳过校验;每日上限也在 login 处算(send 不耗额度)。
if test_account.is_test_account(req.phone):
logger.info("test_account sms_send short-circuit (不真发)")
return SmsSendResponse(sent=True, mock=True, cooldown_sec=0)
try:
cooldown = send_code(req.phone)
except SmsError as e:
@@ -99,6 +110,19 @@ def sms_send(req: SmsSendRequest) -> SmsSendResponse:
dependencies=[Depends(rate_limit(20, 60, "sms-login"))], # 防撞库爆破(另有单码失败次数上限)
)
def sms_login(req: SmsLoginRequest, db: DbSession) -> TokenWithUser:
# 测试账号:免验证码登录 + 每日上限 + 每次都走新手引导(详见 app/core/test_account.py)。
# 放在最前面:命中即不校验验证码;先扣当日额度,超限直接拒,挡住有人猜到号后脚本刷。
if test_account.is_test_account(req.phone):
if not test_account.try_consume_quota():
raise HTTPException(status_code=429, detail="测试账号今日使用次数已达上限,请明天再试")
user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
if user.status != "active":
raise HTTPException(status_code=403, detail="account disabled")
logger.info("sms_login test_account ok user_id=%d phone=%s", user.id, mask_phone(req.phone))
# onboarding 恒 false + force_onboarding=true:每次登录都重走引导(含应用内退出后重登),
# 不读/不写 onboarding_completion 表。
return _login_response(user, onboarding_completed=False, force_onboarding=True)
if not verify_code(req.phone, req.code):
raise HTTPException(status_code=400, detail="invalid sms code")
+89 -11
View File
@@ -8,16 +8,22 @@ from __future__ import annotations
import html
import json
import logging
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.core import media
from app.core.config import settings
from app.db.session import get_db
from app.integrations import wx_oauth
from app.models.cps_activity import CpsActivity
from app.models.cps_link import CpsLink
from app.repositories import cps_link as cps_link_repo
from app.repositories import cps_wx_user as cps_wx_user_repo
logger = logging.getLogger("shagua.cps_redirect")
router = APIRouter(tags=["cps-redirect"])
@@ -27,6 +33,16 @@ _FALLBACK_URL = "https://www.meituan.com/"
# 淘宝活动未设图时的兜底主视觉(存量已回填,基本只在老 link/异常时触发)
_DEFAULT_TAOBAO_IMAGE = "/media/taobao_landing.jpg"
# 微信网页授权拿到的用户标识 cookie(种在 coupon 域,30 天免重复授权)
_WX_OPENID_COOKIE = "wx_openid"
_WX_UINFO_COOKIE = "wx_uinfo" # "1" = 已拿过昵称头像(userinfo),点领券不再跳授权
_COOKIE_MAX_AGE = 30 * 86400
def _is_wechat(request: Request) -> bool:
"""是否微信内置浏览器(网页授权只在微信内有意义,外部浏览器不跳授权)。"""
return "micromessenger" in (request.headers.get("user-agent", "").lower())
def _client_ip(request: Request) -> str | None:
xff = request.headers.get("x-forwarded-for")
@@ -35,11 +51,13 @@ def _client_ip(request: Request) -> str | None:
return request.client.host if request.client else None
def _record(db: Session, link: CpsLink, request: Request, event_type: str) -> None:
def _record(
db: Session, link: CpsLink, request: Request, event_type: str, openid: str | None = None
) -> None:
try:
cps_link_repo.record_click(
db, link=link, ip=_client_ip(request),
ua=request.headers.get("user-agent"), event_type=event_type,
ua=request.headers.get("user-agent"), event_type=event_type, openid=openid,
)
except Exception:
pass # 记点击失败不阻断
@@ -53,38 +71,89 @@ def wx_mp_domain_verify() -> PlainTextResponse:
return PlainTextResponse("F7wnRQ7xPbhVOWC8")
@router.get("/c/{code}", summary="群发短链落地(记点击 + 跳转/淘宝落地页)")
@router.get("/c/{code}", summary="群发短链落地(微信授权拿 openid + 记点击 + 跳转/淘宝落地页)")
def cps_landing(code: str, request: Request, db: Session = Depends(get_db)):
link = cps_link_repo.get_by_code(db, code)
if link is None:
return RedirectResponse(_FALLBACK_URL, status_code=302)
_record(db, link, request, "visit")
openid = request.cookies.get(_WX_OPENID_COOKIE)
# 微信内 + 还没拿到 openid + 配了服务号 → 先 base 静默授权拿 openid,再 302 回本页。
# 非微信 / 已有 cookie / 未配服务号 → 直接走原逻辑(openid 可能为 None,绝不阻断领券)。
if openid is None and settings.wx_oauth_active and _is_wechat(request):
redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb"
auth_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_base", f"base:{code}")
return RedirectResponse(auth_url, status_code=302)
_record(db, link, request, "visit", openid=openid)
if link.platform == "taobao":
activity = db.get(CpsActivity, link.activity_id)
image_url = (activity.image_url if activity else None) or _DEFAULT_TAOBAO_IMAGE
return HTMLResponse(_taobao_landing_html(link.target_url, image_url))
has_uinfo = request.cookies.get(_WX_UINFO_COOKIE) == "1"
return HTMLResponse(
_taobao_landing_html(link.target_url, image_url, code, openid, has_uinfo)
)
# 美团短链 / 京东链接:直接 302 跳
return RedirectResponse(link.target_url, status_code=302)
@router.post("/c/{code}/copy", summary="淘宝落地页点了「复制口令」(记 copy 事件)")
@router.get("/wx/oauth/cb", include_in_schema=False)
def wx_oauth_cb(code: str, state: str, db: Session = Depends(get_db)):
"""微信网页授权回调。state='base:{原code}'(静默拿 openid) 或 'uinfo:{原code}'(补昵称头像)。
openid(+userinfo) upsert 用户 cookie 302 回落地页任何失败兜底回落地页,
绝不阻断用户领券"""
kind, _, orig_code = state.partition(":")
try:
token = wx_oauth.exchange_code(code) # {openid, access_token, scope, ...}
openid = token["openid"]
link = cps_link_repo.get_by_code(db, orig_code)
group_id = link.group_id if link else None
nickname = headimgurl = unionid = None
if kind == "uinfo" and "userinfo" in (token.get("scope") or ""):
info = wx_oauth.get_userinfo(token["access_token"], openid)
nickname, headimgurl, unionid = (
info.get("nickname"), info.get("headimgurl"), info.get("unionid"),
)
cps_wx_user_repo.upsert(
db, openid=openid, code=orig_code, group_id=group_id,
nickname=nickname, headimgurl=headimgurl, unionid=unionid,
)
except Exception:
logger.exception("[wx_oauth] callback failed state=%s", state)
return RedirectResponse(f"/c/{orig_code}" if orig_code else _FALLBACK_URL, status_code=302)
# userinfo 授权回来带 ?authed=1,落地页据此自动触发复制(把"点领券→授权→复制"衔接为一步)
target = f"/c/{orig_code}" + ("?authed=1" if kind == "uinfo" else "")
resp = RedirectResponse(target, status_code=302)
resp.set_cookie(_WX_OPENID_COOKIE, openid, max_age=_COOKIE_MAX_AGE, httponly=True, samesite="lax")
if nickname:
resp.set_cookie(_WX_UINFO_COOKIE, "1", max_age=_COOKIE_MAX_AGE, samesite="lax")
return resp
@router.post("/c/{code}/copy", summary="淘宝落地页点了「复制口令」(记 copy 事件,带 openid)")
def cps_copy(code: str, request: Request, db: Session = Depends(get_db)) -> dict:
link = cps_link_repo.get_by_code(db, code)
if link is not None:
_record(db, link, request, "copy")
_record(db, link, request, "copy", openid=request.cookies.get(_WX_OPENID_COOKIE))
return {"ok": True}
def _taobao_landing_html(token: str, image_url: str) -> str:
def _taobao_landing_html(
token: str, image_url: str, code: str, openid: str | None, has_uinfo: bool
) -> str:
"""淘宝落地页 H5(主视觉图按活动传入,复制淘口令按钮在 75% 屏高)。
image_url 转绝对(落地页 coupon ,相对也可达,绝对更稳) html.escape 嵌入
<img src>(防属性注入);token json.dumps 安全嵌入 JS
image_url html.escape 嵌入 <img src>(防注入);token json.dumps 安全嵌入 JS
uinfo_url:已有 openid 但还没拿过昵称头像时,生成 userinfo 授权链接 用户点领券
时先跳它(交互触发,避免微信快照页),授权回来自动复制已拿过 / openid 则为空,直接复制
"""
safe_img = html.escape(media.to_abs_media_url(image_url) or image_url, quote=True)
uinfo_url = ""
if openid and not has_uinfo and settings.wx_oauth_active:
redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb"
uinfo_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_userinfo", f"uinfo:{code}")
return (
_TAOBAO_HTML
.replace("__IMAGE_URL__", safe_img)
.replace("__UINFO_URL__", json.dumps(uinfo_url))
.replace("__TOKEN_JS__", json.dumps(token))
)
@@ -112,6 +181,7 @@ body{font-family:-apple-system,"PingFang SC",sans-serif;background:#fff0ef;color
<div class="toast" id="toast"></div>
<script>
var TOKEN = __TOKEN_JS__;
var UINFO_URL = __UINFO_URL__; // 非空=还没拿昵称头像,点领券先跳它补(授权回来自动复制)
function showToast(m){var t=document.getElementById('toast');t.textContent=m;t.classList.add('show');setTimeout(function(){t.classList.remove('show')},2200)}
function reportCopy(){try{fetch(location.pathname+'/copy',{method:'POST',keepalive:true})}catch(e){}}
function done(){showToast('复制成功!打开淘宝即可领取');reportCopy()}
@@ -121,10 +191,18 @@ function fallback(){
try{document.execCommand('copy');done()}catch(e){showToast('复制失败,请长按手动复制')}
document.body.removeChild(ta);
}
function copyToken(){
function doCopy(){
if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(TOKEN).then(done).catch(fallback)}
else{fallback()}
}
function copyToken(){
// 第一次领券且还没授权过昵称头像:先跳 userinfo 授权(用户点击=交互触发,避免快照页),
// 授权回来 ?authed=1 自动复制;已授权过/ openid 则直接复制
if(UINFO_URL){location.href=UINFO_URL;return}
doCopy();
}
// userinfo 授权回流(?authed=1):自动复制,"点领券→授权→复制"衔接成一步无感
if(location.search.indexOf('authed=1')>=0){doCopy()}
</script>
</body>
</html>"""
+57 -2
View File
@@ -2,6 +2,7 @@
路由前缀 `/api/v1/feedback`, Bearer 鉴权(反馈绑到登录用户,便于回访)
POST / 提交反馈(multipart:content 必填;contact 可选(原型改版后客户端已不再采集);images 可选 6 )
GET /records 我的反馈历史(pending/adopted/rejected)
截图复用 [app.core.media] 落盘到 /media/feedback/
"""
@@ -9,12 +10,19 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
from app.api.deps import CurrentUser, DbSession
from app.core import media
from app.repositories import feedback as feedback_repo
from app.schemas.feedback import FeedbackOut
from app.repositories import feedback_qr as feedback_qr_repo
from app.schemas.feedback import (
FeedbackOut,
FeedbackQrConfigOut,
FeedbackRecordCountsOut,
FeedbackRecordOut,
FeedbackRecordsOut,
)
logger = logging.getLogger("shagua.feedback")
@@ -23,6 +31,27 @@ router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
_MAX_IMAGES = 6
_CONTENT_MAX = 200
_CONTACT_MAX = 128
_VALID_RECORD_STATUS = {"pending", "adopted", "rejected"}
def _app_status(db_status: str) -> str:
return {
"new": "pending",
"handled": "adopted",
"approved": "adopted",
}.get(db_status, db_status)
def _record_out(fb) -> FeedbackRecordOut:
return FeedbackRecordOut(
id=fb.id,
content=fb.content,
images=fb.images or [],
status=_app_status(fb.status),
reject_reason=getattr(fb, "reject_reason", None),
reward_coins=getattr(fb, "reward_coins", None),
created_at=fb.created_at,
)
@router.post("", response_model=FeedbackOut, summary="提交反馈")
@@ -60,3 +89,29 @@ async def submit_feedback(
)
logger.info("feedback id=%d user_id=%d images=%d", fb.id, user.id, len(urls))
return FeedbackOut.model_validate(fb)
@router.get("/config", response_model=FeedbackQrConfigOut, summary="反馈页二维码卡配置")
def feedback_config(user: CurrentUser, db: DbSession) -> FeedbackQrConfigOut:
"""运营后台配的反馈页「加群二维码」卡(开关 + 二维码图 + 三行文案)。客户端进反馈页时拉取。"""
return FeedbackQrConfigOut(**feedback_qr_repo.get_config(db))
@router.get("/records", response_model=FeedbackRecordsOut, summary="我的反馈历史")
def feedback_records(
user: CurrentUser,
db: DbSession,
status: str | None = Query(default=None),
) -> FeedbackRecordsOut:
if status is not None and status not in _VALID_RECORD_STATUS:
raise HTTPException(status_code=400, detail="invalid status")
all_records = [_record_out(fb) for fb in feedback_repo.list_feedback(db, user_id=user.id)]
counts = FeedbackRecordCountsOut(
all=len(all_records),
pending=sum(1 for r in all_records if r.status == "pending"),
adopted=sum(1 for r in all_records if r.status == "adopted"),
rejected=sum(1 for r in all_records if r.status == "rejected"),
)
records = [r for r in all_records if r.status == status] if status else all_records
return FeedbackRecordsOut(records=records, counts=counts)
+17
View File
@@ -17,6 +17,7 @@ from app.repositories import app_config
from app.repositories import ops_marquee as marquee_crud
from app.repositories import ops_stat as crud
from app.schemas.platform import (
AdConfigPublicOut,
AppFlagsOut,
AppVersionOut,
PlatformStatsOut,
@@ -54,6 +55,22 @@ def flags(db: DbSession) -> AppFlagsOut:
)
@router.get("/ad-config", response_model=AdConfigPublicOut, summary="客户端拉广告配置(穿山甲ID+场景开关,不鉴权)")
def ad_config(db: DbSession) -> AdConfigPublicOut:
"""客户端启动/每场广告前拉,缓存后用:app_id + 各位ID + 各场景开关。
不含验签密钥;空库回退默认(=客户端内置值,维持现状)"""
c = app_config.get_ad_config(db)
return AdConfigPublicOut(
app_id=c["app_id"],
reward_code_id=c["reward_code_id"],
compare_feed_code_id=c["compare_feed_code_id"],
coupon_feed_code_id=c["coupon_feed_code_id"],
reward_enabled=c["reward_enabled"],
compare_ad_enabled=c["compare_ad_enabled"],
coupon_ad_enabled=c["coupon_ad_enabled"],
)
@router.get("/app-version", response_model=AppVersionOut, summary="最新 App 版本(OTA 检查更新,不鉴权)")
def app_version(db: DbSession) -> AppVersionOut:
"""客户端启动 / 手动检查更新时拉取。不鉴权:版本信息非敏感,且检查更新可能在登录前。
+12
View File
@@ -18,6 +18,7 @@ from app.api.deps import CurrentUser, DbSession
from app.core import media
from app.repositories import onboarding as onboarding_repo
from app.repositories import user as user_repo
from app.repositories import wallet as wallet_repo
from app.schemas.auth import UserOut
from app.schemas.user import (
OkResponse,
@@ -87,6 +88,17 @@ def onboarding_status(
@router.delete("", response_model=OkResponse, summary="注销账号(软删除)")
def delete_account(user: CurrentUser, db: DbSession) -> OkResponse:
# 资金前置校验:注销是软删 + 匿名化,余额一旦留在 deleted 行就锁死(既退不出、新号也
# 拿不回)。有可提现现金 / 在审提现单 → 拒绝注销,引导用户先把钱处理掉。
# (pending 提现转账已在途、对账 worker 不依赖 user.status 照常到账,故不拦 pending。)
if wallet_repo.get_cash_balance_cents(db, user.id) > 0:
raise HTTPException(
status_code=409, detail="账户还有未提现的现金余额,请先提现后再注销"
)
if wallet_repo.has_reviewing_withdraw(db, user.id):
raise HTTPException(
status_code=409, detail="有提现正在审核中,请等审核完成后再注销"
)
media.delete_avatar(user.avatar_url)
user_repo.soft_delete_account(db, user)
logger.info("delete account user_id=%d", user.id)
+39 -3
View File
@@ -79,6 +79,19 @@ class Settings(BaseSettings):
SMS_DAILY_LIMIT_PER_PHONE: int = 10 # 单手机号每日发送上限(防刷 + 控费)
SMS_MAX_VERIFY_ATTEMPTS: int = 5 # 单个验证码最多校验失败次数,超过即作废(防爆破)
# ===== 测试账号(release 包全流程联调用)=====
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
# (real 模式下也跳过校验)、每次登录【强制重走新手引导】,并设【每日使用次数上限】防被人
# 猜到号后脚本滥用。两个值都能随时改 .env。逻辑全在 app/core/test_account.py,与其他业务解耦。
# ⚠️ TEST_ACCOUNT_PHONE 留空 = 整个功能关闭(生产默认安全;要启用才显式填号)。
TEST_ACCOUNT_PHONE: str = "" # 测试手机号(11 位,如 11111111111);空=关闭整功能
TEST_ACCOUNT_DAILY_LIMIT: int = 500 # 该测试号每日最多登录次数,当日超过即拒绝登录
@property
def test_account_phone(self) -> str:
"""规整后的测试手机号(去空白);空串=功能关闭。"""
return self.TEST_ACCOUNT_PHONE.strip()
# ===== 美团联盟 CPS =====
# 未配置时所有 /api/v1/meituan/* 接口 200 返空(优雅降级),不影响登录/领券等其他业务。
MT_CPS_APP_KEY: str = ""
@@ -95,6 +108,25 @@ class Settings(BaseSettings):
"""美团 CPS 凭证齐全(缺则接口返空,而非 502)。"""
return bool(self.MT_CPS_APP_KEY and self.MT_CPS_APP_SECRET)
# ===== 微信服务号(网页授权) =====
# CPS 落地页在微信内拿用户 openid(base 静默)/昵称头像(userinfo),做用户级群统计。
# ⚠️ 区别于 WECHAT_APP_ID(那是 App 移动应用,用于微信支付);这是【已认证服务号】。
WX_MP_APPID: str = ""
WX_MP_SECRET: str = ""
# 落地页微信网页授权【总开关】。默认关:服务号认证审核中/未就绪时,落地页走原逻辑
# (不跳授权、不拿 openid),整套微信代码保留。审核通过后 .env 置 true + 重启即启用,无需改代码。
WX_MP_OAUTH_ENABLED: bool = False
@property
def wx_mp_configured(self) -> bool:
"""服务号网页授权凭证齐全(缺则落地页不发起授权,降级为无 openid)。"""
return bool(self.WX_MP_APPID and self.WX_MP_SECRET)
@property
def wx_oauth_active(self) -> bool:
"""落地页是否真正发起微信授权 = 凭证齐全 且 总开关开。"""
return self.wx_mp_configured and self.WX_MP_OAUTH_ENABLED
# ===== 微信支付(商家转账到零钱 / 提现)=====
# 真实凭证放 .env(已 gitignore),证书 .pem 放 secrets/。WECHAT_APP_ID 同时用于
# 微信登录(code 换 openid)与转账,必须与 App 端开放平台 appid 一致。
@@ -110,10 +142,14 @@ class Settings(BaseSettings):
WITHDRAW_AUTO_RECONCILE_ENABLED: bool = False
WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC: int = 300
WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES: int = 15
# 0 点自动兑金币(deploy/daily-exchange.timer 触发 scripts.daily_auto_exchange)。
# 客户端已删手动兑入口、改为「0 点自动兑现金」,故默认开;运营要临时停可在 .env 置 false,
# 无需动 systemd timer——脚本读此开关,false 时直接 no-op 退出。
# 0 点自动兑金币:App 进程内任务 app.core.daily_exchange_worker 跨 0 点跑一轮
# wallet.daily_auto_exchange(原 deploy/daily-exchange.timer 已不再需要,见 deploy/daily-exchange.md)。
# 客户端已删手动兑入口、改为「0 点自动兑现金」,故默认开;运营要临时停在 .env 置 false
# (worker 不启动;脚本也 no-op)。
AUTO_EXCHANGE_ENABLED: bool = True
# 进程内自动兑换 worker 的检查间隔(秒):每隔这么久醒一次,跨过北京 0 点就跑一轮。
# 默认 600s=10min,即 0 点后最多 10 分钟内兑完(客户端文案已注明「可能存在延迟」)。
AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600
# 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。
# 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容
# (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
+125
View File
@@ -0,0 +1,125 @@
"""0 点金币→现金自动兑换的进程内定时任务。
替代原 systemd timer(deploy/daily-exchange.*):App 启动即自带,
`AUTO_EXCHANGE_CHECK_INTERVAL_SEC` 醒一次,跨进北京新的一天(0 点后)就跑一轮
`wallet.daily_auto_exchange`
健壮性:
- **逐用户幂等**:当天已有 exchange_in 流水的用户跳过( wallet._has_exchange_in_on),
故启动补跑 / 多次唤醒 / 进程重启都安全,不会重复兑
- **当天首跑即补**:进程起来时若当天还没兑过,立即兑一轮(等价原 timer Persistent 补跑)
- **同机多进程互斥**:文件锁保证多 worker 只有一个实际跑(防跨进程并发导致 TOCTOU 双兑)
- **开关**:settings.AUTO_EXCHANGE_ENABLED=false 时不启动(与脚本/ timer 同一开关)
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import time
from collections.abc import Iterator
from datetime import date
from pathlib import Path
from sqlalchemy.exc import SQLAlchemyError
from app.core import rewards
from app.core.config import settings
from app.db.session import SessionLocal
from app.repositories import wallet as wallet_repo
logger = logging.getLogger("shagua.daily_exchange")
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "daily_exchange.lock"
def _touch_lock() -> None:
with contextlib.suppress(FileNotFoundError):
os.utime(_LOCK_PATH, None)
@contextlib.contextmanager
def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]:
"""同机多进程保护:同一时间只允许一个自动兑换 worker 运行。"""
_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
fd: int | None = None
try:
try:
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
try:
age = time.time() - _LOCK_PATH.stat().st_mtime
except FileNotFoundError:
age = stale_after_sec + 1
if age > stale_after_sec:
with contextlib.suppress(FileNotFoundError):
_LOCK_PATH.unlink()
try:
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
fd = None
if fd is None:
yield False
return
os.write(fd, f"pid={os.getpid()} started_at={int(time.time())}\n".encode("ascii"))
yield True
finally:
if fd is not None:
os.close(fd)
with contextlib.suppress(FileNotFoundError):
_LOCK_PATH.unlink()
def _exchange_once() -> dict:
with SessionLocal() as db:
return wallet_repo.daily_auto_exchange(db)
async def _run_loop() -> None:
interval = max(60, int(settings.AUTO_EXCHANGE_CHECK_INTERVAL_SEC))
lock_stale_after = max(interval * 3, 1800)
with _single_instance_lock(lock_stale_after) as lock_acquired:
if not lock_acquired:
logger.warning("daily auto-exchange skipped: another worker owns lock")
return
await _run_locked_loop(interval)
async def _run_locked_loop(interval: int) -> None:
logger.info("daily auto-exchange worker started interval=%ss", interval)
# 本进程上次跑过的北京日;None=尚未跑过本进程(启动即补当天)。
last_run: date | None = None
try:
while True:
try:
_touch_lock()
today = rewards.cn_today()
if last_run != today:
result = await asyncio.to_thread(_exchange_once)
last_run = today
logger.info("daily auto-exchange done date=%s result=%s", today, result)
except SQLAlchemyError:
logger.exception("daily auto-exchange db error")
except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出
logger.exception("daily auto-exchange unexpected error")
await asyncio.sleep(interval)
except asyncio.CancelledError:
logger.info("daily auto-exchange worker stopped")
raise
def start_daily_exchange_worker() -> asyncio.Task | None:
if not settings.AUTO_EXCHANGE_ENABLED:
logger.info("daily auto-exchange disabled (AUTO_EXCHANGE_ENABLED=false)")
return None
return asyncio.create_task(_run_loop(), name="daily-auto-exchange")
async def stop_daily_exchange_worker(task: asyncio.Task | None) -> None:
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
+26 -6
View File
@@ -37,7 +37,7 @@ def _sniff_ext(data: bytes) -> str | None:
return None
def _save_image(subdir: str, user_id: int, data: bytes) -> str:
def _save_named(subdir: str, name_prefix: str, data: bytes) -> str:
"""通用图片落盘:校验非空 / ≤上限 / 魔数为图片,随机文件名,返回相对 URL。"""
if not data:
raise MediaError("空文件")
@@ -47,11 +47,16 @@ def _save_image(subdir: str, user_id: int, data: bytes) -> str:
if ext is None:
raise MediaError("仅支持 JPEG / PNG / WebP 图片")
fname = f"u{user_id}_{secrets.token_hex(8)}{ext}"
fname = f"{name_prefix}_{secrets.token_hex(8)}{ext}"
(_media_dir(subdir) / fname).write_bytes(data)
return f"{settings.MEDIA_URL_PREFIX}/{subdir}/{fname}"
def _save_image(subdir: str, user_id: int, data: bytes) -> str:
"""用户上传图片落盘(文件名带 user_id 前缀)。"""
return _save_named(subdir, f"u{user_id}", data)
def save_avatar(user_id: int, data: bytes) -> str:
"""保存头像,返回相对 URL(`/media/avatars/<file>`)。"""
return _save_image("avatars", user_id, data)
@@ -67,6 +72,11 @@ def save_report_image(user_id: int, data: bytes) -> str:
return _save_image("price_report", user_id, data)
def save_feedback_qr(data: bytes) -> str:
"""保存反馈页二维码(运营后台上传的运营素材,非用户文件),返回相对 URL(`/media/feedback_qr/<file>`)。"""
return _save_named("feedback_qr", "qr", data)
def save_cps_image(admin_id: int, data: bytes) -> str:
"""保存 CPS 活动落地页图,返回相对 URL(`/media/cps/<file>`)。admin_id 入文件名便于追溯。"""
return _save_image("cps", admin_id, data)
@@ -84,15 +94,25 @@ def to_abs_media_url(rel_url: str | None) -> str | None:
return f"{base}{rel_url}" if base else rel_url
def delete_avatar(url: str | None) -> None:
"""删除本服务托管的旧头像文件;外部 URL(如微信头像)或空值不处理。"""
prefix = f"{settings.MEDIA_URL_PREFIX}/avatars/"
def _delete_managed(subdir: str, url: str | None) -> None:
"""删除本服务托管的某子目录下文件;外部 URL / 空值 / 非本子目录的不处理。"""
prefix = f"{settings.MEDIA_URL_PREFIX}/{subdir}/"
if not url or not url.startswith(prefix):
return
fname = url[len(prefix):]
if not fname or "/" in fname or "\\" in fname or ".." in fname:
return # 防路径穿越
try:
(_media_dir("avatars") / fname).unlink(missing_ok=True)
(_media_dir(subdir) / fname).unlink(missing_ok=True)
except OSError:
pass
def delete_avatar(url: str | None) -> None:
"""删除本服务托管的旧头像文件;外部 URL(如微信头像)或空值不处理。"""
_delete_managed("avatars", url)
def delete_feedback_qr(url: str | None) -> None:
"""删除本服务托管的旧反馈页二维码文件;外部 URL 或空值不处理。"""
_delete_managed("feedback_qr", url)
+4
View File
@@ -108,6 +108,10 @@ def record_milestone_reward(milestone: int) -> int:
# 与广告/任务同量级;固定值(产品 2026-06 定),要调直接改这里;客户端记录页按 reward_coins 显示。
PRICE_REPORT_REWARD_COINS: int = 1000
# ===== 意见反馈采纳奖励(人工审核按质量发放)=====
# 后台审核反馈时允许发放的单条金币上限。只做后端硬保护,具体档位由 admin-web 呈现。
FEEDBACK_REWARD_MAX_COINS: int = 10000
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。
+80
View File
@@ -0,0 +1,80 @@
"""测试账号:免验证码登录 + 每次重走新手引导 + 每日使用上限。
为打通 **release 包全流程**( SIM 不走极光一键登录)而设的一个固定测试手机号
对这个号(且仅这个号)放开三件事:
1. **免短信验证码**:`/sms/send` 不真发`/sms/login` 跳过 `verify_code`,real 模式下同样生效
测试时直接填任意验证码即可登入,不用等真短信
2. **每次都走新手引导**:登录响应 `onboarding_completed` 恒为 false,不读/不依赖
onboarding_completion ,所以反复登录都能体验引导
3. **每日使用次数上限**:防被人猜到这个号后写脚本一直刷当天登录数超过上限即拒绝(429),
次日自动归零
手机号与上限都在 .env (`TEST_ACCOUNT_PHONE` / `TEST_ACCOUNT_DAILY_LIMIT`),随时可改
`TEST_ACCOUNT_PHONE` 留空 = 整个功能关闭(生产默认态),`is_test_account()` 对任何号都返回
False,登录/短信回到原逻辑,零影响
计数存**进程内存**( worker 够用, sms.py 同款约定):重启清零 worker 不共享作为
一个测试号的粗粒度防滥用闸够用;且因所有登录都落同一个 phone 同一个 user,滥用面天然只
限于这一个账号要严格持久化/跨进程再迁 DB Redis( sms.py 的技术债)
"""
from __future__ import annotations
import logging
from datetime import datetime
from threading import Lock
from app.core.config import settings
logger = logging.getLogger("shagua.test_account")
# 进程内每日计数:(date_str, 当日已登录次数)。单 worker 有效,重启清零(见模块 docstring)。
_lock = Lock()
_usage: tuple[str, int] = ("", 0)
def _today() -> str:
return datetime.now().strftime("%Y-%m-%d")
def is_enabled() -> bool:
"""功能总开关:配了 TEST_ACCOUNT_PHONE 才启用(空=关闭)。"""
return bool(settings.test_account_phone)
def is_test_account(phone: str) -> bool:
"""该手机号是否为配置的测试账号。功能关闭时对任何号都返回 False。"""
return is_enabled() and phone == settings.test_account_phone
def try_consume_quota() -> bool:
"""测试账号登录时调:当日计数 +1。
Returns:
True 未超当日上限,本次放行(计数已 +1);
False 已达上限,本次拒绝(计数不变)
线程安全;跨天自动归零上限取自 settings.TEST_ACCOUNT_DAILY_LIMIT(可在 .env )
"""
global _usage
limit = settings.TEST_ACCOUNT_DAILY_LIMIT
with _lock:
today = _today()
day, cnt = _usage
if day != today: # 跨天归零
cnt = 0
if cnt >= limit:
_usage = (today, cnt) # 已满,保持不变
logger.warning(
"测试账号 %s 今日登录数已达上限 %d,拒绝", settings.test_account_phone, limit
)
return False
_usage = (today, cnt + 1)
logger.info("测试账号 %s%d/%d 次登录", settings.test_account_phone, cnt + 1, limit)
return True
def _reset_for_test() -> None:
"""仅供单测:清空进程内计数,隔离用例间状态。"""
global _usage
with _lock:
_usage = ("", 0)
+75
View File
@@ -0,0 +1,75 @@
"""微信服务号网页授权:构造授权链接 + code 换 openid + 拉 userinfo。
用于 CPS 落地页(coupon.shaguabijia.com)在微信内拿用户身份流程(见微信文档):
授权链接(snsapi_base|snsapi_userinfo) 回调带 code sns/oauth2/access_token openid
(userinfo )sns/userinfo 拿昵称头像
注意:
- 这里的 access_token 网页授权 token,与基础 access_token(cgi-bin/token)不同,
sns/* 接口,不需要 IP 白名单
- secret 只在服务器,不下发客户端
- WX_MP_APPID/SECRET(已认证服务号),区别于 WECHAT_APP_ID(App 移动应用)
"""
from __future__ import annotations
from urllib.parse import quote
import httpx
from app.core.config import settings
_API = "https://api.weixin.qq.com"
_AUTHORIZE = "https://open.weixin.qq.com/connect/oauth2/authorize"
_TIMEOUT = 10
class WxOauthError(Exception):
"""网页授权调用失败(凭证/网络/code 失效等)。调用方兜底为无 openid。"""
def build_authorize_url(redirect_uri: str, scope: str, state: str) -> str:
"""构造网页授权跳转链接。scope: 'snsapi_base'(静默,仅 openid) | 'snsapi_userinfo'(昵称头像)。
微信要求 redirect_uri urlEncode参数顺序固定结尾 #wechat_redirect。
"""
return (
f"{_AUTHORIZE}?appid={settings.WX_MP_APPID}"
f"&redirect_uri={quote(redirect_uri, safe='')}"
f"&response_type=code&scope={scope}&state={quote(state, safe='')}"
f"#wechat_redirect"
)
def exchange_code(code: str) -> dict:
"""code 换网页授权 access_token + openid。返回含 openid/access_token/scope(/unionid)。
code 5 分钟内一次性有效失败(errcode) WxOauthError
"""
resp = httpx.get(
f"{_API}/sns/oauth2/access_token",
params={
"appid": settings.WX_MP_APPID,
"secret": settings.WX_MP_SECRET,
"code": code,
"grant_type": "authorization_code",
},
timeout=_TIMEOUT,
)
data = resp.json()
if "openid" not in data:
raise WxOauthError(f"exchange_code failed: {data}")
return data
def get_userinfo(access_token: str, openid: str) -> dict:
"""拉用户信息(nickname/headimgurl/unionid)。需 scope=snsapi_userinfo 的 access_token。"""
resp = httpx.get(
f"{_API}/sns/userinfo",
params={"access_token": access_token, "openid": openid, "lang": "zh_CN"},
timeout=_TIMEOUT,
)
resp.encoding = "utf-8" # 昵称含中文/emoji,强制 utf-8 避免乱码
data = resp.json()
if "openid" not in data:
raise WxOauthError(f"get_userinfo failed: {data}")
return data
+8
View File
@@ -22,6 +22,7 @@ from app.api.v1.compare_record import router as compare_record_router
from app.api.v1.coupon import router as coupon_router
from app.api.v1.cps_redirect import router as cps_redirect_router
from app.api.internal.app_version import router as internal_app_version_router
from app.api.internal.launch_confirm import router as internal_launch_confirm_router
from app.api.internal.price import router as internal_price_router
from app.api.internal.store import router as internal_store_router
from app.api.v1.feedback import router as feedback_router
@@ -37,6 +38,10 @@ from app.api.v1.user import router as user_router
from app.api.v1.wallet import router as wallet_router
from app.api.v1.wxpay import router as wxpay_router
from app.core.config import settings
from app.core.daily_exchange_worker import (
start_daily_exchange_worker,
stop_daily_exchange_worker,
)
from app.core.logging import setup_logging
from app.core.withdraw_reconcile_worker import (
start_withdraw_reconcile_worker,
@@ -58,10 +63,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings.DATABASE_URL.split("://", 1)[0],
)
reconcile_task = start_withdraw_reconcile_worker()
daily_exchange_task = start_daily_exchange_worker()
try:
yield
finally:
await stop_withdraw_reconcile_worker(reconcile_task)
await stop_daily_exchange_worker(daily_exchange_task)
logger.info("shutting down")
@@ -109,6 +116,7 @@ app.include_router(report_router)
app.include_router(internal_price_router)
app.include_router(internal_store_router)
app.include_router(internal_app_version_router)
app.include_router(internal_launch_confirm_router)
app.include_router(platform_router)
# CPS 群发短链跳转 /c/{code}(公网无鉴权:记点击 → 302 跳美团)
app.include_router(cps_redirect_router)
+2
View File
@@ -10,6 +10,7 @@ from app.models.cps_activity import CpsActivity # noqa: F401
from app.models.cps_group import CpsGroup # noqa: F401
from app.models.cps_link import CpsClick, CpsLink # noqa: F401
from app.models.cps_order import CpsOrder # noqa: F401
from app.models.cps_wx_user import CpsWxUser # noqa: F401
from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401
from app.models.coupon_state import ( # noqa: F401
CouponClaimRecord,
@@ -19,6 +20,7 @@ from app.models.coupon_state import ( # noqa: F401
from app.models.feedback import Feedback # noqa: F401
from app.models.invite import InviteRelation # noqa: F401
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401
from app.models.meituan_coupon import MeituanCoupon # noqa: F401
from app.models.onboarding import OnboardingCompletion # noqa: F401
from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401
+3
View File
@@ -30,6 +30,9 @@ class AdFeedRewardRecord(Base):
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页)。比价与领券共用同一信息流
# 代码位,slot_id/our_code_id 分不出,只能客户端各调用点显式打标;NULL=历史/未升级客户端=未分类。
feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True)
# 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx,由客户端 feed-reward 上报带上。旧数据为 NULL。
app_env: Mapped[str | None] = mapped_column(String(16), nullable=True)
our_code_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
+2
View File
@@ -49,6 +49,8 @@ class CpsClick(Base):
)
ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
ua: Mapped[str | None] = mapped_column(String(512), nullable=True)
# 微信网页授权拿到的用户标识(非微信/未授权为空),做用户级群统计
openid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
clicked_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
+42
View File
@@ -0,0 +1,42 @@
"""CPS 落地页微信用户(cps_wx_user)。
用户在微信内打开群发短链落地页 /c/{code},经服务号网页授权:
- base 静默: openid(唯一标识,统计主力)
- userinfo(点领券触发): nickname/headimgurl/unionid
openid 唯一,记录首次来源群(first_group_id),用于群内用户级统计(谁领了券)
下单归因到人需 user-level sid(另一期),本表只承载身份 + 领券/点击侧
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class CpsWxUser(Base):
__tablename__ = "cps_wx_user"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 服务号下用户唯一标识(网页授权拿到)
openid: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
# 开放平台 unionid(服务号绑开放平台 + scope=userinfo 才有);跨 App/服务号统一用户
unionid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# 昵称/头像:userinfo 授权后才有(base 阶段为空)
nickname: Mapped[str | None] = mapped_column(String(128), nullable=True)
headimgurl: Mapped[str | None] = mapped_column(String(512), nullable=True)
# 首次进入来源(从哪个群的链接授权进来)
first_code: Mapped[str | None] = mapped_column(String(16), nullable=True)
first_group_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
first_seen: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
last_seen: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<CpsWxUser id={self.id} openid={self.openid!r} nickname={self.nickname!r}>"
+13 -3
View File
@@ -2,7 +2,7 @@
每条 = 用户一次提交content 必填;contact 原为必填(微信/QQ/手机),原型改版后客户端不再采集,
新数据存空串(列保持 NOT NULL,免迁移;历史数据仍有值);images 为可选的截图 URL 列表
(/media/feedback/...,JSON )status: new(待处理)/ handled(处理)
(/media/feedback/...,JSON )status: pending(审核中)/adopted(采纳)/rejected(未采纳)
"""
from __future__ import annotations
@@ -25,8 +25,18 @@ class Feedback(Base):
contact: Mapped[str] = mapped_column(String(128), nullable=False)
# 截图 URL 列表(相对路径,如 ["/media/feedback/u1_ab12.jpg"]);无图为 None
images: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
# new(待处理) / handled(已处理)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="new")
# pending(审核中) / adopted(已采纳) / rejected(未采纳)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
reject_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
reward_coins: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 审核批注:采纳时可写采纳要点,未采纳时也可保留运营侧备注
review_note: 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
)
reviewed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
+61
View File
@@ -0,0 +1,61 @@
"""启动确认窗 agent 兜底样本表(launch_confirm_sample)。
pricebot launch_confirm_agent 每次"静态 PROFILES 没认出、靠 LLM 兜底放行"一个跨 App
启动确认窗(繁体 / 英文 / 小语种 / ROM 改版),把完整样本 serverserver 落这里研发定期
**人工** exec_success=true 的样本沉淀进 pricebot launch_confirm.PROFILES(静态快路径),
让以后同款窗不必再调 LLM
设计:
- 与登录无关不鉴权(serverserver 共享密钥头 X-Internal-Secret)
- **都上报不去重**(by design):同一种窗多台机器上报多条,研发按 host_package / locale
聚合后人工筛
- 大字段(弹窗树快照 + LLM plan + 设备信息) payload(JSONB)兜底,免得加字段就迁移 schema
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
# PG 上用 JSONB(可建 GIN 索引),SQLite(本地/测试)退化为通用 JSON(同 price_observation)。
_JSON = JSON().with_variant(JSONB(), "postgresql")
class LaunchConfirmSample(Base):
__tablename__ = "launch_confirm_sample"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
# pricebot 侧 trace_id:回指原始 trace,能去 price.shaguabijia.com 查完整上下文。
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
device_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# 弹窗宿主包(= 触发判据, 也是研发沉淀进 PROFILES.host_packages 的键)。按它聚合人工筛。
host_package: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
# 被打开的目标 App 包名(标题里"想要打开 X"的 X 是变量, 据此把它从 sign 里挖空)。
target_app: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 系统语言地区(zh-TW / en-US…)—— 多语言问题的核心维度。
# ⚠️ 前端 AgentStepRequest 暂未上报设备信息 → 现恒 None,待前端加 device_info 字段后回填。
system_locale: Mapped[str | None] = mapped_column(String(32), index=True, nullable=True)
# 这次兜底是否成功放行(弹窗消失=True;放弃 / LLM 没认出=False)。研发只沉淀 True 的。
exec_success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# 弹窗标题全文(研发据此提 launch_sign),如"「傻瓜比价」想要開啟「京東」"。
dialog_title: Mapped[str | None] = mapped_column(String(256), nullable=True)
# 大 JSON:完整样本。{is_launch_confirm, llm_model, llm_reason, plan, dialog_tree,
# device_info: {screen, locale, brand, model, android_version, rom_version}}
payload: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
def __repr__(self) -> str: # pragma: no cover
return (
f"<LaunchConfirmSample id={self.id} host={self.host_package!r} "
f"locale={self.system_locale!r} success={self.exec_success}>"
)
+6
View File
@@ -69,6 +69,7 @@ def grant_feed_reward(
ad_session_id: str | None = None,
adn: str | None = None,
slot_id: str | None = None,
feed_scene: str | None = None,
app_env: str | None = None,
our_code_id: str | None = None,
aborted: bool = False,
@@ -82,6 +83,7 @@ def grant_feed_reward(
duration_seconds 是整场累计秒数一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到
FEED_MAX_DURATION_SECONDS 限单场份数,eCPM rewards.calculate_ad_reward_coin 内钳到
AD_ECPM_MAX_FEN 限单份金额;叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间
feed_scene:点位场景(comparison/coupon/welfare),仅做归类落库,不参与发奖计算
"""
existing = _find_by_event(db, client_event_id)
if existing is not None:
@@ -104,6 +106,7 @@ def grant_feed_reward(
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
feed_scene=feed_scene,
app_env=app_env,
our_code_id=our_code_id,
coin=0,
@@ -122,6 +125,7 @@ def grant_feed_reward(
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
feed_scene=feed_scene,
app_env=app_env,
our_code_id=our_code_id,
coin=0,
@@ -141,6 +145,7 @@ def grant_feed_reward(
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
feed_scene=feed_scene,
app_env=app_env,
our_code_id=our_code_id,
coin=0,
@@ -165,6 +170,7 @@ def grant_feed_reward(
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
feed_scene=feed_scene,
app_env=app_env,
our_code_id=our_code_id,
coin=coin,
+50
View File
@@ -91,3 +91,53 @@ def set_app_version(db: Session, data: dict, *, commit: bool = True) -> AppConfi
else:
db.flush()
return row
# ── 穿山甲广告配置(admin 可配,客户端动态拉)──────────────────────────────────
# 同 app_version:结构化 dict,复用 AppConfig 表不进 CONFIG_DEFS(它是一组关联配置,不是散项)。
# 默认值 = 客户端当前内置的硬编码 ID(AdConfig.kt),空库时下发默认 = 维持现状。
# reward_mkey 是 GroMore S2S 验签密钥(机密),只后端验签用、绝不下发客户端(见 platform/ad-config)。
AD_CONFIG_KEY = "ad_config"
_AD_CONFIG_DEFAULTS: dict[str, Any] = {
"app_id": "5830519", # 穿山甲应用ID(正式)
"reward_code_id": "104099389", # 福利页激励视频位
# ⚠️ 2026-06-21 真机核对穿山甲后台:5830519 名下信息流真实位是 104142227「信息流 1」;
# 旧值 104090333 不在该应用名下(请求会报 44406/配置 null、出不了广告)。客户端接入下发后以本值为准。
"compare_feed_code_id": "104142227", # 比价信息流位
"coupon_feed_code_id": "104142227", # 领券信息流位(初始同比价,运营可拆)
"reward_mkey": "", # 激励位 GroMore 验签密钥(空则回退 .env PANGLE_REWARD_SECRET*)
"reward_enabled": True, # 福利激励视频开关
"compare_ad_enabled": True, # 比价广告开关
"coupon_ad_enabled": True, # 领券广告开关
}
def get_ad_config(db: Session) -> dict:
"""读广告配置。DB 无则返回默认(=客户端内置值,维持现状);DB 有则与默认 merge
(新增字段向后兼容,老记录缺的字段用默认补)"""
row = db.get(AppConfig, AD_CONFIG_KEY)
merged = dict(_AD_CONFIG_DEFAULTS)
if row is not None and isinstance(row.value, dict):
merged.update(row.value)
return merged
def set_ad_config(db: Session, data: dict, *, admin_id: int, commit: bool = True) -> AppConfig:
"""admin 写广告配置。与现有值 merge(部分更新),只接受已知字段(防脏键)。"""
row = db.get(AppConfig, AD_CONFIG_KEY)
merged = dict(_AD_CONFIG_DEFAULTS)
if row is not None and isinstance(row.value, dict):
merged.update(row.value)
merged.update({k: v for k, v in data.items() if k in _AD_CONFIG_DEFAULTS})
if row is None:
row = AppConfig(key=AD_CONFIG_KEY, value=merged, updated_by_admin_id=admin_id)
db.add(row)
else:
row.value = merged
row.updated_by_admin_id = admin_id
if commit:
db.commit()
db.refresh(row)
else:
db.flush()
return row
+4 -3
View File
@@ -52,12 +52,13 @@ def create_link(
def record_click(
db: Session, *, link: CpsLink, ip: str | None = None, ua: str | None = None,
event_type: str = "visit",
event_type: str = "visit", openid: str | None = None,
) -> None:
"""记一条点击。event_type: visit(进落地页/被跳转) | copy(淘宝点了"复制口令")。"""
"""记一条点击。event_type: visit(进落地页/被跳转) | copy(淘宝点了"复制口令")。
openid: 微信网页授权拿到的用户标识(非微信/未授权为空),做用户级群统计"""
db.add(CpsClick(
link_id=link.id, group_id=link.group_id, sid=link.sid, event_type=event_type,
ip=ip, ua=(ua[:500] if ua else None),
ip=ip, ua=(ua[:500] if ua else None), openid=openid,
))
db.commit()
+43
View File
@@ -0,0 +1,43 @@
"""cps_wx_user 数据访问:按 openid upsert。"""
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.cps_wx_user import CpsWxUser
def get_by_openid(db: Session, openid: str) -> CpsWxUser | None:
return db.execute(
select(CpsWxUser).where(CpsWxUser.openid == openid)
).scalar_one_or_none()
def upsert(
db: Session, *, openid: str, code: str | None = None, group_id: int | None = None,
nickname: str | None = None, headimgurl: str | None = None, unionid: str | None = None,
) -> CpsWxUser:
"""按 openid upsert。
- 首次:建行,记来源 code/group
- 已存在:仅在传入非 None 时更新昵称/头像/unionid(base 阶段为 None,不覆盖已有画像);
并刷新 last_seen(显式 set, onupdate 仅在字段有变更时触发)
"""
u = get_by_openid(db, openid)
if u is None:
u = CpsWxUser(
openid=openid, first_code=code, first_group_id=group_id,
nickname=nickname, headimgurl=headimgurl, unionid=unionid,
)
db.add(u)
else:
if nickname is not None:
u.nickname = nickname
if headimgurl is not None:
u.headimgurl = headimgurl
if unionid is not None:
u.unionid = unionid
u.last_seen = func.now()
db.commit()
db.refresh(u)
return u
+16 -2
View File
@@ -1,8 +1,12 @@
"""feedback 表写"""
"""feedback 表写。"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ
from app.models.feedback import Feedback
@@ -19,9 +23,19 @@ def create_feedback(
content=content,
contact=contact,
images=images or None,
status="new",
status="pending",
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
)
db.add(fb)
db.commit()
db.refresh(fb)
return fb
def list_feedback(db: Session, *, user_id: int) -> list[Feedback]:
stmt = (
select(Feedback)
.where(Feedback.user_id == user_id)
.order_by(Feedback.created_at.desc(), Feedback.id.desc())
)
return list(db.execute(stmt).scalars().all())
+106
View File
@@ -0,0 +1,106 @@
"""反馈页二维码卡配置读写(运营后台可配 → App 反馈页同步)。
整张卡(开关 + 二维码图 + 三行文案)作为一个 JSON 对象,复用通用 app_config
(key=feedback_qr_card,value 存这个 dict)表里没这行 返回内置默认(= App 反馈页原
硬编码文案,空配置 = 改造前现状,图片走 App 本地兜底)admin 写一行 App 下次拉
GET /api/v1/feedback/config 即生效(跨进程一致)
不经 app.repositories.app_config(那层按 CONFIG_DEFS 白名单限定 key,且会出现在系统配置页);
key 由本模块独占维护,不进 CONFIG_DEFS,所以不会污染系统配置页
"""
from __future__ import annotations
from typing import Any
from sqlalchemy.orm import Session
from app.models.app_config import AppConfig
_KEY = "feedback_qr_card"
# 默认值镜像 App 反馈页原硬编码:空配置时整体行为与改造前一致。
_DEFAULTS: dict[str, Any] = {
"enabled": True,
"image_url": None, # None = App 用本地 asset 兜底(group_qr.png),再不行画占位伪码
"title": "长按图片保存二维码",
"group_name": "傻瓜比价官方群",
"subtitle": "一起唠嗑共创、解锁新玩法",
}
_FIELDS = tuple(_DEFAULTS.keys())
def _merge(raw: Any) -> dict[str, Any]:
"""把 DB 存的(可能不全的)dict 叠加到默认上,得到完整配置(5 个字段,无 updated_at)。"""
out = dict(_DEFAULTS)
if isinstance(raw, dict):
for k in _FIELDS:
v = raw.get(k)
if v is not None: # image_url 的 None 与默认 None 等价,无需特判
out[k] = v
return out
def get_config(db: Session) -> dict[str, Any]:
"""完整配置 + updated_at(admin 读 / App 读共用;App schema 会忽略多余的 updated_at)。"""
row = db.get(AppConfig, _KEY)
cfg = _merge(row.value if row is not None else None)
cfg["updated_at"] = row.updated_at.isoformat() if row is not None and row.updated_at else None
return cfg
def _write(db: Session, value: dict[str, Any], *, admin_id: int, commit: bool) -> dict[str, Any]:
"""整体覆写该行(value 须为完整 5 字段 dict),返回合并后的完整配置(含 updated_at)。"""
row = db.get(AppConfig, _KEY)
if row is None:
row = AppConfig(key=_KEY, value=value, updated_by_admin_id=admin_id)
db.add(row)
else:
row.value = value # 重新赋整个 dict,SQLAlchemy 能侦测到变更(in-place 改才侦测不到)
row.updated_by_admin_id = admin_id
if commit:
db.commit()
db.refresh(row)
else:
db.flush()
out = _merge(row.value)
out["updated_at"] = row.updated_at.isoformat() if row.updated_at else None
return out
def update_config(
db: Session,
*,
enabled: bool | None = None,
title: str | None = None,
group_name: str | None = None,
subtitle: str | None = None,
admin_id: int,
commit: bool = True,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""改文案/开关(只改传了的字段;图片走 set_image)。返回 (before, after) 供审计。"""
row = db.get(AppConfig, _KEY)
before = _merge(row.value if row is not None else None)
new_value = {k: before[k] for k in _FIELDS}
if enabled is not None:
new_value["enabled"] = enabled
if title is not None:
new_value["title"] = title.strip()
if group_name is not None:
new_value["group_name"] = group_name.strip()
if subtitle is not None:
new_value["subtitle"] = subtitle.strip()
after = _write(db, new_value, admin_id=admin_id, commit=commit)
return before, after
def set_image(
db: Session, image_url: str | None, *, admin_id: int, commit: bool = True
) -> tuple[dict[str, Any], dict[str, Any]]:
"""设置/清空二维码图 URL。返回 (before, after);before['image_url'] 供调用方删旧文件。"""
row = db.get(AppConfig, _KEY)
before = _merge(row.value if row is not None else None)
new_value = {k: before[k] for k in _FIELDS}
new_value["image_url"] = image_url
after = _write(db, new_value, admin_id=admin_id, commit=commit)
return before, after
+35
View File
@@ -0,0 +1,35 @@
"""启动确认窗兜底样本落库:单条 insert(都上报、不去重,by design)。"""
from __future__ import annotations
import logging
from typing import Optional
from sqlalchemy.orm import Session
from app.models.launch_confirm_sample import LaunchConfirmSample
from app.schemas.launch_confirm_sample import LaunchConfirmSampleIn
logger = logging.getLogger("shagua.launch_confirm")
def _trunc(s: Optional[str], n: int) -> Optional[str]:
"""截断到列长上限(PG varchar 超长会报错;繁体长标题等需要)。空 → None。"""
return s[:n] if s else None
def insert_sample(db: Session, payload: LaunchConfirmSampleIn) -> int:
"""写入一条样本,返回新行 id。"""
row = LaunchConfirmSample(
trace_id=_trunc(payload.trace_id, 64),
device_id=_trunc(payload.device_id, 64),
host_package=_trunc(payload.host_package, 128),
target_app=_trunc(payload.target_app, 128),
system_locale=_trunc(payload.system_locale, 32),
exec_success=bool(payload.exec_success),
dialog_title=_trunc(payload.dialog_title, 256),
payload=payload.payload,
)
db.add(row)
db.commit()
db.refresh(row)
return row.id
+15 -2
View File
@@ -111,14 +111,27 @@ def set_avatar_url(db: Session, user: User, *, avatar_url: str) -> User:
def soft_delete_account(db: Session, user: User) -> None:
"""注销账号:软删除 + 匿名化。
"""注销账号:软删除 + 匿名化 + 释放唯一约束/外部绑定
phone 改成 `deleted_<id>` 释放唯一约束,允许同号码重新注册成全新账号;
phone 改成 `deleted_<id>` 释放手机号唯一约束,允许同号码重新注册成全新账号;
清空 PII(昵称/头像),保留行用于审计status=deleted 后将无法再登录该行
(登录接口校验 status==active)
注销必须连同释放 user 上其余唯一约束/外部身份,否则 deleted 行永久占着槽位,
对应资源再也无法被复用:
- wechat_openid: 不清 这个微信再也无法被任何账号绑定(提现通道彻底锁死);
- invite_code: 不清 邀请码槽位被占 + 裸查仍命中死号(发奖已被 bind
status==active 校验兜住,清掉更干净)
资金安全(未提现现金 / 在审提现单)由调用方 delete_account 端点前置校验,这里只匿名化
"""
user.status = "deleted"
user.phone = f"deleted_{user.id}"
user.nickname = None
user.avatar_url = None
# 等价"解绑微信":释放 openid 唯一槽 + 清微信昵称头像
user.wechat_openid = None
user.wechat_nickname = None
user.wechat_avatar_url = None
# 释放邀请码唯一槽
user.invite_code = None
db.commit()
+9
View File
@@ -340,6 +340,15 @@ def bind_wechat_openid(db: Session, user_id: int, code: str) -> dict:
return info
def get_cash_balance_cents(db: Session, user_id: int) -> int:
"""只读查现金余额(分)。账户不存在返回 0,**不创建账户**——注销前置校验用,
不该给一个即将被删除的用户凭空建一行空账户(get_or_create_account 会建)"""
val = db.execute(
select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id)
).scalar_one_or_none()
return val or 0
def has_reviewing_withdraw(db: Session, user_id: int) -> bool:
"""用户是否有「待审核(reviewing)」的提现单。
+6
View File
@@ -135,6 +135,12 @@ class FeedRewardIn(BaseModel):
duration_seconds: int = Field(..., ge=0, description="整场累计观看秒数(轮播各条相加)")
adn: str | None = Field(None, description="实际投放 ADN")
slot_id: str | None = Field(None, description="实际展示代码位")
feed_scene: str | None = Field(
None,
max_length=16,
description="点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页);"
"比价与领券共用同一信息流代码位,需客户端在各调用点显式标注,缺省=未分类",
)
app_env: str | None = Field(
None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)"
)
+6
View File
@@ -48,6 +48,12 @@ class TokenWithUser(TokenPair):
description="该 设备+账号 是否已走完新手引导(据登录请求里的 device_id 计算)。"
"true → 客户端登录后直接进首页,跳过引导。",
)
force_onboarding: bool = Field(
False,
description="是否强制本次登录走新手引导(仅测试号置 true)。客户端据此让【应用内重新登录】"
"(退出后再登)也重走引导,普通号为 false、按原逻辑回原页/首页。与 onboarding_completed "
"分工:后者管 App 启动路由,本字段管应用内登录后的去向。",
)
# ===== 极光一键登录 =====
+3 -2
View File
@@ -71,8 +71,9 @@ class ComparisonRecordIn(BaseModel):
comparison_results: list[ComparisonResultIn] = Field(default_factory=list)
# 逐平台结局摘要(含失败平台的细分原因 status: store_not_found/items_not_found/below_minimum/
# unsupported/...)。来自 done.params.platform_results,客户端透传;落 raw_payload(不单列),
# admin「卡在哪一步」从这里读。list[dict] 宽松存(结构由 pricebot 定,只作 debug 展示)。
platform_results: list = Field(default_factory=list)
# admin「卡在哪一步」从这里读。dict{platform_id: {...}} 宽松存(结构由 pricebot 定——是
# 按平台 id 索引的 dict 而非 list,只作 debug 展示)。
platform_results: dict = Field(default_factory=dict)
skipped_dish_count: int | None = None
skipped_dish_names: list[str] = Field(default_factory=list)
total_dish_count: int | None = None
+37 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
class FeedbackOut(BaseModel):
@@ -12,3 +12,39 @@ class FeedbackOut(BaseModel):
id: int
status: str
created_at: datetime
class FeedbackQrConfigOut(BaseModel):
"""反馈页二维码卡配置(运营后台可配)。App 反馈页据此渲染整张「加群二维码」卡。
image_url 是相对 /media 路径(客户端按自己的 BASE_URL 拼绝对地址),为空 客户端走本地兜底
"""
enabled: bool
image_url: str | None = None
title: str
group_name: str
subtitle: str
class FeedbackRecordOut(BaseModel):
id: int
content: str
images: list[str] = Field(default_factory=list)
status: str
reject_reason: str | None = None
reward_coins: int | None = None
created_at: datetime
class FeedbackRecordCountsOut(BaseModel):
all: int = 0
pending: int = 0
adopted: int = 0
rejected: int = 0
class FeedbackRecordsOut(BaseModel):
records: list[FeedbackRecordOut]
counts: FeedbackRecordCountsOut
+26
View File
@@ -0,0 +1,26 @@
"""启动确认窗兜底样本的内部上报模型(pricebot → app-server)。"""
from __future__ import annotations
from pydantic import BaseModel
class LaunchConfirmSampleIn(BaseModel):
"""一次 agent 兜底的完整样本。
字段一律宽松可空:落盘是辅助数据,绝不能因个别字段缺失就拒收(否则连累 pricebot 兜底)
"""
trace_id: str | None = None
device_id: str | None = None
host_package: str | None = None
target_app: str | None = None
system_locale: str | None = None
exec_success: bool = False
dialog_title: str | None = None
payload: dict | None = None
class LaunchConfirmSampleOut(BaseModel):
"""落库结果。"""
id: int
+15
View File
@@ -30,6 +30,21 @@ class AppFlagsOut(BaseModel):
comparing_ad_enabled: bool # 比价/领券期是否展示信息流广告(远程 kill-switch)
class AdConfigPublicOut(BaseModel):
"""下发给客户端的穿山甲广告配置(不鉴权,客户端缓存后用)。
不含 reward_mkey(GroMore 验签密钥,机密,只后端验签用,绝不下发)
"""
app_id: str # 穿山甲应用ID(改了客户端需冷启才生效,SDK init 一次性读)
reward_code_id: str # 福利页激励视频位
compare_feed_code_id: str # 比价信息流位
coupon_feed_code_id: str # 领券信息流位
reward_enabled: bool # 福利激励视频开关
compare_ad_enabled: bool # 比价广告开关
coupon_ad_enabled: bool # 领券广告开关
class AppVersionOut(BaseModel):
"""最新 App 版本信息(OTA 检查更新,不鉴权)。
+11 -1
View File
@@ -3,6 +3,15 @@
> 对象:维护「每天 0 点把用户金币自动兑成现金」这套定时任务的同事。
> 🔒 服务器登录信息见**私密交接清单**,不入库。
## 现状(2026-06 起):已改为 App 进程内任务,不再需要 systemd timer
「0 点自动兑换」现由 **App 进程内后台任务** `app.core.daily_exchange_worker` 负责:App 一起来就
每 10 分钟检查、跨过北京 0 点自动跑一轮(逐用户幂等、文件锁互斥、重启补跑当天遗漏)。**无需再装 / 启用
`daily-exchange.timer`**。
- 开关仍是 `.env``AUTO_EXCHANGE_ENABLED`(false → worker 不启动);间隔由 `AUTO_EXCHANGE_CHECK_INTERVAL_SEC`(默认 600s=10min)控。
- 下方 systemd timer / 脚本属**遗留 + 手动应急**:逻辑同一套且幂等,可手动 `--once` 补跑;
但**不要再 `enable` timer 与进程内 worker 并存**(虽幂等不会双兑,纯属多余)。
## 它是什么
客户端已删手动「兑金币」入口、改为「0 点自动兑现金(可能存在延迟)」,故服务端每天 0 点跑一轮:
把每个用户的金币按汇率 **100 金币 = 1 分** 兑成现金。
@@ -21,8 +30,9 @@
| systemd 单元 | `deploy/daily-exchange.{service,timer}` |
| 运行锁 | `/tmp/daily_auto_exchange.lock`(可用环境变量 `DAILY_EXCHANGE_LOCK` 覆盖) |
## 部署(Linux 服务器)
## (遗留 / 可选)systemd timer 部署 — 现已被进程内 worker 取代,正常无需执行
```bash
# ⚠️ 仅在你明确想用外部 timer 而非进程内 worker 时才做(并把 worker 关掉),否则跳过。
sudo cp deploy/daily-exchange.{service,timer} /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now daily-exchange.timer
systemctl list-timers daily-exchange.timer # 确认下次触发时间
+10
View File
@@ -222,6 +222,16 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
---
## 6.5 pricebot 回写:内部端点(server→server)
§6 是 app-server 透传给 pricebot;反过来 pricebot 也会把少量数据回写 app-server 落库,走 `app/api/internal/*` —— **不走用户 JWT**,靠共享密钥头 `X-Internal-Secret`(== `INTERNAL_API_SECRET`,未配则 503,防裸奔写端点)校验。
**启动确认窗兜底样本**(2026-06):国产 ROM 打开别的 App 时弹"想要打开 XX"确认窗,pricebot 对没见过文案(繁体/英文/ROM 改版)的窗用 LLM 兜底放行后,把样本 POST 到 [`/internal/launch-confirm-sample`](../app/api/internal/launch_confirm.py) 落 `launch_confirm_sample` 表(host 包 + 弹窗树 + LLM plan + 设备 locale/机型),供研发定期人工沉淀回 pricebot 的规则 yaml(回到快路径)。**需 pricebot 与 app-server 两边 `.env` 配同一 `INTERNAL_API_SECRET` 才生效**(未配则 pricebot 侧跳过上报、不影响比价/领券)。
> 同类内部回写端点还有 `/internal/price-observation`(价格观测)、`/internal/store-mapping`(跨平台店铺映射)等 pricebot 比价资产沉淀。
---
## 7. 数据模型
业务表(下表)+ `alembic_version` 框架表。生产 PG / 开发可回退 SQLite。
+208
View File
@@ -0,0 +1,208 @@
"""一次性 mock:造几条带「真实可加载截图」的上报更低价记录,供后台「上报审核」页验收图片加载。
为什么要真实图片:验证截图加载bug 是否修好,必须让 images 指向**真实存在于 /media 的文件**,
否则 404 文件不存在而非前端取图地址不对,无法区分本脚本:
1. data/media/price_report/ 生成几张纯色 PNG(手写 PNG 字节,无需 Pillow)作为截图;
2. 3 mock 用户 + 覆盖 待审核 / 已通过 / 已拒绝 的上报记录,引用这些图
幂等:重跑先按固定 mock 手机号清掉旧用户/上报 + mock 图再重建;--clean-only 只清
python -m scripts.seed_mock_price_reports
python -m scripts.seed_mock_price_reports --clean-only
看图:前端上报审核(http://localhost:3001 上报审核)图经 NEXT_PUBLIC_MEDIA_BASE
(本地 = http://localhost:8770) App 后端 /media 加载改过 .env.local 后需重启 next dev,
App 后端(:8770)要在跑
"""
from __future__ import annotations
import argparse
import struct
import sys
import zlib
from datetime import datetime, timedelta
from pathlib import Path
from sqlalchemy import delete, select
from app.core import rewards
from app.core.config import settings
from app.db.session import SessionLocal
from app.models.price_report import PriceReport
from app.models.user import User
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8") # Windows 控制台输出中文/¥
# 固定 mock 手机号:脚本只动这些号,便于幂等重建/清理。
MOCK_PHONES = ["13266660001", "13266660002", "13266660003"]
_REPORT_DIR = Path(settings.MEDIA_ROOT) / "price_report"
_MOCK_IMG_GLOB = "mock_pr_*.png" # 本脚本生成的图前缀,清理时按此删
def _solid_png(width: int, height: int, rgb: tuple[int, int, int]) -> bytes:
"""生成一张纯色 PNG(truecolor RGB)的字节,无需 Pillow。颜色块即可肉眼判断「图加载出来了」。"""
def _chunk(typ: bytes, data: bytes) -> bytes:
body = typ + data
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) # 8bit/通道, color type 2 = RGB
row = b"\x00" + bytes(rgb) * width # 每行前缀 filter byte 0
idat = zlib.compress(row * height, 9)
return b"\x89PNG\r\n\x1a\n" + _chunk(b"IHDR", ihdr) + _chunk(b"IDAT", idat) + _chunk(b"IEND", b"")
def _write_mock_image(name: str, rgb: tuple[int, int, int]) -> str:
"""写一张 mock 截图到 media/price_report/,返回其相对 URL(/media/price_report/<name>)。"""
_REPORT_DIR.mkdir(parents=True, exist_ok=True)
(_REPORT_DIR / name).write_bytes(_solid_png(320, 320, rgb))
return f"{settings.MEDIA_URL_PREFIX}/price_report/{name}"
def _bj_now() -> datetime:
"""北京 wall-clock(naive),与 report_repo.create_report 一致(前端按北京时间解析,不加 Z)。"""
return datetime.now(rewards.CN_TZ).replace(tzinfo=None)
def clean(db) -> int:
uids = list(db.execute(select(User.id).where(User.phone.in_(MOCK_PHONES))).scalars())
if uids:
db.execute(delete(PriceReport).where(PriceReport.user_id.in_(uids)))
db.execute(delete(User).where(User.id.in_(uids)))
db.commit()
# 删 mock 截图文件
if _REPORT_DIR.exists():
for f in _REPORT_DIR.glob(_MOCK_IMG_GLOB):
f.unlink(missing_ok=True)
return len(uids)
def seed(db) -> list[PriceReport]:
now = _bj_now()
# 1) 建 3 个 mock 用户
users: dict[str, User] = {}
for i, (phone, nickname) in enumerate(
zip(MOCK_PHONES, ["省钱小王", "比价老李", "薅羊毛阿珍"])
):
u = User(
phone=phone,
nickname=nickname,
register_channel="sms",
status="active",
created_at=now - timedelta(days=8 + i),
last_login_at=now - timedelta(hours=3),
)
db.add(u)
users[phone] = u
db.flush() # 拿 user.id
# 2) 生成 mock 截图(不同颜色块,便于肉眼区分「都加载出来了」)
palette = [
(250, 173, 20), # 橙
(82, 196, 26), # 绿
(24, 144, 255), # 蓝
(114, 46, 209), # 紫
(245, 34, 45), # 红
]
imgs = [_write_mock_image(f"mock_pr_{i}.png", palette[i]) for i in range(len(palette))]
# 3) 造上报记录(覆盖 pending / approved / rejected 三个 tab,每条都带可加载截图)
def report(
phone: str,
store: str,
dish: str,
orig_name: str,
orig_cents: int,
rep_id: str,
rep_name: str,
rep_cents: int,
images: list[str],
*,
status: str = "pending",
reward_coins: int | None = None,
reject_reason: str | None = None,
created: datetime,
) -> PriceReport:
return PriceReport(
user_id=users[phone].id,
comparison_record_id=None, # 可空:不依赖真实比价记录
store_name=store,
dish_summary=dish,
original_platform_id="meituan-waimai",
original_platform_name=orig_name,
original_price_cents=orig_cents,
reported_platform_id=rep_id,
reported_platform_name=rep_name,
reported_price_cents=rep_cents,
images=images,
status=status,
reward_coins=reward_coins,
reject_reason=reject_reason,
reviewed_at=(created + timedelta(hours=1)) if status != "pending" else None,
created_at=created,
)
reports = [
# 待审核(默认 tab,重点验图):2 图 + 1 图各一条
report("13266660001", "瑞幸咖啡(国贸店)", "生椰拿铁 × 2", "美团外卖", 3980,
"jd-waimai", "京东外卖", 3200, [imgs[0], imgs[1]],
created=now - timedelta(minutes=8)),
report("13266660002", "麦当劳(三里屯店)", "巨无霸套餐", "美团外卖", 4200,
"taobao-shanguang", "淘宝闪购", 3550, [imgs[2]],
created=now - timedelta(minutes=35)),
# 已通过(已发金币)
report("13266660003", "蜜雪冰城(西单店)", "柠檬水 × 3", "美团外卖", 1800,
"jd-waimai", "京东外卖", 1500, [imgs[3]],
status="approved", reward_coins=1000, created=now - timedelta(days=1)),
# 已拒绝(带理由)
report("13266660001", "星巴克(望京店)", "美式 × 1", "美团外卖", 3000,
"jd-waimai", "京东外卖", 2800, [imgs[4]],
status="rejected", reject_reason="截图不清晰", created=now - timedelta(days=2)),
]
db.add_all(reports)
db.commit()
for r in reports:
db.refresh(r)
return reports
def _yuan(cents: int | None) -> str:
return "-" if cents is None else f"¥{cents / 100:.2f}"
def main() -> None:
parser = argparse.ArgumentParser(description="造上报更低价 mock 数据(后台上报审核页验图用)")
parser.add_argument("--clean-only", action="store_true", help="只清理 mock 数据,不重建")
args = parser.parse_args()
db = SessionLocal()
try:
removed = clean(db)
if removed:
print(f"🧹 已清理旧 mock:{removed} 个用户及其上报记录 + mock 截图")
if args.clean_only:
print("✅ 仅清理,已完成。")
return
reports = seed(db)
label = {"pending": "待审核", "approved": "已通过", "rejected": "已拒绝"}
print(f"\n✅ 已生成 {len(reports)} 条上报记录(截图落 {_REPORT_DIR}):")
for r in reports:
print(
f" #{r.id} [{label.get(r.status, r.status)}] {r.store_name} | "
f"{r.original_platform_name}{_yuan(r.original_price_cents)}"
f"{r.reported_platform_name}{_yuan(r.reported_price_cents)} | {len(r.images)}"
)
print(
"\n👉 打开 http://localhost:3001 → 上报审核 查看(默认「待审核」tab)。"
"\n 图加载不出来时排查:① 是否重启过 next dev(读 .env.local 的 NEXT_PUBLIC_MEDIA_BASE)"
" ② App 后端(:8770)是否在跑(它托管 /media)。"
)
finally:
db.close()
if __name__ == "__main__":
main()
+296
View File
@@ -0,0 +1,296 @@
"""一次性 mock:造几条不同状态的提现单,供运营后台「提现管理」页联调验收。
覆盖:
- 全部 5 个状态 tab(待审核 / 打款中 / 已到账 / 已拒绝 / 打款失败)
- 累计提现(按该用户 success 单金额求和;有非零也有 0)
- 空昵称(展示 "-")提现实名失败/拒绝原因
- 配套双分录现金流水(withdraw / withdraw_refund / exchange_in)+ 账户余额,
让顶部账本校验保持绿色详情抽屉的现金余额/流水也真实
约束:withdraw_order 有部分唯一索引(同一 user reviewing/pending 最多 1 ),
本脚本每个 mock 用户至多 1 个活动单,满足约束
幂等:每次运行先按固定 mock 手机号清掉旧 mock 再重建仅清理用 --clean-only
.venv\\Scripts\\python.exe scripts\\seed_mock_withdraws.py
.venv\\Scripts\\python.exe scripts\\seed_mock_withdraws.py --clean-only
"""
from __future__ import annotations
import argparse
import sys
import uuid
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, select
from app.core import rewards
from app.db.session import SessionLocal
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
from app.models.user import User
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
# Windows GBK 控制台下也能正常打印中文(避免 UnicodeEncodeError)
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
# 固定 mock 手机号:脚本只动这些号,便于幂等重建 / 清理。
MOCK_PHONES = [
"13800000001",
"13900000002",
"13700000003",
"13600000004",
"13500000005",
"13312345678",
]
REMARK = {"exchange_in": "金币兑入", "withdraw": "提现扣款", "withdraw_refund": "提现退款"}
def _naive_utc_now() -> datetime:
"""与 func.now() 在 SQLite 的口径一致:naive UTC。前端按 UTC 解析再转北京时间。"""
return datetime.now(timezone.utc).replace(tzinfo=None)
def _mock_transfer_no() -> str:
"""模拟微信转账单号(长串数字)。"""
return "1330" + str(uuid.uuid4().int)[:26]
def clean(db) -> int:
uids = list(db.execute(select(User.id).where(User.phone.in_(MOCK_PHONES))).scalars())
if not uids:
return 0
for model in (
WithdrawOrder,
CashTransaction,
CoinTransaction,
CoinAccount,
AdRewardRecord,
AdFeedRewardRecord,
):
db.execute(delete(model).where(model.user_id.in_(uids)))
db.execute(delete(User).where(User.id.in_(uids)))
db.commit()
return len(uids)
def seed(db) -> list[WithdrawOrder]:
now = _naive_utc_now()
def ago(**kw) -> datetime:
return now - timedelta(**kw)
# 1) 建用户(手机号 / 昵称;U4 昵称留空测 "-" 展示)
users_spec = [
("13800000001", "省钱小能手"),
("13900000002", "薅羊毛达人"),
("13700000003", "比价狂魔"),
("13600000004", None),
("13500000005", "退款体验官"),
("13312345678", "已到账老用户"),
]
users: dict[str, User] = {}
for phone, nickname in users_spec:
u = User(
phone=phone,
nickname=nickname,
register_channel="sms",
status="active",
created_at=ago(days=12),
last_login_at=ago(hours=2),
)
db.add(u)
users[phone] = u
db.flush() # 拿 user.id
# 2) 建提现单(覆盖各状态)
def order(
phone: str,
cents: int,
status: str,
*,
name: str | None = None,
wechat: str | None = None,
transfer: str | None = None,
fail: str | None = None,
created: datetime,
updated: datetime | None = None,
) -> WithdrawOrder:
return WithdrawOrder(
user_id=users[phone].id,
out_bill_no=uuid.uuid4().hex, # 与线上同格式:uuid4 的 32 位 hex
amount_cents=cents,
user_name=name,
status=status,
wechat_state=wechat,
transfer_bill_no=transfer,
fail_reason=fail,
created_at=created,
updated_at=updated or created,
)
orders = [
# U1 省钱小能手:2 已到账 + 1 待审核 → 累计提现 ¥18.00
order("13800000001", 1000, "success", name="张伟", wechat="SUCCESS",
transfer=_mock_transfer_no(), created=ago(days=3)),
order("13800000001", 800, "success", name="张伟", wechat="SUCCESS",
transfer=_mock_transfer_no(), created=ago(days=5)),
order("13800000001", 500, "reviewing", name="张伟", created=ago(minutes=3)),
# U2 薅羊毛达人:1 已到账 + 1 待审核 → 累计提现 ¥20.00
order("13900000002", 2000, "success", name="李娜", wechat="SUCCESS",
transfer=_mock_transfer_no(), created=ago(days=2)),
order("13900000002", 1250, "reviewing", name="李娜", created=ago(minutes=25)),
# U3 比价狂魔:1 待审核 → 累计提现 ¥0
order("13700000003", 100, "reviewing", name="王芳", created=ago(hours=1)),
# U4(空昵称):1 打款中 → 累计提现 ¥0
order("13600000004", 300, "pending", name="刘洋", wechat="ACCEPTED",
transfer=_mock_transfer_no(), created=ago(minutes=40), updated=ago(minutes=20)),
# U5 退款体验官:1 已拒绝 + 1 打款失败 → 累计提现 ¥0
order("13500000005", 5000, "rejected", name="陈静", fail="微信实名不匹配",
created=ago(days=1)),
order("13500000005", 3000, "failed", name="陈静", wechat="FAIL",
transfer=_mock_transfer_no(), fail="收款账户异常,已退回余额",
created=ago(hours=6), updated=ago(hours=5)),
# U6 已到账老用户:2 已到账 → 累计提现 ¥40.00
order("13312345678", 1500, "success", name="赵强", wechat="SUCCESS",
transfer=_mock_transfer_no(), created=ago(days=10)),
order("13312345678", 2500, "success", name="赵强", wechat="SUCCESS",
transfer=_mock_transfer_no(), created=ago(days=8)),
]
db.add_all(orders)
db.flush()
# 3) 配套双分录现金流水 + 账户余额(让账本校验绿、详情抽屉真实):
# 每单创建时扣款(withdraw, -amount, ref=out_bill_no);失败/拒绝再退款(withdraw_refund, +amount);
# 每用户起始补一笔 exchange_in 把余额垫正;CoinAccount.cash 余额 = 该用户流水净额。
by_user: dict[int, list[WithdrawOrder]] = defaultdict(list)
for o in orders:
by_user[o.user_id].append(o)
for uid, uorders in by_user.items():
initial = sum(o.amount_cents for o in uorders) + 2000 # 垫 ¥20 余额,确保全程非负
earliest = min(o.created_at for o in uorders)
events: list[tuple[datetime, str, int, str | None]] = [
(earliest - timedelta(days=1), "exchange_in", initial, None)
]
for o in uorders:
events.append((o.created_at, "withdraw", -o.amount_cents, o.out_bill_no))
if o.status in ("failed", "rejected"):
events.append((o.updated_at, "withdraw_refund", o.amount_cents, o.out_bill_no))
events.sort(key=lambda e: e[0])
bal = 0
for t, biz, amt, ref in events:
bal += amt
db.add(CashTransaction(
user_id=uid, amount_cents=amt, balance_after_cents=bal,
biz_type=biz, ref_id=ref, remark=REMARK[biz], created_at=t,
))
db.add(CoinAccount(user_id=uid, coin_balance=0, cash_balance_cents=bal, total_coin_earned=0))
# 4) 给部分用户造看广告/签到记录(喂提现详情抽屉的「统计区」+「金币记录」表)。
# eCPM 原值单位=分/千次;金币用与线上同源的 rewards.calculate_ad_reward_coin 复算,真实偏小。
def _bj_date(t: datetime) -> str:
return (t + timedelta(hours=8)).date().isoformat()
coin_bal: dict[int, int] = defaultdict(int)
def add_video(uid: int, ecpm_fen: int, created: datetime, nth: int, scene: str = "reward_video") -> None:
db.add(AdRewardRecord(
trans_id=uuid.uuid4().hex, user_id=uid,
coin=rewards.calculate_ad_reward_coin(str(ecpm_fen), nth),
status="granted", reward_scene=scene, ecpm_raw=str(ecpm_fen),
reward_date=_bj_date(created), created_at=created,
))
def add_feed(uid: int, ecpm_fen: int, units: int, created: datetime, scene: str | None = None) -> None:
coin = sum(rewards.calculate_ad_reward_coin(str(ecpm_fen), i) for i in range(1, units + 1))
db.add(AdFeedRewardRecord(
client_event_id=uuid.uuid4().hex, user_id=uid, reward_date=_bj_date(created),
duration_seconds=units * 10, unit_count=units, ecpm_raw=str(ecpm_fen),
feed_scene=scene, coin=coin, status="granted", created_at=created,
))
def add_signin(uid: int, coin: int, created: datetime) -> None:
coin_bal[uid] += coin
db.add(CoinTransaction(
user_id=uid, amount=coin, balance_after=coin_bal[uid],
biz_type="signin", ref_id=_bj_date(created), remark="每日签到", created_at=created,
))
u1 = users["13800000001"].id # 丰富:激励视频 + 信息流 + 签到 + 1 条签到膨胀
for i, e in enumerate([3000, 5000, 8000, 4000, 6000, 3500, 7000, 4500, 5500, 6500]):
add_video(u1, e, ago(days=1, hours=i), nth=(i % 5) + 1)
add_video(u1, 9000, ago(hours=2), nth=1, scene="signin_boost")
# 信息流打 scene:比价×2 / 领券×1 / 未分类×1(演示金币记录「途径」分类)
feed_scenes = ["comparison", "coupon", "comparison", None]
for i, (e, units) in enumerate([(2000, 6), (3000, 4), (5000, 3), (2500, 5)]):
add_feed(u1, e, units, ago(days=2, hours=i), scene=feed_scenes[i])
for i in range(5):
add_signin(u1, 2000, ago(days=i + 1))
u2 = users["13900000002"].id # 较轻:几条激励视频 + 签到
for i, e in enumerate([3000, 4000, 5000]):
add_video(u2, e, ago(days=1, hours=i), nth=i + 1)
for i in range(3):
add_signin(u2, 2000, ago(days=i + 1))
db.commit()
return orders
def _yuan(cents: int) -> str:
return f"¥{cents / 100:.2f}"
def main() -> None:
parser = argparse.ArgumentParser(description="造提现 mock 数据(运营后台提现管理页联调用)")
parser.add_argument("--clean-only", action="store_true", help="只清理 mock 数据,不重建")
args = parser.parse_args()
db = SessionLocal()
try:
removed = clean(db)
if removed:
print(f"🧹 已清理旧 mock:{removed} 个用户及其提现单/流水")
if args.clean_only:
print("✅ 仅清理,已完成。")
return
orders = seed(db)
# 汇总打印(对照页面应显示的内容)
cum: dict[int, int] = defaultdict(int)
for o in orders:
if o.status == "success":
cum[o.user_id] += o.amount_cents
uid2phone = dict(db.execute(select(User.id, User.phone).where(User.phone.in_(MOCK_PHONES))).all())
print(f"\n✅ 已生成 {len(orders)} 条提现单,分布:")
by_status: dict[str, list[WithdrawOrder]] = defaultdict(list)
for o in orders:
by_status[o.status].append(o)
label = {"reviewing": "待审核", "pending": "打款中", "success": "已到账",
"rejected": "已拒绝", "failed": "打款失败"}
for st in ("reviewing", "pending", "success", "rejected", "failed"):
lst = by_status.get(st, [])
total = sum(o.amount_cents for o in lst)
print(f" {label[st]:<5} {len(lst)} 单 合计 {_yuan(total)}")
print("\n 明细(手机号 | 提现金额 | 累计提现 | 状态 | 失败原因):")
for o in sorted(orders, key=lambda x: (x.user_id, x.created_at)):
phone = uid2phone.get(o.user_id, "?")
print(
f" {phone} {_yuan(o.amount_cents):>8} 累计 {_yuan(cum.get(o.user_id, 0)):>8} "
f"{label[o.status]:<5} {o.fail_reason or ''}"
)
print("\n👉 打开 http://localhost:3001 → 提现管理 查看。后端若没带 --reload 需重启才有新列。")
finally:
db.close()
if __name__ == "__main__":
main()
+5 -3
View File
@@ -49,7 +49,7 @@ def _seed_user_with_data(phone: str) -> int:
db.add(WithdrawOrder(
user_id=uid, out_bill_no=f"test{uid}billno0001", amount_cents=100, status="success"
))
db.add(Feedback(user_id=uid, content="测试反馈内容", contact="wx_test", status="new"))
db.add(Feedback(user_id=uid, content="测试反馈内容", contact="wx_test", status="pending"))
db.commit()
return uid
finally:
@@ -181,9 +181,11 @@ def test_wallet_and_withdraw_lists(admin_client: TestClient, admin_token: str) -
def test_feedback_list(admin_client: TestClient, admin_token: str) -> None:
_seed_user_with_data("13800000005")
r = admin_client.get("/admin/api/feedbacks", params={"status": "new"}, headers=_auth(admin_token))
r = admin_client.get(
"/admin/api/feedbacks", params={"status": "pending"}, headers=_auth(admin_token)
)
assert r.status_code == 200
assert all(f["status"] == "new" for f in r.json()["items"])
assert all(f["status"] == "pending" for f in r.json()["items"])
def test_ad_coin_audit_full_count_truncate_and_only_mismatch(
+81 -6
View File
@@ -74,7 +74,7 @@ def _seed_feedback(phone: str) -> int:
uid = _seed_user(phone)
db = SessionLocal()
try:
fb = Feedback(user_id=uid, content="测试", contact="wx", status="new")
fb = Feedback(user_id=uid, content="测试", contact="wx", status="pending")
db.add(fb)
db.commit()
return fb.id
@@ -280,15 +280,90 @@ def test_super_admin_can_grant_coins(admin_client: TestClient, super_token: str)
assert r.status_code == 200
# ===== 反馈处理 =====
# ===== 反馈审核 =====
def test_handle_feedback(admin_client: TestClient, operator_token: str) -> None:
def test_approve_feedback_grants_coins_and_audit(
admin_client: TestClient, operator_token: str
) -> None:
fid = _seed_feedback("13900000006")
r = admin_client.post(f"/admin/api/feedbacks/{fid}/handle", headers=_auth(operator_token))
assert r.status_code == 200
r = admin_client.post(
f"/admin/api/feedbacks/{fid}/approve",
json={"reward_coins": 800, "note": "真实详细,已采纳"},
headers=_auth(operator_token),
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "adopted"
assert r.json()["reward_coins"] == 800
db = SessionLocal()
try:
assert db.get(Feedback, fid).status == "handled"
fb = db.get(Feedback, fid)
assert fb is not None
assert fb.status == "adopted"
assert fb.reward_coins == 800
assert fb.review_note == "真实详细,已采纳"
assert fb.reviewed_by_admin_id is not None
assert fb.reviewed_at is not None
assert db.get(CoinAccount, fb.user_id).coin_balance == 800
txns = db.execute(
select(CoinTransaction).where(
CoinTransaction.user_id == fb.user_id,
CoinTransaction.biz_type == "feedback_reward",
CoinTransaction.ref_id == str(fid),
)
).scalars().all()
assert len(txns) == 1 and txns[0].amount == 800
logs = db.execute(
select(AdminAuditLog).where(
AdminAuditLog.action == "feedback.approve",
AdminAuditLog.target_id == str(fid),
)
).scalars().all()
assert len(logs) == 1 and logs[0].detail["reward_coins"] == 800
finally:
db.close()
r = admin_client.post(
f"/admin/api/feedbacks/{fid}/approve",
json={"reward_coins": 100},
headers=_auth(operator_token),
)
assert r.status_code == 400
def test_reject_feedback_records_reason_and_no_coin(
admin_client: TestClient, operator_token: str
) -> None:
fid = _seed_feedback("13900000013")
r = admin_client.post(
f"/admin/api/feedbacks/{fid}/reject",
json={"reason": "暂未提供可复现信息", "note": "信息不足"},
headers=_auth(operator_token),
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "rejected"
assert r.json()["reject_reason"] == "暂未提供可复现信息"
db = SessionLocal()
try:
fb = db.get(Feedback, fid)
assert fb is not None
assert fb.status == "rejected"
assert fb.reject_reason == "暂未提供可复现信息"
assert fb.review_note == "信息不足"
txns = db.execute(
select(CoinTransaction).where(
CoinTransaction.user_id == fb.user_id,
CoinTransaction.biz_type == "feedback_reward",
CoinTransaction.ref_id == str(fid),
)
).scalars().all()
assert txns == []
logs = db.execute(
select(AdminAuditLog).where(
AdminAuditLog.action == "feedback.reject",
AdminAuditLog.target_id == str(fid),
)
).scalars().all()
assert len(logs) == 1 and logs[0].detail["reason"] == "暂未提供可复现信息"
finally:
db.close()
+141
View File
@@ -0,0 +1,141 @@
"""注销账号(DELETE /api/v1/user):软删 + 释放唯一约束 + 资金前置校验。
覆盖:
- 干净注销:status=deleted + phone 匿名化 + wechat_openid/invite_code 等唯一槽全部释放
- 回归原始 bug:注销释放 openid ,别的账号能绑同一个微信
- 资金前置闸:有可提现现金 / 在审提现单 409 拒绝注销,账号不被删
- 注销后旧 token 即时失效(status==active )
"""
from __future__ import annotations
from sqlalchemy import select
from app.db.session import SessionLocal
from app.models.user import User
from app.models.wallet import CoinAccount
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 _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _patch_userinfo(monkeypatch, openid: str) -> None:
monkeypatch.setattr(
"app.integrations.wxpay.code_to_userinfo",
lambda code: {"openid": openid, "nickname": "微信昵称", "avatar_url": "https://x/a.png", "raw": {}},
)
def _seed_cash(client, token: str, phone: str, cents: int) -> None:
"""先访问 /account 触发建账户,再用 DB 直接灌现金余额。"""
client.get("/api/v1/wallet/account", headers=_auth(token))
db = SessionLocal()
try:
user = db.execute(select(User).where(User.phone == phone)).scalar_one()
acc = db.get(CoinAccount, user.id)
acc.cash_balance_cents = cents
db.commit()
finally:
db.close()
def _get_user(phone: str) -> User | None:
db = SessionLocal()
try:
return db.execute(select(User).where(User.phone == phone)).scalar_one_or_none()
finally:
db.close()
def test_delete_clean_account_releases_all_unique_slots(client) -> None:
phone = "13900000001"
token = _login(client, phone)
# 预置:给该用户造昵称 + 邀请码(占 invite_code 唯一槽),验证注销时一并释放
db = SessionLocal()
try:
u = db.execute(select(User).where(User.phone == phone)).scalar_one()
u.nickname = "张三"
u.invite_code = "INVCODE0001"
db.commit()
uid = u.id
finally:
db.close()
r = client.delete("/api/v1/user", headers=_auth(token))
assert r.status_code == 200, r.text
u = _get_user(f"deleted_{uid}")
assert u is not None
assert u.status == "deleted"
assert u.phone == f"deleted_{uid}"
assert u.nickname is None
assert u.avatar_url is None
assert u.wechat_openid is None
assert u.wechat_nickname is None
assert u.wechat_avatar_url is None
assert u.invite_code is None
def test_delete_releases_wechat_so_another_account_can_bind(client, monkeypatch) -> None:
"""原始 bug 回归:A 绑微信 → 注销,B 必须能绑同一个 openid(修复前会 409)。"""
openid = "openid_delreuse_a1" # 独特值,避免跨测试共享库污染(如 test_withdraw 也用 openid_shared_xyz)
# A 绑微信
_patch_userinfo(monkeypatch, openid)
token_a = _login(client, "13900000002")
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "ca"}, headers=_auth(token_a))
assert r.status_code == 200, r.text
# A 注销(无现金、无提现单 → 允许)
r = client.delete("/api/v1/user", headers=_auth(token_a))
assert r.status_code == 200, r.text
# B 绑同一个 openid → 必须成功(修复前会 409 "该微信已绑定其他账号")
token_b = _login(client, "13900000003")
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "cb"}, headers=_auth(token_b))
assert r.status_code == 200, r.text
assert r.json()["bound"] is True
def test_delete_blocked_when_cash_balance_positive(client) -> None:
phone = "13900000004"
token = _login(client, phone)
_seed_cash(client, token, phone, 100) # 1 元未提现
r = client.delete("/api/v1/user", headers=_auth(token))
assert r.status_code == 409, r.text
assert "现金" in r.json()["detail"]
u = _get_user(phone) # 账号未被删
assert u is not None and u.status == "active"
def test_delete_blocked_when_reviewing_withdraw_exists(client, monkeypatch) -> None:
"""cash 提光(=0)但有在审提现单 → 仍拒绝(命中 reviewing 闸,非 cash 闸)。"""
phone = "13900000005"
_patch_userinfo(monkeypatch, "openid_delrev_b2")
token = _login(client, phone)
_seed_cash(client, token, phone, 50)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
# 提光 50 → cash 归 0,单进 reviewing
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
assert r.status_code == 200 and r.json()["status"] == "reviewing", r.text
assert r.json()["cash_balance_cents"] == 0
r = client.delete("/api/v1/user", headers=_auth(token))
assert r.status_code == 409, r.text
assert "审核" in r.json()["detail"]
u = _get_user(phone)
assert u is not None and u.status == "active"
def test_deleted_user_old_token_is_rejected(client) -> None:
phone = "13900000006"
token = _login(client, phone)
r = client.delete("/api/v1/user", headers=_auth(token))
assert r.status_code == 200, r.text
# 注销后旧 token 立即失效(get_current_user 校验 status==active)
r = client.get("/api/v1/user/onboarding/status", params={"device_id": "d"}, headers=_auth(token))
assert r.status_code == 401, r.text
+77
View File
@@ -0,0 +1,77 @@
"""用户反馈接口测试。"""
from __future__ import annotations
from app.db.session import SessionLocal
from app.models.feedback import Feedback
from app.repositories import user as user_repo
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_feedback_records_empty_returns_200(client) -> None:
token = _login(client, "13622000001")
r = client.get("/api/v1/feedback/records", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 200, r.text
assert r.json() == {
"records": [],
"counts": {"all": 0, "pending": 0, "adopted": 0, "rejected": 0},
}
def test_feedback_config_returns_default(client) -> None:
token = _login(client, "13622000002")
r = client.get("/api/v1/feedback/config", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 200, r.text
body = r.json()
assert body["enabled"] is True
assert body["group_name"] == "傻瓜比价官方群"
def test_feedback_records_maps_existing_statuses(client) -> None:
phone = "13622000003"
token = _login(client, phone)
db = SessionLocal()
try:
user = user_repo.get_user_by_phone(db, phone)
assert user is not None
db.add(Feedback(user_id=user.id, content="待处理反馈", contact="", status="pending"))
db.add(
Feedback(
user_id=user.id,
content="已采纳反馈",
contact="",
status="adopted",
reward_coins=800,
)
)
db.add(
Feedback(
user_id=user.id,
content="未采纳反馈",
contact="",
status="rejected",
reject_reason="暂未提供可复现信息",
)
)
db.commit()
finally:
db.close()
r = client.get("/api/v1/feedback/records", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 200, r.text
body = r.json()
assert body["counts"] == {"all": 3, "pending": 1, "adopted": 1, "rejected": 1}
by_status = {item["status"]: item for item in body["records"]}
assert by_status["adopted"]["reward_coins"] == 800
assert by_status["rejected"]["reject_reason"] == "暂未提供可复现信息"
+165
View File
@@ -0,0 +1,165 @@
"""测试账号(app/core/test_account.py)行为测试。
覆盖四件事:
- 免验证码登录(mock real 模式都跳过校验);
- 每次登录都重走新手引导(onboarding_completed false,即便完成表已有记录);
- 每日登录上限(超过即 429),且只算 login不算 send;
- 功能关闭(TEST_ACCOUNT_PHONE 留空)时对任何号都不生效,回到原逻辑
"""
from __future__ import annotations
import pytest
from app.core import test_account
from app.integrations import sms
# 测试自注入的固定号:各用例用 monkeypatch 把它塞进 settings.TEST_ACCOUNT_PHONE,
# 再断言行为。它与 .env 配的值【相互独立】——测试不读 .env(保证确定性 / CI 无 .env 也能跑),
# 写成和 .env 同值只为直观;换任意 11 位号测试照样全过,二者无需保持一致。
TEST_PHONE = "11111111111"
@pytest.fixture()
def enabled(monkeypatch):
"""启用测试账号功能并清空当日计数;monkeypatch 与 _reset 保证用例间隔离。"""
monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_PHONE", TEST_PHONE)
test_account._reset_for_test()
yield
test_account._reset_for_test()
# ============================ 开关 / 识别 ============================
def test_disabled_when_phone_unset(monkeypatch) -> None:
"""TEST_ACCOUNT_PHONE 留空 = 整功能关闭,对测试号也不特殊处理。"""
monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_PHONE", "")
assert test_account.is_enabled() is False
assert test_account.is_test_account(TEST_PHONE) is False
def test_only_configured_phone_is_test_account(enabled) -> None:
assert test_account.is_test_account(TEST_PHONE) is True
assert test_account.is_test_account("13800138000") is False
def test_phone_is_whitespace_trimmed(monkeypatch) -> None:
""".env 里手机号前后带空格也能正确识别。"""
monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_PHONE", f" {TEST_PHONE} ")
assert test_account.is_test_account(TEST_PHONE) is True
# ============================ 免验证码 + 新手引导 ============================
def test_login_skips_code_and_returns_tokens(client, enabled) -> None:
"""测试号任意验证码即可登入(不用等真短信),拿到 JWT,onboarding 为 false。"""
r = client.post(
"/api/v1/auth/sms/login",
json={"phone": TEST_PHONE, "code": "000000", "device_id": "dev-a"},
)
assert r.status_code == 200, r.text
data = r.json()
assert data["user"]["phone"] == TEST_PHONE
assert data["user"]["register_channel"] == "sms"
assert data["onboarding_completed"] is False
assert data["force_onboarding"] is True # 测试号:应用内重登也强制重走引导
assert "access_token" in data and "refresh_token" in data
def test_onboarding_always_false_even_if_completed(client, enabled) -> None:
"""即便 onboarding_completion 表已有 (该用户, 该设备) 记录,测试号再登仍返回 false。"""
from app.db.session import SessionLocal
from app.repositories import onboarding as onboarding_repo
device = "dev-onb"
r = client.post(
"/api/v1/auth/sms/login",
json={"phone": TEST_PHONE, "code": "123456", "device_id": device},
)
uid = r.json()["user"]["id"]
db = SessionLocal()
try:
onboarding_repo.mark_completed(db, user_id=uid, device_id=device)
assert onboarding_repo.is_completed(db, user_id=uid, device_id=device) is True
finally:
db.close()
r2 = client.post(
"/api/v1/auth/sms/login",
json={"phone": TEST_PHONE, "code": "123456", "device_id": device},
)
assert r2.json()["onboarding_completed"] is False # 仍重走引导
# ============================ real 模式跳过 ============================
def test_login_skips_verify_in_real_mode(client, enabled, monkeypatch) -> None:
"""real 模式(SMS_MOCK=false)普通号要真校验;测试号必须跳过 → 任意码仍 200。"""
monkeypatch.setattr(sms.settings, "SMS_MOCK", False)
r = client.post(
"/api/v1/auth/sms/login",
json={"phone": TEST_PHONE, "code": "9999"},
)
assert r.status_code == 200, r.text
def test_send_short_circuits_in_real_mode(client, enabled, monkeypatch) -> None:
"""real 模式下测试号 /sms/send 不真打极光(否则缺号/缺 key 会 503),直接 200 放行进填码界面。"""
monkeypatch.setattr(sms.settings, "SMS_MOCK", False)
r = client.post("/api/v1/auth/sms/send", json={"phone": TEST_PHONE})
assert r.status_code == 200, r.text
body = r.json()
assert body["sent"] is True
assert body["cooldown_sec"] == 0
# ============================ 每日上限 ============================
def test_daily_limit_blocks_after_threshold(client, enabled, monkeypatch) -> None:
"""当日登录数到上限后再登 → 429。"""
monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_DAILY_LIMIT", 2)
test_account._reset_for_test()
for _ in range(2):
r = client.post("/api/v1/auth/sms/login", json={"phone": TEST_PHONE, "code": "123456"})
assert r.status_code == 200, r.text
r = client.post("/api/v1/auth/sms/login", json={"phone": TEST_PHONE, "code": "123456"})
assert r.status_code == 429
assert "上限" in r.json()["detail"]
def test_send_does_not_consume_quota(client, enabled, monkeypatch) -> None:
"""额度只算 login、不算 send:狂发 send 后那唯一一次 login 仍能成功。"""
monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_DAILY_LIMIT", 1)
test_account._reset_for_test()
for _ in range(3):
assert client.post("/api/v1/auth/sms/send", json={"phone": TEST_PHONE}).status_code == 200
assert client.post("/api/v1/auth/sms/login", json={"phone": TEST_PHONE, "code": "1234"}).status_code == 200
# 第二次 login 才超限
assert client.post("/api/v1/auth/sms/login", json={"phone": TEST_PHONE, "code": "1234"}).status_code == 429
# ============================ 计数单元逻辑 ============================
def test_quota_resets_across_day(enabled, monkeypatch) -> None:
"""跨天自动归零。"""
monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_DAILY_LIMIT", 1)
monkeypatch.setattr(test_account, "_today", lambda: "2026-06-23")
assert test_account.try_consume_quota() is True
assert test_account.try_consume_quota() is False # 当天超限
monkeypatch.setattr(test_account, "_today", lambda: "2026-06-24")
assert test_account.try_consume_quota() is True # 次日归零放行
def test_non_test_phone_unaffected_when_enabled(client, enabled) -> None:
"""功能开启时,普通号走原 mock 流程(任意 6 位通过),不受测试号逻辑影响。"""
phone = "13812341234"
client.post("/api/v1/auth/sms/send", json={"phone": phone})
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456", "device_id": "d1"})
assert r.status_code == 200, r.text
assert r.json()["user"]["phone"] == phone
assert r.json()["force_onboarding"] is False # 普通号不强制重走,按原逻辑