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:
2025-12-18 13:12:26 +08:00
parent f93caefcb2
commit 1da84f2af3
42 changed files with 6468 additions and 265 deletions

View File

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

View 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"`
}

View 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"`
}

View 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"`
}

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

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

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

View File

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

View File

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

View 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(&current).Error; err != nil {
return err
}
out.Ledger = &existing
out.TenantUser = &current
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
}

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

View File

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

View File

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