Java全栈项目--校园快递管理与配送系统(5)

源代码续

vue 复制代码
<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

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

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

text/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

java 复制代码
package com.campus.express.user;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;

/**
 * User Service Application
 * 
 * 用户服务应用程序入口类
 * 
 * @author Campus Express Team
 */
@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

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;

/**
 * CORS配置
 * 允许跨域请求
 */
@Configuration
public class CorsConfig {

    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        
        // 允许跨域的头部信息
        config.addAllowedHeader("*");
        // 允许跨域的方法
        config.addAllowedMethod("*");
        // 允许跨域的来源
        config.addAllowedOrigin("*");
        // 允许携带cookie信息
        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

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

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;

/**
 * JWT认证入口点
 * 
 * 用于处理未认证的请求,返回401错误
 */
@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

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;

/**
 * JWT认证过滤器
 * 
 * 用于从请求中提取JWT令牌并验证用户身份
 */
@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);
    }

    /**
     * 从请求中提取JWT令牌
     * 
     * @param request HTTP请求
     * @return JWT令牌,如果不存在则返回null
     */
    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

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

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;

    /**
     * 用户注册
     *
     * @param signupRequest 注册请求
     * @return 注册结果
     */
    @PostMapping("/signup")
    public ResponseEntity<?> registerUser(@Valid @RequestBody SignupRequest signupRequest) {
        log.info("收到用户注册请求: {}", signupRequest.getUsername());
        return ResponseEntity.ok(ApiResponse.success("用户注册成功", userService.registerUser(signupRequest)));
    }

    /**
     * 用户登录
     *
     * @param loginRequest 登录请求
     * @return JWT响应
     */
    @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

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;

    /**
     * 获取所有角色
     *
     * @return 角色列表
     */
    @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

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;

    /**
     * 获取当前用户信息
     *
     * @return 当前用户信息
     */
    @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));
    }

    /**
     * 获取用户信息
     *
     * @param id 用户ID
     * @return 用户信息
     */
    @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));
    }

    /**
     * 分页获取用户列表
     *
     * @param page     页码
     * @param size     每页大小
     * @param sort     排序字段
     * @param userType 用户类型
     * @param keyword  关键字
     * @return 用户列表
     */
    @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));
    }

    /**
     * 更新用户信息
     *
     * @param id      用户ID
     * @param userDTO 用户信息
     * @return 更新后的用户信息
     */
    @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));
    }

    /**
     * 更新用户状态
     *
     * @param id     用户ID
     * @param status 状态
     * @return 更新后的用户信息
     */
    @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));
    }

    /**
     * 删除用户
     *
     * @param id 用户ID
     * @return 删除结果
     */
    @DeleteMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<?> deleteUser(@PathVariable Long id) {
        userService.deleteUser(id);
        return ResponseEntity.ok(ApiResponse.success("用户删除成功"));
    }

    /**
     * 修改密码
     *
     * @param id          用户ID
     * @param oldPassword 旧密码
     * @param newPassword 新密码
     * @return 修改结果
     */
    @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

java 复制代码
package com.campus.express.user.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

/**
 * API响应包装类
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {
    
    private Integer code;
    private String message;
    private T data;
    private LocalDateTime timestamp;
    
    /**
     * 创建成功响应
     * 
     * @param data 响应数据
     * @param <T> 数据类型
     * @return API响应对象
     */
    public static <T> ApiResponse<T> success(T data) {
        return ApiResponse.<T>builder()
                .code(200)
                .message("操作成功")
                .data(data)
                .timestamp(LocalDateTime.now())
                .build();
    }
    
    /**
     * 创建成功响应(无数据)
     * 
     * @param <T> 数据类型
     * @return API响应对象
     */
    public static <T> ApiResponse<T> success() {
        return ApiResponse.<T>builder()
                .code(200)
                .message("操作成功")
                .timestamp(LocalDateTime.now())
                .build();
    }
    
    /**
     * 创建成功响应(自定义消息)
     * 
     * @param message 响应消息
     * @param data 响应数据
     * @param <T> 数据类型
     * @return API响应对象
     */
    public static <T> ApiResponse<T> success(String message, T data) {
        return ApiResponse.<T>builder()
                .code(200)
                .message(message)
                .data(data)
                .timestamp(LocalDateTime.now())
                .build();
    }
    
    /**
     * 创建错误响应
     * 
     * @param code 错误码
     * @param message 错误消息
     * @param <T> 数据类型
     * @return API响应对象
     */
    public static <T> ApiResponse<T> error(Integer code, String message) {
        return ApiResponse.<T>builder()
                .code(code)
                .message(message)
                .timestamp(LocalDateTime.now())
                .build();
    }
    
    /**
     * 创建错误响应(400 Bad Request)
     * 
     * @param message 错误消息
     * @param <T> 数据类型
     * @return API响应对象
     */
    public static <T> ApiResponse<T> badRequest(String message) {
        return error(400, message);
    }
    
    /**
     * 创建错误响应(401 Unauthorized)
     * 
     * @param message 错误消息
     * @param <T> 数据类型
     * @return API响应对象
     */
    public static <T> ApiResponse<T> unauthorized(String message) {
        return error(401, message);
    }
    
    /**
     * 创建错误响应(403 Forbidden)
     * 
     * @param message 错误消息
     * @param <T> 数据类型
     * @return API响应对象
     */
    public static <T> ApiResponse<T> forbidden(String message) {
        return error(403, message);
    }
    
    /**
     * 创建错误响应(404 Not Found)
     * 
     * @param message 错误消息
     * @param <T> 数据类型
     * @return API响应对象
     */
    public static <T> ApiResponse<T> notFound(String message) {
        return error(404, message);
    }
    
    /**
     * 创建错误响应(500 Internal Server Error)
     * 
     * @param message 错误消息
     * @param <T> 数据类型
     * @return API响应对象
     */
    public static <T> ApiResponse<T> serverError(String message) {
        return error(500, message);
    }
}

user-service\src\main\java\com\campus\express\user\dto\JwtResponse.java

java 复制代码
package com.campus.express.user.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

/**
 * JWT认证响应DTO
 */
@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

java 复制代码
package com.campus.express.user.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.validation.constraints.NotBlank;

/**
 * 用户登录请求DTO
 */
@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

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;

/**
 * 密码修改请求DTO
 */
@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

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;

/**
 * 用户注册请求DTO
 */
@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; // 1-学生,2-教职工,3-快递员,4-管理员
    
    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

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

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

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

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

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

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; // 1-学生,2-教职工,3-快递员,4-管理员

    @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; // 0-禁用,1-启用

    @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

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> {
    
    /**
     * 根据角色名查找角色
     * 
     * @param name 角色名
     * @return 角色对象
     */
    Optional<Role> findByName(Role.RoleName name);
}

user-service\src\main\java\com\campus\express\user\repository\UserRepository.java

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> {
    
    /**
     * 根据用户名查找用户
     * 
     * @param username 用户名
     * @return 用户对象
     */
    Optional<User> findByUsername(String username);
    
    /**
     * 根据手机号查找用户
     * 
     * @param phone 手机号
     * @return 用户对象
     */
    Optional<User> findByPhone(String phone);
    
    /**
     * 根据邮箱查找用户
     * 
     * @param email 邮箱
     * @return 用户对象
     */
    Optional<User> findByEmail(String email);
    
    /**
     * 检查用户名是否存在
     * 
     * @param username 用户名
     * @return 是否存在
     */
    boolean existsByUsername(String username);
    
    /**
     * 检查手机号是否存在
     * 
     * @param phone 手机号
     * @return 是否存在
     */
    boolean existsByPhone(String phone);
    
    /**
     * 检查邮箱是否存在
     * 
     * @param email 邮箱
     * @return 是否存在
     */
    boolean existsByEmail(String email);
}

user-service\src\main\java\com\campus\express\user\service\SecurityService.java

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;

    /**
     * 判断当前用户是否为指定ID的用户
     *
     * @param userId 用户ID
     * @return 是否为当前用户
     */
    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

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;

/**
 * 用户详情服务实现类
 * 
 * 实现Spring Security的UserDetailsService接口,用于加载用户信息
 */
@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

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 {
    
    /**
     * 用户注册
     * 
     * @param signupRequest 注册请求
     * @return 用户DTO
     */
    UserDTO registerUser(SignupRequest signupRequest);
    
    /**
     * 用户登录
     * 
     * @param loginRequest 登录请求
     * @return JWT响应
     */
    JwtResponse authenticateUser(LoginRequest loginRequest);
    
    /**
     * 获取用户信息
     * 
     * @param id 用户ID
     * @return 用户DTO
     */
    UserDTO getUserById(Long id);
    
    /**
     * 获取用户信息
     * 
     * @param username 用户名
     * @return 用户DTO
     */
    UserDTO getUserByUsername(String username);
    
    /**
     * 分页获取用户列表
     * 
     * @param userType 用户类型
     * @param keyword 关键字
     * @param pageable 分页参数
     * @return 用户DTO分页
     */
    Page<UserDTO> getUserList(Integer userType, String keyword, Pageable pageable);
    
    /**
     * 更新用户信息
     * 
     * @param id 用户ID
     * @param userDTO 用户DTO
     * @return 更新后的用户DTO
     */
    UserDTO updateUser(Long id, UserDTO userDTO);
    
    /**
     * 更新用户状态
     * 
     * @param id 用户ID
     * @param status 状态
     * @return 更新后的用户DTO
     */
    UserDTO updateUserStatus(Long id, Integer status);
    
    /**
     * 删除用户
     * 
     * @param id 用户ID
     */
    void deleteUser(Long id);
    
    /**
     * 修改密码
     * 
     * @param id 用户ID
     * @param oldPassword 旧密码
     * @param newPassword 新密码
     * @return 是否成功
     */
    boolean changePassword(Long id, String oldPassword, String newPassword);
}

user-service\src\main\java\com\campus\express\user\service\UserServiceImpl.java

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

        // 转换为DTO并返回
        return convertToDTO(savedUser);
    }

    @Override
    public JwtResponse authenticateUser(LoginRequest loginRequest) {
        // 认证用户
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword()));

        // 设置认证信息
        SecurityContextHolder.getContext().setAuthentication(authentication);

        // 生成JWT令牌
        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());

        // 返回JWT响应
        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;
    }
    
    /**
     * 将用户实体转换为DTO
     * 
     * @param user 用户实体
     * @return 用户DTO
     */
    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

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;

/**
 * JWT工具类,用于生成和验证JWT令牌
 */
@Slf4j
@Component
public class JwtUtils {

    @Value("${jwt.secret}")
    private String jwtSecret;

    @Value("${jwt.expiration}")
    private int jwtExpirationMs;

    /**
     * 生成JWT令牌
     *
     * @param authentication 认证信息
     * @return JWT令牌
     */
    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();
    }

    /**
     * 从JWT令牌中获取用户名
     *
     * @param token JWT令牌
     * @return 用户名
     */
    public String getUsernameFromJwtToken(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

    /**
     * 验证JWT令牌
     *
     * @param authToken JWT令牌
     * @return 是否有效
     */
    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;
    }
    
    /**
     * 获取签名密钥
     *
     * @return 签名密钥
     */
    private Key getSigningKey() {
        byte[] keyBytes = jwtSecret.getBytes(StandardCharsets.UTF_8);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

user-service\src\main\resources\application.yml

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配置
jwt:
  secret: campus_express_secret_key_2025_04_08_very_secure_and_long_key
  expiration: 86400000  # 24小时
  header: Authorization
  prefix: Bearer

# 日志配置
logging:
  level:
    com.campus.express.user: DEBUG
    org.springframework.web: INFO
    org.hibernate: INFO

user-service\target\classes\application.yml

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配置
jwt:
  secret: campus_express_secret_key_2025_04_08_very_secure_and_long_key
  expiration: 86400000  # 24小时
  header: Authorization
  prefix: Bearer

# 日志配置
logging:
  level:
    com.campus.express.user: DEBUG
    org.springframework.web: INFO
    org.hibernate: INFO
相关推荐
木木子999917 小时前
业务架构、应用架构、数据架构、技术架构
java·开发语言·架构
qq_54702617919 小时前
Flowable 工作流引擎
java·服务器·前端
鼓掌MVP20 小时前
Java框架的发展历程体现了软件工程思想的持续进化
java·spring·架构
Sheldon一蓑烟雨任平生20 小时前
Vue3 插件(可选独立模块复用)
vue.js·vue3·插件·vue3 插件·可选独立模块·插件使用方式·插件中的依赖注入
编程爱好者熊浪20 小时前
两次连接池泄露的BUG
java·数据库
lllsure20 小时前
【Spring Cloud】Spring Cloud Config
java·spring·spring cloud
鬼火儿21 小时前
SpringBoot】Spring Boot 项目的打包配置
java·后端
NON-JUDGMENTAL21 小时前
Tomcat 新手避坑指南:环境配置 + 启动问题 + 乱码解决全流程
java·tomcat
鱼与宇21 小时前
苍穹外卖-VUE
前端·javascript·vue.js
用户47949283569151 天前
Safari 中文输入法的诡异 Bug:为什么输入 @ 会变成 @@? ## 开头 做 @ 提及功能的时候,测试同学用 Safari 测出了个奇怪的问题
前端·javascript·浏览器