回调优化为单地址 upsert 配置并支持重置密钥

- 回调订阅改为单记录 upsert:保存即覆盖(URL/事件/状态),自动停用其他订阅,无新增/删除
- 新增重置密钥:保存时传 rotate_secret=true 重新生成密钥并返回一次,解决密钥丢失无法找回
- 删除不再使用的 PATCH /callbacks/:id/status 路由及对应 handler/service 死代码
- 前端回调页改为内联表单(URL/事件/状态),新增重置密钥按钮(确认后展示新密钥一次)
- 补充单地址复用、disabled 不投递、密钥轮换测试
This commit is contained in:
yml2213
2026-08-03 11:23:46 +08:00
parent cf2ee96baa
commit 27a86f5311
9 changed files with 427 additions and 207 deletions
+98 -65
View File
@@ -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']