源代码续
<template>
<div class="app-container">
<el-card class="box-card">
<div slot="header" class="clearfix">
<span>通知统计</span>
<div class="header-operations">
<el-date-picker
v-model="dateRange"
type="daterange"
align="right"
unlink-panels
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
:picker-options="pickerOptions"
@change="handleDateRangeChange"
style="width: 350px">
</el-date-picker>
<el-button type="primary" icon="el-icon-refresh" @click="refreshData">刷新</el-button>
<el-button type="success" icon="el-icon-download" @click="exportData">导出</el-button>
</div>
</div>
<!-- 统计卡片 -->
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="6" :lg="6" :xl="6" v-for="(item, index) in statisticsCards" :key="index">
<el-card class="stat-card" shadow="hover">
<div class="card-icon">
<i :class="item.icon" :style="{ color: item.color }"></i>
</div>
<div class="card-content">
<div class="card-title">{{ item.title }}</div>
<div class="card-value">{{ item.value }}</div>
<div class="card-footer">
<span>{{ item.change >= 0 ? '+' : '' }}{{ item.change }}%</span>
<span>较上期</span>
<i :class="item.change >= 0 ? 'el-icon-top' : 'el-icon-bottom'"
:style="{ color: item.change >= 0 ? '#67C23A' : '#F56C6C' }"></i>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 图表区域 -->
<el-row :gutter="20" style="margin-top: 20px;">
<!-- 发送趋势图 -->
<el-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12">
<el-card shadow="hover">
<div slot="header" class="clearfix">
<span>发送趋势</span>
<el-radio-group v-model="trendGroupBy" size="mini" style="float: right;">
<el-radio-button label="day">按日</el-radio-button>
<el-radio-button label="week">按周</el-radio-button>
<el-radio-button label="month">按月</el-radio-button>
</el-radio-group>
</div>
<div class="chart-container">
<div ref="trendChart" style="width: 100%; height: 300px;"></div>
</div>
</el-card>
</el-col>
<!-- 通知类型分布图 -->
<el-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12">
<el-card shadow="hover">
<div slot="header" class="clearfix">
<span>通知类型分布</span>
</div>
<div class="chart-container">
<div ref="typeChart" style="width: 100%; height: 300px;"></div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px;">
<!-- 通知渠道分布图 -->
<el-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12">
<el-card shadow="hover">
<div slot="header" class="clearfix">
<span>通知渠道分布</span>
</div>
<div class="chart-container">
<div ref="channelChart" style="width: 100%; height: 300px;"></div>
</div>
</el-card>
</el-col>
<!-- 阅读率统计图 -->
<el-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12">
<el-card shadow="hover">
<div slot="header" class="clearfix">
<span>阅读率统计</span>
<el-radio-group v-model="readRateGroupBy" size="mini" style="float: right;">
<el-radio-button label="type">按类型</el-radio-button>
<el-radio-button label="channel">按渠道</el-radio-button>
</el-radio-group>
</div>
<div class="chart-container">
<div ref="readRateChart" style="width: 100%; height: 300px;"></div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 详细数据表格 -->
<el-card shadow="hover" style="margin-top: 20px;">
<div slot="header" class="clearfix">
<span>详细数据</span>
<el-button-group style="float: right;">
<el-button size="mini" :type="tableView === 'daily' ? 'primary' : ''" @click="tableView = 'daily'">日报表</el-button>
<el-button size="mini" :type="tableView === 'type' ? 'primary' : ''" @click="tableView = 'type'">类型报表</el-button>
<el-button size="mini" :type="tableView === 'channel' ? 'primary' : ''" @click="tableView = 'channel'">渠道报表</el-button>
</el-button-group>
</div>
<!-- 日报表 -->
<el-table v-if="tableView === 'daily'" :data="dailyData" style="width: 100%" border>
<el-table-column prop="date" label="日期" width="120" />
<el-table-column prop="sentCount" label="发送数量" width="100" align="center" />
<el-table-column prop="readCount" label="已读数量" width="100" align="center" />
<el-table-column prop="readRate" label="阅读率" width="100" align="center">
<template slot-scope="scope">
{{ scope.row.readRate }}%
</template>
</el-table-column>
<el-table-column prop="systemCount" label="系统通知" width="100" align="center" />
<el-table-column prop="expressCount" label="快递通知" width="100" align="center" />
<el-table-column prop="activityCount" label="活动通知" width="100" align="center" />
<el-table-column prop="inAppCount" label="站内信" width="100" align="center" />
<el-table-column prop="smsCount" label="短信" width="100" align="center" />
<el-table-column prop="emailCount" label="邮件" width="100" align="center" />
<el-table-column prop="pushCount" label="推送" width="100" align="center" />
</el-table>
<!-- 类型报表 -->
<el-table v-if="tableView === 'type'" :data="typeData" style="width: 100%" border>
<el-table-column prop="type" label="通知类型" width="120">
<template slot-scope="scope">
<el-tag :type="getTypeTagType(scope.row.typeId)">{{ scope.row.type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="sentCount" label="发送数量" width="120" align="center" />
<el-table-column prop="readCount" label="已读数量" width="120" align="center" />
<el-table-column prop="readRate" label="阅读率" width="120" align="center">
<template slot-scope="scope">
{{ scope.row.readRate }}%
</template>
</el-table-column>
<el-table-column prop="userCount" label="接收用户数" width="120" align="center" />
<el-table-column prop="avgResponseTime" label="平均响应时间" align="center">
<template slot-scope="scope">
{{ scope.row.avgResponseTime }} 分钟
</template>
</el-table-column>
</el-table>
<!-- 渠道报表 -->
<el-table v-if="tableView === 'channel'" :data="channelData" style="width: 100%" border>
<el-table-column prop="channel" label="通知渠道" width="120">
<template slot-scope="scope">
<el-tag :type="getChannelTagType(scope.row.channelId)">{{ scope.row.channel }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="sentCount" label="发送数量" width="120" align="center" />
<el-table-column prop="successCount" label="成功数量" width="120" align="center" />
<el-table-column prop="successRate" label="发送成功率" width="120" align="center">
<template slot-scope="scope">
{{ scope.row.successRate }}%
</template>
</el-table-column>
<el-table-column prop="readCount" label="已读数量" width="120" align="center" />
<el-table-column prop="readRate" label="阅读率" width="120" align="center">
<template slot-scope="scope">
{{ scope.row.readRate }}%
</template>
</el-table-column>
<el-table-column prop="avgResponseTime" label="平均响应时间" align="center">
<template slot-scope="scope">
{{ scope.row.avgResponseTime }} 分钟
</template>
</el-table-column>
</el-table>
<div class="pagination-container">
<el-pagination
background
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="pagination.currentPage"
:page-sizes="[10, 20, 30, 50]"
:page-size="pagination.pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total">
</el-pagination>
</div>
</el-card>
</el-card>
</div>
</template>
<script>
import * as echarts from 'echarts'
import { NotificationType, NotificationChannel, getTypeTagType, getChannelTagType } from '@/utils/notification'
export default {
name: 'NotificationStatistics',
data() {
return {
// 日期范围选择器配置
pickerOptions: {
shortcuts: [
{
text: '最近一周',
onClick(picker) {
const end = new Date()
const start = new Date()
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7)
picker.$emit('pick', [start, end])
}
},
{
text: '最近一个月',
onClick(picker) {
const end = new Date()
const start = new Date()
start.setTime(start.getTime() - 3600 * 1000 * 24 * 30)
picker.$emit('pick', [start, end])
}
},
{
text: '最近三个月',
onClick(picker) {
const end = new Date()
const start = new Date()
start.setTime(start.getTime() - 3600 * 1000 * 24 * 90)
picker.$emit('pick', [start, end])
}
}
]
},
// 日期范围
dateRange: [new Date(new Date().getTime() - 3600 * 1000 * 24 * 30), new Date()],
// 统计卡片数据
statisticsCards: [
{
title: '总发送量',
value: 12580,
icon: 'el-icon-s-promotion',
color: '#409EFF',
change: 15.8
},
{
title: '阅读率',
value: '78.3%',
icon: 'el-icon-view',
color: '#67C23A',
change: 5.2
},
{
title: '成功率',
value: '99.5%',
icon: 'el-icon-check',
color: '#E6A23C',
change: 0.3
},
{
title: '平均响应时间',
value: '25分钟',
icon: 'el-icon-time',
color: '#F56C6C',
change: -10.5
}
],
// 趋势图分组方式
trendGroupBy: 'day',
// 阅读率图分组方式
readRateGroupBy: 'type',
// 表格视图
tableView: 'daily',
// 分页信息
pagination: {
currentPage: 1,
pageSize: 10,
total: 0
},
// 图表实例
trendChart: null,
typeChart: null,
channelChart: null,
readRateChart: null,
// 日报表数据
dailyData: [],
// 类型报表数据
typeData: [],
// 渠道报表数据
channelData: []
}
},
mounted() {
this.initCharts()
this.fetchData()
window.addEventListener('resize', this.resizeCharts)
},
beforeDestroy() {
window.removeEventListener('resize', this.resizeCharts)
if (this.trendChart) this.trendChart.dispose()
if (this.typeChart) this.typeChart.dispose()
if (this.channelChart) this.channelChart.dispose()
if (this.readRateChart) this.readRateChart.dispose()
},
watch: {
trendGroupBy() {
this.updateTrendChart()
},
readRateGroupBy() {
this.updateReadRateChart()
}
},
methods: {
// 初始化图表
initCharts() {
this.trendChart = echarts.init(this.$refs.trendChart)
this.typeChart = echarts.init(this.$refs.typeChart)
this.channelChart = echarts.init(this.$refs.channelChart)
this.readRateChart = echarts.init(this.$refs.readRateChart)
this.updateTrendChart()
this.updateTypeChart()
this.updateChannelChart()
this.updateReadRateChart()
},
// 更新趋势图
updateTrendChart() {
// 模拟数据
let xAxisData = []
let sentData = []
let readData = []
if (this.trendGroupBy === 'day') {
xAxisData = ['4-1', '4-2', '4-3', '4-4', '4-5', '4-6', '4-7', '4-8', '4-9', '4-10']
sentData = [120, 132, 101, 134, 90, 230, 210, 182, 191, 234]
readData = [90, 110, 80, 100, 70, 180, 160, 140, 150, 180]
} else if (this.trendGroupBy === 'week') {
xAxisData = ['第1周', '第2周', '第3周', '第4周']
sentData = [520, 632, 701, 834]
readData = [410, 520, 580, 690]
} else {
xAxisData = ['1月', '2月', '3月', '4月']
sentData = [1200, 1300, 1400, 1800]
readData = [900, 1000, 1100, 1400]
}
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
legend: {
data: ['发送量', '已读量']
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: xAxisData
},
yAxis: {
type: 'value'
},
series: [
{
name: '发送量',
type: 'bar',
data: sentData,
itemStyle: {
color: '#409EFF'
}
},
{
name: '已读量',
type: 'bar',
data: readData,
itemStyle: {
color: '#67C23A'
}
}
]
}
this.trendChart.setOption(option)
},
// 更新类型分布图
updateTypeChart() {
const option = {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
legend: {
orient: 'vertical',
right: 10,
top: 'center',
data: ['系统通知', '快递通知', '活动通知', '其他通知']
},
series: [
{
name: '通知类型',
type: 'pie',
radius: ['50%', '70%'],
avoidLabelOverlap: false,
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: '18',
fontWeight: 'bold'
}
},
labelLine: {
show: false
},
data: [
{ value: 4500, name: '系统通知' },
{ value: 3500, name: '快递通知' },
{ value: 3000, name: '活动通知' },
{ value: 1500, name: '其他通知' }
],
itemStyle: {
normal: {
color: function(params) {
const colorList = ['#409EFF', '#67C23A', '#E6A23C', '#909399']
return colorList[params.dataIndex]
}
}
}
}
]
}
this.typeChart.setOption(option)
},
// 更新渠道分布图
updateChannelChart() {
const option = {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
legend: {
orient: 'vertical',
right: 10,
top: 'center',
data: ['站内信', '短信', '邮件', '推送']
},
series: [
{
name: '通知渠道',
type: 'pie',
radius: ['50%', '70%'],
avoidLabelOverlap: false,
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: '18',
fontWeight: 'bold'
}
},
labelLine: {
show: false
},
data: [
{ value: 5000, name: '站内信' },
{ value: 3000, name: '短信' },
{ value: 2500, name: '邮件' },
{ value: 2000, name: '推送' }
],
itemStyle: {
normal: {
color: function(params) {
const colorList = ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C']
return colorList[params.dataIndex]
}
}
}
}
]
}
this.channelChart.setOption(option)
},
// 更新阅读率图
updateReadRateChart() {
let xAxisData = []
let seriesData = []
if (this.readRateGroupBy === 'type') {
xAxisData = ['系统通知', '快递通知', '活动通知', '其他通知']
seriesData = [85, 92, 75, 65]
} else {
xAxisData = ['站内信', '短信', '邮件', '推送']
seriesData = [90, 70, 80, 60]
}
const option = {
tooltip: {
trigger: 'axis',
formatter: '{b}: {c}%'
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: xAxisData
},
yAxis: {
type: 'value',
min: 0,
max: 100,
axisLabel: {
formatter: '{value}%'
}
},
series: [
{
name: '阅读率',
type: 'bar',
data: seriesData,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#83bff6' },
{ offset: 0.5, color: '#188df0' },
{ offset: 1, color: '#188df0' }
])
},
emphasis: {
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#2378f7' },
{ offset: 0.7, color: '#2378f7' },
{ offset: 1, color: '#83bff6' }
])
}
}
}
]
}
this.readRateChart.setOption(option)
},
// 调整图表大小
resizeCharts() {
if (this.trendChart) this.trendChart.resize()
if (this.typeChart) this.typeChart.resize()
if (this.channelChart) this.channelChart.resize()
if (this.readRateChart) this.readRateChart.resize()
},
// 获取数据
fetchData() {
// 模拟获取数据
this.generateMockData()
},
// 生成模拟数据
generateMockData() {
// 生成日报表数据
this.dailyData = []
for (let i = 0; i < 30; i++) {
const date = new Date()
date.setDate(date.getDate() - i)
const dateStr = `${date.getMonth() + 1}-${date.getDate()}`
const sentCount = Math.floor(Math.random() * 200) + 100
const readCount = Math.floor(sentCount * (Math.random() * 0.3 + 0.6))
const readRate = Math.round((readCount / sentCount) * 100)
const systemCount = Math.floor(sentCount * 0.4)
const expressCount = Math.floor(sentCount * 0.3)
const activityCount = Math.floor(sentCount * 0.2)
const otherCount = sentCount - systemCount - expressCount - activityCount
const inAppCount = Math.floor(sentCount * 0.5)
const smsCount = Math.floor(sentCount * 0.2)
const emailCount = Math.floor(sentCount * 0.2)
const pushCount = sentCount - inAppCount - smsCount - emailCount
this.dailyData.push({
date: dateStr,
sentCount,
readCount,
readRate,
systemCount,
expressCount,
activityCount,
otherCount,
inAppCount,
smsCount,
emailCount,
pushCount
})
}
// 生成类型报表数据
this.typeData = [
{
typeId: NotificationType.SYSTEM,
type: '系统通知',
sentCount: 4500,
readCount: 3825,
readRate: 85,
userCount: 1200,
avgResponseTime: 30
},
{
typeId: NotificationType.EXPRESS,
type: '快递通知',
sentCount: 3500,
readCount: 3220,
readRate: 92,
userCount: 950,
avgResponseTime: 15
},
{
typeId: NotificationType.ACTIVITY,
type: '活动通知',
sentCount: 3000,
readCount: 2250,
readRate: 75,
userCount: 800,
avgResponseTime: 45
},
{
typeId: 4,
type: '其他通知',
sentCount: 1500,
readCount: 975,
readRate: 65,
userCount: 500,
avgResponseTime: 60
}
]
// 生成渠道报表数据
this.channelData = [
{
channelId: NotificationChannel.IN_APP,
channel: '站内信',
sentCount: 5000,
successCount: 5000,
successRate: 100,
readCount: 4500,
readRate: 90,
avgResponseTime: 35
},
{
channelId: NotificationChannel.SMS,
channel: '短信',
sentCount: 3000,
successCount: 2970,
successRate: 99,
readCount: 2100,
readRate: 70,
avgResponseTime: 20
},
{
channelId: NotificationChannel.EMAIL,
channel: '邮件',
sentCount: 2500,
successCount: 2450,
successRate: 98,
readCount: 2000,
readRate: 80,
avgResponseTime: 60
},
{
channelId: NotificationChannel.PUSH,
channel: '推送',
sentCount: 2000,
successCount: 1980,
successRate: 99,
readCount: 1200,
readRate: 60,
avgResponseTime: 25
}
]
this.pagination.total = this.dailyData.length
},
// 处理日期范围变化
handleDateRangeChange(val) {
if (val) {
// 实际项目中应该根据日期范围重新获取数据
this.fetchData()
}
},
// 刷新数据
refreshData() {
this.fetchData()
this.updateTrendChart()
this.updateTypeChart()
this.updateChannelChart()
this.updateReadRateChart()
},
// 导出数据
exportData() {
this.$message({
message: '数据导出成功',
type: 'success'
})
},
// 处理分页大小变化
handleSizeChange(size) {
this.pagination.pageSize = size
this.fetchData()
},
// 处理页码变化
handleCurrentChange(page) {
this.pagination.currentPage = page
this.fetchData()
},
// 获取通知类型对应的标签类型
getTypeTagType,
// 获取通知渠道对应的标签类型
getChannelTagType
}
}
</script>
<style lang="scss" scoped>
.header-operations {
float: right;
display: flex;
align-items: center;
.el-button {
margin-left: 10px;
}
}
.stat-card {
height: 120px;
margin-bottom: 20px;
.card-icon {
float: left;
font-size: 48px;
padding: 10px;
}
.card-content {
margin-left: 70px;
.card-title {
font-size: 14px;
color: #909399;
}
.card-value {
font-size: 24px;
font-weight: bold;
margin: 10px 0;
}
.card-footer {
font-size: 12px;
color: #909399;
i {
margin-left: 5px;
}
}
}
}
.chart-container {
padding: 10px;
}
.pagination-container {
margin-top: 20px;
text-align: center;
}
</style>
express-ui\src\views\notification\template-editor.vue
<template>
<div class="app-container">
<el-card class="box-card">
<div slot="header" class="clearfix">
<span>{{ isEdit ? '编辑通知模板' : '创建通知模板' }}</span>
<el-button style="float: right; padding: 3px 0" type="text" @click="goBack">返回列表</el-button>
</div>
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
<el-form-item label="模板名称" prop="name">
<el-input v-model="form.name" placeholder="请输入模板名称"></el-input>
</el-form-item>
<el-form-item label="模板代码" prop="code">
<el-input v-model="form.code" placeholder="请输入模板代码(唯一标识符)"></el-input>
</el-form-item>
<el-form-item label="通知类型" prop="type">
<el-select v-model="form.type" placeholder="请选择通知类型">
<el-option :label="'系统通知'" :value="1"></el-option>
<el-option :label="'快递通知'" :value="2"></el-option>
<el-option :label="'活动通知'" :value="3"></el-option>
</el-select>
</el-form-item>
<el-form-item label="适用渠道" prop="channels">
<el-checkbox-group v-model="form.channels">
<el-checkbox :label="1">站内信</el-checkbox>
<el-checkbox :label="2">短信</el-checkbox>
<el-checkbox :label="3">邮件</el-checkbox>
<el-checkbox :label="4">推送</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="模板标题" prop="title">
<el-input v-model="form.title" placeholder="请输入模板标题">
<template slot="append">
<el-button @click="showVariableSelector('title')">插入变量</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="模板内容" prop="content">
<el-tabs v-model="activeTab" type="card">
<el-tab-pane label="编辑器" name="editor">
<el-input
type="textarea"
v-model="form.content"
:rows="10"
placeholder="请输入模板内容,可使用 {{变量名}} 作为占位符">
</el-input>
<div class="editor-toolbar">
<el-button size="small" @click="showVariableSelector('content')">插入变量</el-button>
<el-button size="small" @click="formatContent">格式化内容</el-button>
</div>
</el-tab-pane>
<el-tab-pane label="预览" name="preview">
<div class="preview-container">
<div class="preview-title">{{ previewTitle }}</div>
<div class="preview-content" v-html="previewContent"></div>
</div>
</el-tab-pane>
</el-tabs>
</el-form-item>
<el-form-item label="变量列表">
<el-table :data="variables" style="width: 100%" border>
<el-table-column prop="name" label="变量名" width="180"></el-table-column>
<el-table-column prop="description" label="描述">
<template slot-scope="scope">
<el-input v-model="scope.row.description" placeholder="请输入变量描述"></el-input>
</template>
</el-table-column>
<el-table-column prop="defaultValue" label="默认值" width="180">
<template slot-scope="scope">
<el-input v-model="scope.row.defaultValue" placeholder="请输入默认值"></el-input>
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center">
<template slot-scope="scope">
<el-button type="text" size="small" @click="removeVariable(scope.$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<el-button type="primary" size="small" @click="addVariable">添加变量</el-button>
</div>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input type="textarea" v-model="form.remark" :rows="3" placeholder="请输入备注信息"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm">保存</el-button>
<el-button @click="resetForm">重置</el-button>
<el-button type="success" @click="testTemplate">测试模板</el-button>
</el-form-item>
</el-form>
<!-- 变量选择器对话框 -->
<el-dialog title="插入变量" :visible.sync="variableSelectorVisible" width="500px" append-to-body>
<el-form :inline="true" class="variable-form">
<el-form-item label="变量名">
<el-select v-model="selectedVariable" placeholder="选择变量" filterable allow-create>
<el-option
v-for="item in variables"
:key="item.name"
:label="item.name"
:value="item.name">
<span>{{ item.name }}</span>
<span style="float: right; color: #8492a6; font-size: 13px">{{ item.description }}</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="insertVariable">插入</el-button>
</el-form-item>
</el-form>
<div class="variable-list">
<p>常用变量:</p>
<el-tag
v-for="(item, index) in commonVariables"
:key="index"
@click="quickInsertVariable(item)"
class="variable-tag">
{{ item }}
</el-tag>
</div>
</el-dialog>
<!-- 测试模板对话框 -->
<el-dialog title="测试模板" :visible.sync="testDialogVisible" width="600px" append-to-body>
<el-form label-width="100px">
<el-form-item
v-for="(variable, index) in testVariables"
:key="index"
:label="variable.name">
<el-input v-model="variable.value" :placeholder="'请输入' + variable.name + '的值'"></el-input>
</el-form-item>
</el-form>
<div class="test-preview">
<div class="preview-title">
<h4>预览效果</h4>
</div>
<div class="preview-title">{{ testTitle }}</div>
<div class="preview-content" v-html="testContent"></div>
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="testDialogVisible = false">关闭</el-button>
<el-button type="primary" @click="refreshTestPreview">刷新预览</el-button>
</div>
</el-dialog>
</el-card>
</div>
</template>
<script>
import { extractTemplateVariables, replaceTemplateVariables, formatContent } from '@/utils/notification'
export default {
name: 'NotificationTemplateEditor',
data() {
return {
isEdit: false,
templateId: null,
activeTab: 'editor',
form: {
name: '',
code: '',
type: 1,
channels: [1],
title: '',
content: '',
status: 1,
remark: ''
},
rules: {
name: [
{ required: true, message: '请输入模板名称', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
],
code: [
{ required: true, message: '请输入模板代码', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_]+$/, message: '只能包含字母、数字和下划线', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择通知类型', trigger: 'change' }
],
channels: [
{ type: 'array', required: true, message: '请至少选择一个适用渠道', trigger: 'change' }
],
title: [
{ required: true, message: '请输入模板标题', trigger: 'blur' }
],
content: [
{ required: true, message: '请输入模板内容', trigger: 'blur' }
]
},
variables: [],
commonVariables: ['userName', 'userId', 'date', 'time', 'expressCode', 'expressCompany', 'activityName', 'location'],
variableSelectorVisible: false,
currentField: '',
selectedVariable: '',
testDialogVisible: false,
testVariables: []
}
},
computed: {
previewTitle() {
return this.form.title || '模板标题预览'
},
previewContent() {
return this.form.content ? formatContent(this.form.content) : '模板内容预览'
},
testTitle() {
return replaceTemplateVariables(this.form.title, this.testVariables) || '模板标题预览'
},
testContent() {
const content = replaceTemplateVariables(this.form.content, this.testVariables) || '模板内容预览'
return formatContent(content)
}
},
created() {
// 检查是否是编辑模式
const id = this.$route.params.id
if (id) {
this.isEdit = true
this.templateId = id
this.getTemplateDetail(id)
}
},
watch: {
'form.content': function(val) {
this.updateVariables()
},
'form.title': function(val) {
this.updateVariables()
}
},
methods: {
// 获取模板详情
getTemplateDetail(id) {
// 实际项目中应该从API获取模板详情
// getNotificationTemplate(id).then(response => {
// this.form = response.data
// this.updateVariables()
// })
// 模拟获取数据
setTimeout(() => {
this.form = {
name: '快递到达通知',
code: 'express_arrival',
type: 2,
channels: [1, 2, 4],
title: '您的快递已到达【{{location}}】',
content: '尊敬的 {{userName}},\n\n您的快递({{expressCompany}} - {{expressCode}})已到达【{{location}}】,请凭取件码 {{pickupCode}} 及时领取。\n\n取件时间:{{startTime}} - {{endTime}}\n\n如有问题,请联系快递员:{{courierName}}({{courierPhone}})',
status: 1,
remark: '用于通知用户快递已到达指定位置,提醒及时取件。'
}
this.updateVariables()
}, 500)
},
// 更新变量列表
updateVariables() {
const titleVariables = extractTemplateVariables(this.form.title)
const contentVariables = extractTemplateVariables(this.form.content)
// 合并变量
const allVariables = [...titleVariables, ...contentVariables]
// 去重
const uniqueVariables = allVariables.filter((v, i, a) => a.findIndex(t => t.name === v.name) === i)
// 保留已有变量的描述和默认值
const newVariables = uniqueVariables.map(variable => {
const existingVariable = this.variables.find(v => v.name === variable.name)
return {
name: variable.name,
placeholder: variable.placeholder,
description: existingVariable ? existingVariable.description : '',
defaultValue: existingVariable ? existingVariable.defaultValue : ''
}
})
this.variables = newVariables
},
// 添加变量
addVariable() {
this.variables.push({
name: '',
placeholder: '',
description: '',
defaultValue: ''
})
},
// 移除变量
removeVariable(index) {
this.variables.splice(index, 1)
},
// 显示变量选择器
showVariableSelector(field) {
this.currentField = field
this.selectedVariable = ''
this.variableSelectorVisible = true
},
// 插入变量
insertVariable() {
if (!this.selectedVariable) return
const variable = `{{${this.selectedVariable}}}`
if (this.currentField === 'title') {
this.form.title += variable
} else if (this.currentField === 'content') {
// 获取光标位置并插入变量
const textarea = document.querySelector('textarea')
if (textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
this.form.content = this.form.content.substring(0, start) + variable + this.form.content.substring(end)
} else {
this.form.content += variable
}
}
// 添加到变量列表(如果不存在)
if (!this.variables.find(v => v.name === this.selectedVariable)) {
this.variables.push({
name: this.selectedVariable,
placeholder: `{{${this.selectedVariable}}}`,
description: '',
defaultValue: ''
})
}
this.variableSelectorVisible = false
},
// 快速插入变量
quickInsertVariable(variable) {
this.selectedVariable = variable
this.insertVariable()
},
// 格式化内容
formatContent() {
this.form.content = this.form.content.trim()
},
// 测试模板
testTemplate() {
this.testVariables = this.variables.map(variable => ({
name: variable.name,
placeholder: variable.placeholder,
value: variable.defaultValue || ''
}))
this.testDialogVisible = true
},
// 刷新测试预览
refreshTestPreview() {
// 不需要做任何事情,因为计算属性会自动更新
},
// 提交表单
submitForm() {
this.$refs.form.validate(valid => {
if (valid) {
const data = {
...this.form,
variables: this.variables
}
if (this.isEdit) {
// 更新模板
// updateNotificationTemplate(this.templateId, data).then(response => {
// this.$message.success('模板更新成功')
// this.goBack()
// })
// 模拟更新成功
this.$message({
message: '模板更新成功',
type: 'success'
})
this.goBack()
} else {
// 创建模板
// createNotificationTemplate(data).then(response => {
// this.$message.success('模板创建成功')
// this.goBack()
// })
// 模拟创建成功
this.$message({
message: '模板创建成功',
type: 'success'
})
this.goBack()
}
}
})
},
// 重置表单
resetForm() {
this.$refs.form.resetFields()
if (this.isEdit) {
this.getTemplateDetail(this.templateId)
} else {
this.variables = []
}
},
// 返回列表
goBack() {
this.$router.push('/notification/template')
}
}
}
</script>
<style lang="scss" scoped>
.editor-toolbar {
margin-top: 10px;
text-align: right;
}
.table-footer {
margin-top: 10px;
text-align: right;
}
.preview-container {
padding: 20px;
border: 1px solid #dcdfe6;
border-radius: 4px;
min-height: 200px;
.preview-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #ebeef5;
}
.preview-content {
white-space: pre-wrap;
line-height: 1.5;
}
}
.variable-list {
margin-top: 20px;
p {
margin-bottom: 10px;
}
.variable-tag {
margin-right: 10px;
margin-bottom: 10px;
cursor: pointer;
}
}
.test-preview {
margin-top: 20px;
padding: 15px;
border: 1px solid #dcdfe6;
border-radius: 4px;
.preview-title {
margin-bottom: 15px;
h4 {
margin: 0 0 10px 0;
padding-bottom: 10px;
border-bottom: 1px dashed #ebeef5;
}
}
.preview-content {
white-space: pre-wrap;
line-height: 1.5;
}
}
</style>
express-ui\src\views\notification\template.vue
<template>
<div class="app-container">
<el-card class="box-card">
<div slot="header" class="clearfix">
<span>通知模板管理</span>
<el-button style="float: right; padding: 3px 0" type="text" @click="handleAdd">新增模板</el-button>
</div>
<!-- 搜索区域 -->
<el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="68px">
<el-form-item label="模板名称" prop="name">
<el-input v-model="queryParams.name" placeholder="请输入模板名称" clearable size="small" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="模板类型" prop="type">
<el-select v-model="queryParams.type" placeholder="模板类型" clearable size="small">
<el-option v-for="dict in typeOptions" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<!-- 表格工具栏 -->
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="success" plain icon="el-icon-edit" size="mini" :disabled="single" @click="handleUpdate">修改</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="danger" plain icon="el-icon-delete" size="mini" :disabled="multiple" @click="handleDelete">删除</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
<!-- 数据表格 -->
<el-table v-loading="loading" :data="templateList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="ID" align="center" prop="id" width="80" />
<el-table-column label="模板名称" align="center" prop="name" :show-overflow-tooltip="true" />
<el-table-column label="模板类型" align="center" prop="type">
<template slot-scope="scope">
<el-tag :type="scope.row.type === 1 ? 'primary' : scope.row.type === 2 ? 'success' : 'info'">
{{ typeFormat(scope.row) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="适用渠道" align="center" prop="channel">
<template slot-scope="scope">
<el-tag v-if="scope.row.channel.includes(1)" type="primary" class="channel-tag">站内信</el-tag>
<el-tag v-if="scope.row.channel.includes(2)" type="success" class="channel-tag">短信</el-tag>
<el-tag v-if="scope.row.channel.includes(3)" type="warning" class="channel-tag">邮件</el-tag>
<el-tag v-if="scope.row.channel.includes(4)" type="danger" class="channel-tag">推送</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" align="center" prop="createTime" width="160">
<template slot-scope="scope">
<span>{{ scope.row.createTime }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button size="mini" type="text" icon="el-icon-view" @click="handleView(scope.row)">查看</el-button>
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)">修改</el-button>
<el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<!-- 添加或修改通知模板对话框 -->
<el-dialog :title="title" :visible.sync="open" width="780px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
<el-row>
<el-col :span="12">
<el-form-item label="模板名称" prop="name">
<el-input v-model="form.name" placeholder="请输入模板名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="模板类型" prop="type">
<el-select v-model="form.type" placeholder="请选择模板类型">
<el-option v-for="dict in typeOptions" :key="dict.value" :label="dict.label" :value="parseInt(dict.value)" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24">
<el-form-item label="适用渠道" prop="channel">
<el-checkbox-group v-model="form.channel">
<el-checkbox :label="1">站内信</el-checkbox>
<el-checkbox :label="2">短信</el-checkbox>
<el-checkbox :label="3">邮件</el-checkbox>
<el-checkbox :label="4">推送</el-checkbox>
</el-checkbox-group>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24">
<el-form-item label="模板内容" prop="content">
<el-input v-model="form.content" type="textarea" placeholder="请输入模板内容" :rows="8">
<template slot="prepend">
<div class="template-variables">
<p>可用变量:</p>
<p>{{userName}} - 用户名</p>
<p>{{expressCode}} - 快递单号</p>
<p>{{expressCompany}} - 快递公司</p>
<p>{{pickupCode}} - 取件码</p>
<p>{{deliveryTime}} - 送达时间</p>
<p>{{deliveryLocation}} - 送达地点</p>
</div>
</template>
</el-input>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24">
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" placeholder="请输入备注" :rows="3" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</el-dialog>
<!-- 通知模板详情对话框 -->
<el-dialog title="模板详情" :visible.sync="openView" width="700px" append-to-body>
<el-descriptions :column="2" border>
<el-descriptions-item label="模板名称">{{ form.name }}</el-descriptions-item>
<el-descriptions-item label="模板类型">{{ typeFormat(form) }}</el-descriptions-item>
<el-descriptions-item label="适用渠道" :span="2">
<el-tag v-if="form.channel && form.channel.includes(1)" type="primary" class="channel-tag">站内信</el-tag>
<el-tag v-if="form.channel && form.channel.includes(2)" type="success" class="channel-tag">短信</el-tag>
<el-tag v-if="form.channel && form.channel.includes(3)" type="warning" class="channel-tag">邮件</el-tag>
<el-tag v-if="form.channel && form.channel.includes(4)" type="danger" class="channel-tag">推送</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间" :span="2">{{ form.createTime }}</el-descriptions-item>
<el-descriptions-item label="模板内容" :span="2">
<div style="white-space: pre-wrap;">{{ form.content }}</div>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
<div style="white-space: pre-wrap;">{{ form.remark }}</div>
</el-descriptions-item>
</el-descriptions>
<div slot="footer" class="dialog-footer">
<el-button @click="openView = false">关 闭</el-button>
</div>
</el-dialog>
</el-card>
</div>
</template>
<script>
import { listTemplate, getTemplate, delTemplate, addTemplate, updateTemplate } from '@/api/notification'
export default {
name: 'NotificationTemplate',
data() {
return {
// 遮罩层
loading: true,
// 选中数组
ids: [],
// 非单个禁用
single: true,
// 非多个禁用
multiple: true,
// 显示搜索条件
showSearch: true,
// 总条数
total: 0,
// 模板表格数据
templateList: [],
// 弹出层标题
title: '',
// 是否显示弹出层
open: false,
// 是否显示详情弹出层
openView: false,
// 查询参数
queryParams: {
pageNum: 1,
pageSize: 10,
name: undefined,
type: undefined
},
// 表单参数
form: {},
// 表单校验
rules: {
name: [
{ required: true, message: '模板名称不能为空', trigger: 'blur' }
],
content: [
{ required: true, message: '模板内容不能为空', trigger: 'blur' }
],
type: [
{ required: true, message: '模板类型不能为空', trigger: 'change' }
],
channel: [
{ required: true, message: '适用渠道不能为空', trigger: 'change', type: 'array' }
]
},
// 模板类型选项
typeOptions: [
{ value: '1', label: '系统通知' },
{ value: '2', label: '快递通知' },
{ value: '3', label: '活动通知' }
]
}
},
created() {
this.getList()
},
methods: {
/** 查询模板列表 */
getList() {
this.loading = true
// 模拟数据,实际项目中应该调用API
this.templateList = [
{
id: 1,
name: '系统维护通知模板',
type: 1,
channel: [1, 3],
content: '尊敬的{{userName}},系统将于{{startTime}}至{{endTime}}进行系统维护,届时系统将暂停服务,请提前做好准备。',
remark: '用于系统维护时通知用户',
createTime: '2023-04-20 10:00:00'
},
{
id: 2,
name: '快递到达通知模板',
type: 2,
channel: [1, 2, 4],
content: '您好,{{userName}},您的快递({{expressCompany}} {{expressCode}})已到达校园快递站,取件码:{{pickupCode}},请及时前往领取。',
remark: '用于快递到达时通知用户',
createTime: '2023-04-21 14:30:00'
},
{
id: 3,
name: '活动邀请模板',
type: 3,
channel: [1, 3],
content: '亲爱的{{userName}},诚邀您参加{{activityName}}活动,时间:{{activityTime}},地点:{{activityLocation}},期待您的参与!',
remark: '用于活动邀请',
createTime: '2023-04-22 09:15:00'
}
]
this.total = this.templateList.length
this.loading = false
// 实际项目中的API调用
// listTemplate(this.queryParams).then(response => {
// this.templateList = response.data.rows
// this.total = response.data.total
// this.loading = false
// })
},
// 模板类型字典翻译
typeFormat(row) {
return this.selectDictLabel(this.typeOptions, row.type)
},
// 字典翻译
selectDictLabel(datas, value) {
const actions = []
Object.keys(datas).some(key => {
if (datas[key].value == value) {
actions.push(datas[key].label)
return true
}
})
return actions.join('')
},
/** 搜索按钮操作 */
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
/** 重置按钮操作 */
resetQuery() {
this.resetForm('queryForm')
this.handleQuery()
},
/** 新增按钮操作 */
handleAdd() {
this.reset()
this.open = true
this.title = '添加模板'
},
/** 修改按钮操作 */
handleUpdate(row) {
this.reset()
const id = row.id || this.ids[0]
// 实际项目中应该调用API获取详情
// getTemplate(id).then(response => {
// this.form = response.data
// this.open = true
// this.title = '修改模板'
// })
// 模拟数据
this.form = JSON.parse(JSON.stringify(row))
this.open = true
this.title = '修改模板'
},
/** 查看详情按钮操作 */
handleView(row) {
this.reset()
const id = row.id
// 实际项目中应该调用API获取详情
// getTemplate(id).then(response => {
// this.form = response.data
// this.openView = true
// })
// 模拟数据
this.form = JSON.parse(JSON.stringify(row))
this.openView = true
},
/** 提交按钮 */
submitForm() {
this.$refs['form'].validate(valid => {
if (valid) {
if (this.form.id) {
// updateTemplate(this.form).then(response => {
// this.$modal.msgSuccess('修改成功')
// this.open = false
// this.getList()
// })
this.$message.success('修改成功')
this.open = false
this.getList()
} else {
// addTemplate(this.form).then(response => {
// this.$modal.msgSuccess('新增成功')
// this.open = false
// this.getList()
// })
this.$message.success('新增成功')
this.open = false
this.getList()
}
}
})
},
/** 删除按钮操作 */
handleDelete(row) {
const ids = row.id || this.ids
this.$confirm('是否确认删除模板编号为"' + ids + '"的数据项?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
// delTemplate(ids).then(() => {
// this.getList()
// this.$modal.msgSuccess('删除成功')
// })
this.$message.success('删除成功')
this.getList()
}).catch(() => {})
},
// 多选框选中数据
handleSelectionChange(selection) {
this.ids = selection.map(item => item.id)
this.single = selection.length !== 1
this.multiple = !selection.length
},
/** 重置表单数据 */
reset() {
this.form = {
id: undefined,
name: undefined,
content: undefined,
type: 1,
channel: [1],
remark: undefined
}
this.resetForm('form')
},
/** 取消按钮 */
cancel() {
this.open = false
this.reset()
}
}
}
</script>
<style lang="scss" scoped>
.channel-tag {
margin-right: 5px;
}
.template-variables {
padding: 5px 10px;
background-color: #f5f7fa;
border-radius: 4px;
margin-bottom: 10px;
p {
margin: 5px 0;
font-size: 12px;
color: #606266;
}
}
</style>
user-service\pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.campus.express</groupId>
<artifactId>user-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>user-service</name>
<description>User Service for Campus Express Management System</description>
<properties>
<java.version>11</java.version>
<spring-cloud.version>2021.0.3</spring-cloud.version>
<jjwt.version>0.11.5</jjwt.version>
<mapstruct.version>1.5.3.Final</mapstruct.version>
<lombok.version>1.18.24</lombok.version>
</properties>
<dependencies>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Spring Cloud -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!-- Database -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- Lombok & MapStruct -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>
user-service\src\main\java\com\campus\express\user\UserServiceApplication.java
package com.campus.express.user;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
@SpringBootApplication
@EnableEurekaClient
public class UserServiceApplication {
public static void main(String[] args) {
SpringApplication.run(UserServiceApplication.class, args);
}
}
user-service\src\main\java\com\campus\express\user\config\CorsConfig.java
package com.campus.express.user.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.addAllowedOrigin("*");
config.setAllowCredentials(true);
config.setMaxAge(3600L);
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
user-service\src\main\java\com\campus\express\user\config\DataInitializer.java
package com.campus.express.user.config;
import com.campus.express.user.model.Role;
import com.campus.express.user.model.User;
import com.campus.express.user.repository.RoleRepository;
import com.campus.express.user.repository.UserRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import java.util.HashSet;
import java.util.Set;
@Slf4j
@Component
public class DataInitializer implements CommandLineRunner {
@Autowired
private RoleRepository roleRepository;
@Autowired
private UserRepository userRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public void run(String... args) throws Exception {
log.info("开始初始化基础数据...");
initRoles();
initAdminUser();
log.info("基础数据初始化完成");
}
private void initRoles() {
if (roleRepository.count() > 0) {
log.info("角色数据已存在,跳过初始化");
return;
}
Role adminRole = new Role();
adminRole.setName(Role.RoleName.ROLE_ADMIN);
Role staffRole = new Role();
staffRole.setName(Role.RoleName.ROLE_STAFF);
Role studentRole = new Role();
studentRole.setName(Role.RoleName.ROLE_STUDENT);
Role courierRole = new Role();
courierRole.setName(Role.RoleName.ROLE_COURIER);
roleRepository.save(adminRole);
roleRepository.save(staffRole);
roleRepository.save(studentRole);
roleRepository.save(courierRole);
log.info("角色初始化完成");
}
private void initAdminUser() {
if (userRepository.existsByUsername("admin")) {
log.info("管理员账号已存在,跳过初始化");
return;
}
Role adminRole = roleRepository.findByName(Role.RoleName.ROLE_ADMIN)
.orElseThrow(() -> new RuntimeException("管理员角色不存在"));
User adminUser = User.builder()
.username("admin")
.password(passwordEncoder.encode("admin123"))
.realName("系统管理员")
.phone("13800000000")
.email("admin@campus.com")
.userType(0)
.status(1)
.build();
Set<Role> roles = new HashSet<>();
roles.add(adminRole);
adminUser.setRoles(roles);
userRepository.save(adminUser);
log.info("管理员账号初始化完成");
}
}
user-service\src\main\java\com\campus\express\user\config\JwtAuthenticationEntryPoint.java
package com.campus.express.user.config;
import com.campus.express.user.dto.ApiResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.OutputStream;
@Slf4j
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
log.error("Unauthorized error: {}", authException.getMessage());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
ApiResponse<?> apiResponse = ApiResponse.unauthorized("未授权:" + authException.getMessage());
OutputStream outputStream = response.getOutputStream();
new ObjectMapper().writeValue(outputStream, apiResponse);
outputStream.flush();
}
}
user-service\src\main\java\com\campus\express\user\config\JwtAuthenticationFilter.java
package com.campus.express.user.config;
import com.campus.express.user.util.JwtUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtUtils jwtUtils;
@Autowired
private UserDetailsService userDetailsService;
@Value("${jwt.header}")
private String headerName;
@Value("${jwt.prefix}")
private String tokenPrefix;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
String jwt = parseJwt(request);
if (jwt != null && jwtUtils.validateJwtToken(jwt)) {
String username = jwtUtils.getUsernameFromJwtToken(jwt);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
log.error("Cannot set user authentication: {}", e.getMessage());
}
filterChain.doFilter(request, response);
}
private String parseJwt(HttpServletRequest request) {
String headerAuth = request.getHeader(headerName);
if (StringUtils.hasText(headerAuth) && headerAuth.startsWith(tokenPrefix + " ")) {
return headerAuth.substring(tokenPrefix.length() + 1);
}
return null;
}
}
user-service\src\main\java\com\campus\express\user\config\SecurityConfig.java
package com.campus.express.user.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(
securedEnabled = true,
jsr250Enabled = true,
prePostEnabled = true
)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private JwtAuthenticationEntryPoint unauthorizedHandler;
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter();
}
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable()
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeRequests()
.antMatchers("/auth/**").permitAll()
.antMatchers("/public/**").permitAll()
.antMatchers("/actuator/**").permitAll()
.anyRequest().authenticated();
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
user-service\src\main\java\com\campus\express\user\controller\AuthController.java
package com.campus.express.user.controller;
import com.campus.express.user.dto.ApiResponse;
import com.campus.express.user.dto.JwtResponse;
import com.campus.express.user.dto.LoginRequest;
import com.campus.express.user.dto.SignupRequest;
import com.campus.express.user.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@Slf4j
@RestController
@RequestMapping("/auth")
@CrossOrigin(origins = "*", maxAge = 3600)
public class AuthController {
@Autowired
private UserService userService;
@PostMapping("/signup")
public ResponseEntity<?> registerUser(@Valid @RequestBody SignupRequest signupRequest) {
log.info("收到用户注册请求: {}", signupRequest.getUsername());
return ResponseEntity.ok(ApiResponse.success("用户注册成功", userService.registerUser(signupRequest)));
}
@PostMapping("/login")
public ResponseEntity<?> authenticateUser(@Valid @RequestBody LoginRequest loginRequest) {
log.info("收到用户登录请求: {}", loginRequest.getUsername());
JwtResponse jwtResponse = userService.authenticateUser(loginRequest);
return ResponseEntity.ok(ApiResponse.success("登录成功", jwtResponse));
}
}
user-service\src\main\java\com\campus\express\user\controller\RoleController.java
package com.campus.express.user.controller;
import com.campus.express.user.dto.ApiResponse;
import com.campus.express.user.model.Role;
import com.campus.express.user.repository.RoleRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/roles")
@CrossOrigin(origins = "*", maxAge = 3600)
public class RoleController {
@Autowired
private RoleRepository roleRepository;
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> getAllRoles() {
List<Role> roles = roleRepository.findAll();
return ResponseEntity.ok(ApiResponse.success(roles));
}
}
user-service\src\main\java\com\campus\express\user\controller\UserController.java
package com.campus.express.user.controller;
import com.campus.express.user.dto.ApiResponse;
import com.campus.express.user.dto.UserDTO;
import com.campus.express.user.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/users")
@CrossOrigin(origins = "*", maxAge = 3600)
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/me")
public ResponseEntity<?> getCurrentUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
UserDTO userDTO = userService.getUserByUsername(userDetails.getUsername());
return ResponseEntity.ok(ApiResponse.success(userDTO));
}
@GetMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or @securityService.isCurrentUser(#id)")
public ResponseEntity<?> getUserById(@PathVariable Long id) {
UserDTO userDTO = userService.getUserById(id);
return ResponseEntity.ok(ApiResponse.success(userDTO));
}
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> getUserList(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "id") String sort,
@RequestParam(required = false) Integer userType,
@RequestParam(required = false) String keyword) {
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, sort));
Page<UserDTO> userPage = userService.getUserList(userType, keyword, pageable);
Map<String, Object> response = new HashMap<>();
response.put("content", userPage.getContent());
response.put("currentPage", userPage.getNumber());
response.put("totalItems", userPage.getTotalElements());
response.put("totalPages", userPage.getTotalPages());
return ResponseEntity.ok(ApiResponse.success(response));
}
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or @securityService.isCurrentUser(#id)")
public ResponseEntity<?> updateUser(@PathVariable Long id, @Valid @RequestBody UserDTO userDTO) {
UserDTO updatedUser = userService.updateUser(id, userDTO);
return ResponseEntity.ok(ApiResponse.success("用户信息更新成功", updatedUser));
}
@PutMapping("/{id}/status")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> updateUserStatus(@PathVariable Long id, @RequestParam Integer status) {
UserDTO updatedUser = userService.updateUserStatus(id, status);
return ResponseEntity.ok(ApiResponse.success("用户状态更新成功", updatedUser));
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return ResponseEntity.ok(ApiResponse.success("用户删除成功"));
}
@PutMapping("/{id}/password")
@PreAuthorize("@securityService.isCurrentUser(#id)")
public ResponseEntity<?> changePassword(
@PathVariable Long id,
@RequestParam String oldPassword,
@RequestParam String newPassword) {
boolean result = userService.changePassword(id, oldPassword, newPassword);
return ResponseEntity.ok(ApiResponse.success("密码修改成功"));
}
}
user-service\src\main\java\com\campus\express\user\dto\ApiResponse.java
package com.campus.express.user.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {
private Integer code;
private String message;
private T data;
private LocalDateTime timestamp;
public static <T> ApiResponse<T> success(T data) {
return ApiResponse.<T>builder()
.code(200)
.message("操作成功")
.data(data)
.timestamp(LocalDateTime.now())
.build();
}
public static <T> ApiResponse<T> success() {
return ApiResponse.<T>builder()
.code(200)
.message("操作成功")
.timestamp(LocalDateTime.now())
.build();
}
public static <T> ApiResponse<T> success(String message, T data) {
return ApiResponse.<T>builder()
.code(200)
.message(message)
.data(data)
.timestamp(LocalDateTime.now())
.build();
}
public static <T> ApiResponse<T> error(Integer code, String message) {
return ApiResponse.<T>builder()
.code(code)
.message(message)
.timestamp(LocalDateTime.now())
.build();
}
public static <T> ApiResponse<T> badRequest(String message) {
return error(400, message);
}
public static <T> ApiResponse<T> unauthorized(String message) {
return error(401, message);
}
public static <T> ApiResponse<T> forbidden(String message) {
return error(403, message);
}
public static <T> ApiResponse<T> notFound(String message) {
return error(404, message);
}
public static <T> ApiResponse<T> serverError(String message) {
return error(500, message);
}
}
user-service\src\main\java\com\campus\express\user\dto\JwtResponse.java
package com.campus.express.user.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class JwtResponse {
private String token;
private String type = "Bearer";
private Long id;
private String username;
private String realName;
private String phone;
private Integer userType;
private List<String> roles;
public JwtResponse(String token, Long id, String username, String realName, String phone, Integer userType, List<String> roles) {
this.token = token;
this.id = id;
this.username = username;
this.realName = realName;
this.phone = phone;
this.userType = userType;
this.roles = roles;
}
}
user-service\src\main\java\com\campus\express\user\dto\LoginRequest.java
package com.campus.express.user.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.validation.constraints.NotBlank;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginRequest {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
}
user-service\src\main\java\com\campus\express\user\dto\PasswordChangeRequest.java
package com.campus.express.user.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PasswordChangeRequest {
@NotBlank(message = "旧密码不能为空")
private String oldPassword;
@NotBlank(message = "新密码不能为空")
@Size(min = 6, max = 40, message = "新密码长度必须在6-40个字符之间")
private String newPassword;
@NotBlank(message = "确认密码不能为空")
private String confirmPassword;
}
user-service\src\main\java\com\campus\express\user\dto\SignupRequest.java
package com.campus.express.user.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
import java.util.Set;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SignupRequest {
@NotBlank(message = "用户名不能为空")
@Size(min = 3, max = 50, message = "用户名长度必须在3-50个字符之间")
private String username;
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 40, message = "密码长度必须在6-40个字符之间")
private String password;
@NotBlank(message = "真实姓名不能为空")
@Size(max = 50, message = "真实姓名长度不能超过50个字符")
private String realName;
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
@Email(message = "邮箱格式不正确")
private String email;
private Integer userType;
private String studentId;
private String department;
private String dormitory;
private Set<String> roles;
}
user-service\src\main\java\com\campus\express\user\dto\UserDTO.java
package com.campus.express.user.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.Set;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserDTO {
private Long id;
private String username;
private String realName;
private String phone;
private String email;
private Integer userType;
private String studentId;
private String department;
private String dormitory;
private String avatar;
private Integer status;
private Set<String> roles;
private LocalDateTime createdTime;
private LocalDateTime updatedTime;
}
user-service\src\main\java\com\campus\express\user\exception\BadRequestException.java
package com.campus.express.user.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.BAD_REQUEST)
public class BadRequestException extends RuntimeException {
public BadRequestException(String message) {
super(message);
}
public BadRequestException(String message, Throwable cause) {
super(message, cause);
}
}
user-service\src\main\java\com\campus\express\user\exception\GlobalExceptionHandler.java
package com.campus.express.user.exception;
import com.campus.express.user.dto.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import javax.validation.ConstraintViolationException;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ApiResponse<?> handleResourceNotFoundException(ResourceNotFoundException ex, WebRequest request) {
log.error("Resource not found: {}", ex.getMessage());
return ApiResponse.notFound(ex.getMessage());
}
@ExceptionHandler(BadRequestException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<?> handleBadRequestException(BadRequestException ex, WebRequest request) {
log.error("Bad request: {}", ex.getMessage());
return ApiResponse.badRequest(ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<?> handleValidationExceptions(MethodArgumentNotValidException ex, WebRequest request) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach((error) -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
log.error("Validation error: {}", errors);
return ApiResponse.badRequest("参数校验失败").setData(errors);
}
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<?> handleConstraintViolationException(ConstraintViolationException ex, WebRequest request) {
log.error("Constraint violation: {}", ex.getMessage());
return ApiResponse.badRequest(ex.getMessage());
}
@ExceptionHandler(AuthenticationException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public ApiResponse<?> handleAuthenticationException(AuthenticationException ex, WebRequest request) {
log.error("Authentication error: {}", ex.getMessage());
return ApiResponse.unauthorized("认证失败:" + ex.getMessage());
}
@ExceptionHandler(BadCredentialsException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public ApiResponse<?> handleBadCredentialsException(BadCredentialsException ex, WebRequest request) {
log.error("Bad credentials: {}", ex.getMessage());
return ApiResponse.unauthorized("用户名或密码错误");
}
@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public ApiResponse<?> handleAccessDeniedException(AccessDeniedException ex, WebRequest request) {
log.error("Access denied: {}", ex.getMessage());
return ApiResponse.forbidden("没有权限访问此资源");
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<?> handleAllUncaughtException(Exception ex, WebRequest request) {
log.error("Internal server error: ", ex);
return ApiResponse.serverError("服务器内部错误:" + ex.getMessage());
}
}
user-service\src\main\java\com\campus\express\user\exception\ResourceNotFoundException.java
package com.campus.express.user.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {
private String resourceName;
private String fieldName;
private Object fieldValue;
public ResourceNotFoundException(String resourceName, String fieldName, Object fieldValue) {
super(String.format("%s not found with %s : '%s'", resourceName, fieldName, fieldValue));
this.resourceName = resourceName;
this.fieldName = fieldName;
this.fieldValue = fieldValue;
}
public String getResourceName() {
return resourceName;
}
public String getFieldName() {
return fieldName;
}
public Object getFieldValue() {
return fieldValue;
}
}
user-service\src\main\java\com\campus\express\user\model\Role.java
package com.campus.express.user.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "roles")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
@Column(length = 20, unique = true, nullable = false)
private RoleName name;
@Column(length = 100)
private String description;
public enum RoleName {
ROLE_STUDENT,
ROLE_STAFF,
ROLE_COURIER,
ROLE_ADMIN
}
}
user-service\src\main\java\com\campus\express\user\model\User.java
package com.campus.express.user.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import javax.persistence.*;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
@Size(min = 3, max = 50)
@Column(unique = true, nullable = false)
private String username;
@NotBlank
@Size(min = 6, max = 100)
@Column(nullable = false)
private String password;
@Column(name = "real_name")
private String realName;
@NotBlank
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
@Column(unique = true, nullable = false)
private String phone;
@Email
@Column
private String email;
@Column(name = "user_type", nullable = false)
private Integer userType;
@Column(name = "student_id")
private String studentId;
@Column
private String department;
@Column
private String dormitory;
@Column
private String avatar;
@Column(nullable = false)
private Integer status;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<Role> roles = new HashSet<>();
@CreationTimestamp
@Column(name = "created_time", nullable = false, updatable = false)
private LocalDateTime createdTime;
@UpdateTimestamp
@Column(name = "updated_time", nullable = false)
private LocalDateTime updatedTime;
}
user-service\src\main\java\com\campus\express\user\repository\RoleRepository.java
package com.campus.express.user.repository;
import com.campus.express.user.model.Role;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface RoleRepository extends JpaRepository<Role, Long> {
Optional<Role> findByName(Role.RoleName name);
}
user-service\src\main\java\com\campus\express\user\repository\UserRepository.java
package com.campus.express.user.repository;
import com.campus.express.user.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
Optional<User> findByUsername(String username);
Optional<User> findByPhone(String phone);
Optional<User> findByEmail(String email);
boolean existsByUsername(String username);
boolean existsByPhone(String phone);
boolean existsByEmail(String email);
}
user-service\src\main\java\com\campus\express\user\service\SecurityService.java
package com.campus.express.user.service;
import com.campus.express.user.repository.UserRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class SecurityService {
@Autowired
private UserRepository userRepository;
public boolean isCurrentUser(Long userId) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return false;
}
Object principal = authentication.getPrincipal();
if (!(principal instanceof UserDetails)) {
return false;
}
String username = ((UserDetails) principal).getUsername();
return userRepository.findByUsername(username)
.map(user -> user.getId().equals(userId))
.orElse(false);
}
}
user-service\src\main\java\com\campus\express\user\service\UserDetailsServiceImpl.java
package com.campus.express.user.service;
import com.campus.express.user.model.User;
import com.campus.express.user.repository.UserRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("未找到用户名为 " + username + " 的用户"));
if (user.getStatus() == 0) {
throw new UsernameNotFoundException("用户 " + username + " 已被禁用");
}
List<SimpleGrantedAuthority> authorities = user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority(role.getName().name()))
.collect(Collectors.toList());
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPassword(),
authorities
);
}
}
user-service\src\main\java\com\campus\express\user\service\UserService.java
package com.campus.express.user.service;
import com.campus.express.user.dto.JwtResponse;
import com.campus.express.user.dto.LoginRequest;
import com.campus.express.user.dto.SignupRequest;
import com.campus.express.user.dto.UserDTO;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
public interface UserService {
UserDTO registerUser(SignupRequest signupRequest);
JwtResponse authenticateUser(LoginRequest loginRequest);
UserDTO getUserById(Long id);
UserDTO getUserByUsername(String username);
Page<UserDTO> getUserList(Integer userType, String keyword, Pageable pageable);
UserDTO updateUser(Long id, UserDTO userDTO);
UserDTO updateUserStatus(Long id, Integer status);
void deleteUser(Long id);
boolean changePassword(Long id, String oldPassword, String newPassword);
}
user-service\src\main\java\com\campus\express\user\service\UserServiceImpl.java
package com.campus.express.user.service;
import com.campus.express.user.dto.JwtResponse;
import com.campus.express.user.dto.LoginRequest;
import com.campus.express.user.dto.SignupRequest;
import com.campus.express.user.dto.UserDTO;
import com.campus.express.user.exception.BadRequestException;
import com.campus.express.user.exception.ResourceNotFoundException;
import com.campus.express.user.model.Role;
import com.campus.express.user.model.User;
import com.campus.express.user.repository.RoleRepository;
import com.campus.express.user.repository.UserRepository;
import com.campus.express.user.util.JwtUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import javax.persistence.criteria.Predicate;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private RoleRepository roleRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtUtils jwtUtils;
@Override
@Transactional
public UserDTO registerUser(SignupRequest signupRequest) {
if (userRepository.existsByUsername(signupRequest.getUsername())) {
throw new BadRequestException("用户名已存在");
}
if (userRepository.existsByPhone(signupRequest.getPhone())) {
throw new BadRequestException("手机号已被注册");
}
if (StringUtils.hasText(signupRequest.getEmail()) && userRepository.existsByEmail(signupRequest.getEmail())) {
throw new BadRequestException("邮箱已被注册");
}
User user = User.builder()
.username(signupRequest.getUsername())
.password(passwordEncoder.encode(signupRequest.getPassword()))
.realName(signupRequest.getRealName())
.phone(signupRequest.getPhone())
.email(signupRequest.getEmail())
.userType(signupRequest.getUserType())
.studentId(signupRequest.getStudentId())
.department(signupRequest.getDepartment())
.dormitory(signupRequest.getDormitory())
.status(1)
.build();
Set<Role> roles = new HashSet<>();
if (signupRequest.getRoles() == null || signupRequest.getRoles().isEmpty()) {
Role userRole = roleRepository.findByName(Role.RoleName.ROLE_STUDENT)
.orElseThrow(() -> new RuntimeException("默认角色不存在"));
roles.add(userRole);
} else {
signupRequest.getRoles().forEach(role -> {
switch (role) {
case "admin":
Role adminRole = roleRepository.findByName(Role.RoleName.ROLE_ADMIN)
.orElseThrow(() -> new RuntimeException("管理员角色不存在"));
roles.add(adminRole);
break;
case "staff":
Role staffRole = roleRepository.findByName(Role.RoleName.ROLE_STAFF)
.orElseThrow(() -> new RuntimeException("教职工角色不存在"));
roles.add(staffRole);
break;
case "courier":
Role courierRole = roleRepository.findByName(Role.RoleName.ROLE_COURIER)
.orElseThrow(() -> new RuntimeException("快递员角色不存在"));
roles.add(courierRole);
break;
default:
Role studentRole = roleRepository.findByName(Role.RoleName.ROLE_STUDENT)
.orElseThrow(() -> new RuntimeException("学生角色不存在"));
roles.add(studentRole);
}
});
}
user.setRoles(roles);
User savedUser = userRepository.save(user);
log.info("用户注册成功: {}", savedUser.getUsername());
return convertToDTO(savedUser);
}
@Override
public JwtResponse authenticateUser(LoginRequest loginRequest) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword()));
SecurityContextHolder.getContext().setAuthentication(authentication);
String jwt = jwtUtils.generateJwtToken(authentication);
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
List<String> roles = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
User user = userRepository.findByUsername(userDetails.getUsername())
.orElseThrow(() -> new ResourceNotFoundException("User", "username", userDetails.getUsername()));
log.info("用户登录成功: {}", user.getUsername());
return new JwtResponse(
jwt,
user.getId(),
user.getUsername(),
user.getRealName(),
user.getPhone(),
user.getUserType(),
roles
);
}
@Override
@Transactional(readOnly = true)
public UserDTO getUserById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
return convertToDTO(user);
}
@Override
@Transactional(readOnly = true)
public UserDTO getUserByUsername(String username) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new ResourceNotFoundException("User", "username", username));
return convertToDTO(user);
}
@Override
@Transactional(readOnly = true)
public Page<UserDTO> getUserList(Integer userType, String keyword, Pageable pageable) {
Specification<User> spec = (root, query, criteriaBuilder) -> {
List<Predicate> predicates = new ArrayList<>();
if (userType != null) {
predicates.add(criteriaBuilder.equal(root.get("userType"), userType));
}
if (StringUtils.hasText(keyword)) {
List<Predicate> keywordPredicates = new ArrayList<>();
keywordPredicates.add(criteriaBuilder.like(root.get("username"), "%" + keyword + "%"));
keywordPredicates.add(criteriaBuilder.like(root.get("realName"), "%" + keyword + "%"));
keywordPredicates.add(criteriaBuilder.like(root.get("phone"), "%" + keyword + "%"));
keywordPredicates.add(criteriaBuilder.like(root.get("email"), "%" + keyword + "%"));
predicates.add(criteriaBuilder.or(keywordPredicates.toArray(new Predicate[0])));
}
return criteriaBuilder.and(predicates.toArray(new Predicate[0]));
};
Page<User> userPage = userRepository.findAll(spec, pageable);
return userPage.map(this::convertToDTO);
}
@Override
@Transactional
public UserDTO updateUser(Long id, UserDTO userDTO) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
if (StringUtils.hasText(userDTO.getRealName())) {
user.setRealName(userDTO.getRealName());
}
if (StringUtils.hasText(userDTO.getPhone()) && !user.getPhone().equals(userDTO.getPhone())) {
if (userRepository.existsByPhone(userDTO.getPhone())) {
throw new BadRequestException("手机号已被注册");
}
user.setPhone(userDTO.getPhone());
}
if (StringUtils.hasText(userDTO.getEmail()) && !Objects.equals(user.getEmail(), userDTO.getEmail())) {
if (userRepository.existsByEmail(userDTO.getEmail())) {
throw new BadRequestException("邮箱已被注册");
}
user.setEmail(userDTO.getEmail());
}
if (userDTO.getUserType() != null) {
user.setUserType(userDTO.getUserType());
}
if (StringUtils.hasText(userDTO.getStudentId())) {
user.setStudentId(userDTO.getStudentId());
}
if (StringUtils.hasText(userDTO.getDepartment())) {
user.setDepartment(userDTO.getDepartment());
}
if (StringUtils.hasText(userDTO.getDormitory())) {
user.setDormitory(userDTO.getDormitory());
}
if (StringUtils.hasText(userDTO.getAvatar())) {
user.setAvatar(userDTO.getAvatar());
}
User updatedUser = userRepository.save(user);
log.info("用户信息更新成功: {}", updatedUser.getUsername());
return convertToDTO(updatedUser);
}
@Override
@Transactional
public UserDTO updateUserStatus(Long id, Integer status) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
user.setStatus(status);
User updatedUser = userRepository.save(user);
log.info("用户状态更新成功: {}, 状态: {}", updatedUser.getUsername(), status);
return convertToDTO(updatedUser);
}
@Override
@Transactional
public void deleteUser(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
userRepository.delete(user);
log.info("用户删除成功: {}", user.getUsername());
}
@Override
@Transactional
public boolean changePassword(Long id, String oldPassword, String newPassword) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
if (!passwordEncoder.matches(oldPassword, user.getPassword())) {
throw new BadRequestException("旧密码不正确");
}
user.setPassword(passwordEncoder.encode(newPassword));
userRepository.save(user);
log.info("用户密码修改成功: {}", user.getUsername());
return true;
}
private UserDTO convertToDTO(User user) {
Set<String> roles = user.getRoles().stream()
.map(role -> role.getName().name())
.collect(Collectors.toSet());
return UserDTO.builder()
.id(user.getId())
.username(user.getUsername())
.realName(user.getRealName())
.phone(user.getPhone())
.email(user.getEmail())
.userType(user.getUserType())
.studentId(user.getStudentId())
.department(user.getDepartment())
.dormitory(user.getDormitory())
.avatar(user.getAvatar())
.status(user.getStatus())
.roles(roles)
.createdTime(user.getCreatedTime())
.updatedTime(user.getUpdatedTime())
.build();
}
}
user-service\src\main\java\com\campus\express\user\util\JwtUtils.java
package com.campus.express.user.util;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
@Slf4j
@Component
public class JwtUtils {
@Value("${jwt.secret}")
private String jwtSecret;
@Value("${jwt.expiration}")
private int jwtExpirationMs;
public String generateJwtToken(Authentication authentication) {
UserDetails userPrincipal = (UserDetails) authentication.getPrincipal();
return Jwts.builder()
.setSubject(userPrincipal.getUsername())
.setIssuedAt(new Date())
.setExpiration(new Date((new Date()).getTime() + jwtExpirationMs))
.signWith(getSigningKey(), SignatureAlgorithm.HS512)
.compact();
}
public String getUsernameFromJwtToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
public boolean validateJwtToken(String authToken) {
try {
Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(authToken);
return true;
} catch (MalformedJwtException e) {
log.error("Invalid JWT token: {}", e.getMessage());
} catch (ExpiredJwtException e) {
log.error("JWT token is expired: {}", e.getMessage());
} catch (UnsupportedJwtException e) {
log.error("JWT token is unsupported: {}", e.getMessage());
} catch (IllegalArgumentException e) {
log.error("JWT claims string is empty: {}", e.getMessage());
} catch (Exception e) {
log.error("JWT validation error: {}", e.getMessage());
}
return false;
}
private Key getSigningKey() {
byte[] keyBytes = jwtSecret.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
}
user-service\src\main\resources\application.yml
server:
port: 8081
servlet:
context-path: /api/users
spring:
application:
name: user-service
datasource:
url: jdbc:mysql://localhost:3306/campus_express_user?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf-8
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.MySQL8Dialect
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/
instance:
prefer-ip-address: true
instance-id: ${spring.application.name}:${server.port}
management:
endpoints:
web:
exposure:
include: "*"
endpoint:
health:
show-details: always
jwt:
secret: campus_express_secret_key_2025_04_08_very_secure_and_long_key
expiration: 86400000
header: Authorization
prefix: Bearer
logging:
level:
com.campus.express.user: DEBUG
org.springframework.web: INFO
org.hibernate: INFO
user-service\target\classes\application.yml
server:
port: 8081
servlet:
context-path: /api/users
spring:
application:
name: user-service
datasource:
url: jdbc:mysql://localhost:3306/campus_express_user?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf-8
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.MySQL8Dialect
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/
instance:
prefer-ip-address: true
instance-id: ${spring.application.name}:${server.port}
management:
endpoints:
web:
exposure:
include: "*"
endpoint:
health:
show-details: always
jwt:
secret: campus_express_secret_key_2025_04_08_very_secure_and_long_key
expiration: 86400000
header: Authorization
prefix: Bearer
logging:
level:
com.campus.express.user: DEBUG
org.springframework.web: INFO
org.hibernate: INFO