diff --git a/digital-avatar-app/backend/routers/tokens.py b/digital-avatar-app/backend/routers/tokens.py index 2fd39a4..91b666c 100644 --- a/digital-avatar-app/backend/routers/tokens.py +++ b/digital-avatar-app/backend/routers/tokens.py @@ -810,12 +810,10 @@ async def payment_callback( return ok({"received": True, "paid": True}) -@router.post("/token/payment/huihui/virtual/notify") -async def huihui_virtual_payment_notify(request: Request, db: Session = Depends(get_db)): - """Server-to-server virtual-payment notice using the Huihui HMAC contract.""" - secret = os.getenv("HUIHUI_VIRTUAL_CALLBACK_SECRET", "").strip() - if len(secret) < 32: - raise HTTPException(status_code=503, detail="virtual payment callback is not configured") +async def _signed_huihui_notice(request: Request, *, secret_name: str, minimum_length: int) -> dict: + secret = os.getenv(secret_name, "").strip() + if len(secret) < minimum_length: + raise HTTPException(status_code=503, detail="payment callback is not configured") timestamp = request.headers.get("x-huihui-timestamp", "").strip() signature = request.headers.get("x-huihui-signature", "").strip().lower() if not timestamp.isascii() or not timestamp.isdecimal() or len(timestamp) > 12: @@ -840,6 +838,97 @@ async def huihui_virtual_payment_notify(request: Request, db: Session = Depends( raise HTTPException(status_code=400, detail="invalid callback JSON") if not isinstance(payload, dict): raise HTTPException(status_code=400, detail="invalid callback JSON") + return payload + + +@router.post("/token/payment/huihui/org/notify") +async def huihui_org_payment_notify(request: Request, db: Session = Depends(get_db)): + """Huihui organization recharge notice; independently verify payment before crediting.""" + payload = await _signed_huihui_notice( + request, secret_name="HUIHUI_ORG_CALLBACK_SECRET", minimum_length=16, + ) + order_no = str(payload.get("orderNo") or "").strip() + payment_no = str(payload.get("paymentNo") or "").strip() + status = str(payload.get("status") or "").upper() + if not order_no or not payment_no or status not in {"SUCCESS", "FAILED"}: + raise HTTPException(status_code=400, detail="missing callback fields") + order = db.query(TokenPaymentOrder).filter( + TokenPaymentOrder.provider == "huihui_org", + TokenPaymentOrder.order_no == order_no, + ).first() + if order is None and order_no.isdigit(): + matches = db.query(TokenPaymentOrder).filter( + TokenPaymentOrder.provider == "huihui_org", + TokenPaymentOrder.provider_order_no == order_no, + ).limit(2).all() + if len(matches) == 1: + order = matches[0] + if not order: + raise HTTPException(status_code=404, detail="payment order not found") + if status == "FAILED": + return ok({"received": True, "paid": False}) + + user = db.query(User).filter(User.id == order.user_id).first() + plan = db.query(TokenPlan).filter(TokenPlan.id == order.plan_id).first() + try: + expected_product_id = product_id_for_plan(plan) if plan else "" + amount_cents = int(payload.get("amountCents")) + except (TypeError, ValueError, WechatVirtualPaymentError): + raise HTTPException(status_code=422, detail="invalid payment details") + if ( + isinstance(payload.get("amountCents"), bool) + or not user + or not expected_product_id + or str(payload.get("productId") or "") != expected_product_id + or amount_cents != order.price_cents + ): + raise HTTPException(status_code=422, detail="payment details mismatch") + if order.status in {"paid", "refunded"}: + return ok({"received": True, "duplicate": True}) + if not order.provider_order_no or not user.huihui_token or not user.huihui_user_id: + raise HTTPException(status_code=503, detail="provider order verification unavailable") + try: + provider_order = HuihuiOrgPaymentClient().get_order( + token=user.huihui_token, + user_id=user.huihui_user_id, + order_no=order.provider_order_no, + ) + except HuihuiPaymentError as exc: + raise HTTPException(status_code=503, detail="provider order verification unavailable") from exc + try: + provider_status = int(provider_order.get("payStatus")) + provider_type = int(provider_order.get("chargeType")) + except (TypeError, ValueError): + raise HTTPException(status_code=422, detail="invalid provider order details") + if ( + str(provider_order.get("orderNo") or "") != order.provider_order_no + or str(provider_order.get("paymentNo") or "") != payment_no + or str(provider_order.get("productId") or "") != expected_product_id + or str(provider_order.get("userId") or "") != user.huihui_user_id + or provider_type != 9 + or _callback_amount_cents({"chargeAmount": provider_order.get("chargeAmount")}) != order.price_cents + ): + raise HTTPException(status_code=422, detail="provider order details mismatch") + if provider_status not in {2, 3, 4}: + raise HTTPException(status_code=409, detail="provider payment not confirmed") + existing_payment = db.query(PaymentTransaction).filter( + PaymentTransaction.provider == "huihui_org", + PaymentTransaction.event_type == "payment", + PaymentTransaction.transaction_no == payment_no, + ).first() + if existing_payment and existing_payment.order_no != order.order_no: + raise HTTPException(status_code=409, detail="payment number belongs to another order") + _settle_paid_order(db, order, provider_status="HUIHUI_SUCCESS", transaction_no=payment_no) + db.commit() + return ok({"received": True, "paid": True}) + + +@router.post("/token/payment/huihui/virtual/notify") +async def huihui_virtual_payment_notify(request: Request, db: Session = Depends(get_db)): + """Server-to-server virtual-payment notice using the Huihui HMAC contract.""" + payload = await _signed_huihui_notice( + request, secret_name="HUIHUI_VIRTUAL_CALLBACK_SECRET", minimum_length=32, + ) order_no = str(payload.get("orderNo") or "").strip() payment_no = str(payload.get("paymentNo") or "").strip() @@ -877,7 +966,7 @@ async def huihui_virtual_payment_notify(request: Request, db: Session = Depends( PaymentTransaction.event_type == "payment", PaymentTransaction.transaction_no == payment_no, ).first() - if existing_payment and existing_payment.order_no != order_no: + if existing_payment and existing_payment.order_no != order.order_no: raise HTTPException(status_code=409, detail="payment number belongs to another order") if order.status in {"paid", "refunded"}: return ok({"received": True, "duplicate": True}) diff --git a/digital-avatar-app/backend/tests/test_virtual_payment_flow.py b/digital-avatar-app/backend/tests/test_virtual_payment_flow.py index cf17f3d..9b0ea8f 100644 --- a/digital-avatar-app/backend/tests/test_virtual_payment_flow.py +++ b/digital-avatar-app/backend/tests/test_virtual_payment_flow.py @@ -78,6 +78,74 @@ def test_huihui_virtual_callback_requires_signature_and_exact_order_details(auth db.close() +def test_huihui_org_callback_verifies_provider_before_granting_points(authorization_context): + seed() + context = authorization_context + provider_no = "26102010414409000001" + payment_no = f"WV-{context['suffix']}" + order_no = f"AV-{context['suffix']}" + db = SessionLocal() + try: + user = db.query(User).filter(User.id == context["owner"].id).one() + user.huihui_token = "test-huihui-token" + db.add(TokenPaymentOrder( + order_no=order_no, provider_order_no=provider_no, + user_id=user.id, plan_id="Basic", payment_method="wechat", + pay_type="WECHAT", pay_way="LITE", provider="huihui_org", + points_amount=2_000_000, price_cents=100, status="pending", + )) + db.commit() + finally: + db.close() + + secret = "abcdefghijklmnop" + path = "/api/token/payment/huihui/org/notify" + payload = { + "orderNo": provider_no, "paymentNo": payment_no, + "status": "SUCCESS", "amountCents": 100, + "productId": "Basic", + } + provider_data = { + "orderNo": provider_no, "paymentNo": payment_no, + "payStatus": 1, "chargeType": 9, "chargeAmount": 1, + "productId": "Basic", "userId": context["owner"].huihui_user_id, + } + + def send(body_payload, *, signature=True): + body = json.dumps(body_payload, separators=(",", ":")).encode() + headers = _huihui_virtual_headers(secret, body) if signature else {"X-Huihui-Signature": "bad"} + return client.post(path, content=body, headers=headers) + + with patch.dict(os.environ, {"HUIHUI_ORG_CALLBACK_SECRET": secret}), patch( + "routers.tokens.HuihuiOrgPaymentClient" + ) as client_factory: + provider = client_factory.return_value + provider.get_order.return_value = provider_data + assert send(payload, signature=False).status_code == 401 + assert send({**payload, "amountCents": 99}).status_code == 422 + assert send(payload).status_code == 409 + provider.get_order.return_value = {**provider_data, "payStatus": 2} + assert send({**payload, "paymentNo": "wrong"}).status_code == 422 + provider.get_order.return_value = {**provider_data, "payStatus": 2, "chargeAmount": 2} + assert send(payload).status_code == 422 + provider.get_order.return_value = {**provider_data, "payStatus": 2} + first = send(payload) + second = send(payload) + + assert first.status_code == 200 + assert first.json()["data"] == {"received": True, "paid": True} + assert second.json()["data"] == {"received": True, "duplicate": True} + db = SessionLocal() + try: + order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).one() + account = db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).one() + assert order.status == "paid" + assert account.balance == DEFAULT_TOKEN_GRANT + 2_000_000 + assert provider.get_order.call_count >= 3 + finally: + db.close() + + def _signature(token, timestamp, nonce): return hashlib.sha1("".join(sorted([token, timestamp, nonce])).encode()).hexdigest() diff --git a/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md b/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md index 32a5dec..ecc2a96 100644 --- a/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md +++ b/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md @@ -58,6 +58,8 @@ WECHAT_VIRTUAL_SANDBOX_APP_KEY=<沙箱AppKey> WECHAT_VIRTUAL_APP_KEY=<正式AppKey> WECHAT_VIRTUAL_OFFER_ID= WECHAT_VIRTUAL_CALLBACK_TOKEN=<回调校验Token> +HUIHUI_ORG_CALLBACK_SECRET=<会会组织充值支付结果通知验签密钥> +HUIHUI_VIRTUAL_CALLBACK_SECRET=<微信直连虚拟支付转发通知验签密钥> # 商品 ID 直接使用套餐 ID:Basic、Standard、Professional、Enterprise AVATAR_DB_DIR=/srv/digital-avatar/data/db @@ -93,7 +95,7 @@ CHAT_ATTACHMENT_CLEANUP_MINUTES=60 微信小程序虚拟支付的服务端接口为:小程序先通过 `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)。对于组织订单,回调验签后还必须向会会查单确认实付状态与订单详情。即使接口代码部署到生产,在会会发送格式确认和联调验收前也不得将其视作已上线的支付渠道。 管理后台需要配置相同的 `AVATAR_FINANCE_ADMIN_SECRET` 和 `AVATAR_BACKEND_URL=https://digital.99hui.com`。退款只支持整单原路退款;供应商受理后显示“处理中”,收到渠道成功回调(或经渠道后台核对后人工确认)才将订单置为已退款。已消费掉本订单积分时,后台会拒绝主动退款;若渠道外部退款先发生,积分账户允许形成负数以记录欠额并阻止继续消费。 diff --git a/digital-avatar-app/docs/HUIHUI_VIRTUAL_CALLBACK.md b/digital-avatar-app/docs/HUIHUI_VIRTUAL_CALLBACK.md index e6530e8..04291c9 100644 --- a/digital-avatar-app/docs/HUIHUI_VIRTUAL_CALLBACK.md +++ b/digital-avatar-app/docs/HUIHUI_VIRTUAL_CALLBACK.md @@ -1,31 +1,30 @@ -# 会会微信小程序虚拟支付回调(待双方确认) +# 会会支付回调(待双方确认) -本项目为会会服务端预留固定回调地址: +本项目为会会服务端预留两条固定回调地址: -`POST https://digital.99hui.com/api/token/payment/huihui/virtual/notify` +- 会会组织充值订单(当前 H5 收银台):`POST https://fat-digital.99hui.com/api/token/payment/huihui/org/notify`(测试环境) +- 微信直连虚拟支付订单:`POST https://digital.99hui.com/api/token/payment/huihui/virtual/notify`(生产环境示例) -回调报文及签名协议尚未与会会确认。以下是本项目实现的拟议对接契约,双方确认并安全交换密钥后才能进行测试支付。接口代码部署与会会联调通过是两个独立状态。它不替代现有微信直连回调 `/api/token/payment/wechat/virtual/notify`。 +回调报文及签名协议尚未与会会确认。以下是本项目实现的拟议对接契约,会会服务端必须按此格式发送才能完成联调。接口代码部署与会会联调通过是两个独立状态。它不替代现有微信直连回调 `/api/token/payment/wechat/virtual/notify`。 请求体为 JSON,成功支付示例: ```json { - "orderNo": "AV202610150000000000000001", + "orderNo": "26102010414409234218", "paymentNo": "会会侧唯一支付流水号", "status": "SUCCESS", "amountCents": 1000, - "productId": "Basic", - "openId": "微信小程序用户 OpenID", - "env": 1 + "productId": "Basic" } ``` -`amountCents` 单位为分,`env` 为微信虚拟支付环境(0 正式、1 沙箱)。非成功结果可发送 `status: "FAILED"`,但不会入账。订单号、支付流水号和状态必填;成功结果还必须提供金额、商品 ID、OpenID、环境。仅在订单属于微信虚拟支付,且上述信息与本地订单、套餐、用户一致时入账。重复成功通知不会重复发放积分;同一支付流水号不能用于其他订单。 +`amountCents` 单位为分。对于经 `/order/tokenRecharge` 创建的会会组织订单,`orderNo` 可以是会会订单号或数字分身 `AV` 订单号;成功通知必须提供 `amountCents`、`productId` 和 `paymentNo`,无需 `openId`、`env`。服务端会再次向会会查单,要求订单号、支付流水、商品 ID、用户、收费类型、金额一致,且会会支付状态为已付款,才发放积分。对于微信直连订单,仍需 `openId` 和 `env`(0 正式、1 沙箱)一致。非成功结果可发送 `status: "FAILED"`,但不会入账。重复成功通知不会重复发放积分;同一支付流水号不能用于其他订单。 请求头: - `X-Huihui-Timestamp`:Unix 秒级时间戳,允许与本服务时间相差不超过 300 秒。 -- `X-Huihui-Signature`:小写十六进制 HMAC-SHA256,密钥为双方线下安全交换、服务端配置的 `HUIHUI_VIRTUAL_CALLBACK_SECRET`(至少 32 字符),待签名数据为 `时间戳 + 换行符 + 原始请求体字节`。不要重排 JSON 后签名,也不要把密钥发给前端。 +- `X-Huihui-Signature`:小写十六进制 HMAC-SHA256,待签名数据为 `时间戳 + 换行符 + 原始请求体字节`。组织充值订单使用服务端配置的 `HUIHUI_ORG_CALLBACK_SECRET`(至少 16 字符),微信直连虚拟支付订单使用独立的 `HUIHUI_VIRTUAL_CALLBACK_SECRET`(至少 32 字符)。不要重排 JSON 后签名,也不要把密钥发给前端。 成功接收返回 HTTP 200,`{"code":200,"message":"success","data":{"received":true,"paid":true}}`。重复通知返回 `duplicate: true`。验签、字段、订单或金额不符返回 HTTP 4xx,会会可按其重试策略处理。服务端未配置密钥时返回 HTTP 503。