diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f833d04 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# 支付渠道:xorpay | zpay +PAYMENT_PROVIDER=xorpay + +# XorPay(默认) +XORPAY_AID=8220 +XORPAY_SECRET=your_secret + +# ZPAY(网关: https://zpayz.cn/) +ZPAY_PID=2025121809351743 +ZPAY_KEY=tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89 + +# 回调根地址 +BASE_URL=https://api.hackrobot.cn + +# PocketBase +PB_URL=https://pocketbase.hackrobot.cn +PB_ADMIN_EMAIL=your@email.com +PB_ADMIN_PASSWORD=your_password + +# Memos +MEMOS_API=https://qun.hackrobot.cn/api/v1/users +MEMOS_TOKEN=your_token diff --git a/README.md b/README.md index cdb11ed..58ee62d 100755 --- a/README.md +++ b/README.md @@ -1,6 +1,28 @@ -# androidh5api +# PayJS API +支付 API 服务,支持多支付渠道切换(XorPay、ZPAY)。 +## 支付渠道 + +| 渠道 | 环境变量 | 说明 | +|------|----------|------| +| XorPay | `PAYMENT_PROVIDER=xorpay` | 默认 | +| ZPAY | `PAYMENT_PROVIDER=zpay` | 需配置 `ZPAY_PID`、`ZPAY_KEY` | + +切换渠道:设置环境变量 `PAYMENT_PROVIDER=xorpay` 或 `zpay`,参考 `.env.example`。 + +## 模块结构 + +``` +app/ +├── config.py # 配置(环境变量) +├── main.py # FastAPI 入口 +└── payment/ + ├── base.py # 支付抽象基类 + ├── xorpay.py # XorPay 实现 + ├── zpay.py # ZPAY 实现(易支付兼容) + └── factory.py # 渠道工厂 +``` ## Getting started diff --git a/app/__pycache__/__init__.cpython-312.pyc b/app/__pycache__/__init__.cpython-312.pyc index 720495a..85355e6 100755 Binary files a/app/__pycache__/__init__.cpython-312.pyc and b/app/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/__pycache__/config.cpython-312.pyc b/app/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..57578c7 Binary files /dev/null and b/app/__pycache__/config.cpython-312.pyc differ diff --git a/app/__pycache__/main.cpython-312.pyc b/app/__pycache__/main.cpython-312.pyc index 535956b..2de8ef6 100755 Binary files a/app/__pycache__/main.cpython-312.pyc and b/app/__pycache__/main.cpython-312.pyc differ diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..f6bda29 --- /dev/null +++ b/app/config.py @@ -0,0 +1,35 @@ +""" +应用配置 +支付渠道可通过 PAYMENT_PROVIDER 切换:xorpay | zpay +""" +import os +from typing import Literal + +PaymentChannel = Literal["xorpay", "zpay"] + +# 当前使用的支付渠道 +PAYMENT_PROVIDER: PaymentChannel = os.getenv("PAYMENT_PROVIDER", "xorpay").lower() + +# XorPay 配置 +XORPAY_AID = os.getenv("XORPAY_AID", "8220") +XORPAY_SECRET = os.getenv("XORPAY_SECRET", "afcacd99570945f88de62624aaa3578e") +XORPAY_CASHIER_URL = os.getenv("XORPAY_CASHIER_URL", "https://xorpay.com/api/cashier") + +# ZPAY 配置(网关: https://zpayz.cn/) +ZPAY_PID = os.getenv("ZPAY_PID", "2025121809351743") +ZPAY_KEY = os.getenv("ZPAY_KEY", "tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89") + +# PocketBase +PB_URL = os.getenv("PB_URL", "https://pocketbase.hackrobot.cn") +PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "xiaoshuang.eric@gmail.com") +PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "Xiao4669805@") + +# Memos +MEMOS_API = os.getenv("MEMOS_API", "https://qun.hackrobot.cn/api/v1/users") +MEMOS_TOKEN = os.getenv( + "MEMOS_TOKEN", + "eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiaGFja3JvYm90IiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImlhdCI6MTc1NzQwNTE0OX0.Idn7kBlxE-CoSOXwWuZ1tHGIRKHAIeDyXSafGS5OHsg", +) + +# 回调地址(用于生成 notify_url) +BASE_URL = os.getenv("BASE_URL", "https://api.hackrobot.cn") diff --git a/app/main.py b/app/main.py index 5919580..3d4ba9d 100755 --- a/app/main.py +++ b/app/main.py @@ -1,15 +1,30 @@ -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel -import hashlib -import time +""" +支付 API 主入口 +支持多支付渠道(XorPay、ZPAY),通过 PAYMENT_PROVIDER 环境变量切换 +""" +import logging import random import string +import time +from typing import Dict, Any + import requests -from fastapi import Request, HTTPException -import logging +from fastapi import FastAPI, Request, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel from pocketbase import PocketBase +from .config import ( + PAYMENT_PROVIDER, + BASE_URL, + PB_URL, + PB_ADMIN_EMAIL, + PB_ADMIN_PASSWORD, + MEMOS_API, + MEMOS_TOKEN, +) +from .payment import get_payment_provider + app = FastAPI() app.add_middleware( @@ -20,57 +35,30 @@ app.add_middleware( allow_headers=["*"], ) -# ==================== PocketBase 配置 ==================== -PB_URL = "https://pocketbase.hackrobot.cn" -PB_ADMIN_EMAIL = "xiaoshuang.eric@gmail.com" -PB_ADMIN_PASSWORD = "Xiao4669805@" -# ======================================================== +# 日志 +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(message)s", + handlers=[ + logging.FileHandler("payment_notify.log"), + logging.StreamHandler(), + ], +) + +# 已处理订单(防重复) +processed_orders: set[str] = set() + def get_pb_client(): client = PocketBase(PB_URL) try: client.admins.auth_with_password(PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD) - print("PocketBase admin 登录成功(动态)") + print("PocketBase admin 登录成功") except Exception as e: print("PocketBase admin 登录失败:", e) raise e return client -# 配置日志(添加控制台输出,便于调试) -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(message)s', - handlers=[ - logging.FileHandler('xorpay_notify.log'), - logging.StreamHandler() # 新增:同时输出到控制台 - ] -) - -# 全局已处理订单 -processed_orders = set() - -# ==================== XorPay 配置 ==================== -AID = "8220" -SECRET = "afcacd99570945f88de62624aaa3578e" -# ==================================================== - -# memos 配置 -MEMOS_API = "https://qun.hackrobot.cn/api/v1/users" -MEMOS_TOKEN = "eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiaGFja3JvYm90IiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImlhdCI6MTc1NzQwNTE0OX0.Idn7kBlxE-CoSOXwWuZ1tHGIRKHAIeDyXSafGS5OHsg" - -def xorpay_sign(name: str, pay_type: str, price: str, order_id: str, notify_url: str) -> str: - raw = name + pay_type + price + order_id + notify_url + SECRET - return hashlib.md5(raw.encode('utf-8')).hexdigest().lower() - -def verify_xorpay_notify(data: dict) -> bool: - received_sign = data.get("sign", "") - aoid = data.get("aoid", "") - order_id = data.get("order_id", "") - pay_price = data.get("pay_price", "") - pay_time = data.get("pay_time", "") - raw = aoid + order_id + pay_price + pay_time + SECRET - calculated_sign = hashlib.md5(raw.encode('utf-8')).hexdigest().lower() - return calculated_sign == received_sign def create_memos_user(user_id: str, password: str = "123456"): try: @@ -88,11 +76,11 @@ def create_memos_user(user_id: str, password: str = "123456"): "username": username, "password": password, "role": "USER", - "state": "NORMAL" + "state": "NORMAL", } memos_headers = { "Content-Type": "application/json", - "Authorization": f"Bearer {MEMOS_TOKEN}" + "Authorization": f"Bearer {MEMOS_TOKEN}", } response = requests.post(MEMOS_API, json=memos_data, headers=memos_headers, timeout=10) @@ -113,15 +101,89 @@ def create_memos_user(user_id: str, password: str = "123456"): logging.error(f"创建 memos 用户异常 - username: {user_id}, error: {e}") return {"success": False, "error": str(e)} + +def _parse_order_id(order_id: str) -> tuple[str, str]: + """从 order_id 解析 user_id 和 pay_type""" + if "_order_" not in order_id: + return "", "unknown" + prefix = order_id.split("_order_")[0] + if "_" in prefix: + parts = prefix.rsplit("_", 1) + return parts[0], parts[1].lower() + return prefix, "unknown" + + +def _handle_payment_success(order_id: str, pay_price: str, provider_name: str): + """支付成功后的统一业务处理""" + user_id, pay_type = _parse_order_id(order_id) + if not user_id: + return + + print(f"支付成功 [{provider_name}] 用户ID: {user_id},类型: {pay_type},订单: {order_id}") + + if pay_type == "vip": + create_memos_user(user_id) + + if pay_type == "meetup": + logging.info(f"meetup 类型支付成功,不创建 memos 用户 - user_id: {user_id}") + + valid_types = ["vip", "book", "meetup", "video"] + if pay_type not in valid_types: + pay_type = "vip" + + amount_cents = int(float(pay_price or 0) * 100) + + try: + pb_client = get_pb_client() + if pay_type == "meetup": + solan = pb_client.collection("solan") + existing = solan.get_list(1, 1, { + "filter": f'user_id = "{user_id}"', + "sort": "-created", + }) + if existing.items: + record_id = existing.items[0].id + solan.update(record_id, { + "type": "meetup", + "order_id": order_id, + "amount": amount_cents, + }) + logging.info(f"meetup 支付字段补齐成功 - user_id: {user_id}, record_id: {record_id}") + else: + solan.create({ + "user_id": user_id, + "type": "meetup", + "order_id": order_id, + "amount": amount_cents, + }) + logging.info(f"meetup 完整记录创建(兜底) - user_id: {user_id}") + else: + pb_client.collection("payments").create({ + "user_id": user_id, + "type": pay_type, + "order_id": order_id, + "amount": amount_cents, + }) + except Exception as e: + print(f"PocketBase 操作失败: {e}") + logging.error(f"PocketBase 操作失败 - order_id: {order_id}, error: {e}") + finally: + processed_orders.add(order_id) + + +# ==================== 支付接口 ==================== + + @app.post("/payh5") async def payh5(item: dict): - print('payh5 item:', item) + """创建 H5 支付订单(根据 PAYMENT_PROVIDER 使用对应渠道)""" + print("payh5 item:", item) total_fee_yuan = f"{item['total_fee'] / 100:.2f}" - user_id = item.get('user_id', 'unknown') - pay_type = item.get('type', 'unknown').lower() + user_id = item.get("user_id", "unknown") + pay_type = item.get("type", "unknown").lower() - random_suffix = ''.join(random.choices(string.hexdigits.lower(), k=8)) + random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8)) timestamp = int(time.time()) order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}" @@ -133,37 +195,108 @@ async def payh5(item: dict): } name = type_map.get(pay_type, "商品支付") - notify_url = "https://api.hackrobot.cn/xorpay_notify" + provider = get_payment_provider() + notify_path = "/xorpay_notify" if provider.name == "xorpay" else "/zpay_notify" + notify_url = f"{BASE_URL.rstrip('/')}{notify_path}" - params = { - "name": name, - "pay_type": "jsapi", - "price": total_fee_yuan, - "order_id": order_id, - "notify_url": notify_url, - } + # 支付方式:前端可传 channel=alipay|wxpay,ZPAY 使用;XorPay 固定 jsapi + channel = item.get("channel", "alipay") - params["sign"] = xorpay_sign( - params["name"], - params["pay_type"], - params["price"], - params["order_id"], - params["notify_url"] + result = provider.create_order( + order_id=order_id, + name=name, + total_fee_yuan=total_fee_yuan, + pay_type=channel, + notify_url=notify_url, + return_url=item.get("return_url"), + client_ip=item.get("client_ip"), ) - print("生成的参数:", params) - + # 兼容旧版前端:保留 xorpay_params / xorpay_url 字段名 + if provider.name == "xorpay": + return { + "status": "ok", + "xorpay_params": result.get("params", {}), + "xorpay_url": result.get("pay_url", ""), + "provider": provider.name, + } return { "status": "ok", - "xorpay_params": params, - "xorpay_url": f"https://xorpay.com/api/cashier/{AID}" + "params": result.get("params", {}), + "pay_url": result.get("pay_url", ""), + "provider": provider.name, + **{k: v for k, v in result.items() if k not in ("status", "params", "pay_url", "provider")}, } + +@app.post("/xorpay_notify") +async def xorpay_notify(request: Request): + """XorPay 异步通知(POST form)""" + form_data = await request.form() + data = {k: v for k, v in form_data.items()} + + logging.info(f"XorPay 异步通知: {data}") + + order_id = data.get("order_id", "") + if order_id in processed_orders: + return "ok" + + provider = get_payment_provider() + if provider.name != "xorpay": + raise HTTPException(status_code=400, detail="xorpay_notify only for xorpay") + + if not provider.verify_notify(data): + logging.error(f"XorPay 验签失败: {data}") + raise HTTPException(status_code=400, detail="sign error") + + parsed = provider.parse_notify(data) + if parsed.get("trade_status") != "success": + return "ok" + + _handle_payment_success(order_id, parsed.get("pay_price", data.get("pay_price", "")), "xorpay") + return provider.success_response() + + +@app.get("/zpay_notify") +@app.post("/zpay_notify") +async def zpay_notify(request: Request): + """ZPAY 异步通知(GET 或 POST)""" + if request.method == "GET": + data = dict(request.query_params) + else: + form_data = await request.form() + data = {k: v for k, v in form_data.items()} + + logging.info(f"ZPAY 异步通知: {data}") + + order_id = data.get("out_trade_no", "") + if order_id in processed_orders: + return "success" + + provider = get_payment_provider() + if provider.name != "zpay": + raise HTTPException(status_code=400, detail="zpay_notify only for zpay") + + if not provider.verify_notify(data): + logging.error(f"ZPAY 验签失败: {data}") + raise HTTPException(status_code=400, detail="sign error") + + parsed = provider.parse_notify(data) + if parsed.get("trade_status") != "success": + return "success" + + _handle_payment_success(order_id, parsed.get("pay_price", data.get("money", "")), "zpay") + return provider.success_response() + + +# ==================== 其他接口 ==================== + + @app.post("/submit_meetup_application") async def submit_meetup_application(item: dict): - print('submit_meetup_application item:', item) + print("submit_meetup_application item:", item) - user_id = item.get('user_id') + user_id = item.get("user_id") if not user_id: raise HTTPException(status_code=400, detail="missing user_id") @@ -196,100 +329,11 @@ async def submit_meetup_application(item: dict): logging.error(f"meetup 申请提交失败 - user_id: {user_id}, error: {e}") raise HTTPException(status_code=500, detail="submit failed") -@app.post("/xorpay_notify") -async def xorpay_notify(request: Request): - form_data = await request.form() - data = {key: value for key, value in form_data.items()} - - logging.info(f"XorPay 异步通知原始数据: {data}") - print("XorPay 异步通知:", data) - - order_id = data.get("order_id", "") - - if order_id in processed_orders: - print(f"订单 {order_id} 已处理过,跳过") - return "ok" - - if not verify_xorpay_notify(data): - print("验签失败") - logging.error(f"验签失败: {data}") - raise HTTPException(status_code=400, detail="sign error") - - if "_order_" not in order_id: - raise HTTPException(status_code=400, detail="invalid order_id format") - - prefix = order_id.split("_order_")[0] - - if "_" in prefix: - parts = prefix.rsplit("_", 1) - user_id = parts[0] - pay_type = parts[1].lower() - else: - user_id = prefix - pay_type = "unknown" - - print(f"支付成功,用户ID: {user_id},类型: {pay_type},订单: {order_id}") - - if pay_type == "vip": - create_memos_user(user_id) - - if pay_type == "meetup": - logging.info(f"meetup 类型支付成功(独立逻辑),不创建 memos 用户 - user_id: {user_id}") - - try: - pb_client = get_pb_client() - - valid_types = ["vip", "book", "meetup", "video"] - if pay_type not in valid_types: - print(f"无效的 pay_type: {pay_type},强制设为 vip") - pay_type = "vip" - - amount_cents = int(float(data.get("pay_price", 0)) * 100) - - if pay_type == "meetup": - solan = pb_client.collection("solan") - # 修复 filter 语法:添加空格和 '=' 操作符(PocketBase 要求) - # 同时添加 sort 以取最新记录(防止同一 user_id 多条) - existing = solan.get_list(1, 1, { - "filter": f'user_id = "{user_id}"', - "sort": "-created" # 按创建时间倒序,取最新一条 - }) - if existing.items: - record_id = existing.items[0].id - solan.update(record_id, { - "type": "meetup", - "order_id": order_id, - "amount": amount_cents, - }) - logging.info(f"meetup 支付字段补齐成功 - user_id: {user_id}, record_id: {record_id}") - else: - solan.create({ - "user_id": user_id, - "type": "meetup", - "order_id": order_id, - "amount": amount_cents, - }) - logging.info(f"meetup 完整记录创建(兜底) - user_id: {user_id}") - else: - pb_client.collection("payments").create({ - "user_id": user_id, - "type": pay_type, - "order_id": order_id, - "amount": amount_cents, - }) - - processed_orders.add(order_id) - - except Exception as e: - print(f"PocketBase 操作失败: {e}") - logging.error(f"PocketBase 操作失败 - order_id: {order_id}, error: {e}") - processed_orders.add(order_id) # 防止重复通知 - - return "ok" class CreateUserRequest(BaseModel): - username: str = None - password: str = None + username: str | None = None + password: str | None = None + @app.post("/create_user") async def create_user(item: CreateUserRequest): @@ -298,13 +342,13 @@ async def create_user(item: CreateUserRequest): "username": item.username or f"user{timenew}", "password": item.password or "123456", "role": "USER", - "state": "NORMAL" + "state": "NORMAL", } memos_headers = { "Content-Type": "application/json", - "Authorization": f"Bearer {MEMOS_TOKEN}" + "Authorization": f"Bearer {MEMOS_TOKEN}", } memos_resp = requests.post(MEMOS_API, json=memos_data, headers=memos_headers) memos_result = memos_resp.json() - print('memos_result-', memos_result) - return memos_result \ No newline at end of file + print("memos_result-", memos_result) + return memos_result diff --git a/app/payment/__init__.py b/app/payment/__init__.py new file mode 100644 index 0000000..d6e50aa --- /dev/null +++ b/app/payment/__init__.py @@ -0,0 +1,16 @@ +""" +支付模块 +支持多支付渠道,可通过配置切换 +""" +from .base import PaymentProvider +from .xorpay import XorPayProvider +from .zpay import ZPayProvider +from .factory import get_payment_provider, reset_provider + +__all__ = [ + "PaymentProvider", + "XorPayProvider", + "ZPayProvider", + "get_payment_provider", + "reset_provider", +] diff --git a/app/payment/__pycache__/__init__.cpython-312.pyc b/app/payment/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..822b2c6 Binary files /dev/null and b/app/payment/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/payment/__pycache__/base.cpython-312.pyc b/app/payment/__pycache__/base.cpython-312.pyc new file mode 100644 index 0000000..fd323d0 Binary files /dev/null and b/app/payment/__pycache__/base.cpython-312.pyc differ diff --git a/app/payment/__pycache__/factory.cpython-312.pyc b/app/payment/__pycache__/factory.cpython-312.pyc new file mode 100644 index 0000000..3bf336a Binary files /dev/null and b/app/payment/__pycache__/factory.cpython-312.pyc differ diff --git a/app/payment/__pycache__/xorpay.cpython-312.pyc b/app/payment/__pycache__/xorpay.cpython-312.pyc new file mode 100644 index 0000000..1421d14 Binary files /dev/null and b/app/payment/__pycache__/xorpay.cpython-312.pyc differ diff --git a/app/payment/__pycache__/zpay.cpython-312.pyc b/app/payment/__pycache__/zpay.cpython-312.pyc new file mode 100644 index 0000000..9d9457c Binary files /dev/null and b/app/payment/__pycache__/zpay.cpython-312.pyc differ diff --git a/app/payment/base.py b/app/payment/base.py new file mode 100644 index 0000000..cd0679b --- /dev/null +++ b/app/payment/base.py @@ -0,0 +1,59 @@ +""" +支付渠道抽象基类 +定义统一的支付接口,便于切换不同支付渠道(XorPay、ZPAY 等) +""" +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional + + +class PaymentProvider(ABC): + """支付渠道抽象基类""" + + @property + @abstractmethod + def name(self) -> str: + """支付渠道名称""" + pass + + @abstractmethod + def create_order( + self, + *, + order_id: str, + name: str, + total_fee_yuan: str, + pay_type: str, + notify_url: str, + return_url: Optional[str] = None, + client_ip: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + 创建支付订单 + :return: 包含支付跳转信息等,格式由各渠道自定义 + """ + pass + + @abstractmethod + def verify_notify(self, data: Dict[str, Any]) -> bool: + """验证异步通知签名""" + pass + + @abstractmethod + def parse_notify(self, data: Dict[str, Any]) -> Dict[str, Any]: + """ + 解析异步通知,提取统一字段 + :return: { + "order_id": str, # 商户订单号 + "trade_no": str, # 渠道订单号 + "pay_price": str, # 实付金额(元) + "pay_time": str, # 支付时间 + "trade_status": str, # 支付状态 + } + """ + pass + + @abstractmethod + def success_response(self) -> str: + """异步通知成功时需返回的字符串(如 'ok' 或 'success')""" + pass diff --git a/app/payment/factory.py b/app/payment/factory.py new file mode 100644 index 0000000..4bdcbd7 --- /dev/null +++ b/app/payment/factory.py @@ -0,0 +1,39 @@ +""" +支付渠道工厂 +根据配置返回对应的支付 Provider +""" +from ..config import ( + PAYMENT_PROVIDER, + XORPAY_AID, + XORPAY_SECRET, + XORPAY_CASHIER_URL, + ZPAY_PID, + ZPAY_KEY, +) +from .base import PaymentProvider +from .xorpay import XorPayProvider +from .zpay import ZPayProvider + +_provider_instance: PaymentProvider | None = None + + +def get_payment_provider() -> PaymentProvider: + """获取当前配置的支付渠道实例(单例)""" + global _provider_instance + if _provider_instance is not None: + return _provider_instance + if PAYMENT_PROVIDER == "zpay": + _provider_instance = ZPayProvider(pid=ZPAY_PID, key=ZPAY_KEY) + else: + _provider_instance = XorPayProvider( + aid=XORPAY_AID, + secret=XORPAY_SECRET, + cashier_url=XORPAY_CASHIER_URL, + ) + return _provider_instance + + +def reset_provider(): + """重置 Provider(用于测试或切换渠道)""" + global _provider_instance + _provider_instance = None diff --git a/app/payment/xorpay.py b/app/payment/xorpay.py new file mode 100644 index 0000000..5414b5c --- /dev/null +++ b/app/payment/xorpay.py @@ -0,0 +1,82 @@ +""" +XorPay 支付渠道实现 +文档: https://xorpay.com +""" +import hashlib +from typing import Any, Dict, Optional + +from .base import PaymentProvider + + +class XorPayProvider(PaymentProvider): + """XorPay 支付""" + + def __init__(self, aid: str, secret: str, cashier_url: str = "https://xorpay.com/api/cashier"): + self.aid = aid + self.secret = secret + self.cashier_url = cashier_url.rstrip("/") + + @property + def name(self) -> str: + return "xorpay" + + def _sign(self, name: str, pay_type: str, price: str, order_id: str, notify_url: str) -> str: + raw = name + pay_type + price + order_id + notify_url + self.secret + return hashlib.md5(raw.encode("utf-8")).hexdigest().lower() + + def create_order( + self, + *, + order_id: str, + name: str, + total_fee_yuan: str, + pay_type: str, + notify_url: str, + return_url: Optional[str] = None, + client_ip: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + # XorPay 使用 jsapi 表示 H5/JSAPI 支付 + channel_pay_type = "jsapi" + params = { + "name": name, + "pay_type": channel_pay_type, + "price": total_fee_yuan, + "order_id": order_id, + "notify_url": notify_url, + } + params["sign"] = self._sign( + params["name"], + params["pay_type"], + params["price"], + params["order_id"], + params["notify_url"], + ) + return { + "status": "ok", + "provider": self.name, + "params": params, + "pay_url": f"{self.cashier_url}/{self.aid}", + } + + def verify_notify(self, data: Dict[str, Any]) -> bool: + received_sign = data.get("sign", "") + aoid = data.get("aoid", "") + order_id = data.get("order_id", "") + pay_price = data.get("pay_price", "") + pay_time = data.get("pay_time", "") + raw = aoid + order_id + pay_price + pay_time + self.secret + calculated = hashlib.md5(raw.encode("utf-8")).hexdigest().lower() + return calculated == received_sign + + def parse_notify(self, data: Dict[str, Any]) -> Dict[str, Any]: + return { + "order_id": data.get("order_id", ""), + "trade_no": data.get("aoid", ""), + "pay_price": data.get("pay_price", ""), + "pay_time": data.get("pay_time", ""), + "trade_status": "success", # XorPay 验签通过即视为成功 + } + + def success_response(self) -> str: + return "ok" diff --git a/app/payment/zpay.py b/app/payment/zpay.py new file mode 100644 index 0000000..ce5f4b9 --- /dev/null +++ b/app/payment/zpay.py @@ -0,0 +1,153 @@ +""" +ZPAY 支付渠道实现 +文档: https://z-pay.cn/doc.html +支持:页面跳转支付、API 接口支付、异步通知验签 +""" +import hashlib +from typing import Any, Dict, Optional + +import requests + +from .base import PaymentProvider + + +class ZPayProvider(PaymentProvider): + """ZPAY 支付(易支付兼容接口)""" + + # API 地址 + SUBMIT_URL = "https://zpayz.cn/submit.php" # 页面跳转 + MAPI_URL = "https://zpayz.cn/mapi.php" # API 接口 + + def __init__(self, pid: str, key: str): + self.pid = pid + self.key = key + + @property + def name(self) -> str: + return "zpay" + + def _md5_sign(self, params: Dict[str, Any], exclude: Optional[set] = None) -> str: + """ + ZPAY MD5 签名算法: + 1. 参数按 ASCII 排序,sign/sign_type/空值不参与 + 2. 拼接为 a=b&c=d + 3. sign = md5(拼接串 + KEY),小写 + """ + exclude = exclude or {"sign", "sign_type"} + filtered = {k: v for k, v in params.items() if k not in exclude and v is not None and v != ""} + sorted_keys = sorted(filtered.keys()) + parts = [f"{k}={filtered[k]}" for k in sorted_keys] + raw = "&".join(parts) + self.key + return hashlib.md5(raw.encode("utf-8")).hexdigest().lower() + + def create_order( + self, + *, + order_id: str, + name: str, + total_fee_yuan: str, + pay_type: str, + notify_url: str, + return_url: Optional[str] = None, + client_ip: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + # ZPAY: alipay / wxpay(pay_type 可为业务类型或支付方式,此处按支付方式解析) + channel_type = "wxpay" if str(pay_type).lower() in ("wx", "wechat", "wxpay") else "alipay" + params = { + "pid": self.pid, + "type": channel_type, + "out_trade_no": order_id, + "notify_url": notify_url, + "name": name, + "money": total_fee_yuan, + "return_url": return_url or notify_url, + } + if client_ip: + params["clientip"] = client_ip + if extra and extra.get("param"): + params["param"] = str(extra["param"]) + params["sign_type"] = "MD5" + params["sign"] = self._md5_sign(params) + + # 页面跳转方式:返回 form 提交 URL 和参数 + return { + "status": "ok", + "provider": self.name, + "params": params, + "pay_url": self.SUBMIT_URL, + "submit_method": "POST", + } + + def create_order_api( + self, + *, + order_id: str, + name: str, + total_fee_yuan: str, + pay_type: str, + notify_url: str, + return_url: Optional[str] = None, + client_ip: str = "127.0.0.1", + device: str = "pc", + extra: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + ZPAY API 接口支付(返回 payurl/qrcode 等) + 适用于需要二维码或 H5 跳转的场景 + """ + channel_type = "wxpay" if pay_type.lower() in ("wx", "wechat", "wxpay") else "alipay" + params = { + "pid": self.pid, + "type": channel_type, + "out_trade_no": order_id, + "notify_url": notify_url, + "name": name, + "money": total_fee_yuan, + "clientip": client_ip or "127.0.0.1", + "device": device, + } + if return_url: + params["return_url"] = return_url + if extra and extra.get("param"): + params["param"] = str(extra["param"]) + params["sign_type"] = "MD5" + params["sign"] = self._md5_sign(params) + + resp = requests.post(self.MAPI_URL, data=params, timeout=15) + result = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {} + if isinstance(result, dict) and result.get("code") == 1: + return { + "status": "ok", + "provider": self.name, + "payurl": result.get("payurl"), + "payurl2": result.get("payurl2"), # 微信 H5 + "qrcode": result.get("qrcode"), + "img": result.get("img"), + "O_id": result.get("O_id"), + "trade_no": result.get("trade_no"), + } + return { + "status": "error", + "provider": self.name, + "msg": result.get("msg", resp.text), + } + + def verify_notify(self, data: Dict[str, Any]) -> bool: + received = data.get("sign", "") + calculated = self._md5_sign(data) + return calculated == received + + def parse_notify(self, data: Dict[str, Any]) -> Dict[str, Any]: + # ZPAY 通知:trade_status 为 TRADE_SUCCESS 表示成功 + status = "success" if data.get("trade_status") == "TRADE_SUCCESS" else "pending" + return { + "order_id": data.get("out_trade_no", ""), + "trade_no": data.get("trade_no", ""), + "pay_price": data.get("money", ""), + "pay_time": "", # ZPAY 通知未明确返回 + "trade_status": status, + } + + def success_response(self) -> str: + return "success" diff --git a/payment_notify.log b/payment_notify.log new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt index 27c9242..08c6506 100755 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ uvicorn sqlalchemy pydantic python-multipart -pocketbase \ No newline at end of file +pocketbase +requests \ No newline at end of file