Files
hr_data_analyzer/controllers/lesson_plan.go
T
2026-05-04 16:20:46 +08:00

701 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controllers
import (
"crypto/md5"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"hr_receiver/config"
"hr_receiver/models"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const (
lessonPlanMaxSize = 10 * 1024 * 1024
lessonPlanStorageDir = "storage/lesson_plans"
lessonPlanFieldName = "file"
lessonPlanCleanupDays = 30
shareCodeExpiry = 5 * time.Minute
shareCodeDigits = 6
)
type LessonPlanController struct {
DB *gorm.DB
}
type lessonPlanPaginationParams struct {
PageNum int `form:"pageNum,default=1"`
PageSize int `form:"pageSize,default=10"`
Keyword string `form:"keyword"` // 文件名模糊搜索
UploaderName string `form:"uploaderName"` // 上传者名模糊搜索
RegionID uint32 `form:"regionId"` // 按幼儿园(区域)筛选
SortBy string `form:"sortBy"` // file_size | created_at
SortOrder string `form:"sortOrder"` // asc | desc
}
type lessonPlanShareCodeResponse struct {
Code string `json:"code"`
ExpiresAt int64 `json:"expiresAt"`
FileID uint `json:"fileId"`
}
func NewLessonPlanController() *LessonPlanController {
return &LessonPlanController{DB: config.DB}
}
// @Summary 上传教案文件
// @Description 上传 .docx 格式的教案文件支持MD5去重最大10MB
// @Tags 教案管理
// @Accept multipart/form-data
// @Produce json
// @Param file formData file true "教案文件(.docx)"
// @Security BearerAuth
// @Success 201 {object} SwagAPIResponse "上传成功"
// @Failure 400 {object} SwagAPIResponse "请求参数错误"
// @Failure 401 {object} SwagAPIResponse "未认证"
// @Failure 409 {object} SwagAPIResponse "文件已存在"
// @Router /lesson-plans/upload [post]
func (lc *LessonPlanController) Upload(c *gin.Context) {
fileHeader, err := c.FormFile(lessonPlanFieldName)
if err != nil {
writeError(c, http.StatusBadRequest, "missing file")
return
}
if err := validateLessonPlanFileHeader(fileHeader); err != nil {
writeError(c, http.StatusBadRequest, err.Error())
return
}
uploaderID, uploaderName, ok := currentUser(c)
if !ok {
writeError(c, http.StatusUnauthorized, "invalid user context")
return
}
src, err := fileHeader.Open()
if err != nil {
writeError(c, http.StatusInternalServerError, "failed to open upload")
return
}
defer src.Close()
if err := os.MkdirAll(lessonPlanStorageDir, 0o755); err != nil {
writeError(c, http.StatusInternalServerError, "failed to initialize storage")
return
}
tempFile, err := os.CreateTemp(lessonPlanStorageDir, "upload-*.docx")
if err != nil {
writeError(c, http.StatusInternalServerError, "failed to create temp file")
return
}
tempPath := tempFile.Name()
tempClosed := false
defer func() {
if !tempClosed {
_ = tempFile.Close()
}
if _, statErr := os.Stat(tempPath); statErr == nil {
_ = os.Remove(tempPath)
}
}()
hasher := md5.New()
size, err := io.Copy(io.MultiWriter(tempFile, hasher), src)
if err != nil {
writeError(c, http.StatusInternalServerError, "failed to save upload")
return
}
if size > lessonPlanMaxSize {
writeError(c, http.StatusBadRequest, "file exceeds 10MB limit")
return
}
md5Value := hex.EncodeToString(hasher.Sum(nil))
var existing models.AppFile
if err := lc.DB.Where("md5 = ? AND file_type = ?", md5Value, models.AppFileTypeLessonPlan).First(&existing).Error; err == nil {
writeSuccess(c, http.StatusConflict, "duplicate file", existing)
return
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
writeError(c, http.StatusInternalServerError, "failed to check duplicate file")
return
}
storedFilename := buildStoredLessonPlanFilename(md5Value, fileHeader.Filename)
finalPath := filepath.Join(lessonPlanStorageDir, storedFilename)
if err := tempFile.Close(); err != nil {
writeError(c, http.StatusInternalServerError, "failed to finalize upload")
return
}
tempClosed = true
if err := os.Rename(tempPath, finalPath); err != nil {
writeError(c, http.StatusInternalServerError, "failed to finalize upload")
return
}
record := models.AppFile{
FileType: models.AppFileTypeLessonPlan,
OriginalFilename: fileHeader.Filename,
StoredFilename: storedFilename,
FilePath: finalPath,
ContentType: normalizeLessonPlanContentType(fileHeader.Header.Get("Content-Type")),
FileSize: size,
MD5: md5Value,
UploaderID: uploaderID,
UploaderName: uploaderName,
}
if err := lc.DB.Create(&record).Error; err != nil {
_ = os.Remove(finalPath)
writeError(c, http.StatusInternalServerError, "failed to persist file metadata")
return
}
writeSuccess(c, http.StatusCreated, "upload success", record)
}
// @Summary 获取教案列表
// @Description 获取教案文件列表(非分页),按创建时间倒序
// @Tags 教案管理
// @Produce json
// @Security BearerAuth
// @Success 200 {object} SwagAPIResponse "查询成功"
// @Failure 401 {object} SwagAPIResponse "未认证"
// @Router /lesson-plans [get]
func (lc *LessonPlanController) List(c *gin.Context) {
userID, _, ok := currentUser(c)
if !ok {
writeError(c, http.StatusUnauthorized, "invalid user context")
return
}
role := currentUserRole(c)
query := lc.DB.Where("file_type = ?", models.AppFileTypeLessonPlan)
if role != models.UserRoleSuperAdmin && role != models.UserRoleRegionAdmin {
query = query.Where("uploader_id = ?", userID)
}
var records []models.AppFile
if err := query.Order("created_at DESC").Find(&records).Error; err != nil {
writeError(c, http.StatusInternalServerError, "failed to list lesson plans")
return
}
writeSuccess(c, http.StatusOK, "query success", records)
}
// @Summary 分页查询教案
// @Description 分页获取教案文件列表,支持文件名模糊搜索、上传者搜索、区域筛选、排序
// @Tags 教案管理
// @Produce json
// @Param pageNum query int false "页码(默认1)"
// @Param pageSize query int false "每页数量(默认10,最大100)"
// @Param keyword query string false "文件名模糊搜索"
// @Param uploaderName query string false "上传者名模糊搜索"
// @Param regionId query int false "区域ID"
// @Param sortBy query string false "排序字段: file_size | created_at"
// @Param sortOrder query string false "排序方向: asc | desc"
// @Security BearerAuth
// @Success 200 {object} SwagAPIResponse "查询成功"
// @Failure 400 {object} SwagAPIResponse "请求参数错误"
// @Failure 401 {object} SwagAPIResponse "未认证"
// @Router /lesson-plans/page [get]
func (lc *LessonPlanController) Page(c *gin.Context) {
userID, _, ok := currentUser(c)
if !ok {
writeError(c, http.StatusUnauthorized, "invalid user context")
return
}
role := currentUserRole(c)
var params lessonPlanPaginationParams
if err := c.ShouldBindQuery(&params); err != nil {
writeError(c, http.StatusBadRequest, err.Error())
return
}
if params.PageNum < 1 {
params.PageNum = 1
}
if params.PageSize < 1 || params.PageSize > 100 {
params.PageSize = 10
}
offset := (params.PageNum - 1) * params.PageSize
var total int64
var records []models.AppFile
query := lc.DB.Model(&models.AppFile{}).Where("file_type = ?", models.AppFileTypeLessonPlan)
// 角色权限过滤
if role != models.UserRoleSuperAdmin && role != models.UserRoleRegionAdmin {
query = query.Where("uploader_id = ?", userID)
}
// 文件名模糊搜索
if params.Keyword != "" {
query = query.Where("original_filename LIKE ?", "%"+params.Keyword+"%")
}
// 上传者名模糊搜索
if params.UploaderName != "" {
query = query.Where("uploader_name LIKE ?", "%"+params.UploaderName+"%")
}
// 按区域(幼儿园)筛选:通过 user_region_bindings 查 uploader_id
if params.RegionID > 0 {
query = query.Where("uploader_id IN (SELECT user_id FROM user_region_bindings WHERE region_id = ?)", params.RegionID)
}
// 排序
orderClause := "created_at DESC"
if params.SortBy != "" {
switch params.SortBy {
case "file_size":
orderClause = "file_size"
case "created_at":
orderClause = "created_at"
}
if params.SortOrder == "asc" {
orderClause += " ASC"
} else {
orderClause += " DESC"
}
}
if err := query.Count(&total).Error; err != nil {
writeError(c, http.StatusInternalServerError, "failed to count lesson plans")
return
}
if err := query.Order(orderClause).Offset(offset).Limit(params.PageSize).Find(&records).Error; err != nil {
writeError(c, http.StatusInternalServerError, "failed to query lesson plans")
return
}
// 管理员可见所属幼儿园
isAdmin := role == models.UserRoleSuperAdmin || role == models.UserRoleRegionAdmin
type lessonPlanItem struct {
models.AppFile
KindergartenName string `json:"kindergartenName"`
}
items := make([]lessonPlanItem, len(records))
for i, r := range records {
items[i] = lessonPlanItem{AppFile: r}
}
if isAdmin && len(records) > 0 {
uploaderIDs := make([]uint, len(records))
for i, r := range records {
uploaderIDs[i] = r.UploaderID
}
type uploaderRegion struct {
UserID uint
RegionID uint32
}
var bindings []uploaderRegion
if err := lc.DB.Table("user_region_bindings").
Select("user_id, region_id").
Where("user_id IN ?", uploaderIDs).
Scan(&bindings).Error; err != nil {
writeError(c, http.StatusInternalServerError, "failed to query user regions")
return
}
regionIDs := make([]uint32, len(bindings))
userRegionMap := make(map[uint]uint32, len(bindings))
for _, b := range bindings {
userRegionMap[b.UserID] = b.RegionID
regionIDs = append(regionIDs, b.RegionID)
}
type regionKindergarten struct {
RegionID uint32
Name string
}
var kindergartens []regionKindergarten
if err := lc.DB.Table("kindergartens").
Select("region_id, name").
Where("region_id IN ?", regionIDs).
Scan(&kindergartens).Error; err != nil {
writeError(c, http.StatusInternalServerError, "failed to query kindergartens")
return
}
regionNameMap := make(map[uint32]string, len(kindergartens))
for _, k := range kindergartens {
regionNameMap[k.RegionID] = k.Name
}
for i, r := range records {
if rid, ok := userRegionMap[r.UploaderID]; ok {
if name, ok2 := regionNameMap[rid]; ok2 {
items[i].KindergartenName = name
}
}
}
}
writeSuccess(c, http.StatusOK, "query success", gin.H{
"list": items,
"pagination": gin.H{
"currentPage": params.PageNum,
"pageSize": params.PageSize,
"totalList": total,
"totalPage": int((total + int64(params.PageSize) - 1) / int64(params.PageSize)),
},
})
}
// @Summary 下载教案文件
// @Description 通过ID下载教案文件自动更新下载计数和最后下载时间
// @Tags 教案管理
// @Produce application/octet-stream
// @Param id path int true "教案ID"
// @Security BearerAuth
// @Success 200 {file} binary "教案文件"
// @Failure 401 {object} SwagAPIResponse "未认证"
// @Failure 403 {object} SwagAPIResponse "无权限"
// @Failure 404 {object} SwagAPIResponse "文件不存在"
// @Router /lesson-plans/{id}/download [get]
func (lc *LessonPlanController) Download(c *gin.Context) {
record, err := lc.findLessonPlan(c.Param("id"))
if err != nil {
respondLessonPlanLookupError(c, err)
return
}
userID, _, ok := currentUser(c)
if !ok {
writeError(c, http.StatusUnauthorized, "invalid user context")
return
}
role := currentUserRole(c)
if !canAccessLessonPlan(record, userID, role) {
writeError(c, http.StatusForbidden, "permission denied")
return
}
now := time.Now().UnixMilli()
if err := lc.DB.Model(&models.AppFile{}).
Where("id = ?", record.ID).
Updates(map[string]interface{}{
"download_count": gorm.Expr("download_count + 1"),
"last_download_at": now,
}).Error; err != nil {
writeError(c, http.StatusInternalServerError, "failed to update download stats")
return
}
c.FileAttachment(record.FilePath, record.OriginalFilename)
}
// @Summary 生成分享码
// @Description 为教案文件生成6位数字分享码有效期5分钟每次生成会失效之前的分享码
// @Tags 教案管理
// @Produce json
// @Param id path int true "教案ID"
// @Security BearerAuth
// @Success 200 {object} SwagAPIResponse "生成成功"
// @Failure 401 {object} SwagAPIResponse "未认证"
// @Failure 403 {object} SwagAPIResponse "无权限"
// @Failure 404 {object} SwagAPIResponse "文件不存在"
// @Router /lesson-plans/{id}/share-code [post]
func (lc *LessonPlanController) GenerateShareCode(c *gin.Context) {
record, err := lc.findLessonPlan(c.Param("id"))
if err != nil {
respondLessonPlanLookupError(c, err)
return
}
userID, username, ok := currentUser(c)
if !ok {
writeError(c, http.StatusUnauthorized, "invalid user context")
return
}
role := currentUserRole(c)
if !canAccessLessonPlan(record, userID, role) {
writeError(c, http.StatusForbidden, "permission denied")
return
}
now := time.Now()
expiresAt := now.Add(shareCodeExpiry).UnixMilli()
var created models.AppFileShareCode
if err := lc.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.AppFileShareCode{}).
Where("app_file_id = ? AND is_active = ?", record.ID, true).
Update("is_active", false).Error; err != nil {
return err
}
shareCode, err := generateUniqueShareCode(tx, now.UnixMilli())
if err != nil {
return err
}
created = models.AppFileShareCode{
AppFileID: record.ID,
ShareCode: shareCode,
GeneratedByID: userID,
GeneratedByName: username,
ExpiresAt: expiresAt,
IsActive: true,
}
return tx.Create(&created).Error
}); err != nil {
writeError(c, http.StatusInternalServerError, "failed to generate share code")
return
}
writeSuccess(c, http.StatusOK, "share code generated", lessonPlanShareCodeResponse{
Code: created.ShareCode,
ExpiresAt: created.ExpiresAt,
FileID: record.ID,
})
}
// @Summary 通过分享码下载教案
// @Description 通过6位分享码下载教案文件无需认证
// @Tags 教案管理
// @Produce application/octet-stream
// @Param code path string true "6位分享码"
// @Success 200 {file} binary "教案文件"
// @Failure 400 {object} SwagAPIResponse "分享码无效"
// @Failure 404 {object} SwagAPIResponse "分享码过期或不存在"
// @Router /lesson-plans/share/{code}/download [get]
func (lc *LessonPlanController) DownloadByShareCode(c *gin.Context) {
shareCode := strings.TrimSpace(c.Param("code"))
if len(shareCode) != shareCodeDigits {
writeError(c, http.StatusBadRequest, "invalid share code")
return
}
now := time.Now().UnixMilli()
var share models.AppFileShareCode
if err := lc.DB.
Where("share_code = ? AND is_active = ? AND expires_at > ?", shareCode, true, now).
Order("id DESC").
First(&share).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
writeError(c, http.StatusNotFound, "share code expired or not found")
return
}
writeError(c, http.StatusInternalServerError, "failed to query share code")
return
}
var record models.AppFile
if err := lc.DB.Where("id = ? AND file_type = ?", share.AppFileID, models.AppFileTypeLessonPlan).First(&record).Error; err != nil {
respondLessonPlanLookupError(c, err)
return
}
if err := lc.DB.Model(&models.AppFile{}).
Where("id = ?", record.ID).
Updates(map[string]interface{}{
"download_count": gorm.Expr("download_count + 1"),
"last_download_at": now,
}).Error; err != nil {
writeError(c, http.StatusInternalServerError, "failed to update download stats")
return
}
c.FileAttachment(record.FilePath, record.OriginalFilename)
}
// @Summary 删除教案文件
// @Description 删除教案文件及其关联的分享码
// @Tags 教案管理
// @Produce json
// @Param id path int true "教案ID"
// @Security BearerAuth
// @Success 200 {object} SwagAPIResponse "删除成功"
// @Failure 401 {object} SwagAPIResponse "未认证"
// @Failure 403 {object} SwagAPIResponse "无权限"
// @Failure 404 {object} SwagAPIResponse "文件不存在"
// @Router /lesson-plans/{id} [delete]
func (lc *LessonPlanController) Delete(c *gin.Context) {
record, err := lc.findLessonPlan(c.Param("id"))
if err != nil {
respondLessonPlanLookupError(c, err)
return
}
userID, _, ok := currentUser(c)
if !ok {
writeError(c, http.StatusUnauthorized, "invalid user context")
return
}
role := currentUserRole(c)
if !canAccessLessonPlan(record, userID, role) {
writeError(c, http.StatusForbidden, "permission denied")
return
}
if err := deleteLessonPlanRecord(lc.DB, &record); err != nil {
writeError(c, http.StatusInternalServerError, err.Error())
return
}
writeSuccess(c, http.StatusOK, "delete success", nil)
}
func (lc *LessonPlanController) CleanupExpiredFiles() error {
cutoffMillis := time.Now().AddDate(0, 0, -lessonPlanCleanupDays).UnixMilli()
cutoffTime := time.Now().AddDate(0, 0, -lessonPlanCleanupDays)
var records []models.AppFile
if err := lc.DB.
Where("file_type = ?", models.AppFileTypeLessonPlan).
Where("(last_download_at IS NOT NULL AND last_download_at < ?) OR (last_download_at IS NULL AND created_at < ?)", cutoffMillis, cutoffTime).
Find(&records).Error; err != nil {
return err
}
for i := range records {
if err := deleteLessonPlanRecord(lc.DB, &records[i]); err != nil {
return err
}
}
return nil
}
func StartLessonPlanCleanupJob(db *gorm.DB) {
controller := &LessonPlanController{DB: db}
go func() {
runCleanup := func() {
if err := controller.CleanupExpiredFiles(); err != nil {
fmt.Printf("lesson plan cleanup failed: %v\n", err)
}
}
runCleanup()
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for range ticker.C {
runCleanup()
}
}()
}
func validateLessonPlanFileHeader(fileHeader *multipart.FileHeader) error {
if fileHeader.Size > lessonPlanMaxSize {
return fmt.Errorf("file exceeds 10MB limit")
}
if !strings.EqualFold(filepath.Ext(fileHeader.Filename), ".docx") {
return fmt.Errorf("only .docx files are allowed")
}
contentType := strings.ToLower(strings.TrimSpace(fileHeader.Header.Get("Content-Type")))
if contentType != "" && contentType != "application/vnd.openxmlformats-officedocument.wordprocessingml.document" {
return fmt.Errorf("content-type must be .docx")
}
return nil
}
func buildStoredLessonPlanFilename(md5Value, originalName string) string {
return fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), md5Value, strings.ToLower(filepath.Ext(originalName)))
}
func normalizeLessonPlanContentType(contentType string) string {
if strings.TrimSpace(contentType) == "" {
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
}
return contentType
}
func currentUser(c *gin.Context) (uint, string, bool) {
userIDValue, idExists := c.Get("userID")
usernameValue, usernameExists := c.Get("username")
if !idExists || !usernameExists {
return 0, "", false
}
userID, ok := userIDValue.(uint)
if !ok {
return 0, "", false
}
username, ok := usernameValue.(string)
if !ok {
return 0, "", false
}
return userID, username, true
}
func currentUserRole(c *gin.Context) models.UserRole {
roleValue, exists := c.Get("role")
if !exists {
return ""
}
role, _ := roleValue.(models.UserRole)
return role
}
func canAccessLessonPlan(record models.AppFile, userID uint, role models.UserRole) bool {
if record.UploaderID == userID {
return true
}
return role == models.UserRoleSuperAdmin || role == models.UserRoleRegionAdmin
}
func deleteLessonPlanRecord(db *gorm.DB, record *models.AppFile) error {
if err := db.Where("app_file_id = ?", record.ID).Delete(&models.AppFileShareCode{}).Error; err != nil {
return fmt.Errorf("failed to delete share codes: %w", err)
}
if err := os.Remove(record.FilePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove file: %w", err)
}
if err := db.Unscoped().Delete(&models.AppFile{}, record.ID).Error; err != nil {
return fmt.Errorf("failed to delete metadata: %w", err)
}
return nil
}
func (lc *LessonPlanController) findLessonPlan(id string) (models.AppFile, error) {
var record models.AppFile
if err := lc.DB.Where("id = ? AND file_type = ?", id, models.AppFileTypeLessonPlan).First(&record).Error; err != nil {
return record, err
}
return record, nil
}
func respondLessonPlanLookupError(c *gin.Context, err error) {
if errors.Is(err, gorm.ErrRecordNotFound) {
writeError(c, http.StatusNotFound, "lesson plan not found")
return
}
writeError(c, http.StatusInternalServerError, "failed to query lesson plan")
}
func generateUniqueShareCode(db *gorm.DB, nowMillis int64) (string, error) {
for i := 0; i < 10; i++ {
shareCode, err := randomNumericCode(shareCodeDigits)
if err != nil {
return "", err
}
var count int64
if err := db.Model(&models.AppFileShareCode{}).
Where("share_code = ? AND is_active = ? AND expires_at > ?", shareCode, true, nowMillis).
Count(&count).Error; err != nil {
return "", err
}
if count == 0 {
return shareCode, nil
}
}
return "", fmt.Errorf("failed to allocate unique share code")
}
func randomNumericCode(length int) (string, error) {
if length <= 0 {
return "", fmt.Errorf("invalid code length")
}
buffer := make([]byte, length)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
digits := make([]byte, length)
for i, value := range buffer {
digits[i] = byte('0' + (value % 10))
}
return string(digits), nil
}