Compare commits

..

4 Commits

Author SHA1 Message Date
pure 90368d3f61 日志: app-server 增加 JSON 文件输出供阿里云 SLS 采集
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>
2026-06-07 02:14:32 +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
11 changed files with 1168 additions and 28 deletions
@@ -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')
+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"}
+9
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。
+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
+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()}
+2
View File
@@ -29,6 +29,7 @@ from app.api.v1.signin import router as signin_router
from app.api.v1.tasks import router as tasks_router
from app.api.v1.user import router as user_router
from app.api.v1.wallet import router as wallet_router
from app.api.v1.wxpay import router as wxpay_router
from app.core.config import settings
from app.core.logging import setup_logging
@@ -82,6 +83,7 @@ app.include_router(compare_record_router)
app.include_router(compare_milestone_router)
app.include_router(meituan_router)
app.include_router(wallet_router)
app.include_router(wxpay_router)
app.include_router(signin_router)
app.include_router(tasks_router)
app.include_router(savings_router)
+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):
"""现金流水(单位:分)。金币兑现金、提现都记在这里。"""
+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:
+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):
+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")