26.9(安全审计修复版)

Go 1.27.1 (Gin+GORM) + Vue 3 文件快传服务:

- 安全审计全部修复(docs/security-audit-2026-09-05.md):
  bcrypt 密码哈希与自动升级、presign 直传服务端大小/内容校验、
  全局请求体上限、依赖升级(govulncheck 0 命中)、janitor 后台清理、
  管理端审计动作落库、/admin CORS 收紧、通知内容白名单净化、
  会话默认 7 天、限流缓存故障降级、robots.txt 端点等
- 前端:取件链接复制修复(不再重复拼接提取码)、markdown 净化器加固
- Redis 支持库号(FCB_REDIS_DB / redis://…/db URL)
- 文档:docs/api/* 与 openapi.yaml 同步最新行为(robots.txt、
  提码 5 位起、chunk 32MiB 上限、admin 审计动作等)

验证:gofmt/go vet/go test 全绿;二进制端到端冒烟通过
This commit is contained in:
2026-09-05 04:22:41 +08:00
commit 7f060dd0e4
173 changed files with 32455 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
package cache
import (
"context"
"fmt"
"net/url"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
// RedisCache 基于 Redis 的缓存实现(可选增强)。
type RedisCache struct {
client *redis.Client
}
// NewRedis 连接 Redis 并校验可用性。addr 支持两种形式:
// - host:port(纯地址,库号由 db 参数指定)
// - redis://[:password@]host:port[/db]URL 形式,URL 中的库号优先于 db 参数)
func NewRedis(ctx context.Context, addr string, db int) (*RedisCache, error) {
opts, err := buildRedisOptions(addr, db)
if err != nil {
return nil, err
}
client := redis.NewClient(opts)
if err := client.Ping(ctx).Err(); err != nil {
_ = client.Close()
return nil, fmt.Errorf("cache: Redis 连接失败 %s: %w", addr, err)
}
return &RedisCache{client: client}, nil
}
// buildRedisOptions 构造 go-redis 连接选项(纯地址 / URL 形式统一入口)。
func buildRedisOptions(addr string, db int) (*redis.Options, error) {
opts := &redis.Options{
Addr: addr,
DB: db,
DialTimeout: 5 * time.Second,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
PoolSize: 32,
}
if strings.HasPrefix(addr, "redis://") || strings.HasPrefix(addr, "rediss://") {
u, err := redis.ParseURL(addr)
if err != nil {
return nil, fmt.Errorf("cache: Redis 地址解析失败 %s: %w", addr, err)
}
// URL 未显式携带库号(路径为空或 /)时用 db 参数;显式 /N 优先
if u.DB == 0 && !urlHasDBPath(addr) {
u.DB = db
}
u.DialTimeout = opts.DialTimeout
u.ReadTimeout = opts.ReadTimeout
u.WriteTimeout = opts.WriteTimeout
u.PoolSize = opts.PoolSize
opts = u
}
return opts, nil
}
// urlHasDBPath 判断 redis:// URL 是否显式携带了库号路径(如 /5)。
func urlHasDBPath(raw string) bool {
u, err := url.Parse(raw)
if err != nil {
return false
}
return strings.Trim(u.Path, "/") != ""
}
// Get 读取键值。
func (r *RedisCache) Get(ctx context.Context, key string) (string, error) {
val, err := r.client.Get(ctx, key).Result()
if err == redis.Nil {
return "", ErrNotFound
}
return val, err
}
// Set 写入键值。
func (r *RedisCache) Set(ctx context.Context, key, value string, ttl time.Duration) error {
return r.client.Set(ctx, key, value, ttl).Err()
}
// Delete 删除键。
func (r *RedisCache) Delete(ctx context.Context, keys ...string) error {
if len(keys) == 0 {
return nil
}
return r.client.Del(ctx, keys...).Err()
}
// Exists 判断键是否存在。
func (r *RedisCache) Exists(ctx context.Context, key string) (bool, error) {
n, err := r.client.Exists(ctx, key).Result()
return n > 0, err
}
// Incr 原子自增;首次创建时设置窗口 TTL。
// 使用 Lua 脚本保证 INCR+EXPIRE 原子性,避免多实例下窗口被反复重置。
func (r *RedisCache) Incr(ctx context.Context, key string, ttl time.Duration) (int64, error) {
var incrScript = redis.NewScript(`
local n = redis.call('INCR', KEYS[1])
if n == 1 and ARGV[1] ~= '0' then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
return n
`)
ttlMs := int64(0)
if ttl > 0 {
ttlMs = ttl.Milliseconds()
}
return incrScript.Run(ctx, r.client, []string{key}, ttlMs).Int64()
}
// Close 关闭 Redis 连接。
func (r *RedisCache) Close() error { return r.client.Close() }