整体平台上传功能大改造

This commit is contained in:
2026-09-09 10:49:57 +08:00
parent d2643a3697
commit de7225b61e
21 changed files with 3835 additions and 2622 deletions
+18 -1
View File
@@ -106,10 +106,27 @@ export function getFileById(id) {
*/
export function uploadFile(formData, options = {}) {
// 0 表示「未分类」,不能用 truthy 判断
if (options.cate !== undefined && options.cate !== null && options.cate !== "") {
// 调用方若已手动 append 过 cate,这里不再重复追加(避免 multipart 出现两个 cate)
if (
options.cate !== undefined &&
options.cate !== null &&
options.cate !== "" &&
!formData.has("cate")
) {
formData.append("cate", String(options.cate));
}
// scope: 'user' 表示上传到「我的个人文件」目录(backend/{tid}/{uid}/),
// 不传或 'tenant' 表示租户共享文件(backend/{tid}/)
if (options.scope && !formData.has("scope")) {
formData.append("scope", String(options.scope));
}
// tuid:显式指定归属用户(代他人上传场景),一般不需要传
if (options.tuid && !formData.has("tuid")) {
formData.append("tuid", String(options.tuid));
}
const config = {
url: "/backend/uploadfile",
method: "post",
@@ -151,7 +151,7 @@ const currentBanner = ref<Partial<Banner>>({
// 上传配置
// @ts-ignore
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const uploadUrl = ref(API_BASE_URL + "/admin/uploadfiles");
const uploadUrl = ref(API_BASE_URL + "/backend/uploadfile");
const uploadHeaders = ref({
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
});
@@ -117,7 +117,7 @@ const submitLoading = ref(false)
const formRef = ref(null)
// 上传配置
const uploadUrl = ref(API_BASE_URL + "/admin/uploadfiles");
const uploadUrl = ref(API_BASE_URL + "/backend/uploadfile");
const uploadHeaders = ref({
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
});
@@ -222,7 +222,7 @@ const currentMenu = ref<Partial<Menu>>({
// 上传配置
// @ts-ignore
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const uploadUrl = ref(API_BASE_URL + '/admin/uploadfiles');
const uploadUrl = ref(API_BASE_URL + '/backend/uploadfile');
const uploadHeaders = ref({
'Authorization': 'Bearer ' + (localStorage.getItem('token') || '')
});
@@ -126,7 +126,7 @@ const submitLoading = ref(false);
const formRef = ref(null);
// 上传配置
const uploadUrl = ref(API_BASE_URL + "/admin/uploadfiles");
const uploadUrl = ref(API_BASE_URL + "/backend/uploadfile");
const uploadHeaders = ref({
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
});
@@ -127,7 +127,7 @@ const submitLoading = ref(false);
const formRef = ref(null);
// 上传配置
const uploadUrl = ref(API_BASE_URL + "/admin/uploadfiles");
const uploadUrl = ref(API_BASE_URL + "/backend/uploadfile");
const uploadHeaders = ref({
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
});
@@ -748,7 +748,8 @@ const uploadFileToSystem = async rawFile => {
const formData = new FormData()
formData.append('file', rawFile)
formData.append('cate', 'reimbursement-invoice')
const uploadResponse = await uploadFile(formData)
// 报销票据归属到当前用户个人目录:backend/{tid}/{uid}/年月日/
const uploadResponse = await uploadFile(formData, { scope: 'user' })
if (uploadResponse?.code !== 200 && uploadResponse?.code !== 201) {
throw new Error(uploadResponse?.msg || `「${rawFile.name}」上传失败`)
}
@@ -300,7 +300,8 @@ function ensureImageName(file) {
async function uploadImage(file) {
const formData = new FormData();
formData.append("file", file);
const res = await uploadFile(formData);
// 日程图片归属到当前用户个人目录:backend/{tid}/{uid}/年月日/
const res = await uploadFile(formData, { scope: "user" });
if (res?.code !== 200 && res?.code !== 201) {
throw new Error(res?.msg || `「${file.name}」上传失败`);
}
@@ -24,7 +24,8 @@
<el-form-item label="资质图片" prop="file_url">
<el-upload
class="avatar-uploader"
action="/api/platform/common/upload"
:action="uploadAction"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleUploadSuccess"
:before-upload="beforeAvatarUpload"
@@ -70,7 +71,13 @@
const submitting = ref(false);
const tenantName = ref('');
const formRef = ref();
// 上传地址:走租户后台通用上传接口(原 /api/platform/common/upload 后端不存在,会 404)
const uploadAction = import.meta.env.VITE_API_BASE_URL + '/backend/uploadfile';
const uploadHeaders = {
Authorization: 'Bearer ' + (localStorage.getItem('token') || '')
};
const formData = reactive({
tid: null,
type: '',
+120
View File
@@ -0,0 +1,120 @@
// 存量文件迁移到分层目录(文件存储分层改造 S9)
//
// 用法(在 go/ 目录下执行):
//
// go run ./cmd/migrate_storage # 默认 dry-run,只打印计划,不改动任何数据
// go run ./cmd/migrate_storage --apply # 真正执行迁移
// go run ./cmd/migrate_storage --tid=234573 # 只迁移指定租户
// go run ./cmd/migrate_storage --limit=10 # 只处理前 N 条(调试用)
//
// 迁移原理:
// - 七牛云:BucketManager.Move 同 bucket 内服务端改名,不重新上传、不消耗流量、与文件大小无关
// - 本地 :os.Rename 同盘改名,不搬数据
//
// 幂等:目标 key 已存在时跳过(Skipped),可重复执行。
//
// 注意:老数据没有「个人/共享」归属信息(tuid 全为 NULL、uid 是上传者),
//
// 因此老数据统一按「租户共享」迁移,个人目录只对新上传的文件生效。
package main
import (
"flag"
"fmt"
"os"
"server/models"
"server/services"
)
func main() {
apply := flag.Bool("apply", false, "真正执行迁移;不传则只打印计划(dry-run)")
tid := flag.Uint64("tid", 0, "只迁移指定租户,0 表示全部")
limit := flag.Int("limit", 0, "只处理前 N 条,0 表示不限制")
flag.Parse()
// 注意:需在 go/ 目录下运行,beego 会自动加载 conf/app.conf
models.Init("")
if models.Orm == nil {
fmt.Println("数据库未初始化")
os.Exit(1)
}
models.EnsureSystemFileStorageColumns()
storageSvc, err := services.GetStorageService()
if err != nil {
fmt.Println("获取存储服务失败:", err)
os.Exit(1)
}
fmt.Printf("当前存储类型: %s\n", storageSvc.Type())
qs := models.Orm.QueryTable(new(models.SystemFile)).Filter("delete_time__isnull", true)
if *tid > 0 {
qs = qs.Filter("tid", *tid)
}
if *limit > 0 {
qs = qs.Limit(*limit)
}
var files []models.SystemFile
if _, err := qs.OrderBy("id").All(&files); err != nil {
fmt.Println("读取文件列表失败:", err)
os.Exit(1)
}
fmt.Printf("待处理文件数: %d(模式: %s)\n\n", len(files), modeName(*apply))
planned, skipped, failed := 0, 0, 0
for i := range files {
f := files[i]
oldKey := f.ObjectKey
if oldKey == "" {
oldKey = services.KeyFromSrc(f.Src, storageSvc)
}
if oldKey == "" {
fmt.Printf("[跳过] id=%d 无法解析原路径: %s\n", f.ID, f.Src)
skipped++
continue
}
newKey := services.TargetKey(&f)
if oldKey == newKey {
skipped++
continue
}
planned++
fmt.Printf("[%s] id=%d tid=%d\n 老: %s\n 新: %s\n", modeName(*apply), f.ID, f.Tid, oldKey, newKey)
if !*apply {
continue
}
if err := storageSvc.Move(oldKey, newKey); err != nil {
fmt.Printf(" !! 迁移失败: %v\n", err)
failed++
continue
}
newSrc := storageSvc.GetPublicURL(newKey)
if _, err := models.Orm.QueryTable(new(models.SystemFile)).
Filter("id", f.ID).
Update(map[string]interface{}{
"src": newSrc,
"object_key": newKey,
"storage": storageSvc.Type(),
}); err != nil {
fmt.Printf(" !! 更新数据库失败(文件已移动,请重跑本脚本): %v\n", err)
failed++
}
}
fmt.Printf("\n==== 汇总 ====\n计划迁移: %d\n跳过: %d\n失败: %d\n", planned, skipped, failed)
if !*apply {
fmt.Println("当前为 dry-run,未改动任何数据。确认无误后加 --apply 执行。")
}
}
func modeName(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+326 -327
View File
@@ -1,327 +1,326 @@
package controllers
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"server/models"
"server/pkg/jwtutil"
beego "github.com/beego/beego/v2/server/web"
"github.com/qiniu/go-sdk/v7/auth/qbox"
"github.com/qiniu/go-sdk/v7/storage"
)
// QiniuUploadController 七牛云上传控制器
type QiniuUploadController struct {
beego.Controller
}
// platformClaims 获取平台端 JWT claims
func (c *QiniuUploadController) platformClaims() (*jwtutil.Claims, error) {
auth := c.Ctx.Request.Header.Get("Authorization")
if auth == "" {
return nil, fmt.Errorf("未登录")
}
parts := strings.Split(auth, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
return nil, fmt.Errorf("token 格式错误")
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil {
return nil, fmt.Errorf("token 无效")
}
return claims, nil
}
// effectiveTid 获取有效的租户 ID
func (c *QiniuUploadController) effectiveTid(claims *jwtutil.Claims) uint64 {
if claims.TenantId > 0 {
return uint64(claims.TenantId)
}
return 0
}
// jsonErr 返回错误响应
func (c *QiniuUploadController) jsonErr(httpStatus, bizCode int, msg string) {
c.Ctx.Output.SetStatus(httpStatus)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}
// jsonOK 返回成功响应
func (c *QiniuUploadController) jsonOK(data interface{}) {
c.Data["json"] = map[string]interface{}{"code": 200, "data": data}
_ = c.ServeJSON()
}
// ParseJSON 解析 JSON 请求体
func (c *QiniuUploadController) ParseJSON(v interface{}) error {
body := c.Ctx.Input.RequestBody
if len(body) == 0 {
return fmt.Errorf("请求体为空")
}
return json.Unmarshal(body, v)
}
// GetUploadToken 获取上传凭证
// GET /platform/qiniu/token
func (c *QiniuUploadController) GetUploadToken() {
_, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
// 获取存储配置
cfg, err := models.GetStorageConfig()
if err != nil || cfg.StorageType != "qiniu" {
c.jsonErr(400, 400, "当前未配置七牛云存储")
return
}
// 检查配置完整性
if cfg.QiniuAccessKey == "" || cfg.QiniuSecretKey == "" || cfg.QiniuBucket == "" {
c.jsonErr(500, 500, "七牛云配置不完整")
return
}
// 生成文件 key(前端可以覆盖)
datePath := time.Now().Format("2006/01/02")
timestamp := time.Now().UnixNano()
keyPrefix := fmt.Sprintf("%s/%d", datePath, timestamp)
// 创建上传策略
mac := qbox.NewMac(cfg.QiniuAccessKey, cfg.QiniuSecretKey)
putPolicy := storage.PutPolicy{
Scope: cfg.QiniuBucket,
ReturnBody: `{"key":"$(key)","hash":"$(etag)","size":$(fsize),"mimeType":"$(mimeType)"}`,
Expires: 3600, // 1小时有效期
}
upToken := putPolicy.UploadToken(mac)
// 返回上传凭证和配置
c.jsonOK(map[string]interface{}{
"token": upToken,
"domain": cfg.QiniuDomain,
"bucket": cfg.QiniuBucket,
"region": cfg.QiniuRegion,
"keyPrefix": keyPrefix,
"expires": time.Now().Add(time.Hour).Unix(),
"uploadUrl": getQiniuUploadURL(cfg.QiniuRegion),
})
}
// SaveFileRecord 保存文件记录
// POST /platform/qiniu/save
func (c *QiniuUploadController) SaveFileRecord() {
claims, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
tid := c.effectiveTid(claims)
// 调试:打印请求体
body := c.Ctx.Input.RequestBody
fmt.Println("SaveFileRecord 请求体长度:", len(body))
fmt.Println("SaveFileRecord 请求体内容:", string(body))
// 解析请求参数
type SaveRequest struct {
Key string `json:"key"` // 七牛云文件 key
Hash string `json:"hash"` // 文件 hash (etag)
Size int64 `json:"size"` // 文件大小
Name string `json:"name"` // 原始文件名
MimeType string `json:"mimeType"` // 文件类型
Cate uint64 `json:"cate"` // 分类 ID
}
var req SaveRequest
if err := c.ParseJSON(&req); err != nil {
c.jsonErr(400, 400, "参数解析失败: "+err.Error())
return
}
// 验证必填字段
if req.Key == "" || req.Name == "" {
c.jsonErr(400, 400, "缺少必填参数")
return
}
// 获取存储配置
cfg, err := models.GetStorageConfig()
if err != nil || cfg.StorageType != "qiniu" {
c.jsonErr(400, 400, "当前未配置七牛云存储")
return
}
// 构建完整 URL
domain := strings.TrimRight(cfg.QiniuDomain, "/")
fileURL := fmt.Sprintf("%s/%s", domain, req.Key)
// 计算 MD5(使用 hash 作为 MD5,或者重新计算)
md5Sum := req.Hash
if md5Sum == "" {
// 如果没有 hash,使用 key 生成一个唯一标识
h := md5.New()
h.Write([]byte(req.Key))
md5Sum = hex.EncodeToString(h.Sum(nil))
}
// 检查文件是否已存在(通过 MD5)
var exist models.SystemFile
err = models.Orm.QueryTable(new(models.SystemFile)).
Filter("md5", md5Sum).
Filter("tid", tid).
Filter("delete_time__isnull", true).
One(&exist)
if err == nil {
// 文件已存在,返回已有记录
c.Data["json"] = map[string]interface{}{
"code": 201,
"msg": "文件已存在",
"data": map[string]interface{}{
"url": exist.Src,
"id": exist.ID,
"name": exist.Name,
},
}
_ = c.ServeJSON()
return
}
// 检测文件类型
ext := getQiniuFileExt(req.Name)
fileType := detectQiniuFileType(ext)
// 保存文件记录
adminID := uint64(claims.UserID)
row := &models.SystemFile{
Tid: tid,
Uid: &adminID,
Name: req.Name,
Type: fileType,
Cate: req.Cate,
Size: uint64(req.Size),
Src: fileURL,
Uploader: adminID,
Md5: md5Sum,
}
id, err := models.Orm.Insert(row)
if err != nil {
c.jsonErr(500, 500, "保存文件记录失败: "+err.Error())
return
}
c.jsonOK(map[string]interface{}{
"url": fileURL,
"id": uint64(id),
"name": req.Name,
"key": req.Key,
})
}
// GetStorageConfig 获取存储配置(前端用于判断上传方式)
// GET /platform/storage/config
func (c *QiniuUploadController) GetStorageConfig() {
_, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
cfg, err := models.GetStorageConfig()
if err != nil {
c.jsonOK(map[string]interface{}{
"storageType": "local",
})
return
}
// 只返回必要的配置信息,不返回密钥
c.jsonOK(map[string]interface{}{
"storageType": cfg.StorageType,
"qiniuDomain": cfg.QiniuDomain,
"qiniuRegion": cfg.QiniuRegion,
})
}
// getQiniuUploadURL 根据区域获取上传地址
func getQiniuUploadURL(region string) string {
switch region {
case "z0":
return "https://up-z0.qiniup.com"
case "z1":
return "https://up-z1.qiniup.com"
case "z2":
return "https://up-z2.qiniup.com"
case "na0":
return "https://up-na0.qiniup.com"
case "as0":
return "https://up-as0.qiniup.com"
case "cn-east-2":
return "https://up-cn-east-2.qiniup.com"
default:
return "https://up-z0.qiniup.com" // 默认华东
}
}
// getQiniuFileExt 获取文件扩展名
func getQiniuFileExt(filename string) string {
parts := strings.Split(filename, ".")
if len(parts) > 1 {
return strings.ToLower(parts[len(parts)-1])
}
return ""
}
// detectQiniuFileType 检测文件类型
func detectQiniuFileType(ext string) uint8 {
imageExts := map[string]bool{
"jpg": true, "jpeg": true, "png": true, "gif": true, "bmp": true,
"webp": true, "svg": true, "ico": true,
}
videoExts := map[string]bool{
"mp4": true, "avi": true, "mov": true, "wmv": true, "flv": true,
"mkv": true, "webm": true, "m4v": true,
}
audioExts := map[string]bool{
"mp3": true, "wav": true, "flac": true, "aac": true, "ogg": true,
"m4a": true, "wma": true,
}
docExts := map[string]bool{
"doc": true, "docx": true, "xls": true, "xlsx": true, "ppt": true,
"pptx": true, "pdf": true, "txt": true, "md": true,
}
archiveExts := map[string]bool{
"zip": true, "rar": true, "7z": true, "tar": true, "gz": true,
"bz2": true, "xz": true,
}
executableExts := map[string]bool{
"exe": true, "msi": true, "dmg": true, "pkg": true, "deb": true,
"rpm": true, "apk": true, "msix": true,
}
if imageExts[ext] {
return 1 // 图片
}
if videoExts[ext] {
return 2 // 视频
}
if audioExts[ext] {
return 3 // 音频
}
if docExts[ext] {
return 4 // 文档
}
if archiveExts[ext] || executableExts[ext] {
return 5 // 压缩包/安装包
}
return 0 // 其他
}
package controllers
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"server/models"
"server/pkg/jwtutil"
"server/services"
beego "github.com/beego/beego/v2/server/web"
"github.com/qiniu/go-sdk/v7/auth/qbox"
"github.com/qiniu/go-sdk/v7/storage"
)
// QiniuUploadController 七牛云上传控制器
type QiniuUploadController struct {
beego.Controller
}
// platformClaims 获取平台端 JWT claims
func (c *QiniuUploadController) platformClaims() (*jwtutil.Claims, error) {
auth := c.Ctx.Request.Header.Get("Authorization")
if auth == "" {
return nil, fmt.Errorf("未登录")
}
parts := strings.Split(auth, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
return nil, fmt.Errorf("token 格式错误")
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil {
return nil, fmt.Errorf("token 无效")
}
return claims, nil
}
// effectiveTid 获取有效的租户 ID
func (c *QiniuUploadController) effectiveTid(claims *jwtutil.Claims) uint64 {
if claims.TenantId > 0 {
return uint64(claims.TenantId)
}
return 0
}
// jsonErr 返回错误响应
func (c *QiniuUploadController) jsonErr(httpStatus, bizCode int, msg string) {
c.Ctx.Output.SetStatus(httpStatus)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}
// jsonOK 返回成功响应
func (c *QiniuUploadController) jsonOK(data interface{}) {
c.Data["json"] = map[string]interface{}{"code": 200, "data": data}
_ = c.ServeJSON()
}
// ParseJSON 解析 JSON 请求体
func (c *QiniuUploadController) ParseJSON(v interface{}) error {
body := c.Ctx.Input.RequestBody
if len(body) == 0 {
return fmt.Errorf("请求体为空")
}
return json.Unmarshal(body, v)
}
// GetUploadToken 获取上传凭证
// GET /platform/qiniu/token
func (c *QiniuUploadController) GetUploadToken() {
_, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
// 获取存储配置
cfg, err := models.GetStorageConfig()
if err != nil || cfg.StorageType != "qiniu" {
c.jsonErr(400, 400, "当前未配置七牛云存储")
return
}
// 检查配置完整性
if cfg.QiniuAccessKey == "" || cfg.QiniuSecretKey == "" || cfg.QiniuBucket == "" {
c.jsonErr(500, 500, "七牛云配置不完整")
return
}
// 生成文件 key(前端可以覆盖)
// 平台端固定落在 platform/yyyy/MM/dd/ 下,与服务端中转上传保持同一套目录规则
datePath := time.Now().Format("2006/01/02")
timestamp := time.Now().UnixNano()
keyPrefix := fmt.Sprintf("%s/%s/%d", services.SourcePlatform, datePath, timestamp)
// 创建上传策略
mac := qbox.NewMac(cfg.QiniuAccessKey, cfg.QiniuSecretKey)
putPolicy := storage.PutPolicy{
Scope: cfg.QiniuBucket,
ReturnBody: `{"key":"$(key)","hash":"$(etag)","size":$(fsize),"mimeType":"$(mimeType)"}`,
Expires: 3600, // 1小时有效期
}
upToken := putPolicy.UploadToken(mac)
// 返回上传凭证和配置
c.jsonOK(map[string]interface{}{
"token": upToken,
"domain": cfg.QiniuDomain,
"bucket": cfg.QiniuBucket,
"region": cfg.QiniuRegion,
"keyPrefix": keyPrefix,
"expires": time.Now().Add(time.Hour).Unix(),
"uploadUrl": getQiniuUploadURL(cfg.QiniuRegion),
})
}
// SaveFileRecord 保存文件记录
// POST /platform/qiniu/save
func (c *QiniuUploadController) SaveFileRecord() {
claims, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
tid := c.effectiveTid(claims)
// 调试:打印请求体
body := c.Ctx.Input.RequestBody
fmt.Println("SaveFileRecord 请求体长度:", len(body))
fmt.Println("SaveFileRecord 请求体内容:", string(body))
// 解析请求参数
type SaveRequest struct {
Key string `json:"key"` // 七牛云文件 key
Hash string `json:"hash"` // 文件 hash (etag)
Size int64 `json:"size"` // 文件大小
Name string `json:"name"` // 原始文件名
MimeType string `json:"mimeType"` // 文件类型
Cate uint64 `json:"cate"` // 分类 ID
}
var req SaveRequest
if err := c.ParseJSON(&req); err != nil {
c.jsonErr(400, 400, "参数解析失败: "+err.Error())
return
}
// 验证必填字段
if req.Key == "" || req.Name == "" {
c.jsonErr(400, 400, "缺少必填参数")
return
}
// 获取存储配置
cfg, err := models.GetStorageConfig()
if err != nil || cfg.StorageType != "qiniu" {
c.jsonErr(400, 400, "当前未配置七牛云存储")
return
}
// 构建完整 URL
domain := strings.TrimRight(cfg.QiniuDomain, "/")
fileURL := fmt.Sprintf("%s/%s", domain, req.Key)
// 注意:直传通道前端给的是七牛 etag(req.Hash),并非文件真实 MD5,
// 与服务端中转上传算出的 MD5 不同源,因此该通道的查重只在本通道内有效。
md5Sum := req.Hash
if md5Sum == "" {
// 如果没有 hash,使用 key 生成一个唯一标识
h := md5.New()
h.Write([]byte(req.Key))
md5Sum = hex.EncodeToString(h.Sum(nil))
}
// 检查文件是否已存在(通过 MD5)
exist, err := services.FindDuplicate(services.SourcePlatform, services.ScopeTenant, tid, 0, md5Sum)
if err != nil {
c.jsonErr(500, 500, "查重失败: "+err.Error())
return
}
if exist != nil {
// 文件已存在,返回已有记录
c.Data["json"] = map[string]interface{}{
"code": 201,
"msg": "文件已存在",
"data": map[string]interface{}{
"url": exist.Src,
"id": exist.ID,
"name": exist.Name,
},
}
_ = c.ServeJSON()
return
}
// 保存文件记录(平台端:source=platform,scope=tenant,object_key=七牛 key)
// 文件类型由 services.CreateFileRecord 依据扩展名统一推断
id, err := services.CreateFileRecord(services.FileUploadParams{
Source: services.SourcePlatform,
Scope: services.ScopeTenant,
Tid: tid,
Uid: uint64(claims.UserID),
Name: req.Name,
Ext: getQiniuFileExt(req.Name),
Cate: req.Cate,
Size: uint64(req.Size),
Src: fileURL,
ObjectKey: req.Key,
Storage: services.StorageTypeQiniu,
MD5: md5Sum,
})
if err != nil {
c.jsonErr(500, 500, "保存文件记录失败: "+err.Error())
return
}
c.jsonOK(map[string]interface{}{
"url": fileURL,
"id": uint64(id),
"name": req.Name,
"key": req.Key,
})
}
// GetStorageConfig 获取存储配置(前端用于判断上传方式)
// GET /platform/storage/config
func (c *QiniuUploadController) GetStorageConfig() {
_, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
cfg, err := models.GetStorageConfig()
if err != nil {
c.jsonOK(map[string]interface{}{
"storageType": "local",
})
return
}
// 只返回必要的配置信息,不返回密钥
c.jsonOK(map[string]interface{}{
"storageType": cfg.StorageType,
"qiniuDomain": cfg.QiniuDomain,
"qiniuRegion": cfg.QiniuRegion,
})
}
// getQiniuUploadURL 根据区域获取上传地址
func getQiniuUploadURL(region string) string {
switch region {
case "z0":
return "https://up-z0.qiniup.com"
case "z1":
return "https://up-z1.qiniup.com"
case "z2":
return "https://up-z2.qiniup.com"
case "na0":
return "https://up-na0.qiniup.com"
case "as0":
return "https://up-as0.qiniup.com"
case "cn-east-2":
return "https://up-cn-east-2.qiniup.com"
default:
return "https://up-z0.qiniup.com" // 默认华东
}
}
// getQiniuFileExt 获取文件扩展名
func getQiniuFileExt(filename string) string {
parts := strings.Split(filename, ".")
if len(parts) > 1 {
return strings.ToLower(parts[len(parts)-1])
}
return ""
}
// detectQiniuFileType 检测文件类型
func detectQiniuFileType(ext string) uint8 {
imageExts := map[string]bool{
"jpg": true, "jpeg": true, "png": true, "gif": true, "bmp": true,
"webp": true, "svg": true, "ico": true,
}
videoExts := map[string]bool{
"mp4": true, "avi": true, "mov": true, "wmv": true, "flv": true,
"mkv": true, "webm": true, "m4v": true,
}
audioExts := map[string]bool{
"mp3": true, "wav": true, "flac": true, "aac": true, "ogg": true,
"m4a": true, "wma": true,
}
docExts := map[string]bool{
"doc": true, "docx": true, "xls": true, "xlsx": true, "ppt": true,
"pptx": true, "pdf": true, "txt": true, "md": true,
}
archiveExts := map[string]bool{
"zip": true, "rar": true, "7z": true, "tar": true, "gz": true,
"bz2": true, "xz": true,
}
executableExts := map[string]bool{
"exe": true, "msi": true, "dmg": true, "pkg": true, "deb": true,
"rpm": true, "apk": true, "msix": true,
}
if imageExts[ext] {
return 1 // 图片
}
if videoExts[ext] {
return 2 // 视频
}
if audioExts[ext] {
return 3 // 音频
}
if docExts[ext] {
return 4 // 文档
}
if archiveExts[ext] || executableExts[ext] {
return 5 // 压缩包/安装包
}
return 0 // 其他
}
@@ -0,0 +1,93 @@
-- 文件存储分层改造:yz_system_files 新增归属与存储字段
-- 对应计划文档:go/docs/文件存储分层改造计划.md S1
--
-- 幂等脚本:用存储过程逐项判断,已存在的列/索引自动跳过,可反复执行,不会报
-- 1060 Duplicate column / 1061 Duplicate key。
--
-- 背景:md5 在部分历史库中是 TEXT 类型,MySQL 不允许对 TEXT/BLOB 建整列索引,
-- 因此 idx_file_dedup 使用前缀长度 md5(32)(MD5 十六进制串固定 32 字符,等价全值)。
DROP PROCEDURE IF EXISTS `yz_alter_system_files_storage`;
DELIMITER $$
CREATE PROCEDURE `yz_alter_system_files_storage`()
BEGIN
DECLARE v_db VARCHAR(64);
SET v_db = DATABASE();
-- 1. 字段:source
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'source'
) THEN
ALTER TABLE `yz_system_files`
ADD COLUMN `source` varchar(16) NOT NULL DEFAULT 'backend'
COMMENT '来源端: backend-租户后台 platform-平台端';
END IF;
-- 2. 字段:scope
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'scope'
) THEN
ALTER TABLE `yz_system_files`
ADD COLUMN `scope` varchar(16) NOT NULL DEFAULT 'tenant'
COMMENT '归属: tenant-租户共享 user-用户个人';
END IF;
-- 3. 字段:storage
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'storage'
) THEN
ALTER TABLE `yz_system_files`
ADD COLUMN `storage` varchar(16) NOT NULL DEFAULT ''
COMMENT '存储类型: local/qiniu(冗余,便于迁移与排查)';
END IF;
-- 4. 字段:object_key
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'object_key'
) THEN
ALTER TABLE `yz_system_files`
ADD COLUMN `object_key` varchar(512) NOT NULL DEFAULT ''
COMMENT '存储相对路径(不含域名),用于迁移与精确删除';
END IF;
-- 5. 索引:查重(source + scope + tid + tuid + md5 前缀)
-- 不加 UNIQUE:软删(delete_time)与并发上传下唯一索引会直接报错,去重在代码层完成
IF NOT EXISTS (
SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND INDEX_NAME = 'idx_file_dedup'
) THEN
ALTER TABLE `yz_system_files`
ADD KEY `idx_file_dedup` (`source`, `scope`, `tid`, `tuid`, `md5`(32));
END IF;
-- 6. 索引:归属列表过滤
IF NOT EXISTS (
SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND INDEX_NAME = 'idx_file_owner'
) THEN
ALTER TABLE `yz_system_files`
ADD KEY `idx_file_owner` (`source`, `tid`, `scope`, `tuid`, `delete_time`);
END IF;
END$$
DELIMITER ;
CALL `yz_alter_system_files_storage`();
DROP PROCEDURE IF EXISTS `yz_alter_system_files_storage`;
-- 7. 老数据初始化:迁移脚本执行前先打底,避免 source/scope 为空导致查询遗漏
UPDATE `yz_system_files` SET `source` = 'backend' WHERE `source` = '' OR `source` IS NULL;
UPDATE `yz_system_files` SET `scope` = 'tenant' WHERE `scope` = '' OR `scope` IS NULL;
-- 8. 校验(应看到 4 个字段 + 2 个索引)
-- SHOW COLUMNS FROM yz_system_files LIKE 'source';
-- SHOW COLUMNS FROM yz_system_files LIKE 'scope';
-- SHOW COLUMNS FROM yz_system_files LIKE 'storage';
-- SHOW COLUMNS FROM yz_system_files LIKE 'object_key';
-- SHOW INDEX FROM yz_system_files WHERE Key_name IN ('idx_file_dedup','idx_file_owner');
+403
View File
@@ -0,0 +1,403 @@
# 文件存储分层改造计划
> 创建日期:2026-09-09
> 当前状态:**进行中**(改造中断时,从「进度总表」里第一个未勾选的条目继续)
> 适用范围:`go/`(服务端)+ `backend/`(租户后台前端)+ `platform/`(平台端前端)
---
## 0. 进度总表
> 每完成一项,把 `- [ ]` 改成 `- [x]` 并更新日期。中断后从第一个 `- [ ]` 继续。
| # | 阶段 | 内容 | 状态 |
|---|---|---|---|
| S1 | 数据库 | `yz_system_files` 新增 `source`/`scope`/`storage`/`object_key` + 索引 + `EnsureSystemFileStorageColumns()` | ✅ 2026-09-09 |
| S2 | services | `storage_service.go`:新增 `UploadContext`/`BuildObjectKey()`/`Stage()`/`Commit()`/`Move()` | ✅ 2026-09-09 |
| S3 | services | 新增 `services/file_service.go`:MD5 精确查重、入库、路径解析、文件类型推断 | ✅ 2026-09-09 |
| S4 | controllers | `backend_file.go`:上传走新目录规则 + 先算 MD5 后落盘 + 列表/删除 scope 隔离 | ✅ 2026-09-09 |
| S5 | controllers | `platform_file.go`:固定 `platform/日期`,不做租户/用户分层 | ✅ 2026-09-09 |
| S6 | controllers | `qiniu_upload.go`:token 的 `keyPrefix` 带 `platform/` 前缀,入库写 `source` | ✅ 2026-09-09 |
| S7 | 前端 | backend 端上传点接入新参数(含修复 5 处 404 的 `/admin/uploadfiles`) | ✅ 2026-09-09 |
| S8 | 前端 | platform 端上传点接入新参数(含修复 `/platform/upload`、资质上传) | ✅ 2026-09-09 |
| S9 | 迁移 | `cmd/migrate_storage`(默认 dry-run,`--apply` 执行,七牛走 Move)— **脚本已就绪,尚未执行** | ✅ 2026-09-09(待执行) |
| S10 | 验收 | 上传/去重/隔离/迁移全链路验证 | ☐ |
---
## 1. 目标与非目标
### 目标
1. 两个端的文件物理隔离:`backend/` 与 `platform/` 分目录。
2. backend 端按租户隔离:`backend/{tid}/`。
3. 租户内区分**租户共享文件**与**用户个人文件**:个人文件落到 `backend/{tid}/{tuid}/`。
4. 常规按 `年/月/日` 分目录。
5. MD5 去重**精确到归属**:
- 租户共享文件 → 同 `tid` 内 MD5 相同才算重复;
- 用户个人文件 → 同 `tid` + 同 `tuid` 内 MD5 相同才算重复;
- 「租户已有 a 文件」与「用户 c 上传同样文件」**不冲突**,可并存。
6. 去重改为**先算 MD5 再落盘**,重复文件不产生物理垃圾。
### 非目标(本次不做)
- 不做文件访问鉴权(`/uploads` 仍为公开静态目录),仅做目录隔离。
- 不迁移 `themes/` 下的官网模板(`POST /platform/template/upload` 保持原样,不进文件表)。
- 不改组织架构 CSV 导入(`importOrganization` 是临时解析文件,不入库 `yz_system_files`)。
---
## 2. 现状分析
### 2.1 服务端链路
| 入口 | 代码位置 | 说明 |
|---|---|---|
| `POST /backend/uploadfile`、`/backend/uploadfiles` | `controllers/backend_file.go:463` | backend 端,服务端中转 |
| `POST /platform/uploadfile`、`/platform/uploadfiles` | `controllers/platform_file.go:463` | platform 端,服务端中转 |
| `GET /platform/qiniu/token` + `POST /platform/qiniu/save` | `controllers/qiniu_upload.go` | 七牛前端直传,**仅 platform 端有路由** |
| `POST /platform/template/upload` | `controllers/platform_template.go` | 模板 zip → `themes/`,不进文件表 |
- 存储实现:`services/storage_service.go`,`LocalStorage`(`BaseDir=uploads`、`BaseURL=/`)与 `QiniuStorage`。
- 当前路径规则:`2006/01/02/{UnixNano}{ext}`,**无任何端/租户/用户维度**。
- 静态映射:`beego.SetStaticPath("/uploads","uploads")`(`main.go:23`)。
- `backend_file.go` 与 `platform_file.go` 是**复制粘贴的两份**(约 900 行几乎全同),改规则必须同步两处。
### 2.2 数据库
`yz_system_files`(`models/system_file.go`)现有字段:`tid / uid / tuid / name / type / cate / size / src / uploader / md5 / create_time / update_time / delete_time`。
语义现状(重要):
- `uid` = 上传者 ID(controller 里直接写 `claims.UserID`)。
- `tuid` = 表单可选参数,**前端从未传过,库中全为 NULL**。
- 没有「来源端」「共享/个人」「存储 key」字段 → 无法做目录归属与迁移。
ID 语义(已确认):
- **backend 端登录**:`services/platform_auth.go:122` 用 `tenantUser.Uid` 签发 JWT → `claims.UserID` 就是**租户用户 uid(8 位,如 67091493)**,`claims.TenantId` = 租户 ID(如 234573)。
- **platform 端登录**:`services/platform_auth.go:79` 用 `AdminUser.ID` 签发 → `claims.UserID` 是平台管理员 ID,`claims.TenantId = 0`。
**结论:backend 端 JWT 里天然就带着「租户 ID + 用户 uid」,个人文件目录名可直接用 `claims.UserID`;前端传 `tuid` 只在"代别人上传"场景才需要。**
### 2.3 前端上传点全量清单
#### backend 端(`backend/src`)—— 21 处代码点,19 处真实发请求
| # | 文件(相对 `backend/src`) | 功能 | 接口 | 现状参数 |
|---|---|---|---|---|
| B1 | `views/system/fileManager/components/uploadFile.vue` | 系统文件库上传 | `/backend/uploadfile` | `cate`(重复 append 两次) |
| B2 | `views/components/UmoEditor.vue` | 富文本图片/视频 | `/backend/uploadfile` | 无 |
| B3 | `views/apps/cms/article/index/components/edit.vue` | 文章封面 | `/backend/uploadfile` | `cate=article` |
| B4 | `views/apps/cms/article/type/components/edit.vue` | 文章分类默认图 | `/backend/uploadfile` | 无 |
| B5 | `views/moduleshop/center/index.vue` | 模块中心缩略图 | `/backend/uploadfile` | `cate=module` |
| B6 | `views/basicSettings/siteSettings/components/normalSettings.vue` | 站点 Logo | `/backend/uploadfile` | `cate=site` |
| B7 | 同上 | 站点白色 Logo | `/backend/uploadfile` | `cate=site` |
| B8 | 同上 | 站点 ico 图标 | `/backend/uploadfile` | `cate=site` |
| B9 | `views/apps/oa/schedule/components/detail.vue` | 日程相关图片/粘贴截图 | `/backend/uploadfile` | 无 |
| B10 | `views/apps/oa/reimburse/components/detail.vue` | 报销发票/票据 | `/backend/uploadfile` | `cate=reimbursement-invoice` |
| B11 | `views/apps/oa/employeefile/components/recordEditDialog.vue` | 员工档案记录附件 | `/backend/uploadfile` | 无 |
| B12 | `views/apps/oa/employeefile/components/fileDetailDrawer.vue` | 员工证照-学历照片 | `/backend/uploadfile` | 无 |
| B13 | `views/apps/cms/banner/components/edit.vue` | Banner 图片 | **`{BASE}/admin/uploadfiles`** ⚠️ | 无 |
| B14 | `views/apps/cms/solution/index/components/edit.vue` | 方案图片 | **`{BASE}/admin/uploadfiles`** ⚠️ | 无 |
| B15 | `views/apps/cms/product/index/components/edit.vue` | 产品图片 | **`{BASE}/admin/uploadfiles`** ⚠️ | 无 |
| B16 | `views/apps/cms/frontMenu/components/edit.vue` | 前端菜单图片 | **`{BASE}/admin/uploadfiles`** ⚠️ | 无 |
| B17 | `views/apps/cms/friendlink/components/edit.vue` | 友情链接 Logo | **`{BASE}/admin/uploadfiles`** ⚠️ | 无 |
| B18 | `views/basicSettings/tenants/components/qualification.vue` | 租户资质文件 | `/api/platform/common/upload` ⚠️ | 无,提交为 mock |
| B19 | `views/apps/organization/components/ImportExportDialog.vue` | 组织架构 CSV 导入 | `/backend/.../importOrganization` | CSV,不入库文件表 |
| B20 | `views/moduleshop/publish/index.vue` | 模块 zip | 无(TODO 死代码) | — |
| B21 | `views/moduleshop/components/createModules.vue` | 模块 zip | 无(TODO 死代码) | — |
#### platform 端(`platform/src`)—— 14 处代码点,9 处真实发请求
| # | 文件(相对 `platform/src`) | 功能 | 接口 | 通道 |
|---|---|---|---|---|
| P1 | `views/system/fileManager/components/uploadFile.vue` | 平台文件管理 | `/platform/uploadfile` | 服务端中转 |
| P2 | `views/components/UmoEditor.vue` | 富文本(笔记本) | `/platform/uploadfile` | 服务端中转 |
| P3 | `views/basicSettings/siteSettings/components/normalSettings.vue` | 站点 Logo | `/platform/uploadfile` | 服务端中转 |
| P4 | 同上 | 站点白色 Logo | `/platform/uploadfile` | 服务端中转 |
| P5 | 同上 | 站点 ico 图标 | `/platform/uploadfile` | 服务端中转 |
| P6 | `views/moduleshop/center/index.vue` | 模块中心缩略图 | `/platform/uploadfile` | 服务端中转 |
| P7 | `views/platform/softwareupgrade/components/edit.vue` | 软件升级包(4 平台) | `smartUpload()` | **七牛直传 / 本地自适应** |
| P8 | `views/template/index.vue` | 官网模板 zip | `/platform/template/upload` | 服务端中转(不入库) |
| P9 | `views/apps/babyhealth/users/components/userEdit.vue` | 用户头像 | **`{BASE}/platform/upload`** ⚠️ | 服务端中转 |
| P10 | `views/apps/babyhealth/users/components/userEdit.vue` | 头像(裁剪后) | `uploadAvatar()` | ❌ 死代码(函数未定义) |
| P11 | `views/basicSettings/tenants/components/qualification.vue` | 租户资质图片 | `/api/platform/common/upload` ⚠️ | 悬空(无代理/无 token) |
| P12 | `views/apps/babyhealth/babys/components/edit.vue` | 宝贝头像 | `uploadAvatar()` | ❌ 死代码 |
| P13 | `views/moduleshop/components/createModules.vue` | 创建模块包 | 无(调用被注释) | ❌ 不发请求 |
| P14 | `views/moduleshop/publish/index.vue` | 发布模块包 | 无(TODO) | ❌ 不发请求 |
### 2.4 已发现的问题(本次一并处理)
| 级别 | 问题 | 说明 |
|---|---|---|
| 🔴 | **`/admin/uploadfiles` 路由不存在** | `go/routers` 全量搜索无 `/admin` 前缀路由;B13~B17 五处 CMS 上传实际会 404(`VITE_API_BASE=https://api.yunzer.cn`)。需收敛到 `/backend/uploadfile`。 |
| 🔴 | **`/platform/upload` 路由不存在** | P9 头像上传会 404。需收敛到 `/platform/uploadfile`。 |
| 🟡 | **`/api/platform/common/upload` 路由不存在** | B18 / P11 悬空,且提交逻辑是 mock。 |
| 🔴 | **去重先落盘后判断** | 命中重复时物理文件已写入磁盘/七牛且未删除 → 产生孤儿垃圾。 |
| 🔴 | **去重维度只有 `tid`** | 不区分端、不区分用户,与"精确到用户"要求不符。 |
| 🟡 | **七牛直传的 `md5` 存的是 etag** | `qiniu_upload.go:168` 把 `hash`(etag)当 md5 存,与本地真 MD5 不同源,跨存储去重会失准。 |
| 🟡 | **物理删除路径脆弱** | `removePhysicalBySrc` 直接 `os.Remove(TrimPrefix(src,"/"))`,依赖进程 CWD。 |
| 🟡 | **删除语义不一致** | 单条 `DeleteFile` 只软删,`BatchDeleteFiles` 却真删物理文件。 |
| 🟢 | `cate` 被重复 append | `api/file.js` 的 `options.cate` 与调用处手动 append 各一次 → multipart 里两个 `cate`。 |
---
## 3. 目标目录规范
```
{存储根}/ 本地: uploads/ 七牛: bucket 根
├── backend/
│ └── {tid}/ 例: 234573
│ ├── 2026/09/09/{ts}_{rand}.{ext} ← scope=tenant(租户共享)
│ └── {tuid}/ 例: 67091493
│ └── 2026/09/09/{ts}_{rand}.{ext} ← scope=user(用户个人)
└── platform/
└── 2026/09/09/{ts}_{rand}.{ext} ← 平台端,不分层
```
- `{ts}_{rand}`:`UnixNano` + 6 位随机(避免同纳秒并发冲突)。
- 七牛用同样的 key 字符串(`/` 即逻辑目录)。
- `tid=0`(platform 端或缺失租户上下文)时 backend 端路径退化为 `backend/0/...`,并在日志告警。
### 访问 URL
- 本地:`/uploads/backend/234573/2026/09/09/xxx.png`
- 七牛:`{domain}/backend/234573/2026/09/09/xxx.png`
---
## 4. 数据库改造(S1)
### 4.1 新增字段
```sql
ALTER TABLE `yz_system_files`
ADD COLUMN `source` varchar(16) NOT NULL DEFAULT 'backend' COMMENT '来源端: backend-租户后台 platform-平台端',
ADD COLUMN `scope` varchar(16) NOT NULL DEFAULT 'tenant' COMMENT '归属: tenant-租户共享 user-用户个人',
ADD COLUMN `storage` varchar(16) NOT NULL DEFAULT '' COMMENT '存储类型: local/qiniu(冗余,便于迁移与排查)',
ADD COLUMN `object_key` varchar(512) NOT NULL DEFAULT '' COMMENT '存储相对路径(不含域名),用于迁移与精确删除';
ALTER TABLE `yz_system_files`
ADD KEY `idx_file_dedup` (`source`, `scope`, `tid`, `tuid`, `md5`),
ADD KEY `idx_file_owner` (`source`, `tid`, `scope`, `tuid`, `delete_time`);
```
> 不建议加 UNIQUE:`delete_time` 软删 + 并发上传下唯一索引会直接报错,改用普通索引 + 代码层查重。
### 4.2 字段语义(改造后明确)
| 字段 | 语义 |
|---|---|
| `tid` | 租户 ID |
| `uid` | **上传者** ID(保持不变) |
| `tuid` | **归属用户** ID(个人文件必填,共享文件为 NULL) |
| `source` | `backend` / `platform` |
| `scope` | `tenant` / `user` |
| `object_key` | 存储相对路径,如 `backend/234573/67091493/2026/09/09/xxx.png` |
### 4.3 落地方式
在 `models/system_file.go` 增加 `EnsureSystemFileStorageColumns()`(参照 `EnsureTenantUserGroupColumn` 的既有模式,`ALTER` 报错忽略),并在 `BackendFileController.Prepare()` / `PlatformFileController.Prepare()` 中调用。SQL 脚本同步落到 `go/docs/sql/`。
---
## 5. 服务端改造
### S2 `services/storage_service.go`
```go
// UploadContext 上传上下文,决定最终落盘路径
type UploadContext struct {
Source string // backend / platform
Tid uint64 // 租户 ID
Tuid uint64 // 归属用户 ID,0 = 租户共享
Ext string // 扩展名
}
// BuildObjectKey 生成存储相对路径(不含域名、不含 BaseDir)
// backend 共享: backend/234573/2026/09/09/xxx.png
// backend 个人: backend/234573/67091493/2026/09/09/xxx.png
// platform : platform/2026/09/09/xxx.png
func BuildObjectKey(ctx UploadContext) (key, datePath string)
```
新增方法(保留原 `Upload` 内部复用):
- `StageToTemp(file, header) (tmpPath string, md5 string, size int64, err error)` — 流式算 MD5 并写入**同磁盘**临时目录 `uploads/.tmp/`(保证后续 `os.Rename` 不跨盘)。
- `CommitTemp(tmpPath, objectKey) (*UploadResult, error)` — `MkdirAll` + `Rename`(跨盘失败则回退 `io.Copy`)。
- `UploadWithContext(file, header, ctx)` — 组合上面两步,供迁移脚本等简单场景使用。
> 七牛同样"先算 MD5":先 `StageToTemp` 得到 md5 + 临时文件 → 查重 → 命中则删临时文件返回已存在,未命中才 `Put` 到新 key。
### S3 新增 `services/file_service.go`
- `FindDuplicate(source, scope string, tid, tuid uint64, md5 string) (*models.SystemFile, error)`
- `CreateFileRecord(...) (uint64, error)` — 统一写入 `source/scope/object_key/storage`
- `ListFiles(source, scope string, tid, tuid uint64, ...) ` — 列表的 scope 隔离
- `RemovePhysical(storageType, objectKey, src string) error` — 用 `object_key` 精确删除,替代脆弱的 `removePhysicalBySrc`
### S4 `controllers/backend_file.go`
1. `Prepare()` 里调 `models.EnsureSystemFileStorageColumns()`。
2. `UploadFile` 流程改为:
```
鉴权 → effectiveTid → 解析 tuid(form > X-Tenant-User-Id 头 > claims.UserID)
→ StageToTemp(拿到 md5/size)
→ FindDuplicate(source=backend, scope, tid, tuid, md5)
命中 → 删临时文件 → 返回 code 201(文件已存在)
未命中 → CommitTemp(objectKey) → 入库 → 返回 code 200
```
3. `scope` 判定:`tuid > 0` → `user`,否则 `tenant`。
4. 列表接口 `GetAllFiles` / `GetCateFiles` / `GetUserCate` 增加 `scope` 过滤:
- 默认只返回 `scope=tenant`;
- `?scope=user` 时按当前 `tuid` 过滤,只返回本人文件。
5. 删除:统一用 `object_key` 删除物理文件;统一软删与批量删除的语义(批量删除不再误删物理文件,彻底删除才删)。
### S5 `controllers/platform_file.go`
- `source=platform`,路径固定 `platform/日期`,**忽略 `tuid` 与租户分层**。
- 其余(先算 MD5 再落盘、`object_key` 入库、删除修复)与 S4 保持一致。
### S6 `controllers/qiniu_upload.go`
- `GetUploadToken` 返回的 `keyPrefix` 改为 `platform/2026/09/09/{ts}`(原来是 `2026/09/09/{ts}`)。
- `SaveFileRecord` 入库时写 `source=platform`、`scope=tenant`、`object_key=req.Key`。
- 明确 `md5` 字段:直传场景无法拿到真 MD5,**保留 etag 但写入时打标**(在 `md5` 为空时用 etag,并在注释中说明;后续如需精确去重,此通道需改为服务端中转)。
### S7 / S8 前端改造
统一封装:
- `backend/src/api/file.js` 与 `platform/src/api/file.js` 的 `uploadFile(formData, options)` 增加 `options.tuid`,并**移除调用处重复的 `cate` append**(保留 options 里那次)。
- 个人文件场景传 `tuid`,公共场景不传。
---
## 6. 前端上传点归属判定表
> **`tenant` = 租户共享(`backend/{tid}/日期/`);`user` = 用户个人(`backend/{tid}/{tuid}/日期/`)**
### backend 端
| # | 功能 | 判定 | 依据 |
|---|---|---|---|
| B1 | 系统文件库上传 | `tenant` | ✅ 已确认:素材库不分「共享/我的」,全部走租户共享;个人文件只来自 B9/B10 |
| B2 | 富文本(文章/方案/产品正文) | `tenant` | 业务内容,全租户可见 |
| B3 | CMS 文章封面 | `tenant` | 业务数据 |
| B4 | CMS 文章分类默认图 | `tenant` | 业务数据 |
| B5 | 模块中心缩略图 | `tenant` | 模块市场资源 |
| B6/B7/B8 | 站点 Logo / 白色 Logo / ico | `tenant` | 租户级配置 |
| B9 | OA 日程图片 | `user` | ✅ 已确认:按日程创建人归属 |
| B10 | OA 报销发票 | `user` | ✅ 已确认:挂在员工个人报销单下 |
| B11 | 员工档案记录附件 | `tenant` | ✅ 已确认:HR 需跨员工查看 |
| B12 | 员工证照(学历照片) | `tenant` | ✅ 已确认:HR 需跨员工查看 |
| B13~B17 | CMS Banner/方案/产品/菜单/友链 | `tenant` | 业务数据 + **需修 404**(✅ 已确认要修) |
| B18 | 租户资质文件 | `tenant` | **需修 404**(✅ 已确认要修,接口需重新设计) |
| B19 | 组织架构 CSV 导入 | 不涉及 | 临时解析,不入库 |
### platform 端(全部 `platform/日期/`,不做租户/用户分层)
| # | 功能 | 处理 |
|---|---|---|
| P1~P6 | 文件管理 / 富文本 / 站点 Logo ×3 / 模块缩略图 | 保持调用 `/platform/uploadfile`,无需传 `tuid` |
| P7 | 软件升级包 | `smartUpload` → 七牛直传时 key 也要带 `platform/` 前缀(S6 已覆盖) |
| P8 | 模板 zip | 不动(走 `/platform/template/upload`,不入库) |
| P9 | 用户头像 | **需修**:`{BASE}/platform/upload` → `/platform/uploadfile` |
| P10 / P12 | `uploadAvatar()` 死代码 | 本次不启用(或删除) |
| P11 | 租户资质图片 | ⚠️ **待确认**,见 §8-Q3 |
| P13 / P14 | 模块包(未发请求) | 不动 |
---
## 7. 存量数据迁移方案(S9)
### 7.1 当前存储类型
✅ **已确认:`storage_type = qiniu`(七牛云)** → 迁移走 `BucketManager.Move`(服务端改名,不走流量、与文件大小无关)。
> 若后续切回本地存储,脚本自动改为 `os.Rename` 分支。
### 7.2 迁移原理
- **七牛云**:`BucketManager.Move(srcBucket, srcKey, destBucket, destKey)` — 同 bucket 内**服务端原子改名**,不走流量、秒级完成,只计 API 调用次数。与文件大小无关。
- **本地**:`os.MkdirAll` + `os.Rename`(同盘,不搬数据)。
> 注意:现有 `services/storage_migration.go` 的 `MigrateFile` 是"下载再上传"的旧实现,**本次要替换为 Move/Rename**。
### 7.3 迁移步骤
1. 遍历 `yz_system_files WHERE delete_time IS NULL`。
2. 从 `src` 解析出老 key:
- 本地:`/uploads/2026/09/09/xxx.png` → `2026/09/09/xxx.png`
- 七牛:`{domain}/2026/09/09/xxx.png` → `2026/09/09/xxx.png`
3. 按新规则生成目标 key:**老数据一律按 `scope=tenant`(租户共享)迁移**(原因见 7.4)。
4. Move / Rename。
5. `UPDATE yz_system_files SET src=新URL, object_key=新key, source=..., scope='tenant', storage=... WHERE id=?`。
### 7.4 老数据的两个硬限制(必须知悉)
1. **还原不出"个人/共享"归属**:老数据 `tuid` 全为 NULL,`uid` 是上传者(管理员)ID,没有任何字段能说明"这是谁的个人文件"。因此老数据**统一按租户共享迁移**;个人目录只对改造后新增的文件生效。
2. **`md5` 可能混了七牛 etag**:判断方法
```sql
SELECT COUNT(*) FROM yz_system_files WHERE LENGTH(md5) <> 32;
```
非 32 位的记录是 etag,不是真 MD5,这部分去重会失准(本次不修复,仅记录)。
### 7.5 硬编码 URL 风险
Move 后旧 URL 会 404。需排查是否有业务把上传 URL 写死在 `yz_system_files.src` 之外的地方(CMS 正文 `content`、官网模板配置 `yz_tenant_site_setting` 等)。**执行迁移前先做全库扫描**,这部分在 S9 里做。
### 7.6 脚本形态
`go/cmd/migrate_storage/main.go`:
- 默认 `--dry-run`:只打印 `老key → 新key` 计划与统计,不改动任何数据。
- `--apply`:真正执行。
- `--tid=234573`:可选,只迁移指定租户。
- 幂等:目标 key 已存在则跳过,可重复执行。
---
## 8. 待确认事项(**未答复前这些模块不改动**)
### 已确认
| 编号 | 结论 | 影响范围 |
|---|---|---|
| Q2 | **日程图片、报销发票 → `user`(个人);员工档案附件、员工证照 → `tenant`(共享)** | B9~B12 |
| Q3 | **三组坏链全部修**:①`/admin/uploadfiles`(B13~B17)→ `/backend/uploadfile`;②`/platform/upload`(P9)→ `/platform/uploadfile`;③`/api/platform/common/upload`(B18、P11 资质)→ 重新设计 | B13~B18、P9、P11 |
| Q4 | **存储类型 = 七牛云 qiniu** → 迁移走 `BucketManager.Move` | S9 |
| Q1 | **素材库不分 Tab,全部走租户共享。** 个人文件只来自 B9 日程图片、B10 报销发票。后端已备好 `scope=user` 能力,以后要加「我的文件」Tab 只需前端加 Tab + 传参。 | B1 |
### 待确认
无(Q1~Q4 全部已确认)。
### 后续单列任务(不在本次改造范围)
| 编号 | 事项 | 说明 |
|---|---|---|
| T1 | **租户资质业务落库** | B18/P11 的上传通道已修(改为走通用上传接口、带上 token),但 `submitForm` 仍是前端 mock,后端**没有资质表也没有保存接口**。需要新表(tid/type/file_url/expire_time/remark)+ 保存/详情接口,属新功能,另开任务。 |
| T2 | 七牛直传通道的 MD5 是 etag | 软件升级包走七牛直传,`md5` 存的是 etag 与服务端中转算出的真 MD5 不同源,该通道查重只在通道内有效。如需全局精确去重,需把直传改为服务端中转(大文件代价高)。 |
---
## 9. 风险与回滚
| 风险 | 应对 |
|---|---|
| 新目录规则上线后老 URL 失效 | 迁移前先全库扫描硬编码引用;迁移脚本先 dry-run |
| `effectiveTid()` 拿到 0 | backend 端 tid=0 时落 `backend/0/` 并打 WARN 日志,不阻断 |
| 并发上传同文件 | 临时文件名带随机后缀;查重与入库之间的极短窗口允许少量重复(后续可加分布式锁) |
| `os.Rename` 跨盘失败 | 回退 `io.Copy` |
| 改造中断 | 按 §0 进度总表从第一个未勾选项继续 |
| 回滚 | 服务端改动集中在 `storage_service.go` + 两个 file controller,回滚即恢复这 3 个文件的旧版本;数据库新增列可保留(不影响旧逻辑) |
---
## 10. 验收清单
- [ ] backend 上传图片 → 落盘到 `uploads/backend/{tid}/2026/09/09/`
- [ ] backend 传 `tuid` 上传 → 落盘到 `uploads/backend/{tid}/{tuid}/2026/09/09/`
- [ ] platform 上传 → 落盘到 `uploads/platform/2026/09/09/`
- [ ] 同一租户重复上传同一文件 → 返回 201,且**磁盘上没有新增文件**
- [ ] 租户已有 a 文件,用户 c 上传同样文件 → 正常入库,不冲突(两处物理文件并存)
- [ ] 用户 c 再传同一文件 → 返回 201
- [ ] 文件列表:`scope=user` 只看到自己的;默认只看到租户共享
- [ ] 删除文件 → 物理文件按 `object_key` 精确删除
- [ ] 七牛模式:`keyPrefix` 带 `platform/` 前缀
- [ ] 迁移 dry-run 输出正确,apply 后旧 URL 全部更新且可访问
+81 -25
View File
@@ -1,25 +1,81 @@
package models
import "time"
// SystemFile 附件表 yz_system_files
type SystemFile struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid uint64 `orm:"column(tid)" json:"tid"`
Uid *uint64 `orm:"column(uid);null" json:"uid"`
Tuid *uint64 `orm:"column(tuid);null" json:"tuid"`
Name string `orm:"column(name);size(255)" json:"name"`
Type uint8 `orm:"column(type);default(2)" json:"type"`
Cate uint64 `orm:"column(cate);default(0)" json:"cate"`
Size uint64 `orm:"column(size);default(0)" json:"size"`
Src string `orm:"column(src);size(512)" json:"src"`
Uploader uint64 `orm:"column(uploader);default(0)" json:"uploader"`
Md5 string `orm:"column(md5);size(32)" json:"md5"`
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null;auto_now" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *SystemFile) TableName() string {
return "yz_system_files"
}
package models
import (
"sync"
"time"
)
// SystemFile 附件表 yz_system_files
//
// 归属语义(2026-09-09 文件存储分层改造后明确):
// - tid : 租户 ID
// - uid : 上传者 ID
// - tuid : 归属用户 ID(scope=user 时必填;scope=tenant 时为 NULL)
// - source: 来源端 backend / platform
// - scope : tenant-租户共享 / user-用户个人
// - object_key: 存储相对路径,不含域名,如 backend/234573/67091493/2026/09/09/xxx.png
type SystemFile struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid uint64 `orm:"column(tid)" json:"tid"`
Uid *uint64 `orm:"column(uid);null" json:"uid"`
Tuid *uint64 `orm:"column(tuid);null" json:"tuid"`
Name string `orm:"column(name);size(255)" json:"name"`
Type uint8 `orm:"column(type);default(2)" json:"type"`
Cate uint64 `orm:"column(cate);default(0)" json:"cate"`
Size uint64 `orm:"column(size);default(0)" json:"size"`
Src string `orm:"column(src);size(512)" json:"src"`
Uploader uint64 `orm:"column(uploader);default(0)" json:"uploader"`
Md5 string `orm:"column(md5);size(32)" json:"md5"`
Source string `orm:"column(source);size(16);default(backend)" json:"source"`
Scope string `orm:"column(scope);size(16);default(tenant)" json:"scope"`
Storage string `orm:"column(storage);size(16);default()" json:"storage"`
ObjectKey string `orm:"column(object_key);size(512);default()" json:"object_key"`
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null;auto_now" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *SystemFile) TableName() string {
return "yz_system_files"
}
// 归属与存储字段的取值常量
const (
FileSourceBackend = "backend" // 租户后台端
FileSourcePlatform = "platform" // 平台端
FileScopeTenant = "tenant" // 租户共享文件
FileScopeUser = "user" // 用户个人文件
)
var systemFileStorageColumnsOnce sync.Once
// EnsureSystemFileStorageColumns 为附件表补齐文件存储分层改造所需字段。
// 历史库可能早于该特性建表,字段/索引已存在时 MySQL 报 duplicate,统一忽略,不阻断主流程。
// 写入 yz_system_files 之前必须调用,否则 INSERT 会因 Unknown column 失败。
func EnsureSystemFileStorageColumns() {
if Orm == nil {
return
}
systemFileStorageColumnsOnce.Do(func() {
alters := []string{
"ALTER TABLE yz_system_files ADD COLUMN source varchar(16) NOT NULL DEFAULT 'backend' COMMENT '来源端: backend-租户后台 platform-平台端'",
"ALTER TABLE yz_system_files ADD COLUMN scope varchar(16) NOT NULL DEFAULT 'tenant' COMMENT '归属: tenant-租户共享 user-用户个人'",
"ALTER TABLE yz_system_files ADD COLUMN storage varchar(16) NOT NULL DEFAULT '' COMMENT '存储类型: local/qiniu'",
"ALTER TABLE yz_system_files ADD COLUMN object_key varchar(512) NOT NULL DEFAULT '' COMMENT '存储相对路径(不含域名)'",
}
for _, sql := range alters {
_, _ = Orm.Raw(sql).Exec()
}
// 索引:MD5 精确去重 + 归属列表过滤
// md5 在部分历史库中是 TEXT 类型,MySQL 不允许对 TEXT/BLOB 建整列索引,
// 故使用前缀长度 md5(32)(MD5 十六进制串固定 32 字符)。
indexes := []string{
"ALTER TABLE yz_system_files ADD KEY idx_file_dedup (source, scope, tid, tuid, md5(32))",
"ALTER TABLE yz_system_files ADD KEY idx_file_owner (source, tid, scope, tuid, delete_time)",
}
for _, sql := range indexes {
_, _ = Orm.Raw(sql).Exec()
}
})
}
+216
View File
@@ -0,0 +1,216 @@
package services
import (
"fmt"
"net/url"
"path/filepath"
"strings"
"time"
"server/models"
)
// FileUploadParams 创建文件记录所需参数
type FileUploadParams struct {
Source string // backend / platform
Scope string // tenant / user
Tid uint64
Tuid uint64 // 归属用户,0 表示租户共享
Uid uint64 // 上传者
Name string
Ext string
Cate uint64
Size uint64
Src string
ObjectKey string
Storage string
MD5 string
}
// FindDuplicate 按「来源端 + 归属范围 + 租户 + 归属用户 + MD5」精确查重。
//
// 规则:
// - 租户共享文件:同一 tid 内 MD5 相同才算重复(tuid 为 NULL)
// - 用户个人文件:同一 tid + 同一 tuid 内 MD5 相同才算重复
// - 因此「租户已有 a 文件」与「用户 c 上传同样文件」互不冲突,可并存
//
// 未命中时返回 (nil, nil),调用方按 err == nil && file == nil 处理即可。
func FindDuplicate(source, scope string, tid, tuid uint64, md5Str string) (*models.SystemFile, error) {
if md5Str == "" {
return nil, nil
}
models.EnsureSystemFileStorageColumns()
qs := models.Orm.QueryTable(new(models.SystemFile)).
Filter("source", source).
Filter("scope", scope).
Filter("tid", tid).
Filter("md5", md5Str).
Filter("delete_time__isnull", true)
if scope == ScopeUser {
qs = qs.Filter("tuid", tuid)
} else {
qs = qs.Filter("tuid__isnull", true)
}
var f models.SystemFile
if err := qs.OrderBy("-id").One(&f); err != nil {
return nil, nil
}
return &f, nil
}
// CreateFileRecord 写入文件记录(同步写入归属与存储字段)
func CreateFileRecord(p FileUploadParams) (uint64, error) {
models.EnsureSystemFileStorageColumns()
uid := p.Uid
row := &models.SystemFile{
Tid: p.Tid,
Uid: &uid,
Name: p.Name,
Type: DetectFileType(p.Ext),
Cate: p.Cate,
Size: p.Size,
Src: p.Src,
Uploader: p.Uid,
Md5: p.MD5,
Source: p.Source,
Scope: p.Scope,
Storage: p.Storage,
ObjectKey: p.ObjectKey,
}
if p.Tuid > 0 {
tuid := p.Tuid
row.Tuid = &tuid
}
if row.Source == "" {
row.Source = SourceBackend
}
if row.Scope == "" {
row.Scope = ScopeTenant
}
id, err := models.Orm.Insert(row)
if err != nil {
return 0, err
}
return uint64(id), nil
}
// RemovePhysical 删除物理文件。优先用 object_key,老数据则从 src 反推。
func RemovePhysical(svc StorageService, objectKey, src string) error {
if svc == nil {
return fmt.Errorf("存储服务未初始化")
}
key := strings.TrimSpace(objectKey)
if key == "" {
key = KeyFromSrc(src, svc)
}
if key == "" {
return fmt.Errorf("无法解析文件路径: %s", src)
}
return svc.Delete(key)
}
// KeyFromSrc 从访问 URL/相对路径中解析出存储 key(object_key)
func KeyFromSrc(src string, svc StorageService) string {
src = strings.TrimSpace(src)
if src == "" {
return ""
}
switch s := svc.(type) {
case *LocalStorage:
base := filepath.ToSlash(s.BaseDir)
rel := src
if i := strings.Index(rel, base+"/"); i >= 0 {
rel = rel[i+len(base)+1:]
}
rel = strings.TrimPrefix(rel, "/")
// 去掉可能的查询串
if i := strings.IndexAny(rel, "?#"); i >= 0 {
rel = rel[:i]
}
return rel
case *QiniuStorage:
domain := strings.TrimRight(s.Domain, "/")
rel := src
if domain != "" && strings.HasPrefix(rel, domain) {
rel = strings.TrimPrefix(strings.TrimPrefix(rel, domain), "/")
} else if u, err := url.Parse(rel); err == nil && u.Host != "" {
rel = strings.TrimPrefix(u.Path, "/")
}
rel = strings.TrimPrefix(rel, "/")
if i := strings.IndexAny(rel, "?#"); i >= 0 {
rel = rel[:i]
}
return rel
}
return ""
}
// 文件类型与扩展名白名单(两端共用同一套规则)
var fileTypeByCategory = map[string]uint8{
"image": 1,
"document": 2,
"video": 3,
"audio": 4,
"appsupgrade": 2,
}
var allowedExtByCategory = map[string][]string{
"image": {"jpg", "jpeg", "png", "gif", "bmp", "webp"},
"document": {"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt"},
"video": {"mp4", "webm", "mov"},
"audio": {"mp3", "wav", "ogg"},
"appsupgrade": {"zip", "exe", "dmg", "msi", "msix", "apk", "deb", "rpm", "7z", "tar", "gz", "pkg"},
}
// FileExt 取小写扩展名(不含点)
func FileExt(name string) string {
name = strings.TrimSpace(name)
if i := strings.LastIndex(name, "."); i >= 0 && i < len(name)-1 {
return strings.ToLower(name[i+1:])
}
return ""
}
// DetectFileType 根据扩展名推断文件类型:1图片 2文档 3视频 4音频,未匹配返回 2(文档/其他)
func DetectFileType(ext string) uint8 {
ext = strings.ToLower(strings.TrimPrefix(ext, "."))
for cat, exts := range allowedExtByCategory {
for _, e := range exts {
if e == ext {
if t, ok := fileTypeByCategory[cat]; ok {
return t
}
return 2
}
}
}
return 2
}
// SoftDeleteFiles 软删除(标记 delete_time)
func SoftDeleteFiles(ids []uint64) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
models.EnsureSystemFileStorageColumns()
return models.Orm.QueryTable(new(models.SystemFile)).
Filter("id__in", ids).
Filter("delete_time__isnull", true).
Update(map[string]interface{}{"delete_time": time.Now()})
}
// DeleteFilesPermanently 彻底删除数据库记录(物理文件由调用方先删)
func DeleteFilesPermanently(ids []uint64) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
models.EnsureSystemFileStorageColumns()
return models.Orm.QueryTable(new(models.SystemFile)).
Filter("id__in", ids).
Delete()
}
+222 -191
View File
@@ -1,191 +1,222 @@
package services
import (
"fmt"
"mime/multipart"
"os"
"path/filepath"
"strings"
"sync"
"server/models"
)
// MigrationProgress 迁移进度
type MigrationProgress struct {
Total int
Success int
Failed int
Current string
Errors []string
mu sync.Mutex
}
// AddSuccess 增加成功计数
func (p *MigrationProgress) AddSuccess() {
p.mu.Lock()
defer p.mu.Unlock()
p.Success++
}
// AddFailed 增加失败计数
func (p *MigrationProgress) AddFailed(err string) {
p.mu.Lock()
defer p.mu.Unlock()
p.Failed++
p.Errors = append(p.Errors, err)
}
// SetCurrent 设置当前处理的文件
func (p *MigrationProgress) SetCurrent(filename string) {
p.mu.Lock()
defer p.mu.Unlock()
p.Current = filename
}
// GetProgress 获取进度信息
func (p *MigrationProgress) GetProgress() (int, int, int, string) {
p.mu.Lock()
defer p.mu.Unlock()
return p.Total, p.Success, p.Failed, p.Current
}
// StorageMigration 存储迁移服务
type StorageMigration struct {
fromService StorageService
toService StorageService
progress *MigrationProgress
}
// NewStorageMigration 创建存储迁移服务
func NewStorageMigration(from, to StorageService) *StorageMigration {
return &StorageMigration{
fromService: from,
toService: to,
progress: &MigrationProgress{
Errors: make([]string, 0),
},
}
}
// MigrateFile 迁移单个文件
func (m *StorageMigration) MigrateFile(file *models.SystemFile) error {
m.progress.SetCurrent(file.Name)
// 如果是本地存储,从本地读取文件
if localFrom, ok := m.fromService.(*LocalStorage); ok {
// 从本地文件系统读取
localPath := strings.TrimPrefix(file.Src, "/")
filePath := filepath.Join(localFrom.BaseDir, localPath)
f, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("打开本地文件失败: %w", err)
}
defer f.Close()
// 获取文件信息
stat, err := f.Stat()
if err != nil {
return fmt.Errorf("获取文件信息失败: %w", err)
}
// 创建 multipart.FileHeader
header := &multipart.FileHeader{
Filename: file.Name,
Size: stat.Size(),
}
// 上传到目标存储
result, err := m.toService.Upload(f, header)
if err != nil {
return fmt.Errorf("上传到目标存储失败: %w", err)
}
// 更新数据库记录
_, err = models.Orm.QueryTable(new(models.SystemFile)).
Filter("id", file.ID).
Update(map[string]interface{}{
"src": result.URL,
})
if err != nil {
// 上传成功但更新数据库失败,尝试删除已上传的文件
_ = m.toService.Delete(result.Key)
return fmt.Errorf("更新数据库失败: %w", err)
}
m.progress.AddSuccess()
return nil
}
// 如果是七牛云存储,需要先下载再上传(这里简化处理)
return fmt.Errorf("暂不支持从七牛云迁移到本地")
}
// MigrateAll 迁移所有文件
func (m *StorageMigration) MigrateAll(tid uint64) error {
// 获取所有文件
var files []models.SystemFile
_, err := models.Orm.QueryTable(new(models.SystemFile)).
Filter("tid", tid).
Filter("delete_time__isnull", true).
All(&files)
if err != nil {
return fmt.Errorf("获取文件列表失败: %w", err)
}
m.progress.Total = len(files)
// 并发迁移(限制并发数)
concurrency := 5
sem := make(chan struct{}, concurrency)
var wg sync.WaitGroup
for i := range files {
wg.Add(1)
go func(file *models.SystemFile) {
defer wg.Done()
sem <- struct{}{} // 获取信号量
defer func() { <-sem }() // 释放信号量
if err := m.MigrateFile(file); err != nil {
m.progress.AddFailed(fmt.Sprintf("%s: %v", file.Name, err))
}
}(&files[i])
}
wg.Wait()
return nil
}
// GetProgress 获取迁移进度
func (m *StorageMigration) GetProgress() *MigrationProgress {
return m.progress
}
// MigrateLocalToQiniu 从本地存储迁移到七牛云
func MigrateLocalToQiniu(tid uint64) (*MigrationProgress, error) {
// 获取存储配置
cfg, err := models.GetStorageConfig()
if err != nil {
return nil, fmt.Errorf("获取存储配置失败: %w", err)
}
if cfg.StorageType != "qiniu" {
return nil, fmt.Errorf("当前存储类型不是七牛云")
}
// 创建存储服务
localStorage := NewLocalStorage()
qiniuStorage := NewQiniuStorage(cfg)
// 创建迁移服务
migration := NewStorageMigration(localStorage, qiniuStorage)
// 执行迁移
if err := migration.MigrateAll(tid); err != nil {
return migration.GetProgress(), err
}
return migration.GetProgress(), nil
}
package services
import (
"fmt"
"mime/multipart"
"os"
"path/filepath"
"sync"
"server/models"
)
// MigrationProgress 迁移进度
type MigrationProgress struct {
Total int
Success int
Failed int
Skipped int
Current string
Errors []string
mu sync.Mutex
}
// AddSuccess 增加成功计数
func (p *MigrationProgress) AddSuccess() {
p.mu.Lock()
defer p.mu.Unlock()
p.Success++
}
// AddSkipped 增加跳过计数
func (p *MigrationProgress) AddSkipped() {
p.mu.Lock()
defer p.mu.Unlock()
p.Skipped++
}
// AddFailed 增加失败计数
func (p *MigrationProgress) AddFailed(err string) {
p.mu.Lock()
defer p.mu.Unlock()
p.Failed++
p.Errors = append(p.Errors, err)
}
// SetCurrent 设置当前处理的文件
func (p *MigrationProgress) SetCurrent(filename string) {
p.mu.Lock()
defer p.mu.Unlock()
p.Current = filename
}
// GetProgress 获取进度信息
func (p *MigrationProgress) GetProgress() (int, int, int, string) {
p.mu.Lock()
defer p.mu.Unlock()
return p.Total, p.Success, p.Failed, p.Current
}
// StorageMigration 存储迁移服务
type StorageMigration struct {
fromService StorageService
toService StorageService
progress *MigrationProgress
}
// NewStorageMigration 创建存储迁移服务
func NewStorageMigration(from, to StorageService) *StorageMigration {
return &StorageMigration{
fromService: from,
toService: to,
progress: &MigrationProgress{
Errors: make([]string, 0),
},
}
}
// TargetKey 计算某条文件记录改造后的新 key
func TargetKey(f *models.SystemFile) string {
source := f.Source
if source == "" {
source = SourceBackend
}
var tuid uint64
if f.Tuid != nil {
tuid = *f.Tuid
}
return BuildObjectKey(UploadContext{Source: source, Tid: f.Tid, Tuid: tuid}, FileExt(f.Name))
}
// MigrateFile 迁移单个文件到新的分层目录
//
// 同类型存储(七牛→七牛 / 本地→本地)走服务端改名,不重新上传、不消耗流量;
// 本地→七牛 走"读取 + 上传",其余跨存储方向暂不支持。
func (m *StorageMigration) MigrateFile(file *models.SystemFile) error {
m.progress.SetCurrent(file.Name)
oldKey := file.ObjectKey
if oldKey == "" {
oldKey = KeyFromSrc(file.Src, m.fromService)
}
if oldKey == "" {
return fmt.Errorf("无法解析原存储路径: %s", file.Src)
}
newKey := TargetKey(file)
if oldKey == newKey {
m.progress.AddSkipped()
return nil
}
if m.fromService.Type() == m.toService.Type() {
if err := m.toService.Move(oldKey, newKey); err != nil {
return fmt.Errorf("移动文件失败: %w", err)
}
} else {
// 跨存储:仅支持 本地 → 七牛
localFrom, ok := m.fromService.(*LocalStorage)
if !ok {
return fmt.Errorf("暂不支持从 %s 迁出到 %s", m.fromService.Type(), m.toService.Type())
}
localPath := filepath.Join(localFrom.BaseDir, filepath.FromSlash(oldKey))
f, err := os.Open(localPath)
if err != nil {
return fmt.Errorf("打开本地文件失败: %w", err)
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return fmt.Errorf("获取文件信息失败: %w", err)
}
header := &multipart.FileHeader{Filename: file.Name, Size: stat.Size()}
staged, err := m.toService.Stage(f, header)
if err != nil {
return err
}
source := file.Source
if source == "" {
source = SourceBackend
}
var tuid uint64
if file.Tuid != nil {
tuid = *file.Tuid
}
if _, err := m.toService.Commit(staged, UploadContext{Source: source, Tid: file.Tid, Tuid: tuid}); err != nil {
_ = m.toService.Discard(staged)
return err
}
}
newSrc := m.toService.GetPublicURL(newKey)
if _, err := models.Orm.QueryTable(new(models.SystemFile)).
Filter("id", file.ID).
Update(map[string]interface{}{
"src": newSrc,
"object_key": newKey,
"storage": m.toService.Type(),
}); err != nil {
return fmt.Errorf("更新数据库失败: %w", err)
}
m.progress.AddSuccess()
return nil
}
// MigrateAll 迁移所有文件
func (m *StorageMigration) MigrateAll(tid uint64) error {
var files []models.SystemFile
qs := models.Orm.QueryTable(new(models.SystemFile)).Filter("delete_time__isnull", true)
if tid > 0 {
qs = qs.Filter("tid", tid)
}
if _, err := qs.All(&files); err != nil {
return fmt.Errorf("获取文件列表失败: %w", err)
}
m.progress.Total = len(files)
concurrency := 5
sem := make(chan struct{}, concurrency)
var wg sync.WaitGroup
for i := range files {
wg.Add(1)
go func(file *models.SystemFile) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
if err := m.MigrateFile(file); err != nil {
m.progress.AddFailed(fmt.Sprintf("%s: %v", file.Name, err))
}
}(&files[i])
}
wg.Wait()
return nil
}
// GetProgress 获取迁移进度
func (m *StorageMigration) GetProgress() *MigrationProgress {
return m.progress
}
// MigrateLocalToQiniu 从本地存储迁移到七牛云
func MigrateLocalToQiniu(tid uint64) (*MigrationProgress, error) {
cfg, err := models.GetStorageConfig()
if err != nil {
return nil, fmt.Errorf("获取存储配置失败: %w", err)
}
if cfg.StorageType != StorageTypeQiniu {
return nil, fmt.Errorf("当前存储类型不是七牛云")
}
migration := NewStorageMigration(NewLocalStorage(), NewQiniuStorage(cfg))
if err := migration.MigrateAll(tid); err != nil {
return migration.GetProgress(), err
}
return migration.GetProgress(), nil
}
+440 -252
View File
@@ -1,252 +1,440 @@
package services
import (
"context"
"crypto/md5"
"encoding/hex"
"fmt"
"io"
"mime/multipart"
"os"
"path/filepath"
"strings"
"time"
"server/models"
"github.com/qiniu/go-sdk/v7/auth/qbox"
"github.com/qiniu/go-sdk/v7/storage"
)
// StorageService 存储服务接口
type StorageService interface {
Upload(file multipart.File, header *multipart.FileHeader) (*UploadResult, error)
GetPublicURL(key string) string
Delete(key string) error
}
// UploadResult 上传结果
type UploadResult struct {
URL string // 完整访问URL
Key string // 存储key/路径
Size int64 // 文件大小
MD5 string // 文件MD5
MimeType string // 文件类型
}
// LocalStorage 本地存储实现
type LocalStorage struct {
BaseDir string // 基础目录,默认 "uploads"
BaseURL string // 基础URL,默认 "/"
}
// NewLocalStorage 创建本地存储服务
func NewLocalStorage() *LocalStorage {
return &LocalStorage{
BaseDir: "uploads",
BaseURL: "/",
}
}
// Upload 上传文件到本地
func (s *LocalStorage) Upload(file multipart.File, header *multipart.FileHeader) (*UploadResult, error) {
// 生成存储路径
ext := filepath.Ext(header.Filename)
datePath := time.Now().Format("2006/01/02")
fileName := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
savePath := filepath.Join(datePath, fileName)
// 创建目录
destDir := filepath.Join(s.BaseDir, filepath.FromSlash(datePath))
if err := os.MkdirAll(destDir, 0755); err != nil {
return nil, fmt.Errorf("创建目录失败: %w", err)
}
// 保存文件
destPath := filepath.Join(s.BaseDir, filepath.FromSlash(savePath))
dst, err := os.Create(destPath)
if err != nil {
return nil, fmt.Errorf("创建文件失败: %w", err)
}
defer dst.Close()
// 计算MD5并复制文件
hash := md5.New()
size, err := io.Copy(io.MultiWriter(dst, hash), file)
if err != nil {
_ = os.Remove(destPath)
return nil, fmt.Errorf("保存文件失败: %w", err)
}
md5Sum := hex.EncodeToString(hash.Sum(nil))
webURL := s.BaseURL + strings.ReplaceAll(filepath.ToSlash(destPath), "\\", "/")
return &UploadResult{
URL: webURL,
Key: savePath,
Size: size,
MD5: md5Sum,
MimeType: header.Header.Get("Content-Type"),
}, nil
}
// GetPublicURL 获取公开访问URL
func (s *LocalStorage) GetPublicURL(key string) string {
return s.BaseURL + filepath.ToSlash(filepath.Join(s.BaseDir, key))
}
// Delete 删除本地文件
func (s *LocalStorage) Delete(key string) error {
filePath := filepath.Join(s.BaseDir, filepath.FromSlash(key))
return os.Remove(filePath)
}
// QiniuStorage 七牛云存储实现
type QiniuStorage struct {
AccessKey string
SecretKey string
Bucket string
Domain string
Region string
}
// NewQiniuStorage 创建七牛云存储服务
func NewQiniuStorage(cfg *models.StorageConfig) *QiniuStorage {
return &QiniuStorage{
AccessKey: cfg.QiniuAccessKey,
SecretKey: cfg.QiniuSecretKey,
Bucket: cfg.QiniuBucket,
Domain: cfg.QiniuDomain,
Region: cfg.QiniuRegion,
}
}
// getZone 根据区域代码获取存储区域
func (s *QiniuStorage) getZone() *storage.Region {
switch s.Region {
case "z0":
return &storage.ZoneHuadong
case "z1":
return &storage.ZoneHuabei
case "z2":
return &storage.ZoneHuanan
case "na0":
return &storage.ZoneBeimei
case "as0":
return &storage.ZoneXinjiapo
case "cn-east-2":
return &storage.ZoneHuadongZheJiang2
default:
return &storage.ZoneHuadong // 默认华东
}
}
// Upload 上传文件到七牛云
func (s *QiniuStorage) Upload(file multipart.File, header *multipart.FileHeader) (*UploadResult, error) {
// 生成存储key
ext := filepath.Ext(header.Filename)
datePath := time.Now().Format("2006/01/02")
fileName := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
key := filepath.ToSlash(filepath.Join(datePath, fileName))
// 创建上传凭证
mac := qbox.NewMac(s.AccessKey, s.SecretKey)
putPolicy := storage.PutPolicy{
Scope: s.Bucket,
}
upToken := putPolicy.UploadToken(mac)
// 配置上传参数
cfg := storage.Config{
Region: s.getZone(),
UseHTTPS: true,
UseCdnDomains: false,
}
// 创建表单上传器
formUploader := storage.NewFormUploader(&cfg)
ret := storage.PutRet{}
putExtra := storage.PutExtra{}
// 计算文件大小和MD5
tmpFile, err := os.CreateTemp("", "qiniu_upload_*")
if err != nil {
return nil, fmt.Errorf("创建临时文件失败: %w", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
hash := md5.New()
size, err := io.Copy(io.MultiWriter(tmpFile, hash), file)
if err != nil {
return nil, fmt.Errorf("读取文件失败: %w", err)
}
md5Sum := hex.EncodeToString(hash.Sum(nil))
// 重置文件指针
if _, err := tmpFile.Seek(0, 0); err != nil {
return nil, fmt.Errorf("重置文件指针失败: %w", err)
}
// 执行上传
err = formUploader.Put(context.Background(), &ret, upToken, key, tmpFile, size, &putExtra)
if err != nil {
return nil, fmt.Errorf("上传到七牛云失败: %w", err)
}
// 构建完整URL
domain := strings.TrimRight(s.Domain, "/")
url := fmt.Sprintf("%s/%s", domain, ret.Key)
return &UploadResult{
URL: url,
Key: ret.Key,
Size: size,
MD5: md5Sum,
MimeType: header.Header.Get("Content-Type"),
}, nil
}
// GetPublicURL 获取七牛云公开访问URL
func (s *QiniuStorage) GetPublicURL(key string) string {
domain := strings.TrimRight(s.Domain, "/")
return fmt.Sprintf("%s/%s", domain, key)
}
// Delete 删除七牛云文件
func (s *QiniuStorage) Delete(key string) error {
mac := qbox.NewMac(s.AccessKey, s.SecretKey)
cfg := storage.Config{
Region: s.getZone(),
UseHTTPS: true,
}
bucketManager := storage.NewBucketManager(mac, &cfg)
err := bucketManager.Delete(s.Bucket, key)
if err != nil {
return fmt.Errorf("删除七牛云文件失败: %w", err)
}
return nil
}
// GetStorageService 根据配置获取存储服务
func GetStorageService() (StorageService, error) {
cfg, err := models.GetStorageConfig()
if err != nil {
// 默认使用本地存储
return NewLocalStorage(), nil
}
switch cfg.StorageType {
case "qiniu":
if cfg.QiniuAccessKey == "" || cfg.QiniuSecretKey == "" ||
cfg.QiniuBucket == "" || cfg.QiniuDomain == "" {
return nil, fmt.Errorf("七牛云配置不完整")
}
return NewQiniuStorage(cfg), nil
case "local":
return NewLocalStorage(), nil
default:
return NewLocalStorage(), nil
}
}
package services
import (
"context"
"crypto/md5"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"mime/multipart"
"os"
"path"
"path/filepath"
"strings"
"time"
"server/models"
"github.com/qiniu/go-sdk/v7/auth/qbox"
"github.com/qiniu/go-sdk/v7/storage"
)
// 来源端
const (
SourceBackend = "backend" // 租户后台端
SourcePlatform = "platform" // 平台端
)
// 归属范围
const (
ScopeTenant = "tenant" // 租户共享文件
ScopeUser = "user" // 用户个人文件
)
// 存储类型
const (
StorageTypeLocal = "local"
StorageTypeQiniu = "qiniu"
)
// UploadContext 上传上下文,决定文件最终的存储路径
type UploadContext struct {
Source string // backend / platform
Tid uint64 // 租户 ID(platform 端可为 0)
Tuid uint64 // 归属用户 ID,0 表示租户共享文件
}
// Scope 返回归属范围:带了归属用户即个人文件,否则为租户共享文件。
// 平台端不做用户分层,统一按租户共享处理。
func (c UploadContext) Scope() string {
if c.Source == SourcePlatform {
return ScopeTenant
}
if c.Tuid > 0 {
return ScopeUser
}
return ScopeTenant
}
// BuildObjectKey 生成存储相对路径(不含域名、不含本地 BaseDir)
//
// backend 共享: backend/234573/2026/09/09/xxx.png
// backend 个人: backend/234573/67091493/2026/09/09/xxx.png
// platform : platform/2026/09/09/xxx.png
func BuildObjectKey(ctx UploadContext, ext string) string {
datePath := time.Now().Format("2006/01/02")
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomHex(6), normalizeExt(ext))
if ctx.Source == SourcePlatform {
return path.Join(SourcePlatform, datePath, name)
}
if ctx.Tuid > 0 {
return path.Join(SourceBackend, fmt.Sprint(ctx.Tid), fmt.Sprint(ctx.Tuid), datePath, name)
}
return path.Join(SourceBackend, fmt.Sprint(ctx.Tid), datePath, name)
}
// normalizeExt 规范扩展名:小写、补前导点
func normalizeExt(ext string) string {
ext = strings.TrimSpace(ext)
if ext == "" {
return ""
}
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
return strings.ToLower(ext)
}
// randomHex 生成 n 位十六进制随机串(使用 crypto/rand,无需种子)
func randomHex(n int) string {
b := make([]byte, (n+1)/2)
if _, err := rand.Read(b); err != nil {
return fmt.Sprint(time.Now().UnixNano() % 1000000)
}
return hex.EncodeToString(b)[:n]
}
// StagedFile 已落临时文件、算完 MD5 的待提交文件
type StagedFile struct {
TempPath string // 本地临时文件路径
MD5 string // 文件内容 MD5
Size int64 // 文件大小
MimeType string // 文件类型
Ext string // 扩展名(含点)
}
// UploadResult 上传结果
type UploadResult struct {
URL string // 完整访问URL
Key string // 存储key/相对路径(object_key)
Size int64 // 文件大小
MD5 string // 文件MD5
MimeType string // 文件类型
}
// StorageService 存储服务接口
type StorageService interface {
// Stage 把上传流写入临时文件并计算 MD5,此时文件尚未进入正式存储目录
Stage(file multipart.File, header *multipart.FileHeader) (*StagedFile, error)
// Commit 把临时文件提交到按 ctx 计算出的正式路径;返回访问 URL 与 object_key
Commit(staged *StagedFile, ctx UploadContext) (*UploadResult, error)
// Discard 丢弃临时文件(去重命中或出错时调用)
Discard(staged *StagedFile) error
GetPublicURL(key string) string
Delete(key string) error
// Move 把已存文件从 oldKey 改名到 newKey(迁移用:本地 Rename / 七牛 Move)
Move(oldKey, newKey string) error
// Type 返回存储类型 local / qiniu
Type() string
}
// LocalStorage 本地存储实现
type LocalStorage struct {
BaseDir string // 基础目录,默认 "uploads"
BaseURL string // 基础URL,默认 "/"
}
// NewLocalStorage 创建本地存储服务
func NewLocalStorage() *LocalStorage {
return &LocalStorage{
BaseDir: "uploads",
BaseURL: "/",
}
}
// Type 存储类型
func (s *LocalStorage) Type() string { return StorageTypeLocal }
// tempDir 临时目录:与正式目录同盘,保证 Commit 时 os.Rename 不跨设备
func (s *LocalStorage) tempDir() string {
return filepath.Join(s.BaseDir, ".tmp")
}
// Stage 写入临时文件并计算 MD5
func (s *LocalStorage) Stage(file multipart.File, header *multipart.FileHeader) (*StagedFile, error) {
dir := s.tempDir()
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("创建临时目录失败: %w", err)
}
tmp, err := os.CreateTemp(dir, "stage_*")
if err != nil {
return nil, fmt.Errorf("创建临时文件失败: %w", err)
}
defer tmp.Close()
hash := md5.New()
size, err := io.Copy(io.MultiWriter(tmp, hash), file)
if err != nil {
tmpPath := tmp.Name()
_ = os.Remove(tmpPath)
return nil, fmt.Errorf("读取上传文件失败: %w", err)
}
return &StagedFile{
TempPath: tmp.Name(),
MD5: hex.EncodeToString(hash.Sum(nil)),
Size: size,
MimeType: mimeTypeOf(header),
Ext: normalizeExt(filepath.Ext(header.Filename)),
}, nil
}
// Commit 提交到正式目录
func (s *LocalStorage) Commit(staged *StagedFile, ctx UploadContext) (*UploadResult, error) {
if staged == nil {
return nil, fmt.Errorf("待提交文件为空")
}
key := BuildObjectKey(ctx, staged.Ext)
destPath := filepath.Join(s.BaseDir, filepath.FromSlash(key))
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return nil, fmt.Errorf("创建目录失败: %w", err)
}
// 同盘优先 Rename;跨设备失败则回退复制
if err := os.Rename(staged.TempPath, destPath); err != nil {
if copyErr := copyFile(staged.TempPath, destPath); copyErr != nil {
return nil, fmt.Errorf("保存文件失败: %w", copyErr)
}
_ = os.Remove(staged.TempPath)
}
return &UploadResult{
URL: s.GetPublicURL(key),
Key: key,
Size: staged.Size,
MD5: staged.MD5,
MimeType: staged.MimeType,
}, nil
}
// Discard 删除临时文件
func (s *LocalStorage) Discard(staged *StagedFile) error {
if staged == nil || staged.TempPath == "" {
return nil
}
return os.Remove(staged.TempPath)
}
// GetPublicURL 获取公开访问URL
func (s *LocalStorage) GetPublicURL(key string) string {
full := filepath.ToSlash(filepath.Join(s.BaseDir, filepath.FromSlash(key)))
return s.BaseURL + strings.ReplaceAll(full, "\\", "/")
}
// Delete 删除本地文件
func (s *LocalStorage) Delete(key string) error {
filePath := filepath.Join(s.BaseDir, filepath.FromSlash(key))
return os.Remove(filePath)
}
// Move 本地改名(同盘,不搬数据)
func (s *LocalStorage) Move(oldKey, newKey string) error {
oldPath := filepath.Join(s.BaseDir, filepath.FromSlash(oldKey))
newPath := filepath.Join(s.BaseDir, filepath.FromSlash(newKey))
if err := os.MkdirAll(filepath.Dir(newPath), 0755); err != nil {
return fmt.Errorf("创建目标目录失败: %w", err)
}
if err := os.Rename(oldPath, newPath); err != nil {
// 跨设备时回退为复制 + 删除
if copyErr := copyFile(oldPath, newPath); copyErr != nil {
return fmt.Errorf("移动文件失败: %w", copyErr)
}
_ = os.Remove(oldPath)
}
return nil
}
// QiniuStorage 七牛云存储实现
type QiniuStorage struct {
AccessKey string
SecretKey string
Bucket string
Domain string
Region string
}
// NewQiniuStorage 创建七牛云存储服务
func NewQiniuStorage(cfg *models.StorageConfig) *QiniuStorage {
return &QiniuStorage{
AccessKey: cfg.QiniuAccessKey,
SecretKey: cfg.QiniuSecretKey,
Bucket: cfg.QiniuBucket,
Domain: cfg.QiniuDomain,
Region: cfg.QiniuRegion,
}
}
// Type 存储类型
func (s *QiniuStorage) Type() string { return StorageTypeQiniu }
// getZone 根据区域代码获取存储区域
func (s *QiniuStorage) getZone() *storage.Region {
switch s.Region {
case "z0":
return &storage.ZoneHuadong
case "z1":
return &storage.ZoneHuabei
case "z2":
return &storage.ZoneHuanan
case "na0":
return &storage.ZoneBeimei
case "as0":
return &storage.ZoneXinjiapo
case "cn-east-2":
return &storage.ZoneHuadongZheJiang2
default:
return &storage.ZoneHuadong // 默认华东
}
}
// Stage 写入临时文件并计算 MD5(七牛直传拿不到内容,这里统一先落临时文件算 MD5,
// 便于去重;未命中才会真正上传到七牛)
func (s *QiniuStorage) Stage(file multipart.File, header *multipart.FileHeader) (*StagedFile, error) {
tmp, err := os.CreateTemp("", "yz_upload_*")
if err != nil {
return nil, fmt.Errorf("创建临时文件失败: %w", err)
}
defer tmp.Close()
hash := md5.New()
size, err := io.Copy(io.MultiWriter(tmp, hash), file)
if err != nil {
_ = os.Remove(tmp.Name())
return nil, fmt.Errorf("读取上传文件失败: %w", err)
}
return &StagedFile{
TempPath: tmp.Name(),
MD5: hex.EncodeToString(hash.Sum(nil)),
Size: size,
MimeType: mimeTypeOf(header),
Ext: normalizeExt(filepath.Ext(header.Filename)),
}, nil
}
// Commit 上传到七牛云
func (s *QiniuStorage) Commit(staged *StagedFile, ctx UploadContext) (*UploadResult, error) {
if staged == nil {
return nil, fmt.Errorf("待提交文件为空")
}
key := BuildObjectKey(ctx, staged.Ext)
f, err := os.Open(staged.TempPath)
if err != nil {
return nil, fmt.Errorf("打开临时文件失败: %w", err)
}
defer f.Close()
mac := qbox.NewMac(s.AccessKey, s.SecretKey)
putPolicy := storage.PutPolicy{Scope: s.Bucket}
upToken := putPolicy.UploadToken(mac)
cfg := storage.Config{
Region: s.getZone(),
UseHTTPS: true,
UseCdnDomains: false,
}
formUploader := storage.NewFormUploader(&cfg)
ret := storage.PutRet{}
if err := formUploader.Put(context.Background(), &ret, upToken, key, f, staged.Size, &storage.PutExtra{}); err != nil {
return nil, fmt.Errorf("上传到七牛云失败: %w", err)
}
return &UploadResult{
URL: s.GetPublicURL(key),
Key: key,
Size: staged.Size,
MD5: staged.MD5,
MimeType: staged.MimeType,
}, nil
}
// Discard 删除临时文件
func (s *QiniuStorage) Discard(staged *StagedFile) error {
if staged == nil || staged.TempPath == "" {
return nil
}
return os.Remove(staged.TempPath)
}
// GetPublicURL 获取七牛云公开访问URL
func (s *QiniuStorage) GetPublicURL(key string) string {
domain := strings.TrimRight(s.Domain, "/")
return fmt.Sprintf("%s/%s", domain, key)
}
// Delete 删除七牛云文件
func (s *QiniuStorage) Delete(key string) error {
return s.runBucketManager(func(bm *storage.BucketManager) error {
return bm.Delete(s.Bucket, key)
}, "删除七牛云文件失败")
}
// Move 七牛服务端改名(同 bucket 内原子操作,不走流量)
func (s *QiniuStorage) Move(oldKey, newKey string) error {
return s.runBucketManager(func(bm *storage.BucketManager) error {
// force=false:目标 key 已存在时直接报错,避免覆盖,保证迁移可重复执行
return bm.Move(s.Bucket, oldKey, s.Bucket, newKey, false)
}, "移动七牛云文件失败")
}
func (s *QiniuStorage) runBucketManager(fn func(*storage.BucketManager) error, errMsg string) error {
mac := qbox.NewMac(s.AccessKey, s.SecretKey)
cfg := storage.Config{Region: s.getZone(), UseHTTPS: true}
bm := storage.NewBucketManager(mac, &cfg)
if err := fn(bm); err != nil {
return fmt.Errorf("%s: %w", errMsg, err)
}
return nil
}
// GetStorageService 根据配置获取存储服务
func GetStorageService() (StorageService, error) {
cfg, err := models.GetStorageConfig()
if err != nil {
// 默认使用本地存储
return NewLocalStorage(), nil
}
switch cfg.StorageType {
case "qiniu":
if cfg.QiniuAccessKey == "" || cfg.QiniuSecretKey == "" ||
cfg.QiniuBucket == "" || cfg.QiniuDomain == "" {
return nil, fmt.Errorf("七牛云配置不完整")
}
return NewQiniuStorage(cfg), nil
case "local":
return NewLocalStorage(), nil
default:
return NewLocalStorage(), nil
}
}
// mimeTypeOf 从 multipart header 取 Content-Type
func mimeTypeOf(header *multipart.FileHeader) string {
if header == nil {
return ""
}
return header.Header.Get("Content-Type")
}
// copyFile 文件复制(Rename 跨设备失败时的回退方案)
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Sync()
}
@@ -213,7 +213,7 @@ watch(
);
// 上传配置
const uploadUrl = import.meta.env.VITE_API_BASE_URL + "/platform/upload";
const uploadUrl = import.meta.env.VITE_API_BASE_URL + "/platform/uploadfile";
const uploadHeaders = {
Authorization: "Bearer " + localStorage.getItem("token"),
};
@@ -24,7 +24,8 @@
<el-form-item label="资质图片" prop="file_url">
<el-upload
class="avatar-uploader"
action="/api/platform/common/upload"
:action="uploadAction"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleUploadSuccess"
:before-upload="beforeAvatarUpload"
@@ -70,7 +71,13 @@
const submitting = ref(false);
const tenantName = ref('');
const formRef = ref();
// 上传地址:走平台端通用上传接口(原 /api/platform/common/upload 后端不存在,会 404)
const uploadAction = import.meta.env.VITE_API_BASE_URL + '/platform/uploadfile';
const uploadHeaders = {
Authorization: 'Bearer ' + (localStorage.getItem('token') || '')
};
const formData = reactive({
tid: null,
type: '',