refactor: 虎皮棋适配器内联 payment_order_service + member 服务拆 4 表文件 + file_storage 移入 common
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -6,10 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
commonHttp "slogan-agent/common"
|
commonHttp "slogan-agent/common"
|
||||||
"slogan-agent/styleagent/dao"
|
|
||||||
"slogan-agent/styleagent/model/dto"
|
"slogan-agent/styleagent/model/dto"
|
||||||
"slogan-agent/styleagent/model/entity"
|
|
||||||
"slogan-agent/styleagent/payment"
|
|
||||||
"slogan-agent/styleagent/service"
|
"slogan-agent/styleagent/service"
|
||||||
|
|
||||||
"github.com/gogf/gf/v2/frame/g"
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
@@ -22,7 +19,7 @@ var Member = new(member)
|
|||||||
|
|
||||||
// PlanList 会员套餐列表
|
// PlanList 会员套餐列表
|
||||||
func (c *member) PlanList(ctx context.Context, req *dto.MemberPlanListReq) (res *dto.MemberPlanListRes, err error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -31,7 +28,7 @@ func (c *member) PlanList(ctx context.Context, req *dto.MemberPlanListReq) (res
|
|||||||
|
|
||||||
// Status 我的会员状态
|
// Status 我的会员状态
|
||||||
func (c *member) Status(ctx context.Context, req *dto.MemberStatusReq) (res *dto.MemberStatusRes, err error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -42,7 +39,7 @@ func (c *member) Status(ctx context.Context, req *dto.MemberStatusReq) (res *dto
|
|||||||
|
|
||||||
// OrderCreate 下单 → 返回支付 URL
|
// OrderCreate 下单 → 返回支付 URL
|
||||||
func (c *member) OrderCreate(ctx context.Context, req *dto.MemberOrderCreateReq) (res *dto.MemberOrderCreateRes, err error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -51,7 +48,7 @@ func (c *member) OrderCreate(ctx context.Context, req *dto.MemberOrderCreateReq)
|
|||||||
|
|
||||||
// OrderStatus 订单状态(App 轮询)
|
// OrderStatus 订单状态(App 轮询)
|
||||||
func (c *member) OrderStatus(ctx context.Context, req *dto.MemberOrderStatusReq) (res *dto.MemberOrderStatusRes, err error) {
|
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 {
|
if err != nil || order == nil {
|
||||||
return nil, errors.New("订单不存在")
|
return nil, errors.New("订单不存在")
|
||||||
}
|
}
|
||||||
@@ -75,21 +72,17 @@ func MemberNotify(r *ghttp.Request) {
|
|||||||
for k, v := range r.GetRequestMap() {
|
for k, v := range r.GetRequestMap() {
|
||||||
params[k] = fmt.Sprint(v)
|
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 {
|
if !ok {
|
||||||
_ = dao.PayNotifyLog.Insert(ctx, &entity.PayNotifyLog{
|
_ = service.PayNotifyLogService.Insert(ctx, orderNo, body, hash, remoteIP, "bad_sign")
|
||||||
OrderNo: orderNo, Body: body, Sign: hash, RemoteIp: remoteIP, Status: "bad_sign",
|
|
||||||
})
|
|
||||||
r.Response.Write("fail")
|
r.Response.Write("fail")
|
||||||
r.ExitAll()
|
r.ExitAll()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state, err := service.MemberService.HandlePaidNotify(ctx, orderNo, r.Get("transaction_id").String(), body)
|
state, err := service.PaymentOrderService.HandlePaidNotify(ctx, orderNo, r.Get("transaction_id").String(), body)
|
||||||
_ = dao.PayNotifyLog.Insert(ctx, &entity.PayNotifyLog{
|
_ = service.PayNotifyLogService.Insert(ctx, orderNo, body, hash, remoteIP, state)
|
||||||
OrderNo: orderNo, Body: body, Sign: hash, RemoteIp: remoteIP, Status: state,
|
|
||||||
})
|
|
||||||
// duplicate(幂等重复回调)同样返回 success,避免支付渠道无限重试
|
// duplicate(幂等重复回调)同样返回 success,避免支付渠道无限重试
|
||||||
if err != nil || state == "no_order" {
|
if err != nil || state == "no_order" {
|
||||||
r.Response.Write("fail")
|
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"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
commonHttp "slogan-agent/common"
|
||||||
"slogan-agent/styleagent/dao"
|
"slogan-agent/styleagent/dao"
|
||||||
"slogan-agent/styleagent/model/entity"
|
"slogan-agent/styleagent/model/entity"
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ type userPhotoService struct{}
|
|||||||
var UserPhotoService = new(userPhotoService)
|
var UserPhotoService = new(userPhotoService)
|
||||||
|
|
||||||
func (s *userPhotoService) Upload(ctx context.Context, userId int64, photoType int, file *ghttp.UploadFile) (int64, error) {
|
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 {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -37,7 +38,7 @@ func (s *userPhotoService) Delete(ctx context.Context, userId, id int64) error {
|
|||||||
if err != nil || p == nil {
|
if err != nil || p == nil {
|
||||||
return errors.New("照片不存在")
|
return errors.New("照片不存在")
|
||||||
}
|
}
|
||||||
if err := RemoveWorkspaceFile(p.Url); err != nil {
|
if err := commonHttp.RemoveWorkspaceFile(p.Url); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return dao.UserPhoto.Delete(ctx, id)
|
return dao.UserPhoto.Delete(ctx, id)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
commonHttp "slogan-agent/common"
|
||||||
"slogan-agent/styleagent/dao"
|
"slogan-agent/styleagent/dao"
|
||||||
"slogan-agent/styleagent/model/entity"
|
"slogan-agent/styleagent/model/entity"
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ type wardrobeService struct{}
|
|||||||
var WardrobeService = new(wardrobeService)
|
var WardrobeService = new(wardrobeService)
|
||||||
|
|
||||||
func (s *wardrobeService) Upload(ctx context.Context, userId int64, req entity.WardrobeItem, file *ghttp.UploadFile) (int64, error) {
|
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 {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -47,7 +48,7 @@ func (s *wardrobeService) Delete(ctx context.Context, userId, id int64) error {
|
|||||||
if err != nil || item == nil {
|
if err != nil || item == nil {
|
||||||
return errors.New("服装不存在")
|
return errors.New("服装不存在")
|
||||||
}
|
}
|
||||||
if err := RemoveWorkspaceFile(item.PhotoUrl); err != nil {
|
if err := commonHttp.RemoveWorkspaceFile(item.PhotoUrl); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return dao.WardrobeItem.Delete(ctx, id)
|
return dao.WardrobeItem.Delete(ctx, id)
|
||||||
|
|||||||
Reference in New Issue
Block a user