Compare commits

35 Commits
Author SHA1 Message Date
ops 679a15d1e1 fix(ci): 构建容器名唯一化 + npm 走国内镜像源(避免并发互踩 exit 137) 2026-10-21 06:24:24 +00:00
ops f9ea615e97 ci: apk 源统一走 aliyun(规避 dl-cdn 时段性被拦卡 60s) 2026-10-21 01:36:55 +00:00
chenxing b1b16332e4 ci 2026-09-07 18:29:17 +08:00
chenxing c7404b9d5e env-config修订 2026-09-07 18:16:37 +08:00
chenxing 7e37f2ada6 更改校端权限 2026-09-07 09:32:38 +08:00
chenxing 9200fa2135 更改环境配置 2026-09-03 23:57:05 +08:00
chenxing 790f0e3db5 bug修订 2026-09-02 08:41:35 +08:00
chenxing 428ee56bd3 读取动态配置 2026-09-01 11:50:38 +08:00
chenxing 886cce1b9e 跨校身份更改 2026-08-27 20:13:16 +08:00
chenxing 73066b5ab3 转出状态 2026-08-26 12:37:56 +08:00
chenxing 9c04b5e9fc 样式修复 2026-08-20 15:35:43 +08:00
chenxing f5c1289681 开放日志修改 2026-08-20 12:17:29 +08:00
chenxing 049965f51b 添加开放接口日志 2026-08-20 10:23:47 +08:00
chenxing 35529fc671 api路径修复 2026-08-19 12:01:26 +08:00
chenxing eb0ab76c83 修复api 2026-08-19 11:28:31 +08:00
chenxing bf6439888e 更新api 2026-08-19 00:26:27 +08:00
chenxing a096948371 api更改 2026-08-19 00:19:03 +08:00
chenxing 5247515a85 ci 2026-08-18 23:48:18 +08:00
chenxing 02f1fd2b14 修复重复弹msg的bug 2026-08-18 23:31:27 +08:00
chenxing 8fd5a21fcc 推送/操作日志下载改location.href;移动组关闭自动select;所在组改单选 2026-08-18 00:04:08 +08:00
chenxing 58a606764b 更改推送日志功能 2026-08-17 22:54:49 +08:00
chenxing 4b73cc7b29 推送日志修改 2026-08-17 20:25:18 +08:00
chenxing 89e7c08b2f 功能修改 2026-08-17 19:49:00 +08:00
chenxing 2321571060 修改角色bug 2026-08-17 18:54:00 +08:00
chenxing 0fa4b0f38a 修复所在组bug 2026-08-17 17:19:04 +08:00
chenxing b96bbc9a62 修改功能 2026-08-17 16:41:08 +08:00
chenxing 85619785f6 推送功能添加 2026-08-14 09:08:01 +08:00
chenxing 4077ef64d8 组用户功能修订 2026-08-12 19:19:46 +08:00
chenxing 82f4c623af 修改环境变量,重新ci 2026-08-10 17:57:27 +08:00
chenxing 648c32c29e test 2026-08-10 16:33:36 +08:00
chenxing bf2d800c63 docs: 注明仓库Actions已启用,push到main自动触发部署 2026-08-10 16:24:16 +08:00
chenxing 4adf38af77 fix: deploy.yml 两处修复 - .npmrc 仅存在时删(被gitignore,CI无此文件); 软链改用相对路径(releases/X)避免Caddy容器内绝对路径断链 2026-08-10 16:16:58 +08:00
chenxing 020911419e fix: 前端部署方案B修复 - deploy.yml支持首次真实目录迁移到releases/initial再建软链; 删除多余且基于错误假设的caddy-switch.yml(Caddy已是file_server) 2026-08-10 16:06:09 +08:00
chenxing 06ee85cf8d ci: 前端方案B - 软链接版本化部署(不再打镜像) + caddy-switch 工作流 2026-08-10 15:42:00 +08:00
chenxing eea881b616 init: UserBaseFront (frontend) 2026-08-10 14:41:13 +08:00
436 changed files with 9406 additions and 20908 deletions
+9
View File
@@ -0,0 +1,9 @@
# 开发环境 (dev):仅 vite dev 加载(--mode dev)。值可自由指本地或线上地址。
# build 产物不读此文件(build 默认 production mode,只认 .env / .env.production,两者均不存在),
# 线上环境差异全部由 projman 的 env-config-{目标}.json 承担。
VITE_BASE_HOST=http://base.bestsch.com
VITE_SSO_HOST=http://sso.yunhaixingtong.com
VITE_SERVICE_HOST=http://localhost:8060
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
VITE_IS_MOCK=false
-144
View File
@@ -1,144 +0,0 @@
name: ci-userbase-service
# 后端标准化 CI:maven 构建 jar -> docker build -> 推 Nexus docker-hosted
# 触发:push 到 main(仅 service 或本文件变更)/ 手动 Run workflow
# 依赖仓库 Actions Secrets:NEXUS_USER / NEXUS_PASSWORD(ci-docker 账号,仅 docker-hosted 推送权)
on:
push:
branches: [main]
paths:
- 'service/**'
- '.gitea/workflows/ci.yml'
workflow_dispatch:
inputs:
tag:
description: 'image tag (default = short sha)'
required: false
default: 'auto'
deploy:
description: 'deploy to 160 after build (auto / skip)'
required: false
default: 'auto'
jobs:
build-and-push:
runs-on: ubuntu-latest
# docker:24 自带 docker CLI,复用宿主 docker daemon。
# 注意:act_runner v0.6.1 默认已把宿主 docker.sock 挂入每个 job 容器,
# 切勿在此再声明 volumes: docker.sock(会与默认挂载重复,报
# "Duplicate mount point" 导致容器创建失败、全部 step cancelled)。
container:
image: docker:24
steps:
# docker:24 基于 alpine,默认无 git;先装 git 再手动 clone 本仓库
- name: Install git (alpine)
run: |
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
apk add --no-cache git
# 内网 Gitea 无法访问 github.com 拉取 actions/checkout,改为直接 clone 本仓库
- name: Checkout (manual, internal Gitea)
run: |
git clone --depth 1 --branch main http://192.168.10.160:3000/${GITHUB_REPOSITORY}.git /tmp/src
cp -a /tmp/src/. .
rm -rf /tmp/src
- name: Compute image tag
id: meta
run: |
if [ "${{ inputs.tag }}" = "auto" ] || [ -z "${{ inputs.tag }}" ]; then
echo "tag=$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_OUTPUT
else
echo "tag=${{ inputs.tag }}" >> $GITHUB_OUTPUT
fi
- name: Build jar with Maven
run: |
# 注意:job 容器通过 docker.sock 调的是【宿主 docker daemon】,
# 因此 docker run -v <path> 里的路径会被宿主按【宿主路径】解析;
# 而 act 工作区是宿主上的 docker volume,容器内 $PWD 对宿主不可见,
# 直接 -v $PWD:/build 会绑成空目录导致找不到 pom.xml。
# 故改为:起 maven 容器 -> docker cp 源码入容器 -> exec 构建 -> cp 产物回工作区。
# mavenbuild 加入 infra-infra-net,用容器名 nexus 直连 Nexus(8081 maven-public)。
# 之前用 --add-host=nexus.yunhaixingtong.com:host-gateway 经 Caddy:80 反代,但该路径在
# CI 容器内不可达,maven 回退到 /opt/docker/ubs-m2 里 08-05 的旧 yh-auth 缓存(缺
# CasSsoAutoConfiguration),镜像启动报 serverName 崩溃。现改用 settings.xml mirror 把
# pom 的 HTTP 仓库重定向到 http://nexus:8081/repository/maven-public/(已验证 -7 可达)。
# 先清理上次失败残留的同名容器(set -e 下末尾的 rm 不会执行)
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
docker run -d --name "$BUILD_CONTAINER" \
--network infra-infra-net \
-v /opt/docker/ubs-m2:/root/.m2 \
-w /build maven:3.6.3-jdk-8 sleep 3600
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
# 写 settings.xml:新增 profile 把 nexus:8081 maven-public 作为额外仓库(infra-infra-net 内容器名直连,已验证 -7 可达)。
# 不用 mirror(external:http:*) 是因为它对 BOM import 解析未生效;profile 方式更可靠。
# pom 原有的 nexus.yunhaixingtong.com(Caddy:80) 在 CI 容器内不可解析 → 快速失败,maven 转而用本仓库。
docker exec "$BUILD_CONTAINER" sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
# 仅清旧 yh-auth 缓存(含 08-05 local install 的 maven-metadata-local.xml,避免本地安装优先于远端 -7);
# BOM 等其它 yh.platform 模块保留缓存(Nexus 上它们仍是 08-05 的 -1,缓存与远端一致)。
docker exec "$BUILD_CONTAINER" rm -rf /root/.m2/repository/com/yunhai/platform/yh-auth-spring-boot-starter
docker exec "$BUILD_CONTAINER" sh -c 'cd /build/service && mvn -U -B -DskipTests clean package'
mkdir -p "${PWD}/service/target"
docker cp "$BUILD_CONTAINER":/build/service/target/userbase.jar "${PWD}/service/target/userbase.jar"
docker rm -f "$BUILD_CONTAINER"
- name: Login to Nexus docker registry
run: echo "${{ secrets.NEXUS_PASSWORD }}" | docker login nexus:8083 -u ${{ secrets.NEXUS_USER }} --password-stdin
- name: Build image
run: docker build -t nexus:8083/docker-hosted/userbase-service:${{ steps.meta.outputs.tag }} -f service/docker/Dockerfile service
- name: Push image
run: docker push nexus:8083/docker-hosted/userbase-service:${{ steps.meta.outputs.tag }}
- name: Report build
run: echo "pushed nexus:8083/docker-hosted/userbase-service:${{ steps.meta.outputs.tag }}"
# —— 部署阶段(仅在 push 或手动 dispatch deploy=auto 时执行)——
# 运行在 160 的 act_runner 上,复用宿主 docker daemon(docker.sock 由 act_runner 自动挂载),
# 因此 docker pull / run 直接作用于 160 宿主,等价于在 160 上执行部署。
- name: Deploy (pull + rollout with rollback)
if: github.event_name == 'push' || inputs.deploy == 'auto'
run: |
set +e
IMG=nexus:8083/docker-hosted/userbase-service:${{ steps.meta.outputs.tag }}
echo "deploying $IMG"
echo "${{ secrets.NEXUS_PASSWORD }}" | docker login nexus:8083 -u ${{ secrets.NEXUS_USER }} --password-stdin
docker pull "$IMG"
# 回滚锚:保留当前 userbase-service:latest(已知良好镜像),再额外打一个时间点标签
RB=userbase-service:rb-$(date +%s)
docker tag userbase-service:latest "$RB" 2>/dev/null || true
docker stop userbase-service 2>/dev/null; docker rm -f userbase-service 2>/dev/null
# 注意(SCA 2.2.4 坑): 命名空间必须用标准属性名 SPRING_CLOUD_NACOS_CONFIG_NAMESPACE,
# 用 NACOS_NAMESPACE 这个 env 名不会被 bootstrap 的 ${NACOS_NAMESPACE:dev} 解析到(会落 public);
# 凭据必须放顶层 SPRING_CLOUD_NACOS_USERNAME/PASSWORD, 放在 spring.cloud.nacos.config.* 下不生效(403)。
docker run -d --name userbase-service --restart unless-stopped --network infra-infra-net -p 8060:8060 \
-e NACOS_ADDR=nacos:8848 \
-e SPRING_CLOUD_NACOS_CONFIG_NAMESPACE=6196f0de-a28d-4894-8f10-e2982b57c6bc \
-e SPRING_CLOUD_NACOS_USERNAME=yh-reader \
-e SPRING_CLOUD_NACOS_PASSWORD=123456 \
"$IMG"
# 健康检查:Spring 起来后 /userbase/ 返回 404(根路径无映射控制器),000 表示未起来
OK=0
for i in $(seq 1 30); do
sleep 5
CODE=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8060/userbase/ 2>/dev/null || true)
if [ "$CODE" != "000" ]; then OK=1; break; fi
done
if [ "$OK" = "0" ]; then
echo "!! UNHEALTHY -> rollback to userbase-service:latest"
docker stop userbase-service 2>/dev/null; docker rm -f userbase-service 2>/dev/null
docker run -d --name userbase-service --restart unless-stopped --network infra-infra-net -p 8060:8060 userbase-service:latest
echo "rolled back"; exit 7
fi
echo "DEPLOY OK: $IMG"
docker tag "$IMG" userbase-service:latest
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}' | grep userbase || true
-55
View File
@@ -1,55 +0,0 @@
name: deploy-userbase-front
on:
push:
branches: [main]
paths:
- 'front/**'
- '.gitea/workflows/deploy-front.yml'
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
# node:20 自带 npm 与 git;通过已配置的 registry-mirrors 拉取
# /data/www/userbase 与 docker.sock 由 act_runner 全局 options 统一挂载(见 act-runner config)
container:
image: node:20
# 注意:本环境 act_runner v0.6.1 既不生效 container.options 的额外 --volume,
# 也不生效 workflow 的 container.volumes(自定义卷被静默忽略),
# 但它会默认把宿主 docker.sock 挂入每个 job 容器。
# 因此部署改用 scripts/deploy-via-docker.js:通过该 docker.sock 启动一次性
# helper 容器,把构建产物拷到宿主 /data/www/userbase/front。
steps:
# 内网 Gitea 无法访问 github.com 拉取 actions/checkout,改为直接 clone 本仓库
- name: Checkout (manual, internal Gitea)
run: |
git clone --depth 1 --branch main http://192.168.10.160:3000/${GITHUB_REPOSITORY}.git /tmp/src
cp -a /tmp/src/. .
rm -rf /tmp/src
- name: Use npm mirror (npmmirror)
working-directory: front
run: npm config set registry https://registry.npmmirror.com
# node-sass(6.x) 无 Node20 预编译二进制,源码编译必失败;项目实际用 dart-sass(sass)。
# 本地 lockfile 暂不便修改,这里在 runner 内把它从 lockfile 剔除后再 npm ci(不改仓库文件)。
- name: Strip node-sass from lockfile (CI-only)
run: |
node -e "const fs=require('fs');const p='front/package-lock.json';const d=JSON.parse(fs.readFileSync(p,'utf8'));const pk=d.packages||{};for(const k of Object.keys(pk)){if(k==='node_modules/node-sass'||k.startsWith('node_modules/node-sass/'))delete pk[k];}if(d.dependencies&&d.dependencies.node-sass)delete d.dependencies.node-sass;fs.writeFileSync(p,JSON.stringify(d,null,2));"
echo "node-sass stripped from lockfile"
- name: Install dependencies
working-directory: front
run: npm ci --no-audit --no-fund
- name: Build (prod -> dist/prod)
working-directory: front
run: npm run build:prod
- name: Deploy to static dir (/data/www/userbase/front)
# act_runner 不挂载自定义卷,改用宿主 docker.sock 启动一次性 helper 容器拷贝产物。
run: node scripts/deploy-via-docker.js
- name: Done
run: 'echo "deploy step finished; inspect host /data/www/userbase/_debug.txt for the full report"'
+99
View File
@@ -0,0 +1,99 @@
name: deploy-userbase-front
# 前端部署(方案 B:不打包镜像,直接部署静态文件 + 软链接版本控制)
# 触发:push 到 main(已启用仓库 Actions,push 自动部署)/ 手动 Run workflow
# 核心:vite build --mode prod -> dist/prod
# -> 拷贝到 /data/www/userbase/releases/<version>/
# -> 原子切换 /data/www/userbase/front 软链接指向新版本
# -> 保留最近 5 个版本,更早自动清理(秒级回滚)
# 不再构建/推送 nginx 镜像;Caddy 已改为 file_server(root=/srv/userbase=/data/www/userbase)。
# 依赖:160 宿主的 docker daemon(runner 已挂 docker.sock),且 runner valid_volumes 已放行 /data/www/**。无需 NEXUS secret。
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
# docker:24 自带 docker CLI,复用宿主 docker daemon(act_runner v0.6.1 已挂载 docker.sock)。
# 切勿再声明 volumes: docker.sock(会报 Duplicate mount point)。
container:
image: docker:24
steps:
- name: Install git (alpine)
run: |
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
apk add --no-cache git
# 内网 Gitea 无法访问 github.com 拉取 actions/checkout,改为直接 clone 本仓库
- name: Checkout (manual, internal Gitea)
run: |
git clone --depth 1 --branch main http://192.168.10.160:3000/${GITHUB_REPOSITORY}.git /tmp/src
cp -a /tmp/src/. .
rm -rf /tmp/src
# 构建 + 部署:在 node:20 容器里完成(源码经 docker cp 传入,并挂载宿主 /data/www/userbase)
- name: Build & Deploy (symlink versioning)
run: |
set -e
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
BUILD_CONTAINER="frontbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
# 版本号:short sha + 时间戳(可改为 git tag 优先,见下)
VERSION="$(echo ${GITHUB_SHA} | cut -c1-7)-$(date +%Y%m%d-%H%M)"
echo "Deploying version: $VERSION"
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
docker run -d --name "$BUILD_CONTAINER" \
-e VERSION="$VERSION" \
-v /data/www/userbase:/data/www/userbase \
-w /build node:20 sleep 3600
# 源码拷入构建容器(绕过 act 工作区非宿主路径的坑,与 service 的 mavenbuild 一致)
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
docker exec "$BUILD_CONTAINER" sh -c '
set -e
cd /build
# 去掉 Windows 专用 sass 二进制路径(Linux 容器内该路径无效,否则 build 报错)
# .npmrc 被 gitignore、CI 克隆下来没有它,故仅在存在时才删(缺失不报错)
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
# 国内镜像源:内网直连 registry.npmjs.org 跨境极慢(实测 npm install 12 分 46 秒 / 754 包)
npm config set registry https://registry.npmmirror.com
npm ci
npm run build:prod
'
# 部署:首次把真实目录 front 迁入 releases/initial 并建软链;之后均为软链替换
docker exec "$BUILD_CONTAINER" sh -c '
set -e
cd /build
# ---- 首次初始化:front 当前是真实目录,无法直接 ln -sfn 覆盖 ----
if [ ! -L /data/www/userbase/front ]; then
mkdir -p /data/www/userbase/releases
mv /data/www/userbase/front /data/www/userbase/releases/initial
# 相对软链:Caddy 容器内 /data/www/userbase 挂在 /srv/userbase,
# 绝对路径在容器内解析不到,必须用相对路径(相对 front 所在目录)
ln -sfn releases/initial /data/www/userbase/front
echo "INIT: front -> releases/initial (保留上线前状态用于回滚)"
fi
mkdir -p /data/www/userbase/releases/$VERSION
cp -r /build/dist/prod/. /data/www/userbase/releases/$VERSION/
# 原子切换:front 此时已是软链接,ln -sfn 直接替换指向(必须用相对路径)
ln -sfn releases/$VERSION /data/www/userbase/front
echo "current symlink ->"; ls -la /data/www/userbase/front
# 清理旧版本,保留最近 5 个(initial 也会被计入,最多保留 5 个含 initial)
cd /data/www/userbase/releases
ls -1t | tail -n +6 | while read d; do echo "rm old: $d"; rm -rf "$d"; done
echo "remaining versions:"; ls -1t
'
docker rm -f "$BUILD_CONTAINER"
echo "DEPLOY OK: $VERSION"
-27
View File
@@ -1,27 +0,0 @@
name: diag-userbase
on:
workflow_dispatch:
inputs:
dummy:
description: 'noop'
required: false
default: 'x'
jobs:
diag:
runs-on: ubuntu-latest
container:
image: docker:24
steps:
- name: inspect running container config + embedded run-app.sh
run: |
echo "===== container env (NACOS?) ====="
docker inspect userbase-service --format '{{json .Config.Env}}' 2>&1 || echo "NO CONTAINER"
echo
echo "===== -D patch present in RUNNING container's run-app.sh? ====="
docker exec userbase-service grep -E 'Dyh.sso|client-host-url|server-url-prefix' /app/run-app.sh 2>&1 || echo "NO -D patch found in running container"
echo
echo "===== running image id ====="
docker inspect userbase-service --format '{{.Image}}' 2>&1 || true
echo
echo "===== all userbase-service images (id / tag / age) ====="
docker images --filter reference='*/userbase-service*' --format '{{.ID}} {{.Tag}} {{.CreatedSince}}' 2>&1
-47
View File
@@ -1,47 +0,0 @@
name: example-docker-push
# 示例:演示 act_runner 的 docker.sock 能力(构建并推送镜像到 Nexus docker-hosted)
# 需要仓库 Settings -> Actions Secrets 配置:
# NEXUS_USER = admin
# NEXUS_PASSWORD = <nexus admin password>
# 手动触发(Actions 页面 -> Run workflow),不会自动跑。
on:
workflow_dispatch:
inputs:
tag:
description: 'image tag to push'
required: false
default: 'demo'
jobs:
docker-build-push:
runs-on: ubuntu-latest
# docker:24 自带 docker CLI,复用宿主 docker daemon
# docker.sock 显式挂入 job 容器(act_runner 会校验 valid_volumes 白名单)
container:
image: docker:24
volumes:
- /var/run/docker.sock:/var/run/docker.sock
steps:
# docker:24 基于 alpine,默认无 git;先装 git 再手动 clone 本仓库
- name: Install git (alpine)
run: |
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
apk add --no-cache git
# 内网 Gitea 无法访问 github.com 拉取 actions/checkout,改为直接 clone 本仓库
- name: Checkout (manual, internal Gitea)
run: |
git clone --depth 1 --branch main http://192.168.10.160:3000/${GITHUB_REPOSITORY}.git /tmp/src
cp -a /tmp/src/. .
rm -rf /tmp/src
- name: Login to Nexus docker registry
run: echo "${{ secrets.NEXUS_PASSWORD }}" | docker login nexus:8083 -u ${{ secrets.NEXUS_USER }}
- name: Build image (front nginx)
run: docker build -t nexus:8083/docker-hosted/userbase-front:${{ inputs.tag }} -f front/docker/Dockerfile front
- name: Push image
run: docker push nexus:8083/docker-hosted/userbase-front:${{ inputs.tag }}
+24 -26
View File
@@ -1,32 +1,30 @@
# ---- dependencies ----
node_modules/
# ---- build output (CI builds, do not commit) ----
front/dist/
front/dist_ra/
# ---- java build ----
service/target/
# ---- local tooling / IDE ----
.local-context/
# WorkBuddy local state
.workbuddy/
# ---- env secrets (keep *.example) ----
.env
!.env.example
# ---- logs ----
# Logs
logs
*.log
deploy/*_run*.log
deploy/verify_run*.log
deploy/push_run.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# ---- OS ----
Thumbs.db
node_modules
dist
dist-ssr
*.local
yarn.lock
.npmrc
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
# ---- IDE ----
.idea/
# ---- vite temp cache ----
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.timestamp-*.mjs
+26
View File
@@ -0,0 +1,26 @@
// 纠正当前 api:gen 产物中命名空间的大小写错误
// 映射依据:后端 service 下真实的 *Controller 类名 -> PascalCase 命名空间
const fs = require('fs');
const map = {
Approle: 'AppRole',
Appsettings: 'AppSettings',
Groupauthz: 'GroupAuthz',
Pushsubscription: 'PushSubscription',
Signkeyinternal: 'SignKeyInternal',
Sysgroup: 'SysGroup',
Sysuser: 'SysUser',
};
const targets = ['src/api/apiDefinitions.ts', 'src/api/globals.d.ts'];
for (const f of targets) {
let s = fs.readFileSync(f, 'utf8');
for (const [wrong, correct] of Object.entries(map)) {
// 带引号 key:'Sysgroup. -> 'SysGroup.
s = s.split(`'${wrong}.`).join(`'${correct}.`);
// 裸引用(globals.d.ts 里的 interface/属性声明):Sysgroup: -> SysGroup: / Sysgroup. -> SysGroup.
s = s.split(`${wrong}:`).join(`${correct}:`);
s = s.split(`${wrong}.`).join(`${correct}.`);
}
fs.writeFileSync(f, s);
console.log('fixed', f);
}
console.log('done');
+65
View File
@@ -0,0 +1,65 @@
import type { GeneratorConfig } from "@alova/wormhole";
export default {
generator: [
{
input: "http://localhost:8060/v3/api-docs",
// 文档平台类型
platform: "swagger",
// 生成代码的输出目录
output: "src/api",
// 响应数据的媒体类型
responseMediaType: "application/json",
// 请求体数据的媒体类型
bodyMediaType: "application/json",
// 生成的 API 版本
version: 1,
// 生成代码类型
type: "typescript",
// 全局 API 名称
global: "Apis",
splitImplementation: true,
implementation: "inline",
// API 处理函数
handleApi: apiDescriptor => {
// 过滤掉已废弃的 API
if (apiDescriptor.deprecated) {
return undefined;
}
// 优先使用后端显式声明的 operationId(稳定、可读、无重名后缀),
// 仅在 operationId 缺失时回退到 summary。切勿用 summary 覆盖 operationId,
// 否则中文/长文本 summary 会变成方法名导致运行时查不到。
const newOpId = apiDescriptor.operationId ?? apiDescriptor.summary?.trim();
const url = apiDescriptor.url.replace(/^\/{platform}\//, "/api/");
// springdoc 默认把 Controller 类名转为 kebab tag(如 base-controller),
// 而业务代码统一用 Pascal 无 Controller 的命名空间(如 Base)调用。
// 这里剥掉 -controller 后缀并转为 PascalCase,使 wormhole 生成的
// API 路径(Base.getUserInfo 等)与 Apis.Base.xxx 调用风格一致。
// 由于转换结果是合法 JS 标识符,wormhole 会原样保留、不做 camelCase 改写。
// springdoc v3 的 tag 有两种形态,都要能正确转为 PascalCase 且无 Controller 后缀:
// - Pascal+Controller:SysGroupController -> SysGroup
// - kebab+controller:sys-group-controller -> SysGroup
// 关键:剥掉尾部 Controller 后,要在 camelCase 边界补空格再逐段首字母大写,
// 否则 SysGroup -> Sysgroup(后面全被 toLowerCase 压成小写)会让运行时查不到。
const toNamespace = (tag: string): string => {
const stripped = tag.replace(/controller$/i, "");
const withSpaces = stripped.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
const parts = withSpaces.split(/[^a-zA-Z0-9]+/).filter(Boolean);
return parts
.map((p: string) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase())
.join("");
};
if (Array.isArray(apiDescriptor.tags)) {
apiDescriptor.tags = apiDescriptor.tags.map(toNamespace);
}
return {
...apiDescriptor,
operationId: newOpId, // 关键:改掉它
url,
};
},
},
],
} as GeneratorConfig;
-20
View File
@@ -1,20 +0,0 @@
# 本地构建推送 / 远端部署 用的环境变量示例
# 用法:复制为 .env 或直接 export 后执行脚本
# ---- ACR 阿里云镜像仓库 ----
ACR_REGISTRY=registry.cn-hangzhou.aliyuncs.com
ACR_NAMESPACE=damaomi_docker
ACR_REPO=yunhai
ACR_USERNAME=your-acr-username
ACR_PASSWORD=your-acr-password
# ---- 镜像 tag ----
APP_VERSION=userbase-v1.0
FRONT_VERSION=userbase-front-v1.0
# ---- 部署到远端 ----
DEPLOY_HOST=192.168.10.157
DEPLOY_PORT=22
DEPLOY_USER=yunhai
REMOTE_DIR=/opt/docker/userbase
SSHPASS=your-ssh-password
-6
View File
@@ -1,6 +0,0 @@
docker run --rm --network infra-infra-net --entrypoint java registry.cn-hangzhou.aliyuncs.com/damaomi_docker/yunhai:userbase-v1.0 -jar /app/userbase.jar --spring.profiles.active=prod --debug > /tmp/ub_debug.log 2>&1
echo "EXIT=$?"
echo "=== conditions / binding matches ==="
grep -nE "YhAuthAutoConfiguration|CasSsoConfiguration|Failed to bind|Binding to|YhSsoProperties|Exception starting filter|serverUrlPrefix|NoSuchBean" /tmp/ub_debug.log | head -60
echo "=== tail (conditions report) ==="
tail -50 /tmp/ub_debug.log
-1
View File
@@ -1 +0,0 @@
EOF
-17
View File
@@ -1,17 +0,0 @@
set -e
echo "== wait for health (up to 180s) =="
for i in $(seq 1 36); do
st=$(docker inspect -f '{{.State.Health.Status}}' userbase-app 2>/dev/null || echo unknown)
echo " [$i] health=$st"
if [ "$st" = "healthy" ]; then break; fi
sleep 5
done
echo "== container status =="
docker ps --filter name=userbase --format "table {{.Names}}\t{{.Status}}"
echo "== nacos reachability (host) =="
curl -s -m 5 -o /dev/null -w "host nacos http=%{http_code}\n" "http://nacos.yunhaixingtong.com:8848/nacos/v1/cs/configs?dataId=userbase.yaml&group=USERBASE&tenant=" || echo "host cannot reach nacos"
echo "== nacos reachability (container) =="
docker exec userbase-app sh -c "curl -s -m 5 -o /dev/null -w 'container nacos http=%{http_code}\n' 'http://nacos.yunhaixingtong.com:8848/nacos/v1/cs/configs?dataId=userbase.yaml&group=USERBASE&tenant='" || echo "container cannot reach nacos"
echo "== app logs: nacos / config / errors (tail) =="
docker logs --tail 200 userbase-app 2>&1 | grep -iE "Loading nacos data|userbase.yaml|locate Nacos|NacosConfig|get config from nacos|ERROR|Exception|connect.*refused|UnknownHost|403|401|拒绝|超时|Started " | tail -40 || true
echo "== done =="
-36
View File
@@ -1,36 +0,0 @@
#!/usr/bin/env bash
# 本地构建 UserBase 前后端镜像并推送到阿里云 ACR
# 用法: ./build-push.sh [app_tag] [front_tag]
# 前置环境变量: ACR_NAMESPACE, ACR_USERNAME, ACR_PASSWORD
# 前置产物: service/target/userbase.jar、front/dist/prod/
set -euo pipefail
APP_VERSION="${1:-${APP_VERSION:-userbase-v1.0}}"
FRONT_VERSION="${2:-${FRONT_VERSION:-userbase-front-v1.0}}"
REGISTRY="${ACR_REGISTRY:-registry.cn-hangzhou.aliyuncs.com}"
NAMESPACE="${ACR_NAMESPACE:?请设置 ACR_NAMESPACE}"
REPO="${ACR_REPO:-yunhai}"
APP_IMG="$REGISTRY/$NAMESPACE/$REPO:$APP_VERSION"
FRONT_IMG="$REGISTRY/$NAMESPACE/$REPO:$FRONT_VERSION"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
[ -f "$ROOT/service/target/userbase.jar" ] || { echo "缺少 service/target/userbase.jar,请先 mvn package"; exit 1; }
[ -f "$ROOT/front/dist/prod/index.html" ] || { echo "缺少 front/dist/prod,请先 npm run build:prod"; exit 1; }
echo "== login $REGISTRY =="
echo "${ACR_PASSWORD:?请设置 ACR_PASSWORD}" | docker login --username="${ACR_USERNAME:?请设置 ACR_USERNAME}" --password-stdin "$REGISTRY"
echo "== build $APP_IMG =="
docker build -f "$ROOT/service/docker/Dockerfile" -t "$APP_IMG" "$ROOT/service"
echo "== build $FRONT_IMG =="
docker build -f "$ROOT/front/docker/Dockerfile" -t "$FRONT_IMG" "$ROOT/front"
echo "== push =="
docker push "$APP_IMG"
docker push "$FRONT_IMG"
echo "PUSHED $APP_IMG"
echo "PUSHED $FRONT_IMG"
-22
View File
@@ -1,22 +0,0 @@
#!/usr/bin/env bash
set -e
MC=mysql8
echo "MYSQL_CONTAINER=$MC"
docker exec userbase-app cat /app/config/application-prod.yml > /tmp/cfg.yml
PW=$(awk '/username: root/{f=1} f&&/^ *password:/{sub(/^ *password: */,""); gsub(/\r/,""); print; exit}' /tmp/cfg.yml)
echo "PW_LEN=${#PW}"
printf '[client]\nuser=root\npassword=%s\n' "$PW" > /tmp/my.cnf
docker cp /tmp/my.cnf "$MC":/tmp/my.cnf
echo "== check sys_user.type column (via TCP 127.0.0.1) =="
docker exec "$MC" sh -c "mysql --defaults-extra-file=/tmp/my.cnf -h 127.0.0.1 -N -e \"SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_DEFAULT, IS_NULLABLE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='bsch_userbase' AND TABLE_NAME='sys_user' AND COLUMN_NAME='type';\""
echo "QUERY_EXIT=$?"
echo "== also confirm DB reachable & table exists =="
docker exec "$MC" sh -c "mysql --defaults-extra-file=/tmp/my.cnf -h 127.0.0.1 -N -e \"SHOW TABLES FROM bsch_userbase LIKE 'sys_user';\""
docker exec "$MC" rm -f /tmp/my.cnf
rm -f /tmp/my.cnf /tmp/cfg.yml
echo "CLEANED"
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env bash
# 一键部署:上传编排文件 -> SSH 到服务器拉取镜像并启动
# 用法: ./deploy.sh [app_tag] [front_tag]
# 前置环境变量: DEPLOY_HOST, SSHPASS(可选 ACR_NAMESPACE / DEPLOY_USER / REMOTE_DIR)
# 注:服务器需已 docker login registry.cn-hangzhou.aliyuncs.com
set -euo pipefail
APP_VERSION="${1:-${APP_VERSION:-userbase-v1.0}}"
FRONT_VERSION="${2:-${FRONT_VERSION:-userbase-front-v1.0}}"
HOST="${DEPLOY_HOST:?请设置 DEPLOY_HOST}"
PORT="${DEPLOY_PORT:-22}"
USER="${DEPLOY_USER:-yunhai}"
REMOTE_DIR="${REMOTE_DIR:-/opt/docker/userbase}"
NAMESPACE="${ACR_NAMESPACE:-damaomi_docker}"
REGISTRY="${ACR_REGISTRY:-registry.cn-hangzhou.aliyuncs.com}"
PY="${PYTHON_BIN:-python3}"
cd "$(dirname "$0")"
echo "== 1/2 上传 compose 到 $HOST:$REMOTE_DIR =="
"$PY" upload.py "$HOST" "$PORT" "$USER" \
docker-compose.userbase.yml "$REMOTE_DIR/docker-compose.userbase.yml"
echo "== 2/2 远端拉取镜像并启动 =="
"$PY" remote_run.py "$HOST" "$PORT" "$USER" <<EOF
set -e
mkdir -p "$REMOTE_DIR/upload-data"
cd "$REMOTE_DIR"
cat > .env <<ENV
ACR_REGISTRY=$REGISTRY
ACR_NAMESPACE=$NAMESPACE
APP_VERSION=$APP_VERSION
FRONT_VERSION=$FRONT_VERSION
ENV
docker compose -f docker-compose.userbase.yml pull
docker compose -f docker-compose.userbase.yml up -d
docker image prune -f
docker ps --filter name=userbase --format "table {{.Names}}\t{{.Status}}"
echo DEPLOY_OK
EOF
-23
View File
@@ -1,23 +0,0 @@
set -u
CP=/opt/docker/infra/Caddyfile
echo "===== 1. Caddyfile /userbase blocks (line numbers) ====="
grep -n "userbase" "$CP"
echo
echo "===== 2. Full /userbase route region (lines 40-80) ====="
sed -n '40,80p' "$CP"
echo
echo "===== 3. Caddy mounts in container ====="
docker inspect caddy --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'
echo
echo "===== 4. Running caddy admin config: does it contain 'userbase'? ====="
docker exec caddy curl -s localhost:2019/config/ | grep -i "userbase" | head -n 20
echo "--- end grep ---"
echo
echo "===== 5. Caddy container status ====="
docker ps --filter name=caddy --format '{{.Names}} {{.Status}}'
echo
echo "===== 6. Direct test via 127.0.0.1 with Host header ====="
for p in /userbase/ /userbase/index.html /userbase/static/js/ /userbase/api/x; do
code=$(curl -s -o /tmp/ub_body -w "%{http_code}" -H "Host: www.yunhaixingtong.com" "http://127.0.0.1$p")
echo "$p -> $code | $(head -c 80 /tmp/ub_body | tr -d '\n')"
done
-24
View File
@@ -1,24 +0,0 @@
set -u
echo "===== 1. In-container view of /etc/caddy/Caddyfile: userbase count ====="
docker exec caddy grep -c "userbase" /etc/caddy/Caddyfile
echo
echo "===== 2. Container start command ====="
docker inspect caddy --format 'CMD={{.Config.Cmd}} ENTRY={{.Config.Entrypoint}}'
echo
echo "===== 3. Admin API reachable? top-level keys ====="
docker exec caddy curl -s localhost:2019/
echo
echo "===== 4. Running HTTP servers + route matchers in config ====="
docker exec caddy curl -s localhost:2019/config/ > /tmp/running.json
echo "bytes: $(wc -c < /tmp/running.json)"
echo "occurrences:"
for s in "userbase" "Welcome to Base Platform" "handle_path" "reverse_proxy" "www.yunhaixingtong.com"; do
echo " $s : $(grep -o "$s" /tmp/running.json | wc -l)"
done
echo
echo "===== 5. Do a manual POST /load from inside container and show result ====="
docker exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile 2>&1
echo "exit=$?"
sleep 1
echo "--- after reload, recount ---"
docker exec caddy curl -s localhost:2019/config/ | grep -c "userbase"
-74
View File
@@ -1,74 +0,0 @@
# UserBase 编排:后端(Spring Boot 8060) + 前端(nginx 80)
# 两者都挂在 infra-infra-net,由 caddy 按服务名反代到 www.yunhaixingtong.com/userbase
# 启动:cd /opt/docker/userbase && docker compose -f docker-compose.userbase.yml up -d
services:
userbase-app:
image: ${ACR_REGISTRY:-registry.cn-hangzhou.aliyuncs.com}/${ACR_NAMESPACE:-damaomi_docker}/yunhai:${APP_VERSION:-userbase-v1.0}
container_name: userbase-app
restart: unless-stopped
networks:
- infra-infra-net
# SSO 回调后容器内要做 CAS serviceValidate 回调 sso.yunhaixingtong.com,
# 该虚拟主机由宿主机 caddy 按 vhost 路由,但容器内无 DNS 记录会 UnknownHostException。
# host-gateway 指向宿主机网关(caddy 监听 :80/:443 全接口),无需硬编码 IP;
# 旧版 Docker(<20.10)不支持 host-gateway,可改为宿主机桥接网关 IP,如 172.17.0.1。
extra_hosts:
- "sso.yunhaixingtong.com:host-gateway"
environment:
TZ: Asia/Shanghai
# 配置中心:由 bootstrap.yml 中 spring.cloud.nacos.config 在 bootstrap 阶段自动拉取
# 容器内通过 infra-infra-net 解析内网别名 nacos:8848(宿主机 shell 无法直接解析),
# 公网域名 nacos.yunhaixingtong.com 仅用于控制台管理,生产容器走内网。
NACOS_ADDR: nacos:8848
# Nacos 已开启鉴权,config 客户端需携带账号(与旧 run-app.sh 的 nacos/nacos 一致)
SPRING_CLOUD_NACOS_USERNAME: nacos
SPRING_CLOUD_NACOS_PASSWORD: nacos
SPRING_CLOUD_NACOS_CONFIG_USERNAME: nacos
SPRING_CLOUD_NACOS_CONFIG_PASSWORD: nacos
JAVA_OPTS: "-Xms512m -Xmx1024m"
volumes:
- ./upload-data:/data/upload
expose:
- "8060"
healthcheck:
# 只要能返回 HTTP 状态码就认为进程已起(业务接口普遍需要鉴权,不能用 -f)
test: ["CMD-SHELL", "curl -s -o /dev/null -w '%{http_code}' http://localhost:8060/userbase/ | grep -qE '^[234]'"]
interval: 30s
timeout: 10s
retries: 5
start_period: 90s
deploy:
resources:
limits:
cpus: '1.5'
memory: 1536M
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
userbase-front:
image: ${ACR_REGISTRY:-registry.cn-hangzhou.aliyuncs.com}/${ACR_NAMESPACE:-damaomi_docker}/yunhai:${FRONT_VERSION:-userbase-front-v1.0}
container_name: userbase-front
restart: unless-stopped
networks:
- infra-infra-net
environment:
TZ: Asia/Shanghai
expose:
- "80"
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
infra-infra-net:
external: true
-8
View File
@@ -1,8 +0,0 @@
#!/usr/bin/env bash
set -e
echo "== all containers (name + image) =="
docker ps -a --format '{{.Names}}\t{{.Image}}'
echo "== which containers are on infra-infra-net =="
docker network inspect infra-infra-net --format '{{range .Containers}}{{.Name}} ({{.IPv4Address}}){{"\n"}}{{end}}' 2>/dev/null || echo "no such network"
echo "== resolve mysql8 from userbase-app =="
docker exec userbase-app getent hosts mysql8 2>/dev/null || echo "getent failed"
-15
View File
@@ -1,15 +0,0 @@
set -u
echo "===== A. Reload caddy from inside the container (reads mounted /etc/caddy/Caddyfile) ====="
docker exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
echo "reload exit: $?"
sleep 2
echo
echo "===== B. Verify running config now contains userbase ====="
docker exec caddy curl -s localhost:2019/config/ | grep -c "userbase"
echo "(count above; 0 means still missing)"
echo
echo "===== C. Test routes via 127.0.0.1 with Host header ====="
for p in /userbase/ /userbase/index.html /userbase/static/js/ /userbase/api/x /userbase/login; do
code=$(curl -s -o /tmp/ub_body -w "%{http_code}" -H "Host: www.yunhaixingtong.com" "http://127.0.0.1$p")
echo "$p -> $code | $(head -c 90 /tmp/ub_body | tr -d '\n')"
done
-19
View File
@@ -1,19 +0,0 @@
set -u
echo "===== host file (has userbase) ====="
grep -c "userbase" /opt/docker/infra/Caddyfile
echo "===== container file BEFORE restart (expect 0) ====="
docker exec caddy grep -c "userbase" /etc/caddy/Caddyfile
echo
echo "===== restart caddy to re-bind the edited file ====="
docker restart caddy
echo "restart exit: $?"
sleep 6
echo
echo "===== container file AFTER restart (expect >0) ====="
docker exec caddy grep -c "userbase" /etc/caddy/Caddyfile
echo
echo "===== test routes ====="
for p in /userbase/ /userbase/index.html /userbase/static/js/ /userbase/api/x /userbase/login; do
code=$(curl -s -o /tmp/ub_body -w "%{http_code}" -H "Host: www.yunhaixingtong.com" "http://127.0.0.1$p")
echo "$p -> $code | $(head -c 100 /tmp/ub_body | tr -d '\n')"
done
-33
View File
@@ -1,33 +0,0 @@
set -u
CP=/opt/docker/infra/Caddyfile
echo "===== before: line 70 ====="
sed -n '70p' "$CP"
echo "===== apply wildcard matcher (inode will change; restart re-binds) ====="
sed -i 's#handle_path /userbase/ {#handle_path /userbase* {#' "$CP"
echo "after:"; sed -n '70p' "$CP"
echo
echo "===== restart caddy to re-bind edited file ====="
docker restart caddy
echo "restart exit: $?"
sleep 6
echo "===== container sees fix? ====="
docker exec caddy grep -n "handle_path /userbase" /etc/caddy/Caddyfile
echo
echo "===== extract a real JS asset from SPA and test it ====="
html=$(curl -s -H "Host: www.yunhaixingtong.com" http://127.0.0.1/userbase/)
echo "$html" | grep -oE 'src="[^"]+\.js"' | head -3
asset=$(echo "$html" | grep -oE '/[A-Za-z0-9_./-]+\.js' | head -1)
echo "first asset path: $asset"
if [ -n "$asset" ]; then
code=$(curl -s -o /dev/null -w "%{http_code}" -H "Host: www.yunhaixingtong.com" "http://127.0.0.1/userbase${asset}")
echo "/userbase${asset} -> $code"
fi
echo
echo "===== route tests ====="
for p in /userbase/ /userbase/index.html "/userbase${asset:-/static/js/}"; do
code=$(curl -s -o /tmp/b -w "%{http_code}" -H "Host: www.yunhaixingtong.com" "http://127.0.0.1$p")
echo "$p -> $code | $(head -c 70 /tmp/b|tr -d '\n')"
done
echo
echo "===== backend test ====="
curl -s -o /dev/null -w "/userbase/api/x -> %{http_code}\n" -H "Host: www.yunhaixingtong.com" http://127.0.0.1/userbase/api/x
-19
View File
@@ -1,19 +0,0 @@
#!/bin/sh
# 探测内网 nacos:8848 是否可达、public 空间下 userbase.yaml 是否存在、是否需要鉴权
echo "== resolve nacos =="
getent hosts nacos || echo "cannot resolve nacos"
echo "== fetch userbase.yaml (public, no auth) =="
curl -s -m 8 -o /tmp/nc.txt -w "http=%{http_code}\n" "http://nacos:8848/nacos/v1/cs/configs?dataId=userbase.yaml&group=USERBASE&tenant="
echo "--- body (first 200 chars) ---"
head -c 200 /tmp/nc.txt; echo
if grep -q "unknown user" /tmp/nc.txt || grep -q "403" /tmp/nc.txt || [ "$(head -c 1 /tmp/nc.txt)" = "" ]; then
echo "== needs auth, try login nacos/nacos =="
TOKEN=$(curl -s -m 8 -X POST "http://nacos:8848/nacos/v1/auth/login?username=nacos&password=nacos" | sed -n 's/.*"accessToken":"\([^"]*\)".*/\1/p')
echo "token_len=${#TOKEN}"
if [ -n "$TOKEN" ]; then
curl -s -m 8 -o /tmp/nc2.txt -w "http=%{http_code}\n" "http://nacos:8848/nacos/v1/cs/configs?dataId=userbase.yaml&group=USERBASE&tenant=&accessToken=$TOKEN"
echo "--- authed body (first 200 chars) ---"
head -c 200 /tmp/nc2.txt; echo
fi
fi
echo "== done =="
-10
View File
@@ -1,10 +0,0 @@
#!/bin/sh
echo "== containers on host =="
docker ps --format '{{.Names}}\t{{.Networks}}' | head -40
echo "== infra-infra-net members =="
docker network inspect infra-infra-net --format '{{range .Containers}}{{.Name}} {{end}}' 2>/dev/null
echo "== resolve nacos from inside userbase-app =="
docker exec userbase-app sh -c "getent hosts nacos || echo 'no nacos in container DNS'"
echo "== curl nacos:8848 from inside userbase-app (no auth) =="
docker exec userbase-app sh -c "curl -s -m 8 -o /tmp/nc.txt -w 'http=%{http_code}\n' 'http://nacos:8848/nacos/v1/cs/configs?dataId=userbase.yaml&group=USERBASE&tenant=' ; head -c 200 /tmp/nc.txt; echo" || echo "curl failed"
echo "== done =="
-28
View File
@@ -1,28 +0,0 @@
#!/bin/sh
# 重新部署 userbase-app:拉取新镜像、重建、等待健康,并核对是否真正从 Nacos 拉到了配置
echo "== login ACR =="
echo '59qtJRqlT7' | docker login --username=15121037482 --password-stdin registry.cn-hangzhou.aliyuncs.com
cd /opt/docker/userbase
echo "== pull userbase-app =="
docker compose -f docker-compose.userbase.yml pull userbase-app
echo "== up -d userbase-app =="
docker compose -f docker-compose.userbase.yml up -d userbase-app
echo "== wait for health (up to 150s) =="
for i in $(seq 1 30); do
st=$(docker inspect -f '{{.State.Health.Status}}' userbase-app 2>/dev/null || echo unknown)
echo " [$i] health=$st"
if [ "$st" = "healthy" ]; then break; fi
sleep 5
done
echo "== nacos reachability (host) =="
curl -s -m 5 -o /dev/null -w "host nacos http=%{http_code}\n" "http://nacos.yunhaixingtong.com:8848/nacos/v1/cs/configs?dataId=userbase.yaml&group=USERBASE&tenant=" || echo "host cannot reach nacos"
echo "== nacos reachability (container) =="
docker exec userbase-app sh -c "curl -s -m 5 -o /dev/null -w 'container nacos http=%{http_code}\n' 'http://nacos.yunhaixingtong.com:8848/nacos/v1/cs/configs?dataId=userbase.yaml&group=USERBASE&tenant='" || echo "container cannot reach nacos"
echo "== app logs: nacos / config / errors =="
docker logs --tail 150 userbase-app 2>&1 | grep -iE "Loading nacos data|userbase.yaml|locate Nacos|NacosConfig|get config from nacos|ERROR|Exception|connect.*refused|UnknownHost|403|401|拒绝|超时" | tail -50 || true
echo "== done =="
-35
View File
@@ -1,35 +0,0 @@
#!/usr/bin/env python3
# 通过 SSH 在远端以 sudo 执行脚本(从 stdin 读取)
# 用法: python3 remote_run.py <host> <port> <user> < script.sh
# 依赖环境变量 SSHPASS
import os, sys, paramiko
def main():
host = sys.argv[1]
port = int(sys.argv[2]) if len(sys.argv) > 2 else 22
user = sys.argv[3] if len(sys.argv) > 3 else "root"
pw = os.environ.get("SSHPASS")
if not pw:
sys.stderr.write("ERROR: 请先设置环境变量 SSHPASS\n")
sys.exit(1)
script = sys.stdin.read()
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(host, port, user, password=pw, timeout=30)
stdin, stdout, stderr = c.exec_command("sudo -S bash -s", timeout=1800)
stdin.write(pw + "\n")
stdin.write(script)
stdin.flush()
stdin.channel.shutdown_write()
sys.stdout.write(stdout.read().decode(errors="replace"))
err = stderr.read().decode(errors="replace")
if err.strip():
sys.stderr.write("STDERR:\n" + err)
code = stdout.channel.recv_exit_status()
c.close()
sys.exit(code)
if __name__ == "__main__":
main()
-13
View File
@@ -1,13 +0,0 @@
#!/bin/sh
# 线上冒烟:经 Caddy 访问 UserBase,确认 Nacos 配置生效、前后端可达
echo "== SPA 首页 =="
curl -s -m 8 -o /dev/null -w "/userbase/ -> %{http_code}\n" -H "Host: www.yunhaixingtong.com" http://127.0.0.1/userbase/
echo "== 前端静态资源 =="
curl -s -m 8 -o /dev/null -w "/userbase/index.html -> %{http_code}\n" -H "Host: www.yunhaixingtong.com" http://127.0.0.1/userbase/index.html
echo "== 后端 API 可达性(CAS 重定向即代表到达后端) =="
curl -s -m 8 -o /dev/null -w "/userbase/api/x -> %{http_code}\n" -H "Host: www.yunhaixingtong.com" http://127.0.0.1/userbase/api/x
echo "== 容器健康 =="
docker inspect -f 'userbase-app health={{.State.Health.Status}}' userbase-app
echo "== 确认应用真正连的是 nacos 下发的 mysql8 数据源(日志里看 datasource url) =="
docker logs --tail 200 userbase-app 2>&1 | grep -iE "jdbc:mysql://mysql8|HikariPool|Started UserBaseApplication" | tail -5 || true
echo "== done =="
-37
View File
@@ -1,37 +0,0 @@
#!/usr/bin/env python3
# 通过 SFTP 上传文件到远端(参数: host port user local remote [local remote ...])
# 依赖环境变量 SSHPASS
import os, sys, paramiko
def main():
host = sys.argv[1]
port = int(sys.argv[2]) if len(sys.argv) > 2 else 22
user = sys.argv[3] if len(sys.argv) > 3 else "root"
pw = os.environ.get("SSHPASS")
if not pw:
sys.stderr.write("ERROR: 请先设置环境变量 SSHPASS\n")
sys.exit(1)
t = paramiko.Transport((host, port))
t.connect(username=user, password=pw)
sftp = paramiko.SFTPClient.from_transport(t)
args = sys.argv[4:]
for i in range(0, len(args), 2):
local, remote = args[i], args[i + 1]
cur = ""
for p in [p for p in os.path.dirname(remote).split("/") if p]:
cur += "/" + p
try:
sftp.stat(cur)
except IOError:
try:
sftp.mkdir(cur)
except IOError:
pass
sftp.put(local, remote)
print(f"PUT {local} -> {remote}")
t.close()
if __name__ == "__main__":
main()
-104
View File
@@ -1,104 +0,0 @@
# ============================================================
# 请在 Nacos 控制台新增此配置(public 命名空间)
# Data ID : userbase.yaml
# Group : USERBASE
# Format : YAML
# 说明:
# - 本文件即应用自身配置。Nacos 解析规则为 ${spring.application.name}.${file-extension},
# 因 bootstrap.yml 中 name=userbase、file-extension=yaml,故 Data ID 必须是 userbase.yaml。
# - public 空间下的 common.properties 会作为共享配置优先加载(低优先级),
# 本文件(高优先级)中的同名字段会覆盖 common.properties。
# - 若 common.properties 已包含 datasource / redis 等公共项,下面这些行可按需删减;
# 保留也无害(应用配置优先级更高,仍以本文件为准)。
# - 生产启动时 profile=prod,Nacos 还会额外尝试 userbase-prod.yaml(可选,不存在也无妨)。
# ============================================================
server:
port: 8060
servlet:
# 对外经 caddy 暴露为 http://www.yunhaixingtong.com/userbase
context-path: /userbase
spring:
mvc:
date-format: yyyy-MM-dd HH:mm:ss
servlet:
multipart:
max-file-size: 50MB
max-request-size: 200MB
jackson:
time-zone: GMT+8
date-format: yyyy-MM-dd HH:mm:ss
redis:
host: redis-server
port: 6379
database: 0
password: 'wv&WcDYAmLtqTIGM'
timeout: 2000ms
data:
redis:
repositories:
enabled: false
datasource:
url: jdbc:mysql://mysql8:3306/bsch_userbase?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: 'CVf^x$sW*b6CGHxh'
driver-class-name: com.mysql.cj.jdbc.Driver
min-idle: 10
max-active: 30
jpa:
database: MySQL
show-sql: false
hibernate:
ddl-auto: update
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
properties:
hibernate.order_by.default_null_ordering: last
hibernate:
dialect: org.hibernate.dialect.MySQL5InnoDBDialect
open-in-view: true
rabbitmq:
host: rabbitmq
port: 5672
username: admin
password: 'JdU29I#Q2E4yXoUl'
listener:
direct:
auto-startup: false
simple:
auto-startup: false
# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth)
yh:
sso:
server-url-prefix: http://sso.yunhaixingtong.com
client-host-url: http://www.yunhaixingtong.com/userbase
allow-headers: all
allow-origins: all
url-pattern: /api/*
mobi-api-pattern: /mobiapi/*
app-auth:
enabled: true
# 本应用自身身份(客户端签名用,若 UserBase 需调用其他带签名服务)
app-key: 100616
app-secret: 1febb9502d2744b6a235d3e4cfde4a2d
# 受信应用(服务端验签用):appKey -> appSecret
trusted-apps:
100616: 1febb9502d2744b6a235d3e4cfde4a2d
ADMIN_TAG_ID: 290
# 上传文件落容器卷,compose 里挂到宿主机 ./upload-data
FILE_PATH: /data/upload
FILE_WEB_PATH: http://www.yunhaixingtong.com/userbase/upload/
ZIYUAN_WEB_PATH_HTTP: http://base.bestsch.com/ziyuanku/file/
UPLOAD_WEB_PATH_HTTP: http://www.yunhaixingtong.com/userbase/upload/file/
ORC_SERVICE_URL: http://oa.bestsch.com/OrcService/api/Ocr/Upload
logging:
level:
root: INFO
-10
View File
@@ -1,10 +0,0 @@
#!/usr/bin/env bash
set -e
echo "== app startup DDL/schema hints =="
docker logs userbase-app 2>&1 | grep -iE "alter table|update schema|create table|ddl|column|sys_user" | head -30 || echo "(no ddl lines captured)"
echo "== datasource config from container =="
docker exec userbase-app cat /app/config/application-prod.yml 2>/dev/null | grep -iE "url:|username:|password:|jdbc" | sed -E 's/(password:).*/\1 ****/' || echo "(no config)"
echo "== mysql client available? =="
which mysql || echo "NO_MYSQL_CLI"
-19
View File
@@ -1,19 +0,0 @@
set -u
echo "===== 1. Main app JS chunk from SPA ====="
html=$(curl -s -H "Host: www.yunhaixingtong.com" http://127.0.0.1/userbase/)
appjs=$(echo "$html" | grep -oE '/static/js/[A-Za-z0-9_./-]+\.js' | head -1)
echo "app js: $appjs"
curl -s -o /dev/null -w " /userbase${appjs} -> %{http_code}\n" -H "Host: www.yunhaixingtong.com" "http://127.0.0.1/userbase${appjs}"
echo
echo "===== 2. Ensure other sites not regressed (sso / b) ====="
curl -s -o /dev/null -w " sso host / -> %{http_code}\n" -H "Host: sso.yunhaixingtong.com" http://127.0.0.1/
curl -s -o /dev/null -w " b host / -> %{http_code}\n" -H "Host: www.yunhaixingtong.com" http://127.0.0.1/b/
echo
echo "===== 3. Caddy listening ports on host ====="
ss -ltnp 2>/dev/null | grep -E ':80 |:443 ' | head
echo
echo "===== 4. Containers status ====="
docker ps --format '{{.Names}} {{.Status}}' | grep -E 'caddy|userbase'
echo
echo "===== 5. userbase-front health (serve index directly) ====="
docker exec userbase-front wget -qO- http://localhost/ 2>/dev/null | head -c 60; echo
-7
View File
@@ -1,7 +0,0 @@
# 正式环境 - base
VITE_BASE_HOST=https://base.bestsch.com
VITE_SSO_HOST=https://sso.bestsch.com
VITE_SERVICE_HOST=https://base.bestsch.com/LearnCenter
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
VITE_IS_MOCK=false
-7
View File
@@ -1,7 +0,0 @@
# 开发环境 (dev)
VITE_BASE_HOST=http://base.bestsch.com
VITE_SSO_HOST=http://sso.bestsch.com
VITE_SERVICE_HOST=http://localhost:8060
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
VITE_IS_MOCK=false
-7
View File
@@ -1,7 +0,0 @@
# 正式环境 - ld
VITE_BASE_HOST=https://ld.bestsch.com
VITE_SSO_HOST=https://sso.bestsch.com
VITE_SERVICE_HOST=https://ld.bestsch.com/LearnCenter
VITE_ZIYUANKU_HOST=https://ld.bestsch.com/BschZyk
VITE_ZHUANXIANG_HOST=https://ld.bestsch.com/BschZncpd
VITE_IS_MOCK=false
-7
View File
@@ -1,7 +0,0 @@
# 正式环境 - 云海星通(部署在 www.yunhaixingtong.com/userbase)
VITE_BASE_HOST=http://www.yunhaixingtong.com
VITE_SSO_HOST=http://sso.yunhaixingtong.com
VITE_SERVICE_HOST=http://www.yunhaixingtong.com/userbase
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
VITE_IS_MOCK=false
-26
View File
@@ -1,26 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
yarn.lock
.npmrc
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
-41
View File
@@ -1,41 +0,0 @@
import type { GeneratorConfig } from "@alova/wormhole";
export default {
generator: [
{
input: "http://localhost:8060/v2/api-docs",
// 文档平台类型
platform: "swagger",
// 生成代码的输出目录
output: "src/api",
// 响应数据的媒体类型
responseMediaType: "application/json",
// 请求体数据的媒体类型
bodyMediaType: "application/json",
// 生成的 API 版本
version: 1,
// 生成代码类型
type: "typescript",
// 全局 API 名称
global: "Apis",
splitImplementation: true,
implementation: "inline",
// API 处理函数
handleApi: apiDescriptor => {
// 过滤掉已废弃的 API
if (apiDescriptor.deprecated) {
return undefined;
}
const newOpId = apiDescriptor.summary?.trim() || apiDescriptor.operationId;
const url = apiDescriptor.url.replace(/^\/{platform}\//, "/api/");
return {
...apiDescriptor,
operationId: newOpId, // 关键:改掉它
url: apiDescriptor.url.replace(/^\/{platform}\//, "/api/"),
};
},
},
],
} as GeneratorConfig;
-6
View File
@@ -1,6 +0,0 @@
//铃铛debug
window.conf = {
baseHost: "http://basedebug.bestsch.com",
ssoHost: "http://ssodebug.bestsch.com",
serviceHost: "http://basedebug.bestsch.com/QualityEvaluate",
};
-22
View File
@@ -1,22 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="../../favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%- title %></title>
<link rel="stylesheet" href="./libs/ztree/zTreeStyle.css" />
<script src="./libs/jquery.min.js"></script>
<script src="./libs/lodash.min.js"></script>
<script src="./libs/ztree/jquery.ztree.all.min.js"></script>
<script src=" https://base.bestsch.com/BschHome/base/js/bschload.min.js?v=2025011001"></script>
<%- injectScript %>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
-2949
View File
File diff suppressed because it is too large Load Diff
-40
View File
@@ -1,40 +0,0 @@
import { BaseConf } from "@/types/Basics";
/**
* 通过 Vite 环境变量管理配置,不再依赖 window.conf / config.js
* 环境变量定义在项目根目录的 .env.dev / .env.base / .env.ld 中
*/
const env = import.meta.env;
let conf: BaseConf = {
baseHost: env.VITE_BASE_HOST,
ssoHost: env.VITE_SSO_HOST,
serviceHost: env.VITE_SERVICE_HOST,
relayConf: {
ziyuankuHost: env.VITE_ZIYUANKU_HOST,
zhuanxiangHost: env.VITE_ZHUANXIANG_HOST,
},
isMock: env.VITE_IS_MOCK === "true",
isDebug: env.DEV,
};
// HTTPS 自动升级:当前页面是 https 时,将所有 http:// 配置项升级为 https://
var ishttps = "https:" == document.location.protocol ? true : false;
if (ishttps) {
Object.keys(conf).forEach(element => {
if (typeof conf[element] === "string" && conf[element].indexOf("http://") > -1) {
conf[element] = conf[element].replace("http://", "https://");
}
});
// relayConf 内部也需要处理
if (conf.relayConf) {
Object.keys(conf.relayConf).forEach(key => {
const val = (conf.relayConf as any)[key];
if (typeof val === "string" && val.indexOf("http://") > -1) {
(conf.relayConf as any)[key] = val.replace("http://", "https://");
}
});
}
}
export default conf;
-68
View File
@@ -1,68 +0,0 @@
import type { IModule } from "@/store";
/**
* 左侧菜单数据(系统管理功能分组)
* ----------------------------------------------------------------
* 当前直接 Promise.resolve 返回写死数据;后续接入真实接口时,
* 只需把 `return Promise.resolve(menu)` 替换为
* `return alovaInstance.Request(Apis.xxx.getMenu())`
* 即可无缝对接,调用方(store.fetchModules)无需任何改动。
*
* 分组结构:
* 1. 组,用户管理 —— 顶层独立选项卡(单页)
* 2. 应用管理 —— 顶层独立选项卡(= 应用列表主页 /app-manage/list);
* 页内按钮跳转「应用类别管理」子页(/app-manage/category,不在菜单里,
* 由 routes.ts 的 meta.parentId=1102 关联,面包屑「应用管理>应用类别管理」)
* 3. 权限管理 —— 父目录:应用开通 / 组授权 / 权限一览
* 4. 日志管理 —— 父目录:接口日志 / 系统日志
*
* 每个节点带唯一 id;子节点的 pid 由 layout.vue 的 flaten() 扁平化时
* 依据父节点 id 自动推导,用于「高亮自身 + 面包屑展示父级」联动。
* 菜单项不再渲染 icon。
*/
export function getMenuList(): Promise<IModule[]> {
const menu: IModule[] = [
// 1. 组,用户管理(顶层独立选项卡)
{
id: 1,
modId: 1101,
name: "组,用户管理",
page: "/group-user",
},
// 2. 应用管理(顶层独立选项卡,= 应用列表主页;页内按钮跳转应用类别管理子页)
{
id: 2,
modId: 1102,
name: "应用管理",
page: "/app-manage/list",
},
// 3. 权限管理(父目录)
{
id: 4,
modId: 1108,
name: "权限管理",
page: "",
childState: true,
children: [
{ id: 41, modId: 1103, name: "应用开通", page: "/app-open" },
{ id: 42, modId: 1104, name: "组授权", page: "/group-auth" },
{ id: 44, modId: 1110, name: "角色授权", page: "/role-authz/list" },
{ id: 43, modId: 1105, name: "权限一览", page: "/permission-view" },
],
},
// 4. 日志管理(父目录)
{
id: 5,
modId: 1109,
name: "日志管理",
page: "",
childState: true,
children: [
{ id: 51, modId: 1106, name: "接口日志", page: "/api-log" },
{ id: 52, modId: 1107, name: "系统日志", page: "/sys-log" },
{ id: 53, modId: 1111, name: "操作日志", page: "/oplog" },
],
},
];
return Promise.resolve(menu);
}
-9
View File
@@ -1,9 +0,0 @@
import { createRouter, createWebHashHistory } from "vue-router";
import routes from "./routes";
const router = createRouter({
history: createWebHashHistory(),
routes,
});
export default router;
-15
View File
@@ -1,15 +0,0 @@
<template>
<div class="page-wrapper">
<div class="main-container">
<Card :bordered="false" dis-hover>
<p slot="title">接口日志</p>
<Alert type="info" show-icon>功能开发中,敬请期待...</Alert>
</Card>
</div>
</div>
</template>
<script lang="ts" setup>
import { useDefaultStore } from "@/store";
const store = useDefaultStore();
</script>
@@ -1,158 +0,0 @@
<template>
<Modal v-model="show" :mask-closable="false" width="600" :title="titleComp" @on-visible-change="visibleChange">
<Form ref="formRef" class="mr-15" :model="form" :rules="rules" :label-width="100">
<FormItem :label="bookInfo.name + '名称:'" prop="name">
<Input v-model="form.name" :placeholder="`请输入${bookInfo.name}名称`" />
</FormItem>
<Row class="mb-20">
<Col span="12">
<FormItem label="年级" prop="grade">
<Select v-model="form.grade" transfer clearable placeholder="选择年级">
<Option v-for="item in dictData.grade" :value="item.id" :key="item.id">{{ item.name }}
</Option>
</Select>
</FormItem>
</Col>
<Col span="12">
<FormItem label="学期:" prop="semester">
<Select v-model="form.semester" transfer placeholder="选择学期">
<Option :value="0">上学期</Option>
<Option :value="1">下学期</Option>
</Select>
</FormItem>
</Col>
</Row>
<Row class="mb-20">
<Col span="12">
<FormItem label="学科:" prop="subject">
<Select v-model="form.subject" transfer clearable placeholder="选择科目">
<Option v-for="item in dictData.subject" :value="item.code" :key="item.code">{{ item.name }}
</Option>
</Select>
</FormItem>
</Col>
<Col span="12">
<FormItem label="出版社:" prop="press">
<Input v-model="form.press" placeholder="输入出版社" />
</FormItem>
</Col>
</Row>
<Row class="mb-20">
<Col span="12">
<FormItem label="出版年份:" prop="outTime">
<DatePicker v-model="form.outTime" type="year" placeholder="请选择年份" format="yyyy" transfer
:editable="false" :clearable="true" @on-change="v => form.outTime = v"></DatePicker>
</FormItem>
</Col>
</Row>
</Form>
<template #footer>
<Button type="primary" :loading="loading" @click="handleSubmit">提交</Button>
<Button @click="handleClose">取消</Button>
</template>
</Modal>
</template>
<script setup lang="ts">
import { reactive, watch, computed, nextTick } from 'vue'
import { BooxType, BooxTypeDict } from '@/types/index'
import useBookInfo from '../useBookInfo'
const props = defineProps({
modelValue: Boolean,
id: { type: [String, Number], required: true },// 0 新增 / 其它 编辑
bookType: { type: Number, default: 1, required: true }//教材|教辅
})
const emit = defineEmits(['update:modelValue', 'success'])
const show = computed({
get: () => props.modelValue,
set: val => emit('update:modelValue', val)
})
const { data: dictData } = useRequest(Apis.Base.getSystemDict({
cacheFor: 1000 * 60 * 60
}), {
initialData: {
grade: [],
press: [],
subject: [],
}
});
const isAdd = computed(() => props.id == 0)
const formRef = ref()
const loading = ref(false)
const { bookInfo } = useBookInfo()
const rules = {
name: [{ required: true, message: '请输入名称' }],
subject: [{ required: true, message: '请选择学科' }],
grade: [{ required: true, message: '请选择年级' }],
press: [{ required: false, message: '请输入出版社' }],
semester: [{ required: true, message: '请选择学期' }],
outTime: [{ required: false, message: '请选择年份' }],
}
// 打开弹窗时拉取详情(编辑模式)
watch(() => props.modelValue, async val => {
if (!val) return
if (isAdd.value) {
// 新增重置空表单
nextTick(() => formRef.value?.resetFields())
Object.assign(form.value, { ...form.value, bookType: props.bookType })
} else {
const resp = await Apis.Knowledge.getTextbookById({ params: { id: props.id } })
Object.assign(form.value, resp)
}
})
const titleComp = computed(() => {
return (isAdd.value ? '新增' : '编辑') + bookInfo.value.name
})
function visibleChange(v: any) {
if (!v) setTimeout(() => {
formRef.value?.resetFields()
}, 100);
}
async function handleSubmit() {
let validate = await formRef.value.validate()
loading.value = true
try {
if (!validate) throw '请填写红色星号内容'
await sendForm()
handleClose()
emit('success')
} catch (error) {
Message.info(error)
}
finally {
loading.value = false
}
}
const {
form,
send: sendForm,
} = useForm((dto) => Apis.Knowledge.saveTextbook({ data: dto }), {
initialForm: {
id: null,
name: '',
subject: '',
grade: '',
press: '',
semester: '',
outTime: ''
},
resetAfterSubmiting: false
})
function handleClose() {
emit('update:modelValue', false)
}
</script>
-300
View File
@@ -1,300 +0,0 @@
<template>
<div class="main-wrapper">
<div class="main-container">
<div class="fix mb-12">
<Button type="primary" @click="curEditId = 0, eidtModelShow = true" class="fl">
添加{{ bookInfo.name }}
</Button>
<Button type="success" @click="uploadType = 2, uploadModelShow = true" class="fr ml-12">
导入{{ bookInfo.name }}和目录树
</Button>
<Button type="success" @click="uploadType = 1, uploadModelShow = true" class="fr ml-12">
导入{{ bookInfo.name }}
</Button>
</div>
<div class="search-wrap fix">
<Select class="search-wrap_sel" v-model="dataParams.subject" clearable placeholder="选择科目">
<Option v-for="item in dictData.subject" :value="item.code" :key="item.code">{{ item.name }}
</Option>
</Select>
<Select class="search-wrap_sel ml-12" v-model="dataParams.grade" clearable placeholder="选择年级">
<Option v-for="item in dictData.grade" :value="item.id" :key="item.id">{{ item.name }}
</Option>
</Select>
<Select class="search-wrap_sel ml-12" v-model="dataParams.press" clearable placeholder="选择出版社">
<Option v-for="item in pressList" :value="item.v" :key="item.k">{{ item.v }}
</Option>
</Select>
<Select class="search-wrap_sel ml-12" v-model="dataParams.semester" clearable placeholder="选择学期">
<Option :value="0">上学期</Option>
<Option :value="1">下学期</Option>
</Select>
<Input class="fr ml-12" v-model="dataParams.name" placeholder="输入关键字筛选" suffix="ios-search" clearable
style="width:200px" />
</div>
<TablePageAlova ref="tableRef" :dataApi="tableApi" :columns="columns" :dataParams="dataParams"
:tableHeight="tableHeight">
</TablePageAlova>
<Drawer width="400" :title="drawerTitle" :closable="false" v-model="showDrawer">
<MyTree :treeData="knowTreeData" :setting="treeSetting2" :autoClick="false" />
</Drawer>
</div>
<div class="modal-wrap">
<EditModel v-model="eidtModelShow" :id="curEditId" :bookType="bookInfo.type" @success="reloadTable" />
<Modal v-model="uploadModelShow" :mask-closable="false" width="600" :title="modalTitle">
<BschImport ref="importRef" :tempFileUrl="tempFileUrl" :uploadUrl="uploadUrl" :uploadData="uploadData"
@on-success="reloadTable" />
<template #footer>
<Button @click="uploadModelShow = false">关闭</Button>
</template>
</Modal>
<Modal v-model="knowledgeModelShow" :mask-closable="false" width="900" title="目录树管理">
<Knowledge :id="curKnowId" :name="curKnowName" v-if="knowledgeModelShow" />
<template #footer>
<Button @click="knowledgeModelShow = false">关闭</Button>
</template>
</Modal>
<!-- BschImport -->
</div>
</div>
</template>
<script lang="ts" setup>
import { } from "vue";
import TablePageAlova from '@/components/TablePageAlova.vue'
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import Knowledge from './Knowledge.vue'
import { useDefaultStore } from "@/store";
import EditModel from './EditModel.vue'
import MyTree from '@/components/MyTree.vue'
import MyConfirm from "@/components/MyConfirm";
import BschImport from '@/components/BschImport.vue'
import useBookInfo from "../useBookInfo";
import { useRouter } from "vue-router";
import { OperatorEnum } from "@/types";
const store = useDefaultStore();
const { $conf, $bus, $utils } = useCurrentInstance();
const { bookInfo } = useBookInfo()
const eidtModelShow = ref(false);
const knowledgeModelShow = ref(false);
const uploadType = ref(0);
const uploadModelShow = ref(false);
const curEditId = ref(0)
const tableRef = useTemplateRef('tableRef')
const curKnowId = ref(0)
const curKnowName = ref('')
const router = useRouter();
const dataParams = ref({
pageSize: '',
pageIndex: '',
grade: null,
semester: null,
subject: null,
press: null,
name: null,
bookType: bookInfo.value.type,
})
const treeSetting2 = ref({
view: {
dblClickExpand: false,
showIcon: false,
},
edit: {
enable: true,
showRemoveBtn: false,
showRenameBtn: false,
drag: false,
},
check: {
enable: false,
},
callback: {},
})
const tableHeight = ref($utils.useTableHeight({ isOperat: true, isPage: true, isSearch: true }));
const { data: dictData } = useRequest(Apis.Base.getSystemDict({
cacheFor: 1000 * 60 * 60
}), {
initialData: {
grade: [],
textPress: [],
teachingPress: [],
subject: [],
}
});
const { data: knowTreeData, send: sendKnowledgTree } =
useRequest((textbookId: number) => Apis.Knowledge.getTreeByTextbookId({
params: { textbookId },
transform: (res: any) => {
let arr = res || [];
return arr.map((item: any) => {
item.open = true;
return item;
})
},
}), {
initialData: [],
immediate: false
});
const showDrawer = ref(false);
const drawerTitle = ref('');
const columns = ref([
{ type: 'index', title: '序号', width: 80 },
{ title: bookInfo.value.name + '名称', key: 'name' },
{ title: '年级', key: 'gradeName' },
{
title: '学期',
key: 'semester',
render: (h: any, params: any) => {
return h('span', params.row.semester == 0 ? '上学期' : '下学期');
}
},
{ title: '科目', key: 'subjectName' },
{ title: '出版社', key: 'press' },
{ title: '出版年份', key: 'outTime' },
// {
// title: '封面',
// key: 'coverImage',
// width: 120,
// render: (row: any, { row: r }: any) => {
// const src = r.coverImage || new URL('@/assets/image/default-bg1.png', import.meta.url).href
// return h('img', {
// src,
// style: {
// width: '20px',
// height: '30px',
// objectFit: 'cover',
// borderRadius: '4px'
// },
// alt: '封面'
// })
// }
// },
{
title: '操作',
width: 240,
align: 'center',
render: (h: any, params: any) => {
const disabled = params.row.disabled;
return [
$utils.operatingTextHandle(h, '目录树管理', '', () => {
// router.push({
// path: '/' + bookInfo.value.path + '/manage/knowledge',
// query: { id: params.row.id, name: params.row.name },
// });
curKnowId.value = params.row.id;
curKnowName.value = params.row.name;
knowledgeModelShow.value = true;
}, disabled),
$utils.operatingTextHandle(h, '查看', '', () => {
drawerTitle.value = params.row.name;
showDrawer.value = true;
setTimeout(() => {
sendKnowledgTree(params.row.id);
}, 100);
}),
$utils.operatingTextHandle(h, '编辑', '', () => {
curEditId.value = params.row.id;
eidtModelShow.value = true;
}, disabled),
$utils.operatingTextHandle(h, '删除', 'text-error', () => {
removeSave(params.row.id)
}, disabled),
];
}
}
])
const modalTitle = computed(() => {
return '导入' + bookInfo.value.name + (uploadType.value === 1 ? '' : '和知识树');
})
const tempFileUrl = computed(() => {
const method = uploadType.value == 1 ? 'getTextbookTemplate' : 'getTextbookKnowledgeTemplate';
let url = $conf.serviceHost + `/api/knowledge/${method}?bookType=` + bookInfo.value.type;
return url;
})
const uploadUrl = computed(() => {
const method = uploadType.value == 1 ? '/api/knowledge/importTextbook' : '/api/knowledge/importTextbookInfo';
return $conf.serviceHost + method;
})
const uploadData = computed(() => {
return { bookType: bookInfo.value.type };
})
const pressList = computed(() => {
return bookInfo.value.type == 1 ? dictData.value.textPress : dictData.value.teachingPress
})
const clean = (obj: Record<string, any>) =>
Object.fromEntries(
Object.entries(obj).filter(([, v]) => v != null && v !== '')
)
const tableApi = (params: { pageIndex: number; pageSize: number }) => {
return Apis.Knowledge.searchTextbooks({
params: clean({ ...dataParams.value, ...params }),
cacheFor: null,
})
}
onBeforeMount(() => {
$bus.emit('resetLayout', { identity: bookInfo.value.identity, modId: (bookInfo.value.baseModId + 4) });
});
/**
* removeSave
*/
async function removeSave(id: number) {
await MyConfirm({
msg: "确定删除该教材吗?",
info: "关联目录树也将会被删除,删除后不可恢复!",
async onOk() {
try {
await Apis.Knowledge.deleteTextbook({ params: { id } });
Message.success("操作成功!");
tableRef.value?.reloadData(OperatorEnum.del);
} catch (error) {
Message.error(error);
}
},
});
}
function reloadTable() {
if (!curEditId.value) tableRef.value?.reloadData(OperatorEnum.add);
else tableRef.value?.reloadData(OperatorEnum.edit);
}
</script>
<style lang="scss" scoped></style>
@@ -1,301 +0,0 @@
<template>
<div class="main-wrapper" style="min-height: 400px;">
<div class="main-container">
<div class="search-wrap ">
<h5 class="title mt-4 fl">{{ title }}</h5>
<Button type="success" class="fr" @click="uploadModelShow = true">导入目录树</Button>
</div>
<Table row-key="id" :columns="columns" :loading="tableLoading" :indent-size="20" :data="tableDataComp">
<template #loading>
<div class="table-spin-load">加载中</div>
</template>
</Table>
</div>
<div class="modal-wrap">
<Modal v-model="editNodeModal.modal" :title="editNodeModal.title" width="450px" :mask-closable="false">
<Form ref="refFormData" :label-width="100" :model="editNodeModal" class="myform mb-20 mr-20">
<Form-Item label="节点名称:" prop="name"
:rules="{ required: true, message: '请输入节点名称', trigger: 'blur' }">
<Input ref="refNodeNameInput" maxlength="30" v-model="editNodeModal.name" />
</Form-Item>
</Form>
<template #footer>
<Button type="primary" :loading="editNodeModal.loading" @click="saveEditNode">确认</Button>
<Button :loading="editNodeModal.loading" @click="editNodeModal.modal = false">取消</Button>
</template>
</Modal>
<!-- 排序弹窗 -->
<Modal v-model="sortModal.modal" title="子节点排序" width="450px" :mask-closable="false">
<div class="sort-wrap">
<draggable v-model="sortModal.list" item-key="id" handle=".drag-handle" animation="150">
<template #item="{ element }">
<li class="sort-item">
<span class="drag-handle">≡</span>
<span class="name">{{ element.name }}</span>
</li>
</template>
</draggable>
</div>
<template #footer>
<Button type="primary" :loading="sortModal.loading" @click="saveSort">保存排序</Button>
<Button :loading="sortModal.loading" @click="sortModal.modal = false">取消</Button>
</template>
</Modal>
<Modal v-model="uploadModelShow" :mask-closable="false" width="600" title="导入目录树">
<BschImport ref="importRef" :tempFileUrl="tempFileUrl" :uploadUrl="uploadUrl" :uploadData="uploadData"
@on-success="reloadTable" />
<template #footer>
<Button @click="uploadModelShow = false">关闭</Button>
</template>
</Modal>
</div>
</div>
</template>
<script lang="ts" setup>
import useBookInfo from "../useBookInfo";
import draggable from 'vuedraggable'
import BschImport from '@/components/BschImport.vue'
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
interface Props {
id: number;
name: string;
}
const props = withDefaults(defineProps<Props>(), {
id: 0,
name: '',
});
const { bookInfo } = useBookInfo()
const { $conf, $bus, $utils } = useCurrentInstance();
const bookId = ref<number>()
const title = computed(() => {
return props.name;
});
const refFormData = useTemplateRef('refFormData')
const refNodeNameInput = useTemplateRef('refNodeNameInput')
onBeforeMount(() => {
bookId.value = Number(props.id);
sendTable();
});
const uploadModelShow = ref(false);
const tempFileUrl = computed(() => {
let url = $conf.serviceHost + "/api/knowledge/getKnowledgeTemplate";
return url;
})
const uploadUrl = computed(() => {
return $conf.serviceHost + "/api/knowledge/importKnowledge";
})
const uploadData = computed(() => {
return { textBookId: bookId.value };
})
const editNodeModal = reactive({
id: 0,
name: '',
title: '',
type: 0,
modal: false,
loading: false,
})
const sortModal = reactive({
modal: false,
loading: false,
parentId: 0, // 当前要排序的父节点
list: [] as { id: number; name: string }[]
})
const columns = ref([
{ title: '目录树结构', key: 'name', tree: true },
{
title: '操作',
width: 300,
align: 'center',
render: (h: any, params: any) => {
const { id, name, children } = params.row;
const addChild = $utils.operatingTextHandle(h, '添加子节点', '', () => showAddNode(id));
const sortNode = $utils.operatingTextHandle(h, '子节点排序', '', () => showSortNode(params.row), !children)
const editNode = $utils.operatingTextHandle(h, '编辑', '', () => showEditNode(id, name));
const delNode = $utils.operatingTextHandle(h, '删除', 'text-error', () => showDeleteNode(id, name))
return [addChild, editNode, delNode, sortNode];
}
}
])
const tableDataComp = computed(() => {
function recursion(tree: any[], level: number): any[] {
tree.forEach(element => {
element._showChildren = true;
element.enable = element.show;
element.level = level;
if (!!element.children && element.children.length > 0)
element.children = recursion(element.children, level + 1);
else if (!element.isParent) {
delete element.children;
}
});
return tree;
}
return recursion(tableData.value, 1)
})
const { loading: tableLoading, data: tableData, send: sendTable } = useRequest(
() => Apis.Knowledge.getTreeByTextbookId({ params: { textbookId: bookId.value }, cacheFor: null }),
{
initialData: [],
immediate: false,
})
function showAddNode(pid: number) {
editNodeModal.title = '新增子节点';
editNodeModal.modal = true;
editNodeModal.id = pid;
editNodeModal.type = 1;
editNodeModal.name = '';
nextTick().then(() => {
refFormData.value?.resetFields()
refNodeNameInput.value?.focus()
})
}
function showEditNode(id: number, name: string) {
editNodeModal.title = '编辑节点';
editNodeModal.modal = true;
editNodeModal.id = id;
editNodeModal.type = 2;
nextTick().then(() => {
refFormData.value?.resetFields()
refNodeNameInput.value.focus()
editNodeModal.name = name;
})
}
function showSortNode(parent: any) {
/* 仅当存在子节点才允许排序 */
if (!parent.children || !parent.children.length) {
return Message.warning('暂无子节点,无需排序')
}
sortModal.parentId = parent.id
sortModal.list = parent.children.map((c: any) => ({ id: c.id, name: c.name }))
sortModal.modal = true
}
async function showDeleteNode(id: number, name: string) {
Modal.confirm({
title: '删除目录树节点',
content: `确定删除名为【${name}】的目录树节点记录?`,
onOk: async () => {
await Apis.Knowledge.deleteKnowledge({ params: { id } })
Message.success('删除成功!');
sendTable()
}
});
}
async function saveEditNode() {
let isAdd = editNodeModal.type == 1 ? true : false;
const validate = await refFormData.value.validate();
if (!validate) {
Message.error('请填写红色星号内容!');
return;
}
const { id, name } = editNodeModal;
editNodeModal.loading = true;
if (isAdd) {
await Apis.Knowledge.addKnowledge({ params: { name, parentId: id } })
sendTable()
}
else {
await Apis.Knowledge.updateKnowledge({ params: { name, id } })
sendTable();
}
editNodeModal.loading = false;
editNodeModal.modal = false;
}
/* 真正保存排序 */
async function saveSort() {
sortModal.loading = true
try {
/* 把排序后的 id 顺序传给后台 */
const idList = sortModal.list.map(i => i.id)
await Apis.Knowledge.updateOrderCode({ data: idList })
Message.success('排序已保存')
sortModal.modal = false
sendTable() // 刷新表格
} finally {
sortModal.loading = false
}
}
function reloadTable() {
sendTable();
}
</script>
<style lang="scss" scoped>
h5.title {
position: relative;
padding-left: 12px;
line-height: 24px;
font-size: 16px;
font-weight: 400;
&::before {
content: "";
display: block;
width: 3px;
background: #5979ea;
height: 14px;
border-radius: 4px;
position: absolute;
top: 5px;
left: 0;
}
}
.sort-wrap {
padding: 0 12px;
max-height: 420px;
overflow: auto;
}
.sort-item {
display: flex;
align-items: center;
padding: 3px 0;
border-bottom: 1px solid #f0f0f0;
.drag-handle {
font-size: 18px;
font-weight: 400;
margin-right: 8px;
color: #999;
cursor: move;
}
.name {
flex: 1;
}
}
</style>
-7
View File
@@ -1,7 +0,0 @@
import { computed } from "vue";
export default function useBookInfo() {
const bookInfo = computed(() => {
return { identity: 2, baseModId: 2000, name: "教材", path: "textbook", type: 1 };
});
return { bookInfo };
}
-214
View File
@@ -1,214 +0,0 @@
<template>
<div class="page-wrapper tree-wrapper">
<Group class="tree-container" v-model:curTreeNode="selectedNode" :treeApi="treeApi" @on-change="onTreeChange"
@on-tree-data="onTreeData" />
<div class="main-container">
<div class="search-wrap">
<Button type="primary" @click="showAddUser">
<Icon type="md-add" style="margin-right: 4px" />新增用户
</Button>
<Input placeholder="输入用户名 / 真实姓名搜索" class="fr" search clearable suffix="ios-search" style="width: 220px"
v-model="keyword" @on-search="onSearch" @on-clear="onSearch" />
</div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
<!-- 容器组件:新增 / 编辑 -->
<AddUserContainer ref="addRef" />
<EditUserContainer ref="editRef" />
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from "vue";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import MyConfirm from "@/components/MyConfirm";
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
import { OperatorEnum } from '@/types';
import Group from './group/index.vue'
import AddUserContainer from './user/AddUserContainer.vue';
import EditUserContainer from './user/EditUserContainer.vue';
defineOptions({ name: "GroupUser" });
const { $utils } = useCurrentInstance();
const treeApi = () => Apis.SysGroup.getTree();
/* ================= 组织树 ================= */
const selectedNode = ref<any>({})
const treeData = ref<any[]>([])
function onTreeChange(node: any) {
selectedNode.value = node;
dataParams.value.groupId = node?.id ?? null;
tableRef.value?.refreshData();
}
function onTreeData(tree: any[]) {
treeData.value = tree;
}
function onSearch() {
dataParams.value.name = keyword.value.trim() || undefined;
tableRef.value?.refreshData();
}
/* ================= 所在组下拉(拍平组织树) ================= */
const groupOptions = computed(() => {
const flat: { id: number; name: string }[] = [];
const walk = (list: any[], prefix = '') => {
list.forEach(n => {
flat.push({ id: n.id, name: prefix ? `${prefix} / ${n.name}` : n.name });
if (n.children?.length) walk(n.children, prefix ? `${prefix} / ${n.name}` : n.name);
});
};
walk(treeData.value);
return flat;
})
/* ================= 表格数据源 ================= */
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
const keyword = ref('')
const dataParams = ref<{
name: string | undefined;
groupId: number | null;
}>({
name: undefined,
groupId: null,
});
const tableApi = (params: { pageIndex: number; pageSize: number }) => {
return Apis.SysUser.list({
params: {
name: dataParams.value.name,
groupId: dataParams.value.groupId ?? undefined,
// 选中组织时包含子组用户;未选中时不传
withSub: dataParams.value.groupId != null ? 1 : undefined,
pageIndex: params.pageIndex,
pageSize: params.pageSize,
},
cacheFor: null,
});
};
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }))
/* ================= 用户身份(roles[])展示 ================= */
const userTypeMap: Record<number, string> = {
1: '管理员', 2: '老师', 3: '校领导', 4: '局领导',
5: '学生', 6: '家长', 7: '工作号',
};
const roleMap: Record<number, string> = {
1: '超级管理员', 2: '学校管理员', 3: '普通用户',
};
function describeRoles(roles: any): string {
if (!Array.isArray(roles) || roles.length === 0) return '-';
const userTypes: string[] = [];
const specificRoles: string[] = [];
roles.forEach((r: any) => {
const n = Number(r);
if (userTypeMap[n]) userTypes.push(userTypeMap[n]);
else if (roleMap[n]) specificRoles.push(roleMap[n]);
});
const parts: string[] = [];
if (userTypes.length) parts.push(userTypes.join('/'));
if (specificRoles.length) parts.push(specificRoles.join('/'));
return parts.length ? parts.join(' · ') : '-';
}
/* ================= 表格列 ================= */
const columns = [
{ type: 'index', title: '序号', width: 70, align: 'center' },
{ title: '用户名', key: 'account', minWidth: 120 },
{ title: '真实姓名', key: 'realName', minWidth: 120 },
{ title: '手机号', key: 'phone', minWidth: 140 },
{
title: '用户身份', key: 'roles', minWidth: 180,
render: (h: any, { row }: any) => h('span', describeRoles(row.roles)),
},
{
title: '状态', key: 'disabled', width: 90, align: 'center',
render: (h: any, { row }: any) =>
h('Tag', { color: row.disabled ? 'default' : 'success' },
row.disabled ? '已禁用' : '启用'),
},
{
title: '操作', key: 'action', width: 220, align: 'center', fixed: 'right' as const,
render: (h: any, { row }: any) => {
const opList: any[] = [
h('a', {
style: { marginRight: '8px', color: '#577afb' },
onClick: () => handleEdit(row),
}, '编辑'),
h('a', {
style: { marginRight: '8px', color: '#ed4014' },
onClick: () => handleDelete(row),
}, '删除'),
h('a', {
style: { color: row.disabled ? '#19be6b' : '#ff9900' },
onClick: () => handleToggleStatus(row),
}, row.disabled ? '启用' : '禁用'),
];
return h('div', opList);
},
},
];
/* ================= 新增 ================= */
const addRef = ref();
function showAddUser() {
const curGroupId = selectedNode.value?.id ?? null;
addRef.value?.open(groupOptions.value, curGroupId, () => {
tableRef.value?.reloadData(OperatorEnum.add);
});
}
/* ================= 编辑 ================= */
const editRef = ref();
function handleEdit(row: any) {
editRef.value?.open(row, groupOptions.value, () => {
tableRef.value?.reloadData(OperatorEnum.edit);
});
}
/* ================= 删除 ================= */
async function handleDelete(row: any) {
await MyConfirm({
msg: `确定删除用户「${row.realName || row.account}」吗?`,
info: '删除后不可恢复!',
async onOk() {
try {
await Apis.SysUser.delete({ pathParams: { id: row.id } });
Message.success('删除成功!');
tableRef.value?.reloadData(OperatorEnum.del);
} catch (e: any) {
Message.error(e?.message || '删除失败');
}
},
});
}
/* ================= 禁用 / 启用 ================= */
async function handleToggleStatus(row: any) {
const isDisabled = !!row.disabled;
await MyConfirm({
msg: `确定要${isDisabled ? '启用' : '禁用'}用户「${row.realName || row.account}」吗?`,
async onOk() {
try {
if (isDisabled) {
await Apis.SysUser.enable({ pathParams: { id: row.id } });
} else {
await Apis.SysUser.disable({ pathParams: { id: row.id } });
}
Message.success(isDisabled ? '已启用!' : '已禁用!');
tableRef.value?.reloadData(OperatorEnum.sel);
} catch (e: any) {
Message.error(e?.message || '操作失败');
}
},
});
}
</script>
<style lang="scss" scoped></style>
@@ -1,75 +0,0 @@
<template>
<UserForm v-model="visible" :initForm="initFormData" :groupOptions="groupOptions" :isEdit="false"
:loading="submitting" @submit="handleSubmit" @cancel="visible = false" />
</template>
<script setup lang="ts">
import { ref } from "vue";
import UserForm from "./UserForm.vue";
/* ---------- 对外暴露:由父组件(group-user/index)调用 ---------- */
const visible = ref(false);
// 所在组下拉数据(拍平后的组织树)
const groupOptions = ref<any[]>([]);
// 新增模式:始终传空初始值;若带 defaultGroupId 则预填 groupId
const initFormData = ref<Record<string, any>>({});
// 成功后回调
let onSuccessCallback: (() => void) | null = null;
// useForm 托管提交:loading=submitting、sendForm 发请求、onSuccess/onError 处理结果
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
(dto) => Apis.SysUser.add({ data: dto }),
{ initialForm: { account: '', realName: '', password: '', confirmPassword: '', idcard: '', phone: '', phone2: '', gender: 0, groupId: undefined, userType: [], role: undefined }, resetAfterSubmiting: true }
);
onSuccess(() => {
Message.success('新增用户成功!');
visible.value = false;
onSuccessCallback?.();
});
onError((event) => {
Message.error(event?.error?.message || '新增失败');
});
/* ---------- 打开弹窗 ---------- */
function open(groups: any[], defaultGroupId?: number | null, onSuccess?: () => void) {
groupOptions.value = groups || [];
// 若已选中某个组织节点,新增弹窗自动带入该组;未选时不带入
initFormData.value = defaultGroupId != null ? { groupId: defaultGroupId } : {};
onSuccessCallback = onSuccess || null;
visible.value = true;
}
/* ---------- 提交:由 useForm 接管 loading 与异常处理 ---------- */
async function handleSubmit(formData: Record<string, any>) {
form.value = buildPayload(formData);
await sendForm();
}
/**
* 把表单字段映射成 SysUserRequest:
* - 只提交后端 SysUserRequest 里声明的字段
* - "用户身份"(userType checkbox) + "角色属性"(role select) 合并为 roles[](去重)
*/
function buildPayload(formData: Record<string, any>) {
const roleSet = new Set<number>();
(formData.userType || []).forEach((v: any) => roleSet.add(Number(v)));
if (formData.role != null && formData.role !== '') roleSet.add(Number(formData.role));
return {
account: formData.account,
password: formData.password,
confirmPassword: formData.confirmPassword,
realName: formData.realName,
idcard: formData.idcard || undefined,
phone: formData.phone,
groupId: formData.groupId,
roles: Array.from(roleSet),
};
}
defineExpose({ open, visible });
</script>
@@ -1,114 +0,0 @@
<template>
<UserForm v-model="visible" :initForm="detailData" :groupOptions="groupOptions" :isEdit="true"
:loading="submitting" @submit="handleSubmit" @cancel="visible = false" />
</template>
<script setup lang="ts">
import { ref } from "vue";
import UserForm from "./UserForm.vue";
/* ---------- 对外暴露 ---------- */
const visible = ref(false);
const groupOptions = ref<any[]>([]);
// 编辑模式:当前编辑的用户 ID
const currentId = ref<number | null>(null);
// 编辑模式:回填数据(来自当前行,辅以 roles[] 拆分到 userType/role)
const detailData = ref<Record<string, any>>({});
// 成功后回调
let onSuccessCallback: (() => void) | null = null;
// useForm 托管提交——currentId 是 ref,闭包内 .value 在 sendForm 时取当前值
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
(dto) => Apis.SysUser.edit({ pathParams: { id: currentId.value }, data: dto }),
{ initialForm: { id: null, account: '', realName: '', password: '', confirmPassword: '', idcard: '', phone: '', phone2: '', gender: 0, groupId: undefined, userType: [], role: undefined }, resetAfterSubmiting: true }
);
onSuccess(() => {
Message.success('编辑用户成功!');
visible.value = false;
onSuccessCallback?.();
});
onError((event) => {
Message.error(event?.error?.message || '编辑失败');
});
/* ---------- 打开编辑弹窗 ---------- */
/**
* @param row - 当前行数据(来自 SysUser.list 接口返回的 SysUserVo)
* @param groups - 所在组下拉(拍平的组织树)
* @param onSuccess - 编辑成功后的回调
*/
function open(row: any, groups: any[], onSuccess?: () => void) {
if (!row?.id) {
Message.error('用户 ID 不存在');
return;
}
currentId.value = row.id;
groupOptions.value = groups || [];
detailData.value = buildInitForm(row);
onSuccessCallback = onSuccess || null;
visible.value = true;
}
/* ---------- 把行数据映射成表单回填数据 ---------- */
function buildInitForm(row: any) {
const roles: number[] = Array.isArray(row.roles) ? row.roles : [];
// 用户身份(业务身份)= 1..7;角色属性(具体角色)= 1..3
const userType = roles.filter(r => r >= 1 && r <= 7);
const role = roles.filter(r => r >= 1 && r <= 3).slice(-1)[0];
return {
id: row.id,
account: row.account || '',
realName: row.realName || '',
idcard: row.idcard || '',
phone: row.phone || '',
groupId: row.groupId,
password: '',
confirmPassword: '',
phone2: '',
gender: 0,
userType,
role,
};
}
/* ---------- 提交:由 useForm 接管 loading 与异常处理 ---------- */
async function handleSubmit(formData: Record<string, any>) {
if (!currentId.value) return;
form.value = buildPayload(formData, currentId.value);
await sendForm();
}
/**
* 把表单字段映射成 SysUserRequest:
* - 只提交后端 SysUserRequest 里声明的字段
* - "用户身份"(userType checkbox) + "角色属性"(role select) 合并为 roles[](去重)
* - 编辑时密码留空表示不修改,所以密码字段不传
*/
function buildPayload(formData: Record<string, any>, id: number) {
const roleSet = new Set<number>();
(formData.userType || []).forEach((v: any) => roleSet.add(Number(v)));
if (formData.role != null && formData.role !== '') roleSet.add(Number(formData.role));
const payload: any = {
id,
account: formData.account,
realName: formData.realName,
idcard: formData.idcard || undefined,
phone: formData.phone,
groupId: formData.groupId,
roles: Array.from(roleSet),
};
// 编辑时若填写了密码才提交(密码为空表示不修改)
if (formData.password) {
payload.password = formData.password;
payload.confirmPassword = formData.confirmPassword;
}
return payload;
}
defineExpose({ open, visible });
</script>
@@ -1,295 +0,0 @@
<template>
<Modal v-model="show" :mask-closable="false" width="800" scrollable :title="title"
@on-visible-change="onVisibleChange">
<Form ref="formRef" :model="form" :rules="rules" :label-width="130" class="user-form width-95">
<!-- 第 1 行:用户名 / 真实姓名 -->
<Row>
<Col span="12">
<FormItem label="用户名:" prop="account">
<Input v-model="form.account" :disabled="isEdit" placeholder="请输入用户名" />
</FormItem>
</Col>
<Col span="12">
<FormItem label="真实姓名:" prop="realName">
<Input v-model="form.realName" placeholder="请输入真实姓名" />
</FormItem>
</Col>
</Row>
<!-- 第 2 行:密码 / 确认密码(编辑时变为「重置密码」语义,但视觉一致) -->
<Row>
<Col span="12">
<FormItem label="密码:" prop="password">
<Input v-model="form.password" type="password" :placeholder="isEdit ? '不修改请留空' : '请输入密码'" />
</FormItem>
</Col>
<Col span="12">
<FormItem label="确认密码:" prop="confirmPassword">
<Input v-model="form.confirmPassword" type="password"
:placeholder="isEdit ? '不修改请留空' : '请再次确认密码'" />
</FormItem>
</Col>
</Row>
<!-- 第 3 行:身份证号 / 手机号码 -->
<Row>
<Col span="12">
<FormItem label="身份证号:" prop="idcard">
<Input v-model="form.idcard" placeholder="请输入身份证号" />
</FormItem>
</Col>
<Col span="12">
<FormItem label="手机号码:" prop="phone">
<Input v-model="form.phone" placeholder="请输入手机号码" />
</FormItem>
</Col>
</Row>
<!-- 第 4 行:手机号码2 / 性别 -->
<Row>
<Col span="12">
<FormItem label="手机号码2:">
<Input v-model="form.phone2" placeholder="请输入手机号码" />
</FormItem>
</Col>
<Col span="12">
<FormItem label="性别:">
<RadioGroup v-model="form.gender">
<Radio :label="0">女</Radio>
<Radio :label="1">男</Radio>
</RadioGroup>
</FormItem>
</Col>
</Row>
<!-- 第 5 行:所在组(整行) -->
<Row>
<Col span="24">
<FormItem label="所在组:" prop="groupId">
<Select v-model="form.groupId" placeholder="请选择所在组" filterable transfer>
<Option v-for="g in groupOptions" :key="g.id" :value="g.id" :label="g.name" />
</Select>
</FormItem>
</Col>
</Row>
<!-- 第 6 行:用户身份(checkbox 多选,整行) -->
<Row>
<Col span="24">
<FormItem label="用户身份:" prop="userType">
<CheckboxGroup v-model="form.userType" class="user-type-group">
<Checkbox v-for="opt in userTypeOptions" :key="opt.value" :label="opt.value">
{{ opt.label }}
</Checkbox>
</CheckboxGroup>
</FormItem>
</Col>
</Row>
<!-- 第 7 行:角色属性(整行) -->
<Row>
<Col span="24">
<FormItem label="角色属性:" prop="role">
<Select v-model="form.role" placeholder="请选择" clearable transfer style="width: 220px">
<Option v-for="r in roleOptions" :key="r.value" :value="r.value" :label="r.label" />
</Select>
</FormItem>
</Col>
</Row>
</Form>
<template #footer>
<Button type="primary" :loading="loading" @click="handleSubmit">提交</Button>
<Button @click="handleCancel">取消</Button>
</template>
</Modal>
</template>
<script setup lang="ts">
import { computed, reactive, watch } from "vue";
/* ---------- Props / Emits ---------- */
const props = withDefaults(defineProps<{
modelValue: boolean;
initForm?: Record<string, any>;
groupOptions?: any[];
isEdit?: boolean;
loading?: boolean;
}>(), {
initForm: () => ({}),
groupOptions: () => [],
isEdit: false,
loading: false,
});
const emit = defineEmits(['update:modelValue', 'submit', 'cancel']);
const show = computed({
get: () => props.modelValue,
set: val => emit('update:modelValue', val),
});
const title = computed(() => props.isEdit ? '编辑用户' : '新增用户');
/* ---------- 表单数据 ---------- */
const defaultForm = (): Record<string, any> => ({
id: null,
account: '',
realName: '',
password: '',
confirmPassword: '',
idcard: '',
phone: '',
phone2: '',
gender: 0,
groupId: undefined as number | undefined,
userType: [] as number[],
role: undefined as number | undefined,
});
const form = reactive(defaultForm());
// 外部 initForm 变化时回填
watch(() => props.initForm, (val) => {
if (val && Object.keys(val).length > 0) {
Object.assign(form, defaultForm(), val);
} else {
Object.assign(form, defaultForm());
}
}, { immediate: true, deep: true });
/* ---------- 用户身份(业务身份多选)---------- */
const userTypeOptions = [
{ value: 1, label: '管理员' },
{ value: 2, label: '老师' },
{ value: 3, label: '校领导' },
{ value: 4, label: '局领导' },
{ value: 5, label: '学生' },
{ value: 6, label: '家长' },
{ value: 7, label: '工作号' },
];
/* ---------- 角色属性(具体角色单选,固定枚举)---------- */
const roleOptions = [
{ value: 1, label: '超级管理员' },
{ value: 2, label: '学校管理员' },
{ value: 3, label: '普通用户' },
];
/* ---------- 校验规则 ---------- */
// 自定义确认密码校验
const validateConfirmPassword = (rule: any, value: string, callback: any) => {
if (!props.isEdit) {
// 新增:必填 + 必须与密码一致
if (!value) {
callback(new Error('请再次输入密码'));
} else if (value !== form.password) {
callback(new Error('两次输入的密码不一致'));
} else {
callback();
}
} else {
// 编辑:留空表示不修改密码;非空时必须与密码一致
if (value && value !== form.password) {
callback(new Error('两次输入的密码不一致'));
} else {
callback();
}
}
};
// 自定义密码校验(编辑时可留空)
const validatePassword = (rule: any, value: string, callback: any) => {
if (!props.isEdit && !value) {
callback(new Error('请输入密码'));
} else if (value && value.length < 6) {
callback(new Error('密码长度不能少于 6 位'));
} else {
callback();
}
};
const rules = {
account: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 30, message: '用户名长度 3-30 位', trigger: 'blur' },
],
realName: [
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
],
password: [{ validator: validatePassword, trigger: 'blur' }],
confirmPassword: [{ validator: validateConfirmPassword, trigger: 'blur' }],
phone: [
{ required: true, message: '请输入手机号码', trigger: 'blur' },
{ pattern: /^1\d{10}$/, message: '请输入正确的手机号', trigger: 'blur' },
],
groupId: [
{ required: true, type: 'number' as const, message: '请选择所在组', trigger: 'change' },
],
userType: [
{ required: true, type: 'array' as const, min: 1, message: '请选择至少一个用户身份', trigger: 'change' },
],
role: [
{ required: true, type: 'number' as const, message: '请选择角色属性', trigger: 'change' },
],
};
/* ---------- 提交 ---------- */
const formRef = ref();
async function handleSubmit() {
try {
await formRef.value?.validate();
} catch {
return false;
}
emit('submit', { ...form });
}
function handleCancel() {
emit('cancel');
}
function onVisibleChange(val: boolean) {
if (!val) {
emit('cancel');
}
}
</script>
<style lang="scss" scoped>
.user-form {
margin: 0 auto;
max-height: 60vh;
overflow-y: auto;
overflow-x: hidden;
padding-right: 12px;
:deep(.ivu-form-item) {
display: block;
margin-bottom: 0;
}
:deep(.ivu-form-item-label) {
font-size: 13px;
color: #515a6e;
}
:deep(.ivu-row) {
margin-left: 0 !important;
margin-right: 0 !important;
margin-bottom: 22px;
}
:deep(.ivu-row:last-child) {
margin-bottom: 0;
}
}
/* 用户身份多选:横向换行,避免撑爆 Col */
.user-type-group {
display: flex;
flex-wrap: wrap;
gap: 0 18px;
}
</style>
-146
View File
@@ -1,146 +0,0 @@
import { ConfigEnv, UserConfig, defineConfig } from "vite";
import { createHtmlPlugin } from "vite-plugin-html";
import vue from "@vitejs/plugin-vue";
import path, { resolve } from "path";
import { createSvgIconsPlugin } from "vite-plugin-svg-icons";
import { viteMockServe } from "vite-plugin-mock";
import ViteAutoImport from "unplugin-auto-import/vite";
import UnoCSS from "@unocss/vite";
import presetUno from "@unocss/preset-uno";
import presetRemToPx from "@unocss/preset-rem-to-px";
export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
// 构建输出目录:base -> dist/base, ld -> dist/ld, prod -> dist/prod, 默认 dist/base
const subDir = mode === "ld" || mode === "prod" ? mode : "base";
const outDir = command === "build" ? `dist/${subDir}` : "dist";
return {
plugins: [
vue(),
createSvgIconsPlugin({
iconDirs: [resolve(process.cwd(), "src/assets/icons")],
symbolId: "icon-[dir]-[name]", // symbol的id
inject: "body-last", // 插入的位置
customDomId: "__svg__icons__dom__", // svg的id
}),
viteMockServe({
mockPath: "./src/assets/mock",
}),
ViteAutoImport({
imports: [
"vue",
"vue-router",
{
"view-ui-plus": ["Button", "Table", "Modal", "Message", "Form", "FormItem", "Input", "Select"],
},
{
from: "alova/client",
imports: ["usePagination", "useRequest", "useWatcher", "useForm"],
},
],
dts: "./src/auto-imports.d.ts",
dirs: ["src/api"], // 自动导入 hooks
}),
createHtmlPlugin({
minify: true,
pages: [
{
entry: "src/main.ts",
template: "index.html",
filename: "index.html",
injectOptions: {
data: {
title: "铃铛学习中心",
injectScript: `<script src="./js/userType.js?type=teacher"></script>`,
},
},
},
],
}),
UnoCSS({
presets: [
presetUno(), // 核心预设(Tailwind风格)
presetRemToPx({ baseFontSize: 4 }), // 1单位=1px(去rem化)
],
shortcuts: {
// 布局
"flex-center": "flex items-center justify-center",
// 常用组件
btn: "px-16px py-8px rounded-4px cursor-pointer transition-all duration-200",
"btn-primary": "bg-blue-600 text-white hover:bg-blue-700",
card: "bg-white rounded-8px shadow-md p-16px",
// 文本截断
"text-ellipsis": "truncate",
"text-multi-ellipsis": "line-clamp-2",
},
theme: {
fontSize: {
xs: "12px",
sm: "14px",
base: "16px",
lg: "18px",
xl: "20px",
"2xl": "24px",
},
},
}),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
host: "localhost",
port: 8130,
// proxy: {
// // //api是自行设置的请求前缀,任何请求路径以/api开头的请求将被代理到对应的target目标
// // "/api": {
// // target: "http://localhost:8064/",
// // // target: "http://debugold.hongyucloud.com/ChildOptCourse",
// // changeOrigin: true, //需要代理跨域
// // rewrite: (path: any) => path.replace(/^\/api/, "/api/"), //路径重写,把'/api'替换为''
// // },
// },
},
// css预处理器
css: {
// preprocessorOptions: {
// scss: {
// additionalData: '@import "./src/assets/styles/utils/_varible.scss";',
// },
// },
},
base: "./",
build: {
target: "modules", //设置最终构建的浏览器兼容目标 //es2015(编译成es5) | modules
outDir, // 构建输出目录,根据环境变量区分 base/ld
assetsDir: "assets", // 静态资源得存放路径文件名 assets
sourcemap: false, //构建后是否生成 source map 文件
minify: "esbuild", // 项目压缩 :boolean | 'terser' | 'esbuild'
chunkSizeWarningLimit: 1000, //chunk 大小警告的限制(以 kbs 为单位)默认:500
cssTarget: "chrome61", //防止 vite 将 rgba() 颜色转化为 #RGBA 十六进制符号的形式 (要兼容的场景是安卓微信中的 webview 时,它不支持 CSS 中的 #RGBA 十六进制颜色符号)
rollupOptions: {
input: {
main: resolve("index.html"),
},
output: {
chunkFileNames: "static/js/[name]-[hash].js",
entryFileNames: "static/js/[name]-[hash].js",
assetFileNames: (assetInfo: any) => {
if (assetInfo.type === "asset" && /\.(jpe?g|png|gif|svg)$/i.test(assetInfo.name)) {
return "static/img/[name].[hash][ext]";
}
if (assetInfo.type === "asset" && /\.(ttf|woff|woff2|eot)$/i.test(assetInfo.name)) {
return "static/fonts/[name].[hash][ext]";
}
return "static/[ext]/[name]-[hash].[ext]";
},
},
manualEntrypoint: "index.html",
},
},
};
});
+44
View File
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="../../favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 运行时配置引导:在任何应用模块执行前同步拉取 /<应用>/<前端目标>/api/env-config
(projman 配置管理在线维护、双写 Nacos),替代 config/index.js 与生产构建环境变量。
必须同步:host.config.ts 在模块求值期就读 window.__APP_CONFIG__;失败/本地开发
(路径不足两段)则不设置:本地走 .env.dev(import.meta.env.VITE_*),线上走同域推导兜底。
注意:同步 XHR 不支持 timeout 属性(赋值会抛 InvalidAccessError),依赖内网快速失败 -->
<script>
(function () {
try {
var seg = location.pathname.split("/").filter(Boolean);
if (seg.length < 2) return; // 本地开发(根路径):无应用上下文,走 .env.dev
var xhr = new XMLHttpRequest();
xhr.open("GET", "/" + seg[0] + "/api/env-config?target=" + encodeURIComponent(seg[1]), false);
xhr.send(null);
if (xhr.status === 200) {
var conf = JSON.parse(xhr.responseText);
if (conf && typeof conf === "object") window.__APP_CONFIG__ = conf;
}
} catch (e) {
/* 拉取失败保持未设置,走同域推导兜底 */
}
})();
</script>
<title><%- title %></title>
<link rel="stylesheet" href="./libs/ztree/zTreeStyle.css" />
<script src="./libs/jquery.min.js"></script>
<script src="./libs/lodash.min.js"></script>
<script src="./libs/ztree/jquery.ztree.all.min.js"></script>
<script src=" https://base.bestsch.com/BschHome/base/js/bschload.min.js?v=2025011001"></script>
<%- injectScript %>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
View File
+3 -3
View File
@@ -6,10 +6,10 @@
"type": "module",
"scripts": {
"dev": "vite --mode dev",
"build": "vite build --mode base",
"build:base": "vite build --mode base",
"build:ld": "vite build --mode ld",
"build": "vite build",
"build:prod": "vite build --mode prod",
"build:ld": "vite build --mode ld",
"build:base": "vite build --mode base",
"preview": "vite preview",
"api:gen": "alova gen -f"
},

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before

Width:  |  Height:  |  Size: 933 B

After

Width:  |  Height:  |  Size: 933 B

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

-268
View File
@@ -1,268 +0,0 @@
#!/usr/bin/env node
// Deploy front/dist/prod to host /data/www/userbase/front using the Docker daemon
// that act_runner already mounts into the job container (/var/run/docker.sock).
//
// Why this script exists:
// act_runner (v0.6.1) ignores both `container.options` extra --volume and
// workflow `container.volumes` in this setup, so the host static dir can't be
// bind-mounted directly into the job container. But the host docker.sock IS
// mounted by default, so we use the Docker HTTP API to:
// 1. tar the built assets straight out of THIS container's filesystem
// (GET /containers/{jobId}/archive?path=...), and
// 2. upload that tar into a throwaway helper container that has
// /data/www/userbase mounted RW (PUT /containers/{helperId}/archive),
// then `mv` the extracted folder so files land at the correct top level.
//
// Diagnostics: every step is collected and written to /data/www/userbase/_debug.txt
// on the HOST via `docker exec` into the helper (the job container itself cannot
// write there). The Gitea logs API is unavailable here, so this file is our only
// reliable window into what happened.
//
// No docker CLI and no network egress required — we speak the Docker HTTP API
// over the unix socket directly with Node's http module.
const http = require('http');
const fs = require('fs');
const path = require('path');
const SOCKET = '/var/run/docker.sock';
const HOST_STATIC = '/data/www/userbase';
const HELPER_IMAGE = 'node:20'; // already cached on host (used by the job)
const STAGING = HOST_STATIC + '/_staging';
// --- low level docker http helpers -------------------------------------------
function dockerJson(method, p, bodyObj) {
return new Promise((resolve, reject) => {
const data = bodyObj ? JSON.stringify(bodyObj) : null;
const headers = {};
if (data) {
headers['Content-Type'] = 'application/json';
headers['Content-Length'] = Buffer.byteLength(data);
}
const req = http.request(
{ socketPath: SOCKET, path: p, method, headers },
(res) => {
let buf = '';
res.on('data', (c) => (buf += c));
res.on('end', () => resolve({ status: res.statusCode, body: buf }));
}
);
req.on('error', reject);
if (data) req.write(data);
req.end();
});
}
function getArchive(id, p) {
return new Promise((resolve, reject) => {
const req = http.request(
{
socketPath: SOCKET,
path: `/containers/${id}/archive?path=${encodeURIComponent(p)}`,
method: 'GET',
},
(res) => resolve(res)
);
req.on('error', reject);
req.end();
});
}
function putArchive(id, p, tarStream) {
return new Promise((resolve, reject) => {
const req = http.request(
{
socketPath: SOCKET,
path: `/containers/${id}/archive?path=${encodeURIComponent(p)}`,
method: 'PUT',
headers: { 'Content-Type': 'application/x-tar' },
},
(res) => {
let buf = '';
res.on('data', (c) => (buf += c));
res.on('end', () => resolve({ status: res.statusCode, body: buf }));
}
);
req.on('error', reject);
tarStream.pipe(req);
});
}
// Run a command inside the helper and return its stdout/stderr (Detach:false so
// the call resolves only after the command finishes — reliable for sequencing).
async function runExec(id, cmd) {
const created = await dockerJson('POST', `/containers/${id}/exec`, {
Cmd: ['sh', '-c', cmd],
AttachStdout: true,
AttachStderr: true,
});
if (created.status >= 400) {
throw new Error('exec create failed: ' + created.status + ' ' + created.body);
}
const execId = JSON.parse(created.body).Id;
const started = await dockerJson('POST', `/exec/${execId}/start`, {
Detach: false,
Tty: false,
});
return started; // { status, body(stdout+stderr) }
}
// --- helper container + diagnostics ------------------------------------------
function getContainerId() {
const cg = fs.readFileSync('/proc/self/cgroup', 'utf8');
const m = cg.match(/[0-9a-f]{64}/);
if (!m) {
const h = fs.readFileSync('/etc/hostname', 'utf8').trim();
return h;
}
return m[0];
}
let HELPER_ID = null;
let DEBUG_BUF = [];
function dbg(line) {
const ts = new Date().toISOString();
DEBUG_BUF.push(`[${ts}] ${line}`);
console.log(line); // also emit to job log (best-effort)
}
// Write the accumulated debug buffer to the HOST file via a base64 pipe. Passing
// the payload inline (single-quoted) avoids env-var/newline fragility that broke
// the earlier diagnostics channel. Uses Detach:false so we know it completed.
async function flushDebug() {
if (!HELPER_ID) {
console.error('flushDebug: no helper, cannot write host debug');
return;
}
const content = DEBUG_BUF.join('\n') + '\n';
const b64 = Buffer.from(content, 'utf8').toString('base64');
const cmd = `echo ${b64} | base64 -d > ${HOST_STATIC}/_debug.txt`;
try {
const r = await runExec(HELPER_ID, cmd);
if (r.status >= 400) console.error('flushDebug exec failed:', r.status, r.body);
else dbg('debug written to host (' + content.length + ' bytes)');
} catch (e) {
console.error('flushDebug error:', e.message);
}
}
// --- main --------------------------------------------------------------------
function resolveDistPath() {
const candidates = [
path.join(process.cwd(), 'front', 'dist', 'prod'),
'/workspace/admin/userbase/front/dist/prod',
];
for (const c of candidates) {
if (fs.existsSync(c)) return c;
}
return null;
}
async function main() {
dbg('START script version=2026-08-04c');
dbg('cwd=' + process.cwd());
// 1) helper container with the host static dir mounted RW; keep it alive.
const createBody = {
Image: HELPER_IMAGE,
HostConfig: { NetworkMode: 'none', Binds: [`${HOST_STATIC}:${HOST_STATIC}`] },
Cmd: ['sh', '-c', `mkdir -p ${STAGING} && exec tail -f /dev/null`],
};
const created = await dockerJson('POST', '/containers/create', createBody);
if (created.status >= 400) {
throw new Error('create helper failed: ' + created.status + ' ' + created.body);
}
HELPER_ID = JSON.parse(created.body).Id;
dbg('helper created id=' + HELPER_ID);
const started = await dockerJson('POST', `/containers/${HELPER_ID}/start`);
if (started.status >= 400) {
throw new Error('start helper failed: ' + started.status + ' ' + started.body);
}
dbg('helper started');
// clean any stale staging from a previous failed run
await runExec(HELPER_ID, `rm -rf ${STAGING} && mkdir -p ${STAGING}`);
// 2) locate the built assets inside THIS container.
const jobId = getContainerId();
dbg('job container id=' + jobId);
const distDir = resolveDistPath();
if (!distDir) {
dbg('dist dir NOT found; cwd listing:');
try {
dbg(fs.readdirSync(process.cwd()).join(' '));
const front = path.join(process.cwd(), 'front');
if (fs.existsSync(front)) dbg('front listing: ' + fs.readdirSync(front).join(' '));
} catch (e) {
dbg('ls err: ' + e.message);
}
throw new Error('built dist/prod not found under any candidate path');
}
dbg('dist dir=' + distDir);
dbg('dist listing: ' + fs.readdirSync(distDir).join(' '));
// 3) tar of the built assets, fetched from THIS container's own filesystem.
// We fetch WITHOUT a trailing slash so the tar contains a top-level "prod/"
// folder; we then mv it into place (deterministic, no reliance on the
// trailing-slash "contents only" quirk which this moby build ignores).
const tar = await getArchive(jobId, distDir);
if (tar.statusCode !== 200) {
let msg = '';
tar.on('data', (c) => (msg += c));
await new Promise((r) => tar.on('end', r));
throw new Error('get archive failed (status ' + tar.statusCode + '): ' + msg);
}
dbg('fetched dist tar (content-type=' + tar.headers['content-type'] + ')');
// 4) upload the tar into the staging dir of the helper's mounted host dir.
const put = await putArchive(HELPER_ID, STAGING, tar);
dbg('put archive status=' + put.status + (put.status >= 400 ? ' ' + put.body : ''));
if (put.status >= 400) {
throw new Error('put archive failed: ' + put.status + ' ' + put.body);
}
// 5) move staging/prod -> HOST_STATIC/front (deterministic top-level landing).
const mv = await runExec(
HELPER_ID,
`rm -rf ${HOST_STATIC}/front && mv ${STAGING}/prod ${HOST_STATIC}/front && rm -rf ${STAGING}`
);
dbg('mv status=' + mv.status + ' out=' + mv.body.replace(/\n/g, ' | '));
// 6) verify on host via exec into helper.
const verify = await runExec(
HELPER_ID,
`echo "count=$(find ${HOST_STATIC}/front -type f | wc -l)"; ` +
`echo "has_stray_prod=$(test -d ${HOST_STATIC}/front/prod && echo YES || echo NO)"; ` +
`ls -la ${HOST_STATIC}/front | head -20`
);
dbg('verify output: ' + verify.body.replace(/\n/g, ' | '));
dbg('DEPLOY OK -> ' + HOST_STATIC + '/front (top-level, no prod/ nesting)');
}
main()
.then(async () => {
// On success, keep diagnostics only briefly, then remove the debug file so
// it is not left web-served at /userbase/_debug.txt. Failures keep it for
// post-mortem (see .catch below).
await flushDebug();
try {
if (HELPER_ID) await runExec(HELPER_ID, `rm -f ${HOST_STATIC}/_debug.txt`);
} catch (e) {
console.error('cleanup debug file failed:', e.message);
}
if (HELPER_ID) await dockerJson('DELETE', `/containers/${HELPER_ID}?force=true`);
process.exit(0);
})
.catch(async (err) => {
dbg('DEPLOY FAILED: ' + err.stack);
await flushDebug(); // keep debug file on failure for post-mortem
if (HELPER_ID) await dockerJson('DELETE', `/containers/${HELPER_ID}?force=true`);
console.error('DEPLOY FAILED:', err.message);
process.exit(1);
});
-7
View File
@@ -1,7 +0,0 @@
target/*
!target/userbase.jar
src
.idea
*.iml
*.log
.workbuddy
-3
View File
@@ -1,3 +0,0 @@
EXCLUSION_MISSING. current:
EOF
-20
View File
@@ -1,20 +0,0 @@
# UserBase 后端镜像(基于已构建好的 target/userbase.jar)
# 构建上下文 = service 目录: docker build -f docker/Dockerfile -t <img> .
FROM eclipse-temurin:8-jre
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl tzdata \
&& ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY target/userbase.jar /app/userbase.jar
# 镜像内 application-prod.yml 仅作「nacos 不可达时的兜底默认」;
# 正常启动由 run-app.sh 从 nacos 拉取线上配置(dataId=userbase-prod.yml)后覆盖。
COPY docker/application-prod.yml /app/config/application-prod.yml
COPY docker/run-app.sh /app/run-app.sh
RUN chmod +x /app/run-app.sh && mkdir -p /data/upload
EXPOSE 8060
ENTRYPOINT ["/app/run-app.sh"]
-90
View File
@@ -1,90 +0,0 @@
# UserBase 生产配置(线上以 nacos 的 userbase-prod.yml 为准,本文件是 nacos 不可达时的兜底)
server:
port: 8060
servlet:
# 对外经 caddy 暴露为 http://www.yunhaixingtong.com/userbase
context-path: /userbase
spring:
mvc:
date-format: yyyy-MM-dd HH:mm:ss
servlet:
multipart:
max-file-size: 50MB
max-request-size: 200MB
jackson:
time-zone: GMT+8
date-format: yyyy-MM-dd HH:mm:ss
redis:
host: redis-server
port: 6379
database: 0
password: 'wv&WcDYAmLtqTIGM'
timeout: 2000ms
data:
redis:
repositories:
enabled: false
datasource:
url: jdbc:mysql://mysql8:3306/bsch_userbase?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: 'CVf^x$sW*b6CGHxh'
driver-class-name: com.mysql.cj.jdbc.Driver
min-idle: 10
max-active: 30
jpa:
database: MySQL
show-sql: false
hibernate:
ddl-auto: update
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
properties:
hibernate.order_by.default_null_ordering: last
hibernate:
dialect: org.hibernate.dialect.MySQL5InnoDBDialect
open-in-view: true
rabbitmq:
host: rabbitmq
port: 5672
username: admin
password: 'JdU29I#Q2E4yXoUl'
listener:
direct:
auto-startup: false
simple:
auto-startup: false
# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth)
yh:
sso:
server-url-prefix: http://sso.yunhaixingtong.com
client-host-url: http://www.yunhaixingtong.com/userbase
allow-headers: all
allow-origins: all
url-pattern: /api/*
mobi-api-pattern: /mobiapi/*
app-auth:
enabled: true
# 本应用自身身份(客户端签名用,若 UserBase 需调用其他带签名服务)
app-key: 100616
app-secret: 1febb9502d2744b6a235d3e4cfde4a2d
# 受信应用(服务端验签用):appKey -> appSecret
trusted-apps:
100616: 1febb9502d2744b6a235d3e4cfde4a2d
ADMIN_TAG_ID: 290
# 上传文件落容器卷,compose 里挂到宿主机 ./upload-data
FILE_PATH: /data/upload
FILE_WEB_PATH: http://www.yunhaixingtong.com/userbase/upload/
ZIYUAN_WEB_PATH_HTTP: http://base.bestsch.com/ziyuanku/file/
UPLOAD_WEB_PATH_HTTP: http://www.yunhaixingtong.com/userbase/upload/file/
ORC_SERVICE_URL: http://oa.bestsch.com/OrcService/api/Ocr/Upload
logging:
level:
root: INFO
-10
View File
@@ -1,10 +0,0 @@
#!/bin/sh
# UserBase 启动入口:配置统一由 Nacos 配置中心在 bootstrap 阶段下发(见 bootstrap.yml)
# 不再需要手动 curl 拉取;若 nacos 不可达,bootstrap 中 fail-fast=false 会自动回退到
# 镜像内烤好的 application-prod.yml 兜底默认。
set -e
echo "[run-app] starting UserBase (profile=prod, nacos=${NACOS_ADDR:-nacos.yunhaixingtong.com:8848}) ..."
exec java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/userbase.jar \
--spring.profiles.active=prod
-64
View File
@@ -1,64 +0,0 @@
import ctypes
from ctypes import wintypes
rstrtmgr = ctypes.windll.rstrtmgr
class RM_UNIQUE_PROCESS(ctypes.Structure):
_fields_ = [("dwProcessId", wintypes.DWORD),
("ProcessStartTime", wintypes.FILETIME)]
class RM_PROCESS_INFO(ctypes.Structure):
_fields_ = [("Process", RM_UNIQUE_PROCESS),
("strAppName", ctypes.c_wchar * 255),
("strServiceShortName", ctypes.c_wchar * 63),
("ApplicationType", ctypes.c_int),
("AppStatus", ctypes.c_uint),
("TSSessionId", wintypes.DWORD),
("bRestartable", wintypes.BOOL)]
RmStartSession = rstrtmgr.RmStartSession
RmStartSession.argtypes = [ctypes.POINTER(ctypes.c_uint32), ctypes.c_uint32, ctypes.c_wchar_p]
RmStartSession.restype = ctypes.c_uint32
RmRegisterResources = rstrtmgr.RmRegisterResources
RmRegisterResources.argtypes = [ctypes.c_uint32, ctypes.c_uint32, ctypes.c_wchar_p,
ctypes.c_uint32, ctypes.POINTER(ctypes.c_void_p),
ctypes.c_uint32, ctypes.c_wchar_p]
RmRegisterResources.restype = ctypes.c_uint32
RmGetList = rstrtmgr.RmGetList
RmGetList.argtypes = [ctypes.c_uint32, ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint32),
ctypes.POINTER(RM_PROCESS_INFO), ctypes.POINTER(ctypes.c_uint32)]
RmGetList.restype = ctypes.c_uint32
RmEndSession = rstrtmgr.RmEndSession
RmEndSession.argtypes = [ctypes.c_uint32]
RmEndSession.restype = ctypes.c_uint32
session = ctypes.c_uint32(0)
desc = ctypes.create_unicode_buffer("diag")
rc = RmStartSession(ctypes.byref(session), 0, desc)
if rc != 0:
print("RmStartSession failed", rc)
raise SystemExit
path = ctypes.create_unicode_buffer(r"C:\ub-build\userbase\classes\application.yml")
rc = RmRegisterResources(session, 1, path, 0, None, 0, None)
print("RmRegisterResources rc=", rc)
nNeeded = ctypes.c_uint32(0)
nActual = ctypes.c_uint32(0)
reason = ctypes.c_uint32(0)
rc = RmGetList(session, ctypes.byref(nNeeded), ctypes.byref(nActual), None, ctypes.byref(reason))
print("first RmGetList rc=", rc, "needed=", nNeeded.value)
if nNeeded.value > 0:
arr = (RM_PROCESS_INFO * nNeeded.value)()
nActual = ctypes.c_uint32(nNeeded.value)
rc = RmGetList(session, ctypes.byref(nNeeded), ctypes.byref(nActual), arr, ctypes.byref(reason))
print("second RmGetList rc=", rc, "actual=", nActual.value)
for i in range(nActual.value):
p = arr[i]
print("HOLDER PID=", p.Process.dwProcessId, "app=", p.strAppName)
else:
print("NO PROCESS HOLDER (likely ACL/permission, not a lock)")
RmEndSession(session)
-238
View File
@@ -1,238 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.yunhai</groupId>
<artifactId>yunhaibase</artifactId>
<version>0.0.1</version>
<name>UserBase</name>
<description>用户中心</description>
<properties>
<java.version>1.8</java.version>
<cn.hutool.version>5.8.36</cn.hutool.version>
<easyexcel.version>3.1.2</easyexcel.version>
</properties>
<dependencyManagement>
<!-- 基础平台版本管理(含 yh-* 架包与 spring-boot-dependencies 对齐) -->
<dependencies>
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-platform-dependencies</artifactId>
<version>1.0.0-SNAPSHOT</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Spring Cloud Alibaba(Nacos 配置中心,对齐 Boot 2.7) -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2021.0.5.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- 仅引入 Spring Security 的加密模块(BCryptPasswordEncoder),不引入整套安全框架;密码加密与 CAS 校验共用 -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Nacos 配置中心(替代 Spring Cloud Config)。Spring Cloud Alibaba 2021.0.5 适配 Spring Boot 2.7 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!-- Boot 2.4+ 默认不再处理 bootstrap.yml,需显式引入以继续从 Nacos 拉取配置 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
<version>3.1.8</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Spring Cache 核心抽象(@Cacheable / @CachePut / @CacheEvict 注解支持) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- Spring Cache 抽象层(含 CaffeineCacheManager) -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
</dependency>
<!-- 本地内存缓存兜底:Caffeine(版本由 Boot 2.7 父 POM 统一管理) -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<!-- Jackson JSON 序列化(Redis value 序列化,解决泛型/乱码) -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Redisson 核心:分布式锁、限流器、Lua 复杂操作(与 Spring Cache 隔离,手动配置 RedissonClient 避免自动配置冲突) -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.80</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 核心基础工具 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-core</artifactId>
<version>${cn.hutool.version}</version>
</dependency>
<!-- Swagger / OpenAPI 文档(springdoc,兼容 Boot 2.7;替代旧 Springfox) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>1.7.0</version>
</dependency>
<!-- easypoi 导出相关 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>cn.afterturn</groupId>
<artifactId>easypoi-base</artifactId>
<version>4.1.0</version>
</dependency>
<!-- 基础平台架包:认证(CAS/会话)+ 应用间签名鉴权 -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-auth-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-app-auth-spring-boot-starter</artifactId>
</dependency>
<!-- CAS 客户端(浏览器 SSO 重定向 / ticket 校验 / getRemoteUser 包装)。
yh-auth 只读取 getRemoteUser,不负责跳转,由本依赖补齐标准 CAS 过滤器三件套。
版本 3.5.1 已在本地 .m2 仓库。 -->
<dependency>
<groupId>org.jasig.cas.client</groupId>
<artifactId>cas-client-core</artifactId>
<version>3.5.1</version>
</dependency>
</dependencies>
<build>
<finalName>userbase</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<!-- 关闭增量编译,避免 Windows 下 maven-status 状态文件被锁导致
"Error while storing the mojo status / 拒绝访问" -->
<useIncrementalCompilation>false</useIncrementalCompilation>
</configuration>
</plugin>
<!-- 打可执行 fat jar(Docker 镜像直接 java -jar 启动) -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.yunhai.userbase.UserBaseApplication</mainClass>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<!--
ci 构建档案:将构建产物重定向到 IDE(IntelliJ/Eclipse)索引目录之外,
避免 IDE 后台进程持续锁定 target/maven-status/.../createdFiles.lst 导致
"Error while storing the mojo status / 拒绝访问"。
另:输出目录需从未被 IDE 打开过,否则同一棵输出树下的 application.yml 等资源
会被 IDE 的 JPS 预加载进程持有句柄而写不进(拒绝访问)。常规本地构建无需激活此 profile。
说明:C:/ci-build 已被 IDE 锁过,现改用全新目录 C:/ub-build。
-->
<profiles>
<profile>
<id>ci</id>
<build>
<directory>C:/ub-build2/userbase</directory>
</build>
</profile>
</profiles>
</project>
@@ -1,37 +0,0 @@
package com.bestsch.openapi;
/**
* 迁移兼容 shim:原 bestsch 开放接口的"当前角色"载体。
* 仅保留 {@code Operator.of(...)} 所需的访问器,避免批量改动业务代码。
* 后续可替换为 UserBase 自有角色模型。
*/
public class UserSysRole {
private Integer userId;
private Integer schId;
private Integer roleId;
public Integer getUserId() {
return userId;
}
public void setUserId(Integer userId) {
this.userId = userId;
}
public Integer getSchId() {
return schId;
}
public void setSchId(Integer schId) {
this.schId = schId;
}
public Integer getRoleId() {
return roleId;
}
public void setRoleId(Integer roleId) {
this.roleId = roleId;
}
}
@@ -1,22 +0,0 @@
package com.bestsch.utils;
/**
* 迁移兼容 shim:原 bestsch 框架的业务异常类。
* UserBase 大量业务代码依赖此异常,迁移到基础平台期间保留原包名与常用构造签名,避免批量改动。
* 后续可统一替换为 UserBase 自有异常类型。
*/
public class DException extends RuntimeException {
public DException(String message) {
super(message);
}
public DException(String message, Throwable cause) {
super(message, cause);
}
/** 兼容历史调用方携带错误码的场景 */
public DException(String message, int code) {
super(message);
}
}
@@ -1,22 +0,0 @@
package com.yunhai.userbase;
import com.alibaba.fastjson.parser.ParserConfig;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@SpringBootApplication
@EnableTransactionManagement
@EnableAsync
@EnableScheduling
public class UserBaseApplication {
public static void main(String[] args) {
// 开启安全模式
ParserConfig.getGlobalInstance().setSafeMode(true);
SpringApplication.run(UserBaseApplication.class, args);
}
}
@@ -1,204 +0,0 @@
package com.yunhai.userbase.aspect;
import com.alibaba.fastjson.JSON;
import com.yunhai.userbase.common.annotation.OperationLog;
import com.yunhai.userbase.common.context.UserContext;
import com.yunhai.userbase.entity.SysOperationLog;
import com.yunhai.userbase.service.ISysOperationLogService;
import com.yunhai.userbase.utils.GzipUtil;
import com.yunhai.userbase.utils.IpUtils;
import com.yunhai.userbase.utils.OperationLogResolver;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
/**
* 操作日志切面。<br>
* 拦截所有 Controller 方法,按如下规则自动采集并落库:<br>
* - GET 请求:自动记录为「一般(查询)」,无需任何注解;<br>
* - 非 GET 请求:仅当方法标注 @OperationLog 时才记录(沿用原注解式行为);<br>
* - 排除日志查看接口自身(SysOperationLogController),避免递归与刷屏。<br>
* 响应结果 GZIP 压缩 + 截断后,在 finally 中异步落库,失败不影响主业务。<br>
*
* @author chenx
* @date 2026/7/28
*/
@Slf4j
@Aspect
@Component
public class OperationLogAspect {
/** 不记录日志查询/下载接口自身,避免递归与刷屏。 */
private static final String LOG_CONTROLLER = "com.yunhai.userbase.controller.api.SysOperationLogController";
/** GET 自动记录时使用的默认响应保存上限(10KB)。 */
private static final int DEFAULT_MAX_RESPONSE_SIZE = 10240;
@Autowired
private ISysOperationLogService sysOperationLogService;
@Around("execution(* com.yunhai.userbase.controller..*.*(..))")
public Object around(ProceedingJoinPoint point) throws Throwable {
// 排除日志查看接口自身
if (LOG_CONTROLLER.equals(point.getSignature().getDeclaringTypeName())) {
return point.proceed();
}
HttpServletRequest request = getRequest();
String httpMethod = request != null ? request.getMethod() : "";
boolean isGet = "GET".equalsIgnoreCase(httpMethod);
// 解析方法上的 @OperationLog 注解(可能为 null)
OperationLog operationLog = findAnnotation(point);
// 决策:GET 自动记录;非 GET 仅在有注解时记录
if (!isGet && operationLog == null) {
return point.proceed();
}
long start = System.currentTimeMillis();
SysOperationLog entity = buildEntity(point, request, httpMethod);
// GET 未标注注解时使用默认参数;标注了则优先用注解配置
boolean saveResponse = operationLog != null ? operationLog.saveResponse() : true;
int maxResponseSize = operationLog != null ? operationLog.maxResponseSize() : DEFAULT_MAX_RESPONSE_SIZE;
Object result = null;
try {
result = point.proceed();
entity.setStatusCode(200);
fillResponse(entity, result, saveResponse, maxResponseSize);
} catch (Throwable e) {
entity.setStatusCode(500);
entity.setErrorMsg(truncate(e.getMessage(), 2000));
throw e;
} finally {
entity.setCostTime(System.currentTimeMillis() - start);
sysOperationLogService.saveAsync(entity);
}
return result;
}
/**
* 从连接点解析方法上的 @OperationLog 注解。
*/
private OperationLog findAnnotation(ProceedingJoinPoint point) {
try {
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
return method.getAnnotation(OperationLog.class);
} catch (Exception e) {
return null;
}
}
/**
* 组装日志实体(基础字段 + 级别/类型判定)。
*/
private SysOperationLog buildEntity(ProceedingJoinPoint point, HttpServletRequest request, String httpMethod) {
SysOperationLog entity = new SysOperationLog();
Integer userId = UserContext.getCurrentUser();
entity.setUserId(userId != null ? userId.longValue() : 0L);
String username = request != null ? request.getRemoteUser() : null;
entity.setUsername(StringUtils.isNotBlank(username) ? username : "anonymous");
String requestUri = "";
if (request != null) {
entity.setRequestIp(IpUtils.getIpAddr(request));
requestUri = request.getRequestURI();
entity.setUserAgent(request.getHeader("User-Agent"));
entity.setRequestParams(buildRequestParams(point, request));
} else {
entity.setRequestParams(buildRequestParams(point, null));
}
entity.setRequestUri(requestUri);
entity.setRequestMethod(httpMethod);
String methodName = point.getSignature().getName();
entity.setLogLevel(OperationLogResolver.resolveLevel(httpMethod, requestUri, methodName).getCode());
entity.setOperationType(OperationLogResolver.resolveOperationType(httpMethod, requestUri, methodName));
entity.setClassMethod(point.getTarget().getClass().getName() + "." + methodName);
return entity;
}
/**
* 构建请求参数。<br>
* GET 优先使用 queryString;其余序列化方法入参,过滤掉请求/响应等不可序列化对象。<br>
*/
private String buildRequestParams(ProceedingJoinPoint point, HttpServletRequest request) {
try {
if (request != null && "GET".equalsIgnoreCase(request.getMethod())) {
String qs = request.getQueryString();
if (StringUtils.isNotBlank(qs)) {
return qs;
}
}
Object[] args = point.getArgs();
if (args == null || args.length == 0) {
return "";
}
List<Object> filtered = new ArrayList<Object>();
for (Object arg : args) {
if (arg == null) {
continue;
}
if (arg instanceof HttpServletRequest || arg instanceof HttpServletResponse
|| arg instanceof HttpSession) {
continue;
}
filtered.add(arg);
}
if (filtered.isEmpty()) {
return "";
}
return JSON.toJSONString(filtered);
} catch (Exception e) {
return "";
}
}
/**
* 写入响应结果(GZIP 压缩 + 截断)。
*/
private void fillResponse(SysOperationLog entity, Object result, boolean saveResponse, int maxResponseSize) {
if (!saveResponse || result == null) {
return;
}
try {
String resp = JSON.toJSONString(result);
if (resp.length() > maxResponseSize) {
resp = resp.substring(0, maxResponseSize) + "...[截断]";
}
entity.setResponseData(GzipUtil.compress(resp));
} catch (Exception e) {
log.warn("操作日志响应结果序列化失败, uri={}", entity.getRequestUri(), e);
}
}
private HttpServletRequest getRequest() {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return attrs != null ? attrs.getRequest() : null;
}
private String truncate(String text, int maxLen) {
if (text == null) {
return null;
}
return text.length() > maxLen ? text.substring(0, maxLen) : text;
}
}
@@ -1,69 +0,0 @@
package com.yunhai.userbase.common;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 全局异常处理器:仅处理「参数校验」类异常,把 Spring 默认抛出的冗长消息收敛成「一句话」返回,
* 避免前端收到整段 Validation failed ... 堆栈文本。<br>
* 返回 HTTP 200 + {@code {code:4, msg:..., data:null}} 结构(而非 HTTP 400),
* 与前端 alova errorHandle 的约定一致:status!=200 会被当作网络错误,code=4 才走「参数错误」分支。<br>
* 注意:本处理器【不】处理业务异常(如 {@code DException}),业务异常交由项目框架统一处理
* (返回 code=5、msg=异常消息),此处若拦截会覆盖框架行为。
*
* @author chenx
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
/** 参数校验失败的业务码,前端按「参数错误」处理 */
private static final int CODE_PARAM_ERROR = 4;
/** @RequestBody + @Validated/@Valid 失败:提取每个字段的 defaultMessage。 */
@ExceptionHandler(MethodArgumentNotValidException.class)
public Map<String, Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex) {
return paramError(fieldErrorsMessage(ex.getBindingResult().getFieldErrors()));
}
/** 普通 Bean 参数(如 SortChildren/Move 的 @Validated 请求体对象)校验失败。 */
@ExceptionHandler(BindException.class)
public Map<String, Object> handleBind(BindException ex) {
return paramError(fieldErrorsMessage(ex.getBindingResult().getFieldErrors()));
}
/** 方法级约束(@PathVariable/@RequestParam 上的 @NotNull 等)校验失败。 */
@ExceptionHandler(ConstraintViolationException.class)
public Map<String, Object> handleConstraintViolation(ConstraintViolationException ex) {
String msg = ex.getConstraintViolations().stream()
.map(ConstraintViolation::getMessage)
.collect(Collectors.joining(";"));
return paramError(msg);
}
private String fieldErrorsMessage(java.util.List<FieldError> fieldErrors) {
if (fieldErrors == null || fieldErrors.isEmpty()) {
return "参数校验失败";
}
return fieldErrors.stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(";"));
}
/** HTTP 200 + code/msg/data 结构体,前端根据 code 提示 msg */
private Map<String, Object> paramError(String msg) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("code", CODE_PARAM_ERROR);
body.put("msg", msg);
body.put("data", null);
return body;
}
}
@@ -1,35 +0,0 @@
package com.yunhai.userbase.common.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 操作日志注解。<br>
* 标注在 Controller 方法上,AOP 拦截后自动记录操作日志并异步落库。<br>
*
* @author chenx
* @date 2026/7/28
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface OperationLog {
/**
* 操作描述,可选。
*/
String value() default "";
/**
* 是否保存响应结果(GZIP 压缩后入库)。
*/
boolean saveResponse() default true;
/**
* 响应结果序列化后的最大字符数,超过则截断。默认 10KB。
*/
int maxResponseSize() default 10240;
}
@@ -1,18 +0,0 @@
package com.yunhai.userbase.common.context;
public class UserContext {
private static final ThreadLocal<Integer> CURRENT_USER = new ThreadLocal<>();
public static void setCurrentUser(Integer userId) {
CURRENT_USER.set(userId);
}
public static Integer getCurrentUser() {
return CURRENT_USER.get();
}
public static void clear() {
CURRENT_USER.remove();
}
}
@@ -1,18 +0,0 @@
package com.yunhai.userbase.common.converter;
import com.yunhai.userbase.enums.ClientEnum;
import javax.persistence.AttributeConverter;
/**
* ClientEnum 专用 JPA 转换器(int &lt;-&gt; 枚举)。<br>
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。
*
* @author chenx
*/
public class ClientEnumConverter extends CommonIntEnumConverter<ClientEnum> {
public ClientEnumConverter() {
super(ClientEnum.class);
}
}
@@ -1,47 +0,0 @@
package com.yunhai.userbase.common.converter;
import com.yunhai.userbase.enums.IEnum;
import javax.persistence.AttributeConverter;
/**
* 通用「int &lt;-&gt; 实现 IEnum&lt;Integer&gt; 的枚举」JPA 转换器抽象基类。<br>
* <p>
* <b>绑定机制(通用做法):</b>为每个枚举写一个极薄的子类,在构造器里传入枚举 class 即可完成绑定,
* 然后实体字段用 {@code @Convert(converter = XxxEnumConverter.class)} 引用该子类。例如:
* <pre>
* public class GroupTypeEnumConverter extends CommonIntEnumConverter&lt;GroupTypeEnum&gt; {
* public GroupTypeEnumConverter() { super(GroupTypeEnum.class); }
* }
* </pre>
* 这样同一套逻辑可服务任意枚举;又因为每个枚举拥有<b>独立的转换器实例</b>,
* 彼此之间不会串类型(标准 JPA 的 @Convert 是按 converter 类创建共享单例的,
* 若让多个不同枚举共用同一个泛型实例,则无法区分各自的枚举 class,故采用「每枚举一子类」的绑定方式)。
* </p>
*
* @param <E> 必须实现 IEnum&lt;Integer&gt; 的枚举类型
* @author chenx
*/
public abstract class CommonIntEnumConverter<E extends Enum<E> & IEnum<Integer>>
implements AttributeConverter<E, Integer> {
private final Class<E> enumCls;
protected CommonIntEnumConverter(Class<E> enumCls) {
this.enumCls = enumCls;
}
@Override
public Integer convertToDatabaseColumn(E enumVal) {
return enumVal == null ? null : enumVal.getKey();
}
@Override
public E convertToEntityAttribute(Integer dbVal) {
if (dbVal == null) {
return null;
}
// 读库安全:无匹配的枚举值返回 null(不抛异常,避免脏数据导致整棵树加载失败)
return IEnum.getByKey(enumCls, dbVal);
}
}
@@ -1,18 +0,0 @@
package com.yunhai.userbase.common.converter;
import com.yunhai.userbase.enums.GenderEnum;
import javax.persistence.AttributeConverter;
/**
* GenderEnum 专用 JPA 转换器(int &lt;-&gt; 枚举)。<br>
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。
*
* @author chenx
*/
public class GenderEnumConverter extends CommonIntEnumConverter<GenderEnum> {
public GenderEnumConverter() {
super(GenderEnum.class);
}
}
@@ -1,18 +0,0 @@
package com.yunhai.userbase.common.converter;
import com.yunhai.userbase.enums.GroupTypeEnum;
import javax.persistence.AttributeConverter;
/**
* GroupTypeEnum 专用 JPA 转换器(int &lt;-&gt; 枚举)。<br>
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可,无需重复转换逻辑。
*
* @author chenx
*/
public class GroupTypeEnumConverter extends CommonIntEnumConverter<GroupTypeEnum> {
public GroupTypeEnumConverter() {
super(GroupTypeEnum.class);
}
}
@@ -1,18 +0,0 @@
package com.yunhai.userbase.common.converter;
import com.yunhai.userbase.enums.IntegEnum;
import javax.persistence.AttributeConverter;
/**
* IntegEnum 专用 JPA 转换器(int &lt;-&gt; 枚举)。<br>
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。
*
* @author chenx
*/
public class IntegEnumConverter extends CommonIntEnumConverter<IntegEnum> {
public IntegEnumConverter() {
super(IntegEnum.class);
}
}

Some files were not shown because too many files have changed in this diff Show More