From a232e73358d95e2200b9f73f3497e9d4b795f04c Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 18 Dec 2025 14:09:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=A7=9F=E6=88=B7?= =?UTF-8?q?=E6=88=90=E5=91=98=E5=85=85=E5=80=BC=E5=8A=9F=E8=83=BD=E5=8F=8A?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/http/tenant/dto/topup_admin.go | 11 +++ backend/app/http/tenant/order_admin.go | 83 ++++++++++++++++- backend/app/http/tenant/routes.gen.go | 8 ++ backend/app/services/ledger.go | 5 + backend/app/services/order.go | 101 +++++++++++++++++++++ backend/docs/docs.go | 65 +++++++++++++ backend/docs/swagger.json | 65 +++++++++++++ backend/docs/swagger.yaml | 45 +++++++++ backend/tests/tenant.http | 12 +++ 9 files changed, 391 insertions(+), 4 deletions(-) create mode 100644 backend/app/http/tenant/dto/topup_admin.go diff --git a/backend/app/http/tenant/dto/topup_admin.go b/backend/app/http/tenant/dto/topup_admin.go new file mode 100644 index 0000000..fbe25e1 --- /dev/null +++ b/backend/app/http/tenant/dto/topup_admin.go @@ -0,0 +1,11 @@ +package dto + +// AdminTopupForm defines payload for tenant-admin to topup a tenant member balance. +type AdminTopupForm struct { + // Amount is the topup amount in cents (CNY 分); must be > 0. + Amount int64 `json:"amount,omitempty"` + // Reason is the human-readable topup reason used for audit. + Reason string `json:"reason,omitempty"` + // IdempotencyKey ensures the topup request is processed at most once. + IdempotencyKey string `json:"idempotency_key,omitempty"` +} diff --git a/backend/app/http/tenant/order_admin.go b/backend/app/http/tenant/order_admin.go index 678aeb8..52b9fe4 100644 --- a/backend/app/http/tenant/order_admin.go +++ b/backend/app/http/tenant/order_admin.go @@ -32,7 +32,12 @@ type orderAdmin struct{} // @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) { +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 } @@ -59,7 +64,12 @@ func (*orderAdmin) adminOrderList(ctx fiber.Ctx, tenant *models.Tenant, tenantUs // @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) { +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 } @@ -93,7 +103,13 @@ func (*orderAdmin) adminOrderDetail(ctx fiber.Ctx, tenant *models.Tenant, 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) { +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 } @@ -109,5 +125,64 @@ func (*orderAdmin) adminRefund(ctx fiber.Ctx, tenant *models.Tenant, tenantUser "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()) + return services.Order.AdminRefundOrder( + ctx, + tenant.ID, + tenantUser.UserID, + orderID, + form.Force, + form.Reason, + form.IdempotencyKey, + time.Now(), + ) +} + +// adminTopupUser +// +// @Summary 为租户成员充值(租户管理) +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param userID path int64 true "UserID" +// @Param form body dto.AdminTopupForm true "Form" +// @Success 200 {object} models.Order +// +// @Router /t/:tenantCode/v1/admin/users/:userID/topup [post] +// @Bind tenant local key(tenant) +// @Bind tenantUser local key(tenant_user) +// @Bind userID path +// @Bind form body +func (*orderAdmin) adminTopupUser( + ctx fiber.Ctx, + tenant *models.Tenant, + tenantUser *models.TenantUser, + userID int64, + form *dto.AdminTopupForm, +) (*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, + "operator_user": tenantUser.UserID, + "target_user": userID, + "amount": form.Amount, + "idempotency_key": form.IdempotencyKey, + }).Info("tenant.admin.users.topup") + + return services.Order.AdminTopupUser( + ctx, + tenant.ID, + tenantUser.UserID, + userID, + form.Amount, + form.IdempotencyKey, + form.Reason, + time.Now(), + ) } diff --git a/backend/app/http/tenant/routes.gen.go b/backend/app/http/tenant/routes.gen.go index 255afde..c0dbe9d 100644 --- a/backend/app/http/tenant/routes.gen.go +++ b/backend/app/http/tenant/routes.gen.go @@ -148,6 +148,14 @@ func (r *Routes) Register(router fiber.Router) { PathParam[int64]("orderID"), Body[dto.AdminOrderRefundForm]("form"), )) + r.log.Debugf("Registering route: Post /t/:tenantCode/v1/admin/users/:userID/topup -> orderAdmin.adminTopupUser") + router.Post("/t/:tenantCode/v1/admin/users/:userID/topup"[len(r.Path()):], DataFunc4( + r.orderAdmin.adminTopupUser, + Local[*models.Tenant]("tenant"), + Local[*models.TenantUser]("tenant_user"), + PathParam[int64]("userID"), + Body[dto.AdminTopupForm]("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( diff --git a/backend/app/services/ledger.go b/backend/app/services/ledger.go index 676c521..1d05bdc 100644 --- a/backend/app/services/ledger.go +++ b/backend/app/services/ledger.go @@ -60,6 +60,11 @@ func (s *ledger) CreditRefundTx(ctx context.Context, tx *gorm.DB, tenantID, user return s.apply(ctx, tx, tenantID, userID, orderID, consts.TenantLedgerTypeCreditRefund, amount, amount, 0, idempotencyKey, remark, now) } +// CreditTopupTx credits funds to available balance and records a ledger entry. +func (s *ledger) CreditTopupTx(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.TenantLedgerTypeCreditTopup, amount, amount, 0, idempotencyKey, remark, now) +} + func (s *ledger) apply( ctx context.Context, tx *gorm.DB, diff --git a/backend/app/services/order.go b/backend/app/services/order.go index ebbbc50..e8ec761 100644 --- a/backend/app/services/order.go +++ b/backend/app/services/order.go @@ -56,6 +56,107 @@ type order struct { ledger *ledger } +// AdminTopupUser credits tenant balance to a tenant member (tenant-admin action). +func (s *order) AdminTopupUser(ctx context.Context, tenantID, operatorUserID, targetUserID, amount int64, idempotencyKey, reason string, now time.Time) (*models.Order, error) { + if tenantID <= 0 || operatorUserID <= 0 || targetUserID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/operator_user_id/target_user_id must be > 0") + } + 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, + "operator_user": operatorUserID, + "target_user": targetUserID, + "amount": amount, + "idempotency_key": idempotencyKey, + }).Info("services.order.admin.topup_user") + + var out models.Order + + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // Ensure target user is a tenant member. + var tu models.TenantUser + if err := tx. + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("tenant_id = ? AND user_id = ?", tenantID, targetUserID). + First(&tu).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrPreconditionFailed.WithMsg("目标用户不属于该租户") + } + return err + } + + // Idempotent by (tenant_id, user_id, idempotency_key) on orders. + if idempotencyKey != "" { + var existing models.Order + if err := tx.Where( + "tenant_id = ? AND user_id = ? AND idempotency_key = ?", + tenantID, targetUserID, idempotencyKey, + ).First(&existing).Error; err == nil { + out = existing + return nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + } + + orderModel := models.Order{ + TenantID: tenantID, + UserID: targetUserID, + Type: consts.OrderTypeTopup, + Status: consts.OrderStatusPaid, + Currency: consts.CurrencyCNY, + AmountOriginal: amount, + AmountDiscount: 0, + AmountPaid: amount, + Snapshot: types.JSON([]byte("{}")), + IdempotencyKey: idempotencyKey, + PaidAt: now, + CreatedAt: now, + UpdatedAt: now, + } + if err := tx.Create(&orderModel).Error; err != nil { + return err + } + + ledgerKey := fmt.Sprintf("topup:%d", orderModel.ID) + remark := reason + if remark == "" { + remark = fmt.Sprintf("topup by tenant_admin:%d", operatorUserID) + } + if _, err := s.ledger.CreditTopupTx(ctx, tx, tenantID, targetUserID, orderModel.ID, amount, ledgerKey, remark, now); err != nil { + return err + } + + out = orderModel + return nil + }) + if err != nil { + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "operator_user": operatorUserID, + "target_user": targetUserID, + "amount": amount, + "idempotency_key": idempotencyKey, + }).WithError(err).Warn("services.order.admin.topup_user.failed") + return nil, err + } + + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "target_user": targetUserID, + "order_id": out.ID, + "amount": amount, + }).Info("services.order.admin.topup_user.ok") + + return &out, nil +} + // 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 { diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 3c221fa..23c4903 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -773,6 +773,54 @@ const docTemplate = `{ } } }, + "/t/{tenantCode}/v1/admin/users/{userID}/topup": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "为租户成员充值(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "UserID", + "name": "userID", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.AdminTopupForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Order" + } + } + } + } + }, "/t/{tenantCode}/v1/contents": { "get": { "consumes": [ @@ -1364,6 +1412,23 @@ const docTemplate = `{ } } }, + "dto.AdminTopupForm": { + "type": "object", + "properties": { + "amount": { + "description": "Amount is the topup amount in cents (CNY 分); must be \u003e 0.", + "type": "integer" + }, + "idempotency_key": { + "description": "IdempotencyKey ensures the topup request is processed at most once.", + "type": "string" + }, + "reason": { + "description": "Reason is the human-readable topup reason used for audit.", + "type": "string" + } + } + }, "dto.ContentAssetAttachForm": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index c59fdde..1e3f732 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -767,6 +767,54 @@ } } }, + "/t/{tenantCode}/v1/admin/users/{userID}/topup": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "为租户成员充值(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "UserID", + "name": "userID", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.AdminTopupForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Order" + } + } + } + } + }, "/t/{tenantCode}/v1/contents": { "get": { "consumes": [ @@ -1358,6 +1406,23 @@ } } }, + "dto.AdminTopupForm": { + "type": "object", + "properties": { + "amount": { + "description": "Amount is the topup amount in cents (CNY 分); must be \u003e 0.", + "type": "integer" + }, + "idempotency_key": { + "description": "IdempotencyKey ensures the topup request is processed at most once.", + "type": "string" + }, + "reason": { + "description": "Reason is the human-readable topup reason used for audit.", + "type": "string" + } + } + }, "dto.ContentAssetAttachForm": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 790ce13..0073916 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -169,6 +169,19 @@ definitions: 退款原因:建议必填(由业务侧校验);用于审计与追责。 type: string type: object + dto.AdminTopupForm: + properties: + amount: + description: Amount is the topup amount in cents (CNY 分); must be > 0. + type: integer + idempotency_key: + description: IdempotencyKey ensures the topup request is processed at most + once. + type: string + reason: + description: Reason is the human-readable topup reason used for audit. + type: string + type: object dto.ContentAssetAttachForm: properties: asset_id: @@ -1387,6 +1400,38 @@ paths: summary: 订单退款(租户管理) tags: - Tenant + /t/{tenantCode}/v1/admin/users/{userID}/topup: + post: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: UserID + format: int64 + in: path + name: userID + required: true + type: integer + - description: Form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.AdminTopupForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Order' + summary: 为租户成员充值(租户管理) + tags: + - Tenant /t/{tenantCode}/v1/contents: get: consumes: diff --git a/backend/tests/tenant.http b/backend/tests/tenant.http index 3026e00..5a54b58 100644 --- a/backend/tests/tenant.http +++ b/backend/tests/tenant.http @@ -119,3 +119,15 @@ Authorization: Bearer {{ token }} "reason": "联调退款", "idempotency_key": "refund-{{ orderID }}-001" } + +### Tenant Admin - Topup a tenant member +@topupUserID = 2 +POST {{ host }}/t/{{ tenantCode }}/v1/admin/users/{{ topupUserID }}/topup +Content-Type: application/json +Authorization: Bearer {{ token }} + +{ + "amount": 1000, + "reason": "联调充值", + "idempotency_key": "topup-{{ topupUserID }}-001" +}