阿里云OSS设置

This commit is contained in:
李琦
2026-07-14 10:05:33 +08:00
parent 8ac9f591a0
commit 02cf290bad
16 changed files with 782 additions and 244 deletions

View File

@@ -165,7 +165,7 @@ const uploadFile = async (file: File) => {
if (props.categoryId) {
formData.append('categoryId', props.categoryId.toString())
}
formData.append('storageType', 'local')
formData.append('storageType', 'aliyun')
const result = await parseApiResponse<{ fileUrl: string }>(await authFetch(`${API_BASE}/admin/attachments/upload`, {
method: 'POST',

View File

@@ -366,7 +366,7 @@ const uploadSingleFile = async (file: File): Promise<string | null> => {
if (props.categoryId) {
formData.append('categoryId', props.categoryId.toString())
}
formData.append('storageType', 'local')
formData.append('storageType', 'aliyun')
const result = await parseApiResponse<{ fileUrl: string }>(await authFetch(`${API_BASE}/admin/attachments/upload`, {
method: 'POST',

View File

@@ -46,7 +46,7 @@ const uploadFile = async (file: File) => {
const formData = new FormData()
formData.append('file', file)
if (props.categoryId) formData.append('categoryId', props.categoryId.toString())
formData.append('storageType', 'local')
formData.append('storageType', 'aliyun')
const result = await parseApiResponse<{ fileUrl: string }>(
await authFetch(`${API_BASE}/admin/attachments/upload`, { method: 'POST', headers: {}, body: formData })
)

View File

@@ -14,7 +14,7 @@ export function useMdMediaUpload(editorRef: Ref<ExposeParam | undefined>) {
const formData = new FormData()
formData.append('file', file)
formData.append('categoryId', '1')
formData.append('storageType', 'local')
formData.append('storageType', 'aliyun')
const response = await authFetch('/api/admin/attachments/upload', {
method: 'POST',

View File

@@ -293,7 +293,7 @@ const uploadFileWithFile = async (file: File) => {
if (uploadCategoryId.value > 0) {
formData.append('categoryId', uploadCategoryId.value.toString())
}
formData.append('storageType', 'local')
formData.append('storageType', 'aliyun')
await parseApiResponse(await authFetch(`${API_BASE}/admin/attachments/upload`, {
method: 'POST',

View File

@@ -59,9 +59,9 @@
type="text"
id="ossAccessKeyId"
v-model="ossForm.ossAccessKeyId"
required
:required="!currentConfig"
class="admin-input font-mono"
placeholder="OSS Access Key ID"
placeholder="留空则保持原密钥;新建时必填"
>
</div>
@@ -73,9 +73,9 @@
type="password"
id="ossAccessKeySecret"
v-model="ossForm.ossAccessKeySecret"
required
:required="!currentConfig"
class="admin-input font-mono"
placeholder="OSS Access Key Secret"
placeholder="留空则保持原密钥;新建时必填"
>
</div>
@@ -285,6 +285,15 @@
</button>
<div v-else></div>
<div class="flex gap-3">
<button
v-if="currentConfig && currentConfig.id > 0"
type="button"
class="admin-btn-secondary"
:disabled="isTesting || isSubmitting"
@click="testConnection"
>
{{ isTesting ? '测试中...' : '测试连接' }}
</button>
<button type="submit" class="admin-btn-primary" :disabled="isSubmitting">
{{ isSubmitting ? '保存中...' : (currentConfig ? '保存' : '创建') }}
</button>
@@ -297,7 +306,7 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { getOSSConfigs, createOSSConfig, updateOSSConfig, deleteOSSConfig as deleteOSSConfigApi, OSSConfig } from '../../services/api'
import { getOSSConfigs, createOSSConfig, updateOSSConfig, deleteOSSConfig as deleteOSSConfigApi, testOSSConfig, OSSConfig } from '../../services/api'
import { useToast } from '../../composables/useToast'
const toast = useToast()
@@ -314,6 +323,7 @@ const tabs = [
// OSS配置管理
const ossConfigs = ref<OSSConfig[]>([])
const isSubmitting = ref(false)
const isTesting = ref(false)
const ossForm = ref({
id: 0,
@@ -455,23 +465,32 @@ const fetchOSSConfigs = async () => {
}
}
const secretOrKeep = (value: string, isUpdate: boolean) => {
if (value) return value
return isUpdate ? '***' : ''
}
const saveOSSConfig = async () => {
// 设置存储类型
ossForm.value.storageType = activeTab.value
const isUpdate = ossForm.value.id > 0
// 验证必填字段
// 验证必填字段(更新时密钥可留空表示保持原值)
if (activeTab.value === 'aliyun') {
if (!ossForm.value.ossAccessKeyId || !ossForm.value.ossAccessKeySecret || !ossForm.value.ossEndpoint || !ossForm.value.ossBucket) {
if ((!isUpdate && (!ossForm.value.ossAccessKeyId || !ossForm.value.ossAccessKeySecret)) ||
!ossForm.value.ossEndpoint || !ossForm.value.ossBucket) {
toast.showToast('请填写所有必填字段', 'error')
return
}
} else if (activeTab.value === 'qcloud') {
if (!ossForm.value.qcloudSecretId || !ossForm.value.qcloudSecretKey || !ossForm.value.qcloudRegion || !ossForm.value.qcloudBucket) {
if ((!isUpdate && (!ossForm.value.qcloudSecretId || !ossForm.value.qcloudSecretKey)) ||
!ossForm.value.qcloudRegion || !ossForm.value.qcloudBucket) {
toast.showToast('请填写所有必填字段', 'error')
return
}
} else if (activeTab.value === 'qiniu') {
if (!ossForm.value.qiniuAccessKey || !ossForm.value.qiniuSecretKey || !ossForm.value.qiniuBucket) {
if ((!isUpdate && (!ossForm.value.qiniuAccessKey || !ossForm.value.qiniuSecretKey)) ||
!ossForm.value.qiniuBucket) {
toast.showToast('请填写所有必填字段', 'error')
return
}
@@ -487,31 +506,29 @@ const saveOSSConfig = async () => {
// 根据存储类型设置对应字段
if (activeTab.value === 'aliyun') {
updateData.ossAccessKeyId = ossForm.value.ossAccessKeyId
updateData.ossAccessKeySecret = ossForm.value.ossAccessKeySecret
updateData.ossAccessKeyId = secretOrKeep(ossForm.value.ossAccessKeyId, isUpdate)
updateData.ossAccessKeySecret = secretOrKeep(ossForm.value.ossAccessKeySecret, isUpdate)
updateData.ossEndpoint = ossForm.value.ossEndpoint
updateData.ossBucket = ossForm.value.ossBucket
updateData.ossDomain = ossForm.value.ossDomain || ''
} else if (activeTab.value === 'qcloud') {
updateData.qcloudSecretId = ossForm.value.qcloudSecretId
updateData.qcloudSecretKey = ossForm.value.qcloudSecretKey
updateData.qcloudSecretId = secretOrKeep(ossForm.value.qcloudSecretId, isUpdate)
updateData.qcloudSecretKey = secretOrKeep(ossForm.value.qcloudSecretKey, isUpdate)
updateData.qcloudRegion = ossForm.value.qcloudRegion
updateData.qcloudBucket = ossForm.value.qcloudBucket
updateData.qcloudDomain = ossForm.value.qcloudDomain || ''
} else if (activeTab.value === 'qiniu') {
updateData.qiniuAccessKey = ossForm.value.qiniuAccessKey
updateData.qiniuSecretKey = ossForm.value.qiniuSecretKey
updateData.qiniuAccessKey = secretOrKeep(ossForm.value.qiniuAccessKey, isUpdate)
updateData.qiniuSecretKey = secretOrKeep(ossForm.value.qiniuSecretKey, isUpdate)
updateData.qiniuBucket = ossForm.value.qiniuBucket
updateData.qiniuRegion = ossForm.value.qiniuRegion || ''
updateData.qiniuDomain = ossForm.value.qiniuDomain || ''
}
if (ossForm.value.id > 0) {
// 更新
if (isUpdate) {
await updateOSSConfig(ossForm.value.id, updateData)
toast.showToast('OSS配置更新成功', 'success')
} else {
// 创建
await createOSSConfig({
...updateData,
createdAt: '',
@@ -529,6 +546,24 @@ const saveOSSConfig = async () => {
}
}
const testConnection = async () => {
if (!currentConfig.value?.id) {
toast.showToast('请先保存配置再测试连接', 'error')
return
}
isTesting.value = true
try {
await testOSSConfig(currentConfig.value.id)
toast.showToast('连接测试成功', 'success')
} catch (error: any) {
console.error('Error testing OSS config:', error)
toast.showToast(error.message || '连接测试失败', 'error')
} finally {
isTesting.value = false
}
}
const deleteOSSConfig = async () => {
if (!currentConfig.value) return

View File

@@ -549,7 +549,7 @@ const uploadMediaFile = async (file: File): Promise<string | null> => {
const formData = new FormData()
formData.append('file', file)
formData.append('categoryId', '1')
formData.append('storageType', 'local')
formData.append('storageType', 'aliyun')
const response = await authFetch('/api/admin/attachments/upload', {
method: 'POST',

View File

@@ -2001,6 +2001,19 @@ export const deleteOSSConfig = async (id: number): Promise<void> => {
}
}
export const testOSSConfig = async (id: number): Promise<{ storageType: string; name: string }> => {
try {
const response = await authFetch(`${API_BASE}/admin/oss-configs/${id}/test`, {
method: 'POST',
headers: getAuthHeaders()
})
return await parseApiResponse<{ storageType: string; name: string }>(response)
} catch (error) {
console.error('Test OSS config error:', error)
throw error
}
}
// 附件相关类型
export interface Attachment {
id: number

View File

@@ -1,7 +1,9 @@
package handlers
import (
"fmt"
"log"
"path/filepath"
"strconv"
"github.com/gin-gonic/gin"
@@ -29,8 +31,12 @@ func AdminUploadAttachment(c *gin.Context) {
}
}
// 获取存储类型
storageType := c.DefaultPostForm("storageType", "local")
// 获取存储类型Env 齐全时默认 aliyun
defaultStorage := "local"
if utils.HasAliyunEnvConfig() {
defaultStorage = "aliyun"
}
storageType := c.DefaultPostForm("storageType", defaultStorage)
// 打开文件
src, err := file.Open()
@@ -40,28 +46,33 @@ func AdminUploadAttachment(c *gin.Context) {
}
defer src.Close()
// 获取OSS配置
var ossConfig *models.OSSConfig
if storageType != "local" {
ossConfig, err = repositories.GetActiveOSSConfig(storageType)
if err != nil || ossConfig == nil {
utils.Error(c, 400, "OSS config not found or not active")
return
}
}
// 创建OSS配置对象
config := &utils.OSSConfig{
StorageType: storageType,
}
if ossConfig != nil {
var ossConfig *models.OSSConfig
usedEnvConfig := false
if storageType == "aliyun" {
if envCfg, ok := utils.LoadAliyunConfigFromEnv(); ok {
config = envCfg
usedEnvConfig = true
}
}
if !usedEnvConfig && storageType != "local" {
ossConfig, err = repositories.GetActiveOSSConfig(storageType)
if err != nil || ossConfig == nil {
utils.Error(c, 400, "OSS config not found or not active")
return
}
// 根据存储类型从专用字段或通用字段读取配置
var accessKey, secretKey, bucket, region, domain string
switch ossConfig.StorageType {
case "aliyun":
// 优先使用专用字段
if ossConfig.OSSAccessKeyID != "" {
decrypted, err := utils.DecryptAES(ossConfig.OSSAccessKeyID)
if err == nil {
@@ -76,7 +87,6 @@ func AdminUploadAttachment(c *gin.Context) {
}
bucket = ossConfig.OSSBucket
domain = ossConfig.OSSDomain
// 如果专用字段为空,使用通用字段(向后兼容)
if accessKey == "" && ossConfig.AccessKey != "" {
decrypted, err := utils.DecryptAES(ossConfig.AccessKey)
if err == nil {
@@ -96,7 +106,6 @@ func AdminUploadAttachment(c *gin.Context) {
domain = ossConfig.Domain
}
case "qcloud":
// 优先使用专用字段
if ossConfig.QCloudSecretID != "" {
decrypted, err := utils.DecryptAES(ossConfig.QCloudSecretID)
if err == nil {
@@ -112,7 +121,6 @@ func AdminUploadAttachment(c *gin.Context) {
bucket = ossConfig.QCloudBucket
region = ossConfig.QCloudRegion
domain = ossConfig.QCloudDomain
// 向后兼容
if accessKey == "" && ossConfig.AccessKey != "" {
decrypted, err := utils.DecryptAES(ossConfig.AccessKey)
if err == nil {
@@ -135,7 +143,6 @@ func AdminUploadAttachment(c *gin.Context) {
domain = ossConfig.Domain
}
case "qiniu":
// 优先使用专用字段
if ossConfig.QiniuAccessKey != "" {
decrypted, err := utils.DecryptAES(ossConfig.QiniuAccessKey)
if err == nil {
@@ -151,7 +158,6 @@ func AdminUploadAttachment(c *gin.Context) {
bucket = ossConfig.QiniuBucket
region = ossConfig.QiniuRegion
domain = ossConfig.QiniuDomain
// 向后兼容
if accessKey == "" && ossConfig.AccessKey != "" {
decrypted, err := utils.DecryptAES(ossConfig.AccessKey)
if err == nil {
@@ -174,7 +180,6 @@ func AdminUploadAttachment(c *gin.Context) {
domain = ossConfig.Domain
}
default:
// 使用通用字段
if ossConfig.AccessKey != "" {
decrypted, err := utils.DecryptAES(ossConfig.AccessKey)
if err == nil {
@@ -198,7 +203,6 @@ func AdminUploadAttachment(c *gin.Context) {
config.Region = region
config.Domain = domain
// 填充专用字段
switch ossConfig.StorageType {
case "aliyun":
config.OSSAccessKeyID = accessKey
@@ -235,6 +239,11 @@ func AdminUploadAttachment(c *gin.Context) {
return
}
storedName := filepath.Base(filePath)
if storedName == "." || storedName == "/" || storedName == "" {
storedName = file.Filename
}
// 获取文件类型
fileType := utils.GetFileType(file.Header.Get("Content-Type"))
@@ -242,7 +251,7 @@ func AdminUploadAttachment(c *gin.Context) {
attachment := &models.Attachment{
CategoryID: categoryID,
OriginalName: file.Filename,
StoredName: file.Filename,
StoredName: storedName,
FilePath: filePath,
FileURL: fileURL,
FileSize: file.Size,
@@ -257,7 +266,6 @@ func AdminUploadAttachment(c *gin.Context) {
}
if err := repositories.CreateAttachment(attachment); err != nil {
// 如果数据库保存失败,尝试删除已上传的文件
uploader.Delete(filePath)
utils.ServerError(c, err)
return
@@ -301,11 +309,12 @@ func AdminGetAttachments(c *gin.Context) {
// AdminDeleteAttachment 删除附件
func AdminDeleteAttachment(c *gin.Context) {
idStr := c.Param("id")
var id uint
if _, err := strconv.ParseUint(idStr, 10, 32); err != nil {
parsedID, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.Error(c, 400, "Invalid attachment ID")
return
}
id := uint(parsedID)
// 获取附件信息
attachment, err := repositories.GetAttachmentByID(id)
@@ -471,11 +480,12 @@ func AdminDeleteAttachment(c *gin.Context) {
// AdminUpdateAttachment 更新附件
func AdminUpdateAttachment(c *gin.Context) {
idStr := c.Param("id")
var id uint
if _, err := strconv.ParseUint(idStr, 10, 32); err != nil {
parsedID, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.Error(c, 400, "Invalid attachment ID")
return
}
id := uint(parsedID)
// 获取现有附件
attachment, err := repositories.GetAttachmentByID(id)
@@ -550,11 +560,12 @@ func AdminCreateAttachmentCategory(c *gin.Context) {
// AdminUpdateAttachmentCategory 更新附件分类
func AdminUpdateAttachmentCategory(c *gin.Context) {
idStr := c.Param("id")
var id uint
if _, err := strconv.ParseUint(idStr, 10, 32); err != nil {
parsedID, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.Error(c, 400, "Invalid category ID")
return
}
id := uint(parsedID)
var category models.AttachmentCategory
if err := c.ShouldBindJSON(&category); err != nil {
@@ -575,11 +586,12 @@ func AdminUpdateAttachmentCategory(c *gin.Context) {
// AdminDeleteAttachmentCategory 删除附件分类
func AdminDeleteAttachmentCategory(c *gin.Context) {
idStr := c.Param("id")
var id uint
if _, err := strconv.ParseUint(idStr, 10, 32); err != nil {
parsedID, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.Error(c, 400, "Invalid category ID")
return
}
id := uint(parsedID)
if err := repositories.DeleteAttachmentCategory(id); err != nil {
utils.ServerError(c, err)
@@ -779,11 +791,12 @@ func AdminCreateOSSConfig(c *gin.Context) {
// AdminUpdateOSSConfig 更新OSS配置
func AdminUpdateOSSConfig(c *gin.Context) {
idStr := c.Param("id")
var id uint
if _, err := strconv.ParseUint(idStr, 10, 32); err != nil {
parsedID, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.Error(c, 400, "Invalid OSS config ID")
return
}
id := uint(parsedID)
var req struct {
Name string `json:"name"`
@@ -827,34 +840,18 @@ func AdminUpdateOSSConfig(c *gin.Context) {
return
}
// 检查是否要启用配置如果是非local类型需要验证AccessKey和SecretKey
if req.IsActive == 1 && existingConfig.IsActive == 0 {
// 检查当前配置的AccessKey和SecretKey需要解密
var accessKey, secretKey string
if existingConfig.AccessKey != "" {
decrypted, err := utils.DecryptAES(existingConfig.AccessKey)
if err == nil {
accessKey = decrypted
}
}
if existingConfig.SecretKey != "" {
decrypted, err := utils.DecryptAES(existingConfig.SecretKey)
if err == nil {
secretKey = decrypted
}
storageType := existingConfig.StorageType
if req.StorageType != "" {
storageType = req.StorageType
}
// 如果提供了新的AccessKey或SecretKey使用新的
if req.AccessKey != "" && req.AccessKey != "***" {
accessKey = req.AccessKey
}
if req.SecretKey != "" && req.SecretKey != "***" {
secretKey = req.SecretKey
}
// 对于非local类型必须填写AccessKey和SecretKey
if existingConfig.StorageType != "local" && (accessKey == "" || secretKey == "") {
utils.Error(c, 400, "启用非本地存储配置前必须填写AccessKey和SecretKey")
// 启用非 local 配置前,按存储类型校验密钥(优先专用字段,兼容 legacy
if req.IsActive == 1 && existingConfig.IsActive == 0 && storageType != "local" {
if errMsg := validateOSSCredentialsForEnable(storageType, existingConfig, req.AccessKey, req.SecretKey,
req.OSSAccessKeyID, req.OSSAccessKeySecret, req.OSSEndpoint, req.OSSBucket,
req.QCloudSecretID, req.QCloudSecretKey, req.QCloudRegion, req.QCloudBucket,
req.QiniuAccessKey, req.QiniuSecretKey, req.QiniuBucket); errMsg != "" {
utils.Error(c, 400, errMsg)
return
}
}
@@ -867,6 +864,19 @@ func AdminUpdateOSSConfig(c *gin.Context) {
return utils.EncryptAES(key)
}
// 密钥字段:*** 或空表示不修改已有值
updateSecret := func(reqVal string, dest *string) error {
if reqVal == "***" || reqVal == "" {
return nil
}
encrypted, err := encryptKey(reqVal)
if err != nil {
return err
}
*dest = encrypted
return nil
}
// 更新字段
if req.Name != "" {
existingConfig.Name = req.Name
@@ -875,88 +885,51 @@ func AdminUpdateOSSConfig(c *gin.Context) {
existingConfig.StorageType = req.StorageType
}
// 处理通用字段(向后兼容)
if req.AccessKey != "***" {
encrypted, err := encryptKey(req.AccessKey)
if err != nil {
if err := updateSecret(req.AccessKey, &existingConfig.AccessKey); err != nil {
utils.Error(c, 500, "Failed to encrypt access key: "+err.Error())
return
}
existingConfig.AccessKey = encrypted
}
if req.SecretKey != "***" {
encrypted, err := encryptKey(req.SecretKey)
if err != nil {
if err := updateSecret(req.SecretKey, &existingConfig.SecretKey); err != nil {
utils.Error(c, 500, "Failed to encrypt secret key: "+err.Error())
return
}
existingConfig.SecretKey = encrypted
}
// 更新通用字段(允许空字符串)
existingConfig.Bucket = req.Bucket
existingConfig.Region = req.Region
existingConfig.Domain = req.Domain
// 处理阿里云OSS专用字段
if req.OSSAccessKeyID != "***" {
encrypted, err := encryptKey(req.OSSAccessKeyID)
if err != nil {
if err := updateSecret(req.OSSAccessKeyID, &existingConfig.OSSAccessKeyID); err != nil {
utils.Error(c, 500, "Failed to encrypt OSS access key ID: "+err.Error())
return
}
existingConfig.OSSAccessKeyID = encrypted
}
if req.OSSAccessKeySecret != "***" {
encrypted, err := encryptKey(req.OSSAccessKeySecret)
if err != nil {
if err := updateSecret(req.OSSAccessKeySecret, &existingConfig.OSSAccessKeySecret); err != nil {
utils.Error(c, 500, "Failed to encrypt OSS access key secret: "+err.Error())
return
}
existingConfig.OSSAccessKeySecret = encrypted
}
existingConfig.OSSEndpoint = req.OSSEndpoint
existingConfig.OSSBucket = req.OSSBucket
existingConfig.OSSDomain = req.OSSDomain
// 处理腾讯云COS专用字段
if req.QCloudSecretID != "***" {
encrypted, err := encryptKey(req.QCloudSecretID)
if err != nil {
if err := updateSecret(req.QCloudSecretID, &existingConfig.QCloudSecretID); err != nil {
utils.Error(c, 500, "Failed to encrypt QCloud secret ID: "+err.Error())
return
}
existingConfig.QCloudSecretID = encrypted
}
if req.QCloudSecretKey != "***" {
encrypted, err := encryptKey(req.QCloudSecretKey)
if err != nil {
if err := updateSecret(req.QCloudSecretKey, &existingConfig.QCloudSecretKey); err != nil {
utils.Error(c, 500, "Failed to encrypt QCloud secret key: "+err.Error())
return
}
existingConfig.QCloudSecretKey = encrypted
}
existingConfig.QCloudRegion = req.QCloudRegion
existingConfig.QCloudBucket = req.QCloudBucket
existingConfig.QCloudDomain = req.QCloudDomain
// 处理七牛云专用字段
if req.QiniuAccessKey != "***" {
encrypted, err := encryptKey(req.QiniuAccessKey)
if err != nil {
if err := updateSecret(req.QiniuAccessKey, &existingConfig.QiniuAccessKey); err != nil {
utils.Error(c, 500, "Failed to encrypt Qiniu access key: "+err.Error())
return
}
existingConfig.QiniuAccessKey = encrypted
}
if req.QiniuSecretKey != "***" {
encrypted, err := encryptKey(req.QiniuSecretKey)
if err != nil {
if err := updateSecret(req.QiniuSecretKey, &existingConfig.QiniuSecretKey); err != nil {
utils.Error(c, 500, "Failed to encrypt Qiniu secret key: "+err.Error())
return
}
existingConfig.QiniuSecretKey = encrypted
}
existingConfig.QiniuBucket = req.QiniuBucket
existingConfig.QiniuRegion = req.QiniuRegion
existingConfig.QiniuDomain = req.QiniuDomain
@@ -968,27 +941,19 @@ func AdminUpdateOSSConfig(c *gin.Context) {
return
}
// 不返回加密的密钥
existingConfig.AccessKey = "***"
existingConfig.SecretKey = "***"
existingConfig.OSSAccessKeyID = "***"
existingConfig.OSSAccessKeySecret = "***"
existingConfig.QCloudSecretID = "***"
existingConfig.QCloudSecretKey = "***"
existingConfig.QiniuAccessKey = "***"
existingConfig.QiniuSecretKey = "***"
maskOSSSecrets(existingConfig)
utils.SuccessWithMsg(c, "OSS config updated successfully", existingConfig)
}
// AdminDeleteOSSConfig 删除OSS配置
func AdminDeleteOSSConfig(c *gin.Context) {
idStr := c.Param("id")
var id uint
if _, err := strconv.ParseUint(idStr, 10, 32); err != nil {
parsedID, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.Error(c, 400, "Invalid OSS config ID")
return
}
id := uint(parsedID)
// 获取配置信息,检查是否为默认本地存储配置
ossConfig, err := repositories.GetOSSConfigByID(id)
@@ -1015,3 +980,258 @@ func AdminDeleteOSSConfig(c *gin.Context) {
utils.SuccessWithMsg(c, "OSS config deleted successfully", nil)
}
// AdminTestOSSConfig 测试OSS配置连通性
func AdminTestOSSConfig(c *gin.Context) {
idStr := c.Param("id")
parsedID, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.Error(c, 400, "Invalid OSS config ID")
return
}
id := uint(parsedID)
ossConfig, err := repositories.GetOSSConfigByID(id)
if err != nil || ossConfig == nil {
utils.Error(c, 404, "OSS config not found")
return
}
var runtimeCfg *utils.OSSConfig
if ossConfig.StorageType == "aliyun" {
if envCfg, ok := utils.LoadAliyunConfigFromEnv(); ok {
runtimeCfg = envCfg
}
}
if runtimeCfg == nil {
runtimeCfg, err = buildRuntimeOSSConfig(ossConfig)
if err != nil {
utils.Error(c, 400, err.Error())
return
}
}
if err := utils.TestOSSConnection(runtimeCfg); err != nil {
utils.Error(c, 400, "连接测试失败: "+err.Error())
return
}
source := "db"
if ossConfig.StorageType == "aliyun" && utils.HasAliyunEnvConfig() {
source = "env"
}
utils.SuccessWithMsg(c, "连接测试成功", gin.H{
"storageType": ossConfig.StorageType,
"name": ossConfig.Name,
"source": source,
})
}
func decryptOptional(encrypted string) string {
if encrypted == "" {
return ""
}
decrypted, err := utils.DecryptAES(encrypted)
if err != nil {
return ""
}
return decrypted
}
func pickCredential(reqVal, existingEncrypted string) string {
if reqVal != "" && reqVal != "***" {
return reqVal
}
return decryptOptional(existingEncrypted)
}
func validateOSSCredentialsForEnable(
storageType string,
existing *models.OSSConfig,
legacyAccessKey, legacySecretKey string,
ossAccessKeyID, ossAccessKeySecret, ossEndpoint, ossBucket string,
qcloudSecretID, qcloudSecretKey, qcloudRegion, qcloudBucket string,
qiniuAccessKey, qiniuSecretKey, qiniuBucket string,
) string {
switch storageType {
case "aliyun":
ak := pickCredential(ossAccessKeyID, existing.OSSAccessKeyID)
sk := pickCredential(ossAccessKeySecret, existing.OSSAccessKeySecret)
if ak == "" {
ak = pickCredential(legacyAccessKey, existing.AccessKey)
}
if sk == "" {
sk = pickCredential(legacySecretKey, existing.SecretKey)
}
endpoint := ossEndpoint
if endpoint == "" {
endpoint = existing.OSSEndpoint
}
if endpoint == "" {
endpoint = existing.Region
}
bucket := ossBucket
if bucket == "" {
bucket = existing.OSSBucket
}
if bucket == "" {
bucket = existing.Bucket
}
if ak == "" || sk == "" || endpoint == "" || bucket == "" {
return "启用阿里云OSS前必须填写 AccessKeyId、AccessKeySecret、Endpoint 和 Bucket"
}
case "qcloud":
id := pickCredential(qcloudSecretID, existing.QCloudSecretID)
key := pickCredential(qcloudSecretKey, existing.QCloudSecretKey)
if id == "" {
id = pickCredential(legacyAccessKey, existing.AccessKey)
}
if key == "" {
key = pickCredential(legacySecretKey, existing.SecretKey)
}
region := qcloudRegion
if region == "" {
region = existing.QCloudRegion
}
if region == "" {
region = existing.Region
}
bucket := qcloudBucket
if bucket == "" {
bucket = existing.QCloudBucket
}
if bucket == "" {
bucket = existing.Bucket
}
if id == "" || key == "" || region == "" || bucket == "" {
return "启用腾讯云COS前必须填写 SecretId、SecretKey、Region 和 Bucket"
}
case "qiniu":
ak := pickCredential(qiniuAccessKey, existing.QiniuAccessKey)
sk := pickCredential(qiniuSecretKey, existing.QiniuSecretKey)
if ak == "" {
ak = pickCredential(legacyAccessKey, existing.AccessKey)
}
if sk == "" {
sk = pickCredential(legacySecretKey, existing.SecretKey)
}
bucket := qiniuBucket
if bucket == "" {
bucket = existing.QiniuBucket
}
if bucket == "" {
bucket = existing.Bucket
}
if ak == "" || sk == "" || bucket == "" {
return "启用七牛云前必须填写 AccessKey、SecretKey 和 Bucket"
}
default:
ak := pickCredential(legacyAccessKey, existing.AccessKey)
sk := pickCredential(legacySecretKey, existing.SecretKey)
if ak == "" || sk == "" {
return "启用非本地存储配置前必须填写密钥"
}
}
return ""
}
func maskOSSSecrets(ossConfig *models.OSSConfig) {
ossConfig.AccessKey = "***"
ossConfig.SecretKey = "***"
ossConfig.OSSAccessKeyID = "***"
ossConfig.OSSAccessKeySecret = "***"
ossConfig.QCloudSecretID = "***"
ossConfig.QCloudSecretKey = "***"
ossConfig.QiniuAccessKey = "***"
ossConfig.QiniuSecretKey = "***"
}
func buildRuntimeOSSConfig(ossConfig *models.OSSConfig) (*utils.OSSConfig, error) {
cfg := &utils.OSSConfig{
StorageType: ossConfig.StorageType,
AccessKey: decryptOptional(ossConfig.AccessKey),
SecretKey: decryptOptional(ossConfig.SecretKey),
Bucket: ossConfig.Bucket,
Region: ossConfig.Region,
Domain: ossConfig.Domain,
}
switch ossConfig.StorageType {
case "aliyun":
cfg.OSSAccessKeyID = decryptOptional(ossConfig.OSSAccessKeyID)
cfg.OSSAccessKeySecret = decryptOptional(ossConfig.OSSAccessKeySecret)
cfg.OSSEndpoint = ossConfig.OSSEndpoint
cfg.OSSBucket = ossConfig.OSSBucket
cfg.OSSDomain = ossConfig.OSSDomain
if cfg.OSSAccessKeyID == "" {
cfg.OSSAccessKeyID = cfg.AccessKey
}
if cfg.OSSAccessKeySecret == "" {
cfg.OSSAccessKeySecret = cfg.SecretKey
}
if cfg.OSSBucket == "" {
cfg.OSSBucket = cfg.Bucket
}
if cfg.OSSDomain == "" {
cfg.OSSDomain = cfg.Domain
}
if cfg.OSSAccessKeyID == "" || cfg.OSSAccessKeySecret == "" || cfg.OSSBucket == "" {
return nil, fmt.Errorf("阿里云OSS配置不完整需要 AccessKeyId、AccessKeySecret 和 Bucket")
}
if cfg.OSSEndpoint == "" && cfg.Region == "" {
return nil, fmt.Errorf("阿里云OSS配置不完整需要 Endpoint 或 Region")
}
case "qcloud":
cfg.QCloudSecretID = decryptOptional(ossConfig.QCloudSecretID)
cfg.QCloudSecretKey = decryptOptional(ossConfig.QCloudSecretKey)
cfg.QCloudRegion = ossConfig.QCloudRegion
cfg.QCloudBucket = ossConfig.QCloudBucket
cfg.QCloudDomain = ossConfig.QCloudDomain
if cfg.QCloudSecretID == "" {
cfg.QCloudSecretID = cfg.AccessKey
}
if cfg.QCloudSecretKey == "" {
cfg.QCloudSecretKey = cfg.SecretKey
}
if cfg.QCloudRegion == "" {
cfg.QCloudRegion = cfg.Region
}
if cfg.QCloudBucket == "" {
cfg.QCloudBucket = cfg.Bucket
}
if cfg.QCloudDomain == "" {
cfg.QCloudDomain = cfg.Domain
}
if cfg.QCloudSecretID == "" || cfg.QCloudSecretKey == "" || cfg.QCloudBucket == "" || cfg.QCloudRegion == "" {
return nil, fmt.Errorf("腾讯云COS配置不完整需要 SecretId、SecretKey、Bucket 和 Region")
}
case "qiniu":
cfg.QiniuAccessKey = decryptOptional(ossConfig.QiniuAccessKey)
cfg.QiniuSecretKey = decryptOptional(ossConfig.QiniuSecretKey)
cfg.QiniuBucket = ossConfig.QiniuBucket
cfg.QiniuRegion = ossConfig.QiniuRegion
cfg.QiniuDomain = ossConfig.QiniuDomain
if cfg.QiniuAccessKey == "" {
cfg.QiniuAccessKey = cfg.AccessKey
}
if cfg.QiniuSecretKey == "" {
cfg.QiniuSecretKey = cfg.SecretKey
}
if cfg.QiniuBucket == "" {
cfg.QiniuBucket = cfg.Bucket
}
if cfg.QiniuDomain == "" {
cfg.QiniuDomain = cfg.Domain
}
if cfg.QiniuAccessKey == "" || cfg.QiniuSecretKey == "" || cfg.QiniuBucket == "" {
return nil, fmt.Errorf("七牛云配置不完整:需要 AccessKey、SecretKey 和 Bucket")
}
case "local":
// no credentials required
default:
return nil, fmt.Errorf("不支持的存储类型: %s", ossConfig.StorageType)
}
return cfg, nil
}

View File

@@ -29,6 +29,7 @@ func main() {
repositories.MigrateUserProfileFields()
repositories.MigrateVideoModule()
repositories.MigratePptTemplates()
repositories.MigrateOSSConfigFields()
// 初始化ip2region (如果文件不存在将降级为普通IP记录)
// 函数会自动从环境变量或可执行文件目录查找 ip2region.xdb
@@ -299,6 +300,7 @@ func main() {
authAdmin.GET("/oss-configs", middleware.PermissionMiddleware("settings", "read"), handlers.AdminGetOSSConfigs)
authAdmin.POST("/oss-configs", middleware.PermissionMiddleware("settings", "create"), handlers.AdminCreateOSSConfig)
authAdmin.PUT("/oss-configs/:id", middleware.PermissionMiddleware("settings", "update"), handlers.AdminUpdateOSSConfig)
authAdmin.POST("/oss-configs/:id/test", middleware.PermissionMiddleware("settings", "update"), handlers.AdminTestOSSConfig)
authAdmin.DELETE("/oss-configs/:id", middleware.PermissionMiddleware("settings", "delete"), handlers.AdminDeleteOSSConfig)
}
}

View File

@@ -220,6 +220,21 @@ CREATE TABLE `oss_configs` (
`bucket` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '存储桶名称',
`region` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '区域',
`domain` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '访问域名',
`oss_access_key_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '阿里云 AccessKeyIdAES加密',
`oss_access_key_secret` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '阿里云 AccessKeySecretAES加密',
`oss_endpoint` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '阿里云 Endpoint',
`oss_bucket` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '阿里云 Bucket',
`oss_domain` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '阿里云自定义域名',
`qcloud_secret_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '腾讯云 SecretIdAES加密',
`qcloud_secret_key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '腾讯云 SecretKeyAES加密',
`qcloud_region` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '腾讯云 Region',
`qcloud_bucket` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '腾讯云 Bucket',
`qcloud_domain` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '腾讯云自定义域名',
`qiniu_access_key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '七牛 AccessKeyAES加密',
`qiniu_secret_key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '七牛 SecretKeyAES加密',
`qiniu_bucket` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '七牛 Bucket',
`qiniu_region` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '七牛 Region',
`qiniu_domain` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '七牛自定义域名',
`is_active` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否启用',
`deleted_at` bigint NOT NULL DEFAULT 0,
`created_at` bigint NOT NULL DEFAULT 0,

View File

@@ -272,6 +272,21 @@ func UpdateOSSConfig(ossConfig *models.OSSConfig) error {
"bucket": ossConfig.Bucket,
"region": ossConfig.Region,
"domain": ossConfig.Domain,
"oss_access_key_id": ossConfig.OSSAccessKeyID,
"oss_access_key_secret": ossConfig.OSSAccessKeySecret,
"oss_endpoint": ossConfig.OSSEndpoint,
"oss_bucket": ossConfig.OSSBucket,
"oss_domain": ossConfig.OSSDomain,
"qcloud_secret_id": ossConfig.QCloudSecretID,
"qcloud_secret_key": ossConfig.QCloudSecretKey,
"qcloud_region": ossConfig.QCloudRegion,
"qcloud_bucket": ossConfig.QCloudBucket,
"qcloud_domain": ossConfig.QCloudDomain,
"qiniu_access_key": ossConfig.QiniuAccessKey,
"qiniu_secret_key": ossConfig.QiniuSecretKey,
"qiniu_bucket": ossConfig.QiniuBucket,
"qiniu_region": ossConfig.QiniuRegion,
"qiniu_domain": ossConfig.QiniuDomain,
"is_active": ossConfig.IsActive,
"updated_at": time.Now().Unix(),
}).Error

View File

@@ -322,6 +322,44 @@ func MigratePptTemplates() {
EnsureSystemPptTemplate()
}
// MigrateOSSConfigFields 为 oss_configs 补齐各云厂商专用列(幂等)
func MigrateOSSConfigFields() {
log.Printf("Migrating oss_configs provider fields...")
if !tableExists("oss_configs") {
log.Printf("Table oss_configs does not exist, skip MigrateOSSConfigFields")
return
}
columns := []struct {
name string
ddl string
}{
{"oss_access_key_id", "ALTER TABLE `oss_configs` ADD COLUMN `oss_access_key_id` varchar(255) NOT NULL DEFAULT '' COMMENT '阿里云 AccessKeyIdAES加密' AFTER `domain`"},
{"oss_access_key_secret", "ALTER TABLE `oss_configs` ADD COLUMN `oss_access_key_secret` varchar(255) NOT NULL DEFAULT '' COMMENT '阿里云 AccessKeySecretAES加密' AFTER `oss_access_key_id`"},
{"oss_endpoint", "ALTER TABLE `oss_configs` ADD COLUMN `oss_endpoint` varchar(255) NOT NULL DEFAULT '' COMMENT '阿里云 Endpoint' AFTER `oss_access_key_secret`"},
{"oss_bucket", "ALTER TABLE `oss_configs` ADD COLUMN `oss_bucket` varchar(255) NOT NULL DEFAULT '' COMMENT '阿里云 Bucket' AFTER `oss_endpoint`"},
{"oss_domain", "ALTER TABLE `oss_configs` ADD COLUMN `oss_domain` varchar(255) NOT NULL DEFAULT '' COMMENT '阿里云自定义域名' AFTER `oss_bucket`"},
{"qcloud_secret_id", "ALTER TABLE `oss_configs` ADD COLUMN `qcloud_secret_id` varchar(255) NOT NULL DEFAULT '' COMMENT '腾讯云 SecretIdAES加密' AFTER `oss_domain`"},
{"qcloud_secret_key", "ALTER TABLE `oss_configs` ADD COLUMN `qcloud_secret_key` varchar(255) NOT NULL DEFAULT '' COMMENT '腾讯云 SecretKeyAES加密' AFTER `qcloud_secret_id`"},
{"qcloud_region", "ALTER TABLE `oss_configs` ADD COLUMN `qcloud_region` varchar(255) NOT NULL DEFAULT '' COMMENT '腾讯云 Region' AFTER `qcloud_secret_key`"},
{"qcloud_bucket", "ALTER TABLE `oss_configs` ADD COLUMN `qcloud_bucket` varchar(255) NOT NULL DEFAULT '' COMMENT '腾讯云 Bucket' AFTER `qcloud_region`"},
{"qcloud_domain", "ALTER TABLE `oss_configs` ADD COLUMN `qcloud_domain` varchar(255) NOT NULL DEFAULT '' COMMENT '腾讯云自定义域名' AFTER `qcloud_bucket`"},
{"qiniu_access_key", "ALTER TABLE `oss_configs` ADD COLUMN `qiniu_access_key` varchar(255) NOT NULL DEFAULT '' COMMENT '七牛 AccessKeyAES加密' AFTER `qcloud_domain`"},
{"qiniu_secret_key", "ALTER TABLE `oss_configs` ADD COLUMN `qiniu_secret_key` varchar(255) NOT NULL DEFAULT '' COMMENT '七牛 SecretKeyAES加密' AFTER `qiniu_access_key`"},
{"qiniu_bucket", "ALTER TABLE `oss_configs` ADD COLUMN `qiniu_bucket` varchar(255) NOT NULL DEFAULT '' COMMENT '七牛 Bucket' AFTER `qiniu_secret_key`"},
{"qiniu_region", "ALTER TABLE `oss_configs` ADD COLUMN `qiniu_region` varchar(255) NOT NULL DEFAULT '' COMMENT '七牛 Region' AFTER `qiniu_bucket`"},
{"qiniu_domain", "ALTER TABLE `oss_configs` ADD COLUMN `qiniu_domain` varchar(255) NOT NULL DEFAULT '' COMMENT '七牛自定义域名' AFTER `qiniu_region`"},
}
for _, col := range columns {
if !columnExists("oss_configs", col.name) {
log.Printf("Adding oss_configs.%s...", col.name)
execSQL(col.ddl)
}
}
}
// ensurePptTemplateColumnComments 为已存在但缺少字段备注的 ppt_templates 表补全 COMMENT
func ensurePptTemplateColumnComments() {
if !tableExists("ppt_templates") {

View File

@@ -0,0 +1,146 @@
-- ============================================================
-- OSS 配置表专用字段迁移脚本
-- 文件server/scripts/migrate_oss_configs.sql
-- 说明:可重复执行(幂等),为 oss_configs 补齐阿里云/腾讯云/七牛专用列
-- 用法mysql -u root -p your_database < scripts/migrate_oss_configs.sql
-- ============================================================
SET NAMES utf8mb4;
-- 阿里云 OSS
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'oss_configs' AND COLUMN_NAME = 'oss_access_key_id'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `oss_configs` ADD COLUMN `oss_access_key_id` varchar(255) NOT NULL DEFAULT '''' COMMENT ''阿里云 AccessKeyIdAES加密'' AFTER `domain`',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'oss_configs' AND COLUMN_NAME = 'oss_access_key_secret'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `oss_configs` ADD COLUMN `oss_access_key_secret` varchar(255) NOT NULL DEFAULT '''' COMMENT ''阿里云 AccessKeySecretAES加密'' AFTER `oss_access_key_id`',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'oss_configs' AND COLUMN_NAME = 'oss_endpoint'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `oss_configs` ADD COLUMN `oss_endpoint` varchar(255) NOT NULL DEFAULT '''' COMMENT ''阿里云 Endpoint'' AFTER `oss_access_key_secret`',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'oss_configs' AND COLUMN_NAME = 'oss_bucket'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `oss_configs` ADD COLUMN `oss_bucket` varchar(255) NOT NULL DEFAULT '''' COMMENT ''阿里云 Bucket'' AFTER `oss_endpoint`',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'oss_configs' AND COLUMN_NAME = 'oss_domain'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `oss_configs` ADD COLUMN `oss_domain` varchar(255) NOT NULL DEFAULT '''' COMMENT ''阿里云自定义域名'' AFTER `oss_bucket`',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 腾讯云 COS
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'oss_configs' AND COLUMN_NAME = 'qcloud_secret_id'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `oss_configs` ADD COLUMN `qcloud_secret_id` varchar(255) NOT NULL DEFAULT '''' COMMENT ''腾讯云 SecretIdAES加密'' AFTER `oss_domain`',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'oss_configs' AND COLUMN_NAME = 'qcloud_secret_key'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `oss_configs` ADD COLUMN `qcloud_secret_key` varchar(255) NOT NULL DEFAULT '''' COMMENT ''腾讯云 SecretKeyAES加密'' AFTER `qcloud_secret_id`',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'oss_configs' AND COLUMN_NAME = 'qcloud_region'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `oss_configs` ADD COLUMN `qcloud_region` varchar(255) NOT NULL DEFAULT '''' COMMENT ''腾讯云 Region'' AFTER `qcloud_secret_key`',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'oss_configs' AND COLUMN_NAME = 'qcloud_bucket'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `oss_configs` ADD COLUMN `qcloud_bucket` varchar(255) NOT NULL DEFAULT '''' COMMENT ''腾讯云 Bucket'' AFTER `qcloud_region`',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'oss_configs' AND COLUMN_NAME = 'qcloud_domain'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `oss_configs` ADD COLUMN `qcloud_domain` varchar(255) NOT NULL DEFAULT '''' COMMENT ''腾讯云自定义域名'' AFTER `qcloud_bucket`',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 七牛云
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'oss_configs' AND COLUMN_NAME = 'qiniu_access_key'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `oss_configs` ADD COLUMN `qiniu_access_key` varchar(255) NOT NULL DEFAULT '''' COMMENT ''七牛 AccessKeyAES加密'' AFTER `qcloud_domain`',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'oss_configs' AND COLUMN_NAME = 'qiniu_secret_key'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `oss_configs` ADD COLUMN `qiniu_secret_key` varchar(255) NOT NULL DEFAULT '''' COMMENT ''七牛 SecretKeyAES加密'' AFTER `qiniu_access_key`',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'oss_configs' AND COLUMN_NAME = 'qiniu_bucket'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `oss_configs` ADD COLUMN `qiniu_bucket` varchar(255) NOT NULL DEFAULT '''' COMMENT ''七牛 Bucket'' AFTER `qiniu_secret_key`',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'oss_configs' AND COLUMN_NAME = 'qiniu_region'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `oss_configs` ADD COLUMN `qiniu_region` varchar(255) NOT NULL DEFAULT '''' COMMENT ''七牛 Region'' AFTER `qiniu_bucket`',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'oss_configs' AND COLUMN_NAME = 'qiniu_domain'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `oss_configs` ADD COLUMN `qiniu_domain` varchar(255) NOT NULL DEFAULT '''' COMMENT ''七牛自定义域名'' AFTER `qiniu_region`',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@@ -4,51 +4,59 @@ import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"io"
"os"
"strings"
)
// AESKey 从环境变量或配置中获取,这里使用默认密钥(生产环境应该从配置读取
var defaultAESKey = []byte("your-32-byte-secret-key-here!!") // 32 bytes for AES-256
// defaultAESKey 必须精确为 16/24/32 字节;此处为 AES-25632 字节
var defaultAESKey = []byte("art-code-blog-aes256-key-32b!!!!") // len == 32
// GetAESKey 获取AES密钥(应该从配置文件或环境变量读取)
// GetAESKey 获取 AES 密钥:优先 AES_ENCRYPTION_KEY否则使用默认密钥长度非法时用 SHA-256 规范化为 32 字节
func GetAESKey() []byte {
// TODO: 从配置文件或环境变量读取密钥
// key := os.Getenv("AES_ENCRYPTION_KEY")
// if key == "" {
// return defaultAESKey
// }
// return []byte(key)
return defaultAESKey
key := strings.TrimSpace(os.Getenv("AES_ENCRYPTION_KEY"))
if key == "" {
return normalizeAESKey(defaultAESKey)
}
return normalizeAESKey([]byte(key))
}
func normalizeAESKey(key []byte) []byte {
switch len(key) {
case 16, 24, 32:
return key
default:
sum := sha256.Sum256(key)
out := make([]byte, 32)
copy(out, sum[:])
return out
}
}
// EncryptAES 使用AES-256-GCM加密数据
func EncryptAES(plaintext string) (string, error) {
key := GetAESKey()
// Create cipher block
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
// Create GCM
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
// Create nonce
nonce := make([]byte, aesGCM.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
// Encrypt
ciphertext := aesGCM.Seal(nonce, nonce, []byte(plaintext), nil)
// Encode to base64
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
@@ -56,25 +64,21 @@ func EncryptAES(plaintext string) (string, error) {
func DecryptAES(encrypted string) (string, error) {
key := GetAESKey()
// Decode from base64
ciphertext, err := base64.StdEncoding.DecodeString(encrypted)
if err != nil {
return "", err
}
// Create cipher block
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
// Create GCM
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
// Extract nonce
nonceSize := aesGCM.NonceSize()
if len(ciphertext) < nonceSize {
return "", errors.New("ciphertext too short")
@@ -82,7 +86,6 @@ func DecryptAES(encrypted string) (string, error) {
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
// Decrypt
plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err

View File

@@ -3,6 +3,8 @@ package utils
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"mime/multipart"
@@ -64,6 +66,63 @@ type OSSUploader interface {
Delete(filePath string) error
}
// NormalizeOSSEndpoint 补全 endpoint 协议前缀
func NormalizeOSSEndpoint(endpoint string) string {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return ""
}
if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") {
return "https://" + endpoint
}
return endpoint
}
// LoadAliyunConfigFromEnv 从环境变量加载阿里云 OSS 配置;前 4 项齐全返回 true
func LoadAliyunConfigFromEnv() (*OSSConfig, bool) {
accessKeyID := strings.TrimSpace(os.Getenv("OSS_ACCESS_KEY_ID"))
accessKeySecret := strings.TrimSpace(os.Getenv("OSS_ACCESS_KEY_SECRET"))
endpoint := NormalizeOSSEndpoint(os.Getenv("OSS_ENDPOINT"))
bucket := strings.TrimSpace(os.Getenv("OSS_BUCKET"))
domain := strings.TrimSpace(os.Getenv("OSS_DOMAIN"))
if accessKeyID == "" || accessKeySecret == "" || endpoint == "" || bucket == "" {
return nil, false
}
return &OSSConfig{
StorageType: string(StorageAliyun),
OSSAccessKeyID: accessKeyID,
OSSAccessKeySecret: accessKeySecret,
OSSEndpoint: endpoint,
OSSBucket: bucket,
OSSDomain: domain,
}, true
}
// HasAliyunEnvConfig 环境变量中是否具备完整的阿里云 OSS 配置
func HasAliyunEnvConfig() bool {
_, ok := LoadAliyunConfigFromEnv()
return ok
}
// BuildBlogObjectKey 生成博客上传对象键blog/{YYYYMMDD}/{随机名}{ext}
func BuildBlogObjectKey(originalFilename string) string {
ext := strings.ToLower(filepath.Ext(originalFilename))
randomName := randomHex(16)
dateDir := time.Now().Format("20060102")
return fmt.Sprintf("blog/%s/%s%s", dateDir, randomName, ext)
}
func randomHex(nBytes int) string {
buf := make([]byte, nBytes)
if _, err := rand.Read(buf); err != nil {
// 退化到时间戳,仍保证文件名唯一性
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(buf)
}
// GetOSSUploader 根据存储类型获取上传器
func GetOSSUploader(config *OSSConfig) (OSSUploader, error) {
switch StorageType(config.StorageType) {
@@ -158,6 +217,7 @@ func GetOSSUploader(config *OSSConfig) (OSSUploader, error) {
}
endpoint = fmt.Sprintf("https://oss-%s.aliyuncs.com", region)
}
endpoint = NormalizeOSSEndpoint(endpoint)
client, err := oss.New(endpoint, accessKeyID, accessKeySecret)
if err != nil {
@@ -262,39 +322,25 @@ type LocalUploader struct {
// Upload 上传文件到本地
func (l *LocalUploader) Upload(file multipart.File, filename string, size int64) (string, string, error) {
// 生成唯一文件名
ext := filepath.Ext(filename)
timestamp := time.Now().Unix()
randomStr := fmt.Sprintf("%d", timestamp)
newFilename := fmt.Sprintf("%s_%s%s", strings.TrimSuffix(filename, ext), randomStr, ext)
objectKey := BuildBlogObjectKey(filename)
filePath := filepath.Join(l.BasePath, filepath.FromSlash(objectKey))
uploadDir := filepath.Dir(filePath)
// 按日期创建目录
dateDir := time.Now().Format("2006/01/02")
uploadDir := filepath.Join(l.BasePath, dateDir)
// 创建目录
if err := os.MkdirAll(uploadDir, 0755); err != nil {
return "", "", fmt.Errorf("failed to create upload directory: %v", err)
}
// 完整文件路径
filePath := filepath.Join(uploadDir, newFilename)
// 创建目标文件
dst, err := os.Create(filePath)
if err != nil {
return "", "", fmt.Errorf("failed to create file: %v", err)
}
defer dst.Close()
// 复制文件内容
if _, err := io.Copy(dst, file); err != nil {
return "", "", fmt.Errorf("failed to copy file: %v", err)
}
// 生成访问URL
fileURL := fmt.Sprintf("%s/%s/%s", l.BaseURL, dateDir, newFilename)
fileURL := fmt.Sprintf("%s/%s", strings.TrimSuffix(l.BaseURL, "/"), objectKey)
return filePath, fileURL, nil
}
@@ -326,30 +372,19 @@ type AliyunUploader struct {
// Upload 上传文件到阿里云OSS
func (a *AliyunUploader) Upload(file multipart.File, filename string, size int64) (string, string, error) {
// 生成唯一文件名
ext := filepath.Ext(filename)
timestamp := time.Now().Unix()
randomStr := fmt.Sprintf("%d", timestamp)
newFilename := fmt.Sprintf("%s_%s%s", strings.TrimSuffix(filename, ext), randomStr, ext)
objectKey := BuildBlogObjectKey(filename)
// 按日期创建目录
dateDir := time.Now().Format("2006/01/02")
objectKey := fmt.Sprintf("%s/%s", dateDir, newFilename)
// 上传文件
err := a.Bucket.PutObject(objectKey, file)
err := a.Bucket.PutObject(objectKey, file, oss.ObjectACL(oss.ACLPublicRead))
if err != nil {
return "", "", fmt.Errorf("failed to upload to aliyun oss: %v", err)
}
// 生成访问URL
var fileURL string
if a.Domain != "" {
fileURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(a.Domain, "/"), objectKey)
fileURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(NormalizeOSSEndpoint(a.Domain), "/"), objectKey)
} else {
// 从endpoint中提取region格式为 https://oss-region.aliyuncs.com
endpoint := a.Bucket.Client.Config.Endpoint
fileURL = fmt.Sprintf("%s/%s", endpoint, objectKey)
endpoint := NormalizeOSSEndpoint(a.Bucket.Client.Config.Endpoint)
fileURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(endpoint, "/"), objectKey)
}
return objectKey, fileURL, nil
@@ -364,6 +399,42 @@ func (a *AliyunUploader) Delete(objectKey string) error {
return nil
}
// TestOSSConnection 验证云存储配置是否可用ListObjects / 等价只读探测)
func TestOSSConnection(config *OSSConfig) error {
uploader, err := GetOSSUploader(config)
if err != nil {
return err
}
switch u := uploader.(type) {
case *LocalUploader:
if err := os.MkdirAll(u.BasePath, 0755); err != nil {
return fmt.Errorf("local storage path not writable: %v", err)
}
return nil
case *AliyunUploader:
_, err := u.Bucket.ListObjects(oss.MaxKeys(1))
if err != nil {
return fmt.Errorf("aliyun oss connection failed: %v", err)
}
return nil
case *QCloudUploader:
_, _, err := u.Client.Bucket.Get(context.Background(), &cos.BucketGetOptions{MaxKeys: 1})
if err != nil {
return fmt.Errorf("qcloud cos connection failed: %v", err)
}
return nil
case *QiniuUploader:
_, _, _, _, err := u.BucketManager.ListFiles(u.Bucket, "", "", "", 1)
if err != nil {
return fmt.Errorf("qiniu connection failed: %v", err)
}
return nil
default:
return fmt.Errorf("unsupported storage type: %s", config.StorageType)
}
}
// QCloudUploader 腾讯云COS上传器
type QCloudUploader struct {
Client *cos.Client
@@ -373,29 +444,18 @@ type QCloudUploader struct {
// Upload 上传文件到腾讯云COS
func (q *QCloudUploader) Upload(file multipart.File, filename string, size int64) (string, string, error) {
// 生成唯一文件名
ext := filepath.Ext(filename)
timestamp := time.Now().Unix()
randomStr := fmt.Sprintf("%d", timestamp)
newFilename := fmt.Sprintf("%s_%s%s", strings.TrimSuffix(filename, ext), randomStr, ext)
objectKey := BuildBlogObjectKey(filename)
// 按日期创建目录
dateDir := time.Now().Format("2006/01/02")
objectKey := fmt.Sprintf("%s/%s", dateDir, newFilename)
// 上传文件
_, err := q.Client.Object.Put(context.Background(), objectKey, file, nil)
if err != nil {
return "", "", fmt.Errorf("failed to upload to qcloud cos: %v", err)
}
// 生成访问URL
var fileURL string
if q.Domain != "" {
fileURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(q.Domain, "/"), objectKey)
} else {
// 使用BucketURL生成URL
fileURL = fmt.Sprintf("%s/%s", q.Client.BaseURL.BucketURL.String(), objectKey)
fileURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(q.Client.BaseURL.BucketURL.String(), "/"), objectKey)
}
return objectKey, fileURL, nil
@@ -421,17 +481,8 @@ type QiniuUploader struct {
// Upload 上传文件到七牛云
func (q *QiniuUploader) Upload(file multipart.File, filename string, size int64) (string, string, error) {
// 生成唯一文件名
ext := filepath.Ext(filename)
timestamp := time.Now().Unix()
randomStr := fmt.Sprintf("%d", timestamp)
newFilename := fmt.Sprintf("%s_%s%s", strings.TrimSuffix(filename, ext), randomStr, ext)
key := BuildBlogObjectKey(filename)
// 按日期创建目录
dateDir := time.Now().Format("2006/01/02")
key := fmt.Sprintf("%s/%s", dateDir, newFilename)
// 生成上传凭证
putPolicy := storage.PutPolicy{
Scope: q.Bucket,
}