整体平台上传功能大改造
This commit is contained in:
+18
-1
@@ -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: '',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
+970
-907
File diff suppressed because it is too large
Load Diff
+918
-907
File diff suppressed because it is too large
Load Diff
+326
-327
@@ -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');
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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: '',
|
||||
|
||||
Reference in New Issue
Block a user