diff --git a/backend/internal/service/merchant.go b/backend/internal/service/merchant.go index 3801f12..51197b8 100644 --- a/backend/internal/service/merchant.go +++ b/backend/internal/service/merchant.go @@ -461,6 +461,9 @@ type CreateAPIClientInput struct { ExpiresAt *time.Time } +// MaxAPIClientsPerMerchant 每个商户最多可创建的 API 密钥数量,防止密钥滥用。 +const MaxAPIClientsPerMerchant = 5 + func (s *MerchantService) CreateAPIClient(merchantID uint, in CreateAPIClientInput, actorUserID uint) (*APICredential, error) { in.Name = strings.TrimSpace(in.Name) if in.Name == "" { @@ -502,6 +505,13 @@ func (s *MerchantService) CreateAPIClient(merchantID uint, in CreateAPIClientInp if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil { return errors.New("商户不存在或已禁用") } + var clientCount int64 + if err := tx.Model(&model.APIClient{}).Where("merchant_id = ?", merchantID).Count(&clientCount).Error; err != nil { + return err + } + if clientCount >= MaxAPIClientsPerMerchant { + return fmt.Errorf("每个商户最多可创建 %d 个 API 密钥", MaxAPIClientsPerMerchant) + } if err := tx.Create(client).Error; err != nil { return err } diff --git a/backend/internal/service/merchant_test.go b/backend/internal/service/merchant_test.go new file mode 100644 index 0000000..37c27f8 --- /dev/null +++ b/backend/internal/service/merchant_test.go @@ -0,0 +1,37 @@ +package service + +import ( + "fmt" + "strings" + "testing" + + "affiliate_dash/internal/model" +) + +func TestCreateAPIClientEnforcesPerMerchantLimit(t *testing.T) { + db := newServiceTestDB(t) + codec, err := NewSecretCodec("test-master-key") + if err != nil { + t.Fatalf("codec: %v", err) + } + svc := NewMerchantService(db, codec, NewTenantService(db)) + merchant := model.Merchant{Code: "merchant-api-limit", Name: "限数商户", Status: model.MerchantStatusActive} + if err := db.Create(&merchant).Error; err != nil { + t.Fatalf("create merchant: %v", err) + } + for i := 0; i < MaxAPIClientsPerMerchant; i++ { + if _, err := svc.CreateAPIClient(merchant.ID, CreateAPIClientInput{ + Name: fmt.Sprintf("key-%d", i), + Scopes: "products:read", + }, 1); err != nil { + t.Fatalf("create client %d: %v", i, err) + } + } + _, err = svc.CreateAPIClient(merchant.ID, CreateAPIClientInput{ + Name: "overflow", + Scopes: "products:read", + }, 1) + if err == nil || !strings.Contains(err.Error(), fmt.Sprintf("最多可创建 %d 个", MaxAPIClientsPerMerchant)) { + t.Fatalf("expected per-merchant limit error, got %v", err) + } +} diff --git a/frontend/src/pages/MerchantCenter.tsx b/frontend/src/pages/MerchantCenter.tsx index b8e5f35..a64babe 100644 --- a/frontend/src/pages/MerchantCenter.tsx +++ b/frontend/src/pages/MerchantCenter.tsx @@ -56,6 +56,8 @@ const memberRoleOptions = [ { value: 'viewer', label: '只读' }, ] +const apiClientMax = 5 + const scopeOptions = [ { value: 'products:read', label: '商品读取' }, { value: 'orders:read', label: '订单读取' }, @@ -523,16 +525,16 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer ] const apiClientColumns: ColumnsType = [ - { title: '名称', dataIndex: 'name', width: 180, ellipsis: true }, - { title: 'App Key', dataIndex: 'app_key', width: 260, render: (v) => {v} }, - { title: '签名', dataIndex: 'signature_version', width: 110, render: (v) => {v} }, - { title: '权限', dataIndex: 'scopes', ellipsis: true }, - { title: '状态', dataIndex: 'status', width: 90, render: activeStatusTag }, - { title: '最后使用', dataIndex: 'last_used_at', width: 180, render: formatDateTime }, + { title: '名称', dataIndex: 'name', width: 140, ellipsis: true }, + { title: 'App Key', dataIndex: 'app_key', width: 230, render: (v) => {v} }, + { title: '签名', dataIndex: 'signature_version', width: 80, render: (v) => {v} }, + { title: '权限', dataIndex: 'scopes', width: 250, render: scopesTag }, + { title: '状态', dataIndex: 'status', width: 80, render: activeStatusTag }, + { title: '最后使用', dataIndex: 'last_used_at', width: 170, render: formatDateTime }, { title: '操作', key: 'action', - width: 90, + width: 100, render: (_, record) => canManage ? ( } + }}>新增密钥({apiClients.length}/{apiClientMax})} - +
) @@ -857,8 +859,12 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer setApiClientOpen(false)} destroyOnClose>
- - + + + + 建议不同用途(下单、查询、对账)分别创建密钥,便于独立禁用与审计。每个商户最多 {apiClientMax} 个。 +
@@ -1016,6 +1025,22 @@ function memberRoleTag(value: MerchantMember['role']) { return {roleText(value)} } +function scopesTag(value: string) { + const scopes = (value || '').split(',').filter(Boolean) + if (scopes.length === 0) { + return '-' + } + return ( + + {scopes.map((s) => {scopeLabel(s)})} + + ) +} + +function scopeLabel(scope: string) { + return scopeOptions.find((item) => item.value === scope)?.label || scope +} + function roleText(value: MerchantMember['role']) { return memberRoleOptions.find((item) => item.value === value)?.label || value }