Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90368d3f61 | |||
| b59dc3ac19 | |||
| e252277431 | |||
| 0e42e96ddb | |||
| 07c0b7502c | |||
| 67a9a15775 |
@@ -0,0 +1,33 @@
|
||||
"""app_config table (运营可配置项:rewards 常量等后台化)
|
||||
|
||||
Revision ID: cfg1a2b3c4d5
|
||||
Revises: ad60a1b2c3d4
|
||||
Create Date: 2026-06-04 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'cfg1a2b3c4d5'
|
||||
down_revision: Union[str, Sequence[str], None] = 'ad60a1b2c3d4'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'app_config',
|
||||
sa.Column('key', sa.String(length=64), nullable=False),
|
||||
sa.Column('value', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=False),
|
||||
sa.Column('updated_by_admin_id', sa.Integer(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('key'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('app_config')
|
||||
@@ -0,0 +1,26 @@
|
||||
"""merge app_config and price_report heads
|
||||
|
||||
Revision ID: ebb6af5c0b56
|
||||
Revises: cfg1a2b3c4d5, b7e2c1a9f4d3
|
||||
Create Date: 2026-06-06 02:29:13.475265
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'ebb6af5c0b56'
|
||||
down_revision: Union[str, Sequence[str], None] = ('cfg1a2b3c4d5', 'b7e2c1a9f4d3')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,48 @@
|
||||
"""wechat_transfer_authorization 表(免确认收款授权)
|
||||
|
||||
商家转账「用户授权免确认收款模式」:用户授权一次后,后续提现免逐笔确认直接到账。
|
||||
一个用户一条(user_id 主键)。out_authorization_no 我方生成,authorization_id 微信 active 后返回。
|
||||
|
||||
Revision ID: wx_transfer_auth
|
||||
Revises: withdraw_review_ad_watch
|
||||
Create Date: 2026-06-06 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'wx_transfer_auth'
|
||||
down_revision: Union[str, Sequence[str], None] = 'withdraw_review_ad_watch'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'wechat_transfer_authorization',
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('openid', sa.String(length=64), nullable=False),
|
||||
sa.Column('out_authorization_no', sa.String(length=64), nullable=False),
|
||||
sa.Column('authorization_id', sa.String(length=64), nullable=True),
|
||||
sa.Column('state', sa.String(length=16), server_default='pending', nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('user_id'),
|
||||
)
|
||||
with op.batch_alter_table('wechat_transfer_authorization', schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_wechat_transfer_authorization_out_authorization_no'),
|
||||
['out_authorization_no'],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('wechat_transfer_authorization', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_wechat_transfer_authorization_out_authorization_no'))
|
||||
op.drop_table('wechat_transfer_authorization')
|
||||
@@ -0,0 +1,62 @@
|
||||
"""withdraw_order.user_name 列 + ad_watch_log 表
|
||||
|
||||
提现审核功能:WithdrawOrder 加 user_name(审核异步打款时传给微信的实名,达额转账要求)。
|
||||
看广告时长防刷:新增 ad_watch_log 表(前端 onAdClose 上报观看秒数,按 (user_id, watch_date)
|
||||
聚合做"每天 50 分钟"硬上限)。
|
||||
|
||||
Revision ID: withdraw_review_ad_watch
|
||||
Revises: ebb6af5c0b56
|
||||
Create Date: 2026-06-05 12:00:00.000000
|
||||
|
||||
注:本迁移原 down_revision=ad60a1b2c3d4(分支创建时的 head);rebase 合 main 后 main 侧已新增
|
||||
app_config / price_report 等迁移并 merge 出 head=ebb6af5c0b56,故改挂到其上,保持单 head(本迁移
|
||||
与那些表互不相干,叠加顺序无影响)。
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'withdraw_review_ad_watch'
|
||||
down_revision: Union[str, Sequence[str], None] = 'ebb6af5c0b56'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ① 提现单加实名列:审核通过后异步打款时传给微信(达额转账要求),发起提现时存下
|
||||
with op.batch_alter_table('withdraw_order', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('user_name', sa.String(length=64), nullable=True))
|
||||
|
||||
# ② 看广告观看时长表:前端上报观看秒数,(user_id, watch_date) 聚合做每日 50 分钟防刷主闸
|
||||
op.create_table(
|
||||
'ad_watch_log',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('watch_seconds', sa.Integer(), server_default='0', nullable=False),
|
||||
sa.Column('watch_date', sa.String(length=10), nullable=False),
|
||||
sa.Column(
|
||||
'created_at', sa.DateTime(timezone=True),
|
||||
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
with op.batch_alter_table('ad_watch_log', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_ad_watch_log_user_id'), ['user_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_ad_watch_log_watch_date'), ['watch_date'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_ad_watch_log_created_at'), ['created_at'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('ad_watch_log', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_ad_watch_log_created_at'))
|
||||
batch_op.drop_index(batch_op.f('ix_ad_watch_log_watch_date'))
|
||||
batch_op.drop_index(batch_op.f('ix_ad_watch_log_user_id'))
|
||||
op.drop_table('ad_watch_log')
|
||||
|
||||
with op.batch_alter_table('withdraw_order', schema=None) as batch_op:
|
||||
batch_op.drop_column('user_name')
|
||||
@@ -16,6 +16,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.admin.routers.admins import router as admins_router
|
||||
from app.admin.routers.audit import router as audit_router
|
||||
from app.admin.routers.auth import router as auth_router
|
||||
from app.admin.routers.config import router as config_router
|
||||
from app.admin.routers.dashboard import router as dashboard_router
|
||||
from app.admin.routers.feedback import router as feedback_router
|
||||
from app.admin.routers.users import router as users_router
|
||||
@@ -78,3 +79,4 @@ admin_app.include_router(withdraw_router)
|
||||
admin_app.include_router(feedback_router)
|
||||
admin_app.include_router(admins_router)
|
||||
admin_app.include_router(audit_router)
|
||||
admin_app.include_router(config_router)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""admin 运营配置:列出所有可配项 + 改某项(带审计 + 改值校验)。
|
||||
|
||||
配置项定义见 app.core.config_schema.CONFIG_DEFS;业务读配置 fallback 默认(见 app_config repo)。
|
||||
权限:operator / finance 都可改(运营调奖励、财务调额度),super 恒可。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, 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.config import ConfigItemOut, ConfigUpdateRequest
|
||||
from app.core.config_schema import CONFIG_DEFS
|
||||
from app.models.admin import AdminUser
|
||||
from app.repositories import app_config
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/config",
|
||||
tags=["admin-config"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
def _validate(key: str, value: Any) -> None:
|
||||
"""按配置项 type 校验新值,不合法抛 ValueError(router 转 400)。"""
|
||||
t = CONFIG_DEFS[key]["type"]
|
||||
if t == "int":
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||
raise ValueError("需为非负整数")
|
||||
elif t == "int_list":
|
||||
if not isinstance(value, list) or not value:
|
||||
raise ValueError("需为非空整数列表")
|
||||
if not all(isinstance(x, int) and not isinstance(x, bool) and x >= 0 for x in value):
|
||||
raise ValueError("列表元素需为非负整数")
|
||||
if key == "signin_rewards" and len(value) != 7:
|
||||
raise ValueError("签到档位必须正好 7 个(对应 7 天循环)")
|
||||
elif t == "dict_str_int":
|
||||
if not isinstance(value, dict) or not all(
|
||||
isinstance(k, str) and isinstance(v, int) and not isinstance(v, bool)
|
||||
for k, v in value.items()
|
||||
):
|
||||
raise ValueError("需为 {字符串: 整数} 映射")
|
||||
|
||||
|
||||
def _item(db, key: str) -> ConfigItemOut:
|
||||
for item in app_config.list_all(db):
|
||||
if item["key"] == key:
|
||||
return ConfigItemOut(**item)
|
||||
raise HTTPException(status_code=404, detail="未知配置项")
|
||||
|
||||
|
||||
@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值")
|
||||
def list_config(db: AdminDb) -> list[ConfigItemOut]:
|
||||
return [ConfigItemOut(**item) for item in app_config.list_all(db)]
|
||||
|
||||
|
||||
@router.patch("/{key}", response_model=ConfigItemOut, summary="改某项配置(带审计)")
|
||||
def update_config(
|
||||
key: str,
|
||||
body: ConfigUpdateRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator", "finance"))],
|
||||
db: AdminDb,
|
||||
) -> ConfigItemOut:
|
||||
if key not in CONFIG_DEFS:
|
||||
raise HTTPException(status_code=404, detail="未知配置项")
|
||||
try:
|
||||
_validate(key, body.value)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
before = app_config.get_value(db, key)
|
||||
app_config.set_value(db, key, body.value, admin_id=admin.id, commit=False)
|
||||
write_audit(
|
||||
db, admin, action="config.set", target_type="config", target_id=key,
|
||||
detail={"before": before, "after": body.value}, ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return _item(db, key)
|
||||
@@ -14,7 +14,7 @@ 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 queries
|
||||
from app.admin.schemas.common import CursorPage
|
||||
from app.admin.schemas.wallet import ReconcileResult, WithdrawOrderOut
|
||||
from app.admin.schemas.wallet import ReconcileResult, WithdrawOrderOut, WithdrawRejectRequest
|
||||
from app.integrations import wxpay
|
||||
from app.models.admin import AdminUser
|
||||
from app.repositories import wallet as wallet_repo
|
||||
@@ -83,3 +83,58 @@ def refresh_withdraw(
|
||||
ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return WithdrawOrderOut.model_validate(refreshed)
|
||||
|
||||
|
||||
@router.post("/{out_bill_no}/approve", response_model=WithdrawOrderOut, summary="审核通过(发起微信打款)")
|
||||
def approve_withdraw_order(
|
||||
out_bill_no: str,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("finance"))],
|
||||
db: AdminDb,
|
||||
) -> WithdrawOrderOut:
|
||||
"""审核通过 → 调微信转账。仅 reviewing 单可通过(非 reviewing 返 409,防重复打款)。
|
||||
|
||||
打款的钱逻辑(转账/查单/退款/幂等)复用 wallet_repo.approve_withdraw → execute_withdraw_transfer;
|
||||
返回的 order 可能是 pending(在途/待用户确认)/success/failed(打款失败已退),admin 前端按 status 展示。
|
||||
"""
|
||||
try:
|
||||
order = wallet_repo.approve_withdraw(db, out_bill_no)
|
||||
except wallet_repo.WithdrawOrderNotFound as e:
|
||||
raise HTTPException(status_code=404, detail="提现单不存在") from e
|
||||
except wallet_repo.WithdrawNotReviewable as e:
|
||||
raise HTTPException(status_code=409, detail=str(e)) from e
|
||||
except wxpay.WxPayNotConfiguredError as e:
|
||||
raise HTTPException(status_code=503, detail="微信支付未配置") from e
|
||||
write_audit(
|
||||
db, admin, action="withdraw.approve", target_type="withdraw", target_id=out_bill_no,
|
||||
detail={
|
||||
"status": order.status,
|
||||
"wechat_state": order.wechat_state,
|
||||
"amount_cents": order.amount_cents,
|
||||
},
|
||||
ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return WithdrawOrderOut.model_validate(order)
|
||||
|
||||
|
||||
@router.post("/{out_bill_no}/reject", response_model=WithdrawOrderOut, summary="审核拒绝(退回现金)")
|
||||
def reject_withdraw_order(
|
||||
out_bill_no: str,
|
||||
body: WithdrawRejectRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("finance"))],
|
||||
db: AdminDb,
|
||||
) -> WithdrawOrderOut:
|
||||
"""审核拒绝 → 退回用户现金 + 置 rejected,理由写入 fail_reason(用户可见)。仅 reviewing 单可拒。"""
|
||||
try:
|
||||
order = wallet_repo.reject_withdraw(db, out_bill_no, body.reason)
|
||||
except wallet_repo.WithdrawOrderNotFound as e:
|
||||
raise HTTPException(status_code=404, detail="提现单不存在") from e
|
||||
except wallet_repo.WithdrawNotReviewable as e:
|
||||
raise HTTPException(status_code=409, detail=str(e)) from e
|
||||
write_audit(
|
||||
db, admin, action="withdraw.reject", target_type="withdraw", target_id=out_bill_no,
|
||||
detail={"reason": body.reason, "amount_cents": order.amount_cents},
|
||||
ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return WithdrawOrderOut.model_validate(order)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""admin 运营配置 schemas。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ConfigItemOut(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
group: str
|
||||
type: str # int / int_list / dict_str_int
|
||||
help: str | None = None
|
||||
default: Any
|
||||
value: Any
|
||||
overridden: bool # 是否被运营覆盖过(false=用默认)
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class ConfigUpdateRequest(BaseModel):
|
||||
value: Any
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class CoinTxnOut(BaseModel):
|
||||
@@ -39,6 +39,7 @@ class WithdrawOrderOut(BaseModel):
|
||||
user_id: int
|
||||
out_bill_no: str
|
||||
amount_cents: int
|
||||
user_name: str | None = None # 提现实名(审核核对 + 打款用)
|
||||
status: str
|
||||
wechat_state: str | None = None
|
||||
transfer_bill_no: str | None = None
|
||||
@@ -50,3 +51,9 @@ class WithdrawOrderOut(BaseModel):
|
||||
class ReconcileResult(BaseModel):
|
||||
checked: int
|
||||
resolved: int
|
||||
|
||||
|
||||
class WithdrawRejectRequest(BaseModel):
|
||||
reason: str = Field(
|
||||
..., min_length=1, max_length=200, description="拒绝理由(写入 fail_reason,用户可见)"
|
||||
)
|
||||
|
||||
+37
-3
@@ -21,12 +21,15 @@ from app.integrations import pangle
|
||||
from app.core.ratelimit import rate_limit
|
||||
from app.repositories import ad_ecpm as crud_ecpm
|
||||
from app.repositories import ad_reward as crud_ad
|
||||
from app.repositories import ad_watch as crud_watch
|
||||
from app.schemas.ad import (
|
||||
AdRewardStatusOut,
|
||||
EcpmReportIn,
|
||||
EcpmReportOut,
|
||||
PangleCallbackOut,
|
||||
TestGrantOut,
|
||||
WatchReportIn,
|
||||
WatchReportOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.ad")
|
||||
@@ -71,7 +74,7 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
|
||||
return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS)
|
||||
|
||||
user_id = int(raw_user_id)
|
||||
coin = rewards.resolve_ad_reward_coin(params.get("reward_amount"))
|
||||
coin = rewards.resolve_ad_reward_coin(db, params.get("reward_amount"))
|
||||
raw = "&".join(f"{k}={v}" for k, v in sorted(params.items()) if k != "sign")
|
||||
try:
|
||||
rec = crud_ad.grant_ad_reward(
|
||||
@@ -91,7 +94,8 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
|
||||
|
||||
@router.get("/reward-status", response_model=AdRewardStatusOut, summary="今日看广告发奖进度")
|
||||
def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut:
|
||||
used, limit, coin_per, round_count, cooldown_until = crud_ad.today_status(db, user.id)
|
||||
(used, limit, coin_per, round_count, cooldown_until,
|
||||
watched_sec, watch_limit) = crud_ad.today_status(db, user.id)
|
||||
return AdRewardStatusOut(
|
||||
used_today=used,
|
||||
daily_limit=limit,
|
||||
@@ -99,6 +103,35 @@ def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut:
|
||||
coin_per_ad=coin_per,
|
||||
round_count=round_count,
|
||||
cooldown_until=cooldown_until,
|
||||
watched_seconds_today=watched_sec,
|
||||
watch_seconds_limit=watch_limit,
|
||||
watch_seconds_remaining=max(0, watch_limit - watched_sec),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/watch-report",
|
||||
response_model=WatchReportOut,
|
||||
summary="上报激励视频观看时长(每日 50 分钟防刷计时)",
|
||||
dependencies=[Depends(rate_limit(120, 60, "ad-watch-report"))],
|
||||
)
|
||||
def watch_report(payload: WatchReportIn, user: CurrentUser, db: DbSession) -> WatchReportOut:
|
||||
"""客户端在激励视频关闭(onAdClose)后上报本次实际观看秒数,服务端累计到当日总时长。
|
||||
|
||||
Bearer 鉴权,user_id 取自 JWT(不信 body)。seconds 服务端夹 [0, MAX_SINGLE_WATCH_SECONDS]。
|
||||
当日累计达 DAILY_AD_WATCH_SECONDS_LIMIT(50 分钟)后:① 本接口 / reward-status 返回 remaining=0,
|
||||
客户端不再展示广告;② 后端发奖(S2S 回调)也会因时长闸记 capped 不发金币(双闸,前端绕不过)。
|
||||
"""
|
||||
total = crud_watch.add_watch_seconds(db, user.id, payload.seconds)
|
||||
limit = rewards.DAILY_AD_WATCH_SECONDS_LIMIT
|
||||
logger.info(
|
||||
"ad watch report user_id=%d +%ds total=%d/%d",
|
||||
user.id, payload.seconds, total, limit,
|
||||
)
|
||||
return WatchReportOut(
|
||||
watched_seconds_today=total,
|
||||
watch_seconds_limit=limit,
|
||||
watch_seconds_remaining=max(0, limit - total),
|
||||
)
|
||||
|
||||
|
||||
@@ -151,7 +184,8 @@ def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut:
|
||||
except crud_ad.UnknownUserError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
|
||||
|
||||
used, limit, coin_per, round_count, cooldown_until = crud_ad.today_status(db, user.id)
|
||||
(used, limit, coin_per, round_count, cooldown_until,
|
||||
_watched, _watch_limit) = crud_ad.today_status(db, user.id)
|
||||
logger.info("ad TEST grant user_id=%d status=%s coin=%d", user.id, rec.status, rec.coin)
|
||||
return TestGrantOut(
|
||||
granted=(rec.status == "granted"),
|
||||
|
||||
@@ -42,6 +42,7 @@ def battle(user: CurrentUser, db: DbSession) -> SavingsBattleOut:
|
||||
week_saved_cents=b.week_saved_cents,
|
||||
beat_percent=b.beat_percent,
|
||||
streak_days=b.streak_days,
|
||||
compare_count=b.compare_count,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+77
-6
@@ -33,6 +33,8 @@ from app.schemas.welfare import (
|
||||
ExchangeInfoOut,
|
||||
ExchangeRequest,
|
||||
ExchangeResultOut,
|
||||
TransferAuthResultOut,
|
||||
TransferAuthStatusOut,
|
||||
UnbindWechatResultOut,
|
||||
WithdrawInfoOut,
|
||||
WithdrawOrderOut,
|
||||
@@ -155,25 +157,29 @@ def unbind_wechat(user: CurrentUser, db: DbSession) -> UnbindWechatResultOut:
|
||||
return UnbindWechatResultOut(bound=False)
|
||||
|
||||
|
||||
@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态")
|
||||
@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关")
|
||||
def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut:
|
||||
u = db.get(User, user.id)
|
||||
# 顺带同步免确认授权状态(捕获首单确认后已生效的授权 pending→active),让开关展示实时
|
||||
auth = crud_wallet.sync_transfer_auth(db, user.id)
|
||||
return WithdrawInfoOut(
|
||||
min_cents=WITHDRAW_MIN_CENTS,
|
||||
max_cents=WITHDRAW_MAX_CENTS,
|
||||
wechat_bound=bool(u and u.wechat_openid),
|
||||
wechat_nickname=u.wechat_nickname if u else None,
|
||||
wechat_avatar_url=u.wechat_avatar_url if u else None,
|
||||
transfer_auth_enabled=bool(auth and auth.state == "active"),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/withdraw",
|
||||
response_model=WithdrawResultOut,
|
||||
summary="发起提现到微信零钱",
|
||||
summary="发起提现(扣款建单,待人工审核;审核通过后才打款)",
|
||||
dependencies=[Depends(rate_limit(20, 60, "withdraw"))], # #6 同 IP 每分钟≤20 次
|
||||
)
|
||||
def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> WithdrawResultOut:
|
||||
# 提现发起本身不调微信(打款在审核通过后),但仍要求微信支付已配置——否则审核通过也打不了款,提前拦
|
||||
if not settings.wxpay_configured:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="wechat pay not configured")
|
||||
try:
|
||||
@@ -189,12 +195,24 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="wechat not bound") from e
|
||||
except crud_wallet.InsufficientCashError as e:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="insufficient cash balance") from e
|
||||
except crud_wallet.WithdrawTransferError as e:
|
||||
# 转账调用失败,余额已退回
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"wechat transfer failed: {e}") from e
|
||||
|
||||
# 调试直发(非生产):skip_review=true 时跳过人工审核,立即发起微信转账(等价 admin approve)。
|
||||
# 双闸保护——客户端仅 debug 包下发此 flag,服务端仅非 prod 才认;任一道闸拦住即恢复正常审核,
|
||||
# 生产绝不会被客户端 flag 绕过审核。用于本地联调"提现→微信转账/免确认到账"全链路。
|
||||
if req.skip_review and not settings.is_prod and order.status == "reviewing":
|
||||
logger.warning(
|
||||
"withdraw skip_review(非prod调试直发,跳过人工审核立即打款) user_id=%d bill=%s",
|
||||
user.id, order.out_bill_no,
|
||||
)
|
||||
order = crud_wallet.execute_withdraw_transfer(db, order)
|
||||
|
||||
acc = crud_wallet.get_or_create_account(db, user.id)
|
||||
logger.info("withdraw user_id=%d cents=%d bill=%s state=%s", user.id, req.amount_cents, order.out_bill_no, order.wechat_state)
|
||||
logger.info(
|
||||
"withdraw submitted user_id=%d cents=%d bill=%s status=%s",
|
||||
user.id, req.amount_cents, order.out_bill_no, order.status,
|
||||
)
|
||||
# 此刻 status=reviewing,尚未打款 → 无 package_info;App 据 status 提示"已提交,等待审核"。
|
||||
# 审核通过后客户端轮询 /withdraw/status 拿到 package_info(若需确认)再拉起微信确认页。
|
||||
return WithdrawResultOut(
|
||||
out_bill_no=order.out_bill_no,
|
||||
status=order.status,
|
||||
@@ -223,6 +241,11 @@ def withdraw_status(
|
||||
status=order.status,
|
||||
wechat_state=order.wechat_state,
|
||||
amount_cents=order.amount_cents,
|
||||
fail_reason=order.fail_reason,
|
||||
# 审核通过后 status=pending 且可能带 package_info(WAIT_USER_CONFIRM):供 App 拉起微信确认页
|
||||
package_info=order.package_info,
|
||||
mch_id=settings.WXPAY_MCH_ID if order.package_info else None,
|
||||
app_id=settings.WECHAT_APP_ID if order.package_info else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -238,3 +261,51 @@ def withdraw_orders(
|
||||
items=[WithdrawOrderOut.model_validate(it) for it in items],
|
||||
next_cursor=next_cursor,
|
||||
)
|
||||
|
||||
|
||||
# ===== 免确认收款授权(用户授权免确认模式)=====
|
||||
# 开启一次后,后续提现走免确认转账直接到账,不再跳微信确认。绑定 openid 是前提。
|
||||
|
||||
|
||||
@router.post(
|
||||
"/transfer-auth",
|
||||
response_model=TransferAuthResultOut,
|
||||
summary="开启免确认到账(申请授权,返回拉起微信授权页的 package)",
|
||||
dependencies=[Depends(rate_limit(10, 60, "transfer-auth"))],
|
||||
)
|
||||
def open_transfer_auth(user: CurrentUser, db: DbSession) -> TransferAuthResultOut:
|
||||
if not settings.wxpay_auth_configured:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="wechat transfer auth not configured",
|
||||
)
|
||||
try:
|
||||
info = crud_wallet.apply_transfer_auth(db, user.id)
|
||||
except crud_wallet.WechatNotBoundError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="wechat not bound") from e
|
||||
except crud_wallet.WithdrawTransferError as e:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) from e
|
||||
logger.info("open transfer-auth user_id=%d already_active=%s", user.id, info["already_active"])
|
||||
return TransferAuthResultOut(**info)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/transfer-auth/status",
|
||||
response_model=TransferAuthStatusOut,
|
||||
summary="查免确认授权状态(从微信授权页返回后轮询)",
|
||||
)
|
||||
def transfer_auth_status(user: CurrentUser, db: DbSession) -> TransferAuthStatusOut:
|
||||
auth = crud_wallet.sync_transfer_auth(db, user.id)
|
||||
state = auth.state if auth else "none"
|
||||
return TransferAuthStatusOut(state=state, enabled=(state == "active"))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/transfer-auth/close",
|
||||
response_model=TransferAuthStatusOut,
|
||||
summary="关闭免确认到账(解除授权)",
|
||||
)
|
||||
def close_transfer_auth_endpoint(user: CurrentUser, db: DbSession) -> TransferAuthStatusOut:
|
||||
crud_wallet.close_transfer_auth(db, user.id)
|
||||
logger.info("close transfer-auth user_id=%d", user.id)
|
||||
return TransferAuthStatusOut(state="closed", enabled=False)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""微信支付相关回调(一期:免确认收款授权结果通知 stub)。
|
||||
|
||||
⚠️ 一期不处理回调内容、不验签:授权状态以主动查询(query_transfer_authorization)为准。
|
||||
本端点仅向微信回 200 避免重试风暴,**绝不依据回调内容改账**。二期接入时必须先补
|
||||
V3 平台证书/公钥验签(Wechatpay-Signature) + APIv3 密钥 AEAD 解密,验签通过后方可信任并处理。
|
||||
|
||||
授权回调地址通过 settings.WXPAY_AUTH_NOTIFY_URL 配置,需指向本端点的公网地址。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
logger = logging.getLogger("shagua.wxpay")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/wxpay", tags=["wxpay"])
|
||||
|
||||
|
||||
@router.post("/transfer-auth-notify", summary="免确认收款授权结果通知(一期 stub:仅应答,不处理)")
|
||||
async def transfer_auth_notify(request: Request) -> dict:
|
||||
# 一期:不验签、不解密、不改账。仅记录 + 应答成功;真实授权状态靠 /transfer-auth/status 查询兜底。
|
||||
try:
|
||||
body = await request.json()
|
||||
logger.info(
|
||||
"transfer-auth notify id=%s type=%s", body.get("id"), body.get("event_type")
|
||||
)
|
||||
except Exception: # noqa: BLE001 — body 解析失败也照常应答 200,避免微信重试
|
||||
logger.info("transfer-auth notify (unparseable body)")
|
||||
return {"code": "SUCCESS", "message": "OK"}
|
||||
@@ -99,6 +99,10 @@ class Settings(BaseSettings):
|
||||
WXPAY_PUBLIC_KEY_PATH: str = "./secrets/pub_key.pem" # 微信支付平台公钥
|
||||
WXPAY_TRANSFER_SCENE_ID: str = "1000" # 转账场景 ID(1000=现金营销)
|
||||
WXPAY_REQUEST_TIMEOUT_SEC: int = 10
|
||||
# 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。
|
||||
# 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容
|
||||
# (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
|
||||
WXPAY_AUTH_NOTIFY_URL: str = ""
|
||||
|
||||
@property
|
||||
def wxpay_configured(self) -> bool:
|
||||
@@ -110,6 +114,11 @@ class Settings(BaseSettings):
|
||||
and self.WXPAY_PUBLIC_KEY_ID
|
||||
)
|
||||
|
||||
@property
|
||||
def wxpay_auth_configured(self) -> bool:
|
||||
"""免确认收款授权可用 = 微信支付凭证齐全 + 授权回调地址已配。"""
|
||||
return bool(self.wxpay_configured and self.WXPAY_AUTH_NOTIFY_URL)
|
||||
|
||||
# ===== 穿山甲激励视频(服务端发奖回调)=====
|
||||
# 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。
|
||||
# PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",验签用,从后台取到后填 .env。
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""运营可配置项注册表:默认值(= 原 rewards 常量)+ 元信息(label/group/type)。
|
||||
|
||||
配置项的 single source of truth:
|
||||
- 业务读配置时 fallback 到这里的 default(空 DB = 原行为不变)。
|
||||
- admin 配置页用它展示"有哪些可配项 + 当前值 + 默认值 + 说明"。
|
||||
新增可配项 = 这里加一条 + 业务处改用 app_config.get_value(db, key) 读。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.core import rewards as r
|
||||
|
||||
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int
|
||||
CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
"signin_rewards": {
|
||||
"default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位",
|
||||
"group": "签到", "type": "int_list",
|
||||
"help": "第 1~7 天每天签到发的金币;断签重置回第 1 天。长度需为 7。",
|
||||
},
|
||||
"min_exchange_coin": {
|
||||
"default": r.MIN_EXCHANGE_COIN, "label": "最低兑换金币",
|
||||
"group": "钱包", "type": "int", "help": "金币兑现金的单次最低金币(10000 金币=1 元)。",
|
||||
},
|
||||
"withdraw_min_cents": {
|
||||
"default": r.WITHDRAW_MIN_CENTS, "label": "提现最低额(分)",
|
||||
"group": "钱包", "type": "int", "help": "单次提现最低;微信商家转账最低 10 分(0.1 元)。",
|
||||
},
|
||||
"withdraw_max_cents": {
|
||||
"default": r.WITHDRAW_MAX_CENTS, "label": "提现最高额(分)",
|
||||
"group": "钱包", "type": "int",
|
||||
},
|
||||
"task_rewards": {
|
||||
"default": dict(r.TASK_REWARDS), "label": "一次性任务奖励",
|
||||
"group": "任务", "type": "dict_str_int", "help": "task_key → 金币。",
|
||||
},
|
||||
"record_milestones": {
|
||||
"default": list(r.RECORD_MILESTONES), "label": "比价里程碑金币档位",
|
||||
"group": "里程碑", "type": "int_list",
|
||||
"help": "累计成功比价第 1~N 档解锁发的金币。",
|
||||
},
|
||||
"ad_reward_coin": {
|
||||
"default": r.AD_REWARD_COIN, "label": "看广告单次金币",
|
||||
"group": "看广告", "type": "int",
|
||||
"help": "看完一个激励视频发的金币;须与穿山甲后台代码位'奖励数量'保持一致。",
|
||||
},
|
||||
"ad_daily_limit": {
|
||||
"default": r.DAILY_AD_REWARD_LIMIT, "label": "看广告每日上限(次)",
|
||||
"group": "看广告", "type": "int",
|
||||
},
|
||||
"ad_max_coin": {
|
||||
"default": r.MAX_AD_REWARD_COIN, "label": "看广告单次金币上限",
|
||||
"group": "看广告", "type": "int", "help": "夹紧穿山甲回调里异常的 reward_amount,防刷爆。",
|
||||
},
|
||||
"ad_round_count": {
|
||||
"default": r.VIDEO_ROUND_REQUIRED_COUNT, "label": "每轮看广告次数",
|
||||
"group": "看广告", "type": "int", "help": "看完一轮触发冷却。",
|
||||
},
|
||||
"ad_cooldown_sec": {
|
||||
"default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "每轮冷却(秒)",
|
||||
"group": "看广告", "type": "int",
|
||||
},
|
||||
}
|
||||
+76
-5
@@ -1,20 +1,91 @@
|
||||
"""统一日志配置。
|
||||
|
||||
业务代码用 `logger = logging.getLogger("shagua.xxx")` 即可,本模块在 main.py 启动时调一次。
|
||||
业务代码用 `logger = logging.getLogger("shagua.xxx")` 即可, 本模块在 main.py 启动时调一次。
|
||||
|
||||
- 控制台(stdout): 人类可读文本, 给 systemd / 本地看。
|
||||
- 文件 `logs/app-server.log`: 单行 JSON, 供阿里云 SLS/Logtail 采集(JSON 模式零正则);
|
||||
异常栈作为字段内嵌不换行 → 每条日志一行。
|
||||
- 环境变量:
|
||||
- LOG_JSON_CONSOLE=1 控制台也输出 JSON
|
||||
- LOG_DIR / LOG_FILE 改落盘路径(默认 logs/app-server.log)
|
||||
⚠️ admin 子进程(app.admin.main, 独立进程)应设不同 LOG_FILE, 避免与主进程争抢同一轮转文件
|
||||
- LOG_SERVICE_NAME JSON 里的 service 字段(默认 app-server)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
"""把 LogRecord 序列化成单行 JSON(SLS/Logtail 友好)。异常栈内嵌为字段, 整条仍是一行。"""
|
||||
def __init__(self, service: str = "app-server"):
|
||||
super().__init__()
|
||||
self.service = service
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
dt = datetime.fromtimestamp(record.created)
|
||||
data = {
|
||||
"time": dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(record.msecs):03d}",
|
||||
"level": record.levelname,
|
||||
"service": self.service,
|
||||
"logger": record.name,
|
||||
"func": record.funcName,
|
||||
"line": record.lineno,
|
||||
"message": record.getMessage(),
|
||||
}
|
||||
if record.exc_info:
|
||||
data["exception"] = self.formatException(record.exc_info)
|
||||
if record.stack_info:
|
||||
data["stack"] = self.formatStack(record.stack_info)
|
||||
return json.dumps(data, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
_CONFIGURED = False
|
||||
|
||||
|
||||
def setup_logging(debug: bool = False) -> None:
|
||||
global _CONFIGURED
|
||||
if _CONFIGURED:
|
||||
return
|
||||
|
||||
level = logging.DEBUG if debug else logging.INFO
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
stream=sys.stdout,
|
||||
service = os.getenv("LOG_SERVICE_NAME", "app-server")
|
||||
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level)
|
||||
# 清掉 basicConfig/uvicorn 可能预置的 root handler, 避免重复输出
|
||||
for h in list(root.handlers):
|
||||
root.removeHandler(h)
|
||||
|
||||
# 控制台: 默认文本(systemd/本地看); LOG_JSON_CONSOLE=1 时输出 JSON
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
if os.getenv("LOG_JSON_CONSOLE") == "1":
|
||||
console.setFormatter(JsonFormatter(service))
|
||||
else:
|
||||
console.setFormatter(
|
||||
logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
)
|
||||
root.addHandler(console)
|
||||
|
||||
# 文件: 单行 JSON, 供 Logtail 采集(自动轮转, 单文件 10MB, 保留 5 个)
|
||||
log_file = os.getenv("LOG_FILE") or str(
|
||||
Path(os.getenv("LOG_DIR", "logs")) / "app-server.log"
|
||||
)
|
||||
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = RotatingFileHandler(
|
||||
log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8",
|
||||
)
|
||||
file_handler.setFormatter(JsonFormatter(service))
|
||||
root.addHandler(file_handler)
|
||||
|
||||
# 第三方库降噪
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
|
||||
_CONFIGURED = True
|
||||
|
||||
+77
-11
@@ -34,8 +34,9 @@ COIN_PER_YUAN: int = 10000
|
||||
CENTS_PER_YUAN: int = 100
|
||||
# 1 分对应多少金币 = 100。兑换金币数必须是它的整数倍,否则会出现不足 1 分的零头。
|
||||
COIN_PER_CENT: int = COIN_PER_YUAN // CENTS_PER_YUAN
|
||||
# 单次兑换最少 1 元(避免大量 1 分级碎兑)
|
||||
MIN_EXCHANGE_COIN: int = COIN_PER_YUAN
|
||||
# 单次兑换最少 1 分(=COIN_PER_CENT;产品定:可兑 1 分起,与 step 同粒度)。早先为 1 元(=COIN_PER_YUAN),
|
||||
# 用户 2026-06 改为 1 分:让"换现金"小额即可用(也方便联调验证资产卡飞金币动画)。
|
||||
MIN_EXCHANGE_COIN: int = COIN_PER_CENT
|
||||
|
||||
|
||||
def coins_to_cents(coin_amount: int) -> int:
|
||||
@@ -54,7 +55,7 @@ TASK_ENABLE_NOTIFICATION = "enable_notification"
|
||||
|
||||
# task_key -> 奖励金币
|
||||
# 打开消息提醒: 1000 金币(=¥0.1, 客户端原型展示口径; 量级与签到/里程碑相称)。
|
||||
# 注意: 已不再 = 兑换下限(MIN_EXCHANGE_COIN=10000), test_exchange_flow 改走 grant_coins 直接供款。
|
||||
# 注意: 不再 = 兑换下限(下限已降到 MIN_EXCHANGE_COIN=COIN_PER_CENT=100), test_exchange_flow 改走 grant_coins 直接供款。
|
||||
TASK_REWARDS: dict[str, int] = {
|
||||
TASK_ENABLE_NOTIFICATION: 1000,
|
||||
}
|
||||
@@ -82,9 +83,20 @@ def record_milestone_reward(milestone: int) -> int:
|
||||
AD_REWARD_COIN: int = 666
|
||||
# 单次发奖金币上限:夹紧穿山甲回调里异常的 reward_amount(如后台多打一个 0),防刷爆余额。
|
||||
MAX_AD_REWARD_COIN: int = 1000
|
||||
# 每用户每日发奖次数上限,防刷 + 控成本。
|
||||
# ⚠️ 测试阶段值=20:正式上线前按产品策略 + 广告 eCPM 实测收益重新核定,改这里即可。
|
||||
DAILY_AD_REWARD_LIMIT: int = 20
|
||||
# 每用户每日发奖次数上限——现作为"看广告时长上限"的**次数兜底**(防前端少报观看时长绕过时长闸)。
|
||||
# 主闸是 DAILY_AD_WATCH_SECONDS_LIMIT(50 分钟);次数兜底要 ≥ 时长闸内的"合理最大次数",
|
||||
# 否则会比主闸先掐、误伤看大量短广告的正常用户:50 分钟里若广告各 ~15s,理论可看 ~200 次,
|
||||
# 故取 200(≈时长闸的次数等价),正常用户先撞 50 分钟时长闸,仅前端时长全报 0 等异常时本闸才兜住。
|
||||
# 不要为"更严"而下调到 120 之类(会在 ~30 分钟就掐短广告用户);要更严应在客户端如实上报时长。
|
||||
DAILY_AD_REWARD_LIMIT: int = 200
|
||||
|
||||
# ===== 看激励视频每日总时长上限(防刷主闸)=====
|
||||
# 用户每天最多看 50 分钟激励视频,超了不发奖 / 不给看。时长由前端 onAdClose 上报真实观看
|
||||
# 秒数累计(POST /api/v1/ad/watch-report → ad_watch_log 表 SUM)。前端不可信,故配合上面
|
||||
# DAILY_AD_REWARD_LIMIT 次数兜底一起防刷。要调上限直接改这里。
|
||||
DAILY_AD_WATCH_SECONDS_LIMIT: int = 50 * 60 # 3000 秒 = 50 分钟
|
||||
# 单次上报观看时长的合理上限(夹紧异常值):激励视频一般 15~60 秒,超 120 秒按 120 记,防刷大值。
|
||||
MAX_SINGLE_WATCH_SECONDS: int = 120
|
||||
# 一轮看 N 次激励视频,看完一轮强制 10 分钟冷却(reward-status 派生 cooldown_until 给客户端,
|
||||
# 客户端任务行 CTA 在冷却期间显示"MM:SS"且不可点)。冷却是 UX 约束,后端发奖只看 daily_limit,
|
||||
# 冷却期间穿山甲回调仍照常发奖,不另设拒发分支。
|
||||
@@ -92,17 +104,71 @@ VIDEO_ROUND_REQUIRED_COUNT: int = 3
|
||||
VIDEO_ROUND_COOLDOWN_SECONDS: int = 600
|
||||
|
||||
|
||||
def resolve_ad_reward_coin(reward_amount: str | int | None) -> int:
|
||||
def resolve_ad_reward_coin(db, reward_amount: str | int | None) -> int: # noqa: ANN001
|
||||
"""把穿山甲回调的 reward_amount(后台代码位配的"奖励数量")解析成本次发放金币。
|
||||
|
||||
缺失 / 非数字 / ≤0 → 回退 AD_REWARD_COIN;超过 MAX_AD_REWARD_COIN → 夹紧。
|
||||
缺失 / 非数字 / ≤0 → 回退配置单次金币;超过配置单次上限 → 夹紧(均从 app_config 读)。
|
||||
注:reward_amount 不参与穿山甲 sign(只签 trans_id),但伪造回调需先伪造合法 sign
|
||||
(要 SecurityKey),故能过验签的 reward_amount 即视为可信,这里只防后台配错。
|
||||
"""
|
||||
default_coin = get_ad_reward_coin(db)
|
||||
try:
|
||||
coin = int(reward_amount) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return AD_REWARD_COIN
|
||||
return default_coin
|
||||
if coin <= 0:
|
||||
return AD_REWARD_COIN
|
||||
return min(coin, MAX_AD_REWARD_COIN)
|
||||
return default_coin
|
||||
return min(coin, get_ad_max_coin(db))
|
||||
|
||||
|
||||
# ===== 配置读取(运营后台可覆盖;app_config 表空时返回上面的常量默认 = 现状不变)=====
|
||||
# 业务处用这些 getter(传 db)取值,而非直接用常量,这样 admin 改 app_config 即生效。
|
||||
# 函数内延迟 import,避免 rewards ← app_config ← config_schema ← rewards 的模块级循环。
|
||||
|
||||
def _cfg(db, key: str): # noqa: ANN001
|
||||
from app.repositories import app_config
|
||||
return app_config.get_value(db, key)
|
||||
|
||||
|
||||
def get_signin_rewards(db) -> list[int]: # noqa: ANN001
|
||||
return list(_cfg(db, "signin_rewards"))
|
||||
|
||||
|
||||
def get_min_exchange_coin(db) -> int: # noqa: ANN001
|
||||
return int(_cfg(db, "min_exchange_coin"))
|
||||
|
||||
|
||||
def get_withdraw_min_cents(db) -> int: # noqa: ANN001
|
||||
return int(_cfg(db, "withdraw_min_cents"))
|
||||
|
||||
|
||||
def get_withdraw_max_cents(db) -> int: # noqa: ANN001
|
||||
return int(_cfg(db, "withdraw_max_cents"))
|
||||
|
||||
|
||||
def get_task_rewards(db) -> dict[str, int]: # noqa: ANN001
|
||||
return dict(_cfg(db, "task_rewards"))
|
||||
|
||||
|
||||
def get_record_milestones(db) -> list[int]: # noqa: ANN001
|
||||
return list(_cfg(db, "record_milestones"))
|
||||
|
||||
|
||||
def get_ad_reward_coin(db) -> int: # noqa: ANN001
|
||||
return int(_cfg(db, "ad_reward_coin"))
|
||||
|
||||
|
||||
def get_ad_daily_limit(db) -> int: # noqa: ANN001
|
||||
return int(_cfg(db, "ad_daily_limit"))
|
||||
|
||||
|
||||
def get_ad_max_coin(db) -> int: # noqa: ANN001
|
||||
return int(_cfg(db, "ad_max_coin"))
|
||||
|
||||
|
||||
def get_ad_round_count(db) -> int: # noqa: ANN001
|
||||
return int(_cfg(db, "ad_round_count"))
|
||||
|
||||
|
||||
def get_ad_cooldown_sec(db) -> int: # noqa: ANN001
|
||||
return int(_cfg(db, "ad_cooldown_sec"))
|
||||
|
||||
@@ -24,6 +24,10 @@ from app.core.config import settings
|
||||
|
||||
_API_HOST = "https://api.mch.weixin.qq.com"
|
||||
_TRANSFER_PATH = "/v3/fund-app/mch-transfer/transfer-bills"
|
||||
# 免确认收款授权(用户授权免确认模式)
|
||||
_AUTH_PATH = "/v3/fund-app/mch-transfer/user-confirm-authorization"
|
||||
_PRE_TRANSFER_AUTH_PATH = "/v3/fund-app/mch-transfer/transfer-bills/pre-transfer-with-authorization"
|
||||
_TRANSFER_WITH_AUTH_PATH = "/v3/fund-app/mch-transfer/transfer-bills/transfer"
|
||||
|
||||
# 懒加载缓存
|
||||
_private_key: RSAPrivateKey | None = None
|
||||
@@ -199,3 +203,164 @@ def code_to_userinfo(code: str) -> dict:
|
||||
pass
|
||||
|
||||
return {"openid": openid, "nickname": nickname, "avatar_url": avatar_url, "raw": raw}
|
||||
|
||||
|
||||
# ===== 免确认收款授权(用户授权免确认模式)=====
|
||||
# 用户授权一次后,后续转账走 transfer_with_authorization 免逐笔确认直接到账。
|
||||
# 复用上面同一套 V3 签名(_build_authorization)、敏感字段加密(encrypt_sensitive)、懒加载密钥。
|
||||
|
||||
|
||||
def _transfer_report_infos() -> list[dict]:
|
||||
"""现金营销(scene 1000)转账场景报备信息,与 create_transfer 同口径。"""
|
||||
return [
|
||||
{"info_type": "活动名称", "info_content": "比价返现"},
|
||||
{"info_type": "奖励说明", "info_content": "现金提现到微信零钱"},
|
||||
]
|
||||
|
||||
|
||||
def apply_transfer_authorization(
|
||||
out_authorization_no: str,
|
||||
openid: str,
|
||||
user_display_name: str,
|
||||
notify_url: str,
|
||||
*,
|
||||
scene_info: dict | None = None,
|
||||
) -> dict:
|
||||
"""发起免确认收款授权(方式二:不转账,仅申请授权)。返回 {status_code, data}。
|
||||
成功(200 + state=WAIT_USER_CONFIRM)时 data 带 package_info,供 App 拉起微信授权页。"""
|
||||
body: dict = {
|
||||
"out_authorization_no": out_authorization_no,
|
||||
"appid": settings.WECHAT_APP_ID,
|
||||
"openid": openid,
|
||||
"transfer_scene_id": settings.WXPAY_TRANSFER_SCENE_ID,
|
||||
"user_display_name": user_display_name,
|
||||
"authorization_notify_url": notify_url,
|
||||
}
|
||||
if scene_info:
|
||||
body["scene_info"] = scene_info
|
||||
body_str = json.dumps(body, ensure_ascii=False)
|
||||
headers = {
|
||||
"Authorization": _build_authorization("POST", _AUTH_PATH, body_str),
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
with httpx.Client() as client:
|
||||
resp = client.post(
|
||||
f"{_API_HOST}{_AUTH_PATH}",
|
||||
content=body_str,
|
||||
headers=headers,
|
||||
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
|
||||
)
|
||||
return {"status_code": resp.status_code, "data": resp.json()}
|
||||
|
||||
|
||||
def query_transfer_authorization(out_authorization_no: str) -> dict:
|
||||
"""按商户授权单号查授权结果。返回 {status_code, data}。
|
||||
data.state: WAIT_USER_CONFIRM / TAKING_EFFECT(已生效) / CLOSED;TAKING_EFFECT 时带 authorization_id。"""
|
||||
path = f"{_AUTH_PATH}/out-authorization-no/{out_authorization_no}"
|
||||
headers = {
|
||||
"Authorization": _build_authorization("GET", path, ""),
|
||||
"Accept": "application/json",
|
||||
}
|
||||
with httpx.Client() as client:
|
||||
resp = client.get(
|
||||
f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC
|
||||
)
|
||||
return {"status_code": resp.status_code, "data": resp.json()}
|
||||
|
||||
|
||||
def close_transfer_authorization(out_authorization_no: str) -> dict:
|
||||
"""解除免确认收款授权。返回 {status_code, data}(成功 data.state=CLOSED)。"""
|
||||
path = f"{_AUTH_PATH}/out-authorization-no/{out_authorization_no}/close"
|
||||
headers = {
|
||||
"Authorization": _build_authorization("POST", path, ""),
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
with httpx.Client() as client:
|
||||
resp = client.post(
|
||||
f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC
|
||||
)
|
||||
return {"status_code": resp.status_code, "data": resp.json()}
|
||||
|
||||
|
||||
def pre_transfer_with_authorization(
|
||||
openid: str,
|
||||
amount_fen: int,
|
||||
out_bill_no: str,
|
||||
out_authorization_no: str,
|
||||
user_display_name: str,
|
||||
notify_url: str,
|
||||
user_name: str | None = None,
|
||||
) -> dict:
|
||||
"""方式一:发起转账并同时申请免确认收款授权(用户在确认这笔收款时一并完成授权)。
|
||||
返回 {status_code, data};成功 state=WAIT_USER_CONFIRM 时带 package_info(拉确认+授权页)。"""
|
||||
body: dict = {
|
||||
"appid": settings.WECHAT_APP_ID,
|
||||
"out_bill_no": out_bill_no,
|
||||
"transfer_scene_id": settings.WXPAY_TRANSFER_SCENE_ID,
|
||||
"openid": openid,
|
||||
"transfer_amount": amount_fen,
|
||||
"transfer_remark": "金币提现",
|
||||
"transfer_scene_report_infos": _transfer_report_infos(),
|
||||
"authorization_info": {
|
||||
"user_display_name": user_display_name,
|
||||
"out_authorization_no": out_authorization_no,
|
||||
"authorization_notify_url": notify_url,
|
||||
},
|
||||
}
|
||||
if user_name and amount_fen >= 30:
|
||||
body["user_name"] = encrypt_sensitive(user_name)
|
||||
|
||||
body_str = json.dumps(body, ensure_ascii=False)
|
||||
headers = {
|
||||
"Authorization": _build_authorization("POST", _PRE_TRANSFER_AUTH_PATH, body_str),
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Wechatpay-Serial": settings.WXPAY_PUBLIC_KEY_ID,
|
||||
}
|
||||
with httpx.Client() as client:
|
||||
resp = client.post(
|
||||
f"{_API_HOST}{_PRE_TRANSFER_AUTH_PATH}",
|
||||
content=body_str,
|
||||
headers=headers,
|
||||
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
|
||||
)
|
||||
return {"status_code": resp.status_code, "data": resp.json()}
|
||||
|
||||
|
||||
def transfer_with_authorization(
|
||||
authorization_id: str,
|
||||
amount_fen: int,
|
||||
out_bill_no: str,
|
||||
user_name: str | None = None,
|
||||
) -> dict:
|
||||
"""方式二 / 二次起:用户已授权后免确认转账(无需用户逐笔确认,直接到账)。返回 {status_code, data}。
|
||||
成功 state ∈ ACCEPTED/PROCESSING/TRANSFERING/SUCCESS(无 WAIT_USER_CONFIRM、无 package_info)。"""
|
||||
body: dict = {
|
||||
"appid": settings.WECHAT_APP_ID,
|
||||
"out_bill_no": out_bill_no,
|
||||
"transfer_scene_id": settings.WXPAY_TRANSFER_SCENE_ID,
|
||||
"transfer_amount": amount_fen,
|
||||
"transfer_remark": "金币提现",
|
||||
"transfer_scene_report_infos": _transfer_report_infos(),
|
||||
"authorization_id": authorization_id,
|
||||
}
|
||||
if user_name and amount_fen >= 30:
|
||||
body["user_name"] = encrypt_sensitive(user_name)
|
||||
|
||||
body_str = json.dumps(body, ensure_ascii=False)
|
||||
headers = {
|
||||
"Authorization": _build_authorization("POST", _TRANSFER_WITH_AUTH_PATH, body_str),
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Wechatpay-Serial": settings.WXPAY_PUBLIC_KEY_ID,
|
||||
}
|
||||
with httpx.Client() as client:
|
||||
resp = client.post(
|
||||
f"{_API_HOST}{_TRANSFER_WITH_AUTH_PATH}",
|
||||
content=body_str,
|
||||
headers=headers,
|
||||
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
|
||||
)
|
||||
return {"status_code": resp.status_code, "data": resp.json()}
|
||||
|
||||
@@ -29,6 +29,7 @@ from app.api.v1.signin import router as signin_router
|
||||
from app.api.v1.tasks import router as tasks_router
|
||||
from app.api.v1.user import router as user_router
|
||||
from app.api.v1.wallet import router as wallet_router
|
||||
from app.api.v1.wxpay import router as wxpay_router
|
||||
from app.core.config import settings
|
||||
from app.core.logging import setup_logging
|
||||
|
||||
@@ -82,6 +83,7 @@ app.include_router(compare_record_router)
|
||||
app.include_router(compare_milestone_router)
|
||||
app.include_router(meituan_router)
|
||||
app.include_router(wallet_router)
|
||||
app.include_router(wxpay_router)
|
||||
app.include_router(signin_router)
|
||||
app.include_router(tasks_router)
|
||||
app.include_router(savings_router)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。"""
|
||||
from app.models.ad_ecpm import AdEcpmRecord # noqa: F401
|
||||
from app.models.ad_reward import AdRewardRecord # noqa: F401
|
||||
from app.models.ad_watch_log import AdWatchLog # noqa: F401
|
||||
from app.models.admin import AdminAuditLog, AdminUser # noqa: F401
|
||||
from app.models.app_config import AppConfig # noqa: F401
|
||||
from app.models.comparison import ComparisonRecord # noqa: F401
|
||||
from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401
|
||||
from app.models.feedback import Feedback # noqa: F401
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""看激励视频观看时长记录(每日总时长防刷)。
|
||||
|
||||
每条 = 客户端 onAdClose 上报的一次激励视频实际观看秒数。按 (user_id, watch_date) 聚合
|
||||
SUM(watch_seconds) 得当日总观看时长,用于"每天最多看 50 分钟激励视频"硬上限(超了不再发奖 /
|
||||
不给看,见 core.rewards.DAILY_AD_WATCH_SECONDS_LIMIT)。
|
||||
|
||||
这是看广告的第三条数据流,与另两条并列、互不关联:
|
||||
- ad_reward(AdRewardRecord):穿山甲 S2S 回调发金币(后端,有 trans_id);
|
||||
- ad_ecpm(AdEcpmRecord):客户端上报展示收益(前端,有 ecpm);
|
||||
- ad_watch_log(本表):客户端上报观看时长(前端,有 watch_seconds)。
|
||||
前端上报的时长不可信 → 时长是防刷"主闸",配合 ad_reward 的每日次数上限(DAILY_AD_REWARD_LIMIT)
|
||||
做"次数兜底",前端少报时长也刷不过次数闸。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class AdWatchLog(Base):
|
||||
__tablename__ = "ad_watch_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
# 本次激励视频实际观看秒数(前端 onAdShow→onAdClose);服务端已夹 [0, MAX_SINGLE_WATCH_SECONDS]
|
||||
watch_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# 北京时间日期串 'YYYY-MM-DD',按它等值做"当日总时长"聚合(不在 SQL 里跨时区比较)
|
||||
watch_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<AdWatchLog user_id={self.user_id} sec={self.watch_seconds} {self.watch_date}>"
|
||||
@@ -0,0 +1,32 @@
|
||||
"""运营可配置项(把 rewards.py 等硬编码规则挪到 DB,运营后台可改)。
|
||||
|
||||
key 是配置标识(见 app.core.config_schema.CONFIG_DEFS),value 用 JSON 存任意值(int/list/dict)。
|
||||
表里没有的 key,业务读配置时 fallback 到 CONFIG_DEFS 默认值(= 原 rewards 常量),
|
||||
所以**空表 = 现有行为完全不变**。admin 改某项 → 写一行 → 业务下次读到新值(跨进程一致)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Integer, String, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
_JSON = JSON().with_variant(JSONB(), "postgresql")
|
||||
|
||||
|
||||
class AppConfig(Base):
|
||||
__tablename__ = "app_config"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
value: Mapped[Any] = mapped_column(_JSON, nullable=False)
|
||||
updated_by_admin_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
updated_at: 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"<AppConfig key={self.key}>"
|
||||
@@ -86,7 +86,7 @@ class ComparisonRecord(Base):
|
||||
# ===== 明细(JSON,越详细越好)=====
|
||||
# 下单菜品 [{name, qty, specs?}]
|
||||
items: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
|
||||
# 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved}](price/coupon_saved 单位:元,原样存)
|
||||
# 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名)
|
||||
comparison_results: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
|
||||
# 目标平台未找到、跳过的菜名
|
||||
skipped_dish_names: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
|
||||
|
||||
+48
-6
@@ -65,11 +65,14 @@ class CoinTransaction(Base):
|
||||
|
||||
|
||||
class WithdrawOrder(Base):
|
||||
"""提现单(现金 → 微信零钱)。状态机:pending → success / failed。
|
||||
"""提现单(现金 → 微信零钱)。状态机:
|
||||
reviewing →(管理员通过)→ pending → success / failed
|
||||
reviewing →(管理员拒绝)→ rejected(已退款)
|
||||
|
||||
扣现金 + 写 cash_transaction(withdraw) 与建本单在同一事务;失败/取消时退回现金
|
||||
并写 cash_transaction(withdraw_refund)。wechat_state 存微信侧原始状态(WAIT_USER_CONFIRM /
|
||||
SUCCESS / FAIL / CANCELLED ...),status 是我们归一化后的三态。
|
||||
扣现金 + 写 cash_transaction(withdraw) 与建本单在同一事务,初始 reviewing(待人工审核,
|
||||
**不打款**);管理员审核通过才调微信转账(execute_withdraw_transfer)→ pending,拒绝则退回
|
||||
现金 + 置 rejected。微信侧失败/取消时退回现金并写 cash_transaction(withdraw_refund) → failed。
|
||||
wechat_state 存微信侧原始状态(WAIT_USER_CONFIRM / SUCCESS / FAIL / CANCELLED ...)。
|
||||
"""
|
||||
|
||||
__tablename__ = "withdraw_order"
|
||||
@@ -81,8 +84,10 @@ class WithdrawOrder(Base):
|
||||
# 商户单号(我们生成,微信查单的 out_bill_no),唯一
|
||||
out_bill_no: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
# 归一化状态:pending / success / failed
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
|
||||
# 提现实名(微信达额转账要求):审核后异步打款时要用,发起提现时存下,可空
|
||||
user_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 归一化状态:reviewing(待审核) / pending(打款在途) / success / failed(打款失败已退) / rejected(审核拒绝已退)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="reviewing")
|
||||
# 微信侧原始状态
|
||||
wechat_state: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# 微信转账单号
|
||||
@@ -102,6 +107,43 @@ class WithdrawOrder(Base):
|
||||
return f"<WithdrawOrder id={self.id} user_id={self.user_id} cents={self.amount_cents} {self.status}>"
|
||||
|
||||
|
||||
class WechatTransferAuthorization(Base):
|
||||
"""微信商家转账「免确认收款授权」(用户授权免确认模式)。一个用户一条(user_id 主键)。
|
||||
|
||||
状态机:
|
||||
(无) →(申请 / 首单转账顺带申请)→ pending(微信 WAIT_USER_CONFIRM,待用户在微信确认授权)
|
||||
pending →(用户确认授权)→ active(微信 TAKING_EFFECT,此后转账免用户逐笔确认)
|
||||
pending / active →(用户在微信关 / 商户解除 / 风控)→ closed(终态,需重新开启)
|
||||
out_authorization_no 我方生成(查授权 / 发起授权用,一个用户一条稳定值,重开时换新);
|
||||
authorization_id 微信在 active 后返回,免确认转账(transfer_with_authorization)时必传。
|
||||
"""
|
||||
|
||||
__tablename__ = "wechat_transfer_authorization"
|
||||
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), primary_key=True
|
||||
)
|
||||
openid: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
# 商户侧授权单号(我方生成),唯一
|
||||
out_authorization_no: Mapped[str] = mapped_column(
|
||||
String(64), unique=True, index=True, nullable=False
|
||||
)
|
||||
# 微信侧授权单号(active 后返回,免确认转账要用)
|
||||
authorization_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# pending(待用户确认) / active(已生效可免确认) / closed(已关闭需重开)
|
||||
state: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: 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"<WechatTransferAuthorization user_id={self.user_id} state={self.state}>"
|
||||
|
||||
|
||||
class CashTransaction(Base):
|
||||
"""现金流水(单位:分)。金币兑现金、提现都记在这里。"""
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
3. 当日发奖次数 ≥ 上限 → 记一条 status='capped' 但不发金币。
|
||||
|
||||
发金币复用 `wallet.grant_coins`(biz_type='ad_reward', ref_id=trans_id),与发奖记录同事务,
|
||||
保证"记一笔 + 加金币"原子化。
|
||||
保证"记一笔 + 加金币"原子化。单次金币 / 每日上限 / 每轮冷却 都从 app_config 读(运营后台可改)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -16,15 +16,13 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.core.ad_cooldown import compute_cooldown
|
||||
from app.core.rewards import (
|
||||
AD_REWARD_COIN,
|
||||
DAILY_AD_REWARD_LIMIT,
|
||||
cn_today,
|
||||
)
|
||||
from app.repositories import wallet as crud_wallet
|
||||
from app.core.rewards import DAILY_AD_WATCH_SECONDS_LIMIT, cn_today
|
||||
from app.models.ad_reward import AdRewardRecord
|
||||
from app.models.user import User
|
||||
from app.repositories import wallet as crud_wallet
|
||||
from app.repositories.ad_watch import watched_seconds_today
|
||||
|
||||
|
||||
class UnknownUserError(Exception):
|
||||
@@ -54,14 +52,14 @@ def grant_ad_reward(
|
||||
user_id: int,
|
||||
trans_id: str,
|
||||
*,
|
||||
coin: int = AD_REWARD_COIN,
|
||||
coin: int | None = None,
|
||||
reward_name: str | None = None,
|
||||
raw: str | None = None,
|
||||
) -> AdRewardRecord:
|
||||
"""看广告发奖(幂等 + 每日限额)。返回发奖记录(status=granted/capped)。
|
||||
|
||||
coin 为本次发放金币(由调用方按穿山甲回调 reward_amount 解析,见
|
||||
rewards.resolve_ad_reward_coin);默认 AD_REWARD_COIN 供 test-grant / 缺省场景用。
|
||||
rewards.resolve_ad_reward_coin);None → 读配置 get_ad_reward_coin(test-grant / 缺省场景)。
|
||||
user_id 不存在抛 UnknownUserError(不建账户,防伪造 user_id 刷出脏数据)。
|
||||
"""
|
||||
# #2 幂等:同 trans_id 已处理过 → 原样返回,不重复发
|
||||
@@ -74,14 +72,21 @@ def grant_ad_reward(
|
||||
|
||||
today = cn_today().isoformat()
|
||||
|
||||
# #3 每日上限:超了记一条 capped(不发金币),让审计能看到"今天到顶了"
|
||||
if _granted_today(db, user_id, today) >= DAILY_AD_REWARD_LIMIT:
|
||||
# #3 每日上限:观看时长(主闸,50 分钟) 或 发奖次数(兜底) 任一到顶 → 记 capped 不发金币,
|
||||
# 让审计能看到"今天到顶了"。时长由前端 watch-report 累计(防刷主闸),次数防前端少报时长
|
||||
# 绕过;次数上限走 app_config(运营后台可改,默认 rewards.DAILY_AD_REWARD_LIMIT)。
|
||||
over_time = watched_seconds_today(db, user_id, today=today) >= DAILY_AD_WATCH_SECONDS_LIMIT
|
||||
over_count = _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db)
|
||||
if over_time or over_count:
|
||||
rec = AdRewardRecord(
|
||||
trans_id=trans_id, user_id=user_id, coin=0, status="capped",
|
||||
reward_date=today, reward_name=reward_name, raw=raw,
|
||||
)
|
||||
return _commit_record(db, rec, trans_id)
|
||||
|
||||
if coin is None:
|
||||
coin = rewards.get_ad_reward_coin(db)
|
||||
|
||||
# 发金币 + 记一笔,同事务
|
||||
crud_wallet.grant_coins(
|
||||
db, user_id, coin,
|
||||
@@ -126,20 +131,29 @@ def _granted_times_today_desc(db: Session, user_id: int, reward_date: str) -> li
|
||||
|
||||
def today_status(
|
||||
db: Session, user_id: int
|
||||
) -> tuple[int, int, int, int, datetime | None]:
|
||||
) -> tuple[int, int, int, int, datetime | None, int, int]:
|
||||
"""客户端查"今日看广告发奖"进度。
|
||||
|
||||
返回 (今日已发次数, 每日上限, 单次金币, 本轮已看次数, 本轮冷却结束时间(UTC))。
|
||||
本函数只**取数据**(今日 granted 的 created_at 倒序),把"本轮已看几次 + 冷却到几点"的
|
||||
**策略判断**委托给 [app.core.ad_cooldown.compute_cooldown](纯函数)——换冷却策略只动那个模块。
|
||||
返回 (今日已发次数, 每日次数上限, 单次金币, 本轮已看次数, 本轮冷却结束时间(UTC),
|
||||
今日已观看总秒数, 每日观看总时长上限(秒))。
|
||||
次数上限/单次金币/每轮次数/冷却秒 均从 app_config 读(运营后台可改);次数维度的"本轮已看
|
||||
几次 + 冷却到几点"委托 [app.core.ad_cooldown.compute_cooldown](纯函数);时长维度(50 分钟
|
||||
主闸)查 ad_watch_log 当日累计——前端据此展示"今日已看 X/50 分钟"+ 满则不给看。
|
||||
"""
|
||||
today = cn_today().isoformat()
|
||||
granted_desc = _granted_times_today_desc(db, user_id, today)
|
||||
state = compute_cooldown(granted_desc, datetime.now(timezone.utc))
|
||||
state = compute_cooldown(
|
||||
granted_desc,
|
||||
datetime.now(timezone.utc),
|
||||
round_size=rewards.get_ad_round_count(db),
|
||||
cooldown_seconds=rewards.get_ad_cooldown_sec(db),
|
||||
)
|
||||
return (
|
||||
len(granted_desc),
|
||||
DAILY_AD_REWARD_LIMIT,
|
||||
AD_REWARD_COIN,
|
||||
rewards.get_ad_daily_limit(db),
|
||||
rewards.get_ad_reward_coin(db),
|
||||
state.round_count,
|
||||
state.cooldown_until,
|
||||
watched_seconds_today(db, user_id, today=today),
|
||||
DAILY_AD_WATCH_SECONDS_LIMIT,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""看激励视频观看时长 CRUD(每日总时长防刷)。
|
||||
|
||||
前端 onAdClose 上报本次观看秒数 → add_watch_seconds 落库 + 返回当日累计;
|
||||
watched_seconds_today 给"时长闸"(发奖 / 展示前)判断当天是否已达 50 分钟上限。
|
||||
鉴权接口已确保 user 存在(JWT),故不做 UnknownUser 校验。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import MAX_SINGLE_WATCH_SECONDS, cn_today
|
||||
from app.models.ad_watch_log import AdWatchLog
|
||||
|
||||
|
||||
def watched_seconds_today(db: Session, user_id: int, *, today: str | None = None) -> int:
|
||||
"""该用户今日(北京时间)累计观看秒数。coalesce 防无记录时 SUM 返 None。"""
|
||||
d = today or cn_today().isoformat()
|
||||
return db.execute(
|
||||
select(func.coalesce(func.sum(AdWatchLog.watch_seconds), 0)).where(
|
||||
AdWatchLog.user_id == user_id,
|
||||
AdWatchLog.watch_date == d,
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
|
||||
def add_watch_seconds(db: Session, user_id: int, seconds: int) -> int:
|
||||
"""记一次观看时长,返回当日累计秒数。
|
||||
|
||||
seconds 夹 [0, MAX_SINGLE_WATCH_SECONDS] 防前端报异常大值(刷时长无意义,但夹紧更稳)。
|
||||
"""
|
||||
clamped = max(0, min(int(seconds), MAX_SINGLE_WATCH_SECONDS))
|
||||
today = cn_today().isoformat()
|
||||
db.add(AdWatchLog(user_id=user_id, watch_seconds=clamped, watch_date=today))
|
||||
db.commit()
|
||||
return watched_seconds_today(db, user_id, today=today)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""运营配置读写。
|
||||
|
||||
get_value:业务读配置,DB 没有则 fallback 到 CONFIG_DEFS 默认(= 原 rewards 常量,空表行为不变)。
|
||||
不缓存:配置读频率低(每次福利操作读一次,key 主键查极快),admin 改了立即生效、跨进程一致。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config_schema import CONFIG_DEFS
|
||||
from app.models.app_config import AppConfig
|
||||
|
||||
|
||||
def get_value(db: Session, key: str) -> Any:
|
||||
"""读配置值。DB 有用 DB,没有 fallback 到 CONFIG_DEFS 默认。未知 key 抛 KeyError。"""
|
||||
row = db.get(AppConfig, key)
|
||||
if row is not None:
|
||||
return row.value
|
||||
return CONFIG_DEFS[key]["default"]
|
||||
|
||||
|
||||
def set_value(
|
||||
db: Session, key: str, value: Any, *, admin_id: int, commit: bool = True
|
||||
) -> AppConfig:
|
||||
"""写配置(admin 用)。未知 key 抛 KeyError。commit=False 让调用方与审计同事务。"""
|
||||
if key not in CONFIG_DEFS:
|
||||
raise KeyError(f"unknown config key: {key}")
|
||||
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
|
||||
row.updated_by_admin_id = admin_id
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
else:
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def list_all(db: Session) -> list[dict]:
|
||||
"""所有可配项:default + 当前值(DB 有则用 DB) + 元信息。给 admin 配置页渲染。"""
|
||||
overrides = {r.key: r for r in db.execute(select(AppConfig)).scalars().all()}
|
||||
out: list[dict] = []
|
||||
for key, defn in CONFIG_DEFS.items():
|
||||
row = overrides.get(key)
|
||||
out.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": defn["label"],
|
||||
"group": defn["group"],
|
||||
"type": defn["type"],
|
||||
"help": defn.get("help"),
|
||||
"default": defn["default"],
|
||||
"value": row.value if row is not None else defn["default"],
|
||||
"overridden": row is not None,
|
||||
"updated_at": row.updated_at.isoformat() if row is not None else None,
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
进度 = comparison_record 里 status='success' 的条数(crud_compare.count_success)。
|
||||
第 N 档在"成功次数 >= N"时解锁;每档领一次,写 comparison_milestone_claim 去重(标记已领)。
|
||||
档位金额来自 rewards.get_record_milestones(运营后台可改)。
|
||||
⚠️ 当前产品定暂不真发金币(后续整体删除该功能):claim 只写领取记录,不调 grant_coins、
|
||||
不写 coin_transaction,coin_awarded 恒为 0,余额不变。仿一次性任务 (task.py)。
|
||||
"""
|
||||
@@ -12,17 +13,14 @@ from dataclasses import dataclass
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import (
|
||||
RECORD_MILESTONE_COUNT,
|
||||
RECORD_MILESTONES,
|
||||
)
|
||||
from app.core import rewards
|
||||
from app.models.comparison_milestone import ComparisonMilestoneClaim
|
||||
from app.repositories import comparison as crud_compare
|
||||
from app.repositories import wallet as crud_wallet
|
||||
|
||||
|
||||
class UnknownMilestoneError(Exception):
|
||||
"""档位序号越界(不在 1..RECORD_MILESTONE_COUNT)。"""
|
||||
"""档位序号越界(不在 1..档位数)。"""
|
||||
|
||||
|
||||
class MilestoneLockedError(Exception):
|
||||
@@ -60,10 +58,11 @@ def get_status(db: Session, user_id: int) -> MilestoneStatus:
|
||||
"""各档领取状态:已领=claimed,已解锁未领=active(可领),未解锁=locked。"""
|
||||
success_count = crud_compare.count_success(db, user_id)
|
||||
claimed = _claimed_set(db, user_id)
|
||||
milestones_def = rewards.get_record_milestones(db)
|
||||
|
||||
milestones: list[MilestoneState] = []
|
||||
claimable = 0
|
||||
for m in range(1, RECORD_MILESTONE_COUNT + 1):
|
||||
for m in range(1, len(milestones_def) + 1):
|
||||
if m in claimed:
|
||||
state = "claimed"
|
||||
elif success_count >= m:
|
||||
@@ -72,7 +71,7 @@ def get_status(db: Session, user_id: int) -> MilestoneStatus:
|
||||
else:
|
||||
state = "locked"
|
||||
milestones.append(
|
||||
MilestoneState(milestone=m, coin=RECORD_MILESTONES[m - 1], state=state)
|
||||
MilestoneState(milestone=m, coin=milestones_def[m - 1], state=state)
|
||||
)
|
||||
|
||||
return MilestoneStatus(
|
||||
@@ -87,7 +86,7 @@ def claim(db: Session, user_id: int, milestone: int) -> tuple[int, int]:
|
||||
|
||||
越界抛 UnknownMilestoneError;未解锁抛 MilestoneLockedError;重复领抛 AlreadyClaimedError。
|
||||
"""
|
||||
if milestone < 1 or milestone > RECORD_MILESTONE_COUNT:
|
||||
if milestone < 1 or milestone > len(rewards.get_record_milestones(db)):
|
||||
raise UnknownMilestoneError
|
||||
|
||||
existing = db.execute(
|
||||
|
||||
@@ -52,6 +52,7 @@ class SavingsBattle:
|
||||
week_saved_cents: int # 本周(周一起)已省
|
||||
beat_percent: int # 超过百分之多少用户
|
||||
streak_days: int # 连续省钱天数
|
||||
compare_count: int # 累计完成比价次数(= 有效记录数:真实用 compare 记录、否则 demo 兜底)
|
||||
|
||||
|
||||
def _local_date(dt: datetime):
|
||||
@@ -163,7 +164,10 @@ def get_battle(db: Session, user_id: int) -> SavingsBattle:
|
||||
beat_percent = _compute_beat_percent(db, user_id)
|
||||
|
||||
return SavingsBattle(
|
||||
week_saved_cents=week_saved, beat_percent=beat_percent, streak_days=streak
|
||||
week_saved_cents=week_saved,
|
||||
beat_percent=beat_percent,
|
||||
streak_days=streak,
|
||||
compare_count=len(records),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
7 天循环规则:
|
||||
- 昨天签过 → 今天 cycle_day = 昨天 % 7 + 1,streak += 1(连续)
|
||||
- 否则(首签 / 断签) → cycle_day = 1,streak = 1(重置)
|
||||
今天的 cycle_day 决定发多少金币(见 app.core.rewards.SIGNIN_REWARDS)。
|
||||
今天的 cycle_day 决定发多少金币(档位来自 rewards.get_signin_rewards,运营后台可改)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,9 +13,10 @@ from datetime import timedelta
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import SIGNIN_CYCLE_LEN, SIGNIN_REWARDS, cn_today, signin_reward
|
||||
from app.repositories import wallet as crud_wallet
|
||||
from app.core import rewards
|
||||
from app.core.rewards import SIGNIN_CYCLE_LEN, cn_today
|
||||
from app.models.signin import SigninRecord
|
||||
from app.repositories import wallet as crud_wallet
|
||||
|
||||
|
||||
class AlreadySignedError(Exception):
|
||||
@@ -58,6 +59,7 @@ def _next_cycle_day(last: SigninRecord | None, today) -> int:
|
||||
|
||||
def get_status(db: Session, user_id: int) -> SigninStatus:
|
||||
today = cn_today()
|
||||
rewards_list = rewards.get_signin_rewards(db) # 配置 or 默认(长度=SIGNIN_CYCLE_LEN)
|
||||
last = _latest_record(db, user_id)
|
||||
today_signed = last is not None and last.signin_date == today
|
||||
|
||||
@@ -83,13 +85,13 @@ def get_status(db: Session, user_id: int) -> SigninStatus:
|
||||
status = "today"
|
||||
else:
|
||||
status = "locked"
|
||||
steps.append(SigninStep(day=day, coin=SIGNIN_REWARDS[day - 1], status=status))
|
||||
steps.append(SigninStep(day=day, coin=rewards_list[day - 1], status=status))
|
||||
|
||||
return SigninStatus(
|
||||
today_signed=today_signed,
|
||||
consecutive_days=consecutive_days,
|
||||
today_cycle_day=today_cycle_day,
|
||||
today_coin=signin_reward(today_cycle_day),
|
||||
today_coin=rewards_list[today_cycle_day - 1],
|
||||
can_claim=not today_signed,
|
||||
steps=steps,
|
||||
)
|
||||
@@ -109,7 +111,7 @@ def do_signin(db: Session, user_id: int) -> tuple[SigninRecord, int]:
|
||||
cycle_day = 1
|
||||
streak = 1
|
||||
|
||||
coin = signin_reward(cycle_day)
|
||||
coin = rewards.get_signin_rewards(db)[cycle_day - 1]
|
||||
record = SigninRecord(
|
||||
user_id=user_id,
|
||||
signin_date=today,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""一次性任务 CRUD:列表状态 + 领奖。
|
||||
|
||||
领奖原子化:写 user_task(唯一约束防重复)+ 发金币,同一事务 commit。
|
||||
任务列表 + 奖励来自 rewards.get_task_rewards(运营后台可改)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,9 +10,9 @@ from dataclasses import dataclass
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import TASK_REWARDS
|
||||
from app.repositories import wallet as crud_wallet
|
||||
from app.core import rewards
|
||||
from app.models.task import UserTask
|
||||
from app.repositories import wallet as crud_wallet
|
||||
|
||||
|
||||
class UnknownTaskError(Exception):
|
||||
@@ -31,11 +32,12 @@ class TaskState:
|
||||
|
||||
def list_tasks(db: Session, user_id: int) -> list[TaskState]:
|
||||
"""返回所有已知一次性任务及其领取状态。"""
|
||||
task_rewards = rewards.get_task_rewards(db)
|
||||
stmt = select(UserTask.task_key).where(UserTask.user_id == user_id)
|
||||
claimed_keys = set(db.execute(stmt).scalars().all())
|
||||
return [
|
||||
TaskState(task_key=key, coin=coin, claimed=key in claimed_keys)
|
||||
for key, coin in TASK_REWARDS.items()
|
||||
for key, coin in task_rewards.items()
|
||||
]
|
||||
|
||||
|
||||
@@ -44,7 +46,8 @@ def claim_task(db: Session, user_id: int, task_key: str) -> tuple[int, int]:
|
||||
|
||||
未知任务抛 UnknownTaskError;重复领取抛 AlreadyClaimedError。
|
||||
"""
|
||||
if task_key not in TASK_REWARDS:
|
||||
task_rewards = rewards.get_task_rewards(db)
|
||||
if task_key not in task_rewards:
|
||||
raise UnknownTaskError
|
||||
|
||||
existing = db.execute(
|
||||
@@ -55,7 +58,7 @@ def claim_task(db: Session, user_id: int, task_key: str) -> tuple[int, int]:
|
||||
if existing is not None:
|
||||
raise AlreadyClaimedError
|
||||
|
||||
coin = TASK_REWARDS[task_key]
|
||||
coin = task_rewards[task_key]
|
||||
db.add(
|
||||
UserTask(
|
||||
user_id=user_id,
|
||||
|
||||
+304
-42
@@ -7,27 +7,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import (
|
||||
COIN_PER_CENT,
|
||||
MIN_EXCHANGE_COIN,
|
||||
WITHDRAW_MAX_CENTS,
|
||||
WITHDRAW_MIN_CENTS,
|
||||
coins_to_cents,
|
||||
)
|
||||
from app.core import rewards
|
||||
from app.core.config import settings
|
||||
from app.core.rewards import COIN_PER_CENT, coins_to_cents
|
||||
from app.integrations import wxpay
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
|
||||
from app.models.wallet import (
|
||||
CashTransaction,
|
||||
CoinAccount,
|
||||
CoinTransaction,
|
||||
WechatTransferAuthorization,
|
||||
WithdrawOrder,
|
||||
)
|
||||
|
||||
# 微信转账终态:成功 / 失败(失败/取消/关闭都退款)
|
||||
_WX_STATE_SUCCESS = "SUCCESS"
|
||||
_WX_STATE_FAILED = {"FAIL", "CANCELLED", "CLOSED"}
|
||||
_WX_STATE_WAIT_CONFIRM = "WAIT_USER_CONFIRM" # 用户还没在微信确认页确认
|
||||
# 免确认收款授权状态
|
||||
_WX_AUTH_ACTIVE = "TAKING_EFFECT" # 已生效,可免确认转账
|
||||
_WX_AUTH_CLOSED = "CLOSED" # 已关闭(用户/商户/风控),需重新开启
|
||||
|
||||
|
||||
class InvalidExchangeAmountError(Exception):
|
||||
@@ -62,6 +68,10 @@ class WithdrawOrderNotFound(Exception):
|
||||
"""提现单不存在。"""
|
||||
|
||||
|
||||
class WithdrawNotReviewable(Exception):
|
||||
"""提现单当前状态不可审核(非 reviewing,可能已被处理过)。"""
|
||||
|
||||
|
||||
def get_or_create_account(db: Session, user_id: int, *, commit: bool = True) -> CoinAccount:
|
||||
"""取用户金币账户,不存在则建一个空账户。"""
|
||||
acc = db.get(CoinAccount, user_id)
|
||||
@@ -142,7 +152,7 @@ def exchange_coins_to_cash(
|
||||
校验:金额 >= MIN_EXCHANGE_COIN 且为整分倍数(InvalidExchangeAmountError),
|
||||
余额充足(InsufficientCoinError)。扣金币 + 加现金,两条流水同事务 commit。
|
||||
"""
|
||||
if coin_amount < MIN_EXCHANGE_COIN or coin_amount % COIN_PER_CENT != 0:
|
||||
if coin_amount < rewards.get_min_exchange_coin(db) or coin_amount % COIN_PER_CENT != 0:
|
||||
raise InvalidExchangeAmountError
|
||||
|
||||
acc = get_or_create_account(db, user_id, commit=False)
|
||||
@@ -259,10 +269,18 @@ def _add_cash(db: Session, user_id: int, amount_cents: int) -> int:
|
||||
return bal
|
||||
|
||||
|
||||
def _refund_withdraw(db: Session, order: WithdrawOrder, reason: str) -> None:
|
||||
"""转账失败/取消:退回现金 + 写 withdraw_refund 流水 + 单子置 failed。幂等:已 failed 不重复退。"""
|
||||
if order.status == "failed":
|
||||
return # 防重复退款(并发/对账与查单同时触发)
|
||||
def _refund_withdraw(
|
||||
db: Session, order: WithdrawOrder, reason: str, *, final_status: str = "failed"
|
||||
) -> None:
|
||||
"""退回现金 + 写 withdraw_refund 流水 + 单子置终态。幂等:已是退款终态不重复退。
|
||||
|
||||
final_status:
|
||||
- "failed" (默认):微信转账失败/取消 → 自动退款
|
||||
- "rejected":管理员审核拒绝 → 退款(走 reject_withdraw)
|
||||
两种都已退款,用 in 判定防重复退(并发/对账/拒绝重复点)。
|
||||
"""
|
||||
if order.status in ("failed", "rejected"):
|
||||
return # 防重复退款(并发/对账与查单/重复拒绝同时触发)
|
||||
bal = _add_cash(db, order.user_id, order.amount_cents)
|
||||
db.add(
|
||||
CashTransaction(
|
||||
@@ -271,10 +289,11 @@ def _refund_withdraw(db: Session, order: WithdrawOrder, reason: str) -> None:
|
||||
balance_after_cents=bal,
|
||||
biz_type="withdraw_refund",
|
||||
ref_id=order.out_bill_no,
|
||||
remark="提现未成功,金额已退回", # 用户可见;技术原因记在 order.fail_reason
|
||||
# 用户可见文案区分"未成功(自动退)"vs"审核未通过";技术原因记在 order.fail_reason
|
||||
remark="提现审核未通过,金额已退回" if final_status == "rejected" else "提现未成功,金额已退回",
|
||||
)
|
||||
)
|
||||
order.status = "failed"
|
||||
order.status = final_status
|
||||
order.fail_reason = reason[:256]
|
||||
db.commit()
|
||||
|
||||
@@ -332,22 +351,26 @@ def create_withdraw(
|
||||
user_name: str | None = None,
|
||||
out_bill_no: str | None = None,
|
||||
) -> WithdrawOrder:
|
||||
"""发起提现(原子扣款 + 幂等 + 模糊失败查单)。
|
||||
"""发起提现:原子扣款 + 建单 reviewing(待人工审核),**不打款**。
|
||||
|
||||
#1 用原子条件 UPDATE 扣款,杜绝并发超额。
|
||||
#2 out_bill_no 由客户端提供作幂等键:同号重试不会重复转账,直接返回该单当前状态。
|
||||
#3 微信调用结果不明时先查单再决定,绝不盲目退款。
|
||||
返回提现单(可能含 package_info 供 App 拉起确认页)。
|
||||
打款推迟到管理员在 admin 后台审核通过(approve_withdraw → execute_withdraw_transfer)才发起;
|
||||
拒绝则退回现金(reject_withdraw)。
|
||||
#1 用原子条件 UPDATE 扣款,杜绝并发超额——且"待审核期间钱已扣减",防止用户拿同一笔余额
|
||||
重复发起多笔提现(审核拒绝再退回)。
|
||||
#2 out_bill_no 客户端幂等键:同号重试返回该单现状(reviewing 等审核),不重复扣款建单。
|
||||
实名 user_name 在此存下(WithdrawOrder.user_name),供异步审核打款时传给微信(达额需实名)。
|
||||
"""
|
||||
if amount_cents < WITHDRAW_MIN_CENTS or amount_cents > WITHDRAW_MAX_CENTS:
|
||||
if amount_cents < rewards.get_withdraw_min_cents(db) or amount_cents > rewards.get_withdraw_max_cents(db):
|
||||
raise InvalidWithdrawAmountError
|
||||
|
||||
# 提现即要求已绑微信:否则审核通过也打不了款,提前拦更友好
|
||||
user = db.get(User, user_id)
|
||||
openid = user.wechat_openid if user else None
|
||||
if not openid:
|
||||
raise WechatNotBoundError
|
||||
|
||||
# #2 幂等:客户端传了合法 out_bill_no 且该单已存在 → 返回现状(pending 的顺手查一下),不重复发起
|
||||
# #2 幂等:该单已存在 → 直接返回现状(reviewing 等审核 / 已处理态原样返回),不重复扣款。
|
||||
# 不再像旧版对 pending 查微信:本函数不再涉及微信,查单交给 /withdraw/status 与对账任务。
|
||||
if out_bill_no and _OUT_BILL_NO_RE.match(out_bill_no):
|
||||
existing = db.execute(
|
||||
select(WithdrawOrder).where(
|
||||
@@ -355,8 +378,6 @@ def create_withdraw(
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
if existing.status == "pending":
|
||||
return refresh_withdraw_status(db, user_id, out_bill_no)
|
||||
return existing
|
||||
else:
|
||||
out_bill_no = uuid.uuid4().hex
|
||||
@@ -379,38 +400,173 @@ def create_withdraw(
|
||||
balance_after_cents=bal,
|
||||
biz_type="withdraw",
|
||||
ref_id=out_bill_no,
|
||||
remark="提现到微信零钱",
|
||||
remark="提现到微信零钱(待审核)",
|
||||
)
|
||||
)
|
||||
order = WithdrawOrder(
|
||||
user_id=user_id, out_bill_no=out_bill_no, amount_cents=amount_cents, status="pending"
|
||||
user_id=user_id,
|
||||
out_bill_no=out_bill_no,
|
||||
amount_cents=amount_cents,
|
||||
user_name=user_name,
|
||||
status="reviewing",
|
||||
)
|
||||
db.add(order)
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
return order # 待管理员审核;**不在此处打款**
|
||||
|
||||
# 2) 调微信转账;结果不明先查单再决定(#3)
|
||||
|
||||
# ===== 免确认收款授权(用户授权免确认模式)=====
|
||||
# 用户授权一次后,后续提现走 transfer_with_authorization 免逐笔确认直接到账。
|
||||
# WechatTransferAuthorization 一个用户一条;out_authorization_no 我方生成,authorization_id 微信生效后返回。
|
||||
|
||||
|
||||
def _auth_display_name(user: User | None) -> str:
|
||||
"""微信授权页展示的"开通账号"昵称(≤32,utf8)。
|
||||
|
||||
微信对 user_display_name 校验极严:**连空格和标点(. 等)都算"控制字符"拒收**——实测
|
||||
'wonderable ai'(带空格)被 400 拒、'wonderableai'/'周周'/'傻瓜比价用户8888' 才过。
|
||||
故只保留 文字(Unicode L*,中英文/CJK)与数字(N*),其余(emoji/符号/空格/标点/控制符/
|
||||
零宽连接符/变体选择符/星形面字符)一律剔除;清空则兜底手机号尾号。
|
||||
"""
|
||||
raw = ((user.nickname if user else None) or (user.wechat_nickname if user else None) or "").strip()
|
||||
kept = [
|
||||
ch for ch in raw
|
||||
if ord(ch) <= 0xFFFF and unicodedata.category(ch)[0] in ("L", "N")
|
||||
]
|
||||
name = "".join(kept).strip()
|
||||
if not name and user and user.phone:
|
||||
tail = user.phone[-4:] if len(user.phone) >= 4 else user.phone
|
||||
name = f"傻瓜比价用户{tail}"
|
||||
return (name or "傻瓜比价用户")[:32]
|
||||
|
||||
|
||||
def _refresh_active_auth(db: Session, user_id: int) -> None:
|
||||
"""免确认转账失败后回查授权有效性(权威判定,不靠猜错误码):
|
||||
微信侧明确非生效(用户在微信关闭 / 风控 / 无此单)→ 标 closed,下次提现自动回退方式一重新授权;
|
||||
仍生效(失败实为商户余额不足等与授权无关的原因)→ 保持 active,下次免确认重试。
|
||||
仅对本地 active 记录查询;查询本身失败(5xx/网络)不改状态,下次再核,避免误关有效授权。"""
|
||||
auth = db.get(WechatTransferAuthorization, user_id)
|
||||
if auth is None or auth.state != "active" or not settings.wxpay_configured:
|
||||
return
|
||||
try:
|
||||
result = wxpay.create_transfer(openid, amount_cents, out_bill_no, user_name=user_name)
|
||||
except Exception as e: # noqa: BLE001 — 超时/网络异常≠失败,查单确认
|
||||
_settle_after_ambiguous(db, order, reason=f"转账调用异常: {e}")
|
||||
db.refresh(order)
|
||||
if order.status == "failed":
|
||||
raise WithdrawTransferError(str(e)) from e
|
||||
return order
|
||||
res = wxpay.query_transfer_authorization(auth.out_authorization_no)
|
||||
except Exception: # noqa: BLE001 — 查询失败保持 active,下次再核
|
||||
return
|
||||
if res["status_code"] == 200:
|
||||
if res["data"].get("state") != _WX_AUTH_ACTIVE: # CLOSED / 其他非生效 → 失效
|
||||
auth.state = "closed"
|
||||
db.commit()
|
||||
elif _wx_not_found(res): # 明确无此授权单 → 失效
|
||||
auth.state = "closed"
|
||||
db.commit()
|
||||
# 其他查询错误(5xx 等):不改状态,保持 active,下次再核
|
||||
|
||||
|
||||
def sync_transfer_auth(db: Session, user_id: int) -> WechatTransferAuthorization | None:
|
||||
"""同步待确认授权最新状态(仅 pending 时查微信):
|
||||
TAKING_EFFECT → active + 落 authorization_id;CLOSED/超期无此单 → closed。返回最新记录(或 None)。
|
||||
非 pending(active/closed)直接返回不查;查询异常或缺凭证则保持原状。"""
|
||||
auth = db.get(WechatTransferAuthorization, user_id)
|
||||
if auth is None or auth.state != "pending" or not settings.wxpay_configured:
|
||||
return auth
|
||||
try:
|
||||
res = wxpay.query_transfer_authorization(auth.out_authorization_no)
|
||||
except Exception: # noqa: BLE001 — 查询失败保持 pending,下次再同步
|
||||
return auth
|
||||
if res["status_code"] != 200:
|
||||
if _wx_not_found(res): # 超期未确认被关闭 → closed;其他错误保持 pending
|
||||
auth.state = "closed"
|
||||
db.commit()
|
||||
return auth
|
||||
state = res["data"].get("state", "")
|
||||
if state == _WX_AUTH_ACTIVE:
|
||||
auth.state = "active"
|
||||
auth.authorization_id = res["data"].get("authorization_id") or auth.authorization_id
|
||||
db.commit()
|
||||
elif state == _WX_AUTH_CLOSED:
|
||||
auth.state = "closed"
|
||||
db.commit()
|
||||
# WAIT_USER_CONFIRM:保持 pending,等用户确认
|
||||
return auth
|
||||
|
||||
|
||||
def _ensure_pending_auth_no(db: Session, user_id: int, openid: str) -> str:
|
||||
"""为"方式一(转账顺带授权)"准备待确认授权单号:
|
||||
已有 pending → 复用其 out_authorization_no(避免重复申请刷满"同场景≤5"上限);
|
||||
无 / 已 closed → 生成新单号并 upsert 成 pending。返回 out_authorization_no。"""
|
||||
auth = db.get(WechatTransferAuthorization, user_id)
|
||||
if auth is not None and auth.state == "pending":
|
||||
if auth.openid != openid: # 换绑过 → 刷新快照
|
||||
auth.openid = openid
|
||||
db.commit()
|
||||
return auth.out_authorization_no
|
||||
new_no = uuid.uuid4().hex # 32 位,符合 [0-9A-Za-z_-]{8,32}
|
||||
if auth is None:
|
||||
db.add(
|
||||
WechatTransferAuthorization(
|
||||
user_id=user_id, openid=openid, out_authorization_no=new_no,
|
||||
authorization_id=None, state="pending",
|
||||
)
|
||||
)
|
||||
else: # closed → 重新开启,换新单号
|
||||
auth.openid = openid
|
||||
auth.out_authorization_no = new_no
|
||||
auth.authorization_id = None
|
||||
auth.state = "pending"
|
||||
db.commit()
|
||||
return new_no
|
||||
|
||||
|
||||
def apply_transfer_auth(db: Session, user_id: int) -> dict:
|
||||
"""方式二:显式开启免确认收款(申请授权,不转账)。
|
||||
返回 {already_active, package_info, mch_id, app_id};already_active=True 表示已开启无需再授权。
|
||||
未绑微信抛 WechatNotBoundError;微信返回非 200 抛 WithdrawTransferError。"""
|
||||
user = db.get(User, user_id)
|
||||
openid = user.wechat_openid if user else None
|
||||
if not openid:
|
||||
raise WechatNotBoundError
|
||||
|
||||
auth = db.get(WechatTransferAuthorization, user_id)
|
||||
if auth is not None and auth.state == "active" and auth.authorization_id:
|
||||
return {
|
||||
"already_active": True, "package_info": None,
|
||||
"mch_id": settings.WXPAY_MCH_ID, "app_id": settings.WECHAT_APP_ID,
|
||||
}
|
||||
|
||||
out_auth_no = _ensure_pending_auth_no(db, user_id, openid)
|
||||
result = wxpay.apply_transfer_authorization(
|
||||
out_auth_no, openid, _auth_display_name(user), settings.WXPAY_AUTH_NOTIFY_URL
|
||||
)
|
||||
if result["status_code"] != 200:
|
||||
msg = str(result["data"].get("message") or result["data"])
|
||||
_settle_after_ambiguous(db, order, reason=msg)
|
||||
db.refresh(order)
|
||||
if order.status == "failed":
|
||||
raise WithdrawTransferError(msg)
|
||||
return order
|
||||
raise WithdrawTransferError(str(result["data"].get("message") or result["data"]))
|
||||
return {
|
||||
"already_active": False,
|
||||
"package_info": result["data"].get("package_info"),
|
||||
"mch_id": settings.WXPAY_MCH_ID,
|
||||
"app_id": settings.WECHAT_APP_ID,
|
||||
}
|
||||
|
||||
data = result["data"]
|
||||
|
||||
def close_transfer_auth(db: Session, user_id: int) -> None:
|
||||
"""关闭免确认收款(解除授权)。best-effort 调微信解除 + 本地置 closed。"""
|
||||
auth = db.get(WechatTransferAuthorization, user_id)
|
||||
if auth is None:
|
||||
return
|
||||
if auth.state != "closed" and settings.wxpay_configured:
|
||||
try:
|
||||
wxpay.close_transfer_authorization(auth.out_authorization_no)
|
||||
except Exception: # noqa: BLE001 — 微信解除失败不阻塞本地置 closed(用户也可在微信侧自行关闭)
|
||||
pass
|
||||
auth.state = "closed"
|
||||
db.commit()
|
||||
|
||||
|
||||
def _apply_transfer_result(db: Session, order: WithdrawOrder, data: dict) -> WithdrawOrder:
|
||||
"""落微信转账应答到提现单:记 state/转账单号/package_info,SUCCESS 即 success。"""
|
||||
order.wechat_state = data.get("state")
|
||||
order.transfer_bill_no = data.get("transfer_bill_no")
|
||||
order.package_info = data.get("package_info")
|
||||
order.package_info = data.get("package_info") # 免确认转账无此字段(None);确认模式带它供拉确认页
|
||||
if data.get("state") == _WX_STATE_SUCCESS:
|
||||
order.status = "success"
|
||||
db.commit()
|
||||
@@ -418,6 +574,112 @@ def create_withdraw(
|
||||
return order
|
||||
|
||||
|
||||
def execute_withdraw_transfer(db: Session, order: WithdrawOrder) -> WithdrawOrder:
|
||||
"""审核通过后真正发起微信转账(复用原"转账 + 模糊失败查单"逻辑,绝不盲目退款)。**不抛异常**。
|
||||
|
||||
免确认收款(用户授权免确认模式)分叉:
|
||||
① 已有生效授权(active)→ transfer_with_authorization 免确认转账,直接到账,无 package_info。
|
||||
② 无生效授权:
|
||||
- 已配 WXPAY_AUTH_NOTIFY_URL → pre_transfer_with_authorization(方式一):转账 + 顺带申请授权,
|
||||
返回 WAIT_USER_CONFIRM + package_info,用户在确认这笔收款时一并授权,下次起免确认。
|
||||
- 未配回调地址(未启用免确认)→ 退化为原 create_transfer 确认模式,行为同改造前。
|
||||
免确认转账失败 → 先 _settle_after_ambiguous 保金额安全,再 _refresh_active_auth 回查授权,失效则标 closed(下次回退方式一)。
|
||||
用户审核期间解绑微信 → 退款 failed。结果不明(超时/非200)→ 先查单再决定,绝不盲目退款。
|
||||
"""
|
||||
user = db.get(User, order.user_id)
|
||||
openid = user.wechat_openid if user else None
|
||||
if not openid:
|
||||
_refund_withdraw(db, order, reason="用户已解绑微信,无法打款")
|
||||
db.refresh(order)
|
||||
return order
|
||||
|
||||
# 先同步待确认授权:捕获首单确认后微信已生效的 authorization_id(pending→active),或失效(→closed)
|
||||
auth = sync_transfer_auth(db, order.user_id)
|
||||
|
||||
order.status = "pending" # 进入打款在途(转账调用前崩溃留 pending,交对账兜底)
|
||||
db.commit()
|
||||
|
||||
# ① 有生效授权 → 免确认转账(无需用户确认,直接到账)
|
||||
if auth is not None and auth.state == "active" and auth.authorization_id:
|
||||
try:
|
||||
result = wxpay.transfer_with_authorization(
|
||||
auth.authorization_id, order.amount_cents, order.out_bill_no, user_name=order.user_name
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — 超时/网络异常≠失败,查单确认
|
||||
_settle_after_ambiguous(db, order, reason=f"免确认转账调用异常: {e}")
|
||||
db.refresh(order)
|
||||
return order
|
||||
if result["status_code"] != 200:
|
||||
# 金额安全:查转账单后定夺,绝不盲退(未创建→退款,已创建→按真实状态)
|
||||
_settle_after_ambiguous(db, order, reason=str(result["data"].get("message") or result["data"]))
|
||||
# 授权有效性:回查授权单,微信侧已失效(用户关闭/风控)→标 closed,下次提现自动回退方式一重新授权
|
||||
_refresh_active_auth(db, order.user_id)
|
||||
db.refresh(order)
|
||||
return order
|
||||
return _apply_transfer_result(db, order, result["data"])
|
||||
|
||||
# ② 无生效授权 → 启用免确认则转账+顺带授权(方式一),否则退化为原确认模式
|
||||
try:
|
||||
if settings.WXPAY_AUTH_NOTIFY_URL:
|
||||
out_auth_no = _ensure_pending_auth_no(db, order.user_id, openid)
|
||||
result = wxpay.pre_transfer_with_authorization(
|
||||
openid, order.amount_cents, order.out_bill_no,
|
||||
out_authorization_no=out_auth_no,
|
||||
user_display_name=_auth_display_name(user),
|
||||
notify_url=settings.WXPAY_AUTH_NOTIFY_URL,
|
||||
user_name=order.user_name,
|
||||
)
|
||||
else:
|
||||
result = wxpay.create_transfer(
|
||||
openid, order.amount_cents, order.out_bill_no, user_name=order.user_name
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — 超时/网络异常≠失败,查单确认
|
||||
_settle_after_ambiguous(db, order, reason=f"转账调用异常: {e}")
|
||||
db.refresh(order)
|
||||
return order
|
||||
|
||||
if result["status_code"] != 200:
|
||||
_settle_after_ambiguous(db, order, reason=str(result["data"].get("message") or result["data"]))
|
||||
db.refresh(order)
|
||||
return order
|
||||
|
||||
return _apply_transfer_result(db, order, result["data"])
|
||||
|
||||
|
||||
def _get_withdraw_or_raise(db: Session, out_bill_no: str) -> WithdrawOrder:
|
||||
"""按 out_bill_no 取提现单(admin 跨用户,不限 user_id)。不存在抛 WithdrawOrderNotFound。"""
|
||||
order = db.execute(
|
||||
select(WithdrawOrder).where(WithdrawOrder.out_bill_no == out_bill_no)
|
||||
).scalar_one_or_none()
|
||||
if order is None:
|
||||
raise WithdrawOrderNotFound
|
||||
return order
|
||||
|
||||
|
||||
def approve_withdraw(db: Session, out_bill_no: str) -> WithdrawOrder:
|
||||
"""管理员审核通过:校验 reviewing → 发起微信转账。返回最终 order(pending/success/failed)。
|
||||
|
||||
幂等防误操作:非 reviewing(已通过/已拒绝/已打款)抛 WithdrawNotReviewable,不会重复打款。
|
||||
"""
|
||||
order = _get_withdraw_or_raise(db, out_bill_no)
|
||||
if order.status != "reviewing":
|
||||
raise WithdrawNotReviewable(f"提现单状态为 {order.status},非待审核,不能通过")
|
||||
return execute_withdraw_transfer(db, order)
|
||||
|
||||
|
||||
def reject_withdraw(db: Session, out_bill_no: str, reason: str) -> WithdrawOrder:
|
||||
"""管理员审核拒绝:校验 reviewing → 退回现金 + 置 rejected。
|
||||
|
||||
非 reviewing 抛 WithdrawNotReviewable(防对已打款单误退导致重复退款)。
|
||||
"""
|
||||
order = _get_withdraw_or_raise(db, out_bill_no)
|
||||
if order.status != "reviewing":
|
||||
raise WithdrawNotReviewable(f"提现单状态为 {order.status},非待审核,不能拒绝")
|
||||
_refund_withdraw(db, order, reason=reason or "审核未通过", final_status="rejected")
|
||||
db.refresh(order)
|
||||
return order
|
||||
|
||||
|
||||
def refresh_withdraw_status(
|
||||
db: Session, user_id: int, out_bill_no: str, *, cancel_if_unconfirmed: bool = False
|
||||
) -> WithdrawOrder:
|
||||
|
||||
@@ -37,6 +37,10 @@ class AdRewardStatusOut(BaseModel):
|
||||
description="本轮看完后 10 分钟冷却结束时间(UTC ISO),null 表示不在冷却。"
|
||||
"冷却是 UX 约束,后端发奖不受影响,客户端 CTA 据此显示 MM:SS 倒计时且不可点",
|
||||
)
|
||||
# ===== 看广告每日总时长(50 分钟防刷主闸):客户端据此展示"今日已看 X/50 分钟"、满则不给看 =====
|
||||
watched_seconds_today: int = Field(0, description="今日已观看激励视频总秒数(前端累计上报)")
|
||||
watch_seconds_limit: int = Field(0, description="每日观看总时长上限(秒,默认 3000=50 分钟)")
|
||||
watch_seconds_remaining: int = Field(0, description="今日剩余可观看秒数(<=0 时客户端不应再展示广告)")
|
||||
|
||||
|
||||
class EcpmReportIn(BaseModel):
|
||||
@@ -57,6 +61,23 @@ class EcpmReportOut(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
|
||||
class WatchReportIn(BaseModel):
|
||||
"""客户端上报一次激励视频的实际观看时长(onAdClose 时,onAdShow→onAdClose 墙钟秒数)。
|
||||
|
||||
user_id 由 JWT 取(Bearer),不在 body。seconds 服务端会夹 [0, MAX_SINGLE_WATCH_SECONDS]。
|
||||
"""
|
||||
|
||||
seconds: int = Field(..., ge=0, description="本次观看秒数")
|
||||
|
||||
|
||||
class WatchReportOut(BaseModel):
|
||||
"""上报后返回当日累计 + 剩余,客户端即时更新"今日还能看多久"。"""
|
||||
|
||||
watched_seconds_today: int = Field(..., description="今日累计观看秒数")
|
||||
watch_seconds_limit: int = Field(..., description="每日上限(秒)")
|
||||
watch_seconds_remaining: int = Field(..., description="今日剩余可观看秒数")
|
||||
|
||||
|
||||
class TestGrantOut(BaseModel):
|
||||
"""[仅本地联调]模拟发奖结果。带上今日进度,客户端可直接据此刷新展示。"""
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ class ComparisonResultIn(BaseModel):
|
||||
# 必须显式声明: 落库走 model_dump(), pydantic 默认丢未知字段, 不声明这行会被悄悄吞掉。
|
||||
# 各平台抠到红包即带值(2026-06 起源平台 Phase1 意图识别也抠, 当前仅淘宝源)。见 pricebot 侧 比价红包额留痕-实现方案.md。
|
||||
coupon_saved: float | None = None
|
||||
# 优惠**来源名**(展示用, best-effort): 美团"外卖大额神券"/京东"百亿补贴"/淘宝"平台红包"。
|
||||
# None=没抠到 → 前端走通用"红包"。同样必须显式声明否则上报边界被 pydantic 静默丢弃(pricebot#38 引入)。
|
||||
coupon_name: str | None = None
|
||||
|
||||
|
||||
class ComparisonRecordIn(BaseModel):
|
||||
|
||||
+33
-3
@@ -80,6 +80,23 @@ class WithdrawInfoOut(BaseModel):
|
||||
wechat_bound: bool = Field(..., description="当前用户是否已绑定微信")
|
||||
wechat_nickname: str | None = Field(None, description="微信昵称(可能为空/脱敏)")
|
||||
wechat_avatar_url: str | None = Field(None, description="微信头像 URL(可能为空)")
|
||||
transfer_auth_enabled: bool = Field(
|
||||
False, description="是否已开启免确认到账(开启后提现免跳微信确认,直接到账)"
|
||||
)
|
||||
|
||||
|
||||
# ===== 免确认收款授权(用户授权免确认模式)=====
|
||||
|
||||
class TransferAuthResultOut(BaseModel):
|
||||
already_active: bool = Field(False, description="是否已是开启状态(无需再授权)")
|
||||
package_info: str | None = Field(None, description="拉起微信授权页的 package(已开启时为空)")
|
||||
mch_id: str | None = None
|
||||
app_id: str | None = None
|
||||
|
||||
|
||||
class TransferAuthStatusOut(BaseModel):
|
||||
state: str = Field(..., description="none(未开启)/pending(待确认)/active(已开启)/closed(已关闭)")
|
||||
enabled: bool = Field(..., description="是否已开启免确认到账")
|
||||
|
||||
|
||||
class BindWechatRequest(BaseModel):
|
||||
@@ -102,11 +119,18 @@ class WithdrawRequest(BaseModel):
|
||||
out_bill_no: str | None = Field(
|
||||
None, description="客户端幂等键(商户单号):同号重试不重复转账。不传则服务端生成"
|
||||
)
|
||||
skip_review: bool = Field(
|
||||
False,
|
||||
description=(
|
||||
"调试直发:跳过人工审核立即打款。仅非生产(APP_ENV!=prod)生效,"
|
||||
"客户端仅 debug 包下发(开发设置开关)。生产恒走人工审核。"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class WithdrawResultOut(BaseModel):
|
||||
out_bill_no: str = Field(..., description="商户提现单号(查单用)")
|
||||
status: str = Field(..., description="pending / success / failed")
|
||||
status: str = Field(..., description="reviewing(待审核) / pending / success / failed / rejected")
|
||||
wechat_state: str | None = Field(None, description="微信侧原始状态")
|
||||
amount_cents: int
|
||||
cash_balance_cents: int = Field(..., description="提现后现金余额(分)")
|
||||
@@ -118,9 +142,14 @@ class WithdrawResultOut(BaseModel):
|
||||
|
||||
class WithdrawStatusOut(BaseModel):
|
||||
out_bill_no: str
|
||||
status: str = Field(..., description="pending / success / failed")
|
||||
status: str = Field(..., description="reviewing(待审核) / pending / success / failed / rejected")
|
||||
wechat_state: str | None = None
|
||||
amount_cents: int
|
||||
fail_reason: str | None = Field(None, description="failed/rejected 时的原因(用户可读)")
|
||||
# 审核通过进入打款、且微信要求用户确认(WAIT_USER_CONFIRM)时,带回这三项供 App 拉起微信确认页
|
||||
package_info: str | None = None
|
||||
mch_id: str | None = None
|
||||
app_id: str | None = None
|
||||
|
||||
|
||||
class WithdrawOrderOut(BaseModel):
|
||||
@@ -129,7 +158,7 @@ class WithdrawOrderOut(BaseModel):
|
||||
id: int
|
||||
out_bill_no: str
|
||||
amount_cents: int
|
||||
status: str
|
||||
status: str = Field(..., description="reviewing(待审核) / pending / success / failed / rejected")
|
||||
wechat_state: str | None = None
|
||||
fail_reason: str | None = None
|
||||
created_at: datetime
|
||||
@@ -194,6 +223,7 @@ class SavingsBattleOut(BaseModel):
|
||||
week_saved_cents: int = Field(..., description="本周已省(分)")
|
||||
beat_percent: int = Field(..., description="超过百分之多少用户")
|
||||
streak_days: int = Field(..., description="连续省钱天数")
|
||||
compare_count: int = Field(..., description="累计完成比价次数(= 比价上报记录数)")
|
||||
|
||||
|
||||
class SavingsRecordOut(BaseModel):
|
||||
|
||||
+21
-1
@@ -3,7 +3,7 @@
|
||||
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
|
||||
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
|
||||
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
|
||||
> 最后更新:2026-05-31(+ 比价战绩里程碑 12d/12e)
|
||||
> 最后更新:2026-06-04(+ 运营后台 Admin 子应用 A1–A18,见下方「运营后台 Admin」组)
|
||||
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
|
||||
|
||||
---
|
||||
@@ -69,6 +69,26 @@
|
||||
| **静态资源**(StaticFiles 挂载,见下方 `/media` 静态服务) |||
|
||||
| - | `GET /media/avatars/<file>` | 无 | 用户头像;返回二进制图片 |
|
||||
| - | `GET /media/feedback/<file>` | 无 | 反馈截图;返回二进制图片 |
|
||||
| **运营后台 Admin**(独立子应用 `app/admin/`,前缀 `/admin/api`,独立进程 + 独立 admin JWT。鉴权列:`admin`=任意已登录管理员,`operator`/`finance`/`super_admin`=需对应角色(`super_admin` 恒通过)) |||
|
||||
| A1 | `POST /admin/api/auth/login` | 无 | [详情](./admin-auth-login.md) |
|
||||
| A2 | `GET /admin/api/auth/me` | admin | [详情](./admin-auth-me.md) |
|
||||
| A3 | `GET /admin/api/stats/overview` | admin | [详情](./admin-stats-overview.md) |
|
||||
| A4 | `GET /admin/api/users` | admin | [详情](./admin-users-list.md) |
|
||||
| A5 | `GET /admin/api/users/{user_id}` | admin | [详情](./admin-user-detail.md) |
|
||||
| A6 | `POST /admin/api/users/{user_id}/status` | operator | [详情](./admin-user-status.md) |
|
||||
| A7 | `POST /admin/api/users/{user_id}/coins` | finance | [详情](./admin-user-coins.md) |
|
||||
| A8 | `GET /admin/api/wallet/coin-transactions` | admin | [详情](./admin-wallet-coin-transactions.md) |
|
||||
| A9 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin-wallet-cash-transactions.md) |
|
||||
| A10 | `GET /admin/api/withdraws` | admin | [详情](./admin-withdraws-list.md) |
|
||||
| A11 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin-withdraw-reconcile.md) |
|
||||
| A12 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin-withdraw-refresh.md) |
|
||||
| A13 | `GET /admin/api/feedbacks` | admin | [详情](./admin-feedbacks-list.md) |
|
||||
| A14 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin-feedback-handle.md) |
|
||||
| A15 | `GET /admin/api/admins` | super_admin | [详情](./admin-admins-list.md) |
|
||||
| A16 | `POST /admin/api/admins` | super_admin | [详情](./admin-admin-create.md) |
|
||||
| A17 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin-admin-update.md) |
|
||||
| A18 | `GET /admin/api/audit-logs` | admin | [详情](./admin-audit-logs.md) |
|
||||
| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
|
||||
|
||||
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
|
||||
> `coupon/step` 及外卖比价的 `intent/recognize` / `price/step` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见各接口详情)。
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# POST /admin/api/admins — 创建管理员
|
||||
|
||||
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins`) | 鉴权:Bearer admin_token(角色:super_admin) | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
**application/json**:
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `username` | string | ✓ | — | 账号,3–64 字 |
|
||||
| `password` | string | ✓ | — | 初始密码,8–72 字(bcrypt ≤72 字节) |
|
||||
| `role` | string | ✗ | `operator` | 角色,枚举:`super_admin` / `finance` / `operator` |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`AdminOut`(新建的管理员)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int | 管理员 id |
|
||||
| `username` | string | 账号 |
|
||||
| `role` | string | 角色 |
|
||||
| `status` | string | 状态(新建默认 `active`) |
|
||||
| `created_at` | datetime | 创建时间(UTC) |
|
||||
| `last_login_at` | datetime \| null | 上次登录时间(新建为 null) |
|
||||
|
||||
## 错误码
|
||||
- `401` 未带 admin token / token 无效或过期 / 管理员被禁用
|
||||
- `403` 角色不足(仅 super_admin)
|
||||
- `409` 用户名已存在
|
||||
- `422` 缺字段 / `username` 长度不在 3–64 / `password` 长度不在 8–72 / `role` 非法枚举
|
||||
|
||||
## 说明
|
||||
- 创建成功后写一条审计:`action=admin.create`、`target_type=admin`、`target_id=新管理员 id`、`detail={username, role}`。见 [admin_audit_log](../database/admin_audit_log.md)。
|
||||
- 数据表见 [admin_user](../database/admin_user.md)。
|
||||
@@ -0,0 +1,39 @@
|
||||
# PATCH /admin/api/admins/{admin_id} — 改角色/启停/重置密码
|
||||
|
||||
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins`) | 鉴权:Bearer admin_token(角色:super_admin) | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
**路径参数**:
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `admin_id` | int | ✓ | 目标管理员 id |
|
||||
|
||||
**application/json**(三字段都可选,只改传了的;至少传一个):
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `role` | string | ✗ | 改角色,枚举:`super_admin` / `finance` / `operator` |
|
||||
| `status` | string | ✗ | 启停,枚举:`active`(启用)/ `disabled`(禁用) |
|
||||
| `password` | string | ✗ | 重置密码,8–72 字(传则覆盖原密码) |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`AdminOut`(更新后的管理员)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int | 管理员 id |
|
||||
| `username` | string | 账号 |
|
||||
| `role` | string | 角色 |
|
||||
| `status` | string | 状态 |
|
||||
| `created_at` | datetime | 创建时间(UTC) |
|
||||
| `last_login_at` | datetime \| null | 上次登录时间 |
|
||||
|
||||
## 错误码
|
||||
- `400` 不能禁用自己(`admin_id == 当前 admin.id` 且 `status=disabled`) / 无任何变更字段(三字段全空)
|
||||
- `401` 未带 admin token / token 无效或过期 / 管理员被禁用
|
||||
- `403` 角色不足(仅 super_admin)
|
||||
- `404` 管理员不存在
|
||||
- `422` `role`/`status` 非法枚举 / `password` 长度不在 8–72
|
||||
|
||||
## 说明
|
||||
- 更新成功后写一条审计:`action=admin.update`、`target_type=admin`、`target_id=admin_id`、`detail` 为本次实际变更字段(如 `{"role": "...", "status": "...", "password": "reset"}`,密码只记 `reset` 不记明文)。见 [admin_audit_log](../database/admin_audit_log.md)。
|
||||
- 数据表见 [admin_user](../database/admin_user.md)。
|
||||
@@ -0,0 +1,26 @@
|
||||
# GET /admin/api/admins — 管理员列表
|
||||
|
||||
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins`) | 鉴权:Bearer admin_token(角色:super_admin) | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
无(按 `id` 升序返回全部,无分页)
|
||||
|
||||
## 出参
|
||||
响应 `200`:`AdminOut[]`
|
||||
|
||||
**AdminOut**
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int | 管理员 id |
|
||||
| `username` | string | 账号 |
|
||||
| `role` | string | 角色:`super_admin` / `finance` / `operator` |
|
||||
| `status` | string | 状态:`active` / `disabled` |
|
||||
| `created_at` | datetime | 创建时间(UTC) |
|
||||
| `last_login_at` | datetime \| null | 上次登录时间,从未登录为 null |
|
||||
|
||||
## 错误码
|
||||
- `401` 未带 admin token / token 无效或过期 / 管理员被禁用
|
||||
- `403` 角色不足(仅 super_admin 可访问,detail 形如 `role 'operator' not allowed (need one of [...])`)
|
||||
|
||||
## 说明
|
||||
账号管理整组(`/admin/api/admins`)的角色守卫为 `require_role()` 无参,即仅 `super_admin` 通过。数据表见 [admin_user](../database/admin_user.md)。
|
||||
@@ -0,0 +1,35 @@
|
||||
# GET /admin/api/audit-logs — 审计日志(谁改了什么,游标分页)
|
||||
|
||||
> 所属:Admin·Audit 组(前缀 `/admin/api/audit-logs`) | 鉴权:Bearer admin_token(角色:任意已登录 admin) | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参(query)
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `action` | string | ❌ | null | 按操作类型过滤,如 `admin.create` / `admin.update` |
|
||||
| `target_type` | string | ❌ | null | 按目标对象类型过滤,如 `admin` |
|
||||
| `admin_id` | int | ❌ | null | 按操作人(管理员 id)过滤 |
|
||||
| `limit` | int | ❌ | 50 | 1–100 |
|
||||
| `cursor` | int | ❌ | null | 上一页末条 `id`,首页不传 |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`{ items: AdminAuditLogOut[], next_cursor: int|null }`(按 `id` 倒序;分页见 [索引#游标分页约定](./README.md#游标分页约定))
|
||||
|
||||
**AdminAuditLogOut**
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int | 审计 id(也是游标) |
|
||||
| `admin_id` | int | 操作人管理员 id |
|
||||
| `admin_username` | string | 操作人账号(写入时快照) |
|
||||
| `action` | string | 操作类型,如 `admin.create` / `admin.update` |
|
||||
| `target_type` | string | 目标对象类型,如 `admin` |
|
||||
| `target_id` | string \| null | 目标对象 id(字符串) |
|
||||
| `detail` | object \| null | 操作详情(JSON,如变更字段) |
|
||||
| `ip` | string \| null | 操作来源 IP(取 XFF 首段或直连 IP,仅记录不鉴权) |
|
||||
| `created_at` | datetime | 操作时间(UTC) |
|
||||
|
||||
## 错误码
|
||||
- `401` 未带 admin token / token 无效或过期 / 管理员被禁用
|
||||
|
||||
## 说明
|
||||
- 整组(`/admin/api/audit-logs`)守卫为 `get_current_admin`,任意已登录 admin 均可查看,无角色限制。
|
||||
- 审计日志只增不改不删,任何写操作经 `write_audit` 落一条。数据表见 [admin_audit_log](../database/admin_audit_log.md)。
|
||||
@@ -0,0 +1,40 @@
|
||||
# POST /admin/api/auth/login — 管理员登录
|
||||
|
||||
> 所属:Admin·Auth 组(前缀 `/admin/api/auth`) | 鉴权:无 | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
**application/json**:
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `username` | string | ✓ | 管理员账号,1–64 字 |
|
||||
| `password` | string | ✓ | 密码,1–128 字 |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`AdminLoginResponse`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `access_token` | string | admin JWT(独立 `ADMIN_JWT_SECRET`、payload `typ=admin`、默认 12h、无 refresh) |
|
||||
| `token_type` | string | 固定 `Bearer` |
|
||||
| `expires_in` | int | access_token 剩余秒数(过期需重新登录) |
|
||||
| `admin` | AdminOut | 当前管理员信息,字段见下 |
|
||||
|
||||
**AdminOut**
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int | 管理员 id |
|
||||
| `username` | string | 账号 |
|
||||
| `role` | string | 角色:`super_admin` / `finance` / `operator` |
|
||||
| `status` | string | 状态:`active` / `disabled` |
|
||||
| `created_at` | datetime | 创建时间(UTC) |
|
||||
| `last_login_at` | datetime \| null | 上次登录时间,从未登录为 null |
|
||||
|
||||
## 错误码
|
||||
- `401` 用户名或密码错误(用户名不存在与密码错误统一同文案,防账号枚举)
|
||||
- `403` 账号已禁用(`status != active`)
|
||||
- `422` 缺 `username` 或 `password` / 长度超限
|
||||
- `429` 同 IP 每分钟登录超过 10 次(限流防爆破)
|
||||
|
||||
## 说明
|
||||
- 登录成功后回写 `last_login_at` 为当前时间。
|
||||
- 后续所有 admin 接口须带 `Authorization: Bearer <access_token>`;admin token 与 App 用户 token 完全隔离。
|
||||
@@ -0,0 +1,24 @@
|
||||
# GET /admin/api/auth/me — 当前管理员
|
||||
|
||||
> 所属:Admin·Auth 组(前缀 `/admin/api/auth`) | 鉴权:Bearer admin_token(角色:任意已登录 admin) | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
无(身份取自 Header token)
|
||||
|
||||
## 出参
|
||||
响应 `200`:`AdminOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int | 管理员 id |
|
||||
| `username` | string | 账号 |
|
||||
| `role` | string | 角色:`super_admin` / `finance` / `operator` |
|
||||
| `status` | string | 状态:`active` / `disabled` |
|
||||
| `created_at` | datetime | 创建时间(UTC) |
|
||||
| `last_login_at` | datetime \| null | 上次登录时间,从未登录为 null |
|
||||
|
||||
## 错误码
|
||||
- `401` 未带 admin token / token 无效或过期 / `typ` 非 admin / 管理员不存在或被禁用(响应头带 `WWW-Authenticate: Bearer`)
|
||||
|
||||
## 说明
|
||||
无
|
||||
@@ -0,0 +1,23 @@
|
||||
# POST /admin/api/feedbacks/{feedback_id}/handle — 标记反馈已处理
|
||||
|
||||
> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks`) | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过,`require_role("operator")`) | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
- 路径:`feedback_id`(int)
|
||||
- body:无
|
||||
|
||||
## 出参
|
||||
响应 `200`:`OkResponse` = `{ "ok": true }`
|
||||
|
||||
幂等说明:将该反馈 `status` 置为 `handled`(不校验原状态,重复调用结果一致)。
|
||||
|
||||
## 错误码
|
||||
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
|
||||
- `403` 角色不足(需 `operator` 或 `super_admin`)
|
||||
- `404` 反馈不存在(`detail: "反馈不存在"`)
|
||||
- `422` `feedback_id` 非合法 int
|
||||
|
||||
## 说明
|
||||
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action="feedback.handle"`、`target_type="feedback"`、`target_id=<feedback_id>`、`detail={"before": <原 status>, "after": "handled"}`、`ip=<客户端 IP>`。
|
||||
- 状态变更与审计写入在同一事务(`commit=False` 后统一 `db.commit()`)。
|
||||
- 关联表 [feedback](../database/feedback.md)。
|
||||
@@ -0,0 +1,34 @@
|
||||
# GET /admin/api/feedbacks — 反馈工单列表(游标分页)
|
||||
|
||||
> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 `require_role`,仅 `get_current_admin`) | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参(query)
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `status` | string | ❌ | null | 反馈状态,精确匹配:`new`(待处理) / `handled`(已处理);传空/不传则不筛 |
|
||||
| `user_id` | int | ❌ | null | 按提交用户 id 精确筛 |
|
||||
| `limit` | int | ❌ | 20 | 1–100 |
|
||||
| `cursor` | int | ❌ | null | 上一页 next_cursor(按 feedback id 倒序,查 `id < cursor`) |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`{ items: FeedbackOut[], next_cursor: int|null }`(`next_cursor=null` 表示末页)
|
||||
|
||||
**FeedbackOut**
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int | 反馈 id |
|
||||
| `user_id` | int | 提交用户 id |
|
||||
| `content` | string | 反馈内容 |
|
||||
| `contact` | string | 联系方式(微信/QQ/手机,便于回访) |
|
||||
| `images` | string[] \| null | 截图相对路径列表(如 `["/media/feedback/u1_ab12.jpg"]`),无图为 null |
|
||||
| `status` | string | 反馈状态:`new`(待处理) / `handled`(已处理) |
|
||||
| `created_at` | datetime | 提交时间(UTC) |
|
||||
|
||||
## 错误码
|
||||
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
|
||||
- `422` `limit` 超出 1–100 范围 / 字段类型不合法
|
||||
|
||||
## 说明
|
||||
- 游标分页约定:结果按 feedback `id` 倒序;`cursor` 传上一页返回的 `next_cursor`;`next_cursor=null` 即末页。
|
||||
- `status` / `user_id` 均为精确匹配,可叠加。
|
||||
- 关联表 [feedback](../database/feedback.md);截图为相对路径,经 `GET /media/feedback/<file>` 静态读。
|
||||
@@ -0,0 +1,69 @@
|
||||
# GET /admin/api/stats/overview — 大盘核心指标
|
||||
|
||||
> 所属:Admin·数据大盘 组(前缀 `/admin/api/stats`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
无
|
||||
|
||||
## 出参
|
||||
响应 `200`:`DashboardOverview`(全局只读聚合,六大块嵌套)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `users` | DashboardUsers | 用户指标 |
|
||||
| `coins` | DashboardCoins | 金币指标 |
|
||||
| `cash` | DashboardCash | 现金/提现指标 |
|
||||
| `comparison` | DashboardComparison | 比价指标 |
|
||||
| `feedback` | DashboardFeedback | 反馈指标 |
|
||||
| `cps` | DashboardCps | CPS 收入(P2 未接入) |
|
||||
|
||||
**DashboardUsers**
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `total` | int | 用户总数 |
|
||||
| `active` | int | 状态 `active` 数 |
|
||||
| `disabled` | int | 状态 `disabled` 数 |
|
||||
| `deleted` | int | 状态 `deleted` 数 |
|
||||
| `new_today` | int | 今日新增(按北京时区切天的 `created_at`) |
|
||||
| `dau` | int | 日活(今日有 `last_login_at`,北京时区切天) |
|
||||
|
||||
**DashboardCoins**
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `granted_total` | int | 累计发放金币(coin_transaction 中所有 `amount > 0` 之和;负数兑换/扣减不计) |
|
||||
|
||||
**DashboardCash**
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `withdraw_success_cents` | int | 提现成功累计金额(分,`status=success` 之和) |
|
||||
| `withdraw_pending_count` | int | 提现 `pending` 单数 |
|
||||
| `withdraw_success_count` | int | 提现 `success` 单数 |
|
||||
| `withdraw_failed_count` | int | 提现 `failed` 单数 |
|
||||
|
||||
**DashboardComparison**
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `total` | int | 比价记录总数 |
|
||||
| `success` | int | 比价成功数(`status=success`) |
|
||||
| `success_rate` | float | 成功率 = success/total,保留 4 位小数;total 为 0 时返回 `0.0` |
|
||||
|
||||
**DashboardFeedback**
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `new` | int | 待处理反馈数(`status=new`) |
|
||||
|
||||
**DashboardCps**
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `available` | bool | CPS 数据是否可用(当前固定 `false`) |
|
||||
| `note` | string | 说明文案(当前固定 `"CPS 转化数据未接入(P2)"`) |
|
||||
|
||||
## 错误码
|
||||
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
|
||||
|
||||
## 说明
|
||||
- 全部为全局只读聚合(count / sum / DAU / 成功率),不改任何数据。
|
||||
- `new_today` / `dau` 按**北京时区**(UTC+8)切天,其余金额/计数无时区概念。
|
||||
- 金额单位:`*_cents` 为分;金币(`granted_total`)为个数。
|
||||
- CPS 收入数据源未接入(referral-link 只换链接,转化/佣金未回收),`cps` 恒为 `{available:false, note:...}`,前端显示"待接入"。
|
||||
- 关联表:[user](../database/user.md) / [coin_transaction](../database/coin_transaction.md) / [withdraw_order](../database/withdraw_order.md) / [comparison_record](../database/comparison_record.md) / [feedback](../database/feedback.md)。
|
||||
@@ -0,0 +1,29 @@
|
||||
# POST /admin/api/users/{user_id}/coins — 手动增减金币(带审计)
|
||||
|
||||
> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
- 路径:`user_id`(int)
|
||||
- **application/json**:
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `amount` | int | ✓ | 金币变动(个数):正=增加,负=扣减;不可为 0 |
|
||||
| `reason` | string | ✓ | 操作原因,1–128 字(必填,入审计与流水备注) |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`OkResponse` = `{ "ok": true }`
|
||||
|
||||
## 错误码
|
||||
- `400` `amount == 0`(`detail: "amount 不能为 0"`);或负数扣减后余额会变负(`detail: "扣减后金币为负(当前余额 N)"`)
|
||||
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
|
||||
- `403` 角色不足(需 `finance` 或 `super_admin`)
|
||||
- `404` 用户不存在(`detail: "用户不存在"`)
|
||||
- `422` 缺 `amount`/`reason`、`reason` 长度不在 1–128、字段类型不合法 / `user_id` 非整数
|
||||
|
||||
## 说明
|
||||
- 金币 = 个数(非现金);本接口只动金币余额,不涉及现金(`*_cents`)。
|
||||
- 扣减保护:`amount < 0` 时若扣减后金币余额 < 0 直接拒绝(运营误操作保护)。
|
||||
- 金币变动写流水 [coin_transaction](../database/coin_transaction.md):`biz_type` 增加为 `admin_grant`、扣减为 `admin_deduct`,`remark = admin:<reason>`(截断至 128 字)。
|
||||
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.coins.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount, balance_after, reason}`,并记录操作 IP。
|
||||
- 金币变动 + 审计在同一事务原子提交(改钱必留痕)。
|
||||
- 关联用户表 [user](../database/user.md);金币账户 [coin_account](../database/coin_account.md);金币流水 [coin_transaction](../database/coin_transaction.md)。
|
||||
@@ -0,0 +1,43 @@
|
||||
# GET /admin/api/users/{user_id} — 用户 360 详情
|
||||
|
||||
> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
- 路径:`user_id`(int)
|
||||
|
||||
## 出参
|
||||
响应 `200`:`AdminUserOverview`(基础资料 + 钱包余额 + 各项 count;历史明细走各自分页接口)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `user` | AdminUserListItem | 用户基础资料,字段见下 |
|
||||
| `coin_balance` | int | 当前金币余额(个数);无金币账户时为 0 |
|
||||
| `cash_balance_cents` | int | 当前现金余额(分);无账户时为 0 |
|
||||
| `total_coin_earned` | int | 累计获得金币(个数);无账户时为 0 |
|
||||
| `comparison_total` | int | 比价记录总数 |
|
||||
| `comparison_success` | int | 比价成功数(`status=success`) |
|
||||
| `withdraw_total` | int | 提现单总数 |
|
||||
| `withdraw_success_cents` | int | 提现成功累计金额(分,`status=success` 之和) |
|
||||
| `feedback_total` | int | 反馈总数 |
|
||||
|
||||
**AdminUserListItem**(`user` 字段)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int | 用户 id |
|
||||
| `phone` | string | 手机号 |
|
||||
| `nickname` | string \| null | 昵称,可空 |
|
||||
| `register_channel` | string | 注册渠道 |
|
||||
| `status` | string | 用户状态:`active` / `disabled` / `deleted` |
|
||||
| `wechat_openid` | string \| null | 微信 openid,可空 |
|
||||
| `created_at` | datetime | 注册时间(UTC) |
|
||||
| `last_login_at` | datetime | 上次登录时间(UTC) |
|
||||
|
||||
## 错误码
|
||||
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
|
||||
- `404` 用户不存在(`detail: "用户不存在"`)
|
||||
- `422` `user_id` 非整数
|
||||
|
||||
## 说明
|
||||
- 金币三项(`coin_balance` / `cash_balance_cents` / `total_coin_earned`)读 [coin_account](../database/coin_account.md);从未发生金币动作(账户不存在)时统一返回 0。
|
||||
- 各 count 为聚合数,明细历史走带 `user_id` 过滤的分页接口(金币流水 / 现金流水 / 提现 / 比价 / 反馈)。
|
||||
- 关联用户表 [user](../database/user.md);金币账户 [coin_account](../database/coin_account.md);提现单 [withdraw_order](../database/withdraw_order.md);比价记录 [comparison_record](../database/comparison_record.md);反馈 [feedback](../database/feedback.md)。
|
||||
@@ -0,0 +1,25 @@
|
||||
# POST /admin/api/users/{user_id}/status — 封禁/解封用户
|
||||
|
||||
> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
- 路径:`user_id`(int)
|
||||
- **application/json**:
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `status` | string | ✓ | 目标状态,枚举 `active`(解封)/ `disabled`(封禁);注销 `deleted` 不走此接口 |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`OkResponse` = `{ "ok": true }`
|
||||
|
||||
## 错误码
|
||||
- `400` 已注销账号不可改状态(目标用户当前 `status == deleted`,`detail: "已注销账号不可改状态"`)
|
||||
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
|
||||
- `403` 角色不足(需 `operator` 或 `super_admin`)
|
||||
- `404` 用户不存在(`detail: "用户不存在"`)
|
||||
- `422` `status` 非 `active`/`disabled` 枚举 / 缺字段 / `user_id` 非整数
|
||||
|
||||
## 说明
|
||||
- 业务写(改用户状态)与审计写在同一事务原子提交:改了就有痕、有痕就真改了。
|
||||
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.status.set`,`target_type = user`,`target_id = user_id`,`detail = {before, after}`,并记录操作 IP。
|
||||
- 关联用户表 [user](../database/user.md)。
|
||||
@@ -0,0 +1,37 @@
|
||||
# GET /admin/api/users — 用户列表(筛选+分页)
|
||||
|
||||
> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参(query)
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `phone` | string | ❌ | null | 手机号**前缀**匹配(`phone LIKE '<值>%'`) |
|
||||
| `register_channel` | string | ❌ | null | 注册渠道,精确匹配 |
|
||||
| `status` | string | ❌ | null | 用户状态,精确匹配:`active` / `disabled` / `deleted` |
|
||||
| `limit` | int | ❌ | 20 | 1–100 |
|
||||
| `cursor` | int | ❌ | null | 上一页 next_cursor(按 user id 倒序,查 `id < cursor`) |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`{ items: AdminUserListItem[], next_cursor: int|null }`(`next_cursor=null` 表示末页)
|
||||
|
||||
**AdminUserListItem**
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int | 用户 id |
|
||||
| `phone` | string | 手机号 |
|
||||
| `nickname` | string \| null | 昵称,可空 |
|
||||
| `register_channel` | string | 注册渠道 |
|
||||
| `status` | string | 用户状态:`active` / `disabled` / `deleted` |
|
||||
| `wechat_openid` | string \| null | 微信 openid,可空 |
|
||||
| `created_at` | datetime | 注册时间(UTC) |
|
||||
| `last_login_at` | datetime | 上次登录时间(UTC) |
|
||||
|
||||
## 错误码
|
||||
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
|
||||
- `422` `limit` 超出 1–100 范围 / 字段类型不合法
|
||||
|
||||
## 说明
|
||||
- 游标分页约定:结果按 user `id` 倒序;`cursor` 传上一页返回的 `next_cursor`;`next_cursor=null` 即末页。
|
||||
- `phone` 为前缀匹配(`LIKE '<值>%'`),`register_channel` / `status` 为精确匹配;三者可叠加。
|
||||
- 关联用户表 [user](../database/user.md)。
|
||||
- 历史明细(金币流水、提现、比价、反馈等)不在本列表,走各自带 `user_id` 过滤的分页接口。
|
||||
@@ -0,0 +1,35 @@
|
||||
# GET /admin/api/wallet/cash-transactions — 现金流水(游标分页)
|
||||
|
||||
> 所属:Admin·钱包 组(前缀 `/admin/api/wallet`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,仅需 `get_current_admin`,无 `require_role`) | [← 返回 API 索引](./README.md)
|
||||
|
||||
跨用户查询全量现金流水,可按 `user_id` / `biz_type` 过滤。游标分页(`id` 倒序)。金额单位一律为**分**。
|
||||
|
||||
## 入参(query)
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `user_id` | int | ❌ | null | 按用户过滤;不传则查全量 |
|
||||
| `biz_type` | string | ❌ | null | 按业务类型过滤 |
|
||||
| `limit` | int | ❌ | 20 | 1–100 |
|
||||
| `cursor` | int | ❌ | null | 上一页 next_cursor(查 `id < cursor`) |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`{ items: CashTxnOut[], next_cursor: int|null }`(`next_cursor=null` 表示末页)
|
||||
|
||||
**CashTxnOut**
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int | 流水主键 |
|
||||
| `user_id` | int | 所属用户 |
|
||||
| `amount_cents` | int | 现金变动量,单位:**分**(正/负) |
|
||||
| `balance_after_cents` | int | 本次变动后现金余额,单位:**分** |
|
||||
| `biz_type` | string | 业务类型 |
|
||||
| `ref_id` | string \| null | 关联业务 ID |
|
||||
| `remark` | string \| null | 备注 |
|
||||
| `created_at` | datetime | 创建时间(ISO 8601 UTC) |
|
||||
|
||||
## 错误码
|
||||
- `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`)
|
||||
- `422` query 参数校验失败(如 `limit` 越界、类型错误)
|
||||
|
||||
## 说明
|
||||
关联 [cash_transaction](../database/cash_transaction.md)。所有金额字段单位为分。
|
||||
@@ -0,0 +1,35 @@
|
||||
# GET /admin/api/wallet/coin-transactions — 金币流水(游标分页)
|
||||
|
||||
> 所属:Admin·钱包 组(前缀 `/admin/api/wallet`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,仅需 `get_current_admin`,无 `require_role`) | [← 返回 API 索引](./README.md)
|
||||
|
||||
跨用户查询全量金币流水,可按 `user_id` / `biz_type` 过滤。游标分页(`id` 倒序)。
|
||||
|
||||
## 入参(query)
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `user_id` | int | ❌ | null | 按用户过滤;不传则查全量 |
|
||||
| `biz_type` | string | ❌ | null | 按业务类型过滤 |
|
||||
| `limit` | int | ❌ | 20 | 1–100 |
|
||||
| `cursor` | int | ❌ | null | 上一页 next_cursor(查 `id < cursor`) |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`{ items: CoinTxnOut[], next_cursor: int|null }`(`next_cursor=null` 表示末页)
|
||||
|
||||
**CoinTxnOut**
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int | 流水主键 |
|
||||
| `user_id` | int | 所属用户 |
|
||||
| `amount` | int | 金币变动量(正/负,单位:金币个数,非分) |
|
||||
| `balance_after` | int | 本次变动后金币余额 |
|
||||
| `biz_type` | string | 业务类型 |
|
||||
| `ref_id` | string \| null | 关联业务 ID |
|
||||
| `remark` | string \| null | 备注 |
|
||||
| `created_at` | datetime | 创建时间(ISO 8601 UTC) |
|
||||
|
||||
## 错误码
|
||||
- `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`)
|
||||
- `422` query 参数校验失败(如 `limit` 越界、类型错误)
|
||||
|
||||
## 说明
|
||||
关联 [coin_transaction](../database/coin_transaction.md)。金币为个数计量,非分。
|
||||
@@ -0,0 +1,37 @@
|
||||
# POST /admin/api/withdraws/reconcile — 批量对账(扫超时 pending 单)
|
||||
|
||||
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md)
|
||||
|
||||
扫描创建时间超过 `older_than_minutes` 分钟、仍为 `pending` 的提现单,逐单调微信查单并归一化(成功落 `success`;失败/已撤销则退款落 `failed`;查到 `WAIT_USER_CONFIRM` 视为用户放弃,撤单+退款)。用于解开"扣了款但转账没发起/没确认"的孤儿单。单笔失败不影响其余(内部 rollback 后继续,下轮再试)。
|
||||
|
||||
> 路由注册顺序上,静态路径 `/reconcile` 必须在路径参数 `/{out_bill_no}/refresh` 之前声明。
|
||||
|
||||
## 入参
|
||||
- 路径:无
|
||||
- query:
|
||||
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `older_than_minutes` | int | ❌ | 15 | 只扫创建时间早于「现在 − N 分钟」的 pending 单;`ge=0` |
|
||||
|
||||
- body:无
|
||||
|
||||
## 出参
|
||||
响应 `200`:`ReconcileResult`
|
||||
|
||||
**ReconcileResult**
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `checked` | int | 本次扫到的超时 pending 单数量 |
|
||||
| `resolved` | int | 其中被归一化为终态(success/failed)的数量 |
|
||||
|
||||
## 错误码
|
||||
- `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`)
|
||||
- `403` 角色不足(需 `finance` 或 `super_admin`)
|
||||
- `422` query 参数校验失败(如 `older_than_minutes < 0`)
|
||||
- `503` 微信支付未配置(捕获 `WxPayNotConfiguredError`,`detail="微信支付未配置"`)
|
||||
|
||||
## 说明
|
||||
- 调用底层 `app.repositories.wallet.reconcile_pending_withdraws`(内部逐单调微信查单/撤单/退款,各自 commit)。
|
||||
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md)(`action="withdraw.reconcile"`,`detail` 为 `{checked, resolved}`,含触发管理员与客户端 IP)。
|
||||
- 关联 [withdraw_order](../database/withdraw_order.md)。
|
||||
@@ -0,0 +1,39 @@
|
||||
# POST /admin/api/withdraws/{out_bill_no}/refresh — 单笔提现重试查单
|
||||
|
||||
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md)
|
||||
|
||||
对单笔提现单调微信查单并归一化:`SUCCESS`→`success`;`FAIL`/`CANCELLED`/`CLOSED`→退款+`failed`;查到 `WAIT_USER_CONFIRM` 视为用户放弃(`cancel_if_unconfirmed=True`),撤单+退款;`ACCEPTED`/`PROCESSING` 等仍在途则保持 `pending`。已是终态的单直接返回、不再查。
|
||||
|
||||
## 入参
|
||||
- 路径:`out_bill_no`(string) — 商户提现单号
|
||||
- body:无
|
||||
|
||||
## 出参
|
||||
响应 `200`:`WithdrawOrderOut`(归一化后的最新提现单)
|
||||
|
||||
**WithdrawOrderOut**
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int | 提现单主键 |
|
||||
| `user_id` | int | 所属用户 |
|
||||
| `out_bill_no` | string | 商户提现单号 |
|
||||
| `amount_cents` | int | 提现金额,单位:**分** |
|
||||
| `status` | string | 单状态(`pending` / `success` / `failed`) |
|
||||
| `wechat_state` | string \| null | 微信侧转账状态 |
|
||||
| `transfer_bill_no` | string \| null | 微信转账单号 |
|
||||
| `fail_reason` | string \| null | 失败/退款原因 |
|
||||
| `created_at` | datetime | 创建时间(ISO 8601 UTC) |
|
||||
| `updated_at` | datetime | 更新时间(ISO 8601 UTC) |
|
||||
|
||||
## 错误码
|
||||
- `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`)
|
||||
- `403` 角色不足(需 `finance` 或 `super_admin`)
|
||||
- `404` 提现单不存在(`detail="提现单不存在"`)
|
||||
- `503` 微信支付未配置(捕获 `WxPayNotConfiguredError`,`detail="微信支付未配置"`)
|
||||
|
||||
> 注:微信查单本身失败(非未配置)时不抛错、不改状态,单保持 `pending` 等下次重试,故无 `502`。
|
||||
|
||||
## 说明
|
||||
- 调用底层 `app.repositories.wallet.refresh_withdraw_status(..., cancel_if_unconfirmed=True)`(内部调微信查单/撤单/退款并 commit)。
|
||||
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md)(`action="withdraw.refresh"`,`target_id=out_bill_no`,`detail` 为 `{status, wechat_state}`,含触发管理员与客户端 IP)。
|
||||
- 关联 [withdraw_order](../database/withdraw_order.md)。金额单位为分。
|
||||
@@ -0,0 +1,37 @@
|
||||
# GET /admin/api/withdraws — 提现单列表(游标分页)
|
||||
|
||||
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,列表为只读,仅需 `get_current_admin`,无 `require_role`) | [← 返回 API 索引](./README.md)
|
||||
|
||||
跨用户查询全量提现单,可按 `user_id` / `status` 过滤。游标分页(`id` 倒序)。
|
||||
|
||||
## 入参(query)
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `user_id` | int | ❌ | null | 按用户过滤;不传则查全量 |
|
||||
| `status` | string | ❌ | null | 按状态过滤(如 `pending` / `success` / `failed`) |
|
||||
| `limit` | int | ❌ | 20 | 1–100 |
|
||||
| `cursor` | int | ❌ | null | 上一页 next_cursor(查 `id < cursor`) |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`{ items: WithdrawOrderOut[], next_cursor: int|null }`(`next_cursor=null` 表示末页)
|
||||
|
||||
**WithdrawOrderOut**
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int | 提现单主键 |
|
||||
| `user_id` | int | 所属用户 |
|
||||
| `out_bill_no` | string | 商户提现单号(业务主键,重试/对账以此为准) |
|
||||
| `amount_cents` | int | 提现金额,单位:**分** |
|
||||
| `status` | string | 单状态(`pending` / `success` / `failed`) |
|
||||
| `wechat_state` | string \| null | 微信侧转账状态(如 `ACCEPTED` / `PROCESSING` / `WAIT_USER_CONFIRM` / `SUCCESS`) |
|
||||
| `transfer_bill_no` | string \| null | 微信转账单号 |
|
||||
| `fail_reason` | string \| null | 失败/退款原因 |
|
||||
| `created_at` | datetime | 创建时间(ISO 8601 UTC) |
|
||||
| `updated_at` | datetime | 更新时间(ISO 8601 UTC) |
|
||||
|
||||
## 错误码
|
||||
- `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`)
|
||||
- `422` query 参数校验失败(如 `limit` 越界、类型错误)
|
||||
|
||||
## 说明
|
||||
关联 [withdraw_order](../database/withdraw_order.md)。金额单位为分。
|
||||
@@ -31,9 +31,10 @@
|
||||
|
||||
**Item**:`{ name: string, qty: int=1, specs: string[]\|null }`
|
||||
|
||||
**Result(comparison_results 元素)**:`{ platform_id, platform_name, package, price(元,float\|null), is_source(bool), rank(int\|null), coupon_saved(元,float\|null) }`
|
||||
**Result(comparison_results 元素)**:`{ platform_id, platform_name, package, price(元,float\|null), is_source(bool), rank(int\|null), coupon_saved(元,float\|null), coupon_name(string\|null) }`
|
||||
|
||||
- `coupon_saved`:该平台本单**平台主优惠额**(元)——美团红包 / 淘宝平台红包 / 京东优惠券·百亿补贴,**只取那一笔**,不含配送费减免/共减总额。仅外卖目标平台带值,源平台/没用为 `null`,前端记录页据此展示**「已优惠 ¥X」**(null 不展示)。
|
||||
- `coupon_name`:优惠**来源名**(展示用,best-effort)——美团「外卖大额神券」/ 京东「百亿补贴」/ 淘宝「平台红包」。仅在该行有 `coupon_saved` 时带;`null` 时前端走通用「红包」。非用户选中的具体券名(选中态不可读)。
|
||||
|
||||
## 服务端派生(客户端不用算)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
| `week_saved_cents` | int | 本周已省(分) |
|
||||
| `beat_percent` | int | 超过百分之多少用户 |
|
||||
| `streak_days` | int | 连续省钱天数 |
|
||||
| `compare_count` | int | 累计完成比价次数(= 比价上报记录数;有真实 `compare` 记录用真实数,否则 demo 兜底) |
|
||||
|
||||
## 说明
|
||||
「我的」页「省钱战绩」卡数据源。接口已通但无真实业务写入,实际多为 0。
|
||||
「我的」页「省钱战绩」卡数据源(三列:本周已省 / 完成比价 / 连续比价)。`compare_count` 与 `/summary` 的 `order_count` 同源(均 = 有效记录数),对应原型「完成比价(次)」列。接口已通但无真实业务写入时走 demo 兜底(seed 约 23 条)。
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。
|
||||
> ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。
|
||||
> 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`。
|
||||
> 最后更新:2026-05-31
|
||||
> 最后更新:2026-06-04(+ admin_user / admin_audit_log)
|
||||
|
||||
---
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
| `feedback` | 用户帮助与反馈 | `models/feedback.py` | 反馈 | [详情](./feedback.md) |
|
||||
| `comparison_record` | 比价记录(每次比价完整明细) | `models/comparison.py` | 比价记录 | [详情](./comparison_record.md) |
|
||||
| `comparison_milestone_claim` | 比价战绩里程碑领取记录 | `models/comparison_milestone.py` | 比价记录/福利 | [详情](./comparison_milestone_claim.md) |
|
||||
| `admin_user` | 运营后台管理员账号(独立鉴权) | `models/admin.py` | Admin 后台 | [详情](./admin_user.md) |
|
||||
| `admin_audit_log` | 运营后台操作审计日志(只追加) | `models/admin.py` | Admin 后台 | [详情](./admin_audit_log.md) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# admin_audit_log — 运营后台操作审计日志
|
||||
|
||||
> 模型 `app/models/admin.py` | 关联接口 [admin-audit-logs](../api/admin-audit-logs.md) | [← 表索引](./README.md)
|
||||
|
||||
每个**写操作**(改钱/改状态/处理反馈等)落一条,记录"谁在何时、对谁、做了什么、前后值"。仅追加、不可删,用于追溯。`admin_username` / `target_id` 冗余存字符串,即使关联对象被删/改名也能追溯。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `admin_id` | Integer | FK→admin_user.id, index, NOT NULL | 操作者 |
|
||||
| `admin_username` | String(64) | NOT NULL | 冗余操作者用户名(改名/禁用后仍可追溯) |
|
||||
| `action` | String(64) | index, NOT NULL | 操作类型,如 `user.coins.grant` / `user.status.set` / `withdraw.refresh` / `feedback.handle` |
|
||||
| `target_type` | String(32) | NOT NULL | 被操作对象类型,如 `user` / `withdraw` / `feedback` |
|
||||
| `target_id` | String(64) | nullable | 被操作对象 id(用字符串以兼容 `out_bill_no` 等非整型主键) |
|
||||
| `detail` | JSON | nullable | 上下文 + 前后值,如 `{"amount":1000,"reason":"...","before":{...},"after":{...}}` |
|
||||
| `ip` | String(64) | nullable | 操作者客户端 IP(取自 `X-Forwarded-For` 首段,仅记录不鉴权) |
|
||||
| `created_at` | DateTime(tz) | server_default now(), index, NOT NULL | 操作时间 |
|
||||
|
||||
## 索引与约束
|
||||
- PK: `id`
|
||||
- index: `ix_admin_audit_log_admin_id`(`admin_id`)、`ix_admin_audit_log_action`(`action`)、`ix_admin_audit_log_created_at`(`created_at`)
|
||||
|
||||
## 关系
|
||||
- `admin_id` → [`admin_user`](./admin_user.md).`id`(多对一)
|
||||
|
||||
## 说明
|
||||
- **JSON 列**:`detail` 用 `JSON().with_variant(JSONB(), "postgresql")` —— PG 上 JSONB,SQLite 退化为通用 JSON(同 `comparison_record.raw_payload`)。
|
||||
- **只追加**:无更新/删除接口,审计不可篡改。
|
||||
- **IP 可伪造**:`X-Forwarded-For` 可被客户端伪造,nginx 必须用 `proxy_set_header X-Forwarded-For $remote_addr` 覆盖;审计 IP 仅作记录、不参与鉴权。
|
||||
@@ -0,0 +1,28 @@
|
||||
# admin_user — 运营后台管理员账号
|
||||
|
||||
> 模型 `app/models/admin.py` | 关联接口 [admin-auth-login](../api/admin-auth-login.md) / [admin-admins-list](../api/admin-admins-list.md) | [← 表索引](./README.md)
|
||||
|
||||
运营后台的管理员账号,与 App 用户(`user` 表)**完全隔离**:走独立 JWT secret、独立鉴权链(见 `app/admin/`)。密码 bcrypt 存哈希,带角色做权限分级。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `username` | String(64) | unique, index, NOT NULL | 登录名 |
|
||||
| `password_hash` | String(255) | NOT NULL | bcrypt 哈希(明文不落库) |
|
||||
| `role` | String(20) | NOT NULL, default `operator` | `super_admin`(全权+管账号)/ `finance`(钱:提现+金币)/ `operator`(用户+反馈+大盘) |
|
||||
| `status` | String(20) | NOT NULL, default `active` | `active` / `disabled`(禁用后 token 立即失效) |
|
||||
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间 |
|
||||
| `last_login_at` | DateTime(tz) | nullable | 最近登录时间(登录成功时更新) |
|
||||
|
||||
## 索引与约束
|
||||
- PK: `id`
|
||||
- unique index: `ix_admin_user_username`(`username` 唯一)
|
||||
|
||||
## 关系
|
||||
- 被 [`admin_audit_log`](./admin_audit_log.md).`admin_id` 引用(一管理员多条审计)。
|
||||
|
||||
## 说明
|
||||
- **角色权限**:`super_admin` 恒通过所有角色守卫(`require_role`);`finance` 管钱(提现/金币),`operator` 管用户/反馈/大盘。具体守卫见各接口文档。
|
||||
- **鉴权隔离**:admin token `typ=admin` + 独立 `ADMIN_JWT_SECRET`(≠ App 的 `JWT_SECRET_KEY`),App 用户 token 无法当 admin 用。admin 无 refresh,过期(默认 12h)重新登录。
|
||||
- **初始化**:首个管理员用 `scripts/create_admin.py` 命令行创建(无自助注册接口)。
|
||||
@@ -29,7 +29,7 @@
|
||||
| `status` | String(16) | NOT NULL, default `success` | `success`(拿到有效对比)/ `failed`(出错/没采到目标价) |
|
||||
| `information` | String(256) | nullable | done 帧文案;成功=摘要,失败=具体原因(前端失败时当原因展示) |
|
||||
| `items` | JSON(PG: JSONB) | NOT NULL, default [] | 下单菜品 `[{name, qty, specs?}]` |
|
||||
| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved}]`;`coupon_saved`=该平台主优惠额(元,美团红包/淘宝平台红包/京东优惠券·百亿补贴,只取那一笔,不含配送费/共减总额),各平台抠到红包即带值(2026-06 起源平台 Phase1 意图识别也抠,当前仅淘宝源),没用/没抠到为 null,前端展示「已优惠 ¥X」 |
|
||||
| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved,coupon_name}]`;`coupon_saved`=该平台主优惠额(元,美团红包/淘宝平台红包/京东优惠券·百亿补贴,只取那一笔,不含配送费/共减总额),各平台抠到红包即带值(2026-06 起源平台 Phase1 意图识别也抠,当前仅淘宝源),没用/没抠到为 null,前端展示「已优惠 ¥X」;`coupon_name`=优惠来源名(展示用 best-effort,美团"外卖大额神券"/京东"百亿补贴"/淘宝"平台红包"),null 走前端通用"红包" |
|
||||
| `skipped_dish_names` | JSON(PG: JSONB) | NOT NULL, default [] | 被跳过的菜名 |
|
||||
| `raw_payload` | JSON(PG: JSONB) | nullable | 客户端原始上报(calibration + done.params 全量) |
|
||||
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
|
||||
|
||||
+44
-6
@@ -120,27 +120,65 @@ def test_callback_bad_sign(client) -> None:
|
||||
assert _coin_balance(client, token) == 0
|
||||
|
||||
|
||||
def test_daily_cap(client) -> None:
|
||||
"""达到每日上限后继续回调 → 受理但不发(capped),余额封顶。"""
|
||||
def test_daily_count_cap(client, monkeypatch) -> None:
|
||||
"""达到每日发奖**次数兜底上限**后继续回调 → 受理但不发(capped),余额封顶。
|
||||
|
||||
次数上限现为时长闸的兜底(默认 200),这里 monkeypatch 调小到 3 加速(不真跑 200 次);
|
||||
本用例不上报观看时长 → 时长主闸不触发,纯验次数兜底。
|
||||
"""
|
||||
# 次数兜底现走 app_config getter(rebase 合 main 的运营可配后);patch getter 调小到 3
|
||||
monkeypatch.setattr("app.core.rewards.get_ad_daily_limit", lambda db: 3)
|
||||
phone = "13800003004"
|
||||
token = _login(client, phone)
|
||||
uid = _user_id(phone)
|
||||
|
||||
for i in range(DAILY_AD_REWARD_LIMIT):
|
||||
for i in range(3):
|
||||
r = _callback(client, _signed(uid, f"trans_cap_{i}"))
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# 第 limit+1 次:capped,仍 is_valid(不让穿山甲重试),但不加币
|
||||
# 第 4 次:capped,仍 is_verify(不让穿山甲重试),但不加币
|
||||
r = _callback(client, _signed(uid, "trans_cap_over"))
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"is_verify": True, "reason": 0}
|
||||
|
||||
assert _coin_balance(client, token) == DAILY_AD_REWARD_LIMIT * AD_REWARD_COIN
|
||||
assert _coin_balance(client, token) == 3 * AD_REWARD_COIN
|
||||
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
|
||||
assert st["used_today"] == DAILY_AD_REWARD_LIMIT
|
||||
assert st["used_today"] == 3
|
||||
assert st["remaining"] == 0
|
||||
|
||||
|
||||
def test_daily_watch_time_cap(client, monkeypatch) -> None:
|
||||
"""看广告累计观看时长达每日上限(主闸,这里调小到 100s)后,再回调发奖 → capped 不发金币。
|
||||
|
||||
时长由前端 watch-report 上报累计;到顶后 ① watch-report/reward-status remaining=0(客户端不再展示),
|
||||
② 后端发奖也因时长闸记 capped(双闸,前端绕不过)。次数兜底不动它(此时只看了 1 次,远没到次数上限)。
|
||||
"""
|
||||
# 时长上限两处引用都要 patch:api 用 rewards.X、grant 闸/today_status 用 ad_reward 内绑定的 X
|
||||
monkeypatch.setattr("app.core.rewards.DAILY_AD_WATCH_SECONDS_LIMIT", 100)
|
||||
monkeypatch.setattr("app.repositories.ad_reward.DAILY_AD_WATCH_SECONDS_LIMIT", 100)
|
||||
phone = "13800003201"
|
||||
token = _login(client, phone)
|
||||
uid = _user_id(phone)
|
||||
|
||||
# 上报一次 100s 观看 → 当日累计达上限,remaining=0
|
||||
r = client.post("/api/v1/ad/watch-report", json={"seconds": 100}, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["watched_seconds_today"] == 100
|
||||
assert r.json()["watch_seconds_remaining"] == 0
|
||||
|
||||
# 此时回调发奖 → 时长闸命中 → capped(is_verify 仍 true,不让重试),不加金币
|
||||
before = _coin_balance(client, token)
|
||||
r = _callback(client, _signed(uid, "trans_time_cap"))
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"is_verify": True, "reason": 0}
|
||||
assert _coin_balance(client, token) == before # 未加币
|
||||
|
||||
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
|
||||
assert st["watched_seconds_today"] == 100
|
||||
assert st["watch_seconds_limit"] == 100
|
||||
assert st["watch_seconds_remaining"] == 0
|
||||
|
||||
|
||||
def test_callback_unknown_user(client) -> None:
|
||||
"""验签过但 user_id 不存在 → 不发奖(is_verify false + reason),不崩。"""
|
||||
params = _signed(999999, "trans_ghost")
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Admin M5 配置后台化测试:列出/改配置 + 改配**真生效** + 校验 + 审计。
|
||||
|
||||
autouse 清理每个用例后清空 app_config,避免改配污染其他文件的福利测试。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.admin.main import admin_app
|
||||
from app.admin.repositories import admin_user as admin_repo
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.admin import AdminAuditLog
|
||||
from app.models.app_config import AppConfig
|
||||
from app.repositories import ad_reward, signin
|
||||
from app.repositories import user as user_repo
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin_client() -> TestClient:
|
||||
return TestClient(admin_app)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def token() -> str:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if admin_repo.get_by_username(db, "cfg_admin") is None:
|
||||
admin_repo.create_admin(
|
||||
db, username="cfg_admin", password="cfgpass12", role="super_admin"
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
c = TestClient(admin_app)
|
||||
return c.post(
|
||||
"/admin/api/auth/login", json={"username": "cfg_admin", "password": "cfgpass12"}
|
||||
).json()["access_token"]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_config() -> Iterator[None]:
|
||||
"""每个用例后清空 app_config,避免改配污染其他文件的福利测试(它们假设默认值)。"""
|
||||
yield
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.execute(delete(AppConfig))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _auth(t: str) -> dict:
|
||||
return {"Authorization": f"Bearer {t}"}
|
||||
|
||||
|
||||
def _seed_user(phone: str) -> int:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return user_repo.upsert_user_for_login(db, phone=phone, register_channel="sms").id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_list_config(admin_client: TestClient, token: str) -> None:
|
||||
r = admin_client.get("/admin/api/config", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
items = {i["key"]: i for i in r.json()}
|
||||
assert "signin_rewards" in items and "ad_daily_limit" in items
|
||||
assert items["signin_rewards"]["value"] == [10, 20, 30, 50, 80, 120, 200]
|
||||
assert items["signin_rewards"]["overridden"] is False
|
||||
|
||||
|
||||
def test_update_signin_takes_effect(admin_client: TestClient, token: str) -> None:
|
||||
r = admin_client.patch(
|
||||
"/admin/api/config/signin_rewards",
|
||||
json={"value": [100, 200, 300, 400, 500, 600, 700]},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["value"][0] == 100 and r.json()["overridden"] is True
|
||||
|
||||
# 业务真的用上新值(配置后台化的核心验证)
|
||||
uid = _seed_user("13912340001")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
st = signin.get_status(db, uid)
|
||||
assert st.steps[0].coin == 100
|
||||
assert st.today_coin == 100 # 首签=第 1 天=100(新值)
|
||||
logs = db.execute(
|
||||
select(AdminAuditLog).where(
|
||||
AdminAuditLog.action == "config.set",
|
||||
AdminAuditLog.target_id == "signin_rewards",
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(logs) == 1 and logs[0].detail["after"][0] == 100
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_update_ad_limit_takes_effect(admin_client: TestClient, token: str) -> None:
|
||||
r = admin_client.patch(
|
||||
"/admin/api/config/ad_daily_limit", json={"value": 5}, headers=_auth(token)
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
uid = _seed_user("13912340002")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# today_status 现返 7 元组(末两位为观看时长闸:已看秒数 / 上限),取次数上限位
|
||||
_used, limit, _coin, _rc, _cd, _ws, _wl = ad_reward.today_status(db, uid)
|
||||
assert limit == 5 # 新配置生效
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_config_validation(admin_client: TestClient, token: str) -> None:
|
||||
# 签到档位长度≠7
|
||||
assert admin_client.patch(
|
||||
"/admin/api/config/signin_rewards", json={"value": [1, 2, 3]}, headers=_auth(token)
|
||||
).status_code == 400
|
||||
# int 负数
|
||||
assert admin_client.patch(
|
||||
"/admin/api/config/ad_daily_limit", json={"value": -5}, headers=_auth(token)
|
||||
).status_code == 400
|
||||
# 未知 key
|
||||
assert admin_client.patch(
|
||||
"/admin/api/config/nope", json={"value": 1}, headers=_auth(token)
|
||||
).status_code == 404
|
||||
@@ -33,7 +33,8 @@ def _food_payload(trace_id: str) -> dict:
|
||||
],
|
||||
"comparison_results": [
|
||||
{"platform_id": "meituan", "platform_name": "美团", "package": "com.sankuai.meituan",
|
||||
"price": 123.50, "is_source": False, "rank": 1, "coupon_saved": 7.0},
|
||||
"price": 123.50, "is_source": False, "rank": 1,
|
||||
"coupon_saved": 7.0, "coupon_name": "外卖大额神券"},
|
||||
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购", "package": "com.taobao.taobao",
|
||||
"price": 128.50, "is_source": True, "rank": 2},
|
||||
{"platform_id": "jd_waimai", "platform_name": "京东外卖", "package": "com.jingdong.app.mall",
|
||||
@@ -79,7 +80,7 @@ def test_report_and_derive(client) -> None:
|
||||
|
||||
|
||||
def test_coupon_saved_passthrough(client) -> None:
|
||||
"""红包优惠额 coupon_saved 上报→落库→读出;仅目标平台带值,源平台不带(null)。"""
|
||||
"""红包优惠额 coupon_saved + 来源名 coupon_name 上报→落库→读出;仅目标平台带值,源平台不带(null)。"""
|
||||
token = _login(client, "13800002010")
|
||||
rid = client.post(
|
||||
"/api/v1/compare/record", json=_food_payload("trace-coupon"), headers=_auth(token)
|
||||
@@ -87,7 +88,9 @@ def test_coupon_saved_passthrough(client) -> None:
|
||||
d = client.get(f"/api/v1/compare/records/{rid}", headers=_auth(token)).json()
|
||||
by_pid = {x["platform_id"]: x for x in d["comparison_results"]}
|
||||
assert by_pid["meituan"]["coupon_saved"] == 7.0 # 目标平台带值
|
||||
assert by_pid["meituan"]["coupon_name"] == "外卖大额神券" # 来源名透传(pricebot#38)
|
||||
assert by_pid["taobao_flash"].get("coupon_saved") is None # 源平台不带/为 null
|
||||
assert by_pid["taobao_flash"].get("coupon_name") is None # 源平台无来源名
|
||||
|
||||
|
||||
def test_source_is_cheapest_no_saving(client) -> None:
|
||||
|
||||
+53
-6
@@ -133,10 +133,10 @@ def test_exchange_info(client) -> None:
|
||||
|
||||
|
||||
def test_exchange_flow(client) -> None:
|
||||
"""先供款够兑换下限的金币 → 兑换 1 元 → 金币扣、现金加 → 现金流水有记录。
|
||||
"""先供款够兑 1 元的金币 → 兑换 1 元 → 金币扣、现金加 → 现金流水有记录。
|
||||
|
||||
打开消息提醒任务已降到 1000 金币(不再 = 兑换下限), 不能再靠领任务供款;
|
||||
直接 grant_coins 注入 MIN_EXCHANGE_COIN(= COIN_PER_YUAN = 10000)当种子。
|
||||
兑换下限已降到 1 分(MIN_EXCHANGE_COIN = COIN_PER_CENT = 100), 但本用例验证兑换 1 元,
|
||||
直接 grant_coins 注入 COIN_PER_YUAN(= 10000)当种子(够兑 1 元)。
|
||||
"""
|
||||
phone = "13800001005"
|
||||
token = _login(client, phone)
|
||||
@@ -144,7 +144,7 @@ def test_exchange_flow(client) -> None:
|
||||
with SessionLocal() as db:
|
||||
user = get_user_by_phone(db, phone)
|
||||
assert user is not None
|
||||
crud_wallet.grant_coins(db, user.id, MIN_EXCHANGE_COIN, biz_type="test_seed", remark="测试供款")
|
||||
crud_wallet.grant_coins(db, user.id, COIN_PER_YUAN, biz_type="test_seed", remark="测试供款")
|
||||
db.commit()
|
||||
|
||||
# 兑换 10000 金币 → 100 分
|
||||
@@ -180,6 +180,51 @@ def test_exchange_flow(client) -> None:
|
||||
assert page["items"][0]["biz_type"] == "exchange_in"
|
||||
|
||||
|
||||
def test_exchange_min_floor_one_cent(client) -> None:
|
||||
"""锁定本次下调的兑换下限:正好兑下限额(MIN_EXCHANGE_COIN=100 金币=1 分)应成功。
|
||||
|
||||
这是本 PR 的核心新能力(下限 10000→100,可兑 1 分起)。test_exchange_flow 兑的是
|
||||
1 元(10000),在旧下限下也通过,证明不了新下限;本用例供款并兑换正好等于下限的
|
||||
100 金币,断言到账 1 分。若有人把 MIN_EXCHANGE_COIN 改回 10000,100 会因低于下限被
|
||||
判 400,本用例随之失败,从而把新下限值钉死。
|
||||
"""
|
||||
phone = "13800001010"
|
||||
token = _login(client, phone)
|
||||
# 供款: 正好注入一个下限额度的金币(=COIN_PER_CENT=100)
|
||||
with SessionLocal() as db:
|
||||
user = get_user_by_phone(db, phone)
|
||||
assert user is not None
|
||||
crud_wallet.grant_coins(
|
||||
db, user.id, MIN_EXCHANGE_COIN, biz_type="test_seed", remark="测试供款"
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# 兑换下限额 100 金币 → 1 分
|
||||
r = client.post(
|
||||
"/api/v1/wallet/exchange",
|
||||
json={"coin_amount": MIN_EXCHANGE_COIN},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
res = r.json()
|
||||
assert res["coin_amount"] == MIN_EXCHANGE_COIN
|
||||
assert res["cash_added_cents"] == 1 # coins_to_cents(100) == 1
|
||||
assert res["coin_balance"] == 0
|
||||
assert res["cash_balance_cents"] == 1
|
||||
|
||||
# 金币流水有一笔 exchange_out(=-100)
|
||||
r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token))
|
||||
out_txn = next(t for t in r.json()["items"] if t["biz_type"] == "exchange_out")
|
||||
assert out_txn["amount"] == -MIN_EXCHANGE_COIN
|
||||
|
||||
# 现金流水有且仅有一笔 exchange_in(=+1 分)
|
||||
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
|
||||
cash = r.json()["items"]
|
||||
assert len(cash) == 1
|
||||
assert cash[0]["amount_cents"] == 1
|
||||
assert cash[0]["biz_type"] == "exchange_in"
|
||||
|
||||
|
||||
def test_exchange_insufficient_and_invalid(client) -> None:
|
||||
token = _login(client, "13800001006")
|
||||
|
||||
@@ -191,10 +236,10 @@ def test_exchange_insufficient_and_invalid(client) -> None:
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
# 低于最小额 → 400
|
||||
# 低于最小额(下限现为 1 分=100;取 MIN_EXCHANGE_COIN-1=99,既低于下限又非整分)→ 400
|
||||
r = client.post(
|
||||
"/api/v1/wallet/exchange",
|
||||
json={"coin_amount": MIN_EXCHANGE_COIN - COIN_PER_CENT},
|
||||
json={"coin_amount": MIN_EXCHANGE_COIN - 1},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
@@ -226,6 +271,8 @@ def test_savings_summary_and_battle(client) -> None:
|
||||
assert b["streak_days"] >= 1
|
||||
assert b["week_saved_cents"] >= 0
|
||||
assert 0 <= b["beat_percent"] <= 100
|
||||
# 完成比价次数与 summary 的 order_count 同源(均 = 有效记录数)
|
||||
assert b["compare_count"] == s["order_count"]
|
||||
|
||||
|
||||
def test_savings_seeder_idempotent(client) -> None:
|
||||
|
||||
+79
-10
@@ -10,6 +10,7 @@ from sqlalchemy import select
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CoinAccount, WithdrawOrder
|
||||
from app.repositories import wallet as crud_wallet
|
||||
|
||||
|
||||
def _login(client, phone: str) -> str:
|
||||
@@ -43,6 +44,24 @@ def _patch_userinfo(monkeypatch, openid="openid_test_abc", nickname="测试昵
|
||||
)
|
||||
|
||||
|
||||
def _approve(bill: str) -> None:
|
||||
"""模拟管理员审核通过(走 repo,等价 admin 端点 approve → execute_withdraw_transfer 发起转账)。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
crud_wallet.approve_withdraw(db, bill)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _reject(bill: str, reason: str = "不符合提现规则") -> None:
|
||||
"""模拟管理员审核拒绝(走 repo,等价 admin 端点 reject → 退款 + rejected)。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
crud_wallet.reject_withdraw(db, bill, reason)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_bind_wechat(client, monkeypatch) -> None:
|
||||
_patch_userinfo(monkeypatch)
|
||||
token = _login(client, "13800002001")
|
||||
@@ -64,7 +83,8 @@ def test_bind_wechat(client, monkeypatch) -> None:
|
||||
assert j["wechat_nickname"] == "测试昵称"
|
||||
|
||||
|
||||
def test_withdraw_success_flow(client, monkeypatch) -> None:
|
||||
def test_withdraw_submit_then_approve_success_flow(client, monkeypatch) -> None:
|
||||
"""提现先进 reviewing(扣款不打款) → 管理员审核通过才发起转账 → 查单 SUCCESS → success。"""
|
||||
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_ok", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.create_transfer",
|
||||
@@ -77,13 +97,12 @@ def test_withdraw_success_flow(client, monkeypatch) -> None:
|
||||
_seed_cash(client, token, "13800002002", 100)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
|
||||
# 发起提现 50 分
|
||||
# 发起提现 50 分 → 进入待审核(reviewing),已扣款但未打款,无 package_info
|
||||
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["status"] == "pending"
|
||||
assert body["package_info"] == "pkg123"
|
||||
assert body["mch_id"] and body["app_id"]
|
||||
assert body["status"] == "reviewing"
|
||||
assert body["package_info"] is None
|
||||
assert body["cash_balance_cents"] == 50 # 已扣
|
||||
bill = body["out_bill_no"]
|
||||
|
||||
@@ -92,7 +111,10 @@ def test_withdraw_success_flow(client, monkeypatch) -> None:
|
||||
txns = r.json()["items"]
|
||||
assert any(t["biz_type"] == "withdraw" and t["amount_cents"] == -50 for t in txns)
|
||||
|
||||
# 查单 → 微信返回 SUCCESS → 归一化 success
|
||||
# 管理员审核通过 → 发起微信转账(mock WAIT_USER_CONFIRM,单进 pending)
|
||||
_approve(bill)
|
||||
|
||||
# 用户确认到账后查单 → SUCCESS → 归一化 success
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.query_transfer",
|
||||
lambda out_bill_no: {"status_code": 200, "data": {"state": "SUCCESS"}},
|
||||
@@ -123,8 +145,12 @@ def test_withdraw_abandoned_confirm_cancels_and_refunds(client, monkeypatch) ->
|
||||
|
||||
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
|
||||
bill = r.json()["out_bill_no"]
|
||||
assert r.json()["status"] == "reviewing"
|
||||
assert r.json()["cash_balance_cents"] == 50 # 已扣
|
||||
|
||||
# 管理员审核通过 → 发起转账(mock WAIT_USER_CONFIRM,单进 pending)
|
||||
_approve(bill)
|
||||
|
||||
# 回到 app 查单:微信仍是 WAIT_USER_CONFIRM(没确认) + 撤单成功
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.query_transfer",
|
||||
@@ -166,7 +192,8 @@ def test_withdraw_not_bound(client) -> None:
|
||||
assert r.status_code == 400, r.text
|
||||
|
||||
|
||||
def test_withdraw_transfer_fail_refunds(client, monkeypatch) -> None:
|
||||
def test_withdraw_approve_transfer_fail_refunds(client, monkeypatch) -> None:
|
||||
"""审核通过发起转账失败(非200) + 查单 NOT_FOUND(未创建) → 自动退款 + 单 failed。"""
|
||||
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_fail", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.create_transfer",
|
||||
@@ -184,8 +211,14 @@ def test_withdraw_transfer_fail_refunds(client, monkeypatch) -> None:
|
||||
_seed_cash(client, token, "13800002005", 100)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
|
||||
# 发起提现 → reviewing(已扣款),此刻不打款不会失败
|
||||
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
|
||||
assert r.status_code == 502, r.text
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "reviewing"
|
||||
bill = r.json()["out_bill_no"]
|
||||
|
||||
# 管理员审核通过 → 转账失败 → 自动退款 + 单 failed
|
||||
_approve(bill)
|
||||
|
||||
# 余额已退回
|
||||
r = client.get("/api/v1/wallet/account", headers=_auth(token))
|
||||
@@ -222,7 +255,9 @@ def test_withdraw_idempotent_same_bill_no(client, monkeypatch) -> None:
|
||||
r2 = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50, "out_bill_no": bill}, headers=_auth(token))
|
||||
assert r2.status_code == 200, r2.text
|
||||
|
||||
assert calls["n"] == 1 # 只真发起一次转账
|
||||
# 提现阶段不打款 → create_transfer 一次都不会被调;两次都返回同一张待审核单
|
||||
assert calls["n"] == 0
|
||||
assert r1.json()["status"] == "reviewing" and r2.json()["status"] == "reviewing"
|
||||
assert r1.json()["out_bill_no"] == bill
|
||||
assert r2.json()["out_bill_no"] == bill
|
||||
# 余额只扣一次(100-50=50)
|
||||
@@ -249,7 +284,13 @@ def test_withdraw_ambiguous_timeout_then_success_no_refund(client, monkeypatch)
|
||||
|
||||
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "success"
|
||||
assert r.json()["status"] == "reviewing"
|
||||
bill = r.json()["out_bill_no"]
|
||||
|
||||
# 审核通过 → 转账调用超时(异常) → 查单确认 SUCCESS → 不退款,单 success
|
||||
_approve(bill)
|
||||
r = client.get("/api/v1/wallet/withdraw-orders", headers=_auth(token))
|
||||
assert r.json()["items"][0]["status"] == "success"
|
||||
# 没退款:余额仍是扣后的 50,且无 withdraw_refund
|
||||
r = client.get("/api/v1/wallet/account", headers=_auth(token))
|
||||
assert r.json()["cash_balance_cents"] == 50
|
||||
@@ -269,3 +310,31 @@ def test_bind_rejects_openid_already_bound(client, monkeypatch) -> None:
|
||||
token_b = _login(client, "13800002010")
|
||||
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "cb"}, headers=_auth(token_b))
|
||||
assert r.status_code == 409, r.text
|
||||
|
||||
|
||||
def test_withdraw_reject_refunds(client, monkeypatch) -> None:
|
||||
"""管理员审核拒绝 → 退回现金 + 单 rejected + 理由写入 fail_reason(用户可见)。"""
|
||||
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_reject", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
token = _login(client, "13800002011")
|
||||
_seed_cash(client, token, "13800002011", 100)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
|
||||
# 发起提现 → reviewing(已扣款,余额 50)
|
||||
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
|
||||
assert r.json()["status"] == "reviewing"
|
||||
assert r.json()["cash_balance_cents"] == 50
|
||||
bill = r.json()["out_bill_no"]
|
||||
|
||||
# 管理员拒绝 → 退款 + rejected(不调微信,无需 mock 转账)
|
||||
_reject(bill, "测试拒绝")
|
||||
|
||||
# 余额退回 100
|
||||
r = client.get("/api/v1/wallet/account", headers=_auth(token))
|
||||
assert r.json()["cash_balance_cents"] == 100
|
||||
# 有退款流水
|
||||
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
|
||||
assert any(t["biz_type"] == "withdraw_refund" and t["amount_cents"] == 50 for t in r.json()["items"])
|
||||
# 单 rejected + 理由透出
|
||||
r = client.get("/api/v1/wallet/withdraw/status", params={"out_bill_no": bill}, headers=_auth(token))
|
||||
assert r.json()["status"] == "rejected"
|
||||
assert r.json()["fail_reason"] == "测试拒绝"
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
"""美团 CPS API Playground —— 本地调参/试 API 的可视化工具。
|
||||
|
||||
浏览器填参数 → 本地后端用 meituan.py 的同款签名打美团 → 返回结果,
|
||||
页面渲染「卡片列表 + 可折叠 JSON 树」。AppSecret 只留在后端,浏览器不接触。
|
||||
|
||||
跑法:
|
||||
/Users/pure/miniconda3/envs/price/bin/python tools/meituan_playground.py
|
||||
然后开 http://127.0.0.1:8799
|
||||
|
||||
只读工具(只调 query 类接口),复用本仓库 .env 里的 MT_CPS 凭证。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
# 让 `from app...` 可导入 + pydantic-settings 从仓库根读 .env
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(HERE)
|
||||
os.chdir(ROOT)
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
import httpx # noqa: E402
|
||||
from fastapi import FastAPI, Request # noqa: E402
|
||||
from fastapi.responses import HTMLResponse, JSONResponse # noqa: E402
|
||||
|
||||
from app.core.config import settings # noqa: E402
|
||||
from app.integrations.meituan import _content_md5, _sign # noqa: E402
|
||||
|
||||
app = FastAPI(title="Meituan CPS Playground")
|
||||
|
||||
DEFAULT_PATH = "/cps_open/common/api/v1/query_coupon"
|
||||
|
||||
|
||||
def call_raw(path: str, body_obj: dict) -> dict:
|
||||
"""用 meituan.py 同款 S-Ca 签名打美团,原样返回(不管 code 是否为 0,方便看错误体)。"""
|
||||
body = json.dumps(body_obj, ensure_ascii=False).encode("utf-8")
|
||||
md5 = _content_md5(body)
|
||||
ts = str(int(time.time() * 1000))
|
||||
signed = {"S-Ca-App": settings.MT_CPS_APP_KEY, "S-Ca-Timestamp": ts}
|
||||
sig = _sign(settings.MT_CPS_APP_SECRET, "POST", md5, path, signed)
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Content-MD5": md5,
|
||||
"S-Ca-App": settings.MT_CPS_APP_KEY,
|
||||
"S-Ca-Timestamp": ts,
|
||||
"S-Ca-Signature-Headers": "S-Ca-Timestamp,S-Ca-App",
|
||||
"S-Ca-Signature": sig,
|
||||
}
|
||||
url = f"{settings.MT_CPS_HOST}{path}"
|
||||
t0 = time.time()
|
||||
resp = httpx.post(url, content=body, headers=headers, timeout=settings.MT_CPS_TIMEOUT_SEC)
|
||||
ms = int((time.time() - t0) * 1000)
|
||||
try:
|
||||
j = resp.json()
|
||||
except Exception:
|
||||
j = {"_raw_text": resp.text[:2000]}
|
||||
return {"http_status": resp.status_code, "elapsed_ms": ms, "json": j}
|
||||
|
||||
|
||||
def _is_rate_limited(j: dict) -> bool:
|
||||
code = str(j.get("code"))
|
||||
msg = str(j.get("message") or j.get("msg") or "")
|
||||
return code == "402" or "频繁" in msg
|
||||
|
||||
|
||||
@app.post("/api/query")
|
||||
async def api_query(req: Request):
|
||||
try:
|
||||
payload = await req.json()
|
||||
except Exception as e:
|
||||
return JSONResponse({"ok": False, "error": f"请求体不是合法 JSON: {e}"})
|
||||
path = (payload.get("path") or DEFAULT_PATH).strip()
|
||||
body = payload.get("body")
|
||||
if not isinstance(body, dict):
|
||||
return JSONResponse({"ok": False, "error": "body 必须是 JSON 对象"})
|
||||
if not settings.MT_CPS_APP_KEY or not settings.MT_CPS_APP_SECRET:
|
||||
return JSONResponse({"ok": False, "error": "MT_CPS_APP_KEY / SECRET 未配置(.env)"})
|
||||
|
||||
# 美团这接口很容易 402「调用频繁」,交互式工具自动退避重试 3 次,UX 顺一点
|
||||
last = None
|
||||
for a in range(3):
|
||||
try:
|
||||
out = call_raw(path, body)
|
||||
except Exception as e:
|
||||
return JSONResponse({"ok": False, "error": f"{type(e).__name__}: {e}"})
|
||||
if _is_rate_limited(out.get("json") or {}) and a < 2:
|
||||
last = out
|
||||
await asyncio.sleep(1.5 * (a + 1))
|
||||
continue
|
||||
return JSONResponse({"ok": True, **out, "retries": a})
|
||||
return JSONResponse({"ok": True, **(last or {}), "retries": 2})
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index():
|
||||
return HTML
|
||||
|
||||
|
||||
HTML = r"""<!doctype html>
|
||||
<html lang="zh"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>美团 CPS Playground</title>
|
||||
<style>
|
||||
:root{ --bd:#e3e6ea; --mut:#6b7280; --bg:#f6f7f9; --pri:#ffb000; --pri2:#ff8a00; }
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;font:13px/1.5 -apple-system,"PingFang SC",Segoe UI,Roboto,sans-serif;color:#1f2328;background:var(--bg)}
|
||||
header{display:flex;align-items:center;gap:10px;padding:8px 14px;background:#fff;border-bottom:1px solid var(--bd);position:sticky;top:0;z-index:5}
|
||||
header b{font-size:15px}
|
||||
header .path{flex:1;min-width:200px;font-family:ui-monospace,Menlo,monospace;font-size:12px;padding:5px 8px;border:1px solid var(--bd);border-radius:6px}
|
||||
#status{font-size:12px;color:var(--mut);white-space:nowrap}
|
||||
#status .ok{color:#0a7d28;font-weight:600}
|
||||
#status .err{color:#c0392b;font-weight:600}
|
||||
main{display:flex;gap:0;height:calc(100vh - 49px)}
|
||||
section{height:100%;overflow:auto;padding:12px}
|
||||
#params{width:380px;min-width:340px;border-right:1px solid var(--bd);background:#fff}
|
||||
#cards{flex:1;min-width:300px;border-right:1px solid var(--bd)}
|
||||
#jsonpane{flex:1;min-width:300px;background:#fff}
|
||||
h3{margin:2px 0 8px;font-size:12px;color:var(--mut);text-transform:uppercase;letter-spacing:.04em}
|
||||
#cards h3{display:flex;align-items:center;gap:8px}
|
||||
.presets{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px}
|
||||
.presets button{font-size:12px;padding:4px 8px;border:1px solid var(--bd);background:#fff;border-radius:14px;cursor:pointer}
|
||||
.presets button:hover{border-color:var(--pri2);color:var(--pri2)}
|
||||
.cgrp{font-size:12px;font-weight:700;color:#374151;margin:9px 0 4px;padding-left:2px;border-left:3px solid var(--pri);padding-left:7px}
|
||||
.chtag{display:inline-block;min-width:14px;text-align:center;font-size:10px;font-weight:800;padding:0 3px;border-radius:3px;margin-right:4px}
|
||||
.ch-r{background:#fff1f0;color:#cf1322}
|
||||
.ch-b{background:#eef3ff;color:#1d39c4}
|
||||
.ch-a{background:#fff7e6;color:#d46b08}
|
||||
.grid{display:grid;grid-template-columns:auto 1fr;gap:6px 8px;align-items:center;margin-bottom:10px}
|
||||
.grid label{color:var(--mut);font-size:12px;text-align:right}
|
||||
.grid input,.grid select{width:100%;padding:5px 7px;border:1px solid var(--bd);border-radius:6px;font-size:12px;font-family:inherit}
|
||||
textarea{width:100%;height:200px;font-family:ui-monospace,Menlo,monospace;font-size:12px;padding:8px;border:1px solid var(--bd);border-radius:6px;resize:vertical;white-space:pre}
|
||||
.btns{display:flex;gap:8px;margin:8px 0}
|
||||
button.act{padding:6px 10px;border:1px solid var(--bd);background:#fff;border-radius:6px;cursor:pointer;font-size:12px}
|
||||
button.send{flex:1;background:linear-gradient(180deg,var(--pri),var(--pri2));border:none;color:#3a2600;font-weight:700;padding:9px;border-radius:7px;cursor:pointer;font-size:13px}
|
||||
button.send:active{transform:translateY(1px)}
|
||||
.hint{font-size:11px;color:var(--mut);margin:6px 0}
|
||||
#pageBtn{margin-left:auto;font-size:12px;padding:4px 12px;border:1px solid var(--bd);background:#fff;border-radius:14px;cursor:pointer}
|
||||
#pageBtn:hover:not(:disabled){border-color:var(--pri2);color:var(--pri2)}
|
||||
#pageBtn:disabled{opacity:.4;cursor:not-allowed}
|
||||
/* cards */
|
||||
.cardgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px}
|
||||
.pagediv{grid-column:1/-1;text-align:center;color:#9aa0a6;font-size:12px;border-top:1px dashed var(--bd);padding:9px 0 3px;margin-top:6px}
|
||||
.card{border:1px solid var(--bd);border-radius:10px;background:#fff;overflow:hidden;display:flex;flex-direction:column}
|
||||
.card .thumb{width:100%;height:118px;object-fit:cover;background:#f0f1f3}
|
||||
.card .body{padding:8px 9px;display:flex;flex-direction:column;gap:5px}
|
||||
.card .brand{display:flex;align-items:center;gap:5px;color:var(--mut);font-size:11px}
|
||||
.card .brand img{width:15px;height:15px;border-radius:3px;object-fit:cover}
|
||||
.card .nm{font-weight:600;font-size:13px;line-height:1.3;max-height:2.6em;overflow:hidden}
|
||||
.card .pr{display:flex;align-items:baseline;gap:6px}
|
||||
.card .pr .sell{color:#e8420f;font-weight:800;font-size:17px}
|
||||
.card .pr .sell:before{content:"¥";font-size:12px;font-weight:600}
|
||||
.card .pr .ori{color:#9aa0a6;text-decoration:line-through;font-size:11px}
|
||||
.badges{display:flex;flex-wrap:wrap;gap:4px}
|
||||
.b{font-size:10px;padding:1px 6px;border-radius:8px;background:#f1f3f5;color:#445}
|
||||
.b.sale{background:#fff2e8;color:#d4380d}
|
||||
.b.comm{background:#e6fffb;color:#08979c}
|
||||
.b.poi{background:#f0f5ff;color:#2f54eb}
|
||||
.b.rank{background:#fff7e6;color:#d46b08}
|
||||
.b.dist{background:#eaf7ee;color:#0a7d28;font-weight:600}
|
||||
.pvs{font-family:ui-monospace,Menlo,monospace;font-size:10px;color:#888;display:flex;align-items:center;gap:5px;border-top:1px dashed var(--bd);padding-top:5px;margin-top:2px}
|
||||
.pvs code{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.pvs button{font-size:10px;border:1px solid var(--bd);background:#fff;border-radius:4px;cursor:pointer;padding:1px 5px}
|
||||
.empty{color:var(--mut);padding:20px;text-align:center}
|
||||
/* json tree */
|
||||
.jtools{display:flex;gap:6px;margin-bottom:8px}
|
||||
.jtools button{font-size:11px;border:1px solid var(--bd);background:#fff;border-radius:5px;cursor:pointer;padding:3px 7px}
|
||||
#tree{font-family:ui-monospace,Menlo,monospace;font-size:12px;line-height:1.65}
|
||||
.jrow{padding-left:14px}
|
||||
.jhead{cursor:pointer;user-select:none}
|
||||
.jtog{display:inline-block;width:12px;color:#9aa0a6}
|
||||
.jkey{color:#8250df}
|
||||
.jsum{color:#9aa0a6}
|
||||
.jval.str{color:#0a7d28}
|
||||
.jval.num{color:#0550ae}
|
||||
.jval.bool,.jval.null{color:#cf222e}
|
||||
.jkids{border-left:1px dotted #e3e6ea;margin-left:5px}
|
||||
</style></head>
|
||||
<body>
|
||||
<header>
|
||||
<b>🍔 美团 CPS Playground</b>
|
||||
<input id="path" class="path" value="/cps_open/common/api/v1/query_coupon">
|
||||
<span id="status">就绪</span>
|
||||
</header>
|
||||
<main>
|
||||
<section id="params">
|
||||
<h3>快捷模板</h3>
|
||||
<div class="hint" style="margin:0 0 4px">渠道:<span class="chtag ch-r">榜</span>榜单 <span class="chtag ch-b">搜</span>搜索词 <span class="chtag ch-a">供</span>供给</div>
|
||||
<div class="cgrp">到店 · 团购</div>
|
||||
<div class="presets" id="presets-dd"></div>
|
||||
<div class="cgrp">到家 · 外卖</div>
|
||||
<div class="presets" id="presets-dj"></div>
|
||||
<h3>测试坐标(点一下填经纬度)</h3>
|
||||
<div class="presets" id="coords"></div>
|
||||
<h3>参数(改完点「用表单生成 body」)</h3>
|
||||
<div class="grid">
|
||||
<label>召回方式</label>
|
||||
<select id="channel">
|
||||
<option value="topic">榜单 listTopiId</option>
|
||||
<option value="search">搜索 searchText</option>
|
||||
<option value="supply">多业务供给 multipleSupplyList</option>
|
||||
<option value="ids">按 ID productViewSignList</option>
|
||||
</select>
|
||||
<label>platform</label>
|
||||
<select id="platform"><option value="1">1 到家/外卖</option><option value="2" selected>2 到店</option></select>
|
||||
<label>bizLine</label>
|
||||
<input id="bizLine" value="1" placeholder="到店:1到餐2到综3酒店4门票;外卖填1">
|
||||
<label>listTopiId</label>
|
||||
<input id="listTopiId" value="5" placeholder="到店:2必推3热销5实时;到家:1精选2必推3热销">
|
||||
<label>searchText</label>
|
||||
<input id="searchText" placeholder="搜索关键词(选搜索时填)">
|
||||
<label>productViewSignList</label>
|
||||
<input id="ids" placeholder="逗号分隔的商品ID(按ID查时填)">
|
||||
<label>sortField</label>
|
||||
<select id="sortField">
|
||||
<option value="">(默认/不传)</option>
|
||||
<option value="1">1 售价</option><option value="2">2 销量</option>
|
||||
<option value="3">3 佣金</option><option value="6">6 离我最近</option>
|
||||
</select>
|
||||
<label>cityId</label>
|
||||
<input id="cityId" value="WKV2HMXUEK634WP64CUCUQGM64" placeholder="城市编码(默认北京)">
|
||||
<label>经度</label>
|
||||
<input id="lon" value="116.404" placeholder="十进制,自动×100万">
|
||||
<label>纬度</label>
|
||||
<input id="lat" value="39.928" placeholder="十进制,自动×100万">
|
||||
<label>pageSize</label>
|
||||
<input id="pageSize" value="20">
|
||||
<label>pageNo</label>
|
||||
<input id="pageNo" value="" placeholder="留空=默认1">
|
||||
<label>searchId</label>
|
||||
<input id="searchId" value="" placeholder="翻页用,一般不手填">
|
||||
</div>
|
||||
<div class="btns"><button class="act" id="genBtn">⟳ 用表单生成 body</button></div>
|
||||
<h3>请求 body(可直接手改,发送以这里为准)</h3>
|
||||
<textarea id="body"></textarea>
|
||||
<div class="btns"><button class="send" id="sendBtn">▶ 发送请求(第 1 页)</button></div>
|
||||
<div class="hint">经纬度填十进制(116.404)自动 ×100万;翻页按钮在中间卡片栏右上角(供给/搜索有下一页,榜单没有)。</div>
|
||||
</section>
|
||||
|
||||
<section id="cards">
|
||||
<h3>卡片列表 <span id="cardCount" style="color:#9aa0a6"></span><button id="pageBtn" disabled>下一页 ›</button></h3>
|
||||
<div id="cardlist"><div class="empty">点「发送请求」后,这里渲染商品卡片</div></div>
|
||||
</section>
|
||||
|
||||
<section id="jsonpane">
|
||||
<h3>原始 JSON(点三角折叠/展开)</h3>
|
||||
<div class="jtools">
|
||||
<button id="expandAll">全部展开</button>
|
||||
<button id="collapseAll">全部折叠</button>
|
||||
</div>
|
||||
<div id="tree"><div class="empty">原始响应在这里</div></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s==null?"":s).replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">","\"":"""}[c]));
|
||||
|
||||
// 翻页状态
|
||||
let lastJson = null; // 最近一页响应
|
||||
let pageNum = 0; // 已展示页数
|
||||
let totalShown = 0; // 累计卡片数
|
||||
|
||||
// ---------- 快捷模板(按 到店/到家 分组 + 渠道标识) ----------
|
||||
const LON = 116404000, LAT = 39928000, BJ = "WKV2HMXUEK634WP64CUCUQGM64";
|
||||
const ZLON = 116316000, ZLAT = 39984000; // 中关村
|
||||
// ch: 榜=榜单(红) 搜=搜索词(蓝) 供=供给(琥珀)
|
||||
const PRESETS = [
|
||||
// 到店 · 团购
|
||||
{g:"dd", ch:"榜", label:"实时热销", body:{platform:2, bizLine:1, listTopiId:5, longitude:LON, latitude:LAT, pageSize:20}},
|
||||
{g:"dd", ch:"榜", label:"同城热销", body:{platform:2, bizLine:1, listTopiId:3, longitude:LON, latitude:LAT, pageSize:20}},
|
||||
{g:"dd", ch:"供", label:"到餐·按销量(翻页)", body:{multipleSupplyList:[{platform:2, bizLineParamList:[{bizLine:1}]}], cityId:BJ, sortField:2, pageSize:20}},
|
||||
{g:"dd", ch:"供", label:"到餐·按距离(中关村)", body:{multipleSupplyList:[{platform:2, bizLineParamList:[{bizLine:1}]}], cityId:BJ, sortField:6, longitude:ZLON, latitude:ZLAT, pageSize:20}},
|
||||
// 到家 · 外卖
|
||||
{g:"dj", ch:"榜", label:"同城热销·按销量", body:{platform:1, listTopiId:3, longitude:ZLON, latitude:ZLAT, pageSize:20}},
|
||||
{g:"dj", ch:"搜", label:"按距离(中关村)", body:{searchText:"美食", sortField:6, longitude:ZLON, latitude:ZLAT, pageSize:20}},
|
||||
{g:"dj", ch:"搜", label:"价格升序", body:{searchText:"美食", sortField:2, longitude:ZLON, latitude:ZLAT, pageSize:20}},
|
||||
{g:"dj", ch:"搜", label:"综合(供给深)", body:{searchText:"美食", sortField:1, longitude:ZLON, latitude:ZLAT, pageSize:20}},
|
||||
];
|
||||
const CH_CLS = {"榜":"ch-r", "搜":"ch-b", "供":"ch-a"};
|
||||
PRESETS.forEach(p=>{
|
||||
const box = $(p.g==="dd" ? "presets-dd" : "presets-dj");
|
||||
const b = document.createElement("button");
|
||||
b.innerHTML = `<span class="chtag ${CH_CLS[p.ch]}">${p.ch}</span>${p.label}`;
|
||||
b.onclick = ()=>{ $("body").value = JSON.stringify(p.body, null, 2); };
|
||||
box.appendChild(b);
|
||||
});
|
||||
|
||||
// ---------- 测试坐标 ----------
|
||||
const COORDS = {
|
||||
"天安门":[116.397,39.909], "王府井":[116.418,39.914], "中关村":[116.316,39.984],
|
||||
"国贸":[116.461,39.909], "三里屯":[116.455,39.937], "望京":[116.470,39.997],
|
||||
};
|
||||
const cbox = $("coords");
|
||||
Object.entries(COORDS).forEach(([nm,[lo,la]])=>{
|
||||
const b=document.createElement("button"); b.textContent=nm;
|
||||
b.onclick=()=>applyCoords(lo,la); cbox.appendChild(b);
|
||||
});
|
||||
function applyCoords(lo, la){
|
||||
$("lon").value = lo; $("lat").value = la;
|
||||
// 若当前 body 里已有经纬度,顺手原地替换,立即生效(供给查询用 cityId 无经纬度则不动)
|
||||
try{
|
||||
const o = JSON.parse($("body").value);
|
||||
if(o && typeof o==="object" && ("longitude" in o || "latitude" in o)){
|
||||
o.longitude = Math.round(lo*1e6); o.latitude = Math.round(la*1e6);
|
||||
$("body").value = JSON.stringify(o, null, 2);
|
||||
}
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
// ---------- 表单 → body ----------
|
||||
function num(id){ const v=$(id).value.trim(); return v===""? null : Number(v); }
|
||||
function str(id){ const v=$(id).value.trim(); return v===""? null : v; }
|
||||
function buildBody(){
|
||||
const ch = $("channel").value;
|
||||
const b = {};
|
||||
const ps = num("pageSize"); if(ps!=null) b.pageSize = ps;
|
||||
const pn = num("pageNo"); if(pn!=null) b.pageNo = pn;
|
||||
const lon = num("lon"), lat = num("lat");
|
||||
if(lon!=null) b.longitude = Math.round(lon*1e6);
|
||||
if(lat!=null) b.latitude = Math.round(lat*1e6);
|
||||
const sf = str("sortField"); if(sf!=null) b.sortField = Number(sf);
|
||||
const city = str("cityId"); if(city!=null) b.cityId = city;
|
||||
const sid = str("searchId"); if(sid!=null) b.searchId = sid;
|
||||
const plat = Number($("platform").value);
|
||||
const bl = num("bizLine");
|
||||
|
||||
if(ch==="supply"){
|
||||
b.multipleSupplyList = [{platform:plat, bizLineParamList:[{bizLine: bl!=null? bl : 1}]}];
|
||||
if(b.sortField==null) b.sortField = 2; // 多供给查询 sortField 必填
|
||||
} else if(ch==="ids"){
|
||||
const ids = str("ids");
|
||||
b.productViewSignList = ids? ids.split(",").map(s=>s.trim()).filter(Boolean) : [];
|
||||
} else {
|
||||
b.platform = plat;
|
||||
if(bl!=null) b.bizLine = bl;
|
||||
if(ch==="topic"){ const t=num("listTopiId"); if(t!=null) b.listTopiId=t; }
|
||||
else if(ch==="search"){ const s=str("searchText"); if(s!=null) b.searchText=s; }
|
||||
}
|
||||
return b;
|
||||
}
|
||||
$("genBtn").onclick = ()=>{ $("body").value = JSON.stringify(buildBody(), null, 2); };
|
||||
|
||||
// ---------- 发送 / 翻页 ----------
|
||||
function setStatus(t, cls){ const s=$("status"); s.innerHTML = cls? `<span class="${cls}">${t}</span>` : t; }
|
||||
|
||||
async function doQuery(body, append){
|
||||
setStatus("请求中…", "");
|
||||
$("pageBtn").disabled = true;
|
||||
let res;
|
||||
try{
|
||||
res = await fetch("/api/query", {method:"POST", headers:{"Content-Type":"application/json"},
|
||||
body: JSON.stringify({path: $("path").value.trim(), body})}).then(r=>r.json());
|
||||
}catch(e){ setStatus("本地请求失败: "+e.message, "err"); updatePageBtn(); return; }
|
||||
if(!res.ok){ setStatus("❌ "+res.error, "err"); renderTree({error:res.error}); if(!append) renderCards({}, false); updatePageBtn(); return; }
|
||||
const j = res.json || {};
|
||||
lastJson = j;
|
||||
const code = j.code, n = Array.isArray(j.data)? j.data.length : 0;
|
||||
const cls = code===0? "ok":"err";
|
||||
const rt = res.retries? ` · 退避${res.retries}次`:"";
|
||||
setStatus(`HTTP ${res.http_status} · code=${code} · 本页${n}条 · hasNext=${j.hasNext} · ${res.elapsed_ms}ms${rt}`, cls);
|
||||
renderCards(j, append);
|
||||
renderTree(j);
|
||||
updatePageBtn();
|
||||
}
|
||||
|
||||
async function send(){
|
||||
let body;
|
||||
try{ body = JSON.parse($("body").value); }
|
||||
catch(e){ setStatus("body 不是合法 JSON: "+e.message, "err"); return; }
|
||||
pageNum = 1;
|
||||
await doQuery(body, false);
|
||||
}
|
||||
async function nextPage(){
|
||||
if(!lastJson) return;
|
||||
let body;
|
||||
try{ body = JSON.parse($("body").value); }
|
||||
catch(e){ setStatus("body 不是合法 JSON,无法翻页: "+e.message, "err"); return; }
|
||||
if(lastJson.searchId){ body.searchId = lastJson.searchId; } // 供给/搜索:带令牌
|
||||
else if(lastJson.hasNext){ body.pageNo = (Number(body.pageNo)||1)+1; } // pageNo 翻页
|
||||
else { setStatus("没有下一页(searchId 为空且 hasNext=false)", "err"); return; }
|
||||
$("body").value = JSON.stringify(body, null, 2); // 反映当前翻页状态
|
||||
pageNum += 1;
|
||||
await doQuery(body, true);
|
||||
}
|
||||
function updatePageBtn(){
|
||||
const b = $("pageBtn");
|
||||
const canNext = !!(lastJson && (lastJson.searchId || lastJson.hasNext===true));
|
||||
b.disabled = !canNext;
|
||||
b.textContent = canNext? "下一页 ›" : "没有下一页";
|
||||
}
|
||||
$("sendBtn").onclick = send;
|
||||
$("pageBtn").onclick = nextPage;
|
||||
|
||||
// ---------- 卡片渲染 ----------
|
||||
function clean(u){ return u? String(u).split("@")[0] : ""; }
|
||||
function fmtDist(d){ if(d==null||d==="") return null; const v=Number(d); if(!isFinite(v)) return null; return v>=1000? (v/1000).toFixed(1)+"km" : Math.round(v)+"m"; }
|
||||
function cardGrid(){
|
||||
let g = document.querySelector("#cardlist .cardgrid");
|
||||
if(!g){ $("cardlist").innerHTML=""; g=document.createElement("div"); g.className="cardgrid"; $("cardlist").appendChild(g); }
|
||||
return g;
|
||||
}
|
||||
function makeCardHtml(it){
|
||||
const cpd = it.couponPackDetail || {};
|
||||
const br = it.brandInfo || {};
|
||||
const ci = it.commissionInfo || {};
|
||||
const poi = it.availablePoiInfo || {};
|
||||
const dp = it.deliverablePoiInfo || {};
|
||||
const lab = it.productLabel || {};
|
||||
const pp = lab.pricePowerLabel || {};
|
||||
const pvs = cpd.productViewSign || cpd.skuViewId || "";
|
||||
const commPct = ci.commissionPercent!=null ? (Number(ci.commissionPercent)/100)+"%"
|
||||
: (ci.commission!=null? "¥"+ci.commission : null);
|
||||
const dist = fmtDist(dp.deliveryDistance);
|
||||
const badges = [];
|
||||
if(dist) badges.push(`<span class="b dist" title="${esc((dp.poiName||"")+" · "+(dp.deliveryDistance||"")+"米")}">📍 ${dist}</span>`);
|
||||
if(cpd.saleVolume) badges.push(`<span class="b sale">${esc(cpd.saleVolume)}</span>`);
|
||||
if(commPct) badges.push(`<span class="b comm">佣金 ${esc(commPct)}</span>`);
|
||||
if(poi.availablePoiNum) badges.push(`<span class="b poi">门店 ${esc(poi.availablePoiNum)}</span>`);
|
||||
if(dp.deliveryDistance) badges.push(`<span class="b">${esc(dp.deliveryDistance)}</span>`);
|
||||
if(pp.beatMTLabel) badges.push(`<span class="b rank">${esc(pp.beatMTLabel)}</span>`);
|
||||
else if(pp.historyPriceLabel) badges.push(`<span class="b rank">${esc(pp.historyPriceLabel)}</span>`);
|
||||
if(lab.productRankLabel) badges.push(`<span class="b rank">${esc(lab.productRankLabel)}</span>`);
|
||||
return `<div class="card">
|
||||
${cpd.headUrl? `<img class="thumb" src="${esc(clean(cpd.headUrl))}" loading="lazy" onerror="this.style.visibility='hidden'">` : ``}
|
||||
<div class="body">
|
||||
<div class="brand">${br.brandLogoUrl? `<img src="${esc(clean(br.brandLogoUrl))}">`:``}${esc(br.brandName||dp.poiName||"—")}</div>
|
||||
<div class="nm">${esc(cpd.name||"(无名称)")}</div>
|
||||
<div class="pr"><span class="sell">${esc(cpd.sellPrice??"?")}</span>${cpd.originalPrice!=null?`<span class="ori">¥${esc(cpd.originalPrice)}</span>`:``}</div>
|
||||
<div class="badges">${badges.join("")}</div>
|
||||
${pvs? `<div class="pvs"><code title="${esc(pvs)}">${esc(pvs)}</code><button onclick="navigator.clipboard.writeText('${esc(pvs)}')">复制ID</button></div>`:``}
|
||||
</div></div>`;
|
||||
}
|
||||
function renderCards(json, append){
|
||||
const data = json && Array.isArray(json.data)? json.data : [];
|
||||
if(!append){ $("cardlist").innerHTML=""; totalShown=0; }
|
||||
const g = cardGrid();
|
||||
if(!data.length){
|
||||
if(!append) $("cardlist").innerHTML = `<div class="empty">无 data 数组可渲染(看右侧 JSON)</div>`;
|
||||
else g.insertAdjacentHTML("beforeend", `<div class="pagediv">— 第 ${pageNum} 页 · 0 条 —</div>`);
|
||||
} else {
|
||||
const divider = append? `<div class="pagediv">— 第 ${pageNum} 页 · ${data.length} 条 —</div>` : "";
|
||||
g.insertAdjacentHTML("beforeend", divider + data.map(makeCardHtml).join(""));
|
||||
totalShown += data.length;
|
||||
}
|
||||
$("cardCount").textContent = totalShown? `(累计 ${totalShown} 条 / ${pageNum} 页)` : "";
|
||||
}
|
||||
|
||||
// ---------- JSON 树(可折叠) ----------
|
||||
function el(tag,cls,txt){ const e=document.createElement(tag); if(cls)e.className=cls; if(txt!=null)e.textContent=txt; return e; }
|
||||
function fmt(v){ return v===null? "null" : typeof v==="string"? `"${v}"` : String(v); }
|
||||
function typeCls(v){ return v===null?"null":typeof v==="string"?"str":typeof v==="number"?"num":"bool"; }
|
||||
function buildNode(key, val, depth){
|
||||
const wrap = el("div","jrow");
|
||||
const isObj = val && typeof val==="object";
|
||||
if(isObj){
|
||||
const entries = Array.isArray(val)? val.map((v,i)=>[i,v]) : Object.entries(val);
|
||||
const head = el("div","jhead");
|
||||
const open = depth < 2;
|
||||
const tog = el("span","jtog", entries.length? (open?"▼":"▶") : "·");
|
||||
const k = el("span","jkey", key!==null? key+": " : "");
|
||||
const sum = el("span","jsum", Array.isArray(val)? `[${entries.length}]` : `{${entries.length}}`);
|
||||
head.append(tog,k,sum);
|
||||
const kids = el("div","jkids");
|
||||
entries.forEach(([ck,cv])=> kids.append(buildNode(ck,cv,depth+1)));
|
||||
kids.style.display = open? "block":"none";
|
||||
head.onclick = ()=>{ const o=kids.style.display!=="none"; kids.style.display=o?"none":"block"; if(entries.length) tog.textContent=o?"▶":"▼"; };
|
||||
wrap.append(head,kids);
|
||||
} else {
|
||||
wrap.append(el("span","jkey", key+": "), el("span","jval "+typeCls(val), fmt(val)));
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
function renderTree(obj){ const t=$("tree"); t.innerHTML=""; t.append(buildNode(null, obj, 0)); }
|
||||
$("expandAll").onclick = ()=>{ document.querySelectorAll("#tree .jkids").forEach(k=>k.style.display="block");
|
||||
document.querySelectorAll("#tree .jtog").forEach(x=>{ if(x.textContent==="▶")x.textContent="▼"; }); };
|
||||
$("collapseAll").onclick = ()=>{ document.querySelectorAll("#tree .jrow .jkids").forEach((k,i)=>{ if(i>0) k.style.display="none"; });
|
||||
document.querySelectorAll("#tree .jtog").forEach((x,i)=>{ if(i>0 && x.textContent==="▼")x.textContent="▶"; }); };
|
||||
|
||||
// 初始填一个默认 body
|
||||
$("body").value = JSON.stringify(PRESETS[0].body, null, 2);
|
||||
</script>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
port = int(os.environ.get("MT_PLAYGROUND_PORT", "8799"))
|
||||
print(f"\n 美团 CPS Playground → http://127.0.0.1:{port}\n (Ctrl-C 退出)\n")
|
||||
uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")
|
||||
Reference in New Issue
Block a user