回调优化为单地址 upsert 配置并支持重置密钥
- 回调订阅改为单记录 upsert:保存即覆盖(URL/事件/状态),自动停用其他订阅,无新增/删除 - 新增重置密钥:保存时传 rotate_secret=true 重新生成密钥并返回一次,解决密钥丢失无法找回 - 删除不再使用的 PATCH /callbacks/:id/status 路由及对应 handler/service 死代码 - 前端回调页改为内联表单(URL/事件/状态),新增重置密钥按钮(确认后展示新密钥一次) - 补充单地址复用、disabled 不投递、密钥轮换测试
This commit is contained in:
@@ -77,6 +77,7 @@ const eventOptions = [
|
||||
const testOrderStatusOptions = [
|
||||
{ value: 'paid', label: '已支付,可发货' },
|
||||
{ value: 'ship_failed', label: '发货失败,可重试' },
|
||||
{ value: 'cancelled', label: '已取消,不可发货' },
|
||||
]
|
||||
|
||||
type MerchantCenterTab = 'products' | 'orders' | 'wallet' | 'api' | 'callbacks' | 'members'
|
||||
@@ -97,7 +98,7 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
||||
const [ledger, setLedger] = useState<PageResult<WalletLedgerEntry>>({ list: [], total: 0, page: 1, size: 10 })
|
||||
const [wallet, setWallet] = useState<WalletAccount | null>(null)
|
||||
const [apiClients, setApiClients] = useState<ApiClient[]>([])
|
||||
const [callbacks, setCallbacks] = useState<CallbackSubscription[]>([])
|
||||
const [callback, setCallback] = useState<CallbackSubscription | null>(null)
|
||||
const [members, setMembers] = useState<MerchantMember[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [productOpen, setProductOpen] = useState(false)
|
||||
@@ -105,7 +106,6 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
||||
const [walletOpen, setWalletOpen] = useState(false)
|
||||
const [apiClientOpen, setApiClientOpen] = useState(false)
|
||||
const [apiCredential, setApiCredential] = useState<ApiCredential | null>(null)
|
||||
const [callbackOpen, setCallbackOpen] = useState(false)
|
||||
const [callbackCredential, setCallbackCredential] = useState<CallbackCredential | null>(null)
|
||||
const [memberOpen, setMemberOpen] = useState(false)
|
||||
const [testOrderOpen, setTestOrderOpen] = useState(false)
|
||||
@@ -214,12 +214,18 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
||||
|
||||
const loadCallbacks = useCallback(async (role = merchantRole) => {
|
||||
if (role !== 'owner' && role !== 'operator') {
|
||||
setCallbacks([])
|
||||
setCallback(null)
|
||||
callbackForm.resetFields()
|
||||
return
|
||||
}
|
||||
const callbackData = await merchantApi.callbacks()
|
||||
setCallbacks(callbackData || [])
|
||||
}, [merchantRole])
|
||||
setCallback(callbackData || null)
|
||||
callbackForm.setFieldsValue(callbackData ? {
|
||||
url: callbackData.url,
|
||||
events: eventsToValue(callbackData.events),
|
||||
status: callbackData.status,
|
||||
} : defaultCallbackFormValues())
|
||||
}, [callbackForm, merchantRole])
|
||||
|
||||
const loadMembers = useCallback(async (role = merchantRole) => {
|
||||
if (role !== 'owner' && role !== 'operator') {
|
||||
@@ -414,17 +420,37 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
||||
const submitCallback = async () => {
|
||||
const values = await callbackForm.validateFields()
|
||||
try {
|
||||
const credential = await merchantApi.createCallback({
|
||||
name: values.name,
|
||||
const credential = await merchantApi.saveCallback({
|
||||
url: values.url,
|
||||
events: (values.events || []).join(','),
|
||||
status: values.status,
|
||||
})
|
||||
setCallbackCredential(credential)
|
||||
setCallbackOpen(false)
|
||||
message.success('回调已创建')
|
||||
loadCallbacks()
|
||||
setCallback(credential.subscription)
|
||||
if (credential.secret) {
|
||||
setCallbackCredential(credential)
|
||||
}
|
||||
message.success('回调配置已保存')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败')
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const rotateCallbackSecret = async () => {
|
||||
const values = await callbackForm.validateFields()
|
||||
try {
|
||||
const credential = await merchantApi.saveCallback({
|
||||
url: values.url,
|
||||
events: (values.events || []).join(','),
|
||||
status: values.status,
|
||||
rotate_secret: true,
|
||||
})
|
||||
setCallback(credential.subscription)
|
||||
if (credential.secret) {
|
||||
setCallbackCredential(credential)
|
||||
}
|
||||
message.success('回调密钥已重置,旧密钥立即失效')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '重置失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,16 +490,6 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCallback = async (record: CallbackSubscription) => {
|
||||
try {
|
||||
await merchantApi.updateCallbackStatus(record.id, record.status === 'active' ? 'disabled' : 'active')
|
||||
message.success('状态已更新')
|
||||
loadCallbacks()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
const productColumns: ColumnsType<MerchantProduct> = [
|
||||
{ title: 'SKU', dataIndex: 'sku', width: 200, ellipsis: true, render: (v) => <Typography.Text code copyable={{ tooltips: false }} style={{ maxWidth: '100%' }} ellipsis>{v}</Typography.Text> },
|
||||
{ title: '名称', dataIndex: 'display_name', width: 200, ellipsis: true, render: (_, r) => r.display_name || r.product?.name || '-' },
|
||||
@@ -579,24 +595,6 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
||||
},
|
||||
]
|
||||
|
||||
const callbackColumns: ColumnsType<CallbackSubscription> = [
|
||||
{ title: '名称', dataIndex: 'name', width: 180, ellipsis: true },
|
||||
{ title: 'URL', dataIndex: 'url', ellipsis: true },
|
||||
{ title: '事件', dataIndex: 'events', width: 260, ellipsis: true },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: activeStatusTag },
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 180, render: formatDateTime },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 90,
|
||||
render: (_, record) => canManage ? (
|
||||
<Button type="link" size="small" onClick={() => toggleCallback(record)}>
|
||||
{record.status === 'active' ? '禁用' : '启用'}
|
||||
</Button>
|
||||
) : '-',
|
||||
},
|
||||
]
|
||||
|
||||
const memberColumns: ColumnsType<MerchantMember> = [
|
||||
{ title: '用户', dataIndex: ['user', 'username'], render: (_, r) => r.user?.username || `#${r.user_id}` },
|
||||
{ title: '昵称', dataIndex: ['user', 'nickname'], render: (_, r) => r.user?.nickname || '-' },
|
||||
@@ -729,17 +727,47 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
||||
|
||||
const callbackContent = (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Text type="secondary">
|
||||
回调事件通过 outbox 持久化推送,失败按固定间隔退避重试(最多 16 次)后标记失败。每个商户仅支持一个回调地址,变更地址请先停用当前订阅。
|
||||
</Typography.Text>
|
||||
{canManage && <Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
callbackForm.resetFields()
|
||||
callbackForm.setFieldsValue({ events: ['order.shipping.updated'] })
|
||||
setCallbackOpen(true)
|
||||
}}>新增回调</Button>}
|
||||
</Space>
|
||||
<Table rowKey="id" loading={loading} columns={callbackColumns} dataSource={callbacks} tableLayout="fixed" />
|
||||
<Typography.Text type="secondary">
|
||||
回调事件通过 outbox 持久化推送,失败按固定间隔退避重试(最多 16 次)后标记失败。每个商户只保留一份回调配置。
|
||||
</Typography.Text>
|
||||
<Card size="small" loading={loading}>
|
||||
<Form
|
||||
form={callbackForm}
|
||||
layout="vertical"
|
||||
disabled={!canManage}
|
||||
onFinish={submitCallback}
|
||||
initialValues={defaultCallbackFormValues()}
|
||||
>
|
||||
<Form.Item name="url" label="回调 URL" rules={[{ required: true, message: '请填写回调 URL' }]}>
|
||||
<Input placeholder="https://example.com/callback" />
|
||||
</Form.Item>
|
||||
<Form.Item name="events" label="事件" rules={[{ required: true, message: '请选择回调事件' }]}>
|
||||
<Select mode="multiple" options={eventOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||
<Select options={[{ value: 'active', label: '启用' }, { value: 'disabled', label: '禁用' }]} />
|
||||
</Form.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Text type="secondary">
|
||||
{callback?.updated_at ? `上次保存:${formatDateTime(callback.updated_at)}` : '尚未保存回调配置'}
|
||||
</Typography.Text>
|
||||
<Space>
|
||||
{canManage && (
|
||||
<Popconfirm
|
||||
title="重置回调密钥?"
|
||||
description="重置后旧密钥立即失效,平台将用新密钥签名推送,新密钥仅展示一次。"
|
||||
okText="重置"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={rotateCallbackSecret}
|
||||
>
|
||||
<Button>重置密钥</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{canManage && <Button type="primary" htmlType="submit">保存配置</Button>}
|
||||
</Space>
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
</Space>
|
||||
)
|
||||
|
||||
@@ -904,6 +932,11 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
||||
{testOrderResult.can_ship ? 'can_ship=true' : 'can_ship=false'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
{!testOrderResult.can_ship && (
|
||||
<Descriptions.Item label="不可发货原因">
|
||||
<Typography.Text type="secondary">{testOrderResult.cannot_ship_reason || '-'}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="链接有效期">
|
||||
{testOrderResult.order.delivery_link_revoked_at ? <Tag color="red">已作废</Tag> : formatDateTime(testOrderResult.order.delivery_link_expires_at)}
|
||||
</Descriptions.Item>
|
||||
@@ -953,20 +986,6 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
||||
{apiCredential && <SecretBlock appKey={apiCredential.client.app_key} secret={apiCredential.secret} />}
|
||||
</Modal>
|
||||
|
||||
<Modal title="新增回调" open={callbackOpen} onOk={submitCallback} onCancel={() => setCallbackOpen(false)} destroyOnClose>
|
||||
<Form form={callbackForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="url" label="回调 URL" rules={[{ required: true }]}>
|
||||
<Input placeholder="https://example.com/callback" />
|
||||
</Form.Item>
|
||||
<Form.Item name="events" label="事件" rules={[{ required: true }]}>
|
||||
<Select mode="multiple" options={eventOptions} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="回调密钥" open={!!callbackCredential} onCancel={() => setCallbackCredential(null)} footer={<Button type="primary" onClick={() => setCallbackCredential(null)}>我已保存</Button>}>
|
||||
{callbackCredential && <SecretBlock secret={callbackCredential.secret} />}
|
||||
</Modal>
|
||||
@@ -1030,6 +1049,20 @@ function featuresToList(features?: string) {
|
||||
return list.length > 0 ? list : ['products', 'orders', 'wallet', 'api', 'callbacks']
|
||||
}
|
||||
|
||||
function defaultCallbackFormValues() {
|
||||
return {
|
||||
events: ['order.shipping.updated'],
|
||||
status: 'active',
|
||||
}
|
||||
}
|
||||
|
||||
function eventsToValue(events?: string) {
|
||||
return (events || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function tabFromSearch(search: string): MerchantCenterTab | null {
|
||||
const tab = new URLSearchParams(search).get('tab')
|
||||
const allowedTabs: MerchantCenterTab[] = ['products', 'orders', 'wallet', 'api', 'callbacks', 'members']
|
||||
|
||||
Reference in New Issue
Block a user