bindbox-game/internal/api/admin/shipping_orders_admin.go
Zuncle 0722e515c4 feat(shipping): 新增管理端撤销发货功能
- 新增 AdminCancelShipping handler,支持批量撤销待发货记录(status=1→5)
- 事务内同步恢复 user_inventory.status=1 并清空 shipping_no
- 在 remark 记录操作人 adminID,保证审计可追溯
- 注册路由 POST /api/admin/shipping/orders/cancel
2026-03-18 20:11:37 +08:00

522 lines
16 KiB
Go
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package admin
import (
"fmt"
"net/http"
"strconv"
"time"
"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"
)
type listShippingOrdersRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
Status *int32 `form:"status"` // 1待发货 2已发货 3已签收 4异常
UserID *int64 `form:"user_id"`
BatchNo string `form:"batch_no"`
ExpressNo string `form:"express_no"`
StartDate string `form:"start_date"`
EndDate string `form:"end_date"`
}
type ShippingOrderGroup struct {
GroupKey string `json:"group_key"` // 分组键(用于批量操作)
BatchNo string `json:"batch_no"` // 批次号
ExpressCode string `json:"express_code"` // 快递公司编码
ExpressNo string `json:"express_no"` // 运单号
Status int32 `json:"status"` // 状态(取最大值)
Count int64 `json:"count"` // 商品数量
TotalPrice int64 `json:"total_price"` // 总价格
UserID int64 `json:"user_id"` // 用户ID
UserNickname string `json:"user_nickname"` // 用户昵称
AddressID int64 `json:"address_id"` // 地址ID
AddressInfo string `json:"address_info"` // 地址信息
ShippedAt *time.Time `json:"shipped_at,omitempty"`
ReceivedAt *time.Time `json:"received_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
RecordIDs []int64 `json:"record_ids"` // 发货记录ID列表
InventoryIDs []int64 `json:"inventory_ids"` // 资产ID列表
ProductIDs []int64 `json:"product_ids"` // 商品ID列表
Products []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
Price int64 `json:"price"`
Count int64 `json:"count"` // 增加数量字段
} `json:"products"` // 商品详情列表
}
type listShippingOrdersResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []*ShippingOrderGroup `json:"list"`
}
// ListShippingOrders 发货订单列表(按批次号/运单号聚合)
// @Summary 发货订单列表
// @Description 按批次号或运单号聚合显示发货记录,支持筛选状态、用户、时间等
// @Tags 管理端.发货管理
// @Accept json
// @Produce json
// @Security LoginVerifyToken
// @Param page query int false "页码默认1"
// @Param page_size query int false "每页数量最多100默认20"
// @Param status query int false "状态1待发货 2已发货 3已签收 4异常"
// @Param user_id query int false "用户ID"
// @Param batch_no query string false "批次号"
// @Param express_no query string false "运单号"
// @Param start_date query string false "开始日期 2006-01-02"
// @Param end_date query string false "结束日期 2006-01-02"
// @Success 200 {object} listShippingOrdersResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/shipping/orders [get]
func (h *handler) ListShippingOrders() core.HandlerFunc {
return func(ctx core.Context) {
req := new(listShippingOrdersRequest)
rsp := new(listShippingOrdersResponse)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
if req.PageSize > 100 {
req.PageSize = 100
}
// 构建查询
q := h.readDB.ShippingRecords.WithContext(ctx.RequestContext()).ReadDB()
if req.Status != nil {
q = q.Where(h.readDB.ShippingRecords.Status.Eq(*req.Status))
}
if req.UserID != nil {
q = q.Where(h.readDB.ShippingRecords.UserID.Eq(*req.UserID))
}
if req.BatchNo != "" {
q = q.Where(h.readDB.ShippingRecords.BatchNo.Eq(req.BatchNo))
}
if req.ExpressNo != "" {
q = q.Where(h.readDB.ShippingRecords.ExpressNo.Eq(req.ExpressNo))
}
if req.StartDate != "" {
if t, err := time.Parse("2006-01-02", req.StartDate); err == nil {
q = q.Where(h.readDB.ShippingRecords.CreatedAt.Gte(t))
}
}
if req.EndDate != "" {
if t, err := time.Parse("2006-01-02", req.EndDate); err == nil {
t = t.Add(24 * time.Hour).Add(-time.Second)
q = q.Where(h.readDB.ShippingRecords.CreatedAt.Lte(t))
}
}
// 获取所有符合条件的记录
rows, err := q.Order(h.readDB.ShippingRecords.CreatedAt.Desc(), h.readDB.ShippingRecords.ID.Desc()).Find()
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 30001, err.Error()))
return
}
// 按批次号/运单号分组
type acc struct {
status int32
totalPrice int64
shippedAt *time.Time
receivedAt *time.Time
createdAt time.Time
userID int64
addressID int64
recordIDs []int64
inv []int64
pid []int64
}
m := make(map[string]*acc)
meta := make(map[string]struct{ code, no, batch string })
order := make([]string, 0) // 保持顺序
for _, r := range rows {
// 分组优先级:运单号 > 批次号 > 记录ID
key := ""
if r.ExpressNo != "" {
key = "E|" + r.ExpressCode + "|" + r.ExpressNo
} else if r.BatchNo != "" {
key = "B|" + r.BatchNo
} else {
key = "_" + strconv.FormatInt(r.ID, 10)
}
if _, ok := m[key]; !ok {
m[key] = &acc{
createdAt: r.CreatedAt,
userID: r.UserID,
addressID: r.AddressID,
}
meta[key] = struct{ code, no, batch string }{r.ExpressCode, r.ExpressNo, r.BatchNo}
order = append(order, key)
}
a := m[key]
if a.status == 0 || r.Status >= a.status {
a.status = r.Status
}
a.totalPrice += r.Price
if !r.ShippedAt.IsZero() {
t := r.ShippedAt
a.shippedAt = &t
}
if !r.ReceivedAt.IsZero() {
t := r.ReceivedAt
a.receivedAt = &t
}
a.recordIDs = append(a.recordIDs, r.ID)
a.inv = append(a.inv, r.InventoryID)
if r.ProductID > 0 {
a.pid = append(a.pid, r.ProductID)
}
}
// 分页处理
total := int64(len(order))
start := (req.Page - 1) * req.PageSize
end := start + req.PageSize
if start >= len(order) {
start = len(order)
}
if end > len(order) {
end = len(order)
}
pageKeys := order[start:end]
// 构建返回结果
items := make([]*ShippingOrderGroup, 0, len(pageKeys))
for _, k := range pageKeys {
a := m[k]
md := meta[k]
// 获取用户信息
var userNickname string
if a.userID > 0 {
if user, _ := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.Users.ID.Eq(a.userID)).First(); user != nil {
userNickname = user.Nickname
}
}
// 获取地址信息
var addressInfo string
if a.addressID > 0 {
if addr, _ := h.readDB.UserAddresses.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.UserAddresses.ID.Eq(a.addressID)).First(); addr != nil {
addressInfo = addr.Province + addr.City + addr.District + addr.Address + " " + addr.Name + " " + addr.Mobile
}
}
// 获取商品信息(去重并计数,使用发货记录中的价格快照)
// 按商品ID聚合价格和数量
type productInfo struct {
Name string
Image string
Price int64 // 使用发货记录中的快照价格
Count int64
}
productMap := make(map[int64]*productInfo)
// 查询发货记录获取每个商品的快照价格
if len(a.recordIDs) > 0 {
records, _ := h.readDB.ShippingRecords.WithContext(ctx.RequestContext()).ReadDB().
Where(h.readDB.ShippingRecords.ID.In(a.recordIDs...)).
Find()
for _, r := range records {
if r.ProductID <= 0 {
continue
}
if info, ok := productMap[r.ProductID]; ok {
info.Count++
} else {
// 查询商品名称和图片
var prodName, prodImage string
if prod, _ := h.readDB.Products.WithContext(ctx.RequestContext()).ReadDB().
Where(h.readDB.Products.ID.Eq(r.ProductID)).First(); prod != nil {
prodName = prod.Name
prodImage = prod.ImagesJSON
}
productMap[r.ProductID] = &productInfo{
Name: prodName,
Image: prodImage,
Price: r.Price, // 使用发货记录中的快照价格
Count: 1,
}
}
}
}
var products []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
Price int64 `json:"price"`
Count int64 `json:"count"`
}
for pid, info := range productMap {
products = append(products, struct {
ID int64 `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
Price int64 `json:"price"`
Count int64 `json:"count"`
}{
ID: pid,
Name: info.Name,
Image: info.Image,
Price: info.Price, // 使用快照价格
Count: info.Count,
})
}
items = append(items, &ShippingOrderGroup{
GroupKey: k,
BatchNo: md.batch,
ExpressCode: md.code,
ExpressNo: md.no,
Status: a.status,
Count: int64(len(a.inv)),
TotalPrice: a.totalPrice,
UserID: a.userID,
UserNickname: userNickname,
AddressID: a.addressID,
AddressInfo: addressInfo,
ShippedAt: a.shippedAt,
ReceivedAt: a.receivedAt,
CreatedAt: a.createdAt,
RecordIDs: a.recordIDs,
InventoryIDs: a.inv,
ProductIDs: a.pid,
Products: products,
})
}
rsp.Page = req.Page
rsp.PageSize = req.PageSize
rsp.Total = total
rsp.List = items
ctx.Payload(rsp)
}
}
type updateShippingRequest struct {
RecordIDs []int64 `json:"record_ids"` // 发货记录ID列表
ExpressCode string `json:"express_code"` // 快递公司编码
ExpressNo string `json:"express_no"` // 运单号
Status *int32 `json:"status"` // 状态
}
type updateShippingResponse struct {
Success bool `json:"success"`
UpdatedCount int64 `json:"updated_count"`
}
// UpdateShippingBatch 批量更新发货信息
// @Summary 批量更新发货信息
// @Description 为多条发货记录填写运单号或更新状态
// @Tags 管理端.发货管理
// @Accept json
// @Produce json
// @Security LoginVerifyToken
// @Param RequestBody body updateShippingRequest true "请求参数"
// @Success 200 {object} updateShippingResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/shipping/orders/batch [put]
func (h *handler) UpdateShippingBatch() core.HandlerFunc {
return func(ctx core.Context) {
req := new(updateShippingRequest)
rsp := new(updateShippingResponse)
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
}
updates := make(map[string]any)
if req.ExpressCode != "" {
updates["express_code"] = req.ExpressCode
}
if req.ExpressNo != "" {
updates["express_no"] = req.ExpressNo
}
if req.Status != nil {
updates["status"] = *req.Status
if *req.Status == 2 {
updates["shipped_at"] = time.Now()
} else if *req.Status == 3 {
updates["received_at"] = time.Now()
}
}
updates["updated_at"] = time.Now()
result, err := h.writeDB.ShippingRecords.WithContext(ctx.RequestContext()).
Where(h.writeDB.ShippingRecords.ID.In(req.RecordIDs...)).
Updates(updates)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 30002, err.Error()))
return
}
rsp.Success = true
rsp.UpdatedCount = result.RowsAffected
ctx.Payload(rsp)
}
}
// GetShippingOrderDetail 获取发货订单详情
// @Summary 获取发货订单详情
// @Description 根据发货记录ID获取详情
// @Tags 管理端.发货管理
// @Accept json
// @Produce json
// @Security LoginVerifyToken
// @Param id path int true "发货记录ID"
// @Success 200 {object} model.ShippingRecords
// @Failure 400 {object} code.Failure
// @Router /api/admin/shipping/orders/{id} [get]
func (h *handler) GetShippingOrderDetail() core.HandlerFunc {
return func(ctx core.Context) {
idStr := ctx.Param("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的ID"))
return
}
record, err := h.readDB.ShippingRecords.WithContext(ctx.RequestContext()).ReadDB().
Where(h.readDB.ShippingRecords.ID.Eq(id)).First()
if err != nil || record == nil {
ctx.AbortWithError(core.Error(http.StatusNotFound, 30003, "发货记录不存在"))
return
}
// 获取关联信息
type detailResponse struct {
*model.ShippingRecords
User *model.Users `json:"user"`
Address *model.UserAddresses `json:"address"`
Product *model.Products `json:"product"`
}
rsp := &detailResponse{ShippingRecords: record}
if record.UserID > 0 {
rsp.User, _ = h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.Users.ID.Eq(record.UserID)).First()
}
if record.AddressID > 0 {
rsp.Address, _ = h.readDB.UserAddresses.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.UserAddresses.ID.Eq(record.AddressID)).First()
}
if record.ProductID > 0 {
rsp.Product, _ = h.readDB.Products.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.Products.ID.Eq(record.ProductID)).First()
}
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})
}
}