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>
This commit is contained in:
@@ -604,7 +604,12 @@ func (c *Client) submitVideo(ctx context.Context, client tlsclient.HttpClient, t
|
||||
// it's a bad token, a missing scope, or a WAF/fingerprint block.
|
||||
return respBody, "", fmt.Errorf("%w (%d %s: %s)", ErrAuth, resp.StatusCode, resp.Header.Get("x-access-error"), clip(respBody, 300))
|
||||
}
|
||||
if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
|
||||
if resp.StatusCode == 408 || resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
|
||||
return respBody, "", ErrTemporaryUpstream
|
||||
}
|
||||
// "system under load" / timeout_error = adobe overload — treat as a temporary
|
||||
// error so the tempAsDead policy retires the account (same as the image path).
|
||||
if b := string(respBody); strings.Contains(b, "system under load") || strings.Contains(b, "timeout_error") {
|
||||
return respBody, "", ErrTemporaryUpstream
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// 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))
|
||||
}
|
||||
@@ -224,6 +224,46 @@ func (c *Client) doPost(ctx context.Context, client tlsclient.HttpClient, token,
|
||||
return raw, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// OpenAsset streams a grok asset (e.g. a generated video) authenticated with the
|
||||
// account token — used by the async /v1/videos /content proxy. The caller MUST
|
||||
// close the returned ReadCloser.
|
||||
func (c *Client) OpenAsset(ctx context.Context, token, url string) (io.ReadCloser, string, error) {
|
||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
||||
if token == "" {
|
||||
return nil, "", ErrAuth
|
||||
}
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"user-agent": {userAgent},
|
||||
"referer": {origin + "/"},
|
||||
"cookie": {"sso=" + token + "; sso-rw=" + token},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
||||
return nil, "", fmt.Errorf("%w: asset %d", ErrAuth, resp.StatusCode)
|
||||
}
|
||||
return nil, "", fmt.Errorf("%w: asset %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if ct == "" {
|
||||
ct = "video/mp4"
|
||||
}
|
||||
return resp.Body, ct, nil
|
||||
}
|
||||
|
||||
func (c *Client) download(ctx context.Context, client tlsclient.HttpClient, token, url string) ([]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
@@ -258,10 +298,20 @@ func mapStatus(path string, status int, raw []byte) error {
|
||||
switch {
|
||||
case status == 200:
|
||||
return nil
|
||||
case status == 403 && strings.Contains(strings.ToLower(string(raw)), "anti-bot"):
|
||||
// grok bot-detection (proxy/TLS fingerprint), NOT a dead token — transient,
|
||||
// so a good account isn't killed by an IP/anti-bot hiccup.
|
||||
return fmt.Errorf("%w: %s 403 %s", ErrTemporaryUpstream, path, clip(raw, 160))
|
||||
case status == 401 || status == 403:
|
||||
return fmt.Errorf("%w: %s %d %s", ErrAuth, path, status, clip(raw, 160))
|
||||
case status == 429:
|
||||
return fmt.Errorf("%w: %s 429 %s", ErrQuotaExhausted, path, clip(raw, 160))
|
||||
// 429 is grok RATE-LIMITING ("Too many requests") — a transient error that
|
||||
// must NOT kill the account. Only a body that names a credit/usage-pool
|
||||
// exhaustion is a real quota wall.
|
||||
if isCreditError(string(raw)) {
|
||||
return fmt.Errorf("%w: %s 429 %s", ErrQuotaExhausted, path, clip(raw, 160))
|
||||
}
|
||||
return fmt.Errorf("%w: %s 429 %s", ErrTemporaryUpstream, path, clip(raw, 160))
|
||||
case status >= 500:
|
||||
return fmt.Errorf("%w: %s %d %s", ErrTemporaryUpstream, path, status, clip(raw, 160))
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user