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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user