feat: share code.
This commit is contained in:
@@ -2,6 +2,7 @@ package controllers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -24,6 +25,8 @@ const (
|
|||||||
lessonPlanStorageDir = "storage/lesson_plans"
|
lessonPlanStorageDir = "storage/lesson_plans"
|
||||||
lessonPlanFieldName = "file"
|
lessonPlanFieldName = "file"
|
||||||
lessonPlanCleanupDays = 30
|
lessonPlanCleanupDays = 30
|
||||||
|
shareCodeExpiry = 5 * time.Minute
|
||||||
|
shareCodeDigits = 6
|
||||||
)
|
)
|
||||||
|
|
||||||
type LessonPlanController struct {
|
type LessonPlanController struct {
|
||||||
@@ -35,6 +38,12 @@ type lessonPlanPaginationParams struct {
|
|||||||
PageSize int `form:"pageSize,default=10"`
|
PageSize int `form:"pageSize,default=10"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type lessonPlanShareCodeResponse struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
ExpiresAt int64 `json:"expiresAt"`
|
||||||
|
FileID uint `json:"fileId"`
|
||||||
|
}
|
||||||
|
|
||||||
func NewLessonPlanController() *LessonPlanController {
|
func NewLessonPlanController() *LessonPlanController {
|
||||||
return &LessonPlanController{DB: config.DB}
|
return &LessonPlanController{DB: config.DB}
|
||||||
}
|
}
|
||||||
@@ -216,6 +225,101 @@ func (lc *LessonPlanController) Download(c *gin.Context) {
|
|||||||
c.FileAttachment(record.FilePath, record.OriginalFilename)
|
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) {
|
func (lc *LessonPlanController) Delete(c *gin.Context) {
|
||||||
record, err := lc.findLessonPlan(c.Param("id"))
|
record, err := lc.findLessonPlan(c.Param("id"))
|
||||||
if err != nil {
|
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 {
|
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) {
|
if err := os.Remove(record.FilePath); err != nil && !os.IsNotExist(err) {
|
||||||
return fmt.Errorf("failed to remove file: %w", 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")
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ func main() {
|
|||||||
&models.User{},
|
&models.User{},
|
||||||
&models.UserRegionBinding{},
|
&models.UserRegionBinding{},
|
||||||
&models.AppFile{},
|
&models.AppFile{},
|
||||||
|
&models.AppFileShareCode{},
|
||||||
&models.MqttHeartRateRecord{},
|
&models.MqttHeartRateRecord{},
|
||||||
&models.MqttStepCountRecord{},
|
&models.MqttStepCountRecord{},
|
||||||
&models.MqttGatewayStatusRecord{},
|
&models.MqttGatewayStatusRecord{},
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -38,8 +38,10 @@ func SetupRouter() *gin.Engine {
|
|||||||
lessonPlans.GET("", middleware.RequireHeartRateOperatorOrHigher(), lessonPlanController.List)
|
lessonPlans.GET("", middleware.RequireHeartRateOperatorOrHigher(), lessonPlanController.List)
|
||||||
lessonPlans.GET("/page", middleware.RequireHeartRateOperatorOrHigher(), lessonPlanController.Page)
|
lessonPlans.GET("/page", middleware.RequireHeartRateOperatorOrHigher(), lessonPlanController.Page)
|
||||||
lessonPlans.GET("/:id/download", lessonPlanController.Download)
|
lessonPlans.GET("/:id/download", lessonPlanController.Download)
|
||||||
|
lessonPlans.POST("/:id/share-code", lessonPlanController.GenerateShareCode)
|
||||||
lessonPlans.DELETE("/:id", lessonPlanController.Delete)
|
lessonPlans.DELETE("/:id", lessonPlanController.Delete)
|
||||||
}
|
}
|
||||||
|
v1.GET("/lesson-plans/share/:code/download", lessonPlanController.DownloadByShareCode)
|
||||||
public := v1.Group("")
|
public := v1.Group("")
|
||||||
{
|
{
|
||||||
public.POST("/register", controllers.Register)
|
public.POST("/register", controllers.Register)
|
||||||
|
|||||||
Reference in New Issue
Block a user