This commit is contained in:
parent
c543ebaf8b
commit
2d0c71721d
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -346,6 +346,10 @@ export default {
|
|||
margin-right: 10rpx;
|
||||
}
|
||||
|
||||
.cart-checkout-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cart-checkout-btn.disabled {
|
||||
background: #e0e0e0;
|
||||
color: #999;
|
||||
|
|
|
|||
|
|
@ -438,6 +438,10 @@
|
|||
margin-right: 10rpx;
|
||||
}
|
||||
|
||||
.cart-checkout-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cart-checkout-btn.disabled {
|
||||
background: #e0e0e0;
|
||||
color: #999;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
590
server/Endpoints/AdminEndpoints.cs
Normal file
590
server/Endpoints/AdminEndpoints.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
168
server/Endpoints/AuthEndpoints.cs
Normal file
168
server/Endpoints/AuthEndpoints.cs
Normal 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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
147
server/Endpoints/BannerEndpoints.cs
Normal file
147
server/Endpoints/BannerEndpoints.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
193
server/Endpoints/ConfigEndpoints.cs
Normal file
193
server/Endpoints/ConfigEndpoints.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
330
server/Endpoints/EarningEndpoints.cs
Normal file
330
server/Endpoints/EarningEndpoints.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
25
server/Endpoints/IMEndpoints.cs
Normal file
25
server/Endpoints/IMEndpoints.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
222
server/Endpoints/MessageEndpoints.cs
Normal file
222
server/Endpoints/MessageEndpoints.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
1003
server/Endpoints/OrderEndpoints.cs
Normal file
1003
server/Endpoints/OrderEndpoints.cs
Normal file
File diff suppressed because it is too large
Load Diff
94
server/Endpoints/RunnerEndpoints.cs
Normal file
94
server/Endpoints/RunnerEndpoints.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
125
server/Endpoints/ServiceEntryEndpoints.cs
Normal file
125
server/Endpoints/ServiceEntryEndpoints.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
360
server/Endpoints/ShopEndpoints.cs
Normal file
360
server/Endpoints/ShopEndpoints.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
191
server/Helpers/BusinessHelpers.cs
Normal file
191
server/Helpers/BusinessHelpers.cs
Normal 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} 个超时订单");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
3194
server/Program.cs
3194
server/Program.cs
File diff suppressed because it is too large
Load Diff
239
server/Services/WxPayService.cs
Normal file
239
server/Services/WxPayService.cs
Normal 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; }
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user