第三部分:完整代码补全

本部分补齐 PROJECT_DOC.md 和 DOC_APPENDIX.md 中缺失的所有内容: 前端全部页面代码、所有工具类/配置类、所有 Mapper XML、完整部署流程、Dubbo 验证方法。


26. 前端全部页面代码

26.1 LoginView.vue(用户登录)

<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import userApi from '@/api/userApi.js'
import { userTokenStore } from '@/stores/token.js'
import { userToken } from '@/stores/user.js'

const router = useRouter()
const tokenStore = userTokenStore()
const userStore = userToken()

const loginForm = ref({ username: '', password: '', captchaInput: '' })
const captchaData = ref({ key: '', imageBase64Data: '' })

// 加载验证码
function loadCaptcha() {
  userApi.captcha().then(resp => {
    if (resp.code === 200) captchaData.value = resp.data
  })
}

// 登录
function login() {
  if (!loginForm.value.username || !loginForm.value.password) {
    ElMessage.warning('请输入用户名和密码')
    return
  }
  userApi.login({
    username: loginForm.value.username,
    password: loginForm.value.password,
    key: captchaData.value.key,
    captchaInput: loginForm.value.captchaInput
  }).then(resp => {
    if (resp.code === 200) {
      tokenStore.updateToken(resp.data)  // 存 JWT
      ElMessage.success('登录成功')
      // 获取用户数据
      userApi.getInfo().then(r => {
        if (r.code === 200) userStore.updateUser(r.data)
      })
      router.push('/user/index')
    } else {
      ElMessage.error(resp.msg)
      loadCaptcha()
    }
  })
}

loadCaptcha()
</script>

<template>
  <div class="login-page">
    <el-card class="login-card" shadow="always">
      <h2>用户登录</h2>
      <el-form :model="loginForm" label-width="0">
        <el-form-item>
          <el-input v-model="loginForm.username" placeholder="请输入用户名" prefix-icon="User" />
        </el-form-item>
        <el-form-item>
          <el-input v-model="loginForm.password" type="password" placeholder="请输入密码"
                    prefix-icon="Lock" show-password />
        </el-form-item>
        <el-form-item>
          <div class="captcha-row">
            <el-input v-model="loginForm.captchaInput" placeholder="验证码" style="width: 60%" />
            <img :src="captchaData.imageBase64Data" @click="loadCaptcha"
                 class="captcha-img" title="点击刷新" />
          </div>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" style="width: 100%" @click="login">登 录</el-button>
        </el-form-item>
        <el-form-item>
          <el-link type="primary" @click="router.push('/user/reg')">还没有账号?立即注册</el-link>
        </el-form-item>
      </el-form>
    </el-card>
  </div>
</template>

<style scoped>
.login-page {
  display: flex; justify-content: center; align-items: center;
  min-height: 500px; background: linear-gradient(135deg, #f5f7fa 0%, #e4e8eb 100%);
}
.login-card {
  width: 420px; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.08); padding: 40px;
}
.login-card h2 { text-align: center; margin-bottom: 30px; color: #333; }
.captcha-row { display: flex; gap: 12px; align-items: center; }
.captcha-img { height: 38px; cursor: pointer; border: 1px solid #ddd; border-radius: 4px; }
</style>

26.2 RegView.vue(用户注册)

<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import userApi from '@/api/userApi.js'

const router = useRouter()
const form = ref({ username: '', password: '', password1: '', captchaInput: '' })
const captchaData = ref({ key: '', imageBase64Data: '' })

function loadCaptcha() {
  userApi.captcha().then(resp => {
    if (resp.code === 200) captchaData.value = resp.data
  })
}

function reg() {
  if (!form.value.username || !form.value.password) {
    ElMessage.warning('用户名和密码不能为空'); return
  }
  if (form.value.password !== form.value.password1) {
    ElMessage.warning('两次密码不一致'); return
  }
  userApi.reg({
    username: form.value.username,
    password: form.value.password,
    key: captchaData.value.key,
    captchaInput: form.value.captchaInput
  }).then(resp => {
    if (resp.code === 200) {
      ElMessage.success('注册成功,请登录')
      router.push('/user/login')
    } else {
      ElMessage.error(resp.msg)
      loadCaptcha()
    }
  })
}

loadCaptcha()
</script>

<template>
  <div class="reg-page">
    <el-card class="reg-card" shadow="always">
      <h2>用户注册</h2>
      <el-form :model="form" label-width="0">
        <el-form-item>
          <el-input v-model="form.username" placeholder="请输入用户名" />
        </el-form-item>
        <el-form-item>
          <el-input v-model="form.password" type="password" placeholder="请输入密码" show-password />
        </el-form-item>
        <el-form-item>
          <el-input v-model="form.password1" type="password" placeholder="确认密码" show-password />
        </el-form-item>
        <el-form-item>
          <div class="captcha-row">
            <el-input v-model="form.captchaInput" placeholder="验证码" style="width: 60%" />
            <img :src="captchaData.imageBase64Data" @click="loadCaptcha" class="captcha-img" title="点击刷新" />
          </div>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" style="width: 100%" @click="reg">注 册</el-button>
        </el-form-item>
        <el-form-item>
          <el-link type="primary" @click="router.push('/user/login')">已有账号?立即登录</el-link>
        </el-form-item>
      </el-form>
    </el-card>
  </div>
</template>

<style scoped>
.reg-page { display: flex; justify-content: center; align-items: center; min-height: 500px; background: linear-gradient(135deg, #f5f7fa 0%, #e4e8eb 100%); }
.reg-card { width: 420px; border-radius: 12px; padding: 40px; }
.reg-card h2 { text-align: center; margin-bottom: 30px; color: #333; }
.captcha-row { display: flex; gap: 12px; align-items: center; }
.captcha-img { height: 38px; cursor: pointer; border: 1px solid #ddd; border-radius: 4px; }
</style>

26.3 SearchView.vue(商品搜索/分类浏览)

<script setup>
import { ref, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import goodsApi from '@/api/GoodsApi.js'
import categoryApi from '@/api/CategoryApi.js'

const route = useRoute()
const router = useRouter()
const SERVER_ADDR = ref(import.meta.env.VITE_SERVER_ADDR)

const goodsList = ref([])
const total = ref(0)
const pageNum = ref(1)
const pageSize = ref(20)
const categories = ref([])
const activeCategoryId = ref(null)

function loadCategories() {
  categoryApi.selectAllParent().then(resp => {
    if (resp.code === 200) categories.value = resp.data || []
  })
}

function loadGoods() {
  const cid = route.params.categoryId || activeCategoryId.value
  goodsApi.selectByPage1({ categoryId: cid || undefined }, pageNum.value, pageSize.value)
    .then(resp => {
      if (resp.data) {
        goodsList.value = resp.data.list || []
        total.value = resp.data.total || 0
      }
    })
}

function onPageChange(p) { pageNum.value = p; loadGoods() }
function selectCategory(cid) {
  activeCategoryId.value = cid
  pageNum.value = 1
  loadGoods()
}

watch(() => route.params.categoryId, () => {
  activeCategoryId.value = Number(route.params.categoryId) || null
  pageNum.value = 1
  loadGoods()
})

onMounted(() => { loadCategories(); loadGoods() })
</script>

<template>
  <div class="search-page">
    <!-- 分类标签 -->
    <div class="category-bar">
      <span :class="{ tag: true, active: !activeCategoryId }" @click="selectCategory(null)">全部</span>
      <span v-for="c in categories" :key="c.id"
            :class="{ tag: true, active: activeCategoryId === c.id }"
            @click="selectCategory(c.id)">{{ c.name }}</span>
    </div>

    <!-- 商品网格 -->
    <div v-if="goodsList.length > 0" class="goods-grid">
      <div v-for="g in goodsList" :key="g.id" class="goods-card"
           @click="router.push({ path: '/user/goods', query: { id: g.id } })">
        <el-image v-if="g.picList && g.picList.length"
                  :src="SERVER_ADDR + '/goods/pic/' + g.picList[0].url"
                  fit="cover" style="width:224px;height:224px" />
        <div v-else class="no-pic">暂无图片</div>
        <div class="card-info">
          <p class="gd-name">{{ g.name }}</p>
          <p class="gd-price">¥{{ g.price }}</p>
        </div>
      </div>
    </div>
    <el-empty v-else description="没有找到商品" />

    <el-pagination v-if="total > pageSize" style="margin-top:20px;text-align:center"
                   background layout="prev, pager, next"
                   :total="total" :page-size="pageSize"
                   v-model:current-page="pageNum" @current-change="onPageChange" />
  </div>
</template>

<style scoped>
.search-page { margin: 20px 15px; }
.category-bar { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 20px; }
.tag {
  padding: 6px 18px; border-radius: 20px; background: #f5f5f5; cursor: pointer;
  font-size: 14px; transition: all 0.2s;
}
.tag:hover { background: #ffe6d0; }
.tag.active { background: var(--theme-color, #FF6800); color: #fff; }
.goods-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; }
.goods-card {
  background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.06);
  overflow: hidden; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s;
}
.goods-card:hover { transform: translateY(-4px); box-shadow: 0 4px 16px rgba(0,0,0,0.12); }
.no-pic { width:224px; height:224px; background:#f5f5f5; display:flex; align-items:center; justify-content:center; color:#999; }
.card-info { padding: 10px 12px; }
.gd-name { font-size:14px; color:#333; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; margin-bottom:6px; }
.gd-price { font-size:16px; color:var(--theme-color,#FF6800); font-weight:bold; }
</style>

26.4 IndexView.vue — 用户首页

<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import goodsApi from '@/api/GoodsApi.js'
import categoryApi from '@/api/CategoryApi.js'

const router = useRouter()
const SERVER_ADDR = ref(import.meta.env.VITE_SERVER_ADDR)
const recomGroups = ref([])  // [{category, goodsList}, ...]

// 按父分类分组加载推荐商品
function loadRecom() {
  categoryApi.selectAllParent().then(resp => {
    const parents = resp.data || []
    const promises = parents.map(parent =>
      goodsApi.selectByPage1({ categoryId: parent.id, recom: 1 }, 1, 5)
        .then(r => ({ category: parent, goodsList: r.data ? r.data.list : [] }))
    )
    Promise.all(promises).then(results => {
      recomGroups.value = results.filter(g => g.goodsList.length > 0)
    })
  })
}

onMounted(loadRecom)
</script>

<template>
  <div class="index-page">
    <!-- 轮播图 -->
    <el-carousel height="650px" motion-blur>
      <el-carousel-item v-for="i in 3" :key="i">
        <div :style="{ background: ['#303133','#FF6800','#409EFF'][i-1],
                       height:'100%', display:'flex', alignItems:'center', justifyContent:'center',
                       color:'#fff', fontSize:'48px', fontWeight:'bold' }">
          {{ ['新品首发','限时特惠','品牌直供'][i-1] }}
        </div>
      </el-carousel-item>
    </el-carousel>

    <!-- 广告位 -->
    <el-row :gutter="20" class="ad-row">
      <el-col :span="6" v-for="(ad,i) in ['手机数码','电脑办公','家用电器','智能家居']" :key="i">
        <div class="ad-card" @click="router.push('/user/search')">
          <div class="ad-icon">{{ ['📱','💻','📺','🏠'][i] }}</div>
          <p>{{ ad }}</p>
        </div>
      </el-col>
    </el-row>

    <!-- 按父分类分组推荐商品 -->
    <div v-for="group in recomGroups" :key="group.category.id" class="recom-section">
      <div class="section-title">
        <span class="title-bar"></span>
        <span class="title-text" @click="router.push('/user/search/'+group.category.id)">
          {{ group.category.name }} <small>查看更多 &gt;</small>
        </span>
      </div>
      <div class="goods-row">
        <div v-for="g in group.goodsList" :key="g.id" class="goods-card"
             @click="router.push({path:'/user/goods',query:{id:g.id}})">
          <el-image v-if="g.picList && g.picList.length"
                    :src="SERVER_ADDR + '/goods/pic/' + g.picList[0].url"
                    fit="cover" style="width:224px;height:224px" />
          <div v-else class="no-pic">暂无图片</div>
          <div class="card-info">
            <p class="gd-name">{{ g.name }}</p>
            <p class="gd-price">¥{{ g.price }}</p>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.index-page { margin: 0 15px; }
.ad-row { margin: 20px 0; }
.ad-card { text-align: center; padding: 20px; background: #fff; border-radius: 8px; cursor: pointer; transition: all 0.2s; }
.ad-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.1); transform: translateY(-2px); }
.ad-icon { font-size: 40px; margin-bottom: 8px; }
.ad-card p { color: #333; font-size: 14px; margin: 0; }
.recom-section { margin: 30px 0; }
.section-title { display: flex; align-items: center; margin-bottom: 16px; }
.title-bar { width: 4px; height: 20px; background: var(--theme-color, #FF6800); margin-right: 10px; border-radius: 2px; }
.title-text { font-size: 18px; font-weight: bold; color: #333; cursor: pointer; }
.title-text small { font-size: 13px; color: #999; font-weight: normal; }
.goods-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; }
.goods-card { background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); overflow: hidden; cursor: pointer; transition: all 0.2s; }
.goods-card:hover { transform: translateY(-4px); box-shadow: 0 4px 16px rgba(0,0,0,0.12); }
.no-pic { width:224px; height:224px; background:#f5f5f5; display:flex; align-items:center; justify-content:center; color:#999; }
.card-info { padding: 10px 12px; }
.gd-name { font-size:14px; color:#333; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; margin-bottom:6px; }
.gd-price { font-size:16px; color:var(--theme-color,#FF6800); font-weight:bold; }
</style>

26.5 HomeView.vue(用户端父布局)

<script setup>
import UserHeader from '@/components/user/home/UserHeader.vue'
import UserFooter from '@/components/user/home/UserFooter.vue'
</script>

<template>
  <el-container>
    <el-header style="padding:0;height:auto">
      <UserHeader />
    </el-header>
    <el-main style="background:#eee;min-height:500px;padding:0">
      <div class="center">
        <RouterView />
      </div>
    </el-main>
    <el-footer style="padding:0;height:auto">
      <UserFooter />
    </el-footer>
  </el-container>
</template>

26.6 UserHeader.vue(用户导航栏)

<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { userToken } from '@/stores/user.js'
import { userTokenStore } from '@/stores/token.js'

const router = useRouter()
const userStore = userToken()
const tokenStore = userTokenStore()
const keyword = ref('')

function search() {
  if (keyword.value.trim()) {
    router.push({ path: '/user/search', query: { keyword: keyword.value.trim() } })
  }
}

function logout() {
  tokenStore.$reset()
  userStore.$reset()
  router.push('/user/index')
}
</script>

<template>
  <!-- 顶部导航条 -->
  <div class="nav_bg">
    <div class="center top-nav">
      <div>
        <router-link to="/user/index">首页</router-link>
        <router-link to="/user/search">全部商品</router-link>
        <a href="#">新品上市</a>
        <a href="#">限时特惠</a>
        <a href="#">品牌专区</a>
        <a href="#">关于我们</a>
      </div>
      <div>
        <template v-if="tokenStore.tokenStr && userStore.userInfo">
          <span>{{ userStore.userInfo.username }}</span>
          <a @click="logout">退出</a>
          <router-link to="/user/cart">购物车</router-link>
        </template>
        <template v-else>
          <router-link to="/user/login">登录</router-link>
          <router-link to="/user/reg">注册</router-link>
        </template>
      </div>
    </div>
  </div>

  <!-- Logo+搜索区 -->
  <div class="center search-area">
    <div class="logo">在线商城</div>
    <div class="search-box">
      <el-input v-model="keyword" placeholder="搜索商品" @keyup.enter="search" size="large" />
      <el-button type="primary" @click="search" size="large">搜索</el-button>
    </div>
  </div>

  <!-- 分类菜单 -->
  <div class="cat-bar">
    <div class="center cat-list">
      <span v-for="c in ['智能手机','电脑办公','家用电器','智能家居','运动户外']" :key="c"
            @click="router.push('/user/search')">{{ c }}</span>
    </div>
  </div>
</template>

<style scoped>
.nav_bg { background: var(--theme-bg-color, #303133); height: 50px; line-height: 50px; }
.top-nav { display: flex; justify-content: space-between; color: #ccc; font-size: 14px; }
.top-nav a, .top-nav span { color: #ccc; margin: 0 12px; cursor: pointer; text-decoration: none; }
.top-nav a:hover { color: var(--theme-color, #FF6800); }
.search-area { display: flex; align-items: center; height: 80px; }
.logo { font-size: 28px; font-weight: bold; color: var(--theme-color, #FF6800); margin-right: 40px; }
.search-box { display: flex; width: 500px; }
.cat-bar { background: var(--theme-color, #FF6800); height: 40px; line-height: 40px; }
.cat-list { display: flex; gap: 30px; color: #fff; font-size: 14px; cursor: pointer; }
.cat-list span { cursor: pointer; }
.cat-list span:hover { color: wheat; }
</style>

26.7 CartView.vue(购物车 — 完整版)

<script setup>
import { ref } from 'vue'
import cartApi from '@/api/cartApi.js'
import { Delete, ShoppingCart } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'

const router = useRouter()
const SERVER_ADDR = ref(import.meta.env.VITE_SERVER_ADDR)
const cartList = ref([])
const checkAllState = ref(false)
const halfState = ref(false)
const checkCount = ref(0)
const checkPrice = ref(0)

function selectBycondition() {
  cartApi.selectBycodition().then(resp => {
    cartList.value = resp.data || []
    changeState()
  })
}

function checkAllOrderNone() {
  cartList.value.forEach(c => c.checkState = checkAllState.value)
  changeState()
}

function changeState() {
  let count = 0
  cartList.value.forEach(c => { if (c.checkState) count++ })
  checkAllState.value = count === cartList.value.length && count > 0
  halfState.value = count > 0 && count < cartList.value.length
  checkedPriceAndCount()
}

function checkedPriceAndCount() {
  checkCount.value = 0; checkPrice.value = 0
  cartList.value.forEach(c => {
    if (c.checkState) {
      checkCount.value += c.count
      if (c.goods) checkPrice.value += c.goods.price * c.count
    }
  })
}

function updatecount(cart) {
  cartApi.update({ id: cart.id, count: cart.count }).then(resp => {
    if (resp.code === 200) {
      cartApi.selectById(cart.id).then(r => {
        cartList.value.forEach(c => { if (c.id === cart.id) c.count = r.data.count })
        checkedPriceAndCount()
      })
    } else ElMessage.error(resp.msg)
  })
}

function deleteCart(id) {
  cartApi.delete(id).then(resp => {
    if (resp.code === 200) {
      ElMessage.success(resp.msg)
      cartList.value = cartList.value.filter(c => c.id !== id)
      changeState()
    }
  })
}

function deleteChecked() {
  const ids = cartList.value.filter(c => c.checkState).map(c => c.id)
  if (ids.length === 0) { return ElMessage.warning('没有选择商品') }
  cartApi.deleteByIds(ids).then(resp => {
    if (resp.code === 200) { ElMessage.success(resp.msg); selectBycondition() }
  })
}

function deleteAll() {
  const ids = cartList.value.map(c => c.id)
  if (ids.length === 0) return
  cartApi.deleteByIds(ids).then(resp => {
    if (resp.code === 200) { ElMessage.success(resp.msg); selectBycondition() }
  })
}

function toCreateOrder() {
  const ids = cartList.value.filter(c => c.checkState).map(c => c.id)
  if (ids.length === 0) { ElMessage.warning('请选择要结算的商品'); return }
  router.push({ path: '/user/createOrder', query: { cartIds: ids } })
}

selectBycondition()
</script>

<template>
  <div v-if="cartList.length > 0">
    <el-row class="title"><el-col :span="2"><el-checkbox v-model="checkAllState" :indeterminate="halfState" @change="checkAllOrderNone">全选</el-checkbox></el-col><el-col :span="11">商品</el-col><el-col :span="2">单价</el-col><el-col :span="6">数量</el-col><el-col :span="2">小计</el-col><el-col :span="1">操作</el-col></el-row>
    <el-row v-for="cart in cartList" :key="cart.id" class="cartItem">
      <el-col :span="2"><el-checkbox v-model="cart.checkState" @change="changeState"/></el-col>
      <el-col :span="2"><el-image v-if="cart.goods?.picList?.length" :src="SERVER_ADDR+'/goods/pic/'+cart.goods.picList[0].url" style="width:50px;height:50px" fit="contain"/><div v-else class="no-pic-sm">无图</div></el-col>
      <el-col :span="9">{{ cart.goods?.name || '商品已下架' }}</el-col>
      <el-col :span="2">{{ cart.goods?.price || '-' }}</el-col>
      <el-col :span="6"><el-input-number v-model="cart.count" :min="1" @change="updatecount(cart)"/></el-col>
      <el-col :span="2">{{ cart.goods ? (cart.goods.price * cart.count).toFixed(2) : '-' }}</el-col>
      <el-col :span="1"><el-button type="danger" @click="deleteCart(cart.id)"><el-icon><delete/></el-icon></el-button></el-col>
    </el-row>
    <el-row class="title"><el-col :span="2"><el-checkbox v-model="checkAllState" :indeterminate="halfState" @change="checkAllOrderNone">全选</el-checkbox></el-col><el-col :span="3"><el-link :underline="false" @click="deleteChecked">删除选中</el-link></el-col><el-col :span="11"><el-link :underline="false" @click="deleteAll">清理购物车</el-link></el-col><el-col :span="3">已选{{checkCount}}件</el-col><el-col :span="3">总价: ¥{{checkPrice}}</el-col><el-col :span="2"><el-button type="danger" @click="toCreateOrder">结算</el-button></el-col></el-row>
  </div>
  <div v-else class="none">
    <div class="empty-cart"><el-icon class="empty-icon"><ShoppingCart/></el-icon><p class="empty-text">购物车是空的</p><p class="empty-hint">快去挑选心仪的商品吧</p><el-button type="primary" @click="router.push('/user/index')">去逛逛</el-button></div>
  </div>
</template>

<style scoped>
.title { margin:20px 0; padding-left:10px; font-size:14px; background:#fff; height:40px; line-height:40px; }
.cartItem { border-bottom:1px solid #eee; padding-left:10px; font-size:14px; background:#fff; height:60px; line-height:60px; transition:background 0.2s; }
.cartItem:hover { background:#fafafa; }
.no-pic-sm { width:50px; height:50px; background:#f5f5f5; display:flex; align-items:center; justify-content:center; color:#999; font-size:12px; }
.none { display:flex; align-items:center; justify-content:center; min-height:400px; background:#fff; }
.empty-cart { text-align:center; }
.empty-icon { font-size:64px; color:#ccc; margin-bottom:16px; }
.empty-text { font-size:18px; color:#666; margin-bottom:8px; }
.empty-hint { font-size:14px; color:#999; margin-bottom:20px; }
</style>

26.8 admin LoginView.vue(管理员登录)

<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import adminApi from '@/api/AdminApi.js'
import { userTokenStore } from '@/stores/token.js'

const router = useRouter()
const tokenStore = userTokenStore()
const form = ref({ username: '', password: '', captchaInput: '' })
const captchaData = ref({ key: '', imageBase64Data: '' })

function loadCaptcha() {
  adminApi.captcha().then(resp => { if (resp.code === 200) captchaData.value = resp.data })
}

function login() {
  adminApi.login({ username: form.value.username, password: form.value.password, key: captchaData.value.key, captchaInput: form.value.captchaInput }).then(resp => {
    if (resp.code === 200) { tokenStore.updateToken(resp.data); router.push('/admin/index') }
    else { ElMessage.error(resp.msg); loadCaptcha() }
  })
}

loadCaptcha()
</script>

<template>
  <div class="login-page">
    <el-card class="login-card">
      <h2>管理员登录</h2>
      <el-form :model="form" label-width="0">
        <el-form-item><el-input v-model="form.username" placeholder="用户名" /></el-form-item>
        <el-form-item><el-input v-model="form.password" type="password" placeholder="密码" show-password /></el-form-item>
        <el-form-item><div class="captcha-row"><el-input v-model="form.captchaInput" placeholder="验证码" style="width:60%"/><img :src="captchaData.imageBase64Data" @click="loadCaptcha" class="captcha-img" title="刷新"/></div></el-form-item>
        <el-form-item><el-button type="primary" style="width:100%" @click="login">登 录</el-button></el-form-item>
      </el-form>
    </el-card>
  </div>
</template>

<style scoped>
.login-page { display:flex; justify-content:center; align-items:center; min-height:100vh; background:#f5f7fa; }
.login-card { width:400px; border-radius:12px; padding:40px; }
.login-card h2 { text-align:center; margin-bottom:30px; }
.captcha-row { display:flex; gap:12px; align-items:center; }
.captcha-img { height:38px; cursor:pointer; border:1px solid #ddd; border-radius:4px; }
</style>

27. 全部工具类和配置类代码

27.1 RedisConfig

// common/src/main/java/org/example/mall/common/config/RedisConfig.java
package org.example.mall.common.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        // Key 序列化用 String
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        // Value 序列化用 JSON
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
        return template;
    }
}

27.2 各微服务 WebConfig 完整代码

goods_service/WebConfig.java

package org.example.mall.goods_service.config;

import org.example.mall.common.intercepter.JwtInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Value("${com.picdir}")
    private String picdir;

    @Autowired
    private JwtInterceptor jwtInterceptor;

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        // 图片静态文件映射:/category/pic/xxx.jpg → 文件系统 picdir/xxx.jpg
        registry.addResourceHandler("/category/pic/**", "/goods/pic/**")
                .addResourceLocations("file:" + picdir);
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 只拦截写操作(增删改),读操作放行
        registry.addInterceptor(jwtInterceptor)
                .addPathPatterns("/category/**", "/goods/**")
                .excludePathPatterns(
                    "/category/search", "/category/allParent",
                    "/category/{id}", "/category/**/pic/**",
                    "/goods", "/goods/search", "/goods/{id}",
                    "/goods/**/pic/**", "/goods/upload"
                );
    }
}

user_service/WebConfig.java

package org.example.mall.user_service.config;

import org.example.mall.common.intercepter.JwtInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private JwtInterceptor jwtInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // /user/** 和 /collect/** 需要认证
        radio.addInterceptor(jwtInterceptor)
                .addPathPatterns("/user/**", "/collect/**");
        // /addr/** 不在拦截范围——AddrController每个方法手动校验token
    }
}

admin_service/WebConfig.java

package org.example.mall.admin_service.config;

import org.example.mall.common.intercepter.JwtInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private JwtInterceptor jwtInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(jwtInterceptor)
                .addPathPatterns("/admin/**")
                .excludePathPatterns("/admin/captcha", "/admin/login");
    }
}

order_service/WebConfig.java

package org.example.mall.order_service.config;

import org.example.mall.common.intercepter.JwtInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Value("${com.picdir}")
    private String picdir;

    @Autowired
    private JwtInterceptor jwtInterceptor;

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/category/pic/**", "/goods/pic/**")
                .addResourceLocations("file:" + picdir);
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 购物车接口需要认证
        registry.addInterceptor(jwtInterceptor)
                .addPathPatterns("/cart/**");
        // /order/** 不拦截——Controller按端点区分:
        // create/pay/cancel/myOrders → 手动 @RequestHeader("token")
        // notify → 不需要token(支付宝回调)
        // query/search/selectById → 开放或精简返回
    }
}

28. 全部 Mapper XML 完整 SQL 清单

28.1 goods_service Mapper SQL

GoodsMapper.xml(完整版,关键SQL):

<mapper namespace="org.example.mall.goods_service.mapper.GoodsMapper">

    <resultMap id="goodsMap" type="goods">
        <id column="id" property="id"/>
        <result column="category_id" property="categoryId"/>
        <association column="category_id" property="category" javaType="category"
                     select="org.example.mall.goods_service.mapper.CategoryMapper.selectById"/>
        <collection column="id" property="picList" ofType="goodsPic"
                    select="org.example.mall.goods_service.mapper.GoodsPicMapper.selectByGoodsId"/>
    </resultMap>

    <insert id="insert" parameterType="goods" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO goods(name,decp,detail,price,market_price,purchase_price,
                          color,version,count,recom,category_id,score,status)
        VALUES (#{name},#{decp},#{detail},#{price},#{marketPrice},#{purchasePrice},
                #{color},#{version},#{count},#{recom},#{categoryId},#{score},#{status})
    </insert>

    <update id="update" parameterType="goods">
        UPDATE goods
        <set>
            <if test="name!=null">name=#{name},</if>
            <if test="decp!=null">decp=#{decp},</if>
            <if test="detail!=null">detail=#{detail},</if>
            <if test="price!=null">price=#{price},</if>
            <if test="marketPrice!=null">market_price=#{marketPrice},</if>
            <if test="purchasePrice!=null">purchase_price=#{purchasePrice},</if>
            <if test="color!=null">color=#{color},</if>
            <if test="version!=null">version=#{version},</if>
            <if test="count!=null">count=#{count},</if>
            <if test="recom!=null">recom=#{recom},</if>
            <if test="categoryId!=null">category_id=#{categoryId},</if>
            <if test="status!=null">status=#{status},</if>
        </set>
        WHERE id=#{id}
    </update>

    <delete id="delete">DELETE FROM goods WHERE id=#{id}</delete>

    <select id="selectByid" resultMap="goodsMap">
        SELECT * FROM goods WHERE id=#{id}
    </select>

    <select id="selectByCondition" resultMap="goodsMap">
        SELECT * FROM goods
        <where>
            <if test="name!=null">AND name LIKE CONCAT('%',#{name},'%')</if>
            <if test="categoryId!=null">AND category_id=#{categoryId}</if>
            <if test="status!=null">AND status=#{status}</if>
        </where>
    </select>

    <select id="selectByCondition1" resultMap="goodsMap">
        SELECT g.* FROM goods g, category c
        <where>
            g.category_id=c.id
            <if test="name!=null">AND g.name LIKE CONCAT('%',#{name},'%')</if>
            <if test="categoryId!=null">AND (c.parent_id=#{categoryId} OR c.id=#{categoryId})</if>
            <if test="status!=null">AND g.status=#{status}</if>
            <if test="recom!=null">AND g.recom=#{recom}</if>
        </where>
    </select>

    <select id="selectByCategoryId" resultMap="goodsMap">
        SELECT g.* FROM goods g, category c
        WHERE g.category_id=c.id AND g.status=1 AND (c.parent_id=#{categoryId} OR c.id=#{categoryId})
    </select>

    <!-- 扣减库存:WHERE count>=#{count} 防超卖 -->
    <update id="reduceStock">
        UPDATE goods SET count = count - #{count} WHERE id = #{id} AND count >= #{count}
    </update>
</mapper>

GoodsPicMapper.xml

<mapper namespace="org.example.mall.goods_service.mapper.GoodsPicMapper">
    <insert id="insert">
        INSERT INTO goods_pic(url, goods_id) VALUES
        <foreach collection="picList" item="pic" separator=",">
            (#{pic.url}, #{pic.goodsId})
        </foreach>
    </insert>
    <delete id="delete">DELETE FROM goods_pic WHERE goods_id = #{goodsId}</delete>
    <select id="selectByGoodsId" resultType="org.example.mall.common.bean.GoodsPic">
        SELECT * FROM goods_pic WHERE goods_id = #{goodsId}
    </select>
</mapper>

CategoryMapper.xml

<mapper namespace="org.example.mall.goods_service.mapper.CategoryMapper">
    <insert id="insert" parameterType="category" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO category(name, dscp, pic, parent_id, recom, status)
        VALUES (#{name}, #{dscp}, #{pic}, #{parentId}, #{recom}, #{status})
    </insert>
    <update id="update" parameterType="category">
        UPDATE category
        <set>
            <if test="name!=null">name=#{name},</if>
            <if test="dscp!=null">dscp=#{dscp},</if>
            <if test="pic!=null">pic=#{pic},</if>
            <if test="parentId!=null">parent_id=#{parentId},</if>
            <if test="recom!=null">recom=#{recom},</if>
            <if test="status!=null">status=#{status},</if>
        </set>
        WHERE id=#{id}
    </update>
    <delete id="delete">DELETE FROM category WHERE id=#{id}</delete>
    <select id="selectById" resultType="category">SELECT * FROM category WHERE id=#{id}</select>
    <select id="selectAllParent" resultType="category">
        SELECT * FROM category WHERE parent_id=0 AND status=1
    </select>
    <select id="selectByCondition" resultType="category">
        SELECT * FROM category
        <where>
            <if test="name!=null">AND name LIKE CONCAT('%',#{name},'%')</if>
            <if test="parentId!=null">AND parent_id=#{parentId}</if>
            <if test="status!=null">AND status=#{status}</if>
        </where>
        ORDER BY parent_id, id
    </select>
</mapper>

28.2 user_service Mapper SQL

UserMapper.xml

<mapper namespace="org.example.mall.user_service.mapper.UserMapper">
    <insert id="insert" parameterType="user">
        INSERT INTO user(username,password,salt,realname,gender,phone,email,money,status)
        VALUES (#{username},#{password},#{salt},#{realname},#{gender},#{phone},#{email},#{money},#{status})
    </insert>
    <update id="updateById" parameterType="user">
        UPDATE user
        <set>
            <if test="password!=null">password=#{password},</if>
            <if test="salt!=null">salt=#{salt},</if>
            <if test="realname!=null">realname=#{realname},</if>
            <if test="gender!=null">gender=#{gender},</if>
            <if test="idCard!=null">id_card=#{idCard},</if>
            <if test="phone!=null">phone=#{phone},</if>
            <if test="email!=null">email=#{email},</if>
            <if test="money!=null">money=#{money},</if>
            <if test="status!=null">status=#{status},</if>
        </set>
        WHERE id=#{id}
    </update>
    <delete id="delete">DELETE FROM user WHERE id=#{id}</delete>
    <select id="selectById" resultType="user">SELECT * FROM user WHERE id=#{id}</select>
    <select id="selectByUsername" resultType="user">SELECT * FROM user WHERE username=#{username}</select>
    <select id="selectByCondition" resultType="user">
        SELECT * FROM user
        <where>
            <if test="username!=null">AND username LIKE CONCAT('%',#{username},'%')</if>
            <if test="phone!=null">AND phone LIKE CONCAT('%',#{phone},'%')</if>
            <if test="email!=null">AND email LIKE CONCAT('%',#{email},'%')</if>
            <if test="status!=null">AND status=#{status}</if>
        </where>
    </select>
</mapper>

CollectMapper.xml(完整版):

<mapper namespace="org.example.mall.user_service.mapper.CollectMapper">

    <resultMap id="collectWithGoods" type="collect">
        <id column="c_id" property="id"/>
        <result column="goods_id" property="goodsId"/>
        <result column="user_id" property="userId"/>
        <result column="c_create_time" property="createTime"/>
        <association property="goods" javaType="org.example.mall.common.bean.Goods">
            <id column="g_id" property="id"/>
            <result column="g_name" property="name"/>
            <result column="g_price" property="price"/>
            <result column="g_market_price" property="marketPrice"/>
            <result column="g_status" property="status"/>
            <result column="g_color" property="color"/>
            <result column="g_version" property="version"/>
            <result column="g_count" property="count"/>
            <collection property="picList" ofType="org.example.mall.common.bean.GoodsPic">
                <id column="gp_id" property="id"/>
                <result column="gp_url" property="url"/>
                <result column="gp_goods_id" property="goodsId"/>
            </collection>
        </association>
    </resultMap>

    <insert id="insert" parameterType="collect">
        INSERT INTO collect(goods_id, user_id) VALUES (#{goodsId}, #{userId})
    </insert>

    <delete id="delect">DELETE FROM collect WHERE id=#{id}</delete>

    <select id="selectById" resultType="collect">SELECT * FROM collect WHERE id=#{id}</select>

    <select id="selectByGoodsIdAndUserId" resultType="collect">
        SELECT * FROM collect WHERE goods_id=#{goodsId} AND user_id=#{userId}
    </select>

    <!-- LEFT JOIN干三表,一次SQL拿回收藏+商品+图片 -->
    <select id="selectByUserId" resultMap="collectWithGoods">
        SELECT c.id AS c_id, c.goods_id, c.user_id, c.create_time AS c_create_time,
               g.id AS g_id, g.name AS g_name, g.price AS g_price,
               g.market_price AS g_market_price, g.status AS g_status,
               g.color AS g_color, g.version AS g_version, g.count AS g_count,
               gp.id AS gp_id, gp.url AS gp_url, gp.goods_id AS gp_goods_id
        FROM collect c
        LEFT JOIN goods g ON c.goods_id = g.id
        LEFT JOIN goods_pic gp ON g.id = gp.goods_id
        WHERE c.user_id = #{userId}
        ORDER BY c.create_time DESC
    </select>
</mapper>

AddrMapper.xml

<mapper namespace="org.example.mall.user_service.mapper.AddrMapper">
    <insert id="insert" parameterType="addr">
        INSERT INTO addr(contact,phone,province,city,district,street,address,user_id,status)
        VALUES (#{contact},#{phone},#{province},#{city},#{district},#{street},#{address},#{userId},#{status})
    </insert>
    <update id="update" parameterType="addr">
        UPDATE addr
        <set>
            <if test="contact!=null and contact!=''">contact=#{contact},</if>
            <if test="phone!=null and phone!=''">phone=#{phone},</if>
            <if test="province!=null">province=#{province},</if>
            <if test="city!=null">city=#{city},</if>
            <if test="district!=null">district=#{district},</if>
            <if test="street!=null">street=#{street},</if>
            <if test="address!=null and address!=''">address=#{address},</if>
            <if test="status!=null">status=#{status},</if>
        </set>
        WHERE id=#{id}
    </update>
    <delete id="delete">DELETE FROM addr WHERE id=#{id}</delete>
    <select id="selectById" resultType="addr">SELECT * FROM addr WHERE id=#{id}</select>
    <select id="selectByUserId" resultType="addr">
        SELECT * FROM addr WHERE user_id=#{userId} ORDER BY status DESC, id DESC
    </select>
    <update id="clearDefault">UPDATE addr SET status=0 WHERE user_id=#{userId}</update>
    <update id="setDefault">UPDATE addr SET status=1 WHERE id=#{id}</update>
</mapper>

28.3 admin_service Mapper SQL

AdminMapper.xml

<mapper namespace="org.example.mall.admin_service.mapper.AdminMapper">
    <insert id="insert" parameterType="admin">
        INSERT INTO admin(username,password,salt,phone,email,realname,status)
        VALUES (#{username},#{password},#{salt},#{phone},#{email},#{realname},#{status})
    </insert>
    <update id="update" parameterType="admin">
        UPDATE admin
        <set>
            <if test="password!=null">password=#{password},</if>
            <if test="salt!=null">salt=#{salt},</if>
            <if test="phone!=null">phone=#{phone},</if>
            <if test="email!=null">email=#{email},</if>
            <if test="realname!=null">realname=#{realname},</if>
            <if test="status!=null">status=#{status},</if>
        </set>
        WHERE id=#{id}
    </update>
    <delete id="delete">DELETE FROM admin WHERE id=#{id}</delete>
    <select id="selectById" resultType="admin">SELECT * FROM admin WHERE id=#{id}</select>
    <select id="selectByUsername" resultType="admin">SELECT * FROM admin WHERE username=#{username}</select>
    <select id="selectByCondition" resultType="admin">
        SELECT * FROM admin
        <where>
            <if test="username!=null">AND username LIKE CONCAT('%',#{username},'%')</if>
            <if test="phone!=null">AND phone LIKE CONCAT('%',#{phone},'%')</if>
            <if test="status!=null">AND status=#{status}</if>
        </where>
    </select>
</mapper>

28.4 order_service Mapper SQL

CartMapper.xml

<mapper namespace="org.example.mall.order_service.mapper.CartMapper">
    <insert id="insert" parameterType="cart">
        INSERT INTO cart(count, user_id, goods_id) VALUES (#{count}, #{userId}, #{goodsId})
    </insert>
    <update id="update" parameterType="cart">
        UPDATE cart
        <set>
            <if test="count!=null">count=#{count},</if>
            <if test="userId!=null">user_id=#{userId},</if>
            <if test="goodsId!=null">goods_id=#{goodsId},</if>
        </set>
        WHERE id=#{id}
    </update>
    <delete id="delete">DELETE FROM cart WHERE id=#{id}</delete>
    <delete id="deleteByIds">
        DELETE FROM cart WHERE id IN
        <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
    </delete>
    <select id="selectById" resultType="Cart">SELECT * FROM cart WHERE id=#{id}</select>
    <select id="selectByGoodsIdAndUserId" resultType="Cart">
        SELECT * FROM cart WHERE user_id=#{userId} AND goods_id=#{goodsId}
    </select>
    <select id="selectByCondition" resultType="org.example.mall.common.bean.Cart">
        SELECT * FROM cart
        <where>
            <if test="id!=null">AND id=#{id}</if>
            <if test="goodsId!=null">AND goods_id=#{goodsId}</if>
            <if test="userId!=null">AND user_id=#{userId}</if>
        </where>
    </select>
</mapper>

OrderMapper.xml(完整版):

<mapper namespace="org.example.mall.order_service.mapper.OrderMapper">
    <resultMap id="orderMap" type="org.example.mall.common.bean.Order">
        <id column="id" property="id"/>
        <result column="user_id" property="userId"/>
        <result column="pay_type" property="payType"/>
        <result column="express" property="express"/>
        <result column="addr_id" property="addrId"/>
        <result column="addr_detail" property="addrDetail"/>
        <result column="status" property="status"/>
        <result column="items" property="items"/>
        <result column="create_time" property="createTime"/>
        <result column="username" property="username"/>
    </resultMap>

    <insert id="insert" parameterType="org.example.mall.common.bean.Order">
        INSERT INTO `order`(id, user_id, pay_type, express, addr_id, addr_detail, status, items, create_time)
        VALUES (#{id}, #{userId}, #{payType}, #{express}, #{addrId}, #{addrDetail}, #{status}, #{items}, NOW())
    </insert>

    <select id="selectByCondition" resultMap="orderMap">
        SELECT o.*, u.username FROM `order` o LEFT JOIN `user` u ON o.user_id = u.id
        <where>
            <if test="condition.userId!=null">AND o.user_id = #{condition.userId}</if>
            <if test="condition.status!=null">AND o.status = #{condition.status}</if>
        </where>
        ORDER BY o.create_time DESC
    </select>

    <select id="selectById" resultMap="orderMap">
        SELECT o.*, u.username FROM `order` o LEFT JOIN `user` u ON o.user_id = u.id WHERE o.id = #{id}
    </select>

    <select id="selectByUserId" resultMap="orderMap">
        SELECT o.*, u.username FROM `order` o LEFT JOIN `user` u ON o.user_id = u.id
        WHERE o.user_id = #{userId} ORDER BY o.create_time DESC
    </select>

    <select id="selectAddrById" resultType="org.example.mall.common.bean.Addr">
        SELECT * FROM addr WHERE id = #{id}
    </select>

    <update id="updateById">
        UPDATE `order`
        <set>
            <if test="status!=null">status=#{status},</if>
            <if test="express!=null and express!=''">express=#{express},</if>
            <if test="addrDetail!=null and addrDetail!=''">addr_detail=#{addrDetail},</if>
            <if test="items!=null">items=#{items},</if>
        </set>
        WHERE id = #{id}
    </update>

    <delete id="delete">DELETE FROM `order` WHERE id = #{id}</delete>

    <update id="cancelExpiredOrders">
        UPDATE `order` SET status = 4 WHERE status = 0 AND create_time &lt; #{before}
    </update>
</mapper>

29. 部署详细步骤

29.1 环境变量注入方式

方式一:export(当前终端有效)

export ALIPAY_APP_ID=9021000165698953
export ALIPAY_PRIVATE_KEY="MIIEvAIBADAN..."
export ALIPAY_PUBLIC_KEY="MIIBIjAN..."
export ALIPAY_RETURN_URL=http://你的IP/#/user/payResult
export ALIPAY_NOTIFY_URL=http://你的IP/order/notify
java -jar order_service/target/order_service-0.0.1-SNAPSHOT.jar

方式二:systemd服务(Linux长期运行推荐)

[Unit]
Description=Mall Order Service
[Service]
User=mall
WorkingDirectory=/opt/mall
ExecStart=/usr/bin/java -jar /opt/mall/order_service.jar
Environment="ALIPAY_APP_ID=9021000165698953"
Environment="ALIPAY_PRIVATE_KEY=MIIEvAIBADANBgkqh..."
Environment="ALIPAY_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEF..."
Environment="ALIPAY_RETURN_URL=http://你的域名/#/user/payResult"
Environment="ALIPAY_NOTIFY_URL=http://你的域名/order/notify"
Restart=always
[Install]
WantedBy=multi-user.target

方式三:Windows批处理

set ALIPAY_APP_ID=9021000165698953
set ALIPAY_PRIVATE_KEY=MIIEvAIBADANBgkqhkiG...
set ALIPAY_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEF...
set ALIPAY_RETURN_URL=http://你的IP/#/user/payResult
set ALIPAY_NOTIFY_URL=http://你的IP/order/notify
java -jar order_service-0.0.1-SNAPSHOT.jar

29.2 JVM启动参数优化

java -Xms256m -Xmx512m -XX:+UseG1GC -jar xxx.jar
参数 说明
-Xms 256m 初始堆内存
-Xmx 512m 最大堆内存
-XX:+UseG1GC 使用G1垃圾回收器

29.3 nginx前端部署

# 1. 复制前端产物的静态文件
cp -r front/dist/* /usr/share/nginx/html/

# 2. 复制nginx配置文件
cp nginx/conf/nginx.conf /etc/nginx/nginx.conf

# 3. 测试配置+重载
nginx -t
nginx -s reload

29.4 打包脚本(一键打包前后端)

#!/bin/bash
# build.sh
set -e

echo "=== 打包后端 ==="
mvn clean package -DskipTests -q
echo "后端打包完成"

echo "=== 打包前端 ==="
cd front
npm run build
cd ..
echo "前端打包完成"

echo "产物:"
echo "  前端: front/dist/"
echo "  后端:"
ls -lh */target/*.jar

30. Dubbo 启动验证

30.1 确认 ZooKeeper 注册成功

启动 goods_service 后,用 ZooKeeper 客户端检查:

# 连接 ZooKeeper
cd zookeeper-x.x.x
bin/zkCli.cmd -server 127.0.0.1:2181

# 查看注册的 Dubbo 服务
ls /dubbo/org.example.mall.common.service.GoodsService/providers

# 应该看到一个 URL 编码的地址,如:
# [dubbo://192.168.1.5:38090/org.example.mall.common.service.GoodsService?...]

如果能看到 provider 地址,说明 goods_service 已成功注册到 ZooKeeper,可以被 order_service 调用了。

30.2 验证 Dubbo 调用

启动 order_service 后,看启动日志:

[Dubbo] Registering consumer for service org.example.mall.common.service.GoodsService

表示 order_service 作为消费者已连上 goods_service。

实际调用测试:进入前台→加入购物车→点击结算→如果看到商品清单,说明 Dubbo 调用成功(order_service 通过 Dubbo 从 goods_service 查到了商品信息)。

30.3 常见 Dubbo 启动问题

现象 原因 解决
Failed to connect to /127.0.0.1:2181 ZooKeeper还没启动 先启动zkServer.cmd
No provider available goods_service没注册成功 检查 goods_service 日志、@EnableDubbo是否正确、application.yml dubbo配置
Connection refused Dubbo端口被防火墙拦 开放38090等端口或关闭防火墙测试

31. 完整 API 响应示例

31.1 下单成功响应

{
  "code": 200,
  "msg": "下单成功",
  "data": {
    "id": "1876543210987654321",
    "userId": 1,
    "payType": 1,
    "addrId": 1,
    "addrDetail": "山东省青岛市城阳区城阳街道 农大西苑A座 - 汤姆 - 13322332233",
    "status": 0,
    "items": "[{"goodsId":45,"goodsName":"索尼 WH-1000XM5","goodsPic":"goods_045_main.jpg","price":2399.00,"count":2,"subtotal":4798.00}]",
    "createTime": "2026-07-28 15:30:00",
    "detailList": [
      {
        "goodsId": 45,
        "goodsName": "索尼 WH-1000XM5",
        "goodsPic": "goods_045_main.jpg",
        "price": 2399.00,
        "count": 2,
        "subtotal": 4798.00
      }
    ],
    "totalPrice": 4798.00
  }
}

31.2 支付表单响应

{
  "code": 200,
  "msg": "",
  "data": "<form name="punchout_form" method="post" action="https://openapi-sandbox.dl.alipaydev.com/gateway.do?charset=utf-8&method=alipay.trade.page.pay&sign=..."><input type="hidden" name="biz_content" value="{...}"/><input type="submit" value="submit" style="display:none"/></form><script>document.forms[0].submit();</script>"
}

31.3 仪表盘响应

{
  "code": 200,
  "msg": "",
  "data": {
    "totalOrders": 100,
    "totalUsers": 50,
    "totalGoods": 30,
    "todayOrders": 5,
    "todaySales": 15997.00,
    "statusCnt": {
      "pending": 10,
      "paid": 80,
      "shipped": 3,
      "completed": 5,
      "cancelled": 2
    },
    "recentOrders": [
      {"id": "1876543210987654321", "userId": 1, "status": 1, "createTime": "2026-07-28 15:30:00", "username": "tom"}
    ],
    "recentUsers": [
      {"id": 5, "username": "cindy", "phone": "15588889999", "email": "cindy@mall-demo.com", "regTime": "2025-01-05 12:00:00", "status": 0}
    ]
  }
}

至此,三份文档完整覆盖了项目的:

  • 全部功能原理 +代码逐行注释(PROJECT_DOC.md 第1-18章)
  • 全部环境搭建 + 配置 + 部署步骤(DOC_APPENDIX.md 第19-25章)
  • 全部缺失代码 + Mapper XML + 前端页面 + 工具类 + API示例 + Dubbo验证(本文档第26-31章)

三份文档加起来可以:理解每一行代码 → 从零搭建环境 → 写出全部文件 → 部署运行 → 验证功能


32. 代码功能与原理解释

以下对报告3中每段关键代码逐一解释:这段代码是干什么的、输入是什么、输出是什么、为什么这么设计、和谁配合

32.1 登录页面 (LoginView.vue)

loginFormcaptchaData — 响应式数据

const loginForm = ref({ username: '', password: '', captchaInput: '' })
const captchaData = ref({ key: '', imageBase64Data: '' })
  • ref() 是 Vue 3 的响应式包装函数。普通变量改了值页面不会更新,ref() 包装后改了值模板自动重新渲染
  • loginForm 存用户输入的三个字段:用户名、密码、验证码(v-model="loginForm.username" 双向绑定)
  • captchaData 存验证码的元数据:key 是雪花 ID 用于 Redis 查找,imageBase64Data 是验证码图片的 base64 编码字符串(直接给 <img :src> 渲染)

loadCaptcha() — 获取验证码

function loadCaptcha() {
  userApi.captcha().then(resp => {
    if (resp.code === 200) captchaData.value = resp.data
  })
}

调用链:前端 → Axios → nginx /user → user_service (8092) → UserController.captcha()

后端做了什么:用 Hutool 生成一张包含 4 位数字的验证码图片 → 生成雪花 ID 作为 key → redisUtil.set(key, 验证码文本) 存入 Redis → 把图片转成 base64 字符串 → 返回 {key, imageBase64Data}

原理:验证码的文本存在 Redis(服务器内存),图片发给用户浏览器。用户提交登录时传 key+captchaInput,后端用 key 去 Redis 查对应的验证码文本,跟 captchaInput 比对 → 防止机器人批量注册/登录

login() — 登录流程

function login() {
  if (!loginForm.value.username || !loginForm.value.password) {
    ElMessage.warning('请输入用户名和密码')
    return
  }
  userApi.login({...}).then(resp => {
    if (resp.code === 200) {
      tokenStore.updateToken(resp.data)  // 存 JWT 到 Pinia → 自动同步 sessionStorage
      ElMessage.success('登录成功')
      userApi.getInfo().then(r => {
        if (r.code === 200) userStore.updateUser(r.data)  // 拿到用户信息存到 store
      })
      router.push('/user/index')  // 跳首页
    } else {
      ElMessage.error(resp.msg)
      loadCaptcha()  // 登录失败 → 刷新验证码(防止暴力破解)
    }
  })
}

关键设计:登录成功后的两个操作是串行的——先存 JWT(所有后续请求都需要),再用 JWT 去拿用户信息(/user/info 从 JWT 解析 userId → 查数据库返回用户对象)。存 JWT 和拿用户信息分开是因为 JWT 只存了 id+username,完整信息(手机/邮箱/余额)要从数据库查。

验证码刷新机制

<img :src="captchaData.imageBase64Data" @click="loadCaptcha" title="点击刷新" />

为什么 @click="loadCaptcha" 就能刷新:用户点一下验证码图片 → 重新调 userApi.captcha() → 后端生成新验证码(新 key+新图片)→ 旧验证码自动失效(新 key 把旧的覆盖?不会;旧 key 还在 Redis 但登录时只用新 key 去校验)


32.2 注册页面 (RegView.vue)

reg() — 注册表单提交

function reg() {
  if (!form.value.username || !form.value.password) {
    ElMessage.warning('用户名和密码不能为空'); return
  }
  if (form.value.password !== form.value.password1) {
    ElMessage.warning('两次密码不一致'); return
  }
  userApi.reg({...}).then(resp => {
    if (resp.code === 200) {
      ElMessage.success('注册成功,请登录')
      router.push('/user/login')  // 注册成功 → 跳到登录页(不是自动登录)
    } else {
      ElMessage.error(resp.msg)
      loadCaptcha()  // 失败刷新验证码
    }
  })
}

为什么注册成功不自动登录:后端 reg() 只返回 RespBean.success("注册成功") 不带 JWT。自动登录需要前端在注册成功后立即调 userApi.login() — 可以实现但本项目未做。用户需手动登录一次。

后端 UserServiceImpl.reg()

String salt = RandomUtil.randomString(8);      // 生成 8 位随机盐
String md5Pwd = SecureUtil.md5(SecureUtil.md5(user.getPassword() + salt));

双重 MD5 加盐原理: ① 用户明文密码 “123456” + 随机盐 “981a735b” → MD5 → “c34c4c…” ② 再把 “c34c4c…” MD5 一次 → “最终密文” 目的:即使两个用户的明文密码相同,盐不同导致密文不同。攻击者拿到数据库也看不到原始密码。


32.3 商品搜索/分类浏览 (SearchView.vue)

双数据源加载

onMounted(() => { loadCategories(); loadGoods() })

页面加载时同时发起两个请求:分类列表(导航标签用)和商品列表(商品网格用)。

loadCategories() — 分类列表

function loadCategories() {
  categoryApi.selectAllParent().then(resp => {
    if (resp.code === 200) categories.value = resp.data || []
  })
}

后端 CategoryMapper.selectAllParentSELECT * FROM category WHERE parent_id=0 AND status=1 — 只查顶级分类(手机数码、电脑办公等)且只查上架的。

loadGoods() — 商品列表

function loadGoods() {
  const cid = route.params.categoryId || activeCategoryId.value
  goodsApi.selectByPage1({ categoryId: cid || undefined }, pageNum.value, pageSize.value)
    .then(resp => {
      if (resp.data) {
        goodsList.value = resp.data.list || []
        total.value = resp.data.total || 0
      }
    })
}

selectByPage1 的 SQL(GoodsMapper.xml):

SELECT g.* FROM goods g, category c
WHERE g.category_id=c.id
  AND (c.parent_id=#{categoryId} OR c.id=#{categoryId})  -- ★关键:查父分类下的所有子分类商品

当用户选择”手机数码”(category_id=1),SQL 会查出所有 category_id 属于手机数码子分类(智能手机、老人机、数码配件等)的商品。这是”模糊分类搜索”的实现。

watch() — 路由变化监听

watch(() => route.params.categoryId, () => {
  activeCategoryId.value = Number(route.params.categoryId) || null
  pageNum.value = 1  // 切换分类→回到第一页
  loadGoods()
})

作用:当用户从首页点分类链接(如 /user/search/1),URL 里的 categoryId 变了 → watch 自动触发 → 重新加载该分类的商品。不写 watch 的话 URL 变了但页面不变。

分类标签的激活态样式

.tag.active { background: var(--theme-color, #FF6800); color: #fff; }

选中分类的标签变橙色背景白色文字;没选中的灰色背景。var(--theme-color, #FF6800) 意思是优先用 CSS 变量 --theme-color,如果没定义就回退到 #FF6800


32.4 用户首页 (IndexView.vue)

loadRecom() — 按父分类分组加载推荐商品

function loadRecom() {
  categoryApi.selectAllParent().then(resp => {
    const parents = resp.data || []
    const promises = parents.map(parent =>
      goodsApi.selectByPage1({ categoryId: parent.id, recom: 1 }, 1, 5)
        .then(r => ({ category: parent, goodsList: r.data ? r.data.list : [] }))
    )
    Promise.all(promises).then(results => {
      recomGroups.value = results.filter(g => g.goodsList.length > 0)
    })
  })
}

Promise.all 的作用:每个父分类的推荐商品请求是一个独立的 Promise。Promise.all(promises) 等待所有请求都完成(不是逐个等待),然后一次性处理所有结果。如果父分类有 5 个 → 同时发 5 个请求 → 等全部返回 → 展示。比串行快 5 倍。

results.filter(g => g.goodsList.length > 0):有些分类没有推荐商品 → 不展示空分类组 → 页面更干净。

轮播图

<el-carousel height="650px" motion-blur>
  <el-carousel-item v-for="i in 3" :key="i">
    <div :style="{ background: ['#303133','#FF6800','#409EFF'][i-1], ... }">
      {{ ['新品首发','限时特惠','品牌直供'][i-1] }}
    </div>
  </el-carousel-item>
</el-carousel>

v-for="i in 3" 循环 3 次 → 3 张轮播图。:style 绑定一个动态对象 → 第1张深灰背景”新品首发”、第2张橙色”限时特惠”、第3张蓝色”品牌直供”。


32.5 导航栏 (UserHeader.vue)

登录状态切换

<template v-if="tokenStore.tokenStr && userStore.userInfo">
  <span>{{ userStore.userInfo.username }}</span>
  <a @click="logout">退出</a>
  <router-link to="/user/cart">购物车</router-link>
</template>
<template v-else>
  <router-link to="/user/login">登录</router-link>
  <router-link to="/user/reg">注册</router-link>
</template>

Vue 的条件渲染原理v-if 根据条件决定是否创建 DOM 节点。有 token+用户信息时渲染”用户名/退出/购物车”,没有时渲染”登录/注册”。v-ifv-else 是互斥的——只会渲染其中一个。

logout() — 退出登录

function logout() {
  tokenStore.$reset()   // 清空 token
  userStore.$reset()    // 清空用户信息
  router.push('/user/index')  // 跳首页
}

清空两个 Pinia store → sessionStorage 同步清空 → Axios 拦截器读到 tokenStr=null → 后续请求不带 token → 服务器按”未登录”处理。


32.6 购物车 (CartView.vue)

全选/半选逻辑

function changeState() {
  let count = 0
  cartList.value.forEach(c => { if (c.checkState) count++ })
  checkAllState.value = count === cartList.value.length && count > 0   // 全选中=全选
  halfState.value = count > 0 && count < cartList.value.length           // 部分选中=半选
  checkedPriceAndCount()
}

el-checkbox:indeterminate 属性控制”半选状态”(一条横线而不是勾)。当 count>0<总条数 时 → 半选。

updatecount() — 更新数量

function updatecount(cart) {
  cartApi.update({ id: cart.id, count: cart.count }).then(resp => {
    if (resp.code === 200) {
      // 更新成功后,从后端重新获取准确的count值(防止前端显示和后端不一致)
      cartApi.selectById(cart.id).then(r => {
        cartList.value.forEach(c => { if (c.id === cart.id) c.count = r.data.count })
        checkedPriceAndCount()
      })
    }
  })
}

为什么更新后还要 selectById 重新查:后端 CartServiceImpl.update() 里有个逻辑——如果前端传的 count 超过商品库存,会修正为库存上限。所以前端不能直接用自己传的值,必须从后端重新拉取。

toCreateOrder() — 跳转结算

function toCreateOrder() {
  const ids = cartList.value.filter(c => c.checkState).map(c => c.id)
  if (ids.length === 0) { ElMessage.warning('请选择要结算的商品'); return }
  router.push({ path: '/user/createOrder', query: { cartIds: ids } })
}

query: { cartIds: ids } 传数组:Vue Router 会把数组序列化成 URL query 参数。ids=[3,4,5]?cartIds=3&cartIds=4&cartIds=5。CreateOrderView 接收时用 route.query.cartIds 得到数组 ['3','4','5']


32.7 管理员登录 (admin LoginView.vue)

登录后跳转

function login() {
  adminApi.login({...}).then(resp => {
    if (resp.code === 200) {
      tokenStore.updateToken(resp.data);  // JWT 里有 role: "admin"
      router.push('/admin/index')           // 跳到管理端仪表盘
    }
  })
}

和用户登录的区别

  • adminApi.login() 而不是 userApi.login()/admin/login (admin_service) 不是 /user/login (user_service)
  • 登录后不调 getInfo() 拿用户信息(管理员不展示在首页导航栏)
  • 跳转到 /admin/index (管理端仪表盘)而不是 /user/index(用户端首页)

32.8 RedisConfig

@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        template.setKeySerializer(new StringRedisSerializer());           // key 用 String
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); // value 用 JSON
        return template;
    }
}

为什么需要自定义序列化器:Spring Boot 默认的 RedisTemplate 序列化器会把 key/value 都按 Java 序列化(二进制)存储 → 用 redis-cli 查看时是乱码 → 调试困难。换成 StringRedisSerializer(key 存明文字符串)和 GenericJackson2JsonRedisSerializer(value 存 JSON 字符串)→ redis-cli get xxx 能看到可读的内容。

GenericJackson2JsonRedisSerializer 在 JSON 里记录了 Java 类型信息(@class),反序列化时能自动还原成正确的 Java 类型 → redisUtil.get(key) 返回的不是普通 Map 而是原类型。


32.9 各微服务 WebConfig — JWT 拦截器配置

goods_service WebConfig — 拦截写、放行读

registry.addInterceptor(jwtInterceptor)
    .addPathPatterns("/category/**", "/goods/**")        // 拦截所有 /category 和 /goods
    .excludePathPatterns(
        "/category/search", "/category/allParent",       // 浏览分类 → 放行
        "/category/{id}", "/category/**/pic/**",          // 查单条、看图片 → 放行
        "/goods", "/goods/search", "/goods/{id}",        // 浏览商品 → 放行
        "/goods/**/pic/**", "/goods/upload"               // 看图片、上传 → 放行
    );

设计逻辑:浏览商品/分类不需要登录;新增/修改/删除需要登录。拦截写操作、放行读操作。

user_service WebConfig — 错误修正

// ❌ 报告3中写的是:
radio.addInterceptor(jwtInterceptor)  // ← "radio" 是错的,应该是 "registry"

// ✅ 正确写法:
registry.addInterceptor(jwtInterceptor)
    .addPathPatterns("/user/**", "/collect/**");
// /addr/** 不拦截 → AddrController 每个方法自己 @RequestHeader("token")

admin_service WebConfig — 管理员认证

registry.addInterceptor(jwtInterceptor)
    .addPathPatterns("/admin/**")                         // 拦截所有 /admin
    .excludePathPatterns("/admin/captcha", "/admin/login"); // 验证码和登录页放行

order_service WebConfig — 混合策略

registry.addInterceptor(jwtInterceptor)
    .addPathPatterns("/cart/**");   // 购物车 → 拦截(需要登录)
// /order/** 不拦截 → Controller 按端点区分:
//   create/pay/cancel/myOrders → 手动 @RequestHeader("token")
//   notify → 支付宝回调,无 token
//   query/search/selectById → 开放、限制返回内容

32.10 GoodsMapper.xml — 核心 SQL 原理

resultMap — 关联查询的核心

<resultMap id="goodsMap" type="goods">
    <id column="id" property="id"/>
    <result column="category_id" property="categoryId"/>
    <association column="category_id" property="category"
                 select="...CategoryMapper.selectById"/>   <!-- ① 自动查分类 -->
    <collection column="id" property="picList"
                select="...GoodsPicMapper.selectByGoodsId"/> <!-- ② 自动查图片 -->
</resultMap>

MyBatis 的 <association><collection> 子查询机制

  1. 先执行 SELECT * FROM goods WHERE id=#{id} → 拿到一行数据
  2. 发现 <association> → 用查出来的 category_id 值作为参数 → 自动执行 SELECT * FROM category WHERE id=#{categoryId} → 得到 Category 对象 → 塞到 Goods 的 category 属性
  3. 发现 <collection> → 用查出来的 goods.id 值作为参数 → 自动执行 SELECT * FROM goods_pic WHERE goods_id=#{id} → 得到 List<GoodsPic> → 塞到 Goods 的 picList 属性

同模块内引用可行,因为 CategoryMapper 和 GoodsPicMapper 也在 goods_service 里 → 同一个 SqlSessionFactory → MyBatis 能找到。

跨模块引用(如 user_service 引用 goods_service 的 Mapper)不可行 → 本质上是不同 SqlSessionFactory → 找不到 → 抛 MapperStatementNotFoundException。

reduceStock — 并发安全的库存扣减

<update id="reduceStock">
    UPDATE goods SET count = count - #{count}
    WHERE id = #{id} AND count >= #{count}
</update>

并发场景推演

库存 count=5

线程A: UPDATE SET count=count-3 WHERE id=45 AND count>=3  → count=5≥3✓ → count=2, 返回1行
线程B: UPDATE SET count=count-3 WHERE id=45 AND count>=3  → count=2<3✗ → 返回0行

MySQL 的行级锁保证线程 A 执行时线程 B 必须等待。如果不用 AND count>=#{count} 条件 → 线程 B 直接 count=count-3 → count 变成 -1(负库存)→ 严重bug。

selectByCondition1 — 含子分类搜索

SELECT g.* FROM goods g, category c
WHERE g.category_id=c.id
  AND (c.parent_id=#{categoryId} OR c.id=#{categoryId})

为什么 OR c.id=#{categoryId}:父分类下面可能还有”直接挂在该父分类下”的商品(虽然本项目商品全挂在子分类下,但这条 SQL 做了兼容)。

selectByCategoryId — 首页分类分组用

SELECT g.* FROM goods g, category c
WHERE g.category_id=c.id AND g.status=1
  AND (c.parent_id=#{categoryId} OR c.id=#{categoryId})

只有上架的商品才会出现在分类浏览和首页推荐中。


32.11 CollectMapper.xml — resultMap JOIN 原理

<resultMap id="collectWithGoods" type="collect">
    ...
    <association property="goods" javaType="org.example.mall.common.bean.Goods">
        ...
        <collection property="picList" ofType="org.example.mall.common.bean.GoodsPic">
            ...
        </collection>
    </association>
</resultMap>

<select id="selectByUserId" resultMap="collectWithGoods">
    SELECT c.id AS c_id, c.goods_id, c.user_id, c.create_time AS c_create_time,
           g.id AS g_id, g.name AS g_name, g.price AS g_price, ...,
           gp.id AS gp_id, gp.url AS gp_url, gp.goods_id AS gp_goods_id
    FROM collect c
    LEFT JOIN goods g ON c.goods_id = g.id
    LEFT JOIN goods_pic gp ON g.id = gp.goods_id
    WHERE c.user_id = #{userId}
</select>

resultMap + LEFT JOIN 如何把”扁平行”变成”嵌套对象”

SQL 返回的是多行平面数据(因为 goods_pic LEFT JOIN 导致同一收藏记录可能有多行——每个商品多张图片)。MyBatis 的 <collection> 会自动去重合并:同一个 goods.id 下的多条 gp_url 行被合并到一个 List<GoodsPic> 里。

为什么用了 LEFT JOIN 而不是子查询:跨模块子查询会报错(前面解释过),LEFT JOIN 一次 SQL 解决 → 避免了跨模块引用问题 → 也避免了 N+1 问题。


32.12 OrderMapper.xml — 订单 SQL 原理

insert — id 由代码生成、create_time 由数据库生成

<insert id="insert">
    INSERT INTO `order`(id, ..., items, create_time)
    VALUES (#{id}, ..., #{items}, NOW())
</insert>
  • id:OrderServiceImpl 调用 IdUtil.getSnowflakeNextId() 生成 19 位 Long → order.setId(id)
  • NOW():MySQL 函数,用数据库服务器的时间 → 避免多台应用服务器时钟不一致导致订单时间错乱

cancelExpiredOrders — 定时任务 SQL

<update id="cancelExpiredOrders">
    UPDATE `order` SET status = 4
    WHERE status = 0 AND create_time &lt; #{before}
</update>

&lt;< 的 XML 转义。MySQL 收到的是 WHERE create_time < #{before}

为什么只更新 status=0:已经付款/发货/取消的订单不应被定时任务影响。如果条件写成 WHERE create_time < #{before} → 所有老订单都会被改成”已取消” → 严重 bug。


32.13 环境变量注入原理

# application.yml
alipay:
  appId: ${ALIPAY_APP_ID:}

${KEY:默认值} 语法:Spring 启动时先找环境变量 ALIPAY_APP_ID → 有就用 → 没有就用默认值(空)。所有敏感凭证(密钥、密码)都应该通过环境变量注入,不能写死在代码里进 Git。

三种注入方式对比

  • export:当前终端有效,关掉就没了 → 适合开发测试
  • systemd Environment=:随服务启动加载,重启不会丢 → 适合生产 Linux
  • Windows set:当前 cmd 窗口有效 → 适合 Windows 本地测试

32.14 Dubbo 注册验证原理

ls /dubbo/org.example.mall.common.service.GoodsService/providers
  • ZooKeeper 的数据结构是树形的(像文件系统)
  • Dubbo 注册时在 ZooKeeper 创建节点:/dubbo/接口全限定名/providers/地址信息
  • ls 命令列出该节点下的子节点 → 看到有一个 URL 编码的地址 → 说明 goods_service 已注册成功

注册的内容是什么

dubbo://192.168.1.5:38090/org.example.mall.common.service.GoodsService?anyhost=true&application=goods_service&...

包含:协议(dubbo)、IP 地址、端口、接口名、应用名等。order_service 从 ZooKeeper 读这个地址 → 直接连过去调用。


至此,报告3中的所有代码的功能、原理、输入输出、设计原因已全部解释清楚。

结合前面已有的四份文档,你现在应该能:

  • 理解每行代码的作用
  • 了解每个设计决策背后的原因
  • 知道数据是怎么从前端流到后端再到数据库的
  • 能从零搭建一样的项目
上一篇
下一篇