充值/订单(易支付 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>
286 lines
8.8 KiB
Go
286 lines
8.8 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"backend/internal/model"
|
|
"backend/internal/provider/epay"
|
|
"backend/internal/repo"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const orderTTL = 30 * time.Minute
|
|
|
|
var (
|
|
ErrPayDisabled = errors.New("recharge is currently disabled")
|
|
ErrPayNotConfig = errors.New("payment is not configured")
|
|
ErrPayMethod = errors.New("unsupported payment method")
|
|
ErrPayAmount = errors.New("amount below the minimum")
|
|
ErrOrderNotFound = errors.New("order not found")
|
|
ErrOrderPaid = errors.New("order already paid")
|
|
)
|
|
|
|
// PaymentService drives 易支付 recharge orders: create → pay → async-notify →
|
|
// credit points. Config lives in site settings (pay.*).
|
|
type PaymentService struct {
|
|
orders *repo.OrderRepository
|
|
users *repo.UserRepository
|
|
settings *repo.SiteSettingRepository
|
|
}
|
|
|
|
func NewPaymentService(orders *repo.OrderRepository, users *repo.UserRepository, settings *repo.SiteSettingRepository) *PaymentService {
|
|
return &PaymentService{orders: orders, users: users, settings: settings}
|
|
}
|
|
|
|
type PaySettings struct {
|
|
Enabled bool `json:"enabled"`
|
|
PID string `json:"pid"`
|
|
Key string `json:"key"`
|
|
APIBase string `json:"api_base"` // 易支付站点根地址,代码自动拼 /api/pay/create
|
|
Methods []string `json:"methods"` // wxpay, alipay
|
|
MinAmount float64 `json:"min_amount"`
|
|
PointsRatio int `json:"points_ratio"` // 积分 per 元
|
|
}
|
|
|
|
func (s *PaymentService) get(ctx context.Context, key string) string {
|
|
v, _ := s.settings.GetValue(ctx, key)
|
|
return v
|
|
}
|
|
|
|
func (s *PaymentService) Settings(ctx context.Context) PaySettings {
|
|
api := strings.TrimRight(strings.TrimSpace(s.get(ctx, "pay.api_base")), "/")
|
|
if api == "" {
|
|
api = "https://pay.v8jisu.cn/api/pay"
|
|
}
|
|
ratio, _ := strconv.Atoi(s.get(ctx, "pay.points_ratio"))
|
|
if ratio <= 0 {
|
|
ratio = 100
|
|
}
|
|
minAmt, _ := strconv.ParseFloat(s.get(ctx, "pay.min_amount"), 64)
|
|
var methods []string
|
|
for _, m := range strings.Split(s.get(ctx, "pay.methods"), ",") {
|
|
if m = strings.TrimSpace(m); m != "" {
|
|
methods = append(methods, m)
|
|
}
|
|
}
|
|
if len(methods) == 0 {
|
|
methods = []string{"wxpay", "alipay"}
|
|
}
|
|
return PaySettings{
|
|
Enabled: s.get(ctx, "pay.enabled") == "true",
|
|
PID: s.get(ctx, "pay.pid"),
|
|
Key: s.get(ctx, "pay.key"),
|
|
APIBase: api,
|
|
Methods: methods,
|
|
MinAmount: minAmt,
|
|
PointsRatio: ratio,
|
|
}
|
|
}
|
|
|
|
// Public returns the recharge config for the user UI (no merchant secrets).
|
|
func (s *PaymentService) Public(ctx context.Context) map[string]any {
|
|
c := s.Settings(ctx)
|
|
return map[string]any{
|
|
"enabled": c.Enabled,
|
|
"methods": c.Methods,
|
|
"min_amount": c.MinAmount,
|
|
"points_ratio": c.PointsRatio,
|
|
}
|
|
}
|
|
|
|
func (s *PaymentService) SaveSettings(ctx context.Context, in PaySettings) error {
|
|
api := strings.TrimRight(strings.TrimSpace(in.APIBase), "/")
|
|
pid := strings.TrimSpace(in.PID)
|
|
key := strings.TrimSpace(in.Key)
|
|
if api == "" {
|
|
return errors.New("支付地址不能为空")
|
|
}
|
|
if pid == "" {
|
|
return errors.New("商户ID不能为空")
|
|
}
|
|
if key == "" {
|
|
return errors.New("商户密钥不能为空")
|
|
}
|
|
if in.PointsRatio <= 0 {
|
|
in.PointsRatio = 100
|
|
}
|
|
if in.MinAmount < 0 {
|
|
in.MinAmount = 0
|
|
}
|
|
return s.settings.UpsertValues(ctx, map[string]string{
|
|
"pay.enabled": strconv.FormatBool(in.Enabled),
|
|
"pay.pid": pid,
|
|
"pay.key": key,
|
|
"pay.api_base": api,
|
|
"pay.methods": strings.Join(in.Methods, ","),
|
|
"pay.min_amount": strconv.FormatFloat(in.MinAmount, 'f', -1, 64),
|
|
"pay.points_ratio": strconv.Itoa(in.PointsRatio),
|
|
})
|
|
}
|
|
|
|
func (c PaySettings) allows(method string) bool {
|
|
for _, m := range c.Methods {
|
|
if m == method {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *PaymentService) client(c PaySettings) *epay.Config {
|
|
return &epay.Config{APIBase: c.APIBase, PID: c.PID, Key: c.Key}
|
|
}
|
|
|
|
// CreateOrder validates the request, persists a pending order, and places the
|
|
// 易支付 order. notifyBase is the public site origin (e.g. https://vividai.run).
|
|
func (s *PaymentService) CreateOrder(ctx context.Context, user *model.User, amount float64, method, notifyBase, clientIP string) (*model.Order, error) {
|
|
cfg := s.Settings(ctx)
|
|
if !cfg.Enabled {
|
|
return nil, ErrPayDisabled
|
|
}
|
|
if cfg.PID == "" || cfg.Key == "" {
|
|
return nil, ErrPayNotConfig
|
|
}
|
|
if !cfg.allows(method) {
|
|
return nil, ErrPayMethod
|
|
}
|
|
amount = math.Round(amount*100) / 100
|
|
if amount <= 0 || amount < cfg.MinAmount {
|
|
return nil, ErrPayAmount
|
|
}
|
|
points := int(math.Round(amount * float64(cfg.PointsRatio)))
|
|
now := time.Now()
|
|
order := &model.Order{
|
|
ID: "P" + strconv.FormatInt(now.Unix(), 10) + randomUpper(6),
|
|
UserID: user.ID,
|
|
Amount: amount,
|
|
Points: points,
|
|
PayType: method,
|
|
Status: "pending",
|
|
ExpiresAt: now.Add(orderTTL),
|
|
}
|
|
if err := s.orders.Create(ctx, order); err != nil {
|
|
return nil, err
|
|
}
|
|
res, err := s.client(cfg).Create(ctx, epay.CreateRequest{
|
|
OutTradeNo: order.ID,
|
|
Type: method,
|
|
Name: fmt.Sprintf("积分充值 %d", points),
|
|
Money: strconv.FormatFloat(amount, 'f', 2, 64),
|
|
NotifyURL: strings.TrimRight(notifyBase, "/") + "/admin/api/pay/notify",
|
|
ClientIP: clientIP,
|
|
})
|
|
if err != nil {
|
|
_ = s.orders.Update(ctx, order.ID, map[string]any{"status": "cancelled"})
|
|
return nil, err
|
|
}
|
|
order.TradeNo = res.TradeNo
|
|
order.PayInfo = res.PayInfo
|
|
order.PayInfoType = res.PayType
|
|
_ = s.orders.Update(ctx, order.ID, map[string]any{
|
|
"trade_no": res.TradeNo, "pay_info": res.PayInfo, "pay_info_type": res.PayType,
|
|
})
|
|
return order, nil
|
|
}
|
|
|
|
// Continue re-creates payment for an unpaid order (clones its amount/method into
|
|
// a fresh order — keeps the old one as history). Used by 订单管理 "继续支付".
|
|
func (s *PaymentService) Continue(ctx context.Context, user *model.User, orderID, notifyBase, clientIP string) (*model.Order, error) {
|
|
old, err := s.orders.Get(ctx, orderID)
|
|
if err != nil || old == nil || old.UserID != user.ID {
|
|
return nil, ErrOrderNotFound
|
|
}
|
|
if old.Status == "paid" {
|
|
return nil, ErrOrderPaid
|
|
}
|
|
// Reuse a still-valid pending order — return its saved qrcode/payurl directly,
|
|
// no new upstream order (and the countdown keeps its original expiry).
|
|
if old.Status == "pending" && old.PayInfo != "" && time.Now().Before(old.ExpiresAt) {
|
|
return old, nil
|
|
}
|
|
// Expired / cancelled (or never got a pay_info) → place a fresh order.
|
|
return s.CreateOrder(ctx, user, old.Amount, old.PayType, notifyBase, clientIP)
|
|
}
|
|
|
|
// HandleNotify processes an 易支付 async callback. Returns true when this call is
|
|
// the one that credited the user (first successful notify for the order).
|
|
func (s *PaymentService) HandleNotify(ctx context.Context, params map[string]string) (bool, error) {
|
|
cfg := s.Settings(ctx)
|
|
if cfg.Key == "" {
|
|
return false, ErrPayNotConfig
|
|
}
|
|
if !s.client(cfg).VerifyNotify(params) {
|
|
return false, errors.New("bad sign")
|
|
}
|
|
if params["trade_status"] != "TRADE_SUCCESS" {
|
|
return false, nil
|
|
}
|
|
orderID := params["out_trade_no"]
|
|
order, err := s.orders.Get(ctx, orderID)
|
|
if err != nil || order == nil {
|
|
return false, ErrOrderNotFound
|
|
}
|
|
credited, err := s.orders.MarkPaid(ctx, order.ID, params["trade_no"], time.Now())
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !credited {
|
|
return false, nil // duplicate / already handled
|
|
}
|
|
// Credit points + bump the user's cumulative recharge total, atomically.
|
|
_, err = s.users.Update(ctx, order.UserID, map[string]any{
|
|
"credits": gorm.Expr("credits + ?", order.Points),
|
|
"recharge_total": gorm.Expr("recharge_total + ?", order.Amount),
|
|
})
|
|
return err == nil, err
|
|
}
|
|
|
|
func (s *PaymentService) GetForUser(ctx context.Context, userID, orderID string) (*model.Order, error) {
|
|
o, err := s.orders.Get(ctx, orderID)
|
|
if err != nil || o == nil || o.UserID != userID {
|
|
return nil, ErrOrderNotFound
|
|
}
|
|
return o, nil
|
|
}
|
|
|
|
func (s *PaymentService) ListByUser(ctx context.Context, userID, status string, limit, offset int) ([]model.Order, int64, error) {
|
|
return s.orders.ListByUser(ctx, userID, status, limit, offset)
|
|
}
|
|
|
|
func (s *PaymentService) ListAll(ctx context.Context, status string, limit, offset int) ([]model.Order, int64, error) {
|
|
return s.orders.List(ctx, status, limit, offset)
|
|
}
|
|
|
|
// UserNames maps user id → display name (name, else email, else id) so the admin
|
|
// 订单管理 list can show 用户名.
|
|
func (s *PaymentService) UserNames(ctx context.Context) map[string]string {
|
|
out := map[string]string{}
|
|
users, err := s.users.List(ctx)
|
|
if err != nil {
|
|
return out
|
|
}
|
|
for _, u := range users {
|
|
n := u.Name
|
|
if n == "" {
|
|
n = u.Email
|
|
}
|
|
if n == "" {
|
|
n = u.ID
|
|
}
|
|
out[u.ID] = n
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ExpireStale cancels pending orders past their TTL (called by the maintenance
|
|
// sweep). Returns how many were cancelled.
|
|
func (s *PaymentService) ExpireStale(ctx context.Context) (int64, error) {
|
|
return s.orders.ExpirePending(ctx, time.Now())
|
|
}
|