From 3f179e568267e30436d83647d6ce5fc490d0ce33 Mon Sep 17 00:00:00 2001 From: zpc Date: Fri, 20 Feb 2026 23:21:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(upload):=20=E5=A4=B4=E5=83=8F=E7=9B=B4?= =?UTF-8?q?=E4=BC=A0COS=20+=20=E4=BF=AE=E5=A4=8D=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=B5=84=E6=96=99=E6=8E=A5=E5=8F=A3404?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - Model层新增UploadSetting配置模型 - Core层新增IUploadConfigService/UploadConfigService,从Admin库读取COS配置生成预签名URL - Api层新增UploadController,提供POST /api/upload/presignedUrl接口 - ServiceModule注册UploadConfigService服务 前端: - api/user.js修复接口路径:updateProfileupdate_userinfo,upload/imageupload/presignedUrl - 新增utils/upload.js COS直传工具(获取预签名URL直传COS返回文件URL) - 个人资料页改为:选图直传COS保存时提交headimg URL到update_userinfo --- .../Controllers/UploadController.cs | 76 +++++++ .../Interfaces/IUploadConfigService.cs | 37 ++++ .../Services/UploadConfigService.cs | 187 ++++++++++++++++++ .../Modules/ServiceModule.cs | 11 ++ .../Models/Config/UploadSetting.cs | 42 ++++ uniapp/api/user.js | 58 ++---- uniapp/pages/mine/profile/index.vue | 128 +++++------- uniapp/utils/upload.js | 74 +++++++ 8 files changed, 490 insertions(+), 123 deletions(-) create mode 100644 server/MiAssessment/src/MiAssessment.Api/Controllers/UploadController.cs create mode 100644 server/MiAssessment/src/MiAssessment.Core/Interfaces/IUploadConfigService.cs create mode 100644 server/MiAssessment/src/MiAssessment.Core/Services/UploadConfigService.cs create mode 100644 server/MiAssessment/src/MiAssessment.Model/Models/Config/UploadSetting.cs create mode 100644 uniapp/utils/upload.js diff --git a/server/MiAssessment/src/MiAssessment.Api/Controllers/UploadController.cs b/server/MiAssessment/src/MiAssessment.Api/Controllers/UploadController.cs new file mode 100644 index 0000000..fc01424 --- /dev/null +++ b/server/MiAssessment/src/MiAssessment.Api/Controllers/UploadController.cs @@ -0,0 +1,76 @@ +using System.Security.Claims; +using MiAssessment.Core.Interfaces; +using MiAssessment.Model.Base; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace MiAssessment.Api.Controllers; + +/// +/// 上传控制器 - 提供COS预签名URL供小程序直传 +/// +[ApiController] +[Route("api")] +public class UploadController : ControllerBase +{ + private readonly IUploadConfigService _uploadConfigService; + private readonly ILogger _logger; + + public UploadController( + IUploadConfigService uploadConfigService, + ILogger logger) + { + _uploadConfigService = uploadConfigService; + _logger = logger; + } + + /// + /// 获取COS预签名上传URL + /// POST /api/upload/presignedUrl + /// 小程序端拿到URL后直传COS,上传完成后将fileUrl提交给update_userinfo + /// + [HttpPost("upload/presignedUrl")] + [Authorize] + public async Task> GetPresignedUrl([FromBody] GetPresignedUrlRequest request) + { + if (string.IsNullOrWhiteSpace(request.FileName)) + { + return ApiResponse.Fail("文件名不能为空"); + } + + try + { + var result = await _uploadConfigService.GetPresignedUploadUrlAsync( + request.FileName, + request.ContentType ?? "image/png"); + + if (result == null) + { + return ApiResponse.Fail("当前不支持COS直传,请联系管理员配置上传设置"); + } + + return ApiResponse.Success(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "获取预签名URL失败"); + return ApiResponse.Fail("获取上传地址失败"); + } + } +} + +/// +/// 获取预签名URL请求 +/// +public class GetPresignedUrlRequest +{ + /// + /// 原始文件名 + /// + public string FileName { get; set; } = string.Empty; + + /// + /// 文件MIME类型 + /// + public string? ContentType { get; set; } +} diff --git a/server/MiAssessment/src/MiAssessment.Core/Interfaces/IUploadConfigService.cs b/server/MiAssessment/src/MiAssessment.Core/Interfaces/IUploadConfigService.cs new file mode 100644 index 0000000..3a84fb2 --- /dev/null +++ b/server/MiAssessment/src/MiAssessment.Core/Interfaces/IUploadConfigService.cs @@ -0,0 +1,37 @@ +namespace MiAssessment.Core.Interfaces; + +/// +/// 上传配置服务接口 +/// 从Admin库读取上传配置,生成COS预签名URL +/// +public interface IUploadConfigService +{ + /// + /// 获取COS预签名上传URL + /// + /// 原始文件名 + /// 文件MIME类型 + /// 预签名URL信息,null表示不支持COS直传 + Task GetPresignedUploadUrlAsync(string fileName, string contentType); +} + +/// +/// 预签名上传信息 +/// +public class PresignedUploadInfo +{ + /// + /// 预签名上传URL + /// + public string UploadUrl { get; set; } = string.Empty; + + /// + /// 文件最终访问URL + /// + public string FileUrl { get; set; } = string.Empty; + + /// + /// URL过期时间(秒) + /// + public int ExpiresIn { get; set; } +} diff --git a/server/MiAssessment/src/MiAssessment.Core/Services/UploadConfigService.cs b/server/MiAssessment/src/MiAssessment.Core/Services/UploadConfigService.cs new file mode 100644 index 0000000..e818527 --- /dev/null +++ b/server/MiAssessment/src/MiAssessment.Core/Services/UploadConfigService.cs @@ -0,0 +1,187 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using MiAssessment.Core.Interfaces; +using MiAssessment.Model.Data; +using MiAssessment.Model.Models.Config; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace MiAssessment.Core.Services; + +/// +/// 上传配置服务实现 +/// 从Admin库读取COS配置,生成预签名URL供小程序直传 +/// +public class UploadConfigService : IUploadConfigService +{ + private readonly AdminConfigReadDbContext _adminConfigDbContext; + private readonly IRedisService _redisService; + private readonly ILogger _logger; + + private const string CacheKey = "upload:setting"; + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); + private const string UploadBasePath = "uploads"; + private const int DefaultExpiresInSeconds = 600; + + public UploadConfigService( + AdminConfigReadDbContext adminConfigDbContext, + IRedisService redisService, + ILogger logger) + { + _adminConfigDbContext = adminConfigDbContext; + _redisService = redisService; + _logger = logger; + } + + /// + public async Task GetPresignedUploadUrlAsync(string fileName, string contentType) + { + var setting = await GetUploadSettingAsync(); + if (setting == null || setting.Type != "3") + { + _logger.LogWarning("上传配置不支持COS直传,当前类型: {Type}", setting?.Type); + return null; + } + + var validationError = ValidateConfig(setting); + if (validationError != null) + { + _logger.LogWarning("COS配置验证失败: {Error}", validationError); + return null; + } + + // 生成日期目录和唯一文件名 + var now = DateTime.Now; + var datePath = $"{now.Year}/{now.Month:D2}/{now.Day:D2}"; + var extension = Path.GetExtension(fileName).ToLowerInvariant(); + var timestamp = now.ToString("yyyyMMddHHmmssfff"); + var guid = Guid.NewGuid().ToString("N")[..8]; + var uniqueFileName = $"{timestamp}_{guid}{extension}"; + var objectKey = $"{UploadBasePath}/{datePath}/{uniqueFileName}"; + + // 生成预签名URL + var presignedUrl = GeneratePresignedUrl(setting, objectKey, "PUT", contentType, DefaultExpiresInSeconds); + var fileUrl = GenerateAccessUrl(setting.Domain!, objectKey); + + _logger.LogInformation("生成预签名URL成功: {ObjectKey}", objectKey); + + return new PresignedUploadInfo + { + UploadUrl = presignedUrl, + FileUrl = fileUrl, + ExpiresIn = DefaultExpiresInSeconds + }; + } + + /// + /// 从数据库读取上传配置(带缓存) + /// + private async Task GetUploadSettingAsync() + { + // 尝试从缓存读取 + var cachedJson = await _redisService.GetStringAsync(CacheKey); + if (!string.IsNullOrEmpty(cachedJson)) + { + try + { + return JsonSerializer.Deserialize(cachedJson, JsonOptions); + } + catch { } + } + + try + { + var configValue = await _adminConfigDbContext.AdminConfigs + .Where(c => c.ConfigKey == "upload_setting") + .Select(c => c.ConfigValue) + .FirstOrDefaultAsync(); + + if (string.IsNullOrEmpty(configValue)) + { + _logger.LogWarning("未找到upload_setting配置"); + return null; + } + + var setting = JsonSerializer.Deserialize(configValue, JsonOptions); + if (setting != null) + { + await _redisService.SetStringAsync(CacheKey, configValue, CacheDuration); + } + return setting; + } + catch (Exception ex) + { + _logger.LogError(ex, "读取上传配置失败"); + return null; + } + } + + /// + /// 生成腾讯云COS预签名URL + /// + private static string GeneratePresignedUrl(UploadSetting setting, string objectKey, string httpMethod, string contentType, int expiresInSeconds) + { + var startTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var endTime = startTime + expiresInSeconds; + var keyTime = $"{startTime};{endTime}"; + + var host = $"{setting.Bucket}.cos.{setting.Region}.myqcloud.com"; + var urlPath = $"/{objectKey}"; + + // 1. SignKey + var signKey = HmacSha1(setting.AccessKeySecret!, keyTime); + + // 2. HttpString + var httpString = $"{httpMethod.ToLowerInvariant()}\n{urlPath}\n\nhost={host.ToLowerInvariant()}\n"; + + // 3. StringToSign + var sha1HttpString = Sha1Hash(httpString); + var stringToSign = $"sha1\n{keyTime}\n{sha1HttpString}\n"; + + // 4. Signature + var signature = HmacSha1(signKey, stringToSign); + + // 5. Authorization + var authorization = $"q-sign-algorithm=sha1&q-ak={setting.AccessKeyId}&q-sign-time={keyTime}&q-key-time={keyTime}&q-header-list=host&q-url-param-list=&q-signature={signature}"; + + return $"https://{host}{urlPath}?{authorization}"; + } + + private static string GenerateAccessUrl(string domain, string objectKey) + { + var normalizedDomain = domain.TrimEnd('/'); + if (!normalizedDomain.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && + !normalizedDomain.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + normalizedDomain = $"https://{normalizedDomain}"; + } + var normalizedKey = objectKey.StartsWith('/') ? objectKey : $"/{objectKey}"; + return $"{normalizedDomain}{normalizedKey}"; + } + + private static string HmacSha1(string key, string data) + { + using var hmac = new HMACSHA1(Encoding.UTF8.GetBytes(key)); + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(data)); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + + private static string Sha1Hash(string data) + { + var hash = SHA1.HashData(Encoding.UTF8.GetBytes(data)); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + + private static string? ValidateConfig(UploadSetting setting) + { + if (string.IsNullOrWhiteSpace(setting.Bucket)) return "Bucket不能为空"; + if (string.IsNullOrWhiteSpace(setting.Region)) return "Region不能为空"; + if (string.IsNullOrWhiteSpace(setting.AccessKeyId)) return "AccessKeyId不能为空"; + if (string.IsNullOrWhiteSpace(setting.AccessKeySecret)) return "AccessKeySecret不能为空"; + if (string.IsNullOrWhiteSpace(setting.Domain)) return "Domain不能为空"; + return null; + } + + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; +} diff --git a/server/MiAssessment/src/MiAssessment.Infrastructure/Modules/ServiceModule.cs b/server/MiAssessment/src/MiAssessment.Infrastructure/Modules/ServiceModule.cs index 92f6a62..de49e37 100644 --- a/server/MiAssessment/src/MiAssessment.Infrastructure/Modules/ServiceModule.cs +++ b/server/MiAssessment/src/MiAssessment.Infrastructure/Modules/ServiceModule.cs @@ -191,6 +191,17 @@ public class ServiceModule : Module return new SystemService(configService, logger); }).As().InstancePerLifetimeScope(); + // ========== 上传模块服务注册 ========== + + // 注册上传配置服务(从Admin库读取COS配置,生成预签名URL) + builder.Register(c => + { + var adminConfigDbContext = c.Resolve(); + var redisService = c.Resolve(); + var logger = c.Resolve>(); + return new UploadConfigService(adminConfigDbContext, redisService, logger); + }).As().InstancePerLifetimeScope(); + // ========== 小程序团队模块服务注册 ========== // 注册团队服务 diff --git a/server/MiAssessment/src/MiAssessment.Model/Models/Config/UploadSetting.cs b/server/MiAssessment/src/MiAssessment.Model/Models/Config/UploadSetting.cs new file mode 100644 index 0000000..bffd9ce --- /dev/null +++ b/server/MiAssessment/src/MiAssessment.Model/Models/Config/UploadSetting.cs @@ -0,0 +1,42 @@ +namespace MiAssessment.Model.Models.Config; + +/// +/// 上传配置(Model层,供Core/Api项目使用) +/// +public class UploadSetting +{ + /// + /// 存储类型 1本地 2阿里云 3腾讯云 + /// + public string Type { get; set; } = "1"; + + /// + /// 腾讯云AppId + /// + public string? AppId { get; set; } + + /// + /// 存储桶名称 + /// + public string? Bucket { get; set; } + + /// + /// 地域 + /// + public string? Region { get; set; } + + /// + /// SecretId + /// + public string? AccessKeyId { get; set; } + + /// + /// SecretKey + /// + public string? AccessKeySecret { get; set; } + + /// + /// 访问域名 + /// + public string? Domain { get; set; } +} diff --git a/uniapp/api/user.js b/uniapp/api/user.js index de027dc..59d8110 100644 --- a/uniapp/api/user.js +++ b/uniapp/api/user.js @@ -6,6 +6,7 @@ import { get, post } from './request' /** * 获取当前登录用户信息 + * GET /api/userInfo * @returns {Promise} 用户信息 */ export function getUserInfo() { @@ -13,55 +14,30 @@ export function getUserInfo() { } /** - * 获取用户详情 - */ -export async function getUserDetail(userId) { - const response = await post('/users/detail', { userId }) - return response -} - -/** - * 获取用户资料 - * @returns {Promise} - */ -export async function getProfile() { - const response = await get('/user/getProfile') - return response -} - -/** - * 更新用户资料 - * @param {Object} data - 用户资料 + * 更新用户信息(昵称、头像等) + * POST /api/update_userinfo + * @param {Object} data - 更新数据 * @param {string} [data.nickname] - 昵称 + * @param {string} [data.headimg] - 头像URL(COS地址) * @returns {Promise} */ -export async function updateProfile(data) { - const response = await post('/user/updateProfile', data) - return response +export function updateUserInfo(data) { + return post('/update_userinfo', data) } /** - * 更新用户头像 - * @param {string} avatar - 头像URL - * @returns {Promise} + * 获取COS预签名上传URL + * POST /api/upload/presignedUrl + * @param {string} fileName - 文件名 + * @param {string} [contentType] - MIME类型 + * @returns {Promise} { uploadUrl, fileUrl, expiresIn } */ -export async function updateAvatar(avatar) { - const response = await post('/user/updateAvatar', { avatar }) - return response -} - -/** - * 更新用户昵称 - */ -export async function updateNickname(nickname) { - const response = await post('/users/nickname', { nickname }) - return response +export function getPresignedUploadUrl(fileName, contentType = 'image/png') { + return post('/upload/presignedUrl', { fileName, contentType }) } export default { - getUserDetail, - getProfile, - updateProfile, - updateAvatar, - updateNickname + getUserInfo, + updateUserInfo, + getPresignedUploadUrl } diff --git a/uniapp/pages/mine/profile/index.vue b/uniapp/pages/mine/profile/index.vue index 36c13ec..b88c94a 100644 --- a/uniapp/pages/mine/profile/index.vue +++ b/uniapp/pages/mine/profile/index.vue @@ -7,7 +7,7 @@ @@ -32,7 +32,7 @@ @@ -56,14 +56,14 @@