优化 API 密钥管理与展示
- 每个商户限制最多 5 个 API 密钥,超限拒绝创建并提示 - API 密钥权限列改用中文标签展示,新增按用途命名提示 - 优化 API 密钥表格列宽,App Key 完整单行展示 - 补充密钥数量上限测试
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user