diff --git a/backend/app/commands/testx/testing.go b/backend/app/commands/testx/testing.go index 42a97e4..bd23232 100644 --- a/backend/app/commands/testx/testing.go +++ b/backend/app/commands/testx/testing.go @@ -1,6 +1,7 @@ package testx import ( + "context" "testing" "quyun/v2/database" @@ -10,6 +11,7 @@ import ( "go.ipao.vip/atom" "go.ipao.vip/atom/container" + "go.uber.org/dig" "github.com/rogeecn/fabfile" . "github.com/smartystreets/goconvey/convey" @@ -26,6 +28,13 @@ func Default(providers ...container.ProviderContainer) container.Providers { func Serve(providers container.Providers, t *testing.T, invoke any) { Convey("tests boot up", t, func() { + // 关键语义:测试用例可能会在同一进程内多次调用 Serve。 + // atom/config.Load 会向全局 dig 容器重复 Provide *viper.Viper,若不重置会导致 “already provided”。 + // 因此每次测试启动前都重置容器,保证各测试套件相互独立。 + container.Close() + container.Container = dig.New() + So(container.Container.Provide(func() context.Context { return context.Background() }), ShouldBeNil) + file := fabfile.MustFind("config.toml") // localEnv := os.Getenv("ENV_LOCAL") diff --git a/backend/app/http/tenant/dto/order_admin_batch_topup.go b/backend/app/http/tenant/dto/order_admin_batch_topup.go new file mode 100644 index 0000000..73fe6ce --- /dev/null +++ b/backend/app/http/tenant/dto/order_admin_batch_topup.go @@ -0,0 +1,65 @@ +package dto + +// AdminBatchTopupItem 批量充值的单条明细。 +type AdminBatchTopupItem struct { + // UserID 目标用户ID:必须属于当前租户,否则该条充值失败。 + UserID int64 `json:"user_id"` + + // Amount 充值金额:单位分;必须 > 0。 + Amount int64 `json:"amount"` + + // Reason 充值原因(可选):用于审计与追溯。 + Reason string `json:"reason,omitempty"` + + // IdempotencyKey 幂等键(可选):为空时后端会用 batch_idempotency_key 派生生成; + // 建议前端/调用方提供稳定值,便于重试时保持结果一致。 + IdempotencyKey string `json:"idempotency_key,omitempty"` +} + +// AdminBatchTopupForm 租户管理员批量充值请求参数。 +type AdminBatchTopupForm struct { + // BatchIdempotencyKey 批次幂等键:必须填写;用于重试同一批次时保证不会重复入账。 + BatchIdempotencyKey string `json:"batch_idempotency_key"` + + // Items 充值明细列表:至少 1 条;单批次条数在业务侧限制(避免拖垮系统)。 + Items []*AdminBatchTopupItem `json:"items"` +} + +// AdminBatchTopupResultItem 批量充值的单条处理结果。 +type AdminBatchTopupResultItem struct { + // UserID 目标用户ID。 + UserID int64 `json:"user_id"` + + // Amount 充值金额(单位分)。 + Amount int64 `json:"amount"` + + // IdempotencyKey 实际使用的幂等键:可能为客户端传入,也可能为后端派生生成。 + IdempotencyKey string `json:"idempotency_key"` + + // OrderID 生成的订单ID:成功时返回;失败时为 0。 + OrderID int64 `json:"order_id,omitempty"` + + // OK 是否成功:true 表示该条充值已成功入账或命中幂等成功结果。 + OK bool `json:"ok"` + + // ErrorCode 错误码:失败时返回;成功时为 0。 + ErrorCode int `json:"error_code,omitempty"` + + // ErrorMessage 错误信息:失败时返回;成功时为空。 + ErrorMessage string `json:"error_message,omitempty"` +} + +// AdminBatchTopupResponse 批量充值的汇总结果。 +type AdminBatchTopupResponse struct { + // Total 总条数:等于 items 长度。 + Total int `json:"total"` + + // Success 成功条数。 + Success int `json:"success"` + + // Failed 失败条数。 + Failed int `json:"failed"` + + // Items 明细结果列表:与请求 items 顺序一致,便于前端逐条展示。 + Items []*AdminBatchTopupResultItem `json:"items"` +} diff --git a/backend/app/http/tenant/order_admin.go b/backend/app/http/tenant/order_admin.go index c3cb85a..a26648d 100644 --- a/backend/app/http/tenant/order_admin.go +++ b/backend/app/http/tenant/order_admin.go @@ -234,3 +234,39 @@ func (*orderAdmin) adminTopupUser( time.Now(), ) } + +// adminBatchTopupUsers +// +// @Summary 批量为租户成员充值(租户管理) +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param form body dto.AdminBatchTopupForm true "Form" +// @Success 200 {object} dto.AdminBatchTopupResponse +// +// @Router /t/:tenantCode/v1/admin/users/topup/batch [post] +// @Bind tenant local key(tenant) +// @Bind tenantUser local key(tenant_user) +// @Bind form body +func (*orderAdmin) adminBatchTopupUsers( + ctx fiber.Ctx, + tenant *models.Tenant, + tenantUser *models.TenantUser, + form *dto.AdminBatchTopupForm, +) (*dto.AdminBatchTopupResponse, 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, + "total": len(form.Items), + }).Info("tenant.admin.users.topup.batch") + + return services.Order.AdminBatchTopupUsers(ctx, tenant.ID, tenantUser.UserID, form, time.Now()) +} diff --git a/backend/app/http/tenant/routes.gen.go b/backend/app/http/tenant/routes.gen.go index bb17cf0..2d498c3 100644 --- a/backend/app/http/tenant/routes.gen.go +++ b/backend/app/http/tenant/routes.gen.go @@ -179,6 +179,13 @@ func (r *Routes) Register(router fiber.Router) { PathParam[int64]("userID"), Body[dto.AdminTopupForm]("form"), )) + r.log.Debugf("Registering route: Post /t/:tenantCode/v1/admin/users/topup/batch -> orderAdmin.adminBatchTopupUsers") + router.Post("/t/:tenantCode/v1/admin/users/topup/batch"[len(r.Path()):], DataFunc3( + r.orderAdmin.adminBatchTopupUsers, + Local[*models.Tenant]("tenant"), + Local[*models.TenantUser]("tenant_user"), + Body[dto.AdminBatchTopupForm]("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/order.go b/backend/app/services/order.go index a359335..bfb8e02 100644 --- a/backend/app/services/order.go +++ b/backend/app/services/order.go @@ -180,6 +180,131 @@ func (s *order) AdminOrderExportCSV(ctx context.Context, tenantID int64, filter }, nil } +// AdminBatchTopupUsers 租户管理员批量为成员充值(逐条幂等,允许部分失败)。 +func (s *order) AdminBatchTopupUsers( + ctx context.Context, + tenantID, operatorUserID int64, + form *dto.AdminBatchTopupForm, + now time.Time, +) (*dto.AdminBatchTopupResponse, error) { + if tenantID <= 0 || operatorUserID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/operator_user_id must be > 0") + } + if form == nil { + return nil, errorx.ErrInvalidParameter.WithMsg("form is required") + } + if strings.TrimSpace(form.BatchIdempotencyKey) == "" { + return nil, errorx.ErrInvalidParameter.WithMsg("batch_idempotency_key is required") + } + if len(form.Items) == 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("items is required") + } + if now.IsZero() { + now = time.Now() + } + + // 批量充值属于高敏感操作:限制单次条数,避免拖垮系统。 + const maxItems = 200 + if len(form.Items) > maxItems { + return nil, errorx.ErrInvalidParameter.WithMsg("items too many") + } + + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "operator_user_id": operatorUserID, + "batch_idempotency_key": form.BatchIdempotencyKey, + "total": len(form.Items), + }).Info("services.order.admin.batch_topup") + + out := &dto.AdminBatchTopupResponse{ + Total: len(form.Items), + Items: make([]*dto.AdminBatchTopupResultItem, 0, len(form.Items)), + } + + for idx, item := range form.Items { + if item == nil { + out.Failed++ + out.Items = append(out.Items, &dto.AdminBatchTopupResultItem{ + OK: false, + ErrorCode: int(errorx.CodeInvalidParameter), + ErrorMessage: "item is nil", + }) + continue + } + + idemKey := strings.TrimSpace(item.IdempotencyKey) + if idemKey == "" { + // 关键语义:为空时用批次幂等键派生,确保批次重试不会重复入账。 + idemKey = fmt.Sprintf("batch_topup:%s:%d:%d", strings.TrimSpace(form.BatchIdempotencyKey), item.UserID, idx) + } + + resultItem := &dto.AdminBatchTopupResultItem{ + UserID: item.UserID, + Amount: item.Amount, + IdempotencyKey: idemKey, + OK: false, + } + + // 单条参数校验:失败只影响该条,不影响整批(便于运营侧修正后重试)。 + if item.UserID <= 0 { + resultItem.ErrorCode = int(errorx.CodeInvalidParameter) + resultItem.ErrorMessage = "user_id must be > 0" + out.Failed++ + out.Items = append(out.Items, resultItem) + continue + } + if item.Amount <= 0 { + resultItem.ErrorCode = int(errorx.CodeInvalidParameter) + resultItem.ErrorMessage = "amount must be > 0" + out.Failed++ + out.Items = append(out.Items, resultItem) + continue + } + + // 逐条调用单用户充值逻辑:保持“订单 + 账本 + 余额”一致性与幂等语义一致。 + orderModel, err := s.AdminTopupUser( + ctx, + tenantID, + operatorUserID, + item.UserID, + item.Amount, + idemKey, + item.Reason, + now, + ) + if err != nil { + // 错误收敛为可展示结构:便于前端逐条展示与导出审计。 + var appErr *errorx.AppError + if errors.As(err, &appErr) { + resultItem.ErrorCode = int(appErr.Code) + resultItem.ErrorMessage = appErr.Message + } else { + resultItem.ErrorCode = int(errorx.CodeInternalError) + resultItem.ErrorMessage = err.Error() + } + out.Failed++ + out.Items = append(out.Items, resultItem) + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "operator_user_id": operatorUserID, + "user_id": item.UserID, + "amount": item.Amount, + "idempotency_key": idemKey, + }).WithError(err).Warn("services.order.admin.batch_topup.item_failed") + continue + } + + resultItem.OK = true + if orderModel != nil { + resultItem.OrderID = orderModel.ID + } + out.Success++ + out.Items = append(out.Items, resultItem) + } + + return out, nil +} + // PurchaseOrderSnapshot 为“内容购买订单”的下单快照(用于历史展示与争议审计)。 type PurchaseOrderSnapshot struct { // ContentID 内容ID。 diff --git a/backend/app/services/order_test.go b/backend/app/services/order_test.go index 3d730fd..b848065 100644 --- a/backend/app/services/order_test.go +++ b/backend/app/services/order_test.go @@ -877,6 +877,123 @@ func (s *OrderTestSuite) Test_AdminOrderExportCSV() { }) } +func (s *OrderTestSuite) Test_AdminBatchTopupUsers() { + Convey("Order.AdminBatchTopupUsers", s.T(), func() { + ctx := s.T().Context() + now := time.Now().UTC() + tenantID := int64(1) + operatorUserID := int64(10) + + s.truncate( + ctx, + models.TableNameTenantLedger, + models.TableNameOrderItem, + models.TableNameOrder, + models.TableNameTenantUser, + ) + + Convey("参数非法应返回错误", func() { + _, err := Order.AdminBatchTopupUsers(ctx, 0, operatorUserID, &dto.AdminBatchTopupForm{}, now) + So(err, ShouldNotBeNil) + + _, err = Order.AdminBatchTopupUsers(ctx, tenantID, 0, &dto.AdminBatchTopupForm{}, now) + So(err, ShouldNotBeNil) + + _, err = Order.AdminBatchTopupUsers(ctx, tenantID, operatorUserID, nil, now) + So(err, ShouldNotBeNil) + + _, err = Order.AdminBatchTopupUsers(ctx, tenantID, operatorUserID, &dto.AdminBatchTopupForm{BatchIdempotencyKey: ""}, now) + So(err, ShouldNotBeNil) + + _, err = Order.AdminBatchTopupUsers(ctx, tenantID, operatorUserID, &dto.AdminBatchTopupForm{BatchIdempotencyKey: "b1", Items: nil}, now) + So(err, ShouldNotBeNil) + }) + + Convey("超过单批次最大条数应返回错误", func() { + items := make([]*dto.AdminBatchTopupItem, 0, 201) + for i := 0; i < 201; i++ { + items = append(items, &dto.AdminBatchTopupItem{UserID: int64(1000 + i), Amount: 1}) + } + _, err := Order.AdminBatchTopupUsers(ctx, tenantID, operatorUserID, &dto.AdminBatchTopupForm{ + BatchIdempotencyKey: "too_many", + Items: items, + }, now) + So(err, ShouldNotBeNil) + }) + + Convey("单条参数不合法应只影响该条并返回错误明细", func() { + s.seedTenantUser(ctx, tenantID, 20, 0, 0) + + form := &dto.AdminBatchTopupForm{ + BatchIdempotencyKey: "batch_invalid_item", + Items: []*dto.AdminBatchTopupItem{ + nil, + {UserID: 0, Amount: 100, Reason: "bad_user_id"}, + {UserID: 20, Amount: 0, Reason: "bad_amount"}, + {UserID: 20, Amount: 100, Reason: "ok"}, + }, + } + resp, err := Order.AdminBatchTopupUsers(ctx, tenantID, operatorUserID, form, now) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.Total, ShouldEqual, 4) + So(resp.Success, ShouldEqual, 1) + So(resp.Failed, ShouldEqual, 3) + So(len(resp.Items), ShouldEqual, 4) + So(resp.Items[0].OK, ShouldBeFalse) + So(resp.Items[1].OK, ShouldBeFalse) + So(resp.Items[2].OK, ShouldBeFalse) + So(resp.Items[3].OK, ShouldBeTrue) + }) + + Convey("部分成功应返回明细结果且成功入账", func() { + // seed 2 个成员,1 个非成员 + s.seedTenantUser(ctx, tenantID, 20, 0, 0) + s.seedTenantUser(ctx, tenantID, 21, 0, 0) + + form := &dto.AdminBatchTopupForm{ + BatchIdempotencyKey: "batch_001", + Items: []*dto.AdminBatchTopupItem{ + {UserID: 20, Amount: 100, Reason: "a"}, + {UserID: 999, Amount: 100, Reason: "not_member"}, + {UserID: 21, Amount: 200, Reason: "b"}, + }, + } + resp, err := Order.AdminBatchTopupUsers(ctx, tenantID, operatorUserID, form, now) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.Total, ShouldEqual, 3) + So(resp.Success, ShouldEqual, 2) + So(resp.Failed, ShouldEqual, 1) + So(len(resp.Items), ShouldEqual, 3) + So(resp.Items[0].OK, ShouldBeTrue) + So(resp.Items[0].OrderID, ShouldBeGreaterThan, 0) + So(resp.Items[1].OK, ShouldBeFalse) + So(resp.Items[2].OK, ShouldBeTrue) + + var tu20 models.TenantUser + So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, int64(20)).First(&tu20).Error, ShouldBeNil) + So(tu20.Balance, ShouldEqual, 100) + var tu21 models.TenantUser + So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, int64(21)).First(&tu21).Error, ShouldBeNil) + So(tu21.Balance, ShouldEqual, 200) + + Convey("同一批次重复调用应幂等,不重复入账", func() { + resp2, err := Order.AdminBatchTopupUsers(ctx, tenantID, operatorUserID, form, now.Add(time.Second)) + So(err, ShouldBeNil) + So(resp2.Success, ShouldEqual, 2) + + var tu20b models.TenantUser + So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, int64(20)).First(&tu20b).Error, ShouldBeNil) + So(tu20b.Balance, ShouldEqual, 100) + var tu21b models.TenantUser + So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, int64(21)).First(&tu21b).Error, ShouldBeNil) + So(tu21b.Balance, ShouldEqual, 200) + }) + }) + }) +} + func (s *OrderTestSuite) Test_AdminRefundOrder() { Convey("Order.AdminRefundOrder", s.T(), func() { ctx := s.T().Context() diff --git a/backend/app/services/tenant.go b/backend/app/services/tenant.go index cf766c5..8824f3c 100644 --- a/backend/app/services/tenant.go +++ b/backend/app/services/tenant.go @@ -42,7 +42,8 @@ func (t *tenant) AdminTenantUsersPage(ctx context.Context, tenantID int64, filte conds = append(conds, tbl.UserID.Eq(*filter.UserID)) } if filter.Role != nil && *filter.Role != "" { - conds = append(conds, tbl.Role.Contains(string(*filter.Role))) + // role 字段为 PostgreSQL text[]:使用数组参数才能正确生成 `@> '{"tenant_admin"}'` 语义。 + conds = append(conds, tbl.Role.Contains(types.NewArray([]consts.TenantUserRole{*filter.Role}))) } if filter.Status != nil && *filter.Status != "" { conds = append(conds, tbl.Status.Eq(*filter.Status)) @@ -225,6 +226,19 @@ func (t *tenant) Pager(ctx context.Context, filter *dto.TenantFilter) (*requests } func (t *tenant) TenantUserCountMapping(ctx context.Context, tenantIds []int64) (map[int64]int64, error) { + // 关键语义:返回值必须包含入参中的所有 tenant_id。 + // 即便该租户当前没有成员,也应返回 count=0,便于调用方直接取值而无需额外补全逻辑。 + result := make(map[int64]int64, len(tenantIds)) + for _, id := range tenantIds { + if id <= 0 { + continue + } + result[id] = 0 + } + if len(result) == 0 { + return result, nil + } + tbl, query := models.TenantUserQuery.QueryContext(ctx) var items []struct { @@ -243,7 +257,6 @@ func (t *tenant) TenantUserCountMapping(ctx context.Context, tenantIds []int64) return nil, err } - result := make(map[int64]int64) for _, item := range items { result[item.TenantID] = item.Count } @@ -252,6 +265,19 @@ func (t *tenant) TenantUserCountMapping(ctx context.Context, tenantIds []int64) // TenantUserBalanceMapping func (t *tenant) TenantUserBalanceMapping(ctx context.Context, tenantIds []int64) (map[int64]int64, error) { + // 关键语义:返回值必须包含入参中的所有 tenant_id。 + // 即便该租户当前没有成员,也应返回 balance=0,保持调用方逻辑一致。 + result := make(map[int64]int64, len(tenantIds)) + for _, id := range tenantIds { + if id <= 0 { + continue + } + result[id] = 0 + } + if len(result) == 0 { + return result, nil + } + tbl, query := models.TenantUserQuery.QueryContext(ctx) var items []struct { @@ -270,7 +296,6 @@ func (t *tenant) TenantUserBalanceMapping(ctx context.Context, tenantIds []int64 return nil, err } - result := make(map[int64]int64) for _, item := range items { result[item.TenantID] = item.Balance } diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 2721df3..5c5258e 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -1437,6 +1437,46 @@ const docTemplate = `{ } } }, + "/t/{tenantCode}/v1/admin/users/topup/batch": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "批量为租户成员充值(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.AdminBatchTopupForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.AdminBatchTopupResponse" + } + } + } + } + }, "/t/{tenantCode}/v1/admin/users/{userID}": { "delete": { "consumes": [ @@ -2389,6 +2429,100 @@ const docTemplate = `{ "UserStatusBanned" ] }, + "dto.AdminBatchTopupForm": { + "type": "object", + "properties": { + "batch_idempotency_key": { + "description": "BatchIdempotencyKey 批次幂等键:必须填写;用于重试同一批次时保证不会重复入账。", + "type": "string" + }, + "items": { + "description": "Items 充值明细列表:至少 1 条;单批次条数在业务侧限制(避免拖垮系统)。", + "type": "array", + "items": { + "$ref": "#/definitions/dto.AdminBatchTopupItem" + } + } + } + }, + "dto.AdminBatchTopupItem": { + "type": "object", + "properties": { + "amount": { + "description": "Amount 充值金额:单位分;必须 \u003e 0。", + "type": "integer" + }, + "idempotency_key": { + "description": "IdempotencyKey 幂等键(可选):为空时后端会用 batch_idempotency_key 派生生成;\n建议前端/调用方提供稳定值,便于重试时保持结果一致。", + "type": "string" + }, + "reason": { + "description": "Reason 充值原因(可选):用于审计与追溯。", + "type": "string" + }, + "user_id": { + "description": "UserID 目标用户ID:必须属于当前租户,否则该条充值失败。", + "type": "integer" + } + } + }, + "dto.AdminBatchTopupResponse": { + "type": "object", + "properties": { + "failed": { + "description": "Failed 失败条数。", + "type": "integer" + }, + "items": { + "description": "Items 明细结果列表:与请求 items 顺序一致,便于前端逐条展示。", + "type": "array", + "items": { + "$ref": "#/definitions/dto.AdminBatchTopupResultItem" + } + }, + "success": { + "description": "Success 成功条数。", + "type": "integer" + }, + "total": { + "description": "Total 总条数:等于 items 长度。", + "type": "integer" + } + } + }, + "dto.AdminBatchTopupResultItem": { + "type": "object", + "properties": { + "amount": { + "description": "Amount 充值金额(单位分)。", + "type": "integer" + }, + "error_code": { + "description": "ErrorCode 错误码:失败时返回;成功时为 0。", + "type": "integer" + }, + "error_message": { + "description": "ErrorMessage 错误信息:失败时返回;成功时为空。", + "type": "string" + }, + "idempotency_key": { + "description": "IdempotencyKey 实际使用的幂等键:可能为客户端传入,也可能为后端派生生成。", + "type": "string" + }, + "ok": { + "description": "OK 是否成功:true 表示该条充值已成功入账或命中幂等成功结果。", + "type": "boolean" + }, + "order_id": { + "description": "OrderID 生成的订单ID:成功时返回;失败时为 0。", + "type": "integer" + }, + "user_id": { + "description": "UserID 目标用户ID。", + "type": "integer" + } + } + }, "dto.AdminOrderDetail": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 4b86012..17a2118 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -1431,6 +1431,46 @@ } } }, + "/t/{tenantCode}/v1/admin/users/topup/batch": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "批量为租户成员充值(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.AdminBatchTopupForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.AdminBatchTopupResponse" + } + } + } + } + }, "/t/{tenantCode}/v1/admin/users/{userID}": { "delete": { "consumes": [ @@ -2383,6 +2423,100 @@ "UserStatusBanned" ] }, + "dto.AdminBatchTopupForm": { + "type": "object", + "properties": { + "batch_idempotency_key": { + "description": "BatchIdempotencyKey 批次幂等键:必须填写;用于重试同一批次时保证不会重复入账。", + "type": "string" + }, + "items": { + "description": "Items 充值明细列表:至少 1 条;单批次条数在业务侧限制(避免拖垮系统)。", + "type": "array", + "items": { + "$ref": "#/definitions/dto.AdminBatchTopupItem" + } + } + } + }, + "dto.AdminBatchTopupItem": { + "type": "object", + "properties": { + "amount": { + "description": "Amount 充值金额:单位分;必须 \u003e 0。", + "type": "integer" + }, + "idempotency_key": { + "description": "IdempotencyKey 幂等键(可选):为空时后端会用 batch_idempotency_key 派生生成;\n建议前端/调用方提供稳定值,便于重试时保持结果一致。", + "type": "string" + }, + "reason": { + "description": "Reason 充值原因(可选):用于审计与追溯。", + "type": "string" + }, + "user_id": { + "description": "UserID 目标用户ID:必须属于当前租户,否则该条充值失败。", + "type": "integer" + } + } + }, + "dto.AdminBatchTopupResponse": { + "type": "object", + "properties": { + "failed": { + "description": "Failed 失败条数。", + "type": "integer" + }, + "items": { + "description": "Items 明细结果列表:与请求 items 顺序一致,便于前端逐条展示。", + "type": "array", + "items": { + "$ref": "#/definitions/dto.AdminBatchTopupResultItem" + } + }, + "success": { + "description": "Success 成功条数。", + "type": "integer" + }, + "total": { + "description": "Total 总条数:等于 items 长度。", + "type": "integer" + } + } + }, + "dto.AdminBatchTopupResultItem": { + "type": "object", + "properties": { + "amount": { + "description": "Amount 充值金额(单位分)。", + "type": "integer" + }, + "error_code": { + "description": "ErrorCode 错误码:失败时返回;成功时为 0。", + "type": "integer" + }, + "error_message": { + "description": "ErrorMessage 错误信息:失败时返回;成功时为空。", + "type": "string" + }, + "idempotency_key": { + "description": "IdempotencyKey 实际使用的幂等键:可能为客户端传入,也可能为后端派生生成。", + "type": "string" + }, + "ok": { + "description": "OK 是否成功:true 表示该条充值已成功入账或命中幂等成功结果。", + "type": "boolean" + }, + "order_id": { + "description": "OrderID 生成的订单ID:成功时返回;失败时为 0。", + "type": "integer" + }, + "user_id": { + "description": "UserID 目标用户ID。", + "type": "integer" + } + } + }, "dto.AdminOrderDetail": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 1dfdb84..37f370a 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -180,6 +180,75 @@ definitions: - UserStatusPendingVerify - UserStatusVerified - UserStatusBanned + dto.AdminBatchTopupForm: + properties: + batch_idempotency_key: + description: BatchIdempotencyKey 批次幂等键:必须填写;用于重试同一批次时保证不会重复入账。 + type: string + items: + description: Items 充值明细列表:至少 1 条;单批次条数在业务侧限制(避免拖垮系统)。 + items: + $ref: '#/definitions/dto.AdminBatchTopupItem' + type: array + type: object + dto.AdminBatchTopupItem: + properties: + amount: + description: Amount 充值金额:单位分;必须 > 0。 + type: integer + idempotency_key: + description: |- + IdempotencyKey 幂等键(可选):为空时后端会用 batch_idempotency_key 派生生成; + 建议前端/调用方提供稳定值,便于重试时保持结果一致。 + type: string + reason: + description: Reason 充值原因(可选):用于审计与追溯。 + type: string + user_id: + description: UserID 目标用户ID:必须属于当前租户,否则该条充值失败。 + type: integer + type: object + dto.AdminBatchTopupResponse: + properties: + failed: + description: Failed 失败条数。 + type: integer + items: + description: Items 明细结果列表:与请求 items 顺序一致,便于前端逐条展示。 + items: + $ref: '#/definitions/dto.AdminBatchTopupResultItem' + type: array + success: + description: Success 成功条数。 + type: integer + total: + description: Total 总条数:等于 items 长度。 + type: integer + type: object + dto.AdminBatchTopupResultItem: + properties: + amount: + description: Amount 充值金额(单位分)。 + type: integer + error_code: + description: ErrorCode 错误码:失败时返回;成功时为 0。 + type: integer + error_message: + description: ErrorMessage 错误信息:失败时返回;成功时为空。 + type: string + idempotency_key: + description: IdempotencyKey 实际使用的幂等键:可能为客户端传入,也可能为后端派生生成。 + type: string + ok: + description: OK 是否成功:true 表示该条充值已成功入账或命中幂等成功结果。 + type: boolean + order_id: + description: OrderID 生成的订单ID:成功时返回;失败时为 0。 + type: integer + user_id: + description: UserID 目标用户ID。 + type: integer + type: object dto.AdminOrderDetail: properties: order: @@ -2224,6 +2293,32 @@ paths: summary: 为租户成员充值(租户管理) tags: - Tenant + /t/{tenantCode}/v1/admin/users/topup/batch: + post: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: Form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.AdminBatchTopupForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.AdminBatchTopupResponse' + summary: 批量为租户成员充值(租户管理) + tags: + - Tenant /t/{tenantCode}/v1/contents: get: consumes: diff --git a/backend/tests/tenant.http b/backend/tests/tenant.http index 4ff8b0c..820032e 100644 --- a/backend/tests/tenant.http +++ b/backend/tests/tenant.http @@ -180,6 +180,27 @@ Authorization: Bearer {{ token }} "idempotency_key": "topup-{{ topupUserID }}-001" } +### Tenant Admin - Batch topup users +POST {{ host }}/t/{{ tenantCode }}/v1/admin/users/topup/batch +Content-Type: application/json +Authorization: Bearer {{ token }} + +{ + "batch_idempotency_key": "batch-topup-001", + "items": [ + { + "user_id": 2, + "amount": 1000, + "reason": "批量充值-1" + }, + { + "user_id": 3, + "amount": 2000, + "reason": "批量充值-2" + } + ] +} + ### Tenant Admin - Join a user to tenant (add member) @joinUserID = 3 POST {{ host }}/t/{{ tenantCode }}/v1/admin/users/{{ joinUserID }}/join