From a57ab907fa2ea6726d1efbd89b15cbef8068a2b8 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 13:25:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=9A=E5=91=98=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=EF=BC=88=E5=A5=97=E9=A4=90/=E7=8A=B6=E6=80=81/=E4=B8=8B?= =?UTF-8?q?=E5=8D=95/=E5=9B=9E=E8=B0=83=E5=B9=82=E7=AD=89=E7=BB=AD?= =?UTF-8?q?=E6=9C=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- styleagent/service/member_service.go | 128 ++++++++++++++++++++++ styleagent/service/member_service_test.go | 28 +++++ 2 files changed, 156 insertions(+) create mode 100644 styleagent/service/member_service.go create mode 100644 styleagent/service/member_service_test.go diff --git a/styleagent/service/member_service.go b/styleagent/service/member_service.go new file mode 100644 index 0000000..a742bbb --- /dev/null +++ b/styleagent/service/member_service.go @@ -0,0 +1,128 @@ +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("2006-01-02 15:04:05") + 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) + expireAt := NextExpire(um.ExpireAt, 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/member_service_test.go b/styleagent/service/member_service_test.go new file mode 100644 index 0000000..ef16f0f --- /dev/null +++ b/styleagent/service/member_service_test.go @@ -0,0 +1,28 @@ +package service + +import ( + "testing" + "time" + + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + "github.com/gogf/gf/v2/os/gtime" +) + +func TestNextExpireFromNow(t *testing.T) { + got := NextExpire(nil, 30) + want := time.Now().Add(30 * 24 * time.Hour).Format("2006-01-02 15:04:05") + gotT, _ := time.Parse("2006-01-02 15:04:05", got) + wantT, _ := time.Parse("2006-01-02 15:04:05", want) + if !gotT.Equal(wantT) { + t.Fatalf("过期会员应从现在起算: got=%s want~%s", got, want) + } +} + +func TestNextExpireStackOnFuture(t *testing.T) { + base := gtime.NewFromTime(time.Now().Add(10 * 24 * time.Hour)) + got := NextExpire(base, 30) + gotT, _ := time.Parse("2006-01-02 15:04:05", got) + if gotT.Before(base.Time) { + t.Fatalf("未过期会员应叠加: got=%s base=%s", got, base.Format("2006-01-02 15:04:05")) + } +}