Compare commits
27
Commits
65e64cc717
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c172350200 | ||
|
|
5d7112ce31 | ||
|
|
670dfb0c7a | ||
|
|
abba079dde | ||
|
|
d3892320dd | ||
|
|
4f2ea26e8e | ||
|
|
e295ad8b96 | ||
|
|
99b8126b59 | ||
|
|
b472581622 | ||
|
|
8fe558f6c1 | ||
|
|
5e6df4b323 | ||
|
|
c9d5d678dc | ||
|
|
e21978c25c | ||
|
|
f9545a9d0f | ||
|
|
4d6a9307e5 | ||
|
|
b792f3f21b | ||
|
|
76a0f1ffbd | ||
|
|
70dd8e1d6b | ||
|
|
6e22b24996 | ||
|
|
1affcd9a5e | ||
|
|
b714f9851e | ||
|
|
3e0adfc0bf | ||
|
|
882d8f559c | ||
|
|
a604460542 | ||
|
|
f6e22cb670 | ||
|
|
2524a37a07 | ||
|
|
672569f199 |
@@ -0,0 +1,153 @@
|
|||||||
|
name: Signed Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
releases: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GITEA_BASE_URL: ${{ github.server_url }}
|
||||||
|
GITEA_REPOSITORY: ${{ github.repository }}
|
||||||
|
GITEA_ALLOW_INSECURE_HTTP: ${{ vars.ERP_RELEASE_ALLOW_INSECURE_HTTP }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
RELEASE_PRIVATE_KEY_B64: ${{ secrets.RELEASE_PRIVATE_KEY_B64 }}
|
||||||
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
|
steps:
|
||||||
|
- name: Check out source
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Java 17
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "17"
|
||||||
|
|
||||||
|
- name: Build, sign, and publish release
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
tag="${GITHUB_REF_NAME:?missing tag name}"
|
||||||
|
version="${tag#v}"
|
||||||
|
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] \
|
||||||
|
|| { printf 'invalid release tag: %s\n' "$tag" >&2; exit 2; }
|
||||||
|
|
||||||
|
api_base="${GITEA_BASE_URL:?missing GITEA_BASE_URL repository variable}"
|
||||||
|
repository="${GITEA_REPOSITORY:-awaioi/ERP}"
|
||||||
|
case "$api_base" in
|
||||||
|
https://*) curl_protocols=(--proto '=https' --proto-redir '=https') ;;
|
||||||
|
http://*)
|
||||||
|
[[ "${GITEA_ALLOW_INSECURE_HTTP:-0}" == "1" || "${GITEA_ALLOW_INSECURE_HTTP:-0}" == "true" ]] \
|
||||||
|
|| { printf 'GITEA_BASE_URL must use HTTPS\n' >&2; exit 1; }
|
||||||
|
curl_protocols=(--proto '=http,https' --proto-redir '=http,https')
|
||||||
|
;;
|
||||||
|
*) printf 'invalid GITEA_BASE_URL\n' >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
[[ "$repository" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] \
|
||||||
|
|| { printf 'invalid GITEA_REPOSITORY\n' >&2; exit 1; }
|
||||||
|
[[ -n "${GITEA_TOKEN:-}" ]] || { printf 'missing built-in GITEA_TOKEN\n' >&2; exit 1; }
|
||||||
|
[[ -n "${RELEASE_PRIVATE_KEY_B64:-}" ]] \
|
||||||
|
|| { printf 'missing RELEASE_PRIVATE_KEY_B64 secret\n' >&2; exit 1; }
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
key_file="${RUNNER_TEMP:-/tmp}/kaidi-erp-release-key.pem"
|
||||||
|
cleanup() { rm -f "$key_file" release.json release-payload.json release-notes.md; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
printf '%s' "$RELEASE_PRIVATE_KEY_B64" | base64 --decode > "$key_file"
|
||||||
|
export ERP_RELEASE_PRIVATE_KEY_FILE="$key_file"
|
||||||
|
bash tests/release-scripts.test.sh
|
||||||
|
(
|
||||||
|
cd oa-backend
|
||||||
|
./gradlew test installerTest
|
||||||
|
)
|
||||||
|
bash scripts/package-release.sh "$version"
|
||||||
|
|
||||||
|
previous_tag="$(git describe --tags --match 'v[0-9]*' --abbrev=0 "${tag}^" 2>/dev/null || true)"
|
||||||
|
change_range="$tag"
|
||||||
|
[[ -z "$previous_tag" ]] || change_range="$previous_tag..$tag"
|
||||||
|
{
|
||||||
|
printf '## 更新内容\n\n'
|
||||||
|
if ! git log --no-merges --format='- %s (`%h`)' "$change_range"; then
|
||||||
|
printf -- '- Kaidi ERP %s 正式发布\n' "$version"
|
||||||
|
fi
|
||||||
|
printf '\n## 安全校验\n\n'
|
||||||
|
printf -- '- 安装包:`kaidi-erp-%s.tar.gz`\n' "$version"
|
||||||
|
printf -- '- 完整性:SHA-256\n'
|
||||||
|
printf -- '- 发布签名:Ed25519\n'
|
||||||
|
if [[ -n "$previous_tag" ]]; then
|
||||||
|
printf '\n上一个正式版本:`%s`\n' "$previous_tag"
|
||||||
|
fi
|
||||||
|
} > release-notes.md
|
||||||
|
|
||||||
|
owner="${repository%%/*}"
|
||||||
|
repo="${repository#*/}"
|
||||||
|
release_api="${api_base%/}/api/v1/repos/$owner/$repo/releases"
|
||||||
|
auth_header="Authorization: token $GITEA_TOKEN"
|
||||||
|
status="$(curl "${curl_protocols[@]}" --silent --show-error --location \
|
||||||
|
--output release.json --write-out '%{http_code}' \
|
||||||
|
--header "$auth_header" --header 'Accept: application/json' \
|
||||||
|
"$release_api/tags/$tag")"
|
||||||
|
|
||||||
|
python3 - "$tag" release-notes.md > release-payload.json <<'PY'
|
||||||
|
import json, sys
|
||||||
|
tag = sys.argv[1]
|
||||||
|
with open(sys.argv[2], encoding="utf-8") as handle:
|
||||||
|
notes = handle.read().strip()
|
||||||
|
print(json.dumps({
|
||||||
|
"tag_name": tag,
|
||||||
|
"name": f"Kaidi ERP {tag}",
|
||||||
|
"body": notes,
|
||||||
|
"draft": False,
|
||||||
|
"prerelease": "-" in tag.split("+", 1)[0],
|
||||||
|
}, separators=(",", ":")))
|
||||||
|
PY
|
||||||
|
|
||||||
|
if [[ "$status" == "404" ]]; then
|
||||||
|
curl "${curl_protocols[@]}" --fail-with-body --silent --show-error --location --retry 3 \
|
||||||
|
--header "$auth_header" --header 'Content-Type: application/json' \
|
||||||
|
--data-binary @release-payload.json --output release.json "$release_api"
|
||||||
|
elif [[ "$status" == "200" ]]; then
|
||||||
|
release_id="$(python3 -c 'import json; print(json.load(open("release.json"))["id"])')"
|
||||||
|
curl "${curl_protocols[@]}" --fail-with-body --silent --show-error --location --retry 3 \
|
||||||
|
--request PATCH --header "$auth_header" --header 'Content-Type: application/json' \
|
||||||
|
--data-binary @release-payload.json --output release.json "$release_api/$release_id"
|
||||||
|
else
|
||||||
|
printf 'Gitea release lookup returned HTTP %s\n' "$status" >&2
|
||||||
|
cat release.json >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
release_id="$(python3 -c 'import json; print(json.load(open("release.json"))["id"])')"
|
||||||
|
curl "${curl_protocols[@]}" --fail-with-body --silent --show-error --location --retry 3 \
|
||||||
|
--header "$auth_header" --header 'Accept: application/json' \
|
||||||
|
--output release.json "$release_api/$release_id"
|
||||||
|
for asset in "dist/kaidi-erp-$version.tar.gz" "dist/kaidi-erp-installer-$version.jar" dist/SHA256SUMS dist/SHA256SUMS.sig; do
|
||||||
|
name="$(basename "$asset")"
|
||||||
|
encoded_name="$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$name")"
|
||||||
|
existing_id="$(python3 - "$name" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
release = json.load(open("release.json"))
|
||||||
|
print(next((item["id"] for item in release.get("assets", []) if item.get("name") == sys.argv[1]), ""))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
if [[ -n "$existing_id" ]]; then
|
||||||
|
curl "${curl_protocols[@]}" --fail-with-body --silent --show-error --location --retry 3 \
|
||||||
|
--request DELETE --header "$auth_header" \
|
||||||
|
"$release_api/$release_id/assets/$existing_id"
|
||||||
|
fi
|
||||||
|
curl "${curl_protocols[@]}" --fail-with-body --silent --show-error --location --retry 3 \
|
||||||
|
--request POST --header "$auth_header" \
|
||||||
|
--form "attachment=@$asset" \
|
||||||
|
"$release_api/$release_id/assets?name=$encoded_name" >/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
printf 'Published %s release %s\n' "$repository" "$tag"
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
# 凯迪 ERP + OA 一体化平台
|
||||||
|
|
||||||
|
凯迪 ERP + OA 是面向企业内部运营的综合业务平台,覆盖组织协同、审批、合同、采购、库存、财务、人力、项目、制造、运营、档案、审计和系统管理等工作域。前端采用 Vue 3 + TypeScript + Element Plus,后端采用 Spring Boot 3.2 + Spring Data JPA。
|
||||||
|
|
||||||
|
> 生产部署固定使用 PostgreSQL 15 或更高版本,不使用 Docker。源码目录保留的 SQLite 配置只用于本地开发和测试;正式 Release JAR 会排除 SQLite 驱动和社区方言。
|
||||||
|
|
||||||
|
## 项目现状
|
||||||
|
|
||||||
|
- 业务范围覆盖 29 个机构/部门,包含约 700 个 Vue 业务页面和大规模 REST/JPA 模型。
|
||||||
|
- 当前有效后端位于 `oa-backend/`。
|
||||||
|
- 当前有效前端位于 `ofbiz-framework/plugins/modern-ui/app/`;其上层旧 OFBiz 运行框架已经弃用。
|
||||||
|
- 前端构建产物直接写入后端 `src/main/resources/static/`,由同一个 Spring Boot JAR 提供页面与 `/api/oa/*` API。
|
||||||
|
- 已实现非 Docker 一键安装、PostgreSQL Flyway 迁移、独立网页安装向导、Gitea Release 更新、Ed25519/SHA-256 校验、服务重启、健康检查和自动回滚。
|
||||||
|
- 业务功能和外部系统接入仍需按项目需求验收;技术部署链路通过不等于可以跳过生产安全、备份和数据迁移评审。
|
||||||
|
|
||||||
|
## 两种运行模式
|
||||||
|
|
||||||
|
| 模式 | 数据库 | 用途 | 关键约束 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 源码开发 | SQLite 或 PostgreSQL | 本机开发、界面调试、自动化测试 | SQLite 仅为零配置开发选项,不代表在线环境 |
|
||||||
|
| 正式安装 | PostgreSQL 15+ | 服务器部署、在线更新 | 使用签名 Release、systemd/launchd,生产 JAR 不含 SQLite |
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```text
|
||||||
|
Browser / Mobile Web
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Spring Boot 3.2
|
||||||
|
|- Vue 3 static application
|
||||||
|
|- /api/oa/* REST API
|
||||||
|
|- authentication and permission gates
|
||||||
|
|- system update administration API
|
||||||
|
|
|
||||||
|
v
|
||||||
|
PostgreSQL 15+ <--- Flyway migrations
|
||||||
|
|
||||||
|
Administrator -> System Update UI -> erp-update helper
|
||||||
|
|- Gitea Release API
|
||||||
|
|- Ed25519 + SHA-256 verification
|
||||||
|
|- atomic current symlink switch
|
||||||
|
`- health check and rollback
|
||||||
|
```
|
||||||
|
|
||||||
|
## 仓库结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
|- README.md
|
||||||
|
|- run.command # macOS 本地预览与 ngrok 启动器
|
||||||
|
|- install.sh # 非 Docker 一键安装器
|
||||||
|
|- uninstall.sh # Linux 完整卸载与数据库 schema 重置
|
||||||
|
|- distribution/
|
||||||
|
| |- bin/erp-run # 正式环境应用启动器
|
||||||
|
| `- bin/erp-update # 下载、校验、切换和回滚助手
|
||||||
|
|- scripts/package-release.sh # PostgreSQL-only Release 打包与签名
|
||||||
|
|- .gitea/workflows/release.yml # v* tag 发布流水线
|
||||||
|
|- oa-backend/ # Spring Boot 后端
|
||||||
|
| `- src/main/resources/
|
||||||
|
| |- application.yml # 源码开发默认配置
|
||||||
|
| |- application-postgres.yml # 正式 PostgreSQL profile
|
||||||
|
| `- db/migration/postgresql/ # Flyway 迁移
|
||||||
|
|- ofbiz-framework/plugins/modern-ui/app/
|
||||||
|
| |- src/data/oaModules.ts # 导航与路由数据源
|
||||||
|
| `- src/oa/pages/ # Vue 业务页面
|
||||||
|
|- tests/ # 启动器和 Release 脚本测试
|
||||||
|
|- docs/ # 部署、设计和实现文档
|
||||||
|
`- requirements/ # 原始需求与合规审计材料
|
||||||
|
```
|
||||||
|
|
||||||
|
## 本地构建
|
||||||
|
|
||||||
|
克隆仓库并进入开发分支:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.awaioi.com/awaioi/ERP.git
|
||||||
|
cd ERP
|
||||||
|
git switch dev
|
||||||
|
git config core.hooksPath .githooks
|
||||||
|
```
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Java 17 或更高版本
|
||||||
|
- Node.js 和 npm
|
||||||
|
- Python 3、curl、lsof
|
||||||
|
- 使用公网预览时需要已登录并配置好的 ngrok 3
|
||||||
|
- PostgreSQL 模式需要 PostgreSQL 15+ 以及可连接的数据库账号
|
||||||
|
|
||||||
|
### 1. 构建前端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ofbiz-framework/plugins/modern-ui/app
|
||||||
|
npm ci
|
||||||
|
NODE_OPTIONS=--max-old-space-size=8192 npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite 会清空并重新生成 `oa-backend/src/main/resources/static/`。修改前端后必须先执行前端构建,再执行 `bootJar`,否则 JAR 中仍是旧页面。
|
||||||
|
|
||||||
|
### 2. 构建后端
|
||||||
|
|
||||||
|
开发构建保留 SQLite 运行库:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd oa-backend
|
||||||
|
./gradlew test
|
||||||
|
./gradlew bootJar
|
||||||
|
```
|
||||||
|
|
||||||
|
PostgreSQL-only 正式构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd oa-backend
|
||||||
|
./gradlew clean bootJar -PreleaseVersion=0.3.8 -PproductionBuild=true
|
||||||
|
```
|
||||||
|
|
||||||
|
正式 JAR 必须包含 PostgreSQL 驱动,并且不得包含 `sqlite-jdbc` 或 `hibernate-community-dialects`。
|
||||||
|
|
||||||
|
## 一键启动本地预览
|
||||||
|
|
||||||
|
根目录的 `run.command` 会检查环境、启动或复用 ERP 后端、启动或复用固定 ngrok 隧道、验证本地登录和公网页面,然后打开浏览器。它不会安装 PostgreSQL、构建前端或生成 JAR。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x run.command
|
||||||
|
./run.command
|
||||||
|
```
|
||||||
|
|
||||||
|
macOS 也可以在 Finder 中双击 `run.command`。终端保持运行用于监控服务,按 `Ctrl+C` 只会停止本次启动器自己创建的进程,不会批量终止其他 Java 进程。
|
||||||
|
|
||||||
|
常用覆盖项:
|
||||||
|
|
||||||
|
| 环境变量 | 默认值 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `ERP_RUN_BACKEND_PORT` | `8091` | 本地 Spring Boot 端口 |
|
||||||
|
| `ERP_RUN_JAR_PATH` | 最新的 `oa-backend-*.jar` | 指定要运行的 JAR |
|
||||||
|
| `ERP_RUN_PROFILE` | 当前环境的 Spring profile | 正式环境设为 `postgres` |
|
||||||
|
| `ERP_RUN_CONFIG_FILE` | 空 | 读取安装器生成的 `erp.env` |
|
||||||
|
| `ERP_RUN_PUBLIC_URL` | 固定 ngrok 域名 | 公网预览地址 |
|
||||||
|
| `ERP_RUN_NGROK_API_PORT` | `4040` | ngrok 本地管理端口 |
|
||||||
|
| `ERP_RUN_NO_OPEN` | `0` | 设为 `1` 时不自动打开浏览器 |
|
||||||
|
|
||||||
|
直接以 PostgreSQL profile 运行源码构建时:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export SPRING_PROFILES_ACTIVE=postgres
|
||||||
|
export OA_DB_URL='jdbc:postgresql://127.0.0.1:5432/kaidi_erp?sslmode=disable'
|
||||||
|
export OA_DB_USERNAME='kaidi_erp'
|
||||||
|
export OA_DB_PASSWORD='replace-with-a-strong-password'
|
||||||
|
./run.command
|
||||||
|
```
|
||||||
|
|
||||||
|
默认本地地址为 `http://127.0.0.1:8091`。开发预览固定域名为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://resonant-elated-launder.ngrok-free.dev/
|
||||||
|
```
|
||||||
|
|
||||||
|
演示环境初始账号为 `admin / 123456`。任何共享或生产环境都必须立即更换默认密码,并限制公网访问。
|
||||||
|
|
||||||
|
## 非 Docker 正式安装
|
||||||
|
|
||||||
|
安装器支持 64 位 Linux `amd64/arm64` 和 macOS `arm64/amd64`。Linux 安装需要 root;macOS 不应使用 sudo 启动 LaunchAgent。正式安装前必须已有包含下列四个资产的 Gitea Release:
|
||||||
|
|
||||||
|
- `kaidi-erp-<version>.tar.gz`
|
||||||
|
- `kaidi-erp-installer-<version>.jar`
|
||||||
|
- `SHA256SUMS`
|
||||||
|
- `SHA256SUMS.sig`
|
||||||
|
|
||||||
|
### Linux 一键安装
|
||||||
|
|
||||||
|
官方 Gitea 已启用 HTTPS,Linux 可直接执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.awaioi.com/awaioi/ERP/raw/branch/main/install.sh | sudo -E bash
|
||||||
|
```
|
||||||
|
|
||||||
|
安装器默认使用 `https://git.awaioi.com` 和 `awaioi/ERP`;私有镜像或分叉仓库仍可通过 `--gitea-url` 和 `--repository` 覆盖。
|
||||||
|
|
||||||
|
命令行只做环境准备:优先使用已有的 Java 17+,缺少时通过当前系统的 `apt-get`、`dnf`、`yum`、`zypper` 或 Homebrew 安装 Java、curl、tar、Python 3 和 OpenSSL 3,然后下载并启动独立安装器。数据库信息不在命令行输入。
|
||||||
|
|
||||||
|
Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL 或精简系统只能显式使用 `--no-service` 做开发验收,在线更新也会保持关闭。
|
||||||
|
|
||||||
|
安装器启动后会输出带一次性令牌的访问地址。优先级依次为:命令行 `--public-url`(或 `ERP_PUBLIC_URL`)、HTTPS 服务探测到的公网 IP、局域网 IP。无论使用哪一种方式,都会同时输出仅服务器本机可用的 `Local URL`;公网探测失败时还会明确提示正在回退局域网地址。公网服务器建议显式传入地址,避免 NAT、多网卡或代理环境识别错误:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Setup URL: https://erp.example.com/?token=<one-time-token>
|
||||||
|
Local URL: http://127.0.0.1:8091/?token=<one-time-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
`--public-url` 支持域名、端口、路径和已有查询参数,安装器会安全追加一次性 `token`。使用公网 IP 直连时需要在防火墙或安全组放行 ERP 端口;通过 HTTPS 反向代理安装时,应将公开域名作为 `--public-url`。
|
||||||
|
|
||||||
|
首次打开该地址进入网页向导,依次完成环境检查、PostgreSQL 地址/端口/库名/账号/密码/SSL 测试、管理员账号/姓名/密码设置、数据库迁移和初始化。项目当前没有 Redis 依赖,因此向导不会显示 Redis 配置项。正式服务真实健康检查通过后,启动器才会原子写入安装锁并物理删除 `installer/` 和 `install.pending`。
|
||||||
|
|
||||||
|
PostgreSQL 必须使用专用空数据库,网页中填写的账号必须是该数据库的所有者。该约束保证账号拥有 `public` schema 建表权限,并能持有安装器创建的 `pg_trgm` 扩展;只授予 `CONNECT` 权限不足以完成迁移。
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
macOS 需要 Homebrew,并使用已有 PostgreSQL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.awaioi.com/awaioi/ERP/raw/branch/main/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
### 当前正式版本
|
||||||
|
|
||||||
|
需要固定安装 `v0.3.8` 并验证引导脚本 SHA-256 时:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(
|
||||||
|
set -e
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
trap 'rm -f -- "$tmp"' EXIT
|
||||||
|
curl -fsSL https://git.awaioi.com/awaioi/ERP/raw/tag/v0.3.8/install.sh -o "$tmp"
|
||||||
|
printf '%s %s\n' 'ae6a6613d4abe37ba24b41e1901eaa3b29cc32939583302c7f62277922fbbe9b' "$tmp" | sha256sum -c -
|
||||||
|
sudo -E bash "$tmp" --version 0.3.8
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
只有 `v0.3.8` Release 发布后这条命令才可下载安装包。引导脚本通过固定 tag 和 SHA-256 校验,Release 资产继续执行 Ed25519 和 SHA-256 双重校验。
|
||||||
|
|
||||||
|
### 完整卸载后重装
|
||||||
|
|
||||||
|
以下命令具有破坏性:它会先停止服务,使用现有配置中的 ERP 数据库账号删除并重建目标数据库的 `public` schema,然后删除 systemd unit、程序、配置、状态和日志。脚本只允许数据库所有者执行 schema 清理,并拒绝 `postgres`、`template0`、`template1` 和危险文件路径。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(
|
||||||
|
set -e
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
trap 'rm -f -- "$tmp"' EXIT
|
||||||
|
curl -fsSL https://git.awaioi.com/awaioi/ERP/raw/tag/v0.3.8/uninstall.sh -o "$tmp"
|
||||||
|
printf '%s %s\n' '98c56fed2fd4d01874e4ab5a1a4f3ec42ec3e29b315ffd87385a95488d587546' "$tmp" | sha256sum -c -
|
||||||
|
sudo -E bash "$tmp" --purge-database --yes
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
卸载器会先核对安装配置、路径和 systemd 停止状态,再清理数据库和文件。卸载成功后,再执行上面的 Linux 一键安装命令。不要对包含其他系统数据的共享数据库运行此命令。
|
||||||
|
|
||||||
|
### 正式安装目录
|
||||||
|
|
||||||
|
Linux 默认路径:
|
||||||
|
|
||||||
|
| 内容 | 路径 |
|
||||||
|
|---|---|
|
||||||
|
| 程序和版本目录 | `/opt/kaidi-erp` |
|
||||||
|
| 环境配置 | `/etc/kaidi-erp/erp.env` |
|
||||||
|
| 更新状态 | `/var/lib/kaidi-erp/update-state.json` |
|
||||||
|
| 安装状态 | `/var/lib/kaidi-erp/install.pending`、`/var/lib/kaidi-erp/install.lock` |
|
||||||
|
| 首次安装器 | `/opt/kaidi-erp/installer/`(健康后自动删除) |
|
||||||
|
| systemd 服务 | `kaidi-erp.service` |
|
||||||
|
|
||||||
|
使用 `--no-service` 只用于开发验收:启动器会在当前用户下运行,但默认设置 `OA_UPDATE_ENABLED=false`。生产环境应使用 systemd/launchd,让在线更新可以在 Java 进程退出后自动拉起新版本。
|
||||||
|
|
||||||
|
### HTTPS 反向代理
|
||||||
|
|
||||||
|
正式安装默认监听 `8091`(可通过 `ERP_SERVER_PORT` 覆盖)。Nginx、宝塔、Caddy 或 CDN 终止 HTTPS 后,必须把公网协议和主机转发给 Spring Boot;否则浏览器的同源 API 请求会被误判为跨域,并收到纯文本 `403 Invalid CORS request`,前端表现为“响应非 JSON (HTTP 403)”。Nginx 的代理位置至少包含:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8091;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 Nginx 前面还有 Cloudflare 等上游代理,`X-Forwarded-Proto` 必须保留浏览器实际使用的 `https`,不能被内层 HTTP 链路覆盖。前端与 `/api/oa/*` 推荐使用同一个公网域名;不要在 Nginx 中附加 `Access-Control-Allow-Origin *`。
|
||||||
|
|
||||||
|
## 在线更新与回滚
|
||||||
|
|
||||||
|
管理员登录后可从以下任一入口进入:
|
||||||
|
|
||||||
|
```text
|
||||||
|
顶部工具栏 -> 系统更新
|
||||||
|
用户菜单 -> 系统更新
|
||||||
|
手机导航抽屉 -> 系统更新
|
||||||
|
应用定制平台 -> 系统更新
|
||||||
|
```
|
||||||
|
|
||||||
|
入口只对 `ADMIN` 角色显示,对应前端路由为 `/appdev/update`,后端 API 为 `/api/oa/system-update/*`。页面只显示当前版本、在线最新版本、检查时间、最新版本更新日志、历史正式版本,以及下载、验签、安装、重启和自动回滚进度。顶部和手机入口发现新版本时会显示版本提示。
|
||||||
|
|
||||||
|
更新源由安装器写入服务器的 `/etc/kaidi-erp/erp.env`,后台页面不会要求管理员重复填写 Gitea 地址、仓库、Token、通道或 HTTP 开关。`v0.3.8` 启动时会把旧官方地址 `http://38.76.196.225:10099` 自动迁移到 `https://git.awaioi.com`;其他自定义地址保持不变。需要变更基础设施配置时由服务器运维人员修改 `OA_UPDATE_*` 环境变量并重启服务;公开仓库无需 Token,正式环境应使用 HTTPS。
|
||||||
|
|
||||||
|
更新过程如下:
|
||||||
|
|
||||||
|
1. 从 Gitea 读取 stable channel 的最新 Release。
|
||||||
|
2. 下载归档、`SHA256SUMS` 和 Ed25519 签名(Release 里的独立安装器资产只用于首次安装)。
|
||||||
|
3. 先验证签名和 SHA-256,再拒绝路径穿越、绝对路径、符号链接、硬链接和结构不完整的归档。
|
||||||
|
4. 校验 `manifest.json` 中的版本、`database=postgresql` 和 `rollbackCompatible=true`。
|
||||||
|
5. 可选执行 `pg_dump`,将新版本写入独立目录。
|
||||||
|
6. 原子切换 `current` 符号链接,并停止旧 Java 进程。
|
||||||
|
7. systemd/launchd 拉起新版本,更新助手等待新的 PID 和 `/api/oa/health`;Linux unit 使用 `KillMode=process`,确保更新助手不会随旧 Java 进程一起被 systemd 清理。
|
||||||
|
8. 新版本不健康时切回上一链接,终止故障进程并再次验证旧版本健康状态。
|
||||||
|
|
||||||
|
同一安装目录使用操作系统文件锁,不能并发执行两个更新任务。也可以手工触发:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/opt/kaidi-erp/current/bin/erp-update install 0.3.8
|
||||||
|
```
|
||||||
|
|
||||||
|
应用回滚不等于数据库回滚。包含不可逆 Flyway 迁移的版本必须先保证旧应用仍兼容新结构,并建议在安装配置中启用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ERP_UPDATE_BACKUP_MODE=pg_dump
|
||||||
|
```
|
||||||
|
|
||||||
|
更新助手不会自动覆盖生产数据库。需要恢复数据库时,应由管理员确认后使用 `pg_restore`。
|
||||||
|
|
||||||
|
## Gitea Release 发布
|
||||||
|
|
||||||
|
推送 `v*` tag 会触发 `.gitea/workflows/release.yml`。流水线会先执行 shell、后端和独立安装器测试,再构建前端、生成 PostgreSQL-only JAR、打包、签名,并创建或更新对应 Gitea Release。
|
||||||
|
|
||||||
|
### Actions 前置配置
|
||||||
|
|
||||||
|
Gitea 1.27 仓库需要启用 Actions,并配置带 `ubuntu-latest` 标签的在线 Runner。Runner 必须提供:
|
||||||
|
|
||||||
|
- Java 17+
|
||||||
|
- Node.js 和 npm
|
||||||
|
- Python 3
|
||||||
|
- curl、tar
|
||||||
|
- OpenSSL 3,且支持 Ed25519
|
||||||
|
|
||||||
|
流水线使用 Gitea 内置短期 `GITEA_TOKEN`,权限限定为代码只读、当前仓库 Release 可写。仓库设置只需添加:
|
||||||
|
|
||||||
|
- Secret `RELEASE_PRIVATE_KEY_B64`
|
||||||
|
- `ERP_RELEASE_ALLOW_INSECURE_HTTP` 保持未设置;正式 Release API 和资产下载固定使用 HTTPS
|
||||||
|
|
||||||
|
签名私钥不得提交到 Git。生成 Secret 值:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
base64 < ~/.config/kaidi-erp/release-signing-key.pem | tr -d '\n'
|
||||||
|
```
|
||||||
|
|
||||||
|
发布稳定版本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git switch main
|
||||||
|
git pull --ff-only origin main
|
||||||
|
git tag -a v0.3.8 -m 'Kaidi ERP v0.3.8'
|
||||||
|
git push origin v0.3.8
|
||||||
|
```
|
||||||
|
|
||||||
|
发布完成后必须确认 Release 页面存在四个资产,并使用仓库中的 `distribution/release-public-key.pem` 验证签名。私钥与该公钥不匹配时打包脚本会直接失败。
|
||||||
|
|
||||||
|
本地手工生成签名资产:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ERP_RELEASE_PRIVATE_KEY_FILE="$HOME/.config/kaidi-erp/release-signing-key.pem" \
|
||||||
|
bash scripts/package-release.sh 0.3.8
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置参考
|
||||||
|
|
||||||
|
| 环境变量 | 说明 | 默认值 |
|
||||||
|
|---|---|---|
|
||||||
|
| `SPRING_PROFILES_ACTIVE` | 正式环境必须为 `postgres` | 源码默认 profile |
|
||||||
|
| `OA_DB_URL` | JDBC PostgreSQL URL | 正式环境必填 |
|
||||||
|
| `OA_DB_USERNAME` | PostgreSQL 用户 | 正式环境必填 |
|
||||||
|
| `OA_DB_PASSWORD` | PostgreSQL 密码 | 正式环境必填 |
|
||||||
|
| `OA_DB_POOL_MAX` | 最大连接池 | `20` |
|
||||||
|
| `OA_DB_POOL_MIN` | 最小空闲连接 | `2` |
|
||||||
|
| `OA_UPDATE_ENABLED` | 启用管理后台在线更新 | 安装服务时为 `true` |
|
||||||
|
| `OA_UPDATE_GITEA_BASE_URL` | Gitea 外部地址 | `https://git.awaioi.com` |
|
||||||
|
| `OA_UPDATE_REPOSITORY` | Release 仓库 | `awaioi/ERP` |
|
||||||
|
| `OA_UPDATE_CHANNEL` | 更新通道 | `stable` |
|
||||||
|
| `OA_UPDATE_TOKEN` | 私有仓库下载令牌 | 空;公开仓库不需要 |
|
||||||
|
| `OA_UPDATE_ALLOW_INSECURE_HTTP` | 允许 HTTP 更新地址 | `false` |
|
||||||
|
| `ERP_PUBLIC_URL` | 首次安装向导的公网 URL,等价于 `--public-url` | 自动探测公网 IP |
|
||||||
|
| `OA_SEED_DEMO` | 是否生成演示数据 | 正式安装为 `false` |
|
||||||
|
| `ERP_UPDATE_BACKUP_MODE` | 更新前数据库备份 | `none`,可设 `pg_dump` |
|
||||||
|
| `ERP_UPDATE_HEALTH_TIMEOUT_SECONDS` | 新旧版本健康检查超时 | `120` |
|
||||||
|
| `ERP_UPDATE_HEALTH_POLL_SECONDS` | 健康轮询间隔 | `2` |
|
||||||
|
|
||||||
|
## 测试与验收
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd oa-backend
|
||||||
|
./gradlew test
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端类型检查与构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ofbiz-framework/plugins/modern-ui/app
|
||||||
|
npm ci
|
||||||
|
NODE_OPTIONS=--max-old-space-size=8192 npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动器与发布脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash tests/run-command.test.sh
|
||||||
|
bash tests/release-scripts.test.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 已运行服务的 OA 冒烟和集成测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OA=http://127.0.0.1:8091 bash oa-smoke.sh
|
||||||
|
OA=http://127.0.0.1:8091 bash oa-itest.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
测试脚本会创建业务测试数据,应使用测试数据库,不要直接对生产数据库运行写入型集成测试。
|
||||||
|
|
||||||
|
## 安全与运维注意事项
|
||||||
|
|
||||||
|
- 正式 Gitea、安装和更新流量必须使用 HTTPS;`--allow-insecure` 仅限隔离测试环境。
|
||||||
|
- `RELEASE_PRIVATE_KEY_B64` 和 PostgreSQL 密码不得写入 Git、日志或聊天记录。
|
||||||
|
- 新增返回金额、个人信息或机密汇总的 API 时,必须复核 `AuthInterceptor` 的敏感读取权限前缀。
|
||||||
|
- 金额字段和计算统一使用 `BigDecimal`,禁止使用 `double` 处理货币。
|
||||||
|
- 不要使用 `pkill java` 或 `killall java`;只停止明确属于本项目的 PID。
|
||||||
|
- 修改前端后始终先执行前端构建,再打包后端。
|
||||||
|
- 每次包含数据库结构变更的 Release 都必须新增 Flyway 迁移并做新库首次迁移、旧库升级和回滚兼容性验证。
|
||||||
|
- 默认演示账号只能用于开发环境,生产环境需要更换密码并实施最小权限、备份、监控和审计策略。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 安装器提示没有 Release
|
||||||
|
|
||||||
|
Gitea 仓库尚未发布首个可安装版本,或 Release 缺少四个必需资产。先检查 `/api/v1/repos/awaioi/ERP/releases/latest` 和 Release 页面。
|
||||||
|
|
||||||
|
### 如何填写数据库
|
||||||
|
|
||||||
|
数据库连接信息只在首次网页向导填写。安装器会执行 `SELECT 1`、检查 PostgreSQL 15+ 并创建/验证 `pg_trgm`,错误凭据不会启动正式应用,也不会把密码写入响应或日志。数据库中已经有业务用户时,安装器会拒绝接管。
|
||||||
|
|
||||||
|
### PostgreSQL profile 启动失败
|
||||||
|
|
||||||
|
确认 `SPRING_PROFILES_ACTIVE=postgres`,并检查 `OA_DB_URL`、`OA_DB_USERNAME`、`OA_DB_PASSWORD`。正式 profile 会执行 Flyway,再使用 Hibernate `ddl-auto=validate` 校验实体与数据库结构。
|
||||||
|
|
||||||
|
### `run.command` 提示端口被占用
|
||||||
|
|
||||||
|
启动器只会复用能够通过项目健康检查的监听进程。其他程序占用端口时不会被自动终止;请停止对应程序或设置新的 `ERP_RUN_BACKEND_PORT`。
|
||||||
|
|
||||||
|
### ngrok 无法启动
|
||||||
|
|
||||||
|
运行 `ngrok config check` 确认配置有效,并检查固定域名是否属于当前 ngrok 账号。可以通过 `ERP_RUN_NGROK_BIN`、`ERP_RUN_NGROK_CONFIG` 和 `ERP_RUN_NGROK_API_PORT` 覆盖路径与端口。
|
||||||
|
|
||||||
|
### 反向代理后提示“响应非 JSON (HTTP 403)”
|
||||||
|
|
||||||
|
应用自身的 401/403 权限错误始终是 JSON。该提示表示 Nginx、WAF 或 Spring CORS 层提前返回了纯文本/HTML。先确认代理目标为正式安装端口(默认 `http://127.0.0.1:8091`),再按“HTTPS 反向代理”一节补齐 `Host` 和 `X-Forwarded-*` 请求头;响应正文为 `Invalid CORS request` 时即可确认是协议/主机转发不完整。
|
||||||
|
|
||||||
|
### 签名验证失败
|
||||||
|
|
||||||
|
不要跳过验证。确认 Release 的四个资产来自同一次构建、`SHA256SUMS` 未被改写、签名私钥与 `distribution/release-public-key.pem` 匹配。
|
||||||
|
|
||||||
|
## Git 协作
|
||||||
|
|
||||||
|
- `dev` 是日常开发分支。
|
||||||
|
- `main` 只接收已经通过构建、测试、启动和冒烟验证的稳定提交。
|
||||||
|
- 新环境执行 `git config core.hooksPath .githooks` 启用仓库 hooks。
|
||||||
|
- 不要在脏工作区执行破坏性 reset;先确认哪些修改属于正在进行的开发。
|
||||||
|
|
||||||
|
远程仓库:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://git.awaioi.com/awaioi/ERP.git
|
||||||
|
```
|
||||||
|
|
||||||
|
## 延伸文档
|
||||||
|
|
||||||
|
- [在线安装与更新](docs/online-install-and-update.md)
|
||||||
|
- [项目交接文档](go.md)
|
||||||
|
- [设计原则](DESIGN.md)
|
||||||
|
- [后端 API](oa-backend/API.md)
|
||||||
|
- [后端安全说明](oa-backend/SECURITY.md)
|
||||||
|
- [端点目录](go-endpoints.md)
|
||||||
|
- [实体目录](go-entities.md)
|
||||||
|
- [数据库目录](go-database.md)
|
||||||
Executable
+209
@@ -0,0 +1,209 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||||
|
INSTALL_ROOT="${ERP_INSTALL_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd -P)}"
|
||||||
|
CONFIG_FILE="${ERP_CONFIG_FILE:-$INSTALL_ROOT/config/erp.env}"
|
||||||
|
|
||||||
|
say() { printf '[ERP] %s\n' "$*"; }
|
||||||
|
fail() { printf '[ERP] ERROR: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
migrate_legacy_update_source() {
|
||||||
|
local legacy_url='http://38.76.196.225:10099'
|
||||||
|
local official_url='https://git.awaioi.com'
|
||||||
|
local configured_url="${OA_UPDATE_GITEA_BASE_URL:-}"
|
||||||
|
[[ "${configured_url%/}" == "$legacy_url" ]] || return 0
|
||||||
|
|
||||||
|
local temporary
|
||||||
|
temporary="$(mktemp "${CONFIG_FILE}.migration.XXXXXX")" \
|
||||||
|
|| fail 'unable to create the update-source migration file'
|
||||||
|
if awk -v official_url="$official_url" '
|
||||||
|
BEGIN { base_url_written = 0; insecure_written = 0 }
|
||||||
|
/^OA_UPDATE_GITEA_BASE_URL=/ {
|
||||||
|
if (!base_url_written) print "OA_UPDATE_GITEA_BASE_URL=\047" official_url "\047"
|
||||||
|
base_url_written = 1
|
||||||
|
next
|
||||||
|
}
|
||||||
|
/^OA_UPDATE_ALLOW_INSECURE_HTTP=/ {
|
||||||
|
if (!insecure_written) print "OA_UPDATE_ALLOW_INSECURE_HTTP=\047false\047"
|
||||||
|
insecure_written = 1
|
||||||
|
next
|
||||||
|
}
|
||||||
|
{ print }
|
||||||
|
END {
|
||||||
|
if (!base_url_written) print "OA_UPDATE_GITEA_BASE_URL=\047" official_url "\047"
|
||||||
|
if (!insecure_written) print "OA_UPDATE_ALLOW_INSECURE_HTTP=\047false\047"
|
||||||
|
}
|
||||||
|
' "$CONFIG_FILE" > "$temporary" && chmod 600 "$temporary" && mv -f "$temporary" "$CONFIG_FILE"; then
|
||||||
|
say "Update source migrated to $official_url"
|
||||||
|
else
|
||||||
|
rm -f "$temporary"
|
||||||
|
say "WARNING: could not persist the update source migration; using $official_url for this process"
|
||||||
|
fi
|
||||||
|
|
||||||
|
OA_UPDATE_GITEA_BASE_URL="$official_url"
|
||||||
|
OA_UPDATE_ALLOW_INSECURE_HTTP=false
|
||||||
|
export OA_UPDATE_GITEA_BASE_URL OA_UPDATE_ALLOW_INSECURE_HTTP
|
||||||
|
}
|
||||||
|
|
||||||
|
load_configuration() {
|
||||||
|
[[ -r "$CONFIG_FILE" ]] || fail "configuration not readable: $CONFIG_FILE"
|
||||||
|
set -a
|
||||||
|
# The installer writes this file with POSIX shell-safe single-quoted values.
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$CONFIG_FILE"
|
||||||
|
set +a
|
||||||
|
migrate_legacy_update_source
|
||||||
|
|
||||||
|
RUN_DIR="${ERP_RUN_DIR:-$INSTALL_ROOT/run}"
|
||||||
|
JAR_PATH="${ERP_JAR_PATH:-$INSTALL_ROOT/current/app/kaidi-erp.jar}"
|
||||||
|
INSTALLER_JAR="${ERP_INSTALLER_JAR:-$INSTALL_ROOT/installer/kaidi-erp-installer.jar}"
|
||||||
|
PENDING_FILE="${ERP_INSTALL_PENDING_FILE:-$INSTALL_ROOT/state/install.pending}"
|
||||||
|
INSTALL_LOCK_FILE="${ERP_INSTALL_LOCK_FILE:-$INSTALL_ROOT/state/install.lock}"
|
||||||
|
INSTALL_LOG_FILE="${ERP_INSTALL_LOG_FILE:-$(dirname "$PENDING_FILE")/install-formal.log}"
|
||||||
|
HEALTH_URL="${ERP_HEALTH_URL:-http://127.0.0.1:${SERVER_PORT:-8091}/api/oa/health}"
|
||||||
|
HEALTH_TIMEOUT_SECONDS="${ERP_INSTALL_HEALTH_TIMEOUT_SECONDS:-240}"
|
||||||
|
HEALTH_POLL_SECONDS="${ERP_UPDATE_HEALTH_POLL_SECONDS:-2}"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_java() {
|
||||||
|
JAVA_BIN="${ERP_JAVA_BIN:-}"
|
||||||
|
if [[ -z "$JAVA_BIN" ]]; then
|
||||||
|
if [[ -n "${JAVA_HOME:-}" && -x "$JAVA_HOME/bin/java" ]]; then
|
||||||
|
JAVA_BIN="$JAVA_HOME/bin/java"
|
||||||
|
else
|
||||||
|
JAVA_BIN="$(command -v java || true)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[[ -n "$JAVA_BIN" && -x "$JAVA_BIN" ]] || fail 'Java 17+ is not installed'
|
||||||
|
local java_major
|
||||||
|
java_major="$("$JAVA_BIN" -version 2>&1 | awk -F'[".]' '/version/ {print $2; exit}')"
|
||||||
|
[[ "$java_major" =~ ^[0-9]+$ && "$java_major" -ge 17 ]] || fail 'Java 17+ is required'
|
||||||
|
}
|
||||||
|
|
||||||
|
split_java_options() {
|
||||||
|
IFS=$' \t' read -r -a JAVA_OPTS <<< "${ERP_JAVA_OPTS:--Xms512m -Xmx2g}"
|
||||||
|
IFS=$' \t' read -r -a INSTALLER_JAVA_OPTS <<< "${ERP_INSTALLER_JAVA_OPTS:--Xms128m -Xmx512m}"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_pid() {
|
||||||
|
mkdir -p "$RUN_DIR"
|
||||||
|
printf '%s\n' "$1" > "$RUN_DIR/app.pid"
|
||||||
|
}
|
||||||
|
|
||||||
|
clear_pid() {
|
||||||
|
local expected="$1"
|
||||||
|
if [[ -r "$RUN_DIR/app.pid" && "$(<"$RUN_DIR/app.pid")" == "$expected" ]]; then
|
||||||
|
rm -f "$RUN_DIR/app.pid"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_formal_application_direct() {
|
||||||
|
[[ -r "$JAR_PATH" ]] || fail "application jar not readable: $JAR_PATH"
|
||||||
|
write_pid "$$"
|
||||||
|
exec "$JAVA_BIN" "${JAVA_OPTS[@]}" \
|
||||||
|
-jar "$JAR_PATH" \
|
||||||
|
--spring.profiles.active=postgres \
|
||||||
|
--server.port="${SERVER_PORT:-8091}"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_web_installer() {
|
||||||
|
[[ -r "$INSTALLER_JAR" ]] || fail "installer jar not readable: $INSTALLER_JAR"
|
||||||
|
say "Starting first-run installer on port ${SERVER_PORT:-8091}"
|
||||||
|
printf '%s\n' "$$" > "$RUN_DIR/installer.pid"
|
||||||
|
set +e
|
||||||
|
"$JAVA_BIN" "${INSTALLER_JAVA_OPTS[@]}" \
|
||||||
|
-jar "$INSTALLER_JAR" \
|
||||||
|
--server.port="${SERVER_PORT:-8091}"
|
||||||
|
local status=$?
|
||||||
|
set -e
|
||||||
|
rm -f "$RUN_DIR/installer.pid"
|
||||||
|
[[ "$status" == "0" ]] || fail "web installer exited with status $status"
|
||||||
|
[[ -f "$PENDING_FILE" ]] || fail 'web installer exited before installation was completed'
|
||||||
|
}
|
||||||
|
|
||||||
|
finalize_installation() {
|
||||||
|
[[ -f "$PENDING_FILE" ]] || fail "pending installation marker is missing: $PENDING_FILE"
|
||||||
|
[[ ! -e "$INSTALL_LOCK_FILE" ]] || fail "installation lock already exists: $INSTALL_LOCK_FILE"
|
||||||
|
mkdir -p "$(dirname "$INSTALL_LOCK_FILE")"
|
||||||
|
mv "$PENDING_FILE" "$INSTALL_LOCK_FILE"
|
||||||
|
chmod 600 "$INSTALL_LOCK_FILE"
|
||||||
|
rm -rf "$INSTALL_ROOT/installer"
|
||||||
|
say "Installation lock written; the web installer has been removed"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_pending_formal_application() {
|
||||||
|
[[ -r "$JAR_PATH" ]] || fail "application jar not readable: $JAR_PATH"
|
||||||
|
[[ "$HEALTH_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]] || fail 'invalid installation health timeout'
|
||||||
|
[[ "$HEALTH_POLL_SECONDS" =~ ^[1-9][0-9]*$ ]] || fail 'invalid health poll interval'
|
||||||
|
|
||||||
|
say 'Starting the formal PostgreSQL application'
|
||||||
|
mkdir -p "$(dirname "$INSTALL_LOG_FILE")"
|
||||||
|
: > "$INSTALL_LOG_FILE"
|
||||||
|
chmod 600 "$INSTALL_LOG_FILE"
|
||||||
|
"$JAVA_BIN" "${JAVA_OPTS[@]}" \
|
||||||
|
-jar "$JAR_PATH" \
|
||||||
|
--spring.profiles.active=postgres \
|
||||||
|
--server.port="${SERVER_PORT:-8091}" \
|
||||||
|
> >(tee -a "$INSTALL_LOG_FILE") 2>&1 &
|
||||||
|
APP_PID=$!
|
||||||
|
write_pid "$APP_PID"
|
||||||
|
|
||||||
|
forward_signal() {
|
||||||
|
kill -TERM "$APP_PID" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap forward_signal TERM INT
|
||||||
|
|
||||||
|
local deadline=$((SECONDS + HEALTH_TIMEOUT_SECONDS))
|
||||||
|
while (( SECONDS < deadline )); do
|
||||||
|
if ! kill -0 "$APP_PID" 2>/dev/null; then
|
||||||
|
set +e
|
||||||
|
wait "$APP_PID"
|
||||||
|
local status=$?
|
||||||
|
set -e
|
||||||
|
clear_pid "$APP_PID"
|
||||||
|
say "Formal application diagnostics: $INSTALL_LOG_FILE"
|
||||||
|
fail "formal application exited before becoming healthy (status $status)"
|
||||||
|
fi
|
||||||
|
if curl -fsS --connect-timeout 2 --max-time 5 "$HEALTH_URL" >/dev/null 2>&1; then
|
||||||
|
finalize_installation
|
||||||
|
set +e
|
||||||
|
wait "$APP_PID"
|
||||||
|
local status=$?
|
||||||
|
set -e
|
||||||
|
clear_pid "$APP_PID"
|
||||||
|
return "$status"
|
||||||
|
fi
|
||||||
|
sleep "$HEALTH_POLL_SECONDS"
|
||||||
|
done
|
||||||
|
|
||||||
|
kill -TERM "$APP_PID" 2>/dev/null || true
|
||||||
|
set +e
|
||||||
|
wait "$APP_PID"
|
||||||
|
set -e
|
||||||
|
clear_pid "$APP_PID"
|
||||||
|
say "Formal application diagnostics: $INSTALL_LOG_FILE"
|
||||||
|
fail "formal application did not become healthy within ${HEALTH_TIMEOUT_SECONDS} seconds"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
load_configuration
|
||||||
|
resolve_java
|
||||||
|
split_java_options
|
||||||
|
mkdir -p "$RUN_DIR"
|
||||||
|
|
||||||
|
if [[ -f "$INSTALL_LOCK_FILE" ]]; then
|
||||||
|
run_formal_application_direct
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$PENDING_FILE" ]]; then
|
||||||
|
run_web_installer
|
||||||
|
load_configuration
|
||||||
|
resolve_java
|
||||||
|
split_java_options
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_pending_formal_application
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+482
@@ -0,0 +1,482 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||||
|
INSTALL_ROOT="${ERP_INSTALL_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd -P)}"
|
||||||
|
CONFIG_FILE="${ERP_CONFIG_FILE:-$INSTALL_ROOT/config/erp.env}"
|
||||||
|
|
||||||
|
if [[ ! -r "$CONFIG_FILE" ]]; then
|
||||||
|
printf '[ERP Update] configuration not readable: %s\n' "$CONFIG_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$CONFIG_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
STATE_FILE="${OA_UPDATE_STATE_FILE:-${ERP_STATE_FILE:-$INSTALL_ROOT/state/update-state.json}}"
|
||||||
|
RUN_DIR="${ERP_RUN_DIR:-$INSTALL_ROOT/run}"
|
||||||
|
RELEASES_DIR="$INSTALL_ROOT/releases"
|
||||||
|
CURRENT_LINK="$INSTALL_ROOT/current"
|
||||||
|
HEALTH_URL="${ERP_HEALTH_URL:-http://127.0.0.1:${SERVER_PORT:-8091}/api/oa/health}"
|
||||||
|
GITEA_BASE_URL="${OA_UPDATE_GITEA_BASE_URL:-}"
|
||||||
|
REPOSITORY="${OA_UPDATE_REPOSITORY:-awaioi/ERP}"
|
||||||
|
CHANNEL="${OA_UPDATE_CHANNEL:-stable}"
|
||||||
|
TOKEN="${OA_UPDATE_TOKEN:-}"
|
||||||
|
ALLOW_INSECURE="${OA_UPDATE_ALLOW_INSECURE_HTTP:-false}"
|
||||||
|
if [[ "${GITEA_BASE_URL%/}" == "http://38.76.196.225:10099" ]]; then
|
||||||
|
GITEA_BASE_URL="https://git.awaioi.com"
|
||||||
|
ALLOW_INSECURE=false
|
||||||
|
fi
|
||||||
|
PUBLIC_KEY_FILE="${ERP_UPDATE_PUBLIC_KEY_FILE:-$INSTALL_ROOT/config/release-public-key.pem}"
|
||||||
|
REQUIRE_SIGNATURE="${ERP_UPDATE_REQUIRE_SIGNATURE:-true}"
|
||||||
|
BACKUP_MODE="${ERP_UPDATE_BACKUP_MODE:-none}"
|
||||||
|
HEALTH_TIMEOUT_SECONDS="${ERP_UPDATE_HEALTH_TIMEOUT_SECONDS:-120}"
|
||||||
|
HEALTH_POLL_SECONDS="${ERP_UPDATE_HEALTH_POLL_SECONDS:-2}"
|
||||||
|
TMP_DIR=""
|
||||||
|
LOCK_FILE="$RUN_DIR/update.lock"
|
||||||
|
FINAL_STATE=0
|
||||||
|
|
||||||
|
say() { printf '[ERP Update] %s\n' "$*"; }
|
||||||
|
|
||||||
|
write_state() {
|
||||||
|
local phase="$1" progress="$2" message="$3" version="${4:-}" error="${5:-}"
|
||||||
|
mkdir -p "$(dirname "$STATE_FILE")"
|
||||||
|
python3 - "$STATE_FILE" "$phase" "$progress" "$message" "$version" "$error" <<'PY'
|
||||||
|
import json, os, sys, tempfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
path, phase, progress, message, version, error = sys.argv[1:]
|
||||||
|
payload = {}
|
||||||
|
try:
|
||||||
|
if os.path.getsize(path) <= 64 * 1024:
|
||||||
|
with open(path, encoding="utf-8") as handle:
|
||||||
|
existing = json.load(handle)
|
||||||
|
if isinstance(existing, dict):
|
||||||
|
payload = existing
|
||||||
|
except (OSError, json.JSONDecodeError, TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
payload.update({
|
||||||
|
"phase": phase,
|
||||||
|
"progress": int(progress),
|
||||||
|
"message": message,
|
||||||
|
"version": version or None,
|
||||||
|
"error": error or None,
|
||||||
|
"updatedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||||
|
})
|
||||||
|
parent = os.path.dirname(path) or "."
|
||||||
|
fd, tmp = tempfile.mkstemp(prefix=".update-state-", dir=parent, text=True)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(payload, handle, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
handle.write("\n")
|
||||||
|
os.chmod(tmp, 0o640)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.unlink(tmp)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then
|
||||||
|
rm -rf "$TMP_DIR"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
on_exit() {
|
||||||
|
local code=$?
|
||||||
|
if [[ "$code" != "0" && "$FINAL_STATE" != "1" ]]; then
|
||||||
|
write_state FAILED 0 "更新失败" "${TARGET_VERSION:-}" "更新助手异常退出(code $code)" || true
|
||||||
|
fi
|
||||||
|
cleanup
|
||||||
|
}
|
||||||
|
trap on_exit EXIT
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
local message="$1"
|
||||||
|
write_state FAILED "${2:-0}" "更新失败" "${TARGET_VERSION:-}" "$message" || true
|
||||||
|
FINAL_STATE=1
|
||||||
|
printf '[ERP Update] ERROR: %s\n' "$message" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_command() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || fail "缺少命令:$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_https_url() {
|
||||||
|
local value="$1"
|
||||||
|
case "$value" in
|
||||||
|
https://*) return 0 ;;
|
||||||
|
http://*) [[ "$ALLOW_INSECURE" == "true" || "$ALLOW_INSECURE" == "1" ]] && return 0 ;;
|
||||||
|
esac
|
||||||
|
fail "更新地址必须使用 HTTPS"
|
||||||
|
}
|
||||||
|
|
||||||
|
acquire_lock_or_reexec() {
|
||||||
|
[[ "${ERP_UPDATE_LOCK_HELD:-0}" == "1" ]] && return 0
|
||||||
|
mkdir -p "$RUN_DIR"
|
||||||
|
command -v python3 >/dev/null 2>&1 || {
|
||||||
|
printf '[ERP Update] ERROR: 缺少命令:python3\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
exec python3 - "$LOCK_FILE" "$0" "$@" <<'PY'
|
||||||
|
import fcntl
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
lock_path, script, *args = sys.argv[1:]
|
||||||
|
if os.path.isdir(lock_path):
|
||||||
|
owner = ""
|
||||||
|
try:
|
||||||
|
with open(os.path.join(lock_path, "pid"), encoding="ascii") as handle:
|
||||||
|
owner = handle.read().strip()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
alive = False
|
||||||
|
if owner.isdigit():
|
||||||
|
try:
|
||||||
|
os.kill(int(owner), 0)
|
||||||
|
alive = True
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
recent = time.time() - os.stat(lock_path).st_mtime < 30
|
||||||
|
except OSError:
|
||||||
|
recent = False
|
||||||
|
if alive or recent:
|
||||||
|
raise SystemExit(f"已有更新任务正在执行(PID {owner or 'unknown'})")
|
||||||
|
try:
|
||||||
|
shutil.rmtree(lock_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
lock = open(lock_path, "a+", encoding="ascii")
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except BlockingIOError:
|
||||||
|
lock.seek(0)
|
||||||
|
owner = lock.read().strip() or "unknown"
|
||||||
|
raise SystemExit(f"已有更新任务正在执行(PID {owner})")
|
||||||
|
lock.seek(0)
|
||||||
|
lock.truncate()
|
||||||
|
lock.write(str(os.getpid()) + "\n")
|
||||||
|
lock.flush()
|
||||||
|
os.fchmod(lock.fileno(), 0o640)
|
||||||
|
os.set_inheritable(lock.fileno(), True)
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["ERP_UPDATE_LOCK_HELD"] = "1"
|
||||||
|
script = os.path.abspath(script)
|
||||||
|
os.execve(script, [script, *args], env)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
create_curl_config() {
|
||||||
|
CURL_CONFIG="$TMP_DIR/curl.conf"
|
||||||
|
{
|
||||||
|
printf 'silent\nshow-error\nfail\nlocation\n'
|
||||||
|
printf 'connect-timeout = 15\nmax-time = 900\n'
|
||||||
|
if [[ "$ALLOW_INSECURE" == "true" || "$ALLOW_INSECURE" == "1" ]]; then
|
||||||
|
printf 'proto = "=http,https"\nproto-redir = "=http,https"\n'
|
||||||
|
else
|
||||||
|
printf 'proto = "=https"\nproto-redir = "=https"\n'
|
||||||
|
fi
|
||||||
|
if [[ -n "$TOKEN" ]]; then
|
||||||
|
[[ "$TOKEN" =~ ^[A-Za-z0-9._-]+$ ]] || fail "Gitea Token 格式无效"
|
||||||
|
printf 'header = "Authorization: token %s"\n' "$TOKEN"
|
||||||
|
fi
|
||||||
|
} > "$CURL_CONFIG"
|
||||||
|
chmod 600 "$CURL_CONFIG"
|
||||||
|
}
|
||||||
|
|
||||||
|
download() {
|
||||||
|
local url="$1" output="$2"
|
||||||
|
validate_https_url "$url"
|
||||||
|
curl --config "$CURL_CONFIG" --output "$output" "$url"
|
||||||
|
}
|
||||||
|
|
||||||
|
select_release() {
|
||||||
|
local release_json="$1" requested="$2" output="$3"
|
||||||
|
python3 - "$release_json" "$requested" "$CHANNEL" > "$output" <<'PY'
|
||||||
|
import json, re, sys
|
||||||
|
|
||||||
|
path, requested, channel = sys.argv[1:]
|
||||||
|
with open(path, encoding="utf-8") as handle:
|
||||||
|
release = json.load(handle)
|
||||||
|
tag = str(release.get("tag_name") or "").strip()
|
||||||
|
match = re.fullmatch(r"v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)", tag)
|
||||||
|
if not match:
|
||||||
|
raise SystemExit("invalid release tag")
|
||||||
|
version = match.group(1)
|
||||||
|
if requested and requested.removeprefix("v") != version:
|
||||||
|
raise SystemExit("requested version is no longer latest")
|
||||||
|
if release.get("draft"):
|
||||||
|
raise SystemExit("latest release is a draft")
|
||||||
|
if channel.lower() == "stable" and release.get("prerelease"):
|
||||||
|
raise SystemExit("prerelease rejected on stable channel")
|
||||||
|
if channel.lower() == "stable" and "-" in version.split("+", 1)[0]:
|
||||||
|
raise SystemExit("prerelease tag rejected on stable channel")
|
||||||
|
assets = {
|
||||||
|
str(item.get("name") or "")
|
||||||
|
for item in release.get("assets", [])
|
||||||
|
if isinstance(item, dict)
|
||||||
|
}
|
||||||
|
names = [f"kaidi-erp-{version}.tar.gz", "SHA256SUMS", "SHA256SUMS.sig"]
|
||||||
|
if any(name not in assets for name in names):
|
||||||
|
raise SystemExit("release assets are incomplete")
|
||||||
|
print("\t".join([version, tag]))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_signature_and_checksum() {
|
||||||
|
local archive="$1" sums="$2" signature="$3" archive_name="$4"
|
||||||
|
write_state VERIFYING 45 "正在校验发布签名" "$TARGET_VERSION"
|
||||||
|
if [[ "$REQUIRE_SIGNATURE" == "true" || "$REQUIRE_SIGNATURE" == "1" ]]; then
|
||||||
|
[[ -r "$PUBLIC_KEY_FILE" ]] || fail "找不到发布公钥" 45
|
||||||
|
require_command openssl
|
||||||
|
local openssl_version algorithms
|
||||||
|
openssl_version="$(openssl version 2>/dev/null || true)"
|
||||||
|
[[ "$openssl_version" =~ ^OpenSSL[[:space:]]3\. ]] \
|
||||||
|
|| fail "签名验证需要 OpenSSL 3" 45
|
||||||
|
algorithms="$(openssl list -public-key-algorithms 2>/dev/null || true)"
|
||||||
|
grep -qi ED25519 <<< "$algorithms" \
|
||||||
|
|| fail "当前 OpenSSL 不支持 Ed25519" 45
|
||||||
|
openssl pkeyutl -verify -rawin -pubin -inkey "$PUBLIC_KEY_FILE" \
|
||||||
|
-sigfile "$signature" -in "$sums" >/dev/null \
|
||||||
|
|| fail "Release Ed25519 签名验证失败" 45
|
||||||
|
fi
|
||||||
|
|
||||||
|
local expected actual
|
||||||
|
expected="$(python3 - "$sums" "$archive_name" <<'PY'
|
||||||
|
import re, sys
|
||||||
|
path, wanted = sys.argv[1:]
|
||||||
|
for line in open(path, encoding="utf-8"):
|
||||||
|
match = re.fullmatch(r"([0-9a-fA-F]{64})\s+\*?(.+?)\s*", line)
|
||||||
|
if match and match.group(2) == wanted:
|
||||||
|
print(match.group(1).lower())
|
||||||
|
break
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
[[ "$expected" =~ ^[0-9a-f]{64}$ ]] || fail "SHA256SUMS 缺少安装包校验值" 50
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
actual="$(sha256sum "$archive" | awk '{print $1}')"
|
||||||
|
else
|
||||||
|
actual="$(shasum -a 256 "$archive" | awk '{print $1}')"
|
||||||
|
fi
|
||||||
|
[[ "$actual" == "$expected" ]] || fail "安装包 SHA-256 校验失败" 50
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_archive() {
|
||||||
|
local archive="$1" destination="$2"
|
||||||
|
python3 - "$archive" <<'PY'
|
||||||
|
import pathlib, sys, tarfile
|
||||||
|
with tarfile.open(sys.argv[1], "r:gz") as archive:
|
||||||
|
for member in archive.getmembers():
|
||||||
|
path = pathlib.PurePosixPath(member.name)
|
||||||
|
if path.is_absolute() or ".." in path.parts or member.issym() or member.islnk():
|
||||||
|
raise SystemExit(f"unsafe archive member: {member.name}")
|
||||||
|
PY
|
||||||
|
mkdir -p "$destination"
|
||||||
|
tar -xzf "$archive" -C "$destination"
|
||||||
|
}
|
||||||
|
|
||||||
|
atomic_switch() {
|
||||||
|
local target="$1"
|
||||||
|
python3 - "$CURRENT_LINK" "$target" <<'PY'
|
||||||
|
import os, sys
|
||||||
|
link, target = sys.argv[1:]
|
||||||
|
tmp = f"{link}.new-{os.getpid()}"
|
||||||
|
try:
|
||||||
|
os.symlink(target, tmp)
|
||||||
|
os.replace(tmp, link)
|
||||||
|
finally:
|
||||||
|
if os.path.lexists(tmp):
|
||||||
|
os.unlink(tmp)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_restarted_health() {
|
||||||
|
local old_pid="$1" deadline=$((SECONDS + HEALTH_TIMEOUT_SECONDS))
|
||||||
|
while (( SECONDS < deadline )); do
|
||||||
|
local new_pid=""
|
||||||
|
[[ -r "$RUN_DIR/app.pid" ]] && new_pid="$(<"$RUN_DIR/app.pid")"
|
||||||
|
if [[ "$new_pid" =~ ^[0-9]+$ && "$new_pid" != "$old_pid" ]] \
|
||||||
|
&& kill -0 "$new_pid" 2>/dev/null \
|
||||||
|
&& curl -fsS --connect-timeout 2 --max-time 5 "$HEALTH_URL" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep "$HEALTH_POLL_SECONDS"
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_pid_exit() {
|
||||||
|
local pid="$1" deadline=$((SECONDS + ${2:-60}))
|
||||||
|
while kill -0 "$pid" 2>/dev/null; do
|
||||||
|
(( SECONDS < deadline )) || return 1
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
clear_pid_file() {
|
||||||
|
local expected="$1" current=""
|
||||||
|
[[ -r "$RUN_DIR/app.pid" ]] && current="$(<"$RUN_DIR/app.pid")"
|
||||||
|
if [[ "$current" == "$expected" ]] && ! kill -0 "$expected" 2>/dev/null; then
|
||||||
|
rm -f "$RUN_DIR/app.pid"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
terminate_application_pid() {
|
||||||
|
local pid="$1"
|
||||||
|
[[ "$pid" =~ ^[0-9]+$ && "$pid" != "$$" ]] || return 0
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
kill -TERM "$pid" 2>/dev/null || return 1
|
||||||
|
wait_for_pid_exit "$pid" 60 || return 1
|
||||||
|
fi
|
||||||
|
clear_pid_file "$pid"
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_application() {
|
||||||
|
local pid=""
|
||||||
|
[[ -r "$RUN_DIR/app.pid" ]] && pid="$(<"$RUN_DIR/app.pid")"
|
||||||
|
[[ "$pid" =~ ^[0-9]+$ ]] || pid="${ERP_APP_PID:-}"
|
||||||
|
[[ "$pid" =~ ^[0-9]+$ ]] || fail "无法读取 ERP 服务 PID" 75
|
||||||
|
[[ "$pid" != "$$" ]] || fail "拒绝停止更新助手自身" 75
|
||||||
|
terminate_application_pid "$pid" || fail "ERP 服务 PID $pid 未能在 60 秒内停止" 75
|
||||||
|
printf '%s\n' "$pid"
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_postgres_if_enabled() {
|
||||||
|
[[ "$BACKUP_MODE" == "pg_dump" ]] || return 0
|
||||||
|
require_command pg_dump
|
||||||
|
local backup_dir="$INSTALL_ROOT/backups"
|
||||||
|
mkdir -p "$backup_dir"
|
||||||
|
local output="$backup_dir/pre-${TARGET_VERSION}-$(date -u +%Y%m%dT%H%M%SZ).dump"
|
||||||
|
write_state INSTALLING 60 "正在创建 PostgreSQL 逻辑备份" "$TARGET_VERSION"
|
||||||
|
PGPASSWORD="${OA_DB_PASSWORD:-}" PGSSLMODE="${ERP_PGSSLMODE:-prefer}" pg_dump \
|
||||||
|
--host="${ERP_PGHOST:-127.0.0.1}" \
|
||||||
|
--port="${ERP_PGPORT:-5432}" \
|
||||||
|
--username="${OA_DB_USERNAME:-}" \
|
||||||
|
--dbname="${ERP_PGDATABASE:-oa}" \
|
||||||
|
--format=custom --file="$output" \
|
||||||
|
|| fail "PostgreSQL 备份失败,已取消更新" 60
|
||||||
|
chmod 600 "$output"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_release() {
|
||||||
|
local requested="${1:-}"
|
||||||
|
validate_https_url "$GITEA_BASE_URL"
|
||||||
|
[[ "$HEALTH_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]] \
|
||||||
|
|| fail "健康检查超时时间配置无效"
|
||||||
|
[[ "$HEALTH_POLL_SECONDS" =~ ^[1-9][0-9]*$ ]] \
|
||||||
|
|| fail "健康检查间隔配置无效"
|
||||||
|
[[ "$REPOSITORY" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,99}/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$ ]] \
|
||||||
|
|| fail "更新仓库配置无效"
|
||||||
|
require_command curl
|
||||||
|
require_command python3
|
||||||
|
require_command tar
|
||||||
|
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kaidi-erp-update.XXXXXX")"
|
||||||
|
create_curl_config
|
||||||
|
|
||||||
|
write_state CHECKING 5 "正在读取 Gitea Release" "$requested"
|
||||||
|
local owner="${REPOSITORY%%/*}" repo="${REPOSITORY#*/}"
|
||||||
|
local api_url="${GITEA_BASE_URL%/}/api/v1/repos/$owner/$repo/releases/latest"
|
||||||
|
local release_json="$TMP_DIR/release.json"
|
||||||
|
download "$api_url" "$release_json"
|
||||||
|
|
||||||
|
local selection="$TMP_DIR/selection"
|
||||||
|
select_release "$release_json" "$requested" "$selection" || fail "Release 元数据验证失败" 10
|
||||||
|
local release_tag release_base archive_url sums_url signature_url
|
||||||
|
IFS=$'\t' read -r TARGET_VERSION release_tag < "$selection"
|
||||||
|
local archive_name="kaidi-erp-${TARGET_VERSION}.tar.gz"
|
||||||
|
release_base="${GITEA_BASE_URL%/}/$owner/$repo/releases/download/$release_tag"
|
||||||
|
archive_url="$release_base/$archive_name"
|
||||||
|
sums_url="$release_base/SHA256SUMS"
|
||||||
|
signature_url="$release_base/SHA256SUMS.sig"
|
||||||
|
local archive="$TMP_DIR/$archive_name" sums="$TMP_DIR/SHA256SUMS" signature="$TMP_DIR/SHA256SUMS.sig"
|
||||||
|
|
||||||
|
write_state DOWNLOADING 20 "正在下载版本 ${TARGET_VERSION}" "$TARGET_VERSION"
|
||||||
|
download "$archive_url" "$archive"
|
||||||
|
download "$sums_url" "$sums"
|
||||||
|
download "$signature_url" "$signature"
|
||||||
|
verify_signature_and_checksum "$archive" "$sums" "$signature" "$archive_name"
|
||||||
|
|
||||||
|
write_state INSTALLING 55 "正在准备版本 ${TARGET_VERSION}" "$TARGET_VERSION"
|
||||||
|
local unpack="$TMP_DIR/unpack"
|
||||||
|
extract_archive "$archive" "$unpack" || fail "安装包结构验证失败" 55
|
||||||
|
local source="$unpack/kaidi-erp-${TARGET_VERSION}"
|
||||||
|
[[ -r "$source/app/kaidi-erp.jar" && -x "$source/bin/erp-run" && -x "$source/bin/erp-update" ]] \
|
||||||
|
|| fail "安装包缺少运行文件" 55
|
||||||
|
[[ "$(<"$source/VERSION")" == "$TARGET_VERSION" ]] || fail "安装包版本与 Release 不一致" 55
|
||||||
|
python3 - "$source/manifest.json" "$TARGET_VERSION" <<'PY' || fail "版本清单不允许自动回滚" 55
|
||||||
|
import json, sys
|
||||||
|
manifest = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||||
|
if manifest.get("version") != sys.argv[2] or manifest.get("database") != "postgresql":
|
||||||
|
raise SystemExit(1)
|
||||||
|
if manifest.get("rollbackCompatible") is not True:
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
|
||||||
|
mkdir -p "$RELEASES_DIR"
|
||||||
|
local release_dir="$RELEASES_DIR/$TARGET_VERSION"
|
||||||
|
if [[ -r "$CURRENT_LINK/VERSION" && "$(<"$CURRENT_LINK/VERSION")" == "$TARGET_VERSION" ]]; then
|
||||||
|
fail "目标版本已经安装" 55
|
||||||
|
fi
|
||||||
|
local replaced=""
|
||||||
|
if [[ -e "$release_dir" ]]; then
|
||||||
|
replaced="${release_dir}.replaced-$$"
|
||||||
|
mv "$release_dir" "$replaced"
|
||||||
|
fi
|
||||||
|
if ! mv "$source" "$release_dir"; then
|
||||||
|
[[ -z "$replaced" || ! -e "$replaced" ]] || mv "$replaced" "$release_dir"
|
||||||
|
fail "无法写入目标版本目录" 55
|
||||||
|
fi
|
||||||
|
[[ -z "$replaced" ]] || rm -rf "$replaced"
|
||||||
|
backup_postgres_if_enabled
|
||||||
|
|
||||||
|
local previous=""
|
||||||
|
[[ -L "$CURRENT_LINK" ]] && previous="$(readlink "$CURRENT_LINK")"
|
||||||
|
[[ -n "$previous" ]] || fail "当前版本链接不存在,拒绝无回滚点更新" 70
|
||||||
|
atomic_switch "releases/$TARGET_VERSION"
|
||||||
|
|
||||||
|
write_state RESTARTING 80 "正在重启 ERP 服务" "$TARGET_VERSION"
|
||||||
|
local old_pid
|
||||||
|
old_pid="$(stop_application)"
|
||||||
|
if wait_for_restarted_health "$old_pid"; then
|
||||||
|
write_state SUCCEEDED 100 "更新完成" "$TARGET_VERSION"
|
||||||
|
FINAL_STATE=1
|
||||||
|
say "updated to $TARGET_VERSION"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
write_state ROLLING_BACK 90 "新版本健康检查失败,正在回滚" "$TARGET_VERSION"
|
||||||
|
atomic_switch "$previous"
|
||||||
|
local failed_pid=""
|
||||||
|
[[ -r "$RUN_DIR/app.pid" ]] && failed_pid="$(<"$RUN_DIR/app.pid")"
|
||||||
|
terminate_application_pid "$failed_pid" || true
|
||||||
|
if wait_for_restarted_health "$failed_pid"; then
|
||||||
|
write_state ROLLED_BACK 100 "新版本不可用,已恢复上一版本" "$TARGET_VERSION" "健康检查失败"
|
||||||
|
FINAL_STATE=1
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fail "新版本与回滚版本均未通过健康检查,需要人工处理" 100
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local command="${1:-}" version="${2:-}"
|
||||||
|
case "$command" in
|
||||||
|
install)
|
||||||
|
acquire_lock_or_reexec "$@"
|
||||||
|
install_release "$version"
|
||||||
|
;;
|
||||||
|
*) printf 'usage: erp-update install <version>\n' >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||||
|
main "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEAaErhcY8WZIZvPILmYnfjndVBAdOuWkvhaoHIWqNNdxI=
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# 在线安装与更新
|
||||||
|
|
||||||
|
Kaidi ERP 的生产安装不依赖 Docker。安装器支持 64 位 Linux 和 macOS,生产数据库固定为 PostgreSQL 15 或更高版本;生产 JAR 不包含 SQLite 运行库,SQLite 仅保留给源码目录下的本地开发和测试。
|
||||||
|
|
||||||
|
## 发布链路
|
||||||
|
|
||||||
|
推送 `v*` Git tag 后,[release.yml](../.gitea/workflows/release.yml) 会完成前端构建、Spring Boot 打包、Ed25519 签名,并在 Gitea 中创建或更新对应 Release。每个可安装 Release 必须包含:
|
||||||
|
|
||||||
|
- `kaidi-erp-<version>.tar.gz`
|
||||||
|
- `kaidi-erp-installer-<version>.jar`
|
||||||
|
- `SHA256SUMS`
|
||||||
|
- `SHA256SUMS.sig`
|
||||||
|
|
||||||
|
Gitea Actions runner 需要预装 Java 17 或更高版本、Node.js/npm、Python 3、tar、curl 和 OpenSSL 3。
|
||||||
|
|
||||||
|
流水线使用 Gitea 1.27 提供的短期 `GITEA_TOKEN`,权限限定为代码只读、当前仓库 Release 可写,不需要创建个人访问令牌。仓库 Actions 设置只需创建:
|
||||||
|
|
||||||
|
- Secret `RELEASE_PRIVATE_KEY_B64`:Ed25519 私钥的单行 Base64 内容。
|
||||||
|
- Variable `ERP_RELEASE_ALLOW_INSECURE_HTTP`:正式 HTTPS 服务器保持未设置;仅隔离的 HTTP 开发镜像才允许设为 `1`。Gitea 不允许仓库变量名以保留前缀 `GITEA_` 或 `GITHUB_` 开头。
|
||||||
|
|
||||||
|
本机现有签名私钥位于 `~/.config/kaidi-erp/release-signing-key.pem`,不得提交到 Git。macOS 可用以下命令生成 Secret 值:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
base64 < ~/.config/kaidi-erp/release-signing-key.pem | tr -d '\n'
|
||||||
|
```
|
||||||
|
|
||||||
|
发布稳定版本(先确认发布提交已经同步到 `main` 和 `dev`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git switch main
|
||||||
|
git pull --ff-only origin main
|
||||||
|
git tag -a v0.3.8 -m 'Kaidi ERP v0.3.8'
|
||||||
|
git push origin v0.3.8
|
||||||
|
```
|
||||||
|
|
||||||
|
## 首次安装
|
||||||
|
|
||||||
|
官方 Gitea 已启用 HTTPS,Linux 可直接执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.awaioi.com/awaioi/ERP/raw/branch/main/install.sh | sudo -E bash
|
||||||
|
```
|
||||||
|
|
||||||
|
命令行只检查并安装 Java 17+、curl、tar、Python 3 和 OpenSSL 3,然后启动独立安装器并输出带一次性 token 的网页地址。数据库、管理员和密码全部在首次网页向导填写;安装器会真实测试 PostgreSQL 15+、数据库所有权、`public` schema 建表权限和 `pg_trgm` 所有权,迁移完成并确认正式服务健康后才写 `install.lock`,随后物理删除安装器目录。
|
||||||
|
|
||||||
|
安装器默认从 `https://git.awaioi.com/awaioi/ERP` 获取签名 Release。安装地址优先使用 `--public-url`(或 `ERP_PUBLIC_URL`);未指定时依次尝试探测公网 IP、回退局域网 IP,并始终额外输出 `Local URL`。ERP 已绑定独立业务域名时可显式传入例如 `--public-url https://erp.example.com`。参数支持 HTTPS 域名、端口、路径和已有查询参数,安装器会安全追加 token,不会用局域网 IP 覆盖显式公网地址。
|
||||||
|
|
||||||
|
目标 PostgreSQL 必须是专用空数据库,网页中填写的账号必须是该数据库的所有者。只拥有连接权限的账号会在网页连接测试阶段被拒绝,不再等到 Flyway 迁移后才显示笼统错误。
|
||||||
|
|
||||||
|
Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL 或精简系统只能显式使用 `--no-service` 做开发验收,在线更新也会保持关闭。
|
||||||
|
|
||||||
|
正式安装和更新不得启用 `--allow-insecure`。需要锁定版本时,应从固定 tag 下载引导脚本并验证本版本记录的 SHA-256。
|
||||||
|
|
||||||
|
当前 `v0.3.8` 安装命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(
|
||||||
|
set -e
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
trap 'rm -f -- "$tmp"' EXIT
|
||||||
|
curl -fsSL https://git.awaioi.com/awaioi/ERP/raw/tag/v0.3.8/install.sh -o "$tmp"
|
||||||
|
printf '%s %s\n' 'ae6a6613d4abe37ba24b41e1901eaa3b29cc32939583302c7f62277922fbbe9b' "$tmp" | sha256sum -c -
|
||||||
|
sudo -E bash "$tmp" --version 0.3.8
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
完整卸载并清空本项目数据库 schema 后重装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(
|
||||||
|
set -e
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
trap 'rm -f -- "$tmp"' EXIT
|
||||||
|
curl -fsSL https://git.awaioi.com/awaioi/ERP/raw/tag/v0.3.8/uninstall.sh -o "$tmp"
|
||||||
|
printf '%s %s\n' '98c56fed2fd4d01874e4ab5a1a4f3ec42ec3e29b315ffd87385a95488d587546' "$tmp" | sha256sum -c -
|
||||||
|
sudo -E bash "$tmp" --purge-database --yes
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
该命令会先确认安装路径与 `erp.env` 一致、systemd 服务已经停止,再删除目标数据库 `public` schema 中的全部对象以及 `/opt/kaidi-erp`、`/etc/kaidi-erp`、`/var/lib/kaidi-erp`、`/var/log/kaidi-erp`。只允许对 Kaidi ERP 专用数据库执行。
|
||||||
|
|
||||||
|
## 在线更新
|
||||||
|
|
||||||
|
管理员可从顶部工具栏、用户菜单、手机导航抽屉或“应用定制平台 -> 系统更新”进入 `/appdev/update`。入口只对 `ADMIN` 角色显示;发现新版本时顶部和手机入口会显示版本提示。
|
||||||
|
|
||||||
|
更新源由首次安装器写入 `ERP_CONFIG_FILE` 指向的 `erp.env`。更新页面不会要求管理员重复填写 Gitea 地址、仓库、Token、通道或 HTTP 开关。`v0.3.8` 会把旧官方 HTTP 地址自动迁移到 `https://git.awaioi.com`,但不会覆盖其他自定义源;需要变更基础设施参数时,由服务器运维人员修改 `OA_UPDATE_*` 环境变量并重启服务。
|
||||||
|
|
||||||
|
点击“检查更新”后,页面会展示当前版本、在线最新版本、发布日期、最新版本更新日志和历史正式版本记录。点击“安装并重启”后持续显示下载、验签、安装、重启和回滚进度;服务重启短暂断开期间页面会自动重连。后端启动独立更新助手,更新助手会:
|
||||||
|
|
||||||
|
1. 下载正式归档、`SHA256SUMS` 和签名并验证 Ed25519/SHA-256;独立安装器资产只在首次安装使用。
|
||||||
|
2. 拒绝路径穿越、符号链接和结构不完整的安装包。
|
||||||
|
3. 可选执行 `pg_dump`,再写入独立版本目录。
|
||||||
|
4. 原子切换 `current` 链接并终止旧进程,由 systemd 或 launchd 拉起新版本;Linux unit 使用 `KillMode=process`,让更新助手继续执行健康检查和必要的回滚。
|
||||||
|
5. 等待健康检查;失败时切回上一版本并再次验证健康状态。
|
||||||
|
|
||||||
|
更新过程使用操作系统文件锁,同一安装目录同时只允许一个更新任务。手动触发可执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/opt/kaidi-erp/current/bin/erp-update install 0.3.8
|
||||||
|
```
|
||||||
|
|
||||||
|
在线更新依赖安装器注册的 systemd 或 launchd 服务来拉起新旧版本。使用 `--no-service` 时后台更新默认关闭;如由其他进程管理器接管,须先确认它会在 ERP 进程退出后自动重启,再手工启用 `OA_UPDATE_ENABLED=true`。健康检查默认最多等待 120 秒、每 2 秒轮询一次,可分别通过 `ERP_UPDATE_HEALTH_TIMEOUT_SECONDS` 和 `ERP_UPDATE_HEALTH_POLL_SECONDS` 调整。
|
||||||
|
|
||||||
|
Linux 默认目录:
|
||||||
|
|
||||||
|
- 程序:`/opt/kaidi-erp`
|
||||||
|
- 配置:`/etc/kaidi-erp/erp.env`
|
||||||
|
- 状态:`/var/lib/kaidi-erp/update-state.json`
|
||||||
|
- 服务:`kaidi-erp.service`
|
||||||
|
|
||||||
|
应用回滚不等于数据库回滚。发布包含不可逆 Flyway 迁移前,应先保证旧应用仍兼容新结构,并在安装时设置 `ERP_UPDATE_BACKUP_MODE=pg_dump`。数据库恢复仍需人工确认后使用 `pg_restore`,更新助手不会自动覆盖生产数据。
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
# ERP One-Command Launcher Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add a Finder-double-clickable `run.command` that safely starts or reuses the ERP Spring Boot service and fixed-domain ngrok tunnel, opens the preview, and owns cleanup in one Terminal window.
|
||||||
|
|
||||||
|
**Architecture:** A Bash 3.2-compatible supervisor resolves all paths from its own location, exposes focused health/ownership functions for Shell tests, and executes `main` only when run directly. It records only child PIDs it creates, so signal cleanup cannot kill pre-existing Java or ngrok processes.
|
||||||
|
|
||||||
|
**Tech Stack:** macOS Bash, `curl`, `lsof`, Python 3 JSON parsing, project-bundled Temurin 17, ngrok 3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
- Create: `run.command` — user-facing launcher, readiness checks, process ownership, monitoring, and cleanup.
|
||||||
|
- Create: `tests/run-command.test.sh` — dependency-free Shell regression suite that sources launcher functions in isolated subshells.
|
||||||
|
- Existing reference only: `docs/superpowers/specs/2026-07-15-run-command-design.md` — approved behavioral contract.
|
||||||
|
|
||||||
|
### Task 1: Establish source-safe launcher structure
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/run-command.test.sh`
|
||||||
|
- Create: `run.command`
|
||||||
|
|
||||||
|
- [x] **Step 1: Write the failing path-resolution test**
|
||||||
|
|
||||||
|
Create the initial test runner with a test that sources the launcher from `/tmp` and verifies that it resolves the ERP root without starting services:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
||||||
|
SCRIPT="$PROJECT_ROOT/run.command"
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
run_test() {
|
||||||
|
local name="$1"
|
||||||
|
shift
|
||||||
|
if ("$@"); then
|
||||||
|
printf 'PASS %s\n' "$name"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
printf 'FAIL %s\n' "$name"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_resolves_root_when_sourced_elsewhere() (
|
||||||
|
[ -r "$SCRIPT" ] || return 1
|
||||||
|
cd /tmp || return 1
|
||||||
|
source "$SCRIPT"
|
||||||
|
[ "$ROOT_DIR" = "$PROJECT_ROOT" ]
|
||||||
|
[ -z "$BACKEND_PID" ]
|
||||||
|
[ -z "$NGROK_PID" ]
|
||||||
|
)
|
||||||
|
|
||||||
|
run_test 'resolves project root when sourced elsewhere' test_resolves_root_when_sourced_elsewhere
|
||||||
|
printf 'RESULT pass=%s fail=%s\n' "$PASS" "$FAIL"
|
||||||
|
[ "$FAIL" -eq 0 ]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run the test and verify RED**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash tests/run-command.test.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: exit non-zero with `FAIL resolves project root when sourced elsewhere` because `run.command` does not exist.
|
||||||
|
|
||||||
|
- [x] **Step 3: Add the minimal source-safe launcher skeleton**
|
||||||
|
|
||||||
|
Create `run.command` with project-relative constants, empty ownership state, and a direct-execution guard:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||||
|
ROOT_DIR="${ERP_RUN_ROOT_DIR:-$SCRIPT_DIR}"
|
||||||
|
BACKEND_DIR="${ERP_RUN_BACKEND_DIR:-$ROOT_DIR/oa-backend}"
|
||||||
|
JAVA_BIN="${ERP_RUN_JAVA_BIN:-$ROOT_DIR/.jdks/jdk-17.0.19+10/Contents/Home/bin/java}"
|
||||||
|
JAR_PATH="${ERP_RUN_JAR_PATH:-$BACKEND_DIR/build/libs/oa-backend-0.1.0.jar}"
|
||||||
|
BACKEND_PORT="${ERP_RUN_BACKEND_PORT:-8091}"
|
||||||
|
NGROK_API_PORT="${ERP_RUN_NGROK_API_PORT:-4040}"
|
||||||
|
LOCAL_URL="http://127.0.0.1:$BACKEND_PORT"
|
||||||
|
PUBLIC_URL="${ERP_RUN_PUBLIC_URL:-https://resonant-elated-launder.ngrok-free.dev}"
|
||||||
|
NGROK_TARGET="http://localhost:$BACKEND_PORT"
|
||||||
|
LOG_DIR="${ERP_RUN_LOG_DIR:-${TMPDIR:-/tmp}/kaidi-erp-run}"
|
||||||
|
BACKEND_LOG="$LOG_DIR/backend.log"
|
||||||
|
NGROK_LOG="$LOG_DIR/ngrok.log"
|
||||||
|
BACKEND_PID=""
|
||||||
|
NGROK_PID=""
|
||||||
|
CLEANED_UP=0
|
||||||
|
|
||||||
|
main() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||||
|
main "$@"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Run syntax and source tests and verify GREEN**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash -n run.command
|
||||||
|
bash tests/run-command.test.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both commands exit 0 and the test reports `RESULT pass=1 fail=0`.
|
||||||
|
|
||||||
|
### Task 2: Add safe backend reuse and owned-process cleanup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/run-command.test.sh`
|
||||||
|
- Modify: `run.command`
|
||||||
|
|
||||||
|
- [x] **Step 1: Add failing backend and cleanup tests**
|
||||||
|
|
||||||
|
Insert these tests before the runner calls, then add their `run_test` calls:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test_reuses_healthy_backend() (
|
||||||
|
source "$SCRIPT"
|
||||||
|
backend_is_healthy() { return 0; }
|
||||||
|
port_listener_pid() { printf '4242\n'; }
|
||||||
|
start_backend() { return 77; }
|
||||||
|
ensure_backend
|
||||||
|
[ -z "$BACKEND_PID" ]
|
||||||
|
)
|
||||||
|
|
||||||
|
test_rejects_unhealthy_port_without_killing_owner() (
|
||||||
|
source "$SCRIPT"
|
||||||
|
sleep 30 &
|
||||||
|
local external_pid=$!
|
||||||
|
trap 'kill "$external_pid" 2>/dev/null || true' EXIT
|
||||||
|
backend_is_healthy() { return 1; }
|
||||||
|
port_listener_pid() { printf '%s\n' "$external_pid"; }
|
||||||
|
if ensure_backend; then return 1; fi
|
||||||
|
kill -0 "$external_pid" 2>/dev/null
|
||||||
|
)
|
||||||
|
|
||||||
|
test_cleanup_stops_owned_pid_only() (
|
||||||
|
source "$SCRIPT"
|
||||||
|
sleep 30 &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
sleep 30 &
|
||||||
|
local external_pid=$!
|
||||||
|
cleanup
|
||||||
|
if kill -0 "$BACKEND_PID" 2>/dev/null; then return 1; fi
|
||||||
|
kill -0 "$external_pid" 2>/dev/null || return 1
|
||||||
|
kill "$external_pid" 2>/dev/null || true
|
||||||
|
)
|
||||||
|
|
||||||
|
run_test 'reuses a healthy backend' test_reuses_healthy_backend
|
||||||
|
run_test 'rejects a foreign 8091 listener without killing it' test_rejects_unhealthy_port_without_killing_owner
|
||||||
|
run_test 'cleanup stops owned PID only' test_cleanup_stops_owned_pid_only
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run the tests and verify RED**
|
||||||
|
|
||||||
|
Run `bash tests/run-command.test.sh`.
|
||||||
|
|
||||||
|
Expected: the original path test passes; new tests fail because `ensure_backend` and `cleanup` are undefined.
|
||||||
|
|
||||||
|
- [x] **Step 3: Implement minimal backend and ownership functions**
|
||||||
|
|
||||||
|
Add these functions above `main` in `run.command`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
say() { printf '[ERP] %s\n' "$*"; }
|
||||||
|
fail() { printf '[ERP] ERROR: %s\n' "$*" >&2; return 1; }
|
||||||
|
|
||||||
|
port_listener_pid() {
|
||||||
|
lsof -nP -iTCP:"$1" -sTCP:LISTEN -t 2>/dev/null | head -n 1
|
||||||
|
}
|
||||||
|
|
||||||
|
backend_is_healthy() {
|
||||||
|
local body
|
||||||
|
body="$(curl -fsS --connect-timeout 1 --max-time 3 "$LOCAL_URL/" 2>/dev/null)" || return 1
|
||||||
|
[[ "$body" == *'<title>凯迪协同办公平台</title>'* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
login_is_healthy() {
|
||||||
|
curl -fsS --connect-timeout 1 --max-time 5 \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"loginName":"admin","password":"123456"}' \
|
||||||
|
"$LOCAL_URL/api/oa/auth/login" 2>/dev/null \
|
||||||
|
| python3 -c 'import json,sys; d=json.load(sys.stdin); raise SystemExit(0 if d.get("code")==0 and d.get("data",{}).get("token") else 1)'
|
||||||
|
}
|
||||||
|
|
||||||
|
start_backend() {
|
||||||
|
: > "$BACKEND_LOG"
|
||||||
|
(
|
||||||
|
cd "$BACKEND_DIR" || exit 1
|
||||||
|
exec "$JAVA_BIN" -jar "$JAR_PATH" --server.port="$BACKEND_PORT"
|
||||||
|
) >> "$BACKEND_LOG" 2>&1 &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_backend() {
|
||||||
|
local deadline=$((SECONDS + 60))
|
||||||
|
while [ "$SECONDS" -lt "$deadline" ]; do
|
||||||
|
if backend_is_healthy && login_is_healthy; then return 0; fi
|
||||||
|
if [ -n "$BACKEND_PID" ] && ! kill -0 "$BACKEND_PID" 2>/dev/null; then return 1; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_backend() {
|
||||||
|
local listener
|
||||||
|
listener="$(port_listener_pid "$BACKEND_PORT" || true)"
|
||||||
|
if [ -n "$listener" ]; then
|
||||||
|
if ! backend_is_healthy; then
|
||||||
|
fail "端口 $BACKEND_PORT 已被非本项目服务占用(PID $listener)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
say "复用已运行的 ERP 服务(PID $listener)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
say "启动 ERP 服务..."
|
||||||
|
start_backend || return 1
|
||||||
|
if ! wait_for_backend; then
|
||||||
|
tail -n 40 "$BACKEND_LOG" >&2 || true
|
||||||
|
fail "ERP 服务未在 60 秒内就绪"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_owned_process() {
|
||||||
|
local pid="$1"
|
||||||
|
[ -n "$pid" ] || return 0
|
||||||
|
kill -0 "$pid" 2>/dev/null || return 0
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
local attempt=0
|
||||||
|
while kill -0 "$pid" 2>/dev/null && [ "$attempt" -lt 5 ]; do
|
||||||
|
sleep 1
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
done
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then kill -9 "$pid" 2>/dev/null || true; fi
|
||||||
|
wait "$pid" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
[ "$CLEANED_UP" -eq 0 ] || return 0
|
||||||
|
CLEANED_UP=1
|
||||||
|
stop_owned_process "$NGROK_PID"
|
||||||
|
stop_owned_process "$BACKEND_PID"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Run the tests and verify GREEN**
|
||||||
|
|
||||||
|
Run `bash tests/run-command.test.sh`.
|
||||||
|
|
||||||
|
Expected: all four tests pass and no external test PID is terminated.
|
||||||
|
|
||||||
|
### Task 3: Add ngrok, browser gating, orchestration, and monitoring
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/run-command.test.sh`
|
||||||
|
- Modify: `run.command`
|
||||||
|
|
||||||
|
- [x] **Step 1: Add failing orchestration tests**
|
||||||
|
|
||||||
|
Add these tests and runner calls:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test_reuses_healthy_ngrok() (
|
||||||
|
source "$SCRIPT"
|
||||||
|
ngrok_is_healthy() { return 0; }
|
||||||
|
port_listener_pid() { printf '5252\n'; }
|
||||||
|
start_ngrok() { return 78; }
|
||||||
|
ensure_ngrok
|
||||||
|
[ -z "$NGROK_PID" ]
|
||||||
|
)
|
||||||
|
|
||||||
|
test_no_open_mode_skips_browser() (
|
||||||
|
source "$SCRIPT"
|
||||||
|
local marker="${TMPDIR:-/tmp}/run-command-open-$$"
|
||||||
|
rm -f "$marker"
|
||||||
|
open() { : > "$marker"; }
|
||||||
|
ERP_RUN_NO_OPEN=1
|
||||||
|
maybe_open_browser
|
||||||
|
[ ! -e "$marker" ]
|
||||||
|
)
|
||||||
|
|
||||||
|
test_main_runs_steps_in_order() (
|
||||||
|
source "$SCRIPT"
|
||||||
|
local events="${TMPDIR:-/tmp}/run-command-events-$$"
|
||||||
|
: > "$events"
|
||||||
|
preflight() { printf 'preflight\n' >> "$events"; }
|
||||||
|
ensure_backend() { printf 'backend\n' >> "$events"; }
|
||||||
|
ensure_ngrok() { printf 'ngrok\n' >> "$events"; }
|
||||||
|
wait_for_public() { printf 'public\n' >> "$events"; }
|
||||||
|
maybe_open_browser() { printf 'open\n' >> "$events"; }
|
||||||
|
monitor_services() { printf 'monitor\n' >> "$events"; }
|
||||||
|
main
|
||||||
|
[ "$(tr '\n' ' ' < "$events")" = 'preflight backend ngrok public open monitor ' ]
|
||||||
|
)
|
||||||
|
|
||||||
|
run_test 'reuses a healthy ngrok tunnel' test_reuses_healthy_ngrok
|
||||||
|
run_test 'no-open mode skips browser launch' test_no_open_mode_skips_browser
|
||||||
|
run_test 'main orchestrates steps in order' test_main_runs_steps_in_order
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run the tests and verify RED**
|
||||||
|
|
||||||
|
Run `bash tests/run-command.test.sh`.
|
||||||
|
|
||||||
|
Expected: prior tests pass; the three new tests fail because ngrok/orchestration functions are not implemented.
|
||||||
|
|
||||||
|
- [x] **Step 3: Implement preflight, ngrok, public verification, and main**
|
||||||
|
|
||||||
|
Add the following functions above `main`, then replace the empty `main`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
resolve_ngrok_bin() {
|
||||||
|
if [ -n "${ERP_RUN_NGROK_BIN:-}" ]; then printf '%s\n' "$ERP_RUN_NGROK_BIN"; return; fi
|
||||||
|
if [ -x "$HOME/bin/ngrok" ]; then printf '%s\n' "$HOME/bin/ngrok"; return; fi
|
||||||
|
command -v ngrok 2>/dev/null || return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
preflight() {
|
||||||
|
command -v curl >/dev/null || { fail '缺少 curl'; return 1; }
|
||||||
|
command -v lsof >/dev/null || { fail '缺少 lsof'; return 1; }
|
||||||
|
command -v python3 >/dev/null || { fail '缺少 python3'; return 1; }
|
||||||
|
[ -x "$JAVA_BIN" ] || { fail "找不到项目 JDK:$JAVA_BIN"; return 1; }
|
||||||
|
[ -r "$JAR_PATH" ] || { fail "找不到可运行 JAR:$JAR_PATH"; return 1; }
|
||||||
|
[ -r "$BACKEND_DIR/data/oa.db" ] || { fail "找不到 SQLite 数据库:$BACKEND_DIR/data/oa.db"; return 1; }
|
||||||
|
NGROK_BIN="$(resolve_ngrok_bin)" || { fail '找不到 ngrok(预期 $HOME/bin/ngrok 或 PATH)'; return 1; }
|
||||||
|
mkdir -p "$LOG_DIR" || { fail "无法创建日志目录:$LOG_DIR"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
ngrok_is_healthy() {
|
||||||
|
local payload
|
||||||
|
payload="$(curl -fsS --connect-timeout 1 --max-time 3 "http://127.0.0.1:$NGROK_API_PORT/api/tunnels" 2>/dev/null)" || return 1
|
||||||
|
python3 -c 'import json,sys; d=json.load(sys.stdin); pub,target=sys.argv[1:3]; raise SystemExit(0 if any(t.get("public_url")==pub and t.get("config",{}).get("addr")==target for t in d.get("tunnels",[])) else 1)' \
|
||||||
|
"$PUBLIC_URL" "$NGROK_TARGET" <<< "$payload"
|
||||||
|
}
|
||||||
|
|
||||||
|
start_ngrok() {
|
||||||
|
: > "$NGROK_LOG"
|
||||||
|
"$NGROK_BIN" http --url=resonant-elated-launder.ngrok-free.dev "$BACKEND_PORT" \
|
||||||
|
--log=stdout --log-format=json >> "$NGROK_LOG" 2>&1 &
|
||||||
|
NGROK_PID=$!
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_ngrok() {
|
||||||
|
local deadline=$((SECONDS + 30))
|
||||||
|
while [ "$SECONDS" -lt "$deadline" ]; do
|
||||||
|
ngrok_is_healthy && return 0
|
||||||
|
if [ -n "$NGROK_PID" ] && ! kill -0 "$NGROK_PID" 2>/dev/null; then return 1; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_ngrok() {
|
||||||
|
if ngrok_is_healthy; then
|
||||||
|
say '复用已运行的 ngrok 隧道'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local listener
|
||||||
|
listener="$(port_listener_pid "$NGROK_API_PORT" || true)"
|
||||||
|
if [ -n "$listener" ]; then
|
||||||
|
fail "端口 $NGROK_API_PORT 已有其他 ngrok/服务(PID $listener),但固定隧道不匹配"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
say '启动 ngrok...'
|
||||||
|
start_ngrok || return 1
|
||||||
|
if ! wait_for_ngrok; then
|
||||||
|
tail -n 40 "$NGROK_LOG" >&2 || true
|
||||||
|
fail 'ngrok 未在 30 秒内建立固定隧道'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
public_is_healthy() {
|
||||||
|
local body
|
||||||
|
body="$(curl -fsS --connect-timeout 3 --max-time 10 -H 'ngrok-skip-browser-warning: true' "$PUBLIC_URL/" 2>/dev/null)" || return 1
|
||||||
|
[[ "$body" == *'<title>凯迪协同办公平台</title>'* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_public() {
|
||||||
|
local deadline=$((SECONDS + 30))
|
||||||
|
while [ "$SECONDS" -lt "$deadline" ]; do
|
||||||
|
public_is_healthy && return 0
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fail '公网地址未在 30 秒内可访问'
|
||||||
|
}
|
||||||
|
|
||||||
|
maybe_open_browser() {
|
||||||
|
[ "${ERP_RUN_NO_OPEN:-0}" = '1' ] && return 0
|
||||||
|
command -v open >/dev/null || { fail '找不到 macOS open 命令'; return 1; }
|
||||||
|
open "$PUBLIC_URL"
|
||||||
|
}
|
||||||
|
|
||||||
|
monitor_services() {
|
||||||
|
local backend_active ngrok_active
|
||||||
|
backend_active="$(port_listener_pid "$BACKEND_PORT" || true)"
|
||||||
|
ngrok_active="$(port_listener_pid "$NGROK_API_PORT" || true)"
|
||||||
|
say "本地:$LOCAL_URL"
|
||||||
|
say "公网:$PUBLIC_URL"
|
||||||
|
say "进程:ERP PID ${backend_active:-未知},ngrok PID ${ngrok_active:-未知}"
|
||||||
|
say "日志:$BACKEND_LOG;$NGROK_LOG"
|
||||||
|
say '运行中;按 Ctrl+C 同时停止本次启动的服务。'
|
||||||
|
while :; do
|
||||||
|
if ! backend_is_healthy; then
|
||||||
|
[ -n "$BACKEND_PID" ] && tail -n 40 "$BACKEND_LOG" >&2 || true
|
||||||
|
fail 'ERP 服务失去响应'
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! ngrok_is_healthy; then
|
||||||
|
[ -n "$NGROK_PID" ] && tail -n 40 "$NGROK_LOG" >&2 || true
|
||||||
|
fail 'ngrok 隧道失去响应'
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_signal() {
|
||||||
|
printf '\n'
|
||||||
|
say '正在停止本次启动的服务...'
|
||||||
|
cleanup
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
trap handle_signal HUP INT TERM
|
||||||
|
trap cleanup EXIT
|
||||||
|
say '检查运行环境...'
|
||||||
|
preflight || return 1
|
||||||
|
ensure_backend || return 1
|
||||||
|
ensure_ngrok || return 1
|
||||||
|
wait_for_public || return 1
|
||||||
|
maybe_open_browser || return 1
|
||||||
|
monitor_services
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Run the complete unit suite and verify GREEN**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash -n run.command
|
||||||
|
bash -n tests/run-command.test.sh
|
||||||
|
bash tests/run-command.test.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: syntax checks exit 0; test suite reports seven passing tests and zero failures.
|
||||||
|
|
||||||
|
### Task 4: Verify executable behavior against live services
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify mode: `run.command`
|
||||||
|
- Modify mode: `tests/run-command.test.sh`
|
||||||
|
|
||||||
|
- [x] **Step 1: Mark both scripts executable**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x run.command tests/run-command.test.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Verify repeat-start safety while services already run**
|
||||||
|
|
||||||
|
Run the launcher with browser opening disabled, wait for `运行中`, send `INT`, and verify the pre-existing Java/ngrok PIDs still listen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ERP_RUN_NO_OPEN=1 ./run.command
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: output says both services are reused; after `Ctrl+C`, ports `8091` and `4040` remain listening.
|
||||||
|
|
||||||
|
- [x] **Step 3: Verify cold start and owned cleanup**
|
||||||
|
|
||||||
|
Stop only the known current ERP/ngrok sessions, run `ERP_RUN_NO_OPEN=1 ./run.command`, and verify local/public behavior:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS http://127.0.0.1:8091/ | grep -F '<title>凯迪协同办公平台</title>'
|
||||||
|
curl -fsS -H 'ngrok-skip-browser-warning: true' https://resonant-elated-launder.ngrok-free.dev/ | grep -F '<title>凯迪协同办公平台</title>'
|
||||||
|
```
|
||||||
|
|
||||||
|
Then post `admin/123456`, use the returned token for `/api/oa/dev-projects`, and require API `code=0`. Send `Ctrl+C` and verify both launcher-owned listeners disappear.
|
||||||
|
|
||||||
|
- [x] **Step 4: Start the final user-facing instance**
|
||||||
|
|
||||||
|
Run `./run.command` normally in a persistent terminal session.
|
||||||
|
|
||||||
|
Expected: the public URL opens, both listeners remain active, and the terminal shows the stop instruction.
|
||||||
|
|
||||||
|
- [x] **Step 5: Commit the implementation with Lore trailers**
|
||||||
|
|
||||||
|
Stage only the two implementation files and this plan:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add run.command tests/run-command.test.sh docs/superpowers/plans/2026-07-15-run-command.md
|
||||||
|
git commit -m "Make local ERP previews one double-click away" -m "Constraint: Finder launch must supervise Java and ngrok in one visible Terminal
|
||||||
|
Rejected: LaunchAgent | obscures ownership and makes safe cleanup harder
|
||||||
|
Confidence: high
|
||||||
|
Scope-risk: narrow
|
||||||
|
Directive: Never kill listeners not recorded as launcher-owned PIDs
|
||||||
|
Tested: Shell unit suite, repeat-start reuse, cold start, local/public login and protected API smoke
|
||||||
|
Not-tested: Finder Gatekeeper behavior on a different Mac"
|
||||||
|
```
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# `run.command` 一键启动器设计
|
||||||
|
|
||||||
|
日期:2026-07-15
|
||||||
|
状态:用户已确认设计方向,等待书面规格复核
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
在 macOS Finder 中双击项目根目录的 `run.command`,用一个持续打开的终端窗口启动并监督当前 ERP 主链路:
|
||||||
|
|
||||||
|
- Spring Boot 单体应用监听 `8091`,同时提供 Vue 前端和 `/api/oa/*` 后端。
|
||||||
|
- ngrok 固定域名 `https://resonant-elated-launder.ngrok-free.dev` 转发到 `8091`。
|
||||||
|
- 两项服务就绪后自动打开公网地址。
|
||||||
|
- 用户按 `Ctrl+C` 或关闭终端时,只停止本次脚本启动的进程。
|
||||||
|
|
||||||
|
不启动已弃用的 OFBiz、独立 Vite 开发服务器、PostgreSQL 或 nginx;当前可交付预览已包含在 Spring Boot JAR 中。
|
||||||
|
|
||||||
|
## 启动体验
|
||||||
|
|
||||||
|
1. 脚本以自身所在目录作为项目根目录,不依赖 Finder/Terminal 的当前工作目录。
|
||||||
|
2. 终端逐步显示“环境检查、后端启动、ngrok 启动、公网验证、运行中”。
|
||||||
|
3. 后端通常约 15 秒就绪;脚本按真实 HTTP 状态等待,不使用固定时长假定成功。
|
||||||
|
4. 成功后显示本地地址、公网地址、日志路径和停止方法,并调用 macOS `open` 打开公网地址。
|
||||||
|
5. 终端保持运行并监控服务;任一由脚本启动的子进程意外退出时,脚本报告错误并输出对应日志末尾。
|
||||||
|
|
||||||
|
## 组件与流程
|
||||||
|
|
||||||
|
### 1. 环境与路径
|
||||||
|
|
||||||
|
- 项目根目录:由 `run.command` 的绝对路径推导。
|
||||||
|
- 后端工作目录:固定为 `<项目>/oa-backend`,确保相对数据源 `./data/oa.db` 始终指向 `oa-backend/data/oa.db`。
|
||||||
|
- Java:优先使用项目内 `.jdks/jdk-17.0.19+10/Contents/Home/bin/java`。
|
||||||
|
- JAR:`oa-backend/build/libs/oa-backend-0.1.0.jar`。
|
||||||
|
- ngrok:优先使用 `$HOME/bin/ngrok`,否则回退到 `PATH` 中的 `ngrok`。
|
||||||
|
- 日志:写入 `${TMPDIR:-/tmp}/kaidi-erp-run/`,不污染 Git 工作区。
|
||||||
|
|
||||||
|
缺少 Java、JAR、ngrok、`curl`、`lsof` 或 `python3` 时立即给出可操作错误并退出。
|
||||||
|
|
||||||
|
### 2. 后端复用与启动
|
||||||
|
|
||||||
|
- 若 `8091` 无监听进程,脚本从 `oa-backend` 目录启动 Java,并记录为“本脚本拥有”。
|
||||||
|
- 若 `8091` 已监听,脚本请求根路径并核对页面标题“凯迪协同办公平台”:匹配则复用;不匹配则报端口冲突,不杀进程。
|
||||||
|
- 启动后最多等待 60 秒,要求首页 HTTP 200 且登录接口能返回标准 JSON;超时或进程提前退出时显示后端日志末尾。
|
||||||
|
|
||||||
|
### 3. ngrok 复用与启动
|
||||||
|
|
||||||
|
- 查询本地 ngrok API `127.0.0.1:4040/api/tunnels`。
|
||||||
|
- 若已有固定公网域名且目标为 `http://localhost:8091`,直接复用,不再启动第二个 ngrok。
|
||||||
|
- 若 `4040` 被占用但不存在正确隧道,安全报错,不覆盖现有隧道。
|
||||||
|
- 否则启动 `ngrok http --url=resonant-elated-launder.ngrok-free.dev 8091`,记录为“本脚本拥有”,最多等待 30 秒确认隧道登记成功。
|
||||||
|
|
||||||
|
### 4. 公网验证与浏览器
|
||||||
|
|
||||||
|
- 使用 `ngrok-skip-browser-warning: true` 请求公网首页,必须返回 HTTP 200 且标题正确。
|
||||||
|
- 验证成功后自动打开公网地址。
|
||||||
|
- 环境变量 `ERP_RUN_NO_OPEN=1` 可禁止自动打开,供测试或无界面环境使用。
|
||||||
|
|
||||||
|
### 5. 生命周期与清理
|
||||||
|
|
||||||
|
- `INT`、`TERM`、`EXIT` 使用同一清理函数。
|
||||||
|
- 只向脚本保存的 Java/ngrok PID 发送 `TERM`,等待短时间后才对仍未退出的自有 PID 使用 `KILL`。
|
||||||
|
- 复用的既有服务 PID 不写入“自有 PID”,因此 `Ctrl+C` 不会误杀它们。
|
||||||
|
- 运行阶段周期性检查本地首页和公网隧道;异常时提示并保留日志证据。
|
||||||
|
|
||||||
|
## 可测试性
|
||||||
|
|
||||||
|
脚本使用 Bash 函数组织,并仅在直接执行时进入 `main`;测试可 `source run.command` 后单独验证函数。
|
||||||
|
|
||||||
|
新增 `tests/run-command.test.sh`,覆盖:
|
||||||
|
|
||||||
|
1. `bash -n run.command` 语法检查。
|
||||||
|
2. 脚本从任意当前目录都能解析正确项目根目录。
|
||||||
|
3. 已存在且健康的 `8091` 服务会被复用。
|
||||||
|
4. 非本项目进程占用 `8091` 时返回错误且不会发送终止信号。
|
||||||
|
5. 清理函数只停止记录为本脚本启动的 PID,不停止外部 PID。
|
||||||
|
6. `ERP_RUN_NO_OPEN=1` 时不调用浏览器。
|
||||||
|
7. 实机冒烟:运行启动器,确认本地首页、公网首页、登录和受保护业务接口均成功;随后 `Ctrl+C` 验证自有进程退出。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- Finder 双击一次即可完成后端、ngrok、公网验证和浏览器打开。
|
||||||
|
- 重复双击不会产生第二个 Java/ngrok,也不会杀死已运行实例。
|
||||||
|
- 成功路径明确显示两个地址和 `Ctrl+C` 停止说明。
|
||||||
|
- 失败路径在 60 秒内结束等待,说明失败阶段并展示相关日志。
|
||||||
|
- `run.command` 与测试脚本具有可执行权限,Shell 回归测试全部通过。
|
||||||
Executable
+663
@@ -0,0 +1,663 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
GITEA_BASE_URL="${ERP_GITEA_BASE_URL:-https://git.awaioi.com}"
|
||||||
|
REPOSITORY="${ERP_UPDATE_REPOSITORY:-awaioi/ERP}"
|
||||||
|
REQUESTED_VERSION="${ERP_INSTALL_VERSION:-}"
|
||||||
|
INSTALL_ROOT="${ERP_INSTALL_ROOT:-}"
|
||||||
|
ALLOW_INSECURE="${ERP_UPDATE_ALLOW_INSECURE_HTTP:-0}"
|
||||||
|
NO_SERVICE="${ERP_INSTALL_NO_SERVICE:-0}"
|
||||||
|
TOKEN="${ERP_GITEA_TOKEN:-${OA_UPDATE_TOKEN:-}}"
|
||||||
|
PUBLIC_URL="${ERP_PUBLIC_URL:-}"
|
||||||
|
PUBLIC_KEY='-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEAaErhcY8WZIZvPILmYnfjndVBAdOuWkvhaoHIWqNNdxI=
|
||||||
|
-----END PUBLIC KEY-----'
|
||||||
|
TMP_DIR=""
|
||||||
|
|
||||||
|
say() { printf '[ERP Install] %s\n' "$*"; }
|
||||||
|
fail() { printf '[ERP Install] ERROR: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: install.sh [options]
|
||||||
|
--gitea-url URL Gitea public base URL (default: https://git.awaioi.com)
|
||||||
|
--repository O/R Release repository (default: awaioi/ERP)
|
||||||
|
--version VERSION Install one exact stable release
|
||||||
|
--install-root PATH Override installation directory
|
||||||
|
--public-url URL Public URL used for the one-time web setup link
|
||||||
|
--allow-insecure Development only: allow plain HTTP release URLs
|
||||||
|
--no-service Start without systemd/launchd; online update stays disabled
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--gitea-url) [[ $# -ge 2 ]] || fail '--gitea-url requires a value'; GITEA_BASE_URL="$2"; shift 2 ;;
|
||||||
|
--repository) [[ $# -ge 2 ]] || fail '--repository requires a value'; REPOSITORY="$2"; shift 2 ;;
|
||||||
|
--version) [[ $# -ge 2 ]] || fail '--version requires a value'; REQUESTED_VERSION="$2"; shift 2 ;;
|
||||||
|
--install-root) [[ $# -ge 2 ]] || fail '--install-root requires a value'; INSTALL_ROOT="$2"; shift 2 ;;
|
||||||
|
--public-url) [[ $# -ge 2 ]] || fail '--public-url requires a value'; PUBLIC_URL="$2"; shift 2 ;;
|
||||||
|
--allow-insecure) ALLOW_INSECURE=1; shift ;;
|
||||||
|
--no-service) NO_SERVICE=1; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) fail "unknown option: $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then
|
||||||
|
rm -rf "$TMP_DIR"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
detect_platform() {
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Linux) PLATFORM=linux ;;
|
||||||
|
Darwin) PLATFORM=darwin ;;
|
||||||
|
*) fail "unsupported operating system: $(uname -s)" ;;
|
||||||
|
esac
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64|amd64) ARCH=amd64 ;;
|
||||||
|
arm64|aarch64) ARCH=arm64 ;;
|
||||||
|
*) fail "unsupported CPU architecture: $(uname -m); 32-bit systems are not supported" ;;
|
||||||
|
esac
|
||||||
|
if [[ "$PLATFORM" == "linux" && "$(id -u)" != "0" ]]; then
|
||||||
|
fail 'Linux installation requires root; use: curl ... | sudo -E bash'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_service_manager() {
|
||||||
|
[[ "$PLATFORM" != "linux" || "$NO_SERVICE" == "1" ]] && return 0
|
||||||
|
command -v systemctl >/dev/null 2>&1 \
|
||||||
|
|| fail 'systemd is required for a production Linux install; use --no-service only for development'
|
||||||
|
[[ -d /run/systemd/system ]] \
|
||||||
|
|| fail 'systemd is not running on this Linux host; use --no-service only for development'
|
||||||
|
}
|
||||||
|
|
||||||
|
java_major() {
|
||||||
|
local java_bin="${1:-java}"
|
||||||
|
"$java_bin" -version 2>&1 | awk -F'[".]' '/version/ {print $2; exit}'
|
||||||
|
}
|
||||||
|
|
||||||
|
has_java_17() {
|
||||||
|
command -v java >/dev/null 2>&1 || return 1
|
||||||
|
local major
|
||||||
|
major="$(java_major "$(command -v java)")"
|
||||||
|
[[ "$major" =~ ^[0-9]+$ && "$major" -ge 17 ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
has_openssl_3_ed25519() {
|
||||||
|
command -v openssl >/dev/null 2>&1 || return 1
|
||||||
|
local version algorithms
|
||||||
|
version="$(openssl version 2>/dev/null || true)"
|
||||||
|
[[ "$version" =~ ^OpenSSL[[:space:]]3\. ]] || return 1
|
||||||
|
algorithms="$(openssl list -public-key-algorithms 2>/dev/null || true)"
|
||||||
|
grep -qi ED25519 <<< "$algorithms"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_linux_dependencies() {
|
||||||
|
local need_java="$1"
|
||||||
|
local packages=(ca-certificates)
|
||||||
|
command -v curl >/dev/null 2>&1 || packages+=(curl)
|
||||||
|
command -v tar >/dev/null 2>&1 || packages+=(tar)
|
||||||
|
command -v python3 >/dev/null 2>&1 || packages+=(python3)
|
||||||
|
has_openssl_3_ed25519 || packages+=(openssl)
|
||||||
|
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
[[ "$need_java" == "0" ]] || packages+=(openjdk-17-jre-headless)
|
||||||
|
say 'Installing missing runtime packages with apt-get...'
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y "${packages[@]}"
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
[[ "$need_java" == "0" ]] || packages+=(java-17-openjdk-headless)
|
||||||
|
say 'Installing missing runtime packages with dnf...'
|
||||||
|
dnf install -y "${packages[@]}"
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
[[ "$need_java" == "0" ]] || packages+=(java-17-openjdk-headless)
|
||||||
|
say 'Installing missing runtime packages with yum...'
|
||||||
|
yum install -y "${packages[@]}"
|
||||||
|
elif command -v zypper >/dev/null 2>&1; then
|
||||||
|
[[ "$need_java" == "0" ]] || packages+=(java-17-openjdk-headless)
|
||||||
|
say 'Installing missing runtime packages with zypper...'
|
||||||
|
zypper --non-interactive install "${packages[@]}"
|
||||||
|
else
|
||||||
|
fail 'no supported package manager found (apt-get, dnf, yum, or zypper is required)'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_macos_dependencies() {
|
||||||
|
command -v brew >/dev/null 2>&1 || fail 'Homebrew is required to install missing macOS packages'
|
||||||
|
local packages=()
|
||||||
|
has_java_17 || packages+=(openjdk@17)
|
||||||
|
command -v python3 >/dev/null 2>&1 || packages+=(python@3)
|
||||||
|
has_openssl_3_ed25519 || packages+=(openssl@3)
|
||||||
|
if (( ${#packages[@]} > 0 )); then
|
||||||
|
say 'Installing missing runtime packages with Homebrew...'
|
||||||
|
brew install "${packages[@]}"
|
||||||
|
fi
|
||||||
|
local java_prefix openssl_prefix
|
||||||
|
java_prefix="$(brew --prefix openjdk@17 2>/dev/null || true)"
|
||||||
|
openssl_prefix="$(brew --prefix openssl@3 2>/dev/null || true)"
|
||||||
|
[[ -z "$java_prefix" ]] || export PATH="$java_prefix/bin:$PATH"
|
||||||
|
[[ -z "$openssl_prefix" ]] || export PATH="$openssl_prefix/bin:$PATH"
|
||||||
|
}
|
||||||
|
|
||||||
|
check_and_install_dependencies() {
|
||||||
|
local need_install=0 need_java=0
|
||||||
|
has_java_17 || { need_install=1; need_java=1; }
|
||||||
|
command -v curl >/dev/null 2>&1 || need_install=1
|
||||||
|
command -v tar >/dev/null 2>&1 || need_install=1
|
||||||
|
command -v python3 >/dev/null 2>&1 || need_install=1
|
||||||
|
has_openssl_3_ed25519 || need_install=1
|
||||||
|
|
||||||
|
if [[ "$need_install" == "1" ]]; then
|
||||||
|
if [[ "$PLATFORM" == "linux" ]]; then
|
||||||
|
install_linux_dependencies "$need_java"
|
||||||
|
else
|
||||||
|
install_macos_dependencies
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
for command in curl tar python3 openssl java; do
|
||||||
|
command -v "$command" >/dev/null 2>&1 || fail "missing command after installation: $command"
|
||||||
|
done
|
||||||
|
has_java_17 || fail 'Java 17 or newer is required and could not be installed'
|
||||||
|
has_openssl_3_ed25519 || fail 'OpenSSL 3 with Ed25519 support is required and could not be installed'
|
||||||
|
JAVA_BIN="$(command -v java)"
|
||||||
|
say "Using Java $(java_major "$JAVA_BIN") at $JAVA_BIN"
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_download_url() {
|
||||||
|
case "$1" in
|
||||||
|
https://*) return 0 ;;
|
||||||
|
http://*) [[ "$ALLOW_INSECURE" == "1" || "$ALLOW_INSECURE" == "true" ]] && return 0 ;;
|
||||||
|
esac
|
||||||
|
fail 'release server must use HTTPS (use --allow-insecure only for an isolated test server)'
|
||||||
|
}
|
||||||
|
|
||||||
|
create_curl_config() {
|
||||||
|
CURL_CONFIG="$TMP_DIR/curl.conf"
|
||||||
|
{
|
||||||
|
printf 'silent\nshow-error\nfail\nlocation\nconnect-timeout = 15\nmax-time = 900\n'
|
||||||
|
if [[ "$ALLOW_INSECURE" == "1" || "$ALLOW_INSECURE" == "true" ]]; then
|
||||||
|
printf 'proto = "=http,https"\nproto-redir = "=http,https"\n'
|
||||||
|
else
|
||||||
|
printf 'proto = "=https"\nproto-redir = "=https"\n'
|
||||||
|
fi
|
||||||
|
if [[ -n "$TOKEN" ]]; then
|
||||||
|
[[ "$TOKEN" =~ ^[A-Za-z0-9._-]+$ ]] || fail 'invalid Gitea token format'
|
||||||
|
printf 'header = "Authorization: token %s"\n' "$TOKEN"
|
||||||
|
fi
|
||||||
|
} > "$CURL_CONFIG"
|
||||||
|
chmod 600 "$CURL_CONFIG"
|
||||||
|
}
|
||||||
|
|
||||||
|
download() {
|
||||||
|
validate_download_url "$1"
|
||||||
|
curl --config "$CURL_CONFIG" --output "$2" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
download_release() {
|
||||||
|
[[ "$REPOSITORY" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,99}/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$ ]] \
|
||||||
|
|| fail 'invalid Gitea repository'
|
||||||
|
local owner="${REPOSITORY%%/*}" repo="${REPOSITORY#*/}"
|
||||||
|
local release_json="$TMP_DIR/release.json"
|
||||||
|
download "${GITEA_BASE_URL%/}/api/v1/repos/$owner/$repo/releases/latest" "$release_json" \
|
||||||
|
|| fail 'unable to read the latest Gitea Release'
|
||||||
|
local selection="$TMP_DIR/selection" selection_error="$TMP_DIR/selection-error"
|
||||||
|
if ! python3 - "$release_json" "$REQUESTED_VERSION" > "$selection" 2> "$selection_error" <<'PY'
|
||||||
|
import json, re, sys
|
||||||
|
try:
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||||
|
release = json.load(handle)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
raise SystemExit("invalid release JSON")
|
||||||
|
tag = str(release.get("tag_name") or "").strip()
|
||||||
|
match = re.fullmatch(r"v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)", tag)
|
||||||
|
if not match or release.get("draft") or release.get("prerelease"):
|
||||||
|
raise SystemExit("invalid stable release")
|
||||||
|
version = match.group(1)
|
||||||
|
if "-" in version.split("+", 1)[0]:
|
||||||
|
raise SystemExit("prerelease rejected by stable installer")
|
||||||
|
requested = sys.argv[2].removeprefix("v")
|
||||||
|
if requested and requested != version:
|
||||||
|
raise SystemExit("requested version does not match latest release")
|
||||||
|
assets = {
|
||||||
|
str(a.get("name") or "")
|
||||||
|
for a in release.get("assets", [])
|
||||||
|
if isinstance(a, dict)
|
||||||
|
}
|
||||||
|
names = [
|
||||||
|
f"kaidi-erp-{version}.tar.gz",
|
||||||
|
f"kaidi-erp-installer-{version}.jar",
|
||||||
|
"SHA256SUMS",
|
||||||
|
"SHA256SUMS.sig",
|
||||||
|
]
|
||||||
|
if any(name not in assets for name in names):
|
||||||
|
raise SystemExit("release assets missing")
|
||||||
|
print("\t".join([version, tag]))
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
local reason="invalid Release metadata"
|
||||||
|
[[ ! -s "$selection_error" ]] || reason="$(<"$selection_error")"
|
||||||
|
fail "$reason"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local release_tag release_base archive_url installer_url sums_url signature_url
|
||||||
|
IFS=$'\t' read -r VERSION release_tag < "$selection"
|
||||||
|
ARCHIVE_NAME="kaidi-erp-${VERSION}.tar.gz"
|
||||||
|
INSTALLER_NAME="kaidi-erp-installer-${VERSION}.jar"
|
||||||
|
release_base="${GITEA_BASE_URL%/}/$owner/$repo/releases/download/$release_tag"
|
||||||
|
archive_url="$release_base/$ARCHIVE_NAME"
|
||||||
|
installer_url="$release_base/$INSTALLER_NAME"
|
||||||
|
sums_url="$release_base/SHA256SUMS"
|
||||||
|
signature_url="$release_base/SHA256SUMS.sig"
|
||||||
|
ARCHIVE_PATH="$TMP_DIR/$ARCHIVE_NAME"
|
||||||
|
INSTALLER_PATH="$TMP_DIR/$INSTALLER_NAME"
|
||||||
|
download "$archive_url" "$ARCHIVE_PATH"
|
||||||
|
download "$installer_url" "$INSTALLER_PATH"
|
||||||
|
download "$sums_url" "$TMP_DIR/SHA256SUMS"
|
||||||
|
download "$signature_url" "$TMP_DIR/SHA256SUMS.sig"
|
||||||
|
}
|
||||||
|
|
||||||
|
checksum_for() {
|
||||||
|
python3 - "$TMP_DIR/SHA256SUMS" "$1" <<'PY'
|
||||||
|
import re, sys
|
||||||
|
for line in open(sys.argv[1], encoding="utf-8"):
|
||||||
|
match = re.fullmatch(r"([0-9a-fA-F]{64})\s+\*?(.+?)\s*", line)
|
||||||
|
if match and match.group(2) == sys.argv[2]:
|
||||||
|
print(match.group(1).lower())
|
||||||
|
break
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
actual_checksum() {
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
sha256sum "$1" | awk '{print $1}'
|
||||||
|
else
|
||||||
|
shasum -a 256 "$1" | awk '{print $1}'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_release() {
|
||||||
|
printf '%s\n' "$PUBLIC_KEY" > "$TMP_DIR/release-public-key.pem"
|
||||||
|
openssl pkeyutl -verify -rawin -pubin -inkey "$TMP_DIR/release-public-key.pem" \
|
||||||
|
-sigfile "$TMP_DIR/SHA256SUMS.sig" -in "$TMP_DIR/SHA256SUMS" >/dev/null \
|
||||||
|
|| fail 'Release Ed25519 signature verification failed'
|
||||||
|
|
||||||
|
local name path expected actual
|
||||||
|
for name in "$ARCHIVE_NAME" "$INSTALLER_NAME"; do
|
||||||
|
if [[ "$name" == "$ARCHIVE_NAME" ]]; then path="$ARCHIVE_PATH"; else path="$INSTALLER_PATH"; fi
|
||||||
|
expected="$(checksum_for "$name")"
|
||||||
|
[[ "$expected" =~ ^[0-9a-f]{64}$ ]] || fail "release checksum is missing for $name"
|
||||||
|
actual="$(actual_checksum "$path")"
|
||||||
|
[[ "$actual" == "$expected" ]] || fail "release checksum verification failed for $name"
|
||||||
|
done
|
||||||
|
|
||||||
|
python3 - "$ARCHIVE_PATH" <<'PY'
|
||||||
|
import pathlib, sys, tarfile
|
||||||
|
with tarfile.open(sys.argv[1], "r:gz") as archive:
|
||||||
|
for item in archive.getmembers():
|
||||||
|
path = pathlib.PurePosixPath(item.name)
|
||||||
|
if path.is_absolute() or ".." in path.parts or item.issym() or item.islnk():
|
||||||
|
raise SystemExit(f"unsafe archive member: {item.name}")
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
shell_setting() {
|
||||||
|
printf '%s=' "$1"
|
||||||
|
printf '%q\n' "$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_layout() {
|
||||||
|
if [[ "$PLATFORM" == "linux" ]]; then
|
||||||
|
ERP_USER="${ERP_SERVICE_USER:-kaidi-erp}"
|
||||||
|
[[ "$ERP_USER" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]] || fail 'invalid Linux service user name'
|
||||||
|
id "$ERP_USER" >/dev/null 2>&1 \
|
||||||
|
|| useradd --system --home-dir "$INSTALL_ROOT" --shell /usr/sbin/nologin "$ERP_USER"
|
||||||
|
ERP_GROUP="$(id -gn "$ERP_USER" 2>/dev/null)" \
|
||||||
|
|| fail "unable to resolve the primary group for Linux service user: $ERP_USER"
|
||||||
|
CONFIG_ROOT="${ERP_CONFIG_ROOT:-/etc/kaidi-erp}"
|
||||||
|
STATE_ROOT="${ERP_STATE_ROOT:-/var/lib/kaidi-erp}"
|
||||||
|
LOG_ROOT="${ERP_LOG_ROOT:-/var/log/kaidi-erp}"
|
||||||
|
local path
|
||||||
|
for path in "$INSTALL_ROOT" "$CONFIG_ROOT" "$STATE_ROOT" "$LOG_ROOT"; do
|
||||||
|
[[ "$path" =~ ^/[A-Za-z0-9._/@:+-]+$ ]] \
|
||||||
|
|| fail "Linux installation paths must be absolute and contain only safe characters: $path"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
ERP_USER="$(id -un)"
|
||||||
|
ERP_GROUP="$(id -gn)"
|
||||||
|
CONFIG_ROOT="${ERP_CONFIG_ROOT:-$INSTALL_ROOT/config}"
|
||||||
|
STATE_ROOT="${ERP_STATE_ROOT:-$INSTALL_ROOT/state}"
|
||||||
|
LOG_ROOT="${ERP_LOG_ROOT:-$HOME/Library/Logs/KaidiERP}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$INSTALL_ROOT/releases" "$INSTALL_ROOT/installer" "$INSTALL_ROOT/run" \
|
||||||
|
"$INSTALL_ROOT/backups" "$CONFIG_ROOT" "$STATE_ROOT" "$LOG_ROOT"
|
||||||
|
if [[ "$PLATFORM" == "linux" ]]; then
|
||||||
|
chown -R "$ERP_USER:$ERP_GROUP" "$INSTALL_ROOT" "$CONFIG_ROOT" "$STATE_ROOT" "$LOG_ROOT"
|
||||||
|
chmod 750 "$CONFIG_ROOT" "$STATE_ROOT"
|
||||||
|
fi
|
||||||
|
[[ ! -e "$STATE_ROOT/install.lock" ]] || fail 'Kaidi ERP is already installed; use the system update page'
|
||||||
|
[[ ! -e "$STATE_ROOT/install.pending" ]] || fail 'a previous installation is still pending; inspect the service before retrying'
|
||||||
|
}
|
||||||
|
|
||||||
|
install_release_files() {
|
||||||
|
local unpack="$TMP_DIR/unpack"
|
||||||
|
mkdir -p "$unpack"
|
||||||
|
tar -xzf "$ARCHIVE_PATH" -C "$unpack"
|
||||||
|
local source="$unpack/kaidi-erp-${VERSION}"
|
||||||
|
[[ -r "$source/app/kaidi-erp.jar" && -x "$source/bin/erp-run" && -x "$source/bin/erp-update" ]] \
|
||||||
|
|| fail 'release archive is incomplete'
|
||||||
|
[[ -r "$source/VERSION" && "$(<"$source/VERSION")" == "$VERSION" ]] \
|
||||||
|
|| fail 'release archive version mismatch'
|
||||||
|
|
||||||
|
local destination="$INSTALL_ROOT/releases/$VERSION" replaced=""
|
||||||
|
if [[ -e "$destination" ]]; then
|
||||||
|
replaced="${destination}.replaced-$$"
|
||||||
|
mv "$destination" "$replaced"
|
||||||
|
fi
|
||||||
|
if ! mv "$source" "$destination"; then
|
||||||
|
[[ -z "$replaced" || ! -e "$replaced" ]] || mv "$replaced" "$destination"
|
||||||
|
fail 'unable to write the release directory'
|
||||||
|
fi
|
||||||
|
[[ -z "$replaced" ]] || rm -rf "$replaced"
|
||||||
|
|
||||||
|
local installer_tmp="$INSTALL_ROOT/installer/.kaidi-erp-installer.jar.tmp-$$"
|
||||||
|
cp "$INSTALLER_PATH" "$installer_tmp"
|
||||||
|
chmod 600 "$installer_tmp"
|
||||||
|
mv "$installer_tmp" "$INSTALL_ROOT/installer/kaidi-erp-installer.jar"
|
||||||
|
|
||||||
|
python3 - "$INSTALL_ROOT/current" "releases/$VERSION" <<'PY'
|
||||||
|
import os, sys
|
||||||
|
link, target = sys.argv[1:]
|
||||||
|
temporary = f"{link}.new-{os.getpid()}"
|
||||||
|
try:
|
||||||
|
os.symlink(target, temporary)
|
||||||
|
os.replace(temporary, link)
|
||||||
|
finally:
|
||||||
|
if os.path.lexists(temporary):
|
||||||
|
os.unlink(temporary)
|
||||||
|
PY
|
||||||
|
if [[ "$PLATFORM" == "linux" ]]; then
|
||||||
|
chown -R "$ERP_USER:$ERP_GROUP" "$destination" "$INSTALL_ROOT/installer" "$INSTALL_ROOT/current"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
write_bootstrap_configuration() {
|
||||||
|
CONFIG_FILE="$CONFIG_ROOT/erp.env"
|
||||||
|
PUBLIC_KEY_FILE="$CONFIG_ROOT/release-public-key.pem"
|
||||||
|
PENDING_FILE="$STATE_ROOT/install.pending"
|
||||||
|
INSTALL_LOCK_FILE="$STATE_ROOT/install.lock"
|
||||||
|
OPERATION_LOCK_FILE="$STATE_ROOT/install-operation.lock"
|
||||||
|
SETUP_TOKEN="$(openssl rand -hex 32)" || fail 'unable to generate the one-time setup token'
|
||||||
|
local update_enabled=true port="${ERP_SERVER_PORT:-8091}"
|
||||||
|
[[ "$NO_SERVICE" != "1" ]] || update_enabled=false
|
||||||
|
|
||||||
|
printf '%s\n' "$PUBLIC_KEY" > "$PUBLIC_KEY_FILE"
|
||||||
|
{
|
||||||
|
shell_setting ERP_INSTALL_ROOT "$INSTALL_ROOT"
|
||||||
|
shell_setting ERP_CONFIG_FILE "$CONFIG_FILE"
|
||||||
|
shell_setting ERP_RUN_DIR "$INSTALL_ROOT/run"
|
||||||
|
shell_setting ERP_STATE_FILE "$STATE_ROOT/update-state.json"
|
||||||
|
shell_setting ERP_INSTALL_PENDING_FILE "$PENDING_FILE"
|
||||||
|
shell_setting ERP_INSTALL_LOCK_FILE "$INSTALL_LOCK_FILE"
|
||||||
|
shell_setting ERP_INSTALL_OPERATION_LOCK_FILE "$OPERATION_LOCK_FILE"
|
||||||
|
shell_setting ERP_INSTALL_LOG_FILE "$STATE_ROOT/install-formal.log"
|
||||||
|
shell_setting ERP_INSTALL_HEALTH_TIMEOUT_SECONDS "${ERP_INSTALL_HEALTH_TIMEOUT_SECONDS:-240}"
|
||||||
|
shell_setting ERP_SETUP_TOKEN "$SETUP_TOKEN"
|
||||||
|
shell_setting ERP_INSTALLER_JAR "$INSTALL_ROOT/installer/kaidi-erp-installer.jar"
|
||||||
|
shell_setting ERP_JAR_PATH "$INSTALL_ROOT/current/app/kaidi-erp.jar"
|
||||||
|
shell_setting ERP_JAVA_BIN "$JAVA_BIN"
|
||||||
|
shell_setting ERP_RELEASE_VERSION "$VERSION"
|
||||||
|
shell_setting ERP_HEALTH_URL "http://127.0.0.1:${port}/api/oa/health"
|
||||||
|
shell_setting ERP_UPDATE_PUBLIC_KEY_FILE "$PUBLIC_KEY_FILE"
|
||||||
|
shell_setting ERP_UPDATE_REQUIRE_SIGNATURE true
|
||||||
|
shell_setting ERP_UPDATE_BACKUP_MODE "${ERP_UPDATE_BACKUP_MODE:-none}"
|
||||||
|
shell_setting ERP_UPDATE_HEALTH_TIMEOUT_SECONDS "${ERP_UPDATE_HEALTH_TIMEOUT_SECONDS:-120}"
|
||||||
|
shell_setting ERP_UPDATE_HEALTH_POLL_SECONDS "${ERP_UPDATE_HEALTH_POLL_SECONDS:-2}"
|
||||||
|
shell_setting SERVER_PORT "$port"
|
||||||
|
shell_setting OA_UPDATE_ENABLED "$update_enabled"
|
||||||
|
shell_setting OA_UPDATE_GITEA_BASE_URL "$GITEA_BASE_URL"
|
||||||
|
shell_setting OA_UPDATE_REPOSITORY "$REPOSITORY"
|
||||||
|
shell_setting OA_UPDATE_CHANNEL stable
|
||||||
|
shell_setting OA_UPDATE_TOKEN "$TOKEN"
|
||||||
|
shell_setting OA_UPDATE_HELPER_COMMAND "$INSTALL_ROOT/current/bin/erp-update"
|
||||||
|
shell_setting OA_UPDATE_STATE_FILE "$STATE_ROOT/update-state.json"
|
||||||
|
shell_setting OA_UPDATE_ALLOW_INSECURE_HTTP "$ALLOW_INSECURE"
|
||||||
|
} > "$CONFIG_FILE"
|
||||||
|
if [[ "$PLATFORM" == "linux" ]]; then
|
||||||
|
chown "$ERP_USER:$ERP_GROUP" "$CONFIG_FILE" "$PUBLIC_KEY_FILE"
|
||||||
|
fi
|
||||||
|
chmod 600 "$CONFIG_FILE" "$PUBLIC_KEY_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
start_service() {
|
||||||
|
if [[ "$NO_SERVICE" == "1" ]]; then
|
||||||
|
nohup env ERP_INSTALL_ROOT="$INSTALL_ROOT" ERP_CONFIG_FILE="$CONFIG_FILE" \
|
||||||
|
"$INSTALL_ROOT/current/bin/erp-run" >> "$LOG_ROOT/erp.log" 2>> "$LOG_ROOT/erp-error.log" &
|
||||||
|
printf '%s\n' "$!" > "$INSTALL_ROOT/run/launcher.pid"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$PLATFORM" == "linux" ]]; then
|
||||||
|
# The updater shares this service cgroup and must outlive the Java process
|
||||||
|
# to verify the restarted release and roll back a failed health check.
|
||||||
|
cat > /etc/systemd/system/kaidi-erp.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Kaidi ERP
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
KillMode=process
|
||||||
|
User=$ERP_USER
|
||||||
|
Group=$ERP_GROUP
|
||||||
|
Environment="ERP_INSTALL_ROOT=$INSTALL_ROOT"
|
||||||
|
Environment="ERP_CONFIG_FILE=$CONFIG_FILE"
|
||||||
|
WorkingDirectory=$INSTALL_ROOT
|
||||||
|
ExecStart=$INSTALL_ROOT/current/bin/erp-run
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
TimeoutStopSec=90
|
||||||
|
SuccessExitStatus=143
|
||||||
|
UMask=0077
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=$INSTALL_ROOT $CONFIG_ROOT $STATE_ROOT $LOG_ROOT
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
systemctl daemon-reload
|
||||||
|
if command -v systemd-analyze >/dev/null 2>&1; then
|
||||||
|
systemd-analyze verify /etc/systemd/system/kaidi-erp.service \
|
||||||
|
|| fail 'generated systemd unit is invalid; run systemd-analyze verify /etc/systemd/system/kaidi-erp.service'
|
||||||
|
fi
|
||||||
|
systemctl enable kaidi-erp.service
|
||||||
|
systemctl restart kaidi-erp.service
|
||||||
|
else
|
||||||
|
local agents="$HOME/Library/LaunchAgents" plist="$HOME/Library/LaunchAgents/com.kaidi.erp.plist"
|
||||||
|
mkdir -p "$agents"
|
||||||
|
python3 - "$plist" "$INSTALL_ROOT" "$CONFIG_FILE" "$LOG_ROOT" <<'PY'
|
||||||
|
import plistlib, sys
|
||||||
|
path, root, config, logs = sys.argv[1:]
|
||||||
|
payload = {
|
||||||
|
"Label": "com.kaidi.erp",
|
||||||
|
"ProgramArguments": [f"{root}/current/bin/erp-run"],
|
||||||
|
"EnvironmentVariables": {"ERP_INSTALL_ROOT": root, "ERP_CONFIG_FILE": config},
|
||||||
|
"WorkingDirectory": root,
|
||||||
|
"RunAtLoad": True,
|
||||||
|
"KeepAlive": True,
|
||||||
|
"ThrottleInterval": 3,
|
||||||
|
"StandardOutPath": f"{logs}/erp.log",
|
||||||
|
"StandardErrorPath": f"{logs}/erp-error.log",
|
||||||
|
}
|
||||||
|
with open(path, "wb") as handle:
|
||||||
|
plistlib.dump(payload, handle)
|
||||||
|
PY
|
||||||
|
launchctl bootout "gui/$(id -u)/com.kaidi.erp" >/dev/null 2>&1 || true
|
||||||
|
launchctl bootstrap "gui/$(id -u)" "$plist"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_installer() {
|
||||||
|
local url="http://127.0.0.1:${ERP_SERVER_PORT:-8091}/api/install/status"
|
||||||
|
local deadline=$((SECONDS + 120))
|
||||||
|
while (( SECONDS < deadline )); do
|
||||||
|
if curl -fsS --connect-timeout 2 --max-time 5 \
|
||||||
|
-H "X-Setup-Token: $SETUP_TOKEN" "$url" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
fail 'the web installer did not start within 120 seconds; inspect the kaidi-erp service log'
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_lan_address() {
|
||||||
|
local address=""
|
||||||
|
if command -v hostname >/dev/null 2>&1; then
|
||||||
|
address="$(hostname -I 2>/dev/null | awk '{for (i=1; i<=NF; i++) if ($i ~ /^[0-9]+\./) {print $i; exit}}' || true)"
|
||||||
|
fi
|
||||||
|
if [[ -z "$address" && "$PLATFORM" == "linux" ]] && command -v ip >/dev/null 2>&1; then
|
||||||
|
address="$(ip route get 1.1.1.1 2>/dev/null | awk '/src/ {for (i=1; i<=NF; i++) if ($i=="src") {print $(i+1); exit}}' || true)"
|
||||||
|
fi
|
||||||
|
if [[ -z "$address" && "$PLATFORM" == "darwin" ]] && command -v ipconfig >/dev/null 2>&1; then
|
||||||
|
address="$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
printf '%s' "${address:-127.0.0.1}"
|
||||||
|
}
|
||||||
|
|
||||||
|
is_ip_address() {
|
||||||
|
python3 - "$1" <<'PY' >/dev/null 2>&1
|
||||||
|
import ipaddress, sys
|
||||||
|
ipaddress.ip_address(sys.argv[1])
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_public_address() {
|
||||||
|
local endpoint address
|
||||||
|
for endpoint in \
|
||||||
|
https://api.ipify.org \
|
||||||
|
https://ifconfig.me/ip \
|
||||||
|
https://icanhazip.com; do
|
||||||
|
address="$(curl --silent --show-error --fail --location \
|
||||||
|
--proto '=https' --proto-redir '=https' \
|
||||||
|
--connect-timeout 3 --max-time 5 "$endpoint" 2>/dev/null \
|
||||||
|
| tr -d '[:space:]' | head -c 128 || true)"
|
||||||
|
if [[ -n "$address" ]] && is_ip_address "$address"; then
|
||||||
|
printf '%s' "$address"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_public_url() {
|
||||||
|
python3 - "$1" <<'PY'
|
||||||
|
import sys
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
|
value = sys.argv[1].strip()
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
port = parsed.port
|
||||||
|
except ValueError:
|
||||||
|
raise SystemExit("invalid public URL")
|
||||||
|
if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname:
|
||||||
|
raise SystemExit("public URL must use http or https")
|
||||||
|
if parsed.username is not None or parsed.password is not None:
|
||||||
|
raise SystemExit("public URL must not contain credentials")
|
||||||
|
if port is not None and not 1 <= port <= 65535:
|
||||||
|
raise SystemExit("invalid public URL port")
|
||||||
|
path = parsed.path or "/"
|
||||||
|
print(urlunsplit((parsed.scheme.lower(), parsed.netloc, path, parsed.query, parsed.fragment)))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
ip_setup_base_url() {
|
||||||
|
python3 - "$1" "$2" <<'PY'
|
||||||
|
import ipaddress, sys
|
||||||
|
address = ipaddress.ip_address(sys.argv[1])
|
||||||
|
host = f"[{address}]" if address.version == 6 else str(address)
|
||||||
|
print(f"http://{host}:{int(sys.argv[2])}/")
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
append_setup_token() {
|
||||||
|
python3 - "$1" "$2" <<'PY'
|
||||||
|
import sys
|
||||||
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
|
parsed = urlsplit(sys.argv[1])
|
||||||
|
query = [(key, value) for key, value in parse_qsl(parsed.query, keep_blank_values=True) if key != "token"]
|
||||||
|
query.append(("token", sys.argv[2]))
|
||||||
|
print(urlunsplit((parsed.scheme, parsed.netloc, parsed.path or "/", urlencode(query), parsed.fragment)))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
detect_platform
|
||||||
|
validate_service_manager
|
||||||
|
if [[ -z "$INSTALL_ROOT" ]]; then
|
||||||
|
if [[ "$PLATFORM" == "linux" ]]; then
|
||||||
|
INSTALL_ROOT=/opt/kaidi-erp
|
||||||
|
else
|
||||||
|
INSTALL_ROOT="$HOME/Library/Application Support/KaidiERP"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[[ -n "$GITEA_BASE_URL" ]] || fail 'Gitea URL is required; use --gitea-url or ERP_GITEA_BASE_URL'
|
||||||
|
validate_download_url "$GITEA_BASE_URL"
|
||||||
|
if [[ -n "$PUBLIC_URL" ]]; then
|
||||||
|
case "$PUBLIC_URL" in
|
||||||
|
http://*|https://*) ;;
|
||||||
|
*) fail 'public URL must start with http:// or https://' ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "Detected $PLATFORM/$ARCH"
|
||||||
|
check_and_install_dependencies
|
||||||
|
if [[ -n "$PUBLIC_URL" ]]; then
|
||||||
|
PUBLIC_URL="$(normalize_public_url "$PUBLIC_URL")" || fail 'invalid public URL'
|
||||||
|
fi
|
||||||
|
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kaidi-erp-install.XXXXXX")"
|
||||||
|
create_curl_config
|
||||||
|
say 'Downloading and verifying the signed release...'
|
||||||
|
download_release
|
||||||
|
verify_release
|
||||||
|
prepare_layout
|
||||||
|
install_release_files
|
||||||
|
write_bootstrap_configuration
|
||||||
|
start_service
|
||||||
|
wait_for_installer
|
||||||
|
|
||||||
|
local port="${ERP_SERVER_PORT:-8091}" lan_address public_address setup_base local_base lan_base
|
||||||
|
lan_address="$(detect_lan_address)"
|
||||||
|
local_base="http://127.0.0.1:${port}/"
|
||||||
|
lan_base="$(ip_setup_base_url "$lan_address" "$port")"
|
||||||
|
if [[ -n "$PUBLIC_URL" ]]; then
|
||||||
|
setup_base="$PUBLIC_URL"
|
||||||
|
elif public_address="$(detect_public_address)"; then
|
||||||
|
setup_base="$(ip_setup_base_url "$public_address" "$port")"
|
||||||
|
else
|
||||||
|
setup_base="$lan_base"
|
||||||
|
say 'Public IP detection was unavailable; using the LAN address'
|
||||||
|
fi
|
||||||
|
say "Kaidi ERP $VERSION installer is running"
|
||||||
|
say "Setup URL: $(append_setup_token "$setup_base" "$SETUP_TOKEN")"
|
||||||
|
say "Local URL: $(append_setup_token "$local_base" "$SETUP_TOKEN")"
|
||||||
|
if [[ "$lan_address" != "127.0.0.1" && "$lan_base" != "$setup_base" ]]; then
|
||||||
|
say "LAN URL: $(append_setup_token "$lan_base" "$SETUP_TOKEN")"
|
||||||
|
fi
|
||||||
|
say 'Complete PostgreSQL and administrator setup in the browser. The installer will remove itself after the formal service is healthy.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then
|
||||||
|
main "$@"
|
||||||
|
fi
|
||||||
+76
-5
@@ -5,7 +5,14 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group = 'com.kaidi'
|
group = 'com.kaidi'
|
||||||
version = '0.1.0'
|
version = providers.gradleProperty('releaseVersion')
|
||||||
|
.orElse(System.getenv('ERP_RELEASE_VERSION') ?: '0.1.0')
|
||||||
|
.get()
|
||||||
|
def flywayVersion = '11.20.3'
|
||||||
|
def postgresqlDriverVersion = '42.7.13'
|
||||||
|
def productionBuild = providers.gradleProperty('productionBuild')
|
||||||
|
.map { it.toBoolean() }
|
||||||
|
.orElse(false)
|
||||||
|
|
||||||
java {
|
java {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
@@ -16,6 +23,24 @@ repositories {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
installer {
|
||||||
|
java.srcDirs = ['src/installer/java']
|
||||||
|
resources.srcDirs = ['src/installer/resources']
|
||||||
|
}
|
||||||
|
installerTest {
|
||||||
|
java.srcDirs = ['src/installerTest/java']
|
||||||
|
resources.srcDirs = ['src/installerTest/resources']
|
||||||
|
compileClasspath += sourceSets.installer.output
|
||||||
|
runtimeClasspath += sourceSets.installer.output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configurations {
|
||||||
|
installerTestImplementation.extendsFrom testImplementation, installerImplementation
|
||||||
|
installerTestRuntimeOnly.extendsFrom testRuntimeOnly, installerRuntimeOnly
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||||
@@ -23,10 +48,17 @@ dependencies {
|
|||||||
// Real-time collaboration (yjs CRDT relay) over WebSocket.
|
// Real-time collaboration (yjs CRDT relay) over WebSocket.
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-websocket'
|
implementation 'org.springframework.boot:spring-boot-starter-websocket'
|
||||||
|
|
||||||
// SQLite driver + Hibernate community dialects (SQLite dialect lives here).
|
// Production database and versioned schema migrations.
|
||||||
// Version of hibernate-community-dialects is governed by Spring Boot's BOM.
|
implementation "org.flywaydb:flyway-core:$flywayVersion"
|
||||||
runtimeOnly 'org.xerial:sqlite-jdbc:3.45.3.0'
|
runtimeOnly "org.flywaydb:flyway-database-postgresql:$flywayVersion"
|
||||||
implementation 'org.hibernate.orm:hibernate-community-dialects'
|
runtimeOnly "org.postgresql:postgresql:$postgresqlDriverVersion"
|
||||||
|
|
||||||
|
// SQLite remains available to source-tree development and tests, but is
|
||||||
|
// deliberately absent from PostgreSQL-only production release artifacts.
|
||||||
|
if (!productionBuild.get()) {
|
||||||
|
runtimeOnly 'org.xerial:sqlite-jdbc:3.45.3.0'
|
||||||
|
runtimeOnly 'org.hibernate.orm:hibernate-community-dialects'
|
||||||
|
}
|
||||||
|
|
||||||
// jackson-databind arrives transitively via starter-web; declared for clarity.
|
// jackson-databind arrives transitively via starter-web; declared for clarity.
|
||||||
implementation 'com.fasterxml.jackson.core:jackson-databind'
|
implementation 'com.fasterxml.jackson.core:jackson-databind'
|
||||||
@@ -36,8 +68,47 @@ dependencies {
|
|||||||
implementation 'org.jsoup:jsoup:1.22.2'
|
implementation 'org.jsoup:jsoup:1.22.2'
|
||||||
|
|
||||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||||
|
|
||||||
|
installerImplementation 'org.springframework.boot:spring-boot-starter-web'
|
||||||
|
installerImplementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
|
installerImplementation "org.flywaydb:flyway-core:$flywayVersion"
|
||||||
|
installerRuntimeOnly "org.flywaydb:flyway-database-postgresql:$flywayVersion"
|
||||||
|
installerRuntimeOnly "org.postgresql:postgresql:$postgresqlDriverVersion"
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.named('test') {
|
tasks.named('test') {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.named('processInstallerResources') {
|
||||||
|
from('src/main/resources') {
|
||||||
|
include 'db/migration/postgresql/**'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register('installerTest', Test) {
|
||||||
|
description = 'Runs tests for the standalone web installer.'
|
||||||
|
group = 'verification'
|
||||||
|
testClassesDirs = sourceSets.installerTest.output.classesDirs
|
||||||
|
classpath = sourceSets.installerTest.runtimeClasspath
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register('installerBootJar', org.springframework.boot.gradle.tasks.bundling.BootJar) {
|
||||||
|
description = 'Builds the standalone first-run web installer.'
|
||||||
|
group = 'build'
|
||||||
|
archiveBaseName = 'kaidi-erp-installer'
|
||||||
|
archiveVersion = project.version
|
||||||
|
mainClass = 'com.kaidi.oa.install.InstallerApplication'
|
||||||
|
targetJavaVersion = JavaVersion.VERSION_17
|
||||||
|
classpath = sourceSets.installer.runtimeClasspath
|
||||||
|
dependsOn tasks.named('installerClasses')
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named('check') {
|
||||||
|
dependsOn tasks.named('installerTest')
|
||||||
|
}
|
||||||
|
|
||||||
|
springBoot {
|
||||||
|
buildInfo()
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.kaidi.oa.install;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
||||||
|
public final class InstallApiException extends RuntimeException {
|
||||||
|
|
||||||
|
private final HttpStatus status;
|
||||||
|
private final int code;
|
||||||
|
|
||||||
|
public InstallApiException(HttpStatus status, int code, String message) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpStatus status() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int code() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.kaidi.oa.install;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class InstallApiExceptionHandler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(InstallApiExceptionHandler.class);
|
||||||
|
|
||||||
|
@ExceptionHandler(InstallApiException.class)
|
||||||
|
public ResponseEntity<InstallApiResponse<Void>> handleInstallError(InstallApiException exception) {
|
||||||
|
return ResponseEntity.status(exception.status())
|
||||||
|
.body(InstallApiResponse.error(exception.code(), exception.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ResponseEntity<InstallApiResponse<Void>> handleValidation(MethodArgumentNotValidException exception) {
|
||||||
|
String message = exception.getBindingResult().getFieldErrors().stream()
|
||||||
|
.findFirst()
|
||||||
|
.map(error -> error.getDefaultMessage() == null ? "安装参数不正确" : error.getDefaultMessage())
|
||||||
|
.orElse("安装参数不正确");
|
||||||
|
return ResponseEntity.badRequest().body(InstallApiResponse.error(40001, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(NoResourceFoundException.class)
|
||||||
|
public ResponseEntity<InstallApiResponse<Void>> handleMissingResource() {
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
.body(InstallApiResponse.error(40400, "资源不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<InstallApiResponse<Void>> handleUnexpected(Exception exception) {
|
||||||
|
log.error("Installer operation failed ({})", exception.getClass().getSimpleName());
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(InstallApiResponse.error(50000, "安装操作失败,请检查配置后重试"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.kaidi.oa.install;
|
||||||
|
|
||||||
|
public record InstallApiResponse<T>(int code, String message, T data) {
|
||||||
|
|
||||||
|
public static <T> InstallApiResponse<T> ok(T data) {
|
||||||
|
return new InstallApiResponse<>(0, "ok", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static InstallApiResponse<Void> error(int code, String message) {
|
||||||
|
return new InstallApiResponse<>(code, message, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.kaidi.oa.install;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
public final class InstallRequest {
|
||||||
|
|
||||||
|
private InstallRequest() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Database(
|
||||||
|
@NotBlank(message = "请填写 PostgreSQL 地址")
|
||||||
|
@Size(max = 253, message = "PostgreSQL 地址过长")
|
||||||
|
@Pattern(regexp = "[A-Za-z0-9._:\\-\\[\\]]+", message = "PostgreSQL 地址格式不正确")
|
||||||
|
String host,
|
||||||
|
@Min(value = 1, message = "PostgreSQL 端口不正确")
|
||||||
|
@Max(value = 65535, message = "PostgreSQL 端口不正确")
|
||||||
|
int port,
|
||||||
|
@NotBlank(message = "请填写数据库名")
|
||||||
|
@Pattern(regexp = "[A-Za-z_][A-Za-z0-9_-]{0,62}", message = "数据库名格式不正确")
|
||||||
|
String database,
|
||||||
|
@NotBlank(message = "请填写数据库账号")
|
||||||
|
@Pattern(regexp = "[A-Za-z_][A-Za-z0-9_.-]{0,127}", message = "数据库账号格式不正确")
|
||||||
|
String username,
|
||||||
|
@NotBlank(message = "请填写数据库密码")
|
||||||
|
@Size(max = 500, message = "数据库密码过长")
|
||||||
|
String password,
|
||||||
|
@NotBlank(message = "请选择 SSL 模式")
|
||||||
|
@Pattern(regexp = "disable|allow|prefer|require|verify-ca|verify-full", message = "SSL 模式不正确")
|
||||||
|
String sslMode) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Administrator(
|
||||||
|
@NotBlank(message = "请填写管理员账号")
|
||||||
|
@Pattern(regexp = "[A-Za-z][A-Za-z0-9_.-]{2,63}", message = "管理员账号需以字母开头,长度为 3-64 位")
|
||||||
|
String loginName,
|
||||||
|
@NotBlank(message = "请填写管理员姓名")
|
||||||
|
@Size(max = 100, message = "管理员姓名不能超过 100 个字符")
|
||||||
|
String displayName,
|
||||||
|
@NotBlank(message = "请填写管理员密码")
|
||||||
|
@Size(min = 8, max = 200, message = "管理员密码长度需为 8-200 位")
|
||||||
|
String password) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Complete(
|
||||||
|
@NotNull(message = "缺少 PostgreSQL 配置") @Valid Database database,
|
||||||
|
@NotNull(message = "缺少管理员配置") @Valid Administrator administrator) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.kaidi.oa.install;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
|
||||||
|
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
|
||||||
|
@SpringBootApplication(exclude = {
|
||||||
|
DataSourceAutoConfiguration.class,
|
||||||
|
FlywayAutoConfiguration.class
|
||||||
|
})
|
||||||
|
@EnableConfigurationProperties(InstallerProperties.class)
|
||||||
|
public class InstallerApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(InstallerApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.kaidi.oa.install;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/install")
|
||||||
|
public class InstallerController {
|
||||||
|
|
||||||
|
private final InstallerService installerService;
|
||||||
|
|
||||||
|
public InstallerController(InstallerService installerService) {
|
||||||
|
this.installerService = installerService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/status")
|
||||||
|
public InstallApiResponse<Map<String, Object>> status() {
|
||||||
|
return InstallApiResponse.ok(installerService.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/test-database")
|
||||||
|
public InstallApiResponse<Map<String, Object>> testDatabase(
|
||||||
|
@Valid @RequestBody InstallRequest.Database database) {
|
||||||
|
return InstallApiResponse.ok(installerService.testDatabase(database));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/complete")
|
||||||
|
public InstallApiResponse<Map<String, Object>> complete(
|
||||||
|
@Valid @RequestBody InstallRequest.Complete request) {
|
||||||
|
return InstallApiResponse.ok(installerService.complete(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.kaidi.oa.install;
|
||||||
|
|
||||||
|
import javax.crypto.SecretKeyFactory;
|
||||||
|
import javax.crypto.spec.PBEKeySpec;
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
final class InstallerPasswordHasher {
|
||||||
|
|
||||||
|
private static final int ITERATIONS = 120_000;
|
||||||
|
private static final int KEY_LENGTH = 256;
|
||||||
|
private static final int SALT_BYTES = 16;
|
||||||
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
|
||||||
|
private InstallerPasswordHasher() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static String hash(String raw) {
|
||||||
|
byte[] salt = new byte[SALT_BYTES];
|
||||||
|
RANDOM.nextBytes(salt);
|
||||||
|
try {
|
||||||
|
PBEKeySpec spec = new PBEKeySpec(raw.toCharArray(), salt, ITERATIONS, KEY_LENGTH);
|
||||||
|
byte[] derived = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
|
||||||
|
.generateSecret(spec)
|
||||||
|
.getEncoded();
|
||||||
|
spec.clearPassword();
|
||||||
|
return "pbkdf2$" + ITERATIONS + "$"
|
||||||
|
+ Base64.getEncoder().encodeToString(salt) + "$"
|
||||||
|
+ Base64.getEncoder().encodeToString(derived);
|
||||||
|
} catch (GeneralSecurityException exception) {
|
||||||
|
throw new IllegalStateException("PBKDF2 is not available", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.kaidi.oa.install;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
@Validated
|
||||||
|
@ConfigurationProperties(prefix = "erp.install")
|
||||||
|
public record InstallerProperties(
|
||||||
|
@NotBlank String token,
|
||||||
|
@NotNull Path installRoot,
|
||||||
|
@NotNull Path configFile,
|
||||||
|
@NotNull Path pendingFile,
|
||||||
|
@NotNull Path lockFile,
|
||||||
|
@NotNull Path operationLockFile,
|
||||||
|
@NotBlank String version,
|
||||||
|
boolean exitAfterComplete) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
package com.kaidi.oa.install;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.flywaydb.core.Flyway;
|
||||||
|
import org.flywaydb.core.api.output.MigrateResult;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.context.ConfigurableApplicationContext;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.channels.FileChannel;
|
||||||
|
import java.nio.channels.FileLock;
|
||||||
|
import java.nio.channels.OverlappingFileLockException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.AtomicMoveNotSupportedException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.nio.file.attribute.PosixFilePermission;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.EnumSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Properties;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class InstallerService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(InstallerService.class);
|
||||||
|
|
||||||
|
private static final Set<PosixFilePermission> OWNER_ONLY = EnumSet.of(
|
||||||
|
PosixFilePermission.OWNER_READ,
|
||||||
|
PosixFilePermission.OWNER_WRITE);
|
||||||
|
|
||||||
|
private final InstallerProperties properties;
|
||||||
|
private final ConfigurableApplicationContext applicationContext;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public InstallerService(
|
||||||
|
InstallerProperties properties,
|
||||||
|
ConfigurableApplicationContext applicationContext,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this.properties = properties;
|
||||||
|
this.applicationContext = applicationContext;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> status() {
|
||||||
|
LinkedHashMap<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("version", properties.version());
|
||||||
|
result.put("javaVersion", System.getProperty("java.version"));
|
||||||
|
result.put("minimumJava", 17);
|
||||||
|
result.put("minimumPostgres", 15);
|
||||||
|
result.put("redisRequired", false);
|
||||||
|
result.put("locked", Files.exists(properties.lockFile()));
|
||||||
|
result.put("pending", Files.exists(properties.pendingFile()));
|
||||||
|
result.put("ready", !Files.exists(properties.lockFile()) && !Files.exists(properties.pendingFile()));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> testDatabase(InstallRequest.Database database) {
|
||||||
|
ensureUnlocked();
|
||||||
|
DatabaseCheck check = verifyDatabase(database);
|
||||||
|
LinkedHashMap<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("connected", true);
|
||||||
|
result.put("postgresVersion", check.productVersion());
|
||||||
|
result.put("postgresMajor", check.majorVersion());
|
||||||
|
result.put("pgTrgm", true);
|
||||||
|
result.put("message", "PostgreSQL 连接和扩展检查通过");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> complete(InstallRequest.Complete request) {
|
||||||
|
ensureUnlocked();
|
||||||
|
Path operationLock = properties.operationLockFile().toAbsolutePath().normalize();
|
||||||
|
try {
|
||||||
|
Files.createDirectories(operationLock.getParent());
|
||||||
|
try (FileChannel channel = FileChannel.open(
|
||||||
|
operationLock,
|
||||||
|
StandardOpenOption.CREATE,
|
||||||
|
StandardOpenOption.WRITE);
|
||||||
|
FileLock ignored = tryLock(channel)) {
|
||||||
|
if (ignored == null) {
|
||||||
|
throw new InstallApiException(HttpStatus.CONFLICT, 40901, "安装正在进行,请勿重复提交");
|
||||||
|
}
|
||||||
|
return completeLocked(request);
|
||||||
|
}
|
||||||
|
} catch (InstallApiException exception) {
|
||||||
|
throw exception;
|
||||||
|
} catch (IOException exception) {
|
||||||
|
throw new InstallApiException(HttpStatus.INTERNAL_SERVER_ERROR, 50011, "无法创建安装锁,请检查目录权限");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> completeLocked(InstallRequest.Complete request) {
|
||||||
|
ensureUnlocked();
|
||||||
|
DatabaseCheck check = verifyDatabase(request.database());
|
||||||
|
rejectExistingInstallation(request.database());
|
||||||
|
writeRuntimeConfiguration(request.database());
|
||||||
|
|
||||||
|
MigrateResult migration;
|
||||||
|
try {
|
||||||
|
migration = Flyway.configure()
|
||||||
|
.dataSource(jdbcUrl(request.database()), request.database().username(), request.database().password())
|
||||||
|
.locations("classpath:db/migration/postgresql")
|
||||||
|
.cleanDisabled(true)
|
||||||
|
.validateOnMigrate(true)
|
||||||
|
.baselineOnMigrate(false)
|
||||||
|
.load()
|
||||||
|
.migrate();
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
log.error(
|
||||||
|
"PostgreSQL migration failed for database {} as user {}",
|
||||||
|
request.database().database().strip(),
|
||||||
|
request.database().username().strip(),
|
||||||
|
exception);
|
||||||
|
throw new InstallApiException(
|
||||||
|
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||||
|
42203,
|
||||||
|
"数据库迁移失败,请确认数据库为空且账号拥有建表权限");
|
||||||
|
}
|
||||||
|
|
||||||
|
createAdministrator(request.database(), request.administrator());
|
||||||
|
writePendingMarker(check, migration.migrationsExecuted, request.administrator().loginName());
|
||||||
|
scheduleShutdown();
|
||||||
|
|
||||||
|
LinkedHashMap<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("installed", true);
|
||||||
|
result.put("restarting", true);
|
||||||
|
result.put("version", properties.version());
|
||||||
|
result.put("migrationsExecuted", migration.migrationsExecuted);
|
||||||
|
result.put("message", "初始化完成,正在启动正式服务");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileLock tryLock(FileChannel channel) throws IOException {
|
||||||
|
try {
|
||||||
|
return channel.tryLock();
|
||||||
|
} catch (OverlappingFileLockException exception) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureUnlocked() {
|
||||||
|
if (Files.exists(properties.lockFile())) {
|
||||||
|
throw new InstallApiException(HttpStatus.GONE, 41001, "系统已经完成安装");
|
||||||
|
}
|
||||||
|
if (Files.exists(properties.pendingFile())) {
|
||||||
|
throw new InstallApiException(HttpStatus.CONFLICT, 40902, "系统正在启动正式服务,请稍候");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DatabaseCheck verifyDatabase(InstallRequest.Database database) {
|
||||||
|
try (Connection connection = openConnection(database)) {
|
||||||
|
int versionNumber;
|
||||||
|
try (Statement statement = connection.createStatement();
|
||||||
|
ResultSet result = statement.executeQuery("SHOW server_version_num")) {
|
||||||
|
if (!result.next()) {
|
||||||
|
throw new SQLException("PostgreSQL did not report a version");
|
||||||
|
}
|
||||||
|
versionNumber = Integer.parseInt(result.getString(1));
|
||||||
|
}
|
||||||
|
int major = versionNumber / 10_000;
|
||||||
|
if (major < 15) {
|
||||||
|
throw new InstallApiException(
|
||||||
|
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||||
|
42201,
|
||||||
|
"PostgreSQL 版本过低,需要 15 或更高版本");
|
||||||
|
}
|
||||||
|
requireDatabaseOwnership(connection);
|
||||||
|
try (Statement statement = connection.createStatement()) {
|
||||||
|
statement.execute("SELECT 1");
|
||||||
|
statement.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm");
|
||||||
|
}
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm')");
|
||||||
|
ResultSet result = statement.executeQuery()) {
|
||||||
|
if (!result.next() || !result.getBoolean(1)) {
|
||||||
|
throw new SQLException("pg_trgm is unavailable");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requirePgTrgmOwnership(connection);
|
||||||
|
return new DatabaseCheck(major, connection.getMetaData().getDatabaseProductVersion());
|
||||||
|
} catch (InstallApiException exception) {
|
||||||
|
throw exception;
|
||||||
|
} catch (SQLException | NumberFormatException exception) {
|
||||||
|
log.warn(
|
||||||
|
"PostgreSQL verification failed for database {} as user {}: {}",
|
||||||
|
database.database().strip(),
|
||||||
|
database.username().strip(),
|
||||||
|
exception.getMessage());
|
||||||
|
throw new InstallApiException(
|
||||||
|
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||||
|
42202,
|
||||||
|
"无法连接 PostgreSQL,请检查地址、账号、密码、SSL 和网络设置");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireDatabaseOwnership(Connection connection) throws SQLException {
|
||||||
|
try (Statement statement = connection.createStatement();
|
||||||
|
ResultSet result = statement.executeQuery(
|
||||||
|
"SELECT "
|
||||||
|
+ "pg_get_userbyid(datdba) = current_user AS owns_database, "
|
||||||
|
+ "has_schema_privilege(current_user, 'public', 'USAGE') AS can_use_schema, "
|
||||||
|
+ "has_schema_privilege(current_user, 'public', 'CREATE') AS can_create_in_schema "
|
||||||
|
+ "FROM pg_database WHERE datname = current_database()")) {
|
||||||
|
if (!result.next()
|
||||||
|
|| !result.getBoolean("owns_database")
|
||||||
|
|| !result.getBoolean("can_use_schema")
|
||||||
|
|| !result.getBoolean("can_create_in_schema")) {
|
||||||
|
throw new InstallApiException(
|
||||||
|
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||||
|
42206,
|
||||||
|
"数据库账号必须是该空数据库的所有者,并拥有 public schema 建表权限");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requirePgTrgmOwnership(Connection connection) throws SQLException {
|
||||||
|
try (Statement statement = connection.createStatement();
|
||||||
|
ResultSet result = statement.executeQuery(
|
||||||
|
"SELECT pg_get_userbyid(extowner) = current_user "
|
||||||
|
+ "FROM pg_extension WHERE extname = 'pg_trgm'")) {
|
||||||
|
if (!result.next() || !result.getBoolean(1)) {
|
||||||
|
throw new InstallApiException(
|
||||||
|
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||||
|
42207,
|
||||||
|
"数据库账号必须拥有 pg_trgm 扩展;请由管理员删除预建扩展后重新测试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rejectExistingInstallation(InstallRequest.Database database) {
|
||||||
|
try (Connection connection = openConnection(database);
|
||||||
|
PreparedStatement tableQuery = connection.prepareStatement(
|
||||||
|
"SELECT to_regclass('public.sys_user') IS NOT NULL");
|
||||||
|
ResultSet tableResult = tableQuery.executeQuery()) {
|
||||||
|
if (!tableResult.next() || !tableResult.getBoolean(1)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (Statement countQuery = connection.createStatement();
|
||||||
|
ResultSet countResult = countQuery.executeQuery("SELECT COUNT(*) FROM public.sys_user")) {
|
||||||
|
if (countResult.next() && countResult.getLong(1) > 0) {
|
||||||
|
throw new InstallApiException(
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
40903,
|
||||||
|
"该数据库已经包含业务用户,为防止覆盖现有系统已停止安装");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (InstallApiException exception) {
|
||||||
|
throw exception;
|
||||||
|
} catch (SQLException exception) {
|
||||||
|
throw new InstallApiException(HttpStatus.UNPROCESSABLE_ENTITY, 42204, "无法检查数据库现有数据");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createAdministrator(
|
||||||
|
InstallRequest.Database database,
|
||||||
|
InstallRequest.Administrator administrator) {
|
||||||
|
try (Connection connection = openConnection(database)) {
|
||||||
|
connection.setAutoCommit(false);
|
||||||
|
connection.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE);
|
||||||
|
try {
|
||||||
|
try (Statement statement = connection.createStatement()) {
|
||||||
|
statement.execute("LOCK TABLE public.sys_user IN SHARE ROW EXCLUSIVE MODE");
|
||||||
|
}
|
||||||
|
try (Statement statement = connection.createStatement();
|
||||||
|
ResultSet result = statement.executeQuery("SELECT COUNT(*) FROM public.sys_user")) {
|
||||||
|
if (!result.next() || result.getLong(1) != 0) {
|
||||||
|
throw new InstallApiException(
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
40903,
|
||||||
|
"该数据库已经包含业务用户,为防止覆盖现有系统已停止安装");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
long adminRoleId = upsertRole(connection, "ADMIN", "系统管理员", "系统内置管理员角色");
|
||||||
|
upsertRole(connection, "USER", "普通用户", "系统内置普通用户角色");
|
||||||
|
upsertRole(connection, "APPROVER", "审批人", "系统内置审批角色");
|
||||||
|
|
||||||
|
long userId;
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(
|
||||||
|
"INSERT INTO public.sys_user "
|
||||||
|
+ "(enabled, display_name, login_name, password, dept_id, email, phone, title) "
|
||||||
|
+ "VALUES (TRUE, ?, ?, ?, NULL, NULL, NULL, NULL) RETURNING id")) {
|
||||||
|
statement.setString(1, administrator.displayName().strip());
|
||||||
|
statement.setString(2, administrator.loginName().strip());
|
||||||
|
statement.setString(3, InstallerPasswordHasher.hash(administrator.password()));
|
||||||
|
try (ResultSet result = statement.executeQuery()) {
|
||||||
|
if (!result.next()) {
|
||||||
|
throw new SQLException("administrator insert returned no id");
|
||||||
|
}
|
||||||
|
userId = result.getLong(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(
|
||||||
|
"INSERT INTO public.sys_user_role (user_id, role_id) VALUES (?, ?)")) {
|
||||||
|
statement.setLong(1, userId);
|
||||||
|
statement.setLong(2, adminRoleId);
|
||||||
|
statement.executeUpdate();
|
||||||
|
}
|
||||||
|
connection.commit();
|
||||||
|
} catch (Exception exception) {
|
||||||
|
connection.rollback();
|
||||||
|
if (exception instanceof InstallApiException installApiException) {
|
||||||
|
throw installApiException;
|
||||||
|
}
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
} catch (InstallApiException exception) {
|
||||||
|
throw exception;
|
||||||
|
} catch (Exception exception) {
|
||||||
|
log.error(
|
||||||
|
"Administrator initialization failed for database {} as user {}",
|
||||||
|
database.database().strip(),
|
||||||
|
database.username().strip(),
|
||||||
|
exception);
|
||||||
|
throw new InstallApiException(HttpStatus.UNPROCESSABLE_ENTITY, 42205, "管理员账号初始化失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private long upsertRole(Connection connection, String code, String name, String description) throws SQLException {
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(
|
||||||
|
"INSERT INTO public.sys_role (system, code, description, name) VALUES (TRUE, ?, ?, ?) "
|
||||||
|
+ "ON CONFLICT (code) DO UPDATE SET system = TRUE, description = EXCLUDED.description, "
|
||||||
|
+ "name = EXCLUDED.name RETURNING id")) {
|
||||||
|
statement.setString(1, code);
|
||||||
|
statement.setString(2, description);
|
||||||
|
statement.setString(3, name);
|
||||||
|
try (ResultSet result = statement.executeQuery()) {
|
||||||
|
if (!result.next()) {
|
||||||
|
throw new SQLException("role insert returned no id");
|
||||||
|
}
|
||||||
|
return result.getLong(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Connection openConnection(InstallRequest.Database database) throws SQLException {
|
||||||
|
Properties connectionProperties = new Properties();
|
||||||
|
connectionProperties.setProperty("user", database.username().strip());
|
||||||
|
connectionProperties.setProperty("password", database.password());
|
||||||
|
return DriverManager.getConnection(jdbcUrl(database), connectionProperties);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String jdbcUrl(InstallRequest.Database database) {
|
||||||
|
String host = database.host().strip();
|
||||||
|
if (host.startsWith("[") && host.endsWith("]")) {
|
||||||
|
host = host.substring(1, host.length() - 1);
|
||||||
|
}
|
||||||
|
if (host.contains(":")) {
|
||||||
|
host = "[" + host + "]";
|
||||||
|
}
|
||||||
|
return "jdbc:postgresql://" + host + ":" + database.port() + "/" + database.database().strip()
|
||||||
|
+ "?sslmode=" + database.sslMode()
|
||||||
|
+ "&connectTimeout=10&socketTimeout=20&ApplicationName=KaidiERPInstaller";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeRuntimeConfiguration(InstallRequest.Database database) {
|
||||||
|
LinkedHashMap<String, String> values = new LinkedHashMap<>();
|
||||||
|
Path installRoot = properties.installRoot().toAbsolutePath().normalize();
|
||||||
|
Path stateRoot = properties.pendingFile().toAbsolutePath().normalize().getParent();
|
||||||
|
int port = integerEnvironment("SERVER_PORT", 8091);
|
||||||
|
|
||||||
|
values.put("ERP_INSTALL_ROOT", installRoot.toString());
|
||||||
|
values.put("ERP_CONFIG_FILE", properties.configFile().toAbsolutePath().normalize().toString());
|
||||||
|
values.put("ERP_RUN_DIR", installRoot.resolve("run").toString());
|
||||||
|
values.put("ERP_STATE_FILE", environment("ERP_STATE_FILE", stateRoot.resolve("update-state.json").toString()));
|
||||||
|
values.put("ERP_INSTALL_PENDING_FILE", properties.pendingFile().toAbsolutePath().normalize().toString());
|
||||||
|
values.put("ERP_INSTALL_LOCK_FILE", properties.lockFile().toAbsolutePath().normalize().toString());
|
||||||
|
values.put("ERP_INSTALL_OPERATION_LOCK_FILE", properties.operationLockFile().toAbsolutePath().normalize().toString());
|
||||||
|
values.put("ERP_INSTALL_LOG_FILE", stateRoot.resolve("install-formal.log").toString());
|
||||||
|
values.put("ERP_INSTALL_HEALTH_TIMEOUT_SECONDS", environment("ERP_INSTALL_HEALTH_TIMEOUT_SECONDS", "240"));
|
||||||
|
values.put("ERP_SETUP_TOKEN", properties.token());
|
||||||
|
values.put("ERP_INSTALLER_JAR", installRoot.resolve("installer/kaidi-erp-installer.jar").toString());
|
||||||
|
values.put("ERP_JAR_PATH", installRoot.resolve("current/app/kaidi-erp.jar").toString());
|
||||||
|
values.put("ERP_JAVA_BIN", environment("ERP_JAVA_BIN", ""));
|
||||||
|
values.put("ERP_JAVA_OPTS", environment("ERP_JAVA_OPTS", "-Xms512m -Xmx2g"));
|
||||||
|
values.put("ERP_HEALTH_URL", "http://127.0.0.1:" + port + "/api/oa/health");
|
||||||
|
values.put("ERP_UPDATE_PUBLIC_KEY_FILE", environment(
|
||||||
|
"ERP_UPDATE_PUBLIC_KEY_FILE",
|
||||||
|
properties.configFile().toAbsolutePath().normalize().getParent().resolve("release-public-key.pem").toString()));
|
||||||
|
values.put("ERP_UPDATE_REQUIRE_SIGNATURE", environment("ERP_UPDATE_REQUIRE_SIGNATURE", "true"));
|
||||||
|
values.put("ERP_UPDATE_BACKUP_MODE", environment("ERP_UPDATE_BACKUP_MODE", "none"));
|
||||||
|
values.put("ERP_UPDATE_HEALTH_TIMEOUT_SECONDS", environment("ERP_UPDATE_HEALTH_TIMEOUT_SECONDS", "120"));
|
||||||
|
values.put("ERP_UPDATE_HEALTH_POLL_SECONDS", environment("ERP_UPDATE_HEALTH_POLL_SECONDS", "2"));
|
||||||
|
values.put("ERP_PGHOST", database.host().strip());
|
||||||
|
values.put("ERP_PGPORT", Integer.toString(database.port()));
|
||||||
|
values.put("ERP_PGDATABASE", database.database().strip());
|
||||||
|
values.put("ERP_PGSSLMODE", database.sslMode());
|
||||||
|
values.put("SPRING_PROFILES_ACTIVE", "postgres");
|
||||||
|
values.put("SERVER_PORT", Integer.toString(port));
|
||||||
|
values.put("OA_DB_URL", jdbcUrl(database));
|
||||||
|
values.put("OA_DB_USERNAME", database.username().strip());
|
||||||
|
values.put("OA_DB_PASSWORD", database.password());
|
||||||
|
values.put("OA_DB_POOL_MAX", environment("OA_DB_POOL_MAX", "20"));
|
||||||
|
values.put("OA_DB_POOL_MIN", environment("OA_DB_POOL_MIN", "2"));
|
||||||
|
values.put("OA_SEED_DEMO", "false");
|
||||||
|
values.put("OA_UPDATE_ENABLED", environment("OA_UPDATE_ENABLED", "true"));
|
||||||
|
values.put("OA_UPDATE_GITEA_BASE_URL", environment("OA_UPDATE_GITEA_BASE_URL", "https://git.awaioi.com"));
|
||||||
|
values.put("OA_UPDATE_REPOSITORY", environment("OA_UPDATE_REPOSITORY", "awaioi/ERP"));
|
||||||
|
values.put("OA_UPDATE_CHANNEL", environment("OA_UPDATE_CHANNEL", "stable"));
|
||||||
|
values.put("OA_UPDATE_TOKEN", environment("OA_UPDATE_TOKEN", ""));
|
||||||
|
values.put("OA_UPDATE_HELPER_COMMAND", installRoot.resolve("current/bin/erp-update").toString());
|
||||||
|
values.put("OA_UPDATE_STATE_FILE", environment(
|
||||||
|
"OA_UPDATE_STATE_FILE",
|
||||||
|
stateRoot.resolve("update-state.json").toString()));
|
||||||
|
values.put("OA_UPDATE_ALLOW_INSECURE_HTTP", environment("OA_UPDATE_ALLOW_INSECURE_HTTP", "false"));
|
||||||
|
|
||||||
|
StringBuilder content = new StringBuilder();
|
||||||
|
values.forEach((key, value) -> content.append(key)
|
||||||
|
.append("='")
|
||||||
|
.append(shellSingleQuote(value))
|
||||||
|
.append("'\n"));
|
||||||
|
atomicWrite(properties.configFile(), content.toString().getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writePendingMarker(DatabaseCheck check, int migrations, String loginName) {
|
||||||
|
LinkedHashMap<String, Object> marker = new LinkedHashMap<>();
|
||||||
|
marker.put("status", "PENDING");
|
||||||
|
marker.put("version", properties.version());
|
||||||
|
marker.put("createdAt", Instant.now().toString());
|
||||||
|
marker.put("postgresMajor", check.majorVersion());
|
||||||
|
marker.put("migrationsExecuted", migrations);
|
||||||
|
marker.put("administrator", loginName.strip());
|
||||||
|
try {
|
||||||
|
atomicWrite(properties.pendingFile(), objectMapper.writeValueAsBytes(marker));
|
||||||
|
} catch (IOException exception) {
|
||||||
|
throw new InstallApiException(HttpStatus.INTERNAL_SERVER_ERROR, 50012, "无法写入安装状态,请检查目录权限");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void atomicWrite(Path target, byte[] content) {
|
||||||
|
Path absolute = target.toAbsolutePath().normalize();
|
||||||
|
Path parent = absolute.getParent();
|
||||||
|
Path temporary = null;
|
||||||
|
try {
|
||||||
|
Files.createDirectories(parent);
|
||||||
|
temporary = Files.createTempFile(parent, "." + absolute.getFileName() + "-", ".tmp");
|
||||||
|
setOwnerOnly(temporary);
|
||||||
|
try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
|
||||||
|
channel.write(ByteBuffer.wrap(content));
|
||||||
|
channel.force(true);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Files.move(temporary, absolute, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
} catch (AtomicMoveNotSupportedException exception) {
|
||||||
|
Files.move(temporary, absolute, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
setOwnerOnly(absolute);
|
||||||
|
temporary = null;
|
||||||
|
} catch (IOException exception) {
|
||||||
|
throw new InstallApiException(HttpStatus.INTERNAL_SERVER_ERROR, 50010, "无法安全写入安装配置,请检查目录权限");
|
||||||
|
} finally {
|
||||||
|
if (temporary != null) {
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(temporary);
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
// Best effort cleanup of a file containing protected configuration.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setOwnerOnly(Path path) throws IOException {
|
||||||
|
try {
|
||||||
|
Files.setPosixFilePermissions(path, OWNER_ONLY);
|
||||||
|
} catch (UnsupportedOperationException ignored) {
|
||||||
|
// Windows is not a supported deployment target; this keeps local tests portable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String shellSingleQuote(String value) {
|
||||||
|
if (value.indexOf('\0') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0) {
|
||||||
|
throw new InstallApiException(HttpStatus.BAD_REQUEST, 40002, "配置值不能包含换行符");
|
||||||
|
}
|
||||||
|
return value.replace("'", "'\\''");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String environment(String name, String fallback) {
|
||||||
|
String value = System.getenv(name);
|
||||||
|
return value == null ? fallback : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int integerEnvironment(String name, int fallback) {
|
||||||
|
String value = environment(name, Integer.toString(fallback));
|
||||||
|
try {
|
||||||
|
int parsed = Integer.parseInt(value);
|
||||||
|
return parsed >= 1 && parsed <= 65535 ? parsed : fallback;
|
||||||
|
} catch (NumberFormatException exception) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scheduleShutdown() {
|
||||||
|
if (!properties.exitAfterComplete()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Thread shutdown = new Thread(() -> {
|
||||||
|
try {
|
||||||
|
Thread.sleep(1_500L);
|
||||||
|
} catch (InterruptedException exception) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
System.exit(SpringApplication.exit(applicationContext, () -> 0));
|
||||||
|
}, "installer-complete-shutdown");
|
||||||
|
shutdown.setDaemon(false);
|
||||||
|
shutdown.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private record DatabaseCheck(int majorVersion, String productVersion) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.kaidi.oa.install;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class SetupTokenFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private final byte[] expectedToken;
|
||||||
|
|
||||||
|
public SetupTokenFilter(InstallerProperties properties) {
|
||||||
|
this.expectedToken = properties.token().getBytes(StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||||
|
return !request.getRequestURI().startsWith("/api/install/")
|
||||||
|
|| "OPTIONS".equalsIgnoreCase(request.getMethod());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||||
|
throws ServletException, IOException {
|
||||||
|
String supplied = request.getHeader("X-Setup-Token");
|
||||||
|
byte[] candidate = supplied == null ? new byte[0] : supplied.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (!MessageDigest.isEqual(expectedToken, candidate)) {
|
||||||
|
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||||
|
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||||
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
|
response.getWriter().write("{\"code\":40301,\"message\":\"安装链接无效或已过期\",\"data\":null}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
server:
|
||||||
|
port: ${SERVER_PORT:8091}
|
||||||
|
shutdown: graceful
|
||||||
|
forward-headers-strategy: framework
|
||||||
|
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: kaidi-erp-installer
|
||||||
|
lifecycle:
|
||||||
|
timeout-per-shutdown-phase: 5s
|
||||||
|
jackson:
|
||||||
|
default-property-inclusion: non_null
|
||||||
|
|
||||||
|
erp:
|
||||||
|
install:
|
||||||
|
token: ${ERP_SETUP_TOKEN:}
|
||||||
|
install-root: ${ERP_INSTALL_ROOT:./runtime}
|
||||||
|
config-file: ${ERP_CONFIG_FILE:./runtime/config/erp.env}
|
||||||
|
pending-file: ${ERP_INSTALL_PENDING_FILE:./runtime/install.pending}
|
||||||
|
lock-file: ${ERP_INSTALL_LOCK_FILE:./runtime/install.lock}
|
||||||
|
operation-lock-file: ${ERP_INSTALL_OPERATION_LOCK_FILE:./runtime/install-operation.lock}
|
||||||
|
version: ${ERP_RELEASE_VERSION:development}
|
||||||
|
exit-after-complete: ${ERP_INSTALL_EXIT_AFTER_COMPLETE:true}
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
root: INFO
|
||||||
|
com.kaidi.oa.install: INFO
|
||||||
@@ -0,0 +1,725 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex,nofollow">
|
||||||
|
<title>凯迪 ERP 安装向导</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
|
||||||
|
color: #172033;
|
||||||
|
background: #f3f5f8;
|
||||||
|
font-synthesis: none;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; min-width: 320px; background: #f3f5f8; }
|
||||||
|
button, input, select { font: inherit; letter-spacing: 0; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
.topbar {
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 32px;
|
||||||
|
color: #fff;
|
||||||
|
background: #1d3557;
|
||||||
|
border-bottom: 3px solid #2f6fed;
|
||||||
|
}
|
||||||
|
.brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||||
|
.brand-mark {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #91b3e7;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: #1d3557;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.brand-name { font-size: 16px; font-weight: 700; white-space: nowrap; }
|
||||||
|
.topbar-label { color: #dbe7f8; font-size: 13px; white-space: nowrap; }
|
||||||
|
.shell {
|
||||||
|
width: min(1040px, calc(100% - 40px));
|
||||||
|
margin: 36px auto 48px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 232px minmax(0, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.steps {
|
||||||
|
padding: 18px 0;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dfe4ec;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.step {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 30px 1fr;
|
||||||
|
gap: 11px;
|
||||||
|
min-height: 62px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
color: #748096;
|
||||||
|
}
|
||||||
|
.step:not(:last-child)::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 32px;
|
||||||
|
top: 42px;
|
||||||
|
width: 1px;
|
||||||
|
height: 28px;
|
||||||
|
background: #d8dee8;
|
||||||
|
}
|
||||||
|
.step-index {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #c9d1de;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.step-copy { padding-top: 4px; }
|
||||||
|
.step-title { display: block; color: #4b586d; font-size: 14px; font-weight: 650; }
|
||||||
|
.step-state { display: block; margin-top: 4px; font-size: 12px; }
|
||||||
|
.step.active .step-index { border-color: #2f6fed; background: #2f6fed; color: #fff; }
|
||||||
|
.step.active .step-title { color: #183a72; }
|
||||||
|
.step.done .step-index { border-color: #25805a; background: #e8f5ee; color: #187149; }
|
||||||
|
.panel {
|
||||||
|
min-height: 540px;
|
||||||
|
padding: 30px 34px 28px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dfe4ec;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 6px 18px rgba(24, 38, 61, 0.06);
|
||||||
|
}
|
||||||
|
.panel-head { padding-bottom: 20px; border-bottom: 1px solid #e8ebf0; }
|
||||||
|
h1 { margin: 0; color: #172033; font-size: 24px; line-height: 1.3; letter-spacing: 0; }
|
||||||
|
.subtitle { margin: 8px 0 0; color: #69758a; font-size: 14px; line-height: 1.65; }
|
||||||
|
.section { padding-top: 24px; }
|
||||||
|
.check-list { display: grid; gap: 10px; }
|
||||||
|
.check-row {
|
||||||
|
min-height: 52px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #e1e6ed;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
.check-label { font-size: 14px; font-weight: 600; }
|
||||||
|
.check-detail { margin-top: 3px; color: #778398; font-size: 12px; }
|
||||||
|
.badge {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #e8f5ee;
|
||||||
|
color: #187149;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.badge.wait { background: #eef1f5; color: #667287; }
|
||||||
|
.form-grid { display: grid; grid-template-columns: 1fr 150px; gap: 18px 16px; }
|
||||||
|
.field.full { grid-column: 1 / -1; }
|
||||||
|
.field label { display: block; margin-bottom: 7px; color: #334057; font-size: 13px; font-weight: 650; }
|
||||||
|
.required::after { content: " *"; color: #bd3131; }
|
||||||
|
input, select {
|
||||||
|
width: 100%;
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid #cbd3df;
|
||||||
|
border-radius: 5px;
|
||||||
|
outline: none;
|
||||||
|
background: #fff;
|
||||||
|
color: #172033;
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
input:focus, select:focus { border-color: #2f6fed; box-shadow: 0 0 0 3px rgba(47, 111, 237, .12); }
|
||||||
|
input.invalid { border-color: #bd3131; }
|
||||||
|
.hint { margin-top: 6px; color: #7b8799; font-size: 12px; line-height: 1.5; }
|
||||||
|
.notice {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #bcdcca;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f0f8f4;
|
||||||
|
color: #176542;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.notice.error { border-color: #ebc1c1; background: #fff5f5; color: #a52a2a; }
|
||||||
|
.notice.info { border-color: #c8d7ee; background: #f3f7fd; color: #315b91; }
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 28px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #e8ebf0;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
min-width: 104px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 17px;
|
||||||
|
border: 1px solid #c8d0dc;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #fff;
|
||||||
|
color: #344056;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: #8fa1ba; background: #f7f8fa; }
|
||||||
|
.btn.primary { border-color: #2f6fed; background: #2f6fed; color: #fff; }
|
||||||
|
.btn.primary:hover { border-color: #245fcf; background: #245fcf; }
|
||||||
|
.btn:disabled { cursor: not-allowed; border-color: #d7dce4; background: #e9edf2; color: #939dad; }
|
||||||
|
.summary { display: grid; gap: 1px; overflow: hidden; border: 1px solid #dfe4ec; border-radius: 6px; background: #dfe4ec; }
|
||||||
|
.summary-row { display: grid; grid-template-columns: 148px 1fr; gap: 16px; padding: 13px 15px; background: #fff; font-size: 13px; }
|
||||||
|
.summary-key { color: #707c90; }
|
||||||
|
.summary-value { min-width: 0; overflow-wrap: anywhere; color: #263248; font-weight: 600; }
|
||||||
|
.progress-list { display: grid; gap: 12px; margin-top: 8px; }
|
||||||
|
.progress-item { display: flex; align-items: center; gap: 12px; color: #6b7689; font-size: 14px; }
|
||||||
|
.progress-dot { width: 10px; height: 10px; border: 2px solid #aab3c1; border-radius: 50%; }
|
||||||
|
.progress-item.running { color: #254e86; font-weight: 600; }
|
||||||
|
.progress-item.running .progress-dot { border-color: #2f6fed; background: #2f6fed; box-shadow: 0 0 0 4px #eaf1fd; }
|
||||||
|
.progress-item.done { color: #187149; }
|
||||||
|
.progress-item.done .progress-dot { border-color: #25805a; background: #25805a; }
|
||||||
|
.install-progress { margin-top: 22px; padding-top: 18px; border-top: 1px solid #e8ebf0; }
|
||||||
|
.install-progress-head, .install-progress-meta, .install-console-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.install-progress-label { color: #334057; font-size: 13px; font-weight: 650; }
|
||||||
|
.install-progress-value { color: #1d3557; font-size: 14px; font-variant-numeric: tabular-nums; }
|
||||||
|
.install-progress-track {
|
||||||
|
height: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #e5eaf1;
|
||||||
|
}
|
||||||
|
.install-progress-bar {
|
||||||
|
width: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: #2f6fed;
|
||||||
|
transition: width .35s ease;
|
||||||
|
}
|
||||||
|
.install-progress-meta { margin-top: 8px; color: #707c90; font-size: 12px; }
|
||||||
|
.install-console { margin-top: 14px; overflow: hidden; border: 1px solid #28364a; border-radius: 6px; background: #182233; }
|
||||||
|
.install-console-head { min-height: 36px; padding: 0 12px; border-bottom: 1px solid #314158; background: #111a28; }
|
||||||
|
.install-console-title { color: #dce5f2; font-size: 12px; font-weight: 650; }
|
||||||
|
.install-console-state { color: #7dd3a8; font-size: 12px; }
|
||||||
|
.install-log {
|
||||||
|
height: 148px;
|
||||||
|
margin: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: #c9d5e5;
|
||||||
|
font: 12px/1.65 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
letter-spacing: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.complete-mark {
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e8f5ee;
|
||||||
|
color: #187149;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.topbar { height: 58px; padding: 0 18px; }
|
||||||
|
.topbar-label { display: none; }
|
||||||
|
.shell { width: calc(100% - 24px); margin: 18px auto 30px; display: block; }
|
||||||
|
.steps { display: grid; grid-template-columns: repeat(4, 1fr); margin-bottom: 14px; padding: 10px 4px; }
|
||||||
|
.step { display: flex; min-height: auto; padding: 4px; flex-direction: column; align-items: center; gap: 5px; text-align: center; }
|
||||||
|
.step:not(:last-child)::after { left: calc(50% + 18px); top: 18px; width: calc(100% - 36px); height: 1px; }
|
||||||
|
.step-copy { padding: 0; }
|
||||||
|
.step-title { font-size: 11px; }
|
||||||
|
.step-state { display: none; }
|
||||||
|
.panel { min-height: 0; padding: 23px 18px 20px; }
|
||||||
|
h1 { font-size: 21px; }
|
||||||
|
.form-grid { grid-template-columns: 1fr; gap: 15px; }
|
||||||
|
.field.full { grid-column: auto; }
|
||||||
|
.summary-row { grid-template-columns: 1fr; gap: 5px; }
|
||||||
|
.actions { flex-wrap: wrap-reverse; }
|
||||||
|
.btn { flex: 1 1 120px; }
|
||||||
|
.install-progress-meta { align-items: flex-start; flex-direction: column; gap: 4px; }
|
||||||
|
.install-log { height: 172px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-mark">KD</span>
|
||||||
|
<span class="brand-name">凯迪 ERP + OA</span>
|
||||||
|
</div>
|
||||||
|
<span class="topbar-label">首次安装向导</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="shell">
|
||||||
|
<nav class="steps" aria-label="安装步骤">
|
||||||
|
<div class="step active" data-step-nav="1"><span class="step-index">1</span><span class="step-copy"><span class="step-title">环境检查</span><span class="step-state">运行环境</span></span></div>
|
||||||
|
<div class="step" data-step-nav="2"><span class="step-index">2</span><span class="step-copy"><span class="step-title">数据库</span><span class="step-state">PostgreSQL</span></span></div>
|
||||||
|
<div class="step" data-step-nav="3"><span class="step-index">3</span><span class="step-copy"><span class="step-title">管理员</span><span class="step-state">初始账号</span></span></div>
|
||||||
|
<div class="step" data-step-nav="4"><span class="step-index">4</span><span class="step-copy"><span class="step-title">安装</span><span class="step-state">初始化系统</span></span></div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div id="fatal" class="hidden">
|
||||||
|
<div class="panel-head"><h1>安装链接不可用</h1><p class="subtitle">请回到服务器终端,使用安装程序输出的完整链接重新访问。</p></div>
|
||||||
|
<div class="notice error" id="fatal-message">链接缺少一次性安装令牌。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="view-1">
|
||||||
|
<div class="panel-head"><h1>环境检查</h1><p class="subtitle">确认安装程序和服务器运行环境已经准备就绪。</p></div>
|
||||||
|
<div class="section check-list">
|
||||||
|
<div class="check-row"><div><div class="check-label">Java 运行环境</div><div class="check-detail" id="java-detail">正在检测</div></div><span class="badge wait" id="java-badge">检测中</span></div>
|
||||||
|
<div class="check-row"><div><div class="check-label">安装程序</div><div class="check-detail" id="installer-detail">正在读取版本</div></div><span class="badge wait" id="installer-badge">检测中</span></div>
|
||||||
|
<div class="check-row"><div><div class="check-label">生产数据库要求</div><div class="check-detail">PostgreSQL 15 或更高版本</div></div><span class="badge">支持</span></div>
|
||||||
|
<div class="check-row"><div><div class="check-label">Redis</div><div class="check-detail">当前版本没有 Redis 运行依赖</div></div><span class="badge">无需配置</span></div>
|
||||||
|
</div>
|
||||||
|
<div id="status-message" class="notice info">正在连接本机安装服务。</div>
|
||||||
|
<div class="actions"><button class="btn primary" id="start-button" disabled>开始配置</button></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="view-2" class="hidden">
|
||||||
|
<div class="panel-head"><h1>连接 PostgreSQL</h1><p class="subtitle">填写生产数据库连接信息,并完成真实连接测试。</p></div>
|
||||||
|
<form id="database-form" class="section form-grid" autocomplete="off">
|
||||||
|
<div class="field"><label class="required" for="db-host">服务器地址</label><input id="db-host" name="host" value="127.0.0.1" maxlength="253" required></div>
|
||||||
|
<div class="field"><label class="required" for="db-port">端口</label><input id="db-port" name="port" type="number" min="1" max="65535" value="5432" required></div>
|
||||||
|
<div class="field"><label class="required" for="db-name">数据库名</label><input id="db-name" name="database" value="kaidi_erp" maxlength="63" required></div>
|
||||||
|
<div class="field"><label class="required" for="db-ssl">SSL 模式</label><select id="db-ssl" name="sslMode"><option value="prefer">prefer</option><option value="require">require</option><option value="verify-full">verify-full</option><option value="verify-ca">verify-ca</option><option value="disable">disable</option><option value="allow">allow</option></select></div>
|
||||||
|
<div class="field full"><label class="required" for="db-user">数据库账号</label><input id="db-user" name="username" maxlength="128" autocomplete="username" required></div>
|
||||||
|
<div class="field full"><label class="required" for="db-password">数据库密码</label><input id="db-password" name="password" type="password" maxlength="500" autocomplete="new-password" required><div class="hint">密码只提交给当前服务器上的安装程序,不会显示在确认页。</div></div>
|
||||||
|
</form>
|
||||||
|
<div id="db-message" class="notice info">连接测试通过后才能继续。</div>
|
||||||
|
<div class="actions"><button class="btn" data-back="1">上一步</button><button class="btn" id="test-db-button">测试连接</button><button class="btn primary" id="database-next" disabled>下一步</button></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="view-3" class="hidden">
|
||||||
|
<div class="panel-head"><h1>设置管理员</h1><p class="subtitle">创建系统的第一个管理员账号。</p></div>
|
||||||
|
<form id="admin-form" class="section form-grid" autocomplete="off">
|
||||||
|
<div class="field full"><label class="required" for="admin-login">管理员账号</label><input id="admin-login" name="loginName" value="admin" maxlength="64" autocomplete="username" required><div class="hint">以字母开头,可使用字母、数字、点、下划线和短横线。</div></div>
|
||||||
|
<div class="field full"><label class="required" for="admin-name">管理员姓名</label><input id="admin-name" name="displayName" maxlength="100" required></div>
|
||||||
|
<div class="field"><label class="required" for="admin-password">管理员密码</label><input id="admin-password" name="password" type="password" minlength="8" maxlength="200" autocomplete="new-password" required></div>
|
||||||
|
<div class="field"><label class="required" for="admin-confirm">确认密码</label><input id="admin-confirm" name="confirmPassword" type="password" minlength="8" maxlength="200" autocomplete="new-password" required></div>
|
||||||
|
</form>
|
||||||
|
<div id="admin-message" class="notice info">密码至少 8 位,请使用仅管理员本人知道的强密码。</div>
|
||||||
|
<div class="actions"><button class="btn" data-back="2">上一步</button><button class="btn primary" id="admin-next">下一步</button></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="view-4" class="hidden">
|
||||||
|
<div id="confirm-view">
|
||||||
|
<div class="panel-head"><h1>确认并安装</h1><p class="subtitle">确认连接目标和管理员账号。安装开始后会执行数据库迁移。</p></div>
|
||||||
|
<div class="section summary" id="summary"></div>
|
||||||
|
<div class="notice info">安装不会覆盖已经包含业务用户的数据库。检测到现有系统时会立即停止。</div>
|
||||||
|
<div class="actions"><button class="btn" data-back="3">上一步</button><button class="btn primary" id="install-button">开始安装</button></div>
|
||||||
|
</div>
|
||||||
|
<div id="installing-view" class="hidden">
|
||||||
|
<div class="panel-head"><h1>正在安装</h1><p class="subtitle">请保持当前页面打开。</p></div>
|
||||||
|
<div class="section progress-list">
|
||||||
|
<div class="progress-item running" data-progress="1"><span class="progress-dot"></span><span>验证 PostgreSQL 连接</span></div>
|
||||||
|
<div class="progress-item" data-progress="2"><span class="progress-dot"></span><span>执行数据库迁移</span></div>
|
||||||
|
<div class="progress-item" data-progress="3"><span class="progress-dot"></span><span>创建管理员账号</span></div>
|
||||||
|
<div class="progress-item" data-progress="4"><span class="progress-dot"></span><span>启动正式服务并写入安装锁</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="install-progress">
|
||||||
|
<div class="install-progress-head">
|
||||||
|
<span class="install-progress-label" id="install-phase">准备安装</span>
|
||||||
|
<strong class="install-progress-value" id="install-percent">0%</strong>
|
||||||
|
</div>
|
||||||
|
<div class="install-progress-track" id="install-progress-track" role="progressbar" aria-label="安装进度" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
|
||||||
|
<div class="install-progress-bar" id="install-progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
<div class="install-progress-meta">
|
||||||
|
<span id="install-elapsed">本页已等待 0 秒</span>
|
||||||
|
<span id="install-check">等待安装任务</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="install-console">
|
||||||
|
<div class="install-console-head"><span class="install-console-title">安装状态</span><span class="install-console-state" id="install-console-state">运行中</span></div>
|
||||||
|
<pre class="install-log" id="install-log" aria-live="polite"></pre>
|
||||||
|
</div>
|
||||||
|
<div id="install-message" class="notice info">正在初始化,数据库规模较大时可能需要几分钟。</div>
|
||||||
|
</div>
|
||||||
|
<div id="complete-view" class="hidden">
|
||||||
|
<div class="complete-mark">✓</div>
|
||||||
|
<div class="panel-head"><h1>安装完成</h1><p class="subtitle">正式服务已经通过健康检查,安装向导已被删除。</p></div>
|
||||||
|
<div class="notice">管理员账号已创建,可以进入系统登录。</div>
|
||||||
|
<div class="actions"><button class="btn primary" id="enter-button">进入系统</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const query = new URLSearchParams(location.search);
|
||||||
|
const queryToken = query.get('token') || '';
|
||||||
|
if (queryToken) sessionStorage.setItem('kaidi.setup.token', queryToken);
|
||||||
|
const token = queryToken || sessionStorage.getItem('kaidi.setup.token') || '';
|
||||||
|
if (queryToken) {
|
||||||
|
query.delete('token');
|
||||||
|
const clean = location.pathname + (query.toString() ? `?${query}` : '') + location.hash;
|
||||||
|
history.replaceState(null, '', clean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
step: 1,
|
||||||
|
database: null,
|
||||||
|
databaseFingerprint: '',
|
||||||
|
administrator: null,
|
||||||
|
installing: false,
|
||||||
|
installStartedAt: 0,
|
||||||
|
installPercent: 0,
|
||||||
|
installLogEntries: [],
|
||||||
|
healthPolling: false,
|
||||||
|
};
|
||||||
|
const FORMAL_SERVICE_TIMEOUT_MS = 240000;
|
||||||
|
const $ = (selector) => document.querySelector(selector);
|
||||||
|
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
|
||||||
|
|
||||||
|
function showFatal(message) {
|
||||||
|
['#view-1', '#view-2', '#view-3', '#view-4'].forEach((id) => $(id).classList.add('hidden'));
|
||||||
|
$('#fatal').classList.remove('hidden');
|
||||||
|
$('#fatal-message').textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
...options,
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Setup-Token': token, ...(options.headers || {}) },
|
||||||
|
});
|
||||||
|
let payload;
|
||||||
|
try { payload = await response.json(); } catch { throw new Error('安装服务响应无效'); }
|
||||||
|
if (!response.ok || payload.code !== 0) throw new Error(payload.message || '请求失败');
|
||||||
|
return payload.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function go(step) {
|
||||||
|
state.step = step;
|
||||||
|
for (let i = 1; i <= 4; i += 1) {
|
||||||
|
$(`#view-${i}`).classList.toggle('hidden', i !== step);
|
||||||
|
const nav = $(`[data-step-nav="${i}"]`);
|
||||||
|
nav.classList.toggle('active', i === step);
|
||||||
|
nav.classList.toggle('done', i < step);
|
||||||
|
nav.querySelector('.step-index').textContent = i < step ? '✓' : String(i);
|
||||||
|
}
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function databaseValue() {
|
||||||
|
const data = new FormData($('#database-form'));
|
||||||
|
return {
|
||||||
|
host: String(data.get('host') || '').trim(),
|
||||||
|
port: Number(data.get('port')),
|
||||||
|
database: String(data.get('database') || '').trim(),
|
||||||
|
username: String(data.get('username') || '').trim(),
|
||||||
|
password: String(data.get('password') || ''),
|
||||||
|
sslMode: String(data.get('sslMode') || ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fingerprint(database) { return JSON.stringify(database); }
|
||||||
|
|
||||||
|
function invalidateDatabaseTest() {
|
||||||
|
if (state.databaseFingerprint && fingerprint(databaseValue()) !== state.databaseFingerprint) {
|
||||||
|
state.databaseFingerprint = '';
|
||||||
|
state.database = null;
|
||||||
|
$('#database-next').disabled = true;
|
||||||
|
$('#db-message').className = 'notice info';
|
||||||
|
$('#db-message').textContent = '连接信息已修改,请重新测试。';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function administratorValue() {
|
||||||
|
const data = new FormData($('#admin-form'));
|
||||||
|
return {
|
||||||
|
loginName: String(data.get('loginName') || '').trim(),
|
||||||
|
displayName: String(data.get('displayName') || '').trim(),
|
||||||
|
password: String(data.get('password') || ''),
|
||||||
|
confirmPassword: String(data.get('confirmPassword') || ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummary() {
|
||||||
|
const rows = [
|
||||||
|
['PostgreSQL', `${state.database.host}:${state.database.port}`],
|
||||||
|
['数据库', state.database.database],
|
||||||
|
['数据库账号', state.database.username],
|
||||||
|
['SSL 模式', state.database.sslMode],
|
||||||
|
['管理员账号', state.administrator.loginName],
|
||||||
|
['管理员姓名', state.administrator.displayName],
|
||||||
|
];
|
||||||
|
$('#summary').replaceChildren(...rows.map(([key, value]) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'summary-row';
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'summary-key';
|
||||||
|
label.textContent = key;
|
||||||
|
const content = document.createElement('span');
|
||||||
|
content.className = 'summary-value';
|
||||||
|
content.textContent = value;
|
||||||
|
row.append(label, content);
|
||||||
|
return row;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProgress(index) {
|
||||||
|
$$('[data-progress]').forEach((item) => {
|
||||||
|
const value = Number(item.dataset.progress);
|
||||||
|
item.classList.toggle('done', value < index);
|
||||||
|
item.classList.toggle('running', value === index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElapsed(milliseconds) {
|
||||||
|
const seconds = Math.max(0, Math.floor(milliseconds / 1000));
|
||||||
|
if (seconds < 60) return `${seconds} 秒`;
|
||||||
|
return `${Math.floor(seconds / 60)} 分 ${seconds % 60} 秒`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInstallMeter(percent, phase, detail) {
|
||||||
|
const value = Math.max(state.installPercent, Math.max(0, Math.min(100, Math.round(percent))));
|
||||||
|
state.installPercent = value;
|
||||||
|
$('#install-progress-bar').style.width = `${value}%`;
|
||||||
|
$('#install-percent').textContent = `${value}%`;
|
||||||
|
$('#install-phase').textContent = phase;
|
||||||
|
$('#install-check').textContent = detail;
|
||||||
|
$('#install-elapsed').textContent = `本页已等待 ${formatElapsed(Date.now() - state.installStartedAt)}`;
|
||||||
|
const track = $('#install-progress-track');
|
||||||
|
track.setAttribute('aria-valuenow', String(value));
|
||||||
|
track.setAttribute('aria-valuetext', `${phase},${value}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendInstallLog(message) {
|
||||||
|
const timestamp = new Date().toLocaleTimeString('zh-CN', { hour12: false });
|
||||||
|
state.installLogEntries.push(`[${timestamp}] ${message}`);
|
||||||
|
state.installLogEntries = state.installLogEntries.slice(-16);
|
||||||
|
const output = $('#install-log');
|
||||||
|
output.textContent = state.installLogEntries.join('\n');
|
||||||
|
output.scrollTop = output.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginInstallTracking(resumed) {
|
||||||
|
state.installStartedAt = Date.now();
|
||||||
|
state.installLogEntries = [];
|
||||||
|
$('#install-message').className = 'notice info';
|
||||||
|
$('#install-console-state').textContent = '运行中';
|
||||||
|
if (resumed) {
|
||||||
|
setInstallMeter(70, '恢复安装任务', '准备检查正式服务');
|
||||||
|
appendInstallLog('检测到尚未完成的安装任务,继续等待正式服务。');
|
||||||
|
} else {
|
||||||
|
setInstallMeter(5, '提交安装任务', '准备验证数据库');
|
||||||
|
appendInstallLog('安装请求已提交,正在验证 PostgreSQL 连接和权限。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollFormalService() {
|
||||||
|
if (state.healthPolling) return;
|
||||||
|
state.healthPolling = true;
|
||||||
|
if (!state.installStartedAt) beginInstallTracking(true);
|
||||||
|
setProgress(4);
|
||||||
|
setInstallMeter(Math.max(state.installPercent, 72), '启动正式服务', '等待第 1 次健康检查');
|
||||||
|
appendInstallLog('数据库初始化完成,安装器正在切换到正式服务。');
|
||||||
|
$('#install-message').textContent = '初始化完成,正在等待正式服务通过健康检查。页面会持续显示检查进度。';
|
||||||
|
const healthStartedAt = Date.now();
|
||||||
|
const deadline = healthStartedAt + FORMAL_SERVICE_TIMEOUT_MS;
|
||||||
|
let attempt = 0;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
attempt += 1;
|
||||||
|
const healthElapsed = Date.now() - healthStartedAt;
|
||||||
|
const estimated = 72 + Math.floor((healthElapsed / FORMAL_SERVICE_TIMEOUT_MS) * 23);
|
||||||
|
setInstallMeter(Math.min(95, estimated), '等待正式服务健康检查', `第 ${attempt} 次检查`);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/oa/health', { cache: 'no-store' });
|
||||||
|
const body = await response.json();
|
||||||
|
const serviceStatus = body?.data?.status || body?.status || `HTTP ${response.status}`;
|
||||||
|
const installLocked = body?.data?.installLocked ?? body?.installLocked;
|
||||||
|
if (response.ok && serviceStatus === 'UP' && (installLocked === true || installLocked === 'true')) {
|
||||||
|
setInstallMeter(100, '安装完成', '正式服务已就绪');
|
||||||
|
appendInstallLog(`健康检查第 ${attempt} 次通过,安装锁已确认。`);
|
||||||
|
$('#install-console-state').textContent = '已完成';
|
||||||
|
$$('[data-progress]').forEach((item) => { item.classList.remove('running'); item.classList.add('done'); });
|
||||||
|
$('#installing-view').classList.add('hidden');
|
||||||
|
$('#complete-view').classList.remove('hidden');
|
||||||
|
sessionStorage.removeItem('kaidi.setup.token');
|
||||||
|
state.healthPolling = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.ok && serviceStatus === 'UP') {
|
||||||
|
setInstallMeter(98, '正式服务已通过健康检查', '等待写入安装锁');
|
||||||
|
appendInstallLog(`健康检查第 ${attempt} 次通过,正在等待后台写入安装锁。`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
appendInstallLog(`健康检查第 ${attempt} 次:服务状态 ${serviceStatus}。`);
|
||||||
|
} catch {
|
||||||
|
appendInstallLog(`健康检查第 ${attempt} 次:服务端口正在切换,继续等待。`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.healthPolling = false;
|
||||||
|
setInstallMeter(95, '正式服务尚未就绪', `${Math.round(FORMAL_SERVICE_TIMEOUT_MS / 1000)} 秒检查超时`);
|
||||||
|
appendInstallLog('健康检查超时,安装状态已保留,未写入完成锁。');
|
||||||
|
$('#install-console-state').textContent = '需要检查';
|
||||||
|
$('#install-message').className = 'notice error';
|
||||||
|
$('#install-message').textContent = '正式服务尚未就绪。请查看 systemctl 日志或 /var/lib/kaidi-erp/install-formal.log;安装文件和待确认状态均已保留。';
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#start-button').addEventListener('click', () => go(2));
|
||||||
|
$$('[data-back]').forEach((button) => button.addEventListener('click', () => go(Number(button.dataset.back))));
|
||||||
|
$('#database-form').addEventListener('input', invalidateDatabaseTest);
|
||||||
|
$('#database-form').addEventListener('change', invalidateDatabaseTest);
|
||||||
|
|
||||||
|
$('#test-db-button').addEventListener('click', async () => {
|
||||||
|
const form = $('#database-form');
|
||||||
|
if (!form.reportValidity()) return;
|
||||||
|
const button = $('#test-db-button');
|
||||||
|
const database = databaseValue();
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = '测试中';
|
||||||
|
$('#db-message').className = 'notice info';
|
||||||
|
$('#db-message').textContent = '正在连接 PostgreSQL 并检查 pg_trgm 扩展。';
|
||||||
|
try {
|
||||||
|
const result = await api('/api/install/test-database', { method: 'POST', body: JSON.stringify(database) });
|
||||||
|
state.database = database;
|
||||||
|
state.databaseFingerprint = fingerprint(database);
|
||||||
|
$('#database-next').disabled = false;
|
||||||
|
$('#db-message').className = 'notice';
|
||||||
|
$('#db-message').textContent = `${result.message}(${result.postgresVersion})`;
|
||||||
|
} catch (error) {
|
||||||
|
state.database = null;
|
||||||
|
state.databaseFingerprint = '';
|
||||||
|
$('#database-next').disabled = true;
|
||||||
|
$('#db-message').className = 'notice error';
|
||||||
|
$('#db-message').textContent = error.message;
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = '测试连接';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#database-next').addEventListener('click', () => {
|
||||||
|
if (!state.database || fingerprint(databaseValue()) !== state.databaseFingerprint) return invalidateDatabaseTest();
|
||||||
|
go(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#admin-next').addEventListener('click', () => {
|
||||||
|
const form = $('#admin-form');
|
||||||
|
if (!form.reportValidity()) return;
|
||||||
|
const administrator = administratorValue();
|
||||||
|
if (administrator.password !== administrator.confirmPassword) {
|
||||||
|
$('#admin-message').className = 'notice error';
|
||||||
|
$('#admin-message').textContent = '两次输入的管理员密码不一致。';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.administrator = {
|
||||||
|
loginName: administrator.loginName,
|
||||||
|
displayName: administrator.displayName,
|
||||||
|
password: administrator.password,
|
||||||
|
};
|
||||||
|
$('#admin-message').className = 'notice info';
|
||||||
|
$('#admin-message').textContent = '管理员信息已填写。';
|
||||||
|
renderSummary();
|
||||||
|
go(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#install-button').addEventListener('click', async () => {
|
||||||
|
if (state.installing || !state.database || !state.administrator) return;
|
||||||
|
state.installing = true;
|
||||||
|
$('#confirm-view').classList.add('hidden');
|
||||||
|
$('#installing-view').classList.remove('hidden');
|
||||||
|
beginInstallTracking(false);
|
||||||
|
setProgress(1);
|
||||||
|
let migrationTimer;
|
||||||
|
try {
|
||||||
|
const request = { database: state.database, administrator: state.administrator };
|
||||||
|
migrationTimer = setTimeout(() => {
|
||||||
|
setProgress(2);
|
||||||
|
setInstallMeter(28, '执行数据库初始化', '迁移结构和基础数据');
|
||||||
|
appendInstallLog('数据库连接验证通过,正在执行结构迁移和基础数据初始化。');
|
||||||
|
}, 700);
|
||||||
|
try {
|
||||||
|
await api('/api/install/complete', { method: 'POST', body: JSON.stringify(request) });
|
||||||
|
} catch (error) {
|
||||||
|
// The installer intentionally exits immediately after committing. A
|
||||||
|
// browser may see the TCP connection close before the JSON response
|
||||||
|
// is flushed; the formal health probe is the authoritative result.
|
||||||
|
if (!(error instanceof TypeError) && error.message !== '安装服务响应无效') throw error;
|
||||||
|
}
|
||||||
|
clearTimeout(migrationTimer);
|
||||||
|
setProgress(3);
|
||||||
|
setInstallMeter(68, '创建管理员并切换服务', '数据库初始化已完成');
|
||||||
|
appendInstallLog('数据库迁移和管理员初始化已提交,准备启动正式服务。');
|
||||||
|
state.administrator.password = '';
|
||||||
|
state.database.password = '';
|
||||||
|
await pollFormalService();
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(migrationTimer);
|
||||||
|
state.installing = false;
|
||||||
|
state.healthPolling = false;
|
||||||
|
setInstallMeter(state.installPercent, '安装已中断', '请处理错误后重试');
|
||||||
|
appendInstallLog(`安装中断:${error.message}`);
|
||||||
|
$('#install-console-state').textContent = '已中断';
|
||||||
|
$('#install-message').className = 'notice error';
|
||||||
|
$('#install-message').textContent = error.message;
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'actions';
|
||||||
|
const back = document.createElement('button');
|
||||||
|
back.className = 'btn';
|
||||||
|
back.textContent = '返回检查';
|
||||||
|
back.addEventListener('click', () => location.reload());
|
||||||
|
actions.append(back);
|
||||||
|
$('#installing-view').append(actions);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#enter-button').addEventListener('click', () => location.assign('/'));
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
if (!token) return showFatal('链接缺少一次性安装令牌。请使用服务器终端输出的完整链接。');
|
||||||
|
try {
|
||||||
|
const status = await api('/api/install/status');
|
||||||
|
if (status.locked) return showFatal('系统已经完成安装,安装向导已锁定。');
|
||||||
|
if (status.pending) {
|
||||||
|
go(4);
|
||||||
|
$('#confirm-view').classList.add('hidden');
|
||||||
|
$('#installing-view').classList.remove('hidden');
|
||||||
|
beginInstallTracking(true);
|
||||||
|
return pollFormalService();
|
||||||
|
}
|
||||||
|
$('#java-detail').textContent = `Java ${status.javaVersion},最低要求 Java ${status.minimumJava}`;
|
||||||
|
$('#java-badge').className = 'badge';
|
||||||
|
$('#java-badge').textContent = '通过';
|
||||||
|
$('#installer-detail').textContent = `安装包版本 ${status.version}`;
|
||||||
|
$('#installer-badge').className = 'badge';
|
||||||
|
$('#installer-badge').textContent = '通过';
|
||||||
|
$('#status-message').className = 'notice';
|
||||||
|
$('#status-message').textContent = '服务器环境检查通过,可以开始配置。';
|
||||||
|
$('#start-button').disabled = false;
|
||||||
|
} catch (error) {
|
||||||
|
showFatal(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boot();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.kaidi.oa.install;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@SpringBootTest(
|
||||||
|
classes = InstallerApplication.class,
|
||||||
|
properties = {
|
||||||
|
"erp.install.token=test-token",
|
||||||
|
"erp.install.version=test",
|
||||||
|
"erp.install.exit-after-complete=false",
|
||||||
|
"erp.install.install-root=build/installer-test",
|
||||||
|
"erp.install.config-file=build/installer-test/config/erp.env",
|
||||||
|
"erp.install.pending-file=build/installer-test/state/install.pending",
|
||||||
|
"erp.install.lock-file=build/installer-test/state/install.lock",
|
||||||
|
"erp.install.operation-lock-file=build/installer-test/state/install-operation.lock"
|
||||||
|
})
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
class InstallerApiTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void setupApiRequiresOneTimeToken() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/install/status"))
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(content().json("{\"code\":40301}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void setupStatusAndStaticPageAreAvailableWithToken() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/install/status").header("X-Setup-Token", "test-token"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(content().json("{\"code\":0,\"data\":{\"minimumPostgres\":15,\"redisRequired\":false,\"locked\":false}}", false));
|
||||||
|
mockMvc.perform(get("/"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(forwardedUrl("index.html"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,8 @@ public class AuthInterceptor implements HandlerInterceptor {
|
|||||||
private static final Set<String> PUBLIC = Set.of(
|
private static final Set<String> PUBLIC = Set.of(
|
||||||
"/api/oa/auth/login",
|
"/api/oa/auth/login",
|
||||||
"/api/oa/auth/session",
|
"/api/oa/auth/session",
|
||||||
"/api/oa/auth/logout"
|
"/api/oa/auth/logout",
|
||||||
|
"/api/oa/health"
|
||||||
);
|
);
|
||||||
|
|
||||||
/** 写操作方法(只有这些方法才触发授权门槛;GET/HEAD 等读操作不限制)。 */
|
/** 写操作方法(只有这些方法才触发授权门槛;GET/HEAD 等读操作不限制)。 */
|
||||||
@@ -44,6 +45,7 @@ public class AuthInterceptor implements HandlerInterceptor {
|
|||||||
|
|
||||||
private static final Set<String> ADMIN = Set.of("ADMIN");
|
private static final Set<String> ADMIN = Set.of("ADMIN");
|
||||||
private static final Set<String> APPROVER_OR_ADMIN = Set.of("ADMIN", "APPROVER");
|
private static final Set<String> APPROVER_OR_ADMIN = Set.of("ADMIN", "APPROVER");
|
||||||
|
private static final List<String> ADMIN_READ_PREFIXES = List.of("/api/oa/system-update");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 系统 / 组织 / 权限 / 配置 类写接口 → 仅 ADMIN。
|
* 系统 / 组织 / 权限 / 配置 类写接口 → 仅 ADMIN。
|
||||||
@@ -51,6 +53,7 @@ public class AuthInterceptor implements HandlerInterceptor {
|
|||||||
*/
|
*/
|
||||||
private static final List<String> ADMIN_PREFIXES = List.of(
|
private static final List<String> ADMIN_PREFIXES = List.of(
|
||||||
"/api/oa/users", "/api/oa/depts", "/api/oa/roles", "/api/oa/settings",
|
"/api/oa/users", "/api/oa/depts", "/api/oa/roles", "/api/oa/settings",
|
||||||
|
"/api/oa/system-update",
|
||||||
"/api/oa/delegations", "/api/oa/form-templates", "/api/oa/declaration-templates",
|
"/api/oa/delegations", "/api/oa/form-templates", "/api/oa/declaration-templates",
|
||||||
"/api/oa/contract-templates", "/api/oa/report-definitions", "/api/oa/crawl-sources",
|
"/api/oa/contract-templates", "/api/oa/report-definitions", "/api/oa/crawl-sources",
|
||||||
// 全文索引重建(POST /search/reindex)是全库重活,仅 ADMIN 可触发(杜绝任意角色发起整库扫描)。
|
// 全文索引重建(POST /search/reindex)是全库重活,仅 ADMIN 可触发(杜绝任意角色发起整库扫描)。
|
||||||
@@ -432,6 +435,10 @@ public class AuthInterceptor implements HandlerInterceptor {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (matchesPrefix(path, ADMIN_READ_PREFIXES) && ADMIN.stream().noneMatch(have::contains)) {
|
||||||
|
writeError(response, HttpStatus.FORBIDDEN, 403, "无权限:系统更新仅限 ADMIN 角色");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// 读:机密财务/PII 读口需 ADMIN/APPROVER,其余业务读维持"已登录且有角色"可读。
|
// 读:机密财务/PII 读口需 ADMIN/APPROVER,其余业务读维持"已登录且有角色"可读。
|
||||||
if (isSensitiveRead(path) && APPROVER_OR_ADMIN.stream().noneMatch(have::contains)) {
|
if (isSensitiveRead(path) && APPROVER_OR_ADMIN.stream().noneMatch(have::contains)) {
|
||||||
writeError(response, HttpStatus.FORBIDDEN, 403, "无权限:该数据需要 ADMIN/APPROVER 角色");
|
writeError(response, HttpStatus.FORBIDDEN, 403, "无权限:该数据需要 ADMIN/APPROVER 角色");
|
||||||
@@ -441,6 +448,15 @@ public class AuthInterceptor implements HandlerInterceptor {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean matchesPrefix(String path, List<String> prefixes) {
|
||||||
|
for (String prefix : prefixes) {
|
||||||
|
if (path.equals(prefix) || path.startsWith(prefix + "/")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private static boolean isSensitiveRead(String path) {
|
private static boolean isSensitiveRead(String path) {
|
||||||
// 研发证据链聚合端点 /api/oa/rd-projects/{id}/evidence-chain 会端出研发费用金额/凭证号/申报/专利
|
// 研发证据链聚合端点 /api/oa/rd-projects/{id}/evidence-chain 会端出研发费用金额/凭证号/申报/专利
|
||||||
// 等本应被 rd-expenses 读门槛拦下的明细(基路径 rd-projects 非敏感,故按后缀精确收口,
|
// 等本应被 rd-expenses 读门槛拦下的明细(基路径 rd-projects 非敏感,故按后缀精确收口,
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package com.kaidi.oa.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/** Runtime configuration for the Gitea release updater. */
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "oa.update")
|
||||||
|
public class UpdateProperties {
|
||||||
|
|
||||||
|
private boolean enabled;
|
||||||
|
private String giteaBaseUrl = "https://git.awaioi.com";
|
||||||
|
private String repository = "awaioi/ERP";
|
||||||
|
private String channel = "stable";
|
||||||
|
private String token = "";
|
||||||
|
private String helperCommand = "";
|
||||||
|
private String stateFile = "./runtime/update-state.json";
|
||||||
|
private String configFile = "";
|
||||||
|
private boolean allowInsecureHttp;
|
||||||
|
private int requestTimeoutSeconds = 15;
|
||||||
|
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnabled(boolean enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getGiteaBaseUrl() {
|
||||||
|
return giteaBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setGiteaBaseUrl(String giteaBaseUrl) {
|
||||||
|
this.giteaBaseUrl = giteaBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRepository() {
|
||||||
|
return repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRepository(String repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getChannel() {
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChannel(String channel) {
|
||||||
|
this.channel = channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getToken() {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setToken(String token) {
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getHelperCommand() {
|
||||||
|
return helperCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setHelperCommand(String helperCommand) {
|
||||||
|
this.helperCommand = helperCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStateFile() {
|
||||||
|
return stateFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStateFile(String stateFile) {
|
||||||
|
this.stateFile = stateFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getConfigFile() {
|
||||||
|
return configFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setConfigFile(String configFile) {
|
||||||
|
this.configFile = configFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAllowInsecureHttp() {
|
||||||
|
return allowInsecureHttp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAllowInsecureHttp(boolean allowInsecureHttp) {
|
||||||
|
this.allowInsecureHttp = allowInsecureHttp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getRequestTimeoutSeconds() {
|
||||||
|
return requestTimeoutSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequestTimeoutSeconds(int requestTimeoutSeconds) {
|
||||||
|
this.requestTimeoutSeconds = requestTimeoutSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -24,7 +23,6 @@ public class Announcement {
|
|||||||
|
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,18 +91,18 @@ public class AnnualReport {
|
|||||||
// ---------- 扩展填报字段(模板化,各类年报共享) ----------
|
// ---------- 扩展填报字段(模板化,各类年报共享) ----------
|
||||||
|
|
||||||
/** 关键指标描述(校验摘要/主要指标文字汇总)。 */
|
/** 关键指标描述(校验摘要/主要指标文字汇总)。 */
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String keyIndicatorSummary;
|
private String keyIndicatorSummary;
|
||||||
|
|
||||||
/** 数据一致性校验结果(自动校验后填入,如"研发费用占比符合要求")。 */
|
/** 数据一致性校验结果(自动校验后填入,如"研发费用占比符合要求")。 */
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String validationResult;
|
private String validationResult;
|
||||||
|
|
||||||
/** 校验是否通过:true 全部通过,false 有警告或错误。 */
|
/** 校验是否通过:true 全部通过,false 有警告或错误。 */
|
||||||
private Boolean validationPassed;
|
private Boolean validationPassed;
|
||||||
|
|
||||||
/** 备注/填报说明。 */
|
/** 备注/填报说明。 */
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String remark;
|
private String remark;
|
||||||
|
|
||||||
/** 填报负责人。 */
|
/** 填报负责人。 */
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/** 博客文章(知识社区 - 我的博客)。tags 为逗号分隔。 */
|
/** 博客文章(知识社区 - 我的博客)。tags 为逗号分隔。 */
|
||||||
@@ -22,7 +21,6 @@ public class Blog {
|
|||||||
@Column(length = 2000)
|
@Column(length = 2000)
|
||||||
private String summary;
|
private String summary;
|
||||||
|
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String body;
|
private String body;
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,7 +30,7 @@ public class ChronicleEvent {
|
|||||||
|
|
||||||
private String category;
|
private String category;
|
||||||
|
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
/** 关联文档/单据(逗号分隔的引用,如 IP-2026-0001 / 合同号 / 文件名)。 */
|
/** 关联文档/单据(逗号分隔的引用,如 IP-2026-0001 / 合同号 / 文件名)。 */
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -33,19 +32,16 @@ public class CollabDoc {
|
|||||||
|
|
||||||
private String status;
|
private String status;
|
||||||
|
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
private Instant updatedAt;
|
private Instant updatedAt;
|
||||||
|
|
||||||
/** 历史版本 JSON 数组:[{version, content, savedAt, editor}]。每次更新正文前追加上一版。 */
|
/** 历史版本 JSON 数组:[{version, content, savedAt, editor}]。每次更新正文前追加上一版。 */
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String versionsJson;
|
private String versionsJson;
|
||||||
|
|
||||||
/** 评论/批注 JSON 数组:[{author, content, createdAt}]。 */
|
/** 评论/批注 JSON 数组:[{author, content, createdAt}]。 */
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String commentsJson;
|
private String commentsJson;
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import jakarta.persistence.GeneratedValue;
|
|||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Index;
|
import jakarta.persistence.Index;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
import org.hibernate.annotations.JdbcTypeCode;
|
||||||
|
import org.hibernate.type.SqlTypes;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
@@ -34,8 +35,9 @@ public class CollabDocUpdate {
|
|||||||
/** Monotonic per-document ordering (assigned at append time). */
|
/** Monotonic per-document ordering (assigned at append time). */
|
||||||
private Long seq;
|
private Long seq;
|
||||||
|
|
||||||
/** Raw yjs update bytes. Plain byte[] (NOT @Lob): SQLite JDBC can't read @Lob blobs; getBytes() works. */
|
/** Raw yjs bytes mapped to SQLite BLOB and PostgreSQL bytea. */
|
||||||
@Column(name = "data", columnDefinition = "BLOB")
|
@JdbcTypeCode(SqlTypes.LONGVARBINARY)
|
||||||
|
@Column(name = "data", length = Integer.MAX_VALUE)
|
||||||
private byte[] data;
|
private byte[] data;
|
||||||
|
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,7 +40,7 @@ public class CompetitorIp {
|
|||||||
|
|
||||||
private String publicDate;
|
private String publicDate;
|
||||||
|
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String summary;
|
private String summary;
|
||||||
|
|
||||||
/** 录入来源:手动 / API导入。 */
|
/** 录入来源:手动 / API导入。 */
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -61,13 +60,11 @@ public class ComplianceRiskReport {
|
|||||||
private int findingCount;
|
private int findingCount;
|
||||||
|
|
||||||
/** 漏洞明细文本(可存 JSON 格式)。 */
|
/** 漏洞明细文本(可存 JSON 格式)。 */
|
||||||
@Lob
|
@Column(name = "findings_text", columnDefinition = "TEXT")
|
||||||
@Column(name = "findings_text")
|
|
||||||
private String findings;
|
private String findings;
|
||||||
|
|
||||||
/** 整改建议。 */
|
/** 整改建议。 */
|
||||||
@Lob
|
@Column(name = "suggestion_text", columnDefinition = "TEXT")
|
||||||
@Column(name = "suggestion_text")
|
|
||||||
private String suggestion;
|
private String suggestion;
|
||||||
|
|
||||||
/** 报告生成人。 */
|
/** 报告生成人。 */
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -46,8 +45,7 @@ public class ContentPieceVersion {
|
|||||||
private String titleSnapshot;
|
private String titleSnapshot;
|
||||||
|
|
||||||
/** 正文快照(完整内容,用于 diff 对比)。 */
|
/** 正文快照(完整内容,用于 diff 对比)。 */
|
||||||
@Lob
|
@Column(name = "body_snapshot", columnDefinition = "TEXT")
|
||||||
@Column(name = "body_snapshot")
|
|
||||||
private String bodySnapshot;
|
private String bodySnapshot;
|
||||||
|
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -31,7 +31,7 @@ public class ContractTemplate {
|
|||||||
|
|
||||||
private String applicableSubject;
|
private String applicableSubject;
|
||||||
|
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String bodyTemplate;
|
private String bodyTemplate;
|
||||||
|
|
||||||
private String requiredClauses;
|
private String requiredClauses;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -36,7 +36,7 @@ public class DeclarationTemplate {
|
|||||||
|
|
||||||
private String autoCheckRules;
|
private String autoCheckRules;
|
||||||
|
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String bodyTemplate;
|
private String bodyTemplate;
|
||||||
|
|
||||||
private String version;
|
private String version;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -28,7 +27,6 @@ public class Discussion {
|
|||||||
|
|
||||||
private String category;
|
private String category;
|
||||||
|
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
@@ -37,7 +35,6 @@ public class Discussion {
|
|||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|
||||||
/** 回帖列表 JSON 数组:[{author, content, createdAt}]。 */
|
/** 回帖列表 JSON 数组:[{author, content, createdAt}]。 */
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String repliesJson;
|
private String repliesJson;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/** 享空间动态(文化建设 - 分享流)。tags 为逗号分隔。 */
|
/** 享空间动态(文化建设 - 分享流)。tags 为逗号分隔。 */
|
||||||
@@ -24,7 +23,6 @@ public class Feed {
|
|||||||
/** 动态 / 分享 / 图片 / 打卡 */
|
/** 动态 / 分享 / 图片 / 打卡 */
|
||||||
private String type;
|
private String type;
|
||||||
|
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
@@ -37,7 +35,6 @@ public class Feed {
|
|||||||
private int comments;
|
private int comments;
|
||||||
|
|
||||||
/** 评论正文列表 [{author,content,createdAt}] 的 JSON。 */
|
/** 评论正文列表 [{author,content,createdAt}] 的 JSON。 */
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String commentsJson;
|
private String commentsJson;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -39,12 +38,10 @@ public class FertProductQc {
|
|||||||
private String standardCode;
|
private String standardCode;
|
||||||
|
|
||||||
/** 实测值 JSON:{"有机质":45.2,"水分":28.1,...}(键=indicator)。 */
|
/** 实测值 JSON:{"有机质":45.2,"水分":28.1,...}(键=indicator)。 */
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "text")
|
@Column(columnDefinition = "text")
|
||||||
private String itemsJson;
|
private String itemsJson;
|
||||||
|
|
||||||
/** 逐项判定明细 JSON(自动生成的报告口径)。 */
|
/** 逐项判定明细 JSON(自动生成的报告口径)。 */
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "text")
|
@Column(columnDefinition = "text")
|
||||||
private String judgeJson;
|
private String judgeJson;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -68,7 +67,6 @@ public class FertSupplierProfile {
|
|||||||
private String licenseDoc;
|
private String licenseDoc;
|
||||||
|
|
||||||
/** 补充资质 JSON(扩展字段 {"有机认证":"有","产品标准":"QB/T xxxx",...})。 */
|
/** 补充资质 JSON(扩展字段 {"有机认证":"有","产品标准":"QB/T xxxx",...})。 */
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "text")
|
@Column(columnDefinition = "text")
|
||||||
private String admitDocsJson;
|
private String admitDocsJson;
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ public class Financing {
|
|||||||
private String currency;
|
private String currency;
|
||||||
|
|
||||||
/** 年化利率(百分比,如 4.35 表示 4.35%)。 */
|
/** 年化利率(百分比,如 4.35 表示 4.35%)。 */
|
||||||
private Double rate;
|
private BigDecimal rate;
|
||||||
|
|
||||||
private String startDate;
|
private String startDate;
|
||||||
|
|
||||||
@@ -122,11 +122,11 @@ public class Financing {
|
|||||||
this.currency = currency;
|
this.currency = currency;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Double getRate() {
|
public BigDecimal getRate() {
|
||||||
return rate;
|
return rate;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setRate(Double rate) {
|
public void setRate(BigDecimal rate) {
|
||||||
this.rate = rate;
|
this.rate = rate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -31,7 +30,6 @@ public class FlowTrace {
|
|||||||
/** Who handled the step. */
|
/** Who handled the step. */
|
||||||
private String who;
|
private String who;
|
||||||
|
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String opinion;
|
private String opinion;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -33,7 +32,6 @@ public class FormInstance {
|
|||||||
|
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
@Lob
|
|
||||||
@Column(name = "data_json", columnDefinition = "TEXT")
|
@Column(name = "data_json", columnDefinition = "TEXT")
|
||||||
private String dataJson;
|
private String dataJson;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.kaidi.oa.domain;
|
|||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,15 +27,12 @@ public class FormTemplate {
|
|||||||
|
|
||||||
private String org;
|
private String org;
|
||||||
|
|
||||||
@Lob
|
|
||||||
@Column(name = "form_schema_json", columnDefinition = "TEXT")
|
@Column(name = "form_schema_json", columnDefinition = "TEXT")
|
||||||
private String formSchemaJson;
|
private String formSchemaJson;
|
||||||
|
|
||||||
@Lob
|
|
||||||
@Column(name = "flow_schema_json", columnDefinition = "TEXT")
|
@Column(name = "flow_schema_json", columnDefinition = "TEXT")
|
||||||
private String flowSchemaJson;
|
private String flowSchemaJson;
|
||||||
|
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String instructions;
|
private String instructions;
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -40,7 +40,7 @@ public class HrLaborTemplate {
|
|||||||
private String version;
|
private String version;
|
||||||
|
|
||||||
/** 模板正文(含占位符 {员工姓名} {岗位} {薪资} 等)。 */
|
/** 模板正文(含占位符 {员工姓名} {岗位} {薪资} 等)。 */
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String bodyTemplate;
|
private String bodyTemplate;
|
||||||
|
|
||||||
/** 必备条款(逗号分隔,如「试用期条款,保密条款,竞业限制条款」)。 */
|
/** 必备条款(逗号分隔,如「试用期条款,保密条款,竞业限制条款」)。 */
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -44,7 +43,7 @@ public class InternalNewsletter {
|
|||||||
private String authorDept;
|
private String authorDept;
|
||||||
|
|
||||||
/** 正文(富文本,允许大段文字)。 */
|
/** 正文(富文本,允许大段文字)。 */
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String body;
|
private String body;
|
||||||
|
|
||||||
/** 配图/附件 URL(逗号分隔,如 "https://…/img1.jpg,https://…/img2.jpg")。 */
|
/** 配图/附件 URL(逗号分隔,如 "https://…/img1.jpg,https://…/img2.jpg")。 */
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,7 +92,7 @@ public class IpAsset {
|
|||||||
/** 责任人(IP 部门跟案人)。 */
|
/** 责任人(IP 部门跟案人)。 */
|
||||||
private String owner;
|
private String owner;
|
||||||
|
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String remark;
|
private String remark;
|
||||||
|
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,7 +35,7 @@ public class IpAssetEvent {
|
|||||||
|
|
||||||
private String toValue;
|
private String toValue;
|
||||||
|
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String note;
|
private String note;
|
||||||
|
|
||||||
/** 操作人(跟案人/复核人)。 */
|
/** 操作人(跟案人/复核人)。 */
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,7 +33,7 @@ public class IpKnowledge {
|
|||||||
/** 技术关键词(逗号分隔,供按关键词检索)。 */
|
/** 技术关键词(逗号分隔,供按关键词检索)。 */
|
||||||
private String keywords;
|
private String keywords;
|
||||||
|
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
private String source;
|
private String source;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,7 +71,7 @@ public class IpLicenseTransfer {
|
|||||||
private String status;
|
private String status;
|
||||||
|
|
||||||
/** 备注(合同关键条款摘要)。 */
|
/** 备注(合同关键条款摘要)。 */
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String remark;
|
private String remark;
|
||||||
|
|
||||||
/** 经办人。 */
|
/** 经办人。 */
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,7 +37,7 @@ public class IpRegulation {
|
|||||||
private String currentVersion;
|
private String currentVersion;
|
||||||
|
|
||||||
/** 当前正文(最新草稿或已发布版)。 */
|
/** 当前正文(最新草稿或已发布版)。 */
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
private String owner;
|
private String owner;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,7 +29,7 @@ public class IpRegulationVersion {
|
|||||||
|
|
||||||
private String action;
|
private String action;
|
||||||
|
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String contentSnapshot;
|
private String contentSnapshot;
|
||||||
|
|
||||||
/** 修订说明 / 审批意见。 */
|
/** 修订说明 / 审批意见。 */
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -77,7 +76,6 @@ public class IpSciCredArchive {
|
|||||||
private String validUntil;
|
private String validUntil;
|
||||||
|
|
||||||
/** 文件备注/摘要。 */
|
/** 文件备注/摘要。 */
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String remark;
|
private String remark;
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -34,7 +34,7 @@ public class ItKnowledgeArticle {
|
|||||||
private String keywords;
|
private String keywords;
|
||||||
|
|
||||||
/** 解决方案正文(步骤)。 */
|
/** 解决方案正文(步骤)。 */
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
/** 作者 / 维护人。 */
|
/** 作者 / 维护人。 */
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -28,7 +28,7 @@ public class LegalConsult {
|
|||||||
private String subject;
|
private String subject;
|
||||||
|
|
||||||
/** 问题描述详情。 */
|
/** 问题描述详情。 */
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String detail;
|
private String detail;
|
||||||
|
|
||||||
/** 申请部门。 */
|
/** 申请部门。 */
|
||||||
@@ -44,7 +44,7 @@ public class LegalConsult {
|
|||||||
private String assignee;
|
private String assignee;
|
||||||
|
|
||||||
/** 法律意见(回复内容)。 */
|
/** 法律意见(回复内容)。 */
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String legalOpinion;
|
private String legalOpinion;
|
||||||
|
|
||||||
/** 状态:待受理 / 处理中 / 已答复 / 已关闭。 */
|
/** 状态:待受理 / 处理中 / 已答复 / 已关闭。 */
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -51,7 +50,6 @@ public class Meeting {
|
|||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
/** 会议附件名列表的 JSON(["文件名1","文件名2"],仅记录文件名,不存二进制)。 */
|
/** 会议附件名列表的 JSON(["文件名1","文件名2"],仅记录文件名,不存二进制)。 */
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String attachmentJson;
|
private String attachmentJson;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -29,7 +28,6 @@ public class MeetingMinute {
|
|||||||
|
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
@@ -37,7 +35,6 @@ public class MeetingMinute {
|
|||||||
private String status;
|
private String status;
|
||||||
|
|
||||||
/** 决议项, TEXT/JSON 串. */
|
/** 决议项, TEXT/JSON 串. */
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String decisions;
|
private String decisions;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -34,7 +33,6 @@ public class Message {
|
|||||||
|
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String body;
|
private String body;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -84,7 +83,7 @@ public class MfgEnvSupplierProfile {
|
|||||||
* 历史供货业绩(JSON 数组):[{projectName, deliveryYear, qty, qualifiedRate, customer}...]。
|
* 历史供货业绩(JSON 数组):[{projectName, deliveryYear, qty, qualifiedRate, customer}...]。
|
||||||
* Lob 存储,前端渲染为折叠卡片。
|
* Lob 存储,前端渲染为折叠卡片。
|
||||||
*/
|
*/
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String performanceJson;
|
private String performanceJson;
|
||||||
|
|
||||||
/** 历史供货总次数(冗余,便于快速汇总)。 */
|
/** 历史供货总次数(冗余,便于快速汇总)。 */
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,7 +31,7 @@ public class NetScanLog {
|
|||||||
|
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
private String link;
|
private String link;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/** 通用键值配置:settingKey 唯一,valueJson 存任意 JSON 字符串。用于工时设置/信息项设置等页面级配置。 */
|
/** 通用键值配置:settingKey 唯一,valueJson 存任意 JSON 字符串。用于工时设置/信息项设置等页面级配置。 */
|
||||||
@@ -20,7 +19,6 @@ public class OaSetting {
|
|||||||
@Column(unique = true, nullable = false)
|
@Column(unique = true, nullable = false)
|
||||||
private String settingKey;
|
private String settingKey;
|
||||||
|
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String valueJson;
|
private String valueJson;
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public class PmtFinancingContract {
|
|||||||
private BigDecimal contractAmount = BigDecimal.ZERO;
|
private BigDecimal contractAmount = BigDecimal.ZERO;
|
||||||
|
|
||||||
/** 合同利率(年化,如 4.35)。 */
|
/** 合同利率(年化,如 4.35)。 */
|
||||||
private Double contractRate;
|
private BigDecimal contractRate;
|
||||||
|
|
||||||
/** 合同签署日期(YYYY-MM-DD)。 */
|
/** 合同签署日期(YYYY-MM-DD)。 */
|
||||||
private String signDate;
|
private String signDate;
|
||||||
@@ -94,8 +94,8 @@ public class PmtFinancingContract {
|
|||||||
public BigDecimal getContractAmount() { return contractAmount; }
|
public BigDecimal getContractAmount() { return contractAmount; }
|
||||||
public void setContractAmount(BigDecimal contractAmount) { this.contractAmount = contractAmount; }
|
public void setContractAmount(BigDecimal contractAmount) { this.contractAmount = contractAmount; }
|
||||||
|
|
||||||
public Double getContractRate() { return contractRate; }
|
public BigDecimal getContractRate() { return contractRate; }
|
||||||
public void setContractRate(Double contractRate) { this.contractRate = contractRate; }
|
public void setContractRate(BigDecimal contractRate) { this.contractRate = contractRate; }
|
||||||
|
|
||||||
public String getSignDate() { return signDate; }
|
public String getSignDate() { return signDate; }
|
||||||
public void setSignDate(String signDate) { this.signDate = signDate; }
|
public void setSignDate(String signDate) { this.signDate = signDate; }
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public class PmtInternalTrans {
|
|||||||
private BigDecimal amount = BigDecimal.ZERO;
|
private BigDecimal amount = BigDecimal.ZERO;
|
||||||
|
|
||||||
/** 利率(内部计息时使用,年化,如 3.5 表示 3.5%)。 */
|
/** 利率(内部计息时使用,年化,如 3.5 表示 3.5%)。 */
|
||||||
private Double interestRate;
|
private BigDecimal interestRate;
|
||||||
|
|
||||||
/** 计息起始日。 */
|
/** 计息起始日。 */
|
||||||
private String interestFrom;
|
private String interestFrom;
|
||||||
@@ -104,8 +104,8 @@ public class PmtInternalTrans {
|
|||||||
public BigDecimal getAmount() { return amount; }
|
public BigDecimal getAmount() { return amount; }
|
||||||
public void setAmount(BigDecimal amount) { this.amount = amount; }
|
public void setAmount(BigDecimal amount) { this.amount = amount; }
|
||||||
|
|
||||||
public Double getInterestRate() { return interestRate; }
|
public BigDecimal getInterestRate() { return interestRate; }
|
||||||
public void setInterestRate(Double interestRate) { this.interestRate = interestRate; }
|
public void setInterestRate(BigDecimal interestRate) { this.interestRate = interestRate; }
|
||||||
|
|
||||||
public String getInterestFrom() { return interestFrom; }
|
public String getInterestFrom() { return interestFrom; }
|
||||||
public void setInterestFrom(String interestFrom) { this.interestFrom = interestFrom; }
|
public void setInterestFrom(String interestFrom) { this.interestFrom = interestFrom; }
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ public class PmtLoanScheme {
|
|||||||
private BigDecimal amount = BigDecimal.ZERO;
|
private BigDecimal amount = BigDecimal.ZERO;
|
||||||
|
|
||||||
/** 年化利率(百分比,如 4.35 表示 4.35%)。 */
|
/** 年化利率(百分比,如 4.35 表示 4.35%)。 */
|
||||||
private Double annualRate;
|
private BigDecimal annualRate;
|
||||||
|
|
||||||
/** 期限(月)。 */
|
/** 期限(月)。 */
|
||||||
private Integer termMonths;
|
private Integer termMonths;
|
||||||
@@ -56,7 +56,7 @@ public class PmtLoanScheme {
|
|||||||
private String guaranteeType;
|
private String guaranteeType;
|
||||||
|
|
||||||
/** 担保费率(年化,百分比)。 */
|
/** 担保费率(年化,百分比)。 */
|
||||||
private Double guaranteeRate;
|
private BigDecimal guaranteeRate;
|
||||||
|
|
||||||
/** 提款条件(文字描述)。 */
|
/** 提款条件(文字描述)。 */
|
||||||
private String drawdownConditions;
|
private String drawdownConditions;
|
||||||
@@ -65,7 +65,7 @@ public class PmtLoanScheme {
|
|||||||
* 系统自动计算的综合成本率(利息+费用+担保成本之和/本金,IRR 简化口径,百分比)。
|
* 系统自动计算的综合成本率(利息+费用+担保成本之和/本金,IRR 简化口径,百分比)。
|
||||||
* 创建/更新时服务端根据 rate+费用+担保自动回填。
|
* 创建/更新时服务端根据 rate+费用+担保自动回填。
|
||||||
*/
|
*/
|
||||||
private Double effectiveCostRate;
|
private BigDecimal effectiveCostRate;
|
||||||
|
|
||||||
/** 综合排名(越小越优,由 /rank 端点服务端按综合成本排序后写入)。 */
|
/** 综合排名(越小越优,由 /rank 端点服务端按综合成本排序后写入)。 */
|
||||||
private Integer rank;
|
private Integer rank;
|
||||||
@@ -104,8 +104,8 @@ public class PmtLoanScheme {
|
|||||||
public BigDecimal getAmount() { return amount; }
|
public BigDecimal getAmount() { return amount; }
|
||||||
public void setAmount(BigDecimal amount) { this.amount = amount; }
|
public void setAmount(BigDecimal amount) { this.amount = amount; }
|
||||||
|
|
||||||
public Double getAnnualRate() { return annualRate; }
|
public BigDecimal getAnnualRate() { return annualRate; }
|
||||||
public void setAnnualRate(Double annualRate) { this.annualRate = annualRate; }
|
public void setAnnualRate(BigDecimal annualRate) { this.annualRate = annualRate; }
|
||||||
|
|
||||||
public Integer getTermMonths() { return termMonths; }
|
public Integer getTermMonths() { return termMonths; }
|
||||||
public void setTermMonths(Integer termMonths) { this.termMonths = termMonths; }
|
public void setTermMonths(Integer termMonths) { this.termMonths = termMonths; }
|
||||||
@@ -119,14 +119,14 @@ public class PmtLoanScheme {
|
|||||||
public String getGuaranteeType() { return guaranteeType; }
|
public String getGuaranteeType() { return guaranteeType; }
|
||||||
public void setGuaranteeType(String guaranteeType) { this.guaranteeType = guaranteeType; }
|
public void setGuaranteeType(String guaranteeType) { this.guaranteeType = guaranteeType; }
|
||||||
|
|
||||||
public Double getGuaranteeRate() { return guaranteeRate; }
|
public BigDecimal getGuaranteeRate() { return guaranteeRate; }
|
||||||
public void setGuaranteeRate(Double guaranteeRate) { this.guaranteeRate = guaranteeRate; }
|
public void setGuaranteeRate(BigDecimal guaranteeRate) { this.guaranteeRate = guaranteeRate; }
|
||||||
|
|
||||||
public String getDrawdownConditions() { return drawdownConditions; }
|
public String getDrawdownConditions() { return drawdownConditions; }
|
||||||
public void setDrawdownConditions(String drawdownConditions) { this.drawdownConditions = drawdownConditions; }
|
public void setDrawdownConditions(String drawdownConditions) { this.drawdownConditions = drawdownConditions; }
|
||||||
|
|
||||||
public Double getEffectiveCostRate() { return effectiveCostRate; }
|
public BigDecimal getEffectiveCostRate() { return effectiveCostRate; }
|
||||||
public void setEffectiveCostRate(Double effectiveCostRate) { this.effectiveCostRate = effectiveCostRate; }
|
public void setEffectiveCostRate(BigDecimal effectiveCostRate) { this.effectiveCostRate = effectiveCostRate; }
|
||||||
|
|
||||||
public Integer getRank() { return rank; }
|
public Integer getRank() { return rank; }
|
||||||
public void setRank(Integer rank) { this.rank = rank; }
|
public void setRank(Integer rank) { this.rank = rank; }
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,7 +38,7 @@ public class StdApplication {
|
|||||||
|
|
||||||
private String planNo;
|
private String planNo;
|
||||||
|
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String techContent;
|
private String techContent;
|
||||||
|
|
||||||
private String drafters;
|
private String drafters;
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
import org.hibernate.annotations.JdbcTypeCode;
|
||||||
|
import org.hibernate.type.SqlTypes;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
@@ -34,12 +35,9 @@ public class StoredFile {
|
|||||||
/** Size in bytes. */
|
/** Size in bytes. */
|
||||||
private Long size;
|
private Long size;
|
||||||
|
|
||||||
/**
|
/** Raw bytes mapped to SQLite BLOB and PostgreSQL bytea without JDBC Blob streaming. */
|
||||||
* Raw bytes as a plain byte[] (NOT @Lob): SQLite's JDBC driver does not implement
|
@JdbcTypeCode(SqlTypes.LONGVARBINARY)
|
||||||
* the streamed-Blob read path that @Lob triggers ("not implemented by SQLite JDBC
|
@Column(length = Integer.MAX_VALUE)
|
||||||
* driver"); a plain byte[] is read directly via getBytes() and works.
|
|
||||||
*/
|
|
||||||
@Column(columnDefinition = "BLOB")
|
|
||||||
private byte[] data;
|
private byte[] data;
|
||||||
|
|
||||||
/** Display name of the uploader (resolved from the auth token). */
|
/** Display name of the uploader (resolved from the auth token). */
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,12 +28,10 @@ public class Survey {
|
|||||||
|
|
||||||
private String status;
|
private String status;
|
||||||
|
|
||||||
@Lob
|
|
||||||
@Column(name = "options_json", columnDefinition = "TEXT")
|
@Column(name = "options_json", columnDefinition = "TEXT")
|
||||||
private String optionsJson;
|
private String optionsJson;
|
||||||
|
|
||||||
// --> JSON array of voter usernames who already voted; used to reject duplicate votes.
|
// --> JSON array of voter usernames who already voted; used to reject duplicate votes.
|
||||||
@Lob
|
|
||||||
@Column(name = "voters_json", columnDefinition = "TEXT")
|
@Column(name = "voters_json", columnDefinition = "TEXT")
|
||||||
private String votersJson;
|
private String votersJson;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
@@ -57,7 +56,6 @@ public class SvIndepClaim {
|
|||||||
private String incidentEndDate;
|
private String incidentEndDate;
|
||||||
|
|
||||||
/** 事件经过详细说明。 */
|
/** 事件经过详细说明。 */
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String incidentDetail;
|
private String incidentDetail;
|
||||||
|
|
||||||
@@ -73,7 +71,6 @@ public class SvIndepClaim {
|
|||||||
private Integer criticalPathImpactDays;
|
private Integer criticalPathImpactDays;
|
||||||
|
|
||||||
/** 关键线路影响分析说明(如"该延误不在关键路径,不影响完工日期")。 */
|
/** 关键线路影响分析说明(如"该延误不在关键路径,不影响完工日期")。 */
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String criticalPathImpact;
|
private String criticalPathImpact;
|
||||||
|
|
||||||
@@ -82,7 +79,6 @@ public class SvIndepClaim {
|
|||||||
private BigDecimal claimAmount = BigDecimal.ZERO;
|
private BigDecimal claimAmount = BigDecimal.ZERO;
|
||||||
|
|
||||||
/** 费用影响评估说明。 */
|
/** 费用影响评估说明。 */
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String costImpactDetail;
|
private String costImpactDetail;
|
||||||
|
|
||||||
@@ -102,7 +98,6 @@ public class SvIndepClaim {
|
|||||||
private BigDecimal approvedAmount = BigDecimal.ZERO;
|
private BigDecimal approvedAmount = BigDecimal.ZERO;
|
||||||
|
|
||||||
/** 监理意见详细说明。 */
|
/** 监理意见详细说明。 */
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String reviewOpinion;
|
private String reviewOpinion;
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ public class SysRole {
|
|||||||
@Column(nullable = false, unique = true)
|
@Column(nullable = false, unique = true)
|
||||||
private String code;
|
private String code;
|
||||||
|
|
||||||
|
/** 角色说明(角色管理 UI 用)。 */
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/** 系统内置角色(ADMIN/APPROVER/USER)不可删;自定义部门角色为 false。 */
|
||||||
|
private boolean system = false;
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
@@ -45,4 +51,20 @@ public class SysRole {
|
|||||||
public void setCode(String code) {
|
public void setCode(String code) {
|
||||||
this.code = code;
|
this.code = code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescription(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSystem() {
|
||||||
|
return system;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSystem(boolean system) {
|
||||||
|
this.system = system;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,7 +47,7 @@ public class TechAchievement {
|
|||||||
private Long ipAssetId;
|
private Long ipAssetId;
|
||||||
|
|
||||||
/** 佐证材料清单(论文/标准/软著/检测报告…)。 */
|
/** 佐证材料清单(论文/标准/软著/检测报告…)。 */
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String evidence;
|
private String evidence;
|
||||||
|
|
||||||
/** 成果评价等级。 */
|
/** 成果评价等级。 */
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,11 +58,11 @@ public class TechContract {
|
|||||||
private String ipAssetName;
|
private String ipAssetName;
|
||||||
|
|
||||||
/** 自动生成的创新技术方案(材料)。 */
|
/** 自动生成的创新技术方案(材料)。 */
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String techScheme;
|
private String techScheme;
|
||||||
|
|
||||||
/** 自动生成的承诺书(材料)。 */
|
/** 自动生成的承诺书(材料)。 */
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String commitmentLetter;
|
private String commitmentLetter;
|
||||||
|
|
||||||
private String owner;
|
private String owner;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -62,7 +62,7 @@ public class WmDeclTemplate {
|
|||||||
private String placeholders;
|
private String placeholders;
|
||||||
|
|
||||||
/** 模板正文(含占位符的完整模板文本)。 */
|
/** 模板正文(含占位符的完整模板文本)。 */
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String bodyTemplate;
|
private String bodyTemplate;
|
||||||
|
|
||||||
/** 更新说明(本版变更内容摘要,更新时通知相关人员)。 */
|
/** 更新说明(本版变更内容摘要,更新时通知相关人员)。 */
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -46,7 +46,7 @@ public class WmDocVersion {
|
|||||||
private String summary;
|
private String summary;
|
||||||
|
|
||||||
/** 文档正文(内联富文本)。 */
|
/** 文档正文(内联富文本)。 */
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
/** 外部附件 URL / 文件路径(可空;与通用文件模块结合时存储路径)。 */
|
/** 外部附件 URL / 文件路径(可空;与通用文件模块结合时存储路径)。 */
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,7 +64,7 @@ public class WmPromotion {
|
|||||||
private Integer attendeeCount;
|
private Integer attendeeCount;
|
||||||
|
|
||||||
/** 效果评估说明。 */
|
/** 效果评估说明。 */
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String effectNote;
|
private String effectNote;
|
||||||
|
|
||||||
/** 推广材料(作业指导书/PPT/视频等,逗号分隔文件路径或文件 id)。 */
|
/** 推广材料(作业指导书/PPT/视频等,逗号分隔文件路径或文件 id)。 */
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,41 +70,41 @@ public class WorkMethod {
|
|||||||
private String stage;
|
private String stage;
|
||||||
|
|
||||||
// ---- 立项信息(需求功能1·工法立项) ----
|
// ---- 立项信息(需求功能1·工法立项) ----
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String techBackground;
|
private String techBackground;
|
||||||
|
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String innovation;
|
private String innovation;
|
||||||
|
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String applicableScope;
|
private String applicableScope;
|
||||||
|
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String expectedBenefit;
|
private String expectedBenefit;
|
||||||
|
|
||||||
// ---- 工法文本九大要素(需求功能1·工法编制) ----
|
// ---- 工法文本九大要素(需求功能1·工法编制) ----
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String contentFeature;
|
private String contentFeature;
|
||||||
|
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String contentPrinciple;
|
private String contentPrinciple;
|
||||||
|
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String contentProcess;
|
private String contentProcess;
|
||||||
|
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String contentMaterial;
|
private String contentMaterial;
|
||||||
|
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String contentQuality;
|
private String contentQuality;
|
||||||
|
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String contentSafety;
|
private String contentSafety;
|
||||||
|
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String contentEnv;
|
private String contentEnv;
|
||||||
|
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String contentBenefit;
|
private String contentBenefit;
|
||||||
|
|
||||||
// ---- 证书与有效期(需求功能1·工法证书) ----
|
// ---- 证书与有效期(需求功能1·工法证书) ----
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,13 +53,13 @@ public class WorkMethodApplication {
|
|||||||
* 申报材料清单:逗号分隔的 "材料名:0/1"(0=缺,1=齐),如
|
* 申报材料清单:逗号分隔的 "材料名:0/1"(0=缺,1=齐),如
|
||||||
* "申报书:1,工法文本:1,查新报告:0,应用证明:1,经济效益证明:0"。
|
* "申报书:1,工法文本:1,查新报告:0,应用证明:1,经济效益证明:0"。
|
||||||
*/
|
*/
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String materials;
|
private String materials;
|
||||||
|
|
||||||
/** 批准文号(批准后回填)。 */
|
/** 批准文号(批准后回填)。 */
|
||||||
private String approveDocNo;
|
private String approveDocNo;
|
||||||
|
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String remark;
|
private String remark;
|
||||||
|
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,7 +36,7 @@ public class WorkMethodEvent {
|
|||||||
|
|
||||||
private String toValue;
|
private String toValue;
|
||||||
|
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String note;
|
private String note;
|
||||||
|
|
||||||
private String operator;
|
private String operator;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,7 +44,7 @@ public class WorkMethodReview {
|
|||||||
/** 评分(0-100,可空)。 */
|
/** 评分(0-100,可空)。 */
|
||||||
private Integer score;
|
private Integer score;
|
||||||
|
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String opinion;
|
private String opinion;
|
||||||
|
|
||||||
private String reviewedDate;
|
private String reviewedDate;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,7 +44,7 @@ public class WorkMethodReward {
|
|||||||
* 完成人贡献分配明细:逗号分隔的 "姓名:比例%:金额",如
|
* 完成人贡献分配明细:逗号分隔的 "姓名:比例%:金额",如
|
||||||
* "张三:50:5000.00,李四:30:3000.00,王五:20:2000.00"。比例之和应为 100。
|
* "张三:50:5000.00,李四:30:3000.00,王五:20:2000.00"。比例之和应为 100。
|
||||||
*/
|
*/
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String allocation;
|
private String allocation;
|
||||||
|
|
||||||
/** 奖励状态:待审批 / 已审批 / 已发放 / 已驳回。 */
|
/** 奖励状态:待审批 / 已审批 / 已发放 / 已驳回。 */
|
||||||
@@ -61,7 +61,7 @@ public class WorkMethodReward {
|
|||||||
/** 审批后生成的资金支付中心付款单 id(联动财务)。 */
|
/** 审批后生成的资金支付中心付款单 id(联动财务)。 */
|
||||||
private Long paymentId;
|
private Long paymentId;
|
||||||
|
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String remark;
|
private String remark;
|
||||||
|
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,7 +49,7 @@ public class WorkMethodUsage {
|
|||||||
private Integer shortenDays = 0;
|
private Integer shortenDays = 0;
|
||||||
|
|
||||||
/** 应用效果描述(质量提升/用户评价)。 */
|
/** 应用效果描述(质量提升/用户评价)。 */
|
||||||
@Lob
|
@Column(columnDefinition = "TEXT")
|
||||||
private String effect;
|
private String effect;
|
||||||
|
|
||||||
/** 登记人。 */
|
/** 登记人。 */
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/** A work plan. period is e.g. 本周 / 本月 / 本季; status is 草稿 / 执行中 / 已完成. */
|
/** A work plan. period is e.g. 本周 / 本月 / 本季; status is 草稿 / 执行中 / 已完成. */
|
||||||
@@ -28,7 +27,6 @@ public class WorkPlan {
|
|||||||
|
|
||||||
private String status;
|
private String status;
|
||||||
|
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import com.kaidi.oa.domain.QualElecLicense;
|
|||||||
import com.kaidi.oa.repository.QualElecLicenseRepository;
|
import com.kaidi.oa.repository.QualElecLicenseRepository;
|
||||||
import org.springframework.boot.CommandLineRunner;
|
import org.springframework.boot.CommandLineRunner;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@@ -17,7 +16,7 @@ import java.time.LocalDate;
|
|||||||
*
|
*
|
||||||
* @Order(225) 在所有主数据 Seeder(最高 220)之后运行;幂等(count > 0 跳过)。
|
* @Order(225) 在所有主数据 Seeder(最高 220)之后运行;幂等(count > 0 跳过)。
|
||||||
*/
|
*/
|
||||||
@Component
|
@DemoSeed
|
||||||
@Order(225)
|
@Order(225)
|
||||||
public class AdminQualElecSeeder implements CommandLineRunner {
|
public class AdminQualElecSeeder implements CommandLineRunner {
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import com.kaidi.oa.repository.StaffDossierItemRepository;
|
|||||||
import com.kaidi.oa.repository.StaffDossierRepository;
|
import com.kaidi.oa.repository.StaffDossierRepository;
|
||||||
import org.springframework.boot.CommandLineRunner;
|
import org.springframework.boot.CommandLineRunner;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -26,7 +25,7 @@ import java.time.LocalDate;
|
|||||||
* 幂等 + 非破坏:以各表是否已有数据为哨兵,仅在空表时灌入少量演示数据,让深水页面首屏不空。
|
* 幂等 + 非破坏:以各表是否已有数据为哨兵,仅在空表时灌入少量演示数据,让深水页面首屏不空。
|
||||||
* 日期以"当前日期 +/- 偏移"动态生成,使到期分级预警在任何时间运行都有 即将到期/逾期 样本。
|
* 日期以"当前日期 +/- 偏移"动态生成,使到期分级预警在任何时间运行都有 即将到期/逾期 样本。
|
||||||
*/
|
*/
|
||||||
@Component
|
@DemoSeed
|
||||||
@Order(6)
|
@Order(6)
|
||||||
public class AdminQualSeeder implements CommandLineRunner {
|
public class AdminQualSeeder implements CommandLineRunner {
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import com.kaidi.oa.repository.ArchiveRepository;
|
|||||||
import com.kaidi.oa.repository.ArchiveVersionRepository;
|
import com.kaidi.oa.repository.ArchiveVersionRepository;
|
||||||
import org.springframework.boot.CommandLineRunner;
|
import org.springframework.boot.CommandLineRunner;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
@@ -26,7 +25,7 @@ import java.time.Instant;
|
|||||||
* <p>幂等:同 sourceType 在 archive 表已有记录则跳过整批(count 检查)。
|
* <p>幂等:同 sourceType 在 archive 表已有记录则跳过整批(count 检查)。
|
||||||
* @Order(205) 在所有基础 Seeder 后运行。
|
* @Order(205) 在所有基础 Seeder 后运行。
|
||||||
*/
|
*/
|
||||||
@Component
|
@DemoSeed
|
||||||
@Order(205)
|
@Order(205)
|
||||||
public class ArchiveCrossModuleSeeder implements CommandLineRunner {
|
public class ArchiveCrossModuleSeeder implements CommandLineRunner {
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import com.kaidi.oa.repository.LegalRegulationRepository;
|
|||||||
import org.springframework.boot.ApplicationArguments;
|
import org.springframework.boot.ApplicationArguments;
|
||||||
import org.springframework.boot.ApplicationRunner;
|
import org.springframework.boot.ApplicationRunner;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
@@ -31,7 +30,7 @@ import java.time.temporal.ChronoUnit;
|
|||||||
* 幂等:各表 count > 0 即跳过。
|
* 幂等:各表 count > 0 即跳过。
|
||||||
* @Order(202) 在 FertPkgAccountingSeeder(@Order 201) 之后运行。
|
* @Order(202) 在 FertPkgAccountingSeeder(@Order 201) 之后运行。
|
||||||
*/
|
*/
|
||||||
@Component
|
@DemoSeed
|
||||||
@Order(202)
|
@Order(202)
|
||||||
public class AuditDeptSeeder implements ApplicationRunner {
|
public class AuditDeptSeeder implements ApplicationRunner {
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import com.kaidi.oa.repository.BidTenderCollabRepository;
|
|||||||
import org.springframework.boot.ApplicationArguments;
|
import org.springframework.boot.ApplicationArguments;
|
||||||
import org.springframework.boot.ApplicationRunner;
|
import org.springframework.boot.ApplicationRunner;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
@@ -16,7 +15,7 @@ import java.time.Instant;
|
|||||||
* 让前端看板活体可见且流程闭环可演示。
|
* 让前端看板活体可见且流程闭环可演示。
|
||||||
* 幂等:若已有数据则跳过,防止重启重复写入。
|
* 幂等:若已有数据则跳过,防止重启重复写入。
|
||||||
*/
|
*/
|
||||||
@Component
|
@DemoSeed
|
||||||
@Order(215)
|
@Order(215)
|
||||||
public class BidTenderCollabSeeder implements ApplicationRunner {
|
public class BidTenderCollabSeeder implements ApplicationRunner {
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import com.kaidi.oa.repository.BidRepository;
|
|||||||
import com.kaidi.oa.repository.BidTenderDocRepository;
|
import com.kaidi.oa.repository.BidTenderDocRepository;
|
||||||
import org.springframework.boot.CommandLineRunner;
|
import org.springframework.boot.CommandLineRunner;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@@ -18,7 +17,7 @@ import java.time.LocalDate;
|
|||||||
*
|
*
|
||||||
* @Order(215) 在所有主数据 Seeder 之后运行;幂等(count > 0 则跳过)。
|
* @Order(215) 在所有主数据 Seeder 之后运行;幂等(count > 0 则跳过)。
|
||||||
*/
|
*/
|
||||||
@Component
|
@DemoSeed
|
||||||
@Order(215)
|
@Order(215)
|
||||||
public class BidTenderDocSeeder implements CommandLineRunner {
|
public class BidTenderDocSeeder implements CommandLineRunner {
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import com.kaidi.oa.repository.OrgBranchRepository;
|
|||||||
import org.springframework.boot.ApplicationArguments;
|
import org.springframework.boot.ApplicationArguments;
|
||||||
import org.springframework.boot.ApplicationRunner;
|
import org.springframework.boot.ApplicationRunner;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -32,7 +31,7 @@ import java.util.List;
|
|||||||
*
|
*
|
||||||
* @Order(15) 在 DataSeeder(@Order 1) 之后独立运行。
|
* @Order(15) 在 DataSeeder(@Order 1) 之后独立运行。
|
||||||
*/
|
*/
|
||||||
@Component
|
@DemoSeed
|
||||||
@Order(15)
|
@Order(15)
|
||||||
public class BranchFinDemoSeeder implements ApplicationRunner {
|
public class BranchFinDemoSeeder implements ApplicationRunner {
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import com.kaidi.oa.repository.EmployeeShareMaterialRepository;
|
|||||||
import org.springframework.boot.ApplicationArguments;
|
import org.springframework.boot.ApplicationArguments;
|
||||||
import org.springframework.boot.ApplicationRunner;
|
import org.springframework.boot.ApplicationRunner;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -21,7 +20,7 @@ import java.util.List;
|
|||||||
*
|
*
|
||||||
* @Order(220) 在所有主数据 Seeder 之后运行;幂等(count > 0 则跳过)。
|
* @Order(220) 在所有主数据 Seeder 之后运行;幂等(count > 0 则跳过)。
|
||||||
*/
|
*/
|
||||||
@Component
|
@DemoSeed
|
||||||
@Order(220)
|
@Order(220)
|
||||||
public class BrandCultureGapSeeder implements ApplicationRunner {
|
public class BrandCultureGapSeeder implements ApplicationRunner {
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import com.kaidi.oa.repository.ContentTaskRepository;
|
|||||||
import org.springframework.boot.ApplicationArguments;
|
import org.springframework.boot.ApplicationArguments;
|
||||||
import org.springframework.boot.ApplicationRunner;
|
import org.springframework.boot.ApplicationRunner;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -20,7 +19,7 @@ import java.util.List;
|
|||||||
* 补全两个"活体返回空/无数据"缺口,让前端物料管理与任务分派功能可见可演示。
|
* 补全两个"活体返回空/无数据"缺口,让前端物料管理与任务分派功能可见可演示。
|
||||||
* @Order(22),在 DataSeeder(@Order 1) 之后运行,幂等(count > 0 跳过)。
|
* @Order(22),在 DataSeeder(@Order 1) 之后运行,幂等(count > 0 跳过)。
|
||||||
*/
|
*/
|
||||||
@Component
|
@DemoSeed
|
||||||
@Order(22)
|
@Order(22)
|
||||||
public class BrandCultureSeeder implements ApplicationRunner {
|
public class BrandCultureSeeder implements ApplicationRunner {
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import com.kaidi.oa.repository.StandardCostRepository;
|
|||||||
import org.springframework.boot.ApplicationArguments;
|
import org.springframework.boot.ApplicationArguments;
|
||||||
import org.springframework.boot.ApplicationRunner;
|
import org.springframework.boot.ApplicationRunner;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
@@ -37,7 +36,7 @@ import java.time.temporal.ChronoUnit;
|
|||||||
* 幂等:各表 count() > 0 即跳过,重启不重复插入。
|
* 幂等:各表 count() > 0 即跳过,重启不重复插入。
|
||||||
* @Order(9) 在 DataSeeder(@Order 1) 之后独立运行。
|
* @Order(9) 在 DataSeeder(@Order 1) 之后独立运行。
|
||||||
*/
|
*/
|
||||||
@Component
|
@DemoSeed
|
||||||
@Order(9)
|
@Order(9)
|
||||||
public class CostCtrlDemoSeeder implements ApplicationRunner {
|
public class CostCtrlDemoSeeder implements ApplicationRunner {
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import com.kaidi.oa.repository.RdEbomRepository;
|
|||||||
import org.springframework.boot.ApplicationArguments;
|
import org.springframework.boot.ApplicationArguments;
|
||||||
import org.springframework.boot.ApplicationRunner;
|
import org.springframework.boot.ApplicationRunner;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
@@ -50,7 +49,7 @@ import java.time.temporal.ChronoUnit;
|
|||||||
*
|
*
|
||||||
* 幂等:各表 count() > 0 则跳过,重启安全。
|
* 幂等:各表 count() > 0 则跳过,重启安全。
|
||||||
*/
|
*/
|
||||||
@Component
|
@DemoSeed
|
||||||
@Order(215)
|
@Order(215)
|
||||||
public class CostCtrlGapSeeder implements ApplicationRunner {
|
public class CostCtrlGapSeeder implements ApplicationRunner {
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import com.kaidi.oa.repository.CslTimesheetRepository;
|
|||||||
import org.springframework.boot.ApplicationArguments;
|
import org.springframework.boot.ApplicationArguments;
|
||||||
import org.springframework.boot.ApplicationRunner;
|
import org.springframework.boot.ApplicationRunner;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
@@ -21,7 +20,7 @@ import java.time.Instant;
|
|||||||
*
|
*
|
||||||
* 幂等:按 code 查重后跳过,重启不重复插入。
|
* 幂等:按 code 查重后跳过,重启不重复插入。
|
||||||
*/
|
*/
|
||||||
@Component
|
@DemoSeed
|
||||||
@Order(7)
|
@Order(7)
|
||||||
public class CslDemoSeeder implements ApplicationRunner {
|
public class CslDemoSeeder implements ApplicationRunner {
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import com.kaidi.oa.repository.HrCultureKpiRepository;
|
|||||||
import org.springframework.boot.ApplicationArguments;
|
import org.springframework.boot.ApplicationArguments;
|
||||||
import org.springframework.boot.ApplicationRunner;
|
import org.springframework.boot.ApplicationRunner;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -21,7 +20,7 @@ import java.util.List;
|
|||||||
*
|
*
|
||||||
* @Order(225) 在 BrandCultureGapSeeder(@Order 220) 之后运行,幂等(count>0跳过)。
|
* @Order(225) 在 BrandCultureGapSeeder(@Order 220) 之后运行,幂等(count>0跳过)。
|
||||||
*/
|
*/
|
||||||
@Component
|
@DemoSeed
|
||||||
@Order(225)
|
@Order(225)
|
||||||
public class CultureBriefingKpiSeeder implements ApplicationRunner {
|
public class CultureBriefingKpiSeeder implements ApplicationRunner {
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import com.kaidi.oa.repository.HrEmpSatisfactionSurveyRepository;
|
|||||||
import org.springframework.boot.ApplicationArguments;
|
import org.springframework.boot.ApplicationArguments;
|
||||||
import org.springframework.boot.ApplicationRunner;
|
import org.springframework.boot.ApplicationRunner;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -28,7 +27,7 @@ import java.util.List;
|
|||||||
* @Order(50),在 DataSeeder(@Order 1) 及 BrandCultureSeeder(@Order 22) 之后运行。
|
* @Order(50),在 DataSeeder(@Order 1) 及 BrandCultureSeeder(@Order 22) 之后运行。
|
||||||
* 幂等:CultureGoal 有数据时跳过全部种子。
|
* 幂等:CultureGoal 有数据时跳过全部种子。
|
||||||
*/
|
*/
|
||||||
@Component
|
@DemoSeed
|
||||||
@Order(50)
|
@Order(50)
|
||||||
public class CultureProjectAssessSeeder implements ApplicationRunner {
|
public class CultureProjectAssessSeeder implements ApplicationRunner {
|
||||||
|
|
||||||
|
|||||||
@@ -158,7 +158,6 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.boot.CommandLineRunner;
|
import org.springframework.boot.CommandLineRunner;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -171,7 +170,7 @@ import java.util.List;
|
|||||||
* in-flight form instances, plus org/users/roles, meetings, documents,
|
* in-flight form instances, plus org/users/roles, meetings, documents,
|
||||||
* announcements and schedule events.
|
* announcements and schedule events.
|
||||||
*/
|
*/
|
||||||
@Component
|
@DemoSeed
|
||||||
@Order(1)
|
@Order(1)
|
||||||
public class DataSeeder implements CommandLineRunner {
|
public class DataSeeder implements CommandLineRunner {
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.boot.CommandLineRunner;
|
import org.springframework.boot.CommandLineRunner;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
@@ -15,7 +14,7 @@ import java.time.Instant;
|
|||||||
* 申报成果库演示数据补种(Gap 4:活体 decl-achievements 0条,功能可达但未预置演示数据)。
|
* 申报成果库演示数据补种(Gap 4:活体 decl-achievements 0条,功能可达但未预置演示数据)。
|
||||||
* Order(3)=在 DataSeeder(Order 1)/AdminQualSeeder 之后运行,避免依赖竞争。
|
* Order(3)=在 DataSeeder(Order 1)/AdminQualSeeder 之后运行,避免依赖竞争。
|
||||||
*/
|
*/
|
||||||
@Component
|
@DemoSeed
|
||||||
@Order(3)
|
@Order(3)
|
||||||
public class DeclAchievementSeeder implements CommandLineRunner {
|
public class DeclAchievementSeeder implements CommandLineRunner {
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.kaidi.oa.seed;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
@Target(ElementType.TYPE)
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Documented
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "oa.seed.demo", havingValue = "true", matchIfMissing = true)
|
||||||
|
public @interface DemoSeed {
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ import com.kaidi.oa.repository.FertPkgMaterialLedgerRepository;
|
|||||||
import org.springframework.boot.ApplicationArguments;
|
import org.springframework.boot.ApplicationArguments;
|
||||||
import org.springframework.boot.ApplicationRunner;
|
import org.springframework.boot.ApplicationRunner;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
@@ -27,7 +26,7 @@ import java.time.Instant;
|
|||||||
* 幂等:按唯一编码查重后跳过,重启不重复插入。
|
* 幂等:按唯一编码查重后跳过,重启不重复插入。
|
||||||
* Order(201) 保证在 DataSeeder(200) 之后执行。
|
* Order(201) 保证在 DataSeeder(200) 之后执行。
|
||||||
*/
|
*/
|
||||||
@Component
|
@DemoSeed
|
||||||
@Order(201)
|
@Order(201)
|
||||||
public class FertPkgAccountingSeeder implements ApplicationRunner {
|
public class FertPkgAccountingSeeder implements ApplicationRunner {
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import com.kaidi.oa.repository.FinRdTimesheetRepository;
|
|||||||
import org.springframework.boot.ApplicationArguments;
|
import org.springframework.boot.ApplicationArguments;
|
||||||
import org.springframework.boot.ApplicationRunner;
|
import org.springframework.boot.ApplicationRunner;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
@@ -35,7 +34,7 @@ import java.time.temporal.ChronoUnit;
|
|||||||
*
|
*
|
||||||
* @Order(13) 在 FinDeptSeeder(@Order 12) 之后运行。
|
* @Order(13) 在 FinDeptSeeder(@Order 12) 之后运行。
|
||||||
*/
|
*/
|
||||||
@Component
|
@DemoSeed
|
||||||
@Order(13)
|
@Order(13)
|
||||||
public class FinDeptDeepSeeder implements ApplicationRunner {
|
public class FinDeptDeepSeeder implements ApplicationRunner {
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user