Files
FileShare/server/internal/api/admin.go
T
SKYMirror 84df9996cb
CI 测试 / go vet + go test (push) Successful in 49s
26.9:版本号统一 + CI 精简 + 前端产物重建
- 全项目版本号统一: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 文案嵌入更新)
2026-09-08 03:16:51 +08:00

1526 lines
47 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package 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 分钟数转 Duration0 回退 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_filecode 冲突 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_secretadmin_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_configadmin_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
}
}
// L7notify_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.9site_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_timeISO 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
}
}
}