Compare commits
5
Commits
45f4c3d96f
...
30b9a62d2a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30b9a62d2a | ||
|
|
bcdd1419e5 | ||
|
|
20ab909329 | ||
|
|
45b2c3073f | ||
|
|
4585c824b7 |
@@ -0,0 +1,45 @@
|
|||||||
|
# ==================== 第一阶段:编译 ====================
|
||||||
|
FROM maven:3.8-openjdk-17 AS builder
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# 阿里云 Maven 镜像加速
|
||||||
|
COPY settings.xml /root/.m2/settings.xml
|
||||||
|
|
||||||
|
# 先拷贝 pom 利用 Docker 缓存层(依赖不变时跳过下载)
|
||||||
|
COPY pom.xml .
|
||||||
|
COPY common/pom.xml common/pom.xml
|
||||||
|
COPY manager/pom.xml manager/pom.xml
|
||||||
|
COPY admin/pom.xml admin/pom.xml
|
||||||
|
|
||||||
|
# 拷贝源码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 构建(从父 pom 编译,admin 依赖 common 和 manager)
|
||||||
|
RUN --mount=type=cache,target=/root/.m2/repository \
|
||||||
|
mvn clean package -pl admin -am -DskipTests
|
||||||
|
|
||||||
|
# ==================== 第二阶段:运行 ====================
|
||||||
|
FROM alibabadragonwell/dragonwell:17-anolis
|
||||||
|
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
ENV PROFILES_ACTIVE=prod
|
||||||
|
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 \
|
||||||
|
-XX:+UseG1GC \
|
||||||
|
-XX:+HeapDumpOnOutOfMemoryError \
|
||||||
|
-XX:HeapDumpPath=/app/logs/heapdump.hprof \
|
||||||
|
-Dfile.encoding=UTF-8 \
|
||||||
|
-Duser.timezone=Asia/Shanghai"
|
||||||
|
|
||||||
|
# 时区 + curl(健康检查用)
|
||||||
|
RUN ln -sf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone \
|
||||||
|
&& yum install -y curl && yum clean all
|
||||||
|
|
||||||
|
RUN mkdir -p /app/logs
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
EXPOSE 8081
|
||||||
|
|
||||||
|
COPY --from=builder /build/admin/target/admin.jar ./admin.jar
|
||||||
|
|
||||||
|
CMD java $JAVA_OPTS -jar admin.jar
|
||||||
Vendored
+202
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* OfferPie Backend Admin 蓝绿部署流水线
|
||||||
|
*
|
||||||
|
* 架构:Jenkins 本地编译 → scp 镜像到目标机 → SSH 远程蓝绿切换
|
||||||
|
* 目标机目录:/opt/offerpie/admin/
|
||||||
|
*/
|
||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
|
||||||
|
parameters {
|
||||||
|
choice(name: 'BRANCH', choices: ['master', 'pre', 'dev', 'test'], description: '选择要部署的分支')
|
||||||
|
}
|
||||||
|
|
||||||
|
environment {
|
||||||
|
// 目标服务器配置
|
||||||
|
DEPLOY_HOST = '8.148.237.97'
|
||||||
|
DEPLOY_PORT = '22'
|
||||||
|
DEPLOY_USER = 'root'
|
||||||
|
DEPLOY_PASS = 'Mzpy520@126.com'
|
||||||
|
|
||||||
|
// 项目配置
|
||||||
|
IMAGE_NAME = 'offerpie-backend-admin'
|
||||||
|
IMAGE_TAG = 'latest'
|
||||||
|
CONTAINER_PREFIX = 'offerpie-backend-admin'
|
||||||
|
REMOTE_DIR = '/opt/offerpie/admin'
|
||||||
|
HEALTH_URL = 'http://localhost:8081/admin/public/actuator/health'
|
||||||
|
|
||||||
|
// SSH 命令前缀
|
||||||
|
SSH_CMD = "sshpass -p '${DEPLOY_PASS}' ssh -o StrictHostKeyChecking=no -p ${DEPLOY_PORT} ${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||||
|
SCP_CMD = "sshpass -p '${DEPLOY_PASS}' scp -o StrictHostKeyChecking=no -P ${DEPLOY_PORT}"
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('环境检查') {
|
||||||
|
steps {
|
||||||
|
sh 'sshpass -V'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('拉取代码') {
|
||||||
|
steps {
|
||||||
|
echo "拉取 ${params.BRANCH} 分支代码"
|
||||||
|
git branch: "${params.BRANCH}",
|
||||||
|
credentialsId: 'gitea-fab089c1-b55d-4b58-9fad',
|
||||||
|
url: 'http://git.jianshixingqiu.com/offerpai/offerpai_backend.git'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('本地编译') {
|
||||||
|
steps {
|
||||||
|
echo "开始构建镜像"
|
||||||
|
sh "docker build -f admin/Dockerfile -t ${IMAGE_NAME}:${IMAGE_TAG} ."
|
||||||
|
echo "导出镜像"
|
||||||
|
sh "docker save -o ${IMAGE_NAME}.tar ${IMAGE_NAME}:${IMAGE_TAG}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('文件传输') {
|
||||||
|
steps {
|
||||||
|
echo "传输文件到目标服务器"
|
||||||
|
sh "${SSH_CMD} 'mkdir -p ${REMOTE_DIR}'"
|
||||||
|
sh "${SCP_CMD} ${IMAGE_NAME}.tar ${DEPLOY_USER}@${DEPLOY_HOST}:${REMOTE_DIR}/"
|
||||||
|
sh "${SCP_CMD} admin/nginx.conf ${DEPLOY_USER}@${DEPLOY_HOST}:${REMOTE_DIR}/nginx.conf"
|
||||||
|
sh "${SCP_CMD} docker-compose.admin.yml ${DEPLOY_USER}@${DEPLOY_HOST}:${REMOTE_DIR}/docker-compose.yml"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('加载镜像') {
|
||||||
|
steps {
|
||||||
|
sh "${SSH_CMD} 'docker load < ${REMOTE_DIR}/${IMAGE_NAME}.tar'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('检测部署目标') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
def blueRunning = sh(script: "${SSH_CMD} 'docker ps -q -f name=${CONTAINER_PREFIX}-blue'", returnStdout: true).trim()
|
||||||
|
def greenRunning = sh(script: "${SSH_CMD} 'docker ps -q -f name=${CONTAINER_PREFIX}-green'", returnStdout: true).trim()
|
||||||
|
|
||||||
|
env.DEPLOY_TARGET = ''
|
||||||
|
|
||||||
|
if (blueRunning && !greenRunning) {
|
||||||
|
env.DEPLOY_TARGET = 'green'
|
||||||
|
}
|
||||||
|
if (greenRunning && !blueRunning) {
|
||||||
|
env.DEPLOY_TARGET = 'blue'
|
||||||
|
}
|
||||||
|
if (!env.DEPLOY_TARGET) {
|
||||||
|
echo "当前环境未部署服务或状态异常,默认部署 blue"
|
||||||
|
env.DEPLOY_TARGET = 'blue'
|
||||||
|
}
|
||||||
|
|
||||||
|
env.OTHER_TARGET = (env.DEPLOY_TARGET == 'blue') ? 'green' : 'blue'
|
||||||
|
echo "当前激活: ${env.OTHER_TARGET},即将部署: ${env.DEPLOY_TARGET}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('启动新版本') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
def existingContainer = sh(script: "${SSH_CMD} 'docker ps -aq -f name=${CONTAINER_PREFIX}-${env.DEPLOY_TARGET}'", returnStdout: true).trim()
|
||||||
|
if (existingContainer) {
|
||||||
|
sh "${SSH_CMD} 'docker rm -f ${CONTAINER_PREFIX}-${env.DEPLOY_TARGET}'"
|
||||||
|
}
|
||||||
|
sh "${SSH_CMD} 'cd ${REMOTE_DIR} && docker compose up -d ${env.DEPLOY_TARGET}'"
|
||||||
|
sh 'sleep 35'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('检查Nginx') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
def nginxExists = sh(script: "${SSH_CMD} 'docker ps -aq -f name=${CONTAINER_PREFIX}-nginx'", returnStdout: true).trim()
|
||||||
|
if (!nginxExists) {
|
||||||
|
echo "首次部署,初始化 Nginx 容器"
|
||||||
|
sh "${SSH_CMD} 'cd ${REMOTE_DIR} && docker compose up -d nginx'"
|
||||||
|
sh 'sleep 3'
|
||||||
|
} else {
|
||||||
|
def nginxRunning = sh(script: "${SSH_CMD} 'docker ps -q -f name=${CONTAINER_PREFIX}-nginx'", returnStdout: true).trim()
|
||||||
|
if (!nginxRunning) {
|
||||||
|
echo "Nginx 容器已停止,重新启动"
|
||||||
|
sh "${SSH_CMD} 'docker start ${CONTAINER_PREFIX}-nginx'"
|
||||||
|
sh 'sleep 2'
|
||||||
|
}
|
||||||
|
echo "Nginx 容器已存在且运行中"
|
||||||
|
}
|
||||||
|
// docker cp nginx.conf 进容器(覆盖更新配置模板)
|
||||||
|
sh "${SSH_CMD} 'docker cp ${REMOTE_DIR}/nginx.conf ${CONTAINER_PREFIX}-nginx:/etc/nginx/nginx.conf'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('健康检查') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
def maxRetries = 3
|
||||||
|
def retryCount = 0
|
||||||
|
def healthy = false
|
||||||
|
|
||||||
|
while (retryCount < maxRetries && !healthy) {
|
||||||
|
try {
|
||||||
|
sh "${SSH_CMD} 'docker exec ${CONTAINER_PREFIX}-${env.DEPLOY_TARGET} curl -f ${HEALTH_URL}'"
|
||||||
|
healthy = true
|
||||||
|
} catch (Exception e) {
|
||||||
|
retryCount++
|
||||||
|
if (retryCount < maxRetries) {
|
||||||
|
echo "健康检查失败,5秒后重试 (${retryCount}/${maxRetries})"
|
||||||
|
sleep 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!healthy) {
|
||||||
|
error "健康检查失败,部署中止"
|
||||||
|
}
|
||||||
|
echo "✅ ${CONTAINER_PREFIX}-${env.DEPLOY_TARGET} 健康检查通过"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('切换流量') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
sh "${SSH_CMD} 'docker exec ${CONTAINER_PREFIX}-nginx sed -i \"s/proxy_pass http:\\/\\/\\(blue\\|green\\):8081;/proxy_pass http:\\/\\/${env.DEPLOY_TARGET}:8081;/\" /etc/nginx/nginx.conf'"
|
||||||
|
sh "${SSH_CMD} 'docker exec ${CONTAINER_PREFIX}-nginx nginx -s reload'"
|
||||||
|
echo "✅ 流量已切换到 ${env.DEPLOY_TARGET}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('删除旧版本') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
def otherExists = sh(script: "${SSH_CMD} 'docker ps -aq -f name=${CONTAINER_PREFIX}-${env.OTHER_TARGET}'", returnStdout: true).trim()
|
||||||
|
if (otherExists) {
|
||||||
|
sh "${SSH_CMD} 'docker rm -f ${CONTAINER_PREFIX}-${env.OTHER_TARGET}'"
|
||||||
|
echo "旧版本 ${env.OTHER_TARGET} 已删除"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('清理') {
|
||||||
|
steps {
|
||||||
|
sh "${SSH_CMD} 'rm -f ${REMOTE_DIR}/${IMAGE_NAME}.tar'"
|
||||||
|
sh "rm -f ${IMAGE_NAME}.tar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
success {
|
||||||
|
echo "✅ 蓝绿部署成功!当前运行: ${env.DEPLOY_TARGET}"
|
||||||
|
}
|
||||||
|
failure {
|
||||||
|
echo '❌ 部署失败,请检查日志'
|
||||||
|
sh "rm -f ${IMAGE_NAME}.tar || true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* OfferPie Backend Admin 蓝绿部署流水线(测试环境)
|
||||||
|
*
|
||||||
|
* 架构:Jenkins 本地编译 → scp 镜像到目标机 → SSH 远程蓝绿切换
|
||||||
|
* 目标机目录:/opt/offerpie/admin/
|
||||||
|
*
|
||||||
|
* 与生产差异:
|
||||||
|
* - 部署目标为测试服务器
|
||||||
|
* - 默认分支 test
|
||||||
|
* - 镜像 tag = test(与生产 latest 隔离,避免同一 Jenkins 并发构建串包)
|
||||||
|
* - tar 文件名带 tag(双保险)
|
||||||
|
* - 运行时 PROFILES_ACTIVE=test(在 docker-compose.test.admin.yml 中设置)
|
||||||
|
*/
|
||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
|
||||||
|
parameters {
|
||||||
|
choice(name: 'BRANCH', choices: ['test', 'dev', 'pre', 'master'], description: '选择要部署的分支')
|
||||||
|
}
|
||||||
|
|
||||||
|
environment {
|
||||||
|
// 目标服务器配置(测试)
|
||||||
|
DEPLOY_HOST = '8.163.131.234'
|
||||||
|
DEPLOY_PORT = '22'
|
||||||
|
DEPLOY_USER = 'root'
|
||||||
|
DEPLOY_PASS = 'Mzpy520@126.com'
|
||||||
|
|
||||||
|
// 项目配置
|
||||||
|
IMAGE_NAME = 'offerpie-backend-admin'
|
||||||
|
IMAGE_TAG = 'test'
|
||||||
|
IMAGE_TAR = 'offerpie-backend-admin-test.tar'
|
||||||
|
CONTAINER_PREFIX = 'offerpie-backend-admin'
|
||||||
|
REMOTE_DIR = '/opt/offerpie/admin'
|
||||||
|
HEALTH_URL = 'http://localhost:8081/admin/public/actuator/health'
|
||||||
|
|
||||||
|
// SSH 命令前缀
|
||||||
|
SSH_CMD = "sshpass -p '${DEPLOY_PASS}' ssh -o StrictHostKeyChecking=no -p ${DEPLOY_PORT} ${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||||
|
SCP_CMD = "sshpass -p '${DEPLOY_PASS}' scp -o StrictHostKeyChecking=no -P ${DEPLOY_PORT}"
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('环境检查') {
|
||||||
|
steps {
|
||||||
|
sh 'sshpass -V'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('拉取代码') {
|
||||||
|
steps {
|
||||||
|
echo "拉取 ${params.BRANCH} 分支代码"
|
||||||
|
git branch: "${params.BRANCH}",
|
||||||
|
credentialsId: 'gitea-fab089c1-b55d-4b58-9fad',
|
||||||
|
url: 'http://git.jianshixingqiu.com/offerpai/offerpai_backend.git'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('本地编译') {
|
||||||
|
steps {
|
||||||
|
echo "开始构建镜像"
|
||||||
|
sh "docker build -f admin/Dockerfile -t ${IMAGE_NAME}:${IMAGE_TAG} ."
|
||||||
|
echo "导出镜像"
|
||||||
|
sh "docker save -o ${IMAGE_TAR} ${IMAGE_NAME}:${IMAGE_TAG}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('文件传输') {
|
||||||
|
steps {
|
||||||
|
echo "传输文件到目标服务器"
|
||||||
|
sh "${SSH_CMD} 'mkdir -p ${REMOTE_DIR}'"
|
||||||
|
sh "${SCP_CMD} ${IMAGE_TAR} ${DEPLOY_USER}@${DEPLOY_HOST}:${REMOTE_DIR}/"
|
||||||
|
sh "${SCP_CMD} admin/nginx.conf ${DEPLOY_USER}@${DEPLOY_HOST}:${REMOTE_DIR}/nginx.conf"
|
||||||
|
sh "${SCP_CMD} docker-compose.test.admin.yml ${DEPLOY_USER}@${DEPLOY_HOST}:${REMOTE_DIR}/docker-compose.yml"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('加载镜像') {
|
||||||
|
steps {
|
||||||
|
sh "${SSH_CMD} 'docker load < ${REMOTE_DIR}/${IMAGE_TAR}'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('检测部署目标') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
def blueRunning = sh(script: "${SSH_CMD} 'docker ps -q -f name=${CONTAINER_PREFIX}-blue'", returnStdout: true).trim()
|
||||||
|
def greenRunning = sh(script: "${SSH_CMD} 'docker ps -q -f name=${CONTAINER_PREFIX}-green'", returnStdout: true).trim()
|
||||||
|
|
||||||
|
env.DEPLOY_TARGET = ''
|
||||||
|
|
||||||
|
if (blueRunning && !greenRunning) {
|
||||||
|
env.DEPLOY_TARGET = 'green'
|
||||||
|
}
|
||||||
|
if (greenRunning && !blueRunning) {
|
||||||
|
env.DEPLOY_TARGET = 'blue'
|
||||||
|
}
|
||||||
|
if (!env.DEPLOY_TARGET) {
|
||||||
|
echo "当前环境未部署服务或状态异常,默认部署 blue"
|
||||||
|
env.DEPLOY_TARGET = 'blue'
|
||||||
|
}
|
||||||
|
|
||||||
|
env.OTHER_TARGET = (env.DEPLOY_TARGET == 'blue') ? 'green' : 'blue'
|
||||||
|
echo "当前激活: ${env.OTHER_TARGET},即将部署: ${env.DEPLOY_TARGET}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('启动新版本') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
def existingContainer = sh(script: "${SSH_CMD} 'docker ps -aq -f name=${CONTAINER_PREFIX}-${env.DEPLOY_TARGET}'", returnStdout: true).trim()
|
||||||
|
if (existingContainer) {
|
||||||
|
sh "${SSH_CMD} 'docker rm -f ${CONTAINER_PREFIX}-${env.DEPLOY_TARGET}'"
|
||||||
|
}
|
||||||
|
sh "${SSH_CMD} 'cd ${REMOTE_DIR} && docker compose up -d ${env.DEPLOY_TARGET}'"
|
||||||
|
sh 'sleep 70'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('检查Nginx') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
def nginxExists = sh(script: "${SSH_CMD} 'docker ps -aq -f name=${CONTAINER_PREFIX}-nginx'", returnStdout: true).trim()
|
||||||
|
if (!nginxExists) {
|
||||||
|
echo "首次部署,初始化 Nginx 容器"
|
||||||
|
sh "${SSH_CMD} 'cd ${REMOTE_DIR} && docker compose up -d nginx'"
|
||||||
|
sh 'sleep 3'
|
||||||
|
} else {
|
||||||
|
def nginxRunning = sh(script: "${SSH_CMD} 'docker ps -q -f name=${CONTAINER_PREFIX}-nginx'", returnStdout: true).trim()
|
||||||
|
if (!nginxRunning) {
|
||||||
|
echo "Nginx 容器已停止,重新启动"
|
||||||
|
sh "${SSH_CMD} 'docker start ${CONTAINER_PREFIX}-nginx'"
|
||||||
|
sh 'sleep 2'
|
||||||
|
}
|
||||||
|
echo "Nginx 容器已存在且运行中"
|
||||||
|
}
|
||||||
|
// docker cp nginx.conf 进容器(覆盖更新配置模板)
|
||||||
|
sh "${SSH_CMD} 'docker cp ${REMOTE_DIR}/nginx.conf ${CONTAINER_PREFIX}-nginx:/etc/nginx/nginx.conf'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('健康检查') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
def maxRetries = 3
|
||||||
|
def retryCount = 0
|
||||||
|
def healthy = false
|
||||||
|
|
||||||
|
while (retryCount < maxRetries && !healthy) {
|
||||||
|
try {
|
||||||
|
sh "${SSH_CMD} 'docker exec ${CONTAINER_PREFIX}-${env.DEPLOY_TARGET} curl -f ${HEALTH_URL}'"
|
||||||
|
healthy = true
|
||||||
|
} catch (Exception e) {
|
||||||
|
retryCount++
|
||||||
|
if (retryCount < maxRetries) {
|
||||||
|
echo "健康检查失败,5秒后重试 (${retryCount}/${maxRetries})"
|
||||||
|
sleep 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!healthy) {
|
||||||
|
error "健康检查失败,部署中止"
|
||||||
|
}
|
||||||
|
echo "✅ ${CONTAINER_PREFIX}-${env.DEPLOY_TARGET} 健康检查通过"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('切换流量') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
sh "${SSH_CMD} 'docker exec ${CONTAINER_PREFIX}-nginx sed -i \"s/proxy_pass http:\\/\\/\\(blue\\|green\\):8081;/proxy_pass http:\\/\\/${env.DEPLOY_TARGET}:8081;/\" /etc/nginx/nginx.conf'"
|
||||||
|
sh "${SSH_CMD} 'docker exec ${CONTAINER_PREFIX}-nginx nginx -s reload'"
|
||||||
|
echo "✅ 流量已切换到 ${env.DEPLOY_TARGET}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('删除旧版本') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
def otherExists = sh(script: "${SSH_CMD} 'docker ps -aq -f name=${CONTAINER_PREFIX}-${env.OTHER_TARGET}'", returnStdout: true).trim()
|
||||||
|
if (otherExists) {
|
||||||
|
sh "${SSH_CMD} 'docker rm -f ${CONTAINER_PREFIX}-${env.OTHER_TARGET}'"
|
||||||
|
echo "旧版本 ${env.OTHER_TARGET} 已删除"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('清理') {
|
||||||
|
steps {
|
||||||
|
sh "${SSH_CMD} 'rm -f ${REMOTE_DIR}/${IMAGE_TAR}'"
|
||||||
|
sh "rm -f ${IMAGE_TAR}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
success {
|
||||||
|
echo "✅ 蓝绿部署成功!当前运行: ${env.DEPLOY_TARGET}"
|
||||||
|
}
|
||||||
|
failure {
|
||||||
|
echo '❌ 部署失败,请检查日志'
|
||||||
|
sh "rm -f ${IMAGE_TAR} || true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
use epoll;
|
||||||
|
multi_accept on;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
|
||||||
|
client_max_body_size 20m;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Nginx 自身健康检查
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 'ok';
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 默认代理到 blue,部署时通过 sed 切换
|
||||||
|
location / {
|
||||||
|
proxy_pass http://blue:8081;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_connect_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
|
||||||
|
proxy_buffer_size 128k;
|
||||||
|
proxy_buffers 4 256k;
|
||||||
|
proxy_busy_buffers_size 256k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>org.jiayunet</groupId>
|
||||||
|
<artifactId>back_end</artifactId>
|
||||||
|
<version>1.0-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>admin</artifactId>
|
||||||
|
<version>1.0-SNAPSHOT</version>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jiayunet</groupId>
|
||||||
|
<artifactId>manager</artifactId>
|
||||||
|
<version>1.0-SNAPSHOT</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<finalName>admin</finalName>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<source>17</source>
|
||||||
|
<target>17</target>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<version>2.6.13</version>
|
||||||
|
<configuration>
|
||||||
|
<fork>true</fork>
|
||||||
|
</configuration>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>repackage</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package org.jiayunet;
|
||||||
|
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.context.annotation.EnableAspectJAutoProxy;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B 端管理后台启动类
|
||||||
|
*
|
||||||
|
* @author zk
|
||||||
|
*/
|
||||||
|
@SpringBootApplication(scanBasePackages = "org.jiayunet")
|
||||||
|
@MapperScan("org.jiayunet.**.mapper")
|
||||||
|
@EnableScheduling
|
||||||
|
@EnableAspectJAutoProxy
|
||||||
|
public class AdminApplication {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(AdminApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package org.jiayunet.admin.constant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B 端管理后台 Redis Key 常量
|
||||||
|
*
|
||||||
|
* @author zk
|
||||||
|
*/
|
||||||
|
public interface AdminRedisKeyName {
|
||||||
|
|
||||||
|
/** 管理员登录 token 前缀,完整 key: admin:login:token:{adminId} */
|
||||||
|
String LOGIN_TOKEN = "login:token:";
|
||||||
|
|
||||||
|
/** 登录失败次数计数,完整 key: admin:login:fail:{username} */
|
||||||
|
String LOGIN_FAIL_COUNT = "login:fail:";
|
||||||
|
|
||||||
|
/** 账号锁定标记,完整 key: admin:login:lock:{username} */
|
||||||
|
String LOGIN_LOCK = "login:lock:";
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package org.jiayunet.admin.controller;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import org.jiayunet.admin.pojo.param.login.AdminLoginParam;
|
||||||
|
import org.jiayunet.admin.service.AdminLoginService;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员登录控制类
|
||||||
|
*
|
||||||
|
* @author zk
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/public")
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Validated
|
||||||
|
public class AdminLoginController {
|
||||||
|
|
||||||
|
private AdminLoginService adminLoginService;
|
||||||
|
|
||||||
|
@PostMapping("/login")
|
||||||
|
public Long login(@Validated @RequestBody AdminLoginParam param, HttpServletRequest request, HttpServletResponse response) {
|
||||||
|
return adminLoginService.login(param.getUsername(), param.getPassword(), request, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package org.jiayunet.admin.controller;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import org.jiayunet.admin.pojo.dto.order.OrderListDto;
|
||||||
|
import org.jiayunet.admin.pojo.dto.order.OrderStatsDto;
|
||||||
|
import org.jiayunet.admin.pojo.param.order.OrderPageParam;
|
||||||
|
import org.jiayunet.admin.service.AdminOrderService;
|
||||||
|
import org.jiayunet.pojo.PageResult;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理后台-订单管理控制类
|
||||||
|
*
|
||||||
|
* @author zk
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/order")
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Validated
|
||||||
|
public class AdminOrderController {
|
||||||
|
|
||||||
|
private AdminOrderService adminOrderService;
|
||||||
|
|
||||||
|
/** 订单分页列表 */
|
||||||
|
@PostMapping("/list")
|
||||||
|
public PageResult<OrderListDto> list(@Validated @RequestBody OrderPageParam param) {
|
||||||
|
return adminOrderService.pageList(param);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 订单统计 */
|
||||||
|
@GetMapping("/stats")
|
||||||
|
public OrderStatsDto stats() {
|
||||||
|
return adminOrderService.stats();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package org.jiayunet.admin.controller;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import org.jiayunet.admin.pojo.param.product.ProductPageParam;
|
||||||
|
import org.jiayunet.admin.pojo.param.product.ProductSaveParam;
|
||||||
|
import org.jiayunet.admin.service.AdminProductService;
|
||||||
|
import org.jiayunet.pojo.PageResult;
|
||||||
|
import org.jiayunet.pojo.po.MemberProduct;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理后台-商品管理控制类
|
||||||
|
*
|
||||||
|
* @author zk
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/product")
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Validated
|
||||||
|
public class AdminProductController {
|
||||||
|
|
||||||
|
private AdminProductService adminProductService;
|
||||||
|
|
||||||
|
/** 商品分页列表 */
|
||||||
|
@PostMapping("/list")
|
||||||
|
public PageResult<MemberProduct> list(@Validated @RequestBody ProductPageParam param) {
|
||||||
|
return adminProductService.pageList(param);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增/编辑商品 */
|
||||||
|
@PostMapping("/save")
|
||||||
|
public void save(@Validated @RequestBody ProductSaveParam param) {
|
||||||
|
adminProductService.save(param);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上架/下架 */
|
||||||
|
@PostMapping("/updateStatus")
|
||||||
|
public void updateStatus(@RequestParam Long id, @RequestParam Integer status) {
|
||||||
|
adminProductService.updateStatus(id, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除商品 */
|
||||||
|
@PostMapping("/delete")
|
||||||
|
public void delete(@RequestParam Long id) {
|
||||||
|
adminProductService.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package org.jiayunet.admin.controller;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import org.jiayunet.admin.pojo.dto.user.UserListDto;
|
||||||
|
import org.jiayunet.admin.pojo.dto.user.UserStatsDto;
|
||||||
|
import org.jiayunet.admin.pojo.param.user.UserPageParam;
|
||||||
|
import org.jiayunet.admin.service.AdminUserService;
|
||||||
|
import org.jiayunet.pojo.PageResult;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理后台-用户管理控制类
|
||||||
|
*
|
||||||
|
* @author zk
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/user")
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Validated
|
||||||
|
public class AdminUserController {
|
||||||
|
|
||||||
|
private AdminUserService adminUserService;
|
||||||
|
|
||||||
|
/** 用户分页列表 */
|
||||||
|
@PostMapping("/list")
|
||||||
|
public PageResult<UserListDto> list(@Validated @RequestBody UserPageParam param) {
|
||||||
|
return adminUserService.pageList(param);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户统计(总数/今日/本周/本月) */
|
||||||
|
@GetMapping("/stats")
|
||||||
|
public UserStatsDto stats() {
|
||||||
|
return adminUserService.stats();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 禁用/启用用户 */
|
||||||
|
@PostMapping("/updateStatus")
|
||||||
|
public void updateStatus(@RequestParam Long userId, @RequestParam Integer status) {
|
||||||
|
adminUserService.updateStatus(userId, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package org.jiayunet.admin.controller;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面路由控制器(返回 Thymeleaf 视图名,不走 UnifiedResponseBodyAdvice)
|
||||||
|
*
|
||||||
|
* @author zk
|
||||||
|
*/
|
||||||
|
@Controller
|
||||||
|
@RequestMapping("/page")
|
||||||
|
public class PageController {
|
||||||
|
|
||||||
|
/** 登录页 */
|
||||||
|
@GetMapping("/login")
|
||||||
|
public String login() {
|
||||||
|
return "login";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 管理后台首页(需登录) */
|
||||||
|
@GetMapping("/index")
|
||||||
|
public String index() {
|
||||||
|
return "index";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package org.jiayunet.admin.pojo.dto.order;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单列表出参
|
||||||
|
*
|
||||||
|
* @author zk
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class OrderListDto {
|
||||||
|
|
||||||
|
/** 订单ID */
|
||||||
|
private Long id;
|
||||||
|
/** 订单编号 */
|
||||||
|
private String orderNo;
|
||||||
|
/** 用户昵称 */
|
||||||
|
private String userNick;
|
||||||
|
/** 用户手机号 */
|
||||||
|
private String mobileNumber;
|
||||||
|
/** 商品名称 */
|
||||||
|
private String productName;
|
||||||
|
/** 实付金额(分) */
|
||||||
|
private Integer payAmount;
|
||||||
|
/** 支付渠道 1=微信 2=支付宝 */
|
||||||
|
private Integer payChannel;
|
||||||
|
/** 订单状态 0=待支付 1=已支付 2=已退款 3=已关闭 */
|
||||||
|
private Integer status;
|
||||||
|
/** 下单时间 */
|
||||||
|
private Instant createTime;
|
||||||
|
/** 支付时间 */
|
||||||
|
private Instant payTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package org.jiayunet.admin.pojo.dto.order;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单统计出参
|
||||||
|
*
|
||||||
|
* @author zk
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class OrderStatsDto {
|
||||||
|
|
||||||
|
/** 总订单数 */
|
||||||
|
private Long totalCount;
|
||||||
|
/** 已支付订单数 */
|
||||||
|
private Long paidCount;
|
||||||
|
/** 今日已支付 */
|
||||||
|
private Long todayPaid;
|
||||||
|
/** 本周已支付 */
|
||||||
|
private Long weekPaid;
|
||||||
|
/** 本月已支付 */
|
||||||
|
private Long monthPaid;
|
||||||
|
/** 今日收入(分) */
|
||||||
|
private Long todayIncome;
|
||||||
|
/** 本周收入(分) */
|
||||||
|
private Long weekIncome;
|
||||||
|
/** 本月收入(分) */
|
||||||
|
private Long monthIncome;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package org.jiayunet.admin.pojo.dto.user;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户列表出参
|
||||||
|
*
|
||||||
|
* @author zk
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class UserListDto {
|
||||||
|
|
||||||
|
/** 用户ID */
|
||||||
|
private Long id;
|
||||||
|
/** 昵称 */
|
||||||
|
private String nick;
|
||||||
|
/** 真实姓名 */
|
||||||
|
private String realName;
|
||||||
|
/** 手机号 */
|
||||||
|
private String mobileNumber;
|
||||||
|
/** 状态 0=正常 1=禁用 */
|
||||||
|
private Integer status;
|
||||||
|
/** 简历数量 */
|
||||||
|
private Integer resumeCount;
|
||||||
|
/** 注册时间 */
|
||||||
|
private Instant createTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package org.jiayunet.admin.pojo.dto.user;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户统计出参
|
||||||
|
*
|
||||||
|
* @author zk
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class UserStatsDto {
|
||||||
|
|
||||||
|
/** 用户总数 */
|
||||||
|
private Long total;
|
||||||
|
/** 今日新增 */
|
||||||
|
private Long today;
|
||||||
|
/** 本周新增 */
|
||||||
|
private Long thisWeek;
|
||||||
|
/** 本月新增 */
|
||||||
|
private Long thisMonth;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package org.jiayunet.admin.pojo.param.login;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import javax.validation.constraints.NotBlank;
|
||||||
|
import javax.validation.constraints.Size;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员登录入参
|
||||||
|
*
|
||||||
|
* @author zk
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class AdminLoginParam {
|
||||||
|
|
||||||
|
@NotBlank(message = "账号不能为空")
|
||||||
|
@Size(max = 32, message = "账号长度不能超过32位")
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@NotBlank(message = "密码不能为空")
|
||||||
|
@Size(min = 6, max = 32, message = "密码长度6-32位")
|
||||||
|
private String password;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package org.jiayunet.admin.pojo.param.order;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import org.jiayunet.pojo.PageParam;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单分页查询入参
|
||||||
|
*
|
||||||
|
* @author zk
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class OrderPageParam extends PageParam {
|
||||||
|
|
||||||
|
/** 订单状态 0=待支付 1=已支付 2=已退款 3=已关闭 */
|
||||||
|
private Integer status;
|
||||||
|
/** 支付渠道 1=微信 2=支付宝 */
|
||||||
|
private Integer payChannel;
|
||||||
|
/** 用户手机号(模糊) */
|
||||||
|
private String mobileNumber;
|
||||||
|
/** 下单时间-起 */
|
||||||
|
private Instant startTime;
|
||||||
|
/** 下单时间-止 */
|
||||||
|
private Instant endTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package org.jiayunet.admin.pojo.param.product;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import org.jiayunet.pojo.PageParam;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 商品分页查询入参
|
||||||
|
*
|
||||||
|
* @author zk
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class ProductPageParam extends PageParam {
|
||||||
|
|
||||||
|
/** 状态筛选 0=下架 1=上架 */
|
||||||
|
private Integer status;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package org.jiayunet.admin.pojo.param.product;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import javax.validation.constraints.Min;
|
||||||
|
import javax.validation.constraints.NotBlank;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 商品新增/编辑入参
|
||||||
|
*
|
||||||
|
* @author zk
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ProductSaveParam {
|
||||||
|
|
||||||
|
/** 商品ID(编辑时必传,新增时不传) */
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@NotBlank(message = "商品名称不能为空")
|
||||||
|
private String productName;
|
||||||
|
|
||||||
|
/** 标签,如"限时优惠" */
|
||||||
|
private String tag;
|
||||||
|
|
||||||
|
/** 主推标识 0=否 1=是 */
|
||||||
|
private Integer isFeatured;
|
||||||
|
|
||||||
|
/** 购买按钮文字 */
|
||||||
|
private String buyButtonText;
|
||||||
|
|
||||||
|
@NotNull(message = "价格不能为空")
|
||||||
|
@Min(value = 1, message = "价格必须大于0")
|
||||||
|
private Integer price;
|
||||||
|
|
||||||
|
/** 划线价(分) */
|
||||||
|
private Integer originalPrice;
|
||||||
|
|
||||||
|
/** 折算月价(分) */
|
||||||
|
private Integer monthlyPrice;
|
||||||
|
|
||||||
|
@NotNull(message = "有效天数不能为空")
|
||||||
|
@Min(value = 1, message = "有效天数必须大于0")
|
||||||
|
private Integer durationDays;
|
||||||
|
|
||||||
|
/** 排序 */
|
||||||
|
private Integer sortOrder;
|
||||||
|
|
||||||
|
/** 状态 0=下架 1=上架 */
|
||||||
|
private Integer status;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package org.jiayunet.admin.pojo.param.user;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import org.jiayunet.pojo.PageParam;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户分页查询入参
|
||||||
|
*
|
||||||
|
* @author zk
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class UserPageParam extends PageParam {
|
||||||
|
|
||||||
|
/** 手机号(模糊搜索) */
|
||||||
|
private String mobileNumber;
|
||||||
|
|
||||||
|
/** 状态筛选 0=正常 1=禁用 */
|
||||||
|
private Integer status;
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package org.jiayunet.admin.service;
|
||||||
|
|
||||||
|
import com.auth0.jwt.JWT;
|
||||||
|
import com.auth0.jwt.algorithms.Algorithm;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.jiayunet.admin.constant.AdminRedisKeyName;
|
||||||
|
import org.jiayunet.mapper.AdminUserMapper;
|
||||||
|
import org.jiayunet.pojo.login.RedisLoginTokenInfo;
|
||||||
|
import org.jiayunet.pojo.po.AdminUser;
|
||||||
|
import org.jiayunet.tool.HttpIpTool;
|
||||||
|
import org.jiayunet.tool.server.RedisServerTool;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
|
||||||
|
import javax.servlet.http.Cookie;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员登录服务
|
||||||
|
* <p>依赖:AdminUserMapper(管理员查询)、RedisServerTool(token管理与登录失败计数)</p>
|
||||||
|
* <p>使用表:bg_admin_user(查询管理员账号)</p>
|
||||||
|
* <p>使用Redis:login:token:{adminId}(登录令牌)、login:fail:{username}(失败计数)、login:lock:{username}(账号锁定)</p>
|
||||||
|
*
|
||||||
|
* @author zk
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class AdminLoginService {
|
||||||
|
|
||||||
|
/** 密码错误允许最大次数 */
|
||||||
|
private static final int MAX_FAIL_COUNT = 3;
|
||||||
|
/** 账号锁定时长(小时) */
|
||||||
|
private static final int LOCK_HOURS = 24;
|
||||||
|
|
||||||
|
@Value("${app.secret.token:youweiqingnian123}")
|
||||||
|
private String secret;
|
||||||
|
@Value("${app.login.token.exceed_time:43200}")
|
||||||
|
private int tokenExceedTime;
|
||||||
|
@Value("${app.login.device_online_quantity:5}")
|
||||||
|
private int deviceOnlineQuantity;
|
||||||
|
@Autowired
|
||||||
|
private AdminUserMapper adminUserMapper;
|
||||||
|
@Autowired
|
||||||
|
private RedisServerTool redisServerTool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 账号密码登录
|
||||||
|
* <p>1. 校验账号锁定状态 2. 查询管理员 3. 校验密码(失败累计,3次锁定24小时) 4. 生成JWT 5. 写入Redis管理多设备 6. 设置Cookie</p>
|
||||||
|
*/
|
||||||
|
public Long login(String username, String password, HttpServletRequest request, HttpServletResponse response) {
|
||||||
|
Assert.hasText(username, "账号不能为空");
|
||||||
|
Assert.hasText(password, "密码不能为空");
|
||||||
|
|
||||||
|
// 检查账号是否被锁定
|
||||||
|
Assert.isTrue(!redisServerTool.hasKey(AdminRedisKeyName.LOGIN_LOCK + username), "账号已被锁定,请24小时后再试");
|
||||||
|
|
||||||
|
// 查询管理员
|
||||||
|
AdminUser admin = adminUserMapper.selectOne(new LambdaQueryWrapper<AdminUser>().eq(AdminUser::getUsername, username).eq(AdminUser::getStatus, 0));
|
||||||
|
Assert.notNull(admin, "账号或密码错误");
|
||||||
|
|
||||||
|
// 校验密码:MD5(明文 + salt)
|
||||||
|
if (!md5(password + secret).equals(admin.getPassword())) {
|
||||||
|
handleLoginFail(username);
|
||||||
|
throw new IllegalArgumentException("账号或密码错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录成功,清除失败计数
|
||||||
|
clearLoginFail(username);
|
||||||
|
|
||||||
|
// 生成JWT
|
||||||
|
String uuId = UUID.randomUUID().toString().replace("-", "");
|
||||||
|
String token = JWT.create().withClaim("userId", admin.getId()).withClaim("uuId", uuId).sign(Algorithm.HMAC256(secret));
|
||||||
|
|
||||||
|
// 构建Redis登录信息
|
||||||
|
String redisKey = AdminRedisKeyName.LOGIN_TOKEN + admin.getId();
|
||||||
|
RedisLoginTokenInfo info = redisServerTool.get(redisKey, RedisLoginTokenInfo.class);
|
||||||
|
if (info == null) {
|
||||||
|
info = new RedisLoginTokenInfo();
|
||||||
|
info.setUserId(admin.getId());
|
||||||
|
info.setAuthority(new ArrayList<>());
|
||||||
|
info.setRole(new ArrayList<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤过期设备
|
||||||
|
List<RedisLoginTokenInfo.LoginDevice> devices = info.getLoginDevices() == null ? new ArrayList<>() : info.getLoginDevices();
|
||||||
|
long expireMillis = System.currentTimeMillis() - tokenExceedTime * 1000L;
|
||||||
|
devices = devices.stream().filter(d -> d.getLastLoginTime().isAfter(Instant.ofEpochMilli(expireMillis))).collect(Collectors.toList());
|
||||||
|
|
||||||
|
// 超过设备上限,移除最早的
|
||||||
|
while (devices.size() >= deviceOnlineQuantity) {
|
||||||
|
devices.stream().min(Comparator.comparing(RedisLoginTokenInfo.LoginDevice::getLastLoginTime)).ifPresent(devices::remove);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加当前设备
|
||||||
|
RedisLoginTokenInfo.LoginDevice device = new RedisLoginTokenInfo.LoginDevice();
|
||||||
|
device.setUuId(uuId);
|
||||||
|
device.setLastLoginTime(Instant.now());
|
||||||
|
device.setLoginIp(HttpIpTool.gteRealIP(request));
|
||||||
|
devices.add(device);
|
||||||
|
|
||||||
|
info.setLoginDevices(devices);
|
||||||
|
redisServerTool.set(redisKey, info, tokenExceedTime, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
// 设置Cookie
|
||||||
|
Cookie cookie = new Cookie("Token", token);
|
||||||
|
cookie.setHttpOnly(true);
|
||||||
|
cookie.setPath("/");
|
||||||
|
cookie.setMaxAge(tokenExceedTime);
|
||||||
|
response.addCookie(cookie);
|
||||||
|
|
||||||
|
return admin.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理登录失败:累计失败次数,达到上限则锁定账号24小时 */
|
||||||
|
private void handleLoginFail(String username) {
|
||||||
|
String failKey = AdminRedisKeyName.LOGIN_FAIL_COUNT + username;
|
||||||
|
Integer failCount = redisServerTool.get(failKey, Integer.class);
|
||||||
|
int currentCount = (failCount == null ? 0 : failCount) + 1;
|
||||||
|
if (currentCount >= MAX_FAIL_COUNT) {
|
||||||
|
redisServerTool.set(AdminRedisKeyName.LOGIN_LOCK + username, true, LOCK_HOURS, TimeUnit.HOURS);
|
||||||
|
redisServerTool.delete(failKey);
|
||||||
|
log.warn("管理员账号 [{}] 密码错误{}次,已锁定{}小时", username, MAX_FAIL_COUNT, LOCK_HOURS);
|
||||||
|
} else {
|
||||||
|
redisServerTool.set(failKey, currentCount, LOCK_HOURS, TimeUnit.HOURS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录成功后清除失败计数 */
|
||||||
|
private void clearLoginFail(String username) {
|
||||||
|
redisServerTool.delete(AdminRedisKeyName.LOGIN_FAIL_COUNT + username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MD5 加密 */
|
||||||
|
private String md5(String input) {
|
||||||
|
try {
|
||||||
|
java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5");
|
||||||
|
byte[] digest = md.digest(input.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (byte b : digest) { sb.append(String.format("%02x", b)); }
|
||||||
|
return sb.toString();
|
||||||
|
} catch (java.security.NoSuchAlgorithmException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package org.jiayunet.admin.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.jiayunet.admin.pojo.dto.order.OrderListDto;
|
||||||
|
import org.jiayunet.admin.pojo.dto.order.OrderStatsDto;
|
||||||
|
import org.jiayunet.admin.pojo.param.order.OrderPageParam;
|
||||||
|
import org.jiayunet.mapper.MemberOrderMapper;
|
||||||
|
import org.jiayunet.mapper.MemberProductMapper;
|
||||||
|
import org.jiayunet.mapper.UserMapper;
|
||||||
|
import org.jiayunet.pojo.PageResult;
|
||||||
|
import org.jiayunet.pojo.po.MemberOrder;
|
||||||
|
import org.jiayunet.pojo.po.MemberProduct;
|
||||||
|
import org.jiayunet.pojo.po.User;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.time.*;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理后台-订单管理服务
|
||||||
|
* <p>依赖:MemberOrderMapper、UserMapper、MemberProductMapper</p>
|
||||||
|
* <p>使用表:bg_member_order(订单列表/统计)、bg_user(关联用户信息)、bg_member_product(关联商品名)</p>
|
||||||
|
*
|
||||||
|
* @author zk
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class AdminOrderService {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MemberOrderMapper memberOrderMapper;
|
||||||
|
@Autowired
|
||||||
|
private UserMapper userMapper;
|
||||||
|
@Autowired
|
||||||
|
private MemberProductMapper memberProductMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单分页列表
|
||||||
|
* <p>1. 如果按手机号筛选,先查用户ID集合 2. 分页查订单 3. 批量关联用户和商品信息 4. 组装DTO</p>
|
||||||
|
*/
|
||||||
|
public PageResult<OrderListDto> pageList(OrderPageParam param) {
|
||||||
|
// 如果传了手机号,先查对应用户ID
|
||||||
|
Set<Long> userIdFilter = null;
|
||||||
|
if (StringUtils.hasText(param.getMobileNumber())) {
|
||||||
|
List<User> users = userMapper.selectList(new LambdaQueryWrapper<User>().like(User::getMobileNumber, param.getMobileNumber()).select(User::getId));
|
||||||
|
userIdFilter = users.stream().map(User::getId).collect(Collectors.toSet());
|
||||||
|
if (userIdFilter.isEmpty()) return new PageResult<>(param.getPageNum().longValue(), param.getPageSize().longValue(), 0L, List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
LambdaQueryWrapper<MemberOrder> wrapper = new LambdaQueryWrapper<MemberOrder>()
|
||||||
|
.eq(param.getStatus() != null, MemberOrder::getStatus, param.getStatus())
|
||||||
|
.eq(param.getPayChannel() != null, MemberOrder::getPayChannel, param.getPayChannel())
|
||||||
|
.ge(param.getStartTime() != null, MemberOrder::getCreateTime, param.getStartTime())
|
||||||
|
.le(param.getEndTime() != null, MemberOrder::getCreateTime, param.getEndTime())
|
||||||
|
.in(userIdFilter != null, MemberOrder::getUserId, userIdFilter)
|
||||||
|
.orderByDesc(MemberOrder::getCreateTime);
|
||||||
|
|
||||||
|
Page<MemberOrder> page = memberOrderMapper.selectPage(param.toPage(), wrapper);
|
||||||
|
if (page.getRecords().isEmpty()) return PageResult.from((Page<OrderListDto>) page.convert(o -> new OrderListDto()));
|
||||||
|
|
||||||
|
// 批量查用户和商品
|
||||||
|
Set<Long> userIds = page.getRecords().stream().map(MemberOrder::getUserId).collect(Collectors.toSet());
|
||||||
|
Set<Long> productIds = page.getRecords().stream().map(MemberOrder::getProductId).collect(Collectors.toSet());
|
||||||
|
Map<Long, User> userMap = userMapper.selectBatchIds(userIds).stream().collect(Collectors.toMap(User::getId, u -> u));
|
||||||
|
Map<Long, MemberProduct> productMap = memberProductMapper.selectBatchIds(productIds).stream().collect(Collectors.toMap(MemberProduct::getId, p -> p));
|
||||||
|
|
||||||
|
Page<OrderListDto> dtoPage = (Page<OrderListDto>) page.convert(order -> {
|
||||||
|
OrderListDto dto = new OrderListDto();
|
||||||
|
dto.setId(order.getId());
|
||||||
|
dto.setOrderNo(order.getOrderNo());
|
||||||
|
dto.setPayAmount(order.getPayAmount());
|
||||||
|
dto.setPayChannel(order.getPayChannel());
|
||||||
|
dto.setStatus(order.getStatus());
|
||||||
|
dto.setCreateTime(order.getCreateTime());
|
||||||
|
dto.setPayTime(order.getPayTime());
|
||||||
|
User user = userMap.get(order.getUserId());
|
||||||
|
if (user != null) { dto.setUserNick(user.getNick()); dto.setMobileNumber(user.getMobileNumber()); }
|
||||||
|
MemberProduct product = productMap.get(order.getProductId());
|
||||||
|
if (product != null) { dto.setProductName(product.getProductName()); }
|
||||||
|
return dto;
|
||||||
|
});
|
||||||
|
return PageResult.from(dtoPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单统计:总数/已支付数/今日本周本月已支付数和收入
|
||||||
|
*/
|
||||||
|
public OrderStatsDto stats() {
|
||||||
|
OrderStatsDto dto = new OrderStatsDto();
|
||||||
|
ZoneId zone = ZoneId.of("Asia/Shanghai");
|
||||||
|
LocalDate now = LocalDate.now(zone);
|
||||||
|
Instant todayStart = now.atStartOfDay(zone).toInstant();
|
||||||
|
Instant weekStart = now.with(DayOfWeek.MONDAY).atStartOfDay(zone).toInstant();
|
||||||
|
Instant monthStart = now.withDayOfMonth(1).atStartOfDay(zone).toInstant();
|
||||||
|
|
||||||
|
dto.setTotalCount(memberOrderMapper.selectCount(new LambdaQueryWrapper<MemberOrder>()));
|
||||||
|
dto.setPaidCount(memberOrderMapper.selectCount(new LambdaQueryWrapper<MemberOrder>().eq(MemberOrder::getStatus, 1)));
|
||||||
|
|
||||||
|
// 今日/本周/本月已支付订单
|
||||||
|
List<MemberOrder> todayOrders = memberOrderMapper.selectList(new LambdaQueryWrapper<MemberOrder>().eq(MemberOrder::getStatus, 1).ge(MemberOrder::getPayTime, todayStart));
|
||||||
|
List<MemberOrder> weekOrders = memberOrderMapper.selectList(new LambdaQueryWrapper<MemberOrder>().eq(MemberOrder::getStatus, 1).ge(MemberOrder::getPayTime, weekStart));
|
||||||
|
List<MemberOrder> monthOrders = memberOrderMapper.selectList(new LambdaQueryWrapper<MemberOrder>().eq(MemberOrder::getStatus, 1).ge(MemberOrder::getPayTime, monthStart));
|
||||||
|
|
||||||
|
dto.setTodayPaid((long) todayOrders.size());
|
||||||
|
dto.setWeekPaid((long) weekOrders.size());
|
||||||
|
dto.setMonthPaid((long) monthOrders.size());
|
||||||
|
dto.setTodayIncome(todayOrders.stream().mapToLong(o -> o.getPayAmount() == null ? 0 : o.getPayAmount()).sum());
|
||||||
|
dto.setWeekIncome(weekOrders.stream().mapToLong(o -> o.getPayAmount() == null ? 0 : o.getPayAmount()).sum());
|
||||||
|
dto.setMonthIncome(monthOrders.stream().mapToLong(o -> o.getPayAmount() == null ? 0 : o.getPayAmount()).sum());
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package org.jiayunet.admin.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.jiayunet.admin.pojo.param.product.ProductPageParam;
|
||||||
|
import org.jiayunet.admin.pojo.param.product.ProductSaveParam;
|
||||||
|
import org.jiayunet.mapper.MemberProductMapper;
|
||||||
|
import org.jiayunet.pojo.PageResult;
|
||||||
|
import org.jiayunet.pojo.po.MemberProduct;
|
||||||
|
import org.springframework.beans.BeanUtils;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理后台-商品管理服务
|
||||||
|
* <p>依赖:MemberProductMapper</p>
|
||||||
|
* <p>使用表:bg_member_product(CRUD)</p>
|
||||||
|
*
|
||||||
|
* @author zk
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class AdminProductService {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MemberProductMapper memberProductMapper;
|
||||||
|
|
||||||
|
/** 商品分页列表 */
|
||||||
|
public PageResult<MemberProduct> pageList(ProductPageParam param) {
|
||||||
|
LambdaQueryWrapper<MemberProduct> wrapper = new LambdaQueryWrapper<MemberProduct>()
|
||||||
|
.eq(param.getStatus() != null, MemberProduct::getStatus, param.getStatus())
|
||||||
|
.orderByAsc(MemberProduct::getSortOrder);
|
||||||
|
Page<MemberProduct> page = memberProductMapper.selectPage(param.toPage(), wrapper);
|
||||||
|
return PageResult.from(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增/编辑商品 */
|
||||||
|
public void save(ProductSaveParam param) {
|
||||||
|
MemberProduct product = new MemberProduct();
|
||||||
|
BeanUtils.copyProperties(param, product);
|
||||||
|
if (param.getId() == null) {
|
||||||
|
if (product.getStatus() == null) product.setStatus(0);
|
||||||
|
if (product.getSortOrder() == null) product.setSortOrder(0);
|
||||||
|
if (product.getIsFeatured() == null) product.setIsFeatured(0);
|
||||||
|
memberProductMapper.insert(product);
|
||||||
|
} else {
|
||||||
|
product.setId(param.getId());
|
||||||
|
memberProductMapper.updateById(product);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上架/下架商品 */
|
||||||
|
public void updateStatus(Long id, Integer status) {
|
||||||
|
Assert.notNull(id, "商品ID不能为空");
|
||||||
|
Assert.isTrue(status == 0 || status == 1, "状态值非法");
|
||||||
|
memberProductMapper.update(null, new LambdaUpdateWrapper<MemberProduct>().eq(MemberProduct::getId, id).set(MemberProduct::getStatus, status));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除商品(逻辑删除) */
|
||||||
|
public void delete(Long id) {
|
||||||
|
Assert.notNull(id, "商品ID不能为空");
|
||||||
|
memberProductMapper.deleteById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package org.jiayunet.admin.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.jiayunet.admin.pojo.dto.user.UserListDto;
|
||||||
|
import org.jiayunet.admin.pojo.dto.user.UserStatsDto;
|
||||||
|
import org.jiayunet.admin.pojo.param.user.UserPageParam;
|
||||||
|
import org.jiayunet.mapper.UserMapper;
|
||||||
|
import org.jiayunet.mapper.UserResumeMapper;
|
||||||
|
import org.jiayunet.pojo.PageResult;
|
||||||
|
import org.jiayunet.pojo.po.User;
|
||||||
|
import org.jiayunet.pojo.po.UserResume;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.time.*;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理后台-用户管理服务
|
||||||
|
* <p>依赖:UserMapper(用户查询/更新)、UserResumeMapper(简历数量统计)</p>
|
||||||
|
* <p>使用表:bg_user(用户列表/统计/禁用)、bg_user_resume(简历数量)</p>
|
||||||
|
*
|
||||||
|
* @author zk
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class AdminUserService {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserMapper userMapper;
|
||||||
|
@Autowired
|
||||||
|
private UserResumeMapper userResumeMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户分页列表
|
||||||
|
* <p>1. 分页查询用户 2. 批量查询每个用户的简历数量 3. 组装返回</p>
|
||||||
|
*/
|
||||||
|
public PageResult<UserListDto> pageList(UserPageParam param) {
|
||||||
|
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<User>()
|
||||||
|
.like(StringUtils.hasText(param.getMobileNumber()), User::getMobileNumber, param.getMobileNumber())
|
||||||
|
.like(StringUtils.hasText(param.getKeyword()), User::getNick, param.getKeyword())
|
||||||
|
.eq(param.getStatus() != null, User::getStatus, param.getStatus())
|
||||||
|
.orderByDesc(User::getCreateTime);
|
||||||
|
|
||||||
|
Page<User> page = userMapper.selectPage(param.toPage(), wrapper);
|
||||||
|
if (page.getRecords().isEmpty()) return PageResult.from((Page<UserListDto>) page.convert(u -> new UserListDto()));
|
||||||
|
|
||||||
|
// 批量查询简历数量
|
||||||
|
List<Long> userIds = page.getRecords().stream().map(User::getId).collect(Collectors.toList());
|
||||||
|
List<UserResume> resumes = userResumeMapper.selectList(new LambdaQueryWrapper<UserResume>().in(UserResume::getUserId, userIds).select(UserResume::getUserId));
|
||||||
|
Map<Long, Long> resumeCountMap = resumes.stream().collect(Collectors.groupingBy(UserResume::getUserId, Collectors.counting()));
|
||||||
|
|
||||||
|
// 组装DTO
|
||||||
|
Page<UserListDto> dtoPage = (Page<UserListDto>) page.convert(user -> {
|
||||||
|
UserListDto dto = new UserListDto();
|
||||||
|
dto.setId(user.getId());
|
||||||
|
dto.setNick(user.getNick());
|
||||||
|
dto.setRealName(user.getRealName());
|
||||||
|
dto.setMobileNumber(user.getMobileNumber());
|
||||||
|
dto.setStatus(user.getStatus());
|
||||||
|
dto.setResumeCount(resumeCountMap.getOrDefault(user.getId(), 0L).intValue());
|
||||||
|
dto.setCreateTime(user.getCreateTime());
|
||||||
|
return dto;
|
||||||
|
});
|
||||||
|
return PageResult.from(dtoPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户统计:总数 / 今日 / 本周 / 本月 新增
|
||||||
|
*/
|
||||||
|
public UserStatsDto stats() {
|
||||||
|
UserStatsDto dto = new UserStatsDto();
|
||||||
|
ZoneId zone = ZoneId.of("Asia/Shanghai");
|
||||||
|
LocalDate now = LocalDate.now(zone);
|
||||||
|
|
||||||
|
Instant todayStart = now.atStartOfDay(zone).toInstant();
|
||||||
|
Instant weekStart = now.with(DayOfWeek.MONDAY).atStartOfDay(zone).toInstant();
|
||||||
|
Instant monthStart = now.withDayOfMonth(1).atStartOfDay(zone).toInstant();
|
||||||
|
|
||||||
|
dto.setTotal(userMapper.selectCount(new LambdaQueryWrapper<User>()));
|
||||||
|
dto.setToday(userMapper.selectCount(new LambdaQueryWrapper<User>().ge(User::getCreateTime, todayStart)));
|
||||||
|
dto.setThisWeek(userMapper.selectCount(new LambdaQueryWrapper<User>().ge(User::getCreateTime, weekStart)));
|
||||||
|
dto.setThisMonth(userMapper.selectCount(new LambdaQueryWrapper<User>().ge(User::getCreateTime, monthStart)));
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 禁用/启用用户
|
||||||
|
*/
|
||||||
|
public void updateStatus(Long userId, Integer status) {
|
||||||
|
Assert.notNull(userId, "用户ID不能为空");
|
||||||
|
Assert.isTrue(status == 0 || status == 1, "状态值非法");
|
||||||
|
userMapper.update(null, new LambdaUpdateWrapper<User>().eq(User::getId, userId).set(User::getStatus, status));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# tomcat 端口配置
|
||||||
|
server:
|
||||||
|
port: 8081
|
||||||
|
# 数据源配置
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:mysql://${MYSQL_HOST:192.168.31.105}:${MYSQL_PORT:3306}/${DB_NAME:offerpie}?useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true
|
||||||
|
username: ${MYSQL_USERNAME:root}
|
||||||
|
password: ${MYSQL_PASSWORD:123456}
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
redis:
|
||||||
|
host: ${REDIS_HOST:192.168.31.105}
|
||||||
|
password: ${REDIS_PASSWORD:123456}
|
||||||
|
port: ${REDIS_PORT:6379}
|
||||||
|
timeout: 8s
|
||||||
|
database: 0
|
||||||
|
# 开发环境关闭模板缓存,方便调试
|
||||||
|
thymeleaf:
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
# 电子邮箱
|
||||||
|
email:
|
||||||
|
status: close
|
||||||
|
account: ${EMAIL_ACCOUNT:xxx@163.com}
|
||||||
|
authorization: ${EMAIL_AUTHORIZATION:123456}
|
||||||
|
|
||||||
|
# 微信支付
|
||||||
|
wx_pay:
|
||||||
|
status: close
|
||||||
|
|
||||||
|
# 支付宝支付
|
||||||
|
alipay:
|
||||||
|
status: close
|
||||||
|
|
||||||
|
app:
|
||||||
|
# 加密秘钥配置
|
||||||
|
secret:
|
||||||
|
token: ${SECRET_TOKEN:Aa123123}
|
||||||
|
|
||||||
|
# 登陆配置
|
||||||
|
login:
|
||||||
|
token:
|
||||||
|
exceed_time: 5184000
|
||||||
|
device_online_quantity: 3
|
||||||
|
|
||||||
|
# 短信
|
||||||
|
sms:
|
||||||
|
service_provider: aliyun
|
||||||
|
aliyun:
|
||||||
|
access_key_id: LTAI5tJBVUUJhB7yp14UDzVf
|
||||||
|
access_key_secret: Opf0iO5FKNrdwI63DPhXazW7utAGTj
|
||||||
|
|
||||||
|
oss:
|
||||||
|
service_provider: aliyun
|
||||||
|
aliyun:
|
||||||
|
access_key_id: LTAI5tEdLKKQUKhTyUpfH5Mk
|
||||||
|
access_key_secret: RjUdTrq0V5qA4b3BUElNhXqs3ZLp5k
|
||||||
|
|
||||||
|
# 防刷配置
|
||||||
|
prevent_replay:
|
||||||
|
if_open: false
|
||||||
|
interval_time: 20
|
||||||
|
limit_number: 20
|
||||||
|
|
||||||
|
# 开放接口(登录页面 + 静态资源 + 登录接口)
|
||||||
|
ignore:
|
||||||
|
urls: "/public/**,/page/**,/static/**,/css/**,/js/**,/img/**,/favicon.ico"
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# tomcat 端口配置
|
||||||
|
server:
|
||||||
|
port: 8081
|
||||||
|
# 数据源配置
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:mysql://${MYSQL_HOST:8.163.14.142}:${MYSQL_PORT:30006}/${DB_NAME:offerpie}?useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true
|
||||||
|
username: ${MYSQL_USERNAME:root}
|
||||||
|
password: ${MYSQL_PASSWORD:^CgDatabase2020}
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
redis:
|
||||||
|
host: ${REDIS_HOST:8.163.14.142}
|
||||||
|
password: ${REDIS_PASSWORD:#8kPCdAsser}
|
||||||
|
port: ${REDIS_PORT:30089}
|
||||||
|
timeout: 10s
|
||||||
|
database: 0
|
||||||
|
|
||||||
|
# 电子邮箱
|
||||||
|
email:
|
||||||
|
status: close
|
||||||
|
account: ${EMAIL_ACCOUNT:xxx@163.com}
|
||||||
|
authorization: ${EMAIL_AUTHORIZATION:123456}
|
||||||
|
|
||||||
|
# 微信支付
|
||||||
|
wx_pay:
|
||||||
|
status: close
|
||||||
|
|
||||||
|
# 支付宝支付
|
||||||
|
alipay:
|
||||||
|
status: close
|
||||||
|
|
||||||
|
app:
|
||||||
|
# 加密秘钥配置
|
||||||
|
secret:
|
||||||
|
token: ${SECRET_TOKEN:Aa123123}
|
||||||
|
|
||||||
|
# 登陆配置
|
||||||
|
login:
|
||||||
|
token:
|
||||||
|
exceed_time: 5184000
|
||||||
|
device_online_quantity: 3
|
||||||
|
|
||||||
|
# 短信
|
||||||
|
sms:
|
||||||
|
service_provider: aliyun
|
||||||
|
aliyun:
|
||||||
|
access_key_id: LTAI5tJBVUUJhB7yp14UDzVf
|
||||||
|
access_key_secret: Opf0iO5FKNrdwI63DPhXazW7utAGTj
|
||||||
|
|
||||||
|
oss:
|
||||||
|
service_provider: aliyun
|
||||||
|
aliyun:
|
||||||
|
access_key_id: LTAI5tEdLKKQUKhTyUpfH5Mk
|
||||||
|
access_key_secret: RjUdTrq0V5qA4b3BUElNhXqs3ZLp5k
|
||||||
|
|
||||||
|
# 防刷配置
|
||||||
|
prevent_replay:
|
||||||
|
if_open: false
|
||||||
|
interval_time: 20
|
||||||
|
limit_number: 20
|
||||||
|
|
||||||
|
# 开放接口
|
||||||
|
ignore:
|
||||||
|
urls: "/public/**,/page/**,/static/**,/css/**,/js/**,/img/**,/favicon.ico"
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# tomcat 端口配置
|
||||||
|
server:
|
||||||
|
port: 8081
|
||||||
|
# 数据源配置
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:mysql://${MYSQL_HOST:8.163.14.142}:${MYSQL_PORT:30006}/${DB_NAME:offerpie_test}?useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true
|
||||||
|
username: ${MYSQL_USERNAME:root}
|
||||||
|
password: ${MYSQL_PASSWORD:^CgDatabase2020}
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
redis:
|
||||||
|
host: ${REDIS_HOST:8.163.14.142}
|
||||||
|
password: ${REDIS_PASSWORD:#8kPCdAsser}
|
||||||
|
port: ${REDIS_PORT:30089}
|
||||||
|
timeout: 10s
|
||||||
|
database: 1
|
||||||
|
|
||||||
|
# 电子邮箱
|
||||||
|
email:
|
||||||
|
status: close
|
||||||
|
account: ${EMAIL_ACCOUNT:xxx@163.com}
|
||||||
|
authorization: ${EMAIL_AUTHORIZATION:123456}
|
||||||
|
|
||||||
|
# 微信支付
|
||||||
|
wx_pay:
|
||||||
|
status: close
|
||||||
|
|
||||||
|
# 支付宝支付
|
||||||
|
alipay:
|
||||||
|
status: close
|
||||||
|
|
||||||
|
app:
|
||||||
|
# 加密秘钥配置
|
||||||
|
secret:
|
||||||
|
token: ${SECRET_TOKEN:Aa123123}
|
||||||
|
|
||||||
|
# 登陆配置
|
||||||
|
login:
|
||||||
|
token:
|
||||||
|
exceed_time: 5184000
|
||||||
|
device_online_quantity: 3
|
||||||
|
|
||||||
|
# 短信
|
||||||
|
sms:
|
||||||
|
service_provider: aliyun
|
||||||
|
aliyun:
|
||||||
|
access_key_id: LTAI5tJBVUUJhB7yp14UDzVf
|
||||||
|
access_key_secret: Opf0iO5FKNrdwI63DPhXazW7utAGTj
|
||||||
|
|
||||||
|
oss:
|
||||||
|
service_provider: aliyun
|
||||||
|
aliyun:
|
||||||
|
access_key_id: LTAI5tEdLKKQUKhTyUpfH5Mk
|
||||||
|
access_key_secret: RjUdTrq0V5qA4b3BUElNhXqs3ZLp5k
|
||||||
|
|
||||||
|
# 防刷配置
|
||||||
|
prevent_replay:
|
||||||
|
if_open: false
|
||||||
|
interval_time: 20
|
||||||
|
limit_number: 20
|
||||||
|
|
||||||
|
# 开放接口
|
||||||
|
ignore:
|
||||||
|
urls: "/public/**,/page/**,/static/**,/css/**,/js/**,/img/**,/favicon.ico"
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# tomcat config
|
||||||
|
server:
|
||||||
|
tomcat:
|
||||||
|
accept-count: 100
|
||||||
|
threads:
|
||||||
|
max: 200
|
||||||
|
min-spare: 10
|
||||||
|
servlet:
|
||||||
|
context-path: /admin
|
||||||
|
# spring config
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: admin
|
||||||
|
profiles:
|
||||||
|
active: ${PROFILES_ACTIVE:dev}
|
||||||
|
datasource:
|
||||||
|
hikari:
|
||||||
|
maximum-pool-size: 50
|
||||||
|
minimum-idle: 20
|
||||||
|
connection-timeout: 5000
|
||||||
|
idle-timeout: 300000
|
||||||
|
max-lifetime: 900000
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: 4MB
|
||||||
|
max-request-size: 20MB
|
||||||
|
# Thymeleaf 配置
|
||||||
|
thymeleaf:
|
||||||
|
prefix: classpath:/templates/
|
||||||
|
suffix: .html
|
||||||
|
mode: HTML
|
||||||
|
encoding: UTF-8
|
||||||
|
# 开启静态资源映射
|
||||||
|
web:
|
||||||
|
resources:
|
||||||
|
add-mappings: true
|
||||||
|
static-locations: classpath:/static/
|
||||||
|
cache:
|
||||||
|
redis:
|
||||||
|
cache-null-values: true
|
||||||
|
key-prefix: "${spring.application.name}:"
|
||||||
|
time-to-live: 24h
|
||||||
|
jackson:
|
||||||
|
default-property-inclusion: non_null
|
||||||
|
property-naming-strategy: LOWER_CAMEL_CASE
|
||||||
|
serialization:
|
||||||
|
fail-on-empty-beans: false
|
||||||
|
write-date-keys-as-timestamps: true
|
||||||
|
write-date-timestamps-as-nanoseconds: false
|
||||||
|
write-dates-as-timestamps: true
|
||||||
|
deserialization:
|
||||||
|
fail-on-unknown-properties: false
|
||||||
|
fail-on-numbers-for-enums: true
|
||||||
|
read-date-timestamps-as-nanoseconds: false
|
||||||
|
# mybatis plus config
|
||||||
|
mybatis-plus:
|
||||||
|
mapper-locations: classpath*:mapper/**/*.xml
|
||||||
|
global-config:
|
||||||
|
db-config:
|
||||||
|
id-type: assign_id
|
||||||
|
insert-strategy: not_null
|
||||||
|
update-strategy: not_null
|
||||||
|
logic-delete-field: isDelete
|
||||||
|
logic-delete-value: '1'
|
||||||
|
logic-not-delete-value: '0'
|
||||||
|
configuration:
|
||||||
|
map-underscore-to-camel-case: true
|
||||||
|
auto-mapping-unknown-column-behavior: warning
|
||||||
|
cache-enabled: false
|
||||||
|
call-setters-on-nulls: true
|
||||||
|
jdbc-type-for-null: 'null'
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
org:
|
||||||
|
mybatis: info
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 认证相关 API
|
||||||
|
*/
|
||||||
|
const authApi = {
|
||||||
|
/** 登录 */
|
||||||
|
login(username, password) {
|
||||||
|
return request.post('/public/login', { username, password });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* 订单管理 API
|
||||||
|
*/
|
||||||
|
const orderApi = {
|
||||||
|
/** 订单分页列表 */
|
||||||
|
list(params) {
|
||||||
|
return request.post('/order/list', params);
|
||||||
|
},
|
||||||
|
/** 订单统计 */
|
||||||
|
stats() {
|
||||||
|
return request.get('/order/stats');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* 商品管理 API
|
||||||
|
*/
|
||||||
|
const productApi = {
|
||||||
|
/** 商品分页列表 */
|
||||||
|
list(params) {
|
||||||
|
return request.post('/product/list', params);
|
||||||
|
},
|
||||||
|
/** 新增/编辑商品 */
|
||||||
|
save(data) {
|
||||||
|
return request.post('/product/save', data);
|
||||||
|
},
|
||||||
|
/** 上架/下架 */
|
||||||
|
updateStatus(id, status) {
|
||||||
|
return request.post('/product/updateStatus?id=' + id + '&status=' + status);
|
||||||
|
},
|
||||||
|
/** 删除商品 */
|
||||||
|
delete(id) {
|
||||||
|
return request.post('/product/delete?id=' + id);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 用户管理 API
|
||||||
|
*/
|
||||||
|
const userApi = {
|
||||||
|
/** 用户分页列表 */
|
||||||
|
list(params) {
|
||||||
|
return request.post('/user/list', params);
|
||||||
|
},
|
||||||
|
/** 用户统计 */
|
||||||
|
stats() {
|
||||||
|
return request.get('/user/stats');
|
||||||
|
},
|
||||||
|
/** 禁用/启用用户 */
|
||||||
|
updateStatus(userId, status) {
|
||||||
|
return request.post('/user/updateStatus?userId=' + userId + '&status=' + status);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* 统一请求封装
|
||||||
|
*/
|
||||||
|
const request = (() => {
|
||||||
|
const BASE_URL = '/admin';
|
||||||
|
|
||||||
|
/** 从 cookie 中获取 Token */
|
||||||
|
function getToken() {
|
||||||
|
const match = document.cookie.match(/(?:^|;\s*)Token=([^;]*)/);
|
||||||
|
return match ? match[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一请求方法 */
|
||||||
|
async function http(url, options = {}) {
|
||||||
|
const config = {
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Token': getToken(), ...options.headers },
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
const response = await fetch(BASE_URL + url, config);
|
||||||
|
|
||||||
|
// 401 未登录,跳转登录页
|
||||||
|
if (response.status === 401) {
|
||||||
|
window.location.href = BASE_URL + '/page/login';
|
||||||
|
return Promise.reject('未登录');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析响应体
|
||||||
|
let result;
|
||||||
|
try { result = await response.json(); } catch (e) {
|
||||||
|
const msg = 'HTTP ' + response.status + ' 请求失败';
|
||||||
|
ElementPlus ? ElementPlus.ElMessage.error(msg) : alert(msg);
|
||||||
|
return Promise.reject(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 业务异常:code 不为 "0" 表示失败
|
||||||
|
if (result.code !== '0') {
|
||||||
|
const msg = result.msg || '请求失败';
|
||||||
|
ElementPlus ? ElementPlus.ElMessage.error(msg) : alert(msg);
|
||||||
|
return Promise.reject(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get(url, params) {
|
||||||
|
const query = params ? '?' + new URLSearchParams(params).toString() : '';
|
||||||
|
return http(url + query, { method: 'GET' });
|
||||||
|
},
|
||||||
|
post(url, data) {
|
||||||
|
return http(url, { method: 'POST', body: data != null ? JSON.stringify(data) : undefined });
|
||||||
|
},
|
||||||
|
put(url, data) {
|
||||||
|
return http(url, { method: 'PUT', body: JSON.stringify(data) });
|
||||||
|
},
|
||||||
|
del(url, data) {
|
||||||
|
return http(url, { method: 'DELETE', body: data ? JSON.stringify(data) : undefined });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
const DashboardView = {
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-bottom: 24px; font-size: 18px; font-weight: 600; color: #303133;">数据概览</h2>
|
||||||
|
<el-row :gutter="20" style="margin-bottom: 24px;">
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover" body-style="padding: 24px;">
|
||||||
|
<el-statistic title="用户总数" :value="userStats.total"><template #suffix><span style="font-size: 12px; color: #909399;"> 人</span></template></el-statistic>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover" body-style="padding: 24px;">
|
||||||
|
<el-statistic title="今日新增" :value="userStats.today"><template #suffix><span style="font-size: 12px; color: #909399;"> 人</span></template></el-statistic>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover" body-style="padding: 24px;">
|
||||||
|
<el-statistic title="本月订单" :value="orderStats.monthPaid"><template #suffix><span style="font-size: 12px; color: #909399;"> 笔</span></template></el-statistic>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover" body-style="padding: 24px;">
|
||||||
|
<el-statistic title="本月收入" :value="monthIncome"><template #prefix><span style="font-size: 14px;">¥</span></template></el-statistic>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover" body-style="padding: 24px;"><el-statistic title="本周新增用户" :value="userStats.thisWeek"/></el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover" body-style="padding: 24px;"><el-statistic title="本月新增用户" :value="userStats.thisMonth"/></el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover" body-style="padding: 24px;"><el-statistic title="本周订单" :value="orderStats.weekPaid"/></el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover" body-style="padding: 24px;">
|
||||||
|
<el-statistic title="本周收入" :value="weekIncome"><template #prefix><span style="font-size: 14px;">¥</span></template></el-statistic>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
setup() {
|
||||||
|
const { ref, onMounted, computed } = Vue;
|
||||||
|
const userStats = ref({ total: 0, today: 0, thisWeek: 0, thisMonth: 0 });
|
||||||
|
const orderStats = ref({ totalCount: 0, paidCount: 0, todayPaid: 0, weekPaid: 0, monthPaid: 0, monthIncome: 0, weekIncome: 0 });
|
||||||
|
const monthIncome = computed(() => (orderStats.value.monthIncome / 100).toFixed(2));
|
||||||
|
const weekIncome = computed(() => (orderStats.value.weekIncome / 100).toFixed(2));
|
||||||
|
const toNum = (obj) => { const r = {}; for (const k in obj) r[k] = Number(obj[k]); return r; };
|
||||||
|
onMounted(async () => {
|
||||||
|
try { userStats.value = toNum(await userApi.stats()); } catch(e) {}
|
||||||
|
try { orderStats.value = toNum(await orderApi.stats()); } catch(e) {}
|
||||||
|
});
|
||||||
|
return { userStats, orderStats, monthIncome, weekIncome };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
const OrderView = {
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<el-row :gutter="16" style="margin-bottom: 20px;">
|
||||||
|
<el-col :span="6"><el-card shadow="hover" body-style="padding: 20px;"><el-statistic title="总订单" :value="stats.totalCount"/></el-card></el-col>
|
||||||
|
<el-col :span="6"><el-card shadow="hover" body-style="padding: 20px;"><el-statistic title="已支付" :value="stats.paidCount"/></el-card></el-col>
|
||||||
|
<el-col :span="6"><el-card shadow="hover" body-style="padding: 20px;"><el-statistic title="今日支付" :value="stats.todayPaid"/></el-card></el-col>
|
||||||
|
<el-col :span="6"><el-card shadow="hover" body-style="padding: 20px;"><el-statistic title="今日收入(元)" :value="todayIncome"/></el-card></el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header><span style="font-size: 16px; font-weight: 600;">订单管理</span></template>
|
||||||
|
<el-form :inline="true" style="margin-bottom: 16px;">
|
||||||
|
<el-form-item label="手机号"><el-input v-model="query.mobileNumber" placeholder="用户手机号" clearable @clear="loadData" style="width: 150px;"/></el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="query.status" placeholder="全部" clearable @change="loadData" style="width: 110px;">
|
||||||
|
<el-option label="待支付" :value="0"/><el-option label="已支付" :value="1"/><el-option label="已退款" :value="2"/><el-option label="已关闭" :value="3"/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="渠道">
|
||||||
|
<el-select v-model="query.payChannel" placeholder="全部" clearable @change="loadData" style="width: 100px;">
|
||||||
|
<el-option label="微信" :value="1"/><el-option label="支付宝" :value="2"/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item><el-button type="primary" @click="loadData">查询</el-button></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<el-table :data="tableData" stripe v-loading="loading" style="width: 100%;">
|
||||||
|
<el-table-column prop="orderNo" label="订单编号" min-width="150"/>
|
||||||
|
<el-table-column prop="userNick" label="用户" min-width="100"/>
|
||||||
|
<el-table-column prop="mobileNumber" label="手机号" min-width="120"/>
|
||||||
|
<el-table-column prop="productName" label="商品" min-width="120"/>
|
||||||
|
<el-table-column label="金额(元)" width="100" align="center">
|
||||||
|
<template #default="{row}">{{(row.payAmount/100).toFixed(2)}}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="渠道" width="80" align="center">
|
||||||
|
<template #default="{row}"><el-tag size="small" :type="row.payChannel===1?'success':'primary'">{{row.payChannel===1?'微信':'支付宝'}}</el-tag></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="90" align="center">
|
||||||
|
<template #default="{row}"><el-tag size="small" :type="statusType(row.status)">{{statusText(row.status)}}</el-tag></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="下单时间" min-width="160">
|
||||||
|
<template #default="{row}">{{formatTime(row.createTime)}}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="支付时间" min-width="160">
|
||||||
|
<template #default="{row}">{{formatTime(row.payTime)}}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div style="margin-top: 16px; display: flex; justify-content: flex-end;">
|
||||||
|
<el-pagination background layout="total, prev, pager, next" :total="total" :page-size="query.pageSize" v-model:current-page="query.pageNum" @current-change="loadData"/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
setup() {
|
||||||
|
const { ref, reactive, onMounted, computed } = Vue;
|
||||||
|
const loading = ref(false);
|
||||||
|
const tableData = ref([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const stats = ref({ totalCount: 0, paidCount: 0, todayPaid: 0, todayIncome: 0 });
|
||||||
|
const todayIncome = computed(() => (stats.value.todayIncome / 100).toFixed(2));
|
||||||
|
const query = reactive({ pageNum: 1, pageSize: 10, mobileNumber: '', status: null, payChannel: null });
|
||||||
|
const loadData = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try { const res = await orderApi.list(query); tableData.value = res.list; total.value = Number(res.total); } catch(e) {}
|
||||||
|
finally { loading.value = false; }
|
||||||
|
};
|
||||||
|
const loadStats = async () => { try { const s = await orderApi.stats(); const r = {}; for (const k in s) r[k] = Number(s[k]); stats.value = r; } catch(e) {} };
|
||||||
|
const statusText = (s) => ['待支付','已支付','已退款','已关闭'][s] || '-';
|
||||||
|
const statusType = (s) => ['warning','success','info','danger'][s] || 'info';
|
||||||
|
const formatTime = (ts) => {
|
||||||
|
if (!ts) return '-';
|
||||||
|
const d = new Date(ts);
|
||||||
|
return d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0') + ' ' + String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0');
|
||||||
|
};
|
||||||
|
onMounted(() => { loadData(); loadStats(); });
|
||||||
|
return { loading, tableData, total, stats, todayIncome, query, loadData, statusText, statusType, formatTime };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
const ProductView = {
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||||
|
<span style="font-size: 16px; font-weight: 600;">商品管理</span>
|
||||||
|
<el-button type="primary" @click="openDialog(null)"><el-icon><Plus/></el-icon> 新增商品</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-form :inline="true" style="margin-bottom: 16px;">
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="query.status" placeholder="全部" clearable @change="loadData" style="width: 120px;">
|
||||||
|
<el-option label="上架" :value="1"/><el-option label="下架" :value="0"/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item><el-button type="primary" @click="loadData">查询</el-button></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<el-table :data="tableData" stripe v-loading="loading" style="width: 100%;">
|
||||||
|
<el-table-column prop="productName" label="商品名称" min-width="140"/>
|
||||||
|
<el-table-column prop="tag" label="标签" width="120"/>
|
||||||
|
<el-table-column label="价格(元)" width="100" align="center">
|
||||||
|
<template #default="{row}">{{(row.price/100).toFixed(2)}}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="划线价(元)" width="110" align="center">
|
||||||
|
<template #default="{row}">{{row.originalPrice?(row.originalPrice/100).toFixed(2):'-'}}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="durationDays" label="有效天数" width="90" align="center"/>
|
||||||
|
<el-table-column prop="sortOrder" label="排序" width="70" align="center"/>
|
||||||
|
<el-table-column label="状态" width="80" align="center">
|
||||||
|
<template #default="{row}"><el-tag :type="row.status===1?'success':'info'" size="small">{{row.status===1?'上架':'下架'}}</el-tag></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="180" align="center" fixed="right">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-button link type="primary" size="small" @click="openDialog(row)">编辑</el-button>
|
||||||
|
<el-button link :type="row.status===1?'warning':'success'" size="small" @click="toggleStatus(row)">{{row.status===1?'下架':'上架'}}</el-button>
|
||||||
|
<el-popconfirm title="确定删除该商品?" @confirm="handleDelete(row)"><template #reference><el-button link type="danger" size="small">删除</el-button></template></el-popconfirm>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div style="margin-top: 16px; display: flex; justify-content: flex-end;">
|
||||||
|
<el-pagination background layout="total, prev, pager, next" :total="total" :page-size="query.pageSize" v-model:current-page="query.pageNum" @current-change="loadData"/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="form.id?'编辑商品':'新增商品'" width="520px" destroy-on-close>
|
||||||
|
<el-form :model="form" label-width="90px">
|
||||||
|
<el-form-item label="商品名称"><el-input v-model="form.productName" maxlength="32"/></el-form-item>
|
||||||
|
<el-form-item label="标签"><el-input v-model="form.tag" maxlength="16" placeholder="如:限时优惠"/></el-form-item>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12"><el-form-item label="价格(分)"><el-input-number v-model="form.price" :min="1" style="width: 100%;"/></el-form-item></el-col>
|
||||||
|
<el-col :span="12"><el-form-item label="划线价(分)"><el-input-number v-model="form.originalPrice" :min="0" style="width: 100%;"/></el-form-item></el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12"><el-form-item label="月价(分)"><el-input-number v-model="form.monthlyPrice" :min="0" style="width: 100%;"/></el-form-item></el-col>
|
||||||
|
<el-col :span="12"><el-form-item label="有效天数"><el-input-number v-model="form.durationDays" :min="1" style="width: 100%;"/></el-form-item></el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12"><el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" style="width: 100%;"/></el-form-item></el-col>
|
||||||
|
<el-col :span="12"><el-form-item label="主推"><el-switch v-model="form.isFeatured" :active-value="1" :inactive-value="0"/></el-form-item></el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-form-item label="按钮文字"><el-input v-model="form.buyButtonText" maxlength="16" placeholder="如:立即开通"/></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible=false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
setup() {
|
||||||
|
const { ref, reactive, onMounted } = Vue;
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const tableData = ref([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const query = reactive({ pageNum: 1, pageSize: 10, status: null });
|
||||||
|
const form = reactive({ id: null, productName: '', tag: '', price: null, originalPrice: null, monthlyPrice: null, durationDays: null, sortOrder: 0, isFeatured: 0, buyButtonText: '' });
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try { const res = await productApi.list(query); tableData.value = res.list; total.value = Number(res.total); } catch(e) {}
|
||||||
|
finally { loading.value = false; }
|
||||||
|
};
|
||||||
|
const openDialog = (row) => {
|
||||||
|
if (row) { Object.assign(form, { id: row.id, productName: row.productName, tag: row.tag, price: row.price, originalPrice: row.originalPrice, monthlyPrice: row.monthlyPrice, durationDays: row.durationDays, sortOrder: row.sortOrder, isFeatured: row.isFeatured, buyButtonText: row.buyButtonText }); }
|
||||||
|
else { Object.assign(form, { id: null, productName: '', tag: '', price: null, originalPrice: null, monthlyPrice: null, durationDays: null, sortOrder: 0, isFeatured: 0, buyButtonText: '' }); }
|
||||||
|
dialogVisible.value = true;
|
||||||
|
};
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.productName || !form.price || !form.durationDays) { ElementPlus.ElMessage.warning('请填写必填项'); return; }
|
||||||
|
saving.value = true;
|
||||||
|
try { await productApi.save(form); dialogVisible.value = false; ElementPlus.ElMessage.success('保存成功'); loadData(); } catch(e) {}
|
||||||
|
finally { saving.value = false; }
|
||||||
|
};
|
||||||
|
const toggleStatus = async (row) => {
|
||||||
|
const newStatus = row.status === 1 ? 0 : 1;
|
||||||
|
try { await productApi.updateStatus(row.id, newStatus); row.status = newStatus; ElementPlus.ElMessage.success('操作成功'); } catch(e) {}
|
||||||
|
};
|
||||||
|
const handleDelete = async (row) => {
|
||||||
|
try { await productApi.delete(row.id); ElementPlus.ElMessage.success('删除成功'); loadData(); } catch(e) {}
|
||||||
|
};
|
||||||
|
onMounted(loadData);
|
||||||
|
return { loading, saving, dialogVisible, tableData, total, query, form, loadData, openDialog, handleSave, toggleStatus, handleDelete };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
const UserView = {
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header><span style="font-size: 16px; font-weight: 600;">用户管理</span></template>
|
||||||
|
<el-form :inline="true" style="margin-bottom: 16px;">
|
||||||
|
<el-form-item label="手机号"><el-input v-model="query.mobileNumber" placeholder="手机号" clearable @clear="loadData" style="width: 150px;"/></el-form-item>
|
||||||
|
<el-form-item label="昵称"><el-input v-model="query.keyword" placeholder="昵称" clearable @clear="loadData" style="width: 150px;"/></el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="query.status" placeholder="全部" clearable @change="loadData" style="width: 100px;">
|
||||||
|
<el-option label="正常" :value="0"/><el-option label="禁用" :value="1"/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item><el-button type="primary" @click="loadData">查询</el-button></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<el-table :data="tableData" stripe v-loading="loading" style="width: 100%;">
|
||||||
|
<el-table-column prop="nick" label="昵称" min-width="100"/>
|
||||||
|
<el-table-column prop="realName" label="真实姓名" min-width="100"/>
|
||||||
|
<el-table-column prop="mobileNumber" label="手机号" min-width="130"/>
|
||||||
|
<el-table-column prop="resumeCount" label="简历数" width="80" align="center"/>
|
||||||
|
<el-table-column label="状态" width="80" align="center">
|
||||||
|
<template #default="{row}"><el-tag :type="row.status===0?'success':'danger'" size="small">{{row.status===0?'正常':'禁用'}}</el-tag></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="注册时间" min-width="160">
|
||||||
|
<template #default="{row}">{{formatTime(row.createTime)}}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="90" align="center" fixed="right">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-popconfirm :title="row.status===0?'确定禁用该用户?':'确定启用该用户?'" @confirm="toggleStatus(row)">
|
||||||
|
<template #reference><el-button link :type="row.status===0?'danger':'success'" size="small">{{row.status===0?'禁用':'启用'}}</el-button></template>
|
||||||
|
</el-popconfirm>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div style="margin-top: 16px; display: flex; justify-content: flex-end;">
|
||||||
|
<el-pagination background layout="total, prev, pager, next" :total="total" :page-size="query.pageSize" v-model:current-page="query.pageNum" @current-change="loadData"/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
setup() {
|
||||||
|
const { ref, reactive, onMounted } = Vue;
|
||||||
|
const loading = ref(false);
|
||||||
|
const tableData = ref([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const query = reactive({ pageNum: 1, pageSize: 10, mobileNumber: '', keyword: '', status: null });
|
||||||
|
const loadData = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try { const res = await userApi.list(query); tableData.value = res.list; total.value = Number(res.total); } catch(e) {}
|
||||||
|
finally { loading.value = false; }
|
||||||
|
};
|
||||||
|
const toggleStatus = async (row) => {
|
||||||
|
const newStatus = row.status === 0 ? 1 : 0;
|
||||||
|
try { await userApi.updateStatus(row.id, newStatus); row.status = newStatus; ElementPlus.ElMessage.success('操作成功'); } catch(e) {}
|
||||||
|
};
|
||||||
|
const formatTime = (ts) => {
|
||||||
|
if (!ts) return '-';
|
||||||
|
const d = new Date(ts);
|
||||||
|
return d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0') + ' ' + String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0');
|
||||||
|
};
|
||||||
|
onMounted(loadData);
|
||||||
|
return { loading, tableData, total, query, loadData, toggleStatus, formatTime };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>OfferPie 管理后台</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/element-plus@2.7.3/dist/index.css"/>
|
||||||
|
<style>
|
||||||
|
html, body, #app { margin: 0; padding: 0; height: 100%; font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif; }
|
||||||
|
.layout { height: 100%; }
|
||||||
|
.aside { background: #1d1e3c; overflow-y: auto; }
|
||||||
|
.aside .logo { height: 60px; display: flex; align-items: center; justify-content: center; padding: 0 16px; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
||||||
|
.aside .logo img { height: 32px; max-width: 100%; object-fit: contain; }
|
||||||
|
.aside .el-menu { border-right: none; background: transparent; padding-top: 8px; }
|
||||||
|
.aside .el-menu-item { margin: 4px 10px; border-radius: 6px; }
|
||||||
|
.aside .el-menu-item.is-active { background: rgba(64,158,255,0.18) !important; }
|
||||||
|
.header { background: #fff; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; height: 60px; box-shadow: 0 1px 4px rgba(0,21,41,0.08); z-index: 10; }
|
||||||
|
.header .title { font-size: 16px; font-weight: 600; color: #303133; }
|
||||||
|
.header .admin-info { display: flex; align-items: center; gap: 10px; font-size: 14px; color: #606266; }
|
||||||
|
.main-content { padding: 20px; background: #f0f2f5; overflow-y: auto; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app" v-cloak>
|
||||||
|
<el-container class="layout">
|
||||||
|
<el-aside width="210px" class="aside">
|
||||||
|
<div class="logo">
|
||||||
|
<img src="/admin/img/logo.png" alt="logo"/>
|
||||||
|
</div>
|
||||||
|
<el-menu :default-active="currentView" background-color="#1d1e3c" text-color="rgba(255,255,255,0.65)" active-text-color="#fff" @select="handleMenuSelect">
|
||||||
|
<el-menu-item index="dashboard"><el-icon><data-board/></el-icon><span>数据概览</span></el-menu-item>
|
||||||
|
<el-menu-item index="user"><el-icon><user/></el-icon><span>用户管理</span></el-menu-item>
|
||||||
|
<el-menu-item index="order"><el-icon><tickets/></el-icon><span>订单管理</span></el-menu-item>
|
||||||
|
<el-menu-item index="product"><el-icon><goods/></el-icon><span>商品管理</span></el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</el-aside>
|
||||||
|
<el-container>
|
||||||
|
<el-header class="header">
|
||||||
|
<span class="title">{{ title }}</span>
|
||||||
|
<div class="admin-info">
|
||||||
|
<el-button type="primary" plain @click="handleLogout">退出登录</el-button>
|
||||||
|
</div>
|
||||||
|
</el-header>
|
||||||
|
<el-main class="main-content">
|
||||||
|
<component :is="currentComponent"></component>
|
||||||
|
</el-main>
|
||||||
|
</el-container>
|
||||||
|
</el-container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/vue@3.4.21/dist/vue.global.prod.js"></script>
|
||||||
|
<script src="https://unpkg.com/element-plus@2.7.3/dist/index.full.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@element-plus/icons-vue@2.3.1/dist/index.iife.min.js"></script>
|
||||||
|
<script src="/admin/js/request.js"></script>
|
||||||
|
<script src="/admin/js/api/auth.js"></script>
|
||||||
|
<script src="/admin/js/api/user.js"></script>
|
||||||
|
<script src="/admin/js/api/order.js"></script>
|
||||||
|
<script src="/admin/js/api/product.js"></script>
|
||||||
|
<script src="/admin/js/views/dashboard.js"></script>
|
||||||
|
<script src="/admin/js/views/user.js"></script>
|
||||||
|
<script src="/admin/js/views/order.js"></script>
|
||||||
|
<script src="/admin/js/views/product.js"></script>
|
||||||
|
<style>[v-cloak] { display: none; }</style>
|
||||||
|
<script>
|
||||||
|
const { createApp, ref, computed } = Vue;
|
||||||
|
const app = createApp({
|
||||||
|
setup() {
|
||||||
|
const currentView = ref('dashboard');
|
||||||
|
const views = { dashboard: DashboardView, user: UserView, order: OrderView, product: ProductView };
|
||||||
|
const titles = { dashboard: '数据概览', user: '用户管理', order: '订单管理', product: '商品管理' };
|
||||||
|
const currentComponent = computed(() => views[currentView.value]);
|
||||||
|
const title = computed(() => titles[currentView.value]);
|
||||||
|
const handleMenuSelect = (index) => { currentView.value = index; };
|
||||||
|
const handleLogout = () => {
|
||||||
|
document.cookie = 'Token=; path=/; max-age=0';
|
||||||
|
window.location.href = '/admin/page/login';
|
||||||
|
};
|
||||||
|
return { currentView, currentComponent, title, handleMenuSelect, handleLogout };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 注册 Element Plus 图标(容错:CDN 未加载时不阻断应用)
|
||||||
|
if (window.ElementPlusIconsVue) {
|
||||||
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||||
|
app.component(key, component);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.use(ElementPlus);
|
||||||
|
app.mount('#app');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>OfferPie 管理后台</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/element-plus@2.7.3/dist/index.css"/>
|
||||||
|
<style>[v-cloak] { display: none; }</style>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 0; }
|
||||||
|
#app {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
.left-panel {
|
||||||
|
flex: 1;
|
||||||
|
background: linear-gradient(160deg, #1d1e3c 0%, #2b2d5e 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 80px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.left-panel::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 400px;
|
||||||
|
height: 400px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(64, 158, 255, 0.06);
|
||||||
|
top: 10%;
|
||||||
|
right: -100px;
|
||||||
|
}
|
||||||
|
.left-panel .logo { display: flex; align-items: center; gap: 14px; margin-bottom: 24px; }
|
||||||
|
.left-panel .logo img { height: 44px; }
|
||||||
|
.left-panel .logo span { font-size: 26px; font-weight: 600; color: #fff; }
|
||||||
|
.left-panel .desc { font-size: 15px; color: rgba(255,255,255,0.5); line-height: 1.8; }
|
||||||
|
.left-panel .footer { position: absolute; bottom: 30px; left: 80px; font-size: 12px; color: rgba(255,255,255,0.2); }
|
||||||
|
.right-panel {
|
||||||
|
width: 480px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
.login-box { width: 100%; max-width: 340px; }
|
||||||
|
.login-box h2 { font-size: 22px; color: #303133; margin-bottom: 6px; }
|
||||||
|
.login-box .sub { font-size: 13px; color: #909399; margin-bottom: 32px; }
|
||||||
|
.login-box .el-button { width: 100%; margin-top: 8px; }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.left-panel { display: none; }
|
||||||
|
.right-panel { width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app" v-cloak>
|
||||||
|
<div class="left-panel">
|
||||||
|
<div class="logo">
|
||||||
|
<img src="/admin/img/logo.png" alt="logo"/>
|
||||||
|
<span>OfferPie</span>
|
||||||
|
</div>
|
||||||
|
<div class="desc">运营管理后台</div>
|
||||||
|
<div class="footer">© 2025 OfferPie</div>
|
||||||
|
</div>
|
||||||
|
<div class="right-panel">
|
||||||
|
<div class="login-box">
|
||||||
|
<h2>欢迎回来</h2>
|
||||||
|
<p class="sub">请登录管理员账号</p>
|
||||||
|
<el-form :model="form" :rules="rules" ref="formRef" @keyup.enter="handleLogin">
|
||||||
|
<el-form-item prop="username">
|
||||||
|
<el-input v-model="form.username" placeholder="账号" prefix-icon="User" size="large" maxlength="32"/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item prop="password">
|
||||||
|
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" size="large" maxlength="32" show-password/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" size="large" :loading="loading" @click="handleLogin">登录</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/vue@3.4.21/dist/vue.global.prod.js"></script>
|
||||||
|
<script src="https://unpkg.com/element-plus@2.7.3/dist/index.full.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@element-plus/icons-vue@2.3.1/dist/index.iife.min.js"></script>
|
||||||
|
<script src="/admin/js/request.js"></script>
|
||||||
|
<script src="/admin/js/api/auth.js"></script>
|
||||||
|
<script>
|
||||||
|
const { createApp, ref, reactive } = Vue;
|
||||||
|
const app = createApp({
|
||||||
|
setup() {
|
||||||
|
const formRef = ref(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const form = reactive({ username: '', password: '' });
|
||||||
|
const rules = {
|
||||||
|
username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
|
||||||
|
password: [{ required: true, message: '请输入密码', trigger: 'blur' }, { min: 6, max: 32, message: '密码长度6-32位', trigger: 'blur' }]
|
||||||
|
};
|
||||||
|
const handleLogin = async () => {
|
||||||
|
const valid = await formRef.value.validate().catch(() => false);
|
||||||
|
if (!valid) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await authApi.login(form.username, form.password);
|
||||||
|
window.location.href = '/admin/page/index';
|
||||||
|
} catch (e) { }
|
||||||
|
finally { loading.value = false; }
|
||||||
|
};
|
||||||
|
return { formRef, form, rules, loading, handleLogin };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 注册 Element Plus 图标(容错)
|
||||||
|
if (window.ElementPlusIconsVue) {
|
||||||
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||||
|
app.component(key, component);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.use(ElementPlus);
|
||||||
|
app.mount('#app');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: offerpie-backend-admin-nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "10062:80"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 256M
|
||||||
|
cpus: '1'
|
||||||
|
|
||||||
|
blue:
|
||||||
|
image: offerpie-backend-admin:latest
|
||||||
|
container_name: offerpie-backend-admin-blue
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "8081"
|
||||||
|
environment:
|
||||||
|
- APP_VERSION=blue
|
||||||
|
- PROFILES_ACTIVE=prod
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8081/admin/public/actuator/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
volumes:
|
||||||
|
- /logs/offerpie-backend-admin:/app/logs
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1G
|
||||||
|
cpus: '1'
|
||||||
|
|
||||||
|
green:
|
||||||
|
image: offerpie-backend-admin:latest
|
||||||
|
container_name: offerpie-backend-admin-green
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "8081"
|
||||||
|
environment:
|
||||||
|
- APP_VERSION=green
|
||||||
|
- PROFILES_ACTIVE=prod
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8081/admin/public/actuator/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
volumes:
|
||||||
|
- /logs/offerpie-backend-admin:/app/logs
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1G
|
||||||
|
cpus: '1'
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: offerpie-backend-admin-nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "10062:80"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 256M
|
||||||
|
cpus: '1'
|
||||||
|
|
||||||
|
blue:
|
||||||
|
image: offerpie-backend-admin:test
|
||||||
|
container_name: offerpie-backend-admin-blue
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "8081"
|
||||||
|
environment:
|
||||||
|
- APP_VERSION=blue
|
||||||
|
- PROFILES_ACTIVE=test
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8081/admin/public/actuator/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
volumes:
|
||||||
|
- /logs/offerpie-backend-admin:/app/logs
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1G
|
||||||
|
cpus: '1'
|
||||||
|
|
||||||
|
green:
|
||||||
|
image: offerpie-backend-admin:test
|
||||||
|
container_name: offerpie-backend-admin-green
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "8081"
|
||||||
|
environment:
|
||||||
|
- APP_VERSION=green
|
||||||
|
- PROFILES_ACTIVE=test
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8081/admin/public/actuator/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
volumes:
|
||||||
|
- /logs/offerpie-backend-admin:/app/logs
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1G
|
||||||
|
cpus: '1'
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package org.jiayunet.mapper;
|
||||||
|
|
||||||
|
import org.jiayunet.pojo.po.AdminUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员用户 Mapper
|
||||||
|
*
|
||||||
|
* @author zk
|
||||||
|
*/
|
||||||
|
public interface AdminUserMapper extends CommonMapper<AdminUser> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package org.jiayunet.pojo.po;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员用户表(bg_admin_user)
|
||||||
|
*
|
||||||
|
* @author zk
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("bg_admin_user")
|
||||||
|
public class AdminUser {
|
||||||
|
|
||||||
|
/** 主键 */
|
||||||
|
@TableId(type = IdType.ASSIGN_ID)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 登录账号 */
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
/** 密码(BCrypt) */
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
/** 真实姓名 */
|
||||||
|
private String realName;
|
||||||
|
|
||||||
|
/** 昵称 */
|
||||||
|
private String nickName;
|
||||||
|
|
||||||
|
/** 手机号码 */
|
||||||
|
private String mobileNumber;
|
||||||
|
|
||||||
|
/** 头像URL */
|
||||||
|
private String avatar;
|
||||||
|
|
||||||
|
/** 状态 0=正常 1=禁用 */
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
/** 创建时间 */
|
||||||
|
private Instant createTime;
|
||||||
|
|
||||||
|
/** 更新时间 */
|
||||||
|
private Instant updateTime;
|
||||||
|
|
||||||
|
/** 逻辑删除 0=正常 */
|
||||||
|
@TableLogic(value = "0", delval = "UNIX_TIMESTAMP()")
|
||||||
|
private Long isDelete;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user