feat: add TenantLedger model and query generation
- Introduced TenantLedger model with fields for managing tenant transactions, including ID, TenantID, UserID, OrderID, transaction Type, Amount, and balance details. - Implemented CRUD operations for TenantLedger with methods for Create, Update, Delete, and Reload. - Generated query methods for TenantLedger to facilitate database interactions, including filtering, pagination, and aggregation functions. - Established relationships with Order model for foreign key references.
This commit is contained in:
@@ -18,7 +18,7 @@ import (
|
||||
// Routes implements the HttpRoute contract and provides route registration
|
||||
// for all controllers in the super module.
|
||||
//
|
||||
// @provider contracts.HttpRoute atom.GroupRoutes
|
||||
// @provider contracts.HttpRoute atom.GroupRoutes
|
||||
type Routes struct {
|
||||
log *log.Entry `inject:"false"`
|
||||
middlewares *middlewares.Middlewares
|
||||
|
||||
22
backend/app/http/tenant/dto/order.go
Normal file
22
backend/app/http/tenant/dto/order.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package dto
|
||||
|
||||
import "quyun/v2/database/models"
|
||||
|
||||
// PurchaseContentForm defines the request body for purchasing a content using tenant balance.
|
||||
type PurchaseContentForm struct {
|
||||
// IdempotencyKey is used to ensure the purchase request is processed at most once.
|
||||
// 建议由客户端生成并保持稳定:同一笔购买重复请求时返回相同结果,避免重复扣款/重复下单。
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
}
|
||||
|
||||
// PurchaseContentResponse returns the order and granted access after a purchase.
|
||||
type PurchaseContentResponse struct {
|
||||
// Order is the created or existing order record (may be nil for owner/free-path without order).
|
||||
Order *models.Order `json:"order,omitempty"`
|
||||
// Item is the single order item of this purchase (current implementation is 1 order -> 1 content).
|
||||
Item *models.OrderItem `json:"item,omitempty"`
|
||||
// Access is the content access record after purchase grant.
|
||||
Access *models.ContentAccess `json:"access,omitempty"`
|
||||
// AmountPaid is the final paid amount in cents (CNY 分).
|
||||
AmountPaid int64 `json:"amount_paid,omitempty"`
|
||||
}
|
||||
36
backend/app/http/tenant/dto/order_admin.go
Normal file
36
backend/app/http/tenant/dto/order_admin.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"quyun/v2/app/requests"
|
||||
"quyun/v2/database/models"
|
||||
"quyun/v2/pkg/consts"
|
||||
)
|
||||
|
||||
// AdminOrderListFilter defines query filters for tenant-admin order listing.
|
||||
type AdminOrderListFilter struct {
|
||||
// Pagination controls paging parameters (page/limit).
|
||||
requests.Pagination `json:",inline" query:",inline"`
|
||||
// UserID filters orders by buyer user id.
|
||||
UserID *int64 `json:"user_id,omitempty" query:"user_id"`
|
||||
// Status filters orders by order status.
|
||||
Status *consts.OrderStatus `json:"status,omitempty" query:"status"`
|
||||
}
|
||||
|
||||
// AdminOrderRefundForm defines payload for tenant-admin to refund an order.
|
||||
type AdminOrderRefundForm struct {
|
||||
// Force indicates bypassing the default refund window check (paid_at + 24h).
|
||||
// 强制退款:true 表示绕过默认退款时间窗限制(需审计)。
|
||||
Force bool `json:"force,omitempty"`
|
||||
// Reason is the human-readable refund reason used for audit.
|
||||
// 退款原因:建议必填(由业务侧校验);用于审计与追责。
|
||||
Reason string `json:"reason,omitempty"`
|
||||
// IdempotencyKey ensures refund request is processed at most once.
|
||||
// 幂等键:同一笔退款重复请求时返回一致结果,避免重复退款/重复回滚。
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
}
|
||||
|
||||
// AdminOrderDetail returns a tenant-admin order detail payload.
|
||||
type AdminOrderDetail struct {
|
||||
// Order is the order with items preloaded.
|
||||
Order *models.Order `json:"order,omitempty"`
|
||||
}
|
||||
14
backend/app/http/tenant/dto/order_me.go
Normal file
14
backend/app/http/tenant/dto/order_me.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"quyun/v2/app/requests"
|
||||
"quyun/v2/pkg/consts"
|
||||
)
|
||||
|
||||
// MyOrderListFilter defines query filters for listing current user's orders within a tenant.
|
||||
type MyOrderListFilter struct {
|
||||
// Pagination controls paging parameters (page/limit).
|
||||
requests.Pagination `json:",inline" query:",inline"`
|
||||
// Status filters orders by order status.
|
||||
Status *consts.OrderStatus `json:"status,omitempty" query:"status"`
|
||||
}
|
||||
60
backend/app/http/tenant/order.go
Normal file
60
backend/app/http/tenant/order.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"quyun/v2/app/http/tenant/dto"
|
||||
"quyun/v2/app/services"
|
||||
"quyun/v2/database/models"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// order provides tenant-side order endpoints for members (purchase and my orders).
|
||||
//
|
||||
// @provider
|
||||
type order struct{}
|
||||
|
||||
// purchaseContent
|
||||
//
|
||||
// @Summary 购买内容(余额支付)
|
||||
// @Tags Tenant
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param tenantCode path string true "Tenant Code"
|
||||
// @Param contentID path int64 true "ContentID"
|
||||
// @Param form body dto.PurchaseContentForm true "Form"
|
||||
// @Success 200 {object} dto.PurchaseContentResponse
|
||||
//
|
||||
// @Router /t/:tenantCode/v1/contents/:contentID/purchase [post]
|
||||
// @Bind tenant local key(tenant)
|
||||
// @Bind user local key(user)
|
||||
// @Bind contentID path
|
||||
// @Bind form body
|
||||
func (*order) purchaseContent(ctx fiber.Ctx, tenant *models.Tenant, user *models.User, contentID int64, form *dto.PurchaseContentForm) (*dto.PurchaseContentResponse, error) {
|
||||
log.WithFields(log.Fields{
|
||||
"tenant_id": tenant.ID,
|
||||
"user_id": user.ID,
|
||||
"content_id": contentID,
|
||||
"idempotency_key": form.IdempotencyKey,
|
||||
}).Info("tenant.order.purchase_content")
|
||||
|
||||
res, err := services.Order.PurchaseContent(ctx, &services.PurchaseContentParams{
|
||||
TenantID: tenant.ID,
|
||||
UserID: user.ID,
|
||||
ContentID: contentID,
|
||||
IdempotencyKey: form.IdempotencyKey,
|
||||
Now: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &dto.PurchaseContentResponse{
|
||||
Order: res.Order,
|
||||
Item: res.OrderItem,
|
||||
Access: res.Access,
|
||||
AmountPaid: res.AmountPaid,
|
||||
}, nil
|
||||
}
|
||||
113
backend/app/http/tenant/order_admin.go
Normal file
113
backend/app/http/tenant/order_admin.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"quyun/v2/app/errorx"
|
||||
"quyun/v2/app/http/tenant/dto"
|
||||
"quyun/v2/app/requests"
|
||||
"quyun/v2/app/services"
|
||||
"quyun/v2/database/models"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// orderAdmin provides tenant-admin order management endpoints.
|
||||
//
|
||||
// @provider
|
||||
type orderAdmin struct{}
|
||||
|
||||
// adminOrderList
|
||||
//
|
||||
// @Summary 订单列表(租户管理)
|
||||
// @Tags Tenant
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param tenantCode path string true "Tenant Code"
|
||||
// @Param filter query dto.AdminOrderListFilter true "Filter"
|
||||
// @Success 200 {object} requests.Pager{items=models.Order}
|
||||
//
|
||||
// @Router /t/:tenantCode/v1/admin/orders [get]
|
||||
// @Bind tenant local key(tenant)
|
||||
// @Bind tenantUser local key(tenant_user)
|
||||
// @Bind filter query
|
||||
func (*orderAdmin) adminOrderList(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *models.TenantUser, filter *dto.AdminOrderListFilter) (*requests.Pager, error) {
|
||||
if err := requireTenantAdmin(tenantUser); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"tenant_id": tenant.ID,
|
||||
"user_id": tenantUser.UserID,
|
||||
}).Info("tenant.admin.orders.list")
|
||||
|
||||
return services.Order.AdminOrderPage(ctx, tenant.ID, filter)
|
||||
}
|
||||
|
||||
// adminOrderDetail
|
||||
//
|
||||
// @Summary 订单详情(租户管理)
|
||||
// @Tags Tenant
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param tenantCode path string true "Tenant Code"
|
||||
// @Param orderID path int64 true "OrderID"
|
||||
// @Success 200 {object} dto.AdminOrderDetail
|
||||
//
|
||||
// @Router /t/:tenantCode/v1/admin/orders/:orderID [get]
|
||||
// @Bind tenant local key(tenant)
|
||||
// @Bind tenantUser local key(tenant_user)
|
||||
// @Bind orderID path
|
||||
func (*orderAdmin) adminOrderDetail(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *models.TenantUser, orderID int64) (*dto.AdminOrderDetail, error) {
|
||||
if err := requireTenantAdmin(tenantUser); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"tenant_id": tenant.ID,
|
||||
"user_id": tenantUser.UserID,
|
||||
"order_id": orderID,
|
||||
}).Info("tenant.admin.orders.detail")
|
||||
|
||||
m, err := services.Order.AdminOrderDetail(ctx, tenant.ID, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.AdminOrderDetail{Order: m}, nil
|
||||
}
|
||||
|
||||
// adminRefund
|
||||
//
|
||||
// @Summary 订单退款(租户管理)
|
||||
// @Tags Tenant
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param tenantCode path string true "Tenant Code"
|
||||
// @Param orderID path int64 true "OrderID"
|
||||
// @Param form body dto.AdminOrderRefundForm true "Form"
|
||||
// @Success 200 {object} models.Order
|
||||
//
|
||||
// @Router /t/:tenantCode/v1/admin/orders/:orderID/refund [post]
|
||||
// @Bind tenant local key(tenant)
|
||||
// @Bind tenantUser local key(tenant_user)
|
||||
// @Bind orderID path
|
||||
// @Bind form body
|
||||
func (*orderAdmin) adminRefund(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *models.TenantUser, orderID int64, form *dto.AdminOrderRefundForm) (*models.Order, error) {
|
||||
if err := requireTenantAdmin(tenantUser); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if form == nil {
|
||||
return nil, errorx.ErrInvalidParameter
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"tenant_id": tenant.ID,
|
||||
"user_id": tenantUser.UserID,
|
||||
"order_id": orderID,
|
||||
"force": form.Force,
|
||||
"idempotency_key": form.IdempotencyKey,
|
||||
}).Info("tenant.admin.orders.refund")
|
||||
|
||||
return services.Order.AdminRefundOrder(ctx, tenant.ID, tenantUser.UserID, orderID, form.Force, form.Reason, form.IdempotencyKey, time.Now())
|
||||
}
|
||||
63
backend/app/http/tenant/order_me.go
Normal file
63
backend/app/http/tenant/order_me.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"quyun/v2/app/http/tenant/dto"
|
||||
"quyun/v2/app/requests"
|
||||
"quyun/v2/app/services"
|
||||
"quyun/v2/database/models"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// orderMe provides member order endpoints (my orders within a tenant).
|
||||
//
|
||||
// @provider
|
||||
type orderMe struct{}
|
||||
|
||||
// myOrders
|
||||
//
|
||||
// @Summary 我的订单列表(当前租户)
|
||||
// @Tags Tenant
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param tenantCode path string true "Tenant Code"
|
||||
// @Param filter query dto.MyOrderListFilter true "Filter"
|
||||
// @Success 200 {object} requests.Pager{items=models.Order}
|
||||
//
|
||||
// @Router /t/:tenantCode/v1/orders [get]
|
||||
// @Bind tenant local key(tenant)
|
||||
// @Bind user local key(user)
|
||||
// @Bind filter query
|
||||
func (*orderMe) myOrders(ctx fiber.Ctx, tenant *models.Tenant, user *models.User, filter *dto.MyOrderListFilter) (*requests.Pager, error) {
|
||||
log.WithFields(log.Fields{
|
||||
"tenant_id": tenant.ID,
|
||||
"user_id": user.ID,
|
||||
}).Info("tenant.orders.me.list")
|
||||
|
||||
return services.Order.MyOrderPage(ctx, tenant.ID, user.ID, filter)
|
||||
}
|
||||
|
||||
// myOrderDetail
|
||||
//
|
||||
// @Summary 我的订单详情(当前租户)
|
||||
// @Tags Tenant
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param tenantCode path string true "Tenant Code"
|
||||
// @Param orderID path int64 true "OrderID"
|
||||
// @Success 200 {object} models.Order
|
||||
//
|
||||
// @Router /t/:tenantCode/v1/orders/:orderID [get]
|
||||
// @Bind tenant local key(tenant)
|
||||
// @Bind user local key(user)
|
||||
// @Bind orderID path
|
||||
func (*orderMe) myOrderDetail(ctx fiber.Ctx, tenant *models.Tenant, user *models.User, orderID int64) (*models.Order, error) {
|
||||
log.WithFields(log.Fields{
|
||||
"tenant_id": tenant.ID,
|
||||
"user_id": user.ID,
|
||||
"order_id": orderID,
|
||||
}).Info("tenant.orders.me.detail")
|
||||
|
||||
return services.Order.MyOrderDetail(ctx, tenant.ID, user.ID, orderID)
|
||||
}
|
||||
@@ -31,17 +31,44 @@ func Provide(opts ...opt.Option) error {
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := container.Container.Provide(func() (*order, error) {
|
||||
obj := &order{}
|
||||
|
||||
return obj, nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := container.Container.Provide(func() (*orderAdmin, error) {
|
||||
obj := &orderAdmin{}
|
||||
|
||||
return obj, nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := container.Container.Provide(func() (*orderMe, error) {
|
||||
obj := &orderMe{}
|
||||
|
||||
return obj, nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := container.Container.Provide(func(
|
||||
content *content,
|
||||
contentAdmin *contentAdmin,
|
||||
me *me,
|
||||
middlewares *middlewares.Middlewares,
|
||||
order *order,
|
||||
orderAdmin *orderAdmin,
|
||||
orderMe *orderMe,
|
||||
) (contracts.HttpRoute, error) {
|
||||
obj := &Routes{
|
||||
content: content,
|
||||
contentAdmin: contentAdmin,
|
||||
me: me,
|
||||
middlewares: middlewares,
|
||||
order: order,
|
||||
orderAdmin: orderAdmin,
|
||||
orderMe: orderMe,
|
||||
}
|
||||
if err := obj.Prepare(); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
// Routes implements the HttpRoute contract and provides route registration
|
||||
// for all controllers in the tenant module.
|
||||
//
|
||||
// @provider contracts.HttpRoute atom.GroupRoutes
|
||||
// @provider contracts.HttpRoute atom.GroupRoutes
|
||||
type Routes struct {
|
||||
log *log.Entry `inject:"false"`
|
||||
middlewares *middlewares.Middlewares
|
||||
@@ -27,6 +27,9 @@ type Routes struct {
|
||||
content *content
|
||||
contentAdmin *contentAdmin
|
||||
me *me
|
||||
order *order
|
||||
orderAdmin *orderAdmin
|
||||
orderMe *orderMe
|
||||
}
|
||||
|
||||
// Prepare initializes the routes provider with logging configuration.
|
||||
@@ -113,6 +116,53 @@ func (r *Routes) Register(router fiber.Router) {
|
||||
Local[*models.User]("user"),
|
||||
Local[*models.TenantUser]("tenant_user"),
|
||||
))
|
||||
// Register routes for controller: order
|
||||
r.log.Debugf("Registering route: Post /t/:tenantCode/v1/contents/:contentID/purchase -> order.purchaseContent")
|
||||
router.Post("/t/:tenantCode/v1/contents/:contentID/purchase"[len(r.Path()):], DataFunc4(
|
||||
r.order.purchaseContent,
|
||||
Local[*models.Tenant]("tenant"),
|
||||
Local[*models.User]("user"),
|
||||
PathParam[int64]("contentID"),
|
||||
Body[dto.PurchaseContentForm]("form"),
|
||||
))
|
||||
// Register routes for controller: orderAdmin
|
||||
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/admin/orders -> orderAdmin.adminOrderList")
|
||||
router.Get("/t/:tenantCode/v1/admin/orders"[len(r.Path()):], DataFunc3(
|
||||
r.orderAdmin.adminOrderList,
|
||||
Local[*models.Tenant]("tenant"),
|
||||
Local[*models.TenantUser]("tenant_user"),
|
||||
Query[dto.AdminOrderListFilter]("filter"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/admin/orders/:orderID -> orderAdmin.adminOrderDetail")
|
||||
router.Get("/t/:tenantCode/v1/admin/orders/:orderID"[len(r.Path()):], DataFunc3(
|
||||
r.orderAdmin.adminOrderDetail,
|
||||
Local[*models.Tenant]("tenant"),
|
||||
Local[*models.TenantUser]("tenant_user"),
|
||||
PathParam[int64]("orderID"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Post /t/:tenantCode/v1/admin/orders/:orderID/refund -> orderAdmin.adminRefund")
|
||||
router.Post("/t/:tenantCode/v1/admin/orders/:orderID/refund"[len(r.Path()):], DataFunc4(
|
||||
r.orderAdmin.adminRefund,
|
||||
Local[*models.Tenant]("tenant"),
|
||||
Local[*models.TenantUser]("tenant_user"),
|
||||
PathParam[int64]("orderID"),
|
||||
Body[dto.AdminOrderRefundForm]("form"),
|
||||
))
|
||||
// Register routes for controller: orderMe
|
||||
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/orders -> orderMe.myOrders")
|
||||
router.Get("/t/:tenantCode/v1/orders"[len(r.Path()):], DataFunc3(
|
||||
r.orderMe.myOrders,
|
||||
Local[*models.Tenant]("tenant"),
|
||||
Local[*models.User]("user"),
|
||||
Query[dto.MyOrderListFilter]("filter"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/orders/:orderID -> orderMe.myOrderDetail")
|
||||
router.Get("/t/:tenantCode/v1/orders/:orderID"[len(r.Path()):], DataFunc3(
|
||||
r.orderMe.myOrderDetail,
|
||||
Local[*models.Tenant]("tenant"),
|
||||
Local[*models.User]("user"),
|
||||
PathParam[int64]("orderID"),
|
||||
))
|
||||
|
||||
r.log.Info("Successfully registered all routes")
|
||||
}
|
||||
|
||||
217
backend/app/services/ledger.go
Normal file
217
backend/app/services/ledger.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"quyun/v2/app/errorx"
|
||||
"quyun/v2/database/models"
|
||||
"quyun/v2/pkg/consts"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// LedgerApplyResult is the result of a single ledger application, including the created ledger record
|
||||
// and the updated tenant user balance snapshot.
|
||||
type LedgerApplyResult struct {
|
||||
// Ledger is the created ledger record (or existing one if idempotent hit).
|
||||
Ledger *models.TenantLedger
|
||||
// TenantUser is the updated tenant user record reflecting the post-apply balances.
|
||||
TenantUser *models.TenantUser
|
||||
}
|
||||
|
||||
// ledger provides tenant balance ledger operations (freeze/unfreeze/etc.) with idempotency and row-locking.
|
||||
//
|
||||
// @provider
|
||||
type ledger struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// Freeze moves funds from available balance to frozen balance and records a tenant ledger entry.
|
||||
func (s *ledger) Freeze(ctx context.Context, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
|
||||
return s.apply(ctx, s.db, tenantID, userID, orderID, consts.TenantLedgerTypeFreeze, amount, -amount, amount, idempotencyKey, remark, now)
|
||||
}
|
||||
|
||||
// Unfreeze moves funds from frozen balance back to available balance and records a tenant ledger entry.
|
||||
func (s *ledger) Unfreeze(ctx context.Context, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
|
||||
return s.apply(ctx, s.db, tenantID, userID, orderID, consts.TenantLedgerTypeUnfreeze, amount, amount, -amount, idempotencyKey, remark, now)
|
||||
}
|
||||
|
||||
// FreezeTx is the transaction-scoped variant of Freeze.
|
||||
func (s *ledger) FreezeTx(ctx context.Context, tx *gorm.DB, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
|
||||
return s.apply(ctx, tx, tenantID, userID, orderID, consts.TenantLedgerTypeFreeze, amount, -amount, amount, idempotencyKey, remark, now)
|
||||
}
|
||||
|
||||
// UnfreezeTx is the transaction-scoped variant of Unfreeze.
|
||||
func (s *ledger) UnfreezeTx(ctx context.Context, tx *gorm.DB, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
|
||||
return s.apply(ctx, tx, tenantID, userID, orderID, consts.TenantLedgerTypeUnfreeze, amount, amount, -amount, idempotencyKey, remark, now)
|
||||
}
|
||||
|
||||
// DebitPurchaseTx turns frozen funds into a finalized debit (reduces frozen balance) and records a ledger entry.
|
||||
func (s *ledger) DebitPurchaseTx(ctx context.Context, tx *gorm.DB, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
|
||||
return s.apply(ctx, tx, tenantID, userID, orderID, consts.TenantLedgerTypeDebitPurchase, amount, 0, -amount, idempotencyKey, remark, now)
|
||||
}
|
||||
|
||||
// CreditRefundTx credits funds back to available balance and records a ledger entry.
|
||||
func (s *ledger) CreditRefundTx(ctx context.Context, tx *gorm.DB, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
|
||||
return s.apply(ctx, tx, tenantID, userID, orderID, consts.TenantLedgerTypeCreditRefund, amount, amount, 0, idempotencyKey, remark, now)
|
||||
}
|
||||
|
||||
func (s *ledger) apply(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
tenantID, userID, orderID int64,
|
||||
ledgerType consts.TenantLedgerType,
|
||||
amount, deltaBalance, deltaFrozen int64,
|
||||
idempotencyKey, remark string,
|
||||
now time.Time,
|
||||
) (*LedgerApplyResult, error) {
|
||||
if amount <= 0 {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("amount must be > 0")
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"tenant_id": tenantID,
|
||||
"user_id": userID,
|
||||
"order_id": orderID,
|
||||
"type": ledgerType,
|
||||
"amount": amount,
|
||||
"idempotency_key": idempotencyKey,
|
||||
"delta_balance": deltaBalance,
|
||||
"delta_frozen": deltaFrozen,
|
||||
"remark_non_empty": remark != "",
|
||||
}).Info("services.ledger.apply")
|
||||
|
||||
var out LedgerApplyResult
|
||||
|
||||
err := tx.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if idempotencyKey != "" {
|
||||
var existing models.TenantLedger
|
||||
if err := tx.
|
||||
Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, userID, idempotencyKey).
|
||||
First(&existing).Error; err == nil {
|
||||
var current models.TenantUser
|
||||
if err := tx.Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(¤t).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
out.Ledger = &existing
|
||||
out.TenantUser = ¤t
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var tu models.TenantUser
|
||||
if err := tx.
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("tenant_id = ? AND user_id = ?", tenantID, userID).
|
||||
First(&tu).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errorx.ErrRecordNotFound.WithMsg("tenant user not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if idempotencyKey != "" {
|
||||
var existing models.TenantLedger
|
||||
if err := tx.
|
||||
Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, userID, idempotencyKey).
|
||||
First(&existing).Error; err == nil {
|
||||
out.Ledger = &existing
|
||||
out.TenantUser = &tu
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
balanceBefore := tu.Balance
|
||||
frozenBefore := tu.BalanceFrozen
|
||||
balanceAfter := balanceBefore + deltaBalance
|
||||
frozenAfter := frozenBefore + deltaFrozen
|
||||
|
||||
if balanceAfter < 0 {
|
||||
return errorx.ErrPreconditionFailed.WithMsg("余额不足")
|
||||
}
|
||||
if frozenAfter < 0 {
|
||||
return errorx.ErrPreconditionFailed.WithMsg("冻结余额不足")
|
||||
}
|
||||
|
||||
if err := tx.Model(&models.TenantUser{}).
|
||||
Where("id = ?", tu.ID).
|
||||
Updates(map[string]any{
|
||||
"balance": balanceAfter,
|
||||
"balance_frozen": frozenAfter,
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ledger := &models.TenantLedger{
|
||||
TenantID: tenantID,
|
||||
UserID: userID,
|
||||
OrderID: orderID,
|
||||
Type: ledgerType,
|
||||
Amount: amount,
|
||||
BalanceBefore: balanceBefore,
|
||||
BalanceAfter: balanceAfter,
|
||||
FrozenBefore: frozenBefore,
|
||||
FrozenAfter: frozenAfter,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
Remark: remark,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := tx.Create(ledger).Error; err != nil {
|
||||
if idempotencyKey != "" {
|
||||
var existing models.TenantLedger
|
||||
if e2 := tx.
|
||||
Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, userID, idempotencyKey).
|
||||
First(&existing).Error; e2 == nil {
|
||||
out.Ledger = &existing
|
||||
out.TenantUser = &tu
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
tu.Balance = balanceAfter
|
||||
tu.BalanceFrozen = frozenAfter
|
||||
tu.UpdatedAt = now
|
||||
|
||||
out.Ledger = ledger
|
||||
out.TenantUser = &tu
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"tenant_id": tenantID,
|
||||
"user_id": userID,
|
||||
"order_id": orderID,
|
||||
"type": ledgerType,
|
||||
"idempotency_key": idempotencyKey,
|
||||
}).WithError(err).Warn("services.ledger.apply.failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"tenant_id": tenantID,
|
||||
"user_id": userID,
|
||||
"order_id": orderID,
|
||||
"type": ledgerType,
|
||||
"ledger_id": out.Ledger.ID,
|
||||
"idempotency_key": idempotencyKey,
|
||||
"balance_after": out.TenantUser.Balance,
|
||||
"frozen_after": out.TenantUser.BalanceFrozen,
|
||||
}).Info("services.ledger.apply.ok")
|
||||
|
||||
return &out, nil
|
||||
}
|
||||
612
backend/app/services/order.go
Normal file
612
backend/app/services/order.go
Normal file
@@ -0,0 +1,612 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"quyun/v2/app/errorx"
|
||||
"quyun/v2/app/http/tenant/dto"
|
||||
"quyun/v2/app/requests"
|
||||
"quyun/v2/database/models"
|
||||
"quyun/v2/pkg/consts"
|
||||
|
||||
pkgerrors "github.com/pkg/errors"
|
||||
"github.com/samber/lo"
|
||||
"github.com/sirupsen/logrus"
|
||||
"go.ipao.vip/gen"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"go.ipao.vip/gen/types"
|
||||
)
|
||||
|
||||
// PurchaseContentParams defines parameters for purchasing a content within a tenant using tenant balance.
|
||||
type PurchaseContentParams struct {
|
||||
// TenantID is the tenant scope.
|
||||
TenantID int64
|
||||
// UserID is the buyer user id.
|
||||
UserID int64
|
||||
// ContentID is the target content id.
|
||||
ContentID int64
|
||||
// IdempotencyKey is used to ensure a purchase request is processed at most once.
|
||||
IdempotencyKey string
|
||||
// Now is the logical time used for created_at/paid_at and ledger snapshots (optional).
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
// PurchaseContentResult is returned after purchase attempt (idempotent hit returns existing order/access state).
|
||||
type PurchaseContentResult struct {
|
||||
// Order is the created or existing order record (may be nil when already purchased without order context).
|
||||
Order *models.Order
|
||||
// OrderItem is the related order item record (single-item purchase).
|
||||
OrderItem *models.OrderItem
|
||||
// Access is the content access record after purchase grant.
|
||||
Access *models.ContentAccess
|
||||
// AmountPaid is the final paid amount in cents (CNY 分).
|
||||
AmountPaid int64
|
||||
}
|
||||
|
||||
// order provides order domain operations.
|
||||
//
|
||||
// @provider
|
||||
type order struct {
|
||||
db *gorm.DB
|
||||
ledger *ledger
|
||||
}
|
||||
|
||||
// MyOrderPage lists orders for current user within a tenant.
|
||||
func (s *order) MyOrderPage(ctx context.Context, tenantID, userID int64, filter *dto.MyOrderListFilter) (*requests.Pager, error) {
|
||||
if tenantID <= 0 || userID <= 0 {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id must be > 0")
|
||||
}
|
||||
if filter == nil {
|
||||
filter = &dto.MyOrderListFilter{}
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"tenant_id": tenantID,
|
||||
"user_id": userID,
|
||||
"status": lo.FromPtr(filter.Status),
|
||||
}).Info("services.order.me.page")
|
||||
|
||||
filter.Pagination.Format()
|
||||
|
||||
tbl, query := models.OrderQuery.QueryContext(ctx)
|
||||
query = query.Preload(tbl.Items)
|
||||
|
||||
conds := []gen.Condition{
|
||||
tbl.TenantID.Eq(tenantID),
|
||||
tbl.UserID.Eq(userID),
|
||||
}
|
||||
if filter.Status != nil {
|
||||
conds = append(conds, tbl.Status.Eq(*filter.Status))
|
||||
}
|
||||
|
||||
items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &requests.Pager{
|
||||
Pagination: filter.Pagination,
|
||||
Total: total,
|
||||
Items: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MyOrderDetail returns order detail for current user within a tenant.
|
||||
func (s *order) MyOrderDetail(ctx context.Context, tenantID, userID, orderID int64) (*models.Order, error) {
|
||||
if tenantID <= 0 || userID <= 0 || orderID <= 0 {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id/order_id must be > 0")
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"tenant_id": tenantID,
|
||||
"user_id": userID,
|
||||
"order_id": orderID,
|
||||
}).Info("services.order.me.detail")
|
||||
|
||||
tbl, query := models.OrderQuery.QueryContext(ctx)
|
||||
m, err := query.Preload(tbl.Items).Where(
|
||||
tbl.TenantID.Eq(tenantID),
|
||||
tbl.UserID.Eq(userID),
|
||||
tbl.ID.Eq(orderID),
|
||||
).First()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// AdminOrderPage lists orders within a tenant for tenant-admin.
|
||||
func (s *order) AdminOrderPage(ctx context.Context, tenantID int64, filter *dto.AdminOrderListFilter) (*requests.Pager, error) {
|
||||
if tenantID <= 0 {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0")
|
||||
}
|
||||
if filter == nil {
|
||||
filter = &dto.AdminOrderListFilter{}
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"tenant_id": tenantID,
|
||||
"user_id": lo.FromPtr(filter.UserID),
|
||||
"status": lo.FromPtr(filter.Status),
|
||||
}).Info("services.order.admin.page")
|
||||
|
||||
filter.Pagination.Format()
|
||||
|
||||
tbl, query := models.OrderQuery.QueryContext(ctx)
|
||||
query = query.Preload(tbl.Items)
|
||||
|
||||
conds := []gen.Condition{tbl.TenantID.Eq(tenantID)}
|
||||
if filter.UserID != nil {
|
||||
conds = append(conds, tbl.UserID.Eq(*filter.UserID))
|
||||
}
|
||||
if filter.Status != nil {
|
||||
conds = append(conds, tbl.Status.Eq(*filter.Status))
|
||||
}
|
||||
|
||||
items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &requests.Pager{
|
||||
Pagination: filter.Pagination,
|
||||
Total: total,
|
||||
Items: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AdminOrderDetail returns an order detail within a tenant for tenant-admin.
|
||||
func (s *order) AdminOrderDetail(ctx context.Context, tenantID, orderID int64) (*models.Order, error) {
|
||||
if tenantID <= 0 || orderID <= 0 {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/order_id must be > 0")
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"tenant_id": tenantID,
|
||||
"order_id": orderID,
|
||||
}).Info("services.order.admin.detail")
|
||||
|
||||
tbl, query := models.OrderQuery.QueryContext(ctx)
|
||||
m, err := query.Preload(tbl.Items).Where(tbl.TenantID.Eq(tenantID), tbl.ID.Eq(orderID)).First()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// AdminRefundOrder refunds a paid order (supports forced refund) and revokes granted content access.
|
||||
func (s *order) AdminRefundOrder(ctx context.Context, tenantID, operatorUserID, orderID int64, force bool, reason, idempotencyKey string, now time.Time) (*models.Order, error) {
|
||||
if tenantID <= 0 || operatorUserID <= 0 || orderID <= 0 {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/operator_user_id/order_id must be > 0")
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"tenant_id": tenantID,
|
||||
"operator_user_id": operatorUserID,
|
||||
"order_id": orderID,
|
||||
"force": force,
|
||||
"idempotency_key": idempotencyKey,
|
||||
}).Info("services.order.admin.refund")
|
||||
|
||||
var out *models.Order
|
||||
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var orderModel models.Order
|
||||
if err := tx.
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Preload("Items").
|
||||
Where("tenant_id = ? AND id = ?", tenantID, orderID).
|
||||
First(&orderModel).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if orderModel.Status == consts.OrderStatusRefunded {
|
||||
out = &orderModel
|
||||
return nil
|
||||
}
|
||||
if orderModel.Status != consts.OrderStatusPaid {
|
||||
return errorx.ErrStatusConflict.WithMsg("订单非已支付状态,无法退款")
|
||||
}
|
||||
if orderModel.PaidAt.IsZero() {
|
||||
return errorx.ErrPreconditionFailed.WithMsg("订单缺少 paid_at,无法退款")
|
||||
}
|
||||
|
||||
if !force {
|
||||
deadline := orderModel.PaidAt.Add(consts.DefaultOrderRefundWindow)
|
||||
if now.After(deadline) {
|
||||
return errorx.ErrPreconditionFailed.WithMsg("已超过默认退款时间窗")
|
||||
}
|
||||
}
|
||||
|
||||
amount := orderModel.AmountPaid
|
||||
refundKey := fmt.Sprintf("refund:%d", orderModel.ID)
|
||||
|
||||
if amount > 0 {
|
||||
if _, err := s.ledger.CreditRefundTx(ctx, tx, tenantID, orderModel.UserID, orderModel.ID, amount, refundKey, reason, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// revoke content access immediately
|
||||
for _, item := range orderModel.Items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
if err := tx.Table(models.TableNameContentAccess).
|
||||
Where("tenant_id = ? AND user_id = ? AND content_id = ?", tenantID, orderModel.UserID, item.ContentID).
|
||||
Updates(map[string]any{
|
||||
"status": consts.ContentAccessStatusRevoked,
|
||||
"revoked_at": now,
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Table(models.TableNameOrder).
|
||||
Where("id = ?", orderModel.ID).
|
||||
Updates(map[string]any{
|
||||
"status": consts.OrderStatusRefunded,
|
||||
"refunded_at": now,
|
||||
"refund_forced": force,
|
||||
"refund_operator_user_id": operatorUserID,
|
||||
"refund_reason": reason,
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
orderModel.Status = consts.OrderStatusRefunded
|
||||
orderModel.RefundedAt = now
|
||||
orderModel.RefundForced = force
|
||||
orderModel.RefundOperatorUserID = operatorUserID
|
||||
orderModel.RefundReason = reason
|
||||
orderModel.UpdatedAt = now
|
||||
|
||||
out = &orderModel
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"tenant_id": tenantID,
|
||||
"operator_user_id": operatorUserID,
|
||||
"order_id": orderID,
|
||||
"force": force,
|
||||
"idempotency_key": idempotencyKey,
|
||||
}).WithError(err).Warn("services.order.admin.refund.failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"tenant_id": tenantID,
|
||||
"operator_user_id": operatorUserID,
|
||||
"order_id": orderID,
|
||||
"status": out.Status,
|
||||
"refund_forced": out.RefundForced,
|
||||
}).Info("services.order.admin.refund.ok")
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentParams) (*PurchaseContentResult, error) {
|
||||
if params == nil {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("params is required")
|
||||
}
|
||||
if params.TenantID <= 0 || params.UserID <= 0 || params.ContentID <= 0 {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id/content_id must be > 0")
|
||||
}
|
||||
|
||||
now := params.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"tenant_id": params.TenantID,
|
||||
"user_id": params.UserID,
|
||||
"content_id": params.ContentID,
|
||||
"idempotency_key": params.IdempotencyKey,
|
||||
}).Info("services.order.purchase_content")
|
||||
|
||||
var out PurchaseContentResult
|
||||
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if params.IdempotencyKey != "" {
|
||||
var existing models.Order
|
||||
if err := tx.
|
||||
Preload("Items").
|
||||
Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", params.TenantID, params.UserID, params.IdempotencyKey).
|
||||
First(&existing).Error; err == nil {
|
||||
out.Order = &existing
|
||||
if len(existing.Items) > 0 {
|
||||
out.OrderItem = existing.Items[0]
|
||||
}
|
||||
if out.OrderItem != nil {
|
||||
var access models.ContentAccess
|
||||
if err := tx.
|
||||
Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, out.OrderItem.ContentID).
|
||||
First(&access).Error; err == nil {
|
||||
out.Access = &access
|
||||
}
|
||||
}
|
||||
out.AmountPaid = existing.AmountPaid
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var content models.Content
|
||||
if err := tx.
|
||||
Where("tenant_id = ? AND id = ? AND deleted_at IS NULL", params.TenantID, params.ContentID).
|
||||
First(&content).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errorx.ErrRecordNotFound.WithMsg("content not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if content.Status != consts.ContentStatusPublished {
|
||||
return errorx.ErrPreconditionFailed.WithMsg("content not published")
|
||||
}
|
||||
|
||||
if content.UserID == params.UserID {
|
||||
out.AmountPaid = 0
|
||||
if err := s.grantAccess(ctx, tx, params.TenantID, params.UserID, params.ContentID, 0, now); err != nil {
|
||||
return err
|
||||
}
|
||||
var access models.ContentAccess
|
||||
if err := tx.Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID).First(&access).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
out.Access = &access
|
||||
return nil
|
||||
}
|
||||
|
||||
var accessExisting models.ContentAccess
|
||||
if err := tx.
|
||||
Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID).
|
||||
First(&accessExisting).Error; err == nil {
|
||||
if accessExisting.Status == consts.ContentAccessStatusActive {
|
||||
out.Access = &accessExisting
|
||||
return nil
|
||||
}
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
var price models.ContentPrice
|
||||
priceAmount := int64(0)
|
||||
if err := tx.Where("tenant_id = ? AND content_id = ?", params.TenantID, params.ContentID).First(&price).Error; err == nil {
|
||||
priceAmount = price.PriceAmount
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
amountPaid := s.computeFinalPrice(priceAmount, &price, now)
|
||||
out.AmountPaid = amountPaid
|
||||
|
||||
if amountPaid == 0 {
|
||||
orderModel := &models.Order{
|
||||
TenantID: params.TenantID,
|
||||
UserID: params.UserID,
|
||||
Type: consts.OrderTypeContentPurchase,
|
||||
Status: consts.OrderStatusPaid,
|
||||
Currency: consts.CurrencyCNY,
|
||||
AmountOriginal: priceAmount,
|
||||
AmountDiscount: priceAmount - amountPaid,
|
||||
AmountPaid: amountPaid,
|
||||
Snapshot: types.JSON([]byte("{}")),
|
||||
IdempotencyKey: params.IdempotencyKey,
|
||||
PaidAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := tx.Create(orderModel).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
item := &models.OrderItem{
|
||||
TenantID: params.TenantID,
|
||||
UserID: params.UserID,
|
||||
OrderID: orderModel.ID,
|
||||
ContentID: params.ContentID,
|
||||
ContentUserID: content.UserID,
|
||||
AmountPaid: amountPaid,
|
||||
Snapshot: types.JSON([]byte("{}")),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := tx.Create(item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.grantAccess(ctx, tx, params.TenantID, params.UserID, params.ContentID, orderModel.ID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
var access models.ContentAccess
|
||||
if err := tx.Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID).First(&access).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
out.Order = orderModel
|
||||
out.OrderItem = item
|
||||
out.Access = &access
|
||||
return nil
|
||||
}
|
||||
|
||||
orderModel := &models.Order{
|
||||
TenantID: params.TenantID,
|
||||
UserID: params.UserID,
|
||||
Type: consts.OrderTypeContentPurchase,
|
||||
Status: consts.OrderStatusCreated,
|
||||
Currency: consts.CurrencyCNY,
|
||||
AmountOriginal: priceAmount,
|
||||
AmountDiscount: priceAmount - amountPaid,
|
||||
AmountPaid: amountPaid,
|
||||
Snapshot: types.JSON([]byte("{}")),
|
||||
IdempotencyKey: params.IdempotencyKey,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
freezeKey := fmt.Sprintf("%s:freeze", params.IdempotencyKey)
|
||||
if params.IdempotencyKey == "" {
|
||||
freezeKey = ""
|
||||
}
|
||||
if _, err := s.ledger.FreezeTx(ctx, tx, params.TenantID, params.UserID, 0, amountPaid, freezeKey, "purchase freeze", now); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Create(orderModel).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
item := &models.OrderItem{
|
||||
TenantID: params.TenantID,
|
||||
UserID: params.UserID,
|
||||
OrderID: orderModel.ID,
|
||||
ContentID: params.ContentID,
|
||||
ContentUserID: content.UserID,
|
||||
AmountPaid: amountPaid,
|
||||
Snapshot: types.JSON([]byte("{}")),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := tx.Create(item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
debitKey := fmt.Sprintf("%s:debit", params.IdempotencyKey)
|
||||
if params.IdempotencyKey == "" {
|
||||
debitKey = ""
|
||||
}
|
||||
if _, err := s.ledger.DebitPurchaseTx(ctx, tx, params.TenantID, params.UserID, orderModel.ID, amountPaid, debitKey, "purchase debit", now); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(&models.Order{}).
|
||||
Where("id = ?", orderModel.ID).
|
||||
Updates(map[string]any{
|
||||
"status": consts.OrderStatusPaid,
|
||||
"paid_at": now,
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.grantAccess(ctx, tx, params.TenantID, params.UserID, params.ContentID, orderModel.ID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var access models.ContentAccess
|
||||
if err := tx.Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID).First(&access).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out.Order = orderModel
|
||||
out.OrderItem = item
|
||||
out.Access = &access
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"tenant_id": params.TenantID,
|
||||
"user_id": params.UserID,
|
||||
"content_id": params.ContentID,
|
||||
"idempotency_key": params.IdempotencyKey,
|
||||
}).WithError(err).Warn("services.order.purchase_content.failed")
|
||||
return nil, pkgerrors.Wrap(err, "purchase content failed")
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"tenant_id": params.TenantID,
|
||||
"user_id": params.UserID,
|
||||
"content_id": params.ContentID,
|
||||
"order_id": loID(out.Order),
|
||||
"amount_paid": out.AmountPaid,
|
||||
"idempotency_key": params.IdempotencyKey,
|
||||
}).Info("services.order.purchase_content.ok")
|
||||
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (s *order) computeFinalPrice(priceAmount int64, price *models.ContentPrice, now time.Time) int64 {
|
||||
if priceAmount <= 0 || price == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
discountType := price.DiscountType
|
||||
if discountType == "" {
|
||||
discountType = consts.DiscountTypeNone
|
||||
}
|
||||
|
||||
if !price.DiscountStartAt.IsZero() && now.Before(price.DiscountStartAt) {
|
||||
return priceAmount
|
||||
}
|
||||
if !price.DiscountEndAt.IsZero() && now.After(price.DiscountEndAt) {
|
||||
return priceAmount
|
||||
}
|
||||
|
||||
switch discountType {
|
||||
case consts.DiscountTypePercent:
|
||||
percent := price.DiscountValue
|
||||
if percent <= 0 {
|
||||
return priceAmount
|
||||
}
|
||||
if percent >= 100 {
|
||||
return 0
|
||||
}
|
||||
return priceAmount * (100 - percent) / 100
|
||||
case consts.DiscountTypeAmount:
|
||||
amount := price.DiscountValue
|
||||
if amount <= 0 {
|
||||
return priceAmount
|
||||
}
|
||||
if amount >= priceAmount {
|
||||
return 0
|
||||
}
|
||||
return priceAmount - amount
|
||||
default:
|
||||
return priceAmount
|
||||
}
|
||||
}
|
||||
|
||||
func (s *order) grantAccess(ctx context.Context, tx *gorm.DB, tenantID, userID, contentID, orderID int64, now time.Time) error {
|
||||
insert := map[string]any{
|
||||
"tenant_id": tenantID,
|
||||
"user_id": userID,
|
||||
"content_id": contentID,
|
||||
"order_id": orderID,
|
||||
"status": consts.ContentAccessStatusActive,
|
||||
"revoked_at": nil,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
if err := tx.Table(models.TableNameContentAccess).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "tenant_id"}, {Name: "user_id"}, {Name: "content_id"}},
|
||||
DoUpdates: clause.Assignments(map[string]any{
|
||||
"order_id": orderID,
|
||||
"status": consts.ContentAccessStatusActive,
|
||||
"revoked_at": nil,
|
||||
"updated_at": now,
|
||||
}),
|
||||
}).
|
||||
Create(insert).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loID(m *models.Order) int64 {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
return m.ID
|
||||
}
|
||||
@@ -16,9 +16,35 @@ func Provide(opts ...opt.Option) error {
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := container.Container.Provide(func(
|
||||
db *gorm.DB,
|
||||
) (*ledger, error) {
|
||||
obj := &ledger{
|
||||
db: db,
|
||||
}
|
||||
|
||||
return obj, nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := container.Container.Provide(func(
|
||||
db *gorm.DB,
|
||||
ledger *ledger,
|
||||
) (*order, error) {
|
||||
obj := &order{
|
||||
db: db,
|
||||
ledger: ledger,
|
||||
}
|
||||
|
||||
return obj, nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := container.Container.Provide(func(
|
||||
content *content,
|
||||
db *gorm.DB,
|
||||
ledger *ledger,
|
||||
order *order,
|
||||
tenant *tenant,
|
||||
test *test,
|
||||
user *user,
|
||||
@@ -26,6 +52,8 @@ func Provide(opts ...opt.Option) error {
|
||||
obj := &services{
|
||||
content: content,
|
||||
db: db,
|
||||
ledger: ledger,
|
||||
order: order,
|
||||
tenant: tenant,
|
||||
test: test,
|
||||
user: user,
|
||||
|
||||
@@ -9,6 +9,8 @@ var _db *gorm.DB
|
||||
// exported CamelCase Services
|
||||
var (
|
||||
Content *content
|
||||
Ledger *ledger
|
||||
Order *order
|
||||
Tenant *tenant
|
||||
Test *test
|
||||
User *user
|
||||
@@ -19,6 +21,8 @@ type services struct {
|
||||
db *gorm.DB
|
||||
// define Services
|
||||
content *content
|
||||
ledger *ledger
|
||||
order *order
|
||||
tenant *tenant
|
||||
test *test
|
||||
user *user
|
||||
@@ -29,6 +33,8 @@ func (svc *services) Prepare() error {
|
||||
|
||||
// set exported Services here
|
||||
Content = svc.content
|
||||
Ledger = svc.ledger
|
||||
Order = svc.order
|
||||
Tenant = svc.tenant
|
||||
Test = svc.test
|
||||
User = svc.user
|
||||
|
||||
Reference in New Issue
Block a user