Compare commits

..

1 Commits

Author SHA1 Message Date
marco 88d2b57c7a feat(admin): app_config 运营配置后台(rewards 等常量可后台配置)
- 新增 app_config 表 + model/repository/config_schema + admin config 路由与 schema + 测试
- rewards.py 及 ad_reward/signin/task/wallet/comparison_milestone repositories 接入可配置项;ad.py / admin/main.py 配套
- alembic merge(ebb6af5c0b56)合并本地 app_config(cfg1a2b3c4d5)与 incoming price_report/best_deeplink(b7e2c1a9f4d3)两 head

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 02:29:49 +08:00
82 changed files with 340 additions and 3706 deletions
+2 -15
View File
@@ -41,25 +41,12 @@ MT_CPS_APP_SECRET=
# 默认渠道追踪标识(sid),用于区分不同 app 的 CPS 数据
MT_CPS_DEFAULT_SID=sgbjia
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
# 客户端调本服务的 /api/v1/coupon/step,我们透传到 pricebot-backend。
# ===== Pricebot 上游 (领券业务透传目标) =====
# 客户端调本服务的 /api/v1/coupon/step,我们透传到 pricebot-backend 的 /api/coupon/step
# 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。
PRICEBOT_BASE_URL=http://localhost:8000
# 【多实例】单机多进程部署时,填逗号分隔的实例列表(端口与 pricebot 集群对齐),透传层按
# trace_id 一致性 hash 选实例 → 同一比价所有帧落同一进程(进程内维护 state,无需 Redis)。
# 留空 = 单实例(用上面的 PRICEBOT_BASE_URL)。详见 pricebot-backend/docs/并发部署设计.md
# PRICEBOT_INSTANCES=http://127.0.0.1:8001,http://127.0.0.1:8002,http://127.0.0.1:8003,http://127.0.0.1:8004,http://127.0.0.1:8005,http://127.0.0.1:8006
# 领券单帧最多 wait 6s,加网络往返,30s 兜底
PRICEBOT_REQUEST_TIMEOUT_SEC=30
# 比价(intent/recognize + price/step)透传超时:大上下文 LLM + 逐帧 LLM,给 60s
PRICEBOT_COMPARE_TIMEOUT_SEC=60
# ===== 内部端点 (server→server, pricebot 上报价格观测) =====
# pricebot 比价 done 后把各平台到手价 POST 到本服务 /internal/price-observation 落库
# (price_observation 表,比价资产沉淀层),靠共享密钥头 X-Internal-Secret 校验。
# 必须与 pricebot 侧的 INTERNAL_API_SECRET **同值**;留空 = 内部写端点关闭(返 503)。
# 启用前两边都填同一高熵串:python -c "import secrets; print(secrets.token_urlsafe(48))"
INTERNAL_API_SECRET=
# ===== CORS =====
# 逗号分隔,生产留空(只让 app 调,不开放 web)。本地开发可加 http://localhost:5173 之类
@@ -1,74 +0,0 @@
"""price_observation table (价格观测:比价沉淀的价格资产层,server 侧无条件落库)
Revision ID: price_observation_table
Revises: wx_transfer_auth
Create Date: 2026-06-07 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'price_observation_table'
down_revision: Union[str, Sequence[str], None] = 'wx_transfer_auth'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'price_observation',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('observed_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('trace_id', sa.String(length=64), nullable=False),
sa.Column('business_type', sa.String(length=16), nullable=False),
sa.Column('platform', sa.String(length=32), nullable=False),
sa.Column('platform_store_id', sa.String(length=64), nullable=True),
sa.Column('store_name', sa.String(length=128), nullable=True),
sa.Column('city', sa.String(length=64), nullable=True),
sa.Column('geohash', sa.String(length=16), nullable=True),
sa.Column('lng', sa.Float(), nullable=True),
sa.Column('lat', sa.Float(), nullable=True),
sa.Column('is_source', sa.Boolean(), nullable=False),
sa.Column('scope', sa.String(length=16), nullable=False),
sa.Column('price_cents', sa.Integer(), nullable=True),
sa.Column('coupon_saved_cents', sa.Integer(), nullable=True),
sa.Column('coupon_name', sa.String(length=64), nullable=True),
sa.Column('store_closed', sa.String(length=32), nullable=True),
sa.Column('rank', sa.Integer(), nullable=True),
# PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐
sa.Column('dishes', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=True),
sa.Column('attrs', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=True),
sa.Column('source_device_id', sa.String(length=64), nullable=True),
sa.Column('source_user_id', sa.Integer(), nullable=True),
sa.Column('confidence', sa.Float(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('trace_id', 'platform', 'scope', name='uq_price_obs_trace_platform_scope'),
)
with op.batch_alter_table('price_observation', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_price_observation_observed_at'), ['observed_at'], unique=False)
batch_op.create_index(batch_op.f('ix_price_observation_trace_id'), ['trace_id'], unique=False)
batch_op.create_index(batch_op.f('ix_price_observation_business_type'), ['business_type'], unique=False)
batch_op.create_index(batch_op.f('ix_price_observation_platform'), ['platform'], unique=False)
batch_op.create_index(batch_op.f('ix_price_observation_store_name'), ['store_name'], unique=False)
batch_op.create_index(batch_op.f('ix_price_observation_geohash'), ['geohash'], unique=False)
batch_op.create_index(batch_op.f('ix_price_observation_source_device_id'), ['source_device_id'], unique=False)
batch_op.create_index(batch_op.f('ix_price_observation_source_user_id'), ['source_user_id'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('price_observation', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_price_observation_source_user_id'))
batch_op.drop_index(batch_op.f('ix_price_observation_source_device_id'))
batch_op.drop_index(batch_op.f('ix_price_observation_geohash'))
batch_op.drop_index(batch_op.f('ix_price_observation_store_name'))
batch_op.drop_index(batch_op.f('ix_price_observation_platform'))
batch_op.drop_index(batch_op.f('ix_price_observation_business_type'))
batch_op.drop_index(batch_op.f('ix_price_observation_trace_id'))
batch_op.drop_index(batch_op.f('ix_price_observation_observed_at'))
op.drop_table('price_observation')
@@ -1,48 +0,0 @@
"""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')
@@ -1,62 +0,0 @@
"""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')
+1 -56
View File
@@ -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, WithdrawRejectRequest
from app.admin.schemas.wallet import ReconcileResult, WithdrawOrderOut
from app.integrations import wxpay
from app.models.admin import AdminUser
from app.repositories import wallet as wallet_repo
@@ -83,58 +83,3 @@ 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)
+1 -8
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict
class CoinTxnOut(BaseModel):
@@ -39,7 +39,6 @@ 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
@@ -51,9 +50,3 @@ 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,用户可见)"
)
-1
View File
@@ -1 +0,0 @@
"""内部(server→server)端点。不对客户端开放,靠共享密钥头校验,不走用户 JWT。"""
-63
View File
@@ -1,63 +0,0 @@
"""价格观测内部上报端点(pricebot → app-server)。
pricebot 在比价 done 帧把整批价格事实 POST 到这里落库。**不是给客户端的接口**:
不走用户 JWT,靠 server 间共享密钥头 `X-Internal-Secret` 校验(== settings.INTERNAL_API_SECRET)。
密钥未配置(默认空)时直接 503,避免裸奔的写端点。
与不鉴权的比价透传(compare.py / coupon.py)无关:那两个是客户端→server 的透传壳,
这个是 server→server 的内部写库。
"""
from __future__ import annotations
import hmac
import logging
from typing import Annotated
from fastapi import APIRouter, Header, HTTPException, status
from app.api.deps import DbSession
from app.core.config import settings
from app.repositories import price_observation as repo
from app.schemas.price_observation import (
PriceObservationBatchIn,
PriceObservationBatchOut,
)
logger = logging.getLogger("shagua.internal.price")
router = APIRouter(prefix="/internal", tags=["internal"])
def _check_secret(x_internal_secret: str | None) -> None:
"""共享密钥校验。未配置 → 503(挡住裸奔写端点);不匹配 → 401(常量时间比较)。"""
configured = settings.INTERNAL_API_SECRET
if not configured:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="internal api not configured",
)
if not x_internal_secret or not hmac.compare_digest(x_internal_secret, configured):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid internal secret",
)
@router.post(
"/price-observation",
response_model=PriceObservationBatchOut,
summary="价格观测内部上报(pricebot→app-server,落 price_observation)",
)
def report_price_observation(
payload: PriceObservationBatchIn,
db: DbSession,
x_internal_secret: Annotated[str | None, Header()] = None,
) -> PriceObservationBatchOut:
_check_secret(x_internal_secret)
inserted, skipped = repo.insert_batch(db, payload)
logger.info(
"price_observation trace=%s inserted=%d skipped=%d device=%s user=%s",
payload.trace_id, inserted, skipped,
payload.source_device_id, payload.source_user_id,
)
return PriceObservationBatchOut(inserted=inserted, skipped=skipped)
+2 -36
View File
@@ -21,15 +21,12 @@ 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")
@@ -94,8 +91,7 @@ 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,
watched_sec, watch_limit) = crud_ad.today_status(db, user.id)
used, limit, coin_per, round_count, cooldown_until = crud_ad.today_status(db, user.id)
return AdRewardStatusOut(
used_today=used,
daily_limit=limit,
@@ -103,35 +99,6 @@ 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),
)
@@ -184,8 +151,7 @@ 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,
_watched, _watch_limit) = crud_ad.today_status(db, user.id)
used, limit, coin_per, round_count, cooldown_until = 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"),
+6 -18
View File
@@ -18,7 +18,6 @@ pricebot 协议文档:
"""
from __future__ import annotations
import json
import logging
from typing import Any
@@ -26,7 +25,6 @@ import httpx
from fastapi import APIRouter, HTTPException, Request, status
from app.core.config import settings
from app.core.pricebot_router import pick_pricebot
logger = logging.getLogger("shagua.compare")
@@ -40,35 +38,25 @@ async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
打日志。比价单帧是大上下文 / 逐帧 LLM,超时用 PRICEBOT_COMPARE_TIMEOUT_SEC(60s,
比领券的 30s 长)。
"""
# 读原始字节,避免"反序列化→再序列化"的双重 JSON(省 ~一半透传 CPU,让 app-server
# 单 worker 也扛得住高并发)。只 json.loads 一次拿 trace_id 做亲和 + 打日志,转发时
# 直接发原始 bytes(content=raw),不重新 dumps。
raw = await request.body()
try:
meta = json.loads(raw)
body = await request.json()
except Exception as e:
raise HTTPException(status_code=400, detail=f"invalid json body: {e}") from e
if not isinstance(meta, dict):
meta = {}
# 按 trace_id 一致性 hash 选 pricebot 实例(同一比价的所有帧落同一进程,内存维护 state)
base = pick_pricebot(meta.get("trace_id"))
url = f"{base.rstrip('/')}{upstream_path}"
url = f"{settings.PRICEBOT_BASE_URL.rstrip('/')}{upstream_path}"
timeout = settings.PRICEBOT_COMPARE_TIMEOUT_SEC
logger.info(
"compare %s device_id=%s trace_id=%s step=%s",
upstream_path,
meta.get("device_id"),
meta.get("trace_id"),
meta.get("step"),
body.get("device_id"),
body.get("trace_id"),
body.get("step"),
)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}
)
resp = await client.post(url, json=body)
except httpx.RequestError as e:
logger.error("[pricebot] request failed: %s", e)
raise HTTPException(
+6 -17
View File
@@ -10,7 +10,6 @@ pricebot 协议文档:
"""
from __future__ import annotations
import json
import logging
from typing import Any
@@ -18,7 +17,6 @@ import httpx
from fastapi import APIRouter, HTTPException, Request, status
from app.core.config import settings
from app.core.pricebot_router import pick_pricebot
logger = logging.getLogger("shagua.coupon")
@@ -35,33 +33,24 @@ async def coupon_step(
- 透传: 不做 schema 校验,pricebot 自己校验
- 失败: 网络不可达 / pricebot 5xx → 502 + 友好 message
"""
# 读原始字节,避免"反序列化→再序列化"的双重 JSON(省透传 CPU)。只 loads 一次拿
# trace_id 做亲和 + 打日志,转发时直接发原始 bytes,不重新 dumps。
raw = await request.body()
try:
meta = json.loads(raw)
body = await request.json()
except Exception as e:
raise HTTPException(status_code=400, detail=f"invalid json body: {e}") from e
if not isinstance(meta, dict):
meta = {}
# 按 trace_id 一致性 hash 选 pricebot 实例(同一领券任务的所有帧落同一进程)
base = pick_pricebot(meta.get("trace_id"))
url = f"{base.rstrip('/')}/api/coupon/step"
url = f"{settings.PRICEBOT_BASE_URL.rstrip('/')}/api/coupon/step"
timeout = settings.PRICEBOT_REQUEST_TIMEOUT_SEC
logger.info(
"coupon_step device_id=%s trace_id=%s step=%s",
meta.get("device_id"),
meta.get("trace_id"),
meta.get("step"),
body.get("device_id"),
body.get("trace_id"),
body.get("step"),
)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}
)
resp = await client.post(url, json=body)
except httpx.RequestError as e:
logger.error("[pricebot] request failed: %s", e)
raise HTTPException(
-1
View File
@@ -42,7 +42,6 @@ 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,
)
+6 -77
View File
@@ -33,8 +33,6 @@ from app.schemas.welfare import (
ExchangeInfoOut,
ExchangeRequest,
ExchangeResultOut,
TransferAuthResultOut,
TransferAuthStatusOut,
UnbindWechatResultOut,
WithdrawInfoOut,
WithdrawOrderOut,
@@ -157,29 +155,25 @@ 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:
@@ -195,24 +189,12 @@ 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
# 调试直发(非生产):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)
except crud_wallet.WithdrawTransferError as e:
# 转账调用失败,余额已退回
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"wechat transfer failed: {e}") from e
acc = crud_wallet.get_or_create_account(db, user.id)
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(若需确认)再拉起微信确认页。
logger.info("withdraw user_id=%d cents=%d bill=%s state=%s", user.id, req.amount_cents, order.out_bill_no, order.wechat_state)
return WithdrawResultOut(
out_bill_no=order.out_bill_no,
status=order.status,
@@ -241,11 +223,6 @@ 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,
)
@@ -261,51 +238,3 @@ 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)
-30
View File
@@ -1,30 +0,0 @@
"""微信支付相关回调(一期:免确认收款授权结果通知 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"}
+1 -29
View File
@@ -99,10 +99,6 @@ 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:
@@ -114,11 +110,6 @@ 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。
@@ -135,34 +126,15 @@ class Settings(BaseSettings):
"""回调开关打开且验签密钥已配,才接受发奖回调。"""
return bool(self.PANGLE_CALLBACK_ENABLED and self.PANGLE_REWARD_SECRET)
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
# ===== Pricebot 上游 (领券业务透传目标) =====
# pricebot-backend 默认跑在 8000。/api/v1/coupon/step 会透传到这里的 /api/coupon/step
PRICEBOT_BASE_URL: str = "http://localhost:8000"
# 多实例(单机多进程)时填逗号分隔的实例列表,例如:
# PRICEBOT_INSTANCES=http://127.0.0.1:8001,http://127.0.0.1:8002,http://127.0.0.1:8003
# 透传层按 trace_id 一致性 hash 选实例(见 app/core/pricebot_router.py),保证同一次
# 比价/领券的所有帧落同一 pricebot 进程,从而用进程内内存维护 session/coordinator,
# 无需 Redis。留空 → 退回单实例 [PRICEBOT_BASE_URL],零改动兼容。
PRICEBOT_INSTANCES: str = ""
# 领券一帧最多 wait 6s,加网络往返,timeout 给 30s 比较稳
PRICEBOT_REQUEST_TIMEOUT_SEC: int = 30
# 比价(intent/recognize + price/step)透传超时:意图识别是大上下文 LLM、
# price/step 每帧也是 LLM,可能 >30s;对齐客户端 agent ApiClient 的 60s 读超时。
PRICEBOT_COMPARE_TIMEOUT_SEC: int = 60
@property
def pricebot_instances(self) -> list[str]:
"""pricebot 上游实例列表。空 → 单实例兜底 [PRICEBOT_BASE_URL]。"""
if not self.PRICEBOT_INSTANCES.strip():
return [self.PRICEBOT_BASE_URL]
return [u.strip() for u in self.PRICEBOT_INSTANCES.split(",") if u.strip()]
# ===== 内部(server→server)端点密钥 =====
# pricebot 比价 done 后把价格观测 POST 到 /internal/price-observation 落库,
# 靠这个共享密钥头(X-Internal-Secret)校验,与 pricebot 侧 INTERNAL_API_SECRET 同值。
# 默认空 = 内部写端点关闭(返 503),启用前两边都要配上同一高熵串。
INTERNAL_API_SECRET: str = ""
# ===== 媒体文件(用户头像上传)=====
# 落盘根目录(data/ 已 gitignore,上传不进库);对外经 StaticFiles 挂在 MEDIA_URL_PREFIX。
# 生产可改由 nginx 直接 serve MEDIA_ROOT,绕过应用进程。
+5 -76
View File
@@ -1,91 +1,20 @@
"""统一日志配置。
业务代码用 `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)
业务代码用 `logger = logging.getLogger("shagua.xxx")` 即可,本模块在 main.py 启动时调一次。
"""
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
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"
logging.basicConfig(
level=level,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
stream=sys.stdout,
)
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
-82
View File
@@ -1,82 +0,0 @@
"""pricebot 上游实例选择:按 trace_id 一致性 hash(ketama 风格)选实例。
单机多进程下,保证同一 trace_id(一次比价/领券的所有帧)落同一 pricebot 进程,
从而用进程内内存维护 session/coordinator,不需要 Redis。
为什么用一致性 hash 而非简单取模(crc32 % N):
加/减实例时,取模会让几乎所有 trace 重新映射(扩缩容全量中断进行中的比价);
一致性 hash(虚拟节点)只重映射约 1/N 的 trace,扩缩容对存量冲击最小。
⚠️ 但内存态下,被重映射的那 1/N trace 仍会丢失 session(状态没外置),
所以扩缩容仍建议挑低峰。
hash 用 md5(纯函数),app-server 多 worker / 多实例算出的环一致,亲和不会因
app-server 自身水平扩展而被破坏。
"""
from __future__ import annotations
import bisect
import hashlib
from typing import Dict, List, Optional
from app.core.config import settings
# 每个真实实例在 hash 环上的虚拟节点数。越多分布越均匀。实例数少时(6~10)需要更多
# 虚拟节点才均匀:实测 150→负载偏差~16%、1000→~6%。环只在启动/实例变更时构建一次,
# 1000×N 个点的构建与 bisect 查找开销都可忽略。
_VNODES_PER_NODE = 1000
def _hash(s: str) -> int:
"""取 md5 前 8 hex(32-bit)做环坐标。确定性、跨进程一致。"""
return int(hashlib.md5(s.encode("utf-8")).hexdigest()[:8], 16)
class _HashRing:
"""ketama 风格一致性 hash 环。不可变,实例列表变化时整体重建(见 _get_ring)。"""
def __init__(self, nodes: List[str]):
self._keys: List[int] = [] # 升序的环坐标
self._key_to_node: Dict[int, str] = {}
for node in nodes:
for v in range(_VNODES_PER_NODE):
h = _hash(f"{node}#{v}")
# 极小概率撞坐标,撞了跳过该虚拟节点(不影响正确性,仅少一个 vnode)
if h not in self._key_to_node:
self._key_to_node[h] = node
self._keys.append(h)
self._keys.sort()
def pick(self, key: str) -> str:
h = _hash(key)
idx = bisect.bisect(self._keys, h)
if idx == len(self._keys):
idx = 0 # 环回绕
return self._key_to_node[self._keys[idx]]
# 按实例列表缓存环,列表变了(改 PRICEBOT_INSTANCES + 重启)才重建。
_ring: Optional[_HashRing] = None
_ring_nodes: tuple = ()
def _get_ring(nodes: List[str]) -> _HashRing:
global _ring, _ring_nodes
key = tuple(nodes)
if _ring is None or key != _ring_nodes:
_ring = _HashRing(nodes)
_ring_nodes = key
return _ring
def pick_pricebot(trace_id: Optional[str]) -> str:
"""按 trace_id 选 pricebot 实例 base url。
- 实例列表来自 settings.pricebot_instances(空则单实例兜底 [PRICEBOT_BASE_URL])
- trace_id 缺失/空 或 单实例 → 直接返回第一个,不进环
"""
nodes = settings.pricebot_instances
if len(nodes) == 1 or not trace_id:
return nodes[0]
# str() 防御:协议保证 trace_id 是 str,但万一传入非 str(如 int)也不至于在
# _hash 的 .encode() 处炸,确定性地选到实例。
return _get_ring(nodes).pick(str(trace_id))
+6 -18
View File
@@ -34,9 +34,8 @@ COIN_PER_YUAN: int = 10000
CENTS_PER_YUAN: int = 100
# 1 分对应多少金币 = 100。兑换金币数必须是它的整数倍,否则会出现不足 1 分的零头。
COIN_PER_CENT: int = COIN_PER_YUAN // CENTS_PER_YUAN
# 单次兑换最少 1 分(=COIN_PER_CENT;产品定:可兑 1 分起,与 step 同粒度)。早先为 1 元(=COIN_PER_YUAN),
# 用户 2026-06 改为 1 分:让"换现金"小额即可用(也方便联调验证资产卡飞金币动画)。
MIN_EXCHANGE_COIN: int = COIN_PER_CENT
# 单次兑换最少 1 元(避免大量 1 分级碎兑)
MIN_EXCHANGE_COIN: int = COIN_PER_YUAN
def coins_to_cents(coin_amount: int) -> int:
@@ -55,7 +54,7 @@ TASK_ENABLE_NOTIFICATION = "enable_notification"
# task_key -> 奖励金币
# 打开消息提醒: 1000 金币(=¥0.1, 客户端原型展示口径; 量级与签到/里程碑相称)。
# 注意: 不再 = 兑换下限(下限已降到 MIN_EXCHANGE_COIN=COIN_PER_CENT=100), test_exchange_flow 改走 grant_coins 直接供款。
# 注意: 不再 = 兑换下限(MIN_EXCHANGE_COIN=10000), test_exchange_flow 改走 grant_coins 直接供款。
TASK_REWARDS: dict[str, int] = {
TASK_ENABLE_NOTIFICATION: 1000,
}
@@ -83,20 +82,9 @@ def record_milestone_reward(milestone: int) -> int:
AD_REWARD_COIN: int = 666
# 单次发奖金币上限:夹紧穿山甲回调里异常的 reward_amount(如后台多打一个 0),防刷爆余额。
MAX_AD_REWARD_COIN: int = 1000
# 每用户每日发奖次数上限——现作为"看广告时长上限"的**次数兜底**(防前端少报观看时长绕过时长闸)
# 主闸是 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
# 每用户每日发奖次数上限,防刷 + 控成本
# ⚠️ 测试阶段值=20:正式上线前按产品策略 + 广告 eCPM 实测收益重新核定,改这里即可。
DAILY_AD_REWARD_LIMIT: int = 20
# 一轮看 N 次激励视频,看完一轮强制 10 分钟冷却(reward-status 派生 cooldown_until 给客户端,
# 客户端任务行 CTA 在冷却期间显示"MM:SS"且不可点)。冷却是 UX 约束,后端发奖只看 daily_limit,
# 冷却期间穿山甲回调仍照常发奖,不另设拒发分支。
-165
View File
@@ -24,10 +24,6 @@ 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
@@ -203,164 +199,3 @@ 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()}
-5
View File
@@ -20,7 +20,6 @@ from app.api.v1.compare import router as compare_router
from app.api.v1.compare_milestone import router as compare_milestone_router
from app.api.v1.compare_record import router as compare_record_router
from app.api.v1.coupon import router as coupon_router
from app.api.internal.price import router as internal_price_router
from app.api.v1.feedback import router as feedback_router
from app.api.v1.meituan import router as meituan_router
from app.api.v1.order import router as order_router
@@ -30,7 +29,6 @@ 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
@@ -84,15 +82,12 @@ 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)
app.include_router(ad_router)
app.include_router(order_router)
app.include_router(report_router)
# 内部(server→server)端点:pricebot 上报价格观测,靠共享密钥头校验,不对客户端开放。
app.include_router(internal_price_router)
# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。
_media_root = Path(settings.MEDIA_ROOT)
-2
View File
@@ -1,13 +1,11 @@
"""所有 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
from app.models.price_observation import PriceObservation # noqa: F401
from app.models.price_report import PriceReport # noqa: F401
from app.models.savings import SavingsRecord # noqa: F401
from app.models.signin import SigninRecord # noqa: F401
-41
View File
@@ -1,41 +0,0 @@
"""看激励视频观看时长记录(每日总时长防刷)。
每条 = 客户端 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}>"
+1 -1
View File
@@ -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, coupon_name}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名)
# 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved}](price/coupon_saved 单位:元,原样存)
comparison_results: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 目标平台未找到、跳过的菜名
skipped_dish_names: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
-111
View File
@@ -1,111 +0,0 @@
"""价格观测表(price_observation)—— 比价沉淀的价格资产层。
每完成一次比价,pricebot 在 done 帧把"在某平台某店,这一篮菜/这单到手价多少钱"
按平台逐条算出来,server→server 内部上报落这里。**与登录无关、不依赖客户端上报**——
比价透传链路当前不鉴权,只要比价跑到 done 就记,匿名用户也记(来源记 device_id,
user_id 客户端带上时一并记)。
与 comparison_record 的区别:
- comparison_record:用户视角的「我的比价记录」,客户端登录后主动上报,按 user_id 存。
- price_observation:**平台/门店视角的价格事实**,server 侧无条件沉淀,按 (平台,门店,菜,时间)
组织,是未来"别人查过同店→直接秒回价格"大数据的源头。两表独立,互不影响。
「先存下来、用法后说」:结构化列给将来按 平台/门店/菜/时间/地理 查询聚合用;dishes / attrs
(JSONB)兜底存灵活明细,免得每多记一个字段就迁移 schema。raw trace(逐 step 全过程)
另存 pricebot 本地 jsonl.gz,本表只存提炼后的价格事实(本表可从 raw 重算)。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
JSON,
Boolean,
DateTime,
Float,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
# PG 上用 JSONB(可建 GIN 索引),SQLite(本地/测试)退化为通用 JSON(同 comparison_record)。
_JSON = JSON().with_variant(JSONB(), "postgresql")
class PriceObservation(Base):
__tablename__ = "price_observation"
__table_args__ = (
# 一次比价(trace)里,同一平台、同一口径(scope)只记一条:pricebot 重试 / 客户端
# replay 重复上报时幂等去重(insert-or-ignore)。一次外卖比价 = 源 + N 个目标平台
# 各一条 scope='order'。
UniqueConstraint(
"trace_id", "platform", "scope",
name="uq_price_obs_trace_platform_scope",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 观测时刻(≈ 比价 done 时;服务端落库时取 now)。按时间查"最近有效价"用,建索引。
observed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
# pricebot 侧 trace_id:回指 A 层原始 trace(能溯源 / 重算)+ 幂等去重键
trace_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
# 业务类型:food(外卖,当前唯一接通)/ ecom(电商,二期)。预留扩展。
business_type: Mapped[str] = mapped_column(
String(16), nullable=False, default="food", index=True
)
# ===== 门店维度(先 denormalize 成文本,不建维度表;实体归一二期再做)=====
platform: Mapped[str] = mapped_column(String(32), index=True, nullable=False)
# 平台内门店 ID(抓得到就存,将来归一最稳的 key;现在多为空)
platform_store_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
store_name: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
city: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 地理(现在拿不到 → 留空;二期把 device 经纬度透传进比价请求后回填)
geohash: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
lng: Mapped[float | None] = mapped_column(Float, nullable=True)
lat: Mapped[float | None] = mapped_column(Float, nullable=True)
# ===== 价格事实 =====
# 是否源平台(发起比价那家)。源平台价也是真实观测,照记。
is_source: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# 口径:order(整单到手价,外卖当前唯一)/ dish(单菜单价,引擎暂不逐菜读,二期)
scope: Mapped[str] = mapped_column(String(16), nullable=False, default="order")
# 该口径的到手价(分);None = 该平台采集失败 / 门店打烊,无有效价
price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
coupon_saved_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
coupon_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 该平台目标店打烊原因(非空时 price_cents 为 None)
store_closed: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 本次比价里的全局名次(1=最便宜;按到手价升序,源/目标统一排)
rank: Mapped[int | None] = mapped_column(Integer, nullable=True)
# ===== 明细(JSON)=====
# 菜篮 [{name, qty, subtotal?}](外卖)。dish 级单价二期从这里 + 引擎增强推。
dishes: Mapped[list | None] = mapped_column(_JSON, nullable=True)
# 灵活字段兜底(配送费 / 起送 / 活动名 / 跳过菜数 等),免得加字段就迁移。
attrs: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
# ===== 来源(溯源 / 去重 / 置信)=====
source_device_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# user_id 当前比价链路不鉴权拿不到,客户端带上时才有;先可空。
source_user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=1.0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<PriceObservation id={self.id} platform={self.platform} "
f"store={self.store_name!r} price_cents={self.price_cents} "
f"trace_id={self.trace_id}>"
)
+6 -48
View File
@@ -65,14 +65,11 @@ class CoinTransaction(Base):
class WithdrawOrder(Base):
"""提现单(现金 → 微信零钱)。状态机:
reviewing →(管理员通过)→ pending → success / failed
reviewing →(管理员拒绝)→ rejected(已退款)
"""提现单(现金 → 微信零钱)。状态机:pending → success / failed。
扣现金 + 写 cash_transaction(withdraw) 与建本单在同一事务,初始 reviewing(待人工审核,
**不打款**);管理员审核通过才调微信转账(execute_withdraw_transfer)→ pending,拒绝则退回
现金 + 置 rejected。微信侧失败/取消时退回现金并写 cash_transaction(withdraw_refund) → failed
wechat_state 存微信侧原始状态(WAIT_USER_CONFIRM / SUCCESS / FAIL / CANCELLED ...)。
扣现金 + 写 cash_transaction(withdraw) 与建本单在同一事务;失败/取消时退回现金
并写 cash_transaction(withdraw_refund)。wechat_state 存微信侧原始状态(WAIT_USER_CONFIRM /
SUCCESS / FAIL / CANCELLED ...),status 是我们归一化后的三态
"""
__tablename__ = "withdraw_order"
@@ -84,10 +81,8 @@ 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)
# 提现实名(微信达额转账要求):审核后异步打款时要用,发起提现时存下,可空
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")
# 归一化状态:pending / success / failed
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
# 微信侧原始状态
wechat_state: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 微信转账单号
@@ -107,43 +102,6 @@ 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):
"""现金流水(单位:分)。金币兑现金、提现都记在这里。"""
+7 -16
View File
@@ -18,11 +18,10 @@ from sqlalchemy.orm import Session
from app.core import rewards
from app.core.ad_cooldown import compute_cooldown
from app.core.rewards import DAILY_AD_WATCH_SECONDS_LIMIT, cn_today
from app.core.rewards import 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):
@@ -72,12 +71,8 @@ def grant_ad_reward(
today = cn_today().isoformat()
# #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:
# #3 每日上限:超了记一条 capped(不发金币),让审计能看到"今天到顶了"
if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db):
rec = AdRewardRecord(
trans_id=trans_id, user_id=user_id, coin=0, status="capped",
reward_date=today, reward_name=reward_name, raw=raw,
@@ -131,14 +126,12 @@ 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, int, int]:
) -> tuple[int, int, int, int, datetime | None]:
"""客户端查"今日看广告发奖"进度。
返回 (今日已发次数, 每日次数上限, 单次金币, 本轮已看次数, 本轮冷却结束时间(UTC),
今日已观看总秒数, 每日观看总时长上限(秒))。
次数上限/单次金币/每轮次数/冷却秒 均从 app_config 读(运营后台可改);次数维度的"本轮已看
几次 + 冷却到几点"委托 [app.core.ad_cooldown.compute_cooldown](纯函数);时长维度(50 分钟
主闸)查 ad_watch_log 当日累计——前端据此展示"今日已看 X/50 分钟"+ 满则不给看。
返回 (今日已发次数, 每日上限, 单次金币, 本轮已看次数, 本轮冷却结束时间(UTC))。
上限/单次金币/每轮次数/冷却秒 均从 app_config 读(运营后台可改);冷却策略判断委托
[app.core.ad_cooldown.compute_cooldown](纯函数)。
"""
today = cn_today().isoformat()
granted_desc = _granted_times_today_desc(db, user_id, today)
@@ -154,6 +147,4 @@ def today_status(
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,
)
-36
View File
@@ -1,36 +0,0 @@
"""看激励视频观看时长 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)
-86
View File
@@ -1,86 +0,0 @@
"""价格观测落库:一次比价的整批观测幂等写入。
幂等键 = (trace_id, platform, scope)。pricebot 重试 / replay 会重复上报同一 trace,
这里先查该 trace 已存在的 (platform, scope),只插新的——跨方言(PG / SQLite dev)都安全,
不依赖 ON CONFLICT。极端并发下若仍撞唯一约束,IntegrityError 回滚后忽略(本批本就幂等)。
"""
from __future__ import annotations
import logging
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models.price_observation import PriceObservation
from app.schemas.price_observation import PriceObservationBatchIn
logger = logging.getLogger("shagua.price_obs")
def insert_batch(db: Session, payload: PriceObservationBatchIn) -> tuple[int, int]:
"""写入一次比价的整批观测,返回 (inserted, skipped)。
skipped = 因 (trace_id, platform, scope) 已存在而幂等跳过的条数。
"""
if not payload.observations:
return 0, 0
# 该 trace 已有的 (platform, scope),用于幂等过滤
existing: set[tuple[str, str]] = set(
db.execute(
select(PriceObservation.platform, PriceObservation.scope).where(
PriceObservation.trace_id == payload.trace_id
)
).all()
)
inserted = 0
skipped = 0
for obs in payload.observations:
key = (obs.platform, obs.scope)
if key in existing:
skipped += 1
continue
existing.add(key) # 同批内去重(防 pricebot 同帧给重复平台)
db.add(
PriceObservation(
trace_id=payload.trace_id,
business_type=payload.business_type,
platform=obs.platform,
platform_store_id=obs.platform_store_id,
store_name=payload.store_name,
city=payload.city,
geohash=payload.geohash,
lng=payload.lng,
lat=payload.lat,
is_source=obs.is_source,
scope=obs.scope,
price_cents=obs.price_cents,
coupon_saved_cents=obs.coupon_saved_cents,
coupon_name=obs.coupon_name,
store_closed=obs.store_closed,
rank=obs.rank,
dishes=payload.dishes,
attrs=obs.attrs,
source_device_id=payload.source_device_id,
source_user_id=payload.source_user_id,
)
)
inserted += 1
if inserted == 0:
return 0, skipped
try:
db.commit()
except IntegrityError:
# 并发下另一个请求刚插了同 (trace,platform,scope) → 唯一约束撞了。
# 本批本就幂等,回滚后当作全跳过(不重试,失败就失败,见 P0 约定)。
db.rollback()
logger.warning(
"price_observation 并发幂等冲突 trace=%s,本批回滚跳过", payload.trace_id
)
return 0, skipped + inserted
return inserted, skipped
+1 -5
View File
@@ -52,7 +52,6 @@ class SavingsBattle:
week_saved_cents: int # 本周(周一起)已省
beat_percent: int # 超过百分之多少用户
streak_days: int # 连续省钱天数
compare_count: int # 累计完成比价次数(= 有效记录数:真实用 compare 记录、否则 demo 兜底)
def _local_date(dt: datetime):
@@ -164,10 +163,7 @@ 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,
compare_count=len(records),
week_saved_cents=week_saved, beat_percent=beat_percent, streak_days=streak
)
+32 -299
View File
@@ -7,7 +7,6 @@
from __future__ import annotations
import re
import unicodedata
import uuid
from datetime import datetime, timedelta, timezone
@@ -15,25 +14,15 @@ from sqlalchemy import select, update
from sqlalchemy.orm import Session
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,
WechatTransferAuthorization,
WithdrawOrder,
)
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, 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):
@@ -68,10 +57,6 @@ 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)
@@ -269,18 +254,10 @@ def _add_cash(db: Session, user_id: int, amount_cents: int) -> int:
return bal
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 # 防重复退款(并发/对账与查单/重复拒绝同时触发)
def _refund_withdraw(db: Session, order: WithdrawOrder, reason: str) -> None:
"""转账失败/取消:退回现金 + 写 withdraw_refund 流水 + 单子置 failed。幂等:已 failed 不重复退。"""
if order.status == "failed":
return # 防重复退款(并发/对账与查单同时触发)
bal = _add_cash(db, order.user_id, order.amount_cents)
db.add(
CashTransaction(
@@ -289,11 +266,10 @@ def _refund_withdraw(
balance_after_cents=bal,
biz_type="withdraw_refund",
ref_id=order.out_bill_no,
# 用户可见文案区分"未成功(自动退)"vs"审核未通过";技术原因记在 order.fail_reason
remark="提现审核未通过,金额已退回" if final_status == "rejected" else "提现未成功,金额已退回",
remark="提现未成功,金额已退回", # 用户可见;技术原因记在 order.fail_reason
)
)
order.status = final_status
order.status = "failed"
order.fail_reason = reason[:256]
db.commit()
@@ -351,26 +327,22 @@ def create_withdraw(
user_name: str | None = None,
out_bill_no: str | None = None,
) -> WithdrawOrder:
"""发起提现:原子扣款 + 建单 reviewing(待人工审核),**不打款**
"""发起提现(原子扣款 + 幂等 + 模糊失败查单)
打款推迟到管理员在 admin 后台审核通过(approve_withdraw → execute_withdraw_transfer)才发起;
拒绝则退回现金(reject_withdraw)
#1 用原子条件 UPDATE 扣款,杜绝并发超额——且"待审核期间钱已扣减",防止用户拿同一笔余额
重复发起多笔提现(审核拒绝再退回)。
#2 out_bill_no 客户端幂等键:同号重试返回该单现状(reviewing 等审核),不重复扣款建单。
实名 user_name 在此存下(WithdrawOrder.user_name),供异步审核打款时传给微信(达额需实名)。
#1 用原子条件 UPDATE 扣款,杜绝并发超额。
#2 out_bill_no 由客户端提供作幂等键:同号重试不会重复转账,直接返回该单当前状态
#3 微信调用结果不明时先查单再决定,绝不盲目退款。
返回提现单(可能含 package_info 供 App 拉起确认页)。
"""
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 幂等:该单已存在 → 直接返回现状(reviewing 等审核 / 已处理态原样返回),不重复扣款。
# 不再像旧版对 pending 查微信:本函数不再涉及微信,查单交给 /withdraw/status 与对账任务。
# #2 幂等:客户端传了合法 out_bill_no 且该单已存在 → 返回现状(pending 的顺手查一下),不重复发起
if out_bill_no and _OUT_BILL_NO_RE.match(out_bill_no):
existing = db.execute(
select(WithdrawOrder).where(
@@ -378,6 +350,8 @@ 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
@@ -400,282 +374,41 @@ 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,
user_name=user_name,
status="reviewing",
user_id=user_id, out_bill_no=out_bill_no, amount_cents=amount_cents, status="pending"
)
db.add(order)
db.commit()
db.refresh(order)
return order # 待管理员审核;**不在此处打款**
# ===== 免确认收款授权(用户授权免确认模式)=====
# 用户授权一次后,后续提现走 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
# 2) 调微信转账;结果不明先查单再决定(#3)
try:
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:
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,
}
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") # 免确认转账无此字段(None);确认模式带它供拉确认页
if data.get("state") == _WX_STATE_SUCCESS:
order.status = "success"
db.commit()
db.refresh(order)
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
)
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
if result["status_code"] != 200:
_settle_after_ambiguous(db, order, reason=str(result["data"].get("message") or result["data"]))
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
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")
data = result["data"]
order.wechat_state = data.get("state")
order.transfer_bill_no = data.get("transfer_bill_no")
order.package_info = data.get("package_info")
if data.get("state") == _WX_STATE_SUCCESS:
order.status = "success"
db.commit()
db.refresh(order)
return order
-21
View File
@@ -37,10 +37,6 @@ 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):
@@ -61,23 +57,6 @@ 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):
"""[仅本地联调]模拟发奖结果。带上今日进度,客户端可直接据此刷新展示。"""
-3
View File
@@ -37,9 +37,6 @@ 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):
-49
View File
@@ -1,49 +0,0 @@
"""价格观测内部上报的收发模型。
pricebot 在比价 done 帧 server→server POST 一批观测(一次比价 = 源 + N 目标平台各一条)。
批级字段(门店 / 菜篮 / 地理 / 来源)所有观测共享,放外层;逐平台字段放 observations[]。
"""
from __future__ import annotations
from pydantic import BaseModel, Field
class PriceObservationItem(BaseModel):
"""单个平台的价格观测(一次比价里的一行)。"""
platform: str
is_source: bool = False
scope: str = "order" # order / dish
price_cents: int | None = None # 到手价(分);None=该平台失败/打烊
coupon_saved_cents: int | None = None
coupon_name: str | None = None
store_closed: str | None = None # 打烊原因(非空时 price_cents=None)
rank: int | None = None # 全局名次(1=最便宜)
platform_store_id: str | None = None
attrs: dict | None = None # 该平台的灵活明细兜底
class PriceObservationBatchIn(BaseModel):
"""一次比价的整批价格观测上报体。"""
trace_id: str
business_type: str = "food"
# 门店 / 菜篮 / 地理 —— 一次比价同一份,批级共享
store_name: str | None = None
city: str | None = None
geohash: str | None = None
lng: float | None = None
lat: float | None = None
dishes: list | None = None # [{name, qty, subtotal?}]
# 来源
source_device_id: str | None = None
source_user_id: int | None = None
# 逐平台观测(源 + 各目标)
observations: list[PriceObservationItem] = Field(default_factory=list)
class PriceObservationBatchOut(BaseModel):
"""上报结果:本批新写入条数 / 因幂等跳过条数。"""
inserted: int
skipped: int
+3 -33
View File
@@ -80,23 +80,6 @@ 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):
@@ -119,18 +102,11 @@ 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="reviewing(待审核) / pending / success / failed / rejected")
status: str = Field(..., description="pending / success / failed")
wechat_state: str | None = Field(None, description="微信侧原始状态")
amount_cents: int
cash_balance_cents: int = Field(..., description="提现后现金余额(分)")
@@ -142,14 +118,9 @@ class WithdrawResultOut(BaseModel):
class WithdrawStatusOut(BaseModel):
out_bill_no: str
status: str = Field(..., description="reviewing(待审核) / pending / success / failed / rejected")
status: str = Field(..., description="pending / success / failed")
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):
@@ -158,7 +129,7 @@ class WithdrawOrderOut(BaseModel):
id: int
out_bill_no: str
amount_cents: int
status: str = Field(..., description="reviewing(待审核) / pending / success / failed / rejected")
status: str
wechat_state: str | None = None
fail_reason: str | None = None
created_at: datetime
@@ -223,7 +194,6 @@ 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):
+1 -21
View File
@@ -3,7 +3,7 @@
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
> 最后更新:2026-06-04+ 运营后台 Admin 子应用 A1–A18,见下方「运营后台 Admin」组
> 最后更新:2026-05-31+ 比价战绩里程碑 12d/12e
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
---
@@ -69,26 +69,6 @@
| **静态资源**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——见各接口详情)。
-33
View File
@@ -1,33 +0,0 @@
# POST /admin/api/admins — 创建管理员
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins` | 鉴权:Bearer admin_token(角色:super_admin | [← 返回 API 索引](./README.md)
## 入参
**application/json**:
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `username` | string | ✓ | — | 账号,364 字 |
| `password` | string | ✓ | — | 初始密码,872 字(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` 长度不在 364 / `password` 长度不在 872 / `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)。
-39
View File
@@ -1,39 +0,0 @@
# 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 | ✗ | 重置密码,872 字(传则覆盖原密码) |
## 出参
响应 `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` 长度不在 872
## 说明
- 更新成功后写一条审计:`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)。
-26
View File
@@ -1,26 +0,0 @@
# 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)。
-35
View File
@@ -1,35 +0,0 @@
# 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 | 1100 |
| `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)。
-40
View File
@@ -1,40 +0,0 @@
# POST /admin/api/auth/login — 管理员登录
> 所属:Admin·Auth 组(前缀 `/admin/api/auth` | 鉴权:无 | [← 返回 API 索引](./README.md)
## 入参
**application/json**:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `username` | string | ✓ | 管理员账号,164 字 |
| `password` | string | ✓ | 密码,1128 字 |
## 出参
响应 `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 完全隔离。
-24
View File
@@ -1,24 +0,0 @@
# 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`)
## 说明
-23
View File
@@ -1,23 +0,0 @@
# 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)。
-34
View File
@@ -1,34 +0,0 @@
# 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 | 1100 |
| `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>` 静态读。
-69
View File
@@ -1,69 +0,0 @@
# 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)。
-29
View File
@@ -1,29 +0,0 @@
# 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)。
-43
View File
@@ -1,43 +0,0 @@
# 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)。
-25
View File
@@ -1,25 +0,0 @@
# 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)。
-37
View File
@@ -1,37 +0,0 @@
# 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 | 1100 |
| `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` 过滤的分页接口。
@@ -1,35 +0,0 @@
# 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 | 1100 |
| `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)。所有金额字段单位为分。
@@ -1,35 +0,0 @@
# 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 | 1100 |
| `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)。金币为个数计量,非分。
-37
View File
@@ -1,37 +0,0 @@
# 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)。
-39
View File
@@ -1,39 +0,0 @@
# 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)。金额单位为分。
-37
View File
@@ -1,37 +0,0 @@
# 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 | 1100 |
| `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)。金额单位为分。
+1 -2
View File
@@ -31,10 +31,9 @@
**Item**`{ name: string, qty: int=1, specs: string[]\|null }`
**Resultcomparison_results 元素)**`{ platform_id, platform_name, package, price(元,float\|null), is_source(bool), rank(int\|null), coupon_saved(元,float\|null), coupon_name(string\|null) }`
**Resultcomparison_results 元素)**`{ platform_id, platform_name, package, price(元,float\|null), is_source(bool), rank(int\|null), coupon_saved(元,float\|null) }`
- `coupon_saved`:该平台本单**平台主优惠额**(元)——美团红包 / 淘宝平台红包 / 京东优惠券·百亿补贴,**只取那一笔**,不含配送费减免/共减总额。仅外卖目标平台带值,源平台/没用为 `null`,前端记录页据此展示**「已优惠 ¥X」**(null 不展示)。
- `coupon_name`:优惠**来源名**(展示用,best-effort)——美团「外卖大额神券」/ 京东「百亿补贴」/ 淘宝「平台红包」。仅在该行有 `coupon_saved` 时带;`null` 时前端走通用「红包」。非用户选中的具体券名(选中态不可读)。
## 服务端派生(客户端不用算)
+1 -2
View File
@@ -13,7 +13,6 @@
| `week_saved_cents` | int | 本周已省(分) |
| `beat_percent` | int | 超过百分之多少用户 |
| `streak_days` | int | 连续省钱天数 |
| `compare_count` | int | 累计完成比价次数(= 比价上报记录数;有真实 `compare` 记录用真实数,否则 demo 兜底) |
## 说明
「我的」页「省钱战绩」卡数据源(三列:本周已省 / 完成比价 / 连续比价)。`compare_count``/summary``order_count` 同源(均 = 有效记录数),对应原型「完成比价(次)」列。接口已通但无真实业务写入时走 demo 兜底(seed 约 23 条)
「我的」页「省钱战绩」卡数据源。接口已通但无真实业务写入,实际多为 0
-148
View File
@@ -1,148 +0,0 @@
# 数据库总览 — 表 × 功能 × 关系
> 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。
> 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **19 张业务表** + `alembic_version`(框架的迁移版本指针)。
---
## 一、按功能域看表(每张表用在 App 哪里)
### 比价(核心业务)
| App 位置 / 动作 | 表 | 说明 |
|---|---|---|
| 比价/领券**过程**(看屏→决策→操作) | (无) | 在 pricebot-backend 内存态跑,**过程不落库**;只有结果回到 app-server 才落库 |
| 「我的比价记录」列表 / 详情 | [`comparison_record`](./comparison_record.md) | 每次比价 done 后客户端带 JWT 上报一条完整明细 |
| 比价战绩里程碑(逐档领金币) | [`comparison_milestone_claim`](./comparison_milestone_claim.md) | 累计成功比价 N 次解锁;进度读 `comparison_record` 计数 |
| profile「累计省了 / 省钱战绩 / 省钱明细」 | [`savings_record`](./savings_record.md) | 真实下单归因(source=compare)+ 无真实数据时 demo 兜底 |
| 「上报更低价」提交 / 列表 | [`price_report`](./price_report.md) | 众包纠偏:用户举证某平台更便宜,人工审核发奖 |
### 钱包 / 福利(看广告赚钱闭环)
| App 位置 / 动作 | 表 | 说明 |
|---|---|---|
| 资产卡 / 钱包余额 | [`coin_account`](./coin_account.md) | 一用户一行的金币+现金余额快照 |
| 金币明细 | [`coin_transaction`](./coin_transaction.md) | 每次金币变动一笔流水 |
| 现金明细 | [`cash_transaction`](./cash_transaction.md) | 每次现金变动一笔流水(分) |
| 每日签到 | [`signin_record`](./signin_record.md) | 7 天循环发币 |
| 一次性任务(开消息提醒等) | [`user_task`](./user_task.md) | 领一次发币 |
| 看激励视频赚金币 | [`ad_reward_record`](./ad_reward_record.md) + [`ad_watch_log`](./ad_watch_log.md) + [`ad_ecpm_record`](./ad_ecpm_record.md) | **三条独立数据流**:发奖 / 时长闸 / 收益对账 |
| 金币兑现金 | `coin_account` + `coin_transaction` + `cash_transaction` | exchange_out + exchange_in 两笔流水 |
| 提现到微信零钱 | [`withdraw_order`](./withdraw_order.md) + [`wechat_transfer_authorization`](./wechat_transfer_authorization.md) + `cash_transaction` | 人工审核 + 微信商家转账 |
| 绑定微信(提现前置) | `user`.wechat_* | openid 唯一,一微信一账号 |
### 账号 / 反馈
| App 位置 / 动作 | 表 | 说明 |
|---|---|---|
| 登录(极光/短信)/ 改资料 / 注销 | [`user`](./user.md) | 登录主体,注册即登录 |
| 帮助与反馈 | [`feedback`](./feedback.md) | 含截图,后台人工处理 |
### 运营后台 admin(独立子应用 `app/admin/`,端口 8771,独立鉴权)
| 后台模块 | 表 | 说明 |
|---|---|---|
| 管理员账号 / 登录 | [`admin_user`](./admin_user.md) | 与 C 端 `user` 完全隔离,独立 JWT + RBAC |
| 操作审计 | [`admin_audit_log`](./admin_audit_log.md) | 每个写操作落一条,只增不改不删 |
| 运营可配置项(改奖励常量) | [`app_config`](./app_config.md) | 空表 = 用代码默认;后台改了即覆盖 |
| 用户/钱包/提现/反馈管理 | 跨读写上面的 C 端表 | 见下「写入路径」admin 段 |
---
## 二、写入路径(什么操作 → 写哪张表)
> C=插入新行 · U=更新已有行 · 同一行内多张表表示**同一事务原子写**。
### C 端(App 用户触发)
| 触发(用户动作 / endpoint / 回调) | 写入 | 操作 |
|---|---|---|
| 登录 `POST /auth/jverify-login``/auth/sms/login` | `user` | C(首次=注册)/ U(`last_login_at`) |
| 改昵称 `PATCH /user/profile`、传头像 `POST /user/avatar` | `user` | U |
| 注销 `DELETE /user` | `user` | U(软删:`phone→deleted_<id>``status=deleted`) |
| 绑/解绑微信 `POST /wallet/bind-wechat``/unbind-wechat` | `user`.wechat_* | U |
| 签到 `POST /signin/do` | `signin_record`(C) + `coin_account`(U) + `coin_transaction`(C `signin`) | 同事务 |
| 领任务 `POST /tasks/claim` | `user_task`(C) + `coin_account`(U) + `coin_transaction`(C `task_<key>`) | 同事务 |
| 金币兑现金 `POST /wallet/exchange` | `coin_account`(U) + `coin_transaction`(C `exchange_out` ) + `cash_transaction`(C `exchange_in` +) | 同事务 |
| 发起提现 `POST /wallet/withdraw` | `withdraw_order`(C `reviewing`) + `coin_account`(U 扣现金) + `cash_transaction`(C `withdraw` ) | 同事务,**不打款** |
| 查提现状态 / 用户取消 `GET /wallet/withdraw/status` | `withdraw_order`(U) + 失败→`cash_transaction`(C `withdraw_refund` +) | |
| 穿山甲发奖 S2S 回调 `POST /ad/pangle-callback` | `ad_reward_record`(C)+ granted→`coin_account`(U)+`coin_transaction`(C `ad_reward`) | `trans_id` 幂等 |
| 看广告时长上报 `POST /ad/watch-report` | `ad_watch_log`(C) | |
| 广告 eCPM 上报 `POST /ad/ecpm-report` | `ad_ecpm_record`(C) | |
| 比价 done 上报 `POST /compare/record` | `comparison_record`(C 或 U) | `(user_id, trace_id)` 幂等覆盖 |
| 领里程碑 `POST /compare/milestone/claim` | `comparison_milestone_claim`(C) | **当前不发币**(coin_awarded=0) |
| 支付归因上报 `POST /order/report` | `savings_record`(C `source=compare`) | `(user_id, client_event_id)` 幂等 |
| 首次进 profile 省钱页且无真实记录 | `savings_record`(C `source=demo`) | 懒种子,`ensure_seeded` 按 user 幂等 |
| 上报更低价 `POST /report` | `price_report`(C) | 读 `comparison_record.best_price_cents` 校验 |
| 提交反馈 `POST /feedback` | `feedback`(C) | |
### admin 端(管理员触发,均额外写一条 `admin_audit_log`)
| 后台操作 | 写入 | 操作 |
|---|---|---|
| 手动增减金币 | `coin_account`(U)+`coin_transaction`(C `admin_grant`/`admin_deduct`)+`admin_audit_log`(C) | 同事务 |
| 改用户状态(禁用/启用) | `user`(U)+`admin_audit_log`(C) | 同事务 |
| 审核通过提现 | `withdraw_order`(U→pending/success/failed)+`wechat_transfer_authorization`(C/U)+失败时`cash_transaction`(refund)+`admin_audit_log`(C) | |
| 审核拒绝提现 | `withdraw_order`(U→rejected)+`cash_transaction`(C `withdraw_refund`)+`admin_audit_log`(C) | |
| 处理反馈 | `feedback`(U)+`admin_audit_log`(C) | 同事务 |
| 改运营配置 | `app_config`(C/U)+`admin_audit_log`(C) | 同事务 |
| 任意写操作 | `admin_audit_log`(C,**永不 U/D**) | |
> 没有任何表会被业务流程物理 DELETE。注销是软删(改 user 行),其余只 C/U。
---
## 三、表间关系 & Join Key
### 硬外键(数据库 FK 约束)
- **15 张用户维度表 `.user_id``user.id`**:`coin_account`(同时是 PK)、`coin_transaction``cash_transaction``withdraw_order``wechat_transfer_authorization`(同时是 PK)、`signin_record``user_task``comparison_record``comparison_milestone_claim``savings_record``ad_reward_record``ad_watch_log``ad_ecpm_record``price_report``feedback`
- `admin_audit_log.admin_id``admin_user.id`
- `price_report.comparison_record_id``comparison_record.id`(可空:关联记录被删后仍留上报历史)。
### 语义 join key(无 FK 约束,靠业务字段对齐 —— 排障/对账必看)
- **`coin_transaction.ref_id` 指向随 `biz_type` 变**:
| biz_type | ref_id 指向 | amount 符号 |
|---|---|---|
| `signin` | 当天日期串(= `signin_record.signin_date` 的 ISO `YYYY-MM-DD`) | + |
| `task_<key>` | `user_task.task_key` | + |
| `ad_reward` | `ad_reward_record.trans_id` | + |
| `exchange_out` | null(兑现金,无单据) | |
| `admin_grant` / `admin_deduct` | null(原因记在 `remark`=`admin:<reason>`) | + / |
- **`cash_transaction.ref_id`**:
| biz_type | ref_id 指向 | amount 符号 |
|---|---|---|
| `withdraw` / `withdraw_refund` | `withdraw_order.out_bill_no` | / + |
| `exchange_in` | null | + |
- **`comparison_record.store_name``savings_record.shop_name`**:无 id 关联,按**店名字符串相等**给比价记录打「已下单」标记(瞬态,不写库)。两边店名同源 = 比价意图识别阶段的门店 query,语义=**店级**(同店比价多次会一并标已下单)。
- **三条广告流互不关联**:`ad_reward_record` / `ad_watch_log` / `ad_ecpm_record` 之间**无公共键**,各自只按 `(user_id, 日期串)` 聚合。别试图 join 它们逐条对应。
- **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record``status='success'``count`
### ER 关系(文字版)
```
user ─1:1─ coin_account
user ─1:1─ wechat_transfer_authorization
user ─1:N─ { coin_transaction, cash_transaction, withdraw_order, signin_record,
user_task, comparison_record, comparison_milestone_claim,
savings_record, ad_reward_record, ad_watch_log, ad_ecpm_record,
price_report, feedback }
comparison_record ─1:N─ price_report (comparison_record_id, 可空)
admin_user ─1:N─ admin_audit_log
app_config (独立, 无外键, key 为主键)
```
---
## 四、资金模型(金币 / 现金 / 提现,三层)
1. **余额快照** `coin_account`:`coin_balance`(金币个数)+ `cash_balance_cents`(现金分),一用户一行,读取展示用。
2. **流水账本** `coin_transaction` / `cash_transaction`:每次变动写一笔,`balance_after*` 记变动后余额,可逐笔回溯对账。
3. **唯一发金币入口** `repositories/wallet.grant_coins`:更新快照 + 写流水,**不 commit**,由调用方在同一事务里 commit(保证"记录"和"加币"原子化)。signin / task / ad_reward / exchange / admin 都走它,靠 `biz_type` 区分来源。
- **汇率**:`10000 金币 = 1 元 = 100 分`(`rewards.COIN_PER_YUAN`);兑换额必须是整分倍数。
- **提现状态机**:`reviewing`(发起即原子扣现金、待人工审核、**不打款**)→ 审核通过 `pending`(微信转账在途)→ `success` / `failed`(失败自动退款);审核拒绝 `rejected`(退款)。扣款/退款都写 `cash_transaction`,`out_bill_no` 幂等,孤儿 pending 单由 `reconcile_pending_withdraws` 对账兜底。
- **防超额**:扣现金用带条件 `UPDATE ... WHERE cash_balance_cents >= amount`,并发/重试不会双扣。
---
## 五、通用约定
见 [README → 通用约定](./README.md#通用约定):金额存整数(金币计数 / 现金存分)、业务「今天」按北京时间日期串等值聚合(不在 SQL 跨时区比 date)、JSON 列 PG 用 JSONB·SQLite 退化 JSON、改表必写 alembic 迁移并保持单 head + 同步更新本目录文档。
+23 -45
View File
@@ -3,57 +3,35 @@
> 数据库: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-06-06(补全 19 张表 + 新增 [OVERVIEW 总览](./OVERVIEW.md))
> 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。
> 最后更新:2026-05-31
---
## 表总览(19 张业务表 + `alembic_version` 框架表)
## 表总览
### 账号 / 反馈
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
| `user` | 用户(登录主体);几乎所有表的外键宿主 | `models/user.py` | [详情](./user.md) |
| `feedback` | 用户帮助与反馈(含截图) | `models/feedback.py` | [详情](./feedback.md) |
### 钱包 / 福利(看广告赚钱闭环)
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
| `coin_account` | 金币+现金余额快照(一用户一行) | `models/wallet.py` | [详情](./coin_account.md) |
| `coin_transaction` | 金币流水账本 | `models/wallet.py` | [详情](./coin_transaction.md) |
| `cash_transaction` | 现金流水账本(分) | `models/wallet.py` | [详情](./cash_transaction.md) |
| `withdraw_order` | 提现单(现金→微信零钱,含人工审核态) | `models/wallet.py` | [详情](./withdraw_order.md) |
| `wechat_transfer_authorization` | 微信免确认转账授权(一用户一行) | `models/wallet.py` | [详情](./wechat_transfer_authorization.md) |
| `signin_record` | 签到记录(7 天循环) | `models/signin.py` | [详情](./signin_record.md) |
| `user_task` | 一次性任务领取去重 | `models/task.py` | [详情](./user_task.md) |
| `ad_reward_record` | 看激励视频发奖记录(S2S 回调,trans_id 幂等) | `models/ad_reward.py` | [详情](./ad_reward_record.md) |
| `ad_watch_log` | 看广告观看时长(每日 50min 防刷主闸) | `models/ad_watch_log.py` | [详情](./ad_watch_log.md) |
| `ad_ecpm_record` | 广告展示 eCPM 上报(收益对账) | `models/ad_ecpm.py` | [详情](./ad_ecpm_record.md) |
### 比价 / 省钱
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
| `comparison_record` | 比价记录(每次比价完整明细) | `models/comparison.py` | [详情](./comparison_record.md) |
| `comparison_milestone_claim` | 比价战绩里程碑领取记录 | `models/comparison_milestone.py` | [详情](./comparison_milestone_claim.md) |
| `savings_record` | 省钱记录(profile 省钱战绩源;真实下单归因 + demo) | `models/savings.py` | [详情](./savings_record.md) |
| `price_report` | 上报更低价(众包纠偏,人工审核发奖) | `models/price_report.py` | [详情](./price_report.md) |
### 运营后台 admin(独立子应用 `app/admin/`,独立鉴权)
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
| `admin_user` | 管理员账号(独立 JWT + RBAC) | `models/admin.py` | [详情](./admin_user.md) |
| `admin_audit_log` | 操作审计日志(只追加) | `models/admin.py` | [详情](./admin_audit_log.md) |
| `app_config` | 运营可配置项(覆盖 rewards 常量) | `models/app_config.py` | [详情](./app_config.md) |
| 表 | 用途 | 模型 | 关联模块 | 文档 |
|---|---|---|---|---|
| `user` | 用户(登录主体) | `models/user.py` | 登录/鉴权 | [详情](./user.md) |
| `coin_account` | 金币+现金余额快照(一用户一行) | `models/wallet.py` | 钱包 | [详情](./coin_account.md) |
| `coin_transaction` | 金币流水账本 | `models/wallet.py` | 钱包 | [详情](./coin_transaction.md) |
| `cash_transaction` | 现金流水账本(分) | `models/wallet.py` | 钱包/提现 | [详情](./cash_transaction.md) |
| `withdraw_order` | 提现单(现金→微信零钱) | `models/wallet.py` | 提现 | [详情](./withdraw_order.md) |
| `signin_record` | 签到记录(7 天循环) | `models/signin.py` | 签到 | [详情](./signin_record.md) |
| `user_task` | 一次性任务领取去重 | `models/task.py` | 任务 | [详情](./user_task.md) |
| `savings_record` | 省钱记录(profile 省钱战绩源) | `models/savings.py` | 省钱 | [详情](./savings_record.md) |
| `ad_reward_record` | 看激励视频发奖记录(S2S 回调) | `models/ad_reward.py` | 看广告发奖 | [详情](./ad_reward_record.md) |
| `ad_ecpm_record` | 广告展示 eCPM 上报(收益对账) | `models/ad_ecpm.py` | 看广告 | [详情](./ad_ecpm_record.md) |
| `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) |
---
## 通用约定
- **主键**:`id` Integer autoincrement;例外:`coin_account` / `wechat_transfer_authorization``user_id` 既是主键也是外键(一用户一行),`app_config``key` 作主键
- **外键**:所有用户维度表 `user_id``user.id` 且建 index;`admin_audit_log.admin_id``admin_user.id`;`price_report.comparison_record_id``comparison_record.id`
- **金额**:整数;金币计数,现金/价格存「分」(`*_cents`)。汇率 `10000 金币 = 1 元 = 100 分`
- **时间**:`created_at` 等用 `DateTime(timezone=True)` + `server_default=func.now()`;业务"今天"按**北京时间**(`core/rewards.cn_today`),跨天计数用「日期串」列(`reward_date` / `watch_date` / `report_date`,`String(10)``YYYY-MM-DD`)等值查,**不在 SQL 里做跨时区 date 比较**(SQLite 不可靠)。
- **JSON 列**:`JSON().with_variant(JSONB(), "postgresql")` —— PG 上 JSONB(可建 GIN 索引),SQLite 退化为通用 JSON(否则 `create_all` 编译报错);`feedback.images` 例外,用通用 `JSON`
- **分页**:列表接口统一**游标式**(`id` 倒序,`cursor`=上页最后一条 id,返回 `next_cursor`)。
- **迁移**:改表必写 alembic 迁移并保持单 head;改表/建表同时更新本目录对应文档(一表一文件)+ [OVERVIEW.md](./OVERVIEW.md)。
- **主键**:`id` Integer autoincrement(`coin_account` 例外:`user_id` 既是主键也是外键,一用户一行)。
- **外键**:所有用户维度表 `user_id``user.id`,且建 index。
- **金额**:整数;金币计数,现金/价格存「分」(`*_cents`)。
- **时间**:`created_at` 等用 `DateTime(timezone=True)` + `server_default=func.now()`;业务"今天"按**北京时间**(`core/rewards.cn_today`),跨天计数用「日期串」列(`reward_date`/`report_date`)等值查,不在 SQL 里做跨时区 date 比较(SQLite 不可靠)。
- **JSON 列**:`JSON().with_variant(JSONB(), "postgresql")` —— PG 上 JSONB(可建 GIN 索引),SQLite 退化为通用 JSON(否则 `create_all` 编译报错)。
- **迁移**:改表必写 alembic 迁移并保持单 head;改表/建表同时更新本目录对应文档(一表一文件)。
+12 -18
View File
@@ -1,32 +1,26 @@
# ad_ecpm_record — 广告展示 eCPM 上报(收益对账)
> 模型 `app/models/ad_ecpm.py` · 仓库 `app/repositories/ad_ecpm.py` · 接口 [ad-ecpm-report](../api/ad-ecpm-report.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/ad_ecpm.py` | 关联接口 [ad-ecpm-report](../api/ad-ecpm-report.md) | [← 索引](./README.md)
每条 = 客户端一次广告展示(`onAdShow`)后读到的 eCPM。看广告三条数据流之一(**收益对账**),与 `ad_reward`(发奖,有 trans_id 无 ecpm)、`ad_watch_log`(时长)并列,**无公共键**——只用于**按用户/按天聚合**估算内部广告收益,不做"这条发奖 = 这条展示"的逐条精确关联。穿山甲后台报表才是结算权威,本表是细粒度补充
## 用在哪 / 增删改查
- **C(插入)**:`POST /ad/ecpm-report`(`create_ecpm_record`)。每次广告展示上报一条;best-effort,丢一两条不影响业务。鉴权接口已确保 user 存在。
- **U / D**:无。
- **R**:内部收益统计/对账(按 `(user_id, report_date)` 聚合);`count_today` 排查辅助。当前无面向 C 端用户的读接口。
每条 = 客户端一次广告展示(`onAdShow`)后读到的 eCPM。与发奖记录 `ad_reward_record` 是**两条独立数据流**(发奖走 S2S 有 trans_id 无 ecpm;eCPM 走客户端有 ecpm 无 trans_id),无公共键,只用于**按用户/按天聚合**收益对账,不做逐条精确关联。穿山甲后台报表才是结算权威。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `ad_type` | String(32) | NOT NULL | 广告类型,取值如 `reward_video`(激励视频)/ `draw`(Draw 信息流);各类型各自上报,不强行统一代码位 |
| `adn` | String(32) | nullable | 实际投放 ADN(`getShowEcpm().getSdkName()`,如 `pangle`/`gdt`) |
| `slot_id` | String(64) | nullable | 实际展示代码位(底层 mediation rit,非客户端配置位) |
| `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM **原始串**(单位待确认,原样存) |
| `ad_type` | String(32) | NOT NULL | 广告类型:`reward_video`(激励视频)/ `draw`(Draw 信息流) |
| `adn` | String(32) | nullable | 实际投放 ADN(`getSdkName`,如 pangle/gdt) |
| `slot_id` | String(64) | nullable | 实际展示代码位(底层 mediation rit) |
| `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM 原始串(单位待确认,原样存) |
| `report_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它做按天聚合 |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
## 关系 / Join Key
- `user_id``user.id`(多对一)。
-`ad_reward_record` / `ad_watch_log` **无公共键**(独立数据流),只按 `(user_id, report_date)` 聚合。
## 索引与约束
- PK `id`;index `user_id``report_date``created_at`
- PK: `id`;index: `user_id``report_date``created_at`
## 注意
## 关系
- `user_id``user.id`(多对一)
## 说明
- ⚠️ `ecpm_raw` 单位(分/元)截至 2026-05-31 未最终确认;确认后再加一列解析好的数值,在此之前对账按"待定单位"处理。
+15 -22
View File
@@ -1,35 +1,28 @@
# ad_reward_record — 看激励视频发奖记录(S2S 回调)
> 模型 `app/models/ad_reward.py` · 仓库 `app/repositories/ad_reward.py` · 接口 [ad-pangle-callback](../api/ad-pangle-callback.md) / [ad-reward-status](../api/ad-reward-status.md) / [ad-test-grant](../api/ad-test-grant.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/ad_reward.py` | 关联接口 [ad-pangle-callback](../api/ad-pangle-callback.md) / [ad-reward-status](../api/ad-reward-status.md) | [← 索引](./README.md)
每条 = 穿山甲一次**服务端发奖回调**(用户看完激励视频)。是看广告三条数据流之一(**发奖**;另两条:`ad_watch_log` 时长、`ad_ecpm_record` 收益,三者无公共键)`trans_id` 唯一做幂等键(穿山甲会重试,同号只发一次)。`reward_date`(北京时间日期串)给"每日上限"计数用。
## 用在哪 / 增删改查
- **C(插入)**:`POST /ad/pangle-callback`(穿山甲 S2S,经 SHA256 验签;`grant_ad_reward`)或 `POST /ad/test-grant`(本地联调)。三道闸:① 验签不过 → API 层 403,不进库;② `trans_id` 已存在 → 原样返回不重复发;③ **当日发奖次数(`DAILY_AD_REWARD_LIMIT`)或观看总时长(`DAILY_AD_WATCH_SECONDS_LIMIT`=50min,查 `ad_watch_log`)任一到顶** → 记一行 `status='capped'``coin=0`、不发币。否则 `granted` + `grant_coins(biz_type='ad_reward', ref_id=trans_id)` 加币,同事务。
- **U / D**:无。
- **R**:`GET /ad/reward-status`(看广告页:今日已发次数/上限、单次金币、本轮已看/冷却结束、今日已看时长/上限);审计/对账整表回溯。
每条 = 穿山甲一次发奖回调`trans_id` 唯一做幂等键(穿山甲会重试,同号只发一次)。`reward_date`(北京时间日期串)给"每日上限"计数用,按日期串等值查
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=ad_reward) |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户(回调 media_extra 带回;不存在抛 UnknownUserError) |
| `coin` | Integer | NOT NULL, default 0 | 实发金币;`capped` 时为 0 |
| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数或时长超限,记录但不发) |
| `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值统计当日发奖次数 |
| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键) |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户(回调 media_extra 带回) |
| `coin` | Integer | NOT NULL, default 0 | 实发金币(capped 时为 0) |
| `status` | String(16) | NOT NULL, default `granted` | `granted`(已发)/ `capped`(当日超限未发) |
| `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它统计当日次数 |
| `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) |
| `raw` | String(1024) | nullable | 回调原始参数(审计排查) |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
## 关系 / Join Key
- `user_id``user.id`(多对一)。
- `trans_id` ← 被 `coin_transaction.ref_id` 引用(`granted` 那条发币流水);`capped` 行不发币、无对应流水。
-`ad_watch_log` / `ad_ecpm_record` **无公共键**,仅在发奖时读 `ad_watch_log` 当日时长做闸。
## 索引与约束
- PK `id`;UNIQUE+index `trans_id`;index `user_id``reward_date``created_at`
- PK: `id`;UNIQUE + index: `trans_id`;index: `user_id``reward_date``created_at`
## 注意
- 实发金币以穿山甲回调 `reward_amount` 为准(`resolve_ad_reward_coin` 解析,缺/坏回退默认、超上限夹紧),单次金币/每日上限/单次上限均从 `app_config` 读(运营后台可改)。
- 并发同 `trans_id` 撞唯一约束 → catch IntegrityError 回滚返回已存在那条(幂等兜底)。
## 关系
- `user_id``user.id`(多对一)
## 说明
- 三道闸:验签不过 403 → `trans_id` 唯一幂等(并发 catch IntegrityError)→ 当日次数 ≥ `DAILY_AD_REWARD_LIMIT``capped` 不发币。
- 发币复用 `grant_coins(biz_type='ad_reward', ref_id=trans_id)`
-32
View File
@@ -1,32 +0,0 @@
# ad_watch_log — 看激励视频观看时长(每日防刷主闸)
> 模型 `app/models/ad_watch_log.py` · 仓库 `app/repositories/ad_watch.py` · 接口 `POST /api/v1/ad/watch-report`(上报)、读取面 [ad-reward-status](../api/ad-reward-status.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
每条 = 客户端 `onAdClose` 上报的一次激励视频**实际观看秒数**。按 `(user_id, watch_date)` 聚合 `SUM(watch_seconds)` 得当日总观看时长,用于"每天最多看 50 分钟激励视频"硬上限(`rewards.DAILY_AD_WATCH_SECONDS_LIMIT`)。是看广告三条数据流之一(**时长闸**),与 `ad_reward`(发奖)、`ad_ecpm`(收益)并列、**互不关联**。
> 防刷设计:前端上报时长不可信 → 时长是**主闸**;配合 `ad_reward_record` 的每日**次数兜底**(`DAILY_AD_REWARD_LIMIT`,默认 200),前端少报时长也刷不过次数闸。
## 用在哪 / 增删改查
- **C(插入)**:`POST /ad/watch-report`(`add_watch_seconds`)。每次看完激励视频上报一次 → 写一行,`watch_seconds` 服务端夹 `[0, MAX_SINGLE_WATCH_SECONDS=120]`(防异常大值)。鉴权接口已确保 user 存在,不校验 UnknownUser。
- **U / D**:无。当日多次观看 = 多行,靠 SUM 聚合。
- **R**:`watched_seconds_today` 当日累计 —— 被 `ad_reward.grant_ad_reward`(发奖前的时长闸)和 `GET /ad/reward-status`(展示"今日已看 X/50 分钟" + 满则不给看)调用。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `watch_seconds` | Integer | NOT NULL, default 0 | 本次实际观看秒数(服务端已夹 `[0,120]`) |
| `watch_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值做"当日总时长"聚合 |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
## 关系 / Join Key
- `user_id``user.id`(多对一)。
-`ad_reward_record` / `ad_ecpm_record` **无公共键**:不做逐条关联,只按 `(user_id, watch_date)` 聚合。发奖时 `ad_reward` 读本表当日累计判时长闸。
## 索引与约束
- PK `id`;index `user_id``watch_date``created_at`
## 注意
- 上限走常量 `DAILY_AD_WATCH_SECONDS_LIMIT=3000`(50min)/ `MAX_SINGLE_WATCH_SECONDS=120`(暂未挂 app_config,改值改 `core/rewards.py`)。
- 按日期串等值查,不在 SQL 跨时区比 date(SQLite 不可靠)。
-34
View File
@@ -1,34 +0,0 @@
# admin_audit_log — 运营后台操作审计日志
> 模型 `app/models/admin.py` · 仓库 `app/admin/repositories/audit_log.py`(写 `app/admin/audit.py`) · 接口 [admin-audit-logs](../api/admin-audit-logs.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
每个 admin **写操作**(改钱/改状态/处理反馈/改配置等)落一条,记"谁在何时、对谁、做了什么、前后值"。**只追加、不可改不可删**,用于追溯。`admin_username` / `target_id` 冗余存字符串,即使关联对象被删/改名也能追溯。
## 用在哪 / 增删改查
- **C(插入)**:任何 admin 写操作经 `audit.write_audit`(`add_audit_log`)落一条,**与业务写操作同事务**(`commit=False` 让 router 一起 commit:改了就有审计、有审计就真改了)。覆盖:手动增减金币、改用户状态、提现审核通过/拒绝/刷新、处理反馈、改配置等。
- **U / D**:**无,永久不可变**(无更新/删除接口)。
- **R**:`GET /admin/audit-logs`(审计列表,可按 `action`/`target_type`/`admin_id` 筛,`id` 倒序游标)。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `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` / `withdraw.approve` / `withdraw.reject` / `feedback.handle` / `config.set` |
| `target_type` | String(32) | NOT NULL | 被操作对象类型,取值如 `user` / `withdraw` / `feedback` / `config` |
| `target_id` | String(64) | nullable | 被操作对象 id**字符串**以兼容 `out_bill_no` 等非整型主键)。指向随 `target_type` 变:`user`→user.id / `withdraw`→withdraw_order.out_bill_no / `feedback`→feedback.id / `config`→app_config.key |
| `detail` | JSON(PG: JSONB) | nullable | 上下文 + 前后值,如 `{"amount":1000,"reason":"...","before":{...},"after":{...}}` |
| `ip` | String(64) | nullable | 操作者 IP(取自 `X-Forwarded-For` 首段,仅记录不鉴权) |
| `created_at` | DateTime(tz) | server_default now(), index, NOT NULL | 操作时间 |
## 关系 / Join Key
- `admin_id``admin_user.id`(多对一)。
- `target_id` 是**软关联**(无 FK,字符串),目标表随 `target_type`(见上表 `target_id` 行)。
## 索引与约束
- PK `id`;index `admin_id``action``created_at`
## 注意
- `detail``JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。
- **IP 可伪造**:`X-Forwarded-For` 可被客户端伪造,nginx 必须 `proxy_set_header X-Forwarded-For $remote_addr` 覆盖;审计 IP 仅记录、不参与鉴权。
-33
View File
@@ -1,33 +0,0 @@
# admin_user — 运营后台管理员账号
> 模型 `app/models/admin.py` · 仓库 `app/admin/repositories/admin_user.py` · 接口 [admin-auth-login](../api/admin-auth-login.md) / [admin-admins-list](../api/admin-admins-list.md) / [admin-admin-create](../api/admin-admin-create.md) / [admin-admin-update](../api/admin-admin-update.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
运营后台(`app/admin/` 子应用,端口 8771)的管理员账号,与 App 用户(`user` 表)**完全隔离**:独立 JWT secret、独立鉴权链。密码 bcrypt 存哈希,带角色做 RBAC 权限分级。
## 用在哪 / 增删改查
- **C(插入)**:① 首个管理员用 `scripts/create_admin.py` 命令行创建(无自助注册);② `super_admin` 在后台「管理员管理」`POST` 新建子管理员。
- **U(更新)**:登录成功刷 `last_login_at`;`super_admin` 改他人 `role`/`status`/重置密码(`admin-admin-update`)。
- **D**:无(禁用走 `status='disabled'`,token 立即失效)。
- **R**:每个 admin 请求经 `admin/deps` 解 admin token 查本表(校验 `status=='active'` + 角色守卫);管理员列表。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | 被 `admin_audit_log.admin_id` 引用 |
| `username` | String(64) | UNIQUE, index, NOT NULL | 登录名 |
| `password_hash` | String(255) | NOT NULL | bcrypt 哈希(明文不落库;⚠️ bcrypt 72 字节截断) |
| `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 | 最近登录时间(登录成功时更新) |
## 关系 / Join Key
- ← 被 `admin_audit_log.admin_id` 引用(一管理员多条审计)。
- 与 C 端 `user` **无任何关联**(两套独立体系)。
## 索引与约束
- PK `id`;UNIQUE+index `username`(`ix_admin_user_username`)。
## 注意
- **鉴权隔离**:admin token `typ=admin` + 独立 `ADMIN_JWT_SECRET`(≠ App 的 `JWT_SECRET_KEY`),App 用户 token 无法当 admin 用;admin 无 refresh,过期(默认 12h)重登。
- **RBAC**:`super_admin` 恒过所有角色守卫(`require_role`);`finance` 管钱、`operator` 管用户/反馈/大盘。具体守卫见各接口文档。
-31
View File
@@ -1,31 +0,0 @@
# app_config — 运营可配置项(覆盖 rewards 常量)
> 模型 `app/models/app_config.py` · 仓库 `app/repositories/app_config.py` · 注册表 `app/core/config_schema.py` · 接口 admin [admin-stats-overview](../api/admin-stats-overview.md) 同组的配置页(`app/admin/routers/config.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
把原本硬编码在 `core/rewards.py` 的**产品规则常量**(签到档位、任务奖励、提现额度、看广告金币/上限/冷却等)挪到 DB,运营后台可改、跨进程即时生效。`key` 主键,`value` 用 JSON 存任意值(int / list / dict)。
> **核心约定**:表里**没有**的 key,业务读配置时 fallback 到 `config_schema.CONFIG_DEFS` 的默认值(= 原 rewards 常量)。所以**空表 = 现有行为完全不变**;admin 改某项 = 写一行 → 业务下次 `get_value` 读到新值。
## 用在哪 / 增删改查
- **C / U(upsert)**:admin 配置页改某项 `set_value(key, value, admin_id)`:无该 key 行→插,有→更新 `value`+`updated_by_admin_id`(同事务写 `admin_audit_log`,`action='config.set'`)。未知 key(不在 CONFIG_DEFS)抛 KeyError。
- **D**:无(要恢复默认 = 删行即可回退到 CONFIG_DEFS,但当前无删除接口;改回默认值亦可)。
- **R**:**业务侧**——`rewards.get_*(db)` 一族(`get_signin_rewards` / `get_withdraw_min_cents` / `get_ad_reward_coin` …)经 `get_value` 读,签到/任务/提现/看广告发奖处用;**admin 侧**——`list_all` 列出所有可配项(default + 当前值 + 元信息)给配置页渲染。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `key` | String(64) | **PK** | 配置标识,取值见 `config_schema.CONFIG_DEFS`:`signin_rewards` / `min_exchange_coin` / `withdraw_min_cents` / `withdraw_max_cents` / `task_rewards` / `record_milestones` / `ad_reward_coin` / `ad_daily_limit` / `ad_max_coin` / `ad_round_count` / `ad_cooldown_sec` |
| `value` | JSON(PG: JSONB) | NOT NULL | 配置值,类型随 key(`int` / `int_list` 如签到 7 档 / `dict_str_int` 如 task_rewards) |
| `updated_by_admin_id` | Integer | nullable | 最后修改的管理员 id(= `admin_user.id`,软引用,无 FK) |
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最后修改时间 |
## 关系 / Join Key
- 无外键。`updated_by_admin_id` 软指向 `admin_user.id`(仅记录谁改的)。
- 逻辑上"覆盖"`core/rewards.py` 的常量:每个 key 的默认值/类型/分组/说明在 `config_schema.CONFIG_DEFS` 定义(配置项的 single source of truth)。
## 索引与约束
- PK `key`(无自增 id)。
## 注意
- 不缓存:配置读频率低(每次福利操作读一次,主键查极快),admin 改了立即生效、跨进程一致(多 worker 也对)。
- 新增可配项 = 在 `CONFIG_DEFS` 加一条 + 业务处改用 `app_config.get_value(db, key)` 读;不需要建迁移(行是动态插的,表结构不变)。
+14 -27
View File
@@ -1,39 +1,26 @@
# cash_transaction — 现金流水账本(分)
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-cash-transactions](../api/wallet-cash-transactions.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/wallet.py` | 关联接口 [wallet-cash-transactions](../api/wallet-cash-transactions.md) | [← 索引](./README.md)
现金每变动一笔就记一行(单位:**分**,记变动后余额)。金币兑现金、提现、提现退款都这里。**只增不改不删**。
## 用在哪 / 增删改查
- **C(插入)**:三个来源,每次写一笔:
| 动作 / endpoint | `biz_type` | `amount_cents` | `ref_id` 指向 |
|---|---|---|---|
| 金币兑现金 `POST /wallet/exchange` | `exchange_in` | + | null(配套 `coin_transaction.exchange_out`) |
| 发起提现 `POST /wallet/withdraw` | `withdraw` | (扣现金) | `withdraw_order.out_bill_no` |
| 提现失败/取消/审核拒绝退款 | `withdraw_refund` | +(退回) | `withdraw_order.out_bill_no` |
- **U / D**:无。账本只追加。
- **R**:`GET /wallet/cash-transactions`(现金明细,`id` 倒序游标);admin 跨用户现金流水。
现金每变动一笔流水(单位:分)。金币兑现金、提现、提现退款都这里。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `amount_cents` | Integer | NOT NULL | 本笔变动现金(分);**正=入账(兑入/退款),负=出账(提现)** |
| `balance_after_cents` | Integer | NOT NULL | 本笔后现金余额(= 当时 `coin_account.cash_balance_cents`) |
| `biz_type` | String(32) | NOT NULL | 取值:`exchange_in`(兑入)/ `withdraw`(提现出账)/ `withdraw_refund`(提现退回) |
| `ref_id` | String(64) | nullable | `withdraw`/`withdraw_refund` 时 = **`withdraw_order.out_bill_no`**;`exchange_in` 为 null |
| `remark` | String(128) | nullable | 用户可见备注(如「提现到微信零钱(待审核)」「提现审核未通过,金额已退回) |
| `amount_cents` | Integer | NOT NULL | 正=入账(兑入),负=出账(提现) |
| `balance_after_cents` | Integer | NOT NULL | 本笔变动后现金余额() |
| `biz_type` | String(32) | NOT NULL | `exchange_in`(兑入)/ `withdraw`(提现出账)/ `withdraw_refund`(提现退回) |
| `ref_id` | String(64) | nullable | 关联业务 id(如提现 `out_bill_no`) |
| `remark` | String(128) | nullable | 用户可见备注(如"提现未成功,金额已退回") |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
## 关系 / Join Key
- `user_id``user.id`(多对一)。
- `ref_id` →(仅 withdraw/withdraw_refund)`withdraw_order.out_bill_no`(软关联,无 FK)。一笔提现正常对应两条流水:发起 `withdraw`(),失败/拒绝时再 `withdraw_refund`(+)。
## 索引与约束
- PK `id`;index `user_id``created_at`
- PK: `id`;index: `user_id``created_at`
## 注意
- 退款流水 `remark` 是用户可见文案(区分"未成功自动退"vs"审核未通过退");技术原因记在 `withdraw_order.fail_reason`,不外露。
## 关系
- `user_id``user.id`(多对一)
## 说明
- 提现失败/取消退款写 `withdraw_refund`(+X);退款流水 `remark` 是用户可见文案,技术原因记在 `withdraw_order.fail_reason`
+14 -21
View File
@@ -1,31 +1,24 @@
# coin_account — 金币 + 现金余额快照
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-account](../api/wallet-account.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/wallet.py` | 关联接口 [wallet-account](../api/wallet-account.md) | [← 索引](./README.md)
一用户一行的余额快照,App「资产卡 / 钱包」读它展示。每次余额变动都另写一笔流水(`coin_transaction` / `cash_transaction`)并记 `balance_after`,出问题逐笔回溯。`user_id` 既是主键也是外键(一对一)。详见 [总览 §四 资金模型](./OVERVIEW.md#四资金模型金币--现金--提现三层)
## 用在哪 / 增删改查
- **C(插入)**:`get_or_create_account` 懒建 —— 用户第一次发生金币动作(签到/任务/看广告/admin 发币/兑换/提现)时建一行空账户(全 0)。从未发生过金币动作的用户**没有这行**(读接口按 0 兜底)。
- **U(更新)**:几乎所有钱相关动作都更新它 —— 发金币(签到/任务/看广告/admin)`coin_balance += `、兑现金 `coin_balance = / cash_balance_cents += `、发起提现 `cash_balance_cents = `(带条件原子 UPDATE 防超额)、提现退款 `cash_balance_cents += `
- **D**:无。
- **R**:`GET /wallet/account`(钱包余额卡)、admin 用户 360 概览。
用户一行的余额快照,供读取展示用;每次余额变动都另写流水(`coin_transaction` / `cash_transaction`)并记 `balance_after`,出问题逐笔回溯。`user_id` 既是主键也是外键。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `user_id` | Integer | **PK + FK→user.id** | 用户(一用户一行);既是主键也是外键 |
| `coin_balance` | Integer | NOT NULL, default 0 | 当前金币余额(个数);= 历次 `coin_transaction.amount` 之和 |
| `cash_balance_cents` | Integer | NOT NULL, default 0 | 当前现金余额(分);= 历次 `cash_transaction.amount_cents` 之和 |
| `total_coin_earned` | Integer | NOT NULL, default 0 | 累计赚取金币(**只增不减**,仅正向 grant 累加),用于"历史总收益"展示 |
| `user_id` | Integer | PK, FK→user.id | 用户(一用户一行) |
| `coin_balance` | Integer | NOT NULL, default 0 | 当前金币余额 |
| `cash_balance_cents` | Integer | NOT NULL, default 0 | 当前现金余额(分) |
| `total_coin_earned` | Integer | NOT NULL, default 0 | 累计赚取金币(只增不减) |
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最后更新时间 |
## 关系 / Join Key
- `user_id``user.id`(一对一)。
-`coin_transaction` / `cash_transaction` 无外键直连,靠 `user_id` 关联;快照 = 流水累加值,流水的 `balance_after*` 应等于当时的快照余额(对账依据)。
## 索引与约束
- PK `user_id`(同时是 FK→user.id)
- PK: `user_id`(同时是 FK→user.id)
## 注意
- **唯一发金币入口** `wallet.grant_coins`:更新本表 + 写 `coin_transaction`,**不 commit**,由调用方同事务提交(发币与业务记录原子化)。
- 扣现金用 `UPDATE ... WHERE cash_balance_cents >= amount` 原子条件扣减,并发/重试不会超额。
## 关系
- `user_id``user.id`(一对一)
## 说明
- **唯一发金币入口** `wallet.grant_coins` 更新本表余额快照 + 写 `coin_transaction`,不 commit,由调用方同事务提交。
- 提现扣现金用带条件 `UPDATE ... WHERE cash_balance_cents >= amount` 原子扣减,防并发超额。
+15 -31
View File
@@ -1,43 +1,27 @@
# coin_transaction — 金币流水账本
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py`(写入口 `grant_coins`) · 接口 [wallet-coin-transactions](../api/wallet-coin-transactions.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/wallet.py` | 关联接口 [wallet-coin-transactions](../api/wallet-coin-transactions.md) | [← 索引](./README.md)
金币每变动一笔就记一行(记变动后余额 `balance_after`),用于对账与 App「金币明细」展示。**只增不改不删**——账本不可篡改。
## 用在哪 / 增删改查
- **C(插入)**:唯一来源是 `wallet.grant_coins`(发金币入口),被以下动作调用,每次写一笔:
| 动作 / endpoint | `biz_type` | `amount` | `ref_id` 指向 |
|---|---|---|---|
| 签到 `POST /signin/do` | `signin` | + | 当天日期串(= `signin_record.signin_date` ISO) |
| 领任务 `POST /tasks/claim` | `task_<key>`(如 `task_enable_notification`) | + | `user_task.task_key` |
| 看广告发奖回调 `POST /ad/pangle-callback` | `ad_reward` | + | `ad_reward_record.trans_id` |
| 金币兑现金 `POST /wallet/exchange` | `exchange_out` | | null(配套 `cash_transaction.exchange_in`) |
| admin 手动加金币 | `admin_grant` | + | null(`remark`=`admin:<reason>`) |
| admin 手动扣金币 | `admin_deduct` | | null |
> ⚠️ 比价里程碑 `comparison_milestone_claim` **当前不发币、不写本表**(产品定暂不真发,详见该表文档)。
- **U / D**:无。账本只追加。
- **R**:`GET /wallet/coin-transactions`(金币明细,`id` 倒序游标分页);admin 跨用户金币流水(可按 `biz_type` 筛)。
金币每变动一笔流水,记变动后余额,用于对账与「金币明细」展示。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `amount` | Integer | NOT NULL | 本笔变动金币;**正=入账(赚),负=出账(花/兑换)** |
| `balance_after` | Integer | NOT NULL | 本笔后金币余额(= 当时 `coin_account.coin_balance`,对账用) |
| `biz_type` | String(32) | NOT NULL | 取值见上表:`signin` / `task_<key>` / `ad_reward` / `exchange_out` / `admin_grant` / `admin_deduct`。无 DB 枚举约束,靠写入方约定 |
| `ref_id` | String(64) | nullable | **关联业务键,指向随 `biz_type` 变(见上表)**;无关联时为 null |
| `remark` | String(128) | nullable | 备注(如签到「每日签到 第N天」、admin「admin:<reason>」) |
| `amount` | Integer | NOT NULL | 正=入账(赚),负=出账(花/兑换) |
| `balance_after` | Integer | NOT NULL | 本笔变动后金币余额(对账用) |
| `biz_type` | String(32) | NOT NULL | 业务类型:`signin` / `task_<key>` / `exchange_out` / `ad_reward` / `compare_milestone` |
| `ref_id` | String(64) | nullable | 关联业务 id(签到日期 / 任务 key / trans_id / 里程碑序号等) |
| `remark` | String(128) | nullable | 备注 |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
## 关系 / Join Key
- `user_id``user.id`(多对一)。
- `ref_id` 是**软关联**(无 FK),目标表随 `biz_type`:`signin`→签到日 / `task_<key>``user_task.task_key` / `ad_reward``ad_reward_record.trans_id` / 其余 null。
## 索引与约束
- PK `id`;index `user_id``created_at`
- PK: `id`;index: `user_id``created_at`
## 注意
- 流水与余额快照(`coin_account`)、与对应业务记录(signin/task/ad_reward)在**同一事务**写,不会只发币不留痕。
## 关系
- `user_id``user.id`(多对一)
## 说明
- 明细接口按 `id` 倒序游标分页。
-`biz_type` 取值由各业务写入(签到/任务/兑换/看广告/比价里程碑),无独立枚举约束,靠写入方约定。
+14 -17
View File
@@ -1,29 +1,26 @@
# comparison_milestone_claim — 比价战绩里程碑领取记录
> 模型 `app/models/comparison_milestone.py` · 仓库 `app/repositories/comparison_milestone.py` · 接口 [compare-milestones](../api/compare-milestones.md) / [compare-milestone-claim](../api/compare-milestone-claim.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/comparison_milestone.py` | 关联接口 [compare-milestones](../api/compare-milestones.md) / [compare-milestone-claim](../api/compare-milestone-claim.md) | [← 索引](./README.md)
App「记录比价战绩」每档(第 1~6 次)只能领一次,领取后写一行去重。解锁进度**不存本表**——`comparison_record``status='success'` 的条数实时算(第 N 档在"成功比价 ≥ N 次"时解锁)。档位金额来自 `rewards.RECORD_MILESTONES`(默认 `(120,180,300,500,800,1200)`,运营后台 `app_config.record_milestones` 可改)。仿 `user_task` 的一次性领取模型。
## 用在哪 / 增删改查
- **C(插入)**:`POST /compare/milestone/claim`(`claim`)。档位合法 + 已解锁(`count_success >= milestone`)+ 未领过 → 写一行(`coin_awarded=0`)。越界 `UnknownMilestoneError`(404)、未解锁 `MilestoneLockedError`(409)、已领 `AlreadyClaimedError`(409)。
- **U / D**:无。
- **R**:`GET /compare/milestones`(战绩页:各档 claimed/active/locked + 成功次数),`get_status` 读本表已领集合 + `comparison_record` 成功计数对照。
「记录比价战绩」每档(第 1~6 次)只能领一次,领取后写一行;解锁进度由 `comparison_record``status='success'` 的条数决定,本表只记"哪几档已领"。仿 `user_task` 的一次性领取模型。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `milestone` | Integer | NOT NULL | 档位序号(1-based),取值 `1..len(RECORD_MILESTONES)`(当前 1..6) |
| `coin_awarded` | Integer | NOT NULL, default 0 | 该档应发金币;⚠️ **当前恒为 0**(产品定暂不真发,见下) |
| `milestone` | Integer | NOT NULL | 档位序号(1-based), `rewards.RECORD_MILESTONES` |
| `coin_awarded` | Integer | NOT NULL, default 0 | 该档应发金币;⚠️ 当前产品定暂不真发,实际恒为 0 |
| `claimed_at` | DateTime(tz) | server_default now() | 领取时间 |
## 关系 / Join Key
- `user_id``user.id`(多对一)。
- **解锁进度依赖 `comparison_record`**:进度 = `count_success(user)` = `comparison_record` 中本人 `status='success'` 的条数;本表只记"哪几档已领",不存进度数值。
## 索引与约束
- PK `id`;index `user_id`;UNIQUE(`user_id`, `milestone`) = `uq_compare_milestone_user`(防同档重复领)。
- PK: `id`;index: `user_id`
- UNIQUE(`user_id`, `milestone`) = `uq_compare_milestone_user`(防同档重复领)
## 注意
- ⚠️ **当前 `claim` 只写本表标记已领(去重),不调 `grant_coins`、不写 `coin_transaction`,`coin_awarded` 恒为 0、余额不变**(产品定暂不真发金币,后续可能整体删除该功能)。若将来要真发币,在 `claim` 里补 `grant_coins` 即可(参考 `task.py`)。
## 关系
- `user_id``user.id`(多对一)
- 解锁进度依赖 `comparison_record`(`status='success'` 计数),本表不存进度本身。
## 说明
- ⚠️ 当前 claim 仅写本表标记已领(去重),**不调 `grant_coins`、不写 `coin_transaction`,`coin_awarded` 恒为 0、余额不变**(产品定暂不真发金币,后续整体删除该功能)。
- 领取校验:档位越界 404、未解锁(成功数 < milestone)409、已领 409。
+28 -33
View File
@@ -1,52 +1,47 @@
# comparison_record — 比价记录(每次比价完整明细)
> 模型 `app/models/comparison.py` · 仓库 `app/repositories/comparison.py` · 接口 [compare-record-report](../api/compare-record-report.md) / [compare-records](../api/compare-records.md) / [compare-record-detail](../api/compare-record-detail.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/comparison.py` | 关联接口 [compare-record-report](../api/compare-record-report.md) / [compare-records](../api/compare-records.md) / [compare-record-detail](../api/compare-record-detail.md) | [← 索引](./README.md)
每完成一次比价(外卖/电商/领券),客户端 done 帧后用**带 JWT** 的通道上报一条。App「我的比价记录」列表/详情的数据源,也是比价战绩里程碑解锁进度的计数源(`status='success'` 条数),还被「上报更低价」反查原最低价
每完成一次比价(外卖/电商/领券),客户端 done 帧后用带 JWT 通道上报一条。「我的比价记录」数据源,也是比价战绩里程碑解锁进度的计数源(`status='success'` 条数)。
> 与 `savings_record` 的区别:本表是「每一次**比价行为**的完整明细」(不省钱、甚至失败也记);`savings_record` 是「真正**下单成交**省了多少」。两表独立、互不喂数据。
## 用在哪 / 增删改查
- **C / U(upsert,幂等)**:`POST /compare/record`(`upsert_record`)。按 `(user_id, trace_id)` 查:不存在→新建;已存在→整行覆盖(客户端重试/重复上报时,**更完整的那次胜出**)。`best_*`/`saved_amount_cents`/`is_source_best`/`status``_derive``comparison_results` 算出(协议已按 price 升序、rank=1 最便宜),不信客户端自算。
- **D**:无(关联的 `price_report` 也只把 `comparison_record_id` 置空,不删本表)。
- **R**:`GET /compare/records`(列表,`created_at` 倒序游标 + 「已下单」标记)、`GET /compare/records/{id}`(详情,限本人);`count_success` 给里程碑;`report.py` 反查 `best_*`;admin 大盘/明细。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement |`price_report.comparison_record_id` 引用 |
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `device_id` | String(64) | nullable | 设备号(多设备区分 / 与不鉴权期对账) |
| `business_type` | String(16) | NOT NULL, default `food`, index | 取值:`food`(当前唯一接通)/ `ecom` / `coupon` |
| `device_id` | String(64) | nullable | 设备号 |
| `business_type` | String(16) | NOT NULL, default `food`, index | `food`(当前唯一接通)/ `ecom` / `coupon` |
| `trace_id` | String(64) | NOT NULL | pricebot 侧 trace_id(关联调试落盘 + 幂等键) |
| `source_platform_id` / `_name` | String(32) | nullable | 源平台代号 / 中文名 |
| `source_platform_id` | String(32) | nullable | 源平台代号 |
| `source_platform_name` | String(32) | nullable | 源平台中文名 |
| `source_package` | String(128) | nullable | 源平台 Android 包名 |
| `source_price_cents` | Integer | nullable | 源平台到手价(分) |
| `best_platform_id` / `_name` | String(32) | nullable | 最优平台代号 / 中文名(= rank=1) |
| `best_price_cents` | Integer | nullable | 最优价(分)。**被 `price_report` 反查作"原最低价",校验上报价必须更低** |
| `best_deeplink` | String(1024) | nullable | 最优平台商家深链(再次比价/直达用);2026-06 起新比价才有,旧记录 null |
| `saved_amount_cents` | Integer | nullable | 源价 最优价(可 0/负:源平台本就最便宜) |
| `is_source_best` | Boolean | nullable | 源平台就是最便宜(= 这次没省到) |
| `store_name` | String(128) | nullable | 店铺名。**与 `savings_record.shop_name` 按字符串相等关联**,给本记录打「已下单」 |
| `total_dish_count` / `skipped_dish_count` | Integer | nullable | 菜品总数 / 目标平台没找到被跳过数 |
| `status` | String(16) | NOT NULL, default `success` | 取值:`success`(有非源且有价的目标结果)/ `failed`(出错/没采到目标价)。**里程碑只数 success** |
| `best_platform_id` | String(32) | nullable | 最优平台代号(= rank=1) |
| `best_platform_name` | String(32) | nullable | 最优平台中文名 |
| `best_price_cents` | Integer | nullable | 最优价(分) |
| `saved_amount_cents` | Integer | nullable | 源价 最优价(可 0/负) |
| `is_source_best` | Boolean | nullable | 源平台就是最便宜(= 没省到) |
| `store_name` | String(128) | nullable | 店铺名 |
| `total_dish_count` | Integer | nullable | 菜品总数 |
| `skipped_dish_count` | Integer | nullable | 跳过(没找到)菜品数 |
| `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_name}]`;`coupon_saved`=该平台主优惠额(美团红包/淘宝平台红包/京东百亿补贴,只取一笔),`coupon_name`=优惠来源名(展示用) |
| `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」 |
| `skipped_dish_names` | JSON(PG: JSONB) | NOT NULL, default [] | 被跳过的菜名 |
| `raw_payload` | JSON(PG: JSONB) | nullable | 客户端原始上报全量(calibration + done.params),取数兜底 |
| `raw_payload` | JSON(PG: JSONB) | nullable | 客户端原始上报(calibration + done.params 全量) |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
> `ordered`(已下单)是**瞬态字段**,不在表里:`list_records` 读取时按 `store_name ∈ 该用户 source='compare' 的 savings_record.shop_name 集合` 现挂到实例上供出参用。
## 关系 / Join Key
- `user_id``user.id`(多对一)。
- ← 被 `price_report.comparison_record_id` 引用(选中某条比价记录上报更低价);`price_report` 同时读本表 `best_price_cents`/`best_platform_*`/`store_name`/`items` 做快照与校验。
-`savings_record`(语义 join,无 FK):`store_name` == `savings_record.shop_name`(source='compare')→ 标「已下单」,店级。
-`comparison_milestone_claim` 间接依赖:解锁进度 = 本表 `status='success'` 计数。
## 索引与约束
- PK `id`;index `user_id``business_type``created_at`;UNIQUE(`user_id`, `trace_id`) = `uq_comparison_user_trace`(幂等覆盖)。
- PK: `id`;index: `user_id``business_type``created_at`
- UNIQUE(`user_id`, `trace_id`) = `uq_comparison_user_trace`(同次比价重试/重复上报幂等覆盖)
## 注意
- 4 个 JSON 列用 `JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。结构化金额列存「分」,`comparison_results.price`/`coupon_saved` 原样存「元」。
## 关系
- `user_id``user.id`(多对一)
-`comparison_milestone_claim` 间接依赖:里程碑解锁进度 = 本表 `status='success'` 条数。
## 说明
- `best_*` / `saved_amount_cents` / `is_source_best` / `status``repositories/comparison.py:_derive``comparison_results` 派生,客户端不用自己算。
- 4 个 JSON 列用 `JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。金额结构化列存「分」,`comparison_results.price` 原样存「元」。
+11 -18
View File
@@ -1,32 +1,25 @@
# feedback — 用户帮助与反馈
> 模型 `app/models/feedback.py` · 仓库 `app/repositories/feedback.py` · 接口 [feedback](../api/feedback.md) · admin [admin-feedbacks-list](../api/admin-feedbacks-list.md) / [admin-feedback-handle](../api/admin-feedback-handle.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/feedback.py` | 关联接口 [feedback](../api/feedback.md) | [← 索引](./README.md)
App「帮助与反馈」每次提交写一行`content``contact` 必填,`images` 为可选截图。后台人工处理后置 `handled`。与 `price_report`(结构化上报更低价)不同,本表是**自由文本**反馈
## 用在哪 / 增删改查
- **C(插入)**:`POST /api/v1/feedback`(multipart:`content` + `contact` + 可选 `images`;`create_feedback`)。截图先经 `core.media``/media/feedback/` 拿相对路径,再随反馈写入,`status='new'`
- **U(更新)**:admin 处理反馈 `update_feedback_status``status='handled'`(同事务写 `admin_audit_log`)。
- **D**:无。
- **R**:admin 反馈列表(可按 `status` 筛)。C 端当前无"我的反馈列表"读接口。
每条 = 用户一次反馈提交`content``contact` 必填,`images` 为可选截图 URL 列表
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 提交用户 |
| `content` | Text | NOT NULL | 反馈正文 |
| `contact` | String(128) | NOT NULL | 联系方式(微信/QQ/手机,便于回访) |
| `images` | JSON | nullable | 截图相对 URL 列表 `/media/feedback/...`;无图为 NULL |
| `status` | String(16) | NOT NULL, default `new` | 取值:`new`(待处理)/ `handled`(已处理) |
| `images` | JSON | nullable | 截图 URL 列表(相对路径 `/media/feedback/...`);无图为 NULL |
| `status` | String(16) | NOT NULL, default `new` | `new`(待处理)/ `handled`(已处理) |
| `created_at` | DateTime(tz) | server_default now(), index | 提交时间 |
## 关系 / Join Key
- `user_id``user.id`(多对一)。
- admin 处理时被 `admin_audit_log` 记录(`target_type='feedback'``target_id`=本行 id)。
## 索引与约束
- PK `id`;index `user_id``created_at`
- PK: `id`;index: `user_id``created_at`
## 注意
- `images` 用通用 `JSON`(本表**未**用 JSONB variant,与 comparison/savings 不同)。
## 关系
- `user_id``user.id`(多对一)
## 说明
- `images` 用通用 `JSON`(本表未用 JSONB variant);截图先经 `/media/feedback/` 上传拿到相对路径再随反馈提交。
-42
View File
@@ -1,42 +0,0 @@
# price_report — 上报更低价(众包纠偏)
> 模型 `app/models/price_report.py` · 仓库 `app/repositories/report.py` · 接口 `POST /api/v1/report`(提交,multipart)/ `GET /api/v1/report/records`(列表)· [← 索引](./README.md) · [总览](./OVERVIEW.md)
用户在「我的比价记录」里选一条,举报「某平台比我们算出的最低价更便宜」,带 1~4 张截图证明,人工审核通过后发金币奖励。与 `feedback`(自由文本)不同:本表**结构化**——关联具体比价记录、原最低价 vs 上报价、审核状态、奖励。是「省钱护城河」的众包纠偏入口。
## 用在哪 / 增删改查
- **C(插入)**:`POST /api/v1/report`(multipart:`comparison_record_id` + `reported_platform_id` + `reported_price`(元) + `images`)。后端 `create_report`:① 反查 `comparison_record`(须属本人)取 `best_price_cents` 作"原最低价";② **校验上报价 < 原最低价**(否则 400);③ 截图经 `core.media``/media/price_report/`;④ 写一行 `status='pending'`。**原最低价快照由后端反查,不信客户端传值**。
- **U(更新,审核)**:人工审核后台改 `status`(approved/rejected)+ `reject_reason` / `reward_coins` / `reviewed_at`(当前审核动作在 admin 侧,提交一律 pending)。
- **D**:无。关联的 `comparison_record` 若被删,本行 `comparison_record_id` 置空(可空 FK),上报历史保留。
- **R**:`GET /api/v1/report/records`(用户上报列表,可按 `status` 筛,带各状态计数)。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 上报用户 |
| `comparison_record_id` | Integer | FK→comparison_record.id, index, nullable | 选中的那条比价记录(删后置空保留历史) |
| `store_name` | String(128) | nullable | 门店名快照(反查 comparison_record 冗余存,防关联记录改/删) |
| `dish_summary` | String(256) | nullable | 菜品摘要(由比价记录 items 的菜名拼成) |
| `original_platform_id` / `_name` | String(32) | nullable | 原最低价平台快照(= 比价记录 best_platform_*) |
| `original_price_cents` | Integer | nullable | 原最低价(分,后端反查 `comparison_record.best_price_cents`) |
| `reported_platform_id` | String(32) | NOT NULL | 用户上报的更低价平台,取值 `meituan-waimai`/`jd-waimai`/`taobao-shanguang`(canonical id) |
| `reported_platform_name` | String(32) | NOT NULL | 上报平台展示名(美团外卖/京东外卖/淘宝闪购) |
| `reported_price_cents` | Integer | NOT NULL | 用户上报的更低价(分);**校验必须 < `original_price_cents`** |
| `images` | JSON | NOT NULL, default [] | 截图相对 URL 列表 `/media/price_report/...`(1~4 张) |
| `status` | String(16) | NOT NULL, default `pending`, index | 取值:`pending`(审核中)/ `approved`(通过)/ `rejected`(未通过) |
| `reject_reason` | String(256) | nullable | 驳回原因(审核填) |
| `reward_coins` | Integer | nullable | 通过后发的金币(人工审核后台动作,提交时为 null) |
| `reviewed_at` | DateTime(tz) | nullable | 审核时间 |
| `created_at` | DateTime(tz) | server_default now(), index | 提交时间 |
## 关系 / Join Key
- `user_id``user.id`(多对一)。
- `comparison_record_id``comparison_record.id`(多对一,可空)。提交时**读** `comparison_record``best_price_cents`(校验)/ `best_platform_*` / `store_name` / `items`(冗余快照),之后即使原记录变动也对得上。
## 索引与约束
- PK `id`;index `user_id``comparison_record_id``status``created_at`
## 注意
- 奖励发放(approved → 钱包 +`reward_coins`)是人工审核后台动作,提交一律 `pending`,本表只留字段。
- 截图复用 `core.media`(魔数嗅探 + 随机文件名),与头像/反馈同一套上传。
+15 -34
View File
@@ -1,50 +1,31 @@
# savings_record — 省钱记录(profile 省钱战绩源)
> 模型 `app/models/savings.py` · 仓库 `app/repositories/savings.py` · 接口 [savings-summary](../api/savings-summary.md) / [savings-battle](../api/savings-battle.md) / [savings-records](../api/savings-records.md) · 写入源 `POST /api/v1/order/report`([compare-record 相关](../api/compare-record-report.md)) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/savings.py` | 关联接口 [savings-summary](../api/savings-summary.md) / [savings-battle](../api/savings-battle.md) / [savings-records](../api/savings-records.md) | [← 索引](./README.md)
profile 页「累计帮你省了 / 省钱战绩 / 省钱明细」的唯一数据源:**真正下单成交后**省了多少记一行。是「记账闭环」的落库终点——客户端比价后点结果链接跳走付款,无障碍 watcher 捕获支付成功 + 金额归因(±1 元/5 分钟窗口)→ 上报 `POST /order/report`
profile 页「累计帮你省了 / 省钱战绩 / 省钱明细」的唯一数据源:**真正下单成交后**省了多少记一行。
> **两种来源(`source` 字段区分)**:
> - `source='compare'`:**真实下单上报**(经 `/order/report` 的 `create_from_report` 写,2026-06 已接通)。
> - `source='demo'`:用户从无任何记录时,首次进省钱页**懒种子** `ensure_seeded` 按 user_id 幂等灌 ~23 条演示数据。
> 统计口径:**只要有 ≥1 条 `compare` 记录,就只用 compare**(demo 全忽略);否则用 demo 兜底。聚合(SUM/分组/连续天数/分位)都是生产级真实计算。
## 用在哪 / 增删改查
- **C(插入)**:
- `POST /order/report`(`create_from_report`)→ 写一行 `source='compare'`,省额 = `original_price_cents paid`(下限 0)。按 `(user_id, client_event_id)` 幂等,重复上报返回旧行不新增。
- `ensure_seeded` → 用户无任何 savings 记录时,懒灌 demo(`source='demo'`),进 summary/battle/records 接口时触发。
- **U / D**:无。
- **R**:`GET /savings/summary`(累计省/单数/均省)、`/savings/battle`(本周省/连续天数/超过 X% 用户/比价次数)、`/savings/records`(明细分页);`comparison.py` 读本表 `shop_name`(source=compare)集合给比价记录打「已下单」。
> ⚠️ **当前为 demo 假数据**:`crud/savings.py:ensure_seeded` 按 user_id 幂等灌 ~23 条(`source='demo'`),聚合(SUM/分组/连续天数)是生产级真实计算。真数据要靠**"用户真下单"信号**(app 目前无:AI 比价止于结算页/结果展示,付款用户手动)——**不是**把比价记录 `comparison_record` 喂过来(那是"比价行为",这是"成交省钱",两个维度)。详见 [[project_shaguabijia_app_server]]。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `order_amount_cents` | Integer | NOT NULL | 订单到手价/实付(分) |
| `saved_amount_cents` | Integer | NOT NULL | 本单省下(分,可为 0;= original 实付,下限 0) |
| `order_amount_cents` | Integer | NOT NULL | 订单到手价(分) |
| `saved_amount_cents` | Integer | NOT NULL | 本单省下(分,可为 0) |
| `platform` | String(32) | nullable | 下单平台(美团外卖/淘宝闪购/京东外卖) |
| `title` | String(128) | nullable | 明细卡标题(compare 来源 = 门店名) |
| `shop_name` | String(128) | nullable | 店铺名。**与 `comparison_record.store_name` 字符串相等关联**(打「已下单」) |
| `title` | String(128) | nullable | 标题 |
| `shop_name` | String(128) | nullable | 店铺名 |
| `dishes` | JSON(PG: JSONB) | NOT NULL, default [] | 菜名列表(前 2 道展示,其余"还有 N 道") |
| `source` | String(16) | NOT NULL, default `compare` | 取值:`compare`(真实下单上报)/ `demo`(演示种子) |
| `original_price_cents` | Integer | nullable | 源平台原价(分);compare 上报带,demo 为 null |
| `compared_price_cents` | Integer | nullable | 我们当时给出的比价价(分,审计/备用) |
| `pay_channel` | String(16) | nullable | 支付渠道:`wechat` / `alipay`(归因 watcher 判定) |
| `platform_package` | String(128) | nullable | 实际下单平台包名 |
| `source_platform_name` | String(32) | nullable | 源平台展示名(如「美团」),用于"原价 ¥X(美团)" |
| `source_deeplink` | String(512) | nullable | 源平台重进链接(预留) |
| `client_event_id` | String(64) | nullable | 客户端幂等键(UUID);demo 行为 null |
| `device_id` | String(128) | nullable | 上报设备号 |
| `source` | String(16) | NOT NULL, default `compare` | 来源:`demo`(演示)/ `compare`(真实下单上报,待启用) |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
## 关系 / Join Key
- `user_id``user.id`(多对一)。
-`comparison_record`(语义 join,无 FK):本表 `shop_name`(source=compare)== `comparison_record.store_name` → 给那条比价记录标「已下单」。注意是**店名对齐、店级**(下单上报不带 trace_id,无法逐条精确对应)。
## 索引与约束
- PK `id`;index `user_id``created_at`;UNIQUE(`user_id`, `client_event_id`) = `uq_savings_user_event`(真实上报幂等;demo 行 `client_event_id=NULL` 不参与冲突,允许多 NULL)。
- PK: `id`;index: `user_id``created_at`
## 注意
## 关系
- `user_id``user.id`(多对一)
## 说明
- `dishes``JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。
- `beat_percent`(超过百分之多少用户)按各用户累计省下金额做真实分位,每用户口径与展示一致(有 compare 用 compare 之和,否则 demo 之和)。
- `beat_percent`(超过百分之多少用户)按各用户累计省下金额做真实分位;为有可比人群造了 5 个种子用户(`register_channel='seed'`)。
+14 -20
View File
@@ -1,32 +1,26 @@
# signin_record — 签到记录(7 天循环)
> 模型 `app/models/signin.py` · 仓库 `app/repositories/signin.py` · 接口 [signin-status](../api/signin-status.md) / [signin-do](../api/signin-do.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/signin.py` | 关联接口 [signin-status](../api/signin-status.md) / [signin-do](../api/signin-do.md) | [← 索引](./README.md)
App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天然防一天签两次。`cycle_day`(1..7)决定发多少金币(档位 `rewards.SIGNIN_REWARDS`,默认 `(10,20,30,50,80,120,200)`,运营后台 `app_config.signin_rewards` 可改),断签重置回 1;`streak` 是连续天数(不封顶),给"已连续签到 N 天"展示。
## 用在哪 / 增删改查
- **C(插入)**:`POST /signin/do`(`do_signin`)。今天未签且校验通过 → 算出 `cycle_day`/`streak`(昨天签过则在上次基础上 +1、否则重置 1)→ 写一行 + `grant_coins(biz_type='signin', ref_id=今天日期)` 加币,**同事务 commit**。今天已签再调 → 抛 `AlreadySignedError`(409),不重复写。
- **U / D**:无。每天一行,历史不改。
- **R**:`GET /signin/status`(签到页:今天签没签、连续天数、7 档状态/金币、能否签),内部 `get_status` 读最近一条 + 推算今天档位。
每次签到一行,`(user_id, signin_date)` 唯一,天然防一天签两次。`cycle_day`(1..7)决定发多少金币(`rewards.SIGNIN_REWARDS`),断签重置回 1;`streak` 是连续签到天数(不封顶)用于展示。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `signin_date` | **Date** | NOT NULL | 签到日期(**北京时间** `cn_today()` 的 date)。其 ISO 串被 `coin_transaction.ref_id` 引用(biz_type=signin) |
| `cycle_day` | Integer | NOT NULL | 取值 `1..7`:7 天循环里今天第几档,决定发币额;断签重置回 1 |
| `streak` | Integer | NOT NULL | 连续签到天数(≥1,不封顶);断签重置回 1 |
| `coin_awarded` | Integer | NOT NULL | 本次实发金币(= 当时档位金额) |
| `signin_date` | Date | NOT NULL | 签到日期(北京时间 date) |
| `cycle_day` | Integer | NOT NULL | 7 天循环里今天第几档(1..7),决定发币 |
| `streak` | Integer | NOT NULL | 连续签到天数(不封顶) |
| `coin_awarded` | Integer | NOT NULL | 本次发放金币 |
| `created_at` | DateTime(tz) | server_default now() | 时间 |
## 关系 / Join Key
- `user_id``user.id`(多对一)。
- 配套写 `coin_account`(U,加币)+ `coin_transaction`(C,`biz_type='signin'``ref_id=signin_date.isoformat()`)。`coin_transaction.ref_id` 反查即对应本表当天那条。
## 索引与约束
- PK `id`;index `user_id`;UNIQUE(`user_id`, `signin_date`) = `uq_signin_user_date`(防一天签两次)。
- PK: `id`;index: `user_id`
- UNIQUE(`user_id`, `signin_date`) = `uq_signin_user_date`(防一天签两次)
## 注意
- "今天/昨天"全按北京时间(`CN_TZ=UTC+8`),不用 UTC(否则 0~8 点会算成前一天)。
- 连续判定:`last.signin_date == today - 1天` 才算连续,否则首签/断签重置。
## 关系
- `user_id``user.id`(多对一)
## 说明
- 签到"今天"按北京时间 `cn_today()`(`CN_TZ=UTC+8`);发币与写本表记录同事务(`grant_coins(biz_type='signin', ref_id=日期)`)。
+20 -25
View File
@@ -1,37 +1,32 @@
# user — 用户(登录主体)
> 模型 `app/models/user.py` · 仓库 `app/repositories/user.py` · 接口 [auth-jverify-login](../api/auth-jverify-login.md) / [auth-sms-login](../api/auth-sms-login.md) / [user-profile](../api/user-profile.md) / [user-avatar](../api/user-avatar.md) / [user-delete](../api/user-delete.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/user.py` | 关联接口 [auth-me](../api/auth-me.md) 等 auth 组 | [← 索引](./README.md)
整个 App 的用户主体,也是几乎所有业务表的外键宿主。极光一键登录与短信登录都映射到同一行`phone` 唯一索引;**注册即登录**(phone 不存在则 insert,存在则更新 `last_login_at`)。后续加微信/Apple 登录新增 oauth_account 表,本表不动。
## 用在哪 / 增删改查
- **C(插入=注册)**:首次登录 `POST /auth/jverify-login`(极光)或 `/auth/sms/login`(短信)→ `upsert_user_for_login`,phone 不存在则建行(`register_channel='jverify'/'sms'`)。
- **U(更新)**:① 每次登录刷 `last_login_at`;② 改昵称 `PATCH /user/profile`、传头像 `POST /user/avatar`;③ 绑/解绑微信 `POST /wallet/bind-wechat` / `/unbind-wechat`(写 `wechat_openid/nickname/avatar_url`);④ 注销 `DELETE /user`(软删,见下);⑤ admin 改状态(禁用/启用)。
- **D(删除)**:无物理删除。注销是软删 = U:`status='deleted'` + `phone='deleted_<id>'`(释放唯一约束,可同号重注册)+ 清 PII。
- **R(读取)**:每个鉴权请求经 `api/deps.get_current_user` 解 JWT 的 `sub` 查本表(并校验 `status=='active'`);admin 用户管理列表/详情。
登录主体。极光一键登录与短信登录都映射到同一行,`phone` 唯一索引;注册即登录(phone 不存在则 insert,存在则更新 `last_login_at`)。后续加微信/Apple 登录新增 oauth_account 表,本表不动。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | 用户主键;被 15 张表的 `user_id` 外键引用 |
| `phone` | String(20) | UNIQUE, index, NOT NULL | 手机号(登录主键)。注销后置 `deleted_<id>` 占位释放唯一约束 |
| `register_channel` | String(20) | NOT NULL, default `jverify` | 取值:`jverify`(极光一键)/ `sms`(短信);`savings_record` 的对比人群另有种子用户用 `seed` |
| `nickname` | String(64) | nullable | 通用昵称(`PATCH /user/profile`) |
| `avatar_url` | String(512) | nullable | 通用头像相对 URL `/media/avatars/...`(`POST /user/avatar`) |
| `wechat_openid` | String(64) | UNIQUE, index, nullable | 微信 openid(提现转账用);**一微信一账号**,多个 NULL 允许(多个未绑用户) |
| `wechat_nickname` | String(64) | nullable | 微信昵称(绑定时拉,展示在提现绑定卡;与通用 nickname 分开存) |
| `id` | Integer | PK, autoincrement | 用户主键 |
| `phone` | String(20) | UNIQUE, index, NOT NULL | 手机号(登录主键;注销后置 `deleted_<id>` 释放唯一约束) |
| `register_channel` | String(20) | NOT NULL, default `jverify` | 注册渠道:`jverify` / `sms` |
| `nickname` | String(64) | nullable | 通用昵称(用户改资料设) |
| `avatar_url` | String(512) | nullable | 通用头像相对 URL(`/media/avatars/...`) |
| `wechat_openid` | String(64) | UNIQUE, index, nullable | 微信 openid(绑定后存,提现转账用);一微信一账号 |
| `wechat_nickname` | String(64) | nullable | 微信昵称(绑定时拉,展示在提现绑定卡) |
| `wechat_avatar_url` | String(512) | nullable | 微信头像 URL |
| `status` | String(20) | NOT NULL, default `active` | 取值:`active`(可登录/鉴权)/ `disabled`(admin 禁用)/ `deleted`(已注销);仅 active 能通过鉴权 |
| `status` | String(20) | NOT NULL, default `active` | `active` / `disabled` / `deleted` |
| `created_at` | DateTime(tz) | server_default now() | 注册时间 |
| `last_login_at` | DateTime(tz) | 应用层 default utcnow | 最近登录时间(每次登录更新) |
## 关系 / Join Key
- **被引用方(本表是 1,对方是 N/1)**:`coin_account``coin_transaction``cash_transaction``withdraw_order``wechat_transfer_authorization``signin_record``user_task``comparison_record``comparison_milestone_claim``savings_record``ad_reward_record``ad_watch_log``ad_ecpm_record``price_report``feedback``user_id` 均 → `user.id`
-`admin_user` **无任何关联**(C 端用户 vs 后台管理员,两套体系)。
| `last_login_at` | DateTime(tz) | default utcnow(应用层) | 最近登录时间 |
## 索引与约束
- PK `id`;UNIQUE+index `phone`;UNIQUE+index `wechat_openid`(允许多 NULL)。
- PK: `id`
- UNIQUE + index: `phone`
- UNIQUE + index: `wechat_openid`(允许多个 NULL = 多个未绑定用户)
## 注意
## 关系
- 被引用方:`coin_account` / `coin_transaction` / `cash_transaction` / `withdraw_order` / `signin_record` / `user_task` / `savings_record` / `ad_reward_record` / `ad_ecpm_record` / `feedback` / `comparison_record` / `comparison_milestone_claim``user_id` 均 → `user.id`
## 说明
- `nickname/avatar_url`(通用)与 `wechat_nickname/wechat_avatar_url`(微信)**分开存,不互相覆盖**。
- 软删后旧 JWT 仍能用到自然过期(无 jti 黑名单,见 `待办与技术债.md`);鉴权查 `status` 拦得住绝大多数路径
- 注销账号:phone 改占位串、status=deleted,不物理删行(保留外键完整性)
+15 -19
View File
@@ -1,30 +1,26 @@
# user_task — 一次性任务领取去重
> 模型 `app/models/task.py` · 仓库 `app/repositories/task.py` · 接口 [tasks-list](../api/tasks-list.md) / [tasks-claim](../api/tasks-claim.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/task.py` | 关联接口 [tasks-list](../api/tasks-list.md) / [tasks-claim](../api/tasks-claim.md) | [← 索引](./README.md)
像"打开消息提醒"这类**只能领一次**的任务,领奖后写一行,`(user_id, task_key)` 唯一防重复领。可循环领取的任务(签到)不走这张表,有专表 `signin_record`任务字典与奖励来自 `rewards.TASK_REWARDS`(默认 `{enable_notification: 1000}`,运营后台 `app_config.task_rewards` 可改)。
## 用在哪 / 增删改查
- **C(插入)**:`POST /tasks/claim`(`claim_task`)。`task_key` 合法 + 未领过 → 写一行(`status='completed'`)+ `grant_coins(biz_type='task_<key>', ref_id=task_key)` 加币,**同事务**。未知 key 抛 `UnknownTaskError`(404);已领抛 `AlreadyClaimedError`(409)。
- **U / D**:无。领过即终态。
- **R**:`GET /tasks/list`(任务页:列出所有已知任务 + 是否已领),`list_tasks` 把本表已领的 `task_key` 集合与任务字典对照。
像"打开消息提醒"这类只能领一次的任务,完成后写一行,`(user_id, task_key)` 唯一防重复领。可循环领取的任务(签到)不走这张表,有专表 `signin_record`
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `task_key` | String(48) | NOT NULL | 任务标识,取值`rewards.TASK_REWARDS`(当前仅 `enable_notification`)。**被 `coin_transaction.ref_id` 引用**(biz_type=`task_<key>`) |
| `status` | String(16) | NOT NULL, default `completed` | 当前恒为 `completed`(领即完成) |
| `coin_awarded` | Integer | NOT NULL, default 0 | 该任务发放金币(= `TASK_REWARDS[task_key]`) |
| `completed_at` | DateTime(tz) | server_default now() | 领取时间 |
## 关系 / Join Key
- `user_id``user.id`(多对一)。
- 配套写 `coin_account`(U)+ `coin_transaction`(C,`biz_type=f"task_{task_key}"``ref_id=task_key`)。
| `task_key` | String(48) | NOT NULL | 任务标识,见 `rewards.TASK_REWARDS`( `enable_notification`) |
| `status` | String(16) | NOT NULL, default `completed` | 任务状态 |
| `coin_awarded` | Integer | NOT NULL, default 0 | 该任务发放金币 |
| `completed_at` | DateTime(tz) | server_default now() | 完成/领取时间 |
## 索引与约束
- PK `id`;index `user_id`;UNIQUE(`user_id`, `task_key`) = `uq_task_user_key`(防重复领)。
- PK: `id`;index: `user_id`
- UNIQUE(`user_id`, `task_key`) = `uq_task_user_key`(防重复领)
## 注意
- 比价里程碑虽也是"领一次"模型但**不复用本表**,另有 `comparison_milestone_claim`(task_key 是固定字典,里程碑是按成功次数解锁的序号)。
## 关系
- `user_id``user.id`(多对一)
## 说明
- 领取:写本表 + `grant_coins(biz_type='task_<key>', ref_id=task_key)` 同事务;重复领抛 409,未知 key 抛 404。
- 比价战绩里程碑虽是"领一次"模型但**不复用本表**,另有 `comparison_milestone_claim`(因 task_key 是固定字典,里程碑是按次数解锁的序号)。
@@ -1,45 +0,0 @@
# wechat_transfer_authorization — 微信「免确认转账」授权
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-withdraw](../api/wallet-withdraw.md)(打款时用)、绑定 [wallet-bind-wechat](../api/wallet-bind-wechat.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
微信商家转账的**免确认收款授权**。用户授权一次后,后续提现可走 `transfer_with_authorization` 免逐笔在微信里确认、直接到账;未授权则走确认模式(每笔都要用户在微信点确认)。**一个用户一行**(`user_id` 主键)。
**状态机**:
```
(无) ──申请 / 首单转账顺带申请──▶ pending(微信 WAIT_USER_CONFIRM,待用户在微信确认授权)
pending ──用户确认授权──▶ active(微信 TAKING_EFFECT,此后转账免确认)
pending / active ──用户在微信关 / 商户解除 / 风控──▶ closed(终态,需重新开启,下次提现自动回退确认模式重授权)
```
## 用在哪 / 增删改查
- **C(插入)**:`_ensure_pending_auth_no` —— 用户首次提现走"方式一(转账顺带申请授权)",或显式 `apply_transfer_auth`(开启免确认)时,建一行 `pending`(生成 `out_authorization_no`)。
- **U(更新)**:
- `sync_transfer_auth`(仅 `pending` 时查微信):用户确认后 `TAKING_EFFECT``active` + 回填 `authorization_id`;超期未确认 `CLOSED``closed`
- `_refresh_active_auth`:免确认转账失败后回查,微信侧已失效 → `closed`
- `close_transfer_auth`(用户解除授权)→ `closed`
- `closed` 后重新开启:换新 `out_authorization_no`、清 `authorization_id`、回到 `pending`
- **D**:无(重开是改回 pending,不删行)。
- **R**:`execute_withdraw_transfer` 打款前读它决定走免确认还是确认模式。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `user_id` | Integer | **PK + FK→user.id** | 用户(一用户一行) |
| `openid` | String(64) | NOT NULL | 微信 openid 快照(= `user.wechat_openid`,换绑时刷新) |
| `out_authorization_no` | String(64) | UNIQUE, index, NOT NULL | **我方生成**的授权单号(查授权/发起授权用);重开时换新值 |
| `authorization_id` | String(64) | nullable | **微信** active 后返回的授权号;免确认转账(`transfer_with_authorization`)必传 |
| `state` | String(16) | NOT NULL, default `pending` | 取值:`pending`(待用户确认)/ `active`(已生效可免确认)/ `closed`(已关闭需重开) |
| `created_at` | DateTime(tz) | server_default now() | |
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | |
## 关系 / Join Key
- `user_id``user.id`(一对一)。
- 逻辑配合 `withdraw_order`:打款时按本表 `state=='active' && authorization_id` 决定免确认 vs 确认模式(无 id 直连,按 `user_id` 关联)。
- `openid``user.wechat_openid` 的快照(冗余,避免打款时再查 user)。
## 索引与约束
- PK `user_id`;UNIQUE+index `out_authorization_no`
## 注意
- `out_authorization_no`(我方)与 `authorization_id`(微信)是**两个不同的号**,别混:前者贯穿整个授权生命周期,后者只在生效后才有。
- 未配 `WXPAY_AUTH_NOTIFY_URL`(未启用免确认)时本表不参与,提现退化为原确认模式。
+16 -37
View File
@@ -1,51 +1,30 @@
# withdraw_order — 提现单(现金 → 微信零钱)
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-withdraw](../api/wallet-withdraw.md) / [wallet-withdraw-status](../api/wallet-withdraw-status.md) / [wallet-withdraw-orders](../api/wallet-withdraw-orders.md) · admin [admin-withdraws-list](../api/admin-withdraws-list.md) / [admin-withdraw-refresh](../api/admin-withdraw-refresh.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/wallet.py` | 关联接口 [wallet-withdraw](../api/wallet-withdraw.md) / [wallet-withdraw-status](../api/wallet-withdraw-status.md) | [← 索引](./README.md)
用户把现金余额提到微信零钱的工单。**含人工审核**(2026-06 起):发起即扣现金、进 `reviewing` 待审核、**不打款**;管理员后台审核通过才真正发起微信商家转账,拒绝则退款
**状态机**:
```
发起 ──扣现金+建单──▶ reviewing
reviewing ──admin 审核通过──▶ pending(微信转账在途) ──▶ success / failed(失败自动退款)
reviewing ──admin 审核拒绝──▶ rejected(已退款)
```
## 用在哪 / 增删改查
- **C(插入)**:`POST /wallet/withdraw`(`create_withdraw`)→ 校验额度([10 分, 5,000,000 分])+ 必须已绑微信 → 原子扣现金 + 写 `cash_transaction(withdraw,)` + 建单 `status='reviewing'`,同事务。`out_bill_no` 客户端可传作幂等键(同号重发返回现状,不重复扣款建单)。
- **U(更新,改 `status` / 微信回执)**:
- admin 审核通过 `approve_withdraw``pending` → 调微信转账 → `success`(到账)/ 失败 `_settle_after_ambiguous`(查单后定夺)。
- admin 审核拒绝 `reject_withdraw` → 退款 + `rejected`
- 用户/系统查单 `refresh_withdraw_status`(`GET /wallet/withdraw/status`)、对账 `reconcile_pending_withdraws`(定时任务扫 >15min 的 pending)→ 按微信侧状态归一化 `success`/`failed`
- 落微信回执:`wechat_state` / `transfer_bill_no` / `package_info`
- **D**:无。
- **R**:`GET /wallet/withdraw/orders`(用户提现单列表);admin 提现管理列表/查单。
提现状态机:`pending → success / failed`。扣现金 + 写 `cash_transaction(withdraw)` + 建本单在同一事务;失败/取消时退回现金并写 `cash_transaction(withdraw_refund)``wechat_state` 存微信侧原始状态,`status` 是归一化后的三态
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `out_bill_no` | String(64) | UNIQUE, index, NOT NULL | 商户单号(幂等键 + 微信查单键)。客户端可传(`[0-9A-Za-z_-]{8,32}`),不传则服务端 `uuid4().hex`。**被 `cash_transaction.ref_id` 引用** |
| `out_bill_no` | String(64) | UNIQUE, index, NOT NULL | 商户单号(幂等键 + 微信查单);客户端可传,不传则服务端生成 |
| `amount_cents` | Integer | NOT NULL | 提现金额(分) |
| `user_name` | String(64) | nullable | 提现实名;微信**达额转账要求实名**,发起时存下、审核打款时传给微信 |
| `status` | String(16) | NOT NULL, default `reviewing` | 归一化状态:`reviewing`(待审核,已扣款未打款)/ `pending`(打款在途)/ `success` / `failed`(打款失败已退)/ `rejected`(审核拒绝已退) |
| `wechat_state` | String(32) | nullable | 微信侧原始状态:`WAIT_USER_CONFIRM` / `SUCCESS` / `FAIL` / `CANCELLED` / `CLOSED` |
| `transfer_bill_no` | String(64) | nullable | 微信转账单号(转账成功后回填) |
| `package_info` | String(512) | nullable | 待用户确认时返回 App 拉起微信确认页用(确认模式有,免确认转账无) |
| `fail_reason` | String(256) | nullable | 失败/拒绝技术原因(不外露,用户只看流水 `remark`) |
| `status` | String(16) | NOT NULL, default `pending` | 归一化状态:`pending` / `success` / `failed` |
| `wechat_state` | String(32) | nullable | 微信原始状态:`WAIT_USER_CONFIRM` / `SUCCESS` / `FAIL` / `CANCELLED` |
| `transfer_bill_no` | String(64) | nullable | 微信转账单号 |
| `package_info` | String(512) | nullable | 待用户确认时返回 App 拉起确认页用 |
| `fail_reason` | String(256) | nullable | 失败技术原因(不外露,用户只看流水 remark) |
| `created_at` | DateTime(tz) | server_default now(), index | 发起时间 |
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 更新时间 |
## 关系 / Join Key
- `user_id``user.id`(多对一)。
- `out_bill_no` ← 被 `cash_transaction.ref_id` 引用(发起 `withdraw` 一笔 ,失败/拒绝 `withdraw_refund` 一笔 +)。
- 打款方式依赖 `wechat_transfer_authorization`(用户有生效授权 → 免确认转账,否则确认模式)。
## 索引与约束
- PK `id`;UNIQUE+index `out_bill_no`;index `user_id``created_at`
- PK: `id`;UNIQUE + index: `out_bill_no`;index: `user_id``created_at`
## 注意
- **资金安全**:原子扣款(`WHERE cash_balance_cents >= amount`)+ `out_bill_no` 幂等 + 结果不明时**先查单再决定,绝不盲目退款**(防退款后又到账)+ 孤儿 pending 单 `reconcile_pending_withdraws` 对账兜底。
- `WITHDRAW_MIN_CENTS=10`(0.1 元,微信商家转账地板价),可经 `app_config.withdraw_min_cents` 后台调。
- "待审核期间钱已扣减",防用户拿同一笔余额重复发起多笔提现。
## 关系
- `user_id``user.id`(多对一)
## 说明
- 资金安全:原子扣款 + `out_bill_no` 幂等 + 模糊失败先查单再决定(绝不盲目退款)+ 孤儿单对账 `reconcile_pending_withdraws`。详见 [[project_shaguabijia_app_server]] 提现段。
- `WITHDRAW_MIN_CENTS=10`(0.1 元,微信地板)。
+6 -44
View File
@@ -120,65 +120,27 @@ def test_callback_bad_sign(client) -> None:
assert _coin_balance(client, token) == 0
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)
def test_daily_cap(client) -> None:
"""达到每日上限后继续回调 → 受理但不发(capped),余额封顶。"""
phone = "13800003004"
token = _login(client, phone)
uid = _user_id(phone)
for i in range(3):
for i in range(DAILY_AD_REWARD_LIMIT):
r = _callback(client, _signed(uid, f"trans_cap_{i}"))
assert r.status_code == 200, r.text
# 第 4 次:capped,仍 is_verify(不让穿山甲重试),但不加币
# 第 limit+1 次:capped,仍 is_valid不让穿山甲重试),但不加币
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) == 3 * AD_REWARD_COIN
assert _coin_balance(client, token) == DAILY_AD_REWARD_LIMIT * AD_REWARD_COIN
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
assert st["used_today"] == 3
assert st["used_today"] == DAILY_AD_REWARD_LIMIT
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")
+1 -2
View File
@@ -108,8 +108,7 @@ def test_update_ad_limit_takes_effect(admin_client: TestClient, token: str) -> N
uid = _seed_user("13912340002")
db = SessionLocal()
try:
# today_status 现返 7 元组(末两位为观看时长闸:已看秒数 / 上限),取次数上限位
_used, limit, _coin, _rc, _cd, _ws, _wl = ad_reward.today_status(db, uid)
_used, limit, _coin, _rc, _cd = ad_reward.today_status(db, uid)
assert limit == 5 # 新配置生效
finally:
db.close()
+2 -5
View File
@@ -33,8 +33,7 @@ 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, "coupon_name": "外卖大额神券"},
"price": 123.50, "is_source": False, "rank": 1, "coupon_saved": 7.0},
{"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",
@@ -80,7 +79,7 @@ def test_report_and_derive(client) -> None:
def test_coupon_saved_passthrough(client) -> None:
"""红包优惠额 coupon_saved + 来源名 coupon_name 上报→落库→读出;仅目标平台带值,源平台不带(null)。"""
"""红包优惠额 coupon_saved 上报→落库→读出;仅目标平台带值,源平台不带(null)。"""
token = _login(client, "13800002010")
rid = client.post(
"/api/v1/compare/record", json=_food_payload("trace-coupon"), headers=_auth(token)
@@ -88,9 +87,7 @@ 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:
+6 -53
View File
@@ -133,10 +133,10 @@ def test_exchange_info(client) -> None:
def test_exchange_flow(client) -> None:
"""先供款够兑 1 元的金币 → 兑换 1 元 → 金币扣、现金加 → 现金流水有记录。
"""先供款够兑换下限的金币 → 兑换 1 元 → 金币扣、现金加 → 现金流水有记录。
兑换下限已降到 1 分(MIN_EXCHANGE_COIN = COIN_PER_CENT = 100), 但本用例验证兑换 1 元,
直接 grant_coins 注入 COIN_PER_YUAN(= 10000)当种子(够兑 1 元)
打开消息提醒任务已降到 1000 金币(不再 = 兑换下限), 不能再靠领任务供款;
直接 grant_coins 注入 MIN_EXCHANGE_COIN(= COIN_PER_YUAN = 10000)当种子。
"""
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, COIN_PER_YUAN, biz_type="test_seed", remark="测试供款")
crud_wallet.grant_coins(db, user.id, MIN_EXCHANGE_COIN, biz_type="test_seed", remark="测试供款")
db.commit()
# 兑换 10000 金币 → 100 分
@@ -180,51 +180,6 @@ 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")
@@ -236,10 +191,10 @@ def test_exchange_insufficient_and_invalid(client) -> None:
)
assert r.status_code == 409
# 低于最小额(下限现为 1 分=100;取 MIN_EXCHANGE_COIN-1=99,既低于下限又非整分)→ 400
# 低于最小额 → 400
r = client.post(
"/api/v1/wallet/exchange",
json={"coin_amount": MIN_EXCHANGE_COIN - 1},
json={"coin_amount": MIN_EXCHANGE_COIN - COIN_PER_CENT},
headers=_auth(token),
)
assert r.status_code == 400
@@ -271,8 +226,6 @@ 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:
+10 -79
View File
@@ -10,7 +10,6 @@ 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:
@@ -44,24 +43,6 @@ 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")
@@ -83,8 +64,7 @@ def test_bind_wechat(client, monkeypatch) -> None:
assert j["wechat_nickname"] == "测试昵称"
def test_withdraw_submit_then_approve_success_flow(client, monkeypatch) -> None:
"""提现先进 reviewing(扣款不打款) → 管理员审核通过才发起转账 → 查单 SUCCESS → success。"""
def test_withdraw_success_flow(client, monkeypatch) -> None:
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",
@@ -97,12 +77,13 @@ def test_withdraw_submit_then_approve_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 分 → 进入待审核(reviewing),已扣款但未打款,无 package_info
# 发起提现 50 分
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"] == "reviewing"
assert body["package_info"] is None
assert body["status"] == "pending"
assert body["package_info"] == "pkg123"
assert body["mch_id"] and body["app_id"]
assert body["cash_balance_cents"] == 50 # 已扣
bill = body["out_bill_no"]
@@ -111,10 +92,7 @@ def test_withdraw_submit_then_approve_success_flow(client, monkeypatch) -> None:
txns = r.json()["items"]
assert any(t["biz_type"] == "withdraw" and t["amount_cents"] == -50 for t in txns)
# 管理员审核通过 → 发起微信转账(mock WAIT_USER_CONFIRM,单进 pending)
_approve(bill)
# 用户确认到账后查单 → SUCCESS → 归一化 success
# 查单 → 微信返回 SUCCESS → 归一化 success
monkeypatch.setattr(
"app.integrations.wxpay.query_transfer",
lambda out_bill_no: {"status_code": 200, "data": {"state": "SUCCESS"}},
@@ -145,12 +123,8 @@ 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",
@@ -192,8 +166,7 @@ def test_withdraw_not_bound(client) -> None:
assert r.status_code == 400, r.text
def test_withdraw_approve_transfer_fail_refunds(client, monkeypatch) -> None:
"""审核通过发起转账失败(非200) + 查单 NOT_FOUND(未创建) → 自动退款 + 单 failed。"""
def test_withdraw_transfer_fail_refunds(client, monkeypatch) -> None:
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",
@@ -211,14 +184,8 @@ def test_withdraw_approve_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 == 200, r.text
assert r.json()["status"] == "reviewing"
bill = r.json()["out_bill_no"]
# 管理员审核通过 → 转账失败 → 自动退款 + 单 failed
_approve(bill)
assert r.status_code == 502, r.text
# 余额已退回
r = client.get("/api/v1/wallet/account", headers=_auth(token))
@@ -255,9 +222,7 @@ 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
# 提现阶段不打款 → create_transfer 一次都不会被调;两次都返回同一张待审核单
assert calls["n"] == 0
assert r1.json()["status"] == "reviewing" and r2.json()["status"] == "reviewing"
assert calls["n"] == 1 # 只真发起一次转账
assert r1.json()["out_bill_no"] == bill
assert r2.json()["out_bill_no"] == bill
# 余额只扣一次(100-50=50)
@@ -284,13 +249,7 @@ 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"] == "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"
assert r.json()["status"] == "success"
# 没退款:余额仍是扣后的 50,且无 withdraw_refund
r = client.get("/api/v1/wallet/account", headers=_auth(token))
assert r.json()["cash_balance_cents"] == 50
@@ -310,31 +269,3 @@ 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"] == "测试拒绝"
-494
View File
@@ -1,494 +0,0 @@
"""美团 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>榜单&nbsp;&nbsp;<span class="chtag ch-b">搜</span>搜索词&nbsp;&nbsp;<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 => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;"}[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")