feat(upload): 头像直传COS + 修复用户资料接口404

后端:
- 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
This commit is contained in:
zpc 2026-02-20 23:21:56 +08:00
parent 5454ac5f64
commit 3f179e5682
8 changed files with 490 additions and 123 deletions

View File

@ -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;
/// <summary>
/// 上传控制器 - 提供COS预签名URL供小程序直传
/// </summary>
[ApiController]
[Route("api")]
public class UploadController : ControllerBase
{
private readonly IUploadConfigService _uploadConfigService;
private readonly ILogger<UploadController> _logger;
public UploadController(
IUploadConfigService uploadConfigService,
ILogger<UploadController> logger)
{
_uploadConfigService = uploadConfigService;
_logger = logger;
}
/// <summary>
/// 获取COS预签名上传URL
/// POST /api/upload/presignedUrl
/// 小程序端拿到URL后直传COS上传完成后将fileUrl提交给update_userinfo
/// </summary>
[HttpPost("upload/presignedUrl")]
[Authorize]
public async Task<ApiResponse<PresignedUploadInfo>> GetPresignedUrl([FromBody] GetPresignedUrlRequest request)
{
if (string.IsNullOrWhiteSpace(request.FileName))
{
return ApiResponse<PresignedUploadInfo>.Fail("文件名不能为空");
}
try
{
var result = await _uploadConfigService.GetPresignedUploadUrlAsync(
request.FileName,
request.ContentType ?? "image/png");
if (result == null)
{
return ApiResponse<PresignedUploadInfo>.Fail("当前不支持COS直传请联系管理员配置上传设置");
}
return ApiResponse<PresignedUploadInfo>.Success(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "获取预签名URL失败");
return ApiResponse<PresignedUploadInfo>.Fail("获取上传地址失败");
}
}
}
/// <summary>
/// 获取预签名URL请求
/// </summary>
public class GetPresignedUrlRequest
{
/// <summary>
/// 原始文件名
/// </summary>
public string FileName { get; set; } = string.Empty;
/// <summary>
/// 文件MIME类型
/// </summary>
public string? ContentType { get; set; }
}

View File

@ -0,0 +1,37 @@
namespace MiAssessment.Core.Interfaces;
/// <summary>
/// 上传配置服务接口
/// 从Admin库读取上传配置生成COS预签名URL
/// </summary>
public interface IUploadConfigService
{
/// <summary>
/// 获取COS预签名上传URL
/// </summary>
/// <param name="fileName">原始文件名</param>
/// <param name="contentType">文件MIME类型</param>
/// <returns>预签名URL信息null表示不支持COS直传</returns>
Task<PresignedUploadInfo?> GetPresignedUploadUrlAsync(string fileName, string contentType);
}
/// <summary>
/// 预签名上传信息
/// </summary>
public class PresignedUploadInfo
{
/// <summary>
/// 预签名上传URL
/// </summary>
public string UploadUrl { get; set; } = string.Empty;
/// <summary>
/// 文件最终访问URL
/// </summary>
public string FileUrl { get; set; } = string.Empty;
/// <summary>
/// URL过期时间
/// </summary>
public int ExpiresIn { get; set; }
}

View File

@ -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;
/// <summary>
/// 上传配置服务实现
/// 从Admin库读取COS配置生成预签名URL供小程序直传
/// </summary>
public class UploadConfigService : IUploadConfigService
{
private readonly AdminConfigReadDbContext _adminConfigDbContext;
private readonly IRedisService _redisService;
private readonly ILogger<UploadConfigService> _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<UploadConfigService> logger)
{
_adminConfigDbContext = adminConfigDbContext;
_redisService = redisService;
_logger = logger;
}
/// <inheritdoc />
public async Task<PresignedUploadInfo?> 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
};
}
/// <summary>
/// 从数据库读取上传配置(带缓存)
/// </summary>
private async Task<UploadSetting?> GetUploadSettingAsync()
{
// 尝试从缓存读取
var cachedJson = await _redisService.GetStringAsync(CacheKey);
if (!string.IsNullOrEmpty(cachedJson))
{
try
{
return JsonSerializer.Deserialize<UploadSetting>(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<UploadSetting>(configValue, JsonOptions);
if (setting != null)
{
await _redisService.SetStringAsync(CacheKey, configValue, CacheDuration);
}
return setting;
}
catch (Exception ex)
{
_logger.LogError(ex, "读取上传配置失败");
return null;
}
}
/// <summary>
/// 生成腾讯云COS预签名URL
/// </summary>
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 };
}

View File

@ -191,6 +191,17 @@ public class ServiceModule : Module
return new SystemService(configService, logger);
}).As<ISystemService>().InstancePerLifetimeScope();
// ========== 上传模块服务注册 ==========
// 注册上传配置服务从Admin库读取COS配置生成预签名URL
builder.Register(c =>
{
var adminConfigDbContext = c.Resolve<AdminConfigReadDbContext>();
var redisService = c.Resolve<IRedisService>();
var logger = c.Resolve<ILogger<UploadConfigService>>();
return new UploadConfigService(adminConfigDbContext, redisService, logger);
}).As<IUploadConfigService>().InstancePerLifetimeScope();
// ========== 小程序团队模块服务注册 ==========
// 注册团队服务

View File

@ -0,0 +1,42 @@
namespace MiAssessment.Model.Models.Config;
/// <summary>
/// 上传配置Model层供Core/Api项目使用
/// </summary>
public class UploadSetting
{
/// <summary>
/// 存储类型 1本地 2阿里云 3腾讯云
/// </summary>
public string Type { get; set; } = "1";
/// <summary>
/// 腾讯云AppId
/// </summary>
public string? AppId { get; set; }
/// <summary>
/// 存储桶名称
/// </summary>
public string? Bucket { get; set; }
/// <summary>
/// 地域
/// </summary>
public string? Region { get; set; }
/// <summary>
/// SecretId
/// </summary>
public string? AccessKeyId { get; set; }
/// <summary>
/// SecretKey
/// </summary>
public string? AccessKeySecret { get; set; }
/// <summary>
/// 访问域名
/// </summary>
public string? Domain { get; set; }
}

View File

@ -6,6 +6,7 @@ import { get, post } from './request'
/**
* 获取当前登录用户信息
* GET /api/userInfo
* @returns {Promise<Object>} 用户信息
*/
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<Object>}
*/
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] - 头像URLCOS地址
* @returns {Promise<Object>}
*/
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<Object>}
* 获取COS预签名上传URL
* POST /api/upload/presignedUrl
* @param {string} fileName - 文件名
* @param {string} [contentType] - MIME类型
* @returns {Promise<Object>} { 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
}

View File

@ -7,7 +7,7 @@
<view class="avatar-wrapper">
<image
class="avatar"
:src="userInfo.avatar || '/static/mine/icon-user.png'"
:src="avatarUrl || '/static/mine/icon-user.png'"
mode="aspectFill"
/>
<image class="edit-icon" src="/static/mine/icon-user-icon-edit.png" mode="aspectFit" />
@ -32,7 +32,7 @@
<input
class="form-input readonly"
type="text"
:value="userInfo.uid || '--'"
:value="userStore.uid || '--'"
disabled
/>
</view>
@ -56,14 +56,14 @@
<script setup>
/**
* 个人资料页面
* 展示和修改用户头像昵称
* 头像直传COS昵称通过update_userinfo接口保存
* UID 仅展示不可修改
*/
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useUserStore } from '@/store/user.js'
import { updateProfile, updateAvatar } from '@/api/user.js'
import config from '@/config/index.js'
import { updateUserInfo } from '@/api/user.js'
import { chooseAndUploadImage } from '@/utils/upload.js'
const userStore = useUserStore()
@ -72,112 +72,66 @@ const loading = ref(false)
const saving = ref(false)
const loadingText = ref('加载中...')
// URL + COS
const avatarUrl = ref('')
// COS
const pendingAvatarUrl = ref('')
//
const formData = reactive({
nickname: ''
})
//
const userInfo = computed(() => ({
userId: userStore.userId,
uid: userStore.uid,
nickname: userStore.nickname,
avatar: userStore.avatar
}))
/**
* 初始化表单数据
*/
function initFormData() {
formData.nickname = userStore.nickname || ''
avatarUrl.value = userStore.avatar || ''
pendingAvatarUrl.value = ''
}
/**
* 选择并上传头像
* 选择并上传头像到COS
*/
function handleChangeAvatar() {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
const tempFilePath = res.tempFilePaths[0]
await uploadAvatar(tempFilePath)
},
fail: (err) => {
if (err.errMsg && !err.errMsg.includes('cancel')) {
uni.showToast({ title: '选择图片失败', icon: 'none' })
}
}
})
}
/**
* 上传头像
* @param {string} filePath - 图片临时路径
*/
async function uploadAvatar(filePath) {
async function handleChangeAvatar() {
try {
loading.value = true
loadingText.value = '上传中...'
const uploadRes = await new Promise((resolve, reject) => {
uni.uploadFile({
url: `${config.API_BASE_URL}/upload/image`,
filePath: filePath,
name: 'file',
header: {
'Authorization': `Bearer ${userStore.token}`
},
success: (res) => {
if (res.statusCode === 200) {
try {
resolve(JSON.parse(res.data))
} catch (e) {
reject(new Error('解析响应失败'))
}
} else {
reject(new Error('上传失败'))
}
},
fail: (err) => reject(err)
})
})
const fileUrl = await chooseAndUploadImage()
if (uploadRes.code === 0 && uploadRes.data) {
const avatarUrl = uploadRes.data.url || uploadRes.data
const updateRes = await updateAvatar(avatarUrl)
//
avatarUrl.value = fileUrl
pendingAvatarUrl.value = fileUrl
if (updateRes.code === 0) {
userStore.updateUserInfo({ avatar: avatarUrl })
uni.showToast({ title: '头像更新成功', icon: 'success' })
} else {
throw new Error(updateRes.message || '更新头像失败')
}
} else {
throw new Error(uploadRes.message || '上传失败')
}
uni.showToast({ title: '头像上传成功', icon: 'success' })
} catch (error) {
console.error('上传头像失败:', error)
uni.showToast({ title: error.message || '上传失败', icon: 'none' })
if (error.message !== '用户取消选择') {
console.error('上传头像失败:', error)
uni.showToast({ title: error.message || '上传失败', icon: 'none' })
}
} finally {
loading.value = false
}
}
/**
* 保存资料昵称
* 保存资料
* 提交昵称和头像URL到 POST /api/update_userinfo
*/
async function handleSave() {
const nickname = formData.nickname.trim()
const hasNicknameChange = nickname && nickname !== userStore.nickname
const hasAvatarChange = !!pendingAvatarUrl.value
if (!nickname) {
uni.showToast({ title: '请输入昵称', icon: 'none' })
if (!hasNicknameChange && !hasAvatarChange) {
uni.showToast({ title: '资料未修改', icon: 'none' })
return
}
if (nickname === userStore.nickname) {
uni.showToast({ title: '资料未修改', icon: 'none' })
if (!nickname) {
uni.showToast({ title: '请输入昵称', icon: 'none' })
return
}
@ -186,10 +140,21 @@ async function handleSave() {
loading.value = true
loadingText.value = '保存中...'
const res = await updateProfile({ nickname })
// UpdateUserInfoRequest
const params = {}
if (hasNicknameChange) params.nickname = nickname
if (hasAvatarChange) params.headimg = pendingAvatarUrl.value
const res = await updateUserInfo(params)
if (res.code === 0) {
userStore.updateUserInfo({ nickname })
// store
const updateData = {}
if (hasNicknameChange) updateData.nickname = nickname
if (hasAvatarChange) updateData.avatar = pendingAvatarUrl.value
userStore.updateUserInfo(updateData)
pendingAvatarUrl.value = ''
uni.showToast({ title: '保存成功', icon: 'success' })
} else {
throw new Error(res.message || '保存失败')
@ -217,7 +182,6 @@ onMounted(() => {
<style lang="scss" scoped>
@import '@/styles/variables.scss';
//
$save-btn-color: #F5A623;
$save-btn-active: #E09518;
@ -230,7 +194,7 @@ $save-btn-active: #E09518;
padding: $spacing-lg $spacing-xl;
}
// -
//
.avatar-section {
display: flex;
justify-content: center;

74
uniapp/utils/upload.js Normal file
View File

@ -0,0 +1,74 @@
/**
* COS直传工具
* 通过预签名URL将文件直传到腾讯云COS
*/
import { getPresignedUploadUrl } from '@/api/user.js'
/**
* 选择图片并上传到COS
* @param {Object} [options] - 选项
* @param {number} [options.count=1] - 选择数量
* @param {string[]} [options.sourceType] - 来源类型
* @returns {Promise<string>} 上传后的文件URL
*/
export async function chooseAndUploadImage(options = {}) {
const { count = 1, sourceType = ['album', 'camera'] } = options
// 1. 选择图片
const chooseRes = await new Promise((resolve, reject) => {
uni.chooseImage({
count,
sizeType: ['compressed'],
sourceType,
success: resolve,
fail: (err) => {
if (err.errMsg && err.errMsg.includes('cancel')) {
reject(new Error('用户取消选择'))
} else {
reject(new Error('选择图片失败'))
}
}
})
})
const tempFilePath = chooseRes.tempFilePaths[0]
const fileName = tempFilePath.split('/').pop() || 'image.png'
// 判断文件类型
const ext = fileName.split('.').pop()?.toLowerCase() || 'png'
const mimeMap = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp' }
const contentType = mimeMap[ext] || 'image/png'
// 2. 获取预签名URL
const presignedRes = await getPresignedUploadUrl(fileName, contentType)
if (!presignedRes || presignedRes.code !== 0 || !presignedRes.data) {
throw new Error(presignedRes?.message || '获取上传地址失败')
}
const { uploadUrl, fileUrl } = presignedRes.data
// 3. 直传COS使用PUT请求
await new Promise((resolve, reject) => {
uni.uploadFile({
url: uploadUrl,
filePath: tempFilePath,
name: 'file',
header: {
'Content-Type': contentType
},
success: (res) => {
// COS PUT上传成功返回200
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(res)
} else {
reject(new Error(`上传失败,状态码: ${res.statusCode}`))
}
},
fail: (err) => reject(new Error(err.errMsg || '上传失败'))
})
})
// 4. 返回文件访问URL
return fileUrl
}