重构交易处理逻辑,合并充值与购买流程,优化交易状态管理;更新相关数据结构和接口

This commit is contained in:
2025-06-26 09:28:42 +08:00
parent 065a7c77c3
commit 7d0bd84649
18 changed files with 843 additions and 919 deletions

View File

@@ -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 | 长效代理 |
## 问题备忘录
### 商福通支付接口的同步跳转参数
部分通道支持这个参数,银盛和汇付不支持这个参数

12
pkg/env/env.go vendored
View File

@@ -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

View File

@@ -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 '删除时间';

34
web/domains/bill/bill.go Normal file
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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,

View File

@@ -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),
})
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -15,25 +15,26 @@ const TableNameTrade = "trade"
// Trade mapped from table <trade>
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

View File

@@ -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 {

View File

@@ -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)
// 账单

9
web/services/bill.go Normal file
View File

@@ -0,0 +1,9 @@
package services
var Bill = &billService{}
type billService struct{}
func (s *billService) GenNo() string {
return ID.GenReadable("bil")
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}