Compare commits
5 Commits
e820b858bf
...
128bb7cda6
| Author | SHA1 | Date | |
|---|---|---|---|
|
128bb7cda6
|
|||
|
3ddd4db126
|
|||
|
b1e3adaf6a
|
|||
|
1029d5966b
|
|||
|
0d82260fd9
|
@@ -44,28 +44,34 @@ backend-go/
|
||||
├── config/ # Configuration management
|
||||
│ └── config.go # Config loading (.env, config.yaml, env vars)
|
||||
├── controllers/ # HTTP request handlers
|
||||
│ ├── aftersales_controller.go # Aftersales orders: create, query, submit, confirm, qrcode
|
||||
│ ├── auth_controller.go # Auth: login, profile, password change
|
||||
│ ├── companies_controller.go # Company CRUD
|
||||
│ ├── employees_controller.go # Employee serials: generate, query, update, revoke, qrcode
|
||||
│ ├── helper.go # Helper functions (GetCurrentUser, BindJSON, Response)
|
||||
│ └── serials_controller.go # Company serials: generate, query, update, revoke, qrcode
|
||||
│ ├── serials_controller.go # Company serials: generate, query, update, revoke, qrcode
|
||||
│ └── users_controller.go # User management (admin): create, list, update, reset password, delete
|
||||
├── database/ # Database connection and migrations
|
||||
│ └── database.go # GORM init, AutoMigrate
|
||||
├── docs/ # Swagger documentation (auto-generated)
|
||||
├── logger/ # Structured logging
|
||||
│ └── logger.go # Zap logger wrapper
|
||||
├── middleware/ # Middleware
|
||||
│ └── auth.go # JWT auth, Admin permission check
|
||||
│ └── auth.go # JWT auth, Admin / Technician permission checks
|
||||
├── models/ # Data models and DTOs
|
||||
│ └── models.go # User, Company, Serial, EmployeeSerial and DTOs
|
||||
│ └── models.go # User, Company, Serial, EmployeeSerial, AftersalesOrder and DTOs
|
||||
├── routes/ # Route configuration
|
||||
│ └── routes.go # API route registration
|
||||
├── services/ # Business logic layer
|
||||
│ ├── aftersales_service.go # Aftersales orders: create, list, update, submit, customer confirm, qrcode
|
||||
│ ├── auth_service.go # Auth: validate user, generate token, password management
|
||||
│ ├── companies_service.go # Company CRUD
|
||||
│ ├── employees_service.go # Employee serials: generate, query, update, revoke, qrcode
|
||||
│ ├── serials_service.go # Company serials: generate, query, update, revoke, qrcode
|
||||
│ └── services_test.go # Unit tests
|
||||
│ ├── aftersales_service_test.go # Aftersales unit tests
|
||||
│ ├── services_test.go # Auth / Serials / Employees / Companies unit tests
|
||||
│ ├── users_service.go # User CRUD, role management, password reset (admin)
|
||||
│ └── users_service_test.go # Users unit tests
|
||||
├── tests/ # Integration tests
|
||||
│ └── main_test.go # End-to-end tests
|
||||
├── data/ # SQLite data directory
|
||||
@@ -87,6 +93,11 @@ backend-go/
|
||||
- **Serials**: `POST /api/serials/generate`, `POST /api/serials/generate-with-prefix`, `POST /api/serials/:serialNumber/qrcode`, `GET /api/serials/:serialNumber/query`, `GET /api/serials`, `PATCH /api/serials/:serialNumber`, `PUT /api/serials/:serialNumber`, `POST /api/serials/:serialNumber/revoke`
|
||||
- **Companies**: `GET /api/companies/stats/overview`, `GET /api/companies`, `GET /api/companies/:companyName`, `POST /api/companies`, `PATCH /api/companies/:companyName`, `PUT /api/companies/:companyName`, `POST /api/companies/:companyName/revoke`, `DELETE /api/companies/:companyName/serials/:serialNumber`, `DELETE /api/companies/:companyName`
|
||||
- **Employee Serials**: `POST /api/employee-serials/generate`, `POST /api/employee-serials/:serialNumber/qrcode`, `GET /api/employee-serials/:serialNumber/query`, `GET /api/employee-serials`, `PATCH /api/employee-serials/:serialNumber`, `PUT /api/employee-serials/:serialNumber`, `POST /api/employee-serials/:serialNumber/revoke`
|
||||
- **Aftersales** (公开): `GET /api/aftersales/:serialNumber/query`, `POST /api/aftersales/:serialNumber/confirm`
|
||||
- **Aftersales** (技术员+管理员): `POST /api/aftersales`, `GET /api/aftersales`, `GET /api/aftersales/:serialNumber`, `PATCH /api/aftersales/:serialNumber`, `POST /api/aftersales/:serialNumber/qrcode`, `POST /api/aftersales/:serialNumber/submit`
|
||||
- **Aftersales** (仅管理员): `POST /api/aftersales/:serialNumber/reassign`, `POST /api/aftersales/:serialNumber/force-close`, `DELETE /api/aftersales/:serialNumber`
|
||||
- **Users** (技术员+管理员): `GET /api/users/assignable`
|
||||
- **Users** (仅管理员): `POST /api/users`, `GET /api/users`, `PATCH /api/users/:id`, `POST /api/users/:id/reset-password`, `DELETE /api/users/:id`
|
||||
|
||||
### Import Organization
|
||||
Standard imports followed by third-party imports, then project imports (sorted alphabetically):
|
||||
@@ -219,6 +230,7 @@ After modifying Swagger annotations, run `make swagger`.
|
||||
### Middleware
|
||||
- **JWTAuthMiddleware**: Validates JWT tokens, sets user in context
|
||||
- **AdminMiddleware**: Checks if user has admin role
|
||||
- **TechnicianMiddleware**: Allows admin and technician roles (used for aftersales endpoints)
|
||||
- Access current user: `user, ok := GetCurrentUser(ctx)`
|
||||
|
||||
### Git Hooks
|
||||
|
||||
@@ -29,11 +29,13 @@ backend-go/
|
||||
├── config/ # 配置管理
|
||||
│ └── config.go # 配置加载和解析(支持 .env 文件和环境变量)
|
||||
├── controllers/ # 控制器层,处理 HTTP 请求
|
||||
│ ├── aftersales_controller.go # 售后工单接口
|
||||
│ ├── auth_controller.go # 认证相关接口
|
||||
│ ├── companies_controller.go # 企业管理接口
|
||||
│ ├── employees_controller.go # 员工赋码接口
|
||||
│ ├── helper.go # 控制器通用辅助函数
|
||||
│ └── serials_controller.go # 序列号管理接口
|
||||
│ ├── serials_controller.go # 序列号管理接口
|
||||
│ └── users_controller.go # 用户管理接口(仅管理员)
|
||||
├── database/ # 数据库连接和操作
|
||||
│ └── database.go # 数据库初始化、连接池配置
|
||||
├── docs/ # Swagger API 文档(自动生成)
|
||||
@@ -43,17 +45,21 @@ backend-go/
|
||||
├── logger/ # 日志管理
|
||||
│ └── logger.go # 结构化日志(使用 Zap)
|
||||
├── middleware/ # 中间件层
|
||||
│ └── auth.go # JWT 认证和权限检查
|
||||
│ └── auth.go # JWT 认证、管理员/技术员权限检查
|
||||
├── models/ # 数据模型和 DTO
|
||||
│ └── models.go # User、Company、Serial 等模型定义
|
||||
│ └── models.go # User、Company、Serial、AftersalesOrder 等模型定义
|
||||
├── routes/ # 路由配置
|
||||
│ └── routes.go # API 路由注册
|
||||
├── services/ # 业务逻辑层
|
||||
│ ├── aftersales_service.go # 售后工单业务逻辑
|
||||
│ ├── auth_service.go # 认证业务逻辑
|
||||
│ ├── companies_service.go # 企业管理业务逻辑
|
||||
│ ├── employees_service.go # 员工赋码业务逻辑
|
||||
│ ├── serials_service.go # 序列号业务逻辑
|
||||
│ └── services_test.go # 服务层单元测试
|
||||
│ ├── aftersales_service_test.go # 售后工单单元测试
|
||||
│ ├── services_test.go # 认证/序列号/员工/企业单元测试
|
||||
│ ├── users_service.go # 用户管理业务逻辑
|
||||
│ └── users_service_test.go # 用户管理单元测试
|
||||
├── tests/ # 集成测试
|
||||
│ └── main_test.go # 端到端测试
|
||||
├── data/ # 数据目录(SQLite 数据库存储位置)
|
||||
@@ -292,6 +298,50 @@ swag init -g main.go
|
||||
- 包含部门(department)和员工姓名(employeeName)信息
|
||||
- 序列号格式: `EMP26xxxxxx`(EMP + 年份后两位 + 6位随机字符)
|
||||
|
||||
### 售后工单
|
||||
|
||||
| 方法 | 路径 | 描述 | 需要认证 | 角色 |
|
||||
| ------ | --------------------------------------------- | -------------------------- | -------- | --------------- |
|
||||
| GET | `/api/aftersales/:serialNumber/query` | 公开查询工单(脱敏) | 否 | 任何 |
|
||||
| POST | `/api/aftersales/:serialNumber/confirm` | 客户授权/未授权确认 | 否 | 任何 |
|
||||
| POST | `/api/aftersales` | 创建售后工单 | 是 | 管理员/技术员 |
|
||||
| GET | `/api/aftersales` | 工单列表(支持筛选) | 是 | 管理员/技术员 |
|
||||
| GET | `/api/aftersales/:serialNumber` | 工单详情 | 是 | 管理员/技术员 |
|
||||
| PATCH | `/api/aftersales/:serialNumber` | 更新工单(仅负责人或管理员)| 是 | 管理员/技术员 |
|
||||
| POST | `/api/aftersales/:serialNumber/qrcode` | 生成工单二维码 | 是 | 管理员/技术员 |
|
||||
| POST | `/api/aftersales/:serialNumber/submit` | 提交客户确认 | 是 | 管理员/技术员 |
|
||||
| POST | `/api/aftersales/:serialNumber/reassign` | 重新分配技术员 | 是 | 管理员 |
|
||||
| POST | `/api/aftersales/:serialNumber/force-close` | 强制关闭工单 | 是 | 管理员 |
|
||||
| DELETE | `/api/aftersales/:serialNumber` | 删除工单 | 是 | 管理员 |
|
||||
|
||||
**售后工单特点**:
|
||||
- 工单号格式: `zjbf-sh-YYMMNN`(年份后 2 位 + 月份 2 位 + 当月序号至少 2 位,例:`zjbf-sh-260501`)
|
||||
- 序号按月重置,软删除工单不释放编号(避免回收造成混淆)
|
||||
- 二维码扫码后客户输入手机号后 4 位进行身份校验
|
||||
- 客户确认接口每分钟同一工单最多 5 次请求
|
||||
- 工单状态机: `created` → `pending_confirmation` → `closed` / `rejected`,被退回后可重新提交
|
||||
- 公开查询不返回手机号(脱敏)
|
||||
|
||||
### 用户管理(仅管理员)
|
||||
|
||||
| 方法 | 路径 | 描述 | 需要认证 | 角色 |
|
||||
| ------ | ----------------------------------- | -------------------------- | -------- | ------------- |
|
||||
| GET | `/api/users/assignable` | 可分配用户列表(用于售后) | 是 | 管理员/技术员 |
|
||||
| POST | `/api/users` | 创建用户 | 是 | 管理员 |
|
||||
| GET | `/api/users` | 用户列表(分页+筛选) | 是 | 管理员 |
|
||||
| PATCH | `/api/users/:id` | 更新用户姓名/邮箱/角色 | 是 | 管理员 |
|
||||
| POST | `/api/users/:id/reset-password` | 重置用户密码 | 是 | 管理员 |
|
||||
| DELETE | `/api/users/:id` | 删除用户 | 是 | 管理员 |
|
||||
|
||||
**用户角色**:
|
||||
- `admin`:完整权限,包括用户管理、强制关闭工单、重新分配技术员、删除工单
|
||||
- `technician`:可创建/编辑自己负责的售后工单,可使用 `assignable` 查询同事
|
||||
- `user`:保留角色(暂未实际启用)
|
||||
|
||||
**保护规则**:
|
||||
- 不能删除自己;不能将自己的 admin 角色降级;不能删除最后一个 admin
|
||||
- 默认创建用户密码 bcrypt 加密存储
|
||||
|
||||
## 测试
|
||||
|
||||
### 运行所有测试
|
||||
@@ -317,11 +367,13 @@ go tool cover -html=coverage.out
|
||||
|
||||
### 当前测试覆盖
|
||||
|
||||
- **services/**: 包含 AuthService、SerialsService、EmployeeSerialsService 和 CompaniesService 的完整单元测试
|
||||
- **services/**: 包含 AuthService、SerialsService、EmployeeSerialsService、CompaniesService、AftersalesService 和 UsersService 的完整单元测试
|
||||
- 用户认证测试(登录、获取用户信息、修改密码、更新资料)
|
||||
- 序列号管理测试(生成、查询、更新、吊销、分页列表)
|
||||
- 员工赋码测试(生成、查询、更新、吊销、二维码生成)
|
||||
- 企业统计测试(统计概览)
|
||||
- 售后工单测试(YYMMNN 序号生成、状态机、客户确认手机号校验、强制关闭)
|
||||
- 用户管理测试(重复用户名、自降级保护、最后管理员保护、密码重置)
|
||||
- **tests/**: 集成测试(健康检查、登录流程)
|
||||
|
||||
## 代码检查
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// UsersController 用户管理控制器
|
||||
type UsersController struct {
|
||||
usersService services.UsersService
|
||||
}
|
||||
|
||||
// NewUsersController 创建用户管理控制器实例
|
||||
func NewUsersController() *UsersController {
|
||||
return &UsersController{
|
||||
usersService: services.UsersService{},
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建用户(管理员)
|
||||
// @Summary 创建用户
|
||||
// @Tags 用户管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param data body models.CreateUserDTO true "用户数据"
|
||||
// @Success 200 {object} models.DataResponse
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 401 {object} models.ErrorResponse
|
||||
// @Router /users [post]
|
||||
func (c *UsersController) Create(ctx *gin.Context) {
|
||||
var dto models.CreateUserDTO
|
||||
if !BindJSON(ctx, &dto) {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := c.usersService.Create(dto)
|
||||
if err != nil {
|
||||
ErrorResponse(ctx, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(ctx, "用户创建成功", gin.H{
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
|
||||
// FindAll 用户列表
|
||||
// @Summary 用户列表
|
||||
// @Tags 用户管理
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param page query int false "页码"
|
||||
// @Param limit query int false "每页数量"
|
||||
// @Param role query string false "角色筛选"
|
||||
// @Param search query string false "搜索"
|
||||
// @Success 200 {object} models.PaginationResponse
|
||||
// @Failure 401 {object} models.ErrorResponse
|
||||
// @Router /users [get]
|
||||
func (c *UsersController) FindAll(ctx *gin.Context) {
|
||||
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "20"))
|
||||
role := ctx.DefaultQuery("role", "")
|
||||
search := ctx.DefaultQuery("search", "")
|
||||
|
||||
users, total, totalPages, err := c.usersService.FindAll(page, limit, role, search)
|
||||
if err != nil {
|
||||
ErrorResponse(ctx, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(ctx, "获取用户列表成功", gin.H{
|
||||
"data": users,
|
||||
"pagination": gin.H{
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"totalPages": totalPages,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// FindAssignable 获取可分配的用户(admin + technician)
|
||||
// @Summary 获取可分配用户列表
|
||||
// @Description 用于售后工单分配选择技术员/管理员,无需分页
|
||||
// @Tags 用户管理
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} models.DataResponse
|
||||
// @Failure 401 {object} models.ErrorResponse
|
||||
// @Router /users/assignable [get]
|
||||
func (c *UsersController) FindAssignable(ctx *gin.Context) {
|
||||
users, err := c.usersService.FindAssignable()
|
||||
if err != nil {
|
||||
ErrorResponse(ctx, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(ctx, "获取可分配用户成功", gin.H{
|
||||
"data": users,
|
||||
})
|
||||
}
|
||||
|
||||
// Update 更新用户信息
|
||||
// @Summary 更新用户
|
||||
// @Tags 用户管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "用户 ID"
|
||||
// @Param data body models.UpdateUserDTO true "更新数据"
|
||||
// @Success 200 {object} models.DataResponse
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 401 {object} models.ErrorResponse
|
||||
// @Router /users/{id} [patch]
|
||||
func (c *UsersController) Update(ctx *gin.Context) {
|
||||
userModel, ok := GetCurrentUser(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(ctx, http.StatusBadRequest, "无效的用户 ID")
|
||||
return
|
||||
}
|
||||
|
||||
var dto models.UpdateUserDTO
|
||||
if !BindJSON(ctx, &dto) {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := c.usersService.Update(uint(id), dto, userModel.ID)
|
||||
if err != nil {
|
||||
ErrorResponse(ctx, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(ctx, "用户更新成功", gin.H{
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
|
||||
// ResetPassword 重置用户密码
|
||||
// @Summary 重置密码
|
||||
// @Tags 用户管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "用户 ID"
|
||||
// @Param data body models.AdminResetPasswordDTO true "新密码"
|
||||
// @Success 200 {object} models.BaseResponse
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 401 {object} models.ErrorResponse
|
||||
// @Router /users/{id}/reset-password [post]
|
||||
func (c *UsersController) ResetPassword(ctx *gin.Context) {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(ctx, http.StatusBadRequest, "无效的用户 ID")
|
||||
return
|
||||
}
|
||||
|
||||
var dto models.AdminResetPasswordDTO
|
||||
if !BindJSON(ctx, &dto) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.usersService.ResetPassword(uint(id), dto.NewPassword); err != nil {
|
||||
ErrorResponse(ctx, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(ctx, "密码重置成功")
|
||||
}
|
||||
|
||||
// Delete 删除用户
|
||||
// @Summary 删除用户
|
||||
// @Tags 用户管理
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "用户 ID"
|
||||
// @Success 200 {object} models.BaseResponse
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 401 {object} models.ErrorResponse
|
||||
// @Router /users/{id} [delete]
|
||||
func (c *UsersController) Delete(ctx *gin.Context) {
|
||||
userModel, ok := GetCurrentUser(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(ctx, http.StatusBadRequest, "无效的用户 ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.usersService.Delete(uint(id), userModel.ID); err != nil {
|
||||
ErrorResponse(ctx, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(ctx, "用户删除成功")
|
||||
}
|
||||
@@ -114,6 +114,7 @@ func AutoMigrate() {
|
||||
&models.Company{},
|
||||
&models.Serial{},
|
||||
&models.EmployeeSerial{},
|
||||
&models.AftersalesOrder{},
|
||||
); err != nil {
|
||||
logger.Fatal("数据库迁移失败", logger.Err(err))
|
||||
}
|
||||
|
||||
@@ -110,3 +110,26 @@ func AdminMiddleware() gin.HandlerFunc {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// TechnicianMiddleware 技术员权限中间件(放行 admin 和 technician)
|
||||
func TechnicianMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"message": "未认证",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
userModel := user.(models.User)
|
||||
if userModel.Role != "admin" && userModel.Role != "technician" {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"message": "无权限访问此资源",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
+119
-9
@@ -74,6 +74,27 @@ type UpdateProfileDTO struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
}
|
||||
|
||||
// CreateUserDTO 管理员创建用户请求
|
||||
type CreateUserDTO struct {
|
||||
Username string `json:"username" validate:"required,min=3,max=50"`
|
||||
Password string `json:"password" validate:"required,min=6"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
Email string `json:"email" validate:"omitempty,email"`
|
||||
Role string `json:"role" validate:"required,oneof=admin technician user"`
|
||||
}
|
||||
|
||||
// UpdateUserDTO 管理员更新用户信息请求
|
||||
type UpdateUserDTO struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty" validate:"omitempty,email"`
|
||||
Role string `json:"role,omitempty" validate:"omitempty,oneof=admin technician user"`
|
||||
}
|
||||
|
||||
// AdminResetPasswordDTO 管理员重置用户密码
|
||||
type AdminResetPasswordDTO struct {
|
||||
NewPassword string `json:"newPassword" validate:"required,min=6"`
|
||||
}
|
||||
|
||||
// GenerateSerialDTO 生成序列号请求数据
|
||||
type GenerateSerialDTO struct {
|
||||
CompanyName string `json:"companyName" validate:"required"`
|
||||
@@ -166,15 +187,19 @@ type CompanyUpdateRequest struct {
|
||||
|
||||
// CompanyStatsOverviewDTO 企业统计概览
|
||||
type CompanyStatsOverviewDTO struct {
|
||||
TotalCompanies int64 `json:"totalCompanies"`
|
||||
ActiveCompanies int64 `json:"activeCompanies"`
|
||||
InactiveCompanies int64 `json:"inactiveCompanies"`
|
||||
TotalSerials int64 `json:"totalSerials"`
|
||||
ActiveSerials int64 `json:"activeSerials"`
|
||||
RevokedSerials int64 `json:"revokedSerials"`
|
||||
TotalEmployeeSerials int64 `json:"totalEmployeeSerials"`
|
||||
ActiveEmployeeSerials int64 `json:"activeEmployeeSerials"`
|
||||
RevokedEmployeeSerials int64 `json:"revokedEmployeeSerials"`
|
||||
TotalCompanies int64 `json:"totalCompanies"`
|
||||
ActiveCompanies int64 `json:"activeCompanies"`
|
||||
InactiveCompanies int64 `json:"inactiveCompanies"`
|
||||
TotalSerials int64 `json:"totalSerials"`
|
||||
ActiveSerials int64 `json:"activeSerials"`
|
||||
RevokedSerials int64 `json:"revokedSerials"`
|
||||
TotalEmployeeSerials int64 `json:"totalEmployeeSerials"`
|
||||
ActiveEmployeeSerials int64 `json:"activeEmployeeSerials"`
|
||||
RevokedEmployeeSerials int64 `json:"revokedEmployeeSerials"`
|
||||
TotalAftersales int64 `json:"totalAftersales"`
|
||||
PendingConfirmation int64 `json:"pendingConfirmation"`
|
||||
ClosedAftersales int64 `json:"closedAftersales"`
|
||||
RejectedAftersales int64 `json:"rejectedAftersales"`
|
||||
}
|
||||
|
||||
// EmployeeSerial 员工序列号模型
|
||||
@@ -209,3 +234,88 @@ type UpdateEmployeeSerialDTO struct {
|
||||
EmployeeName string `json:"employeeName,omitempty" validate:"omitempty"`
|
||||
IsActive *bool `json:"isActive,omitempty"`
|
||||
}
|
||||
|
||||
// AftersalesOrder 售后工单模型
|
||||
type AftersalesOrder struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
SerialNumber string `gorm:"uniqueIndex;size:64" json:"serialNumber"`
|
||||
CompanyName string `gorm:"index;size:255" json:"companyName"`
|
||||
CompanyAddress string `gorm:"size:500" json:"companyAddress"`
|
||||
ContactName string `gorm:"size:100" json:"contactName"`
|
||||
ContactPhone string `gorm:"size:32" json:"contactPhone"`
|
||||
ServiceType string `gorm:"size:32" json:"serviceType"`
|
||||
IssueDescription string `gorm:"type:text" json:"issueDescription"`
|
||||
ResolutionNote string `gorm:"type:text" json:"resolutionNote"`
|
||||
|
||||
WorkOrderStatus string `gorm:"size:32;default:'created'" json:"workOrderStatus"`
|
||||
AuthorizationStatus string `gorm:"size:32;default:'pending'" json:"authorizationStatus"`
|
||||
|
||||
TechnicianID *uint `json:"technicianId"`
|
||||
CreatedBy *uint `json:"createdBy"`
|
||||
ScannedAt *time.Time `json:"scannedAt"`
|
||||
ConfirmedAt *time.Time `json:"confirmedAt"`
|
||||
RejectCount int `gorm:"default:0" json:"rejectCount"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
Technician *User `gorm:"foreignKey:TechnicianID" json:"technician,omitempty"`
|
||||
Creator *User `gorm:"foreignKey:CreatedBy" json:"creator,omitempty"`
|
||||
Company *Company `gorm:"foreignKey:CompanyName;references:CompanyName" json:"company,omitempty"`
|
||||
}
|
||||
|
||||
// CreateAftersalesOrderDTO 创建售后工单请求数据
|
||||
type CreateAftersalesOrderDTO struct {
|
||||
CompanyName string `json:"companyName" validate:"required"`
|
||||
CompanyAddress string `json:"companyAddress" validate:"required"`
|
||||
ContactName string `json:"contactName" validate:"required"`
|
||||
ContactPhone string `json:"contactPhone" validate:"required,len=11"`
|
||||
ServiceType string `json:"serviceType" validate:"required,oneof=software hardware other"`
|
||||
IssueDescription string `json:"issueDescription" validate:"required"`
|
||||
TechnicianID *uint `json:"technicianId,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateAftersalesOrderDTO 更新售后工单请求数据
|
||||
type UpdateAftersalesOrderDTO struct {
|
||||
CompanyAddress string `json:"companyAddress,omitempty"`
|
||||
ContactName string `json:"contactName,omitempty"`
|
||||
ContactPhone string `json:"contactPhone,omitempty" validate:"omitempty,len=11"`
|
||||
ServiceType string `json:"serviceType,omitempty" validate:"omitempty,oneof=software hardware other"`
|
||||
IssueDescription string `json:"issueDescription,omitempty"`
|
||||
ResolutionNote string `json:"resolutionNote,omitempty"`
|
||||
TechnicianID *uint `json:"technicianId,omitempty"`
|
||||
}
|
||||
|
||||
// SubmitForConfirmationDTO 提交客户确认请求
|
||||
type SubmitForConfirmationDTO struct {
|
||||
ResolutionNote string `json:"resolutionNote" validate:"required"`
|
||||
}
|
||||
|
||||
// CustomerConfirmDTO 客户确认请求
|
||||
type CustomerConfirmDTO struct {
|
||||
PhoneLast4 string `json:"phoneLast4" validate:"required,len=4,numeric"`
|
||||
Action string `json:"action" validate:"required,oneof=authorize reject"`
|
||||
RejectReason string `json:"rejectReason,omitempty"`
|
||||
}
|
||||
|
||||
// ReassignAftersalesDTO 重新分配技术员请求
|
||||
type ReassignAftersalesDTO struct {
|
||||
TechnicianID uint `json:"technicianId" validate:"required"`
|
||||
}
|
||||
|
||||
// AftersalesPublicView 公开查询返回视图(脱敏)
|
||||
type AftersalesPublicView struct {
|
||||
SerialNumber string `json:"serialNumber"`
|
||||
CompanyName string `json:"companyName"`
|
||||
CompanyAddress string `json:"companyAddress"`
|
||||
ContactName string `json:"contactName"`
|
||||
ServiceType string `json:"serviceType"`
|
||||
IssueDescription string `json:"issueDescription"`
|
||||
ResolutionNote string `json:"resolutionNote"`
|
||||
WorkOrderStatus string `json:"workOrderStatus"`
|
||||
AuthorizationStatus string `json:"authorizationStatus"`
|
||||
TechnicianName string `json:"technicianName"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ConfirmedAt *time.Time `json:"confirmedAt"`
|
||||
}
|
||||
|
||||
@@ -73,4 +73,38 @@ func SetupAPIRoutes(r *gin.RouterGroup) {
|
||||
employeeSerialsRoutes.POST("/:serialNumber/revoke", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), employeeSerialsController.Revoke)
|
||||
employeeSerialsRoutes.DELETE("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), employeeSerialsController.Delete)
|
||||
}
|
||||
|
||||
// 用户管理路由(仅管理员)
|
||||
usersController := controllers.NewUsersController()
|
||||
usersRoutes := r.Group("/users")
|
||||
{
|
||||
usersRoutes.GET("/assignable", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), usersController.FindAssignable)
|
||||
usersRoutes.POST("", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), usersController.Create)
|
||||
usersRoutes.GET("", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), usersController.FindAll)
|
||||
usersRoutes.PATCH("/:id", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), usersController.Update)
|
||||
usersRoutes.POST("/:id/reset-password", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), usersController.ResetPassword)
|
||||
usersRoutes.DELETE("/:id", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), usersController.Delete)
|
||||
}
|
||||
|
||||
// 售后工单路由
|
||||
aftersalesController := controllers.NewAftersalesController()
|
||||
aftersalesRoutes := r.Group("/aftersales")
|
||||
{
|
||||
// 公开(无需登录)
|
||||
aftersalesRoutes.GET("/:serialNumber/query", aftersalesController.PublicQuery)
|
||||
aftersalesRoutes.POST("/:serialNumber/confirm", aftersalesController.CustomerConfirm)
|
||||
|
||||
// 技术员 + 管理员
|
||||
aftersalesRoutes.POST("", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.Create)
|
||||
aftersalesRoutes.GET("", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.FindAll)
|
||||
aftersalesRoutes.GET("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.FindOne)
|
||||
aftersalesRoutes.PATCH("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.Update)
|
||||
aftersalesRoutes.POST("/:serialNumber/qrcode", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.GenerateQRCode)
|
||||
aftersalesRoutes.POST("/:serialNumber/submit", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.SubmitForConfirmation)
|
||||
|
||||
// 仅管理员
|
||||
aftersalesRoutes.POST("/:serialNumber/reassign", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), aftersalesController.Reassign)
|
||||
aftersalesRoutes.POST("/:serialNumber/force-close", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), aftersalesController.ForceClose)
|
||||
aftersalesRoutes.DELETE("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), aftersalesController.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
qr "github.com/yeqown/go-qrcode/v2"
|
||||
"github.com/yeqown/go-qrcode/writer/standard"
|
||||
|
||||
"git.beifan.cn/trace-system/backend-go/database"
|
||||
"git.beifan.cn/trace-system/backend-go/models"
|
||||
)
|
||||
|
||||
// AftersalesService 售后工单服务
|
||||
type AftersalesService struct{}
|
||||
|
||||
// 工单状态常量
|
||||
const (
|
||||
WorkOrderStatusCreated = "created"
|
||||
WorkOrderStatusPendingConfirmation = "pending_confirmation"
|
||||
WorkOrderStatusClosed = "closed"
|
||||
WorkOrderStatusRejected = "rejected"
|
||||
|
||||
AuthorizationStatusPending = "pending"
|
||||
AuthorizationStatusAuthorized = "authorized"
|
||||
AuthorizationStatusUnauthorized = "unauthorized"
|
||||
|
||||
aftersalesSerialPrefix = "zjbf-sh-"
|
||||
)
|
||||
|
||||
// 客户确认接口频率限制:每分钟同一工单最多 5 次尝试
|
||||
var confirmRateLimiter = struct {
|
||||
sync.Mutex
|
||||
attempts map[string][]time.Time
|
||||
}{attempts: map[string][]time.Time{}}
|
||||
|
||||
func checkConfirmRateLimit(serialNumber string) bool {
|
||||
confirmRateLimiter.Lock()
|
||||
defer confirmRateLimiter.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
windowStart := now.Add(-time.Minute)
|
||||
|
||||
attempts := confirmRateLimiter.attempts[serialNumber]
|
||||
filtered := attempts[:0]
|
||||
for _, t := range attempts {
|
||||
if t.After(windowStart) {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
if len(filtered) >= 5 {
|
||||
confirmRateLimiter.attempts[serialNumber] = filtered
|
||||
return false
|
||||
}
|
||||
filtered = append(filtered, now)
|
||||
confirmRateLimiter.attempts[serialNumber] = filtered
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeAftersalesSerial(sn string) string {
|
||||
return strings.ToLower(strings.TrimSpace(sn))
|
||||
}
|
||||
|
||||
// generateUniqueSerial 生成唯一的售后工单序列号
|
||||
// 格式:zjbf-sh-YYMMNN,YY=年份后两位,MM=月份,NN=当月第几单(至少 2 位,溢出自然加宽)
|
||||
func (s *AftersalesService) generateUniqueSerial() (string, error) {
|
||||
now := time.Now()
|
||||
yy := now.Year() % 100
|
||||
mm := int(now.Month())
|
||||
|
||||
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
nextMonth := monthStart.AddDate(0, 1, 0)
|
||||
|
||||
// 统计本月已创建工单数(含软删除,避免编号回收)
|
||||
var count int64
|
||||
if err := database.DB.Unscoped().Model(&models.AftersalesOrder{}).
|
||||
Where("created_at >= ? AND created_at < ?", monthStart, nextMonth).
|
||||
Count(&count).Error; err != nil {
|
||||
return "", fmt.Errorf("统计当月工单数失败: %w", err)
|
||||
}
|
||||
|
||||
seq := int(count) + 1
|
||||
for attempt := 0; attempt < 100; attempt++ {
|
||||
candidate := fmt.Sprintf("%s%02d%02d%02d", aftersalesSerialPrefix, yy, mm, seq)
|
||||
|
||||
var existing models.AftersalesOrder
|
||||
result := database.DB.Unscoped().Where("serial_number = ?", candidate).First(&existing)
|
||||
if result.Error != nil {
|
||||
return candidate, nil
|
||||
}
|
||||
seq++
|
||||
}
|
||||
return "", errors.New("生成唯一序列号失败,请重试")
|
||||
}
|
||||
|
||||
// Create 创建售后工单
|
||||
func (s *AftersalesService) Create(dto models.CreateAftersalesOrderDTO, userId uint) (*models.AftersalesOrder, error) {
|
||||
// 确保公司存在
|
||||
var company models.Company
|
||||
result := database.DB.Where("company_name = ?", dto.CompanyName).First(&company)
|
||||
if result.Error != nil {
|
||||
company = models.Company{
|
||||
CompanyName: dto.CompanyName,
|
||||
IsActive: true,
|
||||
}
|
||||
if err := database.DB.Create(&company).Error; err != nil {
|
||||
return nil, fmt.Errorf("创建公司失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
serialNumber, err := s.generateUniqueSerial()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
technicianID := dto.TechnicianID
|
||||
if technicianID == nil {
|
||||
uid := userId
|
||||
technicianID = &uid
|
||||
}
|
||||
|
||||
order := models.AftersalesOrder{
|
||||
SerialNumber: serialNumber,
|
||||
CompanyName: dto.CompanyName,
|
||||
CompanyAddress: dto.CompanyAddress,
|
||||
ContactName: dto.ContactName,
|
||||
ContactPhone: dto.ContactPhone,
|
||||
ServiceType: dto.ServiceType,
|
||||
IssueDescription: dto.IssueDescription,
|
||||
TechnicianID: technicianID,
|
||||
CreatedBy: &userId,
|
||||
WorkOrderStatus: WorkOrderStatusCreated,
|
||||
AuthorizationStatus: AuthorizationStatusPending,
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&order).Error; err != nil {
|
||||
return nil, fmt.Errorf("创建售后工单失败: %w", err)
|
||||
}
|
||||
|
||||
_ = database.DB.Preload("Technician").Preload("Creator").Where("serial_number = ?", order.SerialNumber).First(&order)
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
// FindAll 获取售后工单列表
|
||||
func (s *AftersalesService) FindAll(
|
||||
page int,
|
||||
limit int,
|
||||
search string,
|
||||
workOrderStatus string,
|
||||
serviceType string,
|
||||
technicianID *uint,
|
||||
) ([]models.AftersalesOrder, int, int, error) {
|
||||
var orders []models.AftersalesOrder
|
||||
var total int64
|
||||
|
||||
offset := (page - 1) * limit
|
||||
db := database.DB.Model(&models.AftersalesOrder{}).Preload("Technician").Preload("Creator")
|
||||
|
||||
if search != "" {
|
||||
pattern := "%" + search + "%"
|
||||
db = db.Where("serial_number LIKE ? OR company_name LIKE ? OR contact_name LIKE ?", pattern, pattern, pattern)
|
||||
}
|
||||
if workOrderStatus != "" {
|
||||
db = db.Where("work_order_status = ?", workOrderStatus)
|
||||
}
|
||||
if serviceType != "" {
|
||||
db = db.Where("service_type = ?", serviceType)
|
||||
}
|
||||
if technicianID != nil {
|
||||
db = db.Where("technician_id = ?", *technicianID)
|
||||
}
|
||||
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, 0, fmt.Errorf("查询售后工单总数失败: %w", err)
|
||||
}
|
||||
|
||||
if err := db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&orders).Error; err != nil {
|
||||
return nil, 0, 0, fmt.Errorf("查询售后工单列表失败: %w", err)
|
||||
}
|
||||
|
||||
totalPages := (int(total) + limit - 1) / limit
|
||||
return orders, int(total), totalPages, nil
|
||||
}
|
||||
|
||||
// FindOne 获取单个售后工单
|
||||
func (s *AftersalesService) FindOne(serialNumber string) (*models.AftersalesOrder, error) {
|
||||
var order models.AftersalesOrder
|
||||
result := database.DB.Preload("Technician").Preload("Creator").
|
||||
Where("serial_number = ?", normalizeAftersalesSerial(serialNumber)).First(&order)
|
||||
if result.Error != nil {
|
||||
return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在"))
|
||||
}
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
// Update 更新售后工单基础信息
|
||||
func (s *AftersalesService) Update(
|
||||
serialNumber string,
|
||||
dto models.UpdateAftersalesOrderDTO,
|
||||
currentUser models.User,
|
||||
) (*models.AftersalesOrder, error) {
|
||||
var order models.AftersalesOrder
|
||||
result := database.DB.Where("serial_number = ?", normalizeAftersalesSerial(serialNumber)).First(&order)
|
||||
if result.Error != nil {
|
||||
return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在"))
|
||||
}
|
||||
|
||||
if order.WorkOrderStatus == WorkOrderStatusClosed {
|
||||
return nil, errors.New("工单已关闭,不可修改")
|
||||
}
|
||||
|
||||
if currentUser.Role != "admin" {
|
||||
if order.TechnicianID == nil || *order.TechnicianID != currentUser.ID {
|
||||
return nil, errors.New("无权修改此工单")
|
||||
}
|
||||
}
|
||||
|
||||
if dto.CompanyAddress != "" {
|
||||
order.CompanyAddress = dto.CompanyAddress
|
||||
}
|
||||
if dto.ContactName != "" {
|
||||
order.ContactName = dto.ContactName
|
||||
}
|
||||
if dto.ContactPhone != "" {
|
||||
order.ContactPhone = dto.ContactPhone
|
||||
}
|
||||
if dto.ServiceType != "" {
|
||||
order.ServiceType = dto.ServiceType
|
||||
}
|
||||
if dto.IssueDescription != "" {
|
||||
order.IssueDescription = dto.IssueDescription
|
||||
}
|
||||
if dto.ResolutionNote != "" {
|
||||
order.ResolutionNote = dto.ResolutionNote
|
||||
}
|
||||
if dto.TechnicianID != nil && currentUser.Role == "admin" {
|
||||
order.TechnicianID = dto.TechnicianID
|
||||
}
|
||||
|
||||
if err := database.DB.Save(&order).Error; err != nil {
|
||||
return nil, fmt.Errorf("更新售后工单失败: %w", err)
|
||||
}
|
||||
|
||||
_ = database.DB.Preload("Technician").Preload("Creator").Where("serial_number = ?", order.SerialNumber).First(&order)
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
// SubmitForConfirmation 技术员提交客户确认
|
||||
func (s *AftersalesService) SubmitForConfirmation(
|
||||
serialNumber string,
|
||||
dto models.SubmitForConfirmationDTO,
|
||||
currentUser models.User,
|
||||
) (*models.AftersalesOrder, error) {
|
||||
var order models.AftersalesOrder
|
||||
result := database.DB.Where("serial_number = ?", normalizeAftersalesSerial(serialNumber)).First(&order)
|
||||
if result.Error != nil {
|
||||
return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在"))
|
||||
}
|
||||
|
||||
if currentUser.Role != "admin" {
|
||||
if order.TechnicianID == nil || *order.TechnicianID != currentUser.ID {
|
||||
return nil, errors.New("无权操作此工单")
|
||||
}
|
||||
}
|
||||
|
||||
if order.WorkOrderStatus != WorkOrderStatusCreated && order.WorkOrderStatus != WorkOrderStatusRejected {
|
||||
return nil, errors.New("当前工单状态不可提交确认")
|
||||
}
|
||||
|
||||
order.ResolutionNote = dto.ResolutionNote
|
||||
order.WorkOrderStatus = WorkOrderStatusPendingConfirmation
|
||||
|
||||
if err := database.DB.Save(&order).Error; err != nil {
|
||||
return nil, fmt.Errorf("提交客户确认失败: %w", err)
|
||||
}
|
||||
|
||||
_ = database.DB.Preload("Technician").Preload("Creator").Where("serial_number = ?", order.SerialNumber).First(&order)
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
// PublicQuery 公开查询(脱敏视图)
|
||||
func (s *AftersalesService) PublicQuery(serialNumber string) (*models.AftersalesPublicView, error) {
|
||||
var order models.AftersalesOrder
|
||||
result := database.DB.Preload("Technician").
|
||||
Where("serial_number = ?", normalizeAftersalesSerial(serialNumber)).First(&order)
|
||||
if result.Error != nil {
|
||||
return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在"))
|
||||
}
|
||||
|
||||
// 首次扫码记录时间
|
||||
if order.ScannedAt == nil {
|
||||
now := time.Now()
|
||||
order.ScannedAt = &now
|
||||
_ = database.DB.Model(&order).Update("scanned_at", now).Error
|
||||
}
|
||||
|
||||
view := &models.AftersalesPublicView{
|
||||
SerialNumber: order.SerialNumber,
|
||||
CompanyName: order.CompanyName,
|
||||
CompanyAddress: order.CompanyAddress,
|
||||
ContactName: order.ContactName,
|
||||
ServiceType: order.ServiceType,
|
||||
IssueDescription: order.IssueDescription,
|
||||
ResolutionNote: order.ResolutionNote,
|
||||
WorkOrderStatus: order.WorkOrderStatus,
|
||||
AuthorizationStatus: order.AuthorizationStatus,
|
||||
CreatedAt: order.CreatedAt,
|
||||
ConfirmedAt: order.ConfirmedAt,
|
||||
}
|
||||
if order.Technician != nil {
|
||||
view.TechnicianName = order.Technician.Name
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// CustomerConfirm 客户授权/未授权确认
|
||||
func (s *AftersalesService) CustomerConfirm(serialNumber string, dto models.CustomerConfirmDTO) (*models.AftersalesPublicView, error) {
|
||||
normalized := normalizeAftersalesSerial(serialNumber)
|
||||
|
||||
if !checkConfirmRateLimit(normalized) {
|
||||
return nil, errors.New("操作过于频繁,请稍后再试")
|
||||
}
|
||||
|
||||
var order models.AftersalesOrder
|
||||
result := database.DB.Where("serial_number = ?", normalized).First(&order)
|
||||
if result.Error != nil {
|
||||
return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在"))
|
||||
}
|
||||
|
||||
if order.WorkOrderStatus != WorkOrderStatusPendingConfirmation {
|
||||
return nil, errors.New("当前工单状态不可确认")
|
||||
}
|
||||
|
||||
if len(order.ContactPhone) < 4 || order.ContactPhone[len(order.ContactPhone)-4:] != dto.PhoneLast4 {
|
||||
return nil, errors.New("手机号校验失败")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
switch dto.Action {
|
||||
case "authorize":
|
||||
order.WorkOrderStatus = WorkOrderStatusClosed
|
||||
order.AuthorizationStatus = AuthorizationStatusAuthorized
|
||||
order.ConfirmedAt = &now
|
||||
case "reject":
|
||||
order.WorkOrderStatus = WorkOrderStatusRejected
|
||||
order.AuthorizationStatus = AuthorizationStatusUnauthorized
|
||||
order.RejectCount++
|
||||
if dto.RejectReason != "" {
|
||||
order.ResolutionNote = order.ResolutionNote + "\n\n[客户退回] " + dto.RejectReason
|
||||
}
|
||||
default:
|
||||
return nil, errors.New("无效的操作")
|
||||
}
|
||||
|
||||
if err := database.DB.Save(&order).Error; err != nil {
|
||||
return nil, fmt.Errorf("提交确认失败: %w", err)
|
||||
}
|
||||
|
||||
return s.PublicQuery(normalized)
|
||||
}
|
||||
|
||||
// Reassign 重新分配技术员(仅管理员)
|
||||
func (s *AftersalesService) Reassign(serialNumber string, technicianID uint) (*models.AftersalesOrder, error) {
|
||||
var order models.AftersalesOrder
|
||||
result := database.DB.Where("serial_number = ?", normalizeAftersalesSerial(serialNumber)).First(&order)
|
||||
if result.Error != nil {
|
||||
return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在"))
|
||||
}
|
||||
|
||||
if order.WorkOrderStatus == WorkOrderStatusClosed {
|
||||
return nil, errors.New("工单已关闭,不可重新分配")
|
||||
}
|
||||
|
||||
var technician models.User
|
||||
if err := database.DB.First(&technician, technicianID).Error; err != nil {
|
||||
return nil, errors.New("指定的技术员不存在")
|
||||
}
|
||||
if technician.Role != "admin" && technician.Role != "technician" {
|
||||
return nil, errors.New("指定的用户不是技术员或管理员")
|
||||
}
|
||||
|
||||
order.TechnicianID = &technicianID
|
||||
if err := database.DB.Save(&order).Error; err != nil {
|
||||
return nil, fmt.Errorf("重新分配技术员失败: %w", err)
|
||||
}
|
||||
|
||||
_ = database.DB.Preload("Technician").Preload("Creator").Where("serial_number = ?", order.SerialNumber).First(&order)
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
// ForceClose 管理员强制关闭工单
|
||||
func (s *AftersalesService) ForceClose(serialNumber string) (*models.AftersalesOrder, error) {
|
||||
var order models.AftersalesOrder
|
||||
result := database.DB.Where("serial_number = ?", normalizeAftersalesSerial(serialNumber)).First(&order)
|
||||
if result.Error != nil {
|
||||
return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在"))
|
||||
}
|
||||
|
||||
if order.WorkOrderStatus == WorkOrderStatusClosed {
|
||||
return nil, errors.New("工单已关闭")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
order.WorkOrderStatus = WorkOrderStatusClosed
|
||||
order.AuthorizationStatus = AuthorizationStatusAuthorized
|
||||
order.ConfirmedAt = &now
|
||||
|
||||
if err := database.DB.Save(&order).Error; err != nil {
|
||||
return nil, fmt.Errorf("强制关闭工单失败: %w", err)
|
||||
}
|
||||
|
||||
_ = database.DB.Preload("Technician").Preload("Creator").Where("serial_number = ?", order.SerialNumber).First(&order)
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
// Delete 物理删除售后工单(仅管理员)
|
||||
func (s *AftersalesService) Delete(serialNumber string) error {
|
||||
var order models.AftersalesOrder
|
||||
result := database.DB.Where("serial_number = ?", normalizeAftersalesSerial(serialNumber)).First(&order)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在"))
|
||||
}
|
||||
|
||||
if err := database.DB.Delete(&order).Error; err != nil {
|
||||
return fmt.Errorf("删除售后工单失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateQRCode 生成售后工单二维码
|
||||
func (s *AftersalesService) GenerateQRCode(
|
||||
serialNumber string,
|
||||
baseUrl string,
|
||||
requestHost string,
|
||||
protocol string,
|
||||
) (string, string, error) {
|
||||
var order models.AftersalesOrder
|
||||
result := database.DB.Where("serial_number = ?", normalizeAftersalesSerial(serialNumber)).First(&order)
|
||||
if result.Error != nil {
|
||||
return "", "", fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在"))
|
||||
}
|
||||
|
||||
// 售后码二维码直接指向 /aftersales/{serialNumber}
|
||||
if baseUrl == "" {
|
||||
baseUrl = fmt.Sprintf("%s://%s/aftersales/%s", protocol, requestHost, order.SerialNumber)
|
||||
}
|
||||
|
||||
filePath := fmt.Sprintf("temp_qr_%s.png", uuid.New().String())
|
||||
writer, err := standard.New(filePath, standard.WithQRWidth(6))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("二维码写入器创建失败: %w", err)
|
||||
}
|
||||
|
||||
qrc, errCode := qr.New(baseUrl)
|
||||
if errCode != nil {
|
||||
os.Remove(filePath)
|
||||
return "", "", fmt.Errorf("二维码创建失败: %w", errCode)
|
||||
}
|
||||
|
||||
if errSave := qrc.Save(writer); errSave != nil {
|
||||
os.Remove(filePath)
|
||||
return "", "", fmt.Errorf("二维码保存失败: %w", errSave)
|
||||
}
|
||||
|
||||
fileContent, errRead := os.ReadFile(filePath)
|
||||
if errRead != nil {
|
||||
os.Remove(filePath)
|
||||
return "", "", fmt.Errorf("二维码文件读取失败: %w", errRead)
|
||||
}
|
||||
os.Remove(filePath)
|
||||
|
||||
qrCodeBase64 := fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(fileContent))
|
||||
return qrCodeBase64, baseUrl, nil
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"git.beifan.cn/trace-system/backend-go/database"
|
||||
"git.beifan.cn/trace-system/backend-go/models"
|
||||
)
|
||||
|
||||
func seedTechnician(t *testing.T, username string) models.User {
|
||||
t.Helper()
|
||||
user := models.User{
|
||||
Username: username,
|
||||
Password: "hashed",
|
||||
Name: "技术员-" + username,
|
||||
Email: username + "@example.com",
|
||||
Role: "technician",
|
||||
}
|
||||
if err := database.DB.Create(&user).Error; err != nil {
|
||||
t.Fatalf("seed technician failed: %v", err)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func createOrderFor(t *testing.T, user models.User, phone string) *models.AftersalesOrder {
|
||||
t.Helper()
|
||||
svc := AftersalesService{}
|
||||
order, err := svc.Create(models.CreateAftersalesOrderDTO{
|
||||
CompanyName: "TestCo_" + user.Username,
|
||||
CompanyAddress: "测试地址",
|
||||
ContactName: "张三",
|
||||
ContactPhone: phone,
|
||||
ServiceType: "software",
|
||||
IssueDescription: "系统无法启动",
|
||||
}, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create order failed: %v", err)
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
func TestAftersalesService_Create_GeneratesYYMMNNSerial(t *testing.T) {
|
||||
user := seedTechnician(t, "aftersales_create_tech")
|
||||
defer database.DB.Unscoped().Delete(&user)
|
||||
|
||||
svc := AftersalesService{}
|
||||
order, err := svc.Create(models.CreateAftersalesOrderDTO{
|
||||
CompanyName: "AftersalesSerialCo",
|
||||
CompanyAddress: "杭州市西湖区",
|
||||
ContactName: "李四",
|
||||
ContactPhone: "13800001234",
|
||||
ServiceType: "hardware",
|
||||
IssueDescription: "硬盘故障",
|
||||
}, user.ID)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, order)
|
||||
assert.True(t, strings.HasPrefix(order.SerialNumber, "zjbf-sh-"))
|
||||
assert.Len(t, order.SerialNumber, len("zjbf-sh-")+6, "default serial should be 6 digits (YYMMNN)")
|
||||
assert.Equal(t, WorkOrderStatusCreated, order.WorkOrderStatus)
|
||||
assert.Equal(t, AuthorizationStatusPending, order.AuthorizationStatus)
|
||||
assert.NotNil(t, order.TechnicianID)
|
||||
assert.Equal(t, user.ID, *order.TechnicianID)
|
||||
|
||||
database.DB.Unscoped().Delete(order)
|
||||
database.DB.Unscoped().Where("company_name = ?", "AftersalesSerialCo").Delete(&models.Company{})
|
||||
}
|
||||
|
||||
func TestAftersalesService_Create_SerialIncrementsWithinMonth(t *testing.T) {
|
||||
user := seedTechnician(t, "aftersales_seq_tech")
|
||||
defer database.DB.Unscoped().Delete(&user)
|
||||
|
||||
svc := AftersalesService{}
|
||||
first, err := svc.Create(models.CreateAftersalesOrderDTO{
|
||||
CompanyName: "SeqCo", CompanyAddress: "addr", ContactName: "A", ContactPhone: "13800002000",
|
||||
ServiceType: "other", IssueDescription: "issue 1",
|
||||
}, user.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
second, err := svc.Create(models.CreateAftersalesOrderDTO{
|
||||
CompanyName: "SeqCo", CompanyAddress: "addr", ContactName: "A", ContactPhone: "13800002000",
|
||||
ServiceType: "other", IssueDescription: "issue 2",
|
||||
}, user.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, first.SerialNumber, second.SerialNumber)
|
||||
// 第二单的序号应大于第一单(按月递增)
|
||||
assert.True(t, second.SerialNumber > first.SerialNumber)
|
||||
|
||||
database.DB.Unscoped().Delete(first)
|
||||
database.DB.Unscoped().Delete(second)
|
||||
database.DB.Unscoped().Where("company_name = ?", "SeqCo").Delete(&models.Company{})
|
||||
}
|
||||
|
||||
func TestAftersalesService_SubmitForConfirmation_OwnerOnly(t *testing.T) {
|
||||
owner := seedTechnician(t, "aftersales_owner")
|
||||
intruder := seedTechnician(t, "aftersales_intruder")
|
||||
defer database.DB.Unscoped().Delete(&owner)
|
||||
defer database.DB.Unscoped().Delete(&intruder)
|
||||
|
||||
order := createOrderFor(t, owner, "13800003000")
|
||||
defer database.DB.Unscoped().Delete(order)
|
||||
defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{})
|
||||
|
||||
svc := AftersalesService{}
|
||||
|
||||
// 非负责人技术员不能提交
|
||||
_, err := svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{
|
||||
ResolutionNote: "已重装系统",
|
||||
}, intruder)
|
||||
assert.Error(t, err)
|
||||
|
||||
// 负责人可以提交
|
||||
updated, err := svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{
|
||||
ResolutionNote: "已重装系统",
|
||||
}, owner)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, WorkOrderStatusPendingConfirmation, updated.WorkOrderStatus)
|
||||
assert.Equal(t, "已重装系统", updated.ResolutionNote)
|
||||
}
|
||||
|
||||
func TestAftersalesService_SubmitForConfirmation_RejectsClosed(t *testing.T) {
|
||||
owner := seedTechnician(t, "aftersales_closed_owner")
|
||||
defer database.DB.Unscoped().Delete(&owner)
|
||||
|
||||
order := createOrderFor(t, owner, "13800004000")
|
||||
defer database.DB.Unscoped().Delete(order)
|
||||
defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{})
|
||||
|
||||
svc := AftersalesService{}
|
||||
|
||||
_, err := svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{
|
||||
ResolutionNote: "first submit",
|
||||
}, owner)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 已经 pending_confirmation 状态,不能再提交
|
||||
_, err = svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{
|
||||
ResolutionNote: "second submit",
|
||||
}, owner)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestAftersalesService_PublicQuery_MasksPhoneAndSetsScannedAt(t *testing.T) {
|
||||
owner := seedTechnician(t, "aftersales_public_owner")
|
||||
defer database.DB.Unscoped().Delete(&owner)
|
||||
|
||||
order := createOrderFor(t, owner, "13800005000")
|
||||
defer database.DB.Unscoped().Delete(order)
|
||||
defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{})
|
||||
|
||||
svc := AftersalesService{}
|
||||
view, err := svc.PublicQuery(order.SerialNumber)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, view)
|
||||
assert.Equal(t, order.CompanyName, view.CompanyName)
|
||||
assert.Equal(t, order.ContactName, view.ContactName)
|
||||
|
||||
// PublicView 不应该带电话字段(只有以下字段,通过类型保证)
|
||||
// 验证 ScannedAt 已设置
|
||||
var refreshed models.AftersalesOrder
|
||||
database.DB.Where("serial_number = ?", order.SerialNumber).First(&refreshed)
|
||||
assert.NotNil(t, refreshed.ScannedAt)
|
||||
}
|
||||
|
||||
func TestAftersalesService_CustomerConfirm_Authorize(t *testing.T) {
|
||||
owner := seedTechnician(t, "aftersales_auth_owner")
|
||||
defer database.DB.Unscoped().Delete(&owner)
|
||||
|
||||
order := createOrderFor(t, owner, "13800006789")
|
||||
defer database.DB.Unscoped().Delete(order)
|
||||
defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{})
|
||||
|
||||
svc := AftersalesService{}
|
||||
_, _ = svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{
|
||||
ResolutionNote: "done",
|
||||
}, owner)
|
||||
|
||||
view, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{
|
||||
PhoneLast4: "6789",
|
||||
Action: "authorize",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, view)
|
||||
assert.Equal(t, WorkOrderStatusClosed, view.WorkOrderStatus)
|
||||
assert.Equal(t, AuthorizationStatusAuthorized, view.AuthorizationStatus)
|
||||
assert.NotNil(t, view.ConfirmedAt)
|
||||
}
|
||||
|
||||
func TestAftersalesService_CustomerConfirm_PhoneMismatch(t *testing.T) {
|
||||
owner := seedTechnician(t, "aftersales_phone_owner")
|
||||
defer database.DB.Unscoped().Delete(&owner)
|
||||
|
||||
order := createOrderFor(t, owner, "13800007777")
|
||||
defer database.DB.Unscoped().Delete(order)
|
||||
defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{})
|
||||
|
||||
svc := AftersalesService{}
|
||||
_, _ = svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{
|
||||
ResolutionNote: "done",
|
||||
}, owner)
|
||||
|
||||
_, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{
|
||||
PhoneLast4: "0000",
|
||||
Action: "authorize",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "手机号校验失败")
|
||||
}
|
||||
|
||||
func TestAftersalesService_CustomerConfirm_RejectIncrementsCount(t *testing.T) {
|
||||
owner := seedTechnician(t, "aftersales_reject_owner")
|
||||
defer database.DB.Unscoped().Delete(&owner)
|
||||
|
||||
order := createOrderFor(t, owner, "13800008888")
|
||||
defer database.DB.Unscoped().Delete(order)
|
||||
defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{})
|
||||
|
||||
svc := AftersalesService{}
|
||||
_, _ = svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{
|
||||
ResolutionNote: "done",
|
||||
}, owner)
|
||||
|
||||
view, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{
|
||||
PhoneLast4: "8888",
|
||||
Action: "reject",
|
||||
RejectReason: "没修好",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, WorkOrderStatusRejected, view.WorkOrderStatus)
|
||||
assert.Equal(t, AuthorizationStatusUnauthorized, view.AuthorizationStatus)
|
||||
|
||||
var refreshed models.AftersalesOrder
|
||||
database.DB.Where("serial_number = ?", order.SerialNumber).First(&refreshed)
|
||||
assert.Equal(t, 1, refreshed.RejectCount)
|
||||
assert.Contains(t, refreshed.ResolutionNote, "没修好")
|
||||
|
||||
// 退回后技术员可以再次提交
|
||||
_, err = svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{
|
||||
ResolutionNote: "re-do",
|
||||
}, owner)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestAftersalesService_CustomerConfirm_RejectsWrongStatus(t *testing.T) {
|
||||
owner := seedTechnician(t, "aftersales_wrongstatus_owner")
|
||||
defer database.DB.Unscoped().Delete(&owner)
|
||||
|
||||
order := createOrderFor(t, owner, "13800009999")
|
||||
defer database.DB.Unscoped().Delete(order)
|
||||
defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{})
|
||||
|
||||
svc := AftersalesService{}
|
||||
// 未提交客户确认,工单仍是 created,应该拒绝
|
||||
_, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{
|
||||
PhoneLast4: "9999",
|
||||
Action: "authorize",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestAftersalesService_ForceClose_AdminOverride(t *testing.T) {
|
||||
owner := seedTechnician(t, "aftersales_force_owner")
|
||||
defer database.DB.Unscoped().Delete(&owner)
|
||||
|
||||
order := createOrderFor(t, owner, "13800001111")
|
||||
defer database.DB.Unscoped().Delete(order)
|
||||
defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{})
|
||||
|
||||
svc := AftersalesService{}
|
||||
updated, err := svc.ForceClose(order.SerialNumber)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, WorkOrderStatusClosed, updated.WorkOrderStatus)
|
||||
assert.Equal(t, AuthorizationStatusAuthorized, updated.AuthorizationStatus)
|
||||
}
|
||||
@@ -317,6 +317,11 @@ func (s *CompaniesService) GetStats() (map[string]any, error) {
|
||||
return nil, errors.New("查询员工序列号统计失败")
|
||||
}
|
||||
|
||||
var aftersales []models.AftersalesOrder
|
||||
if err := database.DB.Preload("Technician").Order("created_at DESC").Find(&aftersales).Error; err != nil {
|
||||
return nil, errors.New("查询售后工单统计失败")
|
||||
}
|
||||
|
||||
companyCount := len(companies)
|
||||
serialCount := len(serials)
|
||||
employeeSerialCount := len(employeeSerials)
|
||||
@@ -392,6 +397,41 @@ func (s *CompaniesService) GetStats() (map[string]any, error) {
|
||||
recentSerials = recentSerials[:10]
|
||||
}
|
||||
|
||||
aftersalesTotal := len(aftersales)
|
||||
aftersalesPending := 0
|
||||
aftersalesClosed := 0
|
||||
aftersalesRejected := 0
|
||||
for _, o := range aftersales {
|
||||
switch o.WorkOrderStatus {
|
||||
case "pending_confirmation":
|
||||
aftersalesPending++
|
||||
case "closed":
|
||||
aftersalesClosed++
|
||||
case "rejected":
|
||||
aftersalesRejected++
|
||||
}
|
||||
}
|
||||
|
||||
recentAftersales := make([]map[string]any, 0)
|
||||
for i, order := range aftersales {
|
||||
if i >= 10 {
|
||||
break
|
||||
}
|
||||
technicianName := ""
|
||||
if order.Technician != nil {
|
||||
technicianName = order.Technician.Name
|
||||
}
|
||||
recentAftersales = append(recentAftersales, map[string]any{
|
||||
"serialNumber": order.SerialNumber,
|
||||
"companyName": order.CompanyName,
|
||||
"serviceType": order.ServiceType,
|
||||
"workOrderStatus": order.WorkOrderStatus,
|
||||
"authorizationStatus": order.AuthorizationStatus,
|
||||
"technicianName": technicianName,
|
||||
"createdAt": order.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"overview": map[string]any{
|
||||
"totalCompanies": companyCount,
|
||||
@@ -399,10 +439,15 @@ func (s *CompaniesService) GetStats() (map[string]any, error) {
|
||||
"totalEmployeeSerials": employeeSerialCount,
|
||||
"activeSerials": activeCount,
|
||||
"inactiveSerials": inactiveCount,
|
||||
"totalAftersales": aftersalesTotal,
|
||||
"pendingConfirmation": aftersalesPending,
|
||||
"closedAftersales": aftersalesClosed,
|
||||
"rejectedAftersales": aftersalesRejected,
|
||||
},
|
||||
"monthlyStats": monthlyItems,
|
||||
"recentCompanies": recentCompanies,
|
||||
"recentSerials": recentSerials,
|
||||
"monthlyStats": monthlyItems,
|
||||
"recentCompanies": recentCompanies,
|
||||
"recentSerials": recentSerials,
|
||||
"recentAftersales": recentAftersales,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -440,5 +485,18 @@ func (s *CompaniesService) GetStatsOverview() (*models.CompanyStatsOverviewDTO,
|
||||
|
||||
stats.RevokedEmployeeSerials = stats.TotalEmployeeSerials - stats.ActiveEmployeeSerials
|
||||
|
||||
if err := database.DB.Model(&models.AftersalesOrder{}).Count(&stats.TotalAftersales).Error; err != nil {
|
||||
return nil, errors.New("统计售后工单总数失败")
|
||||
}
|
||||
if err := database.DB.Model(&models.AftersalesOrder{}).Where("work_order_status = ?", "pending_confirmation").Count(&stats.PendingConfirmation).Error; err != nil {
|
||||
return nil, errors.New("统计待客户确认工单失败")
|
||||
}
|
||||
if err := database.DB.Model(&models.AftersalesOrder{}).Where("work_order_status = ?", "closed").Count(&stats.ClosedAftersales).Error; err != nil {
|
||||
return nil, errors.New("统计已完成工单失败")
|
||||
}
|
||||
if err := database.DB.Model(&models.AftersalesOrder{}).Where("work_order_status = ?", "rejected").Count(&stats.RejectedAftersales).Error; err != nil {
|
||||
return nil, errors.New("统计已退回工单失败")
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
@@ -117,14 +117,14 @@ func (s *EmployeeSerialsService) FindAll(page int, limit int, search string) ([]
|
||||
|
||||
// 搜索条件
|
||||
if search != "" {
|
||||
db = db.Where("serial_number LIKE ? OR company_name LIKE ? OR department LIKE ? OR employee_name LIKE ?",
|
||||
db = db.Where("serial_number LIKE ? OR company_name LIKE ? OR position LIKE ? OR employee_name LIKE ?",
|
||||
"%"+search+"%", "%"+search+"%", "%"+search+"%", "%"+search+"%")
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
countQuery := db.Model(&models.EmployeeSerial{})
|
||||
if search != "" {
|
||||
countQuery = countQuery.Where("serial_number LIKE ? OR company_name LIKE ? OR department LIKE ? OR employee_name LIKE ?",
|
||||
countQuery = countQuery.Where("serial_number LIKE ? OR company_name LIKE ? OR position LIKE ? OR employee_name LIKE ?",
|
||||
"%"+search+"%", "%"+search+"%", "%"+search+"%", "%"+search+"%")
|
||||
}
|
||||
countQuery.Count(&total)
|
||||
|
||||
+17
-15
@@ -32,6 +32,7 @@ func TestMain(m *testing.M) {
|
||||
database.DB.Unscoped().Where("1 = 1").Delete(&models.Company{})
|
||||
database.DB.Unscoped().Where("1 = 1").Delete(&models.Serial{})
|
||||
database.DB.Unscoped().Where("1 = 1").Delete(&models.EmployeeSerial{})
|
||||
database.DB.Unscoped().Where("1 = 1").Delete(&models.AftersalesOrder{})
|
||||
|
||||
exitCode := m.Run()
|
||||
|
||||
@@ -39,6 +40,7 @@ func TestMain(m *testing.M) {
|
||||
database.DB.Unscoped().Where("1 = 1").Delete(&models.Company{})
|
||||
database.DB.Unscoped().Where("1 = 1").Delete(&models.Serial{})
|
||||
database.DB.Unscoped().Where("1 = 1").Delete(&models.EmployeeSerial{})
|
||||
database.DB.Unscoped().Where("1 = 1").Delete(&models.AftersalesOrder{})
|
||||
|
||||
os.Exit(exitCode)
|
||||
}
|
||||
@@ -429,13 +431,13 @@ func TestEmployeeSerialsService_Generate_Success(t *testing.T) {
|
||||
database.DB.Create(&user)
|
||||
|
||||
service := EmployeeSerialsService{}
|
||||
serials, err := service.Generate("EmpTestCompany", "技术部", "张三", 5, user.ID)
|
||||
serials, err := service.Generate("EmpTestCompany", "技术部", "张三", 5, user.ID, "")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, serials, 5)
|
||||
for _, serial := range serials {
|
||||
assert.Equal(t, "EmpTestCompany", serial.CompanyName)
|
||||
assert.Equal(t, "技术部", serial.Department)
|
||||
assert.Equal(t, "技术部", serial.Position)
|
||||
assert.Equal(t, "张三", serial.EmployeeName)
|
||||
assert.True(t, serial.IsActive)
|
||||
assert.True(t, strings.HasPrefix(serial.SerialNumber, "EMP"))
|
||||
@@ -459,7 +461,7 @@ func TestEmployeeSerialsService_Generate_CreateNewCompany(t *testing.T) {
|
||||
database.DB.Create(&user)
|
||||
|
||||
service := EmployeeSerialsService{}
|
||||
serials, err := service.Generate("NewEmpCompany", "市场部", "李四", 3, user.ID)
|
||||
serials, err := service.Generate("NewEmpCompany", "市场部", "李四", 3, user.ID, "")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, serials, 3)
|
||||
@@ -490,7 +492,7 @@ func TestEmployeeSerialsService_Query_Success(t *testing.T) {
|
||||
database.DB.Create(&user)
|
||||
|
||||
service := EmployeeSerialsService{}
|
||||
serials, _ := service.Generate("QueryEmpCompany", "财务部", "王五", 1, user.ID)
|
||||
serials, _ := service.Generate("QueryEmpCompany", "财务部", "王五", 1, user.ID, "")
|
||||
|
||||
serialNumber := serials[0].SerialNumber
|
||||
result, err := service.Query(serialNumber)
|
||||
@@ -499,7 +501,7 @@ func TestEmployeeSerialsService_Query_Success(t *testing.T) {
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, strings.ToUpper(serialNumber), strings.ToUpper(result.SerialNumber))
|
||||
assert.Equal(t, "QueryEmpCompany", result.CompanyName)
|
||||
assert.Equal(t, "财务部", result.Department)
|
||||
assert.Equal(t, "财务部", result.Position)
|
||||
assert.Equal(t, "王五", result.EmployeeName)
|
||||
assert.True(t, result.IsActive)
|
||||
|
||||
@@ -531,7 +533,7 @@ func TestEmployeeSerialsService_FindAll_Success(t *testing.T) {
|
||||
database.DB.Create(&user)
|
||||
|
||||
service := EmployeeSerialsService{}
|
||||
serials, _ := service.Generate("ListEmpCompany", "人事部", "赵六", 10, user.ID)
|
||||
serials, _ := service.Generate("ListEmpCompany", "人事部", "赵六", 10, user.ID, "")
|
||||
|
||||
result, total, totalPages, err := service.FindAll(1, 5, "")
|
||||
|
||||
@@ -560,7 +562,7 @@ func TestEmployeeSerialsService_FindAll_WithSearch(t *testing.T) {
|
||||
database.DB.Create(&user)
|
||||
|
||||
service := EmployeeSerialsService{}
|
||||
serials, _ := service.Generate("SearchEmpCompany", "研发部", "钱七", 5, user.ID)
|
||||
serials, _ := service.Generate("SearchEmpCompany", "研发部", "钱七", 5, user.ID, "")
|
||||
|
||||
result, _, _, err := service.FindAll(1, 10, "SearchEmpCompany")
|
||||
|
||||
@@ -596,14 +598,14 @@ func TestEmployeeSerialsService_Update_Success(t *testing.T) {
|
||||
database.DB.Create(&user)
|
||||
|
||||
service := EmployeeSerialsService{}
|
||||
serials, _ := service.Generate("UpdateEmpCompany", "运营部", "孙八", 1, user.ID)
|
||||
serials, _ := service.Generate("UpdateEmpCompany", "运营部", "孙八", 1, user.ID, "")
|
||||
|
||||
serialNumber := serials[0].SerialNumber
|
||||
isActive := false
|
||||
|
||||
updateData := models.UpdateEmployeeSerialDTO{
|
||||
CompanyName: "UpdatedEmpCompany",
|
||||
Department: "新部门",
|
||||
Position: "新部门",
|
||||
EmployeeName: "新名字",
|
||||
IsActive: &isActive,
|
||||
}
|
||||
@@ -613,7 +615,7 @@ func TestEmployeeSerialsService_Update_Success(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "UpdatedEmpCompany", result.CompanyName)
|
||||
assert.Equal(t, "新部门", result.Department)
|
||||
assert.Equal(t, "新部门", result.Position)
|
||||
assert.Equal(t, "新名字", result.EmployeeName)
|
||||
assert.False(t, result.IsActive)
|
||||
|
||||
@@ -650,7 +652,7 @@ func TestEmployeeSerialsService_Revoke_Success(t *testing.T) {
|
||||
database.DB.Create(&user)
|
||||
|
||||
service := EmployeeSerialsService{}
|
||||
serials, _ := service.Generate("RevokeEmpCompany", "测试部", "周九", 1, user.ID)
|
||||
serials, _ := service.Generate("RevokeEmpCompany", "测试部", "周九", 1, user.ID, "")
|
||||
|
||||
serialNumber := serials[0].SerialNumber
|
||||
err := service.Revoke(serialNumber)
|
||||
@@ -689,7 +691,7 @@ func TestEmployeeSerialsService_Revoke_AlreadyRevoked(t *testing.T) {
|
||||
database.DB.Create(&user)
|
||||
|
||||
service := EmployeeSerialsService{}
|
||||
serials, _ := service.Generate("RevokeEmpCompany2", "行政部", "吴十", 1, user.ID)
|
||||
serials, _ := service.Generate("RevokeEmpCompany2", "行政部", "吴十", 1, user.ID, "")
|
||||
|
||||
serialNumber := serials[0].SerialNumber
|
||||
service.Revoke(serialNumber)
|
||||
@@ -718,7 +720,7 @@ func TestEmployeeSerialsService_GenerateQRCode_Success(t *testing.T) {
|
||||
database.DB.Create(&user)
|
||||
|
||||
service := EmployeeSerialsService{}
|
||||
serials, _ := service.Generate("QREmpCompany", "产品部", "郑十一", 1, user.ID)
|
||||
serials, _ := service.Generate("QREmpCompany", "产品部", "郑十一", 1, user.ID, "")
|
||||
|
||||
serialNumber := serials[0].SerialNumber
|
||||
qrCodeBase64, queryUrl, err := service.GenerateQRCode(serialNumber, "", "localhost:3000", "http")
|
||||
@@ -757,7 +759,7 @@ func TestEmployeeSerialsService_GenerateQRCode_Inactive(t *testing.T) {
|
||||
database.DB.Create(&user)
|
||||
|
||||
service := EmployeeSerialsService{}
|
||||
serials, _ := service.Generate("QREmpCompany2", "设计部", "王十二", 1, user.ID)
|
||||
serials, _ := service.Generate("QREmpCompany2", "设计部", "王十二", 1, user.ID, "")
|
||||
|
||||
serialNumber := serials[0].SerialNumber
|
||||
service.Revoke(serialNumber)
|
||||
@@ -790,7 +792,7 @@ func TestCompaniesService_GetStatsOverview_Success(t *testing.T) {
|
||||
companySerials, _ := serialService.Generate("StatsCompany", 2, 30, user.ID, "STAT")
|
||||
|
||||
employeeSerialsService := EmployeeSerialsService{}
|
||||
employeeSerials, _ := employeeSerialsService.Generate("StatsCompany", "技术部", "测试员工", 2, user.ID)
|
||||
employeeSerials, _ := employeeSerialsService.Generate("StatsCompany", "技术部", "测试员工", 2, user.ID, "")
|
||||
|
||||
_ = serialService.Revoke(companySerials[0].SerialNumber)
|
||||
_ = employeeSerialsService.Revoke(employeeSerials[0].SerialNumber)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.beifan.cn/trace-system/backend-go/database"
|
||||
"git.beifan.cn/trace-system/backend-go/models"
|
||||
)
|
||||
|
||||
// UsersService 用户管理服务
|
||||
type UsersService struct{}
|
||||
|
||||
func toUserDTO(user models.User) models.UserDTO {
|
||||
return models.UserDTO{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Name: user.Name,
|
||||
Email: user.Email,
|
||||
Role: user.Role,
|
||||
CreatedAt: user.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建用户
|
||||
func (s *UsersService) Create(dto models.CreateUserDTO) (*models.UserDTO, error) {
|
||||
username := strings.TrimSpace(dto.Username)
|
||||
|
||||
var existing models.User
|
||||
err := database.DB.Where("username = ?", username).First(&existing).Error
|
||||
if err == nil {
|
||||
return nil, errors.New("用户名已存在")
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("查询用户失败: %w", err)
|
||||
}
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(dto.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("密码加密失败: %w", err)
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
Username: username,
|
||||
Password: string(hashed),
|
||||
Name: dto.Name,
|
||||
Email: dto.Email,
|
||||
Role: dto.Role,
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&user).Error; err != nil {
|
||||
return nil, fmt.Errorf("创建用户失败: %w", err)
|
||||
}
|
||||
|
||||
dtoOut := toUserDTO(user)
|
||||
return &dtoOut, nil
|
||||
}
|
||||
|
||||
// FindAll 分页 + 按角色过滤
|
||||
func (s *UsersService) FindAll(page int, limit int, role string, search string) ([]models.UserDTO, int, int, error) {
|
||||
var users []models.User
|
||||
var total int64
|
||||
|
||||
db := database.DB.Model(&models.User{})
|
||||
if role != "" {
|
||||
db = db.Where("role = ?", role)
|
||||
}
|
||||
if search != "" {
|
||||
pattern := "%" + search + "%"
|
||||
db = db.Where("username LIKE ? OR name LIKE ? OR email LIKE ?", pattern, pattern, pattern)
|
||||
}
|
||||
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, 0, fmt.Errorf("统计用户数失败: %w", err)
|
||||
}
|
||||
|
||||
offset := (page - 1) * limit
|
||||
if err := db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&users).Error; err != nil {
|
||||
return nil, 0, 0, fmt.Errorf("查询用户列表失败: %w", err)
|
||||
}
|
||||
|
||||
result := make([]models.UserDTO, 0, len(users))
|
||||
for _, u := range users {
|
||||
result = append(result, toUserDTO(u))
|
||||
}
|
||||
|
||||
totalPages := (int(total) + limit - 1) / limit
|
||||
return result, int(total), totalPages, nil
|
||||
}
|
||||
|
||||
// FindAssignable 获取可分配的用户(admin + technician),用于售后工单分配
|
||||
func (s *UsersService) FindAssignable() ([]models.UserDTO, error) {
|
||||
var users []models.User
|
||||
if err := database.DB.Where("role IN ?", []string{"admin", "technician"}).
|
||||
Order("role DESC, created_at ASC").Find(&users).Error; err != nil {
|
||||
return nil, fmt.Errorf("查询可分配用户失败: %w", err)
|
||||
}
|
||||
result := make([]models.UserDTO, 0, len(users))
|
||||
for _, u := range users {
|
||||
result = append(result, toUserDTO(u))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Update 更新用户信息(不含密码)
|
||||
func (s *UsersService) Update(userId uint, dto models.UpdateUserDTO, currentUserId uint) (*models.UserDTO, error) {
|
||||
var user models.User
|
||||
if err := database.DB.First(&user, userId).Error; err != nil {
|
||||
return nil, errors.New("用户不存在")
|
||||
}
|
||||
|
||||
if dto.Name != "" {
|
||||
user.Name = dto.Name
|
||||
}
|
||||
if dto.Email != "" {
|
||||
user.Email = dto.Email
|
||||
}
|
||||
if dto.Role != "" {
|
||||
// 防止管理员把自己降级
|
||||
if user.ID == currentUserId && user.Role == "admin" && dto.Role != "admin" {
|
||||
return nil, errors.New("不能修改自己的管理员角色")
|
||||
}
|
||||
user.Role = dto.Role
|
||||
}
|
||||
|
||||
if err := database.DB.Save(&user).Error; err != nil {
|
||||
return nil, fmt.Errorf("更新用户失败: %w", err)
|
||||
}
|
||||
|
||||
dtoOut := toUserDTO(user)
|
||||
return &dtoOut, nil
|
||||
}
|
||||
|
||||
// ResetPassword 管理员重置用户密码
|
||||
func (s *UsersService) ResetPassword(userId uint, newPassword string) error {
|
||||
var user models.User
|
||||
if err := database.DB.First(&user, userId).Error; err != nil {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("密码加密失败: %w", err)
|
||||
}
|
||||
|
||||
user.Password = string(hashed)
|
||||
if err := database.DB.Save(&user).Error; err != nil {
|
||||
return fmt.Errorf("重置密码失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除用户
|
||||
func (s *UsersService) Delete(userId uint, currentUserId uint) error {
|
||||
if userId == currentUserId {
|
||||
return errors.New("不能删除自己")
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := database.DB.First(&user, userId).Error; err != nil {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
|
||||
if user.Role == "admin" {
|
||||
// 防止删除最后一个 admin
|
||||
var adminCount int64
|
||||
database.DB.Model(&models.User{}).Where("role = ?", "admin").Count(&adminCount)
|
||||
if adminCount <= 1 {
|
||||
return errors.New("不能删除最后一个管理员")
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.DB.Delete(&user).Error; err != nil {
|
||||
return fmt.Errorf("删除用户失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.beifan.cn/trace-system/backend-go/database"
|
||||
"git.beifan.cn/trace-system/backend-go/models"
|
||||
)
|
||||
|
||||
func TestUsersService_Create_Success(t *testing.T) {
|
||||
svc := UsersService{}
|
||||
dto, err := svc.Create(models.CreateUserDTO{
|
||||
Username: "users_create_ok",
|
||||
Password: "password123",
|
||||
Name: "新技术员",
|
||||
Email: "new@example.com",
|
||||
Role: "technician",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, dto)
|
||||
assert.Equal(t, "users_create_ok", dto.Username)
|
||||
assert.Equal(t, "technician", dto.Role)
|
||||
|
||||
database.DB.Unscoped().Where("username = ?", "users_create_ok").Delete(&models.User{})
|
||||
}
|
||||
|
||||
func TestUsersService_Create_DuplicateUsername(t *testing.T) {
|
||||
user := models.User{
|
||||
Username: "users_create_dup",
|
||||
Password: "x",
|
||||
Name: "existing",
|
||||
Role: "technician",
|
||||
}
|
||||
database.DB.Create(&user)
|
||||
defer database.DB.Unscoped().Delete(&user)
|
||||
|
||||
svc := UsersService{}
|
||||
_, err := svc.Create(models.CreateUserDTO{
|
||||
Username: "users_create_dup",
|
||||
Password: "password123",
|
||||
Name: "duplicate",
|
||||
Role: "technician",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "用户名已存在")
|
||||
}
|
||||
|
||||
func TestUsersService_Update_BlocksSelfDemotion(t *testing.T) {
|
||||
admin := models.User{
|
||||
Username: "users_self_admin",
|
||||
Password: "x",
|
||||
Name: "self admin",
|
||||
Role: "admin",
|
||||
}
|
||||
database.DB.Create(&admin)
|
||||
defer database.DB.Unscoped().Delete(&admin)
|
||||
|
||||
svc := UsersService{}
|
||||
_, err := svc.Update(admin.ID, models.UpdateUserDTO{
|
||||
Role: "technician",
|
||||
}, admin.ID)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "不能修改自己的管理员角色")
|
||||
}
|
||||
|
||||
func TestUsersService_Update_AllowsRoleChangeForOthers(t *testing.T) {
|
||||
currentAdmin := models.User{
|
||||
Username: "users_update_admin",
|
||||
Password: "x",
|
||||
Name: "current",
|
||||
Role: "admin",
|
||||
}
|
||||
target := models.User{
|
||||
Username: "users_update_target",
|
||||
Password: "x",
|
||||
Name: "target",
|
||||
Role: "technician",
|
||||
}
|
||||
database.DB.Create(¤tAdmin)
|
||||
database.DB.Create(&target)
|
||||
defer database.DB.Unscoped().Delete(¤tAdmin)
|
||||
defer database.DB.Unscoped().Delete(&target)
|
||||
|
||||
svc := UsersService{}
|
||||
updated, err := svc.Update(target.ID, models.UpdateUserDTO{
|
||||
Name: "新名字",
|
||||
Role: "admin",
|
||||
}, currentAdmin.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "新名字", updated.Name)
|
||||
assert.Equal(t, "admin", updated.Role)
|
||||
}
|
||||
|
||||
func TestUsersService_ResetPassword_ChangesHash(t *testing.T) {
|
||||
hashed, _ := bcrypt.GenerateFromPassword([]byte("oldpass"), bcrypt.DefaultCost)
|
||||
user := models.User{
|
||||
Username: "users_reset_pwd",
|
||||
Password: string(hashed),
|
||||
Name: "reset",
|
||||
Role: "technician",
|
||||
}
|
||||
database.DB.Create(&user)
|
||||
defer database.DB.Unscoped().Delete(&user)
|
||||
|
||||
svc := UsersService{}
|
||||
err := svc.ResetPassword(user.ID, "newpass456")
|
||||
assert.NoError(t, err)
|
||||
|
||||
var refreshed models.User
|
||||
database.DB.First(&refreshed, user.ID)
|
||||
assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(refreshed.Password), []byte("newpass456")))
|
||||
assert.Error(t, bcrypt.CompareHashAndPassword([]byte(refreshed.Password), []byte("oldpass")))
|
||||
}
|
||||
|
||||
func TestUsersService_Delete_BlocksSelf(t *testing.T) {
|
||||
user := models.User{
|
||||
Username: "users_delete_self",
|
||||
Password: "x",
|
||||
Name: "self",
|
||||
Role: "admin",
|
||||
}
|
||||
database.DB.Create(&user)
|
||||
defer database.DB.Unscoped().Delete(&user)
|
||||
|
||||
svc := UsersService{}
|
||||
err := svc.Delete(user.ID, user.ID)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "不能删除自己")
|
||||
}
|
||||
|
||||
func TestUsersService_Delete_BlocksLastAdmin(t *testing.T) {
|
||||
// 清理可能存在的其他 admin(来自 seed 或前面测试)
|
||||
database.DB.Unscoped().Where("role = ?", "admin").Delete(&models.User{})
|
||||
|
||||
last := models.User{
|
||||
Username: "users_last_admin",
|
||||
Password: "x",
|
||||
Name: "last",
|
||||
Role: "admin",
|
||||
}
|
||||
other := models.User{
|
||||
Username: "users_other_admin_actor",
|
||||
Password: "x",
|
||||
Name: "actor",
|
||||
Role: "admin",
|
||||
}
|
||||
database.DB.Create(&last)
|
||||
database.DB.Create(&other)
|
||||
|
||||
svc := UsersService{}
|
||||
// 当前调用者是 other(不是 last),但删除会让 last 变成最后一个 admin
|
||||
// 删 last 时计数会变 0,所以拒绝
|
||||
database.DB.Unscoped().Delete(&other)
|
||||
err := svc.Delete(last.ID, 999999)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "不能删除最后一个管理员")
|
||||
|
||||
database.DB.Unscoped().Delete(&last)
|
||||
}
|
||||
|
||||
func TestUsersService_FindAssignable_ReturnsAdminAndTechnician(t *testing.T) {
|
||||
a := models.User{Username: "assignable_admin", Password: "x", Name: "A", Role: "admin"}
|
||||
tech := models.User{Username: "assignable_tech", Password: "x", Name: "T", Role: "technician"}
|
||||
plain := models.User{Username: "assignable_user", Password: "x", Name: "U", Role: "user"}
|
||||
database.DB.Create(&a)
|
||||
database.DB.Create(&tech)
|
||||
database.DB.Create(&plain)
|
||||
defer database.DB.Unscoped().Delete(&a)
|
||||
defer database.DB.Unscoped().Delete(&tech)
|
||||
defer database.DB.Unscoped().Delete(&plain)
|
||||
|
||||
svc := UsersService{}
|
||||
users, err := svc.FindAssignable()
|
||||
assert.NoError(t, err)
|
||||
|
||||
usernames := make(map[string]string)
|
||||
for _, u := range users {
|
||||
usernames[u.Username] = u.Role
|
||||
}
|
||||
assert.Equal(t, "admin", usernames["assignable_admin"])
|
||||
assert.Equal(t, "technician", usernames["assignable_tech"])
|
||||
_, hasPlain := usernames["assignable_user"]
|
||||
assert.False(t, hasPlain, "plain user should not be assignable")
|
||||
}
|
||||
|
||||
func TestUsersService_FindAll_FilterByRole(t *testing.T) {
|
||||
tech1 := models.User{Username: "findall_tech1", Password: "x", Name: "T1", Role: "technician"}
|
||||
tech2 := models.User{Username: "findall_tech2", Password: "x", Name: "T2", Role: "technician"}
|
||||
user1 := models.User{Username: "findall_user1", Password: "x", Name: "U1", Role: "user"}
|
||||
database.DB.Create(&tech1)
|
||||
database.DB.Create(&tech2)
|
||||
database.DB.Create(&user1)
|
||||
defer database.DB.Unscoped().Delete(&tech1)
|
||||
defer database.DB.Unscoped().Delete(&tech2)
|
||||
defer database.DB.Unscoped().Delete(&user1)
|
||||
|
||||
svc := UsersService{}
|
||||
results, _, _, err := svc.FindAll(1, 50, "technician", "")
|
||||
assert.NoError(t, err)
|
||||
for _, u := range results {
|
||||
assert.Equal(t, "technician", u.Role)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user