回调优化为单地址 upsert 配置并支持重置密钥
- 回调订阅改为单记录 upsert:保存即覆盖(URL/事件/状态),自动停用其他订阅,无新增/删除 - 新增重置密钥:保存时传 rotate_secret=true 重新生成密钥并返回一次,解决密钥丢失无法找回 - 删除不再使用的 PATCH /callbacks/:id/status 路由及对应 handler/service 死代码 - 前端回调页改为内联表单(URL/事件/状态),新增重置密钥按钮(确认后展示新密钥一次) - 补充单地址复用、disabled 不投递、密钥轮换测试
This commit is contained in:
@@ -313,30 +313,34 @@ func (h *MerchantHandler) DeleteAPIClient(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *MerchantHandler) ListCallbacks(c *gin.Context) {
|
func (h *MerchantHandler) ListCallbacks(c *gin.Context) {
|
||||||
list, err := h.callbackSvc.ListSubscriptions(middleware.GetMerchantID(c))
|
subscription, err := h.callbackSvc.GetSubscription(middleware.GetMerchantID(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ServerError(c, err.Error())
|
response.ServerError(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, list)
|
response.OK(c, subscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
type callbackReq struct {
|
type callbackReq struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name"`
|
||||||
URL string `json:"url" binding:"required"`
|
URL string `json:"url" binding:"required"`
|
||||||
Events string `json:"events" binding:"required"`
|
Events string `json:"events" binding:"required"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
RotateSecret bool `json:"rotate_secret"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *MerchantHandler) CreateCallback(c *gin.Context) {
|
func (h *MerchantHandler) CreateCallback(c *gin.Context) {
|
||||||
var req callbackReq
|
var req callbackReq
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
response.BadRequest(c, "参数错误:name、url、events 必填")
|
response.BadRequest(c, "参数错误:url、events 必填")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
credential, err := h.callbackSvc.CreateSubscription(middleware.GetMerchantID(c), service.CreateCallbackInput{
|
credential, err := h.callbackSvc.CreateSubscription(middleware.GetMerchantID(c), service.CreateCallbackInput{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
URL: req.URL,
|
URL: req.URL,
|
||||||
Events: req.Events,
|
Events: req.Events,
|
||||||
|
Status: req.Status,
|
||||||
|
RotateSecret: req.RotateSecret,
|
||||||
}, middleware.GetUserID(c))
|
}, middleware.GetUserID(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
@@ -345,20 +349,6 @@ func (h *MerchantHandler) CreateCallback(c *gin.Context) {
|
|||||||
response.OK(c, credential)
|
response.OK(c, credential)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *MerchantHandler) UpdateCallbackStatus(c *gin.Context) {
|
|
||||||
var req statusReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.BadRequest(c, "参数错误")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
if err := h.callbackSvc.UpdateSubscriptionStatus(middleware.GetMerchantID(c), uint(id), req.Status, middleware.GetUserID(c)); err != nil {
|
|
||||||
response.BadRequest(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *MerchantHandler) ListMembers(c *gin.Context) {
|
func (h *MerchantHandler) ListMembers(c *gin.Context) {
|
||||||
members, err := h.merchantSvc.ListMembers(middleware.GetMerchantID(c))
|
members, err := h.merchantSvc.ListMembers(middleware.GetMerchantID(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ func Setup(h *Handlers) *gin.Engine {
|
|||||||
merchant.DELETE("/api-clients/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.DeleteAPIClient)
|
merchant.DELETE("/api-clients/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.DeleteAPIClient)
|
||||||
merchant.GET("/callbacks", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureCallbacks), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.ListCallbacks)
|
merchant.GET("/callbacks", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureCallbacks), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.ListCallbacks)
|
||||||
merchant.POST("/callbacks", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureCallbacks), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateCallback)
|
merchant.POST("/callbacks", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureCallbacks), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateCallback)
|
||||||
merchant.PATCH("/callbacks/:id/status", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureCallbacks), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateCallbackStatus)
|
|
||||||
merchant.GET("/members", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.ListMembers)
|
merchant.GET("/members", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.ListMembers)
|
||||||
merchant.POST("/members", middleware.RequireMerchantRole(model.MemberRoleOwner), h.Merchant.AddCurrentMerchantMember)
|
merchant.POST("/members", middleware.RequireMerchantRole(model.MemberRoleOwner), h.Merchant.AddCurrentMerchantMember)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,27 +21,28 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 回调推送策略(参考微信/支付宝通知机制):
|
// 回调推送策略(参考微信/支付宝通知机制):
|
||||||
// 首次推送失败后按固定序列退避重试,默认共 16 次尝试,之后标记 failed 不再推送。
|
// 首次推送失败后按固定序列退避重试,默认共 16 次尝试,之后标记 failed 不再推送。
|
||||||
// 序列与总次数均可通过环境变量覆盖(CALLBACK_RETRY_SCHEDULE / CALLBACK_MAX_ATTEMPTS)。
|
// 序列与总次数均可通过环境变量覆盖(CALLBACK_RETRY_SCHEDULE / CALLBACK_MAX_ATTEMPTS)。
|
||||||
var defaultCallbackRetrySchedule = []time.Duration{
|
var defaultCallbackRetrySchedule = []time.Duration{
|
||||||
15 * time.Second, // 第 2 次
|
15 * time.Second, // 第 2 次
|
||||||
15 * time.Second, // 第 3 次
|
15 * time.Second, // 第 3 次
|
||||||
30 * time.Second, // 第 4 次
|
30 * time.Second, // 第 4 次
|
||||||
3 * time.Minute, // 第 5 次
|
3 * time.Minute, // 第 5 次
|
||||||
10 * time.Minute, // 第 6 次
|
10 * time.Minute, // 第 6 次
|
||||||
20 * time.Minute, // 第 7 次
|
20 * time.Minute, // 第 7 次
|
||||||
30 * time.Minute, // 第 8 次
|
30 * time.Minute, // 第 8 次
|
||||||
30 * time.Minute, // 第 9 次
|
30 * time.Minute, // 第 9 次
|
||||||
30 * time.Minute, // 第 10 次
|
30 * time.Minute, // 第 10 次
|
||||||
60 * time.Minute, // 第 11 次
|
60 * time.Minute, // 第 11 次
|
||||||
3 * time.Hour, // 第 12 次
|
3 * time.Hour, // 第 12 次
|
||||||
3 * time.Hour, // 第 13 次
|
3 * time.Hour, // 第 13 次
|
||||||
3 * time.Hour, // 第 14 次
|
3 * time.Hour, // 第 14 次
|
||||||
6 * time.Hour, // 第 15 次
|
6 * time.Hour, // 第 15 次
|
||||||
6 * time.Hour, // 第 16 次
|
6 * time.Hour, // 第 16 次
|
||||||
}
|
}
|
||||||
|
|
||||||
// CallbackConfig 回调推送策略配置。
|
// CallbackConfig 回调推送策略配置。
|
||||||
@@ -54,10 +55,10 @@ type CallbackConfig struct {
|
|||||||
|
|
||||||
// CallbackService 以数据库 outbox 方式管理回调,进程重启不会丢失待发送事件。
|
// CallbackService 以数据库 outbox 方式管理回调,进程重启不会丢失待发送事件。
|
||||||
type CallbackService struct {
|
type CallbackService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
codec *SecretCodec
|
codec *SecretCodec
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
maxAttempts int
|
maxAttempts int
|
||||||
retrySchedule []time.Duration
|
retrySchedule []time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +104,9 @@ type CreateCallbackInput struct {
|
|||||||
Name string
|
Name string
|
||||||
URL string
|
URL string
|
||||||
Events string
|
Events string
|
||||||
|
Status string
|
||||||
|
// RotateSecret 重置回调密钥:重新生成 secret(旧密钥立即失效),返回新 secret 仅此一次。
|
||||||
|
RotateSecret bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type CallbackCredential struct {
|
type CallbackCredential struct {
|
||||||
@@ -114,7 +118,7 @@ func (s *CallbackService) CreateSubscription(merchantID uint, in CreateCallbackI
|
|||||||
in.Name = strings.TrimSpace(in.Name)
|
in.Name = strings.TrimSpace(in.Name)
|
||||||
in.URL = strings.TrimSpace(in.URL)
|
in.URL = strings.TrimSpace(in.URL)
|
||||||
if in.Name == "" {
|
if in.Name == "" {
|
||||||
return nil, errors.New("回调名称不能为空")
|
in.Name = "默认回调"
|
||||||
}
|
}
|
||||||
if err := validateCallbackURL(in.URL); err != nil {
|
if err := validateCallbackURL(in.URL); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -123,41 +127,69 @@ func (s *CallbackService) CreateSubscription(merchantID uint, in CreateCallbackI
|
|||||||
if len(events) == 0 {
|
if len(events) == 0 {
|
||||||
return nil, errors.New("至少订阅一个事件")
|
return nil, errors.New("至少订阅一个事件")
|
||||||
}
|
}
|
||||||
// 每个商户仅允许一个回调地址:存在 active 订阅时拒绝创建,先停用旧的再新建。
|
if in.Status == "" {
|
||||||
var activeCount int64
|
in.Status = model.CallbackStatusActive
|
||||||
if err := s.db.Model(&model.CallbackSubscription{}).
|
|
||||||
Where("merchant_id = ? AND status = ?", merchantID, model.CallbackStatusActive).
|
|
||||||
Count(&activeCount).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
if activeCount > 0 {
|
if in.Status != model.CallbackStatusActive && in.Status != model.CallbackStatusDisabled {
|
||||||
return nil, errors.New("每个商户仅支持一个回调地址,请先停用当前订阅再创建")
|
return nil, errors.New("无效的回调状态")
|
||||||
}
|
}
|
||||||
secret, err := randomToken("cb_", 32)
|
|
||||||
if err != nil {
|
subscription := &model.CallbackSubscription{}
|
||||||
return nil, err
|
secret := ""
|
||||||
}
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
ciphertext, err := s.codec.Encrypt(secret)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
subscription := &model.CallbackSubscription{
|
|
||||||
MerchantID: merchantID,
|
|
||||||
Name: in.Name,
|
|
||||||
URL: in.URL,
|
|
||||||
Events: strings.Join(events, ","),
|
|
||||||
SecretCiphertext: ciphertext,
|
|
||||||
Status: model.CallbackStatusActive,
|
|
||||||
}
|
|
||||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
var merchant model.Merchant
|
var merchant model.Merchant
|
||||||
if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil {
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).
|
||||||
|
First(&merchant).Error; err != nil {
|
||||||
return errors.New("商户不存在或已禁用")
|
return errors.New("商户不存在或已禁用")
|
||||||
}
|
}
|
||||||
if err := tx.Create(subscription).Error; err != nil {
|
|
||||||
|
err := tx.Where("merchant_id = ?", merchantID).Order("id DESC").First(subscription).Error
|
||||||
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return writeAudit(tx, &merchantID, &actorUserID, nil, "callback_subscription.create", "callback_subscription", fmt.Sprint(subscription.ID), map[string]string{"url": subscription.URL})
|
action := "callback_subscription.update"
|
||||||
|
rotate := in.RotateSecret
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
rotate = true // 新建时必然生成新密钥
|
||||||
|
action = "callback_subscription.create"
|
||||||
|
}
|
||||||
|
if rotate {
|
||||||
|
var tokenErr error
|
||||||
|
secret, tokenErr = randomToken("cb_", 32)
|
||||||
|
if tokenErr != nil {
|
||||||
|
return tokenErr
|
||||||
|
}
|
||||||
|
ciphertext, encryptErr := s.codec.Encrypt(secret)
|
||||||
|
if encryptErr != nil {
|
||||||
|
return encryptErr
|
||||||
|
}
|
||||||
|
if subscription.ID == 0 {
|
||||||
|
*subscription = model.CallbackSubscription{
|
||||||
|
MerchantID: merchantID,
|
||||||
|
SecretCiphertext: ciphertext,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
subscription.SecretCiphertext = ciphertext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subscription.Name = in.Name
|
||||||
|
subscription.URL = in.URL
|
||||||
|
subscription.Events = strings.Join(events, ",")
|
||||||
|
subscription.Status = in.Status
|
||||||
|
if subscription.ID == 0 {
|
||||||
|
if err := tx.Create(subscription).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if err := tx.Save(subscription).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.CallbackSubscription{}).
|
||||||
|
Where("merchant_id = ? AND id <> ? AND status = ?", merchantID, subscription.ID, model.CallbackStatusActive).
|
||||||
|
Update("status", model.CallbackStatusDisabled).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeAudit(tx, &merchantID, &actorUserID, nil, action, "callback_subscription", fmt.Sprint(subscription.ID), map[string]string{"url": subscription.URL, "status": subscription.Status})
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -165,65 +197,57 @@ func (s *CallbackService) CreateSubscription(merchantID uint, in CreateCallbackI
|
|||||||
return &CallbackCredential{Subscription: subscription, Secret: secret}, nil
|
return &CallbackCredential{Subscription: subscription, Secret: secret}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CallbackService) ListSubscriptions(merchantID uint) ([]model.CallbackSubscription, error) {
|
func (s *CallbackService) GetSubscription(merchantID uint) (*model.CallbackSubscription, error) {
|
||||||
var subscriptions []model.CallbackSubscription
|
var subscription model.CallbackSubscription
|
||||||
err := s.db.Where("merchant_id = ?", merchantID).Order("id DESC").Find(&subscriptions).Error
|
err := s.db.Where("merchant_id = ?", merchantID).Order("id DESC").First(&subscription).Error
|
||||||
return subscriptions, err
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
}
|
return nil, nil
|
||||||
|
|
||||||
func (s *CallbackService) UpdateSubscriptionStatus(merchantID, id uint, status string, actorUserID uint) error {
|
|
||||||
if status != model.CallbackStatusActive && status != model.CallbackStatusDisabled {
|
|
||||||
return errors.New("无效的回调状态")
|
|
||||||
}
|
}
|
||||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
if err != nil {
|
||||||
result := tx.Model(&model.CallbackSubscription{}).
|
return nil, err
|
||||||
Where("id = ? AND merchant_id = ?", id, merchantID).
|
}
|
||||||
Update("status", status)
|
return &subscription, nil
|
||||||
if result.Error != nil {
|
|
||||||
return result.Error
|
|
||||||
}
|
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
return errors.New("回调订阅不存在")
|
|
||||||
}
|
|
||||||
return writeAudit(tx, &merchantID, &actorUserID, nil, "callback_subscription.status.update", "callback_subscription", fmt.Sprint(id), map[string]string{"status": status})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enqueue 在调用方事务中写入回调 outbox,只有订单事务成功才会发送事件。
|
// Enqueue 在调用方事务中写入回调 outbox,只有订单事务成功才会发送事件。
|
||||||
func (s *CallbackService) Enqueue(tx *gorm.DB, merchantID uint, event string, data interface{}) error {
|
func (s *CallbackService) Enqueue(tx *gorm.DB, merchantID uint, event string, data interface{}) error {
|
||||||
var subscriptions []model.CallbackSubscription
|
var subscription model.CallbackSubscription
|
||||||
if err := tx.Where("merchant_id = ? AND status = ?", merchantID, model.CallbackStatusActive).Find(&subscriptions).Error; err != nil {
|
err := tx.Where("merchant_id = ?", merchantID).
|
||||||
|
Order("id DESC").
|
||||||
|
First(&subscription).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
now := time.Now()
|
if subscription.Status != model.CallbackStatusActive {
|
||||||
for _, subscription := range subscriptions {
|
return nil
|
||||||
if !subscribesTo(subscription.Events, event) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
eventID := uuid.NewString()
|
|
||||||
payload, err := json.Marshal(map[string]interface{}{
|
|
||||||
"event_id": eventID,
|
|
||||||
"event": event,
|
|
||||||
"occurred_at": timeutil.FormatAPITime(now),
|
|
||||||
"data": data,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
delivery := model.CallbackDelivery{
|
|
||||||
MerchantID: merchantID,
|
|
||||||
CallbackSubscriptionID: subscription.ID,
|
|
||||||
EventID: eventID,
|
|
||||||
Event: event,
|
|
||||||
Payload: string(payload),
|
|
||||||
Status: model.CallbackDeliveryPending,
|
|
||||||
NextAttemptAt: now,
|
|
||||||
}
|
|
||||||
if err := tx.Create(&delivery).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
if !subscribesTo(subscription.Events, event) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
eventID := uuid.NewString()
|
||||||
|
payload, err := json.Marshal(map[string]interface{}{
|
||||||
|
"event_id": eventID,
|
||||||
|
"event": event,
|
||||||
|
"occurred_at": timeutil.FormatAPITime(now),
|
||||||
|
"data": data,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
delivery := model.CallbackDelivery{
|
||||||
|
MerchantID: merchantID,
|
||||||
|
CallbackSubscriptionID: subscription.ID,
|
||||||
|
EventID: eventID,
|
||||||
|
Event: event,
|
||||||
|
Payload: string(payload),
|
||||||
|
Status: model.CallbackDeliveryPending,
|
||||||
|
NextAttemptAt: now,
|
||||||
|
}
|
||||||
|
return tx.Create(&delivery).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// DispatchDue 执行一批可发送的 outbox 记录。返回成功/失败尝试数,便于日志监控。
|
// DispatchDue 执行一批可发送的 outbox 记录。返回成功/失败尝试数,便于日志监控。
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"affiliate_dash/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCallbackSubscriptionSaveReusesSingleConfigAndControlsDelivery(t *testing.T) {
|
||||||
|
db := newServiceTestDB(t)
|
||||||
|
merchantID, _ := seedFulfillmentMerchant(t, db, "callback-single-config", 1000, 1, 100)
|
||||||
|
codec, err := NewSecretCodec("test-master-key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("codec: %v", err)
|
||||||
|
}
|
||||||
|
svc := NewCallbackService(db, codec)
|
||||||
|
|
||||||
|
first, err := svc.CreateSubscription(merchantID, CreateCallbackInput{
|
||||||
|
URL: "https://example.com/old-callback",
|
||||||
|
Events: "order.shipping.updated",
|
||||||
|
Status: model.CallbackStatusActive,
|
||||||
|
}, 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create callback: %v", err)
|
||||||
|
}
|
||||||
|
if first.Secret == "" {
|
||||||
|
t.Fatalf("new callback should return secret once")
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := svc.CreateSubscription(merchantID, CreateCallbackInput{
|
||||||
|
URL: "https://example.com/new-callback",
|
||||||
|
Events: "order.shipping.updated",
|
||||||
|
Status: model.CallbackStatusDisabled,
|
||||||
|
}, 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update callback: %v", err)
|
||||||
|
}
|
||||||
|
if second.Subscription.ID != first.Subscription.ID {
|
||||||
|
t.Fatalf("callback should reuse one row, first=%d second=%d", first.Subscription.ID, second.Subscription.ID)
|
||||||
|
}
|
||||||
|
if second.Secret != "" {
|
||||||
|
t.Fatalf("updated callback should not rotate or reveal secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscriptionCount int64
|
||||||
|
if err := db.Model(&model.CallbackSubscription{}).Where("merchant_id = ?", merchantID).Count(&subscriptionCount).Error; err != nil {
|
||||||
|
t.Fatalf("count subscriptions: %v", err)
|
||||||
|
}
|
||||||
|
if subscriptionCount != 1 {
|
||||||
|
t.Fatalf("expected one callback subscription, got %d", subscriptionCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
return svc.Enqueue(tx, merchantID, "order.shipping.updated", map[string]string{"source": "test"})
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("enqueue disabled callback: %v", err)
|
||||||
|
}
|
||||||
|
var deliveryCount int64
|
||||||
|
if err := db.Model(&model.CallbackDelivery{}).Where("merchant_id = ?", merchantID).Count(&deliveryCount).Error; err != nil {
|
||||||
|
t.Fatalf("count deliveries: %v", err)
|
||||||
|
}
|
||||||
|
if deliveryCount != 0 {
|
||||||
|
t.Fatalf("disabled callback should not enqueue delivery, got %d", deliveryCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
third, err := svc.CreateSubscription(merchantID, CreateCallbackInput{
|
||||||
|
URL: "https://example.com/new-callback",
|
||||||
|
Events: "order.shipping.updated",
|
||||||
|
Status: model.CallbackStatusActive,
|
||||||
|
}, 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("enable callback: %v", err)
|
||||||
|
}
|
||||||
|
if third.Subscription.ID != first.Subscription.ID {
|
||||||
|
t.Fatalf("enabled callback should still reuse one row")
|
||||||
|
}
|
||||||
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
return svc.Enqueue(tx, merchantID, "order.shipping.updated", map[string]string{"source": "test"})
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("enqueue active callback: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Model(&model.CallbackDelivery{}).Where("merchant_id = ?", merchantID).Count(&deliveryCount).Error; err != nil {
|
||||||
|
t.Fatalf("count deliveries after enable: %v", err)
|
||||||
|
}
|
||||||
|
if deliveryCount != 1 {
|
||||||
|
t.Fatalf("active callback should enqueue one delivery, got %d", deliveryCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallbackSubscriptionRotateSecret(t *testing.T) {
|
||||||
|
db := newServiceTestDB(t)
|
||||||
|
merchantID, _ := seedFulfillmentMerchant(t, db, "callback-rotate-secret", 1000, 1, 100)
|
||||||
|
codec, err := NewSecretCodec("test-master-key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("codec: %v", err)
|
||||||
|
}
|
||||||
|
svc := NewCallbackService(db, codec)
|
||||||
|
|
||||||
|
first, err := svc.CreateSubscription(merchantID, CreateCallbackInput{
|
||||||
|
URL: "https://example.com/cb",
|
||||||
|
Events: "order.shipping.updated",
|
||||||
|
Status: model.CallbackStatusActive,
|
||||||
|
}, 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create callback: %v", err)
|
||||||
|
}
|
||||||
|
if first.Secret == "" {
|
||||||
|
t.Fatalf("new callback should return secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通更新不轮换密钥
|
||||||
|
updated, err := svc.CreateSubscription(merchantID, CreateCallbackInput{
|
||||||
|
URL: "https://example.com/cb",
|
||||||
|
Events: "order.shipping.updated",
|
||||||
|
Status: model.CallbackStatusActive,
|
||||||
|
}, 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update callback: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Secret != "" {
|
||||||
|
t.Fatalf("plain update should not rotate secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显式重置密钥:返回新 secret,且与旧 secret 不同
|
||||||
|
rotated, err := svc.CreateSubscription(merchantID, CreateCallbackInput{
|
||||||
|
URL: "https://example.com/cb",
|
||||||
|
Events: "order.shipping.updated",
|
||||||
|
Status: model.CallbackStatusActive,
|
||||||
|
RotateSecret: true,
|
||||||
|
}, 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rotate callback: %v", err)
|
||||||
|
}
|
||||||
|
if rotated.Secret == "" {
|
||||||
|
t.Fatalf("rotate should return new secret")
|
||||||
|
}
|
||||||
|
if rotated.Secret == first.Secret {
|
||||||
|
t.Fatalf("rotated secret should differ from old secret")
|
||||||
|
}
|
||||||
|
if rotated.Subscription.ID != first.Subscription.ID {
|
||||||
|
t.Fatalf("rotate should keep same subscription row")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -240,9 +240,9 @@ func (s *FulfillmentService) CreateTestOrder(in CreateTestOrderInput) (*model.Fu
|
|||||||
in.OrderStatus = model.OrderStatusPaid
|
in.OrderStatus = model.OrderStatusPaid
|
||||||
}
|
}
|
||||||
switch in.OrderStatus {
|
switch in.OrderStatus {
|
||||||
case model.OrderStatusPaid, model.OrderStatusShipFailed:
|
case model.OrderStatusPaid, model.OrderStatusShipFailed, model.OrderStatusCancelled:
|
||||||
default:
|
default:
|
||||||
return nil, errors.New("测试订单仅支持已支付待发货或发货失败状态")
|
return nil, errors.New("测试订单仅支持已支付待发货、发货失败或已取消状态")
|
||||||
}
|
}
|
||||||
|
|
||||||
requestData := map[string]interface{}{
|
requestData := map[string]interface{}{
|
||||||
@@ -301,13 +301,18 @@ func (s *FulfillmentService) CreateTestOrder(in CreateTestOrderInput) (*model.Fu
|
|||||||
if in.OrderStatus == model.OrderStatusShipFailed {
|
if in.OrderStatus == model.OrderStatusShipFailed {
|
||||||
order.FailureReason = fallbackName(in.Note, "联调测试订单初始化为发货失败,可重新发货")
|
order.FailureReason = fallbackName(in.Note, "联调测试订单初始化为发货失败,可重新发货")
|
||||||
}
|
}
|
||||||
|
if in.OrderStatus == model.OrderStatusCancelled {
|
||||||
|
now := time.Now()
|
||||||
|
order.CancelledAt = &now
|
||||||
|
}
|
||||||
if err := tx.Create(order).Error; err != nil {
|
if err := tx.Create(order).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
canShip, _ := CanFulfill(order)
|
||||||
if err := writeAudit(tx, &in.MerchantID, &in.ActorUserID, nil, "merchant_test_order.create", "fulfillment_order", order.OrderNo, map[string]interface{}{
|
if err := writeAudit(tx, &in.MerchantID, &in.ActorUserID, nil, "merchant_test_order.create", "fulfillment_order", order.OrderNo, map[string]interface{}{
|
||||||
"sku": in.SKU,
|
"sku": in.SKU,
|
||||||
"order_status": in.OrderStatus,
|
"order_status": in.OrderStatus,
|
||||||
"can_ship": true,
|
"can_ship": canShip,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -798,15 +803,15 @@ type DashboardStats struct {
|
|||||||
UserCount int64 `json:"user_count"`
|
UserCount int64 `json:"user_count"`
|
||||||
OrderCount int64 `json:"order_count"`
|
OrderCount int64 `json:"order_count"`
|
||||||
TodayOrderCount int64 `json:"today_order_count"`
|
TodayOrderCount int64 `json:"today_order_count"`
|
||||||
TotalSales int64 `json:"total_sales"`
|
TotalSales int64 `json:"total_sales"`
|
||||||
TodaySales int64 `json:"today_sales"`
|
TodaySales int64 `json:"today_sales"`
|
||||||
TotalFees int64 `json:"total_fees"`
|
TotalFees int64 `json:"total_fees"`
|
||||||
TodayFees int64 `json:"today_fees"`
|
TodayFees int64 `json:"today_fees"`
|
||||||
PaidOrderCount int64 `json:"paid_order_count"`
|
PaidOrderCount int64 `json:"paid_order_count"`
|
||||||
DeliveringOrderCount int64 `json:"delivering_order_count"`
|
DeliveringOrderCount int64 `json:"delivering_order_count"`
|
||||||
DeliveredOrderCount int64 `json:"delivered_order_count"`
|
DeliveredOrderCount int64 `json:"delivered_order_count"`
|
||||||
ShipFailedOrderCount int64 `json:"ship_failed_order_count"`
|
ShipFailedOrderCount int64 `json:"ship_failed_order_count"`
|
||||||
CancelledOrderCount int64 `json:"cancelled_order_count"`
|
CancelledOrderCount int64 `json:"cancelled_order_count"`
|
||||||
WalletAvailableBalance int64 `json:"wallet_available_balance"`
|
WalletAvailableBalance int64 `json:"wallet_available_balance"`
|
||||||
WalletFrozenBalance int64 `json:"wallet_frozen_balance"`
|
WalletFrozenBalance int64 `json:"wallet_frozen_balance"`
|
||||||
APIClientCount int64 `json:"api_client_count"`
|
APIClientCount int64 `json:"api_client_count"`
|
||||||
|
|||||||
@@ -559,7 +559,7 @@ func TestMarkProcessingTimeoutDistinguishesUpstreamSubmission(t *testing.T) {
|
|||||||
t.Fatalf("create submitted order: %v", err)
|
t.Fatalf("create submitted order: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := svc.UpdateFulfillment(FulfillmentUpdateInput{
|
if _, err := svc.UpdateFulfillment(FulfillmentUpdateInput{
|
||||||
MerchantID: merchantID,
|
MerchantID: merchantID,
|
||||||
APIClientID: 13,
|
APIClientID: 13,
|
||||||
OrderNo: submittedOrder.Order.OrderNo,
|
OrderNo: submittedOrder.Order.OrderNo,
|
||||||
Status: model.OrderStatusDelivering,
|
Status: model.OrderStatusDelivering,
|
||||||
@@ -582,7 +582,7 @@ func TestMarkProcessingTimeoutDistinguishesUpstreamSubmission(t *testing.T) {
|
|||||||
t.Fatalf("create interrupted order: %v", err)
|
t.Fatalf("create interrupted order: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := svc.UpdateFulfillment(FulfillmentUpdateInput{
|
if _, err := svc.UpdateFulfillment(FulfillmentUpdateInput{
|
||||||
MerchantID: merchantID,
|
MerchantID: merchantID,
|
||||||
APIClientID: 13,
|
APIClientID: 13,
|
||||||
OrderNo: interruptedOrder.Order.OrderNo,
|
OrderNo: interruptedOrder.Order.OrderNo,
|
||||||
Status: model.OrderStatusDelivering,
|
Status: model.OrderStatusDelivering,
|
||||||
@@ -805,6 +805,31 @@ func TestCreateTestOrderSupportsFailedFulfillableStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateTestOrderSupportsCancelledUnfulfillableStatus(t *testing.T) {
|
||||||
|
db := newServiceTestDB(t)
|
||||||
|
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-test-cancelled", 0, -1, 100)
|
||||||
|
svc := NewFulfillmentService(db, nil)
|
||||||
|
|
||||||
|
order, err := svc.CreateTestOrder(CreateTestOrderInput{
|
||||||
|
MerchantID: merchantID,
|
||||||
|
SKU: product.SKU,
|
||||||
|
OrderStatus: model.OrderStatusCancelled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create cancelled test order: %v", err)
|
||||||
|
}
|
||||||
|
if order.OrderStatus != model.OrderStatusCancelled || order.CancelledAt == nil {
|
||||||
|
t.Fatalf("unexpected cancelled test order: %+v", order)
|
||||||
|
}
|
||||||
|
openOrder, err := svc.QueryOpenOrder(order.OrderNo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query open cancelled order: %v", err)
|
||||||
|
}
|
||||||
|
if openOrder.CanShip || openOrder.Status != "cancelled" || !strings.Contains(openOrder.CannotShipReason, "已取消") {
|
||||||
|
t.Fatalf("cancelled test order should be unfulfillable, got %+v", openOrder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleShipNotifyUpdatesOrderAndEnqueuesMerchantCallback(t *testing.T) {
|
func TestHandleShipNotifyUpdatesOrderAndEnqueuesMerchantCallback(t *testing.T) {
|
||||||
db := newServiceTestDB(t)
|
db := newServiceTestDB(t)
|
||||||
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-source-notify", 1000, 2, 100)
|
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-source-notify", 1000, 2, 100)
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export const merchantApi = {
|
|||||||
sku: string
|
sku: string
|
||||||
buyer_reference?: string
|
buyer_reference?: string
|
||||||
note?: string
|
note?: string
|
||||||
order_status?: 'paid' | 'ship_failed'
|
order_status?: 'paid' | 'ship_failed' | 'cancelled'
|
||||||
}) => request.post('/merchant/orders/test', data).then((r) => r.data.data as CreateTestOrderResult),
|
}) => request.post('/merchant/orders/test', data).then((r) => r.data.data as CreateTestOrderResult),
|
||||||
getDeliveryLink: (orderNo: string) =>
|
getDeliveryLink: (orderNo: string) =>
|
||||||
request.get(`/merchant/orders/${encodeURIComponent(orderNo)}/delivery-link`).then((r) => r.data.data as DeliveryLinkResult),
|
request.get(`/merchant/orders/${encodeURIComponent(orderNo)}/delivery-link`).then((r) => r.data.data as DeliveryLinkResult),
|
||||||
@@ -94,11 +94,9 @@ export const merchantApi = {
|
|||||||
deleteApiClient: (id: number) =>
|
deleteApiClient: (id: number) =>
|
||||||
request.delete(`/merchant/api-clients/${id}`).then((r) => r.data.data),
|
request.delete(`/merchant/api-clients/${id}`).then((r) => r.data.data),
|
||||||
callbacks: () =>
|
callbacks: () =>
|
||||||
request.get('/merchant/callbacks').then((r) => r.data.data as CallbackSubscription[]),
|
request.get('/merchant/callbacks').then((r) => r.data.data as CallbackSubscription | null),
|
||||||
createCallback: (data: { name: string; url: string; events: string }) =>
|
saveCallback: (data: { url: string; events: string; status: CallbackSubscription['status']; rotate_secret?: boolean }) =>
|
||||||
request.post('/merchant/callbacks', data).then((r) => r.data.data as CallbackCredential),
|
request.post('/merchant/callbacks', data).then((r) => r.data.data as CallbackCredential),
|
||||||
updateCallbackStatus: (id: number, status: CallbackSubscription['status']) =>
|
|
||||||
request.patch(`/merchant/callbacks/${id}/status`, { status }).then((r) => r.data.data),
|
|
||||||
members: () =>
|
members: () =>
|
||||||
request.get('/merchant/members').then((r) => r.data.data as MerchantMember[]),
|
request.get('/merchant/members').then((r) => r.data.data as MerchantMember[]),
|
||||||
addMember: (data: { user_id: number; role: MerchantMember['role']; is_default?: boolean }) =>
|
addMember: (data: { user_id: number; role: MerchantMember['role']; is_default?: boolean }) =>
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ const eventOptions = [
|
|||||||
const testOrderStatusOptions = [
|
const testOrderStatusOptions = [
|
||||||
{ value: 'paid', label: '已支付,可发货' },
|
{ value: 'paid', label: '已支付,可发货' },
|
||||||
{ value: 'ship_failed', label: '发货失败,可重试' },
|
{ value: 'ship_failed', label: '发货失败,可重试' },
|
||||||
|
{ value: 'cancelled', label: '已取消,不可发货' },
|
||||||
]
|
]
|
||||||
|
|
||||||
type MerchantCenterTab = 'products' | 'orders' | 'wallet' | 'api' | 'callbacks' | 'members'
|
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 [ledger, setLedger] = useState<PageResult<WalletLedgerEntry>>({ list: [], total: 0, page: 1, size: 10 })
|
||||||
const [wallet, setWallet] = useState<WalletAccount | null>(null)
|
const [wallet, setWallet] = useState<WalletAccount | null>(null)
|
||||||
const [apiClients, setApiClients] = useState<ApiClient[]>([])
|
const [apiClients, setApiClients] = useState<ApiClient[]>([])
|
||||||
const [callbacks, setCallbacks] = useState<CallbackSubscription[]>([])
|
const [callback, setCallback] = useState<CallbackSubscription | null>(null)
|
||||||
const [members, setMembers] = useState<MerchantMember[]>([])
|
const [members, setMembers] = useState<MerchantMember[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [productOpen, setProductOpen] = useState(false)
|
const [productOpen, setProductOpen] = useState(false)
|
||||||
@@ -105,7 +106,6 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
const [walletOpen, setWalletOpen] = useState(false)
|
const [walletOpen, setWalletOpen] = useState(false)
|
||||||
const [apiClientOpen, setApiClientOpen] = useState(false)
|
const [apiClientOpen, setApiClientOpen] = useState(false)
|
||||||
const [apiCredential, setApiCredential] = useState<ApiCredential | null>(null)
|
const [apiCredential, setApiCredential] = useState<ApiCredential | null>(null)
|
||||||
const [callbackOpen, setCallbackOpen] = useState(false)
|
|
||||||
const [callbackCredential, setCallbackCredential] = useState<CallbackCredential | null>(null)
|
const [callbackCredential, setCallbackCredential] = useState<CallbackCredential | null>(null)
|
||||||
const [memberOpen, setMemberOpen] = useState(false)
|
const [memberOpen, setMemberOpen] = useState(false)
|
||||||
const [testOrderOpen, setTestOrderOpen] = useState(false)
|
const [testOrderOpen, setTestOrderOpen] = useState(false)
|
||||||
@@ -214,12 +214,18 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
|
|
||||||
const loadCallbacks = useCallback(async (role = merchantRole) => {
|
const loadCallbacks = useCallback(async (role = merchantRole) => {
|
||||||
if (role !== 'owner' && role !== 'operator') {
|
if (role !== 'owner' && role !== 'operator') {
|
||||||
setCallbacks([])
|
setCallback(null)
|
||||||
|
callbackForm.resetFields()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const callbackData = await merchantApi.callbacks()
|
const callbackData = await merchantApi.callbacks()
|
||||||
setCallbacks(callbackData || [])
|
setCallback(callbackData || null)
|
||||||
}, [merchantRole])
|
callbackForm.setFieldsValue(callbackData ? {
|
||||||
|
url: callbackData.url,
|
||||||
|
events: eventsToValue(callbackData.events),
|
||||||
|
status: callbackData.status,
|
||||||
|
} : defaultCallbackFormValues())
|
||||||
|
}, [callbackForm, merchantRole])
|
||||||
|
|
||||||
const loadMembers = useCallback(async (role = merchantRole) => {
|
const loadMembers = useCallback(async (role = merchantRole) => {
|
||||||
if (role !== 'owner' && role !== 'operator') {
|
if (role !== 'owner' && role !== 'operator') {
|
||||||
@@ -414,17 +420,37 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
const submitCallback = async () => {
|
const submitCallback = async () => {
|
||||||
const values = await callbackForm.validateFields()
|
const values = await callbackForm.validateFields()
|
||||||
try {
|
try {
|
||||||
const credential = await merchantApi.createCallback({
|
const credential = await merchantApi.saveCallback({
|
||||||
name: values.name,
|
|
||||||
url: values.url,
|
url: values.url,
|
||||||
events: (values.events || []).join(','),
|
events: (values.events || []).join(','),
|
||||||
|
status: values.status,
|
||||||
})
|
})
|
||||||
setCallbackCredential(credential)
|
setCallback(credential.subscription)
|
||||||
setCallbackOpen(false)
|
if (credential.secret) {
|
||||||
message.success('回调已创建')
|
setCallbackCredential(credential)
|
||||||
loadCallbacks()
|
}
|
||||||
|
message.success('回调配置已保存')
|
||||||
} catch (e) {
|
} 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> = [
|
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: '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 || '-' },
|
{ 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> = [
|
const memberColumns: ColumnsType<MerchantMember> = [
|
||||||
{ title: '用户', dataIndex: ['user', 'username'], render: (_, r) => r.user?.username || `#${r.user_id}` },
|
{ title: '用户', dataIndex: ['user', 'username'], render: (_, r) => r.user?.username || `#${r.user_id}` },
|
||||||
{ title: '昵称', dataIndex: ['user', 'nickname'], render: (_, r) => r.user?.nickname || '-' },
|
{ title: '昵称', dataIndex: ['user', 'nickname'], render: (_, r) => r.user?.nickname || '-' },
|
||||||
@@ -729,17 +727,47 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
|
|
||||||
const callbackContent = (
|
const callbackContent = (
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
<Typography.Text type="secondary">
|
||||||
<Typography.Text type="secondary">
|
回调事件通过 outbox 持久化推送,失败按固定间隔退避重试(最多 16 次)后标记失败。每个商户只保留一份回调配置。
|
||||||
回调事件通过 outbox 持久化推送,失败按固定间隔退避重试(最多 16 次)后标记失败。每个商户仅支持一个回调地址,变更地址请先停用当前订阅。
|
</Typography.Text>
|
||||||
</Typography.Text>
|
<Card size="small" loading={loading}>
|
||||||
{canManage && <Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
<Form
|
||||||
callbackForm.resetFields()
|
form={callbackForm}
|
||||||
callbackForm.setFieldsValue({ events: ['order.shipping.updated'] })
|
layout="vertical"
|
||||||
setCallbackOpen(true)
|
disabled={!canManage}
|
||||||
}}>新增回调</Button>}
|
onFinish={submitCallback}
|
||||||
</Space>
|
initialValues={defaultCallbackFormValues()}
|
||||||
<Table rowKey="id" loading={loading} columns={callbackColumns} dataSource={callbacks} tableLayout="fixed" />
|
>
|
||||||
|
<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>
|
</Space>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -904,6 +932,11 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
{testOrderResult.can_ship ? 'can_ship=true' : 'can_ship=false'}
|
{testOrderResult.can_ship ? 'can_ship=true' : 'can_ship=false'}
|
||||||
</Tag>
|
</Tag>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
{!testOrderResult.can_ship && (
|
||||||
|
<Descriptions.Item label="不可发货原因">
|
||||||
|
<Typography.Text type="secondary">{testOrderResult.cannot_ship_reason || '-'}</Typography.Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
<Descriptions.Item label="链接有效期">
|
<Descriptions.Item label="链接有效期">
|
||||||
{testOrderResult.order.delivery_link_revoked_at ? <Tag color="red">已作废</Tag> : formatDateTime(testOrderResult.order.delivery_link_expires_at)}
|
{testOrderResult.order.delivery_link_revoked_at ? <Tag color="red">已作废</Tag> : formatDateTime(testOrderResult.order.delivery_link_expires_at)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
@@ -953,20 +986,6 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
{apiCredential && <SecretBlock appKey={apiCredential.client.app_key} secret={apiCredential.secret} />}
|
{apiCredential && <SecretBlock appKey={apiCredential.client.app_key} secret={apiCredential.secret} />}
|
||||||
</Modal>
|
</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>}>
|
<Modal title="回调密钥" open={!!callbackCredential} onCancel={() => setCallbackCredential(null)} footer={<Button type="primary" onClick={() => setCallbackCredential(null)}>我已保存</Button>}>
|
||||||
{callbackCredential && <SecretBlock secret={callbackCredential.secret} />}
|
{callbackCredential && <SecretBlock secret={callbackCredential.secret} />}
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -1030,6 +1049,20 @@ function featuresToList(features?: string) {
|
|||||||
return list.length > 0 ? list : ['products', 'orders', 'wallet', 'api', 'callbacks']
|
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 {
|
function tabFromSearch(search: string): MerchantCenterTab | null {
|
||||||
const tab = new URLSearchParams(search).get('tab')
|
const tab = new URLSearchParams(search).get('tab')
|
||||||
const allowedTabs: MerchantCenterTab[] = ['products', 'orders', 'wallet', 'api', 'callbacks', 'members']
|
const allowedTabs: MerchantCenterTab[] = ['products', 'orders', 'wallet', 'api', 'callbacks', 'members']
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ export interface CallbackSubscription {
|
|||||||
events: string
|
events: string
|
||||||
status: 'active' | 'disabled'
|
status: 'active' | 'disabled'
|
||||||
created_at: string
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CallbackCredential {
|
export interface CallbackCredential {
|
||||||
|
|||||||
Reference in New Issue
Block a user