Initial commit

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 10:47:18 +08:00
co-authored by Claude Opus 4.7
commit 79ac89b458
708 changed files with 151634 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
package payCenter
import (
"crypto/md5"
"crypto/subtle"
"encoding/hex"
"errors"
"net/url"
"strconv"
"strings"
"time"
)
// Conf 与 girl 的 newPayCenter 配置保持一致,App 和 Web 各自加载本进程配置。
type Conf struct {
AppName string `yaml:"appName"`
CallbackUrl string `yaml:"callbackUrl"`
ApiUrl string `yaml:"apiUrl"`
MercID string `yaml:"mercID"`
MercSecret string `yaml:"mercSecret"`
}
var payCenterConf Conf
func Init(c Conf) {
c.ApiUrl = strings.TrimRight(strings.TrimSpace(c.ApiUrl), "/")
c.CallbackUrl = strings.TrimRight(strings.TrimSpace(c.CallbackUrl), "/")
payCenterConf = c
}
// validateConfig 仅在支付请求时校验,配置暂未填写不影响其他业务启动。
func validateConfig(requireCallback bool) error {
if payCenterConf.MercID == "" || payCenterConf.MercSecret == "" || !validHTTPURL(payCenterConf.ApiUrl) {
return errors.New("新支付中心配置不完整:请配置 newPayCenter.apiUrl、mercID、mercSecret")
}
if requireCallback && (payCenterConf.AppName == "" || !validHTTPURL(payCenterConf.CallbackUrl)) {
return errors.New("新支付中心配置不完整:请配置 newPayCenter.appName、callbackUrl")
}
return nil
}
func validHTTPURL(value string) bool {
u, err := url.Parse(value)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" && u.User == nil && u.RawQuery == "" && u.Fragment == ""
}
func Generate13DigitString() string {
return strconv.FormatInt(time.Now().UnixMilli(), 10)
}
func md5Sign(value string) string {
hash := md5.Sum([]byte(value))
return hex.EncodeToString(hash[:])
}
func VerifySign(sign, value string) bool {
return subtle.ConstantTimeCompare([]byte(md5Sign(value)), []byte(sign)) == 1
}
func RChgIDDisassemble(transNo string) string {
parts := strings.Split(transNo, "-")
return parts[len(parts)-1]
}
+103
View File
@@ -0,0 +1,103 @@
package payCenter
import (
"context"
"errors"
"fmt"
"math/big"
"strconv"
"strings"
"videoweb/pub/common/log"
)
// FenToYuan 用整数拆分分/元,避免浮点运算改变下单金额及签名原文。
func FenToYuan(price int64) string {
amount := big.NewInt(price)
negative := amount.Sign() < 0
amount.Abs(amount)
yuan := new(big.Int)
fen := new(big.Int)
yuan.QuoRem(amount, big.NewInt(100), fen)
result := yuan.String()
if fen.Sign() != 0 {
result += fmt.Sprintf(".%02d", fen.Int64())
}
if negative {
return "-" + result
}
return result
}
func YuanToFen(ctx context.Context, money string) int64 {
result, err := ParseYuanToFen(money)
if err != nil {
log.Error("【新支付中心金额转换】元转分失败", log.E(err))
}
return result
}
// ParseYuanToFen 只接受十进制金额和最多两位小数,拒绝指数、内嵌符号和溢出。
func ParseYuanToFen(money string) (int64, error) {
money = strings.TrimSpace(money)
if money == "" {
return 0, errors.New("money is empty")
}
negative := strings.HasPrefix(money, "-")
if negative {
money = money[1:]
if money == "" {
return 0, errors.New("money is empty")
}
}
parts := strings.Split(money, ".")
if len(parts) > 2 {
return 0, fmt.Errorf("invalid money format %q", money)
}
if strings.ReplaceAll(money, ".", "") == "" {
return 0, errors.New("money has no digits")
}
for _, part := range parts {
for _, digit := range part {
if digit < '0' || digit > '9' {
return 0, errors.New("money must contain only decimal digits")
}
}
}
if parts[0] == "" {
parts[0] = "0"
}
yuan, ok := new(big.Int).SetString(parts[0], 10)
if !ok {
return 0, fmt.Errorf("invalid yuan amount %q", money)
}
decimalPart := "00"
if len(parts) == 2 {
decimalPart = parts[1]
if len(decimalPart) > 2 {
return 0, fmt.Errorf("money has more than two decimal places: %q", money)
}
if len(decimalPart) == 0 {
decimalPart = "00"
} else if len(decimalPart) == 1 {
decimalPart += "0"
}
}
cents, err := strconv.ParseInt(decimalPart, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid fen amount %q: %w", money, err)
}
total := new(big.Int).Mul(yuan, big.NewInt(100))
total.Add(total, big.NewInt(cents))
if negative {
total.Neg(total)
}
if !total.IsInt64() {
return 0, fmt.Errorf("money overflows int64 fen: %q", money)
}
return total.Int64(), nil
}
+173
View File
@@ -0,0 +1,173 @@
package payCenter
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"videoweb/pub/common/httputil"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
)
// PayTypeInfo 支付中心返回的支付方式,名称优先使用本地配置。
type PayTypeInfo struct {
Type string
TypeName string
}
const payCenterErrorBodyLogLimit = 4096
type GainPayTypeReq struct {
Ctx context.Context `json:"-"`
Time string `json:"time"`
Sign string `json:"sign"`
MercID string `json:"mercId"`
Money []string `json:"money"`
Mode string `json:"mode,omitempty"`
Payload string `json:"payload,omitempty"`
}
type AllPayType struct {
Money string `json:"money"`
Types []mercPayTypeInfo `json:"types"`
}
type mercPayTypeInfo struct {
Name string `json:"name"`
Type string `json:"type"`
}
type GainPayTypeResp struct {
Code int `json:"code"`
Info string `json:"info"`
Err string `json:"err"`
Msg []AllPayType `json:"msg"`
Tip string `json:"tip"`
}
type AllPayTypeMgr []AllPayType
// GetPayType 保留支付中心返回的全部支付类型和名称,不再限定为支付宝/微信。
func (a AllPayTypeMgr) GetPayType(money int64) []PayTypeInfo {
result := make([]PayTypeInfo, 0)
for _, item := range a {
itemMoney, err := ParseYuanToFen(item.Money)
if err != nil || itemMoney != money {
continue
}
for _, payType := range item.Types {
result = append(result, PayTypeInfo{
Type: payType.Type,
TypeName: payTypeDisplayName(payType.Type, payType.Name),
})
}
}
return result
}
func NewPayTypeReq(ctx context.Context, moneys []int64) *GainPayTypeReq {
moneyStrings := make([]string, 0, len(moneys))
for _, money := range moneys {
moneyStrings = append(moneyStrings, FenToYuan(money))
}
return &GainPayTypeReq{Ctx: ctx, Money: moneyStrings}
}
func (in *GainPayTypeReq) GetPayType() (AllPayTypeMgr, error) {
if in == nil {
return nil, errors.New("pay center get pay type: nil request")
}
if err := validateConfig(false); err != nil {
return nil, err
}
if len(in.Money) == 0 {
return AllPayTypeMgr{}, nil
}
return in.getPayTypeWithCache()
}
// GetPayChannelByMoney 供会员卡、金币、钻石和促销入口共用,统一转换业务错误。
func GetPayChannelByMoney(ctx context.Context, moneys []int64) (AllPayTypeMgr, *srvdef.SrvErr) {
channels, err := NewPayTypeReq(ctx, moneys).GetPayType()
if err != nil {
log.Error("【新支付中心 /api/shark/getPayType】查询支付通道失败", log.Any("amounts", moneys), log.E(err))
return nil, srvdef.New(srvdef.Err, "支付通道暂不可用", err.Error())
}
return channels, nil
}
func (in *GainPayTypeReq) getPayTypeFromProvider() (AllPayTypeMgr, error) {
in.fill()
resp, err := httputil.NewCtx(in.Ctx).POSTJson(in.GetURL(), nil, in)
if err != nil {
log.Error("【新支付中心 /api/shark/getPayType】请求失败", log.E(err))
return nil, fmt.Errorf("get pay type: %w", err)
}
defer resp.Body.Body.Close()
body, err := resp.Body.ReadAll()
if err != nil {
log.Error("【新支付中心 /api/shark/getPayType】读取响应失败",
log.Any("httpStatus", resp.StatusCode),
log.Any("contentType", resp.Body.Header.Get("Content-Type")),
log.E(err),
)
return nil, fmt.Errorf("get pay type: read response: %w", err)
}
bodyForLog := body
truncated := false
if len(bodyForLog) > payCenterErrorBodyLogLimit {
bodyForLog = bodyForLog[:payCenterErrorBodyLogLimit]
truncated = true
}
responseMeta := func() string {
return fmt.Sprintf("HTTP status %d, content-type %q, body %q, body-truncated %t",
resp.StatusCode,
resp.Body.Header.Get("Content-Type"),
string(bodyForLog),
truncated,
)
}
logInvalidResponse := func(message string, responseErr error) {
fields := []log.Field{
log.Any("httpStatus", resp.StatusCode),
log.Any("contentType", resp.Body.Header.Get("Content-Type")),
log.Any("responseBody", string(bodyForLog)),
log.Any("bodyTruncated", truncated),
}
if responseErr != nil {
fields = append(fields, log.E(responseErr))
}
log.Error(message, fields...)
}
if resp.StatusCode != http.StatusOK {
logInvalidResponse("【新支付中心 /api/shark/getPayType】HTTP状态异常", nil)
return nil, fmt.Errorf("get pay type: %s", responseMeta())
}
var response GainPayTypeResp
if err := json.Unmarshal(body, &response); err != nil {
logInvalidResponse("【新支付中心 /api/shark/getPayType】响应JSON解析失败", err)
return nil, fmt.Errorf("get pay type: decode response: %w; %s", err, responseMeta())
}
if response.Code != http.StatusOK || response.Err != "" {
logInvalidResponse("【新支付中心 /api/shark/getPayType】支付中心返回失败", nil)
return nil, fmt.Errorf("get pay type: provider code %d; %s", response.Code, responseMeta())
}
return AllPayTypeMgr(response.Msg), nil
}
func (in *GainPayTypeReq) fill() {
in.MercID = payCenterConf.MercID
in.Time = Generate13DigitString()
in.Sign = md5Sign(in.MercID + strings.Join(in.Money, "") + payCenterConf.MercSecret)
}
func (in *GainPayTypeReq) GetURL() string {
return payCenterConf.ApiUrl + "/api/shark/getPayType"
}
+108
View File
@@ -0,0 +1,108 @@
package payCenter
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"slices"
"sort"
"strings"
"time"
"videoweb/pub/common/log"
"videoweb/pub/redis"
"golang.org/x/sync/singleflight"
)
var payTypeRequests singleflight.Group
// 通道按商户、支付中心和金额组合缓存一分钟;缓存异常直接查询支付中心。
const payTypesCacheExpire = time.Minute
func (in *GainPayTypeReq) getPayTypeWithCache() (AllPayTypeMgr, error) {
if in.Ctx == nil || redis.Client() == nil {
return in.getPayTypeFromProvider()
}
cacheKey := in.payTypeCacheKey()
if payTypes, found := loadCachedPayTypes(in, cacheKey); found {
return payTypes, nil
}
resultCh := payTypeRequests.DoChan(cacheKey, func() (any, error) {
if payTypes, found := loadCachedPayTypes(in, cacheKey); found {
return payTypes, nil
}
payTypes, err := in.getPayTypeFromProvider()
if err != nil {
return nil, err
}
storeCachedPayTypes(in, cacheKey, payTypes)
return payTypes, nil
})
select {
case <-in.Ctx.Done():
return nil, in.Ctx.Err()
case result := <-resultCh:
if result.Err != nil {
if result.Shared {
return in.getPayTypeFromProvider()
}
return nil, result.Err
}
payTypes, ok := result.Val.(AllPayTypeMgr)
if !ok {
return nil, fmt.Errorf("pay center cached pay type has unexpected result type %T", result.Val)
}
return payTypes, nil
}
}
func (in *GainPayTypeReq) payTypeCacheKey() string {
moneys := slices.Clone(in.Money)
sort.Strings(moneys)
moneys = slices.Compact(moneys)
fingerprint := strings.Join([]string{
payCenterConf.ApiUrl,
payCenterConf.MercID,
in.Mode,
in.Payload,
strings.Join(moneys, ","),
}, "\x00")
digest := sha256.Sum256([]byte(fingerprint))
return "videoweb:newpaycenter:pay-types:v1:" + hex.EncodeToString(digest[:])
}
func loadCachedPayTypes(in *GainPayTypeReq, cacheKey string) (AllPayTypeMgr, bool) {
payload, err := redis.Get(cacheKey)
if err != nil {
log.Error("【新支付中心 /api/shark/getPayType】读取通道缓存失败", log.E(err))
return nil, false
}
if payload == nil {
return nil, false
}
var payTypes AllPayTypeMgr
if err := json.Unmarshal([]byte(*payload), &payTypes); err != nil {
log.Error("【新支付中心 /api/shark/getPayType】解析通道缓存失败", log.E(err))
return nil, false
}
return payTypes, true
}
func storeCachedPayTypes(in *GainPayTypeReq, cacheKey string, payTypes AllPayTypeMgr) {
payload, err := json.Marshal(payTypes)
if err != nil {
log.Error("【新支付中心 /api/shark/getPayType】编码通道缓存失败", log.E(err))
return
}
if err := redis.Set(cacheKey, payload, payTypesCacheExpire); err != nil {
log.Error("【新支付中心 /api/shark/getPayType】写入通道缓存失败", log.E(err))
}
}
+32
View File
@@ -0,0 +1,32 @@
package payCenter
import (
"strings"
"sync"
)
var payTypeNames struct {
sync.RWMutex
alipay string
wechat string
}
// SetPayTypeNames 保留 KS 后台已配置的支付名称;未配置时采用新支付中心返回的名称。
func SetPayTypeNames(alipay, wechat string) {
payTypeNames.Lock()
defer payTypeNames.Unlock()
payTypeNames.alipay = strings.TrimSpace(alipay)
payTypeNames.wechat = strings.TrimSpace(wechat)
}
func payTypeDisplayName(kind, providerName string) string {
payTypeNames.RLock()
defer payTypeNames.RUnlock()
if kind == "alipay" && payTypeNames.alipay != "" {
return payTypeNames.alipay
}
if kind == "wechat" && payTypeNames.wechat != "" {
return payTypeNames.wechat
}
return providerName
}
+58
View File
@@ -0,0 +1,58 @@
package payCenter
import (
"context"
"errors"
"fmt"
"videoweb/pub/common/httputil"
"videoweb/pub/common/log"
)
type RechargeQueryResp struct {
Code int `json:"code"`
Msg QueryOrderInfo `json:"msg"`
}
type QueryOrderInfo struct {
MercID string `json:"mercID"`
TradeNo string `json:"tradeNo"`
Money float32 `json:"money"`
PayMoney float32 `json:"payMoney"`
PayTime string `json:"payTime"`
PayStatus string `json:"payStatus"`
NotifyStatus string `json:"notifyStatus"`
}
func (in *Recharge) GetQueryURL() string {
return payCenterConf.ApiUrl + "/api/shark/order/queryOrder"
}
func (in *Recharge) QueryOrder(ctx context.Context) (QueryOrderInfo, error) {
if in == nil {
return QueryOrderInfo{}, errors.New("query pay center order: nil recharge")
}
if err := validateConfig(false); err != nil {
return QueryOrderInfo{}, err
}
if in.TradeNo == "" {
return QueryOrderInfo{}, errors.New("新支付中心查单缺少订单号")
}
var response RechargeQueryResp
body := map[string]string{
"mercId": in.GetMercID(),
"tradeNo": in.TradeNo,
}
code, err := httputil.NewCtx(ctx).GetWithJResp(&response, in.GetQueryURL(), nil, body)
if err != nil {
log.Error("【新支付中心 /api/shark/order/queryOrder】查单失败", log.Any("tradeNo", in.TradeNo), log.E(err))
return QueryOrderInfo{}, fmt.Errorf("query pay center order: %w", err)
}
if code != 200 {
return QueryOrderInfo{}, fmt.Errorf("query pay center order: HTTP status %d", code)
}
if response.Code != 200 {
return QueryOrderInfo{}, fmt.Errorf("query pay center order: provider code %d", response.Code)
}
return response.Msg, nil
}
+181
View File
@@ -0,0 +1,181 @@
package payCenter
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"videoweb/pub/common/httputil"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
)
// Recharge 使用新支付中心的元字符串金额和商户 MD5 签名协议。
type Recharge struct {
Ctx context.Context `json:"-"`
Time string `json:"time"`
Sign string `json:"sign"`
MercID string `json:"mercId"`
Type string `json:"type"`
Money string `json:"money"`
TradeNo string `json:"tradeNo"`
NotifyUrl string `json:"notifyUrl"`
CancelNotifyUrl string `json:"cancelNotifyUrl,omitempty"`
Info PayInfo `json:"info"`
SignType string `json:"signType"`
Mode string `json:"mode"`
Payload string `json:"payload"`
}
type PayInfo struct {
App string `json:"app"`
PlayerId string `json:"playerId"`
PlayerIp string `json:"playerIp"`
DeviceType string `json:"deviceType"`
DeviceId string `json:"deviceId"`
Name string `json:"name"`
Tel string `json:"tel"`
AlipayAct string `json:"alipayAct"`
}
type PayResp struct {
Code srvdef.CodeType `json:"code"`
OID string `json:"oid"`
PayUrl string `json:"payUrl"`
Mode string `json:"mode"`
Rebate string `json:"rebate"`
Sign string `json:"sign"`
}
type RechargeResp struct {
Code int `json:"code"`
Info string `json:"info"`
Err string `json:"err"`
Msg PayResp `json:"msg"`
Tip string `json:"tip"`
}
type RechargeCallbackResp struct {
Code int `json:"code" binding:"required"`
MercID string `json:"mercId" binding:"required"`
OID string `json:"oid" binding:"required"`
PayMoney string `json:"payMoney" binding:"required"`
TradeNo string `json:"tradeNo" binding:"required"`
Sign string `json:"sign" binding:"required"`
}
type RefundCallbackResp struct {
Code int `json:"code"`
MercID string `json:"mercId"`
OID string `json:"oid"`
TradeNo string `json:"tradeNo"`
Sign string `json:"sign"`
}
type rechargePostFunc func(context.Context, *Recharge, *RechargeResp) (int, error)
func (in *Recharge) ToPayNew() (PayResp, error) {
return in.toPayNew(func(ctx context.Context, request *Recharge, response *RechargeResp) (int, error) {
return httputil.NewCtx(ctx).POSTJsonWithJResp(response, request.GetURL(), nil, request)
})
}
func (in *Recharge) toPayNew(post rechargePostFunc) (result PayResp, err error) {
defer func() {
if recovered := recover(); recovered != nil {
result = PayResp{}
err = fmt.Errorf("pay center request panic: %v", recovered)
if in != nil {
log.Error("【新支付中心 /api/shark/topay】请求异常", log.Any("tradeNo", in.TradeNo), log.E(err))
}
}
if in != nil {
if err != nil {
log.Error("【新支付中心 /api/shark/topay】下单失败", log.Any("tradeNo", in.TradeNo), log.E(err))
} else {
log.Info("【新支付中心 /api/shark/topay】下单成功", log.Any("tradeNo", in.TradeNo), log.Any("oid", result.OID))
}
}
}()
if in == nil {
return result, errors.New("pay center request: nil recharge")
}
if post == nil {
return result, errors.New("pay center request: nil poster")
}
if err := validateConfig(true); err != nil {
return result, err
}
amount, err := ParseYuanToFen(in.Money)
if err != nil || amount <= 0 || in.TradeNo == "" || in.Type == "" {
return result, errors.New("新支付中心下单参数错误:订单号、支付方式和正数金额不能为空")
}
in.fill()
var response RechargeResp
code, requestErr := post(in.Ctx, in, &response)
if requestErr != nil {
return result, fmt.Errorf("post pay center order: %w", requestErr)
}
if code != http.StatusOK {
return result, fmt.Errorf("pay center returned HTTP status %d", code)
}
if response.Code != http.StatusOK {
return result, fmt.Errorf("pay center returned provider code %d", response.Code)
}
if response.Err != "" {
return result, fmt.Errorf("pay center rejected order: %s", response.Err)
}
// 外层成功但没有订单号/支付链接不能落为处理中订单。
if response.Msg.OID == "" || response.Msg.PayUrl == "" {
return result, errors.New("新支付中心下单结果缺少订单号或支付链接")
}
return response.Msg, nil
}
func (in *Recharge) fill() {
in.MercID = in.GetMercID()
in.Time = Generate13DigitString()
in.Info.App = payCenterConf.AppName
in.NotifyUrl = in.GetNotifyURL()
in.SignType = "1"
in.Sign = in.sign()
}
func (in *Recharge) GetNotifyURL() string {
return payCenterConf.CallbackUrl + "/api/web/third/newpaycenter/recharge/callback"
}
func (in *Recharge) GetCancelNotifyURL() string {
return payCenterConf.CallbackUrl + "/api/web/third/newpaycenter/refund/callback"
}
func (in *Recharge) sign() string {
// 字段顺序必须与 girl 及支付中心约定一致,金额使用原始元字符串。
var body bytes.Buffer
body.WriteString(in.MercID)
body.WriteString(in.Money)
body.WriteString(in.NotifyUrl)
body.WriteString(in.TradeNo)
body.WriteString(in.Type)
body.WriteString(in.GetAppSecret())
return md5Sign(body.String())
}
func (in *Recharge) GetURL() string {
return payCenterConf.ApiUrl + "/api/shark/topay"
}
func (in *Recharge) Success() string {
return "success"
}
func (in *Recharge) GetAppSecret() string {
return payCenterConf.MercSecret
}
func (in *Recharge) GetMercID() string {
return payCenterConf.MercID
}