Compare commits

..

7 Commits

Author SHA1 Message Date
marco f5d1a1a20d feat(price-observation): 价格观测资产表 + pricebot 内部上报端点 (#21)
比价沉淀的价格事实(平台/门店/菜篮/到手价/时间/来源)无条件落库,server 侧存储、
与登录无关,独立于用户视角的 comparison_record。是未来"别人查过同店→秒回价格"
价格大数据的源头(先存数据,用法后续)。

- models/price_observation.py: 新增 price_observation 表。结构化列(平台/门店/价格/
  红包/地理/来源)+ dishes/attrs 两个 JSONB 兜底列;(trace_id,platform,scope) 幂等
  唯一约束;observed_at/store_name/geohash/source_device_id/source_user_id 等索引
- alembic/versions/price_observation_table.py: 建表迁移,down_revision=wx_transfer_auth
  (已核为当前唯一 head,无多 head 风险)
- schemas/ + repositories/price_observation.py: 批量收发模型 + 跨方言(PG/SQLite)幂等
  批量插入(先查 trace 已有 (platform,scope) 只插新的,并发撞唯一约束则回滚跳过)
- api/internal/price.py: 新增 POST /internal/price-observation,X-Internal-Secret 头校验
  (常量时间比较,未配密钥→503),server→server 专用,不走用户 JWT
- core/config.py + main.py: 新增 INTERNAL_API_SECRET 配置 + 注册内部路由
- .env.example: INTERNAL_API_SECRET(须与 pricebot 侧同值,留空=内部写端点关闭)

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

---------

Co-authored-by: pure <pure@192.168.0.104>
Reviewed-on: #21
2026-06-07 03:31:35 +08:00
marco 7126fb3ba3 feat(concurrency): trace 一致性 hash 亲和 + 透传 stream 优化 (#20)
配合 pricebot 单机多进程:透传层按 trace_id 把同一次比价/领券的所有帧
亲和到同一 pricebot 实例,使进程内内存维护 state、无需 Redis。本仓改动:

- 一致性 hash: 新增 app/core/pricebot_router.py(ketama 环,1000 虚拟节点)
  + pick_pricebot(trace_id),md5 纯函数、跨 worker 一致;实测负载偏差 4%、
  6→7 扩容只重映射 ~13%(取模会 ~85%)。
- 实例列表: app/core/config.py 加 PRICEBOT_INSTANCES(逗号分隔)+
  pricebot_instances property;留空 fallback 单实例,向后兼容。
- 透传亲和 + stream: api/v1/compare.py + coupon.py 改为读原始字节、只
  loads 一次拿 trace_id 选实例、转发原始 bytes 不重序列化(省 ~半透传 CPU,
  单 worker 即可扛高并发,避开多 worker 破坏短信内存表的坑);覆盖
  intent/recognize、intent/step、price/step、coupon/step 四端点。
- .env.example 加 PRICEBOT_INSTANCES + PRICEBOT_COMPARE_TIMEOUT_SEC。

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

---------

Co-authored-by: pure <pure@192.168.0.104>
Reviewed-on: #20
2026-06-07 02:33:50 +08:00
marco f098ba3816 日志: app-server 增加 JSON 文件输出供阿里云 SLS 采集 (#19)
app/core/logging.py:
- 新增 RotatingFileHandler 落 logs/app-server.log (此前仅 stdout), 用 JsonFormatter 输出
  单行 JSON (time/level/service/logger/func/line/message, 异常栈内嵌不换行)。
- 控制台保持文本 (stdout 行为不变, systemd/run.log 不受影响)。
- setup_logging 加幂等保护 + 清理预置 root handler 防重复输出。
- 支持环境变量 LOG_FILE/LOG_DIR/LOG_JSON_CONSOLE/LOG_SERVICE_NAME
  (admin 子进程应设独立 LOG_FILE 避免与主进程争抢同一轮转文件)。

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

---------

Co-authored-by: pure <pure@192.168.0.104>
Reviewed-on: #19
2026-06-07 02:26:45 +08:00
marco b4780e256e docs(database): 补全 19 表文档 + 新增 OVERVIEW 总览
- 重写 15 张已有表文档:每张补「用在 App 哪/增删改查时机/字段取值/join key 指向」
- 新建 4 张缺失表文档:wechat_transfer_authorization / ad_watch_log / price_report / app_config
- 新增 OVERVIEW.md:功能↔表映射 + 写入路径 + 表间关系(含语义 join key)+ ER + 资金模型
- README 索引补齐到 19 表 + 置顶 OVERVIEW + 通用约定补全
- 纠正过时:withdraw_order(reviewing/rejected/user_name)、savings_record(/order/report 真实写入)、coin_transaction(biz_type)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:25:57 +08:00
marco b59dc3ac19 feat(wallet): 微信商家转账「用户授权免确认收款」(免确认到账) (#16)
提现从「每次跳微信确认」升级为免确认模式:首次授权一次,之后审核通过直接到账,用户零跳转。
方式一(首单转账顺带授权 pre-transfer-with-authorization)+ 方式二(显式开启 user-confirm-authorization)。

- 新增 wechat_transfer_authorization 表(user 1:1,out_authorization_no/authorization_id/state) + 迁移
- wxpay.py 新增 apply/query/close_transfer_authorization + pre_transfer_with_authorization + transfer_with_authorization
- repositories/wallet.py 授权 CRUD + sync/_refresh_active_auth + execute_withdraw_transfer 三分叉
  (active->免确认转账 / 无授权且配了回调->方式一 / 未配->退化原确认模式);失效回查授权权威判定,绝不盲退
- /wallet/transfer-auth(开启)/status/close 路由 + withdraw-info 增 transfer_auth_enabled
- 授权结果回调 stub /api/v1/wxpay/transfer-auth-notify(一期仅应答不验签,真值靠 query 轮询)
- config 增 WXPAY_AUTH_NOTIFY_URL

一期无回调验签;二期补 V3 平台证书验签 + AEAD 解密。

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

Reviewed-on: #16
2026-06-06 18:52:42 +08:00
marco e252277431 feat(tools): 美团 CPS API 调试 playground
浏览器填参数 → 本地签名代理打美团 → 卡片列表 + 可折叠 JSON 树。
模板按到店/到家分组 + 渠道标识(榜/搜/供),支持翻页、测试坐标、距离展示。
复用 integrations/meituan.py 的 _sign/_content_md5,AppSecret 只留后端,不碰生产代码。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:13:07 +08:00
ouzhou 0e42e96ddb docs(admin): 补充管理后台 API 与数据库文档 (#12)
新增 admin 模块接口文档(auth login/me、admins CRUD、audit-logs、feedback 处理、stats 概览、user coins/detail/status、wallet 流水、withdraw 对账/刷新/列表) + admin_user/admin_audit_log 两张表文档,更新 api/database README 索引。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #12
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-06 15:58:23 +08:00
43 changed files with 2416 additions and 305 deletions
+15 -2
View File
@@ -41,12 +41,25 @@ MT_CPS_APP_SECRET=
# 默认渠道追踪标识(sid),用于区分不同 app 的 CPS 数据
MT_CPS_DEFAULT_SID=sgbjia
# ===== Pricebot 上游 (领券业务透传目标) =====
# 客户端调本服务的 /api/v1/coupon/step,我们透传到 pricebot-backend 的 /api/coupon/step
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
# 客户端调本服务的 /api/v1/coupon/step,我们透传到 pricebot-backend。
# 本地开发用 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 之类
@@ -0,0 +1,74 @@
"""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')
@@ -0,0 +1,48 @@
"""wechat_transfer_authorization 表(免确认收款授权)
商家转账「用户授权免确认收款模式」:用户授权一次后,后续提现免逐笔确认直接到账。
一个用户一条(user_id 主键)。out_authorization_no 我方生成,authorization_id 微信 active 后返回。
Revision ID: wx_transfer_auth
Revises: withdraw_review_ad_watch
Create Date: 2026-06-06 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'wx_transfer_auth'
down_revision: Union[str, Sequence[str], None] = 'withdraw_review_ad_watch'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'wechat_transfer_authorization',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('openid', sa.String(length=64), nullable=False),
sa.Column('out_authorization_no', sa.String(length=64), nullable=False),
sa.Column('authorization_id', sa.String(length=64), nullable=True),
sa.Column('state', sa.String(length=16), server_default='pending', nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('user_id'),
)
with op.batch_alter_table('wechat_transfer_authorization', schema=None) as batch_op:
batch_op.create_index(
batch_op.f('ix_wechat_transfer_authorization_out_authorization_no'),
['out_authorization_no'],
unique=True,
)
def downgrade() -> None:
with op.batch_alter_table('wechat_transfer_authorization', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_wechat_transfer_authorization_out_authorization_no'))
op.drop_table('wechat_transfer_authorization')
+1
View File
@@ -0,0 +1 @@
"""内部(server→server)端点。不对客户端开放,靠共享密钥头校验,不走用户 JWT。"""
+63
View File
@@ -0,0 +1,63 @@
"""价格观测内部上报端点(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)
+18 -6
View File
@@ -18,6 +18,7 @@ pricebot 协议文档:
"""
from __future__ import annotations
import json
import logging
from typing import Any
@@ -25,6 +26,7 @@ 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")
@@ -38,25 +40,35 @@ 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:
body = await request.json()
meta = json.loads(raw)
except Exception as e:
raise HTTPException(status_code=400, detail=f"invalid json body: {e}") from e
if not isinstance(meta, dict):
meta = {}
url = f"{settings.PRICEBOT_BASE_URL.rstrip('/')}{upstream_path}"
# 按 trace_id 一致性 hash 选 pricebot 实例(同一比价的所有帧落同一进程,内存维护 state)
base = pick_pricebot(meta.get("trace_id"))
url = f"{base.rstrip('/')}{upstream_path}"
timeout = settings.PRICEBOT_COMPARE_TIMEOUT_SEC
logger.info(
"compare %s device_id=%s trace_id=%s step=%s",
upstream_path,
body.get("device_id"),
body.get("trace_id"),
body.get("step"),
meta.get("device_id"),
meta.get("trace_id"),
meta.get("step"),
)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(url, json=body)
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}
)
except httpx.RequestError as e:
logger.error("[pricebot] request failed: %s", e)
raise HTTPException(
+17 -6
View File
@@ -10,6 +10,7 @@ pricebot 协议文档:
"""
from __future__ import annotations
import json
import logging
from typing import Any
@@ -17,6 +18,7 @@ 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")
@@ -33,24 +35,33 @@ async def coupon_step(
- 透传: 不做 schema 校验,pricebot 自己校验
- 失败: 网络不可达 / pricebot 5xx → 502 + 友好 message
"""
# 读原始字节,避免"反序列化→再序列化"的双重 JSON(省透传 CPU)。只 loads 一次拿
# trace_id 做亲和 + 打日志,转发时直接发原始 bytes,不重新 dumps。
raw = await request.body()
try:
body = await request.json()
meta = json.loads(raw)
except Exception as e:
raise HTTPException(status_code=400, detail=f"invalid json body: {e}") from e
if not isinstance(meta, dict):
meta = {}
url = f"{settings.PRICEBOT_BASE_URL.rstrip('/')}/api/coupon/step"
# 按 trace_id 一致性 hash 选 pricebot 实例(同一领券任务的所有帧落同一进程)
base = pick_pricebot(meta.get("trace_id"))
url = f"{base.rstrip('/')}/api/coupon/step"
timeout = settings.PRICEBOT_REQUEST_TIMEOUT_SEC
logger.info(
"coupon_step device_id=%s trace_id=%s step=%s",
body.get("device_id"),
body.get("trace_id"),
body.get("step"),
meta.get("device_id"),
meta.get("trace_id"),
meta.get("step"),
)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(url, json=body)
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}
)
except httpx.RequestError as e:
logger.error("[pricebot] request failed: %s", e)
raise HTTPException(
+65 -2
View File
@@ -33,6 +33,8 @@ from app.schemas.welfare import (
ExchangeInfoOut,
ExchangeRequest,
ExchangeResultOut,
TransferAuthResultOut,
TransferAuthStatusOut,
UnbindWechatResultOut,
WithdrawInfoOut,
WithdrawOrderOut,
@@ -155,15 +157,18 @@ 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"),
)
@@ -191,9 +196,19 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
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)
acc = crud_wallet.get_or_create_account(db, user.id)
logger.info(
"withdraw submitted user_id=%d cents=%d bill=%s status=%s(待审核)",
"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 提示"已提交,等待审核"。
@@ -246,3 +261,51 @@ def withdraw_orders(
items=[WithdrawOrderOut.model_validate(it) for it in items],
next_cursor=next_cursor,
)
# ===== 免确认收款授权(用户授权免确认模式)=====
# 开启一次后,后续提现走免确认转账直接到账,不再跳微信确认。绑定 openid 是前提。
@router.post(
"/transfer-auth",
response_model=TransferAuthResultOut,
summary="开启免确认到账(申请授权,返回拉起微信授权页的 package)",
dependencies=[Depends(rate_limit(10, 60, "transfer-auth"))],
)
def open_transfer_auth(user: CurrentUser, db: DbSession) -> TransferAuthResultOut:
if not settings.wxpay_auth_configured:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="wechat transfer auth not configured",
)
try:
info = crud_wallet.apply_transfer_auth(db, user.id)
except crud_wallet.WechatNotBoundError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="wechat not bound") from e
except crud_wallet.WithdrawTransferError as e:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) from e
logger.info("open transfer-auth user_id=%d already_active=%s", user.id, info["already_active"])
return TransferAuthResultOut(**info)
@router.get(
"/transfer-auth/status",
response_model=TransferAuthStatusOut,
summary="查免确认授权状态(从微信授权页返回后轮询)",
)
def transfer_auth_status(user: CurrentUser, db: DbSession) -> TransferAuthStatusOut:
auth = crud_wallet.sync_transfer_auth(db, user.id)
state = auth.state if auth else "none"
return TransferAuthStatusOut(state=state, enabled=(state == "active"))
@router.post(
"/transfer-auth/close",
response_model=TransferAuthStatusOut,
summary="关闭免确认到账(解除授权)",
)
def close_transfer_auth_endpoint(user: CurrentUser, db: DbSession) -> TransferAuthStatusOut:
crud_wallet.close_transfer_auth(db, user.id)
logger.info("close transfer-auth user_id=%d", user.id)
return TransferAuthStatusOut(state="closed", enabled=False)
+30
View File
@@ -0,0 +1,30 @@
"""微信支付相关回调(一期:免确认收款授权结果通知 stub)。
⚠️ 一期不处理回调内容、不验签:授权状态以主动查询(query_transfer_authorization)为准。
本端点仅向微信回 200 避免重试风暴,**绝不依据回调内容改账**。二期接入时必须先补
V3 平台证书/公钥验签(Wechatpay-Signature) + APIv3 密钥 AEAD 解密,验签通过后方可信任并处理。
授权回调地址通过 settings.WXPAY_AUTH_NOTIFY_URL 配置,需指向本端点的公网地址。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Request
logger = logging.getLogger("shagua.wxpay")
router = APIRouter(prefix="/api/v1/wxpay", tags=["wxpay"])
@router.post("/transfer-auth-notify", summary="免确认收款授权结果通知(一期 stub:仅应答,不处理)")
async def transfer_auth_notify(request: Request) -> dict:
# 一期:不验签、不解密、不改账。仅记录 + 应答成功;真实授权状态靠 /transfer-auth/status 查询兜底。
try:
body = await request.json()
logger.info(
"transfer-auth notify id=%s type=%s", body.get("id"), body.get("event_type")
)
except Exception: # noqa: BLE001 — body 解析失败也照常应答 200,避免微信重试
logger.info("transfer-auth notify (unparseable body)")
return {"code": "SUCCESS", "message": "OK"}
+29 -1
View File
@@ -99,6 +99,10 @@ class Settings(BaseSettings):
WXPAY_PUBLIC_KEY_PATH: str = "./secrets/pub_key.pem" # 微信支付平台公钥
WXPAY_TRANSFER_SCENE_ID: str = "1000" # 转账场景 ID(1000=现金营销)
WXPAY_REQUEST_TIMEOUT_SEC: int = 10
# 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。
# 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容
# (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
WXPAY_AUTH_NOTIFY_URL: str = ""
@property
def wxpay_configured(self) -> bool:
@@ -110,6 +114,11 @@ class Settings(BaseSettings):
and self.WXPAY_PUBLIC_KEY_ID
)
@property
def wxpay_auth_configured(self) -> bool:
"""免确认收款授权可用 = 微信支付凭证齐全 + 授权回调地址已配。"""
return bool(self.wxpay_configured and self.WXPAY_AUTH_NOTIFY_URL)
# ===== 穿山甲激励视频(服务端发奖回调)=====
# 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。
# PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",验签用,从后台取到后填 .env。
@@ -126,15 +135,34 @@ 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,绕过应用进程。
+76 -5
View File
@@ -1,20 +1,91 @@
"""统一日志配置。
业务代码用 `logger = logging.getLogger("shagua.xxx")` 即可,本模块在 main.py 启动时调一次。
业务代码用 `logger = logging.getLogger("shagua.xxx")` 即可, 本模块在 main.py 启动时调一次。
- 控制台(stdout): 人类可读文本, 给 systemd / 本地看。
- 文件 `logs/app-server.log`: 单行 JSON, 供阿里云 SLS/Logtail 采集(JSON 模式零正则);
异常栈作为字段内嵌不换行 → 每条日志一行。
- 环境变量:
- LOG_JSON_CONSOLE=1 控制台也输出 JSON
- LOG_DIR / LOG_FILE 改落盘路径(默认 logs/app-server.log)
⚠️ admin 子进程(app.admin.main, 独立进程)应设不同 LOG_FILE, 避免与主进程争抢同一轮转文件
- LOG_SERVICE_NAME JSON 里的 service 字段(默认 app-server)
"""
from __future__ import annotations
import json
import logging
import os
import sys
from datetime import datetime
from logging.handlers import RotatingFileHandler
from pathlib import Path
class JsonFormatter(logging.Formatter):
"""把 LogRecord 序列化成单行 JSON(SLS/Logtail 友好)。异常栈内嵌为字段, 整条仍是一行。"""
def __init__(self, service: str = "app-server"):
super().__init__()
self.service = service
def format(self, record: logging.LogRecord) -> str:
dt = datetime.fromtimestamp(record.created)
data = {
"time": dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(record.msecs):03d}",
"level": record.levelname,
"service": self.service,
"logger": record.name,
"func": record.funcName,
"line": record.lineno,
"message": record.getMessage(),
}
if record.exc_info:
data["exception"] = self.formatException(record.exc_info)
if record.stack_info:
data["stack"] = self.formatStack(record.stack_info)
return json.dumps(data, ensure_ascii=False, default=str)
_CONFIGURED = False
def setup_logging(debug: bool = False) -> None:
global _CONFIGURED
if _CONFIGURED:
return
level = logging.DEBUG if debug else logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
stream=sys.stdout,
service = os.getenv("LOG_SERVICE_NAME", "app-server")
root = logging.getLogger()
root.setLevel(level)
# 清掉 basicConfig/uvicorn 可能预置的 root handler, 避免重复输出
for h in list(root.handlers):
root.removeHandler(h)
# 控制台: 默认文本(systemd/本地看); LOG_JSON_CONSOLE=1 时输出 JSON
console = logging.StreamHandler(sys.stdout)
if os.getenv("LOG_JSON_CONSOLE") == "1":
console.setFormatter(JsonFormatter(service))
else:
console.setFormatter(
logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
)
root.addHandler(console)
# 文件: 单行 JSON, 供 Logtail 采集(自动轮转, 单文件 10MB, 保留 5 个)
log_file = os.getenv("LOG_FILE") or str(
Path(os.getenv("LOG_DIR", "logs")) / "app-server.log"
)
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
file_handler = RotatingFileHandler(
log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8",
)
file_handler.setFormatter(JsonFormatter(service))
root.addHandler(file_handler)
# 第三方库降噪
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
_CONFIGURED = True
+82
View File
@@ -0,0 +1,82 @@
"""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))
+165
View File
@@ -24,6 +24,10 @@ from app.core.config import settings
_API_HOST = "https://api.mch.weixin.qq.com"
_TRANSFER_PATH = "/v3/fund-app/mch-transfer/transfer-bills"
# 免确认收款授权(用户授权免确认模式)
_AUTH_PATH = "/v3/fund-app/mch-transfer/user-confirm-authorization"
_PRE_TRANSFER_AUTH_PATH = "/v3/fund-app/mch-transfer/transfer-bills/pre-transfer-with-authorization"
_TRANSFER_WITH_AUTH_PATH = "/v3/fund-app/mch-transfer/transfer-bills/transfer"
# 懒加载缓存
_private_key: RSAPrivateKey | None = None
@@ -199,3 +203,164 @@ def code_to_userinfo(code: str) -> dict:
pass
return {"openid": openid, "nickname": nickname, "avatar_url": avatar_url, "raw": raw}
# ===== 免确认收款授权(用户授权免确认模式)=====
# 用户授权一次后,后续转账走 transfer_with_authorization 免逐笔确认直接到账。
# 复用上面同一套 V3 签名(_build_authorization)、敏感字段加密(encrypt_sensitive)、懒加载密钥。
def _transfer_report_infos() -> list[dict]:
"""现金营销(scene 1000)转账场景报备信息,与 create_transfer 同口径。"""
return [
{"info_type": "活动名称", "info_content": "比价返现"},
{"info_type": "奖励说明", "info_content": "现金提现到微信零钱"},
]
def apply_transfer_authorization(
out_authorization_no: str,
openid: str,
user_display_name: str,
notify_url: str,
*,
scene_info: dict | None = None,
) -> dict:
"""发起免确认收款授权(方式二:不转账,仅申请授权)。返回 {status_code, data}。
成功(200 + state=WAIT_USER_CONFIRM)时 data 带 package_info,供 App 拉起微信授权页。"""
body: dict = {
"out_authorization_no": out_authorization_no,
"appid": settings.WECHAT_APP_ID,
"openid": openid,
"transfer_scene_id": settings.WXPAY_TRANSFER_SCENE_ID,
"user_display_name": user_display_name,
"authorization_notify_url": notify_url,
}
if scene_info:
body["scene_info"] = scene_info
body_str = json.dumps(body, ensure_ascii=False)
headers = {
"Authorization": _build_authorization("POST", _AUTH_PATH, body_str),
"Accept": "application/json",
"Content-Type": "application/json",
}
with httpx.Client() as client:
resp = client.post(
f"{_API_HOST}{_AUTH_PATH}",
content=body_str,
headers=headers,
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
)
return {"status_code": resp.status_code, "data": resp.json()}
def query_transfer_authorization(out_authorization_no: str) -> dict:
"""按商户授权单号查授权结果。返回 {status_code, data}。
data.state: WAIT_USER_CONFIRM / TAKING_EFFECT(已生效) / CLOSED;TAKING_EFFECT 时带 authorization_id。"""
path = f"{_AUTH_PATH}/out-authorization-no/{out_authorization_no}"
headers = {
"Authorization": _build_authorization("GET", path, ""),
"Accept": "application/json",
}
with httpx.Client() as client:
resp = client.get(
f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC
)
return {"status_code": resp.status_code, "data": resp.json()}
def close_transfer_authorization(out_authorization_no: str) -> dict:
"""解除免确认收款授权。返回 {status_code, data}(成功 data.state=CLOSED)。"""
path = f"{_AUTH_PATH}/out-authorization-no/{out_authorization_no}/close"
headers = {
"Authorization": _build_authorization("POST", path, ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
with httpx.Client() as client:
resp = client.post(
f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC
)
return {"status_code": resp.status_code, "data": resp.json()}
def pre_transfer_with_authorization(
openid: str,
amount_fen: int,
out_bill_no: str,
out_authorization_no: str,
user_display_name: str,
notify_url: str,
user_name: str | None = None,
) -> dict:
"""方式一:发起转账并同时申请免确认收款授权(用户在确认这笔收款时一并完成授权)。
返回 {status_code, data};成功 state=WAIT_USER_CONFIRM 时带 package_info(拉确认+授权页)。"""
body: dict = {
"appid": settings.WECHAT_APP_ID,
"out_bill_no": out_bill_no,
"transfer_scene_id": settings.WXPAY_TRANSFER_SCENE_ID,
"openid": openid,
"transfer_amount": amount_fen,
"transfer_remark": "金币提现",
"transfer_scene_report_infos": _transfer_report_infos(),
"authorization_info": {
"user_display_name": user_display_name,
"out_authorization_no": out_authorization_no,
"authorization_notify_url": notify_url,
},
}
if user_name and amount_fen >= 30:
body["user_name"] = encrypt_sensitive(user_name)
body_str = json.dumps(body, ensure_ascii=False)
headers = {
"Authorization": _build_authorization("POST", _PRE_TRANSFER_AUTH_PATH, body_str),
"Accept": "application/json",
"Content-Type": "application/json",
"Wechatpay-Serial": settings.WXPAY_PUBLIC_KEY_ID,
}
with httpx.Client() as client:
resp = client.post(
f"{_API_HOST}{_PRE_TRANSFER_AUTH_PATH}",
content=body_str,
headers=headers,
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
)
return {"status_code": resp.status_code, "data": resp.json()}
def transfer_with_authorization(
authorization_id: str,
amount_fen: int,
out_bill_no: str,
user_name: str | None = None,
) -> dict:
"""方式二 / 二次起:用户已授权后免确认转账(无需用户逐笔确认,直接到账)。返回 {status_code, data}。
成功 state ∈ ACCEPTED/PROCESSING/TRANSFERING/SUCCESS(无 WAIT_USER_CONFIRM、无 package_info)。"""
body: dict = {
"appid": settings.WECHAT_APP_ID,
"out_bill_no": out_bill_no,
"transfer_scene_id": settings.WXPAY_TRANSFER_SCENE_ID,
"transfer_amount": amount_fen,
"transfer_remark": "金币提现",
"transfer_scene_report_infos": _transfer_report_infos(),
"authorization_id": authorization_id,
}
if user_name and amount_fen >= 30:
body["user_name"] = encrypt_sensitive(user_name)
body_str = json.dumps(body, ensure_ascii=False)
headers = {
"Authorization": _build_authorization("POST", _TRANSFER_WITH_AUTH_PATH, body_str),
"Accept": "application/json",
"Content-Type": "application/json",
"Wechatpay-Serial": settings.WXPAY_PUBLIC_KEY_ID,
}
with httpx.Client() as client:
resp = client.post(
f"{_API_HOST}{_TRANSFER_WITH_AUTH_PATH}",
content=body_str,
headers=headers,
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
)
return {"status_code": resp.status_code, "data": resp.json()}
+5
View File
@@ -20,6 +20,7 @@ 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
@@ -29,6 +30,7 @@ from app.api.v1.signin import router as signin_router
from app.api.v1.tasks import router as tasks_router
from app.api.v1.user import router as user_router
from app.api.v1.wallet import router as wallet_router
from app.api.v1.wxpay import router as wxpay_router
from app.core.config import settings
from app.core.logging import setup_logging
@@ -82,12 +84,15 @@ 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)
+1
View File
@@ -7,6 +7,7 @@ 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
+111
View File
@@ -0,0 +1,111 @@
"""价格观测表(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}>"
)
+37
View File
@@ -107,6 +107,43 @@ class WithdrawOrder(Base):
return f"<WithdrawOrder id={self.id} user_id={self.user_id} cents={self.amount_cents} {self.status}>"
class WechatTransferAuthorization(Base):
"""微信商家转账「免确认收款授权」(用户授权免确认模式)。一个用户一条(user_id 主键)。
状态机:
(无) →(申请 / 首单转账顺带申请)→ pending(微信 WAIT_USER_CONFIRM,待用户在微信确认授权)
pending →(用户确认授权)→ active(微信 TAKING_EFFECT,此后转账免用户逐笔确认)
pending / active →(用户在微信关 / 商户解除 / 风控)→ closed(终态,需重新开启)
out_authorization_no 我方生成(查授权 / 发起授权用,一个用户一条稳定值,重开时换新);
authorization_id 微信在 active 后返回,免确认转账(transfer_with_authorization)时必传。
"""
__tablename__ = "wechat_transfer_authorization"
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), primary_key=True
)
openid: Mapped[str] = mapped_column(String(64), nullable=False)
# 商户侧授权单号(我方生成),唯一
out_authorization_no: Mapped[str] = mapped_column(
String(64), unique=True, index=True, nullable=False
)
# 微信侧授权单号(active 后返回,免确认转账要用)
authorization_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# pending(待用户确认) / active(已生效可免确认) / closed(已关闭需重开)
state: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<WechatTransferAuthorization user_id={self.user_id} state={self.state}>"
class CashTransaction(Base):
"""现金流水(单位:分)。金币兑现金、提现都记在这里。"""
+86
View File
@@ -0,0 +1,86 @@
"""价格观测落库:一次比价的整批观测幂等写入。
幂等键 = (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
+218 -21
View File
@@ -7,6 +7,7 @@
from __future__ import annotations
import re
import unicodedata
import uuid
from datetime import datetime, timedelta, timezone
@@ -14,15 +15,25 @@ 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, WithdrawOrder
from app.models.wallet import (
CashTransaction,
CoinAccount,
CoinTransaction,
WechatTransferAuthorization,
WithdrawOrder,
)
# 微信转账终态:成功 / 失败(失败/取消/关闭都退款)
_WX_STATE_SUCCESS = "SUCCESS"
_WX_STATE_FAILED = {"FAIL", "CANCELLED", "CLOSED"}
_WX_STATE_WAIT_CONFIRM = "WAIT_USER_CONFIRM" # 用户还没在微信确认页确认
# 免确认收款授权状态
_WX_AUTH_ACTIVE = "TAKING_EFFECT" # 已生效,可免确认转账
_WX_AUTH_CLOSED = "CLOSED" # 已关闭(用户/商户/风控),需重新开启
class InvalidExchangeAmountError(Exception):
@@ -405,13 +416,175 @@ def create_withdraw(
return order # 待管理员审核;**不在此处打款**
def execute_withdraw_transfer(db: Session, order: WithdrawOrder) -> WithdrawOrder:
"""审核通过后真正发起微信转账(复用原"转账 + 模糊失败查单"逻辑,绝不盲目退款)。
# ===== 免确认收款授权(用户授权免确认模式)=====
# 用户授权一次后,后续提现走 transfer_with_authorization 免逐笔确认直接到账。
# WechatTransferAuthorization 一个用户一条;out_authorization_no 我方生成,authorization_id 微信生效后返回。
流程:reviewing → 置 pending → 调微信转账 → SUCCESS=success / 失败/取消=退款 failed /
结果不明=先查单再决定。**不抛异常**(admin 调用方按 order.status 判断结果)。
用户在审核期间解绑微信 → 退款 failed(打不了款)。
可能返回 package_info(WAIT_USER_CONFIRM 场景:需用户在 App 端确认页确认后才真正到账)。
def _auth_display_name(user: User | None) -> str:
"""微信授权页展示的"开通账号"昵称(≤32,utf8)。
微信对 user_display_name 校验极严:**连空格和标点(. 等)都算"控制字符"拒收**——实测
'wonderable ai'(带空格)被 400 拒、'wonderableai'/'周周'/'傻瓜比价用户8888' 才过。
故只保留 文字(Unicode L*,中英文/CJK)与数字(N*),其余(emoji/符号/空格/标点/控制符/
零宽连接符/变体选择符/星形面字符)一律剔除;清空则兜底手机号尾号。
"""
raw = ((user.nickname if user else None) or (user.wechat_nickname if user else None) or "").strip()
kept = [
ch for ch in raw
if ord(ch) <= 0xFFFF and unicodedata.category(ch)[0] in ("L", "N")
]
name = "".join(kept).strip()
if not name and user and user.phone:
tail = user.phone[-4:] if len(user.phone) >= 4 else user.phone
name = f"傻瓜比价用户{tail}"
return (name or "傻瓜比价用户")[:32]
def _refresh_active_auth(db: Session, user_id: int) -> None:
"""免确认转账失败后回查授权有效性(权威判定,不靠猜错误码):
微信侧明确非生效(用户在微信关闭 / 风控 / 无此单)→ 标 closed,下次提现自动回退方式一重新授权;
仍生效(失败实为商户余额不足等与授权无关的原因)→ 保持 active,下次免确认重试。
仅对本地 active 记录查询;查询本身失败(5xx/网络)不改状态,下次再核,避免误关有效授权。"""
auth = db.get(WechatTransferAuthorization, user_id)
if auth is None or auth.state != "active" or not settings.wxpay_configured:
return
try:
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
@@ -420,33 +593,57 @@ def execute_withdraw_transfer(db: Session, order: WithdrawOrder) -> WithdrawOrde
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:
result = wxpay.create_transfer(
openid, order.amount_cents, order.out_bill_no, user_name=order.user_name
)
if settings.WXPAY_AUTH_NOTIFY_URL:
out_auth_no = _ensure_pending_auth_no(db, order.user_id, openid)
result = wxpay.pre_transfer_with_authorization(
openid, order.amount_cents, order.out_bill_no,
out_authorization_no=out_auth_no,
user_display_name=_auth_display_name(user),
notify_url=settings.WXPAY_AUTH_NOTIFY_URL,
user_name=order.user_name,
)
else:
result = wxpay.create_transfer(
openid, order.amount_cents, order.out_bill_no, user_name=order.user_name
)
except Exception as e: # noqa: BLE001 — 超时/网络异常≠失败,查单确认
_settle_after_ambiguous(db, order, reason=f"转账调用异常: {e}")
db.refresh(order)
return order
if result["status_code"] != 200:
msg = str(result["data"].get("message") or result["data"])
_settle_after_ambiguous(db, order, reason=msg)
_settle_after_ambiguous(db, order, reason=str(result["data"].get("message") or result["data"]))
db.refresh(order)
return order
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
return _apply_transfer_result(db, order, result["data"])
def _get_withdraw_or_raise(db: Session, out_bill_no: str) -> WithdrawOrder:
+49
View File
@@ -0,0 +1,49 @@
"""价格观测内部上报的收发模型。
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
+24
View File
@@ -80,6 +80,23 @@ class WithdrawInfoOut(BaseModel):
wechat_bound: bool = Field(..., description="当前用户是否已绑定微信")
wechat_nickname: str | None = Field(None, description="微信昵称(可能为空/脱敏)")
wechat_avatar_url: str | None = Field(None, description="微信头像 URL(可能为空)")
transfer_auth_enabled: bool = Field(
False, description="是否已开启免确认到账(开启后提现免跳微信确认,直接到账)"
)
# ===== 免确认收款授权(用户授权免确认模式)=====
class TransferAuthResultOut(BaseModel):
already_active: bool = Field(False, description="是否已是开启状态(无需再授权)")
package_info: str | None = Field(None, description="拉起微信授权页的 package(已开启时为空)")
mch_id: str | None = None
app_id: str | None = None
class TransferAuthStatusOut(BaseModel):
state: str = Field(..., description="none(未开启)/pending(待确认)/active(已开启)/closed(已关闭)")
enabled: bool = Field(..., description="是否已开启免确认到账")
class BindWechatRequest(BaseModel):
@@ -102,6 +119,13 @@ class WithdrawRequest(BaseModel):
out_bill_no: str | None = Field(
None, description="客户端幂等键(商户单号):同号重试不重复转账。不传则服务端生成"
)
skip_review: bool = Field(
False,
description=(
"调试直发:跳过人工审核立即打款。仅非生产(APP_ENV!=prod)生效,"
"客户端仅 debug 包下发(开发设置开关)。生产恒走人工审核。"
),
)
class WithdrawResultOut(BaseModel):
+148
View File
@@ -0,0 +1,148 @@
# 数据库总览 — 表 × 功能 × 关系
> 跨表视角。单表字段级细节看同目录 `<表名>.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 + 同步更新本目录文档。
+45 -25
View File
@@ -3,37 +3,57 @@
> 数据库: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-04+ admin_user / admin_audit_log
> 最后更新:2026-06-06(补全 19 张表 + 新增 [OVERVIEW 总览](./OVERVIEW.md))
> 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。
---
## 表总览
## 表总览(19 张业务表 + `alembic_version` 框架表)
| 表 | 用途 | 模型 | 关联模块 | 文档 |
|---|---|---|---|---|
| `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) |
| `admin_user` | 运营后台管理员账号(独立鉴权) | `models/admin.py` | Admin 后台 | [详情](./admin_user.md) |
| `admin_audit_log` | 运营后台操作审计日志(只追加) | `models/admin.py` | Admin 后台 | [详情](./admin_audit_log.md) |
### 账号 / 反馈
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
| `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) |
---
## 通用约定
- **主键**:`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;改表/建表同时更新本目录对应文档(一表一文件)。
- **主键**:`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)。
+18 -12
View File
@@ -1,26 +1,32 @@
# ad_ecpm_record — 广告展示 eCPM 上报(收益对账)
> 模型 `app/models/ad_ecpm.py` | 关联接口 [ad-ecpm-report](../api/ad-ecpm-report.md) | [← 索引](./README.md)
> 模型 `app/models/ad_ecpm.py` · 仓库 `app/repositories/ad_ecpm.py` · 接口 [ad-ecpm-report](../api/ad-ecpm-report.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
每条 = 客户端一次广告展示(`onAdShow`)后读到的 eCPM。与发奖记录 `ad_reward_record` 是**两条独立数据流**(发奖走 S2S 有 trans_id 无 ecpm;eCPM 走客户端有 ecpm 无 trans_id),无公共键,只用于**按用户/按天聚合**收益对账,不做逐条精确关联。穿山甲后台报表才是结算权威。
每条 = 客户端一次广告展示(`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 端用户的读接口。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
| 列 | 类型 | 约束 / 默认 | 说明(取值 / 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(`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(`getShowEcpm().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 未最终确认;确认后再加一列解析好的数值,在此之前对账按"待定单位"处理。
+22 -15
View File
@@ -1,28 +1,35 @@
# ad_reward_record — 看激励视频发奖记录(S2S 回调)
> 模型 `app/models/ad_reward.py` | 关联接口 [ad-pangle-callback](../api/ad-pangle-callback.md) / [ad-reward-status](../api/ad-reward-status.md) | [← 索引](./README.md)
> 模型 `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)
每条 = 穿山甲一次发奖回调`trans_id` 唯一做幂等键(穿山甲会重试,同号只发一次)。`reward_date`(北京时间日期串)给"每日上限"计数用,按日期串等值查
每条 = 穿山甲一次**服务端发奖回调**(用户看完激励视频)。是看广告三条数据流之一(**发奖**;另两条:`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`(看广告页:今日已发次数/上限、单次金币、本轮已看/冷却结束、今日已看时长/上限);审计/对账整表回溯。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `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`,按它统计当日次数 |
| `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`,按它等值统计当日发奖次数 |
| `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`
## 关系
- `user_id``user.id`(多对一)
## 说明
- 三道闸:验签不过 403 → `trans_id` 唯一幂等(并发 catch IntegrityError)→ 当日次数 ≥ `DAILY_AD_REWARD_LIMIT``capped` 不发币。
- 发币复用 `grant_coins(biz_type='ad_reward', ref_id=trans_id)`
## 注意
- 实发金币以穿山甲回调 `reward_amount` 为准(`resolve_ad_reward_coin` 解析,缺/坏回退默认、超上限夹紧),单次金币/每日上限/单次上限均从 `app_config` 读(运营后台可改)。
- 并发同 `trans_id` 撞唯一约束 → catch IntegrityError 回滚返回已存在那条(幂等兜底)。
+32
View File
@@ -0,0 +1,32 @@
# 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 不可靠)。
+21 -17
View File
@@ -1,30 +1,34 @@
# admin_audit_log — 运营后台操作审计日志
> 模型 `app/models/admin.py` | 关联接口 [admin-audit-logs](../api/admin-audit-logs.md) | [← 索引](./README.md)
> 模型 `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_username` / `target_id` 冗余存字符串,即使关联对象被删/改名也能追溯。
每个 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` / `feedback.handle` |
| `target_type` | String(32) | NOT NULL | 被操作对象类型,如 `user` / `withdraw` / `feedback` |
| `target_id` | String(64) | nullable | 被操作对象 id(用字符串以兼容 `out_bill_no` 等非整型主键) |
| `detail` | JSON | nullable | 上下文 + 前后值,如 `{"amount":1000,"reason":"...","before":{...},"after":{...}}` |
| `ip` | String(64) | nullable | 操作者客户端 IP(取自 `X-Forwarded-For` 首段,仅记录不鉴权) |
| `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: `ix_admin_audit_log_admin_id`(`admin_id`)、`ix_admin_audit_log_action`(`action`)、`ix_admin_audit_log_created_at`(`created_at`)
- PK `id`;index `admin_id``action``created_at`
## 关系
- `admin_id` → [`admin_user`](./admin_user.md).`id`(多对一)
## 说明
- **JSON 列**:`detail``JSON().with_variant(JSONB(), "postgresql")` —— PG 上 JSONB,SQLite 退化为通用 JSON(同 `comparison_record.raw_payload`)。
- **只追加**:无更新/删除接口,审计不可篡改。
- **IP 可伪造**:`X-Forwarded-For` 可被客户端伪造,nginx 必须用 `proxy_set_header X-Forwarded-For $remote_addr` 覆盖;审计 IP 仅作记录、不参与鉴权。
## 注意
- `detail``JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。
- **IP 可伪造**:`X-Forwarded-For` 可被客户端伪造,nginx 必须 `proxy_set_header X-Forwarded-For $remote_addr` 覆盖;审计 IP 仅记录、不参与鉴权。
+22 -17
View File
@@ -1,28 +1,33 @@
# admin_user — 运营后台管理员账号
> 模型 `app/models/admin.py` | 关联接口 [admin-auth-login](../api/admin-auth-login.md) / [admin-admins-list](../api/admin-admins-list.md) | [← 索引](./README.md)
> 模型 `app/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 用户(`user` 表)**完全隔离**:独立 JWT secret、独立鉴权链(见 `app/admin/`)。密码 bcrypt 存哈希,带角色做权限分级。
运营后台(`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 | |
| `username` | String(64) | unique, index, NOT NULL | 登录名 |
| `password_hash` | String(255) | NOT NULL | bcrypt 哈希(明文不落库) |
| `role` | String(20) | NOT NULL, default `operator` | `super_admin`(全权+管账号)/ `finance`(钱:提现+金币)/ `operator`(用户+反馈+大盘) |
| `status` | String(20) | NOT NULL, default `active` | `active` / `disabled`(禁用后 token 立即失效) |
| `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: `ix_admin_user_username`(`username` 唯一)
- PK `id`;UNIQUE+index `username`(`ix_admin_user_username`)。
## 关系
- 被 [`admin_audit_log`](./admin_audit_log.md).`admin_id` 引用(一管理员多条审计)
## 说明
- **角色权限**:`super_admin` 恒通过所有角色守卫(`require_role`);`finance` 管钱(提现/金币),`operator` 管用户/反馈/大盘。具体守卫见各接口文档。
- **鉴权隔离**:admin token `typ=admin` + 独立 `ADMIN_JWT_SECRET`(≠ App 的 `JWT_SECRET_KEY`),App 用户 token 无法当 admin 用。admin 无 refresh,过期(默认 12h)重新登录。
- **初始化**:首个管理员用 `scripts/create_admin.py` 命令行创建(无自助注册接口)。
## 注意
- **鉴权隔离**: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
@@ -0,0 +1,31 @@
# 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)` 读;不需要建迁移(行是动态插的,表结构不变)。
+27 -14
View File
@@ -1,26 +1,39 @@
# cash_transaction — 现金流水账本(分)
> 模型 `app/models/wallet.py` | 关联接口 [wallet-cash-transactions](../api/wallet-cash-transactions.md) | [← 索引](./README.md)
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-cash-transactions](../api/wallet-cash-transactions.md) · [← 索引](./README.md) · [总览](./OVERVIEW.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 | 本笔变动后现金余额() |
| `biz_type` | String(32) | NOT NULL | `exchange_in`(兑入)/ `withdraw`(提现出账)/ `withdraw_refund`(提现退回) |
| `ref_id` | String(64) | nullable | 关联业务 id(如提现 `out_bill_no`) |
| `remark` | String(128) | nullable | 用户可见备注(如"提现未成功,金额已退回") |
| `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 | 用户可见备注(如提现到微信零钱(待审核)」「提现审核未通过,金额已退回) |
| `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`
## 关系
- `user_id``user.id`(多对一)
## 说明
- 提现失败/取消退款写 `withdraw_refund`(+X);退款流水 `remark` 是用户可见文案,技术原因记在 `withdraw_order.fail_reason`
## 注意
- 退款流水 `remark` 是用户可见文案(区分"未成功自动退"vs"审核未通过退");技术原因记在 `withdraw_order.fail_reason`,不外露。
+21 -14
View File
@@ -1,24 +1,31 @@
# coin_account — 金币 + 现金余额快照
> 模型 `app/models/wallet.py` | 关联接口 [wallet-account](../api/wallet-account.md) | [← 索引](./README.md)
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-account](../api/wallet-account.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
用户一行的余额快照,供读取展示用;每次余额变动都另写流水(`coin_transaction` / `cash_transaction`)并记 `balance_after`,出问题逐笔回溯。`user_id` 既是主键也是外键。
一用户一行的余额快照,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 概览。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `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 | 累计赚取金币(只增不减) |
| `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 累加),用于"历史总收益"展示 |
| `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)
## 关系
- `user_id``user.id`(一对一)
## 说明
- **唯一发金币入口** `wallet.grant_coins` 更新本表余额快照 + 写 `coin_transaction`,不 commit,由调用方同事务提交。
- 提现扣现金用带条件 `UPDATE ... WHERE cash_balance_cents >= amount` 原子扣减,防并发超额。
## 注意
- **唯一发金币入口** `wallet.grant_coins`:更新本表 + 写 `coin_transaction`,**不 commit**,由调用方同事务提交(发币与业务记录原子化)。
- 扣现金用 `UPDATE ... WHERE cash_balance_cents >= amount` 原子条件扣减,并发/重试不会超额。
+31 -15
View File
@@ -1,27 +1,43 @@
# coin_transaction — 金币流水账本
> 模型 `app/models/wallet.py` | 关联接口 [wallet-coin-transactions](../api/wallet-coin-transactions.md) | [← 索引](./README.md)
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py`(写入口 `grant_coins`) · 接口 [wallet-coin-transactions](../api/wallet-coin-transactions.md) · [← 索引](./README.md) · [总览](./OVERVIEW.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 | 本笔变动后金币余额(对账用) |
| `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 | 备注 |
| `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>」) |
| `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`
## 关系
- `user_id``user.id`(多对一)
## 说明
- 明细接口按 `id` 倒序游标分页。
-`biz_type` 取值由各业务写入(签到/任务/兑换/看广告/比价里程碑),无独立枚举约束,靠写入方约定。
## 注意
- 流水与余额快照(`coin_account`)、与对应业务记录(signin/task/ad_reward)在**同一事务**写,不会只发币不留痕。
+17 -14
View File
@@ -1,26 +1,29 @@
# comparison_milestone_claim — 比价战绩里程碑领取记录
> 模型 `app/models/comparison_milestone.py` | 关联接口 [compare-milestones](../api/compare-milestones.md) / [compare-milestone-claim](../api/compare-milestone-claim.md) | [← 索引](./README.md)
> 模型 `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)
「记录比价战绩」每档(第 1~6 次)只能领一次,领取后写一行;解锁进度由 `comparison_record``status='success'` 的条数决定,本表只记"哪几档已领"。仿 `user_task` 的一次性领取模型。
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` 成功计数对照。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `milestone` | Integer | NOT NULL | 档位序号(1-based), `rewards.RECORD_MILESTONES` |
| `coin_awarded` | Integer | NOT NULL, default 0 | 该档应发金币;⚠️ 当前产品定暂不真发,实际恒为 0 |
| `milestone` | Integer | NOT NULL | 档位序号(1-based),取值 `1..len(RECORD_MILESTONES)`(当前 1..6) |
| `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`(防同档重复领)。
## 关系
- `user_id``user.id`(多对一)
- 解锁进度依赖 `comparison_record`(`status='success'` 计数),本表不存进度本身。
## 说明
- ⚠️ 当前 claim 仅写本表标记已领(去重),**不调 `grant_coins`、不写 `coin_transaction`,`coin_awarded` 恒为 0、余额不变**(产品定暂不真发金币,后续整体删除该功能)。
- 领取校验:档位越界 404、未解锁(成功数 < milestone)409、已领 409。
## 注意
- ⚠️ **当前 `claim` 只写本表标记已领(去重),不调 `grant_coins`、不写 `coin_transaction`,`coin_awarded` 恒为 0、余额不变**(产品定暂不真发金币,后续可能整体删除该功能)。若将来要真发币,在 `claim` 里补 `grant_coins` 即可(参考 `task.py`)。
+33 -28
View File
@@ -1,47 +1,52 @@
# comparison_record — 比价记录(每次比价完整明细)
> 模型 `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)
> 模型 `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)
每完成一次比价(外卖/电商/领券),客户端 done 帧后用带 JWT 通道上报一条。「我的比价记录」数据源,也是比价战绩里程碑解锁进度的计数源(`status='success'` 条数)。
每完成一次比价(外卖/电商/领券),客户端 done 帧后用**带 JWT** 的通道上报一条。App「我的比价记录」列表/详情的数据源,也是比价战绩里程碑解锁进度的计数源(`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 | |
| `id` | Integer | PK, autoincrement |`price_report.comparison_record_id` 引用 |
| `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` | String(32) | nullable | 源平台代号 |
| `source_platform_name` | String(32) | nullable | 源平台中文名 |
| `source_platform_id` / `_name` | String(32) | nullable | 源平台代号 / 中文名 |
| `source_package` | String(128) | nullable | 源平台 Android 包名 |
| `source_price_cents` | Integer | nullable | 源平台到手价(分) |
| `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`(出错/没采到目标价) |
| `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** |
| `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`=该平台主优惠额(元,美团红包/淘宝平台红包/京东优惠券·百亿补贴,只取一笔,不含配送费/共减总额),各平台抠到红包即带值(2026-06 起源平台 Phase1 意图识别也抠,当前仅淘宝源),没用/没抠到为 null,前端展示「已优惠 ¥X」;`coupon_name`=优惠来源名(展示用 best-effort,美团"外卖大额神券"/京东"百亿补贴"/淘宝"平台红包"),null 走前端通用"红包" |
| `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`=优惠来源名(展示用) |
| `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`(幂等覆盖)。
## 关系
- `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` 原样存「元」。
## 注意
- 4 个 JSON 列用 `JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。结构化金额列存「分」,`comparison_results.price`/`coupon_saved` 原样存「元」。
+18 -11
View File
@@ -1,25 +1,32 @@
# feedback — 用户帮助与反馈
> 模型 `app/models/feedback.py` | 关联接口 [feedback](../api/feedback.md) | [← 索引](./README.md)
> 模型 `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)
每条 = 用户一次反馈提交`content``contact` 必填,`images` 为可选截图 URL 列表
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 端当前无"我的反馈列表"读接口。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
| 列 | 类型 | 约束 / 默认 | 说明(取值 / 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`
## 关系
- `user_id``user.id`(多对一)
## 说明
- `images` 用通用 `JSON`(本表未用 JSONB variant);截图先经 `/media/feedback/` 上传拿到相对路径再随反馈提交。
## 注意
- `images` 用通用 `JSON`(本表**未**用 JSONB variant,与 comparison/savings 不同)。
+42
View File
@@ -0,0 +1,42 @@
# 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`(魔数嗅探 + 随机文件名),与头像/反馈同一套上传。
+34 -15
View File
@@ -1,31 +1,50 @@
# savings_record — 省钱记录(profile 省钱战绩源)
> 模型 `app/models/savings.py` | 关联接口 [savings-summary](../api/savings-summary.md) / [savings-battle](../api/savings-battle.md) / [savings-records](../api/savings-records.md) | [← 索引](./README.md)
> 模型 `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)
profile 页「累计帮你省了 / 省钱战绩 / 省钱明细」的唯一数据源:**真正下单成交后**省了多少记一行。
profile 页「累计帮你省了 / 省钱战绩 / 省钱明细」的唯一数据源:**真正下单成交后**省了多少记一行。是「记账闭环」的落库终点——客户端比价后点结果链接跳走付款,无障碍 watcher 捕获支付成功 + 金额归因(±1 元/5 分钟窗口)→ 上报 `POST /order/report`
> ⚠️ **当前为 demo 假数据**:`crud/savings.py:ensure_seeded` 按 user_id 幂等灌 ~23 条(`source='demo'`),聚合(SUM/分组/连续天数)是生产级真实计算。真数据要靠**"用户真下单"信号**(app 目前无:AI 比价止于结算页/结果展示,付款用户手动)——**不是**把比价记录 `comparison_record` 喂过来(那是"比价行为",这是"成交省钱",两个维度)。详见 [[project_shaguabijia_app_server]]。
> **两种来源(`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)集合给比价记录打「已下单」。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
| 列 | 类型 | 约束 / 默认 | 说明(取值 / 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) |
| `order_amount_cents` | Integer | NOT NULL | 订单到手价/实付(分) |
| `saved_amount_cents` | Integer | NOT NULL | 本单省下(分,可为 0;= original 实付,下限 0) |
| `platform` | String(32) | nullable | 下单平台(美团外卖/淘宝闪购/京东外卖) |
| `title` | String(128) | nullable | 标题 |
| `shop_name` | String(128) | nullable | 店铺名 |
| `title` | String(128) | nullable | 明细卡标题(compare 来源 = 门店名) |
| `shop_name` | String(128) | nullable | 店铺名。**与 `comparison_record.store_name` 字符串相等关联**(打「已下单」) |
| `dishes` | JSON(PG: JSONB) | NOT NULL, default [] | 菜名列表(前 2 道展示,其余"还有 N 道") |
| `source` | String(16) | NOT NULL, default `compare` | 来源:`demo`(演示)/ `compare`(真实下单上报,待启用) |
| `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 | 上报设备号 |
| `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`
- PK `id`;index `user_id``created_at`;UNIQUE(`user_id`, `client_event_id`) = `uq_savings_user_event`(真实上报幂等;demo 行 `client_event_id=NULL` 不参与冲突,允许多 NULL)。
## 关系
- `user_id``user.id`(多对一)
## 说明
## 注意
- `dishes``JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。
- `beat_percent`(超过百分之多少用户)按各用户累计省下金额做真实分位;为有可比人群造了 5 个种子用户(`register_channel='seed'`)。
- `beat_percent`(超过百分之多少用户)按各用户累计省下金额做真实分位,每用户口径与展示一致(有 compare 用 compare 之和,否则 demo 之和)。
+20 -14
View File
@@ -1,26 +1,32 @@
# signin_record — 签到记录(7 天循环)
> 模型 `app/models/signin.py` | 关联接口 [signin-status](../api/signin-status.md) / [signin-do](../api/signin-do.md) | [← 索引](./README.md)
> 模型 `app/models/signin.py` · 仓库 `app/repositories/signin.py` · 接口 [signin-status](../api/signin-status.md) / [signin-do](../api/signin-do.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
每次签到一行,`(user_id, signin_date)` 唯一,天然防一天签两次。`cycle_day`(1..7)决定发多少金币(`rewards.SIGNIN_REWARDS`),断签重置回 1;`streak` 是连续签到天数(不封顶)用于展示。
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` 读最近一条 + 推算今天档位。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, 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 | 本次发放金币 |
| `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 | 本次实发金币(= 当时档位金额) |
| `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`(防一天签两次)。
## 关系
- `user_id``user.id`(多对一)
## 说明
- 签到"今天"按北京时间 `cn_today()`(`CN_TZ=UTC+8`);发币与写本表记录同事务(`grant_coins(biz_type='signin', ref_id=日期)`)。
## 注意
- "今天/昨天"全按北京时间(`CN_TZ=UTC+8`),不用 UTC(否则 0~8 点会算成前一天)。
- 连续判定:`last.signin_date == today - 1天` 才算连续,否则首签/断签重置。
+25 -20
View File
@@ -1,32 +1,37 @@
# user — 用户(登录主体)
> 模型 `app/models/user.py` | 关联接口 [auth-me](../api/auth-me.md) auth 组 | [← 索引](./README.md)
> 模型 `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)
登录主体。极光一键登录与短信登录都映射到同一行,`phone` 唯一索引;注册即登录(phone 不存在则 insert,存在则更新 `last_login_at`)。后续加微信/Apple 登录新增 oauth_account 表,本表不动。
整个 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 用户管理列表/详情。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `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 | 微信昵称(绑定时拉,展示在提现绑定卡) |
| `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 分开存) |
| `wechat_avatar_url` | String(512) | nullable | 微信头像 URL |
| `status` | String(20) | NOT NULL, default `active` | `active` / `disabled` / `deleted` |
| `status` | String(20) | NOT NULL, default `active` | 取值:`active`(可登录/鉴权)/ `disabled`(admin 禁用)/ `deleted`(已注销);仅 active 能通过鉴权 |
| `created_at` | DateTime(tz) | server_default now() | 注册时间 |
| `last_login_at` | DateTime(tz) | default utcnow(应用层) | 最近登录时间 |
| `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 后台管理员,两套体系)。
## 索引与约束
- 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`(微信)**分开存,不互相覆盖**。
- 注销账号:phone 改占位串、status=deleted,不物理删行(保留外键完整性)
- 软删后旧 JWT 仍能用到自然过期(无 jti 黑名单,见 `待办与技术债.md`);鉴权查 `status` 拦得住绝大多数路径
+19 -15
View File
@@ -1,26 +1,30 @@
# user_task — 一次性任务领取去重
> 模型 `app/models/task.py` | 关联接口 [tasks-list](../api/tasks-list.md) / [tasks-claim](../api/tasks-claim.md) | [← 索引](./README.md)
> 模型 `app/models/task.py` · 仓库 `app/repositories/task.py` · 接口 [tasks-list](../api/tasks-list.md) / [tasks-claim](../api/tasks-claim.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
像"打开消息提醒"这类只能领一次的任务,完成后写一行,`(user_id, task_key)` 唯一防重复领。可循环领取的任务(签到)不走这张表,有专表 `signin_record`
像"打开消息提醒"这类**只能领一次**的任务,领奖后写一行,`(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` 集合与任务字典对照。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
| 列 | 类型 | 约束 / 默认 | 说明(取值 / 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`) |
| `status` | String(16) | NOT NULL, default `completed` | 任务状态 |
| `coin_awarded` | Integer | NOT NULL, default 0 | 该任务发放金币 |
| `completed_at` | DateTime(tz) | server_default now() | 完成/领取时间 |
| `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`)。
## 索引与约束
- 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`(防重复领)。
## 关系
- `user_id``user.id`(多对一)
## 说明
- 领取:写本表 + `grant_coins(biz_type='task_<key>', ref_id=task_key)` 同事务;重复领抛 409,未知 key 抛 404。
- 比价战绩里程碑虽是"领一次"模型但**不复用本表**,另有 `comparison_milestone_claim`(因 task_key 是固定字典,里程碑是按次数解锁的序号)。
## 注意
- 比价里程碑虽也是"领一次"模型但**不复用本表**,另有 `comparison_milestone_claim`(task_key 是固定字典,里程碑是按成功次数解锁的序号)。
@@ -0,0 +1,45 @@
# 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`(未启用免确认)时本表不参与,提现退化为原确认模式。
+37 -16
View File
@@ -1,30 +1,51 @@
# withdraw_order — 提现单(现金 → 微信零钱)
> 模型 `app/models/wallet.py` | 关联接口 [wallet-withdraw](../api/wallet-withdraw.md) / [wallet-withdraw-status](../api/wallet-withdraw-status.md) | [← 索引](./README.md)
> 模型 `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)
提现状态机:`pending → success / failed`。扣现金 + 写 `cash_transaction(withdraw)` + 建本单在同一事务;失败/取消时退回现金并写 `cash_transaction(withdraw_refund)``wechat_state` 存微信侧原始状态,`status` 是归一化后的三态
用户把现金余额提到微信零钱的工单。**含人工审核**(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 提现管理列表/查单。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `out_bill_no` | String(64) | UNIQUE, index, NOT NULL | 商户单号(幂等键 + 微信查单);客户端可传,不传则服务端生成 |
| `out_bill_no` | String(64) | UNIQUE, index, NOT NULL | 商户单号(幂等键 + 微信查单键)。客户端可传(`[0-9A-Za-z_-]{8,32}`),不传则服务端 `uuid4().hex`。**被 `cash_transaction.ref_id` 引用** |
| `amount_cents` | Integer | NOT NULL | 提现金额(分) |
| `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) |
| `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`) |
| `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`
## 关系
- `user_id``user.id`(多对一)
## 说明
- 资金安全:原子扣款 + `out_bill_no` 幂等 + 模糊失败先查单再决定(绝不盲目退款)+ 孤儿单对账 `reconcile_pending_withdraws`。详见 [[project_shaguabijia_app_server]] 提现段。
- `WITHDRAW_MIN_CENTS=10`(0.1 元,微信地板)。
## 注意
- **资金安全**:原子扣款(`WHERE cash_balance_cents >= amount`)+ `out_bill_no` 幂等 + 结果不明时**先查单再决定,绝不盲目退款**(防退款后又到账)+ 孤儿 pending 单 `reconcile_pending_withdraws` 对账兜底。
- `WITHDRAW_MIN_CENTS=10`(0.1 元,微信商家转账地板价),可经 `app_config.withdraw_min_cents` 后台调。
- "待审核期间钱已扣减",防用户拿同一笔余额重复发起多笔提现。
+494
View File
@@ -0,0 +1,494 @@
"""美团 CPS API Playground —— 本地调参/试 API 的可视化工具。
浏览器填参数 → 本地后端用 meituan.py 的同款签名打美团 → 返回结果,
页面渲染「卡片列表 + 可折叠 JSON 树」。AppSecret 只留在后端,浏览器不接触。
跑法:
/Users/pure/miniconda3/envs/price/bin/python tools/meituan_playground.py
然后开 http://127.0.0.1:8799
只读工具(只调 query 类接口),复用本仓库 .env 里的 MT_CPS 凭证。
"""
from __future__ import annotations
import asyncio
import json
import os
import sys
import time
# 让 `from app...` 可导入 + pydantic-settings 从仓库根读 .env
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
os.chdir(ROOT)
sys.path.insert(0, ROOT)
import httpx # noqa: E402
from fastapi import FastAPI, Request # noqa: E402
from fastapi.responses import HTMLResponse, JSONResponse # noqa: E402
from app.core.config import settings # noqa: E402
from app.integrations.meituan import _content_md5, _sign # noqa: E402
app = FastAPI(title="Meituan CPS Playground")
DEFAULT_PATH = "/cps_open/common/api/v1/query_coupon"
def call_raw(path: str, body_obj: dict) -> dict:
"""用 meituan.py 同款 S-Ca 签名打美团,原样返回(不管 code 是否为 0,方便看错误体)。"""
body = json.dumps(body_obj, ensure_ascii=False).encode("utf-8")
md5 = _content_md5(body)
ts = str(int(time.time() * 1000))
signed = {"S-Ca-App": settings.MT_CPS_APP_KEY, "S-Ca-Timestamp": ts}
sig = _sign(settings.MT_CPS_APP_SECRET, "POST", md5, path, signed)
headers = {
"Content-Type": "application/json",
"Content-MD5": md5,
"S-Ca-App": settings.MT_CPS_APP_KEY,
"S-Ca-Timestamp": ts,
"S-Ca-Signature-Headers": "S-Ca-Timestamp,S-Ca-App",
"S-Ca-Signature": sig,
}
url = f"{settings.MT_CPS_HOST}{path}"
t0 = time.time()
resp = httpx.post(url, content=body, headers=headers, timeout=settings.MT_CPS_TIMEOUT_SEC)
ms = int((time.time() - t0) * 1000)
try:
j = resp.json()
except Exception:
j = {"_raw_text": resp.text[:2000]}
return {"http_status": resp.status_code, "elapsed_ms": ms, "json": j}
def _is_rate_limited(j: dict) -> bool:
code = str(j.get("code"))
msg = str(j.get("message") or j.get("msg") or "")
return code == "402" or "频繁" in msg
@app.post("/api/query")
async def api_query(req: Request):
try:
payload = await req.json()
except Exception as e:
return JSONResponse({"ok": False, "error": f"请求体不是合法 JSON: {e}"})
path = (payload.get("path") or DEFAULT_PATH).strip()
body = payload.get("body")
if not isinstance(body, dict):
return JSONResponse({"ok": False, "error": "body 必须是 JSON 对象"})
if not settings.MT_CPS_APP_KEY or not settings.MT_CPS_APP_SECRET:
return JSONResponse({"ok": False, "error": "MT_CPS_APP_KEY / SECRET 未配置(.env)"})
# 美团这接口很容易 402「调用频繁」,交互式工具自动退避重试 3 次,UX 顺一点
last = None
for a in range(3):
try:
out = call_raw(path, body)
except Exception as e:
return JSONResponse({"ok": False, "error": f"{type(e).__name__}: {e}"})
if _is_rate_limited(out.get("json") or {}) and a < 2:
last = out
await asyncio.sleep(1.5 * (a + 1))
continue
return JSONResponse({"ok": True, **out, "retries": a})
return JSONResponse({"ok": True, **(last or {}), "retries": 2})
@app.get("/", response_class=HTMLResponse)
def index():
return HTML
HTML = r"""<!doctype html>
<html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>美团 CPS Playground</title>
<style>
:root{ --bd:#e3e6ea; --mut:#6b7280; --bg:#f6f7f9; --pri:#ffb000; --pri2:#ff8a00; }
*{box-sizing:border-box}
body{margin:0;font:13px/1.5 -apple-system,"PingFang SC",Segoe UI,Roboto,sans-serif;color:#1f2328;background:var(--bg)}
header{display:flex;align-items:center;gap:10px;padding:8px 14px;background:#fff;border-bottom:1px solid var(--bd);position:sticky;top:0;z-index:5}
header b{font-size:15px}
header .path{flex:1;min-width:200px;font-family:ui-monospace,Menlo,monospace;font-size:12px;padding:5px 8px;border:1px solid var(--bd);border-radius:6px}
#status{font-size:12px;color:var(--mut);white-space:nowrap}
#status .ok{color:#0a7d28;font-weight:600}
#status .err{color:#c0392b;font-weight:600}
main{display:flex;gap:0;height:calc(100vh - 49px)}
section{height:100%;overflow:auto;padding:12px}
#params{width:380px;min-width:340px;border-right:1px solid var(--bd);background:#fff}
#cards{flex:1;min-width:300px;border-right:1px solid var(--bd)}
#jsonpane{flex:1;min-width:300px;background:#fff}
h3{margin:2px 0 8px;font-size:12px;color:var(--mut);text-transform:uppercase;letter-spacing:.04em}
#cards h3{display:flex;align-items:center;gap:8px}
.presets{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px}
.presets button{font-size:12px;padding:4px 8px;border:1px solid var(--bd);background:#fff;border-radius:14px;cursor:pointer}
.presets button:hover{border-color:var(--pri2);color:var(--pri2)}
.cgrp{font-size:12px;font-weight:700;color:#374151;margin:9px 0 4px;padding-left:2px;border-left:3px solid var(--pri);padding-left:7px}
.chtag{display:inline-block;min-width:14px;text-align:center;font-size:10px;font-weight:800;padding:0 3px;border-radius:3px;margin-right:4px}
.ch-r{background:#fff1f0;color:#cf1322}
.ch-b{background:#eef3ff;color:#1d39c4}
.ch-a{background:#fff7e6;color:#d46b08}
.grid{display:grid;grid-template-columns:auto 1fr;gap:6px 8px;align-items:center;margin-bottom:10px}
.grid label{color:var(--mut);font-size:12px;text-align:right}
.grid input,.grid select{width:100%;padding:5px 7px;border:1px solid var(--bd);border-radius:6px;font-size:12px;font-family:inherit}
textarea{width:100%;height:200px;font-family:ui-monospace,Menlo,monospace;font-size:12px;padding:8px;border:1px solid var(--bd);border-radius:6px;resize:vertical;white-space:pre}
.btns{display:flex;gap:8px;margin:8px 0}
button.act{padding:6px 10px;border:1px solid var(--bd);background:#fff;border-radius:6px;cursor:pointer;font-size:12px}
button.send{flex:1;background:linear-gradient(180deg,var(--pri),var(--pri2));border:none;color:#3a2600;font-weight:700;padding:9px;border-radius:7px;cursor:pointer;font-size:13px}
button.send:active{transform:translateY(1px)}
.hint{font-size:11px;color:var(--mut);margin:6px 0}
#pageBtn{margin-left:auto;font-size:12px;padding:4px 12px;border:1px solid var(--bd);background:#fff;border-radius:14px;cursor:pointer}
#pageBtn:hover:not(:disabled){border-color:var(--pri2);color:var(--pri2)}
#pageBtn:disabled{opacity:.4;cursor:not-allowed}
/* cards */
.cardgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px}
.pagediv{grid-column:1/-1;text-align:center;color:#9aa0a6;font-size:12px;border-top:1px dashed var(--bd);padding:9px 0 3px;margin-top:6px}
.card{border:1px solid var(--bd);border-radius:10px;background:#fff;overflow:hidden;display:flex;flex-direction:column}
.card .thumb{width:100%;height:118px;object-fit:cover;background:#f0f1f3}
.card .body{padding:8px 9px;display:flex;flex-direction:column;gap:5px}
.card .brand{display:flex;align-items:center;gap:5px;color:var(--mut);font-size:11px}
.card .brand img{width:15px;height:15px;border-radius:3px;object-fit:cover}
.card .nm{font-weight:600;font-size:13px;line-height:1.3;max-height:2.6em;overflow:hidden}
.card .pr{display:flex;align-items:baseline;gap:6px}
.card .pr .sell{color:#e8420f;font-weight:800;font-size:17px}
.card .pr .sell:before{content:"¥";font-size:12px;font-weight:600}
.card .pr .ori{color:#9aa0a6;text-decoration:line-through;font-size:11px}
.badges{display:flex;flex-wrap:wrap;gap:4px}
.b{font-size:10px;padding:1px 6px;border-radius:8px;background:#f1f3f5;color:#445}
.b.sale{background:#fff2e8;color:#d4380d}
.b.comm{background:#e6fffb;color:#08979c}
.b.poi{background:#f0f5ff;color:#2f54eb}
.b.rank{background:#fff7e6;color:#d46b08}
.b.dist{background:#eaf7ee;color:#0a7d28;font-weight:600}
.pvs{font-family:ui-monospace,Menlo,monospace;font-size:10px;color:#888;display:flex;align-items:center;gap:5px;border-top:1px dashed var(--bd);padding-top:5px;margin-top:2px}
.pvs code{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pvs button{font-size:10px;border:1px solid var(--bd);background:#fff;border-radius:4px;cursor:pointer;padding:1px 5px}
.empty{color:var(--mut);padding:20px;text-align:center}
/* json tree */
.jtools{display:flex;gap:6px;margin-bottom:8px}
.jtools button{font-size:11px;border:1px solid var(--bd);background:#fff;border-radius:5px;cursor:pointer;padding:3px 7px}
#tree{font-family:ui-monospace,Menlo,monospace;font-size:12px;line-height:1.65}
.jrow{padding-left:14px}
.jhead{cursor:pointer;user-select:none}
.jtog{display:inline-block;width:12px;color:#9aa0a6}
.jkey{color:#8250df}
.jsum{color:#9aa0a6}
.jval.str{color:#0a7d28}
.jval.num{color:#0550ae}
.jval.bool,.jval.null{color:#cf222e}
.jkids{border-left:1px dotted #e3e6ea;margin-left:5px}
</style></head>
<body>
<header>
<b>🍔 美团 CPS Playground</b>
<input id="path" class="path" value="/cps_open/common/api/v1/query_coupon">
<span id="status">就绪</span>
</header>
<main>
<section id="params">
<h3>快捷模板</h3>
<div class="hint" style="margin:0 0 4px">渠道:<span class="chtag ch-r">榜</span>榜单&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")