feat(shipping): 新增管理端撤销发货功能

- 新增 AdminCancelShipping handler,支持批量撤销待发货记录(status=1→5)
- 事务内同步恢复 user_inventory.status=1 并清空 shipping_no
- 在 remark 记录操作人 adminID,保证审计可追溯
- 注册路由 POST /api/admin/shipping/orders/cancel
This commit is contained in:
Zuncle 2026-03-18 20:11:37 +08:00
parent b9a40df5c5
commit 0722e515c4
2 changed files with 91 additions and 0 deletions

View File

@ -1,6 +1,7 @@
package admin
import (
"fmt"
"net/http"
"strconv"
"time"
@ -8,6 +9,7 @@ import (
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
)
@ -429,3 +431,91 @@ func (h *handler) GetShippingOrderDetail() core.HandlerFunc {
ctx.Payload(rsp)
}
}
type cancelShippingRequest struct {
RecordIDs []int64 `json:"record_ids"`
}
type cancelShippingResponse struct {
CancelledCount int64 `json:"cancelled_count"`
}
// AdminCancelShipping 管理端撤销发货申请
// @Summary 管理端撤销发货申请
// @Description 将待发货(status=1)的记录撤销为已取消(status=5),并恢复对应库存状态
// @Tags 管理端.发货管理
// @Accept json
// @Produce json
// @Security LoginVerifyToken
// @Param RequestBody body cancelShippingRequest true "请求参数"
// @Success 200 {object} cancelShippingResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/shipping/orders/cancel [post]
func (h *handler) AdminCancelShipping() core.HandlerFunc {
return func(ctx core.Context) {
req := new(cancelShippingRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if len(req.RecordIDs) == 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "record_ids不能为空"))
return
}
if len(req.RecordIDs) > 100 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "单次最多处理100条记录"))
return
}
adminID := ctx.SessionUserInfo().Id
var cancelledCount int64
err := h.writeDB.Transaction(func(tx *dao.Query) error {
records, err := tx.ShippingRecords.WithContext(ctx.RequestContext()).
Select(tx.ShippingRecords.ID, tx.ShippingRecords.InventoryID, tx.ShippingRecords.UserID).
Where(tx.ShippingRecords.ID.In(req.RecordIDs...)).
Where(tx.ShippingRecords.Status.Eq(1)).
Find()
if err != nil {
return fmt.Errorf("query shipping records failed: %w", err)
}
if len(records) == 0 {
return fmt.Errorf("没有找到待发货记录,可能已被处理")
}
for _, rec := range records {
res, err := tx.ShippingRecords.WithContext(ctx.RequestContext()).
Where(tx.ShippingRecords.ID.Eq(rec.ID)).
Where(tx.ShippingRecords.Status.Eq(1)).
Update(tx.ShippingRecords.Status, 5)
if err != nil {
return fmt.Errorf("update shipping record failed: %w", err)
}
if res.RowsAffected == 0 {
continue
}
remark := fmt.Sprintf("|shipping_cancelled_by_admin:%d", adminID)
dbResult := tx.UserInventory.WithContext(ctx.RequestContext()).UnderlyingDB().Exec(
"UPDATE user_inventory SET status=1, shipping_no='', remark=CONCAT(IFNULL(remark,''), ?) WHERE id=? AND user_id=?",
remark, rec.InventoryID, rec.UserID,
)
if dbResult.Error != nil {
return fmt.Errorf("restore inventory failed: %w", dbResult.Error)
}
if dbResult.RowsAffected == 0 {
return fmt.Errorf("restore inventory failed: inventory id=%d not matched", rec.InventoryID)
}
cancelledCount++
}
return nil
})
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))
return
}
ctx.Payload(&cancelShippingResponse{CancelledCount: cancelledCount})
}
}

View File

@ -364,6 +364,7 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
adminAuthApiRouter.GET("/shipping/orders", intc.RequireAdminAction("shipping:view"), adminHandler.ListShippingOrders())
adminAuthApiRouter.GET("/shipping/orders/:id", intc.RequireAdminAction("shipping:view"), adminHandler.GetShippingOrderDetail())
adminAuthApiRouter.PUT("/shipping/orders/batch", intc.RequireAdminAction("shipping:modify"), adminHandler.UpdateShippingBatch())
adminAuthApiRouter.POST("/shipping/orders/cancel", intc.RequireAdminAction("shipping:modify"), adminHandler.AdminCancelShipping())
adminAuthApiRouter.POST("/pay/refunds", intc.RequireAdminAction("refund:create"), adminHandler.CreateRefund())
adminAuthApiRouter.GET("/pay/refunds", intc.RequireAdminAction("refund:view"), adminHandler.ListRefunds())