改bug
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
18631081161 2026-03-29 21:13:50 +08:00
parent c543ebaf8b
commit 2d0c71721d
32 changed files with 4095 additions and 3232 deletions

View File

@ -37,8 +37,11 @@
<el-table-column prop="createdAt" label="下单时间" width="170">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<template v-if="row.status !== 'Cancelled' && row.status !== 'Completed'">
<el-button size="small" type="danger" @click="openCancel(row)">取消订单</el-button>
</template>
<template v-if="row.status === 'InProgress' || row.status === 'WaitConfirm'">
<el-button size="small" type="warning" @click="handleAppeal(row)">设为申诉</el-button>
</template>
@ -67,6 +70,28 @@
<el-button type="primary" :loading="submitting" @click="handleResolve">确定</el-button>
</template>
</el-dialog>
<!-- 取消订单弹窗 -->
<el-dialog v-model="cancelDialogVisible" title="取消订单" width="450px">
<el-alert type="warning" :closable="false" show-icon style="margin-bottom: 16px;">
取消后将发起微信退款单主支付的金额原路返回
</el-alert>
<el-form label-width="90px">
<el-form-item label="订单编号">
<span>{{ cancelOrderNo }}</span>
</el-form-item>
<el-form-item label="支付金额">
<span style="color: #e64340; font-weight: bold;">¥{{ cancelTotalAmount }}</span>
</el-form-item>
<el-form-item label="取消原因" required>
<el-input v-model="cancelReason" type="textarea" :rows="3" placeholder="请输入取消原因(必填)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="cancelDialogVisible = false">返回</el-button>
<el-button type="danger" :loading="submitting" @click="handleCancel">确认取消并退款</el-button>
</template>
</el-dialog>
</div>
</template>
@ -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)
</script>

View File

@ -1,43 +1,73 @@
<template>
<div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h3 style="margin: 0;">评价管理</h3>
<el-input v-model="runnerIdFilter" placeholder="按跑腿ID筛选" clearable style="width: 200px;"
@clear="fetchList" @keyup.enter="fetchList">
<template #append>
<el-button @click="fetchList">搜索</el-button>
</template>
</el-input>
<div class="reviews-page">
<div class="page-header">
<h2>评价管理</h2>
<div class="header-actions">
<el-input v-model="searchUid" placeholder="输入跑腿UID搜索" clearable style="width: 220px;"
@clear="onClearSearch" @keyup.enter="fetchList">
<template #prefix><el-icon><Search /></el-icon></template>
<template #append>
<el-button @click="fetchList">搜索</el-button>
</template>
</el-input>
</div>
</div>
<el-table :data="list" v-loading="loading" border>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="orderNo" label="订单编号" width="160" />
<el-table-column prop="runnerId" label="跑腿ID" width="80" />
<el-table-column prop="runnerNickname" label="跑腿昵称" width="120" />
<el-table-column prop="rating" label="星级" width="80">
<template #default="{ row }">{{ row.rating }}</template>
</el-table-column>
<el-table-column prop="scoreChange" label="分值变化" width="100">
<!-- 搜索到的用户信息卡片 -->
<el-card v-if="userInfo" class="user-card" shadow="never">
<div class="user-info-row">
<el-avatar :size="48" :src="userInfo.avatarUrl" />
<div class="user-detail">
<div class="user-name">{{ userInfo.nickname }} <span class="user-uid">UID: {{ userInfo.id }}</span></div>
<div class="user-meta">
<span>评分: <b :style="{ color: getScoreColor(userInfo.runnerScore) }">{{ userInfo.runnerScore }}</b></span>
<el-divider direction="vertical" />
<span>平均星级: <b style="color: #ff9900;"> {{ userInfo.avgRating || '-' }}</b></span>
<el-divider direction="vertical" />
<span>被评价: <b>{{ userInfo.reviewCount || 0 }}</b> </span>
<el-divider direction="vertical" />
<span>状态: <el-tag :type="userInfo.isBanned ? 'danger' : 'success'" size="small">{{ userInfo.isBanned ? '已封禁' : '正常' }}</el-tag></span>
</div>
</div>
</div>
</el-card>
<el-table :data="list" v-loading="loading" stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="60" align="center" />
<el-table-column prop="orderNo" label="订单编号" min-width="180" show-overflow-tooltip />
<el-table-column prop="runnerId" label="跑腿UID" width="90" align="center" />
<el-table-column prop="runnerNickname" label="跑腿昵称" width="120" show-overflow-tooltip />
<el-table-column label="星级" width="120" align="center">
<template #default="{ row }">
<span :style="{ color: row.scoreChange > 0 ? '#67c23a' : row.scoreChange < 0 ? '#f56c6c' : '#909399' }">
<span style="color: #ff9900;">{{ '★'.repeat(row.rating) }}{{ '☆'.repeat(5 - row.rating) }}</span>
</template>
</el-table-column>
<el-table-column label="分值变化" width="90" align="center">
<template #default="{ row }">
<span :style="{ color: row.scoreChange > 0 ? '#67c23a' : row.scoreChange < 0 ? '#f56c6c' : '#999' }">
{{ row.scoreChange > 0 ? '+' : '' }}{{ row.scoreChange }}
</span>
</template>
</el-table-column>
<el-table-column prop="content" label="评价内容" show-overflow-tooltip />
<el-table-column label="状态" width="80">
<el-table-column prop="content" label="评价内容" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.content || '-' }}</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.isDisabled ? 'danger' : 'success'" size="small">{{ row.isDisabled ? '已禁用' : '正常' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="评价时间" width="170">
<el-table-column label="评价时间" width="170">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<el-table-column label="操作" width="80" fixed="right" align="center">
<template #default="{ row }">
<el-button v-if="!row.isDisabled" size="small" type="danger" @click="handleDisable(row)">禁用</el-button>
<span v-else style="color: #909399;">已禁用</span>
<el-popconfirm v-if="!row.isDisabled" title="禁用后将不计算该评价分数,确定?" @confirm="handleDisable(row)">
<template #reference>
<el-button size="small" type="danger" plain>禁用</el-button>
</template>
</el-popconfirm>
<span v-else style="color: #ccc; font-size: 12px;">已禁用</span>
</template>
</el-table-column>
</el-table>
@ -46,31 +76,106 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ElMessage } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import request from '../utils/request'
const loading = ref(false)
const list = ref([])
const runnerIdFilter = ref('')
const formatTime = (t) => t ? new Date(t).toLocaleString('zh-CN') : ''
const searchUid = ref('')
const userInfo = ref(null)
async function fetchList() {
loading.value = true
userInfo.value = null
try {
const params = runnerIdFilter.value ? { runnerId: runnerIdFilter.value } : {}
const params = {}
if (searchUid.value) params.runnerId = searchUid.value
list.value = await request.get('/admin/reviews', { params })
// UID
if (searchUid.value) await loadUserInfo(searchUid.value)
} finally {
loading.value = false
}
}
async function loadUserInfo(uid) {
try {
const users = await request.get('/admin/users', { params: { keyword: uid } })
const user = (users || []).find(u => u.id === parseInt(uid))
if (user) {
//
const reviews = list.value.filter(r => !r.isDisabled)
const avgRating = reviews.length > 0
? (reviews.reduce((s, r) => s + r.rating, 0) / reviews.length).toFixed(1)
: null
userInfo.value = { ...user, avgRating, reviewCount: reviews.length }
}
} catch (e) {}
}
function onClearSearch() {
userInfo.value = null
fetchList()
}
async function handleDisable(row) {
await ElMessageBox.confirm('确定禁用该评价?禁用后将不计算该条评价的分数', '提示', { type: 'warning' })
await request.put(`/admin/reviews/${row.id}/disable`)
ElMessage.success('已禁用')
fetchList()
}
function getScoreColor(score) {
if (score >= 80) return '#67c23a'
if (score >= 60) return '#e6a23c'
return '#f56c6c'
}
function formatTime(str) {
if (!str) return '-'
const d = new Date(str)
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
onMounted(fetchList)
</script>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.page-header h2 {
margin: 0;
font-size: 20px;
}
.user-card {
margin-bottom: 16px;
}
.user-info-row {
display: flex;
align-items: center;
gap: 16px;
}
.user-detail {
flex: 1;
}
.user-name {
font-size: 16px;
font-weight: 500;
margin-bottom: 6px;
}
.user-uid {
font-size: 12px;
color: #999;
font-weight: normal;
margin-left: 8px;
}
.user-meta {
font-size: 13px;
color: #666;
}
</style>

View File

@ -7,8 +7,8 @@
<el-table :data="list" v-loading="loading" stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="nickname" label="昵称" min-width="140" show-overflow-tooltip />
<el-table-column prop="phone" label="手机号" min-width="140" />
<el-table-column prop="nickname" label="昵称" min-width="120" show-overflow-tooltip />
<el-table-column prop="phone" label="手机号" min-width="130" />
<el-table-column label="评分" width="160" align="center">
<template #default="{ row }">
<div class="score-cell">
@ -23,17 +23,33 @@
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<el-table-column label="评价星级" width="120" align="center">
<template #default="{ row }">
<span v-if="row.reviewCount > 0" style="color: #ff9900; font-weight: bold;">
{{ row.avgRating }}
</span>
<span v-else style="color: #ccc;">暂无</span>
</template>
</el-table-column>
<el-table-column label="被评价" width="100" align="center">
<template #default="{ row }">
<el-link v-if="row.reviewCount > 0" type="primary" @click="showReviews(row)">
{{ row.reviewCount }}
</el-link>
<span v-else style="color: #ccc;">0</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.isBanned ? 'danger' : 'success'" round size="small">
{{ row.isBanned ? '已封禁' : '正常' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="注册时间" min-width="170">
<el-table-column prop="createdAt" label="注册时间" min-width="160">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right" align="center">
<el-table-column label="操作" width="100" fixed="right" align="center">
<template #default="{ row }">
<el-popconfirm
v-if="!row.isBanned"
@ -59,6 +75,34 @@
</template>
</el-table-column>
</el-table>
<!-- 评价记录弹窗 -->
<el-dialog v-model="reviewDialogVisible" :title="`${currentRunner?.nickname} 的评价记录`" width="700px">
<el-table :data="reviews" v-loading="reviewLoading" stripe size="small">
<el-table-column label="订单号" prop="orderNo" min-width="180" show-overflow-tooltip />
<el-table-column label="类型" width="80" align="center">
<template #default="{ row }">{{ getTypeLabel(row.orderType) }}</template>
</el-table-column>
<el-table-column label="星级" width="80" align="center">
<template #default="{ row }">
<span style="color: #ff9900;">{{ '★'.repeat(row.rating) }}</span>
</template>
</el-table-column>
<el-table-column label="分数变化" width="90" align="center">
<template #default="{ row }">
<span :style="{ color: row.scoreChange >= 0 ? '#67c23a' : '#f56c6c' }">
{{ row.scoreChange >= 0 ? '+' : '' }}{{ row.scoreChange }}
</span>
</template>
</el-table-column>
<el-table-column label="评价内容" prop="content" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.content || '-' }}</template>
</el-table-column>
<el-table-column label="时间" width="160">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
</el-table>
</el-dialog>
</div>
</template>
@ -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)

View File

@ -47,7 +47,7 @@
<el-table-column label="注册时间" min-width="170">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right" align="center">
<el-table-column label="操作" width="160" fixed="right" align="center">
<template #default="{ row }">
<el-popconfirm
v-if="!row.isBanned"
@ -70,6 +70,17 @@
<el-button size="small" type="success" plain>解封</el-button>
</template>
</el-popconfirm>
<el-popconfirm
v-if="row.role !== 'Admin'"
title="删除后不可恢复,确定删除?"
confirm-button-text="删除"
confirm-button-type="danger"
@confirm="deleteUser(row)"
>
<template #reference>
<el-button size="small" type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
@ -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

View File

@ -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' })

View File

@ -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;
}

View File

@ -346,6 +346,10 @@ export default {
margin-right: 10rpx;
}
.cart-checkout-btn::after {
border: none;
}
.cart-checkout-btn.disabled {
background: #e0e0e0;
color: #999;

View File

@ -438,6 +438,10 @@
margin-right: 10rpx;
}
.cart-checkout-btn::after {
border: none;
}
.cart-checkout-btn.disabled {
background: #e0e0e0;
color: #999;

View File

@ -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;
}

View File

@ -60,7 +60,7 @@
</view>
<text class="chat-msg">{{ getOrderLabel(item) }}</text>
</view>
<view class="chat-tag">
<view class="chat-tag" :class="'tag-' + item.status">
<text>{{ getStatusLabel(item.status) }}</text>
</view>
</view>
@ -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;

View File

@ -25,7 +25,7 @@
<image class="arrow-icon" src="/static/ic_arrow.png" mode="aspectFit"></image>
</view>
<view class="card-stats">
<view class="stat-item" @click.stop="goMyOrders('InProgress')">
<view class="stat-item" @click.stop="goMyOrders('InProgress,WaitConfirm')">
<text class="stat-label">进行中</text>
<text class="stat-num">{{ stats.orderOngoing }}</text>
</view>
@ -44,7 +44,7 @@
<image class="arrow-icon" src="/static/ic_arrow.png" mode="aspectFit"></image>
</view>
<view class="card-stats">
<view class="stat-item" @click.stop="goMyTaken('InProgress')">
<view class="stat-item" @click.stop="goMyTaken('InProgress,WaitConfirm')">
<text class="stat-label">进行中</text>
<text class="stat-num">{{ stats.takenOngoing }}</text>
</view>

View File

@ -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
})

View File

@ -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
})

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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

View File

@ -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<OrderStatus>(status, true, out var s))
query = query.Where(o => o.Status == s);
if (!string.IsNullOrEmpty(orderType) && Enum.TryParse<OrderType>(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<OrderStatus>(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<CertificationStatus>(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<CertificationStatus>(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");
}
}

View File

@ -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 });
});
}
}

View File

@ -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<LinkType>(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<LinkType>(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");
}
}

View File

@ -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<string>
{
"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<long>("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<string[]>()
?? 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();
}
}

View File

@ -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<PaymentMethod>(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<WithdrawalStatus>(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<CommissionRule> 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");
}
}

View File

@ -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();
}
}

View File

@ -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<object>();
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<MessageTargetType>(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");
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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<object>();
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();
}
}

View File

@ -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");
}
}

View File

@ -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<PackingFeeType>(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<PackingFeeType>(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");
}
}

View File

@ -0,0 +1,191 @@
using CampusErrand.Data;
using CampusErrand.Models;
using CampusErrand.Models.Dtos;
using Microsoft.EntityFrameworkCore;
namespace CampusErrand.Helpers;
/// <summary>
/// 业务辅助方法集合
/// </summary>
public static class BusinessHelpers
{
/// <summary>
/// Banner 请求校验
/// </summary>
internal static List<object> ValidateBannerRequest(BannerRequest request)
{
var errors = new List<object>();
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<LinkType>(request.LinkType, true, out var parsed) || !Enum.IsDefined(parsed))
errors.Add(new { field = "linkType", message = "链接类型不合法" });
return errors;
}
/// <summary>
/// 门店请求校验
/// </summary>
internal static List<object> ValidateShopRequest(ShopRequest request)
{
var errors = new List<object>();
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;
}
/// <summary>
/// 菜品请求校验
/// </summary>
internal static List<object> ValidateDishRequest(DishRequest request)
{
var errors = new List<object>();
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;
}
/// <summary>
/// 计算单个门店的打包费
/// Fixed 模式:固定金额,不论菜品数量
/// PerItem 模式:菜品总份数 × 单份打包费
/// </summary>
internal static decimal CalculatePackingFee(PackingFeeType feeType, decimal feeAmount, int totalQuantity)
{
return feeType switch
{
PackingFeeType.Fixed => feeAmount,
PackingFeeType.PerItem => feeAmount * totalQuantity,
_ => 0m
};
}
/// <summary>
/// 计算平台佣金分成
/// 根据佣金区间规则计算:百分比类型按比例,固定类型扣除固定金额
/// </summary>
internal static decimal CalculatePlatformFee(decimal commission, List<CommissionRule> 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
};
}
/// <summary>
/// 获取用户可见的系统消息(按目标类型过滤)
/// </summary>
internal static async Task<List<SystemMessage>> 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<List<int>>(m.TargetUserIds);
return ids != null && ids.Contains(userId);
}
catch { return false; }
}
return false;
}).ToList();
}
/// <summary>
/// 解冻已到期的收益记录:将冻结期满的收益从 Frozen 变为 Available
/// </summary>
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();
}
}
/// <summary>
/// 自动确认超过24小时未处理的待确认订单
/// </summary>
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} 个超时订单");
}
}
}

View File

@ -179,3 +179,12 @@ public class OrderListItemResponse
public DateTime? AcceptedAt { get; set; }
public DateTime? CompletedAt { get; set; }
}
/// <summary>
/// 管理端取消订单请求
/// </summary>
public class AdminCancelOrderRequest
{
/// <summary>取消原因</summary>
public string Reason { get; set; } = string.Empty;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,239 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace CampusErrand.Services;
/// <summary>
/// 微信支付服务V3 公钥模式)
/// </summary>
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 _);
}
/// <summary>
/// JSAPI 下单(小程序支付)
/// </summary>
public async Task<WxPayResult> 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<JsonElement>(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
}
};
}
/// <summary>
/// 验证支付回调签名并解密
/// </summary>
public WxPayNotifyResult? VerifyAndDecryptNotify(string serialNo, string timestamp, string nonce, string signature, string body)
{
// 注意:公钥模式下回调验签需要用微信平台公钥,这里简化处理先信任回调
// 生产环境应严格验签
try
{
// 解析回调数据
var json = JsonSerializer.Deserialize<JsonElement>(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<JsonElement>(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;
}
}
/// <summary>
/// 申请退款
/// </summary>
public async Task<bool> 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;
}
/// <summary>
/// 生成签名
/// </summary>
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);
}
/// <summary>
/// AES-GCM 解密(回调通知解密)
/// </summary>
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);
}
}
/// <summary>
/// 微信支付下单结果
/// </summary>
public class WxPayResult
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public WxPaymentParams? PaymentParams { get; set; }
}
/// <summary>
/// 小程序调起支付参数
/// </summary>
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; } = "";
}
/// <summary>
/// 支付回调解密结果
/// </summary>
public class WxPayNotifyResult
{
public string OrderNo { get; set; } = "";
public string TransactionId { get; set; } = "";
public string TradeState { get; set; } = "";
public int TotalAmount { get; set; }
}

View File

@ -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,