-
评价管理
-
-
- 搜索
-
-
+
+
-
-
-
-
-
-
- {{ row.rating }}星
-
-
+
+
+
+
+
+
{{ userInfo.nickname }} UID: {{ userInfo.id }}
+
+ 评分: {{ userInfo.runnerScore }}
+
+ 平均星级: ★ {{ userInfo.avgRating || '-' }}
+
+ 被评价: {{ userInfo.reviewCount || 0 }} 次
+
+ 状态: {{ userInfo.isBanned ? '已封禁' : '正常' }}
+
+
+
+
+
+
+
+
+
+
+
-
+ {{ '★'.repeat(row.rating) }}{{ '☆'.repeat(5 - row.rating) }}
+
+
+
+
+
{{ row.scoreChange > 0 ? '+' : '' }}{{ row.scoreChange }}
-
-
+
+ {{ row.content || '-' }}
+
+
{{ row.isDisabled ? '已禁用' : '正常' }}
-
+
{{ formatTime(row.createdAt) }}
-
+
- 禁用
- 已禁用
+
+
+ 禁用
+
+
+ 已禁用
@@ -46,31 +76,106 @@
+
+
diff --git a/admin/src/views/Runners.vue b/admin/src/views/Runners.vue
index 0a08187..1f47e12 100644
--- a/admin/src/views/Runners.vue
+++ b/admin/src/views/Runners.vue
@@ -7,8 +7,8 @@
-
-
+
+
@@ -23,17 +23,33 @@
-
+
+
+
+ ★ {{ row.avgRating }}
+
+ 暂无
+
+
+
+
+
+ {{ row.reviewCount }}次
+
+ 0次
+
+
+
{{ row.isBanned ? '已封禁' : '正常' }}
-
+
{{ formatTime(row.createdAt) }}
-
+
+
+
+
+
+
+
+ {{ getTypeLabel(row.orderType) }}
+
+
+
+ {{ '★'.repeat(row.rating) }}
+
+
+
+
+
+ {{ row.scoreChange >= 0 ? '+' : '' }}{{ row.scoreChange }}
+
+
+
+
+ {{ row.content || '-' }}
+
+
+ {{ formatTime(row.createdAt) }}
+
+
+
@@ -69,6 +113,10 @@ import request from '../utils/request'
const loading = ref(false)
const list = ref([])
+const reviewDialogVisible = ref(false)
+const reviewLoading = ref(false)
+const reviews = ref([])
+const currentRunner = ref(null)
async function fetchList() {
loading.value = true
@@ -86,12 +134,28 @@ async function toggleBan(row, isBanned) {
fetchList()
}
+async function showReviews(row) {
+ currentRunner.value = row
+ reviewDialogVisible.value = true
+ reviewLoading.value = true
+ try {
+ reviews.value = await request.get(`/admin/runners/${row.id}/reviews`)
+ } finally {
+ reviewLoading.value = false
+ }
+}
+
function getScoreColor(score) {
if (score >= 80) return '#67c23a'
if (score >= 60) return '#e6a23c'
return '#f56c6c'
}
+function getTypeLabel(type) {
+ const map = { Pickup: '代取', Delivery: '代送', Help: '万能帮', Purchase: '代购', Food: '美食街' }
+ return map[type] || type
+}
+
function formatTime(str) {
if (!str) return '-'
const d = new Date(str)
diff --git a/admin/src/views/Users.vue b/admin/src/views/Users.vue
index a1cf934..c43849d 100644
--- a/admin/src/views/Users.vue
+++ b/admin/src/views/Users.vue
@@ -47,7 +47,7 @@
{{ formatTime(row.createdAt) }}
-
+
解封
+
+
+ 删除
+
+
@@ -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