Align avatar recharge plan IDs with WeChat virtual products

This commit is contained in:
stefanfeng
2026-10-19 14:00:06 +08:00
parent 5f27cc93cc
commit 3379d5a1b2
8 changed files with 73 additions and 32 deletions
+18 -8
View File
@@ -9,7 +9,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
from database import engine, init_db, SessionLocal from database import engine, init_db, SessionLocal
from models import Avatar, Authorization, Organization, TokenAccount, TokenPlan, User from models import Avatar, Authorization, Organization, TokenAccount, TokenPaymentOrder, TokenPlan, User
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
import routers.avatars import routers.avatars
import routers.tokens import routers.tokens
@@ -90,18 +90,28 @@ def seed():
db = SessionLocal() db = SessionLocal()
try: try:
plan_specs = [ plan_specs = [
{"id": "1", "name": "基础套餐", "amount": 2_000_000, "price": 10, "badge": "", "desc": "2M 积分"}, {"id": "Basic", "legacy_id": "1", "name": "基础套餐", "amount": 2_000_000, "price": 10, "badge": "", "desc": "2M 积分"},
{"id": "2", "name": "标准套餐", "amount": 20_000_000, "price": 100, "badge": "常用", "desc": "20M 积分"}, {"id": "Standard", "legacy_id": "2", "name": "标准套餐", "amount": 20_000_000, "price": 100, "badge": "常用", "desc": "20M 积分"},
{"id": "3", "name": "专业套餐", "amount": 250_000_000, "price": 1000, "badge": "加赠25%", "desc": "250M 积分"}, {"id": "Professional", "legacy_id": "3", "name": "专业套餐", "amount": 250_000_000, "price": 1000, "badge": "加赠25%", "desc": "250M 积分"},
{"id": "4", "name": "企业套餐", "amount": 2_500_000_000, "price": 10000, "badge": "企业推荐", "desc": "2500M 积分"}, {"id": "Enterprise", "legacy_id": "4", "name": "企业套餐", "amount": 2_500_000_000, "price": 10000, "badge": "企业推荐", "desc": "2500M 积分"},
] ]
for spec in plan_specs: for spec in plan_specs:
plan = db.query(TokenPlan).filter(TokenPlan.id == spec["id"]).first() plan = db.query(TokenPlan).filter(TokenPlan.id == spec["id"]).first()
legacy = db.query(TokenPlan).filter(TokenPlan.id == spec["legacy_id"]).first()
if legacy is not None and plan is None:
legacy.id = spec["id"]
plan = legacy
db.flush()
if legacy is not None and legacy is not plan:
db.delete(legacy)
db.query(TokenPaymentOrder).filter(TokenPaymentOrder.plan_id == spec["legacy_id"]).update(
{TokenPaymentOrder.plan_id: spec["id"]}, synchronize_session=False
)
if plan is None: if plan is None:
db.add(TokenPlan(**spec)) db.add(TokenPlan(**{key: value for key, value in spec.items() if key != "legacy_id"}, virtual_product_id=spec["id"]))
else: else:
for key, value in spec.items(): plan.name = spec["name"]
setattr(plan, key, value) plan.virtual_product_id = spec["id"]
for user in db.query(User).all(): for user in db.query(User).all():
account = db.query(TokenAccount).filter(TokenAccount.user_id == user.id).first() account = db.query(TokenAccount).filter(TokenAccount.user_id == user.id).first()
+9 -2
View File
@@ -46,6 +46,13 @@ router = APIRouter(tags=["Token"])
PAYMENT_METHODS = {"wechat": "WECHAT", "alipay": "ALIPAY"} PAYMENT_METHODS = {"wechat": "WECHAT", "alipay": "ALIPAY"}
PAYMENT_SCENES = {"APP", "H5", "LITE", "JSAPI"} PAYMENT_SCENES = {"APP", "H5", "LITE", "JSAPI"}
LEGACY_PLAN_IDS = {"1": "Basic", "2": "Standard", "3": "Professional", "4": "Enterprise"}
def _selected_plan(db: Session, plan_id):
return db.query(TokenPlan).filter(TokenPlan.id == LEGACY_PLAN_IDS.get(str(plan_id), plan_id)).first()
SUCCESS_STATUSES = {"SUCCESS", "SUCCEEDED", "PAID", "COMPLETED", "TRADE_SUCCESS"} SUCCESS_STATUSES = {"SUCCESS", "SUCCEEDED", "PAID", "COMPLETED", "TRADE_SUCCESS"}
FAILED_STATUSES = {"FAIL", "FAILED", "CLOSED", "CANCELLED", "CANCELED", "EXPIRED"} FAILED_STATUSES = {"FAIL", "FAILED", "CLOSED", "CANCELLED", "CANCELED", "EXPIRED"}
@@ -307,7 +314,7 @@ def plans(authorization: str = Header(None), db: Session = Depends(get_db)):
@router.post("/token/charge") @router.post("/token/charge")
def charge(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)): def charge(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db) user = _require_user(authorization, db)
plan = db.query(TokenPlan).filter(TokenPlan.id == payload.get("planId")).first() plan = _selected_plan(db, payload.get("planId"))
if not plan: if not plan:
return fail("套餐不存在", 404) return fail("套餐不存在", 404)
@@ -410,7 +417,7 @@ def charge(payload: dict = Body(...), authorization: str = Header(None), db: Ses
@router.post("/token/charge/org") @router.post("/token/charge/org")
def charge_via_huihui_org(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)): def charge_via_huihui_org(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db) user = _require_user(authorization, db)
plan = db.query(TokenPlan).filter(TokenPlan.id == payload.get("planId")).first() plan = _selected_plan(db, payload.get("planId"))
if not plan: if not plan:
return fail("套餐不存在", 404) return fail("套餐不存在", 404)
method = str(payload.get("paymentMethod") or "").lower() method = str(payload.get("paymentMethod") or "").lower()
@@ -49,9 +49,7 @@ def _offer_id() -> str:
def product_id_for_plan(plan) -> str: def product_id_for_plan(plan) -> str:
configured = str(getattr(plan, "virtual_product_id", "") or "").strip() configured = str(getattr(plan, "id", "") or "").strip()
if not configured:
configured = os.getenv(f"WECHAT_VIRTUAL_PRODUCT_{plan.id}", "").strip()
if not configured: if not configured:
raise WechatVirtualPaymentError(f"套餐 {plan.id} 尚未配置微信虚拟支付商品 ID") raise WechatVirtualPaymentError(f"套餐 {plan.id} 尚未配置微信虚拟支付商品 ID")
if len(configured) > 64 or not all(ch.isalnum() or ch in "_-" for ch in configured): if len(configured) > 64 or not all(ch.isalnum() or ch in "_-" for ch in configured):
@@ -57,6 +57,10 @@ def test_seed_synchronizes_requested_recharge_plans():
db = SessionLocal() db = SessionLocal()
try: try:
plans = db.query(TokenPlan).order_by(TokenPlan.price.asc()).all() plans = db.query(TokenPlan).order_by(TokenPlan.price.asc()).all()
assert {plan.id: plan.virtual_product_id for plan in plans} == {
"Basic": "Basic", "Standard": "Standard",
"Professional": "Professional", "Enterprise": "Enterprise",
}
assert [(plan.price, plan.amount) for plan in plans] == [ assert [(plan.price, plan.amount) for plan in plans] == [
(10, 2_000_000), (10, 2_000_000),
(100, 20_000_000), (100, 20_000_000),
@@ -67,6 +71,33 @@ def test_seed_synchronizes_requested_recharge_plans():
db.close() db.close()
def test_seed_preserves_admin_price_and_migrates_old_order_plan_id():
seed()
db = SessionLocal()
order_no = f"TEST-MIGRATION-{uuid.uuid4().hex}"
try:
plan = db.query(TokenPlan).filter_by(id="Basic").one()
original_price = plan.price
plan.price = 0.01
db.add(TokenPlan(id="1", name="基础套餐", amount=2_000_000, price=10))
db.add(TokenPaymentOrder(
order_no=order_no, user_id="test-user", plan_id="1",
payment_method="wechat", pay_type="WECHAT", pay_way="JSAPI",
points_amount=2_000_000, price_cents=1000, status="pending",
))
db.commit()
seed()
db.expire_all()
assert db.query(TokenPlan).filter_by(id="1").first() is None
assert db.query(TokenPlan).filter_by(id="Basic").one().price == 0.01
assert db.query(TokenPaymentOrder).filter_by(order_no=order_no).one().plan_id == "Basic"
finally:
db.query(TokenPaymentOrder).filter_by(order_no=order_no).delete()
db.query(TokenPlan).filter_by(id="Basic").update({"price": original_price})
db.commit()
db.close()
def test_charge_creates_huihui_order_without_early_points(authorization_context): def test_charge_creates_huihui_order_without_early_points(authorization_context):
context = authorization_context context = authorization_context
_enable_huihui_payment_login(context) _enable_huihui_payment_login(context)
@@ -30,8 +30,7 @@ def test_huihui_virtual_callback_requires_signature_and_exact_order_details(auth
user = db.query(User).filter(User.id == context["owner"].id).one() user = db.query(User).filter(User.id == context["owner"].id).one()
user.wechat_mp_openid = "openid-huihui-callback" user.wechat_mp_openid = "openid-huihui-callback"
user.wechat_mp_session_key = "session-huihui-callback" user.wechat_mp_session_key = "session-huihui-callback"
plan = db.query(TokenPlan).filter(TokenPlan.id == "1").one() plan = db.query(TokenPlan).filter(TokenPlan.id == "Basic").one()
plan.virtual_product_id = "points_plan_1"
db.commit() db.commit()
finally: finally:
db.close() db.close()
@@ -47,14 +46,14 @@ def test_huihui_virtual_callback_requires_signature_and_exact_order_details(auth
created = client.post( created = client.post(
"/api/token/charge", "/api/token/charge",
headers=context["owner_headers"], headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "wechat", "payScene": "LITE"}, json={"planId": "Basic", "paymentMethod": "wechat", "payScene": "LITE"},
).json()["data"] ).json()["data"]
payload = { payload = {
"orderNo": created["orderNo"], "orderNo": created["orderNo"],
"paymentNo": f"huihui-virtual-payment-{context['suffix']}", "paymentNo": f"huihui-virtual-payment-{context['suffix']}",
"status": "SUCCESS", "status": "SUCCESS",
"amountCents": 1000, "amountCents": 1000,
"productId": "points_plan_1", "productId": "Basic",
"openId": "openid-huihui-callback", "openId": "openid-huihui-callback",
"env": 1, "env": 1,
} }
@@ -91,8 +90,7 @@ def test_virtual_payment_callback_and_refund_are_idempotent(authorization_contex
user = db.query(User).filter(User.id == context["owner"].id).one() user = db.query(User).filter(User.id == context["owner"].id).one()
user.wechat_mp_openid = "openid-flow" user.wechat_mp_openid = "openid-flow"
user.wechat_mp_session_key = "session-flow" user.wechat_mp_session_key = "session-flow"
plan = db.query(TokenPlan).filter(TokenPlan.id == "1").one() plan = db.query(TokenPlan).filter(TokenPlan.id == "Basic").one()
plan.virtual_product_id = "points_plan_1"
db.commit() db.commit()
finally: finally:
db.close() db.close()
@@ -108,7 +106,7 @@ def test_virtual_payment_callback_and_refund_are_idempotent(authorization_contex
created = client.post( created = client.post(
"/api/token/charge", "/api/token/charge",
headers=context["owner_headers"], headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "wechat", "payScene": "LITE"}, json={"planId": "Basic", "paymentMethod": "wechat", "payScene": "LITE"},
).json()["data"] ).json()["data"]
assert created["provider"] == "wechat_virtual" assert created["provider"] == "wechat_virtual"
params = json.loads(created["payMessage"]) params = json.loads(created["payMessage"])
@@ -120,7 +118,7 @@ def test_virtual_payment_callback_and_refund_are_idempotent(authorization_contex
"OutTradeNo": created["orderNo"], "OutTradeNo": created["orderNo"],
"OpenId": "openid-flow", "OpenId": "openid-flow",
"Env": 1, "Env": 1,
"GoodsInfo": json.dumps({"ProductId": "points_plan_1", "ActualPrice": 1000}), "GoodsInfo": json.dumps({"ProductId": "Basic", "ActualPrice": 1000}),
"WeChatPayInfo": json.dumps({"TransactionId": "wx-transaction-1"}), "WeChatPayInfo": json.dumps({"TransactionId": "wx-transaction-1"}),
} }
query = {"timestamp": "100", "nonce": "nonce", "signature": _signature("callback-token", "100", "nonce")} query = {"timestamp": "100", "nonce": "nonce", "signature": _signature("callback-token", "100", "nonce")}
@@ -9,8 +9,8 @@ from services import wechat_virtual_payment as virtual
def test_build_payment_params_signs_the_exact_compact_payload(): def test_build_payment_params_signs_the_exact_compact_payload():
order = SimpleNamespace(order_no="AV202609080001", plan_id="plan-1", price_cents=1000) order = SimpleNamespace(order_no="AV202609080001", plan_id="Basic", price_cents=1000)
plan = SimpleNamespace(id="plan-1", virtual_product_id="points_plan_1") plan = SimpleNamespace(id="Basic", virtual_product_id="stale-product-id")
env = { env = {
"WECHAT_VIRTUAL_ENV": "sandbox", "WECHAT_VIRTUAL_ENV": "sandbox",
"WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key", "WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key",
@@ -25,10 +25,10 @@ def test_build_payment_params_signs_the_exact_compact_payload():
"buyQuantity": 1, "buyQuantity": 1,
"env": 1, "env": 1,
"currencyType": "CNY", "currencyType": "CNY",
"productId": "points_plan_1", "productId": "Basic",
"goodsPrice": 1000, "goodsPrice": 1000,
"outTradeNo": "AV202609080001", "outTradeNo": "AV202609080001",
"attach": '{"orderNo":"AV202609080001","planId":"plan-1"}', "attach": '{"orderNo":"AV202609080001","planId":"Basic"}',
}, ensure_ascii=False, separators=(",", ":")) }, ensure_ascii=False, separators=(",", ":"))
assert result["paySig"] == hmac.new( assert result["paySig"] == hmac.new(
b"sandbox-key", f"requestVirtualPayment&{sign_data}".encode(), hashlib.sha256 b"sandbox-key", f"requestVirtualPayment&{sign_data}".encode(), hashlib.sha256
@@ -58,10 +58,7 @@ WECHAT_VIRTUAL_SANDBOX_APP_KEY=<沙箱AppKey>
WECHAT_VIRTUAL_APP_KEY=<正式AppKey> WECHAT_VIRTUAL_APP_KEY=<正式AppKey>
WECHAT_VIRTUAL_OFFER_ID=<offer-id> WECHAT_VIRTUAL_OFFER_ID=<offer-id>
WECHAT_VIRTUAL_CALLBACK_TOKEN=<回调校验Token> WECHAT_VIRTUAL_CALLBACK_TOKEN=<回调校验Token>
WECHAT_VIRTUAL_PRODUCT_1=<10元套餐商品ID> # 商品 ID 直接使用套餐 ID:Basic、Standard、Professional、Enterprise
WECHAT_VIRTUAL_PRODUCT_2=<100元套餐商品ID>
WECHAT_VIRTUAL_PRODUCT_3=<1000元套餐商品ID>
WECHAT_VIRTUAL_PRODUCT_4=<10000元套餐商品ID>
AVATAR_DB_DIR=/srv/digital-avatar/data/db AVATAR_DB_DIR=/srv/digital-avatar/data/db
AVATAR_UPLOAD_DIR=/srv/digital-avatar/data/uploads AVATAR_UPLOAD_DIR=/srv/digital-avatar/data/uploads
@@ -92,9 +89,9 @@ CHAT_ATTACHMENT_CLEANUP_MINUTES=60
`EMBEDDING_API_URL` 同时支持 OpenAI 兼容基础地址(如上面的 `/v1`)和完整的 `/v1/embeddings` 地址,后端会统一请求 `/embeddings`。发布后必须在后端容器内执行一次最小向量探针,确认返回向量数量和维度,而不能只检查 `/api/health`。 `EMBEDDING_API_URL` 同时支持 OpenAI 兼容基础地址(如上面的 `/v1`)和完整的 `/v1/embeddings` 地址,后端会统一请求 `/embeddings`。发布后必须在后端容器内执行一次最小向量探针,确认返回向量数量和维度,而不能只检查 `/api/health`。
App 与 H5 积分充值使用会会支付体系的 `payment-v3/payment/pay`,渠道值为 `WECHAT` / `ALIPAY`;App 场景为 `APP`,普通浏览器为 `H5`,微信内 H5 为 `JSAPI`。`HUIHUI_PAYMENT_CALLBACK_SECRET` 只用于为每笔订单生成 HMAC 回调签名,不会发送到前端或直接出现在回调地址中。支付回调确认状态成功且金额与套餐价格完全一致后才增加积分,重复回调不会重复到账。 当前 H5 积分充值调用 `/api/token/charge/org` 创建会会组织充值订单,再用会会返回的订单号打开 `#/payOrder?id=<会会订单号>&isOrgUrl=true&evn=<入口环境>`;测试环境额外透传会会登录 token,生产环境不传。`id` 是收银台查询订单的编号,不能替换为套餐/道具 ID。普通微信 H5 走 JSAPI,App 和其他 H5 沿用会会普通收银台。支付回调确认状态成功且金额与套餐价格完全一致后才增加积分,重复回调不会重复到账。
微信小程序使用微信虚拟支付:小程序先通过 `POST /api/token/wechat/session` 交换临时登录码,再由 `POST /api/token/charge`(`payScene=LITE`)返回已签名的 `requestVirtualPayment` 参数。微信回调地址配置为 `https://digital.99hui.com/api/token/payment/wechat/virtual/notify`。回调会复核签名、OpenID、环境、商品 ID 与实付金额,退款回调确认后才扣回积分。AppKey、AppSecret、session_key 均不得下发前端或写日志。 微信小程序虚拟支付的服务端接口为:小程序先通过 `POST /api/token/wechat/session` 交换临时登录码,再由 `POST /api/token/charge`(`payScene=LITE`)返回已签名的 `requestVirtualPayment` 参数;其中 `productId` 直接采用套餐 ID(`Basic`、`Standard`、`Professional`、`Enterprise`)。小程序原生支付页仍需调用 `wx.requestVirtualPayment`,不能仅打开上述 H5 收银台 URL 来完成虚拟支付。微信回调地址配置为 `https://digital.99hui.com/api/token/payment/wechat/virtual/notify`。回调会复核签名、OpenID、环境、商品 ID 与实付金额,退款回调确认后才扣回积分。AppKey、AppSecret、session_key 均不得下发前端或写日志。
如由会会服务端转发微信小程序虚拟支付结果,另有待双方确认的固定回调契约,见 [HUIHUI_VIRTUAL_CALLBACK.md](HUIHUI_VIRTUAL_CALLBACK.md)。该接口使用独立的 `HUIHUI_VIRTUAL_CALLBACK_SECRET`;即使接口代码部署到生产,在协议确认和联调验收前也不得将其视作已上线的支付渠道。 如由会会服务端转发微信小程序虚拟支付结果,另有待双方确认的固定回调契约,见 [HUIHUI_VIRTUAL_CALLBACK.md](HUIHUI_VIRTUAL_CALLBACK.md)。该接口使用独立的 `HUIHUI_VIRTUAL_CALLBACK_SECRET`;即使接口代码部署到生产,在协议确认和联调验收前也不得将其视作已上线的支付渠道。
@@ -14,7 +14,7 @@
"paymentNo": "会会侧唯一支付流水号", "paymentNo": "会会侧唯一支付流水号",
"status": "SUCCESS", "status": "SUCCESS",
"amountCents": 1000, "amountCents": 1000,
"productId": "points_plan_1", "productId": "Basic",
"openId": "微信小程序用户 OpenID", "openId": "微信小程序用户 OpenID",
"env": 1 "env": 1
} }