From 527f2e0d18db450dbc6dc24d55be425f3c7a2d31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Fri, 31 Jul 2026 15:08:36 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E8=99=8E=E7=9A=AE=E6=A3=8B?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=99=A8=E5=86=85=E8=81=94=20payment=5Forder?= =?UTF-8?q?=5Fservice=20+=20member=20=E6=9C=8D=E5=8A=A1=E6=8B=86=204=20?= =?UTF-8?q?=E8=A1=A8=E6=96=87=E4=BB=B6=20+=20file=5Fstorage=20=E7=A7=BB?= =?UTF-8?q?=E5=85=A5=20common?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service => common}/file_storage.go | 2 +- styleagent/controller/member_controller.go | 23 +- styleagent/payment/gateway.go | 129 ----------- styleagent/payment/gateway_test.go | 49 ---- styleagent/service/member_plan_service.go | 52 +++++ styleagent/service/member_service.go | 132 ----------- styleagent/service/pay_notify_log_service.go | 19 ++ styleagent/service/payment_order_service.go | 213 ++++++++++++++++++ styleagent/service/payment_order_test.go | 56 +++++ styleagent/service/user_member_service.go | 28 +++ styleagent/service/user_photo_service.go | 5 +- styleagent/service/wardrobe_service.go | 5 +- 12 files changed, 383 insertions(+), 330 deletions(-) rename {styleagent/service => common}/file_storage.go (98%) delete mode 100644 styleagent/payment/gateway.go delete mode 100644 styleagent/payment/gateway_test.go create mode 100644 styleagent/service/member_plan_service.go delete mode 100644 styleagent/service/member_service.go create mode 100644 styleagent/service/pay_notify_log_service.go create mode 100644 styleagent/service/payment_order_service.go create mode 100644 styleagent/service/payment_order_test.go create mode 100644 styleagent/service/user_member_service.go diff --git a/styleagent/service/file_storage.go b/common/file_storage.go similarity index 98% rename from styleagent/service/file_storage.go rename to common/file_storage.go index 0b9efb3..c5da339 100644 --- a/styleagent/service/file_storage.go +++ b/common/file_storage.go @@ -1,4 +1,4 @@ -package service +package common import ( "errors" diff --git a/styleagent/controller/member_controller.go b/styleagent/controller/member_controller.go index 23e6346..c060f04 100644 --- a/styleagent/controller/member_controller.go +++ b/styleagent/controller/member_controller.go @@ -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") diff --git a/styleagent/payment/gateway.go b/styleagent/payment/gateway.go deleted file mode 100644 index 9aa04d9..0000000 --- a/styleagent/payment/gateway.go +++ /dev/null @@ -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) -} diff --git a/styleagent/payment/gateway_test.go b/styleagent/payment/gateway_test.go deleted file mode 100644 index a9ea01a..0000000 --- a/styleagent/payment/gateway_test.go +++ /dev/null @@ -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") - } -} diff --git a/styleagent/service/member_plan_service.go b/styleagent/service/member_plan_service.go new file mode 100644 index 0000000..d8581f8 --- /dev/null +++ b/styleagent/service/member_plan_service.go @@ -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 +} diff --git a/styleagent/service/member_service.go b/styleagent/service/member_service.go deleted file mode 100644 index be78cdc..0000000 --- a/styleagent/service/member_service.go +++ /dev/null @@ -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 -} diff --git a/styleagent/service/pay_notify_log_service.go b/styleagent/service/pay_notify_log_service.go new file mode 100644 index 0000000..3901770 --- /dev/null +++ b/styleagent/service/pay_notify_log_service.go @@ -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, + }) +} diff --git a/styleagent/service/payment_order_service.go b/styleagent/service/payment_order_service.go new file mode 100644 index 0000000..210502e --- /dev/null +++ b/styleagent/service/payment_order_service.go @@ -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") +} diff --git a/styleagent/service/payment_order_test.go b/styleagent/service/payment_order_test.go new file mode 100644 index 0000000..db28f94 --- /dev/null +++ b/styleagent/service/payment_order_test.go @@ -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) + } +} diff --git a/styleagent/service/user_member_service.go b/styleagent/service/user_member_service.go new file mode 100644 index 0000000..ea3e0ac --- /dev/null +++ b/styleagent/service/user_member_service.go @@ -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) +} diff --git a/styleagent/service/user_photo_service.go b/styleagent/service/user_photo_service.go index 69274e2..5a17f8f 100644 --- a/styleagent/service/user_photo_service.go +++ b/styleagent/service/user_photo_service.go @@ -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) diff --git a/styleagent/service/wardrobe_service.go b/styleagent/service/wardrobe_service.go index a7cce0e..a8ffa43 100644 --- a/styleagent/service/wardrobe_service.go +++ b/styleagent/service/wardrobe_service.go @@ -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)