Files
image2api/backend/internal/provider/epay/client.go
T
chiyiandClaude Opus 4.8 8ec4562f81 feat: 易支付积分充值 + 站内公告 + OpenAI 视频修复 + grok/adobe 失败处理
充值/订单(易支付 mapi):
- 订单表 + 30 分钟自动取消;支付弹窗(二维码/跳转监控、倒计时、轮询、5s 倒计时关闭)
- 系统设置可配:开关/商户ID/密钥/支付地址(根地址拼 /mapi)/支付方式/最低额/积分比例(默认 1元=100积分)
- 异步通知 MD5 验签、幂等到账;用户累计充值;前台/后台订单页(筛选+搜索+分页,前后台分风格);用户管理累计充值列

站内公告:
- Markdown 公告,登录用户首次访问/刷新弹出;内容哈希做版本,改了就重新推;管理员不弹;空内容=下线

OpenAI 视频(/v1/videos)修复:
- /content 拿不到视频:grok 资源 URL 需鉴权,改为用生成账号 token 取流;adobe/runway 公开 URL 直代理(不存 RustFS)
- size→分辨率用短边判定(1280x720 = 720p,之前误判 1080p 被拒)

失败处理:
- grok 429「Too many requests」/403 anti-bot 改判临时错误(不再误封号),真额度耗尽才算 quota
- adobe 视频 408 / system under load 归为临时错误 → tempAsDead 封号

其它:
- 充值默认关闭;签到格子浅色可见;登录验证码按钮浅色可读;并发/账户信息展示
- 创作记录/画图台只显示画图台作品(排除 API);日志页 API 视频预览显示 —
- 视频去画中画/下载/投屏(全局);图片缩略图改背景图规避 Edge 视觉搜索
- 订单/兑换码/配置/日志菜单文案与图标;充值版块样式

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 01:32:33 +08:00

143 lines
3.7 KiB
Go

// Package epay implements the 易支付 mapi (API 下单) interface with MD5 signing.
// POST {api_base}/mapi → JSON {code, msg, trade_no, payurl, qrcode}.
// Docs: 请求字段 pid/type/out_trade_no/notify_url/name/money/sign/sign_type.
package epay
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
)
type Config struct {
APIBase string // 易支付 API 根地址,如 https://pay.v8jisu.cn/api/pay(自动拼 /mapi)
PID string // 商户ID
Key string // 商户密钥
}
type CreateRequest struct {
OutTradeNo string
Type string // wxpay | alipay | unionpay
Name string
Money string // "10.00"
NotifyURL string
ReturnURL string
ClientIP string // unused by mapi; kept for caller convenience
}
type CreateResult struct {
TradeNo string // 平台订单号
PayType string // qrcode | jump
PayInfo string // 二维码内容 或 跳转 url
}
// mapiResp is the raw mapi response. code: 1 成功, -1 失败.
type mapiResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
TradeNo string `json:"trade_no"`
PayURL string `json:"payurl"`
QRCode string `json:"qrcode"`
}
var httpClient = &http.Client{Timeout: 20 * time.Second}
// sign builds the MD5 signature: take all params except sign/sign_type and empty
// values, sort keys ASCII-ascending, join as k=v&k=v (raw values), append the
// merchant key, MD5, lowercase hex.
func sign(params map[string]string, key string) string {
keys := make([]string, 0, len(params))
for k, v := range params {
if k == "sign" || k == "sign_type" || v == "" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for i, k := range keys {
if i > 0 {
b.WriteByte('&')
}
b.WriteString(k)
b.WriteByte('=')
b.WriteString(params[k])
}
b.WriteString(key)
sum := md5.Sum([]byte(b.String()))
return hex.EncodeToString(sum[:])
}
// Create places a mapi order and returns the payment info (qrcode preferred).
func (c *Config) Create(ctx context.Context, req CreateRequest) (*CreateResult, error) {
params := map[string]string{
"pid": c.PID,
"type": req.Type,
"out_trade_no": req.OutTradeNo,
"notify_url": req.NotifyURL,
"name": req.Name,
"money": req.Money,
"sign_type": "MD5",
}
if req.ReturnURL != "" {
params["return_url"] = req.ReturnURL
}
params["sign"] = sign(params, c.Key)
form := url.Values{}
for k, v := range params {
form.Set(k, v)
}
endpoint := strings.TrimRight(c.APIBase, "/") + "/mapi"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(httpReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var out mapiResp
if err := json.Unmarshal(body, &out); err != nil {
return nil, fmt.Errorf("epay: bad response: %s", strings.TrimSpace(string(body)))
}
if out.Code != 1 {
msg := out.Msg
if msg == "" {
msg = "下单失败"
}
return nil, fmt.Errorf("epay: %s", msg)
}
result := &CreateResult{TradeNo: out.TradeNo}
if out.QRCode != "" {
result.PayType = "qrcode"
result.PayInfo = out.QRCode
} else if out.PayURL != "" {
result.PayType = "jump"
result.PayInfo = out.PayURL
} else {
return nil, fmt.Errorf("epay: 响应缺少 qrcode/payurl")
}
return result, nil
}
// VerifyNotify validates an async-notify callback's MD5 signature.
func (c *Config) VerifyNotify(params map[string]string) bool {
got := strings.TrimSpace(params["sign"])
if got == "" {
return false
}
return strings.EqualFold(got, sign(params, c.Key))
}