From f80f2b43ce8d628b4b953302d323e044109e6d1f Mon Sep 17 00:00:00 2001 From: ZHENG XIAOYI Date: Mon, 2 Mar 2026 10:24:11 +0800 Subject: [PATCH] Re-migrate code --- AGENTS.md | 11 +- README.md | 30 ++- controllers/companies_controller.go | 202 +++++++++++------ routes/routes.go | 10 +- services/companies_service.go | 327 +++++++++++++++++++++++++--- tests/main_test.go | 2 + 6 files changed, 479 insertions(+), 103 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e71497d..f5a0dc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,8 +38,6 @@ swag init -g main.go # Alternative command ## Code Style Guidelines -## Code Style Guidelines - ### Project Structure ``` backend-go/ @@ -84,6 +82,12 @@ backend-go/ - **middleware/**: Authentication and authorization - **routes/**: Route registration, connect controllers to router +### API Surface (Current) +- **Auth**: `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/profile`, `PUT /api/auth/profile`, `POST /api/auth/change-password` +- **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` + ### Import Organization Standard imports followed by third-party imports, then project imports (sorted alphabetically): ```go @@ -175,6 +179,7 @@ logger.Fatal("致命错误", logger.Err(err)) - Always check for errors: `if err != nil { ... }` - Use Unscoped for permanent deletion: `database.DB.Unscoped().Delete(...)` - Test cleanup: `database.DB.Unscoped().Where("1 = 1").Delete(&models.User{})` +- During `AutoMigrate()`, default admin is seeded only when user table is empty ### Testing - Use `testify/assert` for assertions @@ -203,7 +208,7 @@ After modifying Swagger annotations, run `make swagger`. ### Configuration - Load with `config.LoadConfig()` - Access with `config.GetAppConfig()` -- Environment variables: `APP_SERVER_PORT`, `APP_DATABASE_DRIVER`, etc. +- Environment variables must use `APP_` prefix (e.g. `APP_SERVER_PORT`, `APP_DATABASE_DRIVER`, `APP_DATABASE_SQLITE_PATH`, `APP_JWT_SECRET`) - .env file for local development (not committed) ### Middleware diff --git a/README.md b/README.md index bf232fb..7b84a50 100644 --- a/README.md +++ b/README.md @@ -163,8 +163,8 @@ APP_JWT_SECRET=my-secret-key # 覆盖 jwt.secret **开发环境(.env)**: ```bash # 使用 SQLite -DATABASE_DRIVER=sqlite -DATABASE_PATH=./data/dev.sqlite +APP_DATABASE_DRIVER=sqlite +APP_DATABASE_SQLITE_PATH=./data/dev.sqlite ``` **生产环境(环境变量)**: @@ -194,6 +194,11 @@ curl -X POST http://localhost:3000/api/auth/login \ -d '{"username":"admin","password":"password123"}' ``` +首次启动且用户表为空时,系统会自动创建默认管理员账号(请在生产环境立即修改密码): + +- username: `admin` +- password: `Beifan@2026` + ## API 文档 项目使用 Swagger 生成交互式 API 文档。 @@ -230,6 +235,7 @@ swag init -g main.go | 方法 | 路径 | 描述 | 需要认证 | | ---- | --------------------------- | ----------------------- | -------- | | POST | `/api/auth/login` | 用户登录,返回 JWT 令牌 | 否 | +| POST | `/api/auth/logout` | 用户登出 | 是 | | GET | `/api/auth/profile` | 获取当前用户信息 | 是 | | PUT | `/api/auth/profile` | 更新用户信息 | 是 | | POST | `/api/auth/change-password` | 修改密码 | 是 | @@ -243,6 +249,7 @@ swag init -g main.go | 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` | 吊销序列号 | 是 | 管理员 | @@ -250,9 +257,14 @@ swag init -g main.go | 方法 | 路径 | 描述 | 需要认证 | 角色 | | ------ | ----------------------------- | ------------ | -------- | ------ | -| GET | `/api/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` | 删除企业 | 是 | 管理员 | ### 员工赋码 @@ -261,10 +273,11 @@ swag init -g main.go | ---- | -------------------------------------- | ------------------ | -------- | ------ | | POST | `/api/employee-serials/generate` | 生成员工序列号 | 是 | 管理员 | | GET | `/api/employee-serials` | 获取员工序列号列表 | 是 | 任何 | -| GET | `/api/employee-serials/:serial/query` | 查询员工序列号信息 | 否 | 任何 | -| POST | `/api/employee-serials/:serial/qrcode` | 生成员工二维码 | 是 | 任何 | -| PUT | `/api/employee-serials/:serial` | 更新员工序列号信息 | 是 | 管理员 | -| POST | `/api/employee-serials/:serial/revoke` | 吊销员工序列号 | 是 | 管理员 | +| GET | `/api/employee-serials/:serialNumber/query` | 查询员工序列号信息 | 否 | 任何 | +| POST | `/api/employee-serials/:serialNumber/qrcode` | 生成员工二维码 | 是 | 任何 | +| PATCH | `/api/employee-serials/:serialNumber` | 更新员工序列号信息 | 是 | 管理员 | +| PUT | `/api/employee-serials/:serialNumber` | 更新员工序列号信息 | 是 | 管理员 | +| POST | `/api/employee-serials/:serialNumber/revoke` | 吊销员工序列号 | 是 | 管理员 | **员工序列号特点**: - 无有效期限制(与企业赋码不同) @@ -296,10 +309,11 @@ go tool cover -html=coverage.out ### 当前测试覆盖 -- **services/**: 包含 AuthService、SerialsService 和 EmployeeSerialsService 的完整单元测试 +- **services/**: 包含 AuthService、SerialsService、EmployeeSerialsService 和 CompaniesService 的完整单元测试 - 用户认证测试(登录、获取用户信息、修改密码、更新资料) - 序列号管理测试(生成、查询、更新、吊销、分页列表) - 员工赋码测试(生成、查询、更新、吊销、二维码生成) + - 企业统计测试(统计概览) - **tests/**: 集成测试(健康检查、登录流程) ## 代码检查 diff --git a/controllers/companies_controller.go b/controllers/companies_controller.go index 07253ca..0d6ef0d 100644 --- a/controllers/companies_controller.go +++ b/controllers/companies_controller.go @@ -2,6 +2,7 @@ package controllers import ( "net/http" + "net/url" "strconv" "github.com/gin-gonic/gin" @@ -41,15 +42,25 @@ func (c *CompaniesController) FindAll(ctx *gin.Context) { companies, total, totalPages, err := c.companiesService.FindAll(page, limit, search) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{ - "message": err.Error(), - }) + ErrorResponse(ctx, http.StatusInternalServerError, err.Error()) return } - ctx.JSON(http.StatusOK, gin.H{ - "message": "获取企业列表成功", - "data": companies, + items := make([]gin.H, 0, len(companies)) + for _, company := range companies { + items = append(items, gin.H{ + "companyName": company.CompanyName, + "firstCreated": company.CreatedAt, + "lastCreated": company.UpdatedAt, + "status": map[bool]string{ + true: "active", + false: "disabled", + }[company.IsActive], + }) + } + + SuccessResponse(ctx, "获取企业列表成功", gin.H{ + "data": items, "pagination": gin.H{ "page": page, "limit": limit, @@ -59,6 +70,38 @@ func (c *CompaniesController) FindAll(ctx *gin.Context) { }) } +// FindOne 获取企业详情 +// @Summary 获取企业详情 +// @Description 获取指定企业详情(含序列号分页) +// @Tags 企业管理 +// @Produce json +// @Security BearerAuth +// @Param companyName path string true "企业名称" +// @Param page query int false "页码" +// @Param limit query int false "每页数量" +// @Success 200 {object} models.DataResponse +// @Failure 401 {object} models.ErrorResponse +// @Failure 404 {object} models.ErrorResponse +// @Failure 500 {object} models.ErrorResponse +// @Router /companies/{companyName} [get] +func (c *CompaniesController) FindOne(ctx *gin.Context) { + companyName, _ := url.PathUnescape(ctx.Param("companyName")) + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "20")) + + data, err := c.companiesService.FindOne(companyName, page, limit) + if err != nil { + if err.Error() == "企业不存在" { + ErrorResponse(ctx, http.StatusNotFound, err.Error()) + return + } + ErrorResponse(ctx, http.StatusInternalServerError, err.Error()) + return + } + + SuccessResponse(ctx, "获取企业详情成功", data) +} + // Create 创建企业 // @Summary 创建企业 // @Description 创建新的企业 @@ -77,24 +120,16 @@ func (c *CompaniesController) Create(ctx *gin.Context) { var companyData struct { CompanyName string `json:"companyName" validate:"required"` } - if err := ctx.ShouldBindJSON(&companyData); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{ - "message": "无效的请求数据", - "error": err.Error(), - }) + if !BindJSON(ctx, &companyData) { return } company, err := c.companiesService.Create(companyData.CompanyName) if err != nil { if err.Error() == "企业名称已存在" { - ctx.JSON(http.StatusConflict, gin.H{ - "message": err.Error(), - }) + ErrorResponse(ctx, http.StatusConflict, err.Error()) } else { - ctx.JSON(http.StatusInternalServerError, gin.H{ - "message": err.Error(), - }) + ErrorResponse(ctx, http.StatusInternalServerError, err.Error()) } return } @@ -122,47 +157,41 @@ func (c *CompaniesController) Create(ctx *gin.Context) { // @Failure 500 {object} models.ErrorResponse // @Router /companies/{companyName} [put] func (c *CompaniesController) Update(ctx *gin.Context) { - companyName := ctx.Param("companyName") + companyName, _ := url.PathUnescape(ctx.Param("companyName")) var companyData struct { - CompanyName string `json:"companyName"` - IsActive bool `json:"isActive"` + CompanyName string `json:"companyName"` + NewCompanyName string `json:"newCompanyName"` + IsActive *bool `json:"isActive"` } - if err := ctx.ShouldBindJSON(&companyData); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{ - "message": "无效的请求数据", - "error": err.Error(), - }) + if !BindJSON(ctx, &companyData) { return } - company, err := c.companiesService.Update(companyName, companyData.CompanyName, companyData.IsActive) + newName := companyData.NewCompanyName + if newName == "" { + newName = companyData.CompanyName + } + + company, err := c.companiesService.Update(companyName, newName, companyData.IsActive) if err != nil { - if err.Error() == "企业不存在" { - ctx.JSON(http.StatusNotFound, gin.H{ - "message": err.Error(), - }) - } else if err.Error() == "企业名称已存在" { - ctx.JSON(http.StatusConflict, gin.H{ - "message": err.Error(), - }) - } else { - ctx.JSON(http.StatusInternalServerError, gin.H{ - "message": err.Error(), - }) + switch err.Error() { + case "企业不存在": + ErrorResponse(ctx, http.StatusNotFound, err.Error()) + case "企业名称已存在": + ErrorResponse(ctx, http.StatusConflict, err.Error()) + default: + ErrorResponse(ctx, http.StatusInternalServerError, err.Error()) } return } - ctx.JSON(http.StatusOK, gin.H{ - "message": "企业信息更新成功", - "company": company, - }) + SuccessResponse(ctx, "企业信息更新成功", gin.H{"company": company}) } // Delete 删除企业 // @Summary 删除企业 -// @Description 删除企业 +// @Description 删除企业及其关联序列号 // @Tags 企业管理 // @Produce json // @Security BearerAuth @@ -174,34 +203,87 @@ func (c *CompaniesController) Update(ctx *gin.Context) { // @Failure 500 {object} models.ErrorResponse // @Router /companies/{companyName} [delete] func (c *CompaniesController) Delete(ctx *gin.Context) { - companyName := ctx.Param("companyName") + companyName, _ := url.PathUnescape(ctx.Param("companyName")) err := c.companiesService.Delete(companyName) if err != nil { if err.Error() == "企业不存在" { - ctx.JSON(http.StatusNotFound, gin.H{ - "message": err.Error(), - }) - } else if err.Error() == "企业下还有序列号,无法删除" { - ctx.JSON(http.StatusBadRequest, gin.H{ - "message": err.Error(), - }) + ErrorResponse(ctx, http.StatusNotFound, err.Error()) } else { - ctx.JSON(http.StatusInternalServerError, gin.H{ - "message": err.Error(), - }) + ErrorResponse(ctx, http.StatusInternalServerError, err.Error()) } return } - ctx.JSON(http.StatusOK, gin.H{ - "message": "企业删除成功", + SuccessResponse(ctx, "企业已完全删除,所有相关序列号已删除") +} + +// DeleteSerial 删除企业下指定序列号 +// @Summary 删除企业序列号 +// @Description 删除指定企业下的序列号 +// @Tags 企业管理 +// @Produce json +// @Security BearerAuth +// @Param companyName path string true "企业名称" +// @Param serialNumber path string true "序列号" +// @Success 200 {object} models.BaseResponse +// @Failure 401 {object} models.ErrorResponse +// @Failure 404 {object} models.ErrorResponse +// @Failure 500 {object} models.ErrorResponse +// @Router /companies/{companyName}/serials/{serialNumber} [delete] +func (c *CompaniesController) DeleteSerial(ctx *gin.Context) { + companyName, _ := url.PathUnescape(ctx.Param("companyName")) + serialNumber := ctx.Param("serialNumber") + + err := c.companiesService.DeleteSerial(companyName, serialNumber) + if err != nil { + if err.Error() == "序列号不存在或不属于该企业" { + ErrorResponse(ctx, http.StatusNotFound, err.Error()) + } else { + ErrorResponse(ctx, http.StatusInternalServerError, err.Error()) + } + return + } + + SuccessResponse(ctx, "序列号已成功删除", gin.H{ + "serialNumber": serialNumber, + "companyName": companyName, + }) +} + +// Revoke 吊销企业 +// @Summary 吊销企业 +// @Description 吊销企业及其关联序列号 +// @Tags 企业管理 +// @Produce json +// @Security BearerAuth +// @Param companyName path string true "企业名称" +// @Success 200 {object} models.BaseResponse +// @Failure 401 {object} models.ErrorResponse +// @Failure 404 {object} models.ErrorResponse +// @Failure 500 {object} models.ErrorResponse +// @Router /companies/{companyName}/revoke [post] +func (c *CompaniesController) Revoke(ctx *gin.Context) { + companyName, _ := url.PathUnescape(ctx.Param("companyName")) + + err := c.companiesService.Revoke(companyName) + if err != nil { + if err.Error() == "企业不存在" { + ErrorResponse(ctx, http.StatusNotFound, err.Error()) + } else { + ErrorResponse(ctx, http.StatusInternalServerError, err.Error()) + } + return + } + + SuccessResponse(ctx, "企业已吊销,所有序列号已失效", gin.H{ + "companyName": companyName, }) } // StatsOverview 获取企业统计概览 // @Summary 获取企业统计概览 -// @Description 获取企业、企业赋码、员工赋码的统计数据 +// @Description 获取企业、序列号统计数据 // @Tags 企业管理 // @Produce json // @Security BearerAuth @@ -210,13 +292,11 @@ func (c *CompaniesController) Delete(ctx *gin.Context) { // @Failure 500 {object} models.ErrorResponse // @Router /companies/stats/overview [get] func (c *CompaniesController) StatsOverview(ctx *gin.Context) { - stats, err := c.companiesService.GetStatsOverview() + stats, err := c.companiesService.GetStats() if err != nil { ErrorResponse(ctx, http.StatusInternalServerError, err.Error()) return } - SuccessResponse(ctx, "获取企业统计概览成功", gin.H{ - "overview": stats, - }) + SuccessResponse(ctx, "获取统计数据成功", stats) } diff --git a/routes/routes.go b/routes/routes.go index 00b66d3..83702d9 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -35,6 +35,7 @@ func SetupAPIRoutes(r *gin.RouterGroup) { serialsController := controllers.NewSerialsController() serialsRoutes := r.Group("/serials") { + serialsRoutes.PATCH("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), serialsController.Update) serialsRoutes.POST("/generate", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), serialsController.Generate) serialsRoutes.POST("/generate-with-prefix", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), serialsController.GenerateWithPrefix) serialsRoutes.POST("/:serialNumber/qrcode", middleware.JWTAuthMiddleware(), serialsController.GenerateQRCode) @@ -48,10 +49,14 @@ func SetupAPIRoutes(r *gin.RouterGroup) { companiesController := controllers.NewCompaniesController() companiesRoutes := r.Group("/companies") { - companiesRoutes.GET("/stats/overview", middleware.JWTAuthMiddleware(), companiesController.StatsOverview) - companiesRoutes.GET("/", middleware.JWTAuthMiddleware(), companiesController.FindAll) + companiesRoutes.GET("/stats/overview", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), companiesController.StatsOverview) + companiesRoutes.GET("/", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), companiesController.FindAll) + companiesRoutes.GET("/:companyName", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), companiesController.FindOne) companiesRoutes.POST("/", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), companiesController.Create) + companiesRoutes.PATCH("/:companyName", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), companiesController.Update) companiesRoutes.PUT("/:companyName", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), companiesController.Update) + companiesRoutes.DELETE("/:companyName/serials/:serialNumber", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), companiesController.DeleteSerial) + companiesRoutes.POST("/:companyName/revoke", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), companiesController.Revoke) companiesRoutes.DELETE("/:companyName", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), companiesController.Delete) } @@ -59,6 +64,7 @@ func SetupAPIRoutes(r *gin.RouterGroup) { employeeSerialsController := controllers.NewEmployeeSerialsController() employeeSerialsRoutes := r.Group("/employee-serials") { + employeeSerialsRoutes.PATCH("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), employeeSerialsController.Update) employeeSerialsRoutes.POST("/generate", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), employeeSerialsController.Generate) employeeSerialsRoutes.POST("/:serialNumber/qrcode", middleware.JWTAuthMiddleware(), employeeSerialsController.GenerateQRCode) employeeSerialsRoutes.GET("/:serialNumber/query", employeeSerialsController.Query) diff --git a/services/companies_service.go b/services/companies_service.go index 8b09494..3f58c70 100644 --- a/services/companies_service.go +++ b/services/companies_service.go @@ -2,6 +2,10 @@ package services import ( "errors" + "fmt" + "time" + + "gorm.io/gorm" "git.beifan.cn/trace-system/backend-go/database" "git.beifan.cn/trace-system/backend-go/models" @@ -15,31 +19,152 @@ func (s *CompaniesService) FindAll(page int, limit int, search string) ([]models var companies []models.Company var total int64 - offset := (page - 1) * limit - db := database.DB + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 20 + } + + offset := (page - 1) * limit + db := database.DB.Model(&models.Company{}) - // 搜索条件 if search != "" { db = db.Where("company_name LIKE ?", "%"+search+"%") } - // 获取总数 - db.Count(&total) + if err := db.Count(&total).Error; err != nil { + return nil, 0, 0, errors.New("查询企业总数失败") + } - // 分页查询 - result := db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&companies) + result := db.Order("updated_at DESC").Offset(offset).Limit(limit).Find(&companies) if result.Error != nil { return nil, 0, 0, errors.New("查询企业列表失败") } - totalPages := (int(total) + limit - 1) / limit + totalPages := 0 + if total > 0 { + totalPages = (int(total) + limit - 1) / limit + } return companies, int(total), totalPages, nil } +// FindOne 获取单个企业详情(含分页序列号) +func (s *CompaniesService) FindOne(companyName string, page int, limit int) (map[string]any, error) { + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 20 + } + + var company models.Company + if err := database.DB.Where("company_name = ?", companyName).First(&company).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("企业不存在") + } + return nil, errors.New("查询企业失败") + } + + var allSerials []models.Serial + if err := database.DB.Preload("User").Where("company_name = ?", companyName).Order("created_at DESC").Find(&allSerials).Error; err != nil { + return nil, errors.New("查询企业序列号失败") + } + + now := time.Now() + serialCount := len(allSerials) + activeCount := 0 + disabledCount := 0 + expiredCount := 0 + + for _, serial := range allSerials { + if !serial.IsActive { + disabledCount++ + continue + } + if serial.ValidUntil != nil && serial.ValidUntil.Before(now) { + expiredCount++ + continue + } + activeCount++ + } + + offset := (page - 1) * limit + end := offset + limit + if offset > len(allSerials) { + offset = len(allSerials) + } + if end > len(allSerials) { + end = len(allSerials) + } + paginatedSerials := allSerials[offset:end] + + serialItems := make([]map[string]any, 0, len(paginatedSerials)) + for _, serial := range paginatedSerials { + createdBy := "" + if serial.User != nil { + createdBy = serial.User.Name + } + serialItems = append(serialItems, map[string]any{ + "serialNumber": serial.SerialNumber, + "validUntil": serial.ValidUntil, + "isActive": serial.IsActive, + "createdAt": serial.CreatedAt, + "createdBy": createdBy, + }) + } + + monthlyStatsMap := map[string]int{} + for i := 11; i >= 0; i-- { + date := time.Date(now.Year(), now.Month()-time.Month(i), 1, 0, 0, 0, 0, time.Local) + monthKey := date.Format("2006-01") + monthlyStatsMap[monthKey] = 0 + } + for _, serial := range allSerials { + monthKey := serial.CreatedAt.Format("2006-01") + if _, ok := monthlyStatsMap[monthKey]; ok { + monthlyStatsMap[monthKey]++ + } + } + + monthlyStats := make([]map[string]any, 0) + for i := 11; i >= 0; i-- { + date := time.Date(now.Year(), now.Month()-time.Month(i), 1, 0, 0, 0, 0, time.Local) + monthKey := date.Format("2006-01") + count := monthlyStatsMap[monthKey] + if count > 0 { + monthlyStats = append(monthlyStats, map[string]any{"month": monthKey, "count": count}) + } + } + + return map[string]any{ + "companyName": company.CompanyName, + "serialCount": serialCount, + "activeCount": activeCount, + "disabledCount": disabledCount, + "expiredCount": expiredCount, + "firstCreated": company.CreatedAt, + "lastCreated": company.UpdatedAt, + "status": map[bool]string{true: "active", false: "disabled"}[company.IsActive], + "serials": serialItems, + "monthlyStats": monthlyStats, + "pagination": map[string]any{ + "page": page, + "limit": limit, + "total": serialCount, + "totalPages": func() int { + if serialCount == 0 { + return 0 + } + return (serialCount + limit - 1) / limit + }(), + }, + }, nil +} + // Create 创建企业 func (s *CompaniesService) Create(companyName string) (*models.Company, error) { - // 检查企业是否已存在 var existingCompany models.Company result := database.DB.Where("company_name = ?", companyName).First(&existingCompany) if result.Error == nil { @@ -60,57 +185,201 @@ func (s *CompaniesService) Create(companyName string) (*models.Company, error) { } // Update 更新企业信息 -func (s *CompaniesService) Update(companyName string, newCompanyName string, isActive bool) (*models.Company, error) { +func (s *CompaniesService) Update(companyName string, newCompanyName string, isActive *bool) (*models.Company, error) { var company models.Company result := database.DB.Where("company_name = ?", companyName).First(&company) if result.Error != nil { return nil, errors.New("企业不存在") } - // 如果企业名称已变更,检查新名称是否已存在 + if newCompanyName == "" { + newCompanyName = companyName + } + if newCompanyName != companyName { var existingCompany models.Company checkResult := database.DB.Where("company_name = ?", newCompanyName).First(&existingCompany) if checkResult.Error == nil { return nil, errors.New("企业名称已存在") } - - company.CompanyName = newCompanyName } - company.IsActive = isActive + err := database.DB.Transaction(func(tx *gorm.DB) error { + if newCompanyName != companyName { + if err := tx.Model(&models.Serial{}).Where("company_name = ?", companyName).Update("company_name", newCompanyName).Error; err != nil { + return fmt.Errorf("更新企业赋码企业名称失败: %w", err) + } + if err := tx.Model(&models.EmployeeSerial{}).Where("company_name = ?", companyName).Update("company_name", newCompanyName).Error; err != nil { + return fmt.Errorf("更新员工赋码企业名称失败: %w", err) + } + company.CompanyName = newCompanyName + } - result = database.DB.Save(&company) - if result.Error != nil { - return nil, errors.New("更新企业信息失败") + if isActive != nil { + company.IsActive = *isActive + } + + if err := tx.Save(&company).Error; err != nil { + return fmt.Errorf("更新企业信息失败: %w", err) + } + + return nil + }) + if err != nil { + return nil, errors.New(err.Error()) } return &company, nil } -// Delete 删除企业 +// Delete 删除企业(同时删除关联序列号) func (s *CompaniesService) Delete(companyName string) error { var company models.Company - result := database.DB.Where("company_name = ?", companyName).First(&company) - if result.Error != nil { + if err := database.DB.Where("company_name = ?", companyName).First(&company).Error; err != nil { return errors.New("企业不存在") } - // 检查企业是否有关联的序列号 - var serialCount int64 - database.DB.Model(&models.Serial{}).Where("company_name = ?", companyName).Count(&serialCount) - if serialCount > 0 { - return errors.New("企业下还有序列号,无法删除") - } - - result = database.DB.Delete(&company) - if result.Error != nil { + if err := database.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("company_name = ?", companyName).Delete(&models.Serial{}).Error; err != nil { + return err + } + if err := tx.Where("company_name = ?", companyName).Delete(&models.EmployeeSerial{}).Error; err != nil { + return err + } + if err := tx.Delete(&company).Error; err != nil { + return err + } + return nil + }); err != nil { return errors.New("删除企业失败") } return nil } +// DeleteSerial 删除企业下指定企业赋码序列号 +func (s *CompaniesService) DeleteSerial(companyName string, serialNumber string) error { + var serial models.Serial + err := database.DB.Where("serial_number = ? AND company_name = ?", serialNumber, companyName).First(&serial).Error + if err != nil { + return errors.New("序列号不存在或不属于该企业") + } + + if err := database.DB.Delete(&serial).Error; err != nil { + return errors.New("删除序列号失败") + } + + return nil +} + +// Revoke 吊销企业(吊销所有企业赋码与员工赋码) +func (s *CompaniesService) Revoke(companyName string) error { + var company models.Company + if err := database.DB.Where("company_name = ?", companyName).First(&company).Error; err != nil { + return errors.New("企业不存在") + } + + if err := database.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&models.Serial{}).Where("company_name = ?", companyName).Update("is_active", false).Error; err != nil { + return err + } + if err := tx.Model(&models.EmployeeSerial{}).Where("company_name = ?", companyName).Update("is_active", false).Error; err != nil { + return err + } + if err := tx.Model(&company).Update("is_active", false).Error; err != nil { + return err + } + return nil + }); err != nil { + return errors.New("吊销企业失败") + } + + return nil +} + +// GetStats 获取企业统计(兼容 Node 返回结构) +func (s *CompaniesService) GetStats() (map[string]any, error) { + now := time.Now() + + var companies []models.Company + if err := database.DB.Order("updated_at DESC").Find(&companies).Error; err != nil { + return nil, errors.New("查询企业统计失败") + } + + var serials []models.Serial + if err := database.DB.Order("created_at DESC").Find(&serials).Error; err != nil { + return nil, errors.New("查询序列号统计失败") + } + + companyCount := len(companies) + serialCount := len(serials) + activeCount := 0 + for _, serial := range serials { + if serial.IsActive && (serial.ValidUntil == nil || serial.ValidUntil.After(now)) { + activeCount++ + } + } + inactiveCount := serialCount - activeCount + + monthlyItems := make([]map[string]any, 0) + for i := 11; i >= 0; i-- { + date := time.Date(now.Year(), now.Month()-time.Month(i), 1, 0, 0, 0, 0, time.Local) + monthStr := date.Format("2006-01") + monthSerialCount := 0 + companySet := map[string]bool{} + for _, serial := range serials { + if serial.CreatedAt.Year() == date.Year() && serial.CreatedAt.Month() == date.Month() { + monthSerialCount++ + companySet[serial.CompanyName] = true + } + } + if monthSerialCount > 0 { + monthlyItems = append(monthlyItems, map[string]any{ + "month": monthStr, + "company_count": len(companySet), + "serial_count": monthSerialCount, + }) + } + } + + recentCompanies := make([]map[string]any, 0) + for i, company := range companies { + if i >= 10 { + break + } + recentCompanies = append(recentCompanies, map[string]any{ + "companyName": company.CompanyName, + "lastCreated": company.UpdatedAt, + "status": map[bool]string{true: "active", false: "disabled"}[company.IsActive], + }) + } + + recentSerials := make([]map[string]any, 0) + for i, serial := range serials { + if i >= 10 { + break + } + recentSerials = append(recentSerials, map[string]any{ + "serialNumber": serial.SerialNumber, + "companyName": serial.CompanyName, + "isActive": serial.IsActive, + "createdAt": serial.CreatedAt, + }) + } + + return map[string]any{ + "overview": map[string]any{ + "totalCompanies": companyCount, + "totalSerials": serialCount, + "activeSerials": activeCount, + "inactiveSerials": inactiveCount, + }, + "monthlyStats": monthlyItems, + "recentCompanies": recentCompanies, + "recentSerials": recentSerials, + }, nil +} + // GetStatsOverview 获取企业统计概览 func (s *CompaniesService) GetStatsOverview() (*models.CompanyStatsOverviewDTO, error) { stats := &models.CompanyStatsOverviewDTO{} diff --git a/tests/main_test.go b/tests/main_test.go index 751cbb8..7c186ed 100644 --- a/tests/main_test.go +++ b/tests/main_test.go @@ -60,6 +60,8 @@ func cleanupTestData() { } func createTestUsers() { + database.DB.Unscoped().Where("1 = 1").Delete(&models.User{}) + testPassword, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) testUsers := []models.User{