Add aftersales work order feature

- AftersalesOrder model with state machine (created/pending_confirmation/closed/rejected)
- Public scan-to-confirm flow with phone last-4 verification and rate limiting
- Technician role and middleware for ownership-scoped operations
- QR code generation pointing to /aftersales/{serialNumber}
- Admin overrides: reassign, force-close, delete

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Frudrax Cheng
2026-05-26 10:39:49 +08:00
parent e820b858bf
commit 0d82260fd9
6 changed files with 992 additions and 0 deletions
+392
View File
@@ -0,0 +1,392 @@
package controllers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"git.beifan.cn/trace-system/backend-go/models"
"git.beifan.cn/trace-system/backend-go/services"
)
// AftersalesController 售后工单控制器
type AftersalesController struct {
aftersalesService services.AftersalesService
}
// NewAftersalesController 创建售后工单控制器实例
func NewAftersalesController() *AftersalesController {
return &AftersalesController{
aftersalesService: services.AftersalesService{},
}
}
// Create 创建售后工单
// @Summary 创建售后工单
// @Description 创建一个新的售后工单并分配编号
// @Tags 售后工单
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param data body models.CreateAftersalesOrderDTO true "工单数据"
// @Success 200 {object} models.DataResponse
// @Failure 400 {object} models.ErrorResponse
// @Failure 401 {object} models.ErrorResponse
// @Failure 500 {object} models.ErrorResponse
// @Router /aftersales [post]
func (c *AftersalesController) Create(ctx *gin.Context) {
userModel, ok := GetCurrentUser(ctx)
if !ok {
return
}
var dto models.CreateAftersalesOrderDTO
if !BindJSON(ctx, &dto) {
return
}
order, err := c.aftersalesService.Create(dto, userModel.ID)
if err != nil {
ErrorResponse(ctx, http.StatusInternalServerError, err.Error())
return
}
SuccessResponse(ctx, "售后工单创建成功", gin.H{
"order": order,
})
}
// FindAll 获取售后工单列表
// @Summary 获取售后工单列表
// @Description 支持分页、搜索、按状态/服务类型/技术员筛选
// @Tags 售后工单
// @Produce json
// @Security BearerAuth
// @Param page query int false "页码"
// @Param limit query int false "每页数量"
// @Param search query string false "搜索关键词"
// @Param workOrderStatus query string false "工单状态"
// @Param serviceType query string false "服务类型"
// @Param technicianId query int false "技术员 ID"
// @Param mine query bool false "仅查看自己负责的工单"
// @Success 200 {object} models.PaginationResponse
// @Failure 401 {object} models.ErrorResponse
// @Failure 500 {object} models.ErrorResponse
// @Router /aftersales [get]
func (c *AftersalesController) FindAll(ctx *gin.Context) {
userModel, ok := GetCurrentUser(ctx)
if !ok {
return
}
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "20"))
search := ctx.DefaultQuery("search", "")
workOrderStatus := ctx.DefaultQuery("workOrderStatus", "")
serviceType := ctx.DefaultQuery("serviceType", "")
var technicianID *uint
if tidStr := ctx.Query("technicianId"); tidStr != "" {
if tid, err := strconv.ParseUint(tidStr, 10, 32); err == nil {
t := uint(tid)
technicianID = &t
}
}
// 非管理员默认只看自己的工单(除非显式指定 technicianId
if userModel.Role != "admin" && technicianID == nil {
technicianID = &userModel.ID
}
// mine=true 强制只看自己的
if ctx.Query("mine") == "true" {
technicianID = &userModel.ID
}
orders, total, totalPages, err := c.aftersalesService.FindAll(page, limit, search, workOrderStatus, serviceType, technicianID)
if err != nil {
ErrorResponse(ctx, http.StatusInternalServerError, err.Error())
return
}
SuccessResponse(ctx, "获取售后工单列表成功", gin.H{
"data": orders,
"pagination": gin.H{
"page": page,
"limit": limit,
"total": total,
"totalPages": totalPages,
},
})
}
// FindOne 获取单个售后工单详情
// @Summary 获取售后工单详情
// @Tags 售后工单
// @Produce json
// @Security BearerAuth
// @Param serialNumber path string true "工单号"
// @Success 200 {object} models.DataResponse
// @Failure 401 {object} models.ErrorResponse
// @Failure 404 {object} models.ErrorResponse
// @Router /aftersales/{serialNumber} [get]
func (c *AftersalesController) FindOne(ctx *gin.Context) {
serialNumber := ctx.Param("serialNumber")
order, err := c.aftersalesService.FindOne(serialNumber)
if err != nil {
ErrorResponse(ctx, http.StatusNotFound, err.Error())
return
}
SuccessResponse(ctx, "查询成功", gin.H{
"order": order,
})
}
// Update 更新售后工单信息
// @Summary 更新售后工单
// @Tags 售后工单
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param serialNumber path string true "工单号"
// @Param data body models.UpdateAftersalesOrderDTO true "更新数据"
// @Success 200 {object} models.DataResponse
// @Failure 400 {object} models.ErrorResponse
// @Failure 401 {object} models.ErrorResponse
// @Failure 403 {object} models.ErrorResponse
// @Router /aftersales/{serialNumber} [patch]
func (c *AftersalesController) Update(ctx *gin.Context) {
userModel, ok := GetCurrentUser(ctx)
if !ok {
return
}
serialNumber := ctx.Param("serialNumber")
var dto models.UpdateAftersalesOrderDTO
if !BindJSON(ctx, &dto) {
return
}
order, err := c.aftersalesService.Update(serialNumber, dto, userModel)
if err != nil {
ErrorResponse(ctx, http.StatusBadRequest, err.Error())
return
}
SuccessResponse(ctx, "工单更新成功", gin.H{
"order": order,
})
}
// SubmitForConfirmation 技术员提交客户确认
// @Summary 提交客户确认
// @Description 技术员填写处理结果后提交,工单进入"待客户确认"状态
// @Tags 售后工单
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param serialNumber path string true "工单号"
// @Param data body models.SubmitForConfirmationDTO true "处理结果"
// @Success 200 {object} models.DataResponse
// @Failure 400 {object} models.ErrorResponse
// @Failure 401 {object} models.ErrorResponse
// @Router /aftersales/{serialNumber}/submit [post]
func (c *AftersalesController) SubmitForConfirmation(ctx *gin.Context) {
userModel, ok := GetCurrentUser(ctx)
if !ok {
return
}
serialNumber := ctx.Param("serialNumber")
var dto models.SubmitForConfirmationDTO
if !BindJSON(ctx, &dto) {
return
}
order, err := c.aftersalesService.SubmitForConfirmation(serialNumber, dto, userModel)
if err != nil {
ErrorResponse(ctx, http.StatusBadRequest, err.Error())
return
}
SuccessResponse(ctx, "已提交客户确认", gin.H{
"order": order,
})
}
// GenerateQRCode 生成售后工单二维码
// @Summary 生成售后工单二维码
// @Tags 售后工单
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param serialNumber path string true "工单号"
// @Param data body models.QRCodeDTO false "二维码参数"
// @Success 200 {object} models.QRCodeResponse
// @Failure 400 {object} models.ErrorResponse
// @Failure 401 {object} models.ErrorResponse
// @Router /aftersales/{serialNumber}/qrcode [post]
func (c *AftersalesController) GenerateQRCode(ctx *gin.Context) {
serialNumber := ctx.Param("serialNumber")
var qrCodeData models.QRCodeDTO
if !BindJSON(ctx, &qrCodeData) {
return
}
protocol := "http"
if ctx.Request.TLS != nil {
protocol = "https"
}
qrCodeBase64, queryUrl, err := c.aftersalesService.GenerateQRCode(
serialNumber,
qrCodeData.BaseUrl,
ctx.Request.Host,
protocol,
)
if err != nil {
ErrorResponse(ctx, http.StatusBadRequest, err.Error())
return
}
SuccessResponse(ctx, "二维码生成成功", gin.H{
"qrCodeData": qrCodeBase64,
"queryUrl": queryUrl,
})
}
// Reassign 重新分配技术员(仅管理员)
// @Summary 重新分配技术员
// @Tags 售后工单
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param serialNumber path string true "工单号"
// @Param data body models.ReassignAftersalesDTO true "新技术员 ID"
// @Success 200 {object} models.DataResponse
// @Failure 400 {object} models.ErrorResponse
// @Failure 401 {object} models.ErrorResponse
// @Router /aftersales/{serialNumber}/reassign [post]
func (c *AftersalesController) Reassign(ctx *gin.Context) {
serialNumber := ctx.Param("serialNumber")
var dto models.ReassignAftersalesDTO
if !BindJSON(ctx, &dto) {
return
}
order, err := c.aftersalesService.Reassign(serialNumber, dto.TechnicianID)
if err != nil {
ErrorResponse(ctx, http.StatusBadRequest, err.Error())
return
}
SuccessResponse(ctx, "重新分配成功", gin.H{
"order": order,
})
}
// ForceClose 强制关闭工单(仅管理员)
// @Summary 强制关闭工单
// @Tags 售后工单
// @Produce json
// @Security BearerAuth
// @Param serialNumber path string true "工单号"
// @Success 200 {object} models.DataResponse
// @Failure 400 {object} models.ErrorResponse
// @Failure 401 {object} models.ErrorResponse
// @Router /aftersales/{serialNumber}/force-close [post]
func (c *AftersalesController) ForceClose(ctx *gin.Context) {
serialNumber := ctx.Param("serialNumber")
order, err := c.aftersalesService.ForceClose(serialNumber)
if err != nil {
ErrorResponse(ctx, http.StatusBadRequest, err.Error())
return
}
SuccessResponse(ctx, "工单已强制关闭", gin.H{
"order": order,
})
}
// Delete 删除售后工单(仅管理员)
// @Summary 删除售后工单
// @Tags 售后工单
// @Produce json
// @Security BearerAuth
// @Param serialNumber path string true "工单号"
// @Success 200 {object} models.BaseResponse
// @Failure 400 {object} models.ErrorResponse
// @Failure 401 {object} models.ErrorResponse
// @Router /aftersales/{serialNumber} [delete]
func (c *AftersalesController) Delete(ctx *gin.Context) {
serialNumber := ctx.Param("serialNumber")
if err := c.aftersalesService.Delete(serialNumber); err != nil {
ErrorResponse(ctx, http.StatusBadRequest, err.Error())
return
}
SuccessResponse(ctx, "售后工单删除成功")
}
// PublicQuery 公开查询售后工单(无需登录,脱敏)
// @Summary 公开查询售后工单
// @Tags 售后工单查询
// @Produce json
// @Param serialNumber path string true "工单号"
// @Success 200 {object} models.DataResponse
// @Failure 404 {object} models.ErrorResponse
// @Router /aftersales/{serialNumber}/query [get]
func (c *AftersalesController) PublicQuery(ctx *gin.Context) {
serialNumber := ctx.Param("serialNumber")
view, err := c.aftersalesService.PublicQuery(serialNumber)
if err != nil {
ErrorResponse(ctx, http.StatusNotFound, err.Error())
return
}
SuccessResponse(ctx, "查询成功", gin.H{
"order": view,
})
}
// CustomerConfirm 客户授权/未授权确认
// @Summary 客户授权确认
// @Description 客户输入手机号后四位后选择已授权或未授权
// @Tags 售后工单查询
// @Accept json
// @Produce json
// @Param serialNumber path string true "工单号"
// @Param data body models.CustomerConfirmDTO true "确认数据"
// @Success 200 {object} models.DataResponse
// @Failure 400 {object} models.ErrorResponse
// @Failure 401 {object} models.ErrorResponse
// @Failure 429 {object} models.ErrorResponse
// @Router /aftersales/{serialNumber}/confirm [post]
func (c *AftersalesController) CustomerConfirm(ctx *gin.Context) {
serialNumber := ctx.Param("serialNumber")
var dto models.CustomerConfirmDTO
if !BindJSON(ctx, &dto) {
return
}
view, err := c.aftersalesService.CustomerConfirm(serialNumber, dto)
if err != nil {
// 频率限制单独返回 429
if err.Error() == "操作过于频繁,请稍后再试" {
ErrorResponse(ctx, http.StatusTooManyRequests, err.Error())
return
}
ErrorResponse(ctx, http.StatusBadRequest, err.Error())
return
}
SuccessResponse(ctx, "提交成功", gin.H{
"order": view,
})
}