From ea44ea015338a542b4d744a6125857ca93a285b0 Mon Sep 17 00:00:00 2001 From: laoboli <1293528695@qq.com> Date: Tue, 28 Apr 2026 21:47:51 +0800 Subject: [PATCH] feat: share code. --- controllers/lesson_plan.go | 141 ++++++++++++++++++++++++++++++++++ main.go | 1 + models/app_file_share_code.go | 28 +++++++ routes/routes.go | 2 + 4 files changed, 172 insertions(+) create mode 100644 models/app_file_share_code.go diff --git a/controllers/lesson_plan.go b/controllers/lesson_plan.go index 993612a..ebd1dea 100644 --- a/controllers/lesson_plan.go +++ b/controllers/lesson_plan.go @@ -2,6 +2,7 @@ package controllers import ( "crypto/md5" + "crypto/rand" "encoding/hex" "errors" "fmt" @@ -24,6 +25,8 @@ const ( lessonPlanStorageDir = "storage/lesson_plans" lessonPlanFieldName = "file" lessonPlanCleanupDays = 30 + shareCodeExpiry = 5 * time.Minute + shareCodeDigits = 6 ) type LessonPlanController struct { @@ -35,6 +38,12 @@ type lessonPlanPaginationParams struct { PageSize int `form:"pageSize,default=10"` } +type lessonPlanShareCodeResponse struct { + Code string `json:"code"` + ExpiresAt int64 `json:"expiresAt"` + FileID uint `json:"fileId"` +} + func NewLessonPlanController() *LessonPlanController { return &LessonPlanController{DB: config.DB} } @@ -216,6 +225,101 @@ func (lc *LessonPlanController) Download(c *gin.Context) { c.FileAttachment(record.FilePath, record.OriginalFilename) } +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, + }) +} + +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) +} + func (lc *LessonPlanController) Delete(c *gin.Context) { record, err := lc.findLessonPlan(c.Param("id")) if err != nil { @@ -336,6 +440,9 @@ func canAccessLessonPlan(record models.AppFile, userID uint, role models.UserRol } 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) } @@ -360,3 +467,37 @@ func respondLessonPlanLookupError(c *gin.Context, err error) { } 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 +} diff --git a/main.go b/main.go index 1289cea..0dbffcc 100644 --- a/main.go +++ b/main.go @@ -30,6 +30,7 @@ func main() { &models.User{}, &models.UserRegionBinding{}, &models.AppFile{}, + &models.AppFileShareCode{}, &models.MqttHeartRateRecord{}, &models.MqttStepCountRecord{}, &models.MqttGatewayStatusRecord{}, diff --git a/models/app_file_share_code.go b/models/app_file_share_code.go new file mode 100644 index 0000000..fcd79e7 --- /dev/null +++ b/models/app_file_share_code.go @@ -0,0 +1,28 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type AppFileShareCode struct { + gorm.Model + AppFileID uint `gorm:"not null;index" json:"appFileId"` + ShareCode string `gorm:"size:6;not null;index" json:"shareCode"` + GeneratedByID uint `gorm:"not null;index" json:"generatedById"` + GeneratedByName string `gorm:"size:255;not null" json:"generatedByName"` + ExpiresAt int64 `gorm:"not null;index" json:"expiresAt"` + IsActive bool `gorm:"not null;default:true;index" json:"isActive"` +} + +func (AppFileShareCode) TableName() string { + return "app_file_share_codes" +} + +func (s *AppFileShareCode) BeforeCreate(tx *gorm.DB) (err error) { + now := time.Now().UnixMilli() + s.CreatedAt = time.UnixMilli(now) + s.UpdatedAt = time.UnixMilli(now) + return nil +} diff --git a/routes/routes.go b/routes/routes.go index f52ba37..0644fd1 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -38,8 +38,10 @@ func SetupRouter() *gin.Engine { lessonPlans.GET("", middleware.RequireHeartRateOperatorOrHigher(), lessonPlanController.List) lessonPlans.GET("/page", middleware.RequireHeartRateOperatorOrHigher(), lessonPlanController.Page) lessonPlans.GET("/:id/download", lessonPlanController.Download) + lessonPlans.POST("/:id/share-code", lessonPlanController.GenerateShareCode) lessonPlans.DELETE("/:id", lessonPlanController.Delete) } + v1.GET("/lesson-plans/share/:code/download", lessonPlanController.DownloadByShareCode) public := v1.Group("") { public.POST("/register", controllers.Register)