diff --git a/README.md b/README.md index 51439e3..76ca66f 100644 --- a/README.md +++ b/README.md @@ -4,32 +4,30 @@ tasks 取消交易时,需要判断错误的类型,如果是因为支付已 pre 环境屏蔽外部配置后启动,主要用于检查和配置采集器 -创建交易订单后添加一个关闭订单的异步任务 - 支付回调需要判断可能重复调用的情况 实现订单状态查询的 SSE 接口 -考虑将重复量比较大的异步任务修改成定时调度任务 ### 长期 +所有 domain 实现 NewXXModel 方法,约束模型创建字段,以防止数据库可空声明变化 + +更新支付状态后,缓存结果以便查询 + +考虑将重复量比较大的异步任务修改成定时调度任务 + 模型字段修改,特定枚举字段使用自定义类型代替通用 int32 proxy 网关更新接口可以传输更结构化的数据,直接区分不同类型以加快更新速度 ## 业务逻辑 -### 支付处理流程 +### 订单关闭的几种方式 -1. 创建订单,推送异步检查任务 -2. sse 接口推送订单状态 - -- 支付回调更新支付状态 -- 异步任务更新支付状态 -- 主动查询更新支付状态 - -更新支付状态后,缓存结果以便查询 +1. 创建订单后推送异步任务,到时间后尝试关闭订单 +2. sse 接口推送订单状态,轮询尝试完成订单 +3. 异步回调事件,收到支付成功事件后自动完成订单 ### 产品字典表 @@ -37,3 +35,9 @@ proxy 网关更新接口可以传输更结构化的数据,直接区分不同 |-------|------| | short | 短效代理 | | long | 长效代理 | + +## 问题备忘录 + +### 商福通支付接口的同步跳转参数 + +部分通道支持这个参数,银盛和汇付不支持这个参数 diff --git a/pkg/env/env.go b/pkg/env/env.go index a4ec8ad..bb262b2 100644 --- a/pkg/env/env.go +++ b/pkg/env/env.go @@ -17,7 +17,8 @@ const ( ) var ( - RunMode = RunModeDev + RunMode = RunModeDev + TradeExpire = 30 * 60 // 交易过期时间,单位秒 ) func loadApp() { @@ -30,6 +31,15 @@ func loadApp() { default: panic("环境变量 RUN_MODE 的值只能是 " + RunModeDev + " 或 " + RunModeProd) } + + _TradeExpire := os.Getenv("TRADE_EXPIRE") + if _TradeExpire != "" { + value, err := strconv.Atoi(_TradeExpire) + if err != nil { + panic("环境变量 TRADE_EXPIRE 的值不是数字") + } + TradeExpire = value + } } // endregion diff --git a/scripts/sql/init.sql b/scripts/sql/init.sql index c44f237..02737b5 100644 --- a/scripts/sql/init.sql +++ b/scripts/sql/init.sql @@ -68,14 +68,14 @@ comment on column logs_login.time is '登录时间'; drop table if exists logs_user_usage cascade; create table logs_user_usage ( id serial primary key, - user_id int not null, - resource_id int not null, - count int not null, + user_id int not null, + resource_id int not null, + count int not null, prov varchar(255), city varchar(255), isp varchar(255), - ip varchar(45) not null, - time timestamp not null + ip varchar(45) not null, + time timestamp not null ); create index logs_user_usage_user_id_index on logs_user_usage (user_id); create index logs_user_usage_resource_id_index on logs_user_usage (resource_id); @@ -868,27 +868,28 @@ comment on column resource_long.daily_last is '今日最后使用时间'; -- trade drop table if exists trade cascade; create table trade ( - id serial primary key, - user_id int not null references "user" (id) + id serial primary key, + user_id int not null references "user" (id) on update cascade on delete cascade, - inner_no varchar(255) not null unique, - outer_no varchar(255), - type int not null, - subject varchar(255) not null, - remark varchar(255), - amount decimal(12, 2) not null default 0, - payment decimal(12, 2) not null default 0, - method int not null, - platform int not null, - acquirer int, - status int not null default 0, - pay_url text, - paid_at timestamp, - cancel_at timestamp, - created_at timestamp default current_timestamp, - updated_at timestamp default current_timestamp, - deleted_at timestamp + inner_no varchar(255) not null unique, + outer_no varchar(255), + type int not null, + subject varchar(255) not null, + remark varchar(255), + amount decimal(12, 2) not null default 0, + payment decimal(12, 2) not null default 0, + method int not null, + platform int not null, + acquirer int, + status int not null default 0, + refunded bool not null default false, + payment_url text, + completed_at timestamp, + canceled_at timestamp, + created_at timestamp default current_timestamp, + updated_at timestamp default current_timestamp, + deleted_at timestamp ); create index trade_user_id_index on trade (user_id); create index trade_outer_no_index on trade (outer_no); @@ -906,14 +907,14 @@ comment on column trade.type is '订单类型:1-购买产品,2-充值余额' comment on column trade.subject is '订单主题'; comment on column trade.remark is '订单备注'; comment on column trade.amount is '订单总金额'; -comment on column trade.payment is '支付金额'; +comment on column trade.payment is '实际支付金额'; comment on column trade.method is '支付方式:1-支付宝,2-微信,3-商福通渠道支付宝,4-商福通渠道微信'; comment on column trade.platform is '支付平台:1-电脑网站,2-手机网站'; comment on column trade.acquirer is '收单机构:1-支付宝,2-微信,3-银联'; comment on column trade.status is '订单状态:0-待支付,1-已支付,2-已取消'; -comment on column trade.pay_url is '支付链接'; -comment on column trade.paid_at is '支付时间'; -comment on column trade.cancel_at is '取消时间'; +comment on column trade.payment_url is '支付链接'; +comment on column trade.completed_at is '支付时间'; +comment on column trade.canceled_at is '取消时间'; comment on column trade.created_at is '创建时间'; comment on column trade.updated_at is '更新时间'; comment on column trade.deleted_at is '删除时间'; diff --git a/web/domains/bill/bill.go b/web/domains/bill/bill.go new file mode 100644 index 0000000..45696af --- /dev/null +++ b/web/domains/bill/bill.go @@ -0,0 +1,34 @@ +package bill + +import ( + "github.com/shopspring/decimal" + m "platform/web/models" +) + +func NewForRecharge(uid int32, billNo string, info string, amount decimal.Decimal, trade *m.Trade) *m.Bill { + return &m.Bill{ + UserID: uid, + BillNo: billNo, + TradeID: &trade.ID, + Type: int32(TypeRecharge), + Info: &info, + Amount: amount, + } +} + +func NewForConsume(uid int32, billNo string, info string, amount decimal.Decimal, resource *m.Resource, trade ...*m.Trade) *m.Bill { + var bill = &m.Bill{ + UserID: uid, + BillNo: billNo, + ResourceID: &resource.ID, + Type: int32(TypeConsume), + Info: &info, + Amount: amount, + } + + if len(trade) > 0 { + bill.TradeID = &trade[0].ID + } + + return bill +} diff --git a/web/domains/trade/types.go b/web/domains/trade/types.go index 3771972..08729b6 100644 --- a/web/domains/trade/types.go +++ b/web/domains/trade/types.go @@ -1,5 +1,10 @@ package trade +import ( + "github.com/shopspring/decimal" + m "platform/web/models" +) + type Type int32 const ( @@ -39,3 +44,16 @@ const ( StatusSuccess // 已支付 StatusCanceled // 已取消 ) // 已退款 + +type ProductInfo interface { + GetType() Type + GetSubject() string + GetAmount() decimal.Decimal + Serialize() (string, error) + Deserialize(str string) error +} + +type CompleteEvent interface { + Check(t Type) (ProductInfo, bool) + OnTradeComplete(info ProductInfo, trade *m.Trade) error +} diff --git a/web/globals/redis.go b/web/globals/redis.go index 308516f..35e2488 100644 --- a/web/globals/redis.go +++ b/web/globals/redis.go @@ -2,15 +2,21 @@ package globals import ( "github.com/go-redsync/redsync/v4/redis/goredis/v9" + "log/slog" "net" "platform/pkg/env" + "platform/web/core" "github.com/go-redsync/redsync/v4" "github.com/redis/go-redis/v9" ) var Redis *redis.Client -var Redsync *redsync.Redsync +var Redsync *ExtendRedSync + +type ExtendRedSync struct { + *redsync.Redsync +} func initRedis() { client := redis.NewClient(&redis.Options{ @@ -23,7 +29,7 @@ func initRedis() { sync := redsync.New(pool) Redis = client - Redsync = sync + Redsync = &ExtendRedSync{sync} } func ExitRedis() error { @@ -32,3 +38,18 @@ func ExitRedis() error { } return nil } + +func (r *ExtendRedSync) WithLock(key string, callback func() error) error { + var mutex = Redsync.NewMutex(key) + var err = mutex.Lock() + if err != nil { + return core.NewServErr("服务繁忙,请稍后重试") + } + defer func(mutex *redsync.Mutex) { + if ok, err := mutex.Unlock(); err != nil { + slog.Error("解锁失败", slog.Bool("ok", ok), slog.Any("err", err)) + } + }(mutex) + + return callback() +} diff --git a/web/globals/shangfutong.go b/web/globals/shangfutong.go index 123d23d..0f71ab2 100644 --- a/web/globals/shangfutong.go +++ b/web/globals/shangfutong.go @@ -324,8 +324,10 @@ func (s *SftClient) sign(msg any) (*request, error) { return nil, fmt.Errorf("格式化加密正文失败:%w", err) } - pretty, _ := json.MarshalIndent(msg, "", " ") - println("content:\n" + string(pretty) + "\n\n") + if env.DebugHttpDump { + pretty, _ := json.MarshalIndent(msg, "", " ") + println("content:\n" + string(pretty) + "\n\n") + } body := request{ AppId: s.appid, diff --git a/web/handlers/resource.go b/web/handlers/resource.go index ba18155..b7b16bc 100644 --- a/web/handlers/resource.go +++ b/web/handlers/resource.go @@ -406,7 +406,7 @@ func StatisticResourceUsage(c *fiber.Ctx) error { } type CreateResourceReq struct { - s.CreateResourceData + *s.CreateResourceData } func CreateResource(c *fiber.Ctx) error { @@ -424,70 +424,7 @@ func CreateResource(c *fiber.Ctx) error { } // 创建套餐 - err = s.Resource.CreateResource(authCtx.Payload.Id, time.Now(), &req.CreateResourceData) - if err != nil { - return err - } - - return nil -} - -type PrepareResourceReq struct { - s.PrepareResourceData -} - -type PrepareResourceResp struct { - TradeNo string `json:"trade_no"` - PayURL string `json:"pay_url"` -} - -func PrepareCreateResource(c *fiber.Ctx) error { - - // 检查权限 - authCtx, err := auth.NewProtect(c).Payload(auth.PayloadUser).Do() - if err != nil { - return err - } - - // 解析请求参数 - var req = new(PrepareResourceReq) - if err := g.Validator.Validate(c, req); err != nil { - return err - } - - // 准备创建套餐 - result, err := s.Resource.PrepareResource(authCtx.Payload.Id, time.Now(), &req.PrepareResourceData) - if err != nil { - return err - } - - return c.JSON(PrepareResourceResp{ - TradeNo: result.TradeNo, - PayURL: result.PayURL, - }) -} - -type CompleteResourceReq struct { - TradeNo string `json:"trade_no" validate:"required"` -} - -func CompleteCreateResource(c *fiber.Ctx) error { - - // 检查权限 - _, err := auth.NewProtect(c).Payload(auth.PayloadUser).Do() - if err != nil { - return err - } - - // 解析请求参数 - var req = new(CompleteResourceReq) - if err := g.Validator.Validate(c, req); err != nil { - return err - } - - // 完成创建套餐 - var now = time.Now() - err = s.Resource.CompleteResource(req.TradeNo, now) + err = s.Resource.CreateResourceByBalance(authCtx.Payload.Id, time.Now(), req.CreateResourceData) if err != nil { return err } @@ -503,12 +440,13 @@ func ResourcePrice(c *fiber.Ctx) error { } // 解析请求参数 - var req = new(s.PrepareResourceData) + var req = new(CreateResourceReq) if err := g.Validator.Validate(c, req); err != nil { return err } + // 获取套餐价格 return c.JSON(fiber.Map{ - "price": req.GetPrice().StringFixed(2), + "price": req.GetAmount().StringFixed(2), }) } diff --git a/web/handlers/trade.go b/web/handlers/trade.go index a25fc9c..72a3354 100644 --- a/web/handlers/trade.go +++ b/web/handlers/trade.go @@ -2,23 +2,109 @@ package handlers import ( "fmt" - "github.com/shopspring/decimal" - "github.com/valyala/fasthttp/fasthttpadaptor" "log/slog" - "net/http" "platform/web/auth" + "platform/web/core" trade2 "platform/web/domains/trade" g "platform/web/globals" - q "platform/web/queries" s "platform/web/services" "platform/web/tasks" "time" "github.com/gofiber/fiber/v2" - "github.com/smartwalle/alipay/v3" - "github.com/wechatpay-apiv3/wechatpay-go/services/payments" ) +type TradeCreateReq struct { + s.CreateTradeData + Type trade2.Type `json:"type" validate:"required"` + Resource *s.CreateResourceData `json:"resource,omitempty"` + Recharge *s.RechargeProductInfo `json:"recharge,omitempty"` +} + +type TradeCreateResp struct { + PayUrl string `json:"pay_url"` + TradeNo string `json:"trade_no"` +} + +func TradeCreate(c *fiber.Ctx) error { + // 检查权限 + authCtx, err := auth.NewProtect(c).Payload(auth.PayloadUser).Do() + if err != nil { + return err + } + + // 解析请求参数 + req := new(TradeCreateReq) + if err := g.Validator.Validate(c, req); err != nil { + return err + } + + switch req.Type { + case trade2.TypePurchase: + if req.Resource == nil { + return core.NewBizErr("购买信息不能为空") + } + req.Product = req.Resource + case trade2.TypeRecharge: + if req.Recharge == nil { + return core.NewBizErr("充值信息不能为空") + } + req.Product = req.Recharge + } + + // 创建交易 + result, err := s.Trade.CreateTrade(authCtx.Payload.Id, time.Now(), &req.CreateTradeData) + if err != nil { + slog.Error("创建交易失败", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "创建交易失败"}) + } + + return c.JSON(&TradeCreateResp{ + PayUrl: result.PaymentUrl, + TradeNo: result.TradeNo, + }) +} + +type TradeCompleteReq struct { + TradeNo string `json:"trade_no" validate:"required"` + Method trade2.Method `json:"method" validate:"required"` +} + +func TradeComplete(c *fiber.Ctx) error { + // 检查权限 + _, err := auth.Protect(c, []auth.PayloadType{auth.PayloadUser}, []string{}) + if err != nil { + return err + } + + // 解析请求参数 + req := new(TradeCompleteReq) + if err := g.Validator.Validate(c, req); err != nil { + return err + } + + // 检查订单状态 + result, err := s.Trade.ConfirmTradeCompleted(&s.CheckTradeData{ + TradeNo: req.TradeNo, + Method: req.Method, + }) + if err != nil { + return err + } + + err = s.Trade.OnTradeCompleted(&s.OnTradeCompletedData{req.TradeNo, *result}) + if err != nil { + slog.Error("完成交易失败", "trade_no", req.TradeNo, "method", req.Method, "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "完成交易失败"}) + } + + return c.SendStatus(fiber.StatusNoContent) +} + +type TradeCheckReq struct { + tasks.CancelTradeData +} + func TradeCheckSSE(c *fiber.Ctx) error { // 设置响应头 c.Set("Content-Type", "text/event-stream") @@ -28,10 +114,6 @@ func TradeCheckSSE(c *fiber.Ctx) error { return nil } -type TradeCheckReq struct { - tasks.CancelTradeData -} - func TradeCancelByTask(c *fiber.Ctx) error { // 检查权限 _, err := auth.Protect(c, []auth.PayloadType{auth.PayloadInternalServer}, []string{}) @@ -46,14 +128,14 @@ func TradeCancelByTask(c *fiber.Ctx) error { } // 检查订单状态 - err = s.Trade.CheckTradeIfCanceled(&s.CheckTradeData{ + err = s.Trade.ConfirmTradeCanceled(&s.CheckTradeData{ TradeNo: req.TradeNo, Method: req.Method, }) if err != nil { slog.Debug(fmt.Sprintf("订单无需取消:%s", err.Error())) } else { - err = s.Trade.CancelTrade(req.TradeNo, req.Method) + err = s.Trade.CancelTrade(req.TradeNo, req.Method, time.Now()) if err != nil { slog.Warn("取消交易失败", "trade_no", req.TradeNo, "method", req.Method, "error", err) } @@ -61,152 +143,3 @@ func TradeCancelByTask(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) } - -func AlipayCallback(c *fiber.Ctx) (err error) { - - // 解析请求 - httpRequest := new(http.Request) - if err := fasthttpadaptor.ConvertRequest(c.Context(), httpRequest, false); err != nil { - return err - } - if err := httpRequest.ParseForm(); err != nil { - return err - } - notification, err := g.Alipay.DecodeNotification(httpRequest.Form) - if err != nil { - return err - } - - slog.Debug("支付宝支付回调", "notification", fmt.Sprintf("%+v", notification)) - - // 查询交易信息 - trade, err := q.Q.Trade.Where(q.Trade.InnerNo.Eq(notification.OutTradeNo)).Take() - if err != nil { - return c.SendString("success") - } - switch alipay.TradeStatus(notification.NotifyType) { - - // 支付关闭 - case alipay.TradeStatusClosed: - - // todo 退款 - - // 非退款 - switch trade2.Type(trade.Type) { - - // 购买产品 - case trade2.TypePurchase: - err = s.Resource.CancelResource(notification.OutTradeNo, time.Now(), true) - if err != nil { - return err - } - - // 余额充值 - case trade2.TypeRecharge: - err = s.User.RechargeCancel(notification.OutTradeNo, time.Now()) - if err != nil { - return err - } - } - - // 支付成功 - case alipay.TradeStatusSuccess: - - // 收集交易状态 - payment, err := decimal.NewFromString(notification.TotalAmount) - if err != nil { - return err - } - paidAt, err := time.Parse("2006-01-02 15:04:05", notification.GmtPayment) - if err != nil { - return err - } - verified := &s.TradeSuccessResult{ - TransId: notification.TradeNo, - Payment: payment, - Time: paidAt, - } - - // todo 退款 - - // 非退款 - switch trade2.Type(trade.Type) { - - // 购买产品 - case trade2.TypePurchase: - err = s.Resource.CompleteResource(notification.OutTradeNo, time.Now(), verified) - if err != nil { - return err - } - - // 余额充值 - case trade2.TypeRecharge: - err := s.User.RechargeConfirm(notification.OutTradeNo, verified) - if err != nil { - return err - } - } - } - - return c.SendString("success") -} - -func WechatPayCallback(c *fiber.Ctx) error { - - // 解析请求参数 - req := new(http.Request) - if err := fasthttpadaptor.ConvertRequest(c.Context(), req, false); err != nil { - return err - } - - content := new(payments.Transaction) - _, err := g.WechatPay.Notify.ParseNotifyRequest(c.Context(), req, content) - if err != nil { - return err - } - - slog.Debug("微信支付回调", "content", fmt.Sprintf("%+v", content)) - - // 查询交易信息 - trade, err := q.Q.Trade.Where(q.Trade.InnerNo.Eq(*content.OutTradeNo)).Take() - if err != nil { - // 跳过测试通知 - return nil - } - switch *content.TradeState { - - // 支付成功 - case "SUCCESS": - - // 收集交易状态 - payment := decimal.NewFromInt(*content.Amount.PayerTotal).Div(decimal.NewFromInt(100)) - paidAt, err := time.Parse(time.RFC3339, *content.SuccessTime) - if err != nil { - return err - } - verified := &s.TradeSuccessResult{ - TransId: *content.TransactionId, - Payment: payment, - Time: paidAt, - } - - switch { - - // 余额充值 - case trade.Type == int32(trade2.TypeRecharge): - err := s.User.RechargeConfirm(*content.OutTradeNo, verified) - if err != nil { - return err - } - - // 购买产品 - case trade.Type == int32(trade2.TypePurchase): - err = s.Resource.CompleteResource(*content.OutTradeNo, time.Now(), verified) - if err != nil { - return err - } - } - } - - return nil -} diff --git a/web/handlers/user.go b/web/handlers/user.go index 78d90ce..3b038e6 100644 --- a/web/handlers/user.go +++ b/web/handlers/user.go @@ -1,18 +1,12 @@ package handlers import ( - "fmt" - "github.com/shopspring/decimal" + "github.com/gofiber/fiber/v2" + "golang.org/x/crypto/bcrypt" "platform/web/auth" - "platform/web/core" - trade2 "platform/web/domains/trade" m "platform/web/models" q "platform/web/queries" s "platform/web/services" - "time" - - "github.com/gofiber/fiber/v2" - "golang.org/x/crypto/bcrypt" ) // region /update @@ -141,99 +135,3 @@ func UpdatePassword(c *fiber.Ctx) error { } // endregion - -// region /recharge - -type RechargePrepareReq struct { - Amount string `json:"amount" validate:"required,numeric"` - Platform trade2.Platform `json:"platform" validate:"required"` - Method trade2.Method `json:"method" validate:"required"` -} - -type RechargePrepareResp struct { - TradeNo string `json:"trade_no"` - PayURL string `json:"pay_url"` -} - -type RechargeConfirmReq struct { - TradeNo string `json:"trade_no" validate:"required"` -} - -type RechargeConfirmResp struct { - Status string `json:"status"` -} - -func RechargePrepare(c *fiber.Ctx) error { - // 检查权限 - authContext, err := auth.Protect(c, []auth.PayloadType{auth.PayloadUser}, []string{}) - if err != nil { - return err - } - - // 解析请求参数 - req := new(RechargePrepareReq) - if err := c.BodyParser(req); err != nil { - return err - } - - // 保存交易信息 - var now = time.Now() - amount, err := decimal.NewFromString(req.Amount) - if err != nil { - return core.NewBizErr(fmt.Sprintf("金额格式错误: %s", err.Error())) - } - var result *s.TradeCreateResult - err = q.Q.Transaction(func(tx *q.Query) error { - result, err = s.Trade.CreateTrade(tx, authContext.Payload.Id, now, &s.TradeCreateData{ - Subject: "账户充值 - " + amount.StringFixed(2) + "元", - Amount: amount, - ExpireAt: now.Add(30 * time.Minute), - Type: trade2.TypeRecharge, - Method: req.Method, - Platform: req.Platform, - }) - return err - }) - if err != nil { - return err - } - - // 返回结果 - return c.JSON(RechargePrepareResp{ - TradeNo: result.TradeNo, - PayURL: result.PayURL, - }) -} - -func RechargeComplete(c *fiber.Ctx) error { - // 检查权限 - _, err := auth.Protect(c, []auth.PayloadType{auth.PayloadUser}, []string{}) - if err != nil { - return err - } - - // 解析请求参数 - req := new(RechargeConfirmReq) - if err := c.BodyParser(req); err != nil { - return err - } - - // 验证支付结果 - result, err := s.Trade.CheckTradeIfCreated(&s.CheckTradeData{ - TradeNo: req.TradeNo, - Method: trade2.MethodSft, - }) - if err != nil { - return err - } - - // 更新数据库 - err = s.User.RechargeConfirm(req.TradeNo, result) - if err != nil { - return err - } - - return c.JSON(fiber.Map{"status": "success"}) -} - -// endregion diff --git a/web/models/trade.gen.go b/web/models/trade.gen.go index 8281607..38d9a81 100644 --- a/web/models/trade.gen.go +++ b/web/models/trade.gen.go @@ -15,25 +15,26 @@ const TableNameTrade = "trade" // Trade mapped from table type Trade struct { - ID int32 `gorm:"column:id;type:integer;primaryKey;autoIncrement:true;comment:订单ID" json:"id"` // 订单ID - UserID int32 `gorm:"column:user_id;type:integer;not null;comment:用户ID" json:"user_id"` // 用户ID - InnerNo string `gorm:"column:inner_no;type:character varying(255);not null;comment:内部订单号" json:"inner_no"` // 内部订单号 - OuterNo *string `gorm:"column:outer_no;type:character varying(255);comment:外部订单号" json:"outer_no"` // 外部订单号 - Type int32 `gorm:"column:type;type:integer;not null;comment:订单类型:1-购买产品,2-充值余额" json:"type"` // 订单类型:1-购买产品,2-充值余额 - Subject string `gorm:"column:subject;type:character varying(255);not null;comment:订单主题" json:"subject"` // 订单主题 - Remark *string `gorm:"column:remark;type:character varying(255);comment:订单备注" json:"remark"` // 订单备注 - Amount decimal.Decimal `gorm:"column:amount;type:numeric(12,2);not null;comment:订单总金额" json:"amount"` // 订单总金额 - Payment decimal.Decimal `gorm:"column:payment;type:numeric(12,2);not null;comment:支付金额" json:"payment"` // 支付金额 - Method int32 `gorm:"column:method;type:integer;not null;comment:支付方式:1-支付宝,2-微信,3-商福通渠道支付宝,4-商福通渠道微信" json:"method"` // 支付方式:1-支付宝,2-微信,3-商福通渠道支付宝,4-商福通渠道微信 - Status int32 `gorm:"column:status;type:integer;not null;comment:订单状态:0-待支付,1-已支付,2-已取消,3-已退款" json:"status"` // 订单状态:0-待支付,1-已支付,2-已取消,3-已退款 - PayURL *string `gorm:"column:pay_url;type:text;comment:支付链接" json:"pay_url"` // 支付链接 - PaidAt *orm.LocalDateTime `gorm:"column:paid_at;type:timestamp without time zone;comment:支付时间" json:"paid_at"` // 支付时间 - CancelAt *orm.LocalDateTime `gorm:"column:cancel_at;type:timestamp without time zone;comment:取消时间" json:"cancel_at"` // 取消时间 - CreatedAt *orm.LocalDateTime `gorm:"column:created_at;type:timestamp without time zone;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"` // 创建时间 - UpdatedAt *orm.LocalDateTime `gorm:"column:updated_at;type:timestamp without time zone;default:CURRENT_TIMESTAMP;comment:更新时间" json:"updated_at"` // 更新时间 - DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp without time zone;comment:删除时间" json:"deleted_at"` // 删除时间 - Acquirer *int32 `gorm:"column:acquirer;type:integer;comment:收单机构:1-支付宝,2-微信,3-银联" json:"acquirer"` // 收单机构:1-支付宝,2-微信,3-银联 - Platform int32 `gorm:"column:platform;type:integer;not null;comment:支付平台:1-电脑网站,2-手机网站" json:"platform"` // 支付平台:1-电脑网站,2-手机网站 + ID int32 `gorm:"column:id;type:integer;primaryKey;autoIncrement:true;comment:订单ID" json:"id"` // 订单ID + UserID int32 `gorm:"column:user_id;type:integer;not null;comment:用户ID" json:"user_id"` // 用户ID + InnerNo string `gorm:"column:inner_no;type:character varying(255);not null;comment:内部订单号" json:"inner_no"` // 内部订单号 + OuterNo *string `gorm:"column:outer_no;type:character varying(255);comment:外部订单号" json:"outer_no"` // 外部订单号 + Type int32 `gorm:"column:type;type:integer;not null;comment:订单类型:1-购买产品,2-充值余额" json:"type"` // 订单类型:1-购买产品,2-充值余额 + Subject string `gorm:"column:subject;type:character varying(255);not null;comment:订单主题" json:"subject"` // 订单主题 + Remark *string `gorm:"column:remark;type:character varying(255);comment:订单备注" json:"remark"` // 订单备注 + Amount decimal.Decimal `gorm:"column:amount;type:numeric(12,2);not null;comment:订单总金额" json:"amount"` // 订单总金额 + Payment decimal.Decimal `gorm:"column:payment;type:numeric(12,2);not null;comment:支付金额" json:"payment"` // 支付金额 + Method int32 `gorm:"column:method;type:integer;not null;comment:支付方式:1-支付宝,2-微信,3-商福通渠道支付宝,4-商福通渠道微信" json:"method"` // 支付方式:1-支付宝,2-微信,3-商福通渠道支付宝,4-商福通渠道微信 + Status int32 `gorm:"column:status;type:integer;not null;comment:订单状态:0-待支付,1-已支付,2-已取消" json:"status"` // 订单状态:0-待支付,1-已支付,2-已取消 + CreatedAt *orm.LocalDateTime `gorm:"column:created_at;type:timestamp without time zone;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"` // 创建时间 + UpdatedAt *orm.LocalDateTime `gorm:"column:updated_at;type:timestamp without time zone;default:CURRENT_TIMESTAMP;comment:更新时间" json:"updated_at"` // 更新时间 + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp without time zone;comment:删除时间" json:"deleted_at"` // 删除时间 + Acquirer *int32 `gorm:"column:acquirer;type:integer;comment:收单机构:1-支付宝,2-微信,3-银联" json:"acquirer"` // 收单机构:1-支付宝,2-微信,3-银联 + Platform int32 `gorm:"column:platform;type:integer;not null;comment:支付平台:1-电脑网站,2-手机网站" json:"platform"` // 支付平台:1-电脑网站,2-手机网站 + PaymentURL *string `gorm:"column:payment_url;type:text;comment:支付链接" json:"payment_url"` // 支付链接 + CompletedAt *orm.LocalDateTime `gorm:"column:completed_at;type:timestamp without time zone;comment:支付时间" json:"completed_at"` // 支付时间 + CanceledAt *orm.LocalDateTime `gorm:"column:canceled_at;type:timestamp without time zone;comment:取消时间" json:"canceled_at"` // 取消时间 + Refunded bool `gorm:"column:refunded;type:boolean;not null" json:"refunded"` } // TableName Trade's table name diff --git a/web/queries/trade.gen.go b/web/queries/trade.gen.go index 1a639c1..c3d379a 100644 --- a/web/queries/trade.gen.go +++ b/web/queries/trade.gen.go @@ -38,14 +38,15 @@ func newTrade(db *gorm.DB, opts ...gen.DOOption) trade { _trade.Payment = field.NewField(tableName, "payment") _trade.Method = field.NewInt32(tableName, "method") _trade.Status = field.NewInt32(tableName, "status") - _trade.PayURL = field.NewString(tableName, "pay_url") - _trade.PaidAt = field.NewField(tableName, "paid_at") - _trade.CancelAt = field.NewField(tableName, "cancel_at") _trade.CreatedAt = field.NewField(tableName, "created_at") _trade.UpdatedAt = field.NewField(tableName, "updated_at") _trade.DeletedAt = field.NewField(tableName, "deleted_at") _trade.Acquirer = field.NewInt32(tableName, "acquirer") _trade.Platform = field.NewInt32(tableName, "platform") + _trade.PaymentURL = field.NewString(tableName, "payment_url") + _trade.CompletedAt = field.NewField(tableName, "completed_at") + _trade.CanceledAt = field.NewField(tableName, "canceled_at") + _trade.Refunded = field.NewBool(tableName, "refunded") _trade.fillFieldMap() @@ -55,26 +56,27 @@ func newTrade(db *gorm.DB, opts ...gen.DOOption) trade { type trade struct { tradeDo - ALL field.Asterisk - ID field.Int32 // 订单ID - UserID field.Int32 // 用户ID - InnerNo field.String // 内部订单号 - OuterNo field.String // 外部订单号 - Type field.Int32 // 订单类型:1-购买产品,2-充值余额 - Subject field.String // 订单主题 - Remark field.String // 订单备注 - Amount field.Field // 订单总金额 - Payment field.Field // 支付金额 - Method field.Int32 // 支付方式:1-支付宝,2-微信,3-商福通渠道支付宝,4-商福通渠道微信 - Status field.Int32 // 订单状态:0-待支付,1-已支付,2-已取消,3-已退款 - PayURL field.String // 支付链接 - PaidAt field.Field // 支付时间 - CancelAt field.Field // 取消时间 - CreatedAt field.Field // 创建时间 - UpdatedAt field.Field // 更新时间 - DeletedAt field.Field // 删除时间 - Acquirer field.Int32 // 收单机构:1-支付宝,2-微信,3-银联 - Platform field.Int32 // 支付平台:1-电脑网站,2-手机网站 + ALL field.Asterisk + ID field.Int32 // 订单ID + UserID field.Int32 // 用户ID + InnerNo field.String // 内部订单号 + OuterNo field.String // 外部订单号 + Type field.Int32 // 订单类型:1-购买产品,2-充值余额 + Subject field.String // 订单主题 + Remark field.String // 订单备注 + Amount field.Field // 订单总金额 + Payment field.Field // 支付金额 + Method field.Int32 // 支付方式:1-支付宝,2-微信,3-商福通渠道支付宝,4-商福通渠道微信 + Status field.Int32 // 订单状态:0-待支付,1-已支付,2-已取消 + CreatedAt field.Field // 创建时间 + UpdatedAt field.Field // 更新时间 + DeletedAt field.Field // 删除时间 + Acquirer field.Int32 // 收单机构:1-支付宝,2-微信,3-银联 + Platform field.Int32 // 支付平台:1-电脑网站,2-手机网站 + PaymentURL field.String // 支付链接 + CompletedAt field.Field // 支付时间 + CanceledAt field.Field // 取消时间 + Refunded field.Bool fieldMap map[string]field.Expr } @@ -102,14 +104,15 @@ func (t *trade) updateTableName(table string) *trade { t.Payment = field.NewField(table, "payment") t.Method = field.NewInt32(table, "method") t.Status = field.NewInt32(table, "status") - t.PayURL = field.NewString(table, "pay_url") - t.PaidAt = field.NewField(table, "paid_at") - t.CancelAt = field.NewField(table, "cancel_at") t.CreatedAt = field.NewField(table, "created_at") t.UpdatedAt = field.NewField(table, "updated_at") t.DeletedAt = field.NewField(table, "deleted_at") t.Acquirer = field.NewInt32(table, "acquirer") t.Platform = field.NewInt32(table, "platform") + t.PaymentURL = field.NewString(table, "payment_url") + t.CompletedAt = field.NewField(table, "completed_at") + t.CanceledAt = field.NewField(table, "canceled_at") + t.Refunded = field.NewBool(table, "refunded") t.fillFieldMap() @@ -126,7 +129,7 @@ func (t *trade) GetFieldByName(fieldName string) (field.OrderExpr, bool) { } func (t *trade) fillFieldMap() { - t.fieldMap = make(map[string]field.Expr, 19) + t.fieldMap = make(map[string]field.Expr, 20) t.fieldMap["id"] = t.ID t.fieldMap["user_id"] = t.UserID t.fieldMap["inner_no"] = t.InnerNo @@ -138,14 +141,15 @@ func (t *trade) fillFieldMap() { t.fieldMap["payment"] = t.Payment t.fieldMap["method"] = t.Method t.fieldMap["status"] = t.Status - t.fieldMap["pay_url"] = t.PayURL - t.fieldMap["paid_at"] = t.PaidAt - t.fieldMap["cancel_at"] = t.CancelAt t.fieldMap["created_at"] = t.CreatedAt t.fieldMap["updated_at"] = t.UpdatedAt t.fieldMap["deleted_at"] = t.DeletedAt t.fieldMap["acquirer"] = t.Acquirer t.fieldMap["platform"] = t.Platform + t.fieldMap["payment_url"] = t.PaymentURL + t.fieldMap["completed_at"] = t.CompletedAt + t.fieldMap["canceled_at"] = t.CanceledAt + t.fieldMap["refunded"] = t.Refunded } func (t trade) clone(db *gorm.DB) trade { diff --git a/web/router.go b/web/router.go index cd50e0a..a58afe1 100644 --- a/web/router.go +++ b/web/router.go @@ -24,8 +24,6 @@ func ApplyRouters(app *fiber.App) { user.Post("/update/password", handlers.UpdatePassword) user.Post("/identify", handlers.Identify) user.Post("/identify/callback", handlers.IdentifyCallback) - user.Post("/recharge/prepare", handlers.RechargePrepare) - user.Post("/recharge/complete", handlers.RechargeComplete) // 白名单 whitelist := api.Group("/whitelist") @@ -42,8 +40,6 @@ func ApplyRouters(app *fiber.App) { resource.Post("/statistics/free", handlers.StatisticResourceFree) resource.Post("/statistics/usage", handlers.StatisticResourceUsage) resource.Post("/create", handlers.CreateResource) - resource.Post("/create/prepare", handlers.PrepareCreateResource) - resource.Post("/create/complete", handlers.CompleteCreateResource) resource.Post("/price", handlers.ResourcePrice) // 通道 @@ -55,8 +51,8 @@ func ApplyRouters(app *fiber.App) { // 交易 trade := api.Group("/trade") - trade.Post("/callback/alipay", handlers.AlipayCallback) - trade.Post("/callback/wechat", handlers.WechatPayCallback) + trade.Post("/create", handlers.TradeCreate) + trade.Post("/complete", handlers.TradeComplete) trade.Post("/check", handlers.TradeCheckSSE) // 账单 diff --git a/web/services/bill.go b/web/services/bill.go new file mode 100644 index 0000000..5afce25 --- /dev/null +++ b/web/services/bill.go @@ -0,0 +1,9 @@ +package services + +var Bill = &billService{} + +type billService struct{} + +func (s *billService) GenNo() string { + return ID.GenReadable("bil") +} diff --git a/web/services/id.go b/web/services/id.go index ce05ac2..3373bec 100644 --- a/web/services/id.go +++ b/web/services/id.go @@ -43,7 +43,9 @@ var ( ErrSequenceOverflow = errors.New("sequence overflow") ) -func (s *IdService) GenSerial(ctx context.Context) (string, error) { +func (s *IdService) GenSerial() (string, error) { + var ctx = context.Background() + // 构造Redis键 now := time.Now().Unix() key := idSerialKey(now) diff --git a/web/services/resource.go b/web/services/resource.go index 236d8a8..89309ad 100644 --- a/web/services/resource.go +++ b/web/services/resource.go @@ -1,8 +1,6 @@ package services import ( - "context" - "database/sql" "encoding/json" "fmt" "platform/pkg/u" @@ -23,221 +21,68 @@ var Resource = &resourceService{} type resourceService struct{} -func (s *resourceService) CreateResource(uid int32, now time.Time, ser *CreateResourceData) error { +func (s *resourceService) CreateResourceByBalance(uid int32, now time.Time, data *CreateResourceData) error { + return g.Redsync.WithLock(userBalanceKey(uid), func() error { + return q.Q.Transaction(func(q *q.Query) error { + // 检查用户 + user, err := q.User. + Where(q.User.ID.Eq(uid)). + Take() + if err != nil { + return err + } - data, err := ser.ToData() - if err != nil { - return err - } + // 检查余额 + var amount = user.Balance.Sub(data.GetAmount()) + if amount.IsNegative() { + return ErrBalanceNotEnough + } - var name = data.GetName() - var amount = data.GetPrice() + // 保存套餐 + resource, err := createResource(q, uid, now, data) + if err != nil { + return core.NewServErr("创建套餐失败", err) + } - // 保存交易信息 - err = q.Q.Transaction(func(q *q.Query) error { + // 更新用户余额 + _, err = q.User. + Where(q.User.ID.Eq(uid)). + UpdateSimple(q.User.Balance.Value(amount)) + if err != nil { + return core.NewServErr("更新用户余额失败", err) + } - // 检查用户 - user, err := q.User. - Where(q.User.ID.Eq(uid)). - Take() - if err != nil { - return err - } + // 生成账单 + err = q.Bill.Create(bill2.NewForConsume(uid, Bill.GenNo(), data.GetSubject(), data.GetAmount(), resource)) + if err != nil { + return core.NewServErr("生成账单失败", err) + } - // 检查余额 - if user.Balance.Cmp(amount) < 0 { - return ErrBalanceNotEnough - } + return nil + }) + }) +} + +func (s *resourceService) CreateResourceByTrade(uid int32, now time.Time, data *CreateResourceData, trade *m.Trade) error { + return q.Q.Transaction(func(q *q.Query) error { // 保存套餐 resource, err := createResource(q, uid, now, data) if err != nil { - return err + return core.NewServErr("创建套餐失败", err) } // 生成账单 - bill := m.Bill{ - UserID: uid, - ResourceID: &resource.ID, - BillNo: ID.GenReadable("bil"), - Info: u.P("购买套餐 - " + name), - Type: int32(bill2.TypeConsume), - Amount: amount, - } - err = q.Bill. - Omit(q.Bill.TradeID, q.Bill.RefundID). - Create(&bill) + err = q.Bill.Create(bill2.NewForConsume(uid, Bill.GenNo(), data.GetSubject(), data.GetAmount(), resource, trade)) if err != nil { - return err - } - - // 更新用户余额 - _, err = q.User. - Where(q.User.ID.Eq(uid)). - UpdateSimple(q.User.Balance.Value(user.Balance.Sub(amount))) - if err != nil { - return err - } - - return nil - }, &sql.TxOptions{Isolation: sql.LevelRepeatableRead}) - if err != nil { - return err - } - - return nil -} - -func (s *resourceService) PrepareResource(uid int32, now time.Time, ser *PrepareResourceData) (*TradeCreateResult, error) { - - name := ser.GetName() - amount := ser.GetPrice() - - method := ser.PaymentMethod - platform := ser.PaymentPlatform - - // 保存到数据库 - var result *TradeCreateResult - err := q.Q.Transaction(func(q *q.Query) error { - var err error - - // 生成交易订单 - result, err = Trade.CreateTrade(q, uid, now, &TradeCreateData{ - Subject: "购买套餐 - " + name, - Amount: amount, - ExpireAt: time.Now().Add(30 * time.Minute), - Type: trade2.TypeRecharge, - Method: method, - Platform: platform, - }) - if err != nil { - return err - } - - // 保存请求缓存 - err = g.Redis.Set(context.Background(), resPrepareKey(result.TradeNo), &PrepareResourceCache{ - Uid: uid, - TradeId: result.Trade.ID, - BillId: result.Bill.ID, - PrepareResourceData: ser, - }, 30*time.Minute).Err() - if err != nil { - return err + return core.NewServErr("生成账单失败", err) } return nil }) - if err != nil { - return nil, err - } - - return result, nil } -func (s *resourceService) CompleteResource(tradeNo string, now time.Time, opResult ...*TradeSuccessResult) error { - - // 获取请求缓存 - reqStr, err := g.Redis.Get(context.Background(), resPrepareKey(tradeNo)).Result() - if err != nil { - return core.NewBizErr("交易不存在或已过期") - } - cache := new(PrepareResourceCache) - if err := json.Unmarshal([]byte(reqStr), cache); err != nil { - return err - } - - // 检查交易结果 - var rs *TradeSuccessResult - if len(opResult) > 0 && opResult[0] != nil { - rs = opResult[0] - } else { - var err error - rs, err = Trade.CheckTradeIfCreated(&CheckTradeData{ - TradeNo: tradeNo, - Method: cache.PaymentMethod, - }) - if err != nil { - return err - } - } - - data, err := cache.ToData() - if err != nil { - return err - } - - // 保存交易信息 - err = q.Q.Transaction(func(q *q.Query) error { - - // 完成交易 - _, err = Trade.OnTradeCreated(q, &OnTradeCreateData{ - TradeNo: tradeNo, - TradeSuccessResult: *rs, - }) - if err != nil { - return fmt.Errorf("完成交易失败: %w", err) - } - - // 保存套餐 - resource, err := createResource(q, cache.Uid, now, data) - if err != nil { - return fmt.Errorf("创建套餐失败: %w", err) - } - - // 更新账单 - _, err = q.Bill.Debug(). - Where(q.Bill.ID.Eq(cache.BillId)). - Updates(&m.Bill{ - ResourceID: &resource.ID, - }) - if err != nil { - return fmt.Errorf("更新账单失败: %w", err) - } - - // 删除缓存 - err = g.Redis.Del(context.Background(), tradeNo).Err() - if err != nil { - return fmt.Errorf("删除缓存失败: %w", err) - } - - return nil - }) - if err != nil { - return err - } - - return nil -} - -func (s *resourceService) CancelResource(tradeNo string, now time.Time, opRevoked ...bool) error { - // 删除请求缓存 - cacheStr, err := g.Redis.GetDel(context.Background(), tradeNo).Result() - if err != nil { - return err - } - cache := new(PrepareResourceCache) - if err := json.Unmarshal([]byte(cacheStr), cache); err != nil { - return err - } - - // 取消交易 - if len(opRevoked) <= 0 { - err = Trade.CancelTrade(tradeNo, cache.PaymentMethod) - if err != nil { - return err - } - } - - // 更新订单状态 - err = Trade.OnTradeCanceled(q.Q, tradeNo, now) - if err != nil { - return err - } - - return nil -} - -func createResource(q *q.Query, uid int32, now time.Time, data CreateTypeResourceDataInter) (*m.Resource, error) { +func createResource(q *q.Query, uid int32, now time.Time, data *CreateResourceData) (*m.Resource, error) { // 套餐基本信息 var resource = m.Resource{ @@ -245,51 +90,55 @@ func createResource(q *q.Query, uid int32, now time.Time, data CreateTypeResourc ResourceNo: u.P(ID.GenReadable("res")), Active: true, } - - switch data := data.(type) { + switch data.Type { // 短效套餐 - case *CreateShortResourceData: - var duration = time.Duration(data.Expire) * 24 * time.Hour + case resource2.TypeShort: + var short = data.Short + if short == nil { + return nil, core.NewBizErr("短效套餐数据不能为空") + } + var duration = time.Duration(short.Expire) * 24 * time.Hour resource.Type = int32(resource2.TypeShort) resource.Short = &m.ResourceShort{ - Type: data.Mode, - Live: data.Live, - Quota: &data.Quota, + Type: short.Mode, + Live: short.Live, + Quota: &short.Quota, Expire: u.P(orm.LocalDateTime(now.Add(duration))), - DailyLimit: data.DailyLimit, + DailyLimit: short.DailyLimit, } // 长效套餐 - case *CreateLongResourceData: - var duration = time.Duration(data.Expire) * 24 * time.Hour + case resource2.TypeLong: + var long = data.Long + if long == nil { + return nil, core.NewBizErr("长效套餐数据不能为空") + } + var duration = time.Duration(long.Expire) * 24 * time.Hour resource.Type = int32(resource2.TypeLong) resource.Long = &m.ResourceLong{ - Type: data.Mode, - Live: data.Live, - Quota: &data.Quota, + Type: long.Mode, + Live: long.Live, + Quota: &long.Quota, Expire: u.P(orm.LocalDateTime(now.Add(duration))), - DailyLimit: data.DailyLimit, + DailyLimit: long.DailyLimit, } default: - return nil, fmt.Errorf("不支持的套餐类型") + return nil, core.NewBizErr("不支持的套餐类型") } err := q.Resource.Create(&resource) if err != nil { - return nil, err + return nil, core.NewServErr("创建套餐失败", err) } return &resource, nil } -func resPrepareKey(tradeNo string) string { - return fmt.Sprintf("resource:prepare:%s", tradeNo) -} - -type CreateTypeResourceDataInter interface { - GetName() string - GetPrice() decimal.Decimal +type CreateResourceData struct { + Type resource2.Type `json:"type" validate:"required"` + Short *CreateShortResourceData `json:"short,omitempty"` + Long *CreateLongResourceData `json:"long,omitempty"` } type CreateShortResourceData struct { @@ -303,7 +152,51 @@ type CreateShortResourceData struct { price *decimal.Decimal } -func (data *CreateShortResourceData) GetName() string { +type CreateLongResourceData struct { + Live int32 `json:"live" validate:"required,oneof=1 4 8 12 24"` + Mode int32 `json:"mode" validate:"required,oneof=1 2"` + Expire int32 `json:"expire"` + DailyLimit int32 `json:"daily_limit" validate:"min=100"` + Quota int32 `json:"quota" validate:"min=500"` + + name string + price *decimal.Decimal +} + +func (c *CreateResourceData) GetType() trade2.Type { + return trade2.TypePurchase +} + +func (c *CreateResourceData) GetSubject() string { + switch c.Type { + case resource2.TypeShort: + return c.Short.GetSubject() + case resource2.TypeLong: + return c.Long.GetSubject() + } + panic("类型对应的数据为空") +} + +func (c *CreateResourceData) GetAmount() decimal.Decimal { + switch c.Type { + case resource2.TypeShort: + return c.Short.GetAmount() + case resource2.TypeLong: + return c.Long.GetAmount() + } + panic("类型对应的数据为空") +} + +func (c *CreateResourceData) Serialize() (string, error) { + bytes, err := json.Marshal(c) + return string(bytes), err +} + +func (c *CreateResourceData) Deserialize(str string) error { + return json.Unmarshal([]byte(str), c) +} + +func (data *CreateShortResourceData) GetSubject() string { if data.name == "" { var mode string switch data.Mode { @@ -317,7 +210,7 @@ func (data *CreateShortResourceData) GetName() string { return data.name } -func (data *CreateShortResourceData) GetPrice() decimal.Decimal { +func (data *CreateShortResourceData) GetAmount() decimal.Decimal { if data.price == nil { var factor int32 switch data.Mode { @@ -340,18 +233,7 @@ func (data *CreateShortResourceData) GetPrice() decimal.Decimal { return *data.price } -type CreateLongResourceData struct { - Live int32 `json:"live" validate:"required,oneof=1 4 8 12 24"` - Mode int32 `json:"mode" validate:"required,oneof=1 2"` - Expire int32 `json:"expire"` - DailyLimit int32 `json:"daily_limit" validate:"min=100"` - Quota int32 `json:"quota" validate:"min=500"` - - name string - price *decimal.Decimal -} - -func (data *CreateLongResourceData) GetName() string { +func (data *CreateLongResourceData) GetSubject() string { if data.name == "" { var mode string switch data.Mode { @@ -365,7 +247,7 @@ func (data *CreateLongResourceData) GetName() string { return data.name } -func (data *CreateLongResourceData) GetPrice() decimal.Decimal { +func (data *CreateLongResourceData) GetAmount() decimal.Decimal { if data.price == nil { var factor int32 = 0 switch resource2.Mode(data.Mode) { @@ -399,72 +281,85 @@ func (data *CreateLongResourceData) GetPrice() decimal.Decimal { return *data.price } -type CreateResourceData struct { - Type resource2.Type `json:"type" validate:"required"` - Short *CreateShortResourceData `json:"short,omitempty"` - Long *CreateLongResourceData `json:"long,omitempty"` -} +type ResourceOnTradeComplete struct{} -func (data *CreateResourceData) GetName() string { - switch data.Type { - case resource2.TypeShort: - return data.Short.GetName() - case resource2.TypeLong: - return data.Long.GetName() - default: - panic("未处理的 resource type 枚举值") +func (r ResourceOnTradeComplete) Check(t trade2.Type) (trade2.ProductInfo, bool) { + if t == trade2.TypePurchase { + return &CreateResourceData{}, true } + return nil, false } -func (data *CreateResourceData) GetPrice() decimal.Decimal { - switch data.Type { - case resource2.TypeShort: - return data.Short.GetPrice() - case resource2.TypeLong: - return data.Long.GetPrice() - default: - panic("未处理的 resource type 枚举值") - } +func (r ResourceOnTradeComplete) OnTradeComplete(info trade2.ProductInfo, trade *m.Trade) error { + return Resource.CreateResourceByTrade(trade.UserID, time.Time(*trade.CompletedAt), info.(*CreateResourceData), trade) } -func (s *CreateResourceData) ToData() (CreateTypeResourceDataInter, error) { - switch s.Type { - case resource2.TypeShort: - return s.Short, nil - case resource2.TypeLong: - return s.Long, nil - } - - return nil, fmt.Errorf("不支持的套餐类型") -} - -type PrepareResourceData struct { - CreateResourceData - PaymentMethod trade2.Method `json:"payment_method" validate:"required"` - PaymentPlatform trade2.Platform `json:"payment_platform" validate:"required"` -} - -type PrepareResourceCache struct { - Uid int32 `json:"uid"` - TradeId int32 `json:"trade_id"` - BillId int32 `json:"bill_id"` - *PrepareResourceData -} - -func (c PrepareResourceCache) MarshalBinary() (data []byte, err error) { - data, err = json.Marshal(c) - if err != nil { - return nil, err - } - return data, nil -} - -func (c PrepareResourceCache) UnmarshalBinary(data []byte) error { - if err := json.Unmarshal(data, &c); err != nil { - return err - } - return nil -} +// type CreateResourceData struct { +// Type resource2.Type `json:"type" validate:"required"` +// Short *CreateShortResourceData `json:"short,omitempty"` +// Long *CreateLongResourceData `json:"long,omitempty"` +// } +// +// func (data *CreateResourceData) GetSubject() string { +// switch data.Type { +// case resource2.TypeShort: +// return data.Short.GetSubject() +// case resource2.TypeLong: +// return data.Long.GetSubject() +// default: +// panic("未处理的 resource type 枚举值") +// } +// } +// +// func (data *CreateResourceData) GetAmount() decimal.Decimal { +// switch data.Type { +// case resource2.TypeShort: +// return data.Short.GetAmount() +// case resource2.TypeLong: +// return data.Long.GetAmount() +// default: +// panic("未处理的 resource type 枚举值") +// } +// } +// +// func (data *CreateResourceData) ToData() (CreateResourceData, error) { +// switch data.Type { +// case resource2.TypeShort: +// return data.Short, nil +// case resource2.TypeLong: +// return data.Long, nil +// } +// +// return nil, fmt.Errorf("不支持的套餐类型") +// } +// +// type PrepareResourceData struct { +// CreateResourceData +// PaymentMethod trade2.Method `json:"payment_method" validate:"required"` +// PaymentPlatform trade2.Platform `json:"payment_platform" validate:"required"` +// } +// +// type PrepareResourceCache struct { +// Uid int32 `json:"uid"` +// TradeId int32 `json:"trade_id"` +// BillId int32 `json:"bill_id"` +// *PrepareResourceData +// } +// +// func (c PrepareResourceCache) MarshalBinary() (data []byte, err error) { +// data, err = json.Marshal(c) +// if err != nil { +// return nil, err +// } +// return data, nil +// } +// +// func (c PrepareResourceCache) UnmarshalBinary(data []byte) error { +// if err := json.Unmarshal(data, &c); err != nil { +// return err +// } +// return nil +// } type ResourceServiceErr string diff --git a/web/services/trade.go b/web/services/trade.go index 1c9716d..5f0489c 100644 --- a/web/services/trade.go +++ b/web/services/trade.go @@ -12,7 +12,6 @@ import ( "platform/pkg/env" "platform/pkg/u" "platform/web/core" - bill2 "platform/web/domains/bill" coupon2 "platform/web/domains/coupon" trade2 "platform/web/domains/trade" g "platform/web/globals" @@ -27,30 +26,35 @@ import ( "gorm.io/gorm" ) +var ComplementEvents = []trade2.CompleteEvent{ + ResourceOnTradeComplete{}, + UserOnTradeComplete{}, +} + var Trade = &tradeService{} type tradeService struct { } -func (s *tradeService) CreateTrade(q *q.Query, uid int32, now time.Time, data *TradeCreateData) (*TradeCreateResult, error) { - var subject = data.Subject - var expire = data.ExpireAt - var tType = data.Type - var method = data.Method - var platform = data.Platform - var amount = data.Amount +func (s *tradeService) CreateTrade(uid int32, now time.Time, data *CreateTradeData) (*CreateTradeResult, error) { + platform := data.Platform + method := data.Method + tType := data.Product.GetType() + subject := data.Product.GetSubject() + amount := data.Product.GetAmount() + expire := time.Now().Add(30 * time.Minute) // 实际支付金额,只在创建真实订单时使用 - var amountReal = data.Amount + var amountReal = data.Product.GetAmount() if env.RunMode == "debug" { amountReal = decimal.NewFromFloat(0.01) } // 附加优惠券 - if data.CouponCode != "" { + if data.CouponCode != nil { coupon, err := q.Coupon. Where( - q.Coupon.Code.Eq(data.CouponCode), + q.Coupon.Code.Eq(*data.CouponCode), q.Coupon.Status.Eq(int32(coupon2.StatusUnused)), ). Take() @@ -100,13 +104,13 @@ func (s *tradeService) CreateTrade(q *q.Query, uid int32, now time.Time, data *T } // 生成订单号 - tradeNo, err := ID.GenSerial(context.Background()) + var tradeNo, err = ID.GenSerial() if err != nil { - return nil, err + return nil, core.NewServErr("生成订单号失败", err) } - // 创建支付订单 - var payUrl string + // 提交支付订单 + var paymentUrl string switch { // 支付宝 + 电脑网站 @@ -125,7 +129,7 @@ func (s *tradeService) CreateTrade(q *q.Query, uid int32, now time.Time, data *T if err != nil { return nil, err } - payUrl = resp.String() + paymentUrl = resp.String() // 微信 + 电脑网站 case method == trade2.MethodWeChat && platform == trade2.PlatformDesktop: @@ -143,10 +147,13 @@ func (s *tradeService) CreateTrade(q *q.Query, uid int32, now time.Time, data *T if err != nil { return nil, err } - payUrl = *resp.CodeUrl + paymentUrl = *resp.CodeUrl // 商福通 + 电脑网站 - case (method == trade2.MethodSftAlipay || method == trade2.MethodSftWeChat) && platform == trade2.PlatformDesktop: + case + method == trade2.MethodSftAlipay && platform == trade2.PlatformDesktop, + method == trade2.MethodSftWeChat && platform == trade2.PlatformDesktop: + var payType g.SftPayType switch method { case trade2.MethodSftAlipay: @@ -169,10 +176,12 @@ func (s *tradeService) CreateTrade(q *q.Query, uid int32, now time.Time, data *T if err != nil { return nil, err } - payUrl = u.Z(u.Z(resp.PayInfo).QrCodeUrl) + paymentUrl = u.Z(u.Z(resp.PayInfo).QrCodeUrl) // 商福通 + 手机网站 - case (method == trade2.MethodSftAlipay || method == trade2.MethodSftWeChat) && platform == trade2.PlatformMobile: + case + method == trade2.MethodSftAlipay && platform == trade2.PlatformMobile, + method == trade2.MethodSftWeChat && platform == trade2.PlatformMobile: var payType g.SftPayType switch method { case trade2.MethodSftAlipay: @@ -195,7 +204,7 @@ func (s *tradeService) CreateTrade(q *q.Query, uid int32, now time.Time, data *T if err != nil { return nil, err } - payUrl = u.Z(u.Z(resp.PayInfo).PayUrl) + paymentUrl = u.Z(u.Z(resp.PayInfo).PayUrl) // 不支持的支付方式 default: @@ -203,172 +212,245 @@ func (s *tradeService) CreateTrade(q *q.Query, uid int32, now time.Time, data *T return nil, ErrTransactionNotSupported } - // 保存交易订单 - var trade = m.Trade{ - UserID: uid, - InnerNo: tradeNo, - Subject: subject, - Type: int32(tType), - Method: int32(method), - Platform: int32(platform), - Amount: amount, - PayURL: &payUrl, - } - - err = q.Trade.Create(&trade) + // 保存订单 + err = q.Trade.Create(&m.Trade{ + UserID: uid, + InnerNo: tradeNo, + Type: int32(tType), + Subject: subject, + Amount: amount, + Method: int32(method), + Platform: int32(platform), + PaymentURL: &paymentUrl, + }) if err != nil { - return nil, err + return nil, core.NewServErr("保存交易订单失败", err) } - // 保存用户帐单 - var billType bill2.Type - switch tType { - case trade2.TypeRecharge: - billType = bill2.TypeRecharge - case trade2.TypePurchase: - billType = bill2.TypeConsume - } - - var bill = m.Bill{ - BillNo: ID.GenReadable("bil"), - UserID: uid, - TradeID: &trade.ID, - Info: &subject, - Type: int32(billType), - Amount: amount, - } - - err = q.Bill. - Omit(q.Bill.ResourceID, q.Bill.RefundID). - Create(&bill) + // 缓存产品数据 + serialized, err := data.Product.Serialize() if err != nil { - return nil, err + return nil, core.NewServErr("序列化产品信息失败", err) } - // 提交异步任务更新订单状态 + err = g.Redis.Set( + context.Background(), + tradeProductKey(tradeNo), + serialized, + time.Duration(env.TradeExpire+10)*time.Second, + ).Err() + if err != nil { + return nil, core.NewServErr("保存购买信息失败", err) + } + + // 提交异步关闭事件 _, err = g.Asynq.Enqueue(tasks.NewCancelTrade(tasks.CancelTradeData{ TradeNo: tradeNo, Method: method, })) if err != nil { - return nil, err + return nil, core.NewServErr("提交异步关闭事件失败", err) } - return &TradeCreateResult{ - TradeNo: tradeNo, - PayURL: payUrl, - Bill: &bill, - Trade: &trade, + return &CreateTradeResult{ + PaymentUrl: paymentUrl, + TradeNo: tradeNo, }, nil } -func (s *tradeService) OnTradeCreated(q *q.Query, data *OnTradeCreateData) (*m.Trade, error) { - var transId = data.TransId + +func (s *tradeService) OnTradeCompleted(data *OnTradeCompletedData) error { + // 更新交易状态 + var trade = new(m.Trade) + var err = g.Redsync.WithLock(tradeLockKey(data.TradeNo), func() error { + return q.Q.Transaction(func(q *q.Query) (err error) { + trade, err = completeTrade(q, data) + return + }) + }) + if err != nil { + return core.NewServErr("处理交易失败", err) + } + + // 处理交易完成事件 + err = completeTradeAfter(trade) + if err != nil { + return core.NewServErr("处理交易完成事件失败", err) + } + + return nil +} +func completeTrade(q *q.Query, data *OnTradeCompletedData) (*m.Trade, error) { var tradeNo = data.TradeNo + var transId = data.TransId var payment = data.Payment - var paidAt = data.Time var acquirer = data.Acquirer + var paidAt = data.Time // 获取交易信息 trade, err := q.Trade. Where(q.Trade.InnerNo.Eq(tradeNo)). - First() + Take() if err != nil { - return nil, err + return nil, core.NewBizErr("获取交易信息失败", err) } // 检查交易状态 switch trade2.Status(trade.Status) { - case trade2.StatusCanceled: return nil, core.NewBizErr("交易已取消") - case trade2.StatusSuccess: return nil, core.NewBizErr("交易已完成") - - // 如果是未支付,则更新支付状态 case trade2.StatusPending: - trade.Status = int32(trade2.StatusSuccess) - trade.OuterNo = &transId - trade.Payment = payment - trade.Acquirer = u.P(int32(acquirer)) - trade.PaidAt = u.P(orm.LocalDateTime(paidAt)) - trade.PayURL = u.P("") - _, err = q.Trade. - Where(q.Trade.ID.Eq(trade.ID)). - Updates(trade) - if err != nil { - return nil, err - } + } + + // 更新交易信息 + trade.Status = int32(trade2.StatusSuccess) + trade.OuterNo = &transId + trade.Payment = payment + trade.Acquirer = u.P(int32(acquirer)) + trade.CompletedAt = u.P(orm.LocalDateTime(paidAt)) + _, err = q.Trade. + Where(q.Trade.InnerNo.Eq(tradeNo)). + Updates(trade) + if err != nil { + return nil, core.NewServErr("更新交易信息失败", err) } return trade, nil } +func completeTradeAfter(trade *m.Trade) error { -func (s *tradeService) CancelTrade(tradeNo string, method trade2.Method) error { - - switch method { - - case trade2.MethodAlipay: - resp, err := g.Alipay.TradeCancel(context.Background(), alipay.TradeCancel{ - OutTradeNo: tradeNo, - }) - if err != nil { - return err - } - if resp.Code != alipay.CodeSuccess { - slog.Warn("支付宝交易取消失败", "code", resp.Code, "sub_code", resp.SubCode, "msg", resp.Msg) - return errors.New("交易取消失败") - } - - case trade2.MethodWeChat: - resp, err := g.WechatPay.Native.CloseOrder(context.Background(), native.CloseOrderRequest{ - Mchid: &env.WechatPayMchId, - OutTradeNo: &tradeNo, - }) - if err != nil { - return err - } - if resp.Response.StatusCode != http.StatusNoContent { - body, _ := io.ReadAll(resp.Response.Body) - slog.Warn("微信交易取消失败", "code", resp.Response.StatusCode, "body", string(body)) - return errors.New("交易取消失败") - } - - case trade2.MethodSft, trade2.MethodSftAlipay, trade2.MethodSftWeChat: - resp, err := g.SFTPay.OrderClose(&g.OrderCloseReq{ - MchOrderNo: &tradeNo, - }) - if err != nil { - return err - } - if resp.State != "TRADE_CLOSE" { - slog.Warn("商福通交易取消失败", "state", resp.State) - return errors.New("交易取消失败") - } - - default: - return ErrTransactionNotSupported + // 恢复购买信息 + productData, err := g.Redis.Get(context.Background(), tradeProductKey(trade.InnerNo)).Result() + if err != nil { + return core.NewServErr("恢复购买信息失败", err) } + // 执行资源创建 + for _, event := range ComplementEvents { + info, ok := event.Check(trade2.Type(trade.Type)) + if !ok { + continue + } + + err = info.Deserialize(productData) + if err != nil { + return core.NewServErr("反序列化购买信息失败", err) + } + + err = event.OnTradeComplete(info, trade) + if err != nil { + return core.NewServErr("处理交易完成事件失败", err) + } + } + + return nil +} + +func (s *tradeService) CancelTrade(tradeNo string, method trade2.Method, now time.Time) error { + err := g.Redsync.WithLock(tradeLockKey(tradeNo), func() error { + switch method { + + case trade2.MethodAlipay: + resp, err := g.Alipay.TradeCancel(context.Background(), alipay.TradeCancel{ + OutTradeNo: tradeNo, + }) + if err != nil { + return err + } + if resp.Code != alipay.CodeSuccess { + slog.Warn("支付宝交易取消失败", "code", resp.Code, "sub_code", resp.SubCode, "msg", resp.Msg) + return errors.New("交易取消失败") + } + + case trade2.MethodWeChat: + resp, err := g.WechatPay.Native.CloseOrder(context.Background(), native.CloseOrderRequest{ + Mchid: &env.WechatPayMchId, + OutTradeNo: &tradeNo, + }) + if err != nil { + return err + } + if resp.Response.StatusCode != http.StatusNoContent { + body, _ := io.ReadAll(resp.Response.Body) + slog.Warn("微信交易取消失败", "code", resp.Response.StatusCode, "body", string(body)) + return errors.New("交易取消失败") + } + + case trade2.MethodSft, trade2.MethodSftAlipay, trade2.MethodSftWeChat: + resp, err := g.SFTPay.OrderClose(&g.OrderCloseReq{ + MchOrderNo: &tradeNo, + }) + if err != nil { + return err + } + if resp.State != "TRADE_CLOSE" { + slog.Warn("商福通交易取消失败", "state", resp.State) + return errors.New("交易取消失败") + } + + default: + return ErrTransactionNotSupported + } + + err := q.Q.Transaction(func(q *q.Query) error { + return cancelTrade(q, tradeNo, now) + }) + if err != nil { + return err + } + + return nil + }) + if err != nil { + return core.NewServErr("处理交易取消失败", err) + } return nil } func (s *tradeService) OnTradeCanceled(q *q.Query, tradeNo string, now time.Time) error { - _, err := q.Trade. - Where(q.Trade.InnerNo.Eq(tradeNo)). - Select(q.Trade.Status, q.Trade.CancelAt, q.Trade.PayURL). - Updates(m.Trade{ - Status: int32(trade2.StatusCanceled), - CancelAt: u.P(orm.LocalDateTime(now)), - PayURL: u.P(""), - }) + err := g.Redsync.WithLock(tradeLockKey(tradeNo), func() error { + return cancelTrade(q, tradeNo, now) + }) if err != nil { - return err + return core.NewServErr("处理交易取消失败", err) } return nil } +func cancelTrade(q *q.Query, tradeNo string, now time.Time) error { + // 获取交易信息 + var status trade2.Status + err := q.Trade. + Where(q.Trade.InnerNo.Eq(tradeNo)). + Select(q.Trade.Status). + Scan(&status) + if err != nil { + return core.NewBizErr("获取交易信息失败", err) + } -func (s *tradeService) SendRefundTrade(tradeNo string, method trade2.Method) error { + // 检查交易状态 + switch status { + case trade2.StatusCanceled: + return core.NewBizErr("交易已取消") + case trade2.StatusSuccess: + return core.NewBizErr("交易已完成") + case trade2.StatusPending: + } + + // 更新交易状态 + _, err = q.Trade. + Where(q.Trade.InnerNo.Eq(tradeNo)). + UpdateSimple( + q.Trade.Status.Value(int32(trade2.StatusCanceled)), + q.Trade.CanceledAt.Value(orm.LocalDateTime(now)), + ) + if err != nil { + return core.NewServErr("更新交易状态失败", err) + } + return nil +} + +func (s *tradeService) RefundTrade(tradeNo string, method trade2.Method) error { panic("todo") } func (s *tradeService) OnTradeRefunded(q *q.Query, tradeNo string, now time.Time) error { @@ -410,6 +492,7 @@ func (s *tradeService) CheckTrade(data *CheckTradeData) (*CheckTradeResult, erro case alipay.TradeStatusSuccess, alipay.TradeStatusFinished: result.Status = trade2.StatusSuccess + result.Success = &TradeSuccessResult{} result.Success.Acquirer = trade2.AcquirerAlipay result.Success.Payment, err = decimal.NewFromString(resp.TotalAmount) if err != nil { @@ -455,6 +538,7 @@ func (s *tradeService) CheckTrade(data *CheckTradeData) (*CheckTradeResult, erro case "SUCCESS", "REFUND": result.Status = trade2.StatusSuccess + result.Success = &TradeSuccessResult{} result.Success.Acquirer = trade2.AcquirerWeChat result.Success.Payment = decimal.NewFromInt(*resp.Amount.PayerTotal).Div(decimal.NewFromInt(100)) result.Success.Time, err = time.Parse(time.RFC3339, *resp.SuccessTime) @@ -490,6 +574,7 @@ func (s *tradeService) CheckTrade(data *CheckTradeData) (*CheckTradeResult, erro case g.SftTradeSuccess, g.SftTradeRefund, g.SftRefundIng: result.Status = trade2.StatusSuccess + result.Success = &TradeSuccessResult{} switch resp.PayType { case "WECHAT": result.Success.Acquirer = trade2.AcquirerWeChat @@ -512,8 +597,7 @@ func (s *tradeService) CheckTrade(data *CheckTradeData) (*CheckTradeResult, erro return result, nil } -func (s *tradeService) CheckTradeIfCreated(data *CheckTradeData) (*TradeSuccessResult, error) { - +func (s *tradeService) ConfirmTradeCompleted(data *CheckTradeData) (*TradeSuccessResult, error) { rs, err := Trade.CheckTrade(&CheckTradeData{ TradeNo: data.TradeNo, Method: data.Method, @@ -521,19 +605,18 @@ func (s *tradeService) CheckTradeIfCreated(data *CheckTradeData) (*TradeSuccessR if err != nil { return nil, err } + switch rs.Status { case trade2.StatusPending: return nil, core.NewBizErr("订单未支付") case trade2.StatusCanceled: return nil, core.NewBizErr("订单已关闭") case trade2.StatusSuccess: - // pass } return rs.Success, nil } -func (s *tradeService) CheckTradeIfCanceled(data *CheckTradeData) error { - +func (s *tradeService) ConfirmTradeCanceled(data *CheckTradeData) error { rs, err := Trade.CheckTrade(&CheckTradeData{ TradeNo: data.TradeNo, Method: data.Method, @@ -541,33 +624,55 @@ func (s *tradeService) CheckTradeIfCanceled(data *CheckTradeData) error { if err != nil { return err } + switch rs.Status { case trade2.StatusPending: return core.NewBizErr("订单未支付") case trade2.StatusSuccess: return core.NewBizErr("订单已关闭") case trade2.StatusCanceled: - // pass } return nil } +func (s *tradeService) ConfirmTradeRefunded(data *CheckTradeData) error { + rs, err := Trade.CheckTrade(&CheckTradeData{ + TradeNo: data.TradeNo, + Method: data.Method, + }) + if err != nil { + return err + } -type TradeCreateData struct { - Subject string - Amount decimal.Decimal - ExpireAt time.Time - Type trade2.Type - Method trade2.Method - Platform trade2.Platform - CouponCode string + switch rs.Status { + case trade2.StatusPending: + return core.NewBizErr("订单未支付") + case trade2.StatusCanceled: + return core.NewBizErr("订单已关闭") + case trade2.StatusSuccess: + } + + return core.NewBizErr("订单状态异常") } -type TradeCreateResult struct { - TradeNo string - PayURL string - Bill *m.Bill - Trade *m.Trade +func tradeProductKey(no string) string { + return fmt.Sprintf("trade:%s:product", no) +} + +func tradeLockKey(no string) string { + return fmt.Sprintf("trade:%s:lock", no) +} + +type CreateTradeData struct { + Platform trade2.Platform `json:"platform" validate:"required"` + Method trade2.Method `json:"method" validate:"required"` + CouponCode *string `json:"coupon_code"` + Product trade2.ProductInfo +} + +type CreateTradeResult struct { + TradeNo string + PaymentUrl string } type CheckTradeData struct { @@ -588,7 +693,7 @@ type TradeSuccessResult struct { Time time.Time } -type OnTradeCreateData struct { +type OnTradeCompletedData struct { TradeNo string TradeSuccessResult } diff --git a/web/services/user.go b/web/services/user.go index a8ac9b1..8e25b99 100644 --- a/web/services/user.go +++ b/web/services/user.go @@ -1,54 +1,107 @@ package services import ( + "encoding/json" + "fmt" + "github.com/shopspring/decimal" + "platform/web/core" + bill2 "platform/web/domains/bill" + trade2 "platform/web/domains/trade" + g "platform/web/globals" + m "platform/web/models" q "platform/web/queries" - "time" ) var User = &userService{} type userService struct{} -func (s *userService) RechargeConfirm(tradeNo string, verified *TradeSuccessResult) error { +func (s *userService) UpdateBalanceByTrade(uid int32, info *RechargeProductInfo, trade *m.Trade) (err error) { + err = g.Redsync.WithLock(userBalanceKey(uid), func() error { + return q.Q.Transaction(func(q *q.Query) error { - err := q.Q.Transaction(func(tx *q.Query) error { + err = updateBalance(q, uid, info) + if err != nil { + return err + } - // 更新交易状态 - trade, err := Trade.OnTradeCreated(tx, &OnTradeCreateData{ - TradeNo: tradeNo, - TradeSuccessResult: *verified, + // 生成账单 + err = q.Bill.Create(bill2.NewForRecharge(uid, Bill.GenNo(), info.GetSubject(), info.GetAmount(), trade)) + if err != nil { + return core.NewServErr("生成账单失败", err) + } + + return nil }) - if err != nil { - return err - } - - // 更新用户余额 - user, err := tx.User. - Where(tx.User.ID.Eq(trade.UserID)).Take() - if err != nil { - return err - } - - _, err = tx.User. - Where(tx.User.ID.Eq(user.ID)). - UpdateSimple(tx.User.Balance.Value(user.Balance.Add(trade.Amount))) - if err != nil { - return err - } - - return nil }) if err != nil { - return err + return core.NewServErr("更新用户余额失败") + } + + return nil +} +func updateBalance(q *q.Query, uid int32, info *RechargeProductInfo) (err error) { + + // 更新余额 + user, err := q.User. + Where(q.User.ID.Eq(uid)).Take() + if err != nil { + return core.NewServErr("查询用户失败", err) + } + + var amount = user.Balance.Add(info.GetAmount()) + if amount.IsNegative() { + return core.NewServErr("用户余额不足") + } + + _, err = q.User. + Where(q.User.ID.Eq(user.ID)). + UpdateSimple(q.User.Balance.Value(amount)) + if err != nil { + return core.NewServErr("更新用户余额失败", err) } return nil } -func (s *userService) RechargeCancel(tradeNo string, now time.Time) error { - panic("not implemented") +func userBalanceKey(uid int32) string { + return fmt.Sprintf("user:%d:balance", uid) } -func (s *userService) RechargeRefund(tradeNo string, now time.Time) error { - panic("not implemented") +type RechargeProductInfo struct { + Amount int `json:"amount"` +} + +func (r *RechargeProductInfo) GetType() trade2.Type { + return trade2.TypeRecharge +} + +func (r *RechargeProductInfo) GetSubject() string { + return fmt.Sprintf("账户充值 - " + r.GetAmount().StringFixed(2) + "元") +} + +func (r *RechargeProductInfo) GetAmount() decimal.Decimal { + return decimal.NewFromInt(int64(r.Amount)).Div(decimal.NewFromInt(100)) +} + +func (r *RechargeProductInfo) Serialize() (string, error) { + bytes, err := json.Marshal(r) + return string(bytes), err +} + +func (r *RechargeProductInfo) Deserialize(str string) error { + return json.Unmarshal([]byte(str), r) +} + +type UserOnTradeComplete struct{} + +func (u UserOnTradeComplete) Check(t trade2.Type) (trade2.ProductInfo, bool) { + if t == trade2.TypeRecharge { + return &RechargeProductInfo{}, true + } + return nil, false +} + +func (u UserOnTradeComplete) OnTradeComplete(info trade2.ProductInfo, trade *m.Trade) error { + return User.UpdateBalanceByTrade(trade.UserID, info.(*RechargeProductInfo), trade) }