CI 测试 / go vet + go test (push) Successful in 49s
- 全项目版本号统一:v3.x 迭代号(26.9/26.9/26.9/26.9 及裸 v2/v3)→ 26.9, 覆盖 Go 注释 / 文档 / openapi.yaml / README×4 / 前端源码(80+ 处) - v31_test.go 更名 custom_code_test.go;TestV2AccessorDefaults → TestKVAccessorDefaults - docs/api/00-overview.md 更新日志合并为单条 26.9 条目(修复错位拼接) - .goreleaser.yaml 头部注释与实际一致(Pro 2.18.1 / GITEA_TOKEN / semver tag 要求) - CI:release-image.yml → ci.yml,仅保留 vet+test 门禁; 镜像发布移交 GoReleaser Pro(原 build-push 的 tag 校验与 26.9 版本方案冲突,历史 9 次失败) - 前端重建:server/web/dist 与 web-embed 同步(docs 文案嵌入更新)
1526 lines
47 KiB
Go
1526 lines
47 KiB
Go
package api
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"gorm.io/gorm"
|
||
|
||
"fileshare/internal/config"
|
||
"fileshare/internal/middleware"
|
||
"fileshare/internal/model"
|
||
"fileshare/internal/response"
|
||
"fileshare/internal/settings"
|
||
"fileshare/internal/storage"
|
||
)
|
||
|
||
// minutesDuration 分钟数转 Duration(0 回退 1 分钟,对齐 main.go 语义)。
|
||
func minutesDuration(n int) time.Duration {
|
||
if n <= 0 {
|
||
n = 1
|
||
}
|
||
return time.Duration(n) * time.Minute
|
||
}
|
||
|
||
// syncRateRules 配置变更后同步限流规则(settings KV 驱动,运行时生效)。
|
||
func (d *Deps) syncRateRules() {
|
||
cfg := d.Cfg
|
||
d.Limiter.SetRule(middleware.LimitError, middleware.LimitRule{
|
||
Count: cfg.GetInt("errorCount"), Window: minutesDuration(cfg.GetInt("errorMinute"))})
|
||
d.Limiter.SetRule(middleware.LimitUpload, middleware.LimitRule{
|
||
Count: cfg.GetInt("uploadCount"), Window: minutesDuration(cfg.GetInt("uploadMinute"))})
|
||
d.Limiter.SetRule(middleware.LimitLogin, middleware.LimitRule{
|
||
Count: cfg.GetInt("loginCount"), Window: minutesDuration(cfg.GetInt("loginMinute"))})
|
||
d.Limiter.SetRule(middleware.LimitMeta, middleware.LimitRule{
|
||
Count: cfg.GetInt("errorCount"), Window: minutesDuration(cfg.GetInt("errorMinute"))})
|
||
}
|
||
|
||
// registerAdmin 注册管理端路由:login 公开,其余需管理员 JWT。
|
||
func registerAdmin(r *gin.Engine, d *Deps) {
|
||
admin := r.Group("/admin")
|
||
admin.POST("/login", d.adminLogin)
|
||
|
||
authed := admin.Group("", middleware.AdminAuth(d.Mgr.SecretProvider()))
|
||
{
|
||
authed.GET("/verify", d.adminVerify)
|
||
authed.POST("/logout", d.adminLogout)
|
||
authed.GET("/dashboard", d.adminDashboard)
|
||
|
||
// 文件管理
|
||
authed.GET("/file/list", d.adminFileList)
|
||
authed.GET("/file/detail", d.adminFileDetail)
|
||
authed.POST("/file/detail", d.adminFileDetail)
|
||
authed.PATCH("/file/update", d.adminFileUpdate)
|
||
authed.POST("/file/update", d.adminFileUpdate)
|
||
authed.DELETE("/file/delete", d.adminFileDelete)
|
||
authed.POST("/file/delete", d.adminFileDelete)
|
||
authed.DELETE("/file/batch-delete", d.adminFileBatchDelete)
|
||
authed.POST("/file/batch-delete", d.adminFileBatchDelete)
|
||
authed.PATCH("/file/batch-update", d.adminFileBatchUpdate)
|
||
authed.POST("/file/batch-update", d.adminFileBatchUpdate)
|
||
authed.PATCH("/file/policy-action", d.adminFilePolicyAction)
|
||
authed.POST("/file/policy-action", d.adminFilePolicyAction)
|
||
authed.PATCH("/file/batch-policy-action", d.adminFileBatchPolicyAction)
|
||
authed.POST("/file/batch-policy-action", d.adminFileBatchPolicyAction)
|
||
authed.GET("/file/download", d.adminFileDownload)
|
||
authed.GET("/file/preview", d.adminFilePreview)
|
||
|
||
// 配置与安全
|
||
authed.GET("/config/get", d.adminConfigGet)
|
||
authed.PATCH("/config/update", d.adminConfigUpdate)
|
||
authed.POST("/config/update", d.adminConfigUpdate)
|
||
authed.PATCH("/settings/password", d.adminChangePassword)
|
||
authed.POST("/settings/password", d.adminChangePassword)
|
||
|
||
// 26.9 存储引擎:运行时热切换(健康检查通过才生效,失败保持原引擎)
|
||
authed.POST("/storage/switch", d.adminStorageSwitch)
|
||
|
||
// 审计日志查询(需求 ③;logs 为 list 的别名)
|
||
authed.GET("/audit/list", d.adminAuditList)
|
||
authed.GET("/audit/logs", d.adminAuditList)
|
||
}
|
||
}
|
||
|
||
// ============ 认证 ============
|
||
|
||
// adminLogin 管理员登录(对齐参考 login):失败计入 login 限流。
|
||
func (d *Deps) adminLogin(c *gin.Context) {
|
||
// 进入即检查登录限流(对齐参考 Depends(ip_limit["login"]))
|
||
if allowed, _ := d.Limiter.Check(c, middleware.LimitLogin); !allowed {
|
||
response.Fail(c, http.StatusLocked, "请求次数过多,请稍后再试")
|
||
return
|
||
}
|
||
var body struct {
|
||
Password string `json:"password" form:"password"`
|
||
}
|
||
if err := bindJSONOrForm(c, &body); err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
stored := d.Cfg.GetString("admin_token")
|
||
if stored == "" || !settings.VerifyPassword(body.Password, stored) {
|
||
d.Limiter.Add(c, middleware.LimitLogin) // 登录失败计数
|
||
response.Fail(c, http.StatusUnauthorized, "密码错误")
|
||
return
|
||
}
|
||
// M1 透明迁移:旧格式(sha256/明文)或低 cost 哈希在登录成功后升级为 bcrypt
|
||
if settings.NeedsRehash(stored) {
|
||
if err := d.Mgr.UpdateKV(c.Request.Context(), map[string]any{
|
||
"admin_token": settings.HashPassword(body.Password),
|
||
}); err != nil {
|
||
log.Printf("[auth] 密码哈希升级失败(不影响本次登录): %v", err)
|
||
} else if err := d.Mgr.Reload(c.Request.Context()); err != nil {
|
||
log.Printf("[auth] 密码哈希升级后重载失败: %v", err)
|
||
} else {
|
||
log.Printf("[auth] 管理员密码哈希已升级为 bcrypt(旧格式兼容校验通过后自动迁移)")
|
||
}
|
||
}
|
||
expiresIn := d.Cfg.AdminSessionExpireSeconds()
|
||
token, expiresAt, err := middleware.SignAdminToken(d.jwtSecret(), time.Duration(expiresIn)*time.Second)
|
||
if err != nil {
|
||
respondError(c, errInternal("签发会话失败: "+err.Error()))
|
||
return
|
||
}
|
||
response.OK(c, gin.H{
|
||
"id": "admin",
|
||
"username": "admin",
|
||
"token": token,
|
||
"token_type": "Bearer",
|
||
"expires_at": expiresAt.Unix(),
|
||
"expires_in": expiresIn,
|
||
})
|
||
}
|
||
|
||
// adminVerify 会话校验(对齐参考 verify_admin)。
|
||
func (d *Deps) adminVerify(c *gin.Context) {
|
||
header := c.GetHeader("Authorization")
|
||
token := strings.TrimPrefix(header, "Bearer ")
|
||
expiresAt := int64(0)
|
||
if claims, err := middleware.VerifyAdminToken(d.jwtSecret(), token); err == nil && claims.ExpiresAt != nil {
|
||
expiresAt = claims.ExpiresAt.Unix()
|
||
}
|
||
response.OK(c, gin.H{
|
||
"id": "admin",
|
||
"username": "admin",
|
||
"token": token,
|
||
"token_type": "Bearer",
|
||
"expires_at": expiresAt,
|
||
})
|
||
}
|
||
|
||
// adminLogout 登出(无状态 JWT,客户端丢弃 token 即可)。
|
||
func (d *Deps) adminLogout(c *gin.Context) {
|
||
response.OK(c, gin.H{"ok": true})
|
||
}
|
||
|
||
// ============ 仪表盘 ============
|
||
|
||
// adminDashboard 管理端统计(对齐参考 dashboard 字段语义)。
|
||
func (d *Deps) adminDashboard(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
db := d.DB.WithContext(ctx)
|
||
var all []model.FileCodes
|
||
if err := db.Find(&all).Error; err != nil {
|
||
respondError(c, errInternal("查询统计失败: "+err.Error()))
|
||
return
|
||
}
|
||
now := time.Now()
|
||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||
yesterdayStart := todayStart.AddDate(0, 0, -1)
|
||
yesterdayEnd := todayStart.Add(-time.Microsecond)
|
||
|
||
var (
|
||
totalSize, usedCount int64
|
||
todayCount, todaySize int64
|
||
yesterdayCount, yesterdaySize int64
|
||
expiredCount, textCount, chunkedCount int64
|
||
)
|
||
suffixCounter := map[string]int64{}
|
||
recent := make([]model.FileCodes, 0)
|
||
for _, fc := range all {
|
||
totalSize += fc.Size
|
||
usedCount += int64(fc.UsedCount)
|
||
if fc.CreatedAt.After(todayStart) {
|
||
todayCount++
|
||
todaySize += fc.Size
|
||
}
|
||
if fc.CreatedAt.After(yesterdayStart) && (fc.CreatedAt.Before(yesterdayEnd) || fc.CreatedAt.Equal(yesterdayEnd)) {
|
||
yesterdayCount++
|
||
yesterdaySize += fc.Size
|
||
}
|
||
if fc.Expired(now) {
|
||
expiredCount++
|
||
}
|
||
if fc.Text != nil {
|
||
textCount++
|
||
} else {
|
||
chunkedCount += boolToInt64(fc.IsChunked)
|
||
}
|
||
name := "Text"
|
||
if fc.Text == nil {
|
||
name = firstNonEmpty(fc.Suffix, "file")
|
||
}
|
||
suffixCounter[name]++
|
||
recent = append(recent, fc)
|
||
}
|
||
fileCount := int64(len(all)) - textCount
|
||
|
||
// 最近 8 条
|
||
sortRecentDesc(recent)
|
||
if len(recent) > 8 {
|
||
recent = recent[:8]
|
||
}
|
||
recentFiles := make([]gin.H, 0, len(recent))
|
||
for _, fc := range recent {
|
||
recentFiles = append(recentFiles, buildAdminFileItem(&fc, now))
|
||
}
|
||
// 后缀 Top8
|
||
type suffixCount struct {
|
||
Suffix string `json:"suffix"`
|
||
Count int64 `json:"count"`
|
||
}
|
||
top := make([]suffixCount, 0, 8)
|
||
for s, n := range suffixCounter {
|
||
top = append(top, suffixCount{s, n})
|
||
}
|
||
for i := 0; i < len(top); i++ {
|
||
for j := i + 1; j < len(top); j++ {
|
||
if top[j].Count > top[i].Count {
|
||
top[i], top[j] = top[j], top[i]
|
||
}
|
||
}
|
||
}
|
||
if len(top) > 8 {
|
||
top = top[:8]
|
||
}
|
||
|
||
// sys_start(启动时间戳毫秒)
|
||
var sysStartRow model.KeyValue
|
||
var sysUptime any
|
||
if err := db.Where("key = ?", "sys_start").First(&sysStartRow).Error; err == nil && sysStartRow.Value != nil {
|
||
var ms int64
|
||
if err := parseJSONNumber(*sysStartRow.Value, &ms); err == nil {
|
||
sysUptime = ms
|
||
}
|
||
}
|
||
|
||
response.OK(c, gin.H{
|
||
"totalFiles": len(all),
|
||
"storageUsed": strconv.FormatInt(totalSize, 10),
|
||
"sysUptime": sysUptime,
|
||
"yesterdayCount": yesterdayCount,
|
||
"yesterdaySize": strconv.FormatInt(yesterdaySize, 10),
|
||
"todayCount": todayCount,
|
||
"todaySize": strconv.FormatInt(todaySize, 10),
|
||
"activeCount": int64(len(all)) - expiredCount,
|
||
"expiredCount": expiredCount,
|
||
"textCount": textCount,
|
||
"fileCount": fileCount,
|
||
"chunkedCount": chunkedCount,
|
||
"usedCount": usedCount,
|
||
"storageBackend": d.Cfg.Engine(),
|
||
"uploadSizeLimit": d.Cfg.UploadSize(),
|
||
"openUpload": boolToInt(d.Cfg.OpenUpload()),
|
||
"enableChunk": boolToInt(d.Cfg.EnableChunk()),
|
||
"maxSaveSeconds": d.Cfg.MaxSaveSeconds(),
|
||
"topSuffixes": top,
|
||
"recentFiles": recentFiles,
|
||
"recentActivities": []gin.H{},
|
||
})
|
||
}
|
||
|
||
// ============ 文件管理 ============
|
||
|
||
// adminFileList 分页查询分享列表(对齐参考 file_list:过滤/排序/统计)。
|
||
func (d *Deps) adminFileList(c *gin.Context) {
|
||
page := maxInt(queryInt(c, "page", 1), 1)
|
||
size := clampInt(queryInt(c, "size", 10), 1, 100)
|
||
keyword := strings.ToLower(strings.TrimSpace(c.Query("keyword")))
|
||
status := strings.ToLower(strings.TrimSpace(c.Query("status")))
|
||
fileType := strings.ToLower(strings.TrimSpace(c.Query("type")))
|
||
sortBy := normalizeSortBy(c.Query("sortBy"))
|
||
sortOrder := strings.ToLower(c.Query("sortOrder"))
|
||
desc := sortOrder != "asc"
|
||
|
||
ctx := c.Request.Context()
|
||
db := d.DB.WithContext(ctx)
|
||
q := db.Model(&model.FileCodes{})
|
||
if keyword != "" {
|
||
// Info5:转义 LIKE 通配符(%/_/\),避免用户输入被当作通配模式
|
||
escaped := escapeLike(keyword)
|
||
like := "%" + escaped + "%"
|
||
q = q.Where(
|
||
"LOWER(code) LIKE ? ESCAPE '\\' OR LOWER(prefix) LIKE ? ESCAPE '\\' OR LOWER(suffix) LIKE ? ESCAPE '\\' OR LOWER(COALESCE(file_hash,'')) LIKE ? ESCAPE '\\' OR LOWER(COALESCE(text,'')) LIKE ? ESCAPE '\\'",
|
||
like, like, like, like, like)
|
||
}
|
||
switch fileType {
|
||
case "text":
|
||
q = q.Where("text IS NOT NULL")
|
||
case "file":
|
||
q = q.Where("text IS NULL")
|
||
case "chunked":
|
||
q = q.Where("is_chunked = ?", true)
|
||
}
|
||
|
||
// status 过滤在内存中判定(过期语义含 NULL 分支)
|
||
var all []model.FileCodes
|
||
if err := q.Find(&all).Error; err != nil {
|
||
respondError(c, errInternal("查询文件列表失败: "+err.Error()))
|
||
return
|
||
}
|
||
now := time.Now()
|
||
filtered := make([]model.FileCodes, 0, len(all))
|
||
summary := gin.H{
|
||
"totalFiles": len(all),
|
||
"activeCount": 0,
|
||
"expiredCount": 0,
|
||
"textCount": 0,
|
||
"fileCount": 0,
|
||
"chunkedCount": 0,
|
||
"storageUsed": int64(0),
|
||
"usedCount": int64(0),
|
||
}
|
||
for i := range all {
|
||
fc := &all[i]
|
||
isExpired := fc.Expired(now)
|
||
summary["storageUsed"] = summary["storageUsed"].(int64) + fc.Size
|
||
summary["usedCount"] = summary["usedCount"].(int64) + int64(fc.UsedCount)
|
||
if isExpired {
|
||
summary["expiredCount"] = summary["expiredCount"].(int) + 1
|
||
} else {
|
||
summary["activeCount"] = summary["activeCount"].(int) + 1
|
||
}
|
||
if fc.Text != nil {
|
||
summary["textCount"] = summary["textCount"].(int) + 1
|
||
} else {
|
||
summary["fileCount"] = summary["fileCount"].(int) + 1
|
||
if fc.IsChunked {
|
||
summary["chunkedCount"] = summary["chunkedCount"].(int) + 1
|
||
}
|
||
}
|
||
switch status {
|
||
case "active":
|
||
if isExpired {
|
||
continue
|
||
}
|
||
case "expired":
|
||
if !isExpired {
|
||
continue
|
||
}
|
||
}
|
||
filtered = append(filtered, *fc)
|
||
}
|
||
|
||
// 排序(白名单字段,防注入)
|
||
sortFileCodes(filtered, sortBy, desc)
|
||
total := len(filtered)
|
||
offset := (page - 1) * size
|
||
end := offset + size
|
||
if offset > total {
|
||
offset = total
|
||
}
|
||
if end > total {
|
||
end = total
|
||
}
|
||
items := make([]gin.H, 0, end-offset)
|
||
for i := offset; i < end; i++ {
|
||
items = append(items, buildAdminFileItem(&filtered[i], now))
|
||
}
|
||
response.OK(c, gin.H{
|
||
"page": page, "size": size, "data": items, "total": total, "summary": summary,
|
||
})
|
||
}
|
||
|
||
// buildAdminFileItem 构造管理端文件条目(snake_case 与 camelCase 双份,前端宽松解析)。
|
||
func buildAdminFileItem(fc *model.FileCodes, now time.Time) gin.H {
|
||
isText := fc.Text != nil
|
||
isExpired := fc.Expired(now)
|
||
name := fc.Prefix + fc.Suffix
|
||
var remaining any
|
||
if fc.ExpiredCount >= 0 {
|
||
remaining = maxInt(fc.ExpiredCount, 0)
|
||
}
|
||
var expiredAt any
|
||
if fc.ExpiredAt != nil {
|
||
expiredAt = fc.ExpiredAt.Format(time.RFC3339)
|
||
}
|
||
item := gin.H{
|
||
"id": fc.ID, "code": fc.Code, "name": name,
|
||
"prefix": fc.Prefix, "suffix": fc.Suffix,
|
||
"size": fc.Size,
|
||
"isText": isText, "is_text": isText,
|
||
"isChunked": fc.IsChunked, "is_chunked": fc.IsChunked,
|
||
"isExpired": isExpired, "is_expired": isExpired,
|
||
"expiredAt": expiredAt, "expired_at": expiredAt,
|
||
"expiredCount": fc.ExpiredCount, "expired_count": fc.ExpiredCount,
|
||
"usedCount": fc.UsedCount, "used_count": fc.UsedCount,
|
||
"createdAt": fc.CreatedAt.Format(time.RFC3339), "created_at": fc.CreatedAt.Format(time.RFC3339),
|
||
"hasDownloadLimit": fc.ExpiredCount >= 0, "has_download_limit": fc.ExpiredCount >= 0,
|
||
"isPermanent": fc.ExpiredAt == nil && fc.ExpiredCount < 0, "is_permanent": fc.ExpiredAt == nil && fc.ExpiredCount < 0,
|
||
"remainingDownloads": remaining, "remaining_downloads": remaining,
|
||
"engine": fc.Engine, // 26.9:归属引擎(管理端展示/排查用)
|
||
}
|
||
if fc.FileHash != nil {
|
||
item["fileHash"] = *fc.FileHash
|
||
item["file_hash"] = *fc.FileHash
|
||
} else {
|
||
item["fileHash"] = nil
|
||
item["file_hash"] = nil
|
||
}
|
||
if isText {
|
||
item["text"] = true
|
||
} else {
|
||
item["text"] = false
|
||
}
|
||
return item
|
||
}
|
||
|
||
// adminFileDetail 文件详情(GET ?id= 或 POST {id})。
|
||
func (d *Deps) adminFileDetail(c *gin.Context) {
|
||
id, err := requestID(c)
|
||
if err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
fc, err := d.fileByID(c, id)
|
||
if err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
item := buildAdminFileItem(fc, time.Now())
|
||
if fc.Text != nil {
|
||
item["content"] = *fc.Text
|
||
}
|
||
response.OK(c, item)
|
||
}
|
||
|
||
// adminFileUpdate 更新分享字段(对齐参考 update_file:code 冲突 400)。
|
||
func (d *Deps) adminFileUpdate(c *gin.Context) {
|
||
var body struct {
|
||
ID int64 `json:"id"`
|
||
Code *string `json:"code"`
|
||
Prefix *string `json:"prefix"`
|
||
Suffix *string `json:"suffix"`
|
||
ExpiredAt *string `json:"expired_at"`
|
||
ExpiredCount *int `json:"expired_count"`
|
||
}
|
||
if err := bindJSONOrForm(c, &body); err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
if body.ID <= 0 {
|
||
response.Fail(c, http.StatusBadRequest, "请选择要更新的文件")
|
||
return
|
||
}
|
||
ctx := c.Request.Context()
|
||
fc, err := d.fileByID(c, body.ID)
|
||
if err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
updates := map[string]any{}
|
||
if body.Code != nil && *body.Code != fc.Code {
|
||
var cnt int64
|
||
if err := d.DB.WithContext(ctx).Model(&model.FileCodes{}).
|
||
Where("code = ? AND id <> ?", *body.Code, fc.ID).Count(&cnt).Error; err != nil {
|
||
respondError(c, errInternal("查询取件码失败: "+err.Error()))
|
||
return
|
||
}
|
||
if cnt > 0 {
|
||
response.Fail(c, http.StatusBadRequest, "code已存在")
|
||
return
|
||
}
|
||
updates["code"] = *body.Code
|
||
}
|
||
if body.Prefix != nil && *body.Prefix != fc.Prefix {
|
||
updates["prefix"] = *body.Prefix
|
||
}
|
||
if body.Suffix != nil && *body.Suffix != fc.Suffix {
|
||
updates["suffix"] = *body.Suffix
|
||
}
|
||
if body.ExpiredAt != nil && *body.ExpiredAt != "" {
|
||
t, err := parseISOTime(*body.ExpiredAt)
|
||
if err != nil {
|
||
response.Fail(c, http.StatusBadRequest, "expired_at 时间格式错误")
|
||
return
|
||
}
|
||
updates["expired_at"] = t
|
||
}
|
||
if body.ExpiredCount != nil && *body.ExpiredCount != fc.ExpiredCount {
|
||
updates["expired_count"] = *body.ExpiredCount
|
||
}
|
||
if len(updates) > 0 {
|
||
if err := d.DB.WithContext(ctx).Model(&model.FileCodes{}).
|
||
Where("id = ?", fc.ID).Updates(updates).Error; err != nil {
|
||
respondError(c, errInternal("更新失败: "+err.Error()))
|
||
return
|
||
}
|
||
}
|
||
response.OK(c, "更新成功")
|
||
}
|
||
|
||
// adminFileDelete 删除单个分享(连带删除存储文件,DELETE {id} 或 POST {id})。
|
||
func (d *Deps) adminFileDelete(c *gin.Context) {
|
||
var body struct {
|
||
ID int64 `json:"id"`
|
||
}
|
||
if err := bindJSONOrForm(c, &body); err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
if body.ID <= 0 {
|
||
if v := c.Query("id"); v != "" {
|
||
body.ID, _ = strconv.ParseInt(v, 10, 64)
|
||
}
|
||
}
|
||
if body.ID <= 0 {
|
||
response.Fail(c, http.StatusBadRequest, "请选择要删除的文件")
|
||
return
|
||
}
|
||
fc, err := d.fileByID(c, body.ID)
|
||
if err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
if err := d.deleteFileCode(c, fc); err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
response.OK(c, nil)
|
||
}
|
||
|
||
// adminFileBatchDelete 批量删除(对齐参考 delete_files 的统计响应)。
|
||
func (d *Deps) adminFileBatchDelete(c *gin.Context) {
|
||
var body struct {
|
||
IDs []int64 `json:"ids"`
|
||
}
|
||
if err := bindJSONOrForm(c, &body); err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
if len(body.IDs) == 0 {
|
||
response.Fail(c, http.StatusBadRequest, "请选择要删除的文件")
|
||
return
|
||
}
|
||
deleted, missing, failed := d.deleteMany(c, body.IDs)
|
||
response.OK(c, gin.H{
|
||
"requestedCount": len(body.IDs), "requested_count": len(body.IDs),
|
||
"deletedCount": len(deleted), "deleted_count": len(deleted),
|
||
"missingCount": len(missing), "missing_count": len(missing),
|
||
"failedCount": len(failed), "failed_count": len(failed),
|
||
"deleted": deleted, "missing": missing, "failed": failed,
|
||
})
|
||
}
|
||
|
||
// adminFileBatchUpdate 批量更新(对齐参考 batch_update_files)。
|
||
func (d *Deps) adminFileBatchUpdate(c *gin.Context) {
|
||
var body struct {
|
||
IDs []int64 `json:"ids"`
|
||
ExpiredAt *string `json:"expired_at"`
|
||
ExpiredCount *int `json:"expired_count"`
|
||
ClearExpiredAt bool `json:"clearExpiredAt"`
|
||
ClearExpiredAlt bool `json:"clear_expired_at"`
|
||
}
|
||
if err := bindJSONOrForm(c, &body); err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
if len(body.IDs) == 0 {
|
||
response.Fail(c, http.StatusBadRequest, "请选择要更新的文件")
|
||
return
|
||
}
|
||
updates := map[string]any{}
|
||
shouldClear := body.ClearExpiredAt || body.ClearExpiredAlt
|
||
switch {
|
||
case shouldClear:
|
||
updates["expired_at"] = nil
|
||
updates["expired_count"] = -1
|
||
case body.ExpiredAt != nil && *body.ExpiredAt != "":
|
||
t, err := parseISOTime(*body.ExpiredAt)
|
||
if err != nil {
|
||
response.Fail(c, http.StatusBadRequest, "expired_at 时间格式错误")
|
||
return
|
||
}
|
||
updates["expired_at"] = t
|
||
}
|
||
if !shouldClear && body.ExpiredCount != nil {
|
||
updates["expired_count"] = *body.ExpiredCount
|
||
}
|
||
if len(updates) == 0 {
|
||
response.Fail(c, http.StatusBadRequest, "请选择要更新的字段")
|
||
return
|
||
}
|
||
ctx := c.Request.Context()
|
||
updated, missing, failed := 0, []int64{}, []gin.H{}
|
||
for _, id := range body.IDs {
|
||
res := d.DB.WithContext(ctx).Model(&model.FileCodes{}).
|
||
Where("id = ?", id).Updates(updates)
|
||
switch {
|
||
case res.Error != nil:
|
||
failed = append(failed, gin.H{"id": id, "reason": res.Error.Error()})
|
||
case res.RowsAffected == 0:
|
||
missing = append(missing, id)
|
||
default:
|
||
updated++
|
||
}
|
||
}
|
||
response.OK(c, gin.H{
|
||
"requestedCount": len(body.IDs), "requested_count": len(body.IDs),
|
||
"updatedCount": updated, "updated_count": updated,
|
||
"missingCount": len(missing), "missing_count": len(missing),
|
||
"failedCount": len(failed), "failed_count": len(failed),
|
||
"updated": updated, "missing": missing, "failed": failed,
|
||
})
|
||
}
|
||
|
||
// adminFilePolicyAction 单文件策略动作(对齐参考 apply_file_policy_action)。
|
||
func (d *Deps) adminFilePolicyAction(c *gin.Context) {
|
||
var body struct {
|
||
ID int64 `json:"id"`
|
||
Action string `json:"action"`
|
||
DownloadLimit *int `json:"downloadLimit"`
|
||
}
|
||
if err := bindJSONOrForm(c, &body); err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
fc, err := d.fileByID(c, body.ID)
|
||
if err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
updates, err := buildPolicyUpdate(fc, body.Action, body.DownloadLimit)
|
||
if err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
if err := d.DB.WithContext(c.Request.Context()).Model(&model.FileCodes{}).
|
||
Where("id = ?", fc.ID).Updates(updates).Error; err != nil {
|
||
respondError(c, errInternal("策略执行失败: "+err.Error()))
|
||
return
|
||
}
|
||
response.OK(c, gin.H{"id": fc.ID, "action": body.Action})
|
||
}
|
||
|
||
// adminFileBatchPolicyAction 批量策略动作。
|
||
func (d *Deps) adminFileBatchPolicyAction(c *gin.Context) {
|
||
var body struct {
|
||
IDs []int64 `json:"ids"`
|
||
Action string `json:"action"`
|
||
DownloadLimit *int `json:"downloadLimit"`
|
||
}
|
||
if err := bindJSONOrForm(c, &body); err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
if len(body.IDs) == 0 {
|
||
response.Fail(c, http.StatusBadRequest, "请选择要更新的文件")
|
||
return
|
||
}
|
||
ctx := c.Request.Context()
|
||
updated, missing, failed := 0, []int64{}, []gin.H{}
|
||
for _, id := range body.IDs {
|
||
fc, err := d.fileByID(c, id)
|
||
if err != nil {
|
||
if fc == nil {
|
||
missing = append(missing, id)
|
||
} else {
|
||
failed = append(failed, gin.H{"id": id, "reason": err.Error()})
|
||
}
|
||
continue
|
||
}
|
||
updates, err := buildPolicyUpdate(fc, body.Action, body.DownloadLimit)
|
||
if err != nil {
|
||
failed = append(failed, gin.H{"id": id, "reason": err.Error()})
|
||
continue
|
||
}
|
||
if err := d.DB.WithContext(ctx).Model(&model.FileCodes{}).
|
||
Where("id = ?", id).Updates(updates).Error; err != nil {
|
||
failed = append(failed, gin.H{"id": id, "reason": err.Error()})
|
||
continue
|
||
}
|
||
updated++
|
||
}
|
||
response.OK(c, gin.H{
|
||
"requestedCount": len(body.IDs), "requested_count": len(body.IDs),
|
||
"updatedCount": updated, "updated_count": updated,
|
||
"missingCount": len(missing), "missing_count": len(missing),
|
||
"failedCount": len(failed), "failed_count": len(failed),
|
||
"updated": updated, "missing": missing, "failed": failed,
|
||
})
|
||
}
|
||
|
||
// buildPolicyUpdate 构造策略动作更新字段(对齐参考 _build_policy_action_update)。
|
||
func buildPolicyUpdate(fc *model.FileCodes, action string, downloadLimit *int) (map[string]any, error) {
|
||
action = strings.ToLower(strings.TrimSpace(action))
|
||
now := time.Now()
|
||
switch action {
|
||
case "extend_24h":
|
||
return map[string]any{"expired_at": extendExpiration(fc, now, 24*time.Hour)}, nil
|
||
case "extend_7d":
|
||
return map[string]any{"expired_at": extendExpiration(fc, now, 7*24*time.Hour)}, nil
|
||
case "make_permanent":
|
||
return map[string]any{"expired_at": nil, "expired_count": -1}, nil
|
||
case "reset_download_limit":
|
||
limit := 5
|
||
if downloadLimit != nil {
|
||
limit = *downloadLimit
|
||
}
|
||
if limit < 1 {
|
||
return nil, errBadRequest("取件次数必须大于 0")
|
||
}
|
||
return map[string]any{"expired_count": limit}, nil
|
||
}
|
||
return nil, errBadRequest("不支持的策略动作")
|
||
}
|
||
|
||
// extendExpiration 在现有过期时间(未过期时)或当前时间基础上延长。
|
||
func extendExpiration(fc *model.FileCodes, now time.Time, d time.Duration) time.Time {
|
||
base := now
|
||
if fc.ExpiredAt != nil && fc.ExpiredAt.After(now) {
|
||
base = *fc.ExpiredAt
|
||
}
|
||
return base.Add(d)
|
||
}
|
||
|
||
// adminFileDownload 管理员下载原文件(不消耗次数;文本返回 JSON)。
|
||
func (d *Deps) adminFileDownload(c *gin.Context) {
|
||
id, err := requestID(c)
|
||
if err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
fc, err := d.fileByID(c, id)
|
||
if err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
if fc.Text != nil {
|
||
response.OK(c, *fc.Text)
|
||
return
|
||
}
|
||
if fc.FilePath == nil || fc.UUIDFileName == nil {
|
||
response.Fail(c, http.StatusNotFound, "文件不存在")
|
||
return
|
||
}
|
||
d.serveFile(c, fc)
|
||
}
|
||
|
||
// adminFilePreview 文本预览(对齐参考 preview_file:仅文本分享)。
|
||
func (d *Deps) adminFilePreview(c *gin.Context) {
|
||
id, err := requestID(c)
|
||
if err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
maxChars := clampInt(queryInt(c, "maxChars", 4000), 1, 20000)
|
||
fc, err := d.fileByID(c, id)
|
||
if err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
if fc.Text == nil {
|
||
response.Fail(c, http.StatusBadRequest, "仅文本分享支持预览")
|
||
return
|
||
}
|
||
content := []rune(*fc.Text)
|
||
truncated := len(content) > maxChars
|
||
preview := string(content[:minInt(len(content), maxChars)])
|
||
response.OK(c, gin.H{
|
||
"id": fc.ID, "code": fc.Code, "name": fc.Prefix + fc.Suffix,
|
||
"type": "text", "content": preview, "length": len(content),
|
||
"previewLength": len([]rune(preview)), "preview_length": len([]rune(preview)),
|
||
"truncated": truncated, "maxChars": maxChars, "max_chars": maxChars,
|
||
"createdAt": fc.CreatedAt.Format(time.RFC3339), "created_at": fc.CreatedAt.Format(time.RFC3339),
|
||
})
|
||
}
|
||
|
||
// ============ 配置 ============
|
||
|
||
// configKeys 管理端可见/可改的配置键(不含 jwt_secret;admin_token 屏蔽展示)。
|
||
// 26.9 新增键(需求 ①②③④⑩):背景图、页脚、通知开关、保存/存储策略、频率限制。
|
||
var configKeys = []string{
|
||
"site_name", "name", "description", "page_explain", "keywords",
|
||
"notify_title", "notify_content", "notify_enabled", "logo_url", "favicon_url",
|
||
"footer_text", "footer_beian", "background_url",
|
||
"openUpload", "uploadSize", "max_file_size", "allowed_file_types", "expireStyle",
|
||
"max_save_count", "max_save_seconds", "storageLimit",
|
||
"code_generate_type", "enableChunk",
|
||
"uploadMinute", "uploadCount", "errorMinute", "errorCount",
|
||
"loginCount", "loginMinute",
|
||
"opacity", "background", "showAdminAddr", "robotsText", "site_domain", // 26.9:站点对外域名
|
||
"upload_rate", "download_rate", // 26.9:上下行带宽字节/秒(0=不限速)
|
||
"adminSessionExpire", "storage_path", "local_storage_path",
|
||
"file_storage",
|
||
// 26.9 存储引擎与引擎参数(热切换;凭据为敏感键,get 掩码/update 空跳过)
|
||
"storage_engine",
|
||
"local_storage_path",
|
||
"webdav_url", "webdav_root_path", "webdav_username", "webdav_password",
|
||
"s3_endpoint_url", "s3_region_name", "s3_bucket_name",
|
||
"s3_access_key_id", "s3_secret_access_key", "aws_session_token", "s3_addressing_style",
|
||
}
|
||
|
||
// intConfigKeys 需按 schema 边界校验的整型键(adminConfigUpdate 归一化用)。
|
||
// v1 既有键保留原语义;26.9 新增键(max_file_size/max_save_count/notify_enabled)
|
||
// 的边界来自 settings.KVSchema(单一事实来源在 config/schema.go)。
|
||
var intConfigKeys = []string{
|
||
"openUpload", "enableChunk", "showAdminAddr", "storageLimit",
|
||
"uploadMinute", "uploadCount", "errorMinute", "errorCount",
|
||
"loginCount", "loginMinute", "max_save_seconds", "uploadSize",
|
||
"adminSessionExpire",
|
||
"max_save_count", "max_file_size", "notify_enabled",
|
||
"upload_rate", "download_rate", // 26.9
|
||
}
|
||
|
||
// validateConfigValue 按 settings.KVSchema 校验单个配置值:
|
||
// - 整型键:Min/Max 边界(如 max_file_size ≤ 10GiB、notify_enabled ∈ {0,1});
|
||
// - 字符串键:长度上限;
|
||
// - 列表键(expireStyle/allowed_file_types):必须可解析为字符串数组且非空。
|
||
//
|
||
// 校验不通过返回中文 400 错误;未知键不做拦截(与 v1 行为一致,交由 configKeys 过滤)。
|
||
func validateConfigValue(key string, v any) error {
|
||
entry := settings.KVSchemaByKey(key)
|
||
if entry == nil {
|
||
return nil
|
||
}
|
||
switch entry.Type {
|
||
case "int", "int64":
|
||
n, ok := toInt64(v)
|
||
if !ok {
|
||
return errBadRequest(fmt.Sprintf("%s 必须是整数", key))
|
||
}
|
||
if n < entry.Min {
|
||
return errBadRequest(fmt.Sprintf("%s 不能小于 %d", key, entry.Min))
|
||
}
|
||
if entry.Max >= 0 && n > entry.Max {
|
||
return errBadRequest(fmt.Sprintf("%s 不能大于 %d", key, entry.Max))
|
||
}
|
||
case "string":
|
||
s, ok := v.(string)
|
||
if !ok {
|
||
return errBadRequest(fmt.Sprintf("%s 必须是字符串", key))
|
||
}
|
||
if entry.Max >= 0 && len([]rune(s)) > int(entry.Max) {
|
||
return errBadRequest(fmt.Sprintf("%s 长度不能超过 %d 字符", key, entry.Max))
|
||
}
|
||
case "[]string":
|
||
list := toStrSlice(v)
|
||
if list == nil {
|
||
return errBadRequest(fmt.Sprintf("%s 必须是字符串数组", key))
|
||
}
|
||
if len(list) == 0 {
|
||
return errBadRequest(fmt.Sprintf("%s 至少保留一项", key))
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// toInt64 宽松整型转换(JSON 数字 float64、字符串、int/int64)。
|
||
func toInt64(v any) (int64, bool) {
|
||
switch n := v.(type) {
|
||
case int:
|
||
return int64(n), true
|
||
case int64:
|
||
return n, true
|
||
case float64:
|
||
return int64(n), true
|
||
case string:
|
||
if i, err := strconv.ParseInt(strings.TrimSpace(n), 10, 64); err == nil {
|
||
return i, true
|
||
}
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
// toStrSlice 宽松字符串数组转换:JSON 数组 / 逗号分隔字符串。
|
||
func toStrSlice(v any) []string {
|
||
switch s := v.(type) {
|
||
case []string:
|
||
return s
|
||
case []any:
|
||
out := make([]string, 0, len(s))
|
||
for _, item := range s {
|
||
if item == nil {
|
||
continue
|
||
}
|
||
out = append(out, fmt.Sprintf("%v", item))
|
||
}
|
||
return out
|
||
case string:
|
||
var out []string
|
||
for _, item := range strings.Split(s, ",") {
|
||
if item = strings.TrimSpace(item); item != "" {
|
||
out = append(out, item)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// adminConfigGet 读取配置(对齐参考 get_config:admin_token 屏蔽、jwt_secret 不下发)。
|
||
// 26.9:引擎凭据类敏感键返回掩码占位(前端表单"留空=不修改");storage_engine 为当前热切换后的引擎。
|
||
func (d *Deps) adminConfigGet(c *gin.Context) {
|
||
cfg := d.Cfg
|
||
out := gin.H{}
|
||
for _, key := range configKeys {
|
||
if v, ok := cfg.Get(key); ok {
|
||
out[key] = v
|
||
}
|
||
}
|
||
for _, key := range settings.SensitiveKeys {
|
||
if _, present := out[key]; present {
|
||
if key == "admin_token" {
|
||
out[key] = "" // 屏蔽(既有语义)
|
||
} else {
|
||
out[key] = settings.SensitiveMaskValue // 掩码占位
|
||
}
|
||
}
|
||
}
|
||
// jwt_secret 永不下发
|
||
delete(out, "jwt_secret")
|
||
// 引擎运行时状态(26.9:热切换即时生效,无需重启)
|
||
out["_engine_hint"] = gin.H{
|
||
"storage_backend": d.Store.CurrentName(),
|
||
"engines": gin.H{"local": true, "s3": true, "webdav": true},
|
||
"note": "存储引擎支持运行时热切换(POST /admin/storage/switch);修改引擎参数保存后下次构建生效",
|
||
}
|
||
response.OK(c, out)
|
||
}
|
||
|
||
// adminConfigUpdate 部分更新配置(对齐参考 update_config:改密自动轮换 jwt_secret)。
|
||
func (d *Deps) adminConfigUpdate(c *gin.Context) {
|
||
var patch map[string]any
|
||
ct := c.GetHeader("Content-Type")
|
||
var err error
|
||
if strings.Contains(ct, "application/json") {
|
||
err = c.ShouldBindJSON(&patch)
|
||
} else {
|
||
patch = map[string]any{}
|
||
err = c.Request.ParseForm()
|
||
if err == nil {
|
||
for k, v := range c.Request.PostForm {
|
||
if len(v) > 0 {
|
||
patch[k] = v[0]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if err != nil {
|
||
response.Fail(c, http.StatusBadRequest, "请求体格式错误")
|
||
return
|
||
}
|
||
if len(patch) == 0 {
|
||
response.Fail(c, http.StatusBadRequest, "没有需要更新的配置")
|
||
return
|
||
}
|
||
|
||
dbPatch := map[string]any{}
|
||
for k, v := range patch {
|
||
known := false
|
||
for _, key := range configKeys {
|
||
if key == k {
|
||
known = true
|
||
break
|
||
}
|
||
}
|
||
if !known {
|
||
continue
|
||
}
|
||
// 类型归一 + schema 边界校验(26.9:数值/字符串长度/列表键统一走 KVSchema)
|
||
isInt := false
|
||
for _, key := range intConfigKeys {
|
||
if key == k {
|
||
isInt = true
|
||
break
|
||
}
|
||
}
|
||
switch {
|
||
case isInt:
|
||
if n, ok := toInt(v); ok {
|
||
if err := validateConfigValue(k, n); err != nil {
|
||
response.Fail(c, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
dbPatch[k] = n
|
||
} else {
|
||
response.Fail(c, http.StatusBadRequest, fmt.Sprintf("%s 必须是整数", k))
|
||
return
|
||
}
|
||
case k == "opacity":
|
||
if f, ok := toFloat(v); ok {
|
||
dbPatch[k] = f
|
||
}
|
||
case k == "expireStyle" || k == "allowed_file_types":
|
||
list := toStrSlice(v)
|
||
if list == nil {
|
||
response.Fail(c, http.StatusBadRequest, fmt.Sprintf("%s 必须是字符串数组", k))
|
||
return
|
||
}
|
||
if err := validateConfigValue(k, v); err != nil {
|
||
response.Fail(c, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
dbPatch[k] = list
|
||
default:
|
||
// 字符串键按 schema 长度上限校验(背景图/页脚/备案号/通知等)
|
||
if err := validateConfigValue(k, v); err != nil {
|
||
response.Fail(c, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
dbPatch[k] = v
|
||
}
|
||
}
|
||
|
||
// 管理员密码:空串忽略;明文则哈希并轮换 jwt_secret
|
||
passwordChanged := false
|
||
if raw, ok := patch["admin_token"]; ok {
|
||
if s, isStr := raw.(string); isStr && strings.TrimSpace(s) != "" {
|
||
if !settings.IsPasswordHashed(s) {
|
||
dbPatch["admin_token"] = settings.HashPassword(s)
|
||
} else {
|
||
dbPatch["admin_token"] = s
|
||
}
|
||
passwordChanged = true
|
||
}
|
||
}
|
||
// adminSessionExpire 校验(1~365 整天)
|
||
if v, ok := dbPatch["adminSessionExpire"]; ok {
|
||
sec, _ := toInt(v)
|
||
if sec < 86400 || sec > 365*86400 || sec%86400 != 0 {
|
||
response.Fail(c, http.StatusBadRequest, "adminSessionExpire 必须是 1 到 365 个整天")
|
||
return
|
||
}
|
||
}
|
||
if v, ok := dbPatch["storageLimit"]; ok {
|
||
n, _ := toInt(v)
|
||
if n < 0 {
|
||
response.Fail(c, http.StatusBadRequest, "storageLimit 不能小于 0")
|
||
return
|
||
}
|
||
}
|
||
// 背景图 URL 白名单协议(需求 ①):http(s)、data: 与站内相对路径,防 javascript: 注入
|
||
if v, ok := dbPatch["background_url"]; ok {
|
||
if s, isStr := v.(string); isStr {
|
||
s = strings.TrimSpace(s)
|
||
if s != "" && !strings.HasPrefix(s, "http://") && !strings.HasPrefix(s, "https://") &&
|
||
!strings.HasPrefix(s, "data:image/") && !strings.HasPrefix(s, "/") {
|
||
response.Fail(c, http.StatusBadRequest, "background_url 仅支持 http(s) 地址、data:image 图片或站内相对路径")
|
||
return
|
||
}
|
||
dbPatch["background_url"] = s
|
||
}
|
||
}
|
||
// L7:notify_content 白名单净化(仅保留文本与 <a href=http(s)|/|#>),防存储型 XSS
|
||
if v, ok := dbPatch["notify_content"]; ok {
|
||
if s, isStr := v.(string); isStr {
|
||
dbPatch["notify_content"] = settings.SanitizeInlineHTML(s)
|
||
}
|
||
}
|
||
if passwordChanged {
|
||
dbPatch["jwt_secret"] = settings.GenerateJWTSecret()
|
||
}
|
||
|
||
// 26.9:site_domain 规范化(http(s)://host[:port];空=用当前地址)
|
||
if raw, ok := dbPatch["site_domain"]; ok {
|
||
sv, _ := raw.(string)
|
||
normalized, err := normalizeSiteDomain(sv)
|
||
if err != nil {
|
||
response.Fail(c, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
dbPatch["site_domain"] = normalized
|
||
}
|
||
|
||
// 26.9 引擎键处理:参数键与 storage_engine 分离。
|
||
// 1) storage_engine 只接受合法枚举;
|
||
// 2) 敏感凭据键空串/掩码=不修改(避免管理端表单回显把密钥抹掉);
|
||
// 3) 先持久化普通键+参数键 → Invalidate 对应引擎缓存 → 再尝试 Switch 新引擎;
|
||
// 4) Switch 失败回滚 storage_engine 的 KV 值并返回 503(参数与普通键保留)。
|
||
newEngine := ""
|
||
if raw, ok := dbPatch["storage_engine"]; ok {
|
||
s, isStr := raw.(string)
|
||
if !isStr || !storage.ValidEngine(strings.TrimSpace(s)) {
|
||
response.Fail(c, http.StatusBadRequest, "storage_engine 仅支持 local|s3|webdav")
|
||
return
|
||
}
|
||
newEngine = strings.TrimSpace(s)
|
||
delete(dbPatch, "storage_engine")
|
||
}
|
||
changedParamEngine := engineOfParamKeys(dbPatch)
|
||
for _, sk := range settings.SensitiveKeys {
|
||
if v, ok := dbPatch[sk]; ok {
|
||
if s, isStr := v.(string); isStr && (strings.TrimSpace(s) == "" || s == settings.SensitiveMaskValue) {
|
||
delete(dbPatch, sk) // 空/掩码=不修改
|
||
}
|
||
}
|
||
}
|
||
|
||
ctx := c.Request.Context()
|
||
if err := d.Mgr.UpdateKV(ctx, dbPatch); err != nil {
|
||
respondError(c, errInternal("保存配置失败: "+err.Error()))
|
||
return
|
||
}
|
||
// 引擎参数已变:使对应引擎实例缓存失效(下次构建用新参数)
|
||
for _, eng := range changedParamEngine {
|
||
d.Store.Invalidate(eng)
|
||
}
|
||
if newEngine != "" && newEngine != d.Store.CurrentName() {
|
||
if _, err := d.Store.Switch(newEngine); err != nil {
|
||
// 切换失败:不持久化新引擎名(保持旧引擎 KV),参数/普通键已保存
|
||
respondError(c, &apiError{Status: http.StatusServiceUnavailable, Msg: "存储引擎切换失败,已保持原引擎: " + err.Error()})
|
||
return
|
||
}
|
||
if err := d.Mgr.UpdateKV(ctx, map[string]any{config.KeyStorageEngine: newEngine}); err != nil {
|
||
respondError(c, errInternal("保存存储引擎设置失败: "+err.Error()))
|
||
return
|
||
}
|
||
log.Printf("[storage] 存储引擎已热切换: %s -> %s", d.Cfg.Engine(), newEngine)
|
||
}
|
||
if err := d.Mgr.Reload(ctx); err != nil {
|
||
respondError(c, errInternal("配置重载失败: "+err.Error()))
|
||
return
|
||
}
|
||
d.syncRateRules()
|
||
response.OK(c, gin.H{"ok": true, "engine": d.Store.CurrentName()})
|
||
}
|
||
|
||
// engineOfParamKeys 判断 patch 涉及哪些引擎的参数(返回需 Invalidate 的引擎列表)。
|
||
func engineOfParamKeys(patch map[string]any) []string {
|
||
affect := map[string]bool{}
|
||
for k := range patch {
|
||
switch k {
|
||
case "local_storage_path", "storage_path":
|
||
affect["local"] = true
|
||
case "webdav_url", "webdav_root_path", "webdav_username", "webdav_password":
|
||
affect["webdav"] = true
|
||
case "s3_endpoint_url", "s3_region_name", "s3_bucket_name",
|
||
"s3_access_key_id", "s3_secret_access_key", "aws_session_token", "s3_addressing_style":
|
||
affect["s3"] = true
|
||
}
|
||
}
|
||
out := make([]string, 0, len(affect))
|
||
for eng := range affect {
|
||
out = append(out, eng)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// adminStorageSwitch 26.9 存储引擎热切换:{engine:"local"|"s3"|"webdav"}。
|
||
// 成功:持久化 storage_engine KV 并返回当前引擎;失败:503 且原引擎不变。
|
||
func (d *Deps) adminStorageSwitch(c *gin.Context) {
|
||
var body struct {
|
||
Engine string `json:"engine" form:"engine"`
|
||
}
|
||
if err := bindJSONOrForm(c, &body); err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
engine := strings.TrimSpace(body.Engine)
|
||
if !storage.ValidEngine(engine) {
|
||
response.Fail(c, http.StatusBadRequest, "engine 仅支持 local|s3|webdav")
|
||
return
|
||
}
|
||
if engine != d.Store.CurrentName() {
|
||
if _, err := d.Store.Switch(engine); err != nil {
|
||
respondError(c, &apiError{Status: http.StatusServiceUnavailable, Msg: "存储引擎切换失败,已保持原引擎: " + err.Error()})
|
||
return
|
||
}
|
||
}
|
||
if err := d.Mgr.UpdateKV(c.Request.Context(), map[string]any{config.KeyStorageEngine: engine}); err != nil {
|
||
respondError(c, errInternal("保存存储引擎设置失败: "+err.Error()))
|
||
return
|
||
}
|
||
if err := d.Mgr.Reload(c.Request.Context()); err != nil {
|
||
respondError(c, errInternal("配置重载失败: "+err.Error()))
|
||
return
|
||
}
|
||
log.Printf("[storage] 存储引擎已热切换 -> %s", engine)
|
||
response.OK(c, gin.H{"ok": true, "engine": engine})
|
||
}
|
||
|
||
// adminChangePassword 修改管理员密码(校验旧密码;新密码哈希 + 轮换 jwt_secret)。
|
||
func (d *Deps) adminChangePassword(c *gin.Context) {
|
||
var body struct {
|
||
OldPassword string `json:"old_password" form:"old_password"`
|
||
NewPassword string `json:"new_password" form:"new_password"`
|
||
}
|
||
if err := bindJSONOrForm(c, &body); err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
if len(body.NewPassword) < 8 {
|
||
response.Fail(c, http.StatusBadRequest, "新密码长度至少 8 位")
|
||
return
|
||
}
|
||
stored := d.Cfg.GetString("admin_token")
|
||
if stored == "" || !settings.VerifyPassword(body.OldPassword, stored) {
|
||
response.Fail(c, http.StatusUnauthorized, "旧密码错误")
|
||
return
|
||
}
|
||
ctx := c.Request.Context()
|
||
patch := map[string]any{
|
||
"admin_token": settings.HashPassword(body.NewPassword),
|
||
"jwt_secret": settings.GenerateJWTSecret(),
|
||
}
|
||
if err := d.Mgr.UpdateKV(ctx, patch); err != nil {
|
||
respondError(c, errInternal("保存密码失败: "+err.Error()))
|
||
return
|
||
}
|
||
if err := d.Mgr.Reload(ctx); err != nil {
|
||
respondError(c, errInternal("配置重载失败: "+err.Error()))
|
||
return
|
||
}
|
||
response.OK(c, gin.H{"ok": true})
|
||
}
|
||
|
||
// ============ 审计日志查询(需求 ③)============
|
||
|
||
// adminAuditList 分页查询审计日志:
|
||
// 参数 page/size/action/result/ip/start_time/end_time(ISO 8601)。
|
||
func (d *Deps) adminAuditList(c *gin.Context) {
|
||
page := maxInt(queryInt(c, "page", 1), 1)
|
||
size := clampInt(queryInt(c, "size", queryInt(c, "pageSize", 20)), 1, 200)
|
||
action := strings.TrimSpace(c.Query("action"))
|
||
result := strings.TrimSpace(c.Query("result"))
|
||
ip := strings.TrimSpace(c.Query("ip"))
|
||
var begin, end *time.Time
|
||
if raw := strings.TrimSpace(c.Query("start_time")); raw != "" {
|
||
if t, err := parseISOTime(raw); err == nil {
|
||
begin = &t
|
||
} else {
|
||
response.Fail(c, http.StatusBadRequest, "start_time 时间格式错误")
|
||
return
|
||
}
|
||
}
|
||
if raw := strings.TrimSpace(c.Query("end_time")); raw != "" {
|
||
if t, err := parseISOTime(raw); err == nil {
|
||
end = &t
|
||
} else {
|
||
response.Fail(c, http.StatusBadRequest, "end_time 时间格式错误")
|
||
return
|
||
}
|
||
}
|
||
logs, total, err := d.AuditSvc.Query(page, size, action, ip, result, begin, end)
|
||
if err != nil {
|
||
respondError(c, errInternal("查询审计日志失败: "+err.Error()))
|
||
return
|
||
}
|
||
items := make([]gin.H, 0, len(logs))
|
||
for i := range logs {
|
||
items = append(items, buildAuditItem(&logs[i]))
|
||
}
|
||
response.OK(c, gin.H{"data": items, "total": total, "page": page, "size": size})
|
||
}
|
||
|
||
// buildAuditItem 审计行(snake_case 原生 + camelCase 双份)。
|
||
func buildAuditItem(a *model.AuditLog) gin.H {
|
||
return gin.H{
|
||
"id": a.ID,
|
||
"action": a.Action,
|
||
"file_code": a.FileCode, "fileCode": a.FileCode,
|
||
"file_name": a.FileName, "fileName": a.FileName,
|
||
"size_bytes": a.SizeBytes, "sizeBytes": a.SizeBytes,
|
||
"transferred_bytes": a.TransferredBytes, "transferredBytes": a.TransferredBytes,
|
||
"ip": a.IP,
|
||
"user_agent": a.UserAgent, "userAgent": a.UserAgent,
|
||
"device_os": a.DeviceOS, "deviceOs": a.DeviceOS,
|
||
"device_browser": a.DeviceBrowser, "deviceBrowser": a.DeviceBrowser,
|
||
"device_type": a.DeviceType, "deviceType": a.DeviceType,
|
||
"actor": a.Actor,
|
||
"result": a.Result,
|
||
"error_msg": a.ErrorMsg, "errorMsg": a.ErrorMsg,
|
||
"duration_ms": a.DurationMs, "durationMs": a.DurationMs,
|
||
"created_at": a.CreatedAt.Format(time.RFC3339), "createdAt": a.CreatedAt.Format(time.RFC3339),
|
||
}
|
||
}
|
||
|
||
// ============ 内部辅助 ============
|
||
|
||
// fileByID 按 ID 查询分享记录。
|
||
func (d *Deps) fileByID(c *gin.Context, id int64) (*model.FileCodes, error) {
|
||
if id <= 0 {
|
||
return nil, errBadRequest("无效的文件 ID")
|
||
}
|
||
var fc model.FileCodes
|
||
if err := d.DB.WithContext(c.Request.Context()).
|
||
Where("id = ?", id).First(&fc).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, errNotFound("文件不存在")
|
||
}
|
||
return nil, errInternal("查询失败: " + err.Error())
|
||
}
|
||
return &fc, nil
|
||
}
|
||
|
||
// deleteFileCode 删除分享记录与存储文件(文本分享无存储文件)。
|
||
func (d *Deps) deleteFileCode(c *gin.Context, fc *model.FileCodes) error {
|
||
if fc.Text == nil && fc.FilePath != nil && fc.UUIDFileName != nil {
|
||
// 26.9:删除走文件归属引擎(旧引擎里的文件也要能删掉)
|
||
if delStore, dErr := d.storeFor(fc.Engine); dErr == nil {
|
||
if err := delStore.DeleteFile(c.Request.Context(), fileSavePath(fc)); err != nil && !errors.Is(err, storage.ErrNotFound) {
|
||
return errInternal("存储文件删除失败: " + err.Error())
|
||
}
|
||
}
|
||
}
|
||
if err := d.DB.WithContext(c.Request.Context()).
|
||
Where("id = ?", fc.ID).Delete(&model.FileCodes{}).Error; err != nil {
|
||
return errInternal("删除记录失败: " + err.Error())
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// deleteMany 批量删除:返回 (已删除, 不存在, 失败)。
|
||
func (d *Deps) deleteMany(c *gin.Context, ids []int64) (deleted []int64, missing []int64, failed []gin.H) {
|
||
deleted, missing = []int64{}, []int64{}
|
||
failed = []gin.H{}
|
||
for _, id := range ids {
|
||
fc, err := d.fileByID(c, id)
|
||
if err != nil {
|
||
missing = append(missing, id)
|
||
continue
|
||
}
|
||
if err := d.deleteFileCode(c, fc); err != nil {
|
||
failed = append(failed, gin.H{"id": id, "reason": err.Error()})
|
||
continue
|
||
}
|
||
deleted = append(deleted, id)
|
||
}
|
||
return
|
||
}
|
||
|
||
// requestID 从 query 或 JSON body 取 id。
|
||
func requestID(c *gin.Context) (int64, error) {
|
||
if v := c.Query("id"); v != "" {
|
||
n, err := strconv.ParseInt(v, 10, 64)
|
||
if err != nil {
|
||
return 0, errBadRequest("无效的文件 ID")
|
||
}
|
||
return n, nil
|
||
}
|
||
var body struct {
|
||
ID int64 `json:"id"`
|
||
}
|
||
if err := bindJSONOrForm(c, &body); err != nil {
|
||
return 0, err
|
||
}
|
||
return body.ID, nil
|
||
}
|
||
|
||
// queryInt 读取整数 query 参数。
|
||
func queryInt(c *gin.Context, key string, def int) int {
|
||
raw := c.Query(key)
|
||
if raw == "" {
|
||
return def
|
||
}
|
||
n, err := strconv.Atoi(raw)
|
||
if err != nil {
|
||
return def
|
||
}
|
||
return n
|
||
}
|
||
|
||
// maxInt / minInt / clampInt 整数辅助。
|
||
func maxInt(a, b int) int {
|
||
if a > b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
func minInt(a, b int) int {
|
||
if a < b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
func clampInt(v, lo, hi int) int { return maxInt(lo, minInt(hi, v)) }
|
||
|
||
func boolToInt(b bool) int {
|
||
if b {
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func boolToInt64(b bool) int64 {
|
||
if b {
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func firstNonEmpty(items ...string) string {
|
||
for _, s := range items {
|
||
if s != "" {
|
||
return s
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// toInt / toFloat 宽松类型转换(JSON 数字 float64、字符串、int)。
|
||
func toInt(v any) (int, bool) {
|
||
switch n := v.(type) {
|
||
case int:
|
||
return n, true
|
||
case int64:
|
||
return int(n), true
|
||
case float64:
|
||
return int(n), true
|
||
case string:
|
||
if i, err := strconv.Atoi(strings.TrimSpace(n)); err == nil {
|
||
return i, true
|
||
}
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
func toFloat(v any) (float64, bool) {
|
||
switch n := v.(type) {
|
||
case float64:
|
||
return n, true
|
||
case int:
|
||
return float64(n), true
|
||
case string:
|
||
if f, err := strconv.ParseFloat(strings.TrimSpace(n), 64); err == nil {
|
||
return f, true
|
||
}
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
// parseJSONNumber 解析 JSON 标量数字。
|
||
func parseJSONNumber(raw string, out *int64) error {
|
||
raw = strings.TrimSpace(raw)
|
||
n, err := strconv.ParseInt(raw, 10, 64)
|
||
if err != nil {
|
||
return fmt.Errorf("非法数字: %s", raw)
|
||
}
|
||
*out = n
|
||
return nil
|
||
}
|
||
|
||
// escapeLike 转义 LIKE 通配符(\ % _),配合 `LIKE ? ESCAPE '\'` 使用。
|
||
func escapeLike(s string) string {
|
||
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
||
return r.Replace(s)
|
||
}
|
||
|
||
// normalizeSortBy 归一化排序字段(白名单外回退 created_at)。
|
||
func normalizeSortBy(s string) string {
|
||
s = strings.ToLower(strings.ReplaceAll(strings.TrimSpace(s), "-", "_"))
|
||
switch s {
|
||
case "created_at", "createdat", "expired_at", "expiredat", "name", "size", "used_count", "usedcount", "code":
|
||
if s == "createdat" {
|
||
return "created_at"
|
||
}
|
||
if s == "expiredat" {
|
||
return "expired_at"
|
||
}
|
||
if s == "usedcount" {
|
||
return "used_count"
|
||
}
|
||
return s
|
||
}
|
||
return "created_at"
|
||
}
|
||
|
||
// sortRecentDesc 按创建时间倒序(插入排序,数据量小)。
|
||
func sortRecentDesc(items []model.FileCodes) {
|
||
for i := 1; i < len(items); i++ {
|
||
for j := i; j > 0 && items[j].CreatedAt.After(items[j-1].CreatedAt); j-- {
|
||
items[j], items[j-1] = items[j-1], items[j]
|
||
}
|
||
}
|
||
}
|
||
|
||
// sortFileCodes 按白名单字段排序。
|
||
func sortFileCodes(items []model.FileCodes, sortBy string, desc bool) {
|
||
less := func(a, b *model.FileCodes) bool {
|
||
switch sortBy {
|
||
case "expired_at":
|
||
var ta, tb time.Time
|
||
if a.ExpiredAt != nil {
|
||
ta = *a.ExpiredAt
|
||
}
|
||
if b.ExpiredAt != nil {
|
||
tb = *b.ExpiredAt
|
||
}
|
||
return ta.Before(tb)
|
||
case "name":
|
||
return a.Prefix+a.Suffix < b.Prefix+b.Suffix
|
||
case "size":
|
||
return a.Size < b.Size
|
||
case "used_count":
|
||
return a.UsedCount < b.UsedCount
|
||
case "code":
|
||
return a.Code < b.Code
|
||
default: // created_at
|
||
return a.CreatedAt.Before(b.CreatedAt)
|
||
}
|
||
}
|
||
// 插入排序(分页前数据量有限;列表页通常数百条内)
|
||
for i := 1; i < len(items); i++ {
|
||
for j := i; j > 0; j-- {
|
||
if desc {
|
||
if less(&items[j-1], &items[j]) {
|
||
items[j-1], items[j] = items[j], items[j-1]
|
||
continue
|
||
}
|
||
} else if less(&items[j], &items[j-1]) {
|
||
items[j-1], items[j] = items[j], items[j-1]
|
||
continue
|
||
}
|
||
break
|
||
}
|
||
}
|
||
}
|