整体平台上传功能大改造
This commit is contained in:
+18
-1
@@ -106,10 +106,27 @@ export function getFileById(id) {
|
|||||||
*/
|
*/
|
||||||
export function uploadFile(formData, options = {}) {
|
export function uploadFile(formData, options = {}) {
|
||||||
// 0 表示「未分类」,不能用 truthy 判断
|
// 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));
|
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 = {
|
const config = {
|
||||||
url: "/backend/uploadfile",
|
url: "/backend/uploadfile",
|
||||||
method: "post",
|
method: "post",
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ const currentBanner = ref<Partial<Banner>>({
|
|||||||
// 上传配置
|
// 上传配置
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
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({
|
const uploadHeaders = ref({
|
||||||
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
|
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ const submitLoading = ref(false)
|
|||||||
const formRef = ref(null)
|
const formRef = ref(null)
|
||||||
|
|
||||||
// 上传配置
|
// 上传配置
|
||||||
const uploadUrl = ref(API_BASE_URL + "/admin/uploadfiles");
|
const uploadUrl = ref(API_BASE_URL + "/backend/uploadfile");
|
||||||
const uploadHeaders = ref({
|
const uploadHeaders = ref({
|
||||||
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
|
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ const currentMenu = ref<Partial<Menu>>({
|
|||||||
// 上传配置
|
// 上传配置
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
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({
|
const uploadHeaders = ref({
|
||||||
'Authorization': 'Bearer ' + (localStorage.getItem('token') || '')
|
'Authorization': 'Bearer ' + (localStorage.getItem('token') || '')
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ const submitLoading = ref(false);
|
|||||||
const formRef = ref(null);
|
const formRef = ref(null);
|
||||||
|
|
||||||
// 上传配置
|
// 上传配置
|
||||||
const uploadUrl = ref(API_BASE_URL + "/admin/uploadfiles");
|
const uploadUrl = ref(API_BASE_URL + "/backend/uploadfile");
|
||||||
const uploadHeaders = ref({
|
const uploadHeaders = ref({
|
||||||
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
|
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ const submitLoading = ref(false);
|
|||||||
const formRef = ref(null);
|
const formRef = ref(null);
|
||||||
|
|
||||||
// 上传配置
|
// 上传配置
|
||||||
const uploadUrl = ref(API_BASE_URL + "/admin/uploadfiles");
|
const uploadUrl = ref(API_BASE_URL + "/backend/uploadfile");
|
||||||
const uploadHeaders = ref({
|
const uploadHeaders = ref({
|
||||||
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
|
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -748,7 +748,8 @@ const uploadFileToSystem = async rawFile => {
|
|||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', rawFile)
|
formData.append('file', rawFile)
|
||||||
formData.append('cate', 'reimbursement-invoice')
|
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) {
|
if (uploadResponse?.code !== 200 && uploadResponse?.code !== 201) {
|
||||||
throw new Error(uploadResponse?.msg || `「${rawFile.name}」上传失败`)
|
throw new Error(uploadResponse?.msg || `「${rawFile.name}」上传失败`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,7 +300,8 @@ function ensureImageName(file) {
|
|||||||
async function uploadImage(file) {
|
async function uploadImage(file) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
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) {
|
if (res?.code !== 200 && res?.code !== 201) {
|
||||||
throw new Error(res?.msg || `「${file.name}」上传失败`);
|
throw new Error(res?.msg || `「${file.name}」上传失败`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,8 @@
|
|||||||
<el-form-item label="资质图片" prop="file_url">
|
<el-form-item label="资质图片" prop="file_url">
|
||||||
<el-upload
|
<el-upload
|
||||||
class="avatar-uploader"
|
class="avatar-uploader"
|
||||||
action="/api/platform/common/upload"
|
:action="uploadAction"
|
||||||
|
:headers="uploadHeaders"
|
||||||
:show-file-list="false"
|
:show-file-list="false"
|
||||||
:on-success="handleUploadSuccess"
|
:on-success="handleUploadSuccess"
|
||||||
:before-upload="beforeAvatarUpload"
|
:before-upload="beforeAvatarUpload"
|
||||||
@@ -70,7 +71,13 @@
|
|||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
const tenantName = ref('');
|
const tenantName = ref('');
|
||||||
const formRef = 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({
|
const formData = reactive({
|
||||||
tid: null,
|
tid: null,
|
||||||
type: '',
|
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
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"server/models"
|
"server/models"
|
||||||
"server/pkg/jwtutil"
|
"server/pkg/jwtutil"
|
||||||
|
"server/services"
|
||||||
beego "github.com/beego/beego/v2/server/web"
|
|
||||||
"github.com/qiniu/go-sdk/v7/auth/qbox"
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
"github.com/qiniu/go-sdk/v7/storage"
|
"github.com/qiniu/go-sdk/v7/auth/qbox"
|
||||||
)
|
"github.com/qiniu/go-sdk/v7/storage"
|
||||||
|
)
|
||||||
// QiniuUploadController 七牛云上传控制器
|
|
||||||
type QiniuUploadController struct {
|
// QiniuUploadController 七牛云上传控制器
|
||||||
beego.Controller
|
type QiniuUploadController struct {
|
||||||
}
|
beego.Controller
|
||||||
|
}
|
||||||
// platformClaims 获取平台端 JWT claims
|
|
||||||
func (c *QiniuUploadController) platformClaims() (*jwtutil.Claims, error) {
|
// platformClaims 获取平台端 JWT claims
|
||||||
auth := c.Ctx.Request.Header.Get("Authorization")
|
func (c *QiniuUploadController) platformClaims() (*jwtutil.Claims, error) {
|
||||||
if auth == "" {
|
auth := c.Ctx.Request.Header.Get("Authorization")
|
||||||
return nil, fmt.Errorf("未登录")
|
if auth == "" {
|
||||||
}
|
return nil, fmt.Errorf("未登录")
|
||||||
parts := strings.Split(auth, " ")
|
}
|
||||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
parts := strings.Split(auth, " ")
|
||||||
return nil, fmt.Errorf("token 格式错误")
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
}
|
return nil, fmt.Errorf("token 格式错误")
|
||||||
claims, err := jwtutil.ParseToken(parts[1])
|
}
|
||||||
if err != nil {
|
claims, err := jwtutil.ParseToken(parts[1])
|
||||||
return nil, fmt.Errorf("token 无效")
|
if err != nil {
|
||||||
}
|
return nil, fmt.Errorf("token 无效")
|
||||||
return claims, nil
|
}
|
||||||
}
|
return claims, nil
|
||||||
|
}
|
||||||
// effectiveTid 获取有效的租户 ID
|
|
||||||
func (c *QiniuUploadController) effectiveTid(claims *jwtutil.Claims) uint64 {
|
// effectiveTid 获取有效的租户 ID
|
||||||
if claims.TenantId > 0 {
|
func (c *QiniuUploadController) effectiveTid(claims *jwtutil.Claims) uint64 {
|
||||||
return uint64(claims.TenantId)
|
if claims.TenantId > 0 {
|
||||||
}
|
return uint64(claims.TenantId)
|
||||||
return 0
|
}
|
||||||
}
|
return 0
|
||||||
|
}
|
||||||
// jsonErr 返回错误响应
|
|
||||||
func (c *QiniuUploadController) jsonErr(httpStatus, bizCode int, msg string) {
|
// jsonErr 返回错误响应
|
||||||
c.Ctx.Output.SetStatus(httpStatus)
|
func (c *QiniuUploadController) jsonErr(httpStatus, bizCode int, msg string) {
|
||||||
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
c.Ctx.Output.SetStatus(httpStatus)
|
||||||
_ = c.ServeJSON()
|
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||||||
}
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
// jsonOK 返回成功响应
|
|
||||||
func (c *QiniuUploadController) jsonOK(data interface{}) {
|
// jsonOK 返回成功响应
|
||||||
c.Data["json"] = map[string]interface{}{"code": 200, "data": data}
|
func (c *QiniuUploadController) jsonOK(data interface{}) {
|
||||||
_ = c.ServeJSON()
|
c.Data["json"] = map[string]interface{}{"code": 200, "data": data}
|
||||||
}
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
// ParseJSON 解析 JSON 请求体
|
|
||||||
func (c *QiniuUploadController) ParseJSON(v interface{}) error {
|
// ParseJSON 解析 JSON 请求体
|
||||||
body := c.Ctx.Input.RequestBody
|
func (c *QiniuUploadController) ParseJSON(v interface{}) error {
|
||||||
if len(body) == 0 {
|
body := c.Ctx.Input.RequestBody
|
||||||
return fmt.Errorf("请求体为空")
|
if len(body) == 0 {
|
||||||
}
|
return fmt.Errorf("请求体为空")
|
||||||
return json.Unmarshal(body, v)
|
}
|
||||||
}
|
return json.Unmarshal(body, v)
|
||||||
|
}
|
||||||
// GetUploadToken 获取上传凭证
|
|
||||||
// GET /platform/qiniu/token
|
// GetUploadToken 获取上传凭证
|
||||||
func (c *QiniuUploadController) GetUploadToken() {
|
// GET /platform/qiniu/token
|
||||||
_, err := c.platformClaims()
|
func (c *QiniuUploadController) GetUploadToken() {
|
||||||
if err != nil {
|
_, err := c.platformClaims()
|
||||||
c.jsonErr(401, 401, err.Error())
|
if err != nil {
|
||||||
return
|
c.jsonErr(401, 401, err.Error())
|
||||||
}
|
return
|
||||||
|
}
|
||||||
// 获取存储配置
|
|
||||||
cfg, err := models.GetStorageConfig()
|
// 获取存储配置
|
||||||
if err != nil || cfg.StorageType != "qiniu" {
|
cfg, err := models.GetStorageConfig()
|
||||||
c.jsonErr(400, 400, "当前未配置七牛云存储")
|
if err != nil || cfg.StorageType != "qiniu" {
|
||||||
return
|
c.jsonErr(400, 400, "当前未配置七牛云存储")
|
||||||
}
|
return
|
||||||
|
}
|
||||||
// 检查配置完整性
|
|
||||||
if cfg.QiniuAccessKey == "" || cfg.QiniuSecretKey == "" || cfg.QiniuBucket == "" {
|
// 检查配置完整性
|
||||||
c.jsonErr(500, 500, "七牛云配置不完整")
|
if cfg.QiniuAccessKey == "" || cfg.QiniuSecretKey == "" || cfg.QiniuBucket == "" {
|
||||||
return
|
c.jsonErr(500, 500, "七牛云配置不完整")
|
||||||
}
|
return
|
||||||
|
}
|
||||||
// 生成文件 key(前端可以覆盖)
|
|
||||||
datePath := time.Now().Format("2006/01/02")
|
// 生成文件 key(前端可以覆盖)
|
||||||
timestamp := time.Now().UnixNano()
|
// 平台端固定落在 platform/yyyy/MM/dd/ 下,与服务端中转上传保持同一套目录规则
|
||||||
keyPrefix := fmt.Sprintf("%s/%d", datePath, timestamp)
|
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,
|
mac := qbox.NewMac(cfg.QiniuAccessKey, cfg.QiniuSecretKey)
|
||||||
ReturnBody: `{"key":"$(key)","hash":"$(etag)","size":$(fsize),"mimeType":"$(mimeType)"}`,
|
putPolicy := storage.PutPolicy{
|
||||||
Expires: 3600, // 1小时有效期
|
Scope: cfg.QiniuBucket,
|
||||||
}
|
ReturnBody: `{"key":"$(key)","hash":"$(etag)","size":$(fsize),"mimeType":"$(mimeType)"}`,
|
||||||
upToken := putPolicy.UploadToken(mac)
|
Expires: 3600, // 1小时有效期
|
||||||
|
}
|
||||||
// 返回上传凭证和配置
|
upToken := putPolicy.UploadToken(mac)
|
||||||
c.jsonOK(map[string]interface{}{
|
|
||||||
"token": upToken,
|
// 返回上传凭证和配置
|
||||||
"domain": cfg.QiniuDomain,
|
c.jsonOK(map[string]interface{}{
|
||||||
"bucket": cfg.QiniuBucket,
|
"token": upToken,
|
||||||
"region": cfg.QiniuRegion,
|
"domain": cfg.QiniuDomain,
|
||||||
"keyPrefix": keyPrefix,
|
"bucket": cfg.QiniuBucket,
|
||||||
"expires": time.Now().Add(time.Hour).Unix(),
|
"region": cfg.QiniuRegion,
|
||||||
"uploadUrl": getQiniuUploadURL(cfg.QiniuRegion),
|
"keyPrefix": keyPrefix,
|
||||||
})
|
"expires": time.Now().Add(time.Hour).Unix(),
|
||||||
}
|
"uploadUrl": getQiniuUploadURL(cfg.QiniuRegion),
|
||||||
|
})
|
||||||
// SaveFileRecord 保存文件记录
|
}
|
||||||
// POST /platform/qiniu/save
|
|
||||||
func (c *QiniuUploadController) SaveFileRecord() {
|
// SaveFileRecord 保存文件记录
|
||||||
claims, err := c.platformClaims()
|
// POST /platform/qiniu/save
|
||||||
if err != nil {
|
func (c *QiniuUploadController) SaveFileRecord() {
|
||||||
c.jsonErr(401, 401, err.Error())
|
claims, err := c.platformClaims()
|
||||||
return
|
if err != nil {
|
||||||
}
|
c.jsonErr(401, 401, err.Error())
|
||||||
tid := c.effectiveTid(claims)
|
return
|
||||||
|
}
|
||||||
// 调试:打印请求体
|
tid := c.effectiveTid(claims)
|
||||||
body := c.Ctx.Input.RequestBody
|
|
||||||
fmt.Println("SaveFileRecord 请求体长度:", len(body))
|
// 调试:打印请求体
|
||||||
fmt.Println("SaveFileRecord 请求体内容:", string(body))
|
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)
|
type SaveRequest struct {
|
||||||
Size int64 `json:"size"` // 文件大小
|
Key string `json:"key"` // 七牛云文件 key
|
||||||
Name string `json:"name"` // 原始文件名
|
Hash string `json:"hash"` // 文件 hash (etag)
|
||||||
MimeType string `json:"mimeType"` // 文件类型
|
Size int64 `json:"size"` // 文件大小
|
||||||
Cate uint64 `json:"cate"` // 分类 ID
|
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())
|
var req SaveRequest
|
||||||
return
|
if err := c.ParseJSON(&req); err != nil {
|
||||||
}
|
c.jsonErr(400, 400, "参数解析失败: "+err.Error())
|
||||||
|
return
|
||||||
// 验证必填字段
|
}
|
||||||
if req.Key == "" || req.Name == "" {
|
|
||||||
c.jsonErr(400, 400, "缺少必填参数")
|
// 验证必填字段
|
||||||
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, "当前未配置七牛云存储")
|
cfg, err := models.GetStorageConfig()
|
||||||
return
|
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)
|
// 构建完整 URL
|
||||||
|
domain := strings.TrimRight(cfg.QiniuDomain, "/")
|
||||||
// 计算 MD5(使用 hash 作为 MD5,或者重新计算)
|
fileURL := fmt.Sprintf("%s/%s", domain, req.Key)
|
||||||
md5Sum := req.Hash
|
|
||||||
if md5Sum == "" {
|
// 注意:直传通道前端给的是七牛 etag(req.Hash),并非文件真实 MD5,
|
||||||
// 如果没有 hash,使用 key 生成一个唯一标识
|
// 与服务端中转上传算出的 MD5 不同源,因此该通道的查重只在本通道内有效。
|
||||||
h := md5.New()
|
md5Sum := req.Hash
|
||||||
h.Write([]byte(req.Key))
|
if md5Sum == "" {
|
||||||
md5Sum = hex.EncodeToString(h.Sum(nil))
|
// 如果没有 hash,使用 key 生成一个唯一标识
|
||||||
}
|
h := md5.New()
|
||||||
|
h.Write([]byte(req.Key))
|
||||||
// 检查文件是否已存在(通过 MD5)
|
md5Sum = hex.EncodeToString(h.Sum(nil))
|
||||||
var exist models.SystemFile
|
}
|
||||||
err = models.Orm.QueryTable(new(models.SystemFile)).
|
|
||||||
Filter("md5", md5Sum).
|
// 检查文件是否已存在(通过 MD5)
|
||||||
Filter("tid", tid).
|
exist, err := services.FindDuplicate(services.SourcePlatform, services.ScopeTenant, tid, 0, md5Sum)
|
||||||
Filter("delete_time__isnull", true).
|
if err != nil {
|
||||||
One(&exist)
|
c.jsonErr(500, 500, "查重失败: "+err.Error())
|
||||||
if err == nil {
|
return
|
||||||
// 文件已存在,返回已有记录
|
}
|
||||||
c.Data["json"] = map[string]interface{}{
|
if exist != nil {
|
||||||
"code": 201,
|
// 文件已存在,返回已有记录
|
||||||
"msg": "文件已存在",
|
c.Data["json"] = map[string]interface{}{
|
||||||
"data": map[string]interface{}{
|
"code": 201,
|
||||||
"url": exist.Src,
|
"msg": "文件已存在",
|
||||||
"id": exist.ID,
|
"data": map[string]interface{}{
|
||||||
"name": exist.Name,
|
"url": exist.Src,
|
||||||
},
|
"id": exist.ID,
|
||||||
}
|
"name": exist.Name,
|
||||||
_ = c.ServeJSON()
|
},
|
||||||
return
|
}
|
||||||
}
|
_ = c.ServeJSON()
|
||||||
|
return
|
||||||
// 检测文件类型
|
}
|
||||||
ext := getQiniuFileExt(req.Name)
|
|
||||||
fileType := detectQiniuFileType(ext)
|
// 保存文件记录(平台端:source=platform,scope=tenant,object_key=七牛 key)
|
||||||
|
// 文件类型由 services.CreateFileRecord 依据扩展名统一推断
|
||||||
// 保存文件记录
|
id, err := services.CreateFileRecord(services.FileUploadParams{
|
||||||
adminID := uint64(claims.UserID)
|
Source: services.SourcePlatform,
|
||||||
row := &models.SystemFile{
|
Scope: services.ScopeTenant,
|
||||||
Tid: tid,
|
Tid: tid,
|
||||||
Uid: &adminID,
|
Uid: uint64(claims.UserID),
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Type: fileType,
|
Ext: getQiniuFileExt(req.Name),
|
||||||
Cate: req.Cate,
|
Cate: req.Cate,
|
||||||
Size: uint64(req.Size),
|
Size: uint64(req.Size),
|
||||||
Src: fileURL,
|
Src: fileURL,
|
||||||
Uploader: adminID,
|
ObjectKey: req.Key,
|
||||||
Md5: md5Sum,
|
Storage: services.StorageTypeQiniu,
|
||||||
}
|
MD5: md5Sum,
|
||||||
|
})
|
||||||
id, err := models.Orm.Insert(row)
|
if err != nil {
|
||||||
if err != nil {
|
c.jsonErr(500, 500, "保存文件记录失败: "+err.Error())
|
||||||
c.jsonErr(500, 500, "保存文件记录失败: "+err.Error())
|
return
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
c.jsonOK(map[string]interface{}{
|
||||||
c.jsonOK(map[string]interface{}{
|
"url": fileURL,
|
||||||
"url": fileURL,
|
"id": uint64(id),
|
||||||
"id": uint64(id),
|
"name": req.Name,
|
||||||
"name": req.Name,
|
"key": req.Key,
|
||||||
"key": req.Key,
|
})
|
||||||
})
|
}
|
||||||
}
|
|
||||||
|
// GetStorageConfig 获取存储配置(前端用于判断上传方式)
|
||||||
// GetStorageConfig 获取存储配置(前端用于判断上传方式)
|
// GET /platform/storage/config
|
||||||
// GET /platform/storage/config
|
func (c *QiniuUploadController) GetStorageConfig() {
|
||||||
func (c *QiniuUploadController) GetStorageConfig() {
|
_, err := c.platformClaims()
|
||||||
_, err := c.platformClaims()
|
if err != nil {
|
||||||
if err != nil {
|
c.jsonErr(401, 401, err.Error())
|
||||||
c.jsonErr(401, 401, err.Error())
|
return
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
cfg, err := models.GetStorageConfig()
|
||||||
cfg, err := models.GetStorageConfig()
|
if err != nil {
|
||||||
if err != nil {
|
c.jsonOK(map[string]interface{}{
|
||||||
c.jsonOK(map[string]interface{}{
|
"storageType": "local",
|
||||||
"storageType": "local",
|
})
|
||||||
})
|
return
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
// 只返回必要的配置信息,不返回密钥
|
||||||
// 只返回必要的配置信息,不返回密钥
|
c.jsonOK(map[string]interface{}{
|
||||||
c.jsonOK(map[string]interface{}{
|
"storageType": cfg.StorageType,
|
||||||
"storageType": cfg.StorageType,
|
"qiniuDomain": cfg.QiniuDomain,
|
||||||
"qiniuDomain": cfg.QiniuDomain,
|
"qiniuRegion": cfg.QiniuRegion,
|
||||||
"qiniuRegion": cfg.QiniuRegion,
|
})
|
||||||
})
|
}
|
||||||
}
|
|
||||||
|
// getQiniuUploadURL 根据区域获取上传地址
|
||||||
// getQiniuUploadURL 根据区域获取上传地址
|
func getQiniuUploadURL(region string) string {
|
||||||
func getQiniuUploadURL(region string) string {
|
switch region {
|
||||||
switch region {
|
case "z0":
|
||||||
case "z0":
|
return "https://up-z0.qiniup.com"
|
||||||
return "https://up-z0.qiniup.com"
|
case "z1":
|
||||||
case "z1":
|
return "https://up-z1.qiniup.com"
|
||||||
return "https://up-z1.qiniup.com"
|
case "z2":
|
||||||
case "z2":
|
return "https://up-z2.qiniup.com"
|
||||||
return "https://up-z2.qiniup.com"
|
case "na0":
|
||||||
case "na0":
|
return "https://up-na0.qiniup.com"
|
||||||
return "https://up-na0.qiniup.com"
|
case "as0":
|
||||||
case "as0":
|
return "https://up-as0.qiniup.com"
|
||||||
return "https://up-as0.qiniup.com"
|
case "cn-east-2":
|
||||||
case "cn-east-2":
|
return "https://up-cn-east-2.qiniup.com"
|
||||||
return "https://up-cn-east-2.qiniup.com"
|
default:
|
||||||
default:
|
return "https://up-z0.qiniup.com" // 默认华东
|
||||||
return "https://up-z0.qiniup.com" // 默认华东
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// getQiniuFileExt 获取文件扩展名
|
||||||
// getQiniuFileExt 获取文件扩展名
|
func getQiniuFileExt(filename string) string {
|
||||||
func getQiniuFileExt(filename string) string {
|
parts := strings.Split(filename, ".")
|
||||||
parts := strings.Split(filename, ".")
|
if len(parts) > 1 {
|
||||||
if len(parts) > 1 {
|
return strings.ToLower(parts[len(parts)-1])
|
||||||
return strings.ToLower(parts[len(parts)-1])
|
}
|
||||||
}
|
return ""
|
||||||
return ""
|
}
|
||||||
}
|
|
||||||
|
// detectQiniuFileType 检测文件类型
|
||||||
// detectQiniuFileType 检测文件类型
|
func detectQiniuFileType(ext string) uint8 {
|
||||||
func detectQiniuFileType(ext string) uint8 {
|
imageExts := map[string]bool{
|
||||||
imageExts := map[string]bool{
|
"jpg": true, "jpeg": true, "png": true, "gif": true, "bmp": true,
|
||||||
"jpg": true, "jpeg": true, "png": true, "gif": true, "bmp": true,
|
"webp": true, "svg": true, "ico": true,
|
||||||
"webp": true, "svg": true, "ico": true,
|
}
|
||||||
}
|
videoExts := map[string]bool{
|
||||||
videoExts := map[string]bool{
|
"mp4": true, "avi": true, "mov": true, "wmv": true, "flv": true,
|
||||||
"mp4": true, "avi": true, "mov": true, "wmv": true, "flv": true,
|
"mkv": true, "webm": true, "m4v": true,
|
||||||
"mkv": true, "webm": true, "m4v": true,
|
}
|
||||||
}
|
audioExts := map[string]bool{
|
||||||
audioExts := map[string]bool{
|
"mp3": true, "wav": true, "flac": true, "aac": true, "ogg": true,
|
||||||
"mp3": true, "wav": true, "flac": true, "aac": true, "ogg": true,
|
"m4a": true, "wma": true,
|
||||||
"m4a": true, "wma": true,
|
}
|
||||||
}
|
docExts := map[string]bool{
|
||||||
docExts := map[string]bool{
|
"doc": true, "docx": true, "xls": true, "xlsx": true, "ppt": true,
|
||||||
"doc": true, "docx": true, "xls": true, "xlsx": true, "ppt": true,
|
"pptx": true, "pdf": true, "txt": true, "md": true,
|
||||||
"pptx": true, "pdf": true, "txt": true, "md": true,
|
}
|
||||||
}
|
archiveExts := map[string]bool{
|
||||||
archiveExts := map[string]bool{
|
"zip": true, "rar": true, "7z": true, "tar": true, "gz": true,
|
||||||
"zip": true, "rar": true, "7z": true, "tar": true, "gz": true,
|
"bz2": true, "xz": true,
|
||||||
"bz2": true, "xz": true,
|
}
|
||||||
}
|
executableExts := map[string]bool{
|
||||||
executableExts := map[string]bool{
|
"exe": true, "msi": true, "dmg": true, "pkg": true, "deb": true,
|
||||||
"exe": true, "msi": true, "dmg": true, "pkg": true, "deb": true,
|
"rpm": true, "apk": true, "msix": true,
|
||||||
"rpm": true, "apk": true, "msix": true,
|
}
|
||||||
}
|
|
||||||
|
if imageExts[ext] {
|
||||||
if imageExts[ext] {
|
return 1 // 图片
|
||||||
return 1 // 图片
|
}
|
||||||
}
|
if videoExts[ext] {
|
||||||
if videoExts[ext] {
|
return 2 // 视频
|
||||||
return 2 // 视频
|
}
|
||||||
}
|
if audioExts[ext] {
|
||||||
if audioExts[ext] {
|
return 3 // 音频
|
||||||
return 3 // 音频
|
}
|
||||||
}
|
if docExts[ext] {
|
||||||
if docExts[ext] {
|
return 4 // 文档
|
||||||
return 4 // 文档
|
}
|
||||||
}
|
if archiveExts[ext] || executableExts[ext] {
|
||||||
if archiveExts[ext] || executableExts[ext] {
|
return 5 // 压缩包/安装包
|
||||||
return 5 // 压缩包/安装包
|
}
|
||||||
}
|
return 0 // 其他
|
||||||
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
|
package models
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"sync"
|
||||||
// SystemFile 附件表 yz_system_files
|
"time"
|
||||||
type SystemFile struct {
|
)
|
||||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
|
||||||
Tid uint64 `orm:"column(tid)" json:"tid"`
|
// SystemFile 附件表 yz_system_files
|
||||||
Uid *uint64 `orm:"column(uid);null" json:"uid"`
|
//
|
||||||
Tuid *uint64 `orm:"column(tuid);null" json:"tuid"`
|
// 归属语义(2026-09-09 文件存储分层改造后明确):
|
||||||
Name string `orm:"column(name);size(255)" json:"name"`
|
// - tid : 租户 ID
|
||||||
Type uint8 `orm:"column(type);default(2)" json:"type"`
|
// - uid : 上传者 ID
|
||||||
Cate uint64 `orm:"column(cate);default(0)" json:"cate"`
|
// - tuid : 归属用户 ID(scope=user 时必填;scope=tenant 时为 NULL)
|
||||||
Size uint64 `orm:"column(size);default(0)" json:"size"`
|
// - source: 来源端 backend / platform
|
||||||
Src string `orm:"column(src);size(512)" json:"src"`
|
// - scope : tenant-租户共享 / user-用户个人
|
||||||
Uploader uint64 `orm:"column(uploader);default(0)" json:"uploader"`
|
// - object_key: 存储相对路径,不含域名,如 backend/234573/67091493/2026/09/09/xxx.png
|
||||||
Md5 string `orm:"column(md5);size(32)" json:"md5"`
|
type SystemFile struct {
|
||||||
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null;auto_now" json:"update_time"`
|
Tid uint64 `orm:"column(tid)" json:"tid"`
|
||||||
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
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"`
|
||||||
func (m *SystemFile) TableName() string {
|
Type uint8 `orm:"column(type);default(2)" json:"type"`
|
||||||
return "yz_system_files"
|
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
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"sync"
|
||||||
"sync"
|
|
||||||
|
"server/models"
|
||||||
"server/models"
|
)
|
||||||
)
|
|
||||||
|
// MigrationProgress 迁移进度
|
||||||
// MigrationProgress 迁移进度
|
type MigrationProgress struct {
|
||||||
type MigrationProgress struct {
|
Total int
|
||||||
Total int
|
Success int
|
||||||
Success int
|
Failed int
|
||||||
Failed int
|
Skipped int
|
||||||
Current string
|
Current string
|
||||||
Errors []string
|
Errors []string
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddSuccess 增加成功计数
|
// AddSuccess 增加成功计数
|
||||||
func (p *MigrationProgress) AddSuccess() {
|
func (p *MigrationProgress) AddSuccess() {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
p.Success++
|
p.Success++
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddFailed 增加失败计数
|
// AddSkipped 增加跳过计数
|
||||||
func (p *MigrationProgress) AddFailed(err string) {
|
func (p *MigrationProgress) AddSkipped() {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
p.Failed++
|
p.Skipped++
|
||||||
p.Errors = append(p.Errors, err)
|
}
|
||||||
}
|
|
||||||
|
// AddFailed 增加失败计数
|
||||||
// SetCurrent 设置当前处理的文件
|
func (p *MigrationProgress) AddFailed(err string) {
|
||||||
func (p *MigrationProgress) SetCurrent(filename string) {
|
p.mu.Lock()
|
||||||
p.mu.Lock()
|
defer p.mu.Unlock()
|
||||||
defer p.mu.Unlock()
|
p.Failed++
|
||||||
p.Current = filename
|
p.Errors = append(p.Errors, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetProgress 获取进度信息
|
// SetCurrent 设置当前处理的文件
|
||||||
func (p *MigrationProgress) GetProgress() (int, int, int, string) {
|
func (p *MigrationProgress) SetCurrent(filename string) {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
return p.Total, p.Success, p.Failed, p.Current
|
p.Current = filename
|
||||||
}
|
}
|
||||||
|
|
||||||
// StorageMigration 存储迁移服务
|
// GetProgress 获取进度信息
|
||||||
type StorageMigration struct {
|
func (p *MigrationProgress) GetProgress() (int, int, int, string) {
|
||||||
fromService StorageService
|
p.mu.Lock()
|
||||||
toService StorageService
|
defer p.mu.Unlock()
|
||||||
progress *MigrationProgress
|
return p.Total, p.Success, p.Failed, p.Current
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStorageMigration 创建存储迁移服务
|
// StorageMigration 存储迁移服务
|
||||||
func NewStorageMigration(from, to StorageService) *StorageMigration {
|
type StorageMigration struct {
|
||||||
return &StorageMigration{
|
fromService StorageService
|
||||||
fromService: from,
|
toService StorageService
|
||||||
toService: to,
|
progress *MigrationProgress
|
||||||
progress: &MigrationProgress{
|
}
|
||||||
Errors: make([]string, 0),
|
|
||||||
},
|
// NewStorageMigration 创建存储迁移服务
|
||||||
}
|
func NewStorageMigration(from, to StorageService) *StorageMigration {
|
||||||
}
|
return &StorageMigration{
|
||||||
|
fromService: from,
|
||||||
// MigrateFile 迁移单个文件
|
toService: to,
|
||||||
func (m *StorageMigration) MigrateFile(file *models.SystemFile) error {
|
progress: &MigrationProgress{
|
||||||
m.progress.SetCurrent(file.Name)
|
Errors: make([]string, 0),
|
||||||
|
},
|
||||||
// 如果是本地存储,从本地读取文件
|
}
|
||||||
if localFrom, ok := m.fromService.(*LocalStorage); ok {
|
}
|
||||||
// 从本地文件系统读取
|
|
||||||
localPath := strings.TrimPrefix(file.Src, "/")
|
// TargetKey 计算某条文件记录改造后的新 key
|
||||||
filePath := filepath.Join(localFrom.BaseDir, localPath)
|
func TargetKey(f *models.SystemFile) string {
|
||||||
|
source := f.Source
|
||||||
f, err := os.Open(filePath)
|
if source == "" {
|
||||||
if err != nil {
|
source = SourceBackend
|
||||||
return fmt.Errorf("打开本地文件失败: %w", err)
|
}
|
||||||
}
|
var tuid uint64
|
||||||
defer f.Close()
|
if f.Tuid != nil {
|
||||||
|
tuid = *f.Tuid
|
||||||
// 获取文件信息
|
}
|
||||||
stat, err := f.Stat()
|
return BuildObjectKey(UploadContext{Source: source, Tid: f.Tid, Tuid: tuid}, FileExt(f.Name))
|
||||||
if err != nil {
|
}
|
||||||
return fmt.Errorf("获取文件信息失败: %w", err)
|
|
||||||
}
|
// MigrateFile 迁移单个文件到新的分层目录
|
||||||
|
//
|
||||||
// 创建 multipart.FileHeader
|
// 同类型存储(七牛→七牛 / 本地→本地)走服务端改名,不重新上传、不消耗流量;
|
||||||
header := &multipart.FileHeader{
|
// 本地→七牛 走"读取 + 上传",其余跨存储方向暂不支持。
|
||||||
Filename: file.Name,
|
func (m *StorageMigration) MigrateFile(file *models.SystemFile) error {
|
||||||
Size: stat.Size(),
|
m.progress.SetCurrent(file.Name)
|
||||||
}
|
|
||||||
|
oldKey := file.ObjectKey
|
||||||
// 上传到目标存储
|
if oldKey == "" {
|
||||||
result, err := m.toService.Upload(f, header)
|
oldKey = KeyFromSrc(file.Src, m.fromService)
|
||||||
if err != nil {
|
}
|
||||||
return fmt.Errorf("上传到目标存储失败: %w", err)
|
if oldKey == "" {
|
||||||
}
|
return fmt.Errorf("无法解析原存储路径: %s", file.Src)
|
||||||
|
}
|
||||||
// 更新数据库记录
|
|
||||||
_, err = models.Orm.QueryTable(new(models.SystemFile)).
|
newKey := TargetKey(file)
|
||||||
Filter("id", file.ID).
|
if oldKey == newKey {
|
||||||
Update(map[string]interface{}{
|
m.progress.AddSkipped()
|
||||||
"src": result.URL,
|
return nil
|
||||||
})
|
}
|
||||||
if err != nil {
|
|
||||||
// 上传成功但更新数据库失败,尝试删除已上传的文件
|
if m.fromService.Type() == m.toService.Type() {
|
||||||
_ = m.toService.Delete(result.Key)
|
if err := m.toService.Move(oldKey, newKey); err != nil {
|
||||||
return fmt.Errorf("更新数据库失败: %w", err)
|
return fmt.Errorf("移动文件失败: %w", err)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
m.progress.AddSuccess()
|
// 跨存储:仅支持 本地 → 七牛
|
||||||
return nil
|
localFrom, ok := m.fromService.(*LocalStorage)
|
||||||
}
|
if !ok {
|
||||||
|
return fmt.Errorf("暂不支持从 %s 迁出到 %s", m.fromService.Type(), m.toService.Type())
|
||||||
// 如果是七牛云存储,需要先下载再上传(这里简化处理)
|
}
|
||||||
return fmt.Errorf("暂不支持从七牛云迁移到本地")
|
localPath := filepath.Join(localFrom.BaseDir, filepath.FromSlash(oldKey))
|
||||||
}
|
f, err := os.Open(localPath)
|
||||||
|
if err != nil {
|
||||||
// MigrateAll 迁移所有文件
|
return fmt.Errorf("打开本地文件失败: %w", err)
|
||||||
func (m *StorageMigration) MigrateAll(tid uint64) error {
|
}
|
||||||
// 获取所有文件
|
defer f.Close()
|
||||||
var files []models.SystemFile
|
|
||||||
_, err := models.Orm.QueryTable(new(models.SystemFile)).
|
stat, err := f.Stat()
|
||||||
Filter("tid", tid).
|
if err != nil {
|
||||||
Filter("delete_time__isnull", true).
|
return fmt.Errorf("获取文件信息失败: %w", err)
|
||||||
All(&files)
|
}
|
||||||
if err != nil {
|
header := &multipart.FileHeader{Filename: file.Name, Size: stat.Size()}
|
||||||
return fmt.Errorf("获取文件列表失败: %w", err)
|
|
||||||
}
|
staged, err := m.toService.Stage(f, header)
|
||||||
|
if err != nil {
|
||||||
m.progress.Total = len(files)
|
return err
|
||||||
|
}
|
||||||
// 并发迁移(限制并发数)
|
source := file.Source
|
||||||
concurrency := 5
|
if source == "" {
|
||||||
sem := make(chan struct{}, concurrency)
|
source = SourceBackend
|
||||||
var wg sync.WaitGroup
|
}
|
||||||
|
var tuid uint64
|
||||||
for i := range files {
|
if file.Tuid != nil {
|
||||||
wg.Add(1)
|
tuid = *file.Tuid
|
||||||
go func(file *models.SystemFile) {
|
}
|
||||||
defer wg.Done()
|
if _, err := m.toService.Commit(staged, UploadContext{Source: source, Tid: file.Tid, Tuid: tuid}); err != nil {
|
||||||
sem <- struct{}{} // 获取信号量
|
_ = m.toService.Discard(staged)
|
||||||
defer func() { <-sem }() // 释放信号量
|
return err
|
||||||
|
}
|
||||||
if err := m.MigrateFile(file); err != nil {
|
}
|
||||||
m.progress.AddFailed(fmt.Sprintf("%s: %v", file.Name, err))
|
|
||||||
}
|
newSrc := m.toService.GetPublicURL(newKey)
|
||||||
}(&files[i])
|
if _, err := models.Orm.QueryTable(new(models.SystemFile)).
|
||||||
}
|
Filter("id", file.ID).
|
||||||
|
Update(map[string]interface{}{
|
||||||
wg.Wait()
|
"src": newSrc,
|
||||||
return nil
|
"object_key": newKey,
|
||||||
}
|
"storage": m.toService.Type(),
|
||||||
|
}); err != nil {
|
||||||
// GetProgress 获取迁移进度
|
return fmt.Errorf("更新数据库失败: %w", err)
|
||||||
func (m *StorageMigration) GetProgress() *MigrationProgress {
|
}
|
||||||
return m.progress
|
|
||||||
}
|
m.progress.AddSuccess()
|
||||||
|
return nil
|
||||||
// MigrateLocalToQiniu 从本地存储迁移到七牛云
|
}
|
||||||
func MigrateLocalToQiniu(tid uint64) (*MigrationProgress, error) {
|
|
||||||
// 获取存储配置
|
// MigrateAll 迁移所有文件
|
||||||
cfg, err := models.GetStorageConfig()
|
func (m *StorageMigration) MigrateAll(tid uint64) error {
|
||||||
if err != nil {
|
var files []models.SystemFile
|
||||||
return nil, fmt.Errorf("获取存储配置失败: %w", err)
|
qs := models.Orm.QueryTable(new(models.SystemFile)).Filter("delete_time__isnull", true)
|
||||||
}
|
if tid > 0 {
|
||||||
|
qs = qs.Filter("tid", tid)
|
||||||
if cfg.StorageType != "qiniu" {
|
}
|
||||||
return nil, fmt.Errorf("当前存储类型不是七牛云")
|
if _, err := qs.All(&files); err != nil {
|
||||||
}
|
return fmt.Errorf("获取文件列表失败: %w", err)
|
||||||
|
}
|
||||||
// 创建存储服务
|
|
||||||
localStorage := NewLocalStorage()
|
m.progress.Total = len(files)
|
||||||
qiniuStorage := NewQiniuStorage(cfg)
|
|
||||||
|
concurrency := 5
|
||||||
// 创建迁移服务
|
sem := make(chan struct{}, concurrency)
|
||||||
migration := NewStorageMigration(localStorage, qiniuStorage)
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
// 执行迁移
|
for i := range files {
|
||||||
if err := migration.MigrateAll(tid); err != nil {
|
wg.Add(1)
|
||||||
return migration.GetProgress(), err
|
go func(file *models.SystemFile) {
|
||||||
}
|
defer wg.Done()
|
||||||
|
sem <- struct{}{}
|
||||||
return migration.GetProgress(), nil
|
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
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"encoding/hex"
|
"crypto/rand"
|
||||||
"fmt"
|
"encoding/hex"
|
||||||
"io"
|
"fmt"
|
||||||
"mime/multipart"
|
"io"
|
||||||
"os"
|
"mime/multipart"
|
||||||
"path/filepath"
|
"os"
|
||||||
"strings"
|
"path"
|
||||||
"time"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"server/models"
|
"time"
|
||||||
|
|
||||||
"github.com/qiniu/go-sdk/v7/auth/qbox"
|
"server/models"
|
||||||
"github.com/qiniu/go-sdk/v7/storage"
|
|
||||||
)
|
"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
|
const (
|
||||||
Delete(key string) error
|
SourceBackend = "backend" // 租户后台端
|
||||||
}
|
SourcePlatform = "platform" // 平台端
|
||||||
|
)
|
||||||
// UploadResult 上传结果
|
|
||||||
type UploadResult struct {
|
// 归属范围
|
||||||
URL string // 完整访问URL
|
const (
|
||||||
Key string // 存储key/路径
|
ScopeTenant = "tenant" // 租户共享文件
|
||||||
Size int64 // 文件大小
|
ScopeUser = "user" // 用户个人文件
|
||||||
MD5 string // 文件MD5
|
)
|
||||||
MimeType string // 文件类型
|
|
||||||
}
|
// 存储类型
|
||||||
|
const (
|
||||||
// LocalStorage 本地存储实现
|
StorageTypeLocal = "local"
|
||||||
type LocalStorage struct {
|
StorageTypeQiniu = "qiniu"
|
||||||
BaseDir string // 基础目录,默认 "uploads"
|
)
|
||||||
BaseURL string // 基础URL,默认 "/"
|
|
||||||
}
|
// UploadContext 上传上下文,决定文件最终的存储路径
|
||||||
|
type UploadContext struct {
|
||||||
// NewLocalStorage 创建本地存储服务
|
Source string // backend / platform
|
||||||
func NewLocalStorage() *LocalStorage {
|
Tid uint64 // 租户 ID(platform 端可为 0)
|
||||||
return &LocalStorage{
|
Tuid uint64 // 归属用户 ID,0 表示租户共享文件
|
||||||
BaseDir: "uploads",
|
}
|
||||||
BaseURL: "/",
|
|
||||||
}
|
// Scope 返回归属范围:带了归属用户即个人文件,否则为租户共享文件。
|
||||||
}
|
// 平台端不做用户分层,统一按租户共享处理。
|
||||||
|
func (c UploadContext) Scope() string {
|
||||||
// Upload 上传文件到本地
|
if c.Source == SourcePlatform {
|
||||||
func (s *LocalStorage) Upload(file multipart.File, header *multipart.FileHeader) (*UploadResult, error) {
|
return ScopeTenant
|
||||||
// 生成存储路径
|
}
|
||||||
ext := filepath.Ext(header.Filename)
|
if c.Tuid > 0 {
|
||||||
datePath := time.Now().Format("2006/01/02")
|
return ScopeUser
|
||||||
fileName := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
|
}
|
||||||
savePath := filepath.Join(datePath, fileName)
|
return ScopeTenant
|
||||||
|
}
|
||||||
// 创建目录
|
|
||||||
destDir := filepath.Join(s.BaseDir, filepath.FromSlash(datePath))
|
// BuildObjectKey 生成存储相对路径(不含域名、不含本地 BaseDir)
|
||||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
//
|
||||||
return nil, fmt.Errorf("创建目录失败: %w", err)
|
// 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 {
|
||||||
destPath := filepath.Join(s.BaseDir, filepath.FromSlash(savePath))
|
datePath := time.Now().Format("2006/01/02")
|
||||||
dst, err := os.Create(destPath)
|
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomHex(6), normalizeExt(ext))
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("创建文件失败: %w", err)
|
if ctx.Source == SourcePlatform {
|
||||||
}
|
return path.Join(SourcePlatform, datePath, name)
|
||||||
defer dst.Close()
|
}
|
||||||
|
if ctx.Tuid > 0 {
|
||||||
// 计算MD5并复制文件
|
return path.Join(SourceBackend, fmt.Sprint(ctx.Tid), fmt.Sprint(ctx.Tuid), datePath, name)
|
||||||
hash := md5.New()
|
}
|
||||||
size, err := io.Copy(io.MultiWriter(dst, hash), file)
|
return path.Join(SourceBackend, fmt.Sprint(ctx.Tid), datePath, name)
|
||||||
if err != nil {
|
}
|
||||||
_ = os.Remove(destPath)
|
|
||||||
return nil, fmt.Errorf("保存文件失败: %w", err)
|
// normalizeExt 规范扩展名:小写、补前导点
|
||||||
}
|
func normalizeExt(ext string) string {
|
||||||
|
ext = strings.TrimSpace(ext)
|
||||||
md5Sum := hex.EncodeToString(hash.Sum(nil))
|
if ext == "" {
|
||||||
webURL := s.BaseURL + strings.ReplaceAll(filepath.ToSlash(destPath), "\\", "/")
|
return ""
|
||||||
|
}
|
||||||
return &UploadResult{
|
if !strings.HasPrefix(ext, ".") {
|
||||||
URL: webURL,
|
ext = "." + ext
|
||||||
Key: savePath,
|
}
|
||||||
Size: size,
|
return strings.ToLower(ext)
|
||||||
MD5: md5Sum,
|
}
|
||||||
MimeType: header.Header.Get("Content-Type"),
|
|
||||||
}, nil
|
// randomHex 生成 n 位十六进制随机串(使用 crypto/rand,无需种子)
|
||||||
}
|
func randomHex(n int) string {
|
||||||
|
b := make([]byte, (n+1)/2)
|
||||||
// GetPublicURL 获取公开访问URL
|
if _, err := rand.Read(b); err != nil {
|
||||||
func (s *LocalStorage) GetPublicURL(key string) string {
|
return fmt.Sprint(time.Now().UnixNano() % 1000000)
|
||||||
return s.BaseURL + filepath.ToSlash(filepath.Join(s.BaseDir, key))
|
}
|
||||||
}
|
return hex.EncodeToString(b)[:n]
|
||||||
|
}
|
||||||
// Delete 删除本地文件
|
|
||||||
func (s *LocalStorage) Delete(key string) error {
|
// StagedFile 已落临时文件、算完 MD5 的待提交文件
|
||||||
filePath := filepath.Join(s.BaseDir, filepath.FromSlash(key))
|
type StagedFile struct {
|
||||||
return os.Remove(filePath)
|
TempPath string // 本地临时文件路径
|
||||||
}
|
MD5 string // 文件内容 MD5
|
||||||
|
Size int64 // 文件大小
|
||||||
// QiniuStorage 七牛云存储实现
|
MimeType string // 文件类型
|
||||||
type QiniuStorage struct {
|
Ext string // 扩展名(含点)
|
||||||
AccessKey string
|
}
|
||||||
SecretKey string
|
|
||||||
Bucket string
|
// UploadResult 上传结果
|
||||||
Domain string
|
type UploadResult struct {
|
||||||
Region string
|
URL string // 完整访问URL
|
||||||
}
|
Key string // 存储key/相对路径(object_key)
|
||||||
|
Size int64 // 文件大小
|
||||||
// NewQiniuStorage 创建七牛云存储服务
|
MD5 string // 文件MD5
|
||||||
func NewQiniuStorage(cfg *models.StorageConfig) *QiniuStorage {
|
MimeType string // 文件类型
|
||||||
return &QiniuStorage{
|
}
|
||||||
AccessKey: cfg.QiniuAccessKey,
|
|
||||||
SecretKey: cfg.QiniuSecretKey,
|
// StorageService 存储服务接口
|
||||||
Bucket: cfg.QiniuBucket,
|
type StorageService interface {
|
||||||
Domain: cfg.QiniuDomain,
|
// Stage 把上传流写入临时文件并计算 MD5,此时文件尚未进入正式存储目录
|
||||||
Region: cfg.QiniuRegion,
|
Stage(file multipart.File, header *multipart.FileHeader) (*StagedFile, error)
|
||||||
}
|
// Commit 把临时文件提交到按 ctx 计算出的正式路径;返回访问 URL 与 object_key
|
||||||
}
|
Commit(staged *StagedFile, ctx UploadContext) (*UploadResult, error)
|
||||||
|
// Discard 丢弃临时文件(去重命中或出错时调用)
|
||||||
// getZone 根据区域代码获取存储区域
|
Discard(staged *StagedFile) error
|
||||||
func (s *QiniuStorage) getZone() *storage.Region {
|
GetPublicURL(key string) string
|
||||||
switch s.Region {
|
Delete(key string) error
|
||||||
case "z0":
|
// Move 把已存文件从 oldKey 改名到 newKey(迁移用:本地 Rename / 七牛 Move)
|
||||||
return &storage.ZoneHuadong
|
Move(oldKey, newKey string) error
|
||||||
case "z1":
|
// Type 返回存储类型 local / qiniu
|
||||||
return &storage.ZoneHuabei
|
Type() string
|
||||||
case "z2":
|
}
|
||||||
return &storage.ZoneHuanan
|
|
||||||
case "na0":
|
// LocalStorage 本地存储实现
|
||||||
return &storage.ZoneBeimei
|
type LocalStorage struct {
|
||||||
case "as0":
|
BaseDir string // 基础目录,默认 "uploads"
|
||||||
return &storage.ZoneXinjiapo
|
BaseURL string // 基础URL,默认 "/"
|
||||||
case "cn-east-2":
|
}
|
||||||
return &storage.ZoneHuadongZheJiang2
|
|
||||||
default:
|
// NewLocalStorage 创建本地存储服务
|
||||||
return &storage.ZoneHuadong // 默认华东
|
func NewLocalStorage() *LocalStorage {
|
||||||
}
|
return &LocalStorage{
|
||||||
}
|
BaseDir: "uploads",
|
||||||
|
BaseURL: "/",
|
||||||
// Upload 上传文件到七牛云
|
}
|
||||||
func (s *QiniuStorage) Upload(file multipart.File, header *multipart.FileHeader) (*UploadResult, error) {
|
}
|
||||||
// 生成存储key
|
|
||||||
ext := filepath.Ext(header.Filename)
|
// Type 存储类型
|
||||||
datePath := time.Now().Format("2006/01/02")
|
func (s *LocalStorage) Type() string { return StorageTypeLocal }
|
||||||
fileName := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
|
|
||||||
key := filepath.ToSlash(filepath.Join(datePath, fileName))
|
// tempDir 临时目录:与正式目录同盘,保证 Commit 时 os.Rename 不跨设备
|
||||||
|
func (s *LocalStorage) tempDir() string {
|
||||||
// 创建上传凭证
|
return filepath.Join(s.BaseDir, ".tmp")
|
||||||
mac := qbox.NewMac(s.AccessKey, s.SecretKey)
|
}
|
||||||
putPolicy := storage.PutPolicy{
|
|
||||||
Scope: s.Bucket,
|
// Stage 写入临时文件并计算 MD5
|
||||||
}
|
func (s *LocalStorage) Stage(file multipart.File, header *multipart.FileHeader) (*StagedFile, error) {
|
||||||
upToken := putPolicy.UploadToken(mac)
|
dir := s.tempDir()
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
// 配置上传参数
|
return nil, fmt.Errorf("创建临时目录失败: %w", err)
|
||||||
cfg := storage.Config{
|
}
|
||||||
Region: s.getZone(),
|
tmp, err := os.CreateTemp(dir, "stage_*")
|
||||||
UseHTTPS: true,
|
if err != nil {
|
||||||
UseCdnDomains: false,
|
return nil, fmt.Errorf("创建临时文件失败: %w", err)
|
||||||
}
|
}
|
||||||
|
defer tmp.Close()
|
||||||
// 创建表单上传器
|
|
||||||
formUploader := storage.NewFormUploader(&cfg)
|
hash := md5.New()
|
||||||
ret := storage.PutRet{}
|
size, err := io.Copy(io.MultiWriter(tmp, hash), file)
|
||||||
putExtra := storage.PutExtra{}
|
if err != nil {
|
||||||
|
tmpPath := tmp.Name()
|
||||||
// 计算文件大小和MD5
|
_ = os.Remove(tmpPath)
|
||||||
tmpFile, err := os.CreateTemp("", "qiniu_upload_*")
|
return nil, fmt.Errorf("读取上传文件失败: %w", err)
|
||||||
if err != nil {
|
}
|
||||||
return nil, fmt.Errorf("创建临时文件失败: %w", err)
|
|
||||||
}
|
return &StagedFile{
|
||||||
defer os.Remove(tmpFile.Name())
|
TempPath: tmp.Name(),
|
||||||
defer tmpFile.Close()
|
MD5: hex.EncodeToString(hash.Sum(nil)),
|
||||||
|
Size: size,
|
||||||
hash := md5.New()
|
MimeType: mimeTypeOf(header),
|
||||||
size, err := io.Copy(io.MultiWriter(tmpFile, hash), file)
|
Ext: normalizeExt(filepath.Ext(header.Filename)),
|
||||||
if err != nil {
|
}, nil
|
||||||
return nil, fmt.Errorf("读取文件失败: %w", err)
|
}
|
||||||
}
|
|
||||||
md5Sum := hex.EncodeToString(hash.Sum(nil))
|
// Commit 提交到正式目录
|
||||||
|
func (s *LocalStorage) Commit(staged *StagedFile, ctx UploadContext) (*UploadResult, error) {
|
||||||
// 重置文件指针
|
if staged == nil {
|
||||||
if _, err := tmpFile.Seek(0, 0); err != nil {
|
return nil, fmt.Errorf("待提交文件为空")
|
||||||
return nil, fmt.Errorf("重置文件指针失败: %w", err)
|
}
|
||||||
}
|
key := BuildObjectKey(ctx, staged.Ext)
|
||||||
|
destPath := filepath.Join(s.BaseDir, filepath.FromSlash(key))
|
||||||
// 执行上传
|
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
||||||
err = formUploader.Put(context.Background(), &ret, upToken, key, tmpFile, size, &putExtra)
|
return nil, fmt.Errorf("创建目录失败: %w", err)
|
||||||
if err != nil {
|
}
|
||||||
return nil, fmt.Errorf("上传到七牛云失败: %w", err)
|
|
||||||
}
|
// 同盘优先 Rename;跨设备失败则回退复制
|
||||||
|
if err := os.Rename(staged.TempPath, destPath); err != nil {
|
||||||
// 构建完整URL
|
if copyErr := copyFile(staged.TempPath, destPath); copyErr != nil {
|
||||||
domain := strings.TrimRight(s.Domain, "/")
|
return nil, fmt.Errorf("保存文件失败: %w", copyErr)
|
||||||
url := fmt.Sprintf("%s/%s", domain, ret.Key)
|
}
|
||||||
|
_ = os.Remove(staged.TempPath)
|
||||||
return &UploadResult{
|
}
|
||||||
URL: url,
|
|
||||||
Key: ret.Key,
|
return &UploadResult{
|
||||||
Size: size,
|
URL: s.GetPublicURL(key),
|
||||||
MD5: md5Sum,
|
Key: key,
|
||||||
MimeType: header.Header.Get("Content-Type"),
|
Size: staged.Size,
|
||||||
}, nil
|
MD5: staged.MD5,
|
||||||
}
|
MimeType: staged.MimeType,
|
||||||
|
}, nil
|
||||||
// GetPublicURL 获取七牛云公开访问URL
|
}
|
||||||
func (s *QiniuStorage) GetPublicURL(key string) string {
|
|
||||||
domain := strings.TrimRight(s.Domain, "/")
|
// Discard 删除临时文件
|
||||||
return fmt.Sprintf("%s/%s", domain, key)
|
func (s *LocalStorage) Discard(staged *StagedFile) error {
|
||||||
}
|
if staged == nil || staged.TempPath == "" {
|
||||||
|
return nil
|
||||||
// Delete 删除七牛云文件
|
}
|
||||||
func (s *QiniuStorage) Delete(key string) error {
|
return os.Remove(staged.TempPath)
|
||||||
mac := qbox.NewMac(s.AccessKey, s.SecretKey)
|
}
|
||||||
cfg := storage.Config{
|
|
||||||
Region: s.getZone(),
|
// GetPublicURL 获取公开访问URL
|
||||||
UseHTTPS: true,
|
func (s *LocalStorage) GetPublicURL(key string) string {
|
||||||
}
|
full := filepath.ToSlash(filepath.Join(s.BaseDir, filepath.FromSlash(key)))
|
||||||
|
return s.BaseURL + strings.ReplaceAll(full, "\\", "/")
|
||||||
bucketManager := storage.NewBucketManager(mac, &cfg)
|
}
|
||||||
err := bucketManager.Delete(s.Bucket, key)
|
|
||||||
if err != nil {
|
// Delete 删除本地文件
|
||||||
return fmt.Errorf("删除七牛云文件失败: %w", err)
|
func (s *LocalStorage) Delete(key string) error {
|
||||||
}
|
filePath := filepath.Join(s.BaseDir, filepath.FromSlash(key))
|
||||||
return nil
|
return os.Remove(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStorageService 根据配置获取存储服务
|
// Move 本地改名(同盘,不搬数据)
|
||||||
func GetStorageService() (StorageService, error) {
|
func (s *LocalStorage) Move(oldKey, newKey string) error {
|
||||||
cfg, err := models.GetStorageConfig()
|
oldPath := filepath.Join(s.BaseDir, filepath.FromSlash(oldKey))
|
||||||
if err != nil {
|
newPath := filepath.Join(s.BaseDir, filepath.FromSlash(newKey))
|
||||||
// 默认使用本地存储
|
if err := os.MkdirAll(filepath.Dir(newPath), 0755); err != nil {
|
||||||
return NewLocalStorage(), nil
|
return fmt.Errorf("创建目标目录失败: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := os.Rename(oldPath, newPath); err != nil {
|
||||||
switch cfg.StorageType {
|
// 跨设备时回退为复制 + 删除
|
||||||
case "qiniu":
|
if copyErr := copyFile(oldPath, newPath); copyErr != nil {
|
||||||
if cfg.QiniuAccessKey == "" || cfg.QiniuSecretKey == "" ||
|
return fmt.Errorf("移动文件失败: %w", copyErr)
|
||||||
cfg.QiniuBucket == "" || cfg.QiniuDomain == "" {
|
}
|
||||||
return nil, fmt.Errorf("七牛云配置不完整")
|
_ = os.Remove(oldPath)
|
||||||
}
|
}
|
||||||
return NewQiniuStorage(cfg), nil
|
return nil
|
||||||
case "local":
|
}
|
||||||
return NewLocalStorage(), nil
|
|
||||||
default:
|
// QiniuStorage 七牛云存储实现
|
||||||
return NewLocalStorage(), nil
|
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 = {
|
const uploadHeaders = {
|
||||||
Authorization: "Bearer " + localStorage.getItem("token"),
|
Authorization: "Bearer " + localStorage.getItem("token"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,7 +24,8 @@
|
|||||||
<el-form-item label="资质图片" prop="file_url">
|
<el-form-item label="资质图片" prop="file_url">
|
||||||
<el-upload
|
<el-upload
|
||||||
class="avatar-uploader"
|
class="avatar-uploader"
|
||||||
action="/api/platform/common/upload"
|
:action="uploadAction"
|
||||||
|
:headers="uploadHeaders"
|
||||||
:show-file-list="false"
|
:show-file-list="false"
|
||||||
:on-success="handleUploadSuccess"
|
:on-success="handleUploadSuccess"
|
||||||
:before-upload="beforeAvatarUpload"
|
:before-upload="beforeAvatarUpload"
|
||||||
@@ -70,7 +71,13 @@
|
|||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
const tenantName = ref('');
|
const tenantName = ref('');
|
||||||
const formRef = 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({
|
const formData = reactive({
|
||||||
tid: null,
|
tid: null,
|
||||||
type: '',
|
type: '',
|
||||||
|
|||||||
Reference in New Issue
Block a user