This commit is contained in:
zhouwentao 2025-12-19 20:31:58 +08:00
parent 94486dd149
commit 93bfb590d6
10 changed files with 192 additions and 44 deletions

View File

@ -1,6 +1,7 @@
# 代码库函数概览
## server/common
- `constants`: 存放全局常量,如 `RedisTokenPrefix`, `TokenHeader`, `StateActive` 等。
- `Response`: 统一的HTTP响应结构体 `{Code, Message, Data}`
- `Success(c *gin.Context, data interface{})`: 发送成功响应。
- `Error(c *gin.Context, code int, msg string)`: 发送错误响应。
@ -25,7 +26,8 @@
- `YxVolunteerRecordController`: 志愿明细控制器。
## server/modules/user
- `UserScoreService`: 用户成绩服务。
- `GetActiveByID(userID string)`: 获取用户当前激活的成绩,返回 `UserScoreVO`
- `SaveUserScore(req *dto.SaveScoreRequest)`: 保存用户成绩,处理旧记录状态更新及 DTO 转换。
- `UserScoreVO`: 用户成绩视图对象,包含基础信息、选课列表及子专业成绩映射。
- `UserScoreService`: 用户成绩服务。
- `GetActiveByID(userID string)`: 获取用户当前激活的成绩,返回 `UserScoreVO`
- `ListByUser(userID string, page, size int)`: 获取用户的所有成绩分页列表。
- `SaveUserScore(req *dto.SaveScoreRequest)`: 保存用户成绩,处理旧记录状态更新及 DTO 转换。
- `UserScoreVO`: 用户成绩视图对象,包含基础信息、选课列表及子专业成绩映射。

View File

@ -55,4 +55,22 @@
- **修改结果**:
- 定义了 `UserScoreVO` 结构体,其字段设计参考了 `SaveScoreRequest`
- 在 `UserScoreService` 中实现了 `convertEntityToVo` 私有方法,处理了逗号分隔字符串到切片的转换,以及具体分数字段到 Map 的映射。
- 更新 `GetActiveByID` 返回 `UserScoreVO` 对象。
- 更新 `GetActiveByID` 返回 `UserScoreVO` 对象。
### [任务执行] 增加用户成绩分页列表接口
- **操作目标**: 在 `UserScoreController``UserScoreService` 中增加获取当前用户所有成绩的分页列表接口。
- **影响范围**: `server/modules/user/service/user_score_service.go`, `server/modules/user/controller/user_score_controller.go`
- **修改前记录**: 仅支持获取当前激活的单条成绩。
- **修改结果**:
- 在 `UserScoreService` 中实现了 `ListByUser` 方法,支持分页查询并返回 VO 列表。
- 在 `UserScoreController` 中增加了 `List` 方法,并注册了 `GET /user/score/list` 路由。
### [任务执行] 统一管理 Redis Key 及全局常量
- **操作目标**: 创建专门存放 Redis Key 和业务常量的地方,并重构相关代码以引用这些常量。
- **影响范围**: `server/common/constants.go`, `server/modules/system/service/sys_user_service.go`, `server/middleware/auth.go`, `server/common/context.go`
- **修改前记录**: 常量散落在各个业务文件和中间件中,存在硬编码和重复定义。
- **修改结果**:
- 创建了 `server/common/constants.go`,集中管理 Redis 前缀、Token 请求头、业务状态码等。
- 重构了 `SysUserService`,使用 `common.RedisTokenPrefix``common.RedisTokenExpire`
- 重构了 `AuthMiddleware`,使用 `common.TokenHeader``common.ContextUserKey`
- 清理了 `common/context.go` 中的重复定义。

View File

@ -7,6 +7,7 @@
- `database.go`: 数据库连接配置。
- `redis.go`: Redis连接配置。
- `common/`: 通用工具包。
- `constants.go`: 全局常量定义Redis Key、业务状态等
- `response.go`: 统一HTTP响应结构。
- `context.go`: 上下文辅助函数。
- `logger.go`: 日志工具。

View File

@ -0,0 +1,40 @@
package common
import "time"
// Redis 相关常量
const (
// RedisTokenPrefix Redis中Token前缀
RedisTokenPrefix = "login:token:"
// RedisTokenExpire Token过期时间
RedisTokenExpire = 24 * time.Hour
// RedisUserScorePrefix Redis中用户成绩前缀
RedisUserScorePrefix = "user:score:"
// RedisUserScoreExpire 用户成绩过期时间
RedisUserScoreExpire = 8 * time.Hour
)
// HTTP/Context 相关常量
const (
// ContextUserKey 上下文中存储用户信息的key
ContextUserKey = "loginUser"
// TokenHeader 请求头中Token的key
// "X-Access-Token"
TokenHeader = "Authorization"
// HeaderTokenPrefix Token前缀 (如有需要)
HeaderTokenPrefix = ""
)
// 业务状态常量
const (
StateActive = "1" // 使用中
StateInactive = "0" // 未使用/已删除
StateHistory = "2" // 历史记录
)
// 数据类型常量
const (
TypeNormal = "1" // 普通类
TypeArt = "2" // 艺术类
)

View File

@ -7,8 +7,6 @@ import (
"github.com/gin-gonic/gin"
)
const ContextUserKey = "loginUser"
// GetLoginUser 从上下文获取当前登录用户
// 在Controller中使用: user := common.GetLoginUser(c)
func GetLoginUser(c *gin.Context) *entity.LoginUser {

View File

@ -10,15 +10,6 @@ import (
"github.com/gin-gonic/gin"
)
const (
// ContextUserKey 上下文中存储用户信息的key
ContextUserKey = "loginUser"
// TokenHeader 请求头中Token的key
TokenHeader = "Authorization"
// TokenPrefix Token前缀
TokenPrefix = "Bearer "
)
// 白名单路径 (不需要登录即可访问)
var whiteList = []string{
"/api/sys/auth/login",
@ -46,29 +37,28 @@ func AuthMiddleware() gin.HandlerFunc {
}
// 获取Token
token := c.GetHeader(TokenHeader)
token := c.GetHeader(common.TokenHeader)
if token == "" {
common.Error(c, 401, "未登录")
c.Abort()
return
}
// 去除Bearer前缀
token = strings.TrimPrefix(token, TokenPrefix)
// if strings.HasPrefix(token, TokenPrefix) {
// token = token[len(TokenPrefix):]
// }
// 如果有前缀则处理前缀
if common.HeaderTokenPrefix != "" && strings.HasPrefix(token, common.HeaderTokenPrefix) {
token = token[len(common.HeaderTokenPrefix):]
}
// 验证Token并获取用户信息
loginUser, err := userService.GetLoginUser(token)
if err != nil {
common.Error(c, 401, err.Error())
common.Error(c, 401, "登录已失效,请重新登录")
c.Abort()
return
}
// 将用户信息存入上下文
c.Set(ContextUserKey, loginUser)
// 存入上下文
c.Set(common.ContextUserKey, loginUser)
c.Next()
}

View File

@ -17,11 +17,6 @@ import (
"github.com/google/uuid"
)
const (
TokenPrefix = "login:token:" // Redis中Token前缀
TokenExpire = 24 * time.Hour // Token过期时间
)
type SysUserService struct {
mapper *mapper.SysUserMapper
}
@ -117,13 +112,13 @@ func (s *SysUserService) SysLogin(username, password string) (*entity.LoginUser,
// Logout 用户登出
func (s *SysUserService) Logout(token string) error {
ctx := context.Background()
return config.RDB.Del(ctx, TokenPrefix+token).Err()
return config.RDB.Del(ctx, common.RedisTokenPrefix+token).Err()
}
// GetLoginUser 根据Token获取登录用户信息
func (s *SysUserService) GetLoginUser(token string) (*entity.LoginUser, error) {
ctx := context.Background()
data, err := config.RDB.Get(ctx, TokenPrefix+token).Result()
data, err := config.RDB.Get(ctx, common.RedisTokenPrefix+token).Result()
if err != nil {
return nil, errors.New("未登录或登录已过期")
}
@ -134,7 +129,7 @@ func (s *SysUserService) GetLoginUser(token string) (*entity.LoginUser, error) {
}
// 刷新过期时间
config.RDB.Expire(ctx, TokenPrefix+token, TokenExpire)
config.RDB.Expire(ctx, common.RedisTokenPrefix+token, common.RedisTokenExpire)
return &loginUser, nil
}
@ -142,7 +137,7 @@ func (s *SysUserService) GetLoginUser(token string) (*entity.LoginUser, error) {
// RefreshToken 刷新Token过期时间
func (s *SysUserService) RefreshToken(token string) error {
ctx := context.Background()
return config.RDB.Expire(ctx, TokenPrefix+token, TokenExpire).Err()
return config.RDB.Expire(ctx, common.RedisTokenPrefix+token, common.RedisTokenExpire).Err()
}
// 保存登录用户到Redis
@ -152,7 +147,7 @@ func (s *SysUserService) saveLoginUser(token string, user *entity.LoginUser) err
if err != nil {
return err
}
return config.RDB.Set(ctx, TokenPrefix+token, data, TokenExpire).Err()
return config.RDB.Set(ctx, common.RedisTokenPrefix+token, data, common.RedisTokenExpire).Err()
}
// 生成Token

View File

@ -5,6 +5,7 @@ import (
user_service "server/modules/user/service"
"server/modules/yx/dto"
yx_service "server/modules/yx/service"
"strconv"
"github.com/gin-gonic/gin"
)
@ -27,6 +28,7 @@ func (ctrl *UserScoreController) RegisterRoutes(rg *gin.RouterGroup) {
{
// group.GET("/:id", ctrl.Get)
group.GET("/", ctrl.Get)
group.GET("/list", ctrl.List) // 新增分页列表接口
group.POST("/save-score", ctrl.SaveUserScore) // 新增接口
}
}
@ -71,3 +73,25 @@ func (ctrl *UserScoreController) Get(c *gin.Context) {
}
common.Success(c, item)
}
// List 获取当前用户的成绩列表
// @Summary 获取当前用户的成绩列表
// @Tags 用户分数
// @Param page query int false "页码" default(1)
// @Param size query int false "每页数量" default(10)
// @Success 200 {object} common.Response
// @Router /user/score/list [get]
func (ctrl *UserScoreController) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "10"))
loginUserId := common.GetLoginUser(c).ID
items, total, err := ctrl.userScoreService.ListByUser(loginUserId, page, size)
if err != nil {
common.Error(c, 500, err.Error())
return
}
common.Success(c, gin.H{
"items": items,
"total": total,
})
}

View File

@ -11,6 +11,7 @@ import (
"server/modules/yx/mapper"
"server/modules/yx/service"
"strings"
"time"
"gorm.io/gorm"
)
@ -41,6 +42,29 @@ func (s *UserScoreService) GetActiveByID(userID string) (*vo.UserScoreVO, error)
return scoreVO, nil
}
// ListByUser 获取用户的成绩分页列表
func (s *UserScoreService) ListByUser(userID string, page, size int) ([]*vo.UserScoreVO, int64, error) {
var scores []entity.YxUserScore
var total int64
query := config.DB.Model(&entity.YxUserScore{}).Where("create_by = ?", userID)
if err := query.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("查询总数失败: %w", err)
}
if err := query.Offset((page - 1) * size).Limit(size).Order("create_time desc").Find(&scores).Error; err != nil {
return nil, 0, fmt.Errorf("查询成绩列表失败: %w", err)
}
vos := make([]*vo.UserScoreVO, 0, len(scores))
for i := range scores {
vos = append(vos, s.convertEntityToVo(&scores[i]))
}
return vos, total, nil
}
func NewUserScoreService() *UserScoreService {
return &UserScoreService{
yxUserScoreService: service.NewYxUserScoreService(),
@ -56,7 +80,8 @@ func (s *UserScoreService) SaveUserScore(req *dto.SaveScoreRequest) (*entity.YxU
}
// 2. DTO 转 Entity
entityItem := s.convertDtoToEntity(req)
entityItem.CreateTime = time.Now()
entityItem.UpdateTime = time.Now()
// 3. 执行保存操作(可以包含事务)
tx := config.DB.Begin()
defer func() {
@ -67,7 +92,7 @@ func (s *UserScoreService) SaveUserScore(req *dto.SaveScoreRequest) (*entity.YxU
// 标记该用户的所有旧成绩为历史状态
if err := config.DB.Model(&entity.YxUserScore{}).
Where("user_id = ? AND state = ?", entityItem.CreateBy, "1").
Where("create_by = ? AND state = ?", req.CreateBy, "1").
Updates(map[string]interface{}{"state": "2"}).Error; err != nil {
tx.Rollback()
return nil, fmt.Errorf("更新旧记录失败: %w", err)

View File

@ -23,9 +23,19 @@ func (req *SaveScoreRequest) Validate() error {
if req.CognitioPolyclinic != "文科" && req.CognitioPolyclinic != "理科" {
return errors.New("考试类型必须是'物理组'或'历史组'")
}
if len(req.SubjectList) != 2 {
return errors.New("选考科目有且只能传两个值")
if req.ProfessionalCategory == "表演类" || req.ProfessionalCategory == "音乐类" {
if len(req.ProfessionalCategoryChildren) == 0 {
return errors.New("表演类或音乐类必须至少选一个专业子级")
}
} else {
req.ProfessionalCategoryChildren = []string{}
}
if len(req.SubjectList) > 3 {
return errors.New("选考科目有且最多只能传三个值")
}
validSubjects := map[string]bool{"地理": true, "政治": true, "历史": true, "化学": true, "生物": true}
for _, s := range req.SubjectList {
if !validSubjects[s] {
@ -33,16 +43,16 @@ func (req *SaveScoreRequest) Validate() error {
}
}
if !isValidScore(*req.ProfessionalScore, 300) {
return errors.New("ProfessionalScore必须在0-300之间")
return errors.New("统考成绩必须在0-300之间")
}
if !isValidScore(*req.CulturalScore, 300) {
return errors.New("CulturalScore必须在0-300之间")
if !isValidScore(*req.CulturalScore, 750) {
return errors.New("文化成绩必须在0-750之间")
}
if !isValidScore(*req.EnglishScore, 150) {
return errors.New("EnglishScore必须在0-150之间")
return errors.New("英文成绩必须在0-150之间")
}
if !isValidScore(*req.ChineseScore, 150) {
return errors.New("ChineseScore必须在0-150之间")
return errors.New("中文成绩必须在0-150之间")
}
// TODO 在这里判断一下 专业子级,如 表演类只有:"服装表演", "戏剧影视表演", "戏剧影视导演"。音乐类只有:音乐表演声乐、音乐表演器乐、音乐教育。
validProfessionalChildren := map[string]string{ // 子级 -> 父级分类
@ -79,6 +89,51 @@ func (req *SaveScoreRequest) Validate() error {
if count > 3 { // 假设每类最多3个
return errors.New(category + "最多只能有3个子级")
}
for _, key := range req.ProfessionalCategoryChildren {
if req.ProfessionalCategoryChildrenScore[key] == 0 {
return errors.New(key + "成绩不能为空")
}
}
}
// 专业子级和成绩数据一致性校验
if err := req.validateProfessionalConsistency(); err != nil {
return err
}
return nil
}
func (req *SaveScoreRequest) validateProfessionalConsistency() error {
// 特殊情况:如果不需要专业子级,则两个都应该为空
if len(req.ProfessionalCategoryChildren) == 0 {
if len(req.ProfessionalCategoryChildrenScore) != 0 {
return errors.New("未选择专业子级时,成绩数据应为空")
}
return nil
}
// 创建映射用于双向验证
childrenMap := make(map[string]bool)
for _, child := range req.ProfessionalCategoryChildren {
if childrenMap[child] {
return errors.New("专业子级 '" + child + "' 重复")
}
childrenMap[child] = true
}
// 验证长度一致
if len(childrenMap) != len(req.ProfessionalCategoryChildrenScore) {
return errors.New("专业子级列表与成绩数据数量不匹配")
}
// 双向验证一致性
for childName := range req.ProfessionalCategoryChildrenScore {
if !childrenMap[childName] {
return errors.New("成绩数据中的专业子级 '" + childName + "' 未在选中列表中")
}
}
for _, child := range req.ProfessionalCategoryChildren {
if _, exists := req.ProfessionalCategoryChildrenScore[child]; !exists {
return errors.New("选中的专业子级 '" + child + "' 缺少成绩数据")
}
}
return nil
}