refactor: 虎皮棋适配器内联 payment_order_service + member 服务拆 4 表文件 + file_storage 移入 common
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
package service
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -6,10 +6,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
commonHttp "slogan-agent/common"
|
||||
"slogan-agent/styleagent/dao"
|
||||
"slogan-agent/styleagent/model/dto"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
"slogan-agent/styleagent/payment"
|
||||
"slogan-agent/styleagent/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
@@ -22,7 +19,7 @@ var Member = new(member)
|
||||
|
||||
// PlanList 会员套餐列表
|
||||
func (c *member) PlanList(ctx context.Context, req *dto.MemberPlanListReq) (res *dto.MemberPlanListRes, err error) {
|
||||
list, err := service.MemberService.PlanList(ctx)
|
||||
list, err := service.MemberPlanService.PlanList(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -31,7 +28,7 @@ func (c *member) PlanList(ctx context.Context, req *dto.MemberPlanListReq) (res
|
||||
|
||||
// Status 我的会员状态
|
||||
func (c *member) Status(ctx context.Context, req *dto.MemberStatusReq) (res *dto.MemberStatusRes, err error) {
|
||||
st, err := service.MemberService.Status(ctx, commonHttp.GetUserId(g.RequestFromCtx(ctx)))
|
||||
st, err := service.MemberPlanService.Status(ctx, commonHttp.GetUserId(g.RequestFromCtx(ctx)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -42,7 +39,7 @@ func (c *member) Status(ctx context.Context, req *dto.MemberStatusReq) (res *dto
|
||||
|
||||
// OrderCreate 下单 → 返回支付 URL
|
||||
func (c *member) OrderCreate(ctx context.Context, req *dto.MemberOrderCreateReq) (res *dto.MemberOrderCreateRes, err error) {
|
||||
order, payURL, err := service.MemberService.CreateOrder(ctx, commonHttp.GetUserId(g.RequestFromCtx(ctx)), req.PlanId)
|
||||
order, payURL, err := service.PaymentOrderService.CreateMemberOrder(ctx, commonHttp.GetUserId(g.RequestFromCtx(ctx)), req.PlanId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -51,7 +48,7 @@ func (c *member) OrderCreate(ctx context.Context, req *dto.MemberOrderCreateReq)
|
||||
|
||||
// OrderStatus 订单状态(App 轮询)
|
||||
func (c *member) OrderStatus(ctx context.Context, req *dto.MemberOrderStatusReq) (res *dto.MemberOrderStatusRes, err error) {
|
||||
order, err := service.MemberService.OrderStatus(ctx, req.OrderNo)
|
||||
order, err := service.PaymentOrderService.OrderStatus(ctx, req.OrderNo)
|
||||
if err != nil || order == nil {
|
||||
return nil, errors.New("订单不存在")
|
||||
}
|
||||
@@ -75,21 +72,17 @@ func MemberNotify(r *ghttp.Request) {
|
||||
for k, v := range r.GetRequestMap() {
|
||||
params[k] = fmt.Sprint(v)
|
||||
}
|
||||
ok := payment.VerifyNotify(params, hash, g.Cfg().MustGet(ctx, "payment.xunhu_appsecret", "").String())
|
||||
ok := service.PaymentOrderService.VerifyNotify(params, hash, g.Cfg().MustGet(ctx, "payment.xunhu_appsecret", "").String())
|
||||
|
||||
if !ok {
|
||||
_ = dao.PayNotifyLog.Insert(ctx, &entity.PayNotifyLog{
|
||||
OrderNo: orderNo, Body: body, Sign: hash, RemoteIp: remoteIP, Status: "bad_sign",
|
||||
})
|
||||
_ = service.PayNotifyLogService.Insert(ctx, orderNo, body, hash, remoteIP, "bad_sign")
|
||||
r.Response.Write("fail")
|
||||
r.ExitAll()
|
||||
return
|
||||
}
|
||||
|
||||
state, err := service.MemberService.HandlePaidNotify(ctx, orderNo, r.Get("transaction_id").String(), body)
|
||||
_ = dao.PayNotifyLog.Insert(ctx, &entity.PayNotifyLog{
|
||||
OrderNo: orderNo, Body: body, Sign: hash, RemoteIp: remoteIP, Status: state,
|
||||
})
|
||||
state, err := service.PaymentOrderService.HandlePaidNotify(ctx, orderNo, r.Get("transaction_id").String(), body)
|
||||
_ = service.PayNotifyLogService.Insert(ctx, orderNo, body, hash, remoteIP, state)
|
||||
// duplicate(幂等重复回调)同样返回 success,避免支付渠道无限重试
|
||||
if err != nil || state == "no_order" {
|
||||
r.Response.Write("fail")
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// 虎皮棋聚合支付适配器:签名/HTTP 细节全部收敛在本包,业务层不感知。
|
||||
// 注意:签名规则与字段名以官方最新文档为准(当前实现为经典 md5 约定)。
|
||||
|
||||
type Config struct {
|
||||
AppId string
|
||||
AppSecret string
|
||||
NotifyUrl string
|
||||
Channel string // 逗号分隔,如 "alipay,wechat"
|
||||
ApiBase string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
func GetConfig(ctx context.Context) Config {
|
||||
cfg := Config{
|
||||
AppId: g.Cfg().MustGet(ctx, "payment.xunhu_appid", "").String(),
|
||||
AppSecret: g.Cfg().MustGet(ctx, "payment.xunhu_appsecret", "").String(),
|
||||
NotifyUrl: g.Cfg().MustGet(ctx, "payment.notify_url", "").String(),
|
||||
Channel: g.Cfg().MustGet(ctx, "payment.channel", "alipay").String(),
|
||||
ApiBase: g.Cfg().MustGet(ctx, "payment.api_base", "https://api.xunhupay.com").String(),
|
||||
}
|
||||
cfg.Enabled = cfg.AppId != "" && cfg.AppSecret != ""
|
||||
return cfg
|
||||
}
|
||||
|
||||
// CreateOrder 创建支付单,返回收银台/支付 URL(金额单位:分)
|
||||
func CreateOrder(ctx context.Context, orderNo string, amountFen int) (payURL string, err error) {
|
||||
cfg := GetConfig(ctx)
|
||||
if !cfg.Enabled {
|
||||
return "", errors.New("支付未开通,请在 config.yml 配置 payment")
|
||||
}
|
||||
channel := "alipay"
|
||||
if first := strings.Split(cfg.Channel, ",")[0]; first != "" {
|
||||
channel = first
|
||||
}
|
||||
params := map[string]string{
|
||||
"appid": cfg.AppId,
|
||||
"trade_order_id": orderNo,
|
||||
"total_fee": fmt.Sprintf("%.2f", float64(amountFen)/100),
|
||||
"title": "形象会员",
|
||||
"notify_url": cfg.NotifyUrl,
|
||||
"type": channel,
|
||||
"version": "1.1",
|
||||
"nonce_str": nonce(),
|
||||
}
|
||||
params["hash"] = Sign(params, cfg.AppSecret)
|
||||
|
||||
var resp struct {
|
||||
Errcode int `json:"errcode"`
|
||||
Errmsg string `json:"errmsg"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
// 注意:Post 的最后一个参数不会自动解析响应体,需手动读取后反序列化
|
||||
respRaw, err := g.Client().SetTimeout(10*time.Second).Post(context.Background(), cfg.ApiBase+"/payment/do.html", params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("虎皮棋下单失败: %w", err)
|
||||
}
|
||||
defer respRaw.Close()
|
||||
if err := json.Unmarshal(respRaw.ReadAll(), &resp); err != nil {
|
||||
return "", fmt.Errorf("虎皮棋下单失败: %w", err)
|
||||
}
|
||||
if resp.Errcode != 0 {
|
||||
return "", fmt.Errorf("虎皮棋下单失败: %s", resp.Errmsg)
|
||||
}
|
||||
if resp.Url == "" {
|
||||
return "", errors.New("虎皮棋下单失败: 返回为空")
|
||||
}
|
||||
return resp.Url, nil
|
||||
}
|
||||
|
||||
// Sign 参数名升序拼接 key=value,追加 secret 后 md5 hex
|
||||
func Sign(params map[string]string, secret string) string {
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
if params[k] == "" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var sb strings.Builder
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
sb.WriteString("&")
|
||||
}
|
||||
sb.WriteString(k)
|
||||
sb.WriteString("=")
|
||||
sb.WriteString(params[k])
|
||||
}
|
||||
sb.WriteString(secret)
|
||||
sum := md5.Sum([]byte(sb.String()))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// VerifyNotify 验签:复制参数去掉 hash 后重算签名比较
|
||||
func VerifyNotify(params map[string]string, hash, secret string) bool {
|
||||
if hash == "" || secret == "" {
|
||||
return false
|
||||
}
|
||||
cp := make(map[string]string, len(params))
|
||||
for k, v := range params {
|
||||
if k != "hash" {
|
||||
cp[k] = v
|
||||
}
|
||||
}
|
||||
return Sign(cp, secret) == strings.ToLower(hash)
|
||||
}
|
||||
|
||||
func nonce() string {
|
||||
b := make([]byte, 8)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package payment
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSignDeterministic(t *testing.T) {
|
||||
params := map[string]string{
|
||||
"appid": "1000", "trade_order_id": "ORDER001", "total_fee": "29.90",
|
||||
}
|
||||
s1 := Sign(params, "secret123")
|
||||
s2 := Sign(params, "secret123")
|
||||
if s1 != s2 {
|
||||
t.Fatalf("相同参数签名应一致: %s != %s", s1, s2)
|
||||
}
|
||||
if s1 == "" {
|
||||
t.Fatal("签名不应为空")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignChangesWithSecret(t *testing.T) {
|
||||
params := map[string]string{"appid": "1000", "trade_order_id": "ORDER001"}
|
||||
if Sign(params, "a") == Sign(params, "b") {
|
||||
t.Fatal("不同 secret 签名应不同")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyNotify(t *testing.T) {
|
||||
params := map[string]string{
|
||||
"appid": "1000", "trade_order_id": "ORDER001", "total_fee": "29.90",
|
||||
"status": "OD", "hash": "",
|
||||
}
|
||||
hash := Sign(params, "secret123")
|
||||
if !VerifyNotify(params, hash, "secret123") {
|
||||
t.Fatal("正确签名应通过验签")
|
||||
}
|
||||
params["total_fee"] = "0.01"
|
||||
if VerifyNotify(params, hash, "secret123") {
|
||||
t.Fatal("篡改参数后应验签失败")
|
||||
}
|
||||
if VerifyNotify(params, hash, "wrong-secret") {
|
||||
t.Fatal("错误 secret 应验签失败")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigDisabledWhenEmpty(t *testing.T) {
|
||||
cfg := GetConfig(t.Context())
|
||||
if cfg.Enabled {
|
||||
t.Fatal("默认配置(key 为空)应 disabled")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"slogan-agent/styleagent/dao"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
)
|
||||
|
||||
type memberPlanService struct{}
|
||||
|
||||
var MemberPlanService = new(memberPlanService)
|
||||
|
||||
func (s *memberPlanService) PlanList(ctx context.Context) ([]*entity.MemberPlan, error) {
|
||||
return dao.MemberPlan.ListEnabled(ctx)
|
||||
}
|
||||
|
||||
type MemberStatus struct {
|
||||
IsVip bool `json:"is_vip"`
|
||||
ExpireAt string `json:"expire_at"`
|
||||
PlanName string `json:"plan_name"`
|
||||
Benefits []string `json:"benefits"`
|
||||
}
|
||||
|
||||
func (s *memberPlanService) Status(ctx context.Context, userId int64) (*MemberStatus, error) {
|
||||
st := &MemberStatus{Benefits: make([]string, 0)}
|
||||
um, err := dao.UserMember.GetByUser(ctx, userId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if um == nil || um.ExpireAt == nil || um.ExpireAt.Time.Before(time.Now()) {
|
||||
return st, nil
|
||||
}
|
||||
st.IsVip = true
|
||||
st.ExpireAt = um.ExpireAt.Format("Y-m-d H:i:s")
|
||||
if plan, _ := dao.MemberPlan.GetOne(ctx, um.PlanId); plan != nil {
|
||||
st.PlanName = plan.Name
|
||||
st.Benefits = parseBenefits(plan.Features)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func parseBenefits(features string) []string {
|
||||
var list []string
|
||||
_ = json.Unmarshal([]byte(features), &list)
|
||||
if list == nil {
|
||||
list = make([]string, 0)
|
||||
}
|
||||
return list
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"slogan-agent/styleagent/consts"
|
||||
"slogan-agent/styleagent/dao"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
"slogan-agent/styleagent/payment"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type memberService struct{}
|
||||
|
||||
var MemberService = new(memberService)
|
||||
|
||||
func (s *memberService) PlanList(ctx context.Context) ([]*entity.MemberPlan, error) {
|
||||
return dao.MemberPlan.ListEnabled(ctx)
|
||||
}
|
||||
|
||||
type MemberStatus struct {
|
||||
IsVip bool `json:"is_vip"`
|
||||
ExpireAt string `json:"expire_at"`
|
||||
PlanName string `json:"plan_name"`
|
||||
Benefits []string `json:"benefits"`
|
||||
}
|
||||
|
||||
func (s *memberService) Status(ctx context.Context, userId int64) (*MemberStatus, error) {
|
||||
st := &MemberStatus{Benefits: make([]string, 0)}
|
||||
um, err := dao.UserMember.GetByUser(ctx, userId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if um == nil || um.ExpireAt == nil || um.ExpireAt.Time.Before(time.Now()) {
|
||||
return st, nil
|
||||
}
|
||||
st.IsVip = true
|
||||
st.ExpireAt = um.ExpireAt.Format("Y-m-d H:i:s")
|
||||
if plan, _ := dao.MemberPlan.GetOne(ctx, um.PlanId); plan != nil {
|
||||
st.PlanName = plan.Name
|
||||
st.Benefits = parseBenefits(plan.Features)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// CreateOrder 下单:生成业务订单号 → 虎皮棋下单 → 返回支付 URL
|
||||
func (s *memberService) CreateOrder(ctx context.Context, userId, planId int64) (*entity.PaymentOrder, string, error) {
|
||||
plan, err := dao.MemberPlan.GetOne(ctx, planId)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if plan == nil {
|
||||
return nil, "", errors.New("套餐不存在")
|
||||
}
|
||||
order := &entity.PaymentOrder{
|
||||
OrderNo: fmt.Sprintf("M%d%d", time.Now().UnixNano()/1e6, userId%1000),
|
||||
UserId: userId,
|
||||
PlanId: planId,
|
||||
AmountFen: plan.PriceFen,
|
||||
Channel: "alipay",
|
||||
Status: consts.PayStatusPending,
|
||||
}
|
||||
if _, err := dao.PaymentOrder.Insert(ctx, order); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
payURL, err := payment.CreateOrder(ctx, order.OrderNo, plan.PriceFen)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return order, payURL, nil
|
||||
}
|
||||
|
||||
func (s *memberService) OrderStatus(ctx context.Context, orderNo string) (*entity.PaymentOrder, error) {
|
||||
return dao.PaymentOrder.GetByOrderNo(ctx, orderNo)
|
||||
}
|
||||
|
||||
// HandlePaidNotify 验签已在 handler 完成;状态机 pending→paid 幂等,成功开通/续期
|
||||
func (s *memberService) HandlePaidNotify(ctx context.Context, orderNo, tradeNo, notifyRaw string) (string, error) {
|
||||
order, err := dao.PaymentOrder.GetByOrderNo(ctx, orderNo)
|
||||
if err != nil {
|
||||
return "no_order", err
|
||||
}
|
||||
if order == nil {
|
||||
return "no_order", nil
|
||||
}
|
||||
ok, err := dao.PaymentOrder.MarkPaid(ctx, orderNo, tradeNo, notifyRaw)
|
||||
if err != nil {
|
||||
return "no_order", err
|
||||
}
|
||||
if !ok {
|
||||
return "duplicate", nil // 已是 paid 或已关闭
|
||||
}
|
||||
days := 30
|
||||
if plan, _ := dao.MemberPlan.GetOne(ctx, order.PlanId); plan != nil {
|
||||
days = plan.DurationDays
|
||||
}
|
||||
um, _ := dao.UserMember.GetByUser(ctx, order.UserId)
|
||||
var oldExpire *gtime.Time
|
||||
if um != nil {
|
||||
oldExpire = um.ExpireAt
|
||||
}
|
||||
expireAt := NextExpire(oldExpire, days)
|
||||
if err := dao.UserMember.Upsert(ctx, order.UserId, order.PlanId, expireAt, consts.MemberSourceVipPay); err != nil {
|
||||
return "no_order", err
|
||||
}
|
||||
g.Log().Infof(ctx, "会员开通成功 user=%d order=%s expire=%s", order.UserId, orderNo, expireAt)
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// NextExpire 续期计算:未过期在原有效期上叠加,过期/无记录从现在起算
|
||||
func NextExpire(old *gtime.Time, days int) string {
|
||||
base := time.Now()
|
||||
if old != nil && old.Time.After(base) {
|
||||
base = old.Time
|
||||
}
|
||||
return base.Add(time.Duration(days) * 24 * time.Hour).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
func parseBenefits(features string) []string {
|
||||
var list []string
|
||||
_ = json.Unmarshal([]byte(features), &list)
|
||||
if list == nil {
|
||||
list = make([]string, 0)
|
||||
}
|
||||
return list
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"slogan-agent/styleagent/dao"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
)
|
||||
|
||||
type payNotifyLogService struct{}
|
||||
|
||||
var PayNotifyLogService = new(payNotifyLogService)
|
||||
|
||||
// Insert 回调日志全量入库(审计)
|
||||
func (s *payNotifyLogService) Insert(ctx context.Context, orderNo, body, sign, remoteIP, status string) error {
|
||||
return dao.PayNotifyLog.Insert(ctx, &entity.PayNotifyLog{
|
||||
OrderNo: orderNo, Body: body, Sign: sign, RemoteIp: remoteIP, Status: status,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"slogan-agent/styleagent/consts"
|
||||
"slogan-agent/styleagent/dao"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type paymentOrderService struct{}
|
||||
|
||||
var PaymentOrderService = new(paymentOrderService)
|
||||
|
||||
// ==================== 虎皮棋聚合支付适配器(签名/HTTP 细节收敛在本文件,业务层不感知) ====================
|
||||
// 注意:签名规则与字段名以官方最新文档为准(当前实现为经典 md5 约定)。
|
||||
|
||||
type paymentConfig struct {
|
||||
AppId string
|
||||
AppSecret string
|
||||
NotifyUrl string
|
||||
Channel string // 逗号分隔,如 "alipay,wechat"
|
||||
ApiBase string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
func (s *paymentOrderService) getPaymentConfig(ctx context.Context) paymentConfig {
|
||||
cfg := paymentConfig{
|
||||
AppId: g.Cfg().MustGet(ctx, "payment.xunhu_appid", "").String(),
|
||||
AppSecret: g.Cfg().MustGet(ctx, "payment.xunhu_appsecret", "").String(),
|
||||
NotifyUrl: g.Cfg().MustGet(ctx, "payment.notify_url", "").String(),
|
||||
Channel: g.Cfg().MustGet(ctx, "payment.channel", "alipay").String(),
|
||||
ApiBase: g.Cfg().MustGet(ctx, "payment.api_base", "https://api.xunhupay.com").String(),
|
||||
}
|
||||
cfg.Enabled = cfg.AppId != "" && cfg.AppSecret != ""
|
||||
return cfg
|
||||
}
|
||||
|
||||
// CreateOrder 创建支付单,返回收银台/支付 URL(金额单位:分)
|
||||
func (s *paymentOrderService) CreateOrder(ctx context.Context, orderNo string, amountFen int) (payURL string, err error) {
|
||||
cfg := s.getPaymentConfig(ctx)
|
||||
if !cfg.Enabled {
|
||||
return "", errors.New("支付未开通,请在 config.yml 配置 payment")
|
||||
}
|
||||
channel := "alipay"
|
||||
if first := strings.Split(cfg.Channel, ",")[0]; first != "" {
|
||||
channel = first
|
||||
}
|
||||
params := map[string]string{
|
||||
"appid": cfg.AppId,
|
||||
"trade_order_id": orderNo,
|
||||
"total_fee": fmt.Sprintf("%.2f", float64(amountFen)/100),
|
||||
"title": "形象会员",
|
||||
"notify_url": cfg.NotifyUrl,
|
||||
"type": channel,
|
||||
"version": "1.1",
|
||||
"nonce_str": paymentNonce(),
|
||||
}
|
||||
params["hash"] = paymentSign(params, cfg.AppSecret)
|
||||
|
||||
var resp struct {
|
||||
Errcode int `json:"errcode"`
|
||||
Errmsg string `json:"errmsg"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
// 注意:Post 的最后一个参数不会自动解析响应体,需手动读取后反序列化
|
||||
respRaw, err := g.Client().SetTimeout(10*time.Second).Post(context.Background(), cfg.ApiBase+"/payment/do.html", params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("虎皮棋下单失败: %w", err)
|
||||
}
|
||||
defer respRaw.Close()
|
||||
if err := json.Unmarshal(respRaw.ReadAll(), &resp); err != nil {
|
||||
return "", fmt.Errorf("虎皮棋下单失败: %w", err)
|
||||
}
|
||||
if resp.Errcode != 0 {
|
||||
return "", fmt.Errorf("虎皮棋下单失败: %s", resp.Errmsg)
|
||||
}
|
||||
if resp.Url == "" {
|
||||
return "", errors.New("虎皮棋下单失败: 返回为空")
|
||||
}
|
||||
return resp.Url, nil
|
||||
}
|
||||
|
||||
// VerifyNotify 验签:复制参数去掉 hash 后重算签名比较
|
||||
func (s *paymentOrderService) VerifyNotify(params map[string]string, hash, secret string) bool {
|
||||
if hash == "" || secret == "" {
|
||||
return false
|
||||
}
|
||||
cp := make(map[string]string, len(params))
|
||||
for k, v := range params {
|
||||
if k != "hash" {
|
||||
cp[k] = v
|
||||
}
|
||||
}
|
||||
return paymentSign(cp, secret) == strings.ToLower(hash)
|
||||
}
|
||||
|
||||
// paymentSign 参数名升序拼接 key=value,追加 secret 后 md5 hex
|
||||
func paymentSign(params map[string]string, secret string) string {
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
if params[k] == "" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var sb strings.Builder
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
sb.WriteString("&")
|
||||
}
|
||||
sb.WriteString(k)
|
||||
sb.WriteString("=")
|
||||
sb.WriteString(params[k])
|
||||
}
|
||||
sb.WriteString(secret)
|
||||
sum := md5.Sum([]byte(sb.String()))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func paymentNonce() string {
|
||||
b := make([]byte, 8)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// ==================== 订单业务 ====================
|
||||
|
||||
// CreateMemberOrder 下单:生成业务订单号 → 虎皮棋下单 → 返回支付 URL
|
||||
func (s *paymentOrderService) CreateMemberOrder(ctx context.Context, userId, planId int64) (*entity.PaymentOrder, string, error) {
|
||||
plan, err := dao.MemberPlan.GetOne(ctx, planId)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if plan == nil {
|
||||
return nil, "", errors.New("套餐不存在")
|
||||
}
|
||||
order := &entity.PaymentOrder{
|
||||
OrderNo: fmt.Sprintf("M%d%d", time.Now().UnixNano()/1e6, userId%1000),
|
||||
UserId: userId,
|
||||
PlanId: planId,
|
||||
AmountFen: plan.PriceFen,
|
||||
Channel: "alipay",
|
||||
Status: consts.PayStatusPending,
|
||||
}
|
||||
if _, err := dao.PaymentOrder.Insert(ctx, order); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
payURL, err := s.CreateOrder(ctx, order.OrderNo, plan.PriceFen)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return order, payURL, nil
|
||||
}
|
||||
|
||||
func (s *paymentOrderService) OrderStatus(ctx context.Context, orderNo string) (*entity.PaymentOrder, error) {
|
||||
return dao.PaymentOrder.GetByOrderNo(ctx, orderNo)
|
||||
}
|
||||
|
||||
// HandlePaidNotify 验签已在 handler 完成;状态机 pending→paid 幂等,成功开通/续期
|
||||
func (s *paymentOrderService) HandlePaidNotify(ctx context.Context, orderNo, tradeNo, notifyRaw string) (string, error) {
|
||||
order, err := dao.PaymentOrder.GetByOrderNo(ctx, orderNo)
|
||||
if err != nil {
|
||||
return "no_order", err
|
||||
}
|
||||
if order == nil {
|
||||
return "no_order", nil
|
||||
}
|
||||
ok, err := dao.PaymentOrder.MarkPaid(ctx, orderNo, tradeNo, notifyRaw)
|
||||
if err != nil {
|
||||
return "no_order", err
|
||||
}
|
||||
if !ok {
|
||||
return "duplicate", nil // 已是 paid 或已关闭
|
||||
}
|
||||
days := 30
|
||||
if plan, _ := dao.MemberPlan.GetOne(ctx, order.PlanId); plan != nil {
|
||||
days = plan.DurationDays
|
||||
}
|
||||
um, _ := dao.UserMember.GetByUser(ctx, order.UserId)
|
||||
var oldExpire *gtime.Time
|
||||
if um != nil {
|
||||
oldExpire = um.ExpireAt
|
||||
}
|
||||
expireAt := NextExpire(oldExpire, days)
|
||||
if err := dao.UserMember.Upsert(ctx, order.UserId, order.PlanId, expireAt, consts.MemberSourceVipPay); err != nil {
|
||||
return "no_order", err
|
||||
}
|
||||
g.Log().Infof(ctx, "会员开通成功 user=%d order=%s expire=%s", order.UserId, orderNo, expireAt)
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// NextExpire 续期计算:未过期在原有效期上叠加,过期/无记录从现在起算
|
||||
func NextExpire(old *gtime.Time, days int) string {
|
||||
base := time.Now()
|
||||
if old != nil && old.Time.After(base) {
|
||||
base = old.Time
|
||||
}
|
||||
return base.Add(time.Duration(days) * 24 * time.Hour).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPaymentSignDeterministic(t *testing.T) {
|
||||
params := map[string]string{
|
||||
"appid": "1000", "trade_order_id": "ORDER001", "total_fee": "29.90",
|
||||
}
|
||||
s1 := paymentSign(params, "secret123")
|
||||
s2 := paymentSign(params, "secret123")
|
||||
if s1 != s2 {
|
||||
t.Fatalf("相同参数签名应一致: %s != %s", s1, s2)
|
||||
}
|
||||
if s1 == "" {
|
||||
t.Fatal("签名不应为空")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentSignChangesWithSecret(t *testing.T) {
|
||||
params := map[string]string{"appid": "1000", "trade_order_id": "ORDER001"}
|
||||
if paymentSign(params, "a") == paymentSign(params, "b") {
|
||||
t.Fatal("不同 secret 签名应不同")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentVerifyNotify(t *testing.T) {
|
||||
params := map[string]string{
|
||||
"appid": "1000", "trade_order_id": "ORDER001", "total_fee": "29.90",
|
||||
"status": "OD", "hash": "",
|
||||
}
|
||||
hash := paymentSign(params, "secret123")
|
||||
if !PaymentOrderService.VerifyNotify(params, hash, "secret123") {
|
||||
t.Fatal("正确签名应通过验签")
|
||||
}
|
||||
params["total_fee"] = "0.01"
|
||||
if PaymentOrderService.VerifyNotify(params, hash, "secret123") {
|
||||
t.Fatal("篡改参数后应验签失败")
|
||||
}
|
||||
if PaymentOrderService.VerifyNotify(params, hash, "wrong-secret") {
|
||||
t.Fatal("错误 secret 应验签失败")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentConfigDisabledWhenEmpty(t *testing.T) {
|
||||
cfg := PaymentOrderService.getPaymentConfig(t.Context())
|
||||
if cfg.Enabled {
|
||||
t.Fatal("默认配置(key 为空)应 disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextExpireFormat(t *testing.T) {
|
||||
exp := NextExpire(nil, 30)
|
||||
if len(exp) != 19 {
|
||||
t.Fatalf("过期时间应 YYYY-MM-DD HH:MM:SS 格式: %q", exp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"slogan-agent/styleagent/dao"
|
||||
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type userMemberService struct{}
|
||||
|
||||
var UserMemberService = new(userMemberService)
|
||||
|
||||
// IsVip 用户是否有效会员(用于效果图限额等权益判定)
|
||||
func (s *userMemberService) IsVip(ctx context.Context, userId int64) bool {
|
||||
return dao.UserMember.IsVip(ctx, userId)
|
||||
}
|
||||
|
||||
// Upsert 开通/续期会员
|
||||
func (s *userMemberService) Upsert(ctx context.Context, userId, planId int64, expireAt, source string) error {
|
||||
return dao.UserMember.Upsert(ctx, userId, planId, expireAt, source)
|
||||
}
|
||||
|
||||
// NextExpire 续期计算:未过期在原有效期上叠加,过期/无记录从现在起算
|
||||
func (s *userMemberService) NextExpire(old *gtime.Time, days int) string {
|
||||
return NextExpire(old, days)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
commonHttp "slogan-agent/common"
|
||||
"slogan-agent/styleagent/dao"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
@@ -16,7 +17,7 @@ type userPhotoService struct{}
|
||||
var UserPhotoService = new(userPhotoService)
|
||||
|
||||
func (s *userPhotoService) Upload(ctx context.Context, userId int64, photoType int, file *ghttp.UploadFile) (int64, error) {
|
||||
url, err := SaveUploadedFile(file, fmt.Sprintf("user_%d/photos", userId))
|
||||
url, err := commonHttp.SaveUploadedFile(file, fmt.Sprintf("user_%d/photos", userId))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -37,7 +38,7 @@ func (s *userPhotoService) Delete(ctx context.Context, userId, id int64) error {
|
||||
if err != nil || p == nil {
|
||||
return errors.New("照片不存在")
|
||||
}
|
||||
if err := RemoveWorkspaceFile(p.Url); err != nil {
|
||||
if err := commonHttp.RemoveWorkspaceFile(p.Url); err != nil {
|
||||
return err
|
||||
}
|
||||
return dao.UserPhoto.Delete(ctx, id)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
commonHttp "slogan-agent/common"
|
||||
"slogan-agent/styleagent/dao"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
@@ -17,7 +18,7 @@ type wardrobeService struct{}
|
||||
var WardrobeService = new(wardrobeService)
|
||||
|
||||
func (s *wardrobeService) Upload(ctx context.Context, userId int64, req entity.WardrobeItem, file *ghttp.UploadFile) (int64, error) {
|
||||
url, err := SaveUploadedFile(file, fmt.Sprintf("user_%d/wardrobe", userId))
|
||||
url, err := commonHttp.SaveUploadedFile(file, fmt.Sprintf("user_%d/wardrobe", userId))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -47,7 +48,7 @@ func (s *wardrobeService) Delete(ctx context.Context, userId, id int64) error {
|
||||
if err != nil || item == nil {
|
||||
return errors.New("服装不存在")
|
||||
}
|
||||
if err := RemoveWorkspaceFile(item.PhotoUrl); err != nil {
|
||||
if err := commonHttp.RemoveWorkspaceFile(item.PhotoUrl); err != nil {
|
||||
return err
|
||||
}
|
||||
return dao.WardrobeItem.Delete(ctx, id)
|
||||
|
||||
Reference in New Issue
Block a user