优化 API 密钥管理与展示

- 每个商户限制最多 5 个 API 密钥,超限拒绝创建并提示
- API 密钥权限列改用中文标签展示,新增按用途命名提示
- 优化 API 密钥表格列宽,App Key 完整单行展示
- 补充密钥数量上限测试
This commit is contained in:
yml2213
2026-07-31 19:58:17 +08:00
parent eac025c92f
commit 8672ab2dca
3 changed files with 85 additions and 13 deletions
+10
View File
@@ -461,6 +461,9 @@ type CreateAPIClientInput struct {
ExpiresAt *time.Time ExpiresAt *time.Time
} }
// MaxAPIClientsPerMerchant 每个商户最多可创建的 API 密钥数量,防止密钥滥用。
const MaxAPIClientsPerMerchant = 5
func (s *MerchantService) CreateAPIClient(merchantID uint, in CreateAPIClientInput, actorUserID uint) (*APICredential, error) { func (s *MerchantService) CreateAPIClient(merchantID uint, in CreateAPIClientInput, actorUserID uint) (*APICredential, error) {
in.Name = strings.TrimSpace(in.Name) in.Name = strings.TrimSpace(in.Name)
if 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 { if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil {
return errors.New("商户不存在或已禁用") 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 { if err := tx.Create(client).Error; err != nil {
return err return err
} }
+37
View File
@@ -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)
}
}
+38 -13
View File
@@ -56,6 +56,8 @@ const memberRoleOptions = [
{ value: 'viewer', label: '只读' }, { value: 'viewer', label: '只读' },
] ]
const apiClientMax = 5
const scopeOptions = [ const scopeOptions = [
{ value: 'products:read', label: '商品读取' }, { value: 'products:read', label: '商品读取' },
{ value: 'orders:read', label: '订单读取' }, { value: 'orders:read', label: '订单读取' },
@@ -523,16 +525,16 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
] ]
const apiClientColumns: ColumnsType<ApiClient> = [ const apiClientColumns: ColumnsType<ApiClient> = [
{ title: '名称', dataIndex: 'name', width: 180, ellipsis: true }, { title: '名称', dataIndex: 'name', width: 140, ellipsis: true },
{ title: 'App Key', dataIndex: 'app_key', width: 260, render: (v) => <Typography.Text code copyable>{v}</Typography.Text> }, { title: 'App Key', dataIndex: 'app_key', width: 230, render: (v) => <Typography.Text code copyable>{v}</Typography.Text> },
{ title: '签名', dataIndex: 'signature_version', width: 110, render: (v) => <Tag>{v}</Tag> }, { title: '签名', dataIndex: 'signature_version', width: 80, render: (v) => <Tag>{v}</Tag> },
{ title: '权限', dataIndex: 'scopes', ellipsis: true }, { title: '权限', dataIndex: 'scopes', width: 250, render: scopesTag },
{ title: '状态', dataIndex: 'status', width: 90, render: activeStatusTag }, { title: '状态', dataIndex: 'status', width: 80, render: activeStatusTag },
{ title: '最后使用', dataIndex: 'last_used_at', width: 180, render: formatDateTime }, { title: '最后使用', dataIndex: 'last_used_at', width: 170, render: formatDateTime },
{ {
title: '操作', title: '操作',
key: 'action', key: 'action',
width: 90, width: 100,
render: (_, record) => canManage ? ( render: (_, record) => canManage ? (
<Button type="link" size="small" onClick={() => toggleAPIClient(record)}> <Button type="link" size="small" onClick={() => toggleAPIClient(record)}>
{record.status === 'active' ? '禁用' : '启用'} {record.status === 'active' ? '禁用' : '启用'}
@@ -647,14 +649,14 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
const apiKeyContent = ( const apiKeyContent = (
<Space direction="vertical" style={{ width: '100%' }} size="middle"> <Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space style={{ width: '100%', justifyContent: 'space-between' }}> <Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Typography.Text type="secondary"> API Secret </Typography.Text> <Typography.Text type="secondary"> API Secret {apiClientMax} </Typography.Text>
{canManage && <Button type="primary" icon={<ApiOutlined />} onClick={() => { {canManage && <Button type="primary" icon={<ApiOutlined />} disabled={apiClients.length >= apiClientMax} onClick={() => {
apiClientForm.resetFields() apiClientForm.resetFields()
apiClientForm.setFieldsValue({ signature_version: 'v1', scopes: ['products:read', 'orders:read', 'orders:write'] }) apiClientForm.setFieldsValue({ signature_version: 'v1', scopes: ['products:read', 'orders:read', 'orders:write'] })
setApiClientOpen(true) setApiClientOpen(true)
}}></Button>} }}>{apiClients.length}/{apiClientMax}</Button>}
</Space> </Space>
<Table rowKey="id" loading={loading} columns={apiClientColumns} dataSource={apiClients} tableLayout="fixed" /> <Table rowKey="id" loading={loading} columns={apiClientColumns} dataSource={apiClients} tableLayout="fixed" scroll={{ x: 1050 }} />
</Space> </Space>
) )
@@ -857,8 +859,12 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
<Modal title="新增 API 密钥" open={apiClientOpen} onOk={submitAPIClient} onCancel={() => setApiClientOpen(false)} destroyOnClose> <Modal title="新增 API 密钥" open={apiClientOpen} onOk={submitAPIClient} onCancel={() => setApiClientOpen(false)} destroyOnClose>
<Form form={apiClientForm} layout="vertical" style={{ marginTop: 16 }}> <Form form={apiClientForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="name" label="名称" rules={[{ required: true }]}> <Form.Item
<Input /> name="name"
label="名称"
rules={[{ required: true }, { max: 64, message: '名称最长 64 个字符' }]}
>
<Input placeholder="按用途命名,如:下单系统 / 对账脚本" maxLength={64} />
</Form.Item> </Form.Item>
<Form.Item name="scopes" label="权限" rules={[{ required: true }]}> <Form.Item name="scopes" label="权限" rules={[{ required: true }]}>
<Select mode="multiple" options={scopeOptions} /> <Select mode="multiple" options={scopeOptions} />
@@ -866,6 +872,9 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
<Form.Item name="signature_version" label="签名版本"> <Form.Item name="signature_version" label="签名版本">
<Select options={[{ value: 'v1', label: 'v1' }]} /> <Select options={[{ value: 'v1', label: 'v1' }]} />
</Form.Item> </Form.Item>
<Typography.Text type="secondary">
便 {apiClientMax}
</Typography.Text>
</Form> </Form>
</Modal> </Modal>
@@ -1016,6 +1025,22 @@ function memberRoleTag(value: MerchantMember['role']) {
return <Tag color={value === 'owner' ? 'gold' : 'blue'}>{roleText(value)}</Tag> return <Tag color={value === 'owner' ? 'gold' : 'blue'}>{roleText(value)}</Tag>
} }
function scopesTag(value: string) {
const scopes = (value || '').split(',').filter(Boolean)
if (scopes.length === 0) {
return '-'
}
return (
<Space size={4} wrap>
{scopes.map((s) => <Tag key={s}>{scopeLabel(s)}</Tag>)}
</Space>
)
}
function scopeLabel(scope: string) {
return scopeOptions.find((item) => item.value === scope)?.label || scope
}
function roleText(value: MerchantMember['role']) { function roleText(value: MerchantMember['role']) {
return memberRoleOptions.find((item) => item.value === value)?.label || value return memberRoleOptions.find((item) => item.value === value)?.label || value
} }