diff --git a/README.md b/README.md index a5f680bf..1e2f2290 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot ## Documentation - Dependency Security: `docs/dependency-security.md` +- Admin Payment Integration API: `docs/ADMIN_PAYMENT_INTEGRATION_API.md` --- diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 5e78886c..ef5d142e 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -148,7 +148,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService) antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService) proxyHandler := admin.NewProxyHandler(adminService) - adminRedeemHandler := admin.NewRedeemHandler(adminService) + adminRedeemHandler := admin.NewRedeemHandler(adminService, redeemService) promoHandler := admin.NewPromoHandler(promoService) opsRepository := repository.NewOpsRepository(db) pricingRemoteClient := repository.ProvidePricingRemoteClient(configConfig) diff --git a/backend/internal/handler/admin/admin_basic_handlers_test.go b/backend/internal/handler/admin/admin_basic_handlers_test.go index aeb4097f..4de10d3e 100644 --- a/backend/internal/handler/admin/admin_basic_handlers_test.go +++ b/backend/internal/handler/admin/admin_basic_handlers_test.go @@ -19,7 +19,7 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) { userHandler := NewUserHandler(adminSvc, nil) groupHandler := NewGroupHandler(adminSvc) proxyHandler := NewProxyHandler(adminSvc) - redeemHandler := NewRedeemHandler(adminSvc) + redeemHandler := NewRedeemHandler(adminSvc, nil) router.GET("/api/v1/admin/users", userHandler.List) router.GET("/api/v1/admin/users/:id", userHandler.GetByID) diff --git a/backend/internal/handler/admin/redeem_handler.go b/backend/internal/handler/admin/redeem_handler.go index 7073061d..0a932ee9 100644 --- a/backend/internal/handler/admin/redeem_handler.go +++ b/backend/internal/handler/admin/redeem_handler.go @@ -4,11 +4,13 @@ import ( "bytes" "context" "encoding/csv" + "errors" "fmt" "strconv" "strings" "github.com/Wei-Shaw/sub2api/internal/handler/dto" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/service" @@ -17,13 +19,15 @@ import ( // RedeemHandler handles admin redeem code management type RedeemHandler struct { - adminService service.AdminService + adminService service.AdminService + redeemService *service.RedeemService } // NewRedeemHandler creates a new admin redeem handler -func NewRedeemHandler(adminService service.AdminService) *RedeemHandler { +func NewRedeemHandler(adminService service.AdminService, redeemService *service.RedeemService) *RedeemHandler { return &RedeemHandler{ - adminService: adminService, + adminService: adminService, + redeemService: redeemService, } } @@ -36,6 +40,15 @@ type GenerateRedeemCodesRequest struct { ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // 订阅类型使用,默认30天,最大100年 } +// CreateAndRedeemCodeRequest represents creating a fixed code and redeeming it for a target user. +type CreateAndRedeemCodeRequest struct { + Code string `json:"code" binding:"required,min=3,max=128"` + Type string `json:"type" binding:"required,oneof=balance concurrency subscription invitation"` + Value float64 `json:"value" binding:"required,gt=0"` + UserID int64 `json:"user_id" binding:"required,gt=0"` + Notes string `json:"notes"` +} + // List handles listing all redeem codes with pagination // GET /api/v1/admin/redeem-codes func (h *RedeemHandler) List(c *gin.Context) { @@ -109,6 +122,81 @@ func (h *RedeemHandler) Generate(c *gin.Context) { }) } +// CreateAndRedeem creates a fixed redeem code and redeems it for a target user in one step. +// POST /api/v1/admin/redeem-codes/create-and-redeem +func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) { + if h.redeemService == nil { + response.InternalError(c, "redeem service not configured") + return + } + + var req CreateAndRedeemCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + req.Code = strings.TrimSpace(req.Code) + + executeAdminIdempotentJSON(c, "admin.redeem_codes.create_and_redeem", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) { + existing, err := h.redeemService.GetByCode(ctx, req.Code) + if err == nil { + return h.resolveCreateAndRedeemExisting(ctx, existing, req.UserID) + } + if !errors.Is(err, service.ErrRedeemCodeNotFound) { + return nil, err + } + + createErr := h.redeemService.CreateCode(ctx, &service.RedeemCode{ + Code: req.Code, + Type: req.Type, + Value: req.Value, + Status: service.StatusUnused, + Notes: req.Notes, + }) + if createErr != nil { + // Unique code race: if code now exists, use idempotent semantics by used_by. + existingAfterCreateErr, getErr := h.redeemService.GetByCode(ctx, req.Code) + if getErr == nil { + return h.resolveCreateAndRedeemExisting(ctx, existingAfterCreateErr, req.UserID) + } + return nil, createErr + } + + redeemed, redeemErr := h.redeemService.Redeem(ctx, req.UserID, req.Code) + if redeemErr != nil { + return nil, redeemErr + } + return gin.H{"redeem_code": dto.RedeemCodeFromServiceAdmin(redeemed)}, nil + }) +} + +func (h *RedeemHandler) resolveCreateAndRedeemExisting(ctx context.Context, existing *service.RedeemCode, userID int64) (any, error) { + if existing == nil { + return nil, infraerrors.Conflict("REDEEM_CODE_CONFLICT", "redeem code conflict") + } + + // If previous run created the code but crashed before redeem, redeem it now. + if existing.CanUse() { + redeemed, err := h.redeemService.Redeem(ctx, userID, existing.Code) + if err == nil { + return gin.H{"redeem_code": dto.RedeemCodeFromServiceAdmin(redeemed)}, nil + } + if !errors.Is(err, service.ErrRedeemCodeUsed) { + return nil, err + } + latest, getErr := h.redeemService.GetByCode(ctx, existing.Code) + if getErr == nil { + existing = latest + } + } + + if existing.UsedBy != nil && *existing.UsedBy == userID { + return gin.H{"redeem_code": dto.RedeemCodeFromServiceAdmin(existing)}, nil + } + + return nil, infraerrors.Conflict("REDEEM_CODE_CONFLICT", "redeem code already used by another user") +} + // Delete handles deleting a redeem code // DELETE /api/v1/admin/redeem-codes/:id func (h *RedeemHandler) Delete(c *gin.Context) { diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 4d0a33c2..ade4d462 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -351,6 +351,7 @@ func registerRedeemCodeRoutes(admin *gin.RouterGroup, h *handler.Handlers) { codes.GET("/stats", h.Admin.Redeem.GetStats) codes.GET("/export", h.Admin.Redeem.Export) codes.GET("/:id", h.Admin.Redeem.GetByID) + codes.POST("/create-and-redeem", h.Admin.Redeem.CreateAndRedeem) codes.POST("/generate", h.Admin.Redeem.Generate) codes.DELETE("/:id", h.Admin.Redeem.Delete) codes.POST("/batch-delete", h.Admin.Redeem.BatchDelete) diff --git a/backend/internal/service/redeem_service.go b/backend/internal/service/redeem_service.go index ad277ca0..b22da752 100644 --- a/backend/internal/service/redeem_service.go +++ b/backend/internal/service/redeem_service.go @@ -174,6 +174,33 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ return codes, nil } +// CreateCode creates a redeem code with caller-provided code value. +// It is primarily used by admin integrations that require an external order ID +// to be mapped to a deterministic redeem code. +func (s *RedeemService) CreateCode(ctx context.Context, code *RedeemCode) error { + if code == nil { + return errors.New("redeem code is required") + } + code.Code = strings.TrimSpace(code.Code) + if code.Code == "" { + return errors.New("code is required") + } + if code.Type == "" { + code.Type = RedeemTypeBalance + } + if code.Type != RedeemTypeInvitation && code.Value <= 0 { + return errors.New("value must be greater than 0") + } + if code.Status == "" { + code.Status = StatusUnused + } + + if err := s.redeemRepo.Create(ctx, code); err != nil { + return fmt.Errorf("create redeem code: %w", err) + } + return nil +} + // checkRedeemRateLimit 检查用户兑换错误次数是否超限 func (s *RedeemService) checkRedeemRateLimit(ctx context.Context, userID int64) error { if s.cache == nil { diff --git a/docs/ADMIN_PAYMENT_INTEGRATION_API.md b/docs/ADMIN_PAYMENT_INTEGRATION_API.md new file mode 100644 index 00000000..4cc21594 --- /dev/null +++ b/docs/ADMIN_PAYMENT_INTEGRATION_API.md @@ -0,0 +1,241 @@ +# ADMIN_PAYMENT_INTEGRATION_API + +> 单文件中英双语文档 / Single-file bilingual documentation (Chinese + English) + +--- + +## 中文 + +### 目标 +本文档用于对接外部支付系统(如 `sub2apipay`)与 Sub2API 的 Admin API,覆盖: +- 支付成功后充值 +- 用户查询 +- 人工余额修正 +- 前端购买页参数透传 + +### 基础地址 +- 生产:`https://` +- Beta:`http://:8084` + +### 认证 +推荐使用: +- `x-api-key: admin-<64hex>` +- `Content-Type: application/json` +- 幂等接口额外传:`Idempotency-Key` + +说明:管理员 JWT 也可访问 admin 路由,但服务间调用建议使用 Admin API Key。 + +### 1) 一步完成创建并兑换 +`POST /api/v1/admin/redeem-codes/create-and-redeem` + +用途:原子完成“创建兑换码 + 兑换到指定用户”。 + +请求头: +- `x-api-key` +- `Idempotency-Key` + +请求体示例: +```json +{ + "code": "s2p_cm1234567890", + "type": "balance", + "value": 100.0, + "user_id": 123, + "notes": "sub2apipay order: cm1234567890" +} +``` + +幂等语义: +- 同 `code` 且 `used_by` 一致:`200` +- 同 `code` 但 `used_by` 不一致:`409` +- 缺少 `Idempotency-Key`:`400`(`IDEMPOTENCY_KEY_REQUIRED`) + +curl 示例: +```bash +curl -X POST "${BASE}/api/v1/admin/redeem-codes/create-and-redeem" \ + -H "x-api-key: ${KEY}" \ + -H "Idempotency-Key: pay-cm1234567890-success" \ + -H "Content-Type: application/json" \ + -d '{ + "code":"s2p_cm1234567890", + "type":"balance", + "value":100.00, + "user_id":123, + "notes":"sub2apipay order: cm1234567890" + }' +``` + +### 2) 查询用户(可选前置校验) +`GET /api/v1/admin/users/:id` + +```bash +curl -s "${BASE}/api/v1/admin/users/123" \ + -H "x-api-key: ${KEY}" +``` + +### 3) 余额调整(已有接口) +`POST /api/v1/admin/users/:id/balance` + +用途:人工补偿 / 扣减,支持 `set` / `add` / `subtract`。 + +请求体示例(扣减): +```json +{ + "balance": 100.0, + "operation": "subtract", + "notes": "manual correction" +} +``` + +```bash +curl -X POST "${BASE}/api/v1/admin/users/123/balance" \ + -H "x-api-key: ${KEY}" \ + -H "Idempotency-Key: balance-subtract-cm1234567890" \ + -H "Content-Type: application/json" \ + -d '{ + "balance":100.00, + "operation":"subtract", + "notes":"manual correction" + }' +``` + +### 4) 购买页 URL Query 透传(iframe / 新窗口一致) +当 Sub2API 打开 `purchase_subscription_url` 时,会统一追加: +- `user_id` +- `token` +- `theme`(`light` / `dark`) +- `ui_mode`(固定 `embedded`) + +示例: +```text +https://pay.example.com/pay?user_id=123&token=&theme=light&ui_mode=embedded +``` + +### 5) 失败处理建议 +- 支付成功与充值成功分状态落库 +- 回调验签成功后立即标记“支付成功” +- 支付成功但充值失败的订单允许后续重试 +- 重试保持相同 `code`,并使用新的 `Idempotency-Key` + +### 6) `doc_url` 配置建议 +- 查看链接:`https://github.com/Wei-Shaw/sub2api/blob/main/ADMIN_PAYMENT_INTEGRATION_API.md` +- 下载链接:`https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/ADMIN_PAYMENT_INTEGRATION_API.md` + +--- + +## English + +### Purpose +This document describes the minimal Sub2API Admin API surface for external payment integrations (for example, `sub2apipay`), including: +- Recharge after payment success +- User lookup +- Manual balance correction +- Purchase page query parameter forwarding + +### Base URL +- Production: `https://` +- Beta: `http://:8084` + +### Authentication +Recommended headers: +- `x-api-key: admin-<64hex>` +- `Content-Type: application/json` +- `Idempotency-Key` for idempotent endpoints + +Note: Admin JWT can also access admin routes, but Admin API Key is recommended for server-to-server integration. + +### 1) Create and Redeem in one step +`POST /api/v1/admin/redeem-codes/create-and-redeem` + +Use case: atomically create a redeem code and redeem it to a target user. + +Headers: +- `x-api-key` +- `Idempotency-Key` + +Request body: +```json +{ + "code": "s2p_cm1234567890", + "type": "balance", + "value": 100.0, + "user_id": 123, + "notes": "sub2apipay order: cm1234567890" +} +``` + +Idempotency behavior: +- Same `code` and same `used_by`: `200` +- Same `code` but different `used_by`: `409` +- Missing `Idempotency-Key`: `400` (`IDEMPOTENCY_KEY_REQUIRED`) + +curl example: +```bash +curl -X POST "${BASE}/api/v1/admin/redeem-codes/create-and-redeem" \ + -H "x-api-key: ${KEY}" \ + -H "Idempotency-Key: pay-cm1234567890-success" \ + -H "Content-Type: application/json" \ + -d '{ + "code":"s2p_cm1234567890", + "type":"balance", + "value":100.00, + "user_id":123, + "notes":"sub2apipay order: cm1234567890" + }' +``` + +### 2) Query User (optional pre-check) +`GET /api/v1/admin/users/:id` + +```bash +curl -s "${BASE}/api/v1/admin/users/123" \ + -H "x-api-key: ${KEY}" +``` + +### 3) Balance Adjustment (existing API) +`POST /api/v1/admin/users/:id/balance` + +Use case: manual correction with `set` / `add` / `subtract`. + +Request body example (`subtract`): +```json +{ + "balance": 100.0, + "operation": "subtract", + "notes": "manual correction" +} +``` + +```bash +curl -X POST "${BASE}/api/v1/admin/users/123/balance" \ + -H "x-api-key: ${KEY}" \ + -H "Idempotency-Key: balance-subtract-cm1234567890" \ + -H "Content-Type: application/json" \ + -d '{ + "balance":100.00, + "operation":"subtract", + "notes":"manual correction" + }' +``` + +### 4) Purchase URL query forwarding (iframe and new tab) +When Sub2API opens `purchase_subscription_url`, it appends: +- `user_id` +- `token` +- `theme` (`light` / `dark`) +- `ui_mode` (fixed: `embedded`) + +Example: +```text +https://pay.example.com/pay?user_id=123&token=&theme=light&ui_mode=embedded +``` + +### 5) Failure handling recommendations +- Persist payment success and recharge success as separate states +- Mark payment as successful immediately after verified callback +- Allow retry for orders with payment success but recharge failure +- Keep the same `code` for retry, and use a new `Idempotency-Key` + +### 6) Recommended `doc_url` +- View URL: `https://github.com/Wei-Shaw/sub2api/blob/main/ADMIN_PAYMENT_INTEGRATION_API.md` +- Download URL: `https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/ADMIN_PAYMENT_INTEGRATION_API.md` diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index e89e73b1..b356e3e5 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -290,6 +290,26 @@ const CreditCardIcon = { ) } +const RechargeSubscriptionIcon = { + render: () => + h( + 'svg', + { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, + [ + h('path', { + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + d: 'M2.25 7.5A2.25 2.25 0 014.5 5.25h15A2.25 2.25 0 0121.75 7.5v9A2.25 2.25 0 0119.5 18.75h-15A2.25 2.25 0 012.25 16.5v-9z' + }), + h('path', { + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + d: 'M6.75 12h3m4.5 0h3m-3-3v6' + }) + ] + ) +} + const GlobeIcon = { render: () => h( @@ -490,7 +510,7 @@ const userNavItems = computed(() => { { path: '/purchase', label: t('nav.buySubscription'), - icon: CreditCardIcon, + icon: RechargeSubscriptionIcon, hideInSimpleMode: true } ] @@ -515,7 +535,7 @@ const personalNavItems = computed(() => { { path: '/purchase', label: t('nav.buySubscription'), - icon: CreditCardIcon, + icon: RechargeSubscriptionIcon, hideInSimpleMode: true } ] diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 4e70ddda..9e41ea76 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -280,7 +280,7 @@ export default { logout: 'Logout', github: 'GitHub', mySubscriptions: 'My Subscriptions', - buySubscription: 'Purchase Subscription', + buySubscription: 'Recharge / Subscription', docs: 'Docs', sora: 'Sora Studio' }, @@ -3590,15 +3590,17 @@ export default { hideCcsImportButtonHint: 'When enabled, the "Import to CCS" button will be hidden on the API Keys page' }, purchase: { - title: 'Purchase Page', - description: 'Show a "Purchase Subscription" entry in the sidebar and open the configured URL in an iframe', - enabled: 'Show Purchase Entry', + title: 'Recharge / Subscription Page', + description: 'Show a "Recharge / Subscription" entry in the sidebar and open the configured URL in an iframe', + enabled: 'Show Recharge / Subscription Entry', enabledHint: 'Only shown in standard mode (not simple mode)', - url: 'Purchase URL', + url: 'Recharge / Subscription URL', urlPlaceholder: 'https://example.com/purchase', urlHint: 'Must be an absolute http(s) URL', iframeWarning: - '⚠️ iframe note: Some websites block embedding via X-Frame-Options or CSP (frame-ancestors). If the page is blank, provide an "Open in new tab" alternative.' + '⚠️ iframe note: Some websites block embedding via X-Frame-Options or CSP (frame-ancestors). If the page is blank, provide an "Open in new tab" alternative.', + integrationDoc: 'Payment Integration Docs', + integrationDocHint: 'Covers endpoint specs, idempotency semantics, and code samples' }, soraClient: { title: 'Sora Client', @@ -3882,16 +3884,16 @@ export default { retry: 'Retry' }, - // Purchase Subscription Page + // Recharge / Subscription Page purchase: { - title: 'Purchase Subscription', - description: 'Purchase a subscription via the embedded page', + title: 'Recharge / Subscription', + description: 'Recharge balance or purchase subscription via the embedded page', openInNewTab: 'Open in new tab', notEnabledTitle: 'Feature not enabled', - notEnabledDesc: 'The administrator has not enabled the purchase page. Please contact admin.', - notConfiguredTitle: 'Purchase URL not configured', + notEnabledDesc: 'The administrator has not enabled the recharge/subscription entry. Please contact admin.', + notConfiguredTitle: 'Recharge / Subscription URL not configured', notConfiguredDesc: - 'The administrator enabled the entry but has not configured a purchase URL. Please contact admin.' + 'The administrator enabled the entry but has not configured a recharge/subscription URL. Please contact admin.' }, // Announcements Page diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index bc5e6b69..36986867 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -280,7 +280,7 @@ export default { logout: '退出登录', github: 'GitHub', mySubscriptions: '我的订阅', - buySubscription: '购买订阅', + buySubscription: '充值/订阅', docs: '文档', sora: 'Sora 创作' }, @@ -3761,15 +3761,17 @@ export default { hideCcsImportButtonHint: '启用后将在 API Keys 页面隐藏"导入 CCS"按钮' }, purchase: { - title: '购买订阅页面', - description: '在侧边栏展示”购买订阅”入口,并在页面内通过 iframe 打开指定链接', - enabled: '显示购买订阅入口', + title: '充值/订阅页面', + description: '在侧边栏展示“充值/订阅”入口,并在页面内通过 iframe 打开指定链接', + enabled: '显示充值/订阅入口', enabledHint: '仅在标准模式(非简单模式)下展示', - url: '购买页面 URL', + url: '充值/订阅页面 URL', urlPlaceholder: 'https://example.com/purchase', urlHint: '必须是完整的 http(s) 链接', iframeWarning: - '⚠️ iframe 提示:部分网站会通过 X-Frame-Options 或 CSP(frame-ancestors)禁止被 iframe 嵌入,出现空白时可引导用户使用”新窗口打开”。' + '⚠️ iframe 提示:部分网站会通过 X-Frame-Options 或 CSP(frame-ancestors)禁止被 iframe 嵌入,出现空白时可引导用户使用”新窗口打开”。', + integrationDoc: '支付集成文档', + integrationDocHint: '包含接口说明、幂等语义及示例代码' }, soraClient: { title: 'Sora 客户端', @@ -4052,15 +4054,15 @@ export default { retry: '重试' }, - // Purchase Subscription Page + // Recharge / Subscription Page purchase: { - title: '购买订阅', - description: '通过内嵌页面完成订阅购买', + title: '充值/订阅', + description: '通过内嵌页面完成充值/订阅', openInNewTab: '新窗口打开', notEnabledTitle: '该功能未开启', - notEnabledDesc: '管理员暂未开启购买订阅入口,请联系管理员。', - notConfiguredTitle: '购买链接未配置', - notConfiguredDesc: '管理员已开启入口,但尚未配置购买订阅链接,请联系管理员。' + notEnabledDesc: '管理员暂未开启充值/订阅入口,请联系管理员。', + notConfiguredTitle: '充值/订阅链接未配置', + notConfiguredDesc: '管理员已开启入口,但尚未配置充值/订阅链接,请联系管理员。' }, // Announcements Page diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 90d07c45..c87ced78 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1020,6 +1020,26 @@ {{ t('admin.settings.purchase.iframeWarning') }}

+ + +
+ + + + + {{ t('admin.settings.purchase.integrationDoc') }} + + + + {{ t('admin.settings.purchase.integrationDocHint') }} + +
diff --git a/frontend/src/views/user/PurchaseSubscriptionView.vue b/frontend/src/views/user/PurchaseSubscriptionView.vue index 55bcf307..fdcd0d34 100644 --- a/frontend/src/views/user/PurchaseSubscriptionView.vue +++ b/frontend/src/views/user/PurchaseSubscriptionView.vue @@ -1,30 +1,6 @@ -