diff --git a/admin/.env.development b/admin/.env.development index 4d841c3..a7c01e5 100644 --- a/admin/.env.development +++ b/admin/.env.development @@ -5,3 +5,6 @@ VITE_APP_TITLE=相宜相亲后台管理系统(开发) # API基础地址 VITE_API_BASE_URL=http://localhost:5000/api + +# 静态资源服务器地址(AppApi,用于图片等静态资源) +VITE_STATIC_BASE_URL=http://localhost:5001 diff --git a/admin/.env.production b/admin/.env.production index 603b087..6fb36dc 100644 --- a/admin/.env.production +++ b/admin/.env.production @@ -5,3 +5,6 @@ VITE_APP_TITLE=相宜相亲后台管理系统 # API基础地址 - 生产环境请修改为实际地址 VITE_API_BASE_URL=https://api.example.com + +# 静态资源服务器地址 - 生产环境请修改为实际地址 +VITE_STATIC_BASE_URL=https://static.example.com diff --git a/admin/src/api/config.ts b/admin/src/api/config.ts new file mode 100644 index 0000000..8b9eea4 --- /dev/null +++ b/admin/src/api/config.ts @@ -0,0 +1,22 @@ +import request from '@/utils/request' + +/** + * 获取默认头像配置 + */ +export function getDefaultAvatar() { + return request.get('/admin/config/defaultAvatar') +} + +/** + * 设置默认头像 + */ +export function setDefaultAvatar(avatarUrl: string) { + return request.post('/admin/config/defaultAvatar', { avatarUrl }) +} + +/** + * 获取所有系统配置 + */ +export function getAllConfigs() { + return request.get('/admin/config/all') +} diff --git a/admin/src/api/user.ts b/admin/src/api/user.ts index b3e407c..1d4c452 100644 --- a/admin/src/api/user.ts +++ b/admin/src/api/user.ts @@ -57,3 +57,12 @@ export function getUserStatistics(): Promise { export function createTestUsers(count: number, gender?: number): Promise { return request.post('/admin/users/test-users', { count, gender }) } + +/** + * 删除用户(硬删除) + * @param id 用户ID + * @returns 操作结果 + */ +export function deleteUser(id: number): Promise { + return request.delete(`/admin/users/${id}`) +} diff --git a/admin/src/router/routes.ts b/admin/src/router/routes.ts index e89c861..8afa196 100644 --- a/admin/src/router/routes.ts +++ b/admin/src/router/routes.ts @@ -234,6 +234,12 @@ export const asyncRoutes: RouteRecordRaw[] = [ name: 'OperationLog', component: () => import('@/views/system/log.vue'), meta: { title: '操作日志' } + }, + { + path: 'config', + name: 'SystemConfig', + component: () => import('@/views/system/config.vue'), + meta: { title: '系统配置' } } ] }, diff --git a/admin/src/utils/image.ts b/admin/src/utils/image.ts index 3ecd72e..5c689d8 100644 --- a/admin/src/utils/image.ts +++ b/admin/src/utils/image.ts @@ -2,9 +2,8 @@ * 图片URL处理工具 */ -// 获取后端基础地址(去掉 /api 后缀) -const API_BASE = import.meta.env.VITE_API_BASE_URL || '' -const SERVER_BASE = API_BASE.replace(/\/api$/, '') +// 获取静态资源服务器地址(AppApi,用于图片等静态资源) +const STATIC_BASE = import.meta.env.VITE_STATIC_BASE_URL || 'http://localhost:5001' /** * 处理图片URL,将相对路径转换为完整URL @@ -17,8 +16,8 @@ export function getFullImageUrl(url: string | undefined | null): string { if (url.startsWith('http://') || url.startsWith('https://')) { return url } - // 相对路径,拼接服务器地址 - return `${SERVER_BASE}${url.startsWith('/') ? '' : '/'}${url}` + // 相对路径,拼接静态资源服务器地址 + return `${STATIC_BASE}${url.startsWith('/') ? '' : '/'}${url}` } /** diff --git a/admin/src/views/system/config.vue b/admin/src/views/system/config.vue new file mode 100644 index 0000000..72ea414 --- /dev/null +++ b/admin/src/views/system/config.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/admin/src/views/user/detail.vue b/admin/src/views/user/detail.vue index d27dc96..8f6e1d9 100644 --- a/admin/src/views/user/detail.vue +++ b/admin/src/views/user/detail.vue @@ -89,6 +89,78 @@ const formatMoney = (amount: number) => { return `¥${amount.toFixed(2)}` } +// 学历映射 +const educationMap: Record = { + 1: '高中', + 2: '中专', + 3: '大专', + 4: '本科', + 5: '研究生', + 6: '博士及以上' +} + +// 婚姻状态映射 +const marriageStatusMap: Record = { + 1: '未婚', + 2: '离异未育', + 3: '离异已育' +} + +// 格式化学历数组 +const formatEducation = (education: string | number[] | undefined) => { + if (!education) return '-' + + // 如果是字符串,尝试解析为数组 + let eduArray: number[] = [] + if (typeof education === 'string') { + try { + eduArray = JSON.parse(education) + } catch { + return education || '-' + } + } else if (Array.isArray(education)) { + eduArray = education + } + + if (!eduArray || eduArray.length === 0) return '-' + return eduArray.map(e => educationMap[e] || e).join('、') +} + +// 格式化婚姻状态数组 +const formatMarriageStatus = (status: string | number[] | undefined) => { + if (!status) return '-' + + // 如果是字符串,尝试解析为数组 + let statusArray: number[] = [] + if (typeof status === 'string') { + try { + statusArray = JSON.parse(status) + } catch { + return status || '-' + } + } else if (Array.isArray(status)) { + statusArray = status + } + + if (!statusArray || statusArray.length === 0) return '-' + return statusArray.map(s => marriageStatusMap[s] || s).join('、') +} + +// 格式化月收入 +const formatIncome = (min: number | undefined, max: number | undefined) => { + const incomeMap: Record = { + 1: '5000以下', + 2: '5000-10000', + 3: '10000-20000', + 4: '20000-50000', + 5: '50000以上' + } + // 优先显示最小值(期望最低收入) + if (min) return incomeMap[min] || '-' + if (max) return incomeMap[max] || '-' + return '不限' +} + onMounted(() => { fetchUserDetail() }) @@ -193,7 +265,7 @@ onMounted(() => { {{ userDetail.genderText || '-' }} - {{ userDetail.city || '-' }} + {{ userDetail.profile?.workCity || userDetail.city || '-' }} { {{ userDetail.requirement.heightMin || '-' }} ~ {{ userDetail.requirement.heightMax || '-' }}cm - {{ userDetail.requirement.education || '-' }} + {{ formatEducation(userDetail.requirement.education) }} {{ userDetail.requirement.workCity || '-' }} - {{ userDetail.requirement.incomeMin || '-' }} ~ {{ userDetail.requirement.incomeMax || '-' }} + {{ formatIncome(userDetail.requirement.incomeMin, userDetail.requirement.incomeMax) }} - {{ userDetail.requirement.marriageStatus || '-' }} + {{ formatMarriageStatus(userDetail.requirement.marriageStatus) }} {{ userDetail.requirement.requireHouse ? '是' : '否' }} diff --git a/admin/src/views/user/list.vue b/admin/src/views/user/list.vue index af0e454..0172eaf 100644 --- a/admin/src/views/user/list.vue +++ b/admin/src/views/user/list.vue @@ -6,11 +6,11 @@ import { ref, reactive, onMounted } from 'vue' import { useRouter } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' -import { View, Edit, Plus } from '@element-plus/icons-vue' +import { View, Edit, Plus, Delete } from '@element-plus/icons-vue' import SearchForm from '@/components/SearchForm/index.vue' import Pagination from '@/components/Pagination/index.vue' import StatusTag from '@/components/StatusTag/index.vue' -import { getUserList, updateUserStatus, createTestUsers } from '@/api/user' +import { getUserList, updateUserStatus, createTestUsers, deleteUser } from '@/api/user' import { getFullImageUrl } from '@/utils/image' import type { UserListItem, UserQueryParams } from '@/types/user.d' @@ -151,6 +151,29 @@ const handleToggleStatus = async (row: UserListItem) => { } } +// 删除用户 +const handleDeleteUser = async (row: UserListItem) => { + try { + await ElMessageBox.confirm( + `确定要删除用户「${row.nickname || row.xiangQinNo}」吗?此操作不可恢复!`, + '警告', + { + confirmButtonText: '确定删除', + cancelButtonText: '取消', + type: 'error' + } + ) + + await deleteUser(row.userId) + ElMessage.success('删除成功') + fetchUserList() + } catch (error) { + if (error !== 'cancel') { + console.error('删除用户失败:', error) + } + } +} + // 格式化时间 const formatTime = (time: string) => { if (!time) return '-' @@ -438,7 +461,7 @@ onMounted(() => { @@ -459,6 +482,14 @@ onMounted(() => { > {{ row.status === 1 ? '禁用' : '启用' }} + + 删除 + diff --git a/miniapp/api/profile.js b/miniapp/api/profile.js index 559b1b0..8d8fdb6 100644 --- a/miniapp/api/profile.js +++ b/miniapp/api/profile.js @@ -7,7 +7,9 @@ import { get, post, del } from './request' import { getToken } from '../utils/storage' // API 基础地址 -const BASE_URL = 'http://localhost:5000/api/app' +const BASE_URL = 'http://localhost:5001/api/app' +// 静态资源服务器地址 +const STATIC_URL = 'http://localhost:5001' /** * 提交/更新用户资料 @@ -59,54 +61,79 @@ export async function getMyProfile() { * Requirements: 4.4 * * @param {Array} filePaths - 本地文件路径数组 - * @returns {Promise} 上传结果 + * @returns {Promise} 上传结果,包含 photos 数组 [{url: string}] */ export async function uploadPhotos(filePaths) { - return new Promise((resolve, reject) => { - const token = getToken() - - // 使用 uni.uploadFile 上传文件 - const uploadTasks = filePaths.map((filePath, index) => { - return new Promise((res, rej) => { + const token = getToken() + + // 逐个上传文件(uni.uploadFile 不支持一次上传多个文件) + const uploadedPhotos = [] + + for (const filePath of filePaths) { + try { + const result = await new Promise((resolve, reject) => { uni.uploadFile({ url: `${BASE_URL}/profile/photos`, filePath: filePath, - name: 'files', + name: 'files', // 后端期望的参数名 header: { 'Authorization': `Bearer ${token}` }, success: (uploadRes) => { + console.log('上传响应:', uploadRes) if (uploadRes.statusCode === 200) { try { const data = JSON.parse(uploadRes.data) - res(data) + // 后端返回格式: { code: 0, message: "success", data: {...} } + if (data.code === 0 && data.data) { + resolve(data.data) + } else { + reject(new Error(data.message || '上传失败')) + } } catch (e) { - rej(new Error('解析响应失败')) + console.error('解析响应失败:', e, uploadRes.data) + reject(new Error('解析响应失败')) } } else if (uploadRes.statusCode === 401) { - rej(new Error('未授权,请重新登录')) + reject(new Error('未授权,请重新登录')) } else { - rej(new Error('上传失败')) + // 尝试解析错误信息 + try { + const errData = JSON.parse(uploadRes.data) + reject(new Error(errData.message || '上传失败')) + } catch { + reject(new Error(`上传失败: ${uploadRes.statusCode}`)) + } } }, fail: (err) => { - rej(new Error('网络连接失败')) + console.error('上传网络错误:', err) + reject(new Error('网络连接失败')) } }) }) - }) - - // 并行上传所有文件 - Promise.all(uploadTasks) - .then(results => { - // 合并所有上传结果 - const photos = results.flatMap(r => r.data?.photos || []) - resolve({ success: true, data: { photos } }) - }) - .catch(err => { - reject(err) - }) - }) + + // 后端返回 { photos: [{id, photoUrl}], totalCount: number } + // 转换为前端需要的格式,拼接完整URL + if (result.photos && result.photos.length > 0) { + result.photos.forEach(photo => { + // 如果是相对路径,拼接服务器地址 + const fullUrl = photo.photoUrl.startsWith('http') ? photo.photoUrl : `${STATIC_URL}${photo.photoUrl}` + uploadedPhotos.push({ id: photo.id, url: fullUrl }) + }) + } + } catch (error) { + console.error('上传照片失败:', error) + throw error + } + } + + return { + success: true, + data: { + photos: uploadedPhotos + } + } } /** @@ -120,9 +147,33 @@ export async function deletePhoto(photoId) { return response } +/** + * 清理未提交资料的孤立照片 + * 进入填写资料页面时调用,如果用户没有Profile记录,清理之前上传的照片 + * + * @returns {Promise} 清理结果 + */ +export async function cleanupOrphanPhotos() { + const response = await post('/profile/photos/cleanup') + return response +} + +/** + * 更新头像 + * + * @param {string} avatarUrl - 头像URL + * @returns {Promise} 更新结果 + */ +export async function updateAvatar(avatarUrl) { + const response = await post('/profile/avatar', { avatarUrl }) + return response +} + export default { createOrUpdate, getMyProfile, uploadPhotos, - deletePhoto + deletePhoto, + cleanupOrphanPhotos, + updateAvatar } diff --git a/miniapp/api/user.js b/miniapp/api/user.js index c3905b5..907ba37 100644 --- a/miniapp/api/user.js +++ b/miniapp/api/user.js @@ -10,10 +10,12 @@ import { get, post } from './request' * WHEN a user visits the home page, THE XiangYi_MiniApp SHALL display recommended user list * Requirements: 2.3 * + * @param {number} pageIndex - 页码 + * @param {number} pageSize - 每页数量 * @returns {Promise} 推荐用户列表 */ -export async function getRecommend() { - const response = await get('/users/recommend') +export async function getRecommend(pageIndex = 1, pageSize = 10) { + const response = await get('/users/recommend', { pageIndex, pageSize }, { needAuth: false }) return response } @@ -57,8 +59,44 @@ export async function search(params = {}) { return response } +/** + * 更新用户头像 + * + * @param {string} avatar - 头像URL + * @returns {Promise} 更新结果 + */ +export async function updateAvatar(avatar) { + const response = await post('/users/avatar', { avatar }) + return response +} + +/** + * 更新用户昵称 + * + * @param {string} nickname - 昵称 + * @returns {Promise} 更新结果 + */ +export async function updateNickname(nickname) { + const response = await post('/users/nickname', { nickname }) + return response +} + +/** + * 解密微信手机号 + * + * @param {string} code - 微信返回的 code + * @returns {Promise} 解密结果,包含 phone 字段 + */ +export async function decryptPhone(code) { + const response = await post('/users/phone/decrypt', { code }) + return response +} + export default { getRecommend, getUserDetail, - search + search, + updateAvatar, + updateNickname, + decryptPhone } diff --git a/miniapp/components/UserCard/index.vue b/miniapp/components/UserCard/index.vue index 4686b1c..f3ce59f 100644 --- a/miniapp/components/UserCard/index.vue +++ b/miniapp/components/UserCard/index.vue @@ -1,56 +1,125 @@ + + diff --git a/miniapp/pages/message/index.vue b/miniapp/pages/message/index.vue index 6023385..325a0fe 100644 --- a/miniapp/pages/message/index.vue +++ b/miniapp/pages/message/index.vue @@ -2,106 +2,147 @@ - - - - - - {{ interactCounts.viewedMe || 0 }} - 看过我 - - - {{ interactCounts.favoritedMe || 0 }} - 收藏我 - - - {{ interactCounts.unlockedMe || 0 }} - 解锁我 - + + + + + + + + + + 消息 - - - - - 聊天消息 - - - - - - - - - - - {{ session.unreadCount > 99 ? '99+' : session.unreadCount }} + + + + + + + + + + + 看过我 + + +{{ interactCounts.viewedMe }} - - - - - {{ session.targetNickname }} - {{ formatTime(session.lastMessageTime) }} + + + - - {{ session.lastMessage || '暂无消息' }} + 收藏我 + + +{{ interactCounts.favoritedMe }} + + + + + + + 解锁我 + + +{{ interactCounts.unlockedMe }} - - - - - - + + + + + + + + + + + 系统消息 + + + + + + + + + + + + + + + {{ session.unreadCount > 99 ? '99+' : session.unreadCount }} + + + + + + + {{ session.targetNickname }}{{ session.relationship ? `(${session.relationship})` : '' }} + {{ formatTime(session.lastMessageTime) }} + + + {{ session.lastMessage || '暂无消息' }} + + + + + + + + @@ -1174,8 +1336,63 @@ onMounted(() => { padding-bottom: 140rpx; } -// 步骤指示器 +// 固定头部区域 +.fixed-header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; +} + +// 自定义导航栏(水平渐变背景) +.custom-navbar { + position: relative; + z-index: 1; + background: linear-gradient(90deg, #FFDEE0 0%, #FF939C 100%); + + .navbar-content { + height: 44px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 24rpx; + + .navbar-back { + width: 80rpx; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; + + .back-icon { + font-size: 64rpx; + color: #333; + font-weight: 300; + } + } + + .navbar-title { + font-size: 34rpx; + font-weight: 600; + color: #333; + } + + .navbar-placeholder { + width: 80rpx; + } + } +} + +// 占位区域 +.header-placeholder { + width: 100%; +} + +// 步骤指示器(白色背景) .step-indicator { + position: relative; + z-index: 1; display: flex; justify-content: space-between; padding: 30rpx 40rpx; @@ -1202,16 +1419,17 @@ onMounted(() => { .step-label { font-size: 22rpx; - color: #999; + color: #666; } &.active { .step-dot { - background: linear-gradient(135deg, #ff6b6b 0%, #ff5252 100%); + background: #FF6B6B; color: #fff; } .step-label { - color: #ff6b6b; + color: #FF6B6B; + font-weight: 500; } } @@ -1230,6 +1448,8 @@ onMounted(() => { // 表单区域 .step-content { padding: 20rpx; + position: relative; + z-index: 1; } .form-section { @@ -1271,6 +1491,11 @@ onMounted(() => { border-radius: 12rpx; font-size: 28rpx; box-sizing: border-box; + + &[disabled] { + color: #999; + background-color: #f0f0f0; + } } .form-tip { @@ -1503,12 +1728,37 @@ onMounted(() => { } } +// 已验证手机号显示 +.phone-verified { + display: flex; + align-items: center; + justify-content: space-between; + height: 80rpx; + padding: 0 20rpx; + background-color: #f0fff0; + border-radius: 12rpx; + + .phone-number { + font-size: 28rpx; + color: #333; + } + + .verified-tag { + font-size: 24rpx; + color: #4cd964; + padding: 4rpx 12rpx; + background-color: #e8f8e8; + border-radius: 8rpx; + } +} + // 底部按钮 .bottom-buttons { position: fixed; bottom: 0; left: 0; right: 0; + z-index: 999; display: flex; gap: 20rpx; padding: 20rpx 30rpx; @@ -1539,3 +1789,11 @@ onMounted(() => { } } + + + diff --git a/miniapp/pages/profile/personal.vue b/miniapp/pages/profile/personal.vue new file mode 100644 index 0000000..b639b8b --- /dev/null +++ b/miniapp/pages/profile/personal.vue @@ -0,0 +1,432 @@ + + + + + diff --git a/miniapp/static/butler.png b/miniapp/static/butler.png new file mode 100644 index 0000000..cc642d6 Binary files /dev/null and b/miniapp/static/butler.png differ diff --git a/miniapp/static/ic_agreement1.png b/miniapp/static/ic_agreement1.png new file mode 100644 index 0000000..9d58944 Binary files /dev/null and b/miniapp/static/ic_agreement1.png differ diff --git a/miniapp/static/ic_agreement2.png b/miniapp/static/ic_agreement2.png new file mode 100644 index 0000000..529731a Binary files /dev/null and b/miniapp/static/ic_agreement2.png differ diff --git a/miniapp/static/ic_check.png b/miniapp/static/ic_check.png new file mode 100644 index 0000000..9935ac5 Binary files /dev/null and b/miniapp/static/ic_check.png differ diff --git a/miniapp/static/ic_check_s.png b/miniapp/static/ic_check_s.png new file mode 100644 index 0000000..756e6f6 Binary files /dev/null and b/miniapp/static/ic_check_s.png differ diff --git a/miniapp/static/ic_collection.png b/miniapp/static/ic_collection.png new file mode 100644 index 0000000..f281330 Binary files /dev/null and b/miniapp/static/ic_collection.png differ diff --git a/miniapp/static/ic_exit.png b/miniapp/static/ic_exit.png new file mode 100644 index 0000000..90c05c5 Binary files /dev/null and b/miniapp/static/ic_exit.png differ diff --git a/miniapp/static/ic_seen.png b/miniapp/static/ic_seen.png new file mode 100644 index 0000000..8b23c9d Binary files /dev/null and b/miniapp/static/ic_seen.png differ diff --git a/miniapp/static/ic_unlock.png b/miniapp/static/ic_unlock.png new file mode 100644 index 0000000..0934723 Binary files /dev/null and b/miniapp/static/ic_unlock.png differ diff --git a/miniapp/static/logo.png b/miniapp/static/logo.png index b5771e2..7bb79cf 100644 Binary files a/miniapp/static/logo.png and b/miniapp/static/logo.png differ diff --git a/miniapp/static/real_name.png b/miniapp/static/real_name.png new file mode 100644 index 0000000..3f5d96e Binary files /dev/null and b/miniapp/static/real_name.png differ diff --git a/miniapp/static/title_bg.png b/miniapp/static/title_bg.png new file mode 100644 index 0000000..c0d28ad Binary files /dev/null and b/miniapp/static/title_bg.png differ diff --git a/server/src/XiangYi.AdminApi/Controllers/AdminConfigController.cs b/server/src/XiangYi.AdminApi/Controllers/AdminConfigController.cs new file mode 100644 index 0000000..59b064b --- /dev/null +++ b/server/src/XiangYi.AdminApi/Controllers/AdminConfigController.cs @@ -0,0 +1,86 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using XiangYi.Application.DTOs.Responses; +using XiangYi.Application.Interfaces; + +namespace XiangYi.AdminApi.Controllers; + +/// +/// 后台系统配置控制器 +/// +[ApiController] +[Route("api/admin/config")] +[Authorize] +public class AdminConfigController : ControllerBase +{ + private readonly ISystemConfigService _configService; + private readonly ILogger _logger; + + public AdminConfigController( + ISystemConfigService configService, + ILogger logger) + { + _configService = configService; + _logger = logger; + } + + /// + /// 获取默认头像 + /// + [HttpGet("defaultAvatar")] + public async Task> GetDefaultAvatar() + { + var avatarUrl = await _configService.GetDefaultAvatarAsync(); + return ApiResponse.Success(new DefaultAvatarResponse + { + AvatarUrl = avatarUrl + }); + } + + /// + /// 设置默认头像 + /// + [HttpPost("defaultAvatar")] + public async Task SetDefaultAvatar([FromBody] SetDefaultAvatarRequest request) + { + if (string.IsNullOrWhiteSpace(request.AvatarUrl)) + { + return ApiResponse.Error(40001, "头像URL不能为空"); + } + + var result = await _configService.SetDefaultAvatarAsync(request.AvatarUrl); + return result ? ApiResponse.Success("设置成功") : ApiResponse.Error(40001, "设置失败"); + } + + /// + /// 获取所有系统配置 + /// + [HttpGet("all")] + public async Task>> GetAllConfigs() + { + var configs = await _configService.GetAllConfigsAsync(); + return ApiResponse>.Success(configs); + } +} + +/// +/// 默认头像响应 +/// +public class DefaultAvatarResponse +{ + /// + /// 头像URL + /// + public string? AvatarUrl { get; set; } +} + +/// +/// 设置默认头像请求 +/// +public class SetDefaultAvatarRequest +{ + /// + /// 头像URL + /// + public string AvatarUrl { get; set; } = string.Empty; +} diff --git a/server/src/XiangYi.AdminApi/Controllers/AdminUserController.cs b/server/src/XiangYi.AdminApi/Controllers/AdminUserController.cs index 164c70b..438b584 100644 --- a/server/src/XiangYi.AdminApi/Controllers/AdminUserController.cs +++ b/server/src/XiangYi.AdminApi/Controllers/AdminUserController.cs @@ -98,6 +98,19 @@ public class AdminUserController : ControllerBase var adminIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; return long.TryParse(adminIdClaim, out var adminId) ? adminId : 0; } + + /// + /// 删除用户(硬删除) + /// + /// 用户ID + /// 操作结果 + [HttpDelete("{id}")] + public async Task DeleteUser(long id) + { + var adminId = GetCurrentAdminId(); + var result = await _adminUserService.DeleteUserAsync(id, adminId); + return result ? ApiResponse.Success("删除成功") : ApiResponse.Error(40001, "删除失败"); + } } /// diff --git a/server/src/XiangYi.AppApi/Controllers/ProfileController.cs b/server/src/XiangYi.AppApi/Controllers/ProfileController.cs index ae51619..6a6b75d 100644 --- a/server/src/XiangYi.AppApi/Controllers/ProfileController.cs +++ b/server/src/XiangYi.AppApi/Controllers/ProfileController.cs @@ -101,6 +101,19 @@ public class ProfileController : ControllerBase } } + /// + /// 清理未提交资料用户的照片 + /// 当用户进入填写资料页面时调用,清理之前未提交的孤立照片 + /// + /// 清理结果 + [HttpPost("photos/cleanup")] + public async Task CleanupPhotos() + { + var userId = GetCurrentUserId(); + var count = await _profileService.CleanupPhotosIfNoProfileAsync(userId); + return ApiResponse.Success($"已清理{count}张照片"); + } + /// /// 删除照片 /// diff --git a/server/src/XiangYi.AppApi/Controllers/UploadController.cs b/server/src/XiangYi.AppApi/Controllers/UploadController.cs new file mode 100644 index 0000000..1191bd2 --- /dev/null +++ b/server/src/XiangYi.AppApi/Controllers/UploadController.cs @@ -0,0 +1,135 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using XiangYi.Application.DTOs.Responses; +using XiangYi.Infrastructure.Storage; + +namespace XiangYi.AppApi.Controllers; + +/// +/// 小程序文件上传控制器 +/// +[ApiController] +[Route("api/app/upload")] +[Authorize] +public class UploadController : ControllerBase +{ + private readonly IStorageProvider _storageProvider; + private readonly ILogger _logger; + private readonly IWebHostEnvironment _environment; + + // 允许的图片类型 + private static readonly string[] AllowedImageTypes = { ".jpg", ".jpeg", ".png", ".gif", ".webp" }; + // 最大文件大小 5MB + private const long MaxFileSize = 5 * 1024 * 1024; + + public UploadController( + IStorageProvider storageProvider, + ILogger logger, + IWebHostEnvironment environment) + { + _storageProvider = storageProvider; + _logger = logger; + _environment = environment; + } + + /// + /// 上传图片 + /// + /// 图片文件 + /// 图片URL + [HttpPost] + public async Task> Upload(IFormFile file) + { + if (file == null || file.Length == 0) + { + return ApiResponse.Error(40001, "请选择要上传的文件"); + } + + // 检查文件大小 + if (file.Length > MaxFileSize) + { + return ApiResponse.Error(40002, "文件大小不能超过5MB"); + } + + // 检查文件类型 + var extension = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (!AllowedImageTypes.Contains(extension)) + { + return ApiResponse.Error(40003, "只支持 jpg、jpeg、png、gif、webp 格式的图片"); + } + + try + { + // 生成文件名 + var fileName = $"{Guid.NewGuid():N}{extension}"; + var folder = $"app/{DateTime.Now:yyyyMMdd}"; + + // 上传文件 + string url; + using var stream = file.OpenReadStream(); + var relativePath = await _storageProvider.UploadAsync(stream, fileName, folder); + + // 如果返回的是相对路径,转换为完整URL + if (relativePath.StartsWith("/")) + { + var request = HttpContext.Request; + var baseUrl = $"{request.Scheme}://{request.Host}"; + url = $"{baseUrl}{relativePath}"; + } + else + { + url = relativePath; + } + + _logger.LogInformation("文件上传成功: {FileName} -> {Url}", file.FileName, url); + + return ApiResponse.Success(new UploadResult + { + Url = url, + FileName = file.FileName + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "文件上传失败: {FileName}", file.FileName); + return ApiResponse.Error(50001, "文件上传失败"); + } + } + + /// + /// 保存到本地 + /// + private async Task SaveToLocalAsync(IFormFile file, string fileName, string folder) + { + var uploadPath = Path.Combine(_environment.WebRootPath ?? "wwwroot", "uploads", folder); + if (!Directory.Exists(uploadPath)) + { + Directory.CreateDirectory(uploadPath); + } + + var filePath = Path.Combine(uploadPath, fileName); + using var stream = new FileStream(filePath, FileMode.Create); + await file.CopyToAsync(stream); + + // 返回完整URL + var request = HttpContext.Request; + var baseUrl = $"{request.Scheme}://{request.Host}"; + return $"{baseUrl}/uploads/{folder}/{fileName}"; + } +} + +/// +/// 上传结果 +/// +public class UploadResult +{ + /// + /// 文件URL + /// + public string Url { get; set; } = string.Empty; + + /// + /// 原始文件名 + /// + public string FileName { get; set; } = string.Empty; +} diff --git a/server/src/XiangYi.AppApi/Controllers/UserController.cs b/server/src/XiangYi.AppApi/Controllers/UserController.cs index 5e28219..2ccff66 100644 --- a/server/src/XiangYi.AppApi/Controllers/UserController.cs +++ b/server/src/XiangYi.AppApi/Controllers/UserController.cs @@ -4,6 +4,7 @@ using XiangYi.Application.DTOs.Requests; using XiangYi.Application.DTOs.Responses; using XiangYi.Application.Interfaces; using XiangYi.Core.Constants; +using XiangYi.Infrastructure.WeChat; using System.Security.Claims; namespace XiangYi.AppApi.Controllers; @@ -20,6 +21,7 @@ public class UserController : ControllerBase private readonly ISearchService _searchService; private readonly IProfileService _profileService; private readonly IInteractService _interactService; + private readonly IWeChatService _weChatService; private readonly ILogger _logger; public UserController( @@ -27,25 +29,30 @@ public class UserController : ControllerBase ISearchService searchService, IProfileService profileService, IInteractService interactService, + IWeChatService weChatService, ILogger logger) { _recommendService = recommendService; _searchService = searchService; _profileService = profileService; _interactService = interactService; + _weChatService = weChatService; _logger = logger; } /// /// 获取每日推荐列表 /// + /// 页码 + /// 每页数量 /// 推荐用户列表 [HttpGet("recommend")] - public async Task>> GetRecommend() + [AllowAnonymous] + public async Task>> GetRecommend([FromQuery] int pageIndex = 1, [FromQuery] int pageSize = 10) { var userId = GetCurrentUserId(); - var result = await _recommendService.GetDailyRecommendAsync(userId); - return ApiResponse>.Success(result); + var result = await _recommendService.GetDailyRecommendAsync(userId, pageIndex, pageSize); + return ApiResponse>.Success(result); } /// @@ -106,6 +113,90 @@ public class UserController : ControllerBase var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; return long.TryParse(userIdClaim, out var userId) ? userId : 0; } + + /// + /// 更新头像 + /// + /// 头像请求 + /// 更新结果 + [HttpPost("avatar")] + public async Task UpdateAvatar([FromBody] UpdateAvatarRequest request) + { + if (string.IsNullOrEmpty(request.Avatar)) + { + return ApiResponse.Error(ErrorCodes.InvalidParameter, "头像URL不能为空"); + } + + var userId = GetCurrentUserId(); + var success = await _profileService.UpdateAvatarAsync(userId, request.Avatar); + + if (!success) + { + return ApiResponse.Error(ErrorCodes.UserNotFound, "用户不存在"); + } + + return ApiResponse.Success("头像更新成功"); + } + + /// + /// 更新昵称 + /// + /// 昵称请求 + /// 更新结果 + [HttpPost("nickname")] + public async Task UpdateNickname([FromBody] UpdateNicknameRequest request) + { + if (string.IsNullOrEmpty(request.Nickname)) + { + return ApiResponse.Error(ErrorCodes.InvalidParameter, "昵称不能为空"); + } + + if (request.Nickname.Length > 20) + { + return ApiResponse.Error(ErrorCodes.InvalidParameter, "昵称不能超过20个字符"); + } + + var userId = GetCurrentUserId(); + var success = await _profileService.UpdateNicknameAsync(userId, request.Nickname); + + if (!success) + { + return ApiResponse.Error(ErrorCodes.UserNotFound, "用户不存在"); + } + + return ApiResponse.Success("昵称更新成功"); + } + + /// + /// 解密微信手机号 + /// + /// 解密请求 + /// 手机号 + [HttpPost("phone/decrypt")] + public async Task> DecryptPhone([FromBody] DecryptPhoneRequest request) + { + if (string.IsNullOrEmpty(request.Code)) + { + return ApiResponse.Error(ErrorCodes.InvalidParameter, "code不能为空"); + } + + try + { + var phone = await _weChatService.GetPhoneNumberAsync(request.Code); + + if (string.IsNullOrEmpty(phone)) + { + return ApiResponse.Error(ErrorCodes.WeChatServiceError, "获取手机号失败"); + } + + return ApiResponse.Success(new DecryptPhoneResponse { Phone = phone }); + } + catch (Exception ex) + { + _logger.LogError(ex, "解密手机号失败"); + return ApiResponse.Error(ErrorCodes.WeChatServiceError, "获取手机号失败"); + } + } } /// @@ -118,3 +209,47 @@ public class UserDetailRequest /// public long UserId { get; set; } } + +/// +/// 更新头像请求 +/// +public class UpdateAvatarRequest +{ + /// + /// 头像URL + /// + public string Avatar { get; set; } = string.Empty; +} + +/// +/// 更新昵称请求 +/// +public class UpdateNicknameRequest +{ + /// + /// 昵称 + /// + public string Nickname { get; set; } = string.Empty; +} + +/// +/// 解密手机号请求 +/// +public class DecryptPhoneRequest +{ + /// + /// 微信返回的code + /// + public string Code { get; set; } = string.Empty; +} + +/// +/// 解密手机号响应 +/// +public class DecryptPhoneResponse +{ + /// + /// 手机号 + /// + public string Phone { get; set; } = string.Empty; +} diff --git a/server/src/XiangYi.AppApi/appsettings.json b/server/src/XiangYi.AppApi/appsettings.json index 483d16d..c2d4919 100644 --- a/server/src/XiangYi.AppApi/appsettings.json +++ b/server/src/XiangYi.AppApi/appsettings.json @@ -17,10 +17,10 @@ }, "ConnectionStrings": { "BizDb": "Server=192.168.195.15;Database=XiangYi_Biz;User Id=sa;Password=Dbt@com@123;TrustServerCertificate=true;", - "Redis": "localhost:6379,defaultDatabase=0" + "Redis": "192.168.195.15:6379,defaultDatabase=0" }, "Redis": { - "ConnectionString": "localhost:6379,defaultDatabase=0", + "ConnectionString": "192.168.195.15:6379,defaultDatabase=0", "DefaultDatabase": 0, "KeyPrefix": "xiangyi:app:", "DefaultExpireSeconds": 3600, @@ -34,14 +34,23 @@ "ExpireMinutes": 10080 }, "WeChat": { - "AppId": "", - "AppSecret": "" + "MiniProgram": { + "AppId": "wx21b4110b18b31831", + "AppSecret": "fe3b5aa5715820cd66af3d42d55efad6" + } }, - "TencentCos": { - "SecretId": "", - "SecretKey": "", - "Region": "ap-guangzhou", - "BucketName": "" + "Storage": { + "Provider": "Local", + "Local": { + "BasePath": "wwwroot/uploads", + "BaseUrl": "/uploads" + }, + "TencentCos": { + "SecretId": "", + "SecretKey": "", + "Region": "ap-guangzhou", + "BucketName": "" + } }, "AliyunSms": { "AccessKeyId": "", diff --git a/server/src/XiangYi.Application/DTOs/Responses/ProfileResponses.cs b/server/src/XiangYi.Application/DTOs/Responses/ProfileResponses.cs index ac96d2d..419da49 100644 --- a/server/src/XiangYi.Application/DTOs/Responses/ProfileResponses.cs +++ b/server/src/XiangYi.Application/DTOs/Responses/ProfileResponses.cs @@ -264,12 +264,28 @@ public class RequirementResponse public class UploadPhotoResponse { /// - /// 上传成功的照片URL列表 + /// 上传成功的照片列表(包含ID和URL) /// - public List PhotoUrls { get; set; } = new(); + public List Photos { get; set; } = new(); /// /// 当前照片总数 /// public int TotalCount { get; set; } } + +/// +/// 上传的照片信息 +/// +public class UploadedPhotoInfo +{ + /// + /// 照片ID + /// + public long Id { get; set; } + + /// + /// 照片URL + /// + public string PhotoUrl { get; set; } = string.Empty; +} diff --git a/server/src/XiangYi.Application/DTOs/Responses/RecommendResponses.cs b/server/src/XiangYi.Application/DTOs/Responses/RecommendResponses.cs index 424ba16..7f5a254 100644 --- a/server/src/XiangYi.Application/DTOs/Responses/RecommendResponses.cs +++ b/server/src/XiangYi.Application/DTOs/Responses/RecommendResponses.cs @@ -20,21 +20,41 @@ public class RecommendUserResponse /// public string? Avatar { get; set; } + /// + /// 性别 1=男 2=女 + /// + public int Gender { get; set; } + /// /// 年龄 /// public int Age { get; set; } + /// + /// 出生年份 + /// + public int BirthYear { get; set; } + /// /// 工作城市 /// public string? WorkCity { get; set; } + /// + /// 家乡 + /// + public string? Hometown { get; set; } + /// /// 身高(cm) /// public int Height { get; set; } + /// + /// 体重(kg) + /// + public int Weight { get; set; } + /// /// 学历 /// @@ -50,6 +70,16 @@ public class RecommendUserResponse /// public string? Occupation { get; set; } + /// + /// 月收入 + /// + public int MonthlyIncome { get; set; } + + /// + /// 个人简介 + /// + public string? Intro { get; set; } + /// /// 是否会员 /// diff --git a/server/src/XiangYi.Application/Extensions/ServiceCollectionExtensions.cs b/server/src/XiangYi.Application/Extensions/ServiceCollectionExtensions.cs index 2f192e8..c8b69ff 100644 --- a/server/src/XiangYi.Application/Extensions/ServiceCollectionExtensions.cs +++ b/server/src/XiangYi.Application/Extensions/ServiceCollectionExtensions.cs @@ -46,6 +46,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/server/src/XiangYi.Application/Interfaces/IAdminUserService.cs b/server/src/XiangYi.Application/Interfaces/IAdminUserService.cs index ac6e312..e953f55 100644 --- a/server/src/XiangYi.Application/Interfaces/IAdminUserService.cs +++ b/server/src/XiangYi.Application/Interfaces/IAdminUserService.cs @@ -44,4 +44,12 @@ public interface IAdminUserService /// 性别:1男 2女,null则随机 /// 创建的用户ID列表 Task> CreateTestUsersAsync(int count, int? gender = null); + + /// + /// 删除用户(硬删除,仅用于测试数据清理) + /// + /// 用户ID + /// 操作管理员ID + /// 是否成功 + Task DeleteUserAsync(long userId, long adminId); } diff --git a/server/src/XiangYi.Application/Interfaces/IProfileService.cs b/server/src/XiangYi.Application/Interfaces/IProfileService.cs index 8f19409..ea4f923 100644 --- a/server/src/XiangYi.Application/Interfaces/IProfileService.cs +++ b/server/src/XiangYi.Application/Interfaces/IProfileService.cs @@ -62,4 +62,27 @@ public interface IProfileService /// 要上传的数量 /// 是否可以上传 Task CanUploadPhotosAsync(long userId, int uploadCount); + + /// + /// 更新用户头像 + /// + /// 用户ID + /// 头像URL + /// 是否成功 + Task UpdateAvatarAsync(long userId, string avatarUrl); + + /// + /// 更新用户昵称 + /// + /// 用户ID + /// 昵称 + /// 是否成功 + Task UpdateNicknameAsync(long userId, string nickname); + + /// + /// 清理没有关联Profile的孤立照片 + /// + /// 用户ID + /// 清理的照片数量 + Task CleanupPhotosIfNoProfileAsync(long userId); } diff --git a/server/src/XiangYi.Application/Interfaces/IRecommendService.cs b/server/src/XiangYi.Application/Interfaces/IRecommendService.cs index 809d14d..a40316e 100644 --- a/server/src/XiangYi.Application/Interfaces/IRecommendService.cs +++ b/server/src/XiangYi.Application/Interfaces/IRecommendService.cs @@ -8,11 +8,13 @@ namespace XiangYi.Application.Interfaces; public interface IRecommendService { /// - /// 获取用户的每日推荐列表 + /// 获取用户的每日推荐列表(分页) /// - /// 用户ID - /// 推荐用户列表 - Task> GetDailyRecommendAsync(long userId); + /// 用户ID,0表示未登录 + /// 页码 + /// 每页数量 + /// 分页推荐用户列表 + Task> GetDailyRecommendAsync(long userId, int pageIndex = 1, int pageSize = 10); /// /// 为指定用户生成每日推荐列表 diff --git a/server/src/XiangYi.Application/Interfaces/ISystemConfigService.cs b/server/src/XiangYi.Application/Interfaces/ISystemConfigService.cs new file mode 100644 index 0000000..71ed1ab --- /dev/null +++ b/server/src/XiangYi.Application/Interfaces/ISystemConfigService.cs @@ -0,0 +1,32 @@ +namespace XiangYi.Application.Interfaces; + +/// +/// 系统配置服务接口 +/// +public interface ISystemConfigService +{ + /// + /// 获取配置值 + /// + Task GetConfigValueAsync(string key); + + /// + /// 设置配置值 + /// + Task SetConfigValueAsync(string key, string value, string? description = null); + + /// + /// 获取默认头像URL + /// + Task GetDefaultAvatarAsync(); + + /// + /// 设置默认头像URL + /// + Task SetDefaultAvatarAsync(string avatarUrl); + + /// + /// 获取所有配置 + /// + Task> GetAllConfigsAsync(); +} diff --git a/server/src/XiangYi.Application/Services/AdminUserService.cs b/server/src/XiangYi.Application/Services/AdminUserService.cs index d2f583a..668f4d2 100644 --- a/server/src/XiangYi.Application/Services/AdminUserService.cs +++ b/server/src/XiangYi.Application/Services/AdminUserService.cs @@ -418,6 +418,27 @@ public class AdminUserService : IAdminUserService return createdIds; } + /// + public async Task DeleteUserAsync(long userId, long adminId) + { + var user = await _userRepository.GetByIdAsync(userId); + if (user == null) + { + throw new BusinessException(ErrorCodes.UserNotFound, "用户不存在"); + } + + // 删除用户相关数据 + await _photoRepository.DeleteAsync(p => p.UserId == userId); + await _requirementRepository.DeleteAsync(r => r.UserId == userId); + await _profileRepository.DeleteAsync(p => p.UserId == userId); + await _userRepository.DeleteAsync(userId); + + _logger.LogInformation("管理员删除用户: AdminId={AdminId}, UserId={UserId}, Nickname={Nickname}", + adminId, userId, user.Nickname); + + return true; + } + #region Private Helper Methods private static AdminUserListDto MapToUserListDto(User user, UserProfile? profile) diff --git a/server/src/XiangYi.Application/Services/AuthService.cs b/server/src/XiangYi.Application/Services/AuthService.cs index 831d00b..bb57fc6 100644 --- a/server/src/XiangYi.Application/Services/AuthService.cs +++ b/server/src/XiangYi.Application/Services/AuthService.cs @@ -24,6 +24,7 @@ public class AuthService : IAuthService private readonly IRepository _userRepository; private readonly IWeChatService _weChatService; private readonly ICacheService _cacheService; + private readonly ISystemConfigService _systemConfigService; private readonly JwtOptions _jwtOptions; private readonly ILogger _logger; private static readonly Random _random = new(); @@ -32,12 +33,14 @@ public class AuthService : IAuthService IRepository userRepository, IWeChatService weChatService, ICacheService cacheService, + ISystemConfigService systemConfigService, IOptions jwtOptions, ILogger logger) { _userRepository = userRepository; _weChatService = weChatService; _cacheService = cacheService; + _systemConfigService = systemConfigService; _jwtOptions = jwtOptions.Value; _logger = logger; } @@ -66,11 +69,15 @@ public class AuthService : IAuthService isNewUser = true; var xiangQinNo = await GenerateXiangQinNoAsync(); + // 获取默认头像 + var defaultAvatar = await _systemConfigService.GetDefaultAvatarAsync(); + existingUser = new User { OpenId = weChatResult.OpenId, UnionId = weChatResult.UnionId, XiangQinNo = xiangQinNo, + Avatar = defaultAvatar, Status = 1, ContactCount = 2, CreateTime = DateTime.Now, diff --git a/server/src/XiangYi.Application/Services/ProfileService.cs b/server/src/XiangYi.Application/Services/ProfileService.cs index de0ac42..03ca739 100644 --- a/server/src/XiangYi.Application/Services/ProfileService.cs +++ b/server/src/XiangYi.Application/Services/ProfileService.cs @@ -165,14 +165,32 @@ public class ProfileService : IProfileService // 获取资料 var profiles = await _profileRepository.GetListAsync(p => p.UserId == userId); var profile = profiles.FirstOrDefault(); + + // 获取照片(无论是否有 Profile 都获取) + var photos = await _photoRepository.GetListAsync(p => p.UserId == userId); + + // 如果没有 Profile,但有照片,返回只包含照片的响应 if (profile == null) { + // 如果有照片,返回包含照片的空资料 + if (photos.Any()) + { + return new ProfileResponse + { + UserId = userId, + Nickname = user.Nickname, + XiangQinNo = user.XiangQinNo, + Photos = photos.OrderBy(p => p.Sort).Select(p => new PhotoResponse + { + Id = p.Id, + PhotoUrl = p.PhotoUrl, + Sort = p.Sort + }).ToList() + }; + } return null; } - // 获取照片 - var photos = await _photoRepository.GetListAsync(p => p.UserId == userId); - // 获取择偶要求 var requirements = await _requirementRepository.GetListAsync(r => r.UserId == userId); var requirement = requirements.FirstOrDefault(); @@ -236,7 +254,7 @@ public class ProfileService : IProfileService } // 3. 上传照片 - var uploadedUrls = new List(); + var uploadedPhotos = new List(); var sort = currentCount; foreach (var stream in photoStreams) @@ -256,15 +274,19 @@ public class ProfileService : IProfileService }; await _photoRepository.AddAsync(photo); - uploadedUrls.Add(photoUrl); + uploadedPhotos.Add(new UploadedPhotoInfo + { + Id = photo.Id, + PhotoUrl = photoUrl + }); } - _logger.LogInformation("用户上传照片成功: UserId={UserId}, Count={Count}", userId, uploadedUrls.Count); + _logger.LogInformation("用户上传照片成功: UserId={UserId}, Count={Count}", userId, uploadedPhotos.Count); return new UploadPhotoResponse { - PhotoUrls = uploadedUrls, - TotalCount = currentCount + uploadedUrls.Count + Photos = uploadedPhotos, + TotalCount = currentCount + uploadedPhotos.Count }; } @@ -443,4 +465,69 @@ public class ProfileService : IProfileService // 注:即使是会员,也需要通过交换请求才能查看不公开的照片 return new List(); } + + /// + public async Task UpdateAvatarAsync(long userId, string avatarUrl) + { + var user = await _userRepository.GetByIdAsync(userId); + if (user == null) + { + return false; + } + + user.Avatar = avatarUrl; + user.UpdateTime = DateTime.Now; + await _userRepository.UpdateAsync(user); + + _logger.LogInformation("用户头像更新成功: UserId={UserId}, Avatar={Avatar}", userId, avatarUrl); + return true; + } + + /// + public async Task UpdateNicknameAsync(long userId, string nickname) + { + var user = await _userRepository.GetByIdAsync(userId); + if (user == null) + { + return false; + } + + user.Nickname = nickname; + user.UpdateTime = DateTime.Now; + await _userRepository.UpdateAsync(user); + + _logger.LogInformation("用户昵称更新成功: UserId={UserId}, Nickname={Nickname}", userId, nickname); + return true; + } + + /// + public async Task CleanupPhotosIfNoProfileAsync(long userId) + { + // 检查用户是否有 Profile 记录 + var profiles = await _profileRepository.GetListAsync(p => p.UserId == userId); + if (profiles.Any()) + { + // 有 Profile,不需要清理 + return 0; + } + + // 没有 Profile,清理所有照片 + var photos = await _photoRepository.GetListAsync(p => p.UserId == userId); + if (!photos.Any()) + { + return 0; + } + + var count = photos.Count; + foreach (var photo in photos) + { + // 删除云存储文件 + await _storageProvider.DeleteAsync(photo.PhotoUrl); + // 删除数据库记录 + await _photoRepository.DeleteAsync(photo.Id); + } + + _logger.LogInformation("清理用户孤立照片: UserId={UserId}, Count={Count}", userId, count); + return count; + } } diff --git a/server/src/XiangYi.Application/Services/RecommendService.cs b/server/src/XiangYi.Application/Services/RecommendService.cs index 36281e3..95ac887 100644 --- a/server/src/XiangYi.Application/Services/RecommendService.cs +++ b/server/src/XiangYi.Application/Services/RecommendService.cs @@ -76,10 +76,16 @@ public class RecommendService : IRecommendService } /// - public async Task> GetDailyRecommendAsync(long userId) + public async Task> GetDailyRecommendAsync(long userId, int pageIndex = 1, int pageSize = 10) { var today = DateTime.Today; + // 未登录用户,返回默认推荐(随机推荐已审核通过的用户) + if (userId <= 0) + { + return await GetDefaultRecommendAsync(pageIndex, pageSize); + } + // 获取今日推荐列表 var recommends = await _recommendRepository.GetListAsync( r => r.UserId == userId && r.RecommendDate == today); @@ -92,9 +98,16 @@ public class RecommendService : IRecommendService r => r.UserId == userId && r.RecommendDate == today); } + var total = recommends.Count; + var pagedRecommends = recommends + .OrderBy(r => r.Sort) + .Skip((pageIndex - 1) * pageSize) + .Take(pageSize) + .ToList(); + var result = new List(); - foreach (var recommend in recommends.OrderBy(r => r.Sort)) + foreach (var recommend in pagedRecommends) { var recommendUser = await _userRepository.GetByIdAsync(recommend.RecommendUserId); if (recommendUser == null || recommendUser.Status != 1) @@ -117,12 +130,18 @@ public class RecommendService : IRecommendService UserId = recommendUser.Id, Nickname = recommendUser.Nickname, Avatar = recommendUser.Avatar, + Gender = recommendUser.Gender ?? 0, Age = DateTime.Now.Year - profile.BirthYear, + BirthYear = profile.BirthYear, WorkCity = profile.WorkCity, + Hometown = profile.HomeCity, Height = profile.Height, + Weight = profile.Weight, Education = profile.Education, EducationName = GetEducationName(profile.Education), Occupation = profile.Occupation, + MonthlyIncome = profile.MonthlyIncome, + Intro = profile.Introduction, IsMember = recommendUser.IsMember, IsRealName = recommendUser.IsRealName, IsPhotoPublic = profile.IsPhotoPublic, @@ -134,7 +153,85 @@ public class RecommendService : IRecommendService }); } - return result; + return new PagedResult + { + Items = result, + Total = total, + PageIndex = pageIndex, + PageSize = pageSize + }; + } + + /// + /// 获取默认推荐列表(未登录用户) + /// + private async Task> GetDefaultRecommendAsync(int pageIndex, int pageSize) + { + // 获取已完成资料、状态正常的用户 + var allUsers = await _userRepository.GetListAsync( + u => u.IsProfileCompleted && u.Status == 1); + + // 过滤出审核通过的用户 + var validUsers = new List<(Core.Entities.Biz.User User, Core.Entities.Biz.UserProfile Profile)>(); + foreach (var user in allUsers.OrderByDescending(u => u.CreateTime)) + { + var profiles = await _profileRepository.GetListAsync(p => p.UserId == user.Id); + var profile = profiles.FirstOrDefault(); + if (profile != null && profile.AuditStatus == (int)AuditStatus.Approved) + { + validUsers.Add((user, profile)); + } + } + + var total = validUsers.Count; + var pagedUsers = validUsers + .Skip((pageIndex - 1) * pageSize) + .Take(pageSize) + .ToList(); + + var result = new List(); + var sort = (pageIndex - 1) * pageSize; + + foreach (var (user, profile) in pagedUsers) + { + var photos = await _photoRepository.GetListAsync(p => p.UserId == user.Id); + var firstPhoto = profile.IsPhotoPublic ? photos.OrderBy(p => p.Sort).FirstOrDefault()?.PhotoUrl : null; + + result.Add(new RecommendUserResponse + { + UserId = user.Id, + Nickname = user.Nickname, + Avatar = user.Avatar, + Gender = user.Gender ?? 0, + Age = DateTime.Now.Year - profile.BirthYear, + BirthYear = profile.BirthYear, + WorkCity = profile.WorkCity, + Hometown = profile.HomeCity, + Height = profile.Height, + Weight = profile.Weight, + Education = profile.Education, + EducationName = GetEducationName(profile.Education), + Occupation = profile.Occupation, + MonthlyIncome = profile.MonthlyIncome, + Intro = profile.Introduction, + IsMember = user.IsMember, + IsRealName = user.IsRealName, + IsPhotoPublic = profile.IsPhotoPublic, + FirstPhoto = firstPhoto, + ViewedToday = false, + MatchScore = 0, + Sort = sort++, + IsNewUser = user.CreateTime > DateTime.Now.AddMinutes(-NewUserPriorityMinutes) + }); + } + + return new PagedResult + { + Items = result, + Total = total, + PageIndex = pageIndex, + PageSize = pageSize + }; } /// diff --git a/server/src/XiangYi.Application/Services/SystemConfigService.cs b/server/src/XiangYi.Application/Services/SystemConfigService.cs new file mode 100644 index 0000000..5e0d512 --- /dev/null +++ b/server/src/XiangYi.Application/Services/SystemConfigService.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.Logging; +using XiangYi.Application.Interfaces; +using XiangYi.Core.Entities.Biz; +using XiangYi.Core.Interfaces; + +namespace XiangYi.Application.Services; + +/// +/// 系统配置服务实现 +/// +public class SystemConfigService : ISystemConfigService +{ + private readonly IRepository _configRepository; + private readonly ILogger _logger; + + /// + /// 默认头像配置键 + /// + public const string DefaultAvatarKey = "default_avatar"; + + public SystemConfigService( + IRepository configRepository, + ILogger logger) + { + _configRepository = configRepository; + _logger = logger; + } + + /// + public async Task GetConfigValueAsync(string key) + { + var configs = await _configRepository.GetListAsync(c => c.ConfigKey == key); + return configs.FirstOrDefault()?.ConfigValue; + } + + /// + public async Task SetConfigValueAsync(string key, string value, string? description = null) + { + try + { + var configs = await _configRepository.GetListAsync(c => c.ConfigKey == key); + var config = configs.FirstOrDefault(); + + if (config != null) + { + config.ConfigValue = value; + config.UpdateTime = DateTime.Now; + if (description != null) + { + config.Description = description; + } + await _configRepository.UpdateAsync(config); + } + else + { + config = new SystemConfig + { + ConfigKey = key, + ConfigValue = value, + ConfigType = "system", + Description = description, + CreateTime = DateTime.Now, + UpdateTime = DateTime.Now + }; + await _configRepository.AddAsync(config); + } + + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "设置配置失败: {Key}", key); + return false; + } + } + + /// + public async Task GetDefaultAvatarAsync() + { + return await GetConfigValueAsync(DefaultAvatarKey); + } + + /// + public async Task SetDefaultAvatarAsync(string avatarUrl) + { + return await SetConfigValueAsync(DefaultAvatarKey, avatarUrl, "新用户默认头像"); + } + + /// + public async Task> GetAllConfigsAsync() + { + var configs = await _configRepository.GetListAsync(c => true); + return configs.ToDictionary(c => c.ConfigKey, c => c.ConfigValue); + } +}