feat: 分片上传断点续传、临时目录后台配置与清扫、宇恒云账号管理
- 管理端大文件分片上传与 sessionStorage 续传;Nginx 大请求体/超时 - .chunk-uploads 定期清扫;system_config 后台配置保留时长与扫描间隔 - 宇恒云 POST /register 对接与 yuheng_cloud_register_records 留痕;yuheng_cloud:manage 权限 Made-with: Cursor
This commit is contained in:
163
server/handlers/chunk_upload_cleanup_config.go
Normal file
163
server/handlers/chunk_upload_cleanup_config.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"yh_web/server/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
const chunkCleanupConfigID = "chunk_upload_cleanup"
|
||||
|
||||
type chunkCleanupConfigDoc struct {
|
||||
MaxAgeHours float64 `bson:"max_age_hours" json:"max_age_hours"`
|
||||
SweepMinutes int `bson:"sweep_minutes" json:"sweep_minutes"`
|
||||
}
|
||||
|
||||
func maxAgeFromEnv() time.Duration {
|
||||
h := strings.TrimSpace(os.Getenv("YH_CHUNK_UPLOAD_MAX_AGE_HOURS"))
|
||||
if h == "" {
|
||||
return 72 * time.Hour
|
||||
}
|
||||
v, err := strconv.ParseFloat(h, 64)
|
||||
if err != nil || v < 6 {
|
||||
return 72 * time.Hour
|
||||
}
|
||||
return time.Duration(v * float64(time.Hour))
|
||||
}
|
||||
|
||||
func sweepFromEnv() time.Duration {
|
||||
m := strings.TrimSpace(os.Getenv("YH_CHUNK_UPLOAD_SWEEP_MINUTES"))
|
||||
if m == "" {
|
||||
return time.Hour
|
||||
}
|
||||
v, err := strconv.Atoi(m)
|
||||
if err != nil || v < 5 {
|
||||
return time.Hour
|
||||
}
|
||||
return time.Duration(v) * time.Minute
|
||||
}
|
||||
|
||||
func normalizeMaxAgeHours(h float64) time.Duration {
|
||||
if h < 6 {
|
||||
return 6 * time.Hour
|
||||
}
|
||||
if h > 336 {
|
||||
return 336 * time.Hour
|
||||
}
|
||||
return time.Duration(h * float64(time.Hour))
|
||||
}
|
||||
|
||||
func normalizeSweepMinutes(m int) time.Duration {
|
||||
if m < 5 {
|
||||
return 5 * time.Minute
|
||||
}
|
||||
if m > 1440 {
|
||||
return 1440 * time.Minute
|
||||
}
|
||||
return time.Duration(m) * time.Minute
|
||||
}
|
||||
|
||||
// loadChunkCleanupParameters 优先读 MongoDB system_config;无文档时用环境变量;用于定时清扫
|
||||
func loadChunkCleanupParameters() (maxAge time.Duration, sweepEvery time.Duration) {
|
||||
db := config.GetDB(config.DBName)
|
||||
if db != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var doc chunkCleanupConfigDoc
|
||||
err := db.Collection("system_config").FindOne(ctx, bson.M{"_id": chunkCleanupConfigID}).Decode(&doc)
|
||||
if err == nil && doc.MaxAgeHours >= 6 && doc.SweepMinutes >= 5 {
|
||||
return normalizeMaxAgeHours(doc.MaxAgeHours), normalizeSweepMinutes(doc.SweepMinutes)
|
||||
}
|
||||
}
|
||||
return maxAgeFromEnv(), sweepFromEnv()
|
||||
}
|
||||
|
||||
// GetChunkUploadCleanupConfig 后台读取当前保存的配置(无文档时返回默认值)
|
||||
func GetChunkUploadCleanupConfig(c *gin.Context) {
|
||||
db := config.GetDB(config.DBName)
|
||||
if db == nil {
|
||||
c.JSON(http.StatusOK, chunkCleanupConfigDoc{
|
||||
MaxAgeHours: 72,
|
||||
SweepMinutes: 60,
|
||||
})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var doc chunkCleanupConfigDoc
|
||||
err := db.Collection("system_config").FindOne(ctx, bson.M{"_id": chunkCleanupConfigID}).Decode(&doc)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
c.JSON(http.StatusOK, chunkCleanupConfigDoc{
|
||||
MaxAgeHours: 72,
|
||||
SweepMinutes: 60,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if doc.MaxAgeHours < 6 {
|
||||
doc.MaxAgeHours = 72
|
||||
}
|
||||
if doc.SweepMinutes < 5 {
|
||||
doc.SweepMinutes = 60
|
||||
}
|
||||
c.JSON(http.StatusOK, doc)
|
||||
}
|
||||
|
||||
// ChunkUploadCleanupUpdateInput 后台保存
|
||||
type ChunkUploadCleanupUpdateInput struct {
|
||||
MaxAgeHours float64 `json:"max_age_hours" binding:"required"`
|
||||
SweepMinutes int `json:"sweep_minutes" binding:"required"`
|
||||
}
|
||||
|
||||
// UpdateChunkUploadCleanupConfig 保存分片临时目录保留时长与扫描间隔
|
||||
func UpdateChunkUploadCleanupConfig(c *gin.Context) {
|
||||
var input ChunkUploadCleanupUpdateInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if input.MaxAgeHours < 6 || input.MaxAgeHours > 336 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "保留时长须在 6~336 小时之间"})
|
||||
return
|
||||
}
|
||||
if input.SweepMinutes < 5 || input.SweepMinutes > 1440 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "扫描间隔须在 5~1440 分钟之间"})
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB(config.DBName)
|
||||
if db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "数据库未连接,无法保存"})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
coll := db.Collection("system_config")
|
||||
set := bson.M{
|
||||
"_id": chunkCleanupConfigID,
|
||||
"max_age_hours": input.MaxAgeHours,
|
||||
"sweep_minutes": input.SweepMinutes,
|
||||
"updated_at": time.Now().Format(time.RFC3339),
|
||||
"updated_by_hint": "admin",
|
||||
}
|
||||
opts := options.UpdateOne().SetUpsert(true)
|
||||
_, err := coll.UpdateOne(ctx, bson.M{"_id": chunkCleanupConfigID}, bson.M{"$set": set}, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "配置已保存"})
|
||||
}
|
||||
@@ -207,6 +207,42 @@ func ServePromotionMedia(c *gin.Context) {
|
||||
c.File(fullPath)
|
||||
}
|
||||
|
||||
// computeSiteUploadDest 与整文件上传、分片合并完成时一致的目标路径(非 preserve 时 saveName 含当前时间戳)
|
||||
func computeSiteUploadDest(siteID, folder, originalFilename string, preserve bool) (relPath, destPath string, errMsg string) {
|
||||
name := originalFilename
|
||||
ext := filepath.Ext(name)
|
||||
nameNoExt := strings.TrimSuffix(name, ext)
|
||||
var saveName string
|
||||
if preserve {
|
||||
saveName = filepath.Base(name)
|
||||
if saveName == "." || saveName == string(filepath.Separator) || saveName == "" {
|
||||
return "", "", "无效的文件名"
|
||||
}
|
||||
} else {
|
||||
if len(ext) == 0 {
|
||||
saveName = nameNoExt + "_" + time.Now().Format("20060102150405")
|
||||
} else {
|
||||
saveName = nameNoExt + "_" + time.Now().Format("20060102150405") + ext
|
||||
}
|
||||
}
|
||||
|
||||
folderClean := ""
|
||||
if folder != "" {
|
||||
folderClean = filepath.ToSlash(filepath.Clean(folder))
|
||||
if strings.HasPrefix(folderClean, "../") || strings.Contains(folderClean, "/../") {
|
||||
return "", "", "无效的目录路径"
|
||||
}
|
||||
}
|
||||
|
||||
if folderClean != "" {
|
||||
relPath = filepath.ToSlash(filepath.Join("sites", siteID, folderClean, saveName))
|
||||
} else {
|
||||
relPath = filepath.ToSlash(filepath.Join("sites", siteID, saveName))
|
||||
}
|
||||
destPath = filepath.Join(getUploadDir(), filepath.FromSlash(relPath))
|
||||
return relPath, destPath, ""
|
||||
}
|
||||
|
||||
// UploadSiteAsset 上传功能模块/文件;form 可选:folder(当前目录相对路径)、downloadable(true/false)、preserve_filename(true 时保持原名并覆盖同路径旧文件,用于首页推广视频固定 URL)
|
||||
func UploadSiteAsset(c *gin.Context) {
|
||||
siteID := c.Param("site_id")
|
||||
@@ -225,41 +261,12 @@ func UploadSiteAsset(c *gin.Context) {
|
||||
downloadable := c.PostForm("downloadable") == "true" || c.PostForm("downloadable") == "1"
|
||||
preserve := c.PostForm("preserve_filename") == "true" || c.PostForm("preserve_filename") == "1"
|
||||
|
||||
name := file.Filename
|
||||
ext := filepath.Ext(name)
|
||||
nameNoExt := strings.TrimSuffix(name, ext)
|
||||
var saveName string
|
||||
if preserve {
|
||||
saveName = filepath.Base(name)
|
||||
if saveName == "." || saveName == string(filepath.Separator) || saveName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件名"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if len(ext) == 0 {
|
||||
saveName = nameNoExt + "_" + time.Now().Format("20060102150405")
|
||||
} else {
|
||||
saveName = nameNoExt + "_" + time.Now().Format("20060102150405") + ext
|
||||
}
|
||||
relPath, destPath, errMsg := computeSiteUploadDest(siteID, folder, file.Filename, preserve)
|
||||
if errMsg != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": errMsg})
|
||||
return
|
||||
}
|
||||
|
||||
folderClean := ""
|
||||
if folder != "" {
|
||||
folderClean = filepath.ToSlash(filepath.Clean(folder))
|
||||
if strings.HasPrefix(folderClean, "../") || strings.Contains(folderClean, "/../") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的目录路径"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var relPath string
|
||||
if folderClean != "" {
|
||||
relPath = filepath.ToSlash(filepath.Join("sites", siteID, folderClean, saveName))
|
||||
} else {
|
||||
relPath = filepath.ToSlash(filepath.Join("sites", siteID, saveName))
|
||||
}
|
||||
destPath := filepath.Join(getUploadDir(), filepath.FromSlash(relPath))
|
||||
|
||||
if preserve {
|
||||
ctxDel, cancelDel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancelDel()
|
||||
|
||||
501
server/handlers/multipart_upload.go
Normal file
501
server/handlers/multipart_upload.go
Normal file
@@ -0,0 +1,501 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"yh_web/server/config"
|
||||
"yh_web/server/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// 与 Nginx client_max_body_size 对齐;分片单请求仅 chunk_size 字节量级
|
||||
const maxMultipartTotalSize = int64(800 << 20)
|
||||
const defaultChunkSize = int64(4 << 20)
|
||||
const minChunkSize = int64(1 << 20)
|
||||
const maxChunkSize = int64(32 << 20)
|
||||
|
||||
type chunkSessionMeta struct {
|
||||
SiteID string `json:"site_id"`
|
||||
OriginalFilename string `json:"original_filename"`
|
||||
TotalSize int64 `json:"total_size"`
|
||||
ChunkSize int64 `json:"chunk_size"`
|
||||
TotalChunks int `json:"total_chunks"`
|
||||
Folder string `json:"folder"`
|
||||
Downloadable bool `json:"downloadable"`
|
||||
PreserveFilename bool `json:"preserve_filename"`
|
||||
CreatedUnix int64 `json:"created_unix"`
|
||||
}
|
||||
|
||||
func chunkSessionsRoot() string {
|
||||
return filepath.Join(getUploadDir(), ".chunk-uploads")
|
||||
}
|
||||
|
||||
func chunkSessionDir(uploadID string) string {
|
||||
return filepath.Join(chunkSessionsRoot(), uploadID)
|
||||
}
|
||||
|
||||
func metaPath(uploadID string) string {
|
||||
return filepath.Join(chunkSessionDir(uploadID), "meta.json")
|
||||
}
|
||||
|
||||
func validUploadID(uploadID string) bool {
|
||||
if len(uploadID) != 24 {
|
||||
return false
|
||||
}
|
||||
for _, c := range uploadID {
|
||||
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
_, err := bson.ObjectIDFromHex(uploadID)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func readChunkMeta(uploadID string) (*chunkSessionMeta, error) {
|
||||
data, err := os.ReadFile(metaPath(uploadID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m chunkSessionMeta
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func chunkExpectedSize(meta *chunkSessionMeta, index int) int64 {
|
||||
if index < 0 || index >= meta.TotalChunks {
|
||||
return -1
|
||||
}
|
||||
start := int64(index) * meta.ChunkSize
|
||||
end := start + meta.ChunkSize
|
||||
if end > meta.TotalSize {
|
||||
end = meta.TotalSize
|
||||
}
|
||||
return end - start
|
||||
}
|
||||
|
||||
// InitMultipartUpload 创建分片会话(断点续传第一步)
|
||||
func InitMultipartUpload(c *gin.Context) {
|
||||
siteID := c.Param("site_id")
|
||||
if siteID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Filename string `json:"filename" binding:"required"`
|
||||
TotalSize int64 `json:"total_size" binding:"required"`
|
||||
ChunkSize int64 `json:"chunk_size"`
|
||||
Folder string `json:"folder"`
|
||||
Downloadable bool `json:"downloadable"`
|
||||
PreserveFilename bool `json:"preserve_filename"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 filename、total_size"})
|
||||
return
|
||||
}
|
||||
if body.TotalSize <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "文件大小无效"})
|
||||
return
|
||||
}
|
||||
if body.TotalSize > maxMultipartTotalSize {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "文件超过当前站点允许的最大体积(800MB)"})
|
||||
return
|
||||
}
|
||||
cs := body.ChunkSize
|
||||
if cs <= 0 {
|
||||
cs = defaultChunkSize
|
||||
}
|
||||
if cs < minChunkSize || cs > maxChunkSize {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "chunk_size 须在 1MB~32MB 之间"})
|
||||
return
|
||||
}
|
||||
totalChunks := int((body.TotalSize + cs - 1) / cs)
|
||||
if totalChunks <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "分片数无效"})
|
||||
return
|
||||
}
|
||||
|
||||
folder := strings.TrimSpace(body.Folder)
|
||||
if folder != "" {
|
||||
fc := filepath.ToSlash(filepath.Clean(folder))
|
||||
if strings.HasPrefix(fc, "../") || strings.Contains(fc, "/../") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的目录路径"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
uploadID := bson.NewObjectID().Hex()
|
||||
dir := chunkSessionDir(uploadID)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建临时目录失败"})
|
||||
return
|
||||
}
|
||||
meta := chunkSessionMeta{
|
||||
SiteID: siteID,
|
||||
OriginalFilename: body.Filename,
|
||||
TotalSize: body.TotalSize,
|
||||
ChunkSize: cs,
|
||||
TotalChunks: totalChunks,
|
||||
Folder: folder,
|
||||
Downloadable: body.Downloadable,
|
||||
PreserveFilename: body.PreserveFilename,
|
||||
CreatedUnix: time.Now().Unix(),
|
||||
}
|
||||
raw, _ := json.Marshal(meta)
|
||||
if err := os.WriteFile(filepath.Join(dir, "meta.json"), raw, 0644); err != nil {
|
||||
_ = os.RemoveAll(dir)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "写入会话失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"upload_id": uploadID,
|
||||
"chunk_size": cs,
|
||||
"total_chunks": totalChunks,
|
||||
"received_chunks": []int{},
|
||||
})
|
||||
}
|
||||
|
||||
// MultipartUploadStatus 返回已收到的分片下标(用于续传)
|
||||
func MultipartUploadStatus(c *gin.Context) {
|
||||
siteID := c.Param("site_id")
|
||||
uploadID := c.Param("upload_id")
|
||||
if siteID == "" || !validUploadID(uploadID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
meta, err := readChunkMeta(uploadID)
|
||||
if err != nil || meta.SiteID != siteID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "上传会话不存在或已过期"})
|
||||
return
|
||||
}
|
||||
dir := chunkSessionDir(uploadID)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取会话失败"})
|
||||
return
|
||||
}
|
||||
received := make([]int, 0, meta.TotalChunks)
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || e.Name() == "meta.json" {
|
||||
continue
|
||||
}
|
||||
idx, err := strconv.Atoi(e.Name())
|
||||
if err != nil || idx < 0 || idx >= meta.TotalChunks {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
exp := chunkExpectedSize(meta, idx)
|
||||
if exp >= 0 && info.Size() == exp {
|
||||
received = append(received, idx)
|
||||
}
|
||||
}
|
||||
sort.Ints(received)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"upload_id": uploadID,
|
||||
"total_chunks": meta.TotalChunks,
|
||||
"total_size": meta.TotalSize,
|
||||
"chunk_size": meta.ChunkSize,
|
||||
"received_chunks": received,
|
||||
"original_filename": meta.OriginalFilename,
|
||||
})
|
||||
}
|
||||
|
||||
// PutMultipartChunk 上传单个分片(二进制 body,长度须与分片大小一致)
|
||||
func PutMultipartChunk(c *gin.Context) {
|
||||
siteID := c.Param("site_id")
|
||||
uploadID := c.Param("upload_id")
|
||||
chunkStr := c.Param("chunk_index")
|
||||
if siteID == "" || !validUploadID(uploadID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
chunkIndex, err := strconv.Atoi(chunkStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的分片序号"})
|
||||
return
|
||||
}
|
||||
meta, err := readChunkMeta(uploadID)
|
||||
if err != nil || meta.SiteID != siteID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "上传会话不存在"})
|
||||
return
|
||||
}
|
||||
expected := chunkExpectedSize(meta, chunkIndex)
|
||||
if expected < 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "分片序号越界"})
|
||||
return
|
||||
}
|
||||
|
||||
chunkFile := filepath.Join(chunkSessionDir(uploadID), strconv.Itoa(chunkIndex))
|
||||
if fi, err := os.Stat(chunkFile); err == nil && fi.Size() == expected {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "分片已存在", "chunk_index": chunkIndex, "size": expected})
|
||||
return
|
||||
}
|
||||
|
||||
tmp := chunkFile + ".part"
|
||||
f, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建临时文件失败"})
|
||||
return
|
||||
}
|
||||
n, err := io.Copy(f, io.LimitReader(c.Request.Body, expected+1))
|
||||
_ = f.Close()
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "读取分片失败"})
|
||||
return
|
||||
}
|
||||
if n != expected {
|
||||
_ = os.Remove(tmp)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "分片大小不符"})
|
||||
return
|
||||
}
|
||||
if err := os.Rename(tmp, chunkFile); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存分片失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "分片已保存", "chunk_index": chunkIndex, "size": expected})
|
||||
}
|
||||
|
||||
// CompleteMultipartUpload 合并分片并写入 site_assets
|
||||
func CompleteMultipartUpload(c *gin.Context) {
|
||||
siteID := c.Param("site_id")
|
||||
uploadID := c.Param("upload_id")
|
||||
if siteID == "" || !validUploadID(uploadID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
meta, err := readChunkMeta(uploadID)
|
||||
if err != nil || meta.SiteID != siteID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "上传会话不存在"})
|
||||
return
|
||||
}
|
||||
dir := chunkSessionDir(uploadID)
|
||||
for i := 0; i < meta.TotalChunks; i++ {
|
||||
p := filepath.Join(dir, strconv.Itoa(i))
|
||||
fi, err := os.Stat(p)
|
||||
if err != nil || fi.IsDir() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "分片未齐,无法合并", "missing_chunk": i})
|
||||
return
|
||||
}
|
||||
if fi.Size() != chunkExpectedSize(meta, i) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "分片大小异常", "chunk_index": i})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
relPath, destPath, errMsg := computeSiteUploadDest(siteID, meta.Folder, meta.OriginalFilename, meta.PreserveFilename)
|
||||
if errMsg != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": errMsg})
|
||||
return
|
||||
}
|
||||
|
||||
if meta.PreserveFilename {
|
||||
ctxDel, cancelDel := context.WithTimeout(c.Request.Context(), 8*time.Second)
|
||||
defer cancelDel()
|
||||
coll := config.GetDB(config.DBName).Collection("site_assets")
|
||||
_, _ = coll.DeleteMany(ctxDel, bson.M{"site_id": siteID, "file_path": relPath})
|
||||
_ = os.Remove(destPath)
|
||||
}
|
||||
|
||||
baseDir := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"})
|
||||
return
|
||||
}
|
||||
|
||||
dst, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目标文件失败"})
|
||||
return
|
||||
}
|
||||
for i := 0; i < meta.TotalChunks; i++ {
|
||||
srcPath := filepath.Join(dir, strconv.Itoa(i))
|
||||
src, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
_ = dst.Close()
|
||||
_ = os.Remove(destPath)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "打开分片失败"})
|
||||
return
|
||||
}
|
||||
_, err = io.Copy(dst, src)
|
||||
_ = src.Close()
|
||||
if err != nil {
|
||||
_ = dst.Close()
|
||||
_ = os.Remove(destPath)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "合并分片失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = dst.Close()
|
||||
|
||||
fi, err := os.Stat(destPath)
|
||||
if err != nil || fi.Size() != meta.TotalSize {
|
||||
_ = os.Remove(destPath)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "合并后大小与声明不符"})
|
||||
return
|
||||
}
|
||||
|
||||
buf := make([]byte, 512)
|
||||
fh, err := os.Open(destPath)
|
||||
var contentType string
|
||||
if err == nil {
|
||||
n, _ := fh.Read(buf)
|
||||
_ = fh.Close()
|
||||
contentType = http.DetectContentType(buf[:n])
|
||||
}
|
||||
if contentType == "" || contentType == "application/octet-stream" {
|
||||
if x := promotionMimeType(filepath.Ext(meta.OriginalFilename)); x != "" {
|
||||
contentType = x
|
||||
}
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
res, err := config.GetDB(config.DBName).Collection("site_assets").InsertOne(ctx, bson.M{
|
||||
"site_id": siteID,
|
||||
"name": meta.OriginalFilename,
|
||||
"file_path": relPath,
|
||||
"size": meta.TotalSize,
|
||||
"content_type": contentType,
|
||||
"downloadable": meta.Downloadable,
|
||||
"created_at": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
if err != nil {
|
||||
_ = os.Remove(destPath)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = os.RemoveAll(dir)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"id": res.InsertedID, "file_path": relPath, "message": "上传成功"})
|
||||
ScheduleTranscodeAfterUpload(siteID, relPath, destPath, res.InsertedID)
|
||||
}
|
||||
|
||||
// AbortMultipartUpload 取消分片会话并删除临时文件
|
||||
func AbortMultipartUpload(c *gin.Context) {
|
||||
siteID := c.Param("site_id")
|
||||
uploadID := c.Param("upload_id")
|
||||
if siteID == "" || !validUploadID(uploadID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
meta, err := readChunkMeta(uploadID)
|
||||
if err != nil || meta.SiteID != siteID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在"})
|
||||
return
|
||||
}
|
||||
_ = os.RemoveAll(chunkSessionDir(uploadID))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已取消"})
|
||||
}
|
||||
|
||||
func chunkSessionCreatedAt(uploadID string) time.Time {
|
||||
meta, err := readChunkMeta(uploadID)
|
||||
if err == nil && meta.CreatedUnix > 0 {
|
||||
return time.Unix(meta.CreatedUnix, 0)
|
||||
}
|
||||
fi, err := os.Stat(chunkSessionDir(uploadID))
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return fi.ModTime()
|
||||
}
|
||||
|
||||
// SweepStaleChunkUploadSessions 删除 {UPLOAD_DIR}/.chunk-uploads 下超过 staleChunkMaxAge 的会话目录
|
||||
func SweepStaleChunkUploadSessions() (removed int, err error) {
|
||||
root := chunkSessionsRoot()
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
maxAge, _ := loadChunkCleanupParameters()
|
||||
now := time.Now()
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !validUploadID(name) {
|
||||
continue
|
||||
}
|
||||
created := chunkSessionCreatedAt(name)
|
||||
if created.IsZero() {
|
||||
continue
|
||||
}
|
||||
if now.Sub(created) < maxAge {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(root, name)
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
logger.Err("chunk_upload", "删除过期分片目录失败 %s: %v", dir, err)
|
||||
continue
|
||||
}
|
||||
removed++
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
// StartStaleChunkUploadSweep 启动后延迟执行一次,再按周期清扫非活动 .chunk-uploads
|
||||
func StartStaleChunkUploadSweep(ctx context.Context) {
|
||||
go func() {
|
||||
const bootDelay = 2 * time.Minute
|
||||
t := time.NewTimer(bootDelay)
|
||||
select {
|
||||
case <-t.C:
|
||||
case <-ctx.Done():
|
||||
if !t.Stop() {
|
||||
<-t.C
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
run := func() {
|
||||
n, err := SweepStaleChunkUploadSessions()
|
||||
if err != nil {
|
||||
logger.Err("chunk_upload", "扫描 .chunk-uploads 失败: %v", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
logger.Log("chunk_upload", "已删除 %d 个过期分片上传临时目录(超过后台或环境变量配置的保留时长)", n)
|
||||
}
|
||||
}
|
||||
run()
|
||||
lastSweep := time.Now()
|
||||
|
||||
tick := time.NewTicker(time.Minute)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
_, interval := loadChunkCleanupParameters()
|
||||
if time.Since(lastSweep) >= interval {
|
||||
run()
|
||||
lastSweep = time.Now()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
182
server/handlers/yuheng_cloud_register.go
Normal file
182
server/handlers/yuheng_cloud_register.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"yh_web/server/config"
|
||||
"yh_web/server/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
const yuhengCloudRegisterColl = "yuheng_cloud_register_records"
|
||||
|
||||
func cloudRegisterURL() string {
|
||||
u := strings.TrimSpace(os.Getenv("YH_CLOUD_REGISTER_URL"))
|
||||
if u != "" {
|
||||
return strings.TrimSuffix(u, "/")
|
||||
}
|
||||
return "http://www.cloud.yuxindazhineng.com:3001/register"
|
||||
}
|
||||
|
||||
// YuhengCloudRegisterInput 与云端 POST /register 一致;email 仅用于调用云端,不写入 Mongo
|
||||
type YuhengCloudRegisterInput struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Email string `json:"email" binding:"required"`
|
||||
}
|
||||
|
||||
type cloudRegisterPayload struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
func postCloudRegister(ctx context.Context, payload cloudRegisterPayload) (int, string, error) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cloudRegisterURL(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
client := &http.Client{Timeout: 45 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return resp.StatusCode, strings.TrimSpace(string(b)), nil
|
||||
}
|
||||
|
||||
// CreateYuhengCloudRegister 调用云端注册接口,成功后在 Mongo 写入一条记录(仅 username、password)
|
||||
func CreateYuhengCloudRegister(c *gin.Context) {
|
||||
var in YuhengCloudRegisterInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写用户名、密码与邮箱(邮箱仅提交云端)"})
|
||||
return
|
||||
}
|
||||
in.Username = strings.TrimSpace(in.Username)
|
||||
in.Password = strings.TrimSpace(in.Password)
|
||||
in.Email = strings.TrimSpace(in.Email)
|
||||
if in.Username == "" || in.Password == "" || in.Email == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名、密码、邮箱不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 50*time.Second)
|
||||
defer cancel()
|
||||
|
||||
status, bodySnippet, err := postCloudRegister(ctx, cloudRegisterPayload{
|
||||
Username: in.Username,
|
||||
Password: in.Password,
|
||||
Email: in.Email,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "调用云端注册失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
msg := bodySnippet
|
||||
if len(msg) > 500 {
|
||||
msg = msg[:500] + "…"
|
||||
}
|
||||
if msg == "" {
|
||||
msg = http.StatusText(status)
|
||||
}
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("云端返回 %d: %s", status, msg)})
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB(config.DBName)
|
||||
if db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "数据库未连接,未写入本地记录"})
|
||||
return
|
||||
}
|
||||
insCtx, insCancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer insCancel()
|
||||
doc := bson.M{
|
||||
"username": in.Username,
|
||||
"password": in.Password,
|
||||
"created_at": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
res, err := db.Collection(yuhengCloudRegisterColl).InsertOne(insCtx, doc)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "云端已成功但本地记录失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
idHex := ""
|
||||
switch v := res.InsertedID.(type) {
|
||||
case bson.ObjectID:
|
||||
idHex = v.Hex()
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"id": idHex, "message": "已提交云端注册并写入本地记录"})
|
||||
}
|
||||
|
||||
// ListYuhengCloudRegisterRecords 分页列出本地留痕(便于管理页展示)
|
||||
func ListYuhengCloudRegisterRecords(c *gin.Context) {
|
||||
db := config.GetDB(config.DBName)
|
||||
if db == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"list": []any{}, "total": 0})
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
skip := int64((page - 1) * pageSize)
|
||||
limit := int64(pageSize)
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
coll := db.Collection(yuhengCloudRegisterColl)
|
||||
total, err := coll.CountDocuments(ctx, bson.M{})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}).SetSkip(skip).SetLimit(limit)
|
||||
cur, err := coll.Find(ctx, bson.M{}, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
var list []models.YuhengCloudRegisterRecord
|
||||
for cur.Next(ctx) {
|
||||
var row struct {
|
||||
ID bson.ObjectID `bson:"_id"`
|
||||
Username string `bson:"username"`
|
||||
Password string `bson:"password"`
|
||||
CreatedAt string `bson:"created_at"`
|
||||
}
|
||||
if err := cur.Decode(&row); err != nil {
|
||||
continue
|
||||
}
|
||||
list = append(list, models.YuhengCloudRegisterRecord{
|
||||
ID: row.ID.Hex(),
|
||||
Username: row.Username,
|
||||
Password: row.Password,
|
||||
CreatedAt: row.CreatedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"list": list, "total": total})
|
||||
}
|
||||
@@ -145,6 +145,8 @@ func main() {
|
||||
c.JSON(http.StatusOK, structure)
|
||||
})
|
||||
admin.GET("/stats", handlers.GetStats)
|
||||
admin.POST("/yuheng-cloud-accounts", handlers.RequirePermission(models.PermYuhengCloudManage), handlers.CreateYuhengCloudRegister)
|
||||
admin.GET("/yuheng-cloud-accounts", handlers.RequirePermission(models.PermYuhengCloudManage), handlers.ListYuhengCloudRegisterRecords)
|
||||
admin.GET("/live/moderation", handlers.RequirePermission(models.PermHomepageEdit), weblive.GetLiveModeration)
|
||||
admin.PUT("/live/moderation/mute-all", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteAll)
|
||||
admin.PUT("/live/moderation/mute-ip", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteIP)
|
||||
@@ -170,6 +172,11 @@ func main() {
|
||||
admin.GET("/sites/:site_id/assets/downloadable", handlers.RequirePermission(models.PermHomepageEdit), handlers.ListDownloadableAssets)
|
||||
admin.GET("/sites/:site_id/assets", handlers.RequirePermission(models.PermSiteManage), handlers.ListSiteAssets)
|
||||
admin.POST("/sites/:site_id/assets", handlers.RequirePermission(models.PermModuleUpload), handlers.UploadSiteAsset)
|
||||
admin.POST("/sites/:site_id/assets/init-multipart", handlers.RequirePermission(models.PermModuleUpload), handlers.InitMultipartUpload)
|
||||
admin.GET("/sites/:site_id/assets/multipart/:upload_id/status", handlers.RequirePermission(models.PermModuleUpload), handlers.MultipartUploadStatus)
|
||||
admin.PUT("/sites/:site_id/assets/multipart/:upload_id/chunk/:chunk_index", handlers.RequirePermission(models.PermModuleUpload), handlers.PutMultipartChunk)
|
||||
admin.POST("/sites/:site_id/assets/multipart/:upload_id/complete", handlers.RequirePermission(models.PermModuleUpload), handlers.CompleteMultipartUpload)
|
||||
admin.DELETE("/sites/:site_id/assets/multipart/:upload_id", handlers.RequirePermission(models.PermModuleUpload), handlers.AbortMultipartUpload)
|
||||
admin.POST("/sites/:site_id/folders", handlers.RequirePermission(models.PermModuleUpload), handlers.CreateSiteFolder)
|
||||
admin.DELETE("/sites/:site_id/assets/:asset_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSiteAsset)
|
||||
admin.GET("/sites", handlers.RequirePermission(models.PermSiteManage), handlers.GetSites)
|
||||
@@ -179,6 +186,8 @@ func main() {
|
||||
admin.DELETE("/sites/:site_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSite)
|
||||
admin.GET("/system/official-site", handlers.RequirePermission(models.PermSiteManage), handlers.GetOfficialSite)
|
||||
admin.PUT("/system/official-site", handlers.RequirePermission(models.PermSiteManage), handlers.SetOfficialSite)
|
||||
admin.GET("/system/chunk-upload-cleanup", handlers.RequirePermission(models.PermSiteManage), handlers.GetChunkUploadCleanupConfig)
|
||||
admin.PUT("/system/chunk-upload-cleanup", handlers.RequirePermission(models.PermSiteManage), handlers.UpdateChunkUploadCleanupConfig)
|
||||
|
||||
// 角色权限管理
|
||||
admin.GET("/role-permissions", handlers.RequirePermission(models.PermRolePermission), handlers.GetRolePermissionsList)
|
||||
@@ -240,6 +249,8 @@ func main() {
|
||||
|
||||
// 启动后扫描 uploads 下各站点 promotion 中遗留的 .mov 并补转码(与上传后异步转码逻辑一致)
|
||||
go handlers.SweepPromotionTranscodeOnStartup()
|
||||
// 定期删除未合并、超过保留期的分片临时目录(.chunk-uploads)
|
||||
go handlers.StartStaleChunkUploadSweep(context.Background())
|
||||
|
||||
r.Run(":" + port)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ const (
|
||||
PermSMSConfig = "sms_config"
|
||||
PermPaymentConfig = "payment_config"
|
||||
PermRolePermission = "role:permission" // 角色权限管理
|
||||
PermYuhengCloudManage = "yuheng_cloud:manage" // 宇恒云账号(云端注册留痕)
|
||||
)
|
||||
|
||||
// PermissionItem 单条权限定义(JSON 须用小写 key/name,供前端展示与勾选)
|
||||
@@ -32,6 +33,7 @@ var AllPermissions = []PermissionItem{
|
||||
{Key: PermSMSConfig, Name: "短信配置"},
|
||||
{Key: PermPaymentConfig, Name: "支付配置"},
|
||||
{Key: PermRolePermission, Name: "角色权限管理"},
|
||||
{Key: PermYuhengCloudManage, Name: "宇恒云账号管理"},
|
||||
}
|
||||
|
||||
// RolePermissionsDoc MongoDB 文档:角色 ID -> 名称与权限列表(支持自定义角色)
|
||||
|
||||
9
server/models/yuheng_cloud_register.go
Normal file
9
server/models/yuheng_cloud_register.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package models
|
||||
|
||||
// YuhengCloudRegisterRecord 宇恒云注册请求在本库的留痕(仅账号与密码;email 仅转发云端接口不落库)
|
||||
type YuhengCloudRegisterRecord struct {
|
||||
ID string `bson:"_id,omitempty" json:"id"`
|
||||
Username string `bson:"username" json:"username"`
|
||||
Password string `bson:"password" json:"password"`
|
||||
CreatedAt string `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
@@ -29,6 +29,7 @@ var requiredCollections = map[string][]indexSpec{
|
||||
"system_config": {},
|
||||
"role_permissions": {{Keys: bson.D{{Key: "role_id", Value: 1}}, Name: "idx_role_id", Unique: true}},
|
||||
"site_users": {{Keys: bson.D{{Key: "username", Value: 1}}, Name: "idx_username", Unique: true}},
|
||||
"yuheng_cloud_register_records": {{Keys: bson.D{{Key: "created_at", Value: -1}}, Name: "idx_created_at"}},
|
||||
}
|
||||
|
||||
type indexSpec struct {
|
||||
@@ -50,6 +51,7 @@ var tableDDL = map[string]string{
|
||||
"files": "CREATE TABLE IF NOT EXISTS \x60files\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60user_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '用户ID',\n \x60name\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '文件名',\n \x60file_path\x60 VARCHAR(512) NOT NULL DEFAULT '' COMMENT '存储路径',\n \x60size\x60 BIGINT NOT NULL DEFAULT 0 COMMENT '字节数',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_user_id\x60 (\x60user_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件表';",
|
||||
"system_config": "CREATE TABLE IF NOT EXISTS \x60system_config\x60 (\n \x60id\x60 VARCHAR(64) NOT NULL COMMENT '配置键 payment, sms_platform 等',\n \x60payload\x60 JSON COMMENT '配置内容(支付/短信等)',\n \x60updated_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',\n PRIMARY KEY (\x60id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表';",
|
||||
"site_users": "CREATE TABLE IF NOT EXISTS \x60site_users\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60username\x60 VARCHAR(64) NOT NULL DEFAULT '' COMMENT '登录名',\n \x60password_hash\x60 VARCHAR(128) NOT NULL DEFAULT '' COMMENT '密码哈希',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间(UTC)',\n PRIMARY KEY (\x60id\x60),\n UNIQUE KEY \x60uk_username\x60 (\x60username\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='前台用户(弹幕)';",
|
||||
"yuheng_cloud_register_records": "CREATE TABLE IF NOT EXISTS \x60yuheng_cloud_register_records\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60username\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '账号',\n \x60password\x60 VARCHAR(512) NOT NULL DEFAULT '' COMMENT '密码明文留痕',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_created_at\x60 (\x60created_at\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='宇恒云注册请求本地留痕';",
|
||||
}
|
||||
|
||||
// CollectionInfo 线上集合信息(名称、文档数、索引)
|
||||
|
||||
Reference in New Issue
Block a user