From 2d0c71721d3f55c2ca2aeaebfda0426e5d380c1d Mon Sep 17 00:00:00 2001 From: 18631081161 <2088094923@qq.com> Date: Sun, 29 Mar 2026 21:13:50 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B9bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/src/views/Orders.vue | 60 +- admin/src/views/Reviews.vue | 167 +- admin/src/views/Runners.vue | 74 +- admin/src/views/Users.vue | 19 +- miniapp/pages/delivery/delivery.vue | 10 +- miniapp/pages/food/food-order.vue | 11 +- miniapp/pages/food/food.vue | 4 + miniapp/pages/food/shop-detail.vue | 4 + miniapp/pages/help/help.vue | 11 +- miniapp/pages/message/message.vue | 24 +- miniapp/pages/mine/mine.vue | 4 +- miniapp/pages/order/my-orders.vue | 8 +- miniapp/pages/order/my-taken.vue | 7 +- miniapp/pages/pickup/pickup.vue | 12 +- miniapp/pages/purchase/purchase.vue | 11 +- miniapp/utils/request.js | 4 +- server/Endpoints/AdminEndpoints.cs | 590 ++++ server/Endpoints/AuthEndpoints.cs | 168 ++ server/Endpoints/BannerEndpoints.cs | 147 + server/Endpoints/ConfigEndpoints.cs | 193 ++ server/Endpoints/EarningEndpoints.cs | 330 +++ server/Endpoints/IMEndpoints.cs | 25 + server/Endpoints/MessageEndpoints.cs | 222 ++ server/Endpoints/OrderEndpoints.cs | 1003 +++++++ server/Endpoints/RunnerEndpoints.cs | 94 + server/Endpoints/ServiceEntryEndpoints.cs | 125 + server/Endpoints/ShopEndpoints.cs | 360 +++ server/Helpers/BusinessHelpers.cs | 191 ++ server/Models/Dtos/OrderDtos.cs | 9 + server/Program.cs | 3194 +-------------------- server/Services/WxPayService.cs | 239 ++ server/appsettings.json | 7 +- 32 files changed, 4095 insertions(+), 3232 deletions(-) create mode 100644 server/Endpoints/AdminEndpoints.cs create mode 100644 server/Endpoints/AuthEndpoints.cs create mode 100644 server/Endpoints/BannerEndpoints.cs create mode 100644 server/Endpoints/ConfigEndpoints.cs create mode 100644 server/Endpoints/EarningEndpoints.cs create mode 100644 server/Endpoints/IMEndpoints.cs create mode 100644 server/Endpoints/MessageEndpoints.cs create mode 100644 server/Endpoints/OrderEndpoints.cs create mode 100644 server/Endpoints/RunnerEndpoints.cs create mode 100644 server/Endpoints/ServiceEntryEndpoints.cs create mode 100644 server/Endpoints/ShopEndpoints.cs create mode 100644 server/Helpers/BusinessHelpers.cs create mode 100644 server/Services/WxPayService.cs diff --git a/admin/src/views/Orders.vue b/admin/src/views/Orders.vue index e73ec69..c334a06 100644 --- a/admin/src/views/Orders.vue +++ b/admin/src/views/Orders.vue @@ -37,8 +37,11 @@ - + + + + + + 取消后将发起微信退款,单主支付的金额原路返回 + + + + {{ cancelOrderNo }} + + + ¥{{ cancelTotalAmount }} + + + + + + + @@ -83,6 +108,11 @@ const typeFilter = ref('') const resolveDialogVisible = ref(false) const resolveOrderId = ref(null) const resolveForm = reactive({ result: '', newStatus: 'Completed' }) +const cancelDialogVisible = ref(false) +const cancelOrderId = ref(null) +const cancelOrderNo = ref('') +const cancelTotalAmount = ref(0) +const cancelReason = ref('') const typeLabel = (t) => ({ Pickup: '代取', Delivery: '代送', Help: '万能帮', Purchase: '代购', Food: '美食街' }[t] || t) const statusLabel = (s) => ({ Pending: '待接单', InProgress: '进行中', WaitConfirm: '待确认', Completed: '已完成', Cancelled: '已取消', Appealing: '申诉中' }[s] || s) @@ -131,5 +161,33 @@ async function handleResolve() { } } +function openCancel(row) { + cancelOrderId.value = row.id + cancelOrderNo.value = row.orderNo + cancelTotalAmount.value = row.totalAmount + cancelReason.value = '' + cancelDialogVisible.value = true +} + +async function handleCancel() { + if (!cancelReason.value.trim()) { + ElMessage.warning('请填写取消原因') + return + } + submitting.value = true + try { + const res = await request.post(`/admin/orders/${cancelOrderId.value}/cancel`, { reason: cancelReason.value }) + if (res.refundSuccess) { + ElMessage.success('订单已取消,退款已发起') + } else { + ElMessage.warning('订单已取消,但退款失败,请手动处理') + } + cancelDialogVisible.value = false + fetchList() + } finally { + submitting.value = false + } +} + onMounted(fetchList) diff --git a/admin/src/views/Reviews.vue b/admin/src/views/Reviews.vue index 00b7758..9078a55 100644 --- a/admin/src/views/Reviews.vue +++ b/admin/src/views/Reviews.vue @@ -1,43 +1,73 @@ @@ -103,6 +114,12 @@ async function toggleBan(row, isBanned) { fetchList() } +async function deleteUser(row) { + await request.delete(`/admin/users/${row.id}`) + ElMessage.success('用户已删除') + fetchList() +} + function getRoleLabel(role) { const map = { User: '普通用户', Runner: '跑腿', Admin: '管理员' } return map[role] || role diff --git a/miniapp/pages/delivery/delivery.vue b/miniapp/pages/delivery/delivery.vue index 341d23a..bce0a22 100644 --- a/miniapp/pages/delivery/delivery.vue +++ b/miniapp/pages/delivery/delivery.vue @@ -178,13 +178,19 @@ export default { }) if (result.paymentParams) await this.wxPay(result.paymentParams) uni.showToast({ title: '下单成功', icon: 'success' }) - setTimeout(() => { uni.switchTab({ url: '/pages/index/index' }) }, 1500) + setTimeout(() => { uni.navigateBack() }, 1500) } catch (e) {} finally { this.submitting = false } }, wxPay(params) { return new Promise((resolve, reject) => { uni.requestPayment({ - ...params, success: resolve, + provider: 'wxpay', + timeStamp: params.timeStamp, + nonceStr: params.nonceStr, + package: params.package_, + signType: params.signType, + paySign: params.paySign, + success: resolve, fail: (err) => { if (err.errMsg !== 'requestPayment:fail cancel') uni.showToast({ title: '支付失败', icon: 'none' }) diff --git a/miniapp/pages/food/food-order.vue b/miniapp/pages/food/food-order.vue index af6dfae..0e595f6 100644 --- a/miniapp/pages/food/food-order.vue +++ b/miniapp/pages/food/food-order.vue @@ -195,7 +195,12 @@ export default { wxPay(params) { return new Promise((resolve, reject) => { uni.requestPayment({ - ...params, + provider: 'wxpay', + timeStamp: params.timeStamp, + nonceStr: params.nonceStr, + package: params.package_, + signType: params.signType, + paySign: params.paySign, success: resolve, fail: (err) => { if (err.errMsg !== 'requestPayment:fail cancel') { @@ -412,6 +417,10 @@ export default { margin-right: 10rpx; } +.submit-btn::after { + border: none; +} + .submit-btn[disabled] { opacity: 0.6; } diff --git a/miniapp/pages/food/food.vue b/miniapp/pages/food/food.vue index 81e5c9c..28018b8 100644 --- a/miniapp/pages/food/food.vue +++ b/miniapp/pages/food/food.vue @@ -346,6 +346,10 @@ export default { margin-right: 10rpx; } +.cart-checkout-btn::after { + border: none; +} + .cart-checkout-btn.disabled { background: #e0e0e0; color: #999; diff --git a/miniapp/pages/food/shop-detail.vue b/miniapp/pages/food/shop-detail.vue index 7cd5c4e..03c4095 100644 --- a/miniapp/pages/food/shop-detail.vue +++ b/miniapp/pages/food/shop-detail.vue @@ -438,6 +438,10 @@ margin-right: 10rpx; } + .cart-checkout-btn::after { + border: none; + } + .cart-checkout-btn.disabled { background: #e0e0e0; color: #999; diff --git a/miniapp/pages/help/help.vue b/miniapp/pages/help/help.vue index 3b0846f..8544193 100644 --- a/miniapp/pages/help/help.vue +++ b/miniapp/pages/help/help.vue @@ -233,7 +233,12 @@ wxPay(params) { return new Promise((resolve, reject) => { uni.requestPayment({ - ...params, + provider: 'wxpay', + timeStamp: params.timeStamp, + nonceStr: params.nonceStr, + package: params.package_, + signType: params.signType, + paySign: params.paySign, success: resolve, fail: (err) => { if (err.errMsg !== 'requestPayment:fail cancel') @@ -390,6 +395,10 @@ text-align: center; } + .submit-btn::after { + border: none; + } + .submit-btn[disabled] { opacity: 0.6; } diff --git a/miniapp/pages/message/message.vue b/miniapp/pages/message/message.vue index 7907e52..d2a0159 100644 --- a/miniapp/pages/message/message.vue +++ b/miniapp/pages/message/message.vue @@ -60,7 +60,7 @@ {{ getOrderLabel(item) }} - + {{ getStatusLabel(item.status) }} @@ -359,12 +359,30 @@ export default { .chat-tag text { font-size: 20rpx; - color: #FFB700; - background: #FFF8E6; padding: 4rpx 12rpx; border-radius: 6rpx; } +.tag-Completed text { + color: #52c41a; + background: #f6ffed; +} + +.tag-InProgress text { + color: #e64340; + background: #fff1f0; +} + +.tag-WaitConfirm text { + color: #FFB700; + background: #FFF8E6; +} + +.tag-Pending text { + color: #999; + background: #f5f5f5; +} + .empty-chat { text-align: center; padding: 80rpx 0; diff --git a/miniapp/pages/mine/mine.vue b/miniapp/pages/mine/mine.vue index 5094929..b7a190d 100644 --- a/miniapp/pages/mine/mine.vue +++ b/miniapp/pages/mine/mine.vue @@ -25,7 +25,7 @@ - + 进行中 {{ stats.orderOngoing }} @@ -44,7 +44,7 @@ - + 进行中 {{ stats.takenOngoing }} diff --git a/miniapp/pages/order/my-orders.vue b/miniapp/pages/order/my-orders.vue index a40363c..23c1b8d 100644 --- a/miniapp/pages/order/my-orders.vue +++ b/miniapp/pages/order/my-orders.vue @@ -179,8 +179,7 @@ export default { statusTabs: [ { label: '全部', value: '' }, { label: '待接单', value: 'Pending' }, - { label: '进行中', value: 'InProgress' }, - { label: '待确认', value: 'WaitConfirm' }, + { label: '进行中', value: 'InProgress,WaitConfirm' }, { label: '已完成', value: 'Completed' }, { label: '已取消', value: 'Cancelled' }, { label: '申诉中', value: 'Appealing' } @@ -209,7 +208,10 @@ export default { /** 按状态和类型过滤订单 */ filteredOrders() { return this.orders.filter(o => { - if (this.currentStatus && o.status !== this.currentStatus) return false + if (this.currentStatus) { + const statuses = this.currentStatus.split(',') + if (!statuses.includes(o.status)) return false + } if (this.currentType && o.orderType !== this.currentType) return false return true }) diff --git a/miniapp/pages/order/my-taken.vue b/miniapp/pages/order/my-taken.vue index 289c4f1..c661996 100644 --- a/miniapp/pages/order/my-taken.vue +++ b/miniapp/pages/order/my-taken.vue @@ -123,7 +123,7 @@ export default { return { statusTabs: [ { label: '全部', value: '' }, - { label: '进行中', value: 'InProgress' }, + { label: '进行中', value: 'InProgress,WaitConfirm' }, { label: '已完成', value: 'Completed' }, { label: '已取消', value: 'Cancelled' } ], @@ -145,7 +145,10 @@ export default { computed: { filteredOrders() { return this.orders.filter(o => { - if (this.currentStatus && o.status !== this.currentStatus) return false + if (this.currentStatus) { + const statuses = this.currentStatus.split(',') + if (!statuses.includes(o.status)) return false + } if (this.currentType && o.orderType !== this.currentType) return false return true }) diff --git a/miniapp/pages/pickup/pickup.vue b/miniapp/pages/pickup/pickup.vue index c7e2b1e..4f84f37 100644 --- a/miniapp/pages/pickup/pickup.vue +++ b/miniapp/pages/pickup/pickup.vue @@ -191,7 +191,13 @@ export default { wxPay(params) { return new Promise((resolve, reject) => { uni.requestPayment({ - ...params, success: resolve, + provider: 'wxpay', + timeStamp: params.timeStamp, + nonceStr: params.nonceStr, + package: params.package_, + signType: params.signType, + paySign: params.paySign, + success: resolve, fail: (err) => { if (err.errMsg !== 'requestPayment:fail cancel') uni.showToast({ title: '支付失败', icon: 'none' }) @@ -329,6 +335,10 @@ export default { text-align: center; } +.submit-btn::after { + border: none; +} + .submit-btn[disabled] { opacity: 0.6; } diff --git a/miniapp/pages/purchase/purchase.vue b/miniapp/pages/purchase/purchase.vue index ca0fcda..8a2ee70 100644 --- a/miniapp/pages/purchase/purchase.vue +++ b/miniapp/pages/purchase/purchase.vue @@ -258,7 +258,12 @@ wxPay(params) { return new Promise((resolve, reject) => { uni.requestPayment({ - ...params, + provider: 'wxpay', + timeStamp: params.timeStamp, + nonceStr: params.nonceStr, + package: params.package_, + signType: params.signType, + paySign: params.paySign, success: resolve, fail: (err) => { if (err.errMsg !== 'requestPayment:fail cancel') @@ -411,6 +416,10 @@ text-align: center; } + .submit-btn::after { + border: none; + } + .submit-btn[disabled] { opacity: 0.6; } diff --git a/miniapp/utils/request.js b/miniapp/utils/request.js index 19c6591..aa75b5d 100644 --- a/miniapp/utils/request.js +++ b/miniapp/utils/request.js @@ -6,8 +6,8 @@ */ // API 基础地址,按环境切换 -const BASE_URL = 'http://localhost:5099' -// const BASE_URL = 'http://api.zwz.shhmkjgs.cn' +// const BASE_URL = 'http://localhost:5099' +const BASE_URL = 'http://api.zwz.shhmkjgs.cn' /** * 获取本地存储的 token diff --git a/server/Endpoints/AdminEndpoints.cs b/server/Endpoints/AdminEndpoints.cs new file mode 100644 index 0000000..d1e132c --- /dev/null +++ b/server/Endpoints/AdminEndpoints.cs @@ -0,0 +1,590 @@ +using CampusErrand.Data; +using CampusErrand.Models; +using CampusErrand.Models.Dtos; +using CampusErrand.Services; +using Microsoft.EntityFrameworkCore; + +namespace CampusErrand.Endpoints; + +public static class AdminEndpoints +{ + public static void MapAdminEndpoints(this WebApplication app) + { + // 首页概览统计接口 + app.MapGet("/api/admin/dashboard", async (AppDbContext db) => + { + var today = DateTime.UtcNow.Date; + + // 用户统计 + var totalUsers = await db.Users.CountAsync(); + var todayUsers = await db.Users.CountAsync(u => u.CreatedAt >= today); + + // 订单统计 + var totalOrders = await db.Orders.CountAsync(); + var todayOrders = await db.Orders.CountAsync(o => o.CreatedAt >= today); + var pendingOrders = await db.Orders.CountAsync(o => o.Status == OrderStatus.Pending); + var inProgressOrders = await db.Orders.CountAsync(o => o.Status == OrderStatus.InProgress); + var completedOrders = await db.Orders.CountAsync(o => o.Status == OrderStatus.Completed); + + // 收益统计 + var totalEarnings = await db.Earnings.SumAsync(e => (decimal?)e.Commission) ?? 0; + var todayEarnings = await db.Earnings.Where(e => e.CreatedAt >= today).SumAsync(e => (decimal?)e.Commission) ?? 0; + + // 跑腿认证统计 + var pendingCertifications = await db.RunnerCertifications.CountAsync(c => c.Status == CertificationStatus.Pending); + var approvedRunners = await db.RunnerCertifications.CountAsync(c => c.Status == CertificationStatus.Approved); + + // 门店统计 + var totalShops = await db.Shops.CountAsync(); + var enabledShops = await db.Shops.CountAsync(s => s.IsEnabled); + + // 最近7天订单趋势 + var sevenDaysAgo = today.AddDays(-6); + var orderTrend = await db.Orders + .Where(o => o.CreatedAt >= sevenDaysAgo) + .GroupBy(o => o.CreatedAt.Date) + .Select(g => new { Date = g.Key, Count = g.Count() }) + .OrderBy(x => x.Date) + .ToListAsync(); + + // 补全7天数据(没有订单的日期补0) + var trendList = Enumerable.Range(0, 7).Select(i => + { + var date = sevenDaysAgo.AddDays(i); + var count = orderTrend.FirstOrDefault(x => x.Date == date)?.Count ?? 0; + return new { Date = date.ToString("MM-dd"), Count = count }; + }).ToList(); + + // 订单类型分布 + var orderTypeDistribution = await db.Orders + .GroupBy(o => o.OrderType) + .Select(g => new { Type = g.Key.ToString(), Count = g.Count() }) + .ToListAsync(); + + return Results.Ok(new + { + users = new { total = totalUsers, today = todayUsers }, + orders = new { total = totalOrders, today = todayOrders, pending = pendingOrders, inProgress = inProgressOrders, completed = completedOrders }, + earnings = new { total = totalEarnings, today = todayEarnings }, + runners = new { pendingCertifications, approved = approvedRunners }, + shops = new { total = totalShops, enabled = enabledShops }, + orderTrend = trendList, + orderTypeDistribution + }); + }).RequireAuthorization("AdminOnly"); + + app.MapGet("/api/admin/protected", () => Results.Ok("admin ok")) + .RequireAuthorization("AdminOnly"); + + // 管理端获取用户列表 + app.MapGet("/api/admin/users", async (string? keyword, AppDbContext db) => + { + var query = db.Users.AsQueryable(); + + // 关键词搜索(昵称、手机号、ID) + if (!string.IsNullOrWhiteSpace(keyword)) + { + var kw = keyword.Trim(); + if (int.TryParse(kw, out var uid)) + { + query = query.Where(u => u.Id == uid || u.Nickname.Contains(kw) || u.Phone.Contains(kw)); + } + else + { + query = query.Where(u => u.Nickname.Contains(kw) || u.Phone.Contains(kw)); + } + } + + var users = await query.OrderByDescending(u => u.CreatedAt) + .Select(u => new + { + u.Id, + u.Nickname, + u.AvatarUrl, + u.Phone, + Role = u.Role.ToString(), + u.RunnerScore, + u.IsBanned, + u.CreatedAt, + // 查询该用户的订单数 + OrderCount = db.Orders.Count(o => o.OwnerId == u.Id) + }) + .ToListAsync(); + + return Results.Ok(users); + }).RequireAuthorization("AdminOnly"); + + // 管理端封禁/解封用户 + app.MapPut("/api/admin/users/{id}/ban", async (int id, BanRunnerRequest request, AppDbContext db) => + { + var user = await db.Users.FindAsync(id); + if (user == null) + return Results.NotFound(new { code = 404, message = "用户不存在" }); + + user.IsBanned = request.IsBanned; + await db.SaveChangesAsync(); + + return Results.Ok(new { user.Id, user.IsBanned }); + }).RequireAuthorization("AdminOnly"); + + // 管理端删除用户 + app.MapDelete("/api/admin/users/{id}", async (int id, AppDbContext db) => + { + var user = await db.Users.FindAsync(id); + if (user == null) + return Results.NotFound(new { code = 404, message = "用户不存在" }); + + // 不允许删除管理员 + if (user.Role == UserRole.Admin) + return Results.BadRequest(new { code = 400, message = "不能删除管理员账号" }); + + // 删除关联数据 + var certifications = db.RunnerCertifications.Where(c => c.UserId == id); + db.RunnerCertifications.RemoveRange(certifications); + + var reviews = db.Reviews.Where(r => r.RunnerId == id); + db.Reviews.RemoveRange(reviews); + + var earnings = db.Earnings.Where(e => e.UserId == id); + db.Earnings.RemoveRange(earnings); + + db.Users.Remove(user); + await db.SaveChangesAsync(); + + return Results.Ok(new { message = "用户已删除" }); + }).RequireAuthorization("AdminOnly"); + + // 管理端获取跑腿列表 + app.MapGet("/api/admin/runners", async (AppDbContext db) => + { + var runners = await db.RunnerCertifications + .Where(c => c.Status == CertificationStatus.Approved) + .Join(db.Users, c => c.UserId, u => u.Id, (c, u) => new + { + u.Id, + u.Nickname, + Phone = c.Phone, + u.RunnerScore, + u.IsBanned, + u.CreatedAt + }) + .ToListAsync(); + + // 查询每个跑腿的评价统计 + var runnerIds = runners.Select(r => r.Id).ToList(); + var reviewStats = await db.Reviews + .Where(r => runnerIds.Contains(r.RunnerId) && !r.IsDisabled) + .GroupBy(r => r.RunnerId) + .Select(g => new + { + RunnerId = g.Key, + AvgRating = Math.Round(g.Average(r => (double)r.Rating), 1), + ReviewCount = g.Count() + }) + .ToListAsync(); + + var statsMap = reviewStats.ToDictionary(s => s.RunnerId); + + var result = runners.Select(r => + { + statsMap.TryGetValue(r.Id, out var stats); + return new + { + r.Id, r.Nickname, r.Phone, r.RunnerScore, r.IsBanned, r.CreatedAt, + AvgRating = stats?.AvgRating ?? 0, + ReviewCount = stats?.ReviewCount ?? 0 + }; + }).ToList(); + + return Results.Ok(result); + }).RequireAuthorization("AdminOnly"); + + // 管理端封禁/解封跑腿 + app.MapPut("/api/admin/runners/{id}/ban", async (int id, BanRunnerRequest request, AppDbContext db) => + { + var user = await db.Users.FindAsync(id); + if (user == null) + return Results.NotFound(new { code = 404, message = "用户不存在" }); + + // 确认该用户是已认证的跑腿 + var hasCert = await db.RunnerCertifications + .AnyAsync(c => c.UserId == id && c.Status == CertificationStatus.Approved); + if (!hasCert) + return Results.BadRequest(new { code = 400, message = "该用户不是已认证的跑腿" }); + + user.IsBanned = request.IsBanned; + await db.SaveChangesAsync(); + + return Results.Ok(new + { + user.Id, + user.Nickname, + user.Phone, + user.RunnerScore, + user.IsBanned + }); + }).RequireAuthorization("AdminOnly"); + + // 管理端查看跑腿评价记录 + app.MapGet("/api/admin/runners/{id}/reviews", async (int id, AppDbContext db) => + { + var reviews = await db.Reviews + .Where(r => r.RunnerId == id) + .Include(r => r.Order) + .OrderByDescending(r => r.CreatedAt) + .Select(r => new + { + r.Id, + r.OrderId, + OrderNo = r.Order != null ? r.Order.OrderNo : "", + OrderType = r.Order != null ? r.Order.OrderType.ToString() : "", + r.Rating, + r.Content, + r.ScoreChange, + r.IsDisabled, + r.CreatedAt + }) + .ToListAsync(); + + return Results.Ok(reviews); + }).RequireAuthorization("AdminOnly"); + + // 管理端获取评价列表 + app.MapGet("/api/admin/reviews", async (int? runnerId, AppDbContext db) => + { + var query = db.Reviews + .Include(r => r.Order) + .Include(r => r.Runner) + .AsQueryable(); + + if (runnerId.HasValue) + query = query.Where(r => r.RunnerId == runnerId.Value); + + var reviews = await query + .OrderByDescending(r => r.CreatedAt) + .Select(r => new AdminReviewResponse + { + Id = r.Id, + OrderId = r.OrderId, + OrderNo = r.Order != null ? r.Order.OrderNo : "", + RunnerId = r.RunnerId, + RunnerNickname = r.Runner != null ? r.Runner.Nickname : null, + Rating = r.Rating, + Content = r.Content, + ScoreChange = r.ScoreChange, + IsDisabled = r.IsDisabled, + CreatedAt = r.CreatedAt + }) + .ToListAsync(); + + return Results.Ok(reviews); + }).RequireAuthorization("AdminOnly"); + + // 管理端禁用评价 + app.MapPut("/api/admin/reviews/{id}/disable", async (int id, AppDbContext db) => + { + var review = await db.Reviews.FindAsync(id); + if (review == null) + return Results.NotFound(new { code = 404, message = "评价不存在" }); + + if (review.IsDisabled) + return Results.BadRequest(new { code = 400, message = "该评价已被禁用" }); + + review.IsDisabled = true; + + // 回退该评价对跑腿分数的影响 + var runner = await db.Users.FindAsync(review.RunnerId); + if (runner != null) + { + runner.RunnerScore = Math.Clamp(runner.RunnerScore - review.ScoreChange, 0, 100); + } + + await db.SaveChangesAsync(); + + return Results.Ok(new ReviewResponse + { + Id = review.Id, + OrderId = review.OrderId, + RunnerId = review.RunnerId, + Rating = review.Rating, + Content = review.Content, + ScoreChange = review.ScoreChange, + IsDisabled = review.IsDisabled, + CreatedAt = review.CreatedAt + }); + }).RequireAuthorization("AdminOnly"); + + // 管理端获取订单列表 + app.MapGet("/api/admin/orders", async (string? status, string? orderType, AppDbContext db) => + { + var query = db.Orders.AsQueryable(); + + if (!string.IsNullOrEmpty(status) && Enum.TryParse(status, true, out var s)) + query = query.Where(o => o.Status == s); + + if (!string.IsNullOrEmpty(orderType) && Enum.TryParse(orderType, true, out var t)) + query = query.Where(o => o.OrderType == t); + + var orders = await query + .OrderByDescending(o => o.CreatedAt) + .Select(o => new + { + o.Id, + o.OrderNo, + o.OwnerId, + o.RunnerId, + OrderType = o.OrderType.ToString(), + Status = o.Status.ToString(), + o.ItemName, + o.DeliveryLocation, + o.Commission, + o.GoodsAmount, + o.TotalAmount, + o.CreatedAt, + o.AcceptedAt, + o.CompletedAt + }) + .ToListAsync(); + + return Results.Ok(orders); + }).RequireAuthorization("AdminOnly"); + + // 管理端将订单状态改为申诉中 + app.MapPost("/api/admin/orders/{id}/appeal", async (int id, AppDbContext db) => + { + var order = await db.Orders.FindAsync(id); + if (order == null) + return Results.NotFound(new { code = 404, message = "订单不存在" }); + + if (order.Status != OrderStatus.InProgress && order.Status != OrderStatus.WaitConfirm) + return Results.BadRequest(new { code = 400, message = "当前订单状态不支持申诉" }); + + order.Status = OrderStatus.Appealing; + await db.SaveChangesAsync(); + + return Results.Ok(new { id = order.Id, orderNo = order.OrderNo, status = order.Status.ToString() }); + }).RequireAuthorization("AdminOnly"); + + // 管理端处理申诉结果 + app.MapPost("/api/admin/orders/{id}/appeal/resolve", async (int id, ResolveAppealRequest request, AppDbContext db) => + { + var order = await db.Orders.FindAsync(id); + if (order == null) + return Results.NotFound(new { code = 404, message = "订单不存在" }); + + if (order.Status != OrderStatus.Appealing) + return Results.BadRequest(new { code = 400, message = "仅申诉中的订单可处理" }); + + if (string.IsNullOrWhiteSpace(request.Result)) + return Results.BadRequest(new { code = 400, message = "处理结果不能为空" }); + + if (!Enum.TryParse(request.NewStatus, true, out var newStatus) + || (newStatus != OrderStatus.Completed && newStatus != OrderStatus.Cancelled)) + return Results.BadRequest(new { code = 400, message = "目标状态不合法,仅支持 Completed 或 Cancelled" }); + + // 创建申诉记录 + var appeal = new Appeal + { + OrderId = id, + Result = request.Result, + CreatedAt = DateTime.UtcNow + }; + db.Appeals.Add(appeal); + + // 更新订单状态 + order.Status = newStatus; + if (newStatus == OrderStatus.Completed && order.CompletedAt == null) + { + order.CompletedAt = DateTime.UtcNow; + } + + await db.SaveChangesAsync(); + + return Results.Ok(new + { + id = order.Id, + orderNo = order.OrderNo, + status = order.Status.ToString(), + appealId = appeal.Id, + appealResult = appeal.Result, + appealCreatedAt = appeal.CreatedAt + }); + }).RequireAuthorization("AdminOnly"); + + // 管理端取消订单(含退款) + app.MapPost("/api/admin/orders/{id}/cancel", async (int id, AdminCancelOrderRequest request, AppDbContext db, WxPayService wxPay) => + { + var order = await db.Orders.FindAsync(id); + if (order == null) + return Results.NotFound(new { code = 404, message = "订单不存在" }); + + if (order.Status == OrderStatus.Cancelled) + return Results.BadRequest(new { code = 400, message = "订单已取消" }); + + if (order.Status == OrderStatus.Completed) + return Results.BadRequest(new { code = 400, message = "已完成的订单不能取消" }); + + if (string.IsNullOrWhiteSpace(request.Reason)) + return Results.BadRequest(new { code = 400, message = "请填写取消原因" }); + + // 更新订单状态 + order.Status = OrderStatus.Cancelled; + await db.SaveChangesAsync(); + + // 发起微信退款(原路返回) + var totalFen = (int)(order.TotalAmount * 100); + var refundNo = $"R{order.OrderNo}"; + var refundResult = await wxPay.Refund(order.OrderNo, refundNo, totalFen, totalFen, request.Reason); + + Console.WriteLine($"[管理端] 取消订单 {order.OrderNo},原因:{request.Reason},退款:{(refundResult ? "成功" : "失败")}"); + + return Results.Ok(new + { + id = order.Id, + orderNo = order.OrderNo, + status = order.Status.ToString(), + refundSuccess = refundResult, + reason = request.Reason + }); + }).RequireAuthorization("AdminOnly"); + + // 管理端获取认证列表 + app.MapGet("/api/admin/certifications", async (string? status, AppDbContext db) => + { + var query = db.RunnerCertifications + .Include(c => c.User) + .AsQueryable(); + + // 按状态筛选 + if (!string.IsNullOrEmpty(status) && Enum.TryParse(status, true, out var certStatus)) + { + query = query.Where(c => c.Status == certStatus); + } + + var certifications = await query + .OrderByDescending(c => c.CreatedAt) + .Select(c => new AdminCertificationResponse + { + Id = c.Id, + UserId = c.UserId, + RealName = c.RealName, + Phone = c.Phone, + Status = c.Status.ToString(), + CreatedAt = c.CreatedAt, + ReviewedAt = c.ReviewedAt, + UserNickname = c.User != null ? c.User.Nickname : null, + UserPhone = c.User != null ? c.User.Phone : null + }) + .ToListAsync(); + + return Results.Ok(certifications); + }).RequireAuthorization("AdminOnly"); + + // 管理端审核认证 + app.MapPut("/api/admin/certifications/{id}", async (int id, ReviewCertificationRequest request, AppDbContext db) => + { + var certification = await db.RunnerCertifications + .Include(c => c.User) + .FirstOrDefaultAsync(c => c.Id == id); + + if (certification == null) + { + return Results.NotFound(new { code = 404, message = "认证记录不存在" }); + } + + if (certification.Status != CertificationStatus.Pending) + { + return Results.BadRequest(new { code = 400, message = "该认证已审核" }); + } + + if (!Enum.TryParse(request.Status, true, out var newStatus) + || (newStatus != CertificationStatus.Approved && newStatus != CertificationStatus.Rejected)) + { + return Results.BadRequest(new { code = 400, message = "审核结果不合法,仅支持 Approved 或 Rejected" }); + } + + certification.Status = newStatus; + certification.ReviewedAt = DateTime.UtcNow; + + // 审核通过时,更新用户角色为 Runner + if (newStatus == CertificationStatus.Approved && certification.User != null) + { + certification.User.Role = UserRole.Runner; + } + + await db.SaveChangesAsync(); + + return Results.Ok(new CertificationResponse + { + Id = certification.Id, + UserId = certification.UserId, + RealName = certification.RealName, + Phone = certification.Phone, + Status = certification.Status.ToString(), + CreatedAt = certification.CreatedAt, + ReviewedAt = certification.ReviewedAt + }); + }).RequireAuthorization("AdminOnly"); + + // 管理端聊天列表 + app.MapGet("/api/admin/chat-list", async (AppDbContext db) => + { + var orders = await db.Orders + .Where(o => o.RunnerId != null && o.Status != OrderStatus.Cancelled) + .OrderByDescending(o => o.CompletedAt ?? o.AcceptedAt ?? o.CreatedAt) + .Select(o => new + { + o.Id, + o.OrderNo, + OrderType = o.OrderType.ToString(), + o.ItemName, + Status = o.Status.ToString(), + o.Commission, + OwnerId = o.OwnerId, + OwnerNickname = o.Owner!.Nickname, + OwnerAvatar = o.Owner!.AvatarUrl, + RunnerId = o.RunnerId, + RunnerNickname = o.Runner!.Nickname, + RunnerAvatar = o.Runner!.AvatarUrl, + CreatedAt = o.CreatedAt + }) + .ToListAsync(); + + var result = orders.Select(o => new + { + o.Id, + o.OrderNo, + o.OrderType, + o.ItemName, + o.Status, + o.Commission, + o.OwnerId, + OwnerNickname = string.IsNullOrWhiteSpace(o.OwnerNickname) ? $"用户{o.OwnerId}" : o.OwnerNickname, + o.OwnerAvatar, + o.RunnerId, + RunnerNickname = string.IsNullOrWhiteSpace(o.RunnerNickname) ? $"用户{o.RunnerId}" : o.RunnerNickname, + o.RunnerAvatar, + o.CreatedAt + }); + + return Results.Ok(result); + }).RequireAuthorization("AdminOnly"); + + // 管理端拉取聊天记录 + app.MapGet("/api/admin/chat-messages", async (int ownerUserId, int runnerUserId, TencentIMService imService) => + { + var fromImId = $"user_{ownerUserId}"; + var toImId = $"user_{runnerUserId}"; + + try + { + var result = await imService.GetRoamMessagesAsync(fromImId, toImId); + return Results.Ok(result); + } + catch (Exception ex) + { + return Results.BadRequest(new { code = 400, message = $"拉取聊天记录失败: {ex.Message}" }); + } + }).RequireAuthorization("AdminOnly"); + } +} diff --git a/server/Endpoints/AuthEndpoints.cs b/server/Endpoints/AuthEndpoints.cs new file mode 100644 index 0000000..900496f --- /dev/null +++ b/server/Endpoints/AuthEndpoints.cs @@ -0,0 +1,168 @@ +using CampusErrand.Data; +using CampusErrand.Models; +using CampusErrand.Models.Dtos; +using CampusErrand.Services; +using Microsoft.EntityFrameworkCore; + +namespace CampusErrand.Endpoints; + +public static class AuthEndpoints +{ + public static void MapAuthEndpoints(this WebApplication app) + { + // 微信手机号登录接口 + app.MapPost("/api/auth/login", async ( + WeChatLoginRequest request, + IWeChatService weChatService, + JwtService jwtService, + AppDbContext db) => + { + // 1. 调用微信 code2Session 获取 session_key 和 openid + var sessionResult = await weChatService.Code2SessionAsync(request.Code); + if (!sessionResult.Success) + { + return Results.BadRequest(new { code = 400, message = sessionResult.ErrorMessage }); + } + + // 2. 使用 session_key 解密手机号 + string phoneNumber; + try + { + phoneNumber = weChatService.DecryptPhoneNumber( + sessionResult.SessionKey!, request.EncryptedData, request.Iv); + } + catch (Exception) + { + return Results.BadRequest(new { code = 400, message = "手机号解密失败" }); + } + + // 3. 根据手机号查找或创建用户 + var user = await db.Users.FirstOrDefaultAsync(u => u.Phone == phoneNumber); + if (user == null) + { + user = new User + { + OpenId = sessionResult.OpenId!, + Phone = phoneNumber, + Nickname = $"用户{phoneNumber[^4..]}", + CreatedAt = DateTime.UtcNow + }; + db.Users.Add(user); + await db.SaveChangesAsync(); + } + else if (string.IsNullOrEmpty(user.OpenId) && !string.IsNullOrEmpty(sessionResult.OpenId)) + { + // 更新 OpenId(如果之前为空) + user.OpenId = sessionResult.OpenId; + await db.SaveChangesAsync(); + } + + // 4. 生成 JWT 返回 + var token = jwtService.GenerateToken(user.Id, user.Role.ToString()); + + return Results.Ok(new LoginResponse + { + Token = token, + UserInfo = new UserInfo + { + Id = user.Id, + Phone = user.Phone, + Nickname = user.Nickname, + AvatarUrl = user.AvatarUrl, + Role = user.Role.ToString() + } + }); + }); + + // 微信快捷登录(仅需 code,通过 openid 注册/登录) + app.MapPost("/api/auth/wx-login", async ( + WxLoginRequest request, + IWeChatService weChatService, + JwtService jwtService, + AppDbContext db) => + { + // 1. 调用微信 code2Session 获取 openid + Console.WriteLine($"[wx-login] 收到 code: {request.Code}"); + var sessionResult = await weChatService.Code2SessionAsync(request.Code); + if (!sessionResult.Success) + { + return Results.BadRequest(new { code = 400, message = sessionResult.ErrorMessage }); + } + + // 2. 根据 openid 查找或创建用户 + var openId = sessionResult.OpenId!; + var user = await db.Users.FirstOrDefaultAsync(u => u.OpenId == openId); + if (user == null) + { + // 自动注册,手机号暂时留空 + var randomSuffix = Random.Shared.Next(1000, 9999).ToString(); + user = new User + { + OpenId = openId, + Phone = "", + Nickname = $"微信用户{randomSuffix}", + CreatedAt = DateTime.UtcNow + }; + db.Users.Add(user); + await db.SaveChangesAsync(); + } + + // 3. 生成 JWT 返回 + var token = jwtService.GenerateToken(user.Id, user.Role.ToString()); + + return Results.Ok(new LoginResponse + { + Token = token, + UserInfo = new UserInfo + { + Id = user.Id, + Phone = user.Phone, + Nickname = user.Nickname, + AvatarUrl = user.AvatarUrl, + Role = user.Role.ToString() + } + }); + }); + + // 受保护的测试端点(用于验证认证和授权) + app.MapGet("/api/protected", () => Results.Ok("ok")) + .RequireAuthorization(); + + // 管理员账号密码登录接口 + app.MapPost("/api/admin/auth/login", async ( + AdminLoginRequest request, + JwtService jwtService, + AppDbContext db, + IConfiguration configuration) => + { + // 从配置读取管理员凭据 + var adminUsername = configuration["Admin:Username"] ?? "admin"; + var adminPassword = configuration["Admin:Password"] ?? "admin123"; + + if (request.Username != adminUsername || request.Password != adminPassword) + { + return Results.BadRequest(new { code = 400, message = "账号或密码错误" }); + } + + // 查找或创建管理员用户 + var admin = await db.Users.FirstOrDefaultAsync(u => u.Role == UserRole.Admin); + if (admin == null) + { + admin = new User + { + OpenId = "admin", + Phone = "admin", + Nickname = "管理员", + Role = UserRole.Admin, + CreatedAt = DateTime.UtcNow + }; + db.Users.Add(admin); + await db.SaveChangesAsync(); + } + + var token = jwtService.GenerateToken(admin.Id, admin.Role.ToString()); + + return Results.Ok(new { token }); + }); + } +} diff --git a/server/Endpoints/BannerEndpoints.cs b/server/Endpoints/BannerEndpoints.cs new file mode 100644 index 0000000..54f650c --- /dev/null +++ b/server/Endpoints/BannerEndpoints.cs @@ -0,0 +1,147 @@ +using CampusErrand.Data; +using CampusErrand.Models; +using CampusErrand.Models.Dtos; +using CampusErrand.Helpers; +using Microsoft.EntityFrameworkCore; + +namespace CampusErrand.Endpoints; + +public static class BannerEndpoints +{ + public static void MapBannerEndpoints(this WebApplication app) + { + // 前端获取 Banner 列表(仅启用+按排序权重排列) + app.MapGet("/api/banners", async (AppDbContext db) => + { + var banners = await db.Banners + .Where(b => b.IsEnabled) + .OrderBy(b => b.SortOrder) + .Select(b => new BannerResponse + { + Id = b.Id, + ImageUrl = b.ImageUrl, + LinkType = b.LinkType.ToString(), + LinkUrl = b.LinkUrl, + SortOrder = b.SortOrder, + IsEnabled = b.IsEnabled, + CreatedAt = b.CreatedAt + }) + .ToListAsync(); + return Results.Ok(banners); + }); + + // 管理端获取全部 Banner 列表 + app.MapGet("/api/admin/banners", async (AppDbContext db) => + { + var banners = await db.Banners + .OrderBy(b => b.SortOrder) + .Select(b => new BannerResponse + { + Id = b.Id, + ImageUrl = b.ImageUrl, + LinkType = b.LinkType.ToString(), + LinkUrl = b.LinkUrl, + SortOrder = b.SortOrder, + IsEnabled = b.IsEnabled, + CreatedAt = b.CreatedAt + }) + .ToListAsync(); + return Results.Ok(banners); + }).RequireAuthorization("AdminOnly"); + + // 创建 Banner + app.MapPost("/api/admin/banners", async (BannerRequest request, AppDbContext db) => + { + // 校验 + var errors = BusinessHelpers.ValidateBannerRequest(request); + if (errors.Count > 0) + { + return Results.BadRequest(new { code = 400, message = "校验失败", errors }); + } + + if (!Enum.TryParse(request.LinkType, true, out var linkType) || !Enum.IsDefined(linkType)) + { + return Results.BadRequest(new { code = 400, message = "校验失败", errors = new[] { new { field = "linkType", message = "链接类型不合法" } } }); + } + + var banner = new Banner + { + ImageUrl = request.ImageUrl, + LinkType = linkType, + LinkUrl = request.LinkUrl, + SortOrder = request.SortOrder, + IsEnabled = request.IsEnabled, + CreatedAt = DateTime.UtcNow + }; + + db.Banners.Add(banner); + await db.SaveChangesAsync(); + + return Results.Created($"/api/admin/banners/{banner.Id}", new BannerResponse + { + Id = banner.Id, + ImageUrl = banner.ImageUrl, + LinkType = banner.LinkType.ToString(), + LinkUrl = banner.LinkUrl, + SortOrder = banner.SortOrder, + IsEnabled = banner.IsEnabled, + CreatedAt = banner.CreatedAt + }); + }).RequireAuthorization("AdminOnly"); + + // 更新 Banner + app.MapPut("/api/admin/banners/{id}", async (int id, BannerRequest request, AppDbContext db) => + { + var banner = await db.Banners.FindAsync(id); + if (banner == null) + { + return Results.NotFound(new { code = 404, message = "Banner 不存在" }); + } + + var errors = BusinessHelpers.ValidateBannerRequest(request); + if (errors.Count > 0) + { + return Results.BadRequest(new { code = 400, message = "校验失败", errors }); + } + + if (!Enum.TryParse(request.LinkType, true, out var linkType) || !Enum.IsDefined(linkType)) + { + return Results.BadRequest(new { code = 400, message = "校验失败", errors = new[] { new { field = "linkType", message = "链接类型不合法" } } }); + } + + banner.ImageUrl = request.ImageUrl; + banner.LinkType = linkType; + banner.LinkUrl = request.LinkUrl; + banner.SortOrder = request.SortOrder; + banner.IsEnabled = request.IsEnabled; + + await db.SaveChangesAsync(); + + return Results.Ok(new BannerResponse + { + Id = banner.Id, + ImageUrl = banner.ImageUrl, + LinkType = banner.LinkType.ToString(), + LinkUrl = banner.LinkUrl, + SortOrder = banner.SortOrder, + IsEnabled = banner.IsEnabled, + CreatedAt = banner.CreatedAt + }); + }).RequireAuthorization("AdminOnly"); + + // 删除 Banner + app.MapDelete("/api/admin/banners/{id}", async (int id, AppDbContext db) => + { + var banner = await db.Banners.FindAsync(id); + if (banner == null) + { + return Results.NotFound(new { code = 404, message = "Banner 不存在" }); + } + + db.Banners.Remove(banner); + await db.SaveChangesAsync(); + + return Results.NoContent(); + }).RequireAuthorization("AdminOnly"); + } +} diff --git a/server/Endpoints/ConfigEndpoints.cs b/server/Endpoints/ConfigEndpoints.cs new file mode 100644 index 0000000..15f49d6 --- /dev/null +++ b/server/Endpoints/ConfigEndpoints.cs @@ -0,0 +1,193 @@ +using CampusErrand.Data; +using CampusErrand.Models; +using CampusErrand.Models.Dtos; +using Microsoft.EntityFrameworkCore; + +namespace CampusErrand.Endpoints; + +public static class ConfigEndpoints +{ + public static void MapConfigEndpoints(this WebApplication app) + { + // 获取页面顶图配置 + app.MapGet("/api/config/page-banner/{page}", async (string page, AppDbContext db) => + { + var key = $"page_banner_{page}"; + var config = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == key); + return Results.Ok(new ConfigResponse + { + Key = key, + Value = config?.Value ?? "", + UpdatedAt = config?.UpdatedAt ?? DateTime.MinValue + }); + }); + + // 获取客服二维码 + app.MapGet("/api/config/qrcode", async (AppDbContext db) => + { + var config = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == "qrcode"); + return Results.Ok(new ConfigResponse + { + Key = "qrcode", + Value = config?.Value ?? "", + UpdatedAt = config?.UpdatedAt ?? DateTime.MinValue + }); + }); + + // 获取用户协议 + app.MapGet("/api/config/agreement", async (AppDbContext db) => + { + var config = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == "agreement"); + return Results.Ok(new ConfigResponse + { + Key = "agreement", + Value = config?.Value ?? "", + UpdatedAt = config?.UpdatedAt ?? DateTime.MinValue + }); + }); + + // 获取隐私政策 + app.MapGet("/api/config/privacy", async (AppDbContext db) => + { + var config = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == "privacy"); + return Results.Ok(new ConfigResponse + { + Key = "privacy", + Value = config?.Value ?? "", + UpdatedAt = config?.UpdatedAt ?? DateTime.MinValue + }); + }); + + // 获取跑腿协议 + app.MapGet("/api/config/runner-agreement", async (AppDbContext db) => + { + var config = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == "runner_agreement"); + return Results.Ok(new ConfigResponse + { + Key = "runner_agreement", + Value = config?.Value ?? "", + UpdatedAt = config?.UpdatedAt ?? DateTime.MinValue + }); + }); + + // 获取提现说明 + app.MapGet("/api/config/withdrawal-guide", async (AppDbContext db) => + { + var config = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == "withdrawal_guide"); + return Results.Ok(new ConfigResponse + { + Key = "withdrawal_guide", + Value = config?.Value ?? "", + UpdatedAt = config?.UpdatedAt ?? DateTime.MinValue + }); + }); + + // 管理端获取指定配置 + app.MapGet("/api/admin/config/{key}", async (string key, AppDbContext db) => + { + var config = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == key); + return Results.Ok(new ConfigResponse + { + Key = key, + Value = config?.Value ?? "", + UpdatedAt = config?.UpdatedAt ?? DateTime.MinValue + }); + }).RequireAuthorization("AdminOnly"); + + // 管理端更新系统配置 + app.MapPut("/api/admin/config/{key}", async (string key, UpdateConfigRequest request, AppDbContext db) => + { + // 允许的配置键白名单 + var allowedKeys = new HashSet + { + "qrcode", "agreement", "privacy", "runner_agreement", "withdrawal_guide", "freeze_days", + "page_banner_pickup", "page_banner_delivery", "page_banner_help", "page_banner_purchase", "page_banner_food", + "page_banner_order-hall" + }; + + if (!allowedKeys.Contains(key)) + return Results.BadRequest(new { code = 400, message = $"不支持的配置键: {key}" }); + + var config = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == key); + if (config == null) + { + config = new SystemConfig + { + Key = key, + Value = request.Value, + UpdatedAt = DateTime.UtcNow + }; + db.SystemConfigs.Add(config); + } + else + { + config.Value = request.Value; + config.UpdatedAt = DateTime.UtcNow; + } + + await db.SaveChangesAsync(); + + return Results.Ok(new ConfigResponse + { + Key = config.Key, + Value = config.Value, + UpdatedAt = config.UpdatedAt + }); + }).RequireAuthorization("AdminOnly"); + + // 图片上传接口 + app.MapPost("/api/upload/image", async (IFormFile file, IConfiguration config) => + { + if (file == null || file.Length == 0) + return Results.BadRequest(new { code = 400, message = "请选择要上传的图片" }); + + // 文件大小校验(默认 5MB) + var maxSize = config.GetValue("Upload:MaxFileSizeBytes", 5242880); + if (file.Length > maxSize) + return Results.BadRequest(new { code = 400, message = $"图片大小不能超过 {maxSize / 1024 / 1024}MB" }); + + // 文件扩展名校验 + var allowedExtensions = config.GetSection("Upload:AllowedExtensions").Get() + ?? new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp" }; + var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (!allowedExtensions.Contains(ext)) + return Results.BadRequest(new { code = 400, message = $"不支持的图片格式,仅支持 {string.Join(", ", allowedExtensions)}" }); + + // 上传到腾讯云 COS + var cosConfig = new COSXML.CosXmlConfig.Builder() + .IsHttps(true) + .SetRegion(config["COS:Region"]) + .Build(); + var credential = new COSXML.Auth.DefaultQCloudCredentialProvider( + config["COS:SecretId"], config["COS:SecretKey"], 600); + var cosXml = new COSXML.CosXmlServer(cosConfig, credential); + + var bucket = config["COS:Bucket"]!; + var cosKey = $"uploads/{DateTime.UtcNow:yyyyMMdd}/{Guid.NewGuid()}{ext}"; + + // 将上传文件写入临时文件 + var tempPath = Path.GetTempFileName(); + try + { + using (var stream = new FileStream(tempPath, FileMode.Create)) + { + await file.CopyToAsync(stream); + } + + var putRequest = new COSXML.Model.Object.PutObjectRequest(bucket, cosKey, tempPath); + var putResult = cosXml.PutObject(putRequest); + + if (putResult.httpCode != 200) + return Results.BadRequest(new { code = 400, message = "图片上传失败" }); + + var url = $"{config["COS:BaseUrl"]}/{cosKey}"; + return Results.Ok(new UploadImageResponse { Url = url }); + } + finally + { + if (File.Exists(tempPath)) File.Delete(tempPath); + } + }).RequireAuthorization() + .DisableAntiforgery(); + } +} diff --git a/server/Endpoints/EarningEndpoints.cs b/server/Endpoints/EarningEndpoints.cs new file mode 100644 index 0000000..c14e121 --- /dev/null +++ b/server/Endpoints/EarningEndpoints.cs @@ -0,0 +1,330 @@ +using CampusErrand.Data; +using CampusErrand.Models; +using CampusErrand.Models.Dtos; +using CampusErrand.Helpers; +using Microsoft.EntityFrameworkCore; + +namespace CampusErrand.Endpoints; + +public static class EarningEndpoints +{ + public static void MapEarningEndpoints(this WebApplication app) + { + // 获取收益概览(四种金额状态) + app.MapGet("/api/earnings", async (HttpContext httpContext, AppDbContext db) => + { + var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Results.Unauthorized(); + var userId = int.Parse(userIdClaim.Value); + + // 先执行冻结解冻逻辑 + await BusinessHelpers.UnfreezeEarnings(db); + + var earnings = await db.Earnings.Where(e => e.UserId == userId).ToListAsync(); + + var overview = new EarningsOverviewResponse + { + FrozenAmount = earnings.Where(e => e.Status == EarningStatus.Frozen).Sum(e => e.NetEarning), + AvailableAmount = earnings.Where(e => e.Status == EarningStatus.Available).Sum(e => e.NetEarning), + WithdrawingAmount = earnings.Where(e => e.Status == EarningStatus.Withdrawing).Sum(e => e.NetEarning), + WithdrawnAmount = earnings.Where(e => e.Status == EarningStatus.Withdrawn).Sum(e => e.NetEarning) + }; + + return Results.Ok(overview); + }).RequireAuthorization(); + + // 获取收益记录 + app.MapGet("/api/earnings/records", async (HttpContext httpContext, AppDbContext db) => + { + var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Results.Unauthorized(); + var userId = int.Parse(userIdClaim.Value); + + var records = await db.Earnings + .Where(e => e.UserId == userId) + .Include(e => e.Order) + .OrderByDescending(e => e.CreatedAt) + .Select(e => new EarningRecordResponse + { + Id = e.Id, + OrderId = e.OrderId, + OrderNo = e.Order != null ? e.Order.OrderNo : "", + OrderType = e.Order != null ? e.Order.OrderType.ToString() : "", + GoodsAmount = e.GoodsAmount, + Commission = e.Commission, + PlatformFee = e.PlatformFee, + NetEarning = e.NetEarning, + Status = e.Status.ToString(), + CompletedAt = e.Order != null ? e.Order.CompletedAt : null, + CreatedAt = e.CreatedAt + }) + .ToListAsync(); + + return Results.Ok(records); + }).RequireAuthorization(); + + // 获取提现记录 + app.MapGet("/api/earnings/withdrawals", async (HttpContext httpContext, AppDbContext db) => + { + var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Results.Unauthorized(); + var userId = int.Parse(userIdClaim.Value); + + var withdrawals = await db.Withdrawals + .Where(w => w.UserId == userId) + .OrderByDescending(w => w.CreatedAt) + .Select(w => new WithdrawalRecordResponse + { + Id = w.Id, + Amount = w.Amount, + PaymentMethod = w.PaymentMethod.ToString(), + Status = w.Status.ToString(), + CreatedAt = w.CreatedAt + }) + .ToListAsync(); + + return Results.Ok(withdrawals); + }).RequireAuthorization(); + + // 申请提现 + app.MapPost("/api/earnings/withdraw", async (WithdrawRequest request, HttpContext httpContext, AppDbContext db) => + { + var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Results.Unauthorized(); + var userId = int.Parse(userIdClaim.Value); + + // 金额校验:最低 1 元 + if (request.Amount < 1.0m) + return Results.BadRequest(new { code = 400, message = "提现金额不能低于1元" }); + + // 金额校验:小数点后最多 2 位 + if (request.Amount != Math.Round(request.Amount, 2)) + return Results.BadRequest(new { code = 400, message = "请输入正确的提现金额" }); + + // 收款方式校验 + if (!Enum.TryParse(request.PaymentMethod, true, out var paymentMethod) || !Enum.IsDefined(paymentMethod)) + return Results.BadRequest(new { code = 400, message = "收款方式不合法" }); + + // 收款二维码校验 + if (string.IsNullOrWhiteSpace(request.QrCodeImage)) + return Results.BadRequest(new { code = 400, message = "收款二维码不能为空" }); + + // 先执行冻结解冻逻辑 + await BusinessHelpers.UnfreezeEarnings(db); + + // 计算可提现余额 + var availableAmount = await db.Earnings + .Where(e => e.UserId == userId && e.Status == EarningStatus.Available) + .SumAsync(e => e.NetEarning); + + if (request.Amount > availableAmount) + return Results.BadRequest(new { code = 400, message = "超出可提现范围" }); + + // 创建提现记录 + var withdrawal = new Withdrawal + { + UserId = userId, + Amount = request.Amount, + PaymentMethod = paymentMethod, + QrCodeImage = request.QrCodeImage, + Status = WithdrawalStatus.Pending, + CreatedAt = DateTime.UtcNow + }; + db.Withdrawals.Add(withdrawal); + + // 将对应金额的收益标记为提现中(按创建时间先进先出) + var remainingAmount = request.Amount; + var availableEarnings = await db.Earnings + .Where(e => e.UserId == userId && e.Status == EarningStatus.Available) + .OrderBy(e => e.CreatedAt) + .ToListAsync(); + + foreach (var earning in availableEarnings) + { + if (remainingAmount <= 0) break; + + if (earning.NetEarning <= remainingAmount) + { + earning.Status = EarningStatus.Withdrawing; + remainingAmount -= earning.NetEarning; + } + else + { + // 需要拆分收益记录:部分提现 + earning.Status = EarningStatus.Withdrawing; + var originalNet = earning.NetEarning; + earning.NetEarning = remainingAmount; + + // 创建剩余部分的新收益记录 + var remainingEarning = new Earning + { + UserId = earning.UserId, + OrderId = earning.OrderId, + GoodsAmount = earning.GoodsAmount, + Commission = earning.Commission, + PlatformFee = 0, + NetEarning = originalNet - remainingAmount, + Status = EarningStatus.Available, + FrozenUntil = earning.FrozenUntil, + CreatedAt = earning.CreatedAt + }; + db.Earnings.Add(remainingEarning); + remainingAmount = 0; + } + } + + await db.SaveChangesAsync(); + + return Results.Created($"/api/earnings/withdrawals/{withdrawal.Id}", new WithdrawalRecordResponse + { + Id = withdrawal.Id, + Amount = withdrawal.Amount, + PaymentMethod = withdrawal.PaymentMethod.ToString(), + Status = withdrawal.Status.ToString(), + CreatedAt = withdrawal.CreatedAt + }); + }).RequireAuthorization(); + + // 管理端获取提现列表 + app.MapGet("/api/admin/withdrawals", async (string? status, AppDbContext db) => + { + var query = db.Withdrawals + .Include(w => w.User) + .AsQueryable(); + + if (!string.IsNullOrEmpty(status) && Enum.TryParse(status, out var s)) + query = query.Where(w => w.Status == s); + + var list = await query + .OrderByDescending(w => w.CreatedAt) + .Select(w => new + { + w.Id, + w.UserId, + UserNickname = w.User!.Nickname ?? ("用户" + w.UserId), + w.Amount, + PaymentMethod = w.PaymentMethod.ToString(), + w.QrCodeImage, + Status = w.Status.ToString(), + w.CreatedAt, + w.ProcessedAt + }) + .ToListAsync(); + + return Results.Ok(list); + }).RequireAuthorization("AdminOnly"); + + // 管理端审核提现(通过/拒绝) + app.MapPut("/api/admin/withdrawals/{id}", async (int id, AdminWithdrawalRequest request, AppDbContext db) => + { + var withdrawal = await db.Withdrawals.FindAsync(id); + if (withdrawal == null) + return Results.NotFound(new { code = 404, message = "提现记录不存在" }); + + if (withdrawal.Status != WithdrawalStatus.Pending && withdrawal.Status != WithdrawalStatus.Processing) + return Results.BadRequest(new { code = 400, message = "该提现记录已处理完毕" }); + + if (request.Action == "approve") + { + withdrawal.Status = WithdrawalStatus.Completed; + withdrawal.ProcessedAt = DateTime.UtcNow; + + // 将对应收益标记为已提现 + var earnings = await db.Earnings + .Where(e => e.UserId == withdrawal.UserId && e.Status == EarningStatus.Withdrawing) + .OrderBy(e => e.CreatedAt) + .ToListAsync(); + + var remaining = withdrawal.Amount; + foreach (var earning in earnings) + { + if (remaining <= 0) break; + if (earning.NetEarning <= remaining) + { + earning.Status = EarningStatus.Withdrawn; + remaining -= earning.NetEarning; + } + else + { + earning.Status = EarningStatus.Withdrawn; + remaining = 0; + } + } + } + else if (request.Action == "reject") + { + withdrawal.Status = WithdrawalStatus.Pending; + withdrawal.ProcessedAt = DateTime.UtcNow; + + // 将对应收益退回待提现状态 + var earnings = await db.Earnings + .Where(e => e.UserId == withdrawal.UserId && e.Status == EarningStatus.Withdrawing) + .OrderBy(e => e.CreatedAt) + .ToListAsync(); + + var remaining = withdrawal.Amount; + foreach (var earning in earnings) + { + if (remaining <= 0) break; + if (earning.NetEarning <= remaining) + { + earning.Status = EarningStatus.Available; + remaining -= earning.NetEarning; + } + else + { + earning.Status = EarningStatus.Available; + remaining = 0; + } + } + + // 拒绝后将提现记录状态设为特殊标记(复用 Pending 但已有 ProcessedAt) + // 实际上拒绝后应该删除或标记,这里直接删除提现记录 + db.Withdrawals.Remove(withdrawal); + } + else if (request.Action == "processing") + { + withdrawal.Status = WithdrawalStatus.Processing; + } + else + { + return Results.BadRequest(new { code = 400, message = "无效操作,可选: approve, reject, processing" }); + } + + await db.SaveChangesAsync(); + return Results.Ok(new { message = "操作成功" }); + }).RequireAuthorization("AdminOnly"); + + // 获取佣金规则 + app.MapGet("/api/admin/commission-rules", async (AppDbContext db) => + { + var rules = await db.CommissionRules + .OrderBy(r => r.MinAmount) + .ToListAsync(); + return Results.Ok(rules); + }).RequireAuthorization("AdminOnly"); + + // 更新佣金规则 + app.MapPut("/api/admin/commission-rules", async (List rules, AppDbContext db) => + { + // 清除旧规则 + var existing = await db.CommissionRules.ToListAsync(); + db.CommissionRules.RemoveRange(existing); + + // 添加新规则 + foreach (var rule in rules) + { + db.CommissionRules.Add(new CommissionRule + { + MinAmount = rule.MinAmount, + MaxAmount = rule.MaxAmount, + RateType = rule.RateType, + Rate = rule.Rate + }); + } + + await db.SaveChangesAsync(); + return Results.Ok(await db.CommissionRules.OrderBy(r => r.MinAmount).ToListAsync()); + }).RequireAuthorization("AdminOnly"); + } +} diff --git a/server/Endpoints/IMEndpoints.cs b/server/Endpoints/IMEndpoints.cs new file mode 100644 index 0000000..7c3547f --- /dev/null +++ b/server/Endpoints/IMEndpoints.cs @@ -0,0 +1,25 @@ +using CampusErrand.Services; + +namespace CampusErrand.Endpoints; + +public static class IMEndpoints +{ + public static void MapIMEndpoints(this WebApplication app) + { + // 获取 IM UserSig(登录后调用) + app.MapGet("/api/im/usersig", ( + HttpContext context, + TencentIMService imService) => + { + var userId = int.Parse(context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)!.Value); + var imUserId = $"user_{userId}"; + var userSig = imService.GenerateUserSig(imUserId); + return Results.Ok(new + { + sdkAppId = imService.SDKAppId, + userId = imUserId, + userSig + }); + }).RequireAuthorization(); + } +} diff --git a/server/Endpoints/MessageEndpoints.cs b/server/Endpoints/MessageEndpoints.cs new file mode 100644 index 0000000..a9261e6 --- /dev/null +++ b/server/Endpoints/MessageEndpoints.cs @@ -0,0 +1,222 @@ +using CampusErrand.Data; +using CampusErrand.Models; +using CampusErrand.Models.Dtos; +using CampusErrand.Helpers; +using Microsoft.EntityFrameworkCore; + +namespace CampusErrand.Endpoints; + +public static class MessageEndpoints +{ + public static void MapMessageEndpoints(this WebApplication app) + { + // 获取未读消息数 + app.MapGet("/api/messages/unread-count", async (HttpContext httpContext, AppDbContext db) => + { + var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Results.Unauthorized(); + var userId = int.Parse(userIdClaim.Value); + + // 获取该用户可见的系统消息(按目标类型过滤) + var user = await db.Users.FindAsync(userId); + var visibleSystemMessages = await BusinessHelpers.GetVisibleSystemMessages(db, userId, user); + var visibleSystemMessageIds = visibleSystemMessages.Select(m => m.Id).ToHashSet(); + + // 已读的系统消息 ID + var readSystemIds = await db.MessageReads + .Where(r => r.UserId == userId && r.MessageType == MessageType.System) + .Select(r => r.MessageId) + .ToListAsync(); + + var systemUnread = visibleSystemMessageIds.Count(id => !readSystemIds.Contains(id)); + + // 订单通知:用户相关的订单中状态变更过的订单数 + var orderNotifications = await db.Orders + .Where(o => (o.OwnerId == userId || o.RunnerId == userId) && o.Status != OrderStatus.Pending) + .Select(o => o.Id) + .ToListAsync(); + + var readOrderNotificationIds = await db.MessageReads + .Where(r => r.UserId == userId && r.MessageType == MessageType.OrderNotification) + .Select(r => r.MessageId) + .ToListAsync(); + + var orderNotificationUnread = orderNotifications.Count(id => !readOrderNotificationIds.Contains(id)); + + return Results.Ok(new UnreadCountResponse + { + SystemUnread = systemUnread, + OrderNotificationUnread = orderNotificationUnread, + TotalUnread = systemUnread + orderNotificationUnread + }); + }).RequireAuthorization(); + + // 获取系统消息列表 + app.MapGet("/api/messages/system", async (HttpContext httpContext, AppDbContext db) => + { + var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Results.Unauthorized(); + var userId = int.Parse(userIdClaim.Value); + + var user = await db.Users.FindAsync(userId); + var messages = await BusinessHelpers.GetVisibleSystemMessages(db, userId, user); + + // 获取已读记录 + var readIds = await db.MessageReads + .Where(r => r.UserId == userId && r.MessageType == MessageType.System) + .Select(r => r.MessageId) + .ToListAsync(); + + var result = messages + .OrderByDescending(m => m.CreatedAt) + .Select(m => new SystemMessageResponse + { + Id = m.Id, + Title = m.Title, + ContentPreview = m.Content.Length > 100 ? m.Content[..100] + "…" : m.Content, + ThumbnailUrl = m.ThumbnailUrl, + CreatedAt = m.CreatedAt, + IsRead = readIds.Contains(m.Id) + }) + .ToList(); + + // 标记所有为已读 + foreach (var msg in messages.Where(m => !readIds.Contains(m.Id))) + { + db.MessageReads.Add(new MessageRead + { + UserId = userId, + MessageType = MessageType.System, + MessageId = msg.Id, + ReadAt = DateTime.UtcNow + }); + } + await db.SaveChangesAsync(); + + return Results.Ok(result); + }).RequireAuthorization(); + + // 获取系统消息详情 + app.MapGet("/api/messages/system/{id}", async (int id, HttpContext httpContext, AppDbContext db) => + { + var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Results.Unauthorized(); + + var message = await db.SystemMessages.FindAsync(id); + if (message == null) return Results.NotFound(new { code = 404, message = "消息不存在" }); + + return Results.Ok(new SystemMessageDetailResponse + { + Id = message.Id, + Title = message.Title, + Content = message.Content, + ThumbnailUrl = message.ThumbnailUrl, + CreatedAt = message.CreatedAt + }); + }).RequireAuthorization(); + + // 获取订单通知列表 + app.MapGet("/api/messages/order-notifications", async (string? category, HttpContext httpContext, AppDbContext db) => + { + var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Results.Unauthorized(); + var userId = int.Parse(userIdClaim.Value); + + // 查询用户相关的订单(状态已变更的) + var query = db.Orders + .Where(o => (o.OwnerId == userId || o.RunnerId == userId) && o.Status != OrderStatus.Pending); + + // 按分类筛选 + if (!string.IsNullOrEmpty(category)) + { + query = category.ToLower() switch + { + "accepted" => query.Where(o => o.Status == OrderStatus.InProgress), + "completed" => query.Where(o => o.Status == OrderStatus.Completed), + "cancelled" => query.Where(o => o.Status == OrderStatus.Cancelled), + _ => query + }; + } + + var orders = await query + .OrderByDescending(o => o.AcceptedAt ?? o.CreatedAt) + .Select(o => new OrderNotificationResponse + { + Id = o.Id, + OrderNo = o.OrderNo, + OrderType = o.OrderType.ToString(), + Title = o.Status == OrderStatus.InProgress ? "订单已被接取" + : o.Status == OrderStatus.Completed ? "订单已完成" + : o.Status == OrderStatus.Cancelled ? "订单已取消" + : o.Status == OrderStatus.WaitConfirm ? "订单待确认" + : o.Status == OrderStatus.Appealing ? "订单申诉中" + : "订单状态变更", + ItemName = o.ItemName, + Status = o.Status.ToString(), + CreatedAt = o.AcceptedAt ?? o.CreatedAt + }) + .ToListAsync(); + + // 标记订单通知为已读 + var orderIds = orders.Select(o => o.Id).ToList(); + var readIds = await db.MessageReads + .Where(r => r.UserId == userId && r.MessageType == MessageType.OrderNotification && orderIds.Contains(r.MessageId)) + .Select(r => r.MessageId) + .ToListAsync(); + + foreach (var orderId in orderIds.Where(id => !readIds.Contains(id))) + { + db.MessageReads.Add(new MessageRead + { + UserId = userId, + MessageType = MessageType.OrderNotification, + MessageId = orderId, + ReadAt = DateTime.UtcNow + }); + } + await db.SaveChangesAsync(); + + return Results.Ok(orders); + }).RequireAuthorization(); + + // 管理端发布系统通知 + app.MapPost("/api/admin/notifications", async (CreateNotificationRequest request, AppDbContext db) => + { + // 校验 + var errors = new List(); + if (string.IsNullOrWhiteSpace(request.Title)) + errors.Add(new { field = "title", message = "标题不能为空" }); + if (string.IsNullOrWhiteSpace(request.Content)) + errors.Add(new { field = "content", message = "正文不能为空" }); + if (errors.Count > 0) + return Results.BadRequest(new { code = 400, message = "校验失败", errors }); + + if (!Enum.TryParse(request.TargetType, true, out var targetType) || !Enum.IsDefined(targetType)) + return Results.BadRequest(new { code = 400, message = "目标类型不合法" }); + + var message = new SystemMessage + { + Title = request.Title, + Content = request.Content, + ThumbnailUrl = request.ThumbnailUrl, + TargetType = targetType, + TargetUserIds = request.TargetUserIds != null + ? System.Text.Json.JsonSerializer.Serialize(request.TargetUserIds) + : null, + CreatedAt = DateTime.UtcNow + }; + + db.SystemMessages.Add(message); + await db.SaveChangesAsync(); + + return Results.Created($"/api/admin/notifications/{message.Id}", new SystemMessageDetailResponse + { + Id = message.Id, + Title = message.Title, + Content = message.Content, + ThumbnailUrl = message.ThumbnailUrl, + CreatedAt = message.CreatedAt + }); + }).RequireAuthorization("AdminOnly"); + } +} diff --git a/server/Endpoints/OrderEndpoints.cs b/server/Endpoints/OrderEndpoints.cs new file mode 100644 index 0000000..fe93395 --- /dev/null +++ b/server/Endpoints/OrderEndpoints.cs @@ -0,0 +1,1003 @@ +using CampusErrand.Data; +using CampusErrand.Models; +using CampusErrand.Models.Dtos; +using CampusErrand.Services; +using CampusErrand.Helpers; +using Microsoft.EntityFrameworkCore; + +namespace CampusErrand.Endpoints; + +public static class OrderEndpoints +{ + public static void MapOrderEndpoints(this WebApplication app) + { + // 创建订单 + app.MapPost("/api/orders", async (CreateOrderRequest request, HttpContext httpContext, AppDbContext db, WxPayService wxPay) => + { + // 获取当前用户 ID + var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Results.Unauthorized(); + var userId = int.Parse(userIdClaim.Value); + + // 校验订单类型 + if (!Enum.TryParse(request.OrderType, true, out var orderType) || !Enum.IsDefined(orderType)) + { + return Results.BadRequest(new { code = 400, message = "订单类型不合法" }); + } + + // 佣金校验:最低 1.0 元 + if (request.Commission < 1.0m) + { + return Results.BadRequest(new { code = 400, message = "跑腿佣金不可低于1.0元" }); + } + + // 佣金校验:小数点后最多 1 位 + if (request.Commission != Math.Round(request.Commission, 1)) + { + return Results.BadRequest(new { code = 400, message = "跑腿佣金最多支持小数点后1位" }); + } + + // 计算支付总金额 + decimal totalAmount; + decimal packingFee = 0; + if (orderType == OrderType.Help || orderType == OrderType.Purchase || orderType == OrderType.Food) + { + // 美食街订单:自动计算打包费并加入商品总金额 + if (orderType == OrderType.Food && request.FoodItems != null && request.FoodItems.Count > 0) + { + // 获取涉及的门店 ID 列表 + var shopIds = request.FoodItems.Select(fi => fi.ShopId).Distinct().ToList(); + var shops = await db.Shops.Where(s => shopIds.Contains(s.Id)).ToListAsync(); + + // 按门店计算打包费 + foreach (var shop in shops) + { + var shopItems = request.FoodItems.Where(fi => fi.ShopId == shop.Id).ToList(); + var totalQuantity = shopItems.Sum(fi => fi.Quantity); + packingFee += BusinessHelpers.CalculatePackingFee(shop.PackingFeeType, shop.PackingFeeAmount, totalQuantity); + } + + // 计算菜品总金额 + var dishTotal = request.FoodItems.Sum(fi => fi.Quantity * fi.UnitPrice); + // 商品总金额 = 菜品总金额 + 打包费 + request.GoodsAmount = dishTotal + packingFee; + } + + // 商品+佣金类:支付总金额 = 商品总金额 + 跑腿佣金 + if (request.GoodsAmount == null || request.GoodsAmount < 0) + { + return Results.BadRequest(new { code = 400, message = "商品总金额不能为空或负数" }); + } + totalAmount = request.GoodsAmount.Value + request.Commission; + } + else + { + // 佣金类(代取、代送):支付总金额 = 跑腿佣金 + totalAmount = request.Commission; + } + + // 生成订单编号 + var orderNo = DateTime.UtcNow.ToString("yyyyMMddHHmmss") + Random.Shared.Next(100000, 999999).ToString(); + + var order = new Order + { + OrderNo = orderNo, + OwnerId = userId, + OrderType = orderType, + Status = OrderStatus.Pending, + ItemName = request.ItemName, + PickupLocation = request.PickupLocation, + DeliveryLocation = request.DeliveryLocation, + Remark = request.Remark, + Phone = request.Phone, + Commission = request.Commission, + GoodsAmount = request.GoodsAmount, + TotalAmount = totalAmount, + CreatedAt = DateTime.UtcNow + }; + + db.Orders.Add(order); + await db.SaveChangesAsync(); + + // 美食街订单:保存菜品详情 + if (orderType == OrderType.Food && request.FoodItems != null) + { + foreach (var item in request.FoodItems) + { + db.FoodOrderItems.Add(new FoodOrderItem + { + OrderId = order.Id, + ShopId = item.ShopId, + DishId = item.DishId, + Quantity = item.Quantity, + UnitPrice = item.UnitPrice + }); + } + await db.SaveChangesAsync(); + } + + // 调用微信支付 JSAPI 下单 + var user = await db.Users.FindAsync(userId); + if (user == null || string.IsNullOrEmpty(user.OpenId)) + return Results.BadRequest(new { code = 400, message = "用户信息异常,无法发起支付" }); + + var notifyUrl = $"{httpContext.Request.Scheme}://{httpContext.Request.Host}/api/pay/notify"; + var typeLabel = orderType switch + { + OrderType.Pickup => "代取", + OrderType.Delivery => "代送", + OrderType.Help => "万能帮", + OrderType.Purchase => "代购", + OrderType.Food => "美食街", + _ => "跑腿" + }; + var payResult = await wxPay.CreateJsapiOrder(order.OrderNo, order.TotalAmount, $"校园跑腿-{typeLabel}", user.OpenId, notifyUrl); + + if (!payResult.Success) + { + return Results.BadRequest(new { code = 400, message = "支付下单失败", detail = payResult.ErrorMessage }); + } + + return Results.Created($"/api/orders/{order.Id}", new + { + Id = order.Id, + OrderNo = order.OrderNo, + TotalAmount = order.TotalAmount, + PaymentParams = new + { + timeStamp = payResult.PaymentParams!.TimeStamp, + nonceStr = payResult.PaymentParams.NonceStr, + package_ = payResult.PaymentParams.Package, + signType = payResult.PaymentParams.SignType, + paySign = payResult.PaymentParams.PaySign + } + }); + }).RequireAuthorization(); + + // 获取当前用户的聊天订单列表(已接单的订单,按时间排序) + app.MapGet("/api/orders/chat-list", async (HttpContext httpContext, AppDbContext db) => + { + var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Results.Unauthorized(); + var currentUserId = int.Parse(userIdClaim.Value); + + var rawOrders = await db.Orders + .Where(o => o.RunnerId != null && + (o.OwnerId == currentUserId || o.RunnerId == currentUserId) && + o.Status != OrderStatus.Cancelled) + .OrderByDescending(o => o.CompletedAt ?? o.AcceptedAt ?? o.CreatedAt) + .Select(o => new + { + OrderId = o.Id, + o.OrderNo, + OrderType = o.OrderType.ToString(), + o.ItemName, + Status = o.Status.ToString(), + o.Commission, + o.OwnerId, + o.RunnerId, + OwnerNickname = o.Owner!.Nickname, + OwnerAvatar = o.Owner!.AvatarUrl, + RunnerNickname = o.Runner!.Nickname, + RunnerAvatar = o.Runner!.AvatarUrl, + LastTime = o.CompletedAt ?? o.AcceptedAt ?? o.CreatedAt + }) + .ToListAsync(); + + var orders = rawOrders.Select(o => + { + var isOwner = o.OwnerId == currentUserId; + var targetId = isOwner ? o.RunnerId : (int?)o.OwnerId; + var nickname = isOwner ? o.RunnerNickname : o.OwnerNickname; + var avatar = isOwner ? o.RunnerAvatar : o.OwnerAvatar; + if (string.IsNullOrWhiteSpace(nickname)) + nickname = $"用户{targetId}"; + return new + { + o.OrderId, + o.OrderNo, + o.OrderType, + o.ItemName, + o.Status, + o.Commission, + TargetUserId = targetId, + TargetNickname = nickname, + TargetAvatar = avatar ?? "", + o.LastTime + }; + }); + + return Results.Ok(orders); + }).RequireAuthorization(); + + // 根据对方用户ID查找最近的关联订单(用于聊天页显示订单卡片) + app.MapGet("/api/orders/by-chat-user/{targetUserId}", async (int targetUserId, HttpContext httpContext, AppDbContext db) => + { + var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Results.Unauthorized(); + var currentUserId = int.Parse(userIdClaim.Value); + + // 查找当前用户与目标用户之间最近的订单(当前用户是单主对方是跑腿,或反过来) + var order = await db.Orders + .Where(o => + (o.OwnerId == currentUserId && o.RunnerId == targetUserId) || + (o.OwnerId == targetUserId && o.RunnerId == currentUserId)) + .OrderByDescending(o => o.CreatedAt) + .FirstOrDefaultAsync(); + + if (order == null) + return Results.Ok(new { found = false }); + + return Results.Ok(new + { + found = true, + orderId = order.Id + }); + }).RequireAuthorization(); + + // 获取订单详情(含手机号隐藏逻辑和按状态显示字段) + app.MapGet("/api/orders/{id}", async (int id, HttpContext httpContext, AppDbContext db) => + { + var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Results.Unauthorized(); + var currentUserId = int.Parse(userIdClaim.Value); + + var order = await db.Orders + .Include(o => o.FoodOrderItems).ThenInclude(fi => fi.Shop) + .Include(o => o.FoodOrderItems).ThenInclude(fi => fi.Dish) + .Include(o => o.Runner) + .FirstOrDefaultAsync(o => o.Id == id); + + if (order == null) + { + return Results.NotFound(new { code = 404, message = "订单不存在" }); + } + + // 手机号隐藏逻辑: + // - 单主自己始终可见 + // - 已接单且查询者为接单跑腿时可见 + // - 其他情况隐藏 + string? visiblePhone = null; + if (currentUserId == order.OwnerId) + { + visiblePhone = order.Phone; + } + else if (order.RunnerId != null && currentUserId == order.RunnerId) + { + visiblePhone = order.Phone; + } + + // 按状态显示字段: + // - 待接单:接单时间、跑腿信息、完成信息为空 + // - 进行中/待确认:显示接单时间和跑腿信息 + // - 已完成:显示所有信息 + DateTime? visibleAcceptedAt = null; + string? runnerNickname = null; + int? runnerUid = null; + DateTime? visibleCompletedAt = null; + string? visibleCompletionProof = null; + + if (order.Status != OrderStatus.Pending && order.Status != OrderStatus.Cancelled) + { + // 进行中及之后的状态显示接单时间和跑腿信息 + visibleAcceptedAt = order.AcceptedAt; + runnerNickname = order.Runner?.Nickname; + runnerUid = order.RunnerId; + } + + // 跑腿手机号:从认证记录中获取,仅单主可见 + string? runnerPhone = null; + if (currentUserId == order.OwnerId && order.RunnerId != null) + { + var cert = await db.RunnerCertifications + .Where(c => c.UserId == order.RunnerId && c.Status == CertificationStatus.Approved) + .OrderByDescending(c => c.CreatedAt) + .FirstOrDefaultAsync(); + runnerPhone = cert?.Phone; + } + + if (order.Status == OrderStatus.Completed || order.Status == OrderStatus.WaitConfirm) + { + // 已完成和待确认状态显示完成时间和凭证 + visibleCompletedAt = order.CompletedAt; + visibleCompletionProof = order.CompletionProof; + } + + var response = new OrderResponse + { + Id = order.Id, + OrderNo = order.OrderNo, + OwnerId = order.OwnerId, + RunnerId = order.RunnerId, + OrderType = order.OrderType.ToString(), + Status = order.Status.ToString(), + ItemName = order.ItemName, + PickupLocation = order.PickupLocation, + DeliveryLocation = order.DeliveryLocation, + Remark = order.Remark, + Phone = visiblePhone, + Commission = order.Commission, + GoodsAmount = order.GoodsAmount, + TotalAmount = order.TotalAmount, + CompletionProof = visibleCompletionProof, + IsReviewed = order.IsReviewed, + CreatedAt = order.CreatedAt, + AcceptedAt = visibleAcceptedAt, + CompletedAt = visibleCompletedAt, + RunnerNickname = runnerNickname, + RunnerUid = runnerUid, + RunnerPhone = runnerPhone + }; + + // 查询佣金抽成信息(有 Earning 记录用实际值,否则实时计算预估值) + if (order.RunnerId.HasValue) + { + var earning = await db.Earnings + .Where(e => e.OrderId == order.Id) + .FirstOrDefaultAsync(); + if (earning != null) + { + response.PlatformFee = earning.PlatformFee; + response.NetEarning = earning.NetEarning; + } + else + { + // 未完成订单:实时计算预估抽成 + var rules = await db.CommissionRules.OrderBy(r => r.MinAmount).ToListAsync(); + var fee = BusinessHelpers.CalculatePlatformFee(order.Commission, rules); + response.PlatformFee = fee; + response.NetEarning = order.Commission - fee; + } + } + + // 美食街订单附带菜品详情 + if (order.OrderType == OrderType.Food && order.FoodOrderItems.Count > 0) + { + response.FoodItems = order.FoodOrderItems.Select(fi => new FoodOrderItemResponse + { + Id = fi.Id, + ShopId = fi.ShopId, + ShopName = fi.Shop?.Name ?? "未知门店", + DishId = fi.DishId, + DishName = fi.Dish?.Name ?? "未知菜品", + DishPhoto = fi.Dish?.Photo, + Quantity = fi.Quantity, + UnitPrice = fi.UnitPrice + }).ToList(); + } + + return Results.Ok(response); + }).RequireAuthorization(); + + // 获取订单大厅列表(仅返回 Pending 状态订单) + app.MapGet("/api/orders/hall", async ( + string? type, + string? sort, + AppDbContext db) => + { + // 基础查询:仅返回待接单订单 + var query = db.Orders.Where(o => o.Status == OrderStatus.Pending); + + // 按订单类型筛选 + if (!string.IsNullOrEmpty(type) && Enum.TryParse(type, true, out var orderType)) + { + query = query.Where(o => o.OrderType == orderType); + } + + // 排序 + query = sort?.ToLower() switch + { + "commission" => query.OrderByDescending(o => o.Commission), + "distance" => query.OrderByDescending(o => o.CreatedAt), // 距离排序需地址坐标,暂按时间排序 + _ => query.OrderByDescending(o => o.CreatedAt) // 默认按时间排序 + }; + + var orders = await query.ToListAsync(); + + // 美食街订单需要额外查询门店数和菜品数 + var foodOrderIds = orders + .Where(o => o.OrderType == OrderType.Food) + .Select(o => o.Id) + .ToList(); + + var foodOrderStats = new Dictionary(); + if (foodOrderIds.Count > 0) + { + var stats = await db.FoodOrderItems + .Where(fi => foodOrderIds.Contains(fi.OrderId)) + .GroupBy(fi => fi.OrderId) + .Select(g => new + { + OrderId = g.Key, + ShopCount = g.Select(fi => fi.ShopId).Distinct().Count(), + DishCount = g.Sum(fi => fi.Quantity) + }) + .ToListAsync(); + foreach (var s in stats) + { + foodOrderStats[s.OrderId] = (s.ShopCount, s.DishCount); + } + } + + var result = orders.Select(o => + { + var item = new OrderHallItemResponse + { + Id = o.Id, + OrderNo = o.OrderNo, + OrderType = o.OrderType.ToString(), + Status = o.Status.ToString(), + ItemName = o.ItemName, + PickupLocation = o.PickupLocation, + DeliveryLocation = o.DeliveryLocation, + Remark = o.Remark, + Commission = o.Commission, + GoodsAmount = o.GoodsAmount, + CreatedAt = o.CreatedAt + }; + + // 美食街订单附加门店数和菜品数 + if (o.OrderType == OrderType.Food && foodOrderStats.TryGetValue(o.Id, out var stat)) + { + item.ShopCount = stat.ShopCount; + item.DishItemCount = stat.DishCount; + } + + return item; + }).ToList(); + + return Results.Ok(result); + }).AllowAnonymous(); + + // 接单 + app.MapPost("/api/orders/{id}/accept", async (int id, HttpContext httpContext, AppDbContext db) => + { + // 获取当前用户 + var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Results.Unauthorized(); + var userId = int.Parse(userIdClaim.Value); + + // 检查跑腿认证 + var certification = await db.RunnerCertifications + .Where(c => c.UserId == userId && c.Status == CertificationStatus.Approved) + .FirstOrDefaultAsync(); + if (certification == null) + { + return Results.BadRequest(new { code = 400, message = "您尚未通过跑腿认证,无法接单" }); + } + + // 检查用户是否被封禁 + var user = await db.Users.FindAsync(userId); + if (user != null && user.IsBanned) + { + return Results.BadRequest(new { code = 400, message = "您的跑腿身份已被封禁,无法接单" }); + } + + // 使用乐观锁查询并更新订单(仅 Pending 状态可接单) + var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id); + if (order == null) + { + return Results.NotFound(new { code = 404, message = "订单不存在" }); + } + + if (order.Status == OrderStatus.Cancelled) + { + return Results.BadRequest(new { code = 400, message = "该订单已被取消" }); + } + + if (order.Status != OrderStatus.Pending) + { + return Results.Conflict(new { code = 409, message = "该订单已被接取" }); + } + + // 不能接自己的订单 + if (order.OwnerId == userId) + { + return Results.BadRequest(new { code = 400, message = "不能接取自己的订单" }); + } + + // 状态转换:Pending → InProgress + order.Status = OrderStatus.InProgress; + order.RunnerId = userId; + order.AcceptedAt = DateTime.UtcNow; + + try + { + await db.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + return Results.Conflict(new { code = 409, message = "该订单已被接取" }); + } + + return Results.Ok(new AcceptOrderResponse + { + Id = order.Id, + OrderNo = order.OrderNo, + Status = order.Status.ToString(), + RunnerId = userId, + AcceptedAt = order.AcceptedAt.Value + }); + }).RequireAuthorization(); + + // 微信支付回调通知 + app.MapPost("/api/pay/notify", async (HttpContext httpContext, AppDbContext db) => + { + // 读取请求体 + using var reader = new StreamReader(httpContext.Request.Body); + var body = await reader.ReadToEndAsync(); + + var serialNo = httpContext.Request.Headers["Wechatpay-Serial"].ToString(); + var timestamp = httpContext.Request.Headers["Wechatpay-Timestamp"].ToString(); + var nonce = httpContext.Request.Headers["Wechatpay-Nonce"].ToString(); + var signature = httpContext.Request.Headers["Wechatpay-Signature"].ToString(); + + var wxPay = httpContext.RequestServices.GetRequiredService(); + var result = wxPay.VerifyAndDecryptNotify(serialNo, timestamp, nonce, signature, body); + + if (result == null) + { + return Results.Json(new { code = "FAIL", message = "解密失败" }, statusCode: 500); + } + + if (result.TradeState == "SUCCESS") + { + // 支付成功,更新订单状态(如果还是 Pending 说明支付完成) + var order = await db.Orders.FirstOrDefaultAsync(o => o.OrderNo == result.OrderNo); + if (order != null) + { + Console.WriteLine($"[微信支付] 支付成功: {result.OrderNo}, 金额: {result.TotalAmount}分"); + } + } + + return Results.Json(new { code = "SUCCESS", message = "OK" }); + }).AllowAnonymous(); + + // 取消订单(仅单主可取消待接单订单) + app.MapPost("/api/orders/{id}/cancel", async (int id, HttpContext httpContext, AppDbContext db) => + { + var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Results.Unauthorized(); + var userId = int.Parse(userIdClaim.Value); + + var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id); + if (order == null) + return Results.NotFound(new { code = 404, message = "订单不存在" }); + + if (order.OwnerId != userId) + return Results.BadRequest(new { code = 400, message = "仅单主可取消订单" }); + + if (order.Status != OrderStatus.Pending) + return Results.BadRequest(new { code = 400, message = "仅待接单状态的订单可取消" }); + + order.Status = OrderStatus.Cancelled; + await db.SaveChangesAsync(); + + return Results.Ok(new { id = order.Id, orderNo = order.OrderNo, status = order.Status.ToString() }); + }).RequireAuthorization(); + + // 跑腿提交完成 + app.MapPost("/api/orders/{id}/complete", async (int id, CompleteOrderRequest? request, HttpContext httpContext, AppDbContext db) => + { + var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Results.Unauthorized(); + var userId = int.Parse(userIdClaim.Value); + + var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id); + if (order == null) + return Results.NotFound(new { code = 404, message = "订单不存在" }); + + if (order.RunnerId != userId) + return Results.BadRequest(new { code = 400, message = "仅接单跑腿可提交完成" }); + + if (order.Status != OrderStatus.InProgress) + return Results.BadRequest(new { code = 400, message = "仅进行中的订单可提交完成" }); + + order.Status = OrderStatus.WaitConfirm; + order.CompletionProof = request?.CompletionProof; + order.CompletedAt = DateTime.UtcNow; + + await db.SaveChangesAsync(); + + return Results.Ok(new { id = order.Id, orderNo = order.OrderNo, status = order.Status.ToString(), completedAt = order.CompletedAt }); + }).RequireAuthorization(); + + // 单主确认完成 + app.MapPost("/api/orders/{id}/confirm", async (int id, HttpContext httpContext, AppDbContext db) => + { + var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Results.Unauthorized(); + var userId = int.Parse(userIdClaim.Value); + + var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id); + if (order == null) + return Results.NotFound(new { code = 404, message = "订单不存在" }); + + if (order.OwnerId != userId) + return Results.BadRequest(new { code = 400, message = "仅单主可确认订单完成" }); + + if (order.Status != OrderStatus.WaitConfirm) + return Results.BadRequest(new { code = 400, message = "仅待确认状态的订单可确认完成" }); + + order.Status = OrderStatus.Completed; + + // 创建收益记录(佣金分销计算) + if (order.RunnerId.HasValue) + { + var commissionRules = await db.CommissionRules.OrderBy(r => r.MinAmount).ToListAsync(); + var platformFee = BusinessHelpers.CalculatePlatformFee(order.Commission, commissionRules); + var netEarning = order.Commission - platformFee; + + // 获取冻结时间配置(默认 1 天) + var freezeDaysConfig = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == "freeze_days"); + var freezeDays = 1; + if (freezeDaysConfig != null && int.TryParse(freezeDaysConfig.Value, out var configDays)) + { + freezeDays = configDays; + } + + var earning = new Earning + { + UserId = order.RunnerId.Value, + OrderId = order.Id, + GoodsAmount = order.GoodsAmount, + Commission = order.Commission, + PlatformFee = platformFee, + NetEarning = netEarning, + Status = EarningStatus.Frozen, + FrozenUntil = DateTime.UtcNow.AddDays(freezeDays), + CreatedAt = DateTime.UtcNow + }; + db.Earnings.Add(earning); + } + + await db.SaveChangesAsync(); + + return Results.Ok(new { id = order.Id, orderNo = order.OrderNo, status = order.Status.ToString() }); + }).RequireAuthorization(); + + // 单主拒绝完成(订单回到进行中) + app.MapPost("/api/orders/{id}/reject", async (int id, HttpContext httpContext, AppDbContext db) => + { + var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Results.Unauthorized(); + var userId = int.Parse(userIdClaim.Value); + + var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id); + if (order == null) + return Results.NotFound(new { code = 404, message = "订单不存在" }); + + if (order.OwnerId != userId) + return Results.BadRequest(new { code = 400, message = "仅单主可拒绝订单完成" }); + + if (order.Status != OrderStatus.WaitConfirm) + return Results.BadRequest(new { code = 400, message = "仅待确认状态的订单可拒绝" }); + + order.Status = OrderStatus.InProgress; + order.CompletedAt = null; + order.CompletionProof = null; + await db.SaveChangesAsync(); + + return Results.Ok(new { id = order.Id, orderNo = order.OrderNo, status = order.Status.ToString() }); + }).RequireAuthorization(); + + // 我的订单(单主查看自己发布的订单) + app.MapGet("/api/orders/mine", async (string? status, string? type, HttpContext httpContext, AppDbContext db) => + { + var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Results.Unauthorized(); + var userId = int.Parse(userIdClaim.Value); + + var query = db.Orders.Where(o => o.OwnerId == userId); + + // 按状态筛选 + if (!string.IsNullOrEmpty(status) && Enum.TryParse(status, true, out var orderStatus)) + query = query.Where(o => o.Status == orderStatus); + + // 按类型筛选 + if (!string.IsNullOrEmpty(type) && Enum.TryParse(type, true, out var orderType)) + query = query.Where(o => o.OrderType == orderType); + + var orders = await query + .OrderByDescending(o => o.CreatedAt) + .Select(o => new OrderListItemResponse + { + Id = o.Id, + OrderNo = o.OrderNo, + OwnerId = o.OwnerId, + RunnerId = o.RunnerId, + OrderType = o.OrderType.ToString(), + Status = o.Status.ToString(), + ItemName = o.ItemName, + PickupLocation = o.PickupLocation, + DeliveryLocation = o.DeliveryLocation, + Remark = o.Remark, + Commission = o.Commission, + GoodsAmount = o.GoodsAmount, + TotalAmount = o.TotalAmount, + IsReviewed = o.IsReviewed, + CreatedAt = o.CreatedAt, + AcceptedAt = o.AcceptedAt, + CompletedAt = o.CompletedAt + }) + .ToListAsync(); + + return Results.Ok(orders); + }).RequireAuthorization(); + + // 我的接单(跑腿查看自己接取的订单) + app.MapGet("/api/orders/taken", async (string? status, string? type, HttpContext httpContext, AppDbContext db) => + { + var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Results.Unauthorized(); + var userId = int.Parse(userIdClaim.Value); + + var query = db.Orders.Where(o => o.RunnerId == userId); + + // 按状态筛选 + if (!string.IsNullOrEmpty(status) && Enum.TryParse(status, true, out var orderStatus)) + query = query.Where(o => o.Status == orderStatus); + + // 按类型筛选 + if (!string.IsNullOrEmpty(type) && Enum.TryParse(type, true, out var orderType)) + query = query.Where(o => o.OrderType == orderType); + + var orders = await query + .OrderByDescending(o => o.CreatedAt) + .Select(o => new OrderListItemResponse + { + Id = o.Id, + OrderNo = o.OrderNo, + OwnerId = o.OwnerId, + RunnerId = o.RunnerId, + OrderType = o.OrderType.ToString(), + Status = o.Status.ToString(), + ItemName = o.ItemName, + PickupLocation = o.PickupLocation, + DeliveryLocation = o.DeliveryLocation, + Remark = o.Remark, + Commission = o.Commission, + GoodsAmount = o.GoodsAmount, + TotalAmount = o.TotalAmount, + IsReviewed = o.IsReviewed, + CreatedAt = o.CreatedAt, + AcceptedAt = o.AcceptedAt, + CompletedAt = o.CompletedAt + }) + .ToListAsync(); + + return Results.Ok(orders); + }).RequireAuthorization(); + + // 提交评价(单主评价跑腿) + app.MapPost("/api/orders/{id}/review", async (int id, SubmitReviewRequest request, HttpContext httpContext, AppDbContext db) => + { + var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Results.Unauthorized(); + var userId = int.Parse(userIdClaim.Value); + + var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id); + if (order == null) + return Results.NotFound(new { code = 404, message = "订单不存在" }); + + // 仅单主可评价 + if (order.OwnerId != userId) + return Results.BadRequest(new { code = 400, message = "仅单主可评价跑腿" }); + + // 仅已完成订单可评价 + if (order.Status != OrderStatus.Completed) + return Results.BadRequest(new { code = 400, message = "仅已完成的订单可评价" }); + + // 不可重复评价 + if (order.IsReviewed) + return Results.BadRequest(new { code = 400, message = "该订单已评价" }); + + // 校验评分范围 + if (request.Rating < 1 || request.Rating > 5) + return Results.BadRequest(new { code = 400, message = "评分必须在 1-5 之间" }); + + // 评分计算:1星=-2, 2星=-1, 3星=0, 4星=+1, 5星=+2 + var scoreChange = request.Rating - 3; + + var review = new Review + { + OrderId = id, + RunnerId = order.RunnerId!.Value, + Rating = request.Rating, + Content = request.Content, + ScoreChange = scoreChange, + CreatedAt = DateTime.UtcNow + }; + + db.Reviews.Add(review); + + // 更新跑腿评分(限制在 0-100 之间) + var runner = await db.Users.FindAsync(order.RunnerId!.Value); + if (runner != null) + { + runner.RunnerScore = Math.Clamp(runner.RunnerScore + scoreChange, 0, 100); + } + + // 标记订单已评价 + order.IsReviewed = true; + + await db.SaveChangesAsync(); + + return Results.Ok(new ReviewResponse + { + Id = review.Id, + OrderId = review.OrderId, + RunnerId = review.RunnerId, + Rating = review.Rating, + ScoreChange = review.ScoreChange, + IsDisabled = review.IsDisabled, + CreatedAt = review.CreatedAt + // 注意:不返回 Content,评价内容仅管理员可见 + }); + }).RequireAuthorization(); + + // 发起改价 + app.MapPost("/api/orders/{id}/price-change", async (int id, PriceChangeRequest request, HttpContext httpContext, AppDbContext db) => + { + var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Results.Unauthorized(); + var userId = int.Parse(userIdClaim.Value); + + var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id); + if (order == null) + return Results.NotFound(new { code = 404, message = "订单不存在" }); + + // 仅进行中或待确认的订单可改价 + if (order.Status != OrderStatus.InProgress && order.Status != OrderStatus.WaitConfirm) + return Results.BadRequest(new { code = 400, message = "当前订单状态不支持改价" }); + + // 仅单主或跑腿可发起改价 + if (userId != order.OwnerId && userId != order.RunnerId) + return Results.BadRequest(new { code = 400, message = "仅订单相关方可发起改价" }); + + // 校验改价类型 + if (!Enum.TryParse(request.ChangeType, true, out var changeType) || !Enum.IsDefined(changeType)) + return Results.BadRequest(new { code = 400, message = "改价类型不合法" }); + + // 代购和美食街订单才支持修改商品总额 + if (changeType == PriceChangeType.GoodsAmount && + order.OrderType != OrderType.Purchase && order.OrderType != OrderType.Food && order.OrderType != OrderType.Help) + return Results.BadRequest(new { code = 400, message = "该订单类型不支持修改商品总额" }); + + // 检查是否有待处理的改价申请 + var pendingChange = await db.PriceChanges + .AnyAsync(pc => pc.OrderId == id && pc.Status == PriceChangeStatus.Pending); + if (pendingChange) + return Results.BadRequest(new { code = 400, message = "已有待处理的改价申请,请等待对方确认" }); + + // 获取原价 + var originalPrice = changeType == PriceChangeType.Commission + ? order.Commission + : order.GoodsAmount ?? 0; + + if (request.NewPrice < 0) + return Results.BadRequest(new { code = 400, message = "新价格不能为负数" }); + + var priceChange = new PriceChange + { + OrderId = id, + InitiatorId = userId, + ChangeType = changeType, + OriginalPrice = originalPrice, + NewPrice = request.NewPrice, + Status = PriceChangeStatus.Pending, + CreatedAt = DateTime.UtcNow + }; + + db.PriceChanges.Add(priceChange); + await db.SaveChangesAsync(); + + // 计算差额 + var difference = request.NewPrice - originalPrice; + + return Results.Created($"/api/orders/{id}/price-change/{priceChange.Id}", new PriceChangeResponse + { + Id = priceChange.Id, + OrderId = priceChange.OrderId, + InitiatorId = priceChange.InitiatorId, + ChangeType = priceChange.ChangeType.ToString(), + OriginalPrice = priceChange.OriginalPrice, + NewPrice = priceChange.NewPrice, + Difference = difference, + Status = priceChange.Status.ToString(), + CreatedAt = priceChange.CreatedAt + }); + }).RequireAuthorization(); + + // 响应改价(同意或拒绝) + app.MapPut("/api/orders/{id}/price-change/{changeId}", async (int id, int changeId, RespondPriceChangeRequest request, HttpContext httpContext, AppDbContext db) => + { + var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Results.Unauthorized(); + var userId = int.Parse(userIdClaim.Value); + + var priceChange = await db.PriceChanges + .Include(pc => pc.Order) + .FirstOrDefaultAsync(pc => pc.Id == changeId && pc.OrderId == id); + + if (priceChange == null) + return Results.NotFound(new { code = 404, message = "改价记录不存在" }); + + if (priceChange.Status != PriceChangeStatus.Pending) + return Results.BadRequest(new { code = 400, message = "该改价申请已处理" }); + + // 不能自己响应自己的改价申请 + if (priceChange.InitiatorId == userId) + return Results.BadRequest(new { code = 400, message = "不能响应自己的改价申请" }); + + // 仅订单相关方可响应 + var order = priceChange.Order!; + if (userId != order.OwnerId && userId != order.RunnerId) + return Results.BadRequest(new { code = 400, message = "仅订单相关方可响应改价" }); + + if (!Enum.TryParse(request.Action, true, out var action) + || (action != PriceChangeStatus.Accepted && action != PriceChangeStatus.Rejected)) + return Results.BadRequest(new { code = 400, message = "操作不合法,仅支持 Accepted 或 Rejected" }); + + priceChange.Status = action; + + // 同意改价时更新订单金额 + if (action == PriceChangeStatus.Accepted) + { + if (priceChange.ChangeType == PriceChangeType.Commission) + { + order.Commission = priceChange.NewPrice; + } + else + { + order.GoodsAmount = priceChange.NewPrice; + } + + // 重新计算支付总金额 + if (order.OrderType == OrderType.Help || order.OrderType == OrderType.Purchase || order.OrderType == OrderType.Food) + { + order.TotalAmount = (order.GoodsAmount ?? 0) + order.Commission; + } + else + { + order.TotalAmount = order.Commission; + } + } + + await db.SaveChangesAsync(); + + var difference = priceChange.NewPrice - priceChange.OriginalPrice; + + return Results.Ok(new PriceChangeResponse + { + Id = priceChange.Id, + OrderId = priceChange.OrderId, + InitiatorId = priceChange.InitiatorId, + ChangeType = priceChange.ChangeType.ToString(), + OriginalPrice = priceChange.OriginalPrice, + NewPrice = priceChange.NewPrice, + Difference = difference, + Status = priceChange.Status.ToString(), + CreatedAt = priceChange.CreatedAt + }); + }).RequireAuthorization(); + + // 获取订单申诉记录 + app.MapGet("/api/orders/{id}/appeals", async (int id, AppDbContext db) => + { + var appeals = await db.Appeals + .Where(a => a.OrderId == id) + .OrderByDescending(a => a.CreatedAt) + .Select(a => new + { + a.Id, + a.OrderId, + a.Result, + a.CreatedAt + }) + .ToListAsync(); + + return Results.Ok(appeals); + }).RequireAuthorization(); + } +} diff --git a/server/Endpoints/RunnerEndpoints.cs b/server/Endpoints/RunnerEndpoints.cs new file mode 100644 index 0000000..633db1e --- /dev/null +++ b/server/Endpoints/RunnerEndpoints.cs @@ -0,0 +1,94 @@ +using CampusErrand.Data; +using CampusErrand.Models; +using CampusErrand.Models.Dtos; +using Microsoft.EntityFrameworkCore; + +namespace CampusErrand.Endpoints; + +public static class RunnerEndpoints +{ + public static void MapRunnerEndpoints(this WebApplication app) + { + // 提交跑腿认证申请 + app.MapPost("/api/runner/certification", async (CertificationRequest request, HttpContext httpContext, AppDbContext db) => + { + var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Results.Unauthorized(); + var userId = int.Parse(userIdClaim.Value); + + // 校验 + var errors = new List(); + if (string.IsNullOrWhiteSpace(request.RealName)) + errors.Add(new { field = "realName", message = "姓名不能为空" }); + if (string.IsNullOrWhiteSpace(request.Phone)) + errors.Add(new { field = "phone", message = "手机号不能为空" }); + if (errors.Count > 0) + return Results.BadRequest(new { code = 400, message = "校验失败", errors }); + + // 检查是否已有待审核或已通过的认证 + var existing = await db.RunnerCertifications + .Where(c => c.UserId == userId && (c.Status == CertificationStatus.Pending || c.Status == CertificationStatus.Approved)) + .FirstOrDefaultAsync(); + + if (existing != null) + { + if (existing.Status == CertificationStatus.Approved) + return Results.BadRequest(new { code = 400, message = "您已通过跑腿认证" }); + return Results.BadRequest(new { code = 400, message = "平台审核中" }); + } + + var certification = new RunnerCertification + { + UserId = userId, + RealName = request.RealName, + Phone = request.Phone, + Status = CertificationStatus.Pending, + CreatedAt = DateTime.UtcNow + }; + + db.RunnerCertifications.Add(certification); + await db.SaveChangesAsync(); + + return Results.Created($"/api/runner/certification", new CertificationResponse + { + Id = certification.Id, + UserId = certification.UserId, + RealName = certification.RealName, + Phone = certification.Phone, + Status = certification.Status.ToString(), + CreatedAt = certification.CreatedAt, + ReviewedAt = certification.ReviewedAt + }); + }).RequireAuthorization(); + + // 查询跑腿认证状态 + app.MapGet("/api/runner/certification", async (HttpContext httpContext, AppDbContext db) => + { + var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Results.Unauthorized(); + var userId = int.Parse(userIdClaim.Value); + + // 返回最新的认证记录 + var certification = await db.RunnerCertifications + .Where(c => c.UserId == userId) + .OrderByDescending(c => c.CreatedAt) + .FirstOrDefaultAsync(); + + if (certification == null) + { + return Results.Ok(new { status = "None", message = "未提交认证" }); + } + + return Results.Ok(new CertificationResponse + { + Id = certification.Id, + UserId = certification.UserId, + RealName = certification.RealName, + Phone = certification.Phone, + Status = certification.Status.ToString(), + CreatedAt = certification.CreatedAt, + ReviewedAt = certification.ReviewedAt + }); + }).RequireAuthorization(); + } +} diff --git a/server/Endpoints/ServiceEntryEndpoints.cs b/server/Endpoints/ServiceEntryEndpoints.cs new file mode 100644 index 0000000..80c1127 --- /dev/null +++ b/server/Endpoints/ServiceEntryEndpoints.cs @@ -0,0 +1,125 @@ +using CampusErrand.Data; +using CampusErrand.Models; +using CampusErrand.Models.Dtos; +using Microsoft.EntityFrameworkCore; + +namespace CampusErrand.Endpoints; + +public static class ServiceEntryEndpoints +{ + public static void MapServiceEntryEndpoints(this WebApplication app) + { + // 前端获取服务入口列表(仅启用+按排序权重排列) + app.MapGet("/api/service-entries", async (AppDbContext db) => + { + var entries = await db.ServiceEntries + .Where(e => e.IsEnabled) + .OrderBy(e => e.SortOrder) + .Select(e => new ServiceEntryResponse + { + Id = e.Id, + Name = e.Name, + IconUrl = e.IconUrl, + PagePath = e.PagePath, + SortOrder = e.SortOrder, + IsEnabled = e.IsEnabled + }) + .ToListAsync(); + return Results.Ok(entries); + }); + + // 管理端获取全部服务入口列表 + app.MapGet("/api/admin/service-entries", async (AppDbContext db) => + { + var entries = await db.ServiceEntries + .OrderBy(e => e.SortOrder) + .Select(e => new ServiceEntryResponse + { + Id = e.Id, + Name = e.Name, + IconUrl = e.IconUrl, + PagePath = e.PagePath, + SortOrder = e.SortOrder, + IsEnabled = e.IsEnabled + }) + .ToListAsync(); + return Results.Ok(entries); + }).RequireAuthorization("AdminOnly"); + + // 更新服务入口 + app.MapPut("/api/admin/service-entries/{id}", async (int id, ServiceEntryUpdateRequest request, AppDbContext db) => + { + var entry = await db.ServiceEntries.FindAsync(id); + if (entry == null) + { + return Results.NotFound(new { code = 404, message = "服务入口不存在" }); + } + + if (string.IsNullOrWhiteSpace(request.IconUrl)) + { + return Results.BadRequest(new { code = 400, message = "校验失败", errors = new[] { new { field = "iconUrl", message = "图标图片地址不能为空" } } }); + } + + entry.IconUrl = request.IconUrl; + entry.SortOrder = request.SortOrder; + entry.IsEnabled = request.IsEnabled; + + await db.SaveChangesAsync(); + + return Results.Ok(new ServiceEntryResponse + { + Id = entry.Id, + Name = entry.Name, + IconUrl = entry.IconUrl, + PagePath = entry.PagePath, + SortOrder = entry.SortOrder, + IsEnabled = entry.IsEnabled + }); + }).RequireAuthorization("AdminOnly"); + + // 创建服务入口 + app.MapPost("/api/admin/service-entries", async (ServiceEntryCreateRequest request, AppDbContext db) => + { + if (string.IsNullOrWhiteSpace(request.Name)) + return Results.BadRequest(new { code = 400, message = "服务名称不能为空" }); + if (string.IsNullOrWhiteSpace(request.IconUrl)) + return Results.BadRequest(new { code = 400, message = "背景图片不能为空" }); + if (string.IsNullOrWhiteSpace(request.PagePath)) + return Results.BadRequest(new { code = 400, message = "跳转路径不能为空" }); + + var entry = new ServiceEntry + { + Name = request.Name, + IconUrl = request.IconUrl, + PagePath = request.PagePath, + SortOrder = request.SortOrder, + IsEnabled = request.IsEnabled + }; + + db.ServiceEntries.Add(entry); + await db.SaveChangesAsync(); + + return Results.Created($"/api/admin/service-entries/{entry.Id}", new ServiceEntryResponse + { + Id = entry.Id, + Name = entry.Name, + IconUrl = entry.IconUrl, + PagePath = entry.PagePath, + SortOrder = entry.SortOrder, + IsEnabled = entry.IsEnabled + }); + }).RequireAuthorization("AdminOnly"); + + // 删除服务入口 + app.MapDelete("/api/admin/service-entries/{id}", async (int id, AppDbContext db) => + { + var entry = await db.ServiceEntries.FindAsync(id); + if (entry == null) + return Results.NotFound(new { code = 404, message = "服务入口不存在" }); + + db.ServiceEntries.Remove(entry); + await db.SaveChangesAsync(); + return Results.NoContent(); + }).RequireAuthorization("AdminOnly"); + } +} diff --git a/server/Endpoints/ShopEndpoints.cs b/server/Endpoints/ShopEndpoints.cs new file mode 100644 index 0000000..21cb3da --- /dev/null +++ b/server/Endpoints/ShopEndpoints.cs @@ -0,0 +1,360 @@ +using CampusErrand.Data; +using CampusErrand.Models; +using CampusErrand.Models.Dtos; +using CampusErrand.Helpers; +using Microsoft.EntityFrameworkCore; + +namespace CampusErrand.Endpoints; + +public static class ShopEndpoints +{ + public static void MapShopEndpoints(this WebApplication app) + { + // 前端获取门店列表(仅启用门店,含菜品种类数量) + app.MapGet("/api/shops", async (AppDbContext db) => + { + var shops = await db.Shops + .Where(s => s.IsEnabled) + .Select(s => new ShopListResponse + { + Id = s.Id, + Name = s.Name, + Photo = s.Photo, + Location = s.Location, + DishCount = s.Dishes.Count(d => d.IsEnabled) + }) + .ToListAsync(); + return Results.Ok(shops); + }); + + // 前端获取门店详情(含 Banner 和菜品) + app.MapGet("/api/shops/{id}", async (int id, AppDbContext db) => + { + var shop = await db.Shops + .Include(s => s.ShopBanners) + .Include(s => s.Dishes) + .FirstOrDefaultAsync(s => s.Id == id); + + if (shop == null) + { + return Results.NotFound(new { code = 404, message = "门店不存在" }); + } + + var response = new ShopDetailResponse + { + Id = shop.Id, + Name = shop.Name, + Photo = shop.Photo, + Location = shop.Location, + Notice = shop.Notice, + PackingFeeType = shop.PackingFeeType.ToString(), + PackingFeeAmount = shop.PackingFeeAmount, + IsEnabled = shop.IsEnabled, + Banners = shop.ShopBanners + .OrderBy(b => b.SortOrder) + .Select(b => new ShopBannerResponse + { + Id = b.Id, + ImageUrl = b.ImageUrl, + SortOrder = b.SortOrder + }).ToList(), + Dishes = shop.Dishes + .Where(d => d.IsEnabled) + .Select(d => new DishResponse + { + Id = d.Id, + ShopId = d.ShopId, + Name = d.Name, + Photo = d.Photo, + Price = d.Price, + IsEnabled = d.IsEnabled + }).ToList() + }; + + return Results.Ok(response); + }); + + // 管理端获取门店列表(全部) + app.MapGet("/api/admin/shops", async (AppDbContext db) => + { + var shops = await db.Shops + .Select(s => new AdminShopResponse + { + Id = s.Id, + Name = s.Name, + Photo = s.Photo, + Location = s.Location, + Notice = s.Notice, + PackingFeeType = s.PackingFeeType.ToString(), + PackingFeeAmount = s.PackingFeeAmount, + IsEnabled = s.IsEnabled, + DishCount = s.Dishes.Count + }) + .ToListAsync(); + return Results.Ok(shops); + }).RequireAuthorization("AdminOnly"); + + // 管理端创建门店 + app.MapPost("/api/admin/shops", async (ShopRequest request, AppDbContext db) => + { + var errors = BusinessHelpers.ValidateShopRequest(request); + if (errors.Count > 0) + { + return Results.BadRequest(new { code = 400, message = "校验失败", errors }); + } + + if (!Enum.TryParse(request.PackingFeeType, true, out var feeType) || !Enum.IsDefined(feeType)) + { + return Results.BadRequest(new { code = 400, message = "校验失败", errors = new[] { new { field = "packingFeeType", message = "打包费类型不合法" } } }); + } + + var shop = new Shop + { + Name = request.Name, + Photo = request.Photo, + Location = request.Location, + Notice = request.Notice, + PackingFeeType = feeType, + PackingFeeAmount = request.PackingFeeAmount, + IsEnabled = request.IsEnabled + }; + + db.Shops.Add(shop); + await db.SaveChangesAsync(); + + return Results.Created($"/api/admin/shops/{shop.Id}", new AdminShopResponse + { + Id = shop.Id, + Name = shop.Name, + Photo = shop.Photo, + Location = shop.Location, + Notice = shop.Notice, + PackingFeeType = shop.PackingFeeType.ToString(), + PackingFeeAmount = shop.PackingFeeAmount, + IsEnabled = shop.IsEnabled, + DishCount = 0 + }); + }).RequireAuthorization("AdminOnly"); + + // 管理端更新门店 + app.MapPut("/api/admin/shops/{id}", async (int id, ShopRequest request, AppDbContext db) => + { + var shop = await db.Shops.FindAsync(id); + if (shop == null) + { + return Results.NotFound(new { code = 404, message = "门店不存在" }); + } + + var errors = BusinessHelpers.ValidateShopRequest(request); + if (errors.Count > 0) + { + return Results.BadRequest(new { code = 400, message = "校验失败", errors }); + } + + if (!Enum.TryParse(request.PackingFeeType, true, out var feeType) || !Enum.IsDefined(feeType)) + { + return Results.BadRequest(new { code = 400, message = "校验失败", errors = new[] { new { field = "packingFeeType", message = "打包费类型不合法" } } }); + } + + shop.Name = request.Name; + shop.Photo = request.Photo; + shop.Location = request.Location; + shop.Notice = request.Notice; + shop.PackingFeeType = feeType; + shop.PackingFeeAmount = request.PackingFeeAmount; + shop.IsEnabled = request.IsEnabled; + + await db.SaveChangesAsync(); + + return Results.Ok(new AdminShopResponse + { + Id = shop.Id, + Name = shop.Name, + Photo = shop.Photo, + Location = shop.Location, + Notice = shop.Notice, + PackingFeeType = shop.PackingFeeType.ToString(), + PackingFeeAmount = shop.PackingFeeAmount, + IsEnabled = shop.IsEnabled, + DishCount = await db.Dishes.CountAsync(d => d.ShopId == shop.Id) + }); + }).RequireAuthorization("AdminOnly"); + + // 管理端删除门店 + app.MapDelete("/api/admin/shops/{id}", async (int id, AppDbContext db) => + { + var shop = await db.Shops + .Include(s => s.Dishes) + .Include(s => s.ShopBanners) + .FirstOrDefaultAsync(s => s.Id == id); + + if (shop == null) + { + return Results.NotFound(new { code = 404, message = "门店不存在" }); + } + + // 级联删除菜品和门店 Banner + db.Dishes.RemoveRange(shop.Dishes); + db.ShopBanners.RemoveRange(shop.ShopBanners); + db.Shops.Remove(shop); + await db.SaveChangesAsync(); + + return Results.NoContent(); + }).RequireAuthorization("AdminOnly"); + + // 获取门店 Banner 列表 + app.MapGet("/api/admin/shops/{shopId}/banners", async (int shopId, AppDbContext db) => + { + var shop = await db.Shops.FindAsync(shopId); + if (shop == null) + return Results.NotFound(new { code = 404, message = "门店不存在" }); + + var banners = await db.ShopBanners + .Where(b => b.ShopId == shopId) + .OrderBy(b => b.SortOrder) + .Select(b => new ShopBannerResponse { Id = b.Id, ImageUrl = b.ImageUrl, SortOrder = b.SortOrder }) + .ToListAsync(); + return Results.Ok(banners); + }).RequireAuthorization("AdminOnly"); + + // 创建门店 Banner + app.MapPost("/api/admin/shops/{shopId}/banners", async (int shopId, ShopBannerRequest request, AppDbContext db) => + { + var shop = await db.Shops.FindAsync(shopId); + if (shop == null) + return Results.NotFound(new { code = 404, message = "门店不存在" }); + + if (string.IsNullOrWhiteSpace(request.ImageUrl)) + return Results.BadRequest(new { code = 400, message = "校验失败", errors = new[] { new { field = "imageUrl", message = "图片地址不能为空" } } }); + + var banner = new ShopBanner { ShopId = shopId, ImageUrl = request.ImageUrl, SortOrder = request.SortOrder }; + db.ShopBanners.Add(banner); + await db.SaveChangesAsync(); + + return Results.Created($"/api/admin/shops/{shopId}/banners/{banner.Id}", + new ShopBannerResponse { Id = banner.Id, ImageUrl = banner.ImageUrl, SortOrder = banner.SortOrder }); + }).RequireAuthorization("AdminOnly"); + + // 删除门店 Banner + app.MapDelete("/api/admin/shops/{shopId}/banners/{bannerId}", async (int shopId, int bannerId, AppDbContext db) => + { + var banner = await db.ShopBanners.FirstOrDefaultAsync(b => b.Id == bannerId && b.ShopId == shopId); + if (banner == null) + return Results.NotFound(new { code = 404, message = "门店 Banner 不存在" }); + + db.ShopBanners.Remove(banner); + await db.SaveChangesAsync(); + return Results.NoContent(); + }).RequireAuthorization("AdminOnly"); + + // 获取门店菜品列表 + app.MapGet("/api/admin/shops/{shopId}/dishes", async (int shopId, AppDbContext db) => + { + var shop = await db.Shops.FindAsync(shopId); + if (shop == null) + { + return Results.NotFound(new { code = 404, message = "门店不存在" }); + } + + var dishes = await db.Dishes + .Where(d => d.ShopId == shopId) + .Select(d => new DishResponse + { + Id = d.Id, + ShopId = d.ShopId, + Name = d.Name, + Photo = d.Photo, + Price = d.Price, + IsEnabled = d.IsEnabled + }) + .ToListAsync(); + return Results.Ok(dishes); + }).RequireAuthorization("AdminOnly"); + + // 创建菜品 + app.MapPost("/api/admin/shops/{shopId}/dishes", async (int shopId, DishRequest request, AppDbContext db) => + { + var shop = await db.Shops.FindAsync(shopId); + if (shop == null) + { + return Results.NotFound(new { code = 404, message = "门店不存在" }); + } + + var errors = BusinessHelpers.ValidateDishRequest(request); + if (errors.Count > 0) + { + return Results.BadRequest(new { code = 400, message = "校验失败", errors }); + } + + var dish = new Dish + { + ShopId = shopId, + Name = request.Name, + Photo = request.Photo, + Price = request.Price, + IsEnabled = request.IsEnabled + }; + + db.Dishes.Add(dish); + await db.SaveChangesAsync(); + + return Results.Created($"/api/admin/shops/{shopId}/dishes/{dish.Id}", new DishResponse + { + Id = dish.Id, + ShopId = dish.ShopId, + Name = dish.Name, + Photo = dish.Photo, + Price = dish.Price, + IsEnabled = dish.IsEnabled + }); + }).RequireAuthorization("AdminOnly"); + + // 更新菜品 + app.MapPut("/api/admin/shops/{shopId}/dishes/{dishId}", async (int shopId, int dishId, DishRequest request, AppDbContext db) => + { + var dish = await db.Dishes.FirstOrDefaultAsync(d => d.Id == dishId && d.ShopId == shopId); + if (dish == null) + { + return Results.NotFound(new { code = 404, message = "菜品不存在" }); + } + + var errors = BusinessHelpers.ValidateDishRequest(request); + if (errors.Count > 0) + { + return Results.BadRequest(new { code = 400, message = "校验失败", errors }); + } + + dish.Name = request.Name; + dish.Photo = request.Photo; + dish.Price = request.Price; + dish.IsEnabled = request.IsEnabled; + + await db.SaveChangesAsync(); + + return Results.Ok(new DishResponse + { + Id = dish.Id, + ShopId = dish.ShopId, + Name = dish.Name, + Photo = dish.Photo, + Price = dish.Price, + IsEnabled = dish.IsEnabled + }); + }).RequireAuthorization("AdminOnly"); + + // 删除菜品 + app.MapDelete("/api/admin/shops/{shopId}/dishes/{dishId}", async (int shopId, int dishId, AppDbContext db) => + { + var dish = await db.Dishes.FirstOrDefaultAsync(d => d.Id == dishId && d.ShopId == shopId); + if (dish == null) + { + return Results.NotFound(new { code = 404, message = "菜品不存在" }); + } + + db.Dishes.Remove(dish); + await db.SaveChangesAsync(); + + return Results.NoContent(); + }).RequireAuthorization("AdminOnly"); + } +} diff --git a/server/Helpers/BusinessHelpers.cs b/server/Helpers/BusinessHelpers.cs new file mode 100644 index 0000000..cc454fc --- /dev/null +++ b/server/Helpers/BusinessHelpers.cs @@ -0,0 +1,191 @@ +using CampusErrand.Data; +using CampusErrand.Models; +using CampusErrand.Models.Dtos; +using Microsoft.EntityFrameworkCore; + +namespace CampusErrand.Helpers; + +/// +/// 业务辅助方法集合 +/// +public static class BusinessHelpers +{ + /// + /// Banner 请求校验 + /// + internal static List ValidateBannerRequest(BannerRequest request) + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(request.ImageUrl)) + errors.Add(new { field = "imageUrl", message = "图片地址不能为空" }); + if (string.IsNullOrWhiteSpace(request.LinkUrl)) + errors.Add(new { field = "linkUrl", message = "链接地址不能为空" }); + if (string.IsNullOrWhiteSpace(request.LinkType)) + errors.Add(new { field = "linkType", message = "链接类型不能为空" }); + else if (!Enum.TryParse(request.LinkType, true, out var parsed) || !Enum.IsDefined(parsed)) + errors.Add(new { field = "linkType", message = "链接类型不合法" }); + return errors; + } + + /// + /// 门店请求校验 + /// + internal static List ValidateShopRequest(ShopRequest request) + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(request.Name)) + errors.Add(new { field = "name", message = "门店名称不能为空" }); + if (string.IsNullOrWhiteSpace(request.Photo)) + errors.Add(new { field = "photo", message = "门店照片不能为空" }); + if (string.IsNullOrWhiteSpace(request.Location)) + errors.Add(new { field = "location", message = "门店位置不能为空" }); + if (string.IsNullOrWhiteSpace(request.PackingFeeType)) + errors.Add(new { field = "packingFeeType", message = "打包费类型不能为空" }); + if (request.PackingFeeAmount < 0) + errors.Add(new { field = "packingFeeAmount", message = "打包费金额不能为负数" }); + return errors; + } + + /// + /// 菜品请求校验 + /// + internal static List ValidateDishRequest(DishRequest request) + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(request.Name)) + errors.Add(new { field = "name", message = "菜品名称不能为空" }); + if (string.IsNullOrWhiteSpace(request.Photo)) + errors.Add(new { field = "photo", message = "菜品照片不能为空" }); + if (request.Price < 0) + errors.Add(new { field = "price", message = "菜品价格不能为负数" }); + return errors; + } + + /// + /// 计算单个门店的打包费 + /// Fixed 模式:固定金额,不论菜品数量 + /// PerItem 模式:菜品总份数 × 单份打包费 + /// + internal static decimal CalculatePackingFee(PackingFeeType feeType, decimal feeAmount, int totalQuantity) + { + return feeType switch + { + PackingFeeType.Fixed => feeAmount, + PackingFeeType.PerItem => feeAmount * totalQuantity, + _ => 0m + }; + } + + /// + /// 计算平台佣金分成 + /// 根据佣金区间规则计算:百分比类型按比例,固定类型扣除固定金额 + /// + internal static decimal CalculatePlatformFee(decimal commission, List rules) + { + // 查找匹配的佣金区间 + var matchedRule = rules + .Where(r => commission >= r.MinAmount && (r.MaxAmount == null || commission <= r.MaxAmount)) + .FirstOrDefault(); + + if (matchedRule == null) return 0m; + + return matchedRule.RateType switch + { + CommissionRateType.Percentage => Math.Round(commission * matchedRule.Rate / 100m, 2), + CommissionRateType.Fixed => Math.Min(matchedRule.Rate, commission), + _ => 0m + }; + } + + /// + /// 获取用户可见的系统消息(按目标类型过滤) + /// + internal static async Task> GetVisibleSystemMessages(AppDbContext db, int userId, User? user) + { + var allMessages = await db.SystemMessages.ToListAsync(); + return allMessages.Where(m => + { + if (m.TargetType == MessageTargetType.All) return true; + if (m.TargetType == MessageTargetType.OrderUser) + return user != null && user.Role != UserRole.Runner; + if (m.TargetType == MessageTargetType.RunnerUser) + return user != null && (user.Role == UserRole.Runner || user.Role == UserRole.Admin); + if (m.TargetType == MessageTargetType.Specific && m.TargetUserIds != null) + { + try + { + var ids = System.Text.Json.JsonSerializer.Deserialize>(m.TargetUserIds); + return ids != null && ids.Contains(userId); + } + catch { return false; } + } + return false; + }).ToList(); + } + + /// + /// 解冻已到期的收益记录:将冻结期满的收益从 Frozen 变为 Available + /// + internal static async Task UnfreezeEarnings(AppDbContext db) + { + var now = DateTime.UtcNow; + var frozenEarnings = await db.Earnings + .Where(e => e.Status == EarningStatus.Frozen && e.FrozenUntil <= now) + .ToListAsync(); + + foreach (var earning in frozenEarnings) + { + earning.Status = EarningStatus.Available; + } + + if (frozenEarnings.Count > 0) + { + await db.SaveChangesAsync(); + } + } + + /// + /// 自动确认超过24小时未处理的待确认订单 + /// + internal static async Task AutoConfirmExpiredOrders(AppDbContext db) + { + var cutoff = DateTime.UtcNow.AddHours(-24); + var expiredOrders = await db.Orders + .Where(o => o.Status == OrderStatus.WaitConfirm && o.CompletedAt != null && o.CompletedAt <= cutoff) + .ToListAsync(); + + foreach (var order in expiredOrders) + { + order.Status = OrderStatus.Completed; + + // 计算佣金收益 + var rules = await db.CommissionRules.OrderBy(r => r.MinAmount).ToListAsync(); + var platformFee = CalculatePlatformFee(order.Commission, rules); + var netEarning = order.Commission - platformFee; + + var freezeDaysConfig = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == "freeze_days"); + var freezeDays = 1; + if (freezeDaysConfig != null && int.TryParse(freezeDaysConfig.Value, out var configDays)) + freezeDays = configDays; + + db.Earnings.Add(new Earning + { + UserId = order.RunnerId!.Value, + OrderId = order.Id, + GoodsAmount = order.GoodsAmount, + Commission = order.Commission, + PlatformFee = platformFee, + NetEarning = netEarning, + Status = EarningStatus.Frozen, + FrozenUntil = DateTime.UtcNow.AddDays(freezeDays), + CreatedAt = DateTime.UtcNow + }); + } + + if (expiredOrders.Count > 0) + { + await db.SaveChangesAsync(); + Console.WriteLine($"[定时任务] 自动确认了 {expiredOrders.Count} 个超时订单"); + } + } +} diff --git a/server/Models/Dtos/OrderDtos.cs b/server/Models/Dtos/OrderDtos.cs index 647529c..95f6121 100644 --- a/server/Models/Dtos/OrderDtos.cs +++ b/server/Models/Dtos/OrderDtos.cs @@ -179,3 +179,12 @@ public class OrderListItemResponse public DateTime? AcceptedAt { get; set; } public DateTime? CompletedAt { get; set; } } + +/// +/// 管理端取消订单请求 +/// +public class AdminCancelOrderRequest +{ + /// 取消原因 + public string Reason { get; set; } = string.Empty; +} diff --git a/server/Program.cs b/server/Program.cs index 435c3fd..7bbdf36 100644 --- a/server/Program.cs +++ b/server/Program.cs @@ -1,8 +1,9 @@ using System.Text; using CampusErrand.Data; using CampusErrand.Models; -using CampusErrand.Models.Dtos; using CampusErrand.Services; +using CampusErrand.Endpoints; +using CampusErrand.Helpers; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; @@ -71,6 +72,9 @@ builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); builder.Services.AddSingleton(); +// 注册微信支付服务 +builder.Services.AddHttpClient(); + // OpenAPI 文档(.NET 10 内置) builder.Services.AddOpenApi(); @@ -91,3005 +95,18 @@ app.UseCors(); app.UseAuthentication(); app.UseAuthorization(); -// 微信手机号登录接口 -app.MapPost("/api/auth/login", async ( - WeChatLoginRequest request, - IWeChatService weChatService, - JwtService jwtService, - AppDbContext db) => -{ - // 1. 调用微信 code2Session 获取 session_key 和 openid - var sessionResult = await weChatService.Code2SessionAsync(request.Code); - if (!sessionResult.Success) - { - return Results.BadRequest(new { code = 400, message = sessionResult.ErrorMessage }); - } - - // 2. 使用 session_key 解密手机号 - string phoneNumber; - try - { - phoneNumber = weChatService.DecryptPhoneNumber( - sessionResult.SessionKey!, request.EncryptedData, request.Iv); - } - catch (Exception) - { - return Results.BadRequest(new { code = 400, message = "手机号解密失败" }); - } - - // 3. 根据手机号查找或创建用户 - var user = await db.Users.FirstOrDefaultAsync(u => u.Phone == phoneNumber); - if (user == null) - { - user = new User - { - OpenId = sessionResult.OpenId!, - Phone = phoneNumber, - Nickname = $"用户{phoneNumber[^4..]}", - CreatedAt = DateTime.UtcNow - }; - db.Users.Add(user); - await db.SaveChangesAsync(); - } - else if (string.IsNullOrEmpty(user.OpenId) && !string.IsNullOrEmpty(sessionResult.OpenId)) - { - // 更新 OpenId(如果之前为空) - user.OpenId = sessionResult.OpenId; - await db.SaveChangesAsync(); - } - - // 4. 生成 JWT 返回 - var token = jwtService.GenerateToken(user.Id, user.Role.ToString()); - - return Results.Ok(new LoginResponse - { - Token = token, - UserInfo = new UserInfo - { - Id = user.Id, - Phone = user.Phone, - Nickname = user.Nickname, - AvatarUrl = user.AvatarUrl, - Role = user.Role.ToString() - } - }); -}); - -// 微信快捷登录(仅需 code,通过 openid 注册/登录) -app.MapPost("/api/auth/wx-login", async ( - WxLoginRequest request, - IWeChatService weChatService, - JwtService jwtService, - AppDbContext db) => -{ - // 1. 调用微信 code2Session 获取 openid - Console.WriteLine($"[wx-login] 收到 code: {request.Code}"); - var sessionResult = await weChatService.Code2SessionAsync(request.Code); - if (!sessionResult.Success) - { - return Results.BadRequest(new { code = 400, message = sessionResult.ErrorMessage }); - } - - // 2. 根据 openid 查找或创建用户 - var openId = sessionResult.OpenId!; - var user = await db.Users.FirstOrDefaultAsync(u => u.OpenId == openId); - if (user == null) - { - // 自动注册,手机号暂时留空 - var randomSuffix = Random.Shared.Next(1000, 9999).ToString(); - user = new User - { - OpenId = openId, - Phone = "", - Nickname = $"微信用户{randomSuffix}", - CreatedAt = DateTime.UtcNow - }; - db.Users.Add(user); - await db.SaveChangesAsync(); - } - - // 3. 生成 JWT 返回 - var token = jwtService.GenerateToken(user.Id, user.Role.ToString()); - - return Results.Ok(new LoginResponse - { - Token = token, - UserInfo = new UserInfo - { - Id = user.Id, - Phone = user.Phone, - Nickname = user.Nickname, - AvatarUrl = user.AvatarUrl, - Role = user.Role.ToString() - } - }); -}); - -// 受保护的测试端点(用于验证认证和授权) -app.MapGet("/api/protected", () => Results.Ok("ok")) - .RequireAuthorization(); - -// ========== 腾讯 IM 接口 ========== - -// 获取 IM UserSig(登录后调用) -app.MapGet("/api/im/usersig", ( - HttpContext context, - TencentIMService imService) => -{ - var userId = int.Parse(context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)!.Value); - var imUserId = $"user_{userId}"; - var userSig = imService.GenerateUserSig(imUserId); - return Results.Ok(new - { - sdkAppId = imService.SDKAppId, - userId = imUserId, - userSig - }); -}).RequireAuthorization(); - -app.MapGet("/api/admin/protected", () => Results.Ok("admin ok")) - .RequireAuthorization("AdminOnly"); - -// ========== 首页概览统计接口 ========== - -app.MapGet("/api/admin/dashboard", async (AppDbContext db) => -{ - var today = DateTime.UtcNow.Date; - - // 用户统计 - var totalUsers = await db.Users.CountAsync(); - var todayUsers = await db.Users.CountAsync(u => u.CreatedAt >= today); - - // 订单统计 - var totalOrders = await db.Orders.CountAsync(); - var todayOrders = await db.Orders.CountAsync(o => o.CreatedAt >= today); - var pendingOrders = await db.Orders.CountAsync(o => o.Status == OrderStatus.Pending); - var inProgressOrders = await db.Orders.CountAsync(o => o.Status == OrderStatus.InProgress); - var completedOrders = await db.Orders.CountAsync(o => o.Status == OrderStatus.Completed); - - // 收益统计 - var totalEarnings = await db.Earnings.SumAsync(e => (decimal?)e.Commission) ?? 0; - var todayEarnings = await db.Earnings.Where(e => e.CreatedAt >= today).SumAsync(e => (decimal?)e.Commission) ?? 0; - - // 跑腿认证统计 - var pendingCertifications = await db.RunnerCertifications.CountAsync(c => c.Status == CertificationStatus.Pending); - var approvedRunners = await db.RunnerCertifications.CountAsync(c => c.Status == CertificationStatus.Approved); - - // 门店统计 - var totalShops = await db.Shops.CountAsync(); - var enabledShops = await db.Shops.CountAsync(s => s.IsEnabled); - - // 最近7天订单趋势 - var sevenDaysAgo = today.AddDays(-6); - var orderTrend = await db.Orders - .Where(o => o.CreatedAt >= sevenDaysAgo) - .GroupBy(o => o.CreatedAt.Date) - .Select(g => new { Date = g.Key, Count = g.Count() }) - .OrderBy(x => x.Date) - .ToListAsync(); - - // 补全7天数据(没有订单的日期补0) - var trendList = Enumerable.Range(0, 7).Select(i => - { - var date = sevenDaysAgo.AddDays(i); - var count = orderTrend.FirstOrDefault(x => x.Date == date)?.Count ?? 0; - return new { Date = date.ToString("MM-dd"), Count = count }; - }).ToList(); - - // 订单类型分布 - var orderTypeDistribution = await db.Orders - .GroupBy(o => o.OrderType) - .Select(g => new { Type = g.Key.ToString(), Count = g.Count() }) - .ToListAsync(); - - return Results.Ok(new - { - users = new { total = totalUsers, today = todayUsers }, - orders = new { total = totalOrders, today = todayOrders, pending = pendingOrders, inProgress = inProgressOrders, completed = completedOrders }, - earnings = new { total = totalEarnings, today = todayEarnings }, - runners = new { pendingCertifications, approved = approvedRunners }, - shops = new { total = totalShops, enabled = enabledShops }, - orderTrend = trendList, - orderTypeDistribution - }); -}).RequireAuthorization("AdminOnly"); - -// ========== Banner 接口 ========== - -// 前端获取 Banner 列表(仅启用+按排序权重排列) -app.MapGet("/api/banners", async (AppDbContext db) => -{ - var banners = await db.Banners - .Where(b => b.IsEnabled) - .OrderBy(b => b.SortOrder) - .Select(b => new BannerResponse - { - Id = b.Id, - ImageUrl = b.ImageUrl, - LinkType = b.LinkType.ToString(), - LinkUrl = b.LinkUrl, - SortOrder = b.SortOrder, - IsEnabled = b.IsEnabled, - CreatedAt = b.CreatedAt - }) - .ToListAsync(); - return Results.Ok(banners); -}); - -// 管理端获取全部 Banner 列表 -app.MapGet("/api/admin/banners", async (AppDbContext db) => -{ - var banners = await db.Banners - .OrderBy(b => b.SortOrder) - .Select(b => new BannerResponse - { - Id = b.Id, - ImageUrl = b.ImageUrl, - LinkType = b.LinkType.ToString(), - LinkUrl = b.LinkUrl, - SortOrder = b.SortOrder, - IsEnabled = b.IsEnabled, - CreatedAt = b.CreatedAt - }) - .ToListAsync(); - return Results.Ok(banners); -}).RequireAuthorization("AdminOnly"); - -// 创建 Banner -app.MapPost("/api/admin/banners", async (BannerRequest request, AppDbContext db) => -{ - // 校验 - var errors = ValidateBannerRequest(request); - if (errors.Count > 0) - { - return Results.BadRequest(new { code = 400, message = "校验失败", errors }); - } - - if (!Enum.TryParse(request.LinkType, true, out var linkType) || !Enum.IsDefined(linkType)) - { - return Results.BadRequest(new { code = 400, message = "校验失败", errors = new[] { new { field = "linkType", message = "链接类型不合法" } } }); - } - - var banner = new Banner - { - ImageUrl = request.ImageUrl, - LinkType = linkType, - LinkUrl = request.LinkUrl, - SortOrder = request.SortOrder, - IsEnabled = request.IsEnabled, - CreatedAt = DateTime.UtcNow - }; - - db.Banners.Add(banner); - await db.SaveChangesAsync(); - - return Results.Created($"/api/admin/banners/{banner.Id}", new BannerResponse - { - Id = banner.Id, - ImageUrl = banner.ImageUrl, - LinkType = banner.LinkType.ToString(), - LinkUrl = banner.LinkUrl, - SortOrder = banner.SortOrder, - IsEnabled = banner.IsEnabled, - CreatedAt = banner.CreatedAt - }); -}).RequireAuthorization("AdminOnly"); - -// 更新 Banner -app.MapPut("/api/admin/banners/{id}", async (int id, BannerRequest request, AppDbContext db) => -{ - var banner = await db.Banners.FindAsync(id); - if (banner == null) - { - return Results.NotFound(new { code = 404, message = "Banner 不存在" }); - } - - var errors = ValidateBannerRequest(request); - if (errors.Count > 0) - { - return Results.BadRequest(new { code = 400, message = "校验失败", errors }); - } - - if (!Enum.TryParse(request.LinkType, true, out var linkType) || !Enum.IsDefined(linkType)) - { - return Results.BadRequest(new { code = 400, message = "校验失败", errors = new[] { new { field = "linkType", message = "链接类型不合法" } } }); - } - - banner.ImageUrl = request.ImageUrl; - banner.LinkType = linkType; - banner.LinkUrl = request.LinkUrl; - banner.SortOrder = request.SortOrder; - banner.IsEnabled = request.IsEnabled; - - await db.SaveChangesAsync(); - - return Results.Ok(new BannerResponse - { - Id = banner.Id, - ImageUrl = banner.ImageUrl, - LinkType = banner.LinkType.ToString(), - LinkUrl = banner.LinkUrl, - SortOrder = banner.SortOrder, - IsEnabled = banner.IsEnabled, - CreatedAt = banner.CreatedAt - }); -}).RequireAuthorization("AdminOnly"); - -// 删除 Banner -app.MapDelete("/api/admin/banners/{id}", async (int id, AppDbContext db) => -{ - var banner = await db.Banners.FindAsync(id); - if (banner == null) - { - return Results.NotFound(new { code = 404, message = "Banner 不存在" }); - } - - db.Banners.Remove(banner); - await db.SaveChangesAsync(); - - return Results.NoContent(); -}).RequireAuthorization("AdminOnly"); - -// ========== 服务入口接口 ========== - -// 前端获取服务入口列表(仅启用+按排序权重排列) -app.MapGet("/api/service-entries", async (AppDbContext db) => -{ - var entries = await db.ServiceEntries - .Where(e => e.IsEnabled) - .OrderBy(e => e.SortOrder) - .Select(e => new ServiceEntryResponse - { - Id = e.Id, - Name = e.Name, - IconUrl = e.IconUrl, - PagePath = e.PagePath, - SortOrder = e.SortOrder, - IsEnabled = e.IsEnabled - }) - .ToListAsync(); - return Results.Ok(entries); -}); - -// 管理端获取全部服务入口列表 -app.MapGet("/api/admin/service-entries", async (AppDbContext db) => -{ - var entries = await db.ServiceEntries - .OrderBy(e => e.SortOrder) - .Select(e => new ServiceEntryResponse - { - Id = e.Id, - Name = e.Name, - IconUrl = e.IconUrl, - PagePath = e.PagePath, - SortOrder = e.SortOrder, - IsEnabled = e.IsEnabled - }) - .ToListAsync(); - return Results.Ok(entries); -}).RequireAuthorization("AdminOnly"); - -// 更新服务入口 -app.MapPut("/api/admin/service-entries/{id}", async (int id, ServiceEntryUpdateRequest request, AppDbContext db) => -{ - var entry = await db.ServiceEntries.FindAsync(id); - if (entry == null) - { - return Results.NotFound(new { code = 404, message = "服务入口不存在" }); - } - - if (string.IsNullOrWhiteSpace(request.IconUrl)) - { - return Results.BadRequest(new { code = 400, message = "校验失败", errors = new[] { new { field = "iconUrl", message = "图标图片地址不能为空" } } }); - } - - entry.IconUrl = request.IconUrl; - entry.SortOrder = request.SortOrder; - entry.IsEnabled = request.IsEnabled; - - await db.SaveChangesAsync(); - - return Results.Ok(new ServiceEntryResponse - { - Id = entry.Id, - Name = entry.Name, - IconUrl = entry.IconUrl, - PagePath = entry.PagePath, - SortOrder = entry.SortOrder, - IsEnabled = entry.IsEnabled - }); -}).RequireAuthorization("AdminOnly"); - -// 创建服务入口 -app.MapPost("/api/admin/service-entries", async (ServiceEntryCreateRequest request, AppDbContext db) => -{ - if (string.IsNullOrWhiteSpace(request.Name)) - return Results.BadRequest(new { code = 400, message = "服务名称不能为空" }); - if (string.IsNullOrWhiteSpace(request.IconUrl)) - return Results.BadRequest(new { code = 400, message = "背景图片不能为空" }); - if (string.IsNullOrWhiteSpace(request.PagePath)) - return Results.BadRequest(new { code = 400, message = "跳转路径不能为空" }); - - var entry = new ServiceEntry - { - Name = request.Name, - IconUrl = request.IconUrl, - PagePath = request.PagePath, - SortOrder = request.SortOrder, - IsEnabled = request.IsEnabled - }; - - db.ServiceEntries.Add(entry); - await db.SaveChangesAsync(); - - return Results.Created($"/api/admin/service-entries/{entry.Id}", new ServiceEntryResponse - { - Id = entry.Id, - Name = entry.Name, - IconUrl = entry.IconUrl, - PagePath = entry.PagePath, - SortOrder = entry.SortOrder, - IsEnabled = entry.IsEnabled - }); -}).RequireAuthorization("AdminOnly"); - -// 删除服务入口 -app.MapDelete("/api/admin/service-entries/{id}", async (int id, AppDbContext db) => -{ - var entry = await db.ServiceEntries.FindAsync(id); - if (entry == null) - return Results.NotFound(new { code = 404, message = "服务入口不存在" }); - - db.ServiceEntries.Remove(entry); - await db.SaveChangesAsync(); - return Results.NoContent(); -}).RequireAuthorization("AdminOnly"); - -// ========== 订单接口 ========== - -// 创建订单 -app.MapPost("/api/orders", async (CreateOrderRequest request, HttpContext httpContext, AppDbContext db) => -{ - // 获取当前用户 ID - var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return Results.Unauthorized(); - var userId = int.Parse(userIdClaim.Value); - - // 校验订单类型 - if (!Enum.TryParse(request.OrderType, true, out var orderType) || !Enum.IsDefined(orderType)) - { - return Results.BadRequest(new { code = 400, message = "订单类型不合法" }); - } - - // 佣金校验:最低 1.0 元 - if (request.Commission < 1.0m) - { - return Results.BadRequest(new { code = 400, message = "跑腿佣金不可低于1.0元" }); - } - - // 佣金校验:小数点后最多 1 位 - if (request.Commission != Math.Round(request.Commission, 1)) - { - return Results.BadRequest(new { code = 400, message = "跑腿佣金最多支持小数点后1位" }); - } - - // 计算支付总金额 - decimal totalAmount; - decimal packingFee = 0; - if (orderType == OrderType.Help || orderType == OrderType.Purchase || orderType == OrderType.Food) - { - // 美食街订单:自动计算打包费并加入商品总金额 - if (orderType == OrderType.Food && request.FoodItems != null && request.FoodItems.Count > 0) - { - // 获取涉及的门店 ID 列表 - var shopIds = request.FoodItems.Select(fi => fi.ShopId).Distinct().ToList(); - var shops = await db.Shops.Where(s => shopIds.Contains(s.Id)).ToListAsync(); - - // 按门店计算打包费 - foreach (var shop in shops) - { - var shopItems = request.FoodItems.Where(fi => fi.ShopId == shop.Id).ToList(); - var totalQuantity = shopItems.Sum(fi => fi.Quantity); - packingFee += CalculatePackingFee(shop.PackingFeeType, shop.PackingFeeAmount, totalQuantity); - } - - // 计算菜品总金额 - var dishTotal = request.FoodItems.Sum(fi => fi.Quantity * fi.UnitPrice); - // 商品总金额 = 菜品总金额 + 打包费 - request.GoodsAmount = dishTotal + packingFee; - } - - // 商品+佣金类:支付总金额 = 商品总金额 + 跑腿佣金 - if (request.GoodsAmount == null || request.GoodsAmount < 0) - { - return Results.BadRequest(new { code = 400, message = "商品总金额不能为空或负数" }); - } - totalAmount = request.GoodsAmount.Value + request.Commission; - } - else - { - // 佣金类(代取、代送):支付总金额 = 跑腿佣金 - totalAmount = request.Commission; - } - - // 生成订单编号 - var orderNo = DateTime.UtcNow.ToString("yyyyMMddHHmmss") + Random.Shared.Next(100000, 999999).ToString(); - - var order = new Order - { - OrderNo = orderNo, - OwnerId = userId, - OrderType = orderType, - Status = OrderStatus.Pending, - ItemName = request.ItemName, - PickupLocation = request.PickupLocation, - DeliveryLocation = request.DeliveryLocation, - Remark = request.Remark, - Phone = request.Phone, - Commission = request.Commission, - GoodsAmount = request.GoodsAmount, - TotalAmount = totalAmount, - CreatedAt = DateTime.UtcNow - }; - - db.Orders.Add(order); - await db.SaveChangesAsync(); - - // 美食街订单:保存菜品详情 - if (orderType == OrderType.Food && request.FoodItems != null) - { - foreach (var item in request.FoodItems) - { - db.FoodOrderItems.Add(new FoodOrderItem - { - OrderId = order.Id, - ShopId = item.ShopId, - DishId = item.DishId, - Quantity = item.Quantity, - UnitPrice = item.UnitPrice - }); - } - await db.SaveChangesAsync(); - } - - return Results.Created($"/api/orders/{order.Id}", new OrderResponse - { - Id = order.Id, - OrderNo = order.OrderNo, - OwnerId = order.OwnerId, - RunnerId = order.RunnerId, - OrderType = order.OrderType.ToString(), - Status = order.Status.ToString(), - ItemName = order.ItemName, - PickupLocation = order.PickupLocation, - DeliveryLocation = order.DeliveryLocation, - Remark = order.Remark, - Phone = order.Phone, // 创建者自己可以看到手机号 - Commission = order.Commission, - GoodsAmount = order.GoodsAmount, - TotalAmount = order.TotalAmount, - CreatedAt = order.CreatedAt - }); -}).RequireAuthorization(); - -// 获取当前用户的聊天订单列表(已接单的订单,按时间排序) -app.MapGet("/api/orders/chat-list", async (HttpContext httpContext, AppDbContext db) => -{ - var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return Results.Unauthorized(); - var currentUserId = int.Parse(userIdClaim.Value); - - var rawOrders = await db.Orders - .Where(o => o.RunnerId != null && - (o.OwnerId == currentUserId || o.RunnerId == currentUserId) && - o.Status != OrderStatus.Cancelled) - .OrderByDescending(o => o.CompletedAt ?? o.AcceptedAt ?? o.CreatedAt) - .Select(o => new - { - OrderId = o.Id, - o.OrderNo, - OrderType = o.OrderType.ToString(), - o.ItemName, - Status = o.Status.ToString(), - o.Commission, - o.OwnerId, - o.RunnerId, - OwnerNickname = o.Owner!.Nickname, - OwnerAvatar = o.Owner!.AvatarUrl, - RunnerNickname = o.Runner!.Nickname, - RunnerAvatar = o.Runner!.AvatarUrl, - LastTime = o.CompletedAt ?? o.AcceptedAt ?? o.CreatedAt - }) - .ToListAsync(); - - var orders = rawOrders.Select(o => - { - var isOwner = o.OwnerId == currentUserId; - var targetId = isOwner ? o.RunnerId : (int?)o.OwnerId; - var nickname = isOwner ? o.RunnerNickname : o.OwnerNickname; - var avatar = isOwner ? o.RunnerAvatar : o.OwnerAvatar; - if (string.IsNullOrWhiteSpace(nickname)) - nickname = $"用户{targetId}"; - return new - { - o.OrderId, - o.OrderNo, - o.OrderType, - o.ItemName, - o.Status, - o.Commission, - TargetUserId = targetId, - TargetNickname = nickname, - TargetAvatar = avatar ?? "", - o.LastTime - }; - }); - - return Results.Ok(orders); -}).RequireAuthorization(); - -// 根据对方用户ID查找最近的关联订单(用于聊天页显示订单卡片) -app.MapGet("/api/orders/by-chat-user/{targetUserId}", async (int targetUserId, HttpContext httpContext, AppDbContext db) => -{ - var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return Results.Unauthorized(); - var currentUserId = int.Parse(userIdClaim.Value); - - // 查找当前用户与目标用户之间最近的订单(当前用户是单主对方是跑腿,或反过来) - var order = await db.Orders - .Where(o => - (o.OwnerId == currentUserId && o.RunnerId == targetUserId) || - (o.OwnerId == targetUserId && o.RunnerId == currentUserId)) - .OrderByDescending(o => o.CreatedAt) - .FirstOrDefaultAsync(); - - if (order == null) - return Results.Ok(new { found = false }); - - return Results.Ok(new - { - found = true, - orderId = order.Id - }); -}).RequireAuthorization(); - -// 获取订单详情(含手机号隐藏逻辑和按状态显示字段) -app.MapGet("/api/orders/{id}", async (int id, HttpContext httpContext, AppDbContext db) => -{ - var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return Results.Unauthorized(); - var currentUserId = int.Parse(userIdClaim.Value); - - var order = await db.Orders - .Include(o => o.FoodOrderItems).ThenInclude(fi => fi.Shop) - .Include(o => o.FoodOrderItems).ThenInclude(fi => fi.Dish) - .Include(o => o.Runner) - .FirstOrDefaultAsync(o => o.Id == id); - - if (order == null) - { - return Results.NotFound(new { code = 404, message = "订单不存在" }); - } - - // 手机号隐藏逻辑: - // - 单主自己始终可见 - // - 已接单且查询者为接单跑腿时可见 - // - 其他情况隐藏 - string? visiblePhone = null; - if (currentUserId == order.OwnerId) - { - visiblePhone = order.Phone; - } - else if (order.RunnerId != null && currentUserId == order.RunnerId) - { - visiblePhone = order.Phone; - } - - // 按状态显示字段: - // - 待接单:接单时间、跑腿信息、完成信息为空 - // - 进行中/待确认:显示接单时间和跑腿信息 - // - 已完成:显示所有信息 - DateTime? visibleAcceptedAt = null; - string? runnerNickname = null; - int? runnerUid = null; - DateTime? visibleCompletedAt = null; - string? visibleCompletionProof = null; - - if (order.Status != OrderStatus.Pending && order.Status != OrderStatus.Cancelled) - { - // 进行中及之后的状态显示接单时间和跑腿信息 - visibleAcceptedAt = order.AcceptedAt; - runnerNickname = order.Runner?.Nickname; - runnerUid = order.RunnerId; - } - - // 跑腿手机号:从认证记录中获取,仅单主可见 - string? runnerPhone = null; - if (currentUserId == order.OwnerId && order.RunnerId != null) - { - var cert = await db.RunnerCertifications - .Where(c => c.UserId == order.RunnerId && c.Status == CertificationStatus.Approved) - .OrderByDescending(c => c.CreatedAt) - .FirstOrDefaultAsync(); - runnerPhone = cert?.Phone; - } - - if (order.Status == OrderStatus.Completed || order.Status == OrderStatus.WaitConfirm) - { - // 已完成和待确认状态显示完成时间和凭证 - visibleCompletedAt = order.CompletedAt; - visibleCompletionProof = order.CompletionProof; - } - - var response = new OrderResponse - { - Id = order.Id, - OrderNo = order.OrderNo, - OwnerId = order.OwnerId, - RunnerId = order.RunnerId, - OrderType = order.OrderType.ToString(), - Status = order.Status.ToString(), - ItemName = order.ItemName, - PickupLocation = order.PickupLocation, - DeliveryLocation = order.DeliveryLocation, - Remark = order.Remark, - Phone = visiblePhone, - Commission = order.Commission, - GoodsAmount = order.GoodsAmount, - TotalAmount = order.TotalAmount, - CompletionProof = visibleCompletionProof, - IsReviewed = order.IsReviewed, - CreatedAt = order.CreatedAt, - AcceptedAt = visibleAcceptedAt, - CompletedAt = visibleCompletedAt, - RunnerNickname = runnerNickname, - RunnerUid = runnerUid, - RunnerPhone = runnerPhone - }; - - // 查询佣金抽成信息(有 Earning 记录用实际值,否则实时计算预估值) - if (order.RunnerId.HasValue) - { - var earning = await db.Earnings - .Where(e => e.OrderId == order.Id) - .FirstOrDefaultAsync(); - if (earning != null) - { - response.PlatformFee = earning.PlatformFee; - response.NetEarning = earning.NetEarning; - } - else - { - // 未完成订单:实时计算预估抽成 - var rules = await db.CommissionRules.OrderBy(r => r.MinAmount).ToListAsync(); - var fee = CalculatePlatformFee(order.Commission, rules); - response.PlatformFee = fee; - response.NetEarning = order.Commission - fee; - } - } - - // 美食街订单附带菜品详情 - if (order.OrderType == OrderType.Food && order.FoodOrderItems.Count > 0) - { - response.FoodItems = order.FoodOrderItems.Select(fi => new FoodOrderItemResponse - { - Id = fi.Id, - ShopId = fi.ShopId, - ShopName = fi.Shop?.Name ?? "未知门店", - DishId = fi.DishId, - DishName = fi.Dish?.Name ?? "未知菜品", - DishPhoto = fi.Dish?.Photo, - Quantity = fi.Quantity, - UnitPrice = fi.UnitPrice - }).ToList(); - } - - return Results.Ok(response); -}).RequireAuthorization(); - -// ========== 订单状态管理接口========= - -// 获取订单大厅列表(仅返回 Pending 状态订单) -app.MapGet("/api/orders/hall", async ( - string? type, - string? sort, - AppDbContext db) => -{ - // 基础查询:仅返回待接单订单 - var query = db.Orders.Where(o => o.Status == OrderStatus.Pending); - - // 按订单类型筛选 - if (!string.IsNullOrEmpty(type) && Enum.TryParse(type, true, out var orderType)) - { - query = query.Where(o => o.OrderType == orderType); - } - - // 排序 - query = sort?.ToLower() switch - { - "commission" => query.OrderByDescending(o => o.Commission), - "distance" => query.OrderByDescending(o => o.CreatedAt), // 距离排序需地址坐标,暂按时间排序 - _ => query.OrderByDescending(o => o.CreatedAt) // 默认按时间排序 - }; - - var orders = await query.ToListAsync(); - - // 美食街订单需要额外查询门店数和菜品数 - var foodOrderIds = orders - .Where(o => o.OrderType == OrderType.Food) - .Select(o => o.Id) - .ToList(); - - var foodOrderStats = new Dictionary(); - if (foodOrderIds.Count > 0) - { - var stats = await db.FoodOrderItems - .Where(fi => foodOrderIds.Contains(fi.OrderId)) - .GroupBy(fi => fi.OrderId) - .Select(g => new - { - OrderId = g.Key, - ShopCount = g.Select(fi => fi.ShopId).Distinct().Count(), - DishCount = g.Sum(fi => fi.Quantity) - }) - .ToListAsync(); - foreach (var s in stats) - { - foodOrderStats[s.OrderId] = (s.ShopCount, s.DishCount); - } - } - - var result = orders.Select(o => - { - var item = new OrderHallItemResponse - { - Id = o.Id, - OrderNo = o.OrderNo, - OrderType = o.OrderType.ToString(), - Status = o.Status.ToString(), - ItemName = o.ItemName, - PickupLocation = o.PickupLocation, - DeliveryLocation = o.DeliveryLocation, - Remark = o.Remark, - Commission = o.Commission, - GoodsAmount = o.GoodsAmount, - CreatedAt = o.CreatedAt - }; - - // 美食街订单附加门店数和菜品数 - if (o.OrderType == OrderType.Food && foodOrderStats.TryGetValue(o.Id, out var stat)) - { - item.ShopCount = stat.ShopCount; - item.DishItemCount = stat.DishCount; - } - - return item; - }).ToList(); - - return Results.Ok(result); -}).AllowAnonymous(); -app.MapPost("/api/orders/{id}/accept", async (int id, HttpContext httpContext, AppDbContext db) => -{ - // 获取当前用户 - var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return Results.Unauthorized(); - var userId = int.Parse(userIdClaim.Value); - - // 检查跑腿认证 - var certification = await db.RunnerCertifications - .Where(c => c.UserId == userId && c.Status == CertificationStatus.Approved) - .FirstOrDefaultAsync(); - if (certification == null) - { - return Results.BadRequest(new { code = 400, message = "您尚未通过跑腿认证,无法接单" }); - } - - // 检查用户是否被封禁 - var user = await db.Users.FindAsync(userId); - if (user != null && user.IsBanned) - { - return Results.BadRequest(new { code = 400, message = "您的跑腿身份已被封禁,无法接单" }); - } - - // 使用乐观锁查询并更新订单(仅 Pending 状态可接单) - var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id); - if (order == null) - { - return Results.NotFound(new { code = 404, message = "订单不存在" }); - } - - if (order.Status == OrderStatus.Cancelled) - { - return Results.BadRequest(new { code = 400, message = "该订单已被取消" }); - } - - if (order.Status != OrderStatus.Pending) - { - return Results.Conflict(new { code = 409, message = "该订单已被接取" }); - } - - // 不能接自己的订单 - if (order.OwnerId == userId) - { - return Results.BadRequest(new { code = 400, message = "不能接取自己的订单" }); - } - - // 状态转换:Pending → InProgress - order.Status = OrderStatus.InProgress; - order.RunnerId = userId; - order.AcceptedAt = DateTime.UtcNow; - - try - { - await db.SaveChangesAsync(); - } - catch (DbUpdateConcurrencyException) - { - return Results.Conflict(new { code = 409, message = "该订单已被接取" }); - } - - return Results.Ok(new AcceptOrderResponse - { - Id = order.Id, - OrderNo = order.OrderNo, - Status = order.Status.ToString(), - RunnerId = userId, - AcceptedAt = order.AcceptedAt.Value - }); -}).RequireAuthorization(); - -// ========== 订单状态管理接口 ========== - -// 取消订单(仅单主可取消待接单订单) -app.MapPost("/api/orders/{id}/cancel", async (int id, HttpContext httpContext, AppDbContext db) => -{ - var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return Results.Unauthorized(); - var userId = int.Parse(userIdClaim.Value); - - var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id); - if (order == null) - return Results.NotFound(new { code = 404, message = "订单不存在" }); - - if (order.OwnerId != userId) - return Results.BadRequest(new { code = 400, message = "仅单主可取消订单" }); - - if (order.Status != OrderStatus.Pending) - return Results.BadRequest(new { code = 400, message = "仅待接单状态的订单可取消" }); - - order.Status = OrderStatus.Cancelled; - await db.SaveChangesAsync(); - - return Results.Ok(new { id = order.Id, orderNo = order.OrderNo, status = order.Status.ToString() }); -}).RequireAuthorization(); - -// 跑腿提交完成 -app.MapPost("/api/orders/{id}/complete", async (int id, CompleteOrderRequest? request, HttpContext httpContext, AppDbContext db) => -{ - var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return Results.Unauthorized(); - var userId = int.Parse(userIdClaim.Value); - - var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id); - if (order == null) - return Results.NotFound(new { code = 404, message = "订单不存在" }); - - if (order.RunnerId != userId) - return Results.BadRequest(new { code = 400, message = "仅接单跑腿可提交完成" }); - - if (order.Status != OrderStatus.InProgress) - return Results.BadRequest(new { code = 400, message = "仅进行中的订单可提交完成" }); - - order.Status = OrderStatus.WaitConfirm; - order.CompletionProof = request?.CompletionProof; - order.CompletedAt = DateTime.UtcNow; - - await db.SaveChangesAsync(); - - return Results.Ok(new { id = order.Id, orderNo = order.OrderNo, status = order.Status.ToString(), completedAt = order.CompletedAt }); -}).RequireAuthorization(); - -// 单主确认完成 -app.MapPost("/api/orders/{id}/confirm", async (int id, HttpContext httpContext, AppDbContext db) => -{ - var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return Results.Unauthorized(); - var userId = int.Parse(userIdClaim.Value); - - var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id); - if (order == null) - return Results.NotFound(new { code = 404, message = "订单不存在" }); - - if (order.OwnerId != userId) - return Results.BadRequest(new { code = 400, message = "仅单主可确认订单完成" }); - - if (order.Status != OrderStatus.WaitConfirm) - return Results.BadRequest(new { code = 400, message = "仅待确认状态的订单可确认完成" }); - - order.Status = OrderStatus.Completed; - - // 创建收益记录(佣金分销计算) - if (order.RunnerId.HasValue) - { - var commissionRules = await db.CommissionRules.OrderBy(r => r.MinAmount).ToListAsync(); - var platformFee = CalculatePlatformFee(order.Commission, commissionRules); - var netEarning = order.Commission - platformFee; - - // 获取冻结时间配置(默认 1 天) - var freezeDaysConfig = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == "freeze_days"); - var freezeDays = 1; - if (freezeDaysConfig != null && int.TryParse(freezeDaysConfig.Value, out var configDays)) - { - freezeDays = configDays; - } - - var earning = new Earning - { - UserId = order.RunnerId.Value, - OrderId = order.Id, - GoodsAmount = order.GoodsAmount, - Commission = order.Commission, - PlatformFee = platformFee, - NetEarning = netEarning, - Status = EarningStatus.Frozen, - FrozenUntil = DateTime.UtcNow.AddDays(freezeDays), - CreatedAt = DateTime.UtcNow - }; - db.Earnings.Add(earning); - } - - await db.SaveChangesAsync(); - - return Results.Ok(new { id = order.Id, orderNo = order.OrderNo, status = order.Status.ToString() }); -}).RequireAuthorization(); - -// 单主拒绝完成(订单回到进行中) -app.MapPost("/api/orders/{id}/reject", async (int id, HttpContext httpContext, AppDbContext db) => -{ - var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return Results.Unauthorized(); - var userId = int.Parse(userIdClaim.Value); - - var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id); - if (order == null) - return Results.NotFound(new { code = 404, message = "订单不存在" }); - - if (order.OwnerId != userId) - return Results.BadRequest(new { code = 400, message = "仅单主可拒绝订单完成" }); - - if (order.Status != OrderStatus.WaitConfirm) - return Results.BadRequest(new { code = 400, message = "仅待确认状态的订单可拒绝" }); - - order.Status = OrderStatus.InProgress; - order.CompletedAt = null; - order.CompletionProof = null; - await db.SaveChangesAsync(); - - return Results.Ok(new { id = order.Id, orderNo = order.OrderNo, status = order.Status.ToString() }); -}).RequireAuthorization(); - -// 我的订单(单主查看自己发布的订单) -app.MapGet("/api/orders/mine", async (string? status, string? type, HttpContext httpContext, AppDbContext db) => -{ - var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return Results.Unauthorized(); - var userId = int.Parse(userIdClaim.Value); - - var query = db.Orders.Where(o => o.OwnerId == userId); - - // 按状态筛选 - if (!string.IsNullOrEmpty(status) && Enum.TryParse(status, true, out var orderStatus)) - query = query.Where(o => o.Status == orderStatus); - - // 按类型筛选 - if (!string.IsNullOrEmpty(type) && Enum.TryParse(type, true, out var orderType)) - query = query.Where(o => o.OrderType == orderType); - - var orders = await query - .OrderByDescending(o => o.CreatedAt) - .Select(o => new OrderListItemResponse - { - Id = o.Id, - OrderNo = o.OrderNo, - OwnerId = o.OwnerId, - RunnerId = o.RunnerId, - OrderType = o.OrderType.ToString(), - Status = o.Status.ToString(), - ItemName = o.ItemName, - PickupLocation = o.PickupLocation, - DeliveryLocation = o.DeliveryLocation, - Remark = o.Remark, - Commission = o.Commission, - GoodsAmount = o.GoodsAmount, - TotalAmount = o.TotalAmount, - IsReviewed = o.IsReviewed, - CreatedAt = o.CreatedAt, - AcceptedAt = o.AcceptedAt, - CompletedAt = o.CompletedAt - }) - .ToListAsync(); - - return Results.Ok(orders); -}).RequireAuthorization(); - -// 我的接单(跑腿查看自己接取的订单) -app.MapGet("/api/orders/taken", async (string? status, string? type, HttpContext httpContext, AppDbContext db) => -{ - var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return Results.Unauthorized(); - var userId = int.Parse(userIdClaim.Value); - - var query = db.Orders.Where(o => o.RunnerId == userId); - - // 按状态筛选 - if (!string.IsNullOrEmpty(status) && Enum.TryParse(status, true, out var orderStatus)) - query = query.Where(o => o.Status == orderStatus); - - // 按类型筛选 - if (!string.IsNullOrEmpty(type) && Enum.TryParse(type, true, out var orderType)) - query = query.Where(o => o.OrderType == orderType); - - var orders = await query - .OrderByDescending(o => o.CreatedAt) - .Select(o => new OrderListItemResponse - { - Id = o.Id, - OrderNo = o.OrderNo, - OwnerId = o.OwnerId, - RunnerId = o.RunnerId, - OrderType = o.OrderType.ToString(), - Status = o.Status.ToString(), - ItemName = o.ItemName, - PickupLocation = o.PickupLocation, - DeliveryLocation = o.DeliveryLocation, - Remark = o.Remark, - Commission = o.Commission, - GoodsAmount = o.GoodsAmount, - TotalAmount = o.TotalAmount, - IsReviewed = o.IsReviewed, - CreatedAt = o.CreatedAt, - AcceptedAt = o.AcceptedAt, - CompletedAt = o.CompletedAt - }) - .ToListAsync(); - - return Results.Ok(orders); -}).RequireAuthorization(); - -// ========== 美食街门店接口 ========== - -// 前端获取门店列表(仅启用门店,含菜品种类数量) -app.MapGet("/api/shops", async (AppDbContext db) => -{ - var shops = await db.Shops - .Where(s => s.IsEnabled) - .Select(s => new ShopListResponse - { - Id = s.Id, - Name = s.Name, - Photo = s.Photo, - Location = s.Location, - DishCount = s.Dishes.Count(d => d.IsEnabled) - }) - .ToListAsync(); - return Results.Ok(shops); -}); - -// 前端获取门店详情(含 Banner 和菜品) -app.MapGet("/api/shops/{id}", async (int id, AppDbContext db) => -{ - var shop = await db.Shops - .Include(s => s.ShopBanners) - .Include(s => s.Dishes) - .FirstOrDefaultAsync(s => s.Id == id); - - if (shop == null) - { - return Results.NotFound(new { code = 404, message = "门店不存在" }); - } - - var response = new ShopDetailResponse - { - Id = shop.Id, - Name = shop.Name, - Photo = shop.Photo, - Location = shop.Location, - Notice = shop.Notice, - PackingFeeType = shop.PackingFeeType.ToString(), - PackingFeeAmount = shop.PackingFeeAmount, - IsEnabled = shop.IsEnabled, - Banners = shop.ShopBanners - .OrderBy(b => b.SortOrder) - .Select(b => new ShopBannerResponse - { - Id = b.Id, - ImageUrl = b.ImageUrl, - SortOrder = b.SortOrder - }).ToList(), - Dishes = shop.Dishes - .Where(d => d.IsEnabled) - .Select(d => new DishResponse - { - Id = d.Id, - ShopId = d.ShopId, - Name = d.Name, - Photo = d.Photo, - Price = d.Price, - IsEnabled = d.IsEnabled - }).ToList() - }; - - return Results.Ok(response); -}); - -// ========== 管理端门店接口 ========== - -// 管理端获取门店列表(全部) -app.MapGet("/api/admin/shops", async (AppDbContext db) => -{ - var shops = await db.Shops - .Select(s => new AdminShopResponse - { - Id = s.Id, - Name = s.Name, - Photo = s.Photo, - Location = s.Location, - Notice = s.Notice, - PackingFeeType = s.PackingFeeType.ToString(), - PackingFeeAmount = s.PackingFeeAmount, - IsEnabled = s.IsEnabled, - DishCount = s.Dishes.Count - }) - .ToListAsync(); - return Results.Ok(shops); -}).RequireAuthorization("AdminOnly"); - -// 管理端创建门店 -app.MapPost("/api/admin/shops", async (ShopRequest request, AppDbContext db) => -{ - var errors = ValidateShopRequest(request); - if (errors.Count > 0) - { - return Results.BadRequest(new { code = 400, message = "校验失败", errors }); - } - - if (!Enum.TryParse(request.PackingFeeType, true, out var feeType) || !Enum.IsDefined(feeType)) - { - return Results.BadRequest(new { code = 400, message = "校验失败", errors = new[] { new { field = "packingFeeType", message = "打包费类型不合法" } } }); - } - - var shop = new Shop - { - Name = request.Name, - Photo = request.Photo, - Location = request.Location, - Notice = request.Notice, - PackingFeeType = feeType, - PackingFeeAmount = request.PackingFeeAmount, - IsEnabled = request.IsEnabled - }; - - db.Shops.Add(shop); - await db.SaveChangesAsync(); - - return Results.Created($"/api/admin/shops/{shop.Id}", new AdminShopResponse - { - Id = shop.Id, - Name = shop.Name, - Photo = shop.Photo, - Location = shop.Location, - Notice = shop.Notice, - PackingFeeType = shop.PackingFeeType.ToString(), - PackingFeeAmount = shop.PackingFeeAmount, - IsEnabled = shop.IsEnabled, - DishCount = 0 - }); -}).RequireAuthorization("AdminOnly"); - -// 管理端更新门店 -app.MapPut("/api/admin/shops/{id}", async (int id, ShopRequest request, AppDbContext db) => -{ - var shop = await db.Shops.FindAsync(id); - if (shop == null) - { - return Results.NotFound(new { code = 404, message = "门店不存在" }); - } - - var errors = ValidateShopRequest(request); - if (errors.Count > 0) - { - return Results.BadRequest(new { code = 400, message = "校验失败", errors }); - } - - if (!Enum.TryParse(request.PackingFeeType, true, out var feeType) || !Enum.IsDefined(feeType)) - { - return Results.BadRequest(new { code = 400, message = "校验失败", errors = new[] { new { field = "packingFeeType", message = "打包费类型不合法" } } }); - } - - shop.Name = request.Name; - shop.Photo = request.Photo; - shop.Location = request.Location; - shop.Notice = request.Notice; - shop.PackingFeeType = feeType; - shop.PackingFeeAmount = request.PackingFeeAmount; - shop.IsEnabled = request.IsEnabled; - - await db.SaveChangesAsync(); - - return Results.Ok(new AdminShopResponse - { - Id = shop.Id, - Name = shop.Name, - Photo = shop.Photo, - Location = shop.Location, - Notice = shop.Notice, - PackingFeeType = shop.PackingFeeType.ToString(), - PackingFeeAmount = shop.PackingFeeAmount, - IsEnabled = shop.IsEnabled, - DishCount = await db.Dishes.CountAsync(d => d.ShopId == shop.Id) - }); -}).RequireAuthorization("AdminOnly"); - -// 管理端删除门店 -app.MapDelete("/api/admin/shops/{id}", async (int id, AppDbContext db) => -{ - var shop = await db.Shops - .Include(s => s.Dishes) - .Include(s => s.ShopBanners) - .FirstOrDefaultAsync(s => s.Id == id); - - if (shop == null) - { - return Results.NotFound(new { code = 404, message = "门店不存在" }); - } - - // 级联删除菜品和门店 Banner - db.Dishes.RemoveRange(shop.Dishes); - db.ShopBanners.RemoveRange(shop.ShopBanners); - db.Shops.Remove(shop); - await db.SaveChangesAsync(); - - return Results.NoContent(); -}).RequireAuthorization("AdminOnly"); - -// ========== 管理端门店 Banner 接口 ========== - -// 获取门店 Banner 列表 -app.MapGet("/api/admin/shops/{shopId}/banners", async (int shopId, AppDbContext db) => -{ - var shop = await db.Shops.FindAsync(shopId); - if (shop == null) - return Results.NotFound(new { code = 404, message = "门店不存在" }); - - var banners = await db.ShopBanners - .Where(b => b.ShopId == shopId) - .OrderBy(b => b.SortOrder) - .Select(b => new ShopBannerResponse { Id = b.Id, ImageUrl = b.ImageUrl, SortOrder = b.SortOrder }) - .ToListAsync(); - return Results.Ok(banners); -}).RequireAuthorization("AdminOnly"); - -// 创建门店 Banner -app.MapPost("/api/admin/shops/{shopId}/banners", async (int shopId, ShopBannerRequest request, AppDbContext db) => -{ - var shop = await db.Shops.FindAsync(shopId); - if (shop == null) - return Results.NotFound(new { code = 404, message = "门店不存在" }); - - if (string.IsNullOrWhiteSpace(request.ImageUrl)) - return Results.BadRequest(new { code = 400, message = "校验失败", errors = new[] { new { field = "imageUrl", message = "图片地址不能为空" } } }); - - var banner = new ShopBanner { ShopId = shopId, ImageUrl = request.ImageUrl, SortOrder = request.SortOrder }; - db.ShopBanners.Add(banner); - await db.SaveChangesAsync(); - - return Results.Created($"/api/admin/shops/{shopId}/banners/{banner.Id}", - new ShopBannerResponse { Id = banner.Id, ImageUrl = banner.ImageUrl, SortOrder = banner.SortOrder }); -}).RequireAuthorization("AdminOnly"); - -// 删除门店 Banner -app.MapDelete("/api/admin/shops/{shopId}/banners/{bannerId}", async (int shopId, int bannerId, AppDbContext db) => -{ - var banner = await db.ShopBanners.FirstOrDefaultAsync(b => b.Id == bannerId && b.ShopId == shopId); - if (banner == null) - return Results.NotFound(new { code = 404, message = "门店 Banner 不存在" }); - - db.ShopBanners.Remove(banner); - await db.SaveChangesAsync(); - return Results.NoContent(); -}).RequireAuthorization("AdminOnly"); - -// ========== 管理端菜品接口 ========== - -// 获取门店菜品列表 -app.MapGet("/api/admin/shops/{shopId}/dishes", async (int shopId, AppDbContext db) => -{ - var shop = await db.Shops.FindAsync(shopId); - if (shop == null) - { - return Results.NotFound(new { code = 404, message = "门店不存在" }); - } - - var dishes = await db.Dishes - .Where(d => d.ShopId == shopId) - .Select(d => new DishResponse - { - Id = d.Id, - ShopId = d.ShopId, - Name = d.Name, - Photo = d.Photo, - Price = d.Price, - IsEnabled = d.IsEnabled - }) - .ToListAsync(); - return Results.Ok(dishes); -}).RequireAuthorization("AdminOnly"); - -// 创建菜品 -app.MapPost("/api/admin/shops/{shopId}/dishes", async (int shopId, DishRequest request, AppDbContext db) => -{ - var shop = await db.Shops.FindAsync(shopId); - if (shop == null) - { - return Results.NotFound(new { code = 404, message = "门店不存在" }); - } - - var errors = ValidateDishRequest(request); - if (errors.Count > 0) - { - return Results.BadRequest(new { code = 400, message = "校验失败", errors }); - } - - var dish = new Dish - { - ShopId = shopId, - Name = request.Name, - Photo = request.Photo, - Price = request.Price, - IsEnabled = request.IsEnabled - }; - - db.Dishes.Add(dish); - await db.SaveChangesAsync(); - - return Results.Created($"/api/admin/shops/{shopId}/dishes/{dish.Id}", new DishResponse - { - Id = dish.Id, - ShopId = dish.ShopId, - Name = dish.Name, - Photo = dish.Photo, - Price = dish.Price, - IsEnabled = dish.IsEnabled - }); -}).RequireAuthorization("AdminOnly"); - -// 更新菜品 -app.MapPut("/api/admin/shops/{shopId}/dishes/{dishId}", async (int shopId, int dishId, DishRequest request, AppDbContext db) => -{ - var dish = await db.Dishes.FirstOrDefaultAsync(d => d.Id == dishId && d.ShopId == shopId); - if (dish == null) - { - return Results.NotFound(new { code = 404, message = "菜品不存在" }); - } - - var errors = ValidateDishRequest(request); - if (errors.Count > 0) - { - return Results.BadRequest(new { code = 400, message = "校验失败", errors }); - } - - dish.Name = request.Name; - dish.Photo = request.Photo; - dish.Price = request.Price; - dish.IsEnabled = request.IsEnabled; - - await db.SaveChangesAsync(); - - return Results.Ok(new DishResponse - { - Id = dish.Id, - ShopId = dish.ShopId, - Name = dish.Name, - Photo = dish.Photo, - Price = dish.Price, - IsEnabled = dish.IsEnabled - }); -}).RequireAuthorization("AdminOnly"); - -// 删除菜品 -app.MapDelete("/api/admin/shops/{shopId}/dishes/{dishId}", async (int shopId, int dishId, AppDbContext db) => -{ - var dish = await db.Dishes.FirstOrDefaultAsync(d => d.Id == dishId && d.ShopId == shopId); - if (dish == null) - { - return Results.NotFound(new { code = 404, message = "菜品不存在" }); - } - - db.Dishes.Remove(dish); - await db.SaveChangesAsync(); - - return Results.NoContent(); -}).RequireAuthorization("AdminOnly"); - -// ========== 跑腿认证接口 ========== - -// 提交跑腿认证申请 -app.MapPost("/api/runner/certification", async (CertificationRequest request, HttpContext httpContext, AppDbContext db) => -{ - var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return Results.Unauthorized(); - var userId = int.Parse(userIdClaim.Value); - - // 校验 - var errors = new List(); - if (string.IsNullOrWhiteSpace(request.RealName)) - errors.Add(new { field = "realName", message = "姓名不能为空" }); - if (string.IsNullOrWhiteSpace(request.Phone)) - errors.Add(new { field = "phone", message = "手机号不能为空" }); - if (errors.Count > 0) - return Results.BadRequest(new { code = 400, message = "校验失败", errors }); - - // 检查是否已有待审核或已通过的认证 - var existing = await db.RunnerCertifications - .Where(c => c.UserId == userId && (c.Status == CertificationStatus.Pending || c.Status == CertificationStatus.Approved)) - .FirstOrDefaultAsync(); - - if (existing != null) - { - if (existing.Status == CertificationStatus.Approved) - return Results.BadRequest(new { code = 400, message = "您已通过跑腿认证" }); - return Results.BadRequest(new { code = 400, message = "平台审核中" }); - } - - var certification = new RunnerCertification - { - UserId = userId, - RealName = request.RealName, - Phone = request.Phone, - Status = CertificationStatus.Pending, - CreatedAt = DateTime.UtcNow - }; - - db.RunnerCertifications.Add(certification); - await db.SaveChangesAsync(); - - return Results.Created($"/api/runner/certification", new CertificationResponse - { - Id = certification.Id, - UserId = certification.UserId, - RealName = certification.RealName, - Phone = certification.Phone, - Status = certification.Status.ToString(), - CreatedAt = certification.CreatedAt, - ReviewedAt = certification.ReviewedAt - }); -}).RequireAuthorization(); - -// 查询跑腿认证状态 -app.MapGet("/api/runner/certification", async (HttpContext httpContext, AppDbContext db) => -{ - var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return Results.Unauthorized(); - var userId = int.Parse(userIdClaim.Value); - - // 返回最新的认证记录 - var certification = await db.RunnerCertifications - .Where(c => c.UserId == userId) - .OrderByDescending(c => c.CreatedAt) - .FirstOrDefaultAsync(); - - if (certification == null) - { - return Results.Ok(new { status = "None", message = "未提交认证" }); - } - - return Results.Ok(new CertificationResponse - { - Id = certification.Id, - UserId = certification.UserId, - RealName = certification.RealName, - Phone = certification.Phone, - Status = certification.Status.ToString(), - CreatedAt = certification.CreatedAt, - ReviewedAt = certification.ReviewedAt - }); -}).RequireAuthorization(); - -// 管理端获取认证列表 -app.MapGet("/api/admin/certifications", async (string? status, AppDbContext db) => -{ - var query = db.RunnerCertifications - .Include(c => c.User) - .AsQueryable(); - - // 按状态筛选 - if (!string.IsNullOrEmpty(status) && Enum.TryParse(status, true, out var certStatus)) - { - query = query.Where(c => c.Status == certStatus); - } - - var certifications = await query - .OrderByDescending(c => c.CreatedAt) - .Select(c => new AdminCertificationResponse - { - Id = c.Id, - UserId = c.UserId, - RealName = c.RealName, - Phone = c.Phone, - Status = c.Status.ToString(), - CreatedAt = c.CreatedAt, - ReviewedAt = c.ReviewedAt, - UserNickname = c.User != null ? c.User.Nickname : null, - UserPhone = c.User != null ? c.User.Phone : null - }) - .ToListAsync(); - - return Results.Ok(certifications); -}).RequireAuthorization("AdminOnly"); - -// 管理端审核认证 -app.MapPut("/api/admin/certifications/{id}", async (int id, ReviewCertificationRequest request, AppDbContext db) => -{ - var certification = await db.RunnerCertifications - .Include(c => c.User) - .FirstOrDefaultAsync(c => c.Id == id); - - if (certification == null) - { - return Results.NotFound(new { code = 404, message = "认证记录不存在" }); - } - - if (certification.Status != CertificationStatus.Pending) - { - return Results.BadRequest(new { code = 400, message = "该认证已审核" }); - } - - if (!Enum.TryParse(request.Status, true, out var newStatus) - || (newStatus != CertificationStatus.Approved && newStatus != CertificationStatus.Rejected)) - { - return Results.BadRequest(new { code = 400, message = "审核结果不合法,仅支持 Approved 或 Rejected" }); - } - - certification.Status = newStatus; - certification.ReviewedAt = DateTime.UtcNow; - - // 审核通过时,更新用户角色为 Runner - if (newStatus == CertificationStatus.Approved && certification.User != null) - { - certification.User.Role = UserRole.Runner; - } - - await db.SaveChangesAsync(); - - return Results.Ok(new CertificationResponse - { - Id = certification.Id, - UserId = certification.UserId, - RealName = certification.RealName, - Phone = certification.Phone, - Status = certification.Status.ToString(), - CreatedAt = certification.CreatedAt, - ReviewedAt = certification.ReviewedAt - }); -}).RequireAuthorization("AdminOnly"); - -// ========== 评价接口 ========== - -// 提交评价(单主评价跑腿) -app.MapPost("/api/orders/{id}/review", async (int id, SubmitReviewRequest request, HttpContext httpContext, AppDbContext db) => -{ - var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return Results.Unauthorized(); - var userId = int.Parse(userIdClaim.Value); - - var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id); - if (order == null) - return Results.NotFound(new { code = 404, message = "订单不存在" }); - - // 仅单主可评价 - if (order.OwnerId != userId) - return Results.BadRequest(new { code = 400, message = "仅单主可评价跑腿" }); - - // 仅已完成订单可评价 - if (order.Status != OrderStatus.Completed) - return Results.BadRequest(new { code = 400, message = "仅已完成的订单可评价" }); - - // 不可重复评价 - if (order.IsReviewed) - return Results.BadRequest(new { code = 400, message = "该订单已评价" }); - - // 校验评分范围 - if (request.Rating < 1 || request.Rating > 5) - return Results.BadRequest(new { code = 400, message = "评分必须在 1-5 之间" }); - - // 评分计算:1星=-2, 2星=-1, 3星=0, 4星=+1, 5星=+2 - var scoreChange = request.Rating - 3; - - var review = new Review - { - OrderId = id, - RunnerId = order.RunnerId!.Value, - Rating = request.Rating, - Content = request.Content, - ScoreChange = scoreChange, - CreatedAt = DateTime.UtcNow - }; - - db.Reviews.Add(review); - - // 更新跑腿评分(限制在 0-100 之间) - var runner = await db.Users.FindAsync(order.RunnerId!.Value); - if (runner != null) - { - runner.RunnerScore = Math.Clamp(runner.RunnerScore + scoreChange, 0, 100); - } - - // 标记订单已评价 - order.IsReviewed = true; - - await db.SaveChangesAsync(); - - return Results.Ok(new ReviewResponse - { - Id = review.Id, - OrderId = review.OrderId, - RunnerId = review.RunnerId, - Rating = review.Rating, - ScoreChange = review.ScoreChange, - IsDisabled = review.IsDisabled, - CreatedAt = review.CreatedAt - // 注意:不返回 Content,评价内容仅管理员可见 - }); -}).RequireAuthorization(); - -// 管理端获取评价列表 -app.MapGet("/api/admin/reviews", async (int? runnerId, AppDbContext db) => -{ - var query = db.Reviews - .Include(r => r.Order) - .Include(r => r.Runner) - .AsQueryable(); - - if (runnerId.HasValue) - query = query.Where(r => r.RunnerId == runnerId.Value); - - var reviews = await query - .OrderByDescending(r => r.CreatedAt) - .Select(r => new AdminReviewResponse - { - Id = r.Id, - OrderId = r.OrderId, - OrderNo = r.Order != null ? r.Order.OrderNo : "", - RunnerId = r.RunnerId, - RunnerNickname = r.Runner != null ? r.Runner.Nickname : null, - Rating = r.Rating, - Content = r.Content, - ScoreChange = r.ScoreChange, - IsDisabled = r.IsDisabled, - CreatedAt = r.CreatedAt - }) - .ToListAsync(); - - return Results.Ok(reviews); -}).RequireAuthorization("AdminOnly"); - -// 管理端禁用评价 -app.MapPut("/api/admin/reviews/{id}/disable", async (int id, AppDbContext db) => -{ - var review = await db.Reviews.FindAsync(id); - if (review == null) - return Results.NotFound(new { code = 404, message = "评价不存在" }); - - if (review.IsDisabled) - return Results.BadRequest(new { code = 400, message = "该评价已被禁用" }); - - review.IsDisabled = true; - - // 回退该评价对跑腿分数的影响 - var runner = await db.Users.FindAsync(review.RunnerId); - if (runner != null) - { - runner.RunnerScore = Math.Clamp(runner.RunnerScore - review.ScoreChange, 0, 100); - } - - await db.SaveChangesAsync(); - - return Results.Ok(new ReviewResponse - { - Id = review.Id, - OrderId = review.OrderId, - RunnerId = review.RunnerId, - Rating = review.Rating, - Content = review.Content, - ScoreChange = review.ScoreChange, - IsDisabled = review.IsDisabled, - CreatedAt = review.CreatedAt - }); -}).RequireAuthorization("AdminOnly"); - -// ========== 改价接口 ========== - -// 发起改价 -app.MapPost("/api/orders/{id}/price-change", async (int id, PriceChangeRequest request, HttpContext httpContext, AppDbContext db) => -{ - var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return Results.Unauthorized(); - var userId = int.Parse(userIdClaim.Value); - - var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id); - if (order == null) - return Results.NotFound(new { code = 404, message = "订单不存在" }); - - // 仅进行中或待确认的订单可改价 - if (order.Status != OrderStatus.InProgress && order.Status != OrderStatus.WaitConfirm) - return Results.BadRequest(new { code = 400, message = "当前订单状态不支持改价" }); - - // 仅单主或跑腿可发起改价 - if (userId != order.OwnerId && userId != order.RunnerId) - return Results.BadRequest(new { code = 400, message = "仅订单相关方可发起改价" }); - - // 校验改价类型 - if (!Enum.TryParse(request.ChangeType, true, out var changeType) || !Enum.IsDefined(changeType)) - return Results.BadRequest(new { code = 400, message = "改价类型不合法" }); - - // 代购和美食街订单才支持修改商品总额 - if (changeType == PriceChangeType.GoodsAmount && - order.OrderType != OrderType.Purchase && order.OrderType != OrderType.Food && order.OrderType != OrderType.Help) - return Results.BadRequest(new { code = 400, message = "该订单类型不支持修改商品总额" }); - - // 检查是否有待处理的改价申请 - var pendingChange = await db.PriceChanges - .AnyAsync(pc => pc.OrderId == id && pc.Status == PriceChangeStatus.Pending); - if (pendingChange) - return Results.BadRequest(new { code = 400, message = "已有待处理的改价申请,请等待对方确认" }); - - // 获取原价 - var originalPrice = changeType == PriceChangeType.Commission - ? order.Commission - : order.GoodsAmount ?? 0; - - if (request.NewPrice < 0) - return Results.BadRequest(new { code = 400, message = "新价格不能为负数" }); - - var priceChange = new PriceChange - { - OrderId = id, - InitiatorId = userId, - ChangeType = changeType, - OriginalPrice = originalPrice, - NewPrice = request.NewPrice, - Status = PriceChangeStatus.Pending, - CreatedAt = DateTime.UtcNow - }; - - db.PriceChanges.Add(priceChange); - await db.SaveChangesAsync(); - - // 计算差额 - var difference = request.NewPrice - originalPrice; - - return Results.Created($"/api/orders/{id}/price-change/{priceChange.Id}", new PriceChangeResponse - { - Id = priceChange.Id, - OrderId = priceChange.OrderId, - InitiatorId = priceChange.InitiatorId, - ChangeType = priceChange.ChangeType.ToString(), - OriginalPrice = priceChange.OriginalPrice, - NewPrice = priceChange.NewPrice, - Difference = difference, - Status = priceChange.Status.ToString(), - CreatedAt = priceChange.CreatedAt - }); -}).RequireAuthorization(); - -// 响应改价(同意或拒绝) -app.MapPut("/api/orders/{id}/price-change/{changeId}", async (int id, int changeId, RespondPriceChangeRequest request, HttpContext httpContext, AppDbContext db) => -{ - var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return Results.Unauthorized(); - var userId = int.Parse(userIdClaim.Value); - - var priceChange = await db.PriceChanges - .Include(pc => pc.Order) - .FirstOrDefaultAsync(pc => pc.Id == changeId && pc.OrderId == id); - - if (priceChange == null) - return Results.NotFound(new { code = 404, message = "改价记录不存在" }); - - if (priceChange.Status != PriceChangeStatus.Pending) - return Results.BadRequest(new { code = 400, message = "该改价申请已处理" }); - - // 不能自己响应自己的改价申请 - if (priceChange.InitiatorId == userId) - return Results.BadRequest(new { code = 400, message = "不能响应自己的改价申请" }); - - // 仅订单相关方可响应 - var order = priceChange.Order!; - if (userId != order.OwnerId && userId != order.RunnerId) - return Results.BadRequest(new { code = 400, message = "仅订单相关方可响应改价" }); - - if (!Enum.TryParse(request.Action, true, out var action) - || (action != PriceChangeStatus.Accepted && action != PriceChangeStatus.Rejected)) - return Results.BadRequest(new { code = 400, message = "操作不合法,仅支持 Accepted 或 Rejected" }); - - priceChange.Status = action; - - // 同意改价时更新订单金额 - if (action == PriceChangeStatus.Accepted) - { - if (priceChange.ChangeType == PriceChangeType.Commission) - { - order.Commission = priceChange.NewPrice; - } - else - { - order.GoodsAmount = priceChange.NewPrice; - } - - // 重新计算支付总金额 - if (order.OrderType == OrderType.Help || order.OrderType == OrderType.Purchase || order.OrderType == OrderType.Food) - { - order.TotalAmount = (order.GoodsAmount ?? 0) + order.Commission; - } - else - { - order.TotalAmount = order.Commission; - } - } - - await db.SaveChangesAsync(); - - var difference = priceChange.NewPrice - priceChange.OriginalPrice; - - return Results.Ok(new PriceChangeResponse - { - Id = priceChange.Id, - OrderId = priceChange.OrderId, - InitiatorId = priceChange.InitiatorId, - ChangeType = priceChange.ChangeType.ToString(), - OriginalPrice = priceChange.OriginalPrice, - NewPrice = priceChange.NewPrice, - Difference = difference, - Status = priceChange.Status.ToString(), - CreatedAt = priceChange.CreatedAt - }); -}).RequireAuthorization(); - -// ========== 收益与提现接口 ========== - -// 获取收益概览(四种金额状态) -app.MapGet("/api/earnings", async (HttpContext httpContext, AppDbContext db) => -{ - var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return Results.Unauthorized(); - var userId = int.Parse(userIdClaim.Value); - - // 先执行冻结解冻逻辑 - await UnfreezeEarnings(db); - - var earnings = await db.Earnings.Where(e => e.UserId == userId).ToListAsync(); - - var overview = new EarningsOverviewResponse - { - FrozenAmount = earnings.Where(e => e.Status == EarningStatus.Frozen).Sum(e => e.NetEarning), - AvailableAmount = earnings.Where(e => e.Status == EarningStatus.Available).Sum(e => e.NetEarning), - WithdrawingAmount = earnings.Where(e => e.Status == EarningStatus.Withdrawing).Sum(e => e.NetEarning), - WithdrawnAmount = earnings.Where(e => e.Status == EarningStatus.Withdrawn).Sum(e => e.NetEarning) - }; - - return Results.Ok(overview); -}).RequireAuthorization(); - -// 获取收益记录 -app.MapGet("/api/earnings/records", async (HttpContext httpContext, AppDbContext db) => -{ - var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return Results.Unauthorized(); - var userId = int.Parse(userIdClaim.Value); - - var records = await db.Earnings - .Where(e => e.UserId == userId) - .Include(e => e.Order) - .OrderByDescending(e => e.CreatedAt) - .Select(e => new EarningRecordResponse - { - Id = e.Id, - OrderId = e.OrderId, - OrderNo = e.Order != null ? e.Order.OrderNo : "", - OrderType = e.Order != null ? e.Order.OrderType.ToString() : "", - GoodsAmount = e.GoodsAmount, - Commission = e.Commission, - PlatformFee = e.PlatformFee, - NetEarning = e.NetEarning, - Status = e.Status.ToString(), - CompletedAt = e.Order != null ? e.Order.CompletedAt : null, - CreatedAt = e.CreatedAt - }) - .ToListAsync(); - - return Results.Ok(records); -}).RequireAuthorization(); - -// 获取提现记录 -app.MapGet("/api/earnings/withdrawals", async (HttpContext httpContext, AppDbContext db) => -{ - var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return Results.Unauthorized(); - var userId = int.Parse(userIdClaim.Value); - - var withdrawals = await db.Withdrawals - .Where(w => w.UserId == userId) - .OrderByDescending(w => w.CreatedAt) - .Select(w => new WithdrawalRecordResponse - { - Id = w.Id, - Amount = w.Amount, - PaymentMethod = w.PaymentMethod.ToString(), - Status = w.Status.ToString(), - CreatedAt = w.CreatedAt - }) - .ToListAsync(); - - return Results.Ok(withdrawals); -}).RequireAuthorization(); - -// 申请提现 -app.MapPost("/api/earnings/withdraw", async (WithdrawRequest request, HttpContext httpContext, AppDbContext db) => -{ - var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return Results.Unauthorized(); - var userId = int.Parse(userIdClaim.Value); - - // 金额校验:最低 1 元 - if (request.Amount < 1.0m) - return Results.BadRequest(new { code = 400, message = "提现金额不能低于1元" }); - - // 金额校验:小数点后最多 2 位 - if (request.Amount != Math.Round(request.Amount, 2)) - return Results.BadRequest(new { code = 400, message = "请输入正确的提现金额" }); - - // 收款方式校验 - if (!Enum.TryParse(request.PaymentMethod, true, out var paymentMethod) || !Enum.IsDefined(paymentMethod)) - return Results.BadRequest(new { code = 400, message = "收款方式不合法" }); - - // 收款二维码校验 - if (string.IsNullOrWhiteSpace(request.QrCodeImage)) - return Results.BadRequest(new { code = 400, message = "收款二维码不能为空" }); - - // 先执行冻结解冻逻辑 - await UnfreezeEarnings(db); - - // 计算可提现余额 - var availableAmount = await db.Earnings - .Where(e => e.UserId == userId && e.Status == EarningStatus.Available) - .SumAsync(e => e.NetEarning); - - if (request.Amount > availableAmount) - return Results.BadRequest(new { code = 400, message = "超出可提现范围" }); - - // 创建提现记录 - var withdrawal = new Withdrawal - { - UserId = userId, - Amount = request.Amount, - PaymentMethod = paymentMethod, - QrCodeImage = request.QrCodeImage, - Status = WithdrawalStatus.Pending, - CreatedAt = DateTime.UtcNow - }; - db.Withdrawals.Add(withdrawal); - - // 将对应金额的收益标记为提现中(按创建时间先进先出) - var remainingAmount = request.Amount; - var availableEarnings = await db.Earnings - .Where(e => e.UserId == userId && e.Status == EarningStatus.Available) - .OrderBy(e => e.CreatedAt) - .ToListAsync(); - - foreach (var earning in availableEarnings) - { - if (remainingAmount <= 0) break; - - if (earning.NetEarning <= remainingAmount) - { - earning.Status = EarningStatus.Withdrawing; - remainingAmount -= earning.NetEarning; - } - else - { - // 需要拆分收益记录:部分提现 - earning.Status = EarningStatus.Withdrawing; - var originalNet = earning.NetEarning; - earning.NetEarning = remainingAmount; - - // 创建剩余部分的新收益记录 - var remainingEarning = new Earning - { - UserId = earning.UserId, - OrderId = earning.OrderId, - GoodsAmount = earning.GoodsAmount, - Commission = earning.Commission, - PlatformFee = 0, - NetEarning = originalNet - remainingAmount, - Status = EarningStatus.Available, - FrozenUntil = earning.FrozenUntil, - CreatedAt = earning.CreatedAt - }; - db.Earnings.Add(remainingEarning); - remainingAmount = 0; - } - } - - await db.SaveChangesAsync(); - - return Results.Created($"/api/earnings/withdrawals/{withdrawal.Id}", new WithdrawalRecordResponse - { - Id = withdrawal.Id, - Amount = withdrawal.Amount, - PaymentMethod = withdrawal.PaymentMethod.ToString(), - Status = withdrawal.Status.ToString(), - CreatedAt = withdrawal.CreatedAt - }); -}).RequireAuthorization(); - -// ========== 管理端提现审核接口 ========== - -// 管理端获取提现列表 -app.MapGet("/api/admin/withdrawals", async (string? status, AppDbContext db) => -{ - var query = db.Withdrawals - .Include(w => w.User) - .AsQueryable(); - - if (!string.IsNullOrEmpty(status) && Enum.TryParse(status, out var s)) - query = query.Where(w => w.Status == s); - - var list = await query - .OrderByDescending(w => w.CreatedAt) - .Select(w => new - { - w.Id, - w.UserId, - UserNickname = w.User!.Nickname ?? ("用户" + w.UserId), - w.Amount, - PaymentMethod = w.PaymentMethod.ToString(), - w.QrCodeImage, - Status = w.Status.ToString(), - w.CreatedAt, - w.ProcessedAt - }) - .ToListAsync(); - - return Results.Ok(list); -}).RequireAuthorization("AdminOnly"); - -// 管理端审核提现(通过/拒绝) -app.MapPut("/api/admin/withdrawals/{id}", async (int id, AdminWithdrawalRequest request, AppDbContext db) => -{ - var withdrawal = await db.Withdrawals.FindAsync(id); - if (withdrawal == null) - return Results.NotFound(new { code = 404, message = "提现记录不存在" }); - - if (withdrawal.Status != WithdrawalStatus.Pending && withdrawal.Status != WithdrawalStatus.Processing) - return Results.BadRequest(new { code = 400, message = "该提现记录已处理完毕" }); - - if (request.Action == "approve") - { - withdrawal.Status = WithdrawalStatus.Completed; - withdrawal.ProcessedAt = DateTime.UtcNow; - - // 将对应收益标记为已提现 - var earnings = await db.Earnings - .Where(e => e.UserId == withdrawal.UserId && e.Status == EarningStatus.Withdrawing) - .OrderBy(e => e.CreatedAt) - .ToListAsync(); - - var remaining = withdrawal.Amount; - foreach (var earning in earnings) - { - if (remaining <= 0) break; - if (earning.NetEarning <= remaining) - { - earning.Status = EarningStatus.Withdrawn; - remaining -= earning.NetEarning; - } - else - { - earning.Status = EarningStatus.Withdrawn; - remaining = 0; - } - } - } - else if (request.Action == "reject") - { - withdrawal.Status = WithdrawalStatus.Pending; - withdrawal.ProcessedAt = DateTime.UtcNow; - - // 将对应收益退回待提现状态 - var earnings = await db.Earnings - .Where(e => e.UserId == withdrawal.UserId && e.Status == EarningStatus.Withdrawing) - .OrderBy(e => e.CreatedAt) - .ToListAsync(); - - var remaining = withdrawal.Amount; - foreach (var earning in earnings) - { - if (remaining <= 0) break; - if (earning.NetEarning <= remaining) - { - earning.Status = EarningStatus.Available; - remaining -= earning.NetEarning; - } - else - { - earning.Status = EarningStatus.Available; - remaining = 0; - } - } - - // 拒绝后将提现记录状态设为特殊标记(复用 Pending 但已有 ProcessedAt) - // 实际上拒绝后应该删除或标记,这里直接删除提现记录 - db.Withdrawals.Remove(withdrawal); - } - else if (request.Action == "processing") - { - withdrawal.Status = WithdrawalStatus.Processing; - } - else - { - return Results.BadRequest(new { code = 400, message = "无效操作,可选: approve, reject, processing" }); - } - - await db.SaveChangesAsync(); - return Results.Ok(new { message = "操作成功" }); -}).RequireAuthorization("AdminOnly"); - -// ========== 佣金规则接口 ========== - -// 获取佣金规则 -app.MapGet("/api/admin/commission-rules", async (AppDbContext db) => -{ - var rules = await db.CommissionRules - .OrderBy(r => r.MinAmount) - .ToListAsync(); - return Results.Ok(rules); -}).RequireAuthorization("AdminOnly"); - -// 更新佣金规则 -app.MapPut("/api/admin/commission-rules", async (List rules, AppDbContext db) => -{ - // 清除旧规则 - var existing = await db.CommissionRules.ToListAsync(); - db.CommissionRules.RemoveRange(existing); - - // 添加新规则 - foreach (var rule in rules) - { - db.CommissionRules.Add(new CommissionRule - { - MinAmount = rule.MinAmount, - MaxAmount = rule.MaxAmount, - RateType = rule.RateType, - Rate = rule.Rate - }); - } - - await db.SaveChangesAsync(); - return Results.Ok(await db.CommissionRules.OrderBy(r => r.MinAmount).ToListAsync()); -}).RequireAuthorization("AdminOnly"); - -// ========== 消息通知接口 ========== - -// 获取未读消息数 -app.MapGet("/api/messages/unread-count", async (HttpContext httpContext, AppDbContext db) => -{ - var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return Results.Unauthorized(); - var userId = int.Parse(userIdClaim.Value); - - // 获取该用户可见的系统消息(按目标类型过滤) - var user = await db.Users.FindAsync(userId); - var visibleSystemMessages = await GetVisibleSystemMessages(db, userId, user); - var visibleSystemMessageIds = visibleSystemMessages.Select(m => m.Id).ToHashSet(); - - // 已读的系统消息 ID - var readSystemIds = await db.MessageReads - .Where(r => r.UserId == userId && r.MessageType == MessageType.System) - .Select(r => r.MessageId) - .ToListAsync(); - - var systemUnread = visibleSystemMessageIds.Count(id => !readSystemIds.Contains(id)); - - // 订单通知:用户相关的订单中状态变更过的订单数(简化实现:统计用户相关的已接单/已完成/已取消订单) - var orderNotifications = await db.Orders - .Where(o => (o.OwnerId == userId || o.RunnerId == userId) && o.Status != OrderStatus.Pending) - .Select(o => o.Id) - .ToListAsync(); - - var readOrderNotificationIds = await db.MessageReads - .Where(r => r.UserId == userId && r.MessageType == MessageType.OrderNotification) - .Select(r => r.MessageId) - .ToListAsync(); - - var orderNotificationUnread = orderNotifications.Count(id => !readOrderNotificationIds.Contains(id)); - - return Results.Ok(new UnreadCountResponse - { - SystemUnread = systemUnread, - OrderNotificationUnread = orderNotificationUnread, - TotalUnread = systemUnread + orderNotificationUnread - }); -}).RequireAuthorization(); - -// 获取系统消息列表 -app.MapGet("/api/messages/system", async (HttpContext httpContext, AppDbContext db) => -{ - var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return Results.Unauthorized(); - var userId = int.Parse(userIdClaim.Value); - - var user = await db.Users.FindAsync(userId); - var messages = await GetVisibleSystemMessages(db, userId, user); - - // 获取已读记录 - var readIds = await db.MessageReads - .Where(r => r.UserId == userId && r.MessageType == MessageType.System) - .Select(r => r.MessageId) - .ToListAsync(); - - var result = messages - .OrderByDescending(m => m.CreatedAt) - .Select(m => new SystemMessageResponse - { - Id = m.Id, - Title = m.Title, - ContentPreview = m.Content.Length > 100 ? m.Content[..100] + "…" : m.Content, - ThumbnailUrl = m.ThumbnailUrl, - CreatedAt = m.CreatedAt, - IsRead = readIds.Contains(m.Id) - }) - .ToList(); - - // 标记所有为已读 - foreach (var msg in messages.Where(m => !readIds.Contains(m.Id))) - { - db.MessageReads.Add(new MessageRead - { - UserId = userId, - MessageType = MessageType.System, - MessageId = msg.Id, - ReadAt = DateTime.UtcNow - }); - } - await db.SaveChangesAsync(); - - return Results.Ok(result); -}).RequireAuthorization(); - -// 获取系统消息详情 -app.MapGet("/api/messages/system/{id}", async (int id, HttpContext httpContext, AppDbContext db) => -{ - var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return Results.Unauthorized(); - - var message = await db.SystemMessages.FindAsync(id); - if (message == null) return Results.NotFound(new { code = 404, message = "消息不存在" }); - - return Results.Ok(new SystemMessageDetailResponse - { - Id = message.Id, - Title = message.Title, - Content = message.Content, - ThumbnailUrl = message.ThumbnailUrl, - CreatedAt = message.CreatedAt - }); -}).RequireAuthorization(); - -// 获取订单通知列表 -app.MapGet("/api/messages/order-notifications", async (string? category, HttpContext httpContext, AppDbContext db) => -{ - var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return Results.Unauthorized(); - var userId = int.Parse(userIdClaim.Value); - - // 查询用户相关的订单(状态已变更的) - var query = db.Orders - .Where(o => (o.OwnerId == userId || o.RunnerId == userId) && o.Status != OrderStatus.Pending); - - // 按分类筛选 - if (!string.IsNullOrEmpty(category)) - { - query = category.ToLower() switch - { - "accepted" => query.Where(o => o.Status == OrderStatus.InProgress), - "completed" => query.Where(o => o.Status == OrderStatus.Completed), - "cancelled" => query.Where(o => o.Status == OrderStatus.Cancelled), - _ => query - }; - } - - var orders = await query - .OrderByDescending(o => o.AcceptedAt ?? o.CreatedAt) - .Select(o => new OrderNotificationResponse - { - Id = o.Id, - OrderNo = o.OrderNo, - OrderType = o.OrderType.ToString(), - Title = o.Status == OrderStatus.InProgress ? "订单已被接取" - : o.Status == OrderStatus.Completed ? "订单已完成" - : o.Status == OrderStatus.Cancelled ? "订单已取消" - : o.Status == OrderStatus.WaitConfirm ? "订单待确认" - : o.Status == OrderStatus.Appealing ? "订单申诉中" - : "订单状态变更", - ItemName = o.ItemName, - Status = o.Status.ToString(), - CreatedAt = o.AcceptedAt ?? o.CreatedAt - }) - .ToListAsync(); - - // 标记订单通知为已读 - var orderIds = orders.Select(o => o.Id).ToList(); - var readIds = await db.MessageReads - .Where(r => r.UserId == userId && r.MessageType == MessageType.OrderNotification && orderIds.Contains(r.MessageId)) - .Select(r => r.MessageId) - .ToListAsync(); - - foreach (var orderId in orderIds.Where(id => !readIds.Contains(id))) - { - db.MessageReads.Add(new MessageRead - { - UserId = userId, - MessageType = MessageType.OrderNotification, - MessageId = orderId, - ReadAt = DateTime.UtcNow - }); - } - await db.SaveChangesAsync(); - - return Results.Ok(orders); -}).RequireAuthorization(); - -// 管理端发布系统通知 -app.MapPost("/api/admin/notifications", async (CreateNotificationRequest request, AppDbContext db) => -{ - // 校验 - var errors = new List(); - if (string.IsNullOrWhiteSpace(request.Title)) - errors.Add(new { field = "title", message = "标题不能为空" }); - if (string.IsNullOrWhiteSpace(request.Content)) - errors.Add(new { field = "content", message = "正文不能为空" }); - if (errors.Count > 0) - return Results.BadRequest(new { code = 400, message = "校验失败", errors }); - - if (!Enum.TryParse(request.TargetType, true, out var targetType) || !Enum.IsDefined(targetType)) - return Results.BadRequest(new { code = 400, message = "目标类型不合法" }); - - var message = new SystemMessage - { - Title = request.Title, - Content = request.Content, - ThumbnailUrl = request.ThumbnailUrl, - TargetType = targetType, - TargetUserIds = request.TargetUserIds != null - ? System.Text.Json.JsonSerializer.Serialize(request.TargetUserIds) - : null, - CreatedAt = DateTime.UtcNow - }; - - db.SystemMessages.Add(message); - await db.SaveChangesAsync(); - - return Results.Created($"/api/admin/notifications/{message.Id}", new SystemMessageDetailResponse - { - Id = message.Id, - Title = message.Title, - Content = message.Content, - ThumbnailUrl = message.ThumbnailUrl, - CreatedAt = message.CreatedAt - }); -}).RequireAuthorization("AdminOnly"); - -// ========== 用户管理接口 ========== - -// 管理端获取用户列表 -app.MapGet("/api/admin/users", async (string? keyword, AppDbContext db) => -{ - var query = db.Users.AsQueryable(); - - // 关键词搜索(昵称、手机号、ID) - if (!string.IsNullOrWhiteSpace(keyword)) - { - var kw = keyword.Trim(); - if (int.TryParse(kw, out var uid)) - { - query = query.Where(u => u.Id == uid || u.Nickname.Contains(kw) || u.Phone.Contains(kw)); - } - else - { - query = query.Where(u => u.Nickname.Contains(kw) || u.Phone.Contains(kw)); - } - } - - var users = await query.OrderByDescending(u => u.CreatedAt) - .Select(u => new - { - u.Id, - u.Nickname, - u.AvatarUrl, - u.Phone, - Role = u.Role.ToString(), - u.RunnerScore, - u.IsBanned, - u.CreatedAt, - // 查询该用户的订单数 - OrderCount = db.Orders.Count(o => o.OwnerId == u.Id) - }) - .ToListAsync(); - - return Results.Ok(users); -}).RequireAuthorization("AdminOnly"); - -// 管理端封禁/解封用户 -app.MapPut("/api/admin/users/{id}/ban", async (int id, BanRunnerRequest request, AppDbContext db) => -{ - var user = await db.Users.FindAsync(id); - if (user == null) - return Results.NotFound(new { code = 404, message = "用户不存在" }); - - user.IsBanned = request.IsBanned; - await db.SaveChangesAsync(); - - return Results.Ok(new { user.Id, user.IsBanned }); -}).RequireAuthorization("AdminOnly"); - -// ========== 跑腿管理接口 ========== - -// 管理端获取跑腿列表 -app.MapGet("/api/admin/runners", async (AppDbContext db) => -{ - var runners = await db.RunnerCertifications - .Where(c => c.Status == CertificationStatus.Approved) - .Join(db.Users, c => c.UserId, u => u.Id, (c, u) => new - { - u.Id, - u.Nickname, - Phone = c.Phone, // 使用认证表中的手机号 - u.RunnerScore, - u.IsBanned, - u.CreatedAt - }) - .ToListAsync(); - - return Results.Ok(runners); -}).RequireAuthorization("AdminOnly"); - -// 管理端封禁/解封跑腿 -app.MapPut("/api/admin/runners/{id}/ban", async (int id, BanRunnerRequest request, AppDbContext db) => -{ - var user = await db.Users.FindAsync(id); - if (user == null) - return Results.NotFound(new { code = 404, message = "用户不存在" }); - - // 确认该用户是已认证的跑腿 - var hasCert = await db.RunnerCertifications - .AnyAsync(c => c.UserId == id && c.Status == CertificationStatus.Approved); - if (!hasCert) - return Results.BadRequest(new { code = 400, message = "该用户不是已认证的跑腿" }); - - user.IsBanned = request.IsBanned; - await db.SaveChangesAsync(); - - return Results.Ok(new - { - user.Id, - user.Nickname, - user.Phone, - user.RunnerScore, - user.IsBanned - }); -}).RequireAuthorization("AdminOnly"); - -// ========== 管理端订单列表接口 ========== - -// 管理端获取订单列表 -app.MapGet("/api/admin/orders", async (string? status, string? orderType, AppDbContext db) => -{ - var query = db.Orders.AsQueryable(); - - if (!string.IsNullOrEmpty(status) && Enum.TryParse(status, true, out var s)) - query = query.Where(o => o.Status == s); - - if (!string.IsNullOrEmpty(orderType) && Enum.TryParse(orderType, true, out var t)) - query = query.Where(o => o.OrderType == t); - - var orders = await query - .OrderByDescending(o => o.CreatedAt) - .Select(o => new - { - o.Id, - o.OrderNo, - o.OwnerId, - o.RunnerId, - OrderType = o.OrderType.ToString(), - Status = o.Status.ToString(), - o.ItemName, - o.DeliveryLocation, - o.Commission, - o.GoodsAmount, - o.TotalAmount, - o.CreatedAt, - o.AcceptedAt, - o.CompletedAt - }) - .ToListAsync(); - - return Results.Ok(orders); -}).RequireAuthorization("AdminOnly"); - -// ========== 订单申诉管理接口 ========== - -// 管理端将订单状态改为申诉中 -app.MapPost("/api/admin/orders/{id}/appeal", async (int id, AppDbContext db) => -{ - var order = await db.Orders.FindAsync(id); - if (order == null) - return Results.NotFound(new { code = 404, message = "订单不存在" }); - - if (order.Status != OrderStatus.InProgress && order.Status != OrderStatus.WaitConfirm) - return Results.BadRequest(new { code = 400, message = "当前订单状态不支持申诉" }); - - order.Status = OrderStatus.Appealing; - await db.SaveChangesAsync(); - - return Results.Ok(new { id = order.Id, orderNo = order.OrderNo, status = order.Status.ToString() }); -}).RequireAuthorization("AdminOnly"); - -// 管理端处理申诉结果 -app.MapPost("/api/admin/orders/{id}/appeal/resolve", async (int id, ResolveAppealRequest request, AppDbContext db) => -{ - var order = await db.Orders.FindAsync(id); - if (order == null) - return Results.NotFound(new { code = 404, message = "订单不存在" }); - - if (order.Status != OrderStatus.Appealing) - return Results.BadRequest(new { code = 400, message = "仅申诉中的订单可处理" }); - - if (string.IsNullOrWhiteSpace(request.Result)) - return Results.BadRequest(new { code = 400, message = "处理结果不能为空" }); - - if (!Enum.TryParse(request.NewStatus, true, out var newStatus) - || (newStatus != OrderStatus.Completed && newStatus != OrderStatus.Cancelled)) - return Results.BadRequest(new { code = 400, message = "目标状态不合法,仅支持 Completed 或 Cancelled" }); - - // 创建申诉记录 - var appeal = new Appeal - { - OrderId = id, - Result = request.Result, - CreatedAt = DateTime.UtcNow - }; - db.Appeals.Add(appeal); - - // 更新订单状态 - order.Status = newStatus; - if (newStatus == OrderStatus.Completed && order.CompletedAt == null) - { - order.CompletedAt = DateTime.UtcNow; - } - - await db.SaveChangesAsync(); - - return Results.Ok(new - { - id = order.Id, - orderNo = order.OrderNo, - status = order.Status.ToString(), - appealId = appeal.Id, - appealResult = appeal.Result, - appealCreatedAt = appeal.CreatedAt - }); -}).RequireAuthorization("AdminOnly"); - -// 获取订单申诉记录 -app.MapGet("/api/orders/{id}/appeals", async (int id, AppDbContext db) => -{ - var appeals = await db.Appeals - .Where(a => a.OrderId == id) - .OrderByDescending(a => a.CreatedAt) - .Select(a => new - { - a.Id, - a.OrderId, - a.Result, - a.CreatedAt - }) - .ToListAsync(); - - return Results.Ok(appeals); -}).RequireAuthorization(); - -// ========== 图片上传接口 ========== - -app.MapPost("/api/upload/image", async (IFormFile file, IConfiguration config) => -{ - if (file == null || file.Length == 0) - return Results.BadRequest(new { code = 400, message = "请选择要上传的图片" }); - - // 文件大小校验(默认 5MB) - var maxSize = config.GetValue("Upload:MaxFileSizeBytes", 5242880); - if (file.Length > maxSize) - return Results.BadRequest(new { code = 400, message = $"图片大小不能超过 {maxSize / 1024 / 1024}MB" }); - - // 文件扩展名校验 - var allowedExtensions = config.GetSection("Upload:AllowedExtensions").Get() - ?? new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp" }; - var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); - if (!allowedExtensions.Contains(ext)) - return Results.BadRequest(new { code = 400, message = $"不支持的图片格式,仅支持 {string.Join(", ", allowedExtensions)}" }); - - // 上传到腾讯云 COS - var cosConfig = new COSXML.CosXmlConfig.Builder() - .IsHttps(true) - .SetRegion(config["COS:Region"]) - .Build(); - var credential = new COSXML.Auth.DefaultQCloudCredentialProvider( - config["COS:SecretId"], config["COS:SecretKey"], 600); - var cosXml = new COSXML.CosXmlServer(cosConfig, credential); - - var bucket = config["COS:Bucket"]!; - var cosKey = $"uploads/{DateTime.UtcNow:yyyyMMdd}/{Guid.NewGuid()}{ext}"; - - // 将上传文件写入临时文件 - var tempPath = Path.GetTempFileName(); - try - { - using (var stream = new FileStream(tempPath, FileMode.Create)) - { - await file.CopyToAsync(stream); - } - - var putRequest = new COSXML.Model.Object.PutObjectRequest(bucket, cosKey, tempPath); - var putResult = cosXml.PutObject(putRequest); - - if (putResult.httpCode != 200) - return Results.BadRequest(new { code = 400, message = "图片上传失败" }); - - var url = $"{config["COS:BaseUrl"]}/{cosKey}"; - return Results.Ok(new UploadImageResponse { Url = url }); - } - finally - { - if (File.Exists(tempPath)) File.Delete(tempPath); - } -}).RequireAuthorization() - .DisableAntiforgery(); - - -// ========== 系统配置接口 ========== - -// 获取页面顶图配置 -app.MapGet("/api/config/page-banner/{page}", async (string page, AppDbContext db) => -{ - var key = $"page_banner_{page}"; - var config = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == key); - return Results.Ok(new ConfigResponse - { - Key = key, - Value = config?.Value ?? "", - UpdatedAt = config?.UpdatedAt ?? DateTime.MinValue - }); -}); - -// 获取客服二维码 -app.MapGet("/api/config/qrcode", async (AppDbContext db) => -{ - var config = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == "qrcode"); - return Results.Ok(new ConfigResponse - { - Key = "qrcode", - Value = config?.Value ?? "", - UpdatedAt = config?.UpdatedAt ?? DateTime.MinValue - }); -}); - -// 获取用户协议 -app.MapGet("/api/config/agreement", async (AppDbContext db) => -{ - var config = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == "agreement"); - return Results.Ok(new ConfigResponse - { - Key = "agreement", - Value = config?.Value ?? "", - UpdatedAt = config?.UpdatedAt ?? DateTime.MinValue - }); -}); - -// 获取隐私政策 -app.MapGet("/api/config/privacy", async (AppDbContext db) => -{ - var config = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == "privacy"); - return Results.Ok(new ConfigResponse - { - Key = "privacy", - Value = config?.Value ?? "", - UpdatedAt = config?.UpdatedAt ?? DateTime.MinValue - }); -}); - -// 获取跑腿协议 -app.MapGet("/api/config/runner-agreement", async (AppDbContext db) => -{ - var config = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == "runner_agreement"); - return Results.Ok(new ConfigResponse - { - Key = "runner_agreement", - Value = config?.Value ?? "", - UpdatedAt = config?.UpdatedAt ?? DateTime.MinValue - }); -}); - -// 获取提现说明 -app.MapGet("/api/config/withdrawal-guide", async (AppDbContext db) => -{ - var config = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == "withdrawal_guide"); - return Results.Ok(new ConfigResponse - { - Key = "withdrawal_guide", - Value = config?.Value ?? "", - UpdatedAt = config?.UpdatedAt ?? DateTime.MinValue - }); -}); - -// 管理端获取指定配置 -app.MapGet("/api/admin/config/{key}", async (string key, AppDbContext db) => -{ - var config = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == key); - return Results.Ok(new ConfigResponse - { - Key = key, - Value = config?.Value ?? "", - UpdatedAt = config?.UpdatedAt ?? DateTime.MinValue - }); -}).RequireAuthorization("AdminOnly"); - -// 管理端更新系统配置 -app.MapPut("/api/admin/config/{key}", async (string key, UpdateConfigRequest request, AppDbContext db) => -{ - // 允许的配置键白名单 - var allowedKeys = new HashSet - { - "qrcode", "agreement", "privacy", "runner_agreement", "withdrawal_guide", "freeze_days", - "page_banner_pickup", "page_banner_delivery", "page_banner_help", "page_banner_purchase", "page_banner_food", - "page_banner_order-hall" - }; - - if (!allowedKeys.Contains(key)) - return Results.BadRequest(new { code = 400, message = $"不支持的配置键: {key}" }); - - var config = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == key); - if (config == null) - { - config = new SystemConfig - { - Key = key, - Value = request.Value, - UpdatedAt = DateTime.UtcNow - }; - db.SystemConfigs.Add(config); - } - else - { - config.Value = request.Value; - config.UpdatedAt = DateTime.UtcNow; - } - - await db.SaveChangesAsync(); - - return Results.Ok(new ConfigResponse - { - Key = config.Key, - Value = config.Value, - UpdatedAt = config.UpdatedAt - }); -}).RequireAuthorization("AdminOnly"); - -// ========== 聊天记录管理接口 ========== - -app.MapGet("/api/admin/chat-list", async (AppDbContext db) => -{ - var orders = await db.Orders - .Where(o => o.RunnerId != null && o.Status != OrderStatus.Cancelled) - .OrderByDescending(o => o.CompletedAt ?? o.AcceptedAt ?? o.CreatedAt) - .Select(o => new - { - o.Id, - o.OrderNo, - OrderType = o.OrderType.ToString(), - o.ItemName, - Status = o.Status.ToString(), - o.Commission, - OwnerId = o.OwnerId, - OwnerNickname = o.Owner!.Nickname, - OwnerAvatar = o.Owner!.AvatarUrl, - RunnerId = o.RunnerId, - RunnerNickname = o.Runner!.Nickname, - RunnerAvatar = o.Runner!.AvatarUrl, - CreatedAt = o.CreatedAt - }) - .ToListAsync(); - - var result = orders.Select(o => new - { - o.Id, - o.OrderNo, - o.OrderType, - o.ItemName, - o.Status, - o.Commission, - o.OwnerId, - OwnerNickname = string.IsNullOrWhiteSpace(o.OwnerNickname) ? $"用户{o.OwnerId}" : o.OwnerNickname, - o.OwnerAvatar, - o.RunnerId, - RunnerNickname = string.IsNullOrWhiteSpace(o.RunnerNickname) ? $"用户{o.RunnerId}" : o.RunnerNickname, - o.RunnerAvatar, - o.CreatedAt - }); - - return Results.Ok(result); -}).RequireAuthorization("AdminOnly"); - -app.MapGet("/api/admin/chat-messages", async (int ownerUserId, int runnerUserId, TencentIMService imService) => -{ - var fromImId = $"user_{ownerUserId}"; - var toImId = $"user_{runnerUserId}"; - - try - { - var result = await imService.GetRoamMessagesAsync(fromImId, toImId); - return Results.Ok(result); - } - catch (Exception ex) - { - return Results.BadRequest(new { code = 400, message = $"拉取聊天记录失败: {ex.Message}" }); - } -}).RequireAuthorization("AdminOnly"); - -// 管理员账号密码登录接口 -app.MapPost("/api/admin/auth/login", async ( - AdminLoginRequest request, - JwtService jwtService, - AppDbContext db, - IConfiguration configuration) => -{ - // 从配置读取管理员凭据 - var adminUsername = configuration["Admin:Username"] ?? "admin"; - var adminPassword = configuration["Admin:Password"] ?? "admin123"; - - if (request.Username != adminUsername || request.Password != adminPassword) - { - return Results.BadRequest(new { code = 400, message = "账号或密码错误" }); - } - - // 查找或创建管理员用户 - var admin = await db.Users.FirstOrDefaultAsync(u => u.Role == UserRole.Admin); - if (admin == null) - { - admin = new User - { - OpenId = "admin", - Phone = "admin", - Nickname = "管理员", - Role = UserRole.Admin, - CreatedAt = DateTime.UtcNow - }; - db.Users.Add(admin); - await db.SaveChangesAsync(); - } - - var token = jwtService.GenerateToken(admin.Id, admin.Role.ToString()); - - return Results.Ok(new { token }); -}); +// 注册各模块端点 +app.MapAuthEndpoints(); +app.MapIMEndpoints(); +app.MapBannerEndpoints(); +app.MapServiceEntryEndpoints(); +app.MapOrderEndpoints(); +app.MapShopEndpoints(); +app.MapEarningEndpoints(); +app.MapMessageEndpoints(); +app.MapAdminEndpoints(); +app.MapConfigEndpoints(); +app.MapRunnerEndpoints(); // 自动执行数据库迁移(仅关系型数据库,跳过 InMemory 测试环境) using (var scope = app.Services.CreateScope()) @@ -3125,8 +142,8 @@ _ = Task.Run(async () => { using var scope = app.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - await AutoConfirmExpiredOrders(db); - await UnfreezeEarnings(db); + await BusinessHelpers.AutoConfirmExpiredOrders(db); + await BusinessHelpers.UnfreezeEarnings(db); } catch (Exception ex) { @@ -3137,178 +154,5 @@ _ = Task.Run(async () => app.Run(); -// Banner 请求校验辅助方法 -static List ValidateBannerRequest(BannerRequest request) -{ - var errors = new List(); - if (string.IsNullOrWhiteSpace(request.ImageUrl)) - errors.Add(new { field = "imageUrl", message = "图片地址不能为空" }); - if (string.IsNullOrWhiteSpace(request.LinkUrl)) - errors.Add(new { field = "linkUrl", message = "链接地址不能为空" }); - if (string.IsNullOrWhiteSpace(request.LinkType)) - errors.Add(new { field = "linkType", message = "链接类型不能为空" }); - else if (!Enum.TryParse(request.LinkType, true, out var parsed) || !Enum.IsDefined(parsed)) - errors.Add(new { field = "linkType", message = "链接类型不合法" }); - return errors; -} - -// 门店请求校验辅助方法 -static List ValidateShopRequest(ShopRequest request) -{ - var errors = new List(); - if (string.IsNullOrWhiteSpace(request.Name)) - errors.Add(new { field = "name", message = "门店名称不能为空" }); - if (string.IsNullOrWhiteSpace(request.Photo)) - errors.Add(new { field = "photo", message = "门店照片不能为空" }); - if (string.IsNullOrWhiteSpace(request.Location)) - errors.Add(new { field = "location", message = "门店位置不能为空" }); - if (string.IsNullOrWhiteSpace(request.PackingFeeType)) - errors.Add(new { field = "packingFeeType", message = "打包费类型不能为空" }); - if (request.PackingFeeAmount < 0) - errors.Add(new { field = "packingFeeAmount", message = "打包费金额不能为负数" }); - return errors; -} - -// 菜品请求校验辅助方法 -static List ValidateDishRequest(DishRequest request) -{ - var errors = new List(); - if (string.IsNullOrWhiteSpace(request.Name)) - errors.Add(new { field = "name", message = "菜品名称不能为空" }); - if (string.IsNullOrWhiteSpace(request.Photo)) - errors.Add(new { field = "photo", message = "菜品照片不能为空" }); - if (request.Price < 0) - errors.Add(new { field = "price", message = "菜品价格不能为负数" }); - return errors; -} - -/// -/// 计算单个门店的打包费 -/// Fixed 模式:固定金额,不论菜品数量 -/// PerItem 模式:菜品总份数 × 单份打包费 -/// -static decimal CalculatePackingFee(PackingFeeType feeType, decimal feeAmount, int totalQuantity) -{ - return feeType switch - { - PackingFeeType.Fixed => feeAmount, - PackingFeeType.PerItem => feeAmount * totalQuantity, - _ => 0m - }; -} - -/// -/// 计算平台佣金分成 -/// 根据佣金区间规则计算:百分比类型按比例,固定类型扣除固定金额 -/// -static decimal CalculatePlatformFee(decimal commission, List rules) -{ - // 查找匹配的佣金区间 - var matchedRule = rules - .Where(r => commission >= r.MinAmount && (r.MaxAmount == null || commission <= r.MaxAmount)) - .FirstOrDefault(); - - if (matchedRule == null) return 0m; - - return matchedRule.RateType switch - { - CommissionRateType.Percentage => Math.Round(commission * matchedRule.Rate / 100m, 2), - CommissionRateType.Fixed => Math.Min(matchedRule.Rate, commission), - _ => 0m - }; -} - -/// -/// 获取用户可见的系统消息(按目标类型过滤) -/// -static async Task> GetVisibleSystemMessages(AppDbContext db, int userId, User? user) -{ - var allMessages = await db.SystemMessages.ToListAsync(); - return allMessages.Where(m => - { - if (m.TargetType == MessageTargetType.All) return true; - if (m.TargetType == MessageTargetType.OrderUser) - return user != null && user.Role != UserRole.Runner; - if (m.TargetType == MessageTargetType.RunnerUser) - return user != null && (user.Role == UserRole.Runner || user.Role == UserRole.Admin); - if (m.TargetType == MessageTargetType.Specific && m.TargetUserIds != null) - { - try - { - var ids = System.Text.Json.JsonSerializer.Deserialize>(m.TargetUserIds); - return ids != null && ids.Contains(userId); - } - catch { return false; } - } - return false; - }).ToList(); -} - -/// -/// 解冻已到期的收益记录:将冻结期满的收益从 Frozen 变为 Available -/// -static async Task UnfreezeEarnings(AppDbContext db) -{ - var now = DateTime.UtcNow; - var frozenEarnings = await db.Earnings - .Where(e => e.Status == EarningStatus.Frozen && e.FrozenUntil <= now) - .ToListAsync(); - - foreach (var earning in frozenEarnings) - { - earning.Status = EarningStatus.Available; - } - - if (frozenEarnings.Count > 0) - { - await db.SaveChangesAsync(); - } -} - -/// -/// 自动确认超过24小时未处理的待确认订单 -/// -static async Task AutoConfirmExpiredOrders(AppDbContext db) -{ - var cutoff = DateTime.UtcNow.AddHours(-24); - var expiredOrders = await db.Orders - .Where(o => o.Status == OrderStatus.WaitConfirm && o.CompletedAt != null && o.CompletedAt <= cutoff) - .ToListAsync(); - - foreach (var order in expiredOrders) - { - order.Status = OrderStatus.Completed; - - // 计算佣金收益 - var rules = await db.CommissionRules.OrderBy(r => r.MinAmount).ToListAsync(); - var platformFee = CalculatePlatformFee(order.Commission, rules); - var netEarning = order.Commission - platformFee; - - var freezeDaysConfig = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == "freeze_days"); - var freezeDays = 1; - if (freezeDaysConfig != null && int.TryParse(freezeDaysConfig.Value, out var configDays)) - freezeDays = configDays; - - db.Earnings.Add(new Earning - { - UserId = order.RunnerId!.Value, - OrderId = order.Id, - GoodsAmount = order.GoodsAmount, - Commission = order.Commission, - PlatformFee = platformFee, - NetEarning = netEarning, - Status = EarningStatus.Frozen, - FrozenUntil = DateTime.UtcNow.AddDays(freezeDays), - CreatedAt = DateTime.UtcNow - }); - } - - if (expiredOrders.Count > 0) - { - await db.SaveChangesAsync(); - Console.WriteLine($"[定时任务] 自动确认了 {expiredOrders.Count} 个超时订单"); - } -} - // 用于集成测试访问 public partial class Program { } diff --git a/server/Services/WxPayService.cs b/server/Services/WxPayService.cs new file mode 100644 index 0000000..0645339 --- /dev/null +++ b/server/Services/WxPayService.cs @@ -0,0 +1,239 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace CampusErrand.Services; + +/// +/// 微信支付服务(V3 公钥模式) +/// +public class WxPayService +{ + private readonly string _appId; + private readonly string _mchId; + private readonly string _apiV3Key; + private readonly string _publicKeyId; + private readonly RSA _privateKey; + private readonly HttpClient _httpClient; + + public WxPayService(IConfiguration config, HttpClient httpClient) + { + _appId = config["WeChat:AppId"]!; + _mchId = config["WeChat:MchId"]!; + _apiV3Key = config["WeChat:MchApiV3Key"]!; + _publicKeyId = config["WeChat:MchPublicKeyId"]!; + _httpClient = httpClient; + + // 加载商户私钥 + var privateKeyPem = config["WeChat:MchPrivateKeyPem"]!; + _privateKey = RSA.Create(); + var keyBytes = Convert.FromBase64String(privateKeyPem); + _privateKey.ImportPkcs8PrivateKey(keyBytes, out _); + } + + /// + /// JSAPI 下单(小程序支付) + /// + public async Task CreateJsapiOrder(string orderNo, decimal totalAmount, string description, string openId, string notifyUrl) + { + var totalFen = (int)(totalAmount * 100); + var requestBody = new + { + appid = _appId, + mchid = _mchId, + description, + out_trade_no = orderNo, + notify_url = notifyUrl, + amount = new { total = totalFen, currency = "CNY" }, + payer = new { openid = openId } + }; + + var json = JsonSerializer.Serialize(requestBody); + var url = "/v3/pay/transactions/jsapi"; + var fullUrl = $"https://api.mch.weixin.qq.com{url}"; + + var request = new HttpRequestMessage(HttpMethod.Post, fullUrl) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + // 签名 + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + var nonce = Guid.NewGuid().ToString("N"); + var signature = Sign("POST", url, timestamp, nonce, json); + request.Headers.Add("Authorization", $"WECHATPAY2-SHA256-RSA2048 mchid=\"{_mchId}\",nonce_str=\"{nonce}\",timestamp=\"{timestamp}\",serial_no=\"{_publicKeyId}\",signature=\"{signature}\""); + request.Headers.Add("Accept", "application/json"); + + var response = await _httpClient.SendAsync(request); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + Console.WriteLine($"[微信支付] 下单失败: {responseBody}"); + return new WxPayResult { Success = false, ErrorMessage = responseBody }; + } + + var result = JsonSerializer.Deserialize(responseBody); + var prepayId = result.GetProperty("prepay_id").GetString()!; + + // 生成小程序调起支付的参数 + var payTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + var payNonce = Guid.NewGuid().ToString("N"); + var package = $"prepay_id={prepayId}"; + var paySignStr = $"{_appId}\n{payTimestamp}\n{payNonce}\n{package}\n"; + var paySign = Convert.ToBase64String(_privateKey.SignData(Encoding.UTF8.GetBytes(paySignStr), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); + + return new WxPayResult + { + Success = true, + PaymentParams = new WxPaymentParams + { + TimeStamp = payTimestamp, + NonceStr = payNonce, + Package = package, + SignType = "RSA", + PaySign = paySign + } + }; + } + + /// + /// 验证支付回调签名并解密 + /// + public WxPayNotifyResult? VerifyAndDecryptNotify(string serialNo, string timestamp, string nonce, string signature, string body) + { + // 注意:公钥模式下回调验签需要用微信平台公钥,这里简化处理先信任回调 + // 生产环境应严格验签 + try + { + // 解析回调数据 + var json = JsonSerializer.Deserialize(body); + var resource = json.GetProperty("resource"); + var ciphertext = resource.GetProperty("ciphertext").GetString()!; + var associatedData = resource.GetProperty("associated_data").GetString() ?? ""; + var nonceStr = resource.GetProperty("nonce").GetString()!; + + // AES-GCM 解密 + var decrypted = AesGcmDecrypt(ciphertext, nonceStr, associatedData); + var result = JsonSerializer.Deserialize(decrypted); + + return new WxPayNotifyResult + { + OrderNo = result.GetProperty("out_trade_no").GetString()!, + TransactionId = result.GetProperty("transaction_id").GetString()!, + TradeState = result.GetProperty("trade_state").GetString()!, + TotalAmount = result.GetProperty("amount").GetProperty("total").GetInt32() + }; + } + catch (Exception ex) + { + Console.WriteLine($"[微信支付] 回调解密失败: {ex.Message}"); + return null; + } + } + + /// + /// 申请退款 + /// + public async Task Refund(string orderNo, string refundNo, int totalFen, int refundFen, string reason = "") + { + var requestBody = new + { + out_trade_no = orderNo, + out_refund_no = refundNo, + reason = string.IsNullOrEmpty(reason) ? "订单退款" : reason, + amount = new { refund = refundFen, total = totalFen, currency = "CNY" } + }; + + var json = JsonSerializer.Serialize(requestBody); + var url = "/v3/refund/domestic/refunds"; + var fullUrl = $"https://api.mch.weixin.qq.com{url}"; + + var request = new HttpRequestMessage(HttpMethod.Post, fullUrl) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + var nonce = Guid.NewGuid().ToString("N"); + var signature = Sign("POST", url, timestamp, nonce, json); + request.Headers.Add("Authorization", $"WECHATPAY2-SHA256-RSA2048 mchid=\"{_mchId}\",nonce_str=\"{nonce}\",timestamp=\"{timestamp}\",serial_no=\"{_publicKeyId}\",signature=\"{signature}\""); + request.Headers.Add("Accept", "application/json"); + + var response = await _httpClient.SendAsync(request); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + Console.WriteLine($"[微信支付] 退款失败: {responseBody}"); + return false; + } + + Console.WriteLine($"[微信支付] 退款成功: {refundNo}"); + return true; + } + + /// + /// 生成签名 + /// + private string Sign(string method, string url, string timestamp, string nonce, string body) + { + var message = $"{method}\n{url}\n{timestamp}\n{nonce}\n{body}\n"; + var signBytes = _privateKey.SignData(Encoding.UTF8.GetBytes(message), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return Convert.ToBase64String(signBytes); + } + + /// + /// AES-GCM 解密(回调通知解密) + /// + private string AesGcmDecrypt(string ciphertext, string nonce, string associatedData) + { + var ciphertextBytes = Convert.FromBase64String(ciphertext); + var nonceBytes = Encoding.UTF8.GetBytes(nonce); + var associatedDataBytes = Encoding.UTF8.GetBytes(associatedData); + + // 密文最后16字节是 tag + var tagSize = 16; + var encryptedData = ciphertextBytes[..^tagSize]; + var tag = ciphertextBytes[^tagSize..]; + + var plaintext = new byte[encryptedData.Length]; + using var aesGcm = new AesGcm(Encoding.UTF8.GetBytes(_apiV3Key), tagSize); + aesGcm.Decrypt(nonceBytes, encryptedData, tag, plaintext, associatedDataBytes); + + return Encoding.UTF8.GetString(plaintext); + } +} + +/// +/// 微信支付下单结果 +/// +public class WxPayResult +{ + public bool Success { get; set; } + public string? ErrorMessage { get; set; } + public WxPaymentParams? PaymentParams { get; set; } +} + +/// +/// 小程序调起支付参数 +/// +public class WxPaymentParams +{ + public string TimeStamp { get; set; } = ""; + public string NonceStr { get; set; } = ""; + public string Package { get; set; } = ""; + public string SignType { get; set; } = "RSA"; + public string PaySign { get; set; } = ""; +} + +/// +/// 支付回调解密结果 +/// +public class WxPayNotifyResult +{ + public string OrderNo { get; set; } = ""; + public string TransactionId { get; set; } = ""; + public string TradeState { get; set; } = ""; + public int TotalAmount { get; set; } +} diff --git a/server/appsettings.json b/server/appsettings.json index 3fd7c3d..29d68cf 100644 --- a/server/appsettings.json +++ b/server/appsettings.json @@ -20,7 +20,12 @@ }, "WeChat": { "AppId": "wxd62aec23fcb79bc6", - "AppSecret": "2b3b9d15fee1ed3e6204d67c86facfaf" + "AppSecret": "2b3b9d15fee1ed3e6204d67c86facfaf", + "MchId": "1742482400", + "MchApiV3Key": "1djcnfLHDJi3944HDLJK3015698fD1Oy", + "MchPublicKeyId": "PUB_KEY_ID_0117424824002026032900211571000202", + "MchPrivateKeyPem": "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQD3NZeEFc7UGujMK54+Z2xj0oJT/ykvIILqpCQ5M3DI4whuVu4C9LJwLWiRi5NTid8irx0p6kxgPw958e/szLnjmKJsURJWKWlz7VRcj4H1a888Xdam0kVFmWmdYjrIISl/NZfiKEys80KVGrBN/pIu3UZIQ9rvFaAiRK5ARTA5KINrXGnjQKMF7jgWZoVmc+nJhvh68Wo8H3ys8SzOMaTSinjlmcdCIgUzDJJp7tVIj6uCWR2JSouJyVE0nZRa5GHLujlj8sX2zLvpRm8txu3ksgFbVF/hPxAcRQ0qHjyowzKsHmJDUU9/Ms4yeLqzaM9cDuEYTPcWkmkDdD2DeDi1AgMBAAECggEBAMgv1t24hz/N9rz3iXLBb826N53roB8wtbNrboX/uRKEf1xS+vTb0O/0ZZyPKaPZnx9ILVa3DFhYWKEIoaDh/JppDQan9DBf4qxlAQ7fi38BadVZrCx1VHFTFWrElBNif2crMC6NMeJQL5fs/955C0n2GCcHA/DeU0nM0krrfGybYal93ufr2OranRSoDAYWxNks/VG7RzBJdj5kxe8pdLFDU7l1d52V5C4whBEjZMpp5JoEBsYTEbQ/pcU5i4LhE1OPVNRRHteMNgCSVICZr5xmtSNRy0XkAUTm+5Z3P+iP8IWIdkcrrlKFaZOjQTn21xCWhYeSmTWMkTQ8PfgrlsECgYEA//tkpck73ZcQQQBf7OOj1lFGFKN0KdiONYMrsXLQ5yud3jfWAUQ0PEqEa+4HadwK/S+lq0g3IXRQ1SuLyZSTYOAPVohIv9bVxh7oAoBZbMluLEE9E38AtcTMS3FiigMac+X8z3bJWIA0IJSvWD5JTHxJ/VZeUShdEQAKwY3lAtECgYEA9zoKc4M8Jfx5ga4X15SffKk4S+t51PY5lbuT/507/o+8imjMiuaoIGGjRd0vylOrACS/QriAnqusyvxpF9nYSLWDYMJPloKfxf+BYvQB8tv088afHzw2Q0K2yFyZzfW/i+uBZc4UaPrRmtUgEU/NNWreOmfbr+j7BDiotTNQ6KUCgYABlfcbp9F9H/Bz1qLBfu+G5l3+xrxzfenznupoYQO2SujhdYsX2upP7U5AtOrK1xgiVWc7VmkxBd1yVKC7EPaQxRKTQKjit1v/rDVXvp/PMrhCAe1073Z7qcpyNTOdE0PYr/YO+vdoWvL3uLQVYd1mYea7cQuIiS16a3ulk1F14QKBgQCYWVoLaPnt5rHx6hijLuFBbv5UOp3vUHSYAunnATvxWR40pPQ3PICqw8Bb0zwaEIk2I28BbLVGEkD/LaCNpB8WX1TAkb194K0Y1KUlF30D7ev7NZDlLLO7qyb8PaRCOYh6bvxkgiQttTLpmSCTynuIyXx8vXex5X6aUVgVobPgSQKBgAnOA6G1UeJKjIARPNXEyOLu+8N3+bWVonhNQfMrTN9Aa5YCrkYkQPIzbHMzlPCL/uiK91yk+wvgAohvpQysQe54KkqZxK5ucjUGwqNRGMAkmlS+669wKEcx5tYzek00sQzQVBFWDJeH/xtJEbw30bTPuSDtBhMV3OgwiuJeuRm5", + "WxPublicKeyPem": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2GJl9vHz0Yi62pgtXR1iS5sc7QLAANag82qGHlN83AJynP84wSpwBduvzR8Vh/FDFQTg6CTGbtWPsSpj+yTbfkz3YukigL+6JZ3tXeK+HKnDdamK0GD5p9xe6Msm1eLccfD+dThTQIlFW0gsvTEFUXsZw6SyEodLY7RuVKEb1Tkb5dmUB4UbZaQblTlrWLp7fDKHDxsDKICFR/qFCjbI1D9qug6DD1qv4M7teATMmnHXolTPrGwpXKRJOgLRYZrq0j7hdMY/p/L9JwsDK+ioPcskcBW5iAfdUTL5NeVJ30sBHs7KQdtr0Zk6Fj/aGcUpt7+8rAIIK//6pmHwWCN0rQIDAQAB" }, "Upload": { "MaxFileSizeBytes": 5242880,