自动构建
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
.artifacts
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.vs
|
||||||
|
.vscode
|
||||||
|
**/bin
|
||||||
|
**/obj
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/*.sqlite
|
||||||
|
**/*.sqlite-shm
|
||||||
|
**/*.sqlite-wal
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.docker.example
|
||||||
|
src/Jiaowu.Api/data
|
||||||
|
src/Jiaowu.Api/wwwroot
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 复制为 .env.docker,并通过 docker run --env-file .env.docker 使用。
|
||||||
|
ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
ASPNETCORE_HTTP_PORTS=8080
|
||||||
|
Database__Provider=MySql
|
||||||
|
Database__ApplyMigrationsOnStartup=false
|
||||||
|
Database__CommandTimeoutSeconds=30
|
||||||
|
ConnectionStrings__MySql=Server=db.example.edu.cn;Port=3306;Database=jiaowu;User=APP_USER;Password=REPLACE_WITH_A_STRONG_PASSWORD;SslMode=VerifyFull;SslCa=/run/secrets/mysql-ca.pem;
|
||||||
|
Jwt__Issuer=Jiaowu.Api
|
||||||
|
Jwt__Audience=Jiaowu.Web
|
||||||
|
Jwt__Key=REPLACE_WITH_AT_LEAST_32_RANDOM_BYTES
|
||||||
|
Jwt__ExpireMinutes=60
|
||||||
|
AllowedHosts=jiaowu.example.edu.cn
|
||||||
|
Cors__Origins__0=https://jiaowu.example.edu.cn
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 复制为发布目录中的 .env。真实密钥和密码不要提交到仓库。
|
||||||
|
ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
ASPNETCORE_URLS=http://0.0.0.0:8080
|
||||||
|
|
||||||
|
Database__Provider=MySql
|
||||||
|
Database__ApplyMigrationsOnStartup=false
|
||||||
|
Database__CommandTimeoutSeconds=30
|
||||||
|
ConnectionStrings__MySql="Server=db.example.edu.cn;Port=3306;Database=jiaowu;User=APP_USER;Password=REPLACE_WITH_A_STRONG_PASSWORD;SslMode=VerifyFull;SslCa=/etc/jiaowu/mysql-ca.pem;"
|
||||||
|
|
||||||
|
Jwt__Issuer=Jiaowu.Api
|
||||||
|
Jwt__Audience=Jiaowu.Web
|
||||||
|
Jwt__Key=REPLACE_WITH_AT_LEAST_32_RANDOM_BYTES
|
||||||
|
Jwt__ExpireMinutes=60
|
||||||
|
|
||||||
|
AllowedHosts=jiaowu.example.edu.cn
|
||||||
|
Cors__Origins__0=https://jiaowu.example.edu.cn
|
||||||
|
|
||||||
|
# 仅首次创建管理员时临时取消注释,创建成功后立即删除。
|
||||||
|
# SeedAdmin__UserName=admin
|
||||||
|
# SeedAdmin__Password=REPLACE_WITH_A_STRONG_ADMIN_PASSWORD
|
||||||
|
# SeedAdmin__DisplayName=系统管理员
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Build and publish packages and Docker images
|
name: Build and publish Jiaowu packages and container image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -12,12 +12,16 @@ on:
|
|||||||
default: false
|
default: false
|
||||||
type: boolean
|
type: boolean
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_IMAGE: git.biss.click/biss/academic-affairs-system
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
publish:
|
||||||
name: Test, build and publish
|
name: Test, package and publish
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
code: read
|
||||||
|
packages: write
|
||||||
releases: write
|
releases: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -27,40 +31,45 @@ jobs:
|
|||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: https://github.com/actions/setup-dotnet@v4
|
uses: https://github.com/actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: "10.0.x"
|
dotnet-version: "10.0.302"
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: https://github.com/actions/setup-node@v4
|
uses: https://github.com/actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: src/Eis.Web/ClientApp/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
|
|
||||||
- name: Build Vue application
|
- name: Build Vue application
|
||||||
working-directory: src/Eis.Web/ClientApp
|
working-directory: web
|
||||||
run: |
|
run: |
|
||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run backend tests
|
||||||
run: dotnet test Eis.slnx --configuration Release
|
run: dotnet test Jiaowu.slnx --configuration Release
|
||||||
|
|
||||||
- name: Determine package version
|
- name: Determine package version and platform profile
|
||||||
id: package_version
|
id: package
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
|
REF_TYPE: ${{ gitea.ref_type }}
|
||||||
|
REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
RUN_NUMBER: ${{ gitea.run_number }}
|
||||||
INCLUDE_EXTENDED_PLATFORMS: ${{ inputs.include_extended_platforms }}
|
INCLUDE_EXTENDED_PLATFORMS: ${{ inputs.include_extended_platforms }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
if [[ "$REF_TYPE" == "tag" && "$REF_NAME" == v* ]]; then
|
||||||
version="${GITHUB_REF_NAME#v}"
|
version="${REF_NAME#v}"
|
||||||
else
|
else
|
||||||
version="0.0.0-dev.${GITHUB_RUN_NUMBER:-0}"
|
version="0.0.0-dev.${RUN_NUMBER:-0}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
profile=default
|
profile=default
|
||||||
if [[ "${INCLUDE_EXTENDED_PLATFORMS:-false}" == "true" ]]; then
|
if [[ "${INCLUDE_EXTENDED_PLATFORMS:-false}" == "true" ]]; then
|
||||||
profile=all
|
profile=all
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
echo "profile=$profile" >> "$GITHUB_OUTPUT"
|
echo "profile=$profile" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
@@ -68,14 +77,14 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
bash ./scripts/publish-platform-packages.sh \
|
bash ./scripts/publish-platform-packages.sh \
|
||||||
"${{ steps.package_version.outputs.version }}" \
|
"${{ steps.package.outputs.version }}" \
|
||||||
"artifacts/packages" \
|
"artifacts/packages" \
|
||||||
"${{ steps.package_version.outputs.profile }}"
|
"${{ steps.package.outputs.profile }}"
|
||||||
|
|
||||||
- name: Upload platform packages as workflow artifact
|
- name: Upload platform packages as workflow artifact
|
||||||
uses: https://github.com/christopherHX/gitea-upload-artifact@v4
|
uses: https://github.com/christopherHX/gitea-upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: eis-${{ steps.package_version.outputs.version }}-${{ steps.package_version.outputs.profile }}-platform-packages
|
name: jiaowu-${{ steps.package.outputs.version }}-${{ steps.package.outputs.profile }}-packages
|
||||||
path: artifacts/packages/*
|
path: artifacts/packages/*
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
@@ -85,34 +94,26 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: https://github.com/docker/setup-buildx-action@v3
|
uses: https://github.com/docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
|
||||||
uses: https://github.com/docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: docker.io
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Log in to Gitea Container Registry
|
- name: Log in to Gitea Container Registry
|
||||||
uses: https://github.com/docker/login-action@v3
|
uses: https://github.com/docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: git.biss.click
|
registry: git.biss.click
|
||||||
username: ${{ vars.REGISTRY_USERNAME }}
|
username: ${{ gitea.actor }}
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
password: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|
||||||
- name: Generate image tags and labels
|
- name: Generate image tags and labels
|
||||||
id: metadata
|
id: metadata
|
||||||
uses: https://github.com/docker/metadata-action@v5
|
uses: https://github.com/docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: ${{ env.GITEA_IMAGE }}
|
||||||
docker.io/${{ vars.DOCKERHUB_IMAGE }}
|
|
||||||
git.biss.click/biss/eis-dotnet
|
|
||||||
flavor: latest=auto
|
flavor: latest=auto
|
||||||
tags: |
|
tags: |
|
||||||
type=sha,format=short,prefix=sha-
|
type=sha,format=short,prefix=sha-
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=raw,value=manual-${{ gitea.run_number }},enable=${{ gitea.ref_type != 'tag' }}
|
||||||
|
|
||||||
- name: Build and push image
|
- name: Build and push multi-architecture image
|
||||||
uses: https://github.com/docker/build-push-action@v6
|
uses: https://github.com/docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -121,15 +122,17 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.metadata.outputs.tags }}
|
tags: ${{ steps.metadata.outputs.tags }}
|
||||||
labels: ${{ steps.metadata.outputs.labels }}
|
labels: ${{ steps.metadata.outputs.labels }}
|
||||||
cache-from: type=registry,ref=docker.io/${{ vars.DOCKERHUB_IMAGE }}:buildcache
|
cache-from: type=registry,ref=${{ env.GITEA_IMAGE }}:buildcache
|
||||||
cache-to: type=registry,ref=docker.io/${{ vars.DOCKERHUB_IMAGE }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ env.GITEA_IMAGE }}:buildcache,mode=max
|
||||||
|
|
||||||
- name: Detect release type
|
- name: Detect release type
|
||||||
id: release_type
|
id: release
|
||||||
if: gitea.ref_type == 'tag'
|
if: gitea.ref_type == 'tag'
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ gitea.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
case "$GITHUB_REF_NAME" in
|
case "$REF_NAME" in
|
||||||
*-*) prerelease=true ;;
|
*-*) prerelease=true ;;
|
||||||
*) prerelease=false ;;
|
*) prerelease=false ;;
|
||||||
esac
|
esac
|
||||||
@@ -142,21 +145,21 @@ jobs:
|
|||||||
token: ${{ secrets.GITEA_TOKEN }}
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
tag_name: ${{ gitea.ref_name }}
|
tag_name: ${{ gitea.ref_name }}
|
||||||
name: ${{ gitea.ref_name }}
|
name: ${{ gitea.ref_name }}
|
||||||
prerelease: ${{ steps.release_type.outputs.prerelease }}
|
prerelease: ${{ steps.release.outputs.prerelease }}
|
||||||
files: |
|
files: |
|
||||||
artifacts/packages/*
|
artifacts/packages/*
|
||||||
body: |
|
body: |
|
||||||
已发布以下自包含程序包,无需预装 .NET 运行时:
|
已发布大学教务管理系统 ${{ gitea.ref_name }}:
|
||||||
|
|
||||||
- Windows x64:下载对应的 `.zip`
|
- Windows x64:`.zip`
|
||||||
- Linux x64 / ARM64:下载对应的 `.tar.gz`
|
- Linux x64 / ARM64:`.tar.gz`
|
||||||
- 使用 `SHA256SUMS` 校验下载文件
|
- `SHA256SUMS`:发布包校验值
|
||||||
|
- 自包含程序包无需预装 .NET 或 ASP.NET Core Runtime
|
||||||
|
|
||||||
Windows ARM64 与 macOS Intel / Apple Silicon 包可在 Actions 页面选择扩展平台后手动构建。
|
Windows ARM64 与 macOS Intel / Apple Silicon 包可通过手动运行工作流并启用扩展平台生成。
|
||||||
|
|
||||||
Docker 多架构镜像(linux/amd64、linux/arm64):
|
Docker 多架构镜像(linux/amd64、linux/arm64):
|
||||||
|
|
||||||
```text
|
```text
|
||||||
docker pull docker.io/${{ vars.DOCKERHUB_IMAGE }}:${{ steps.metadata.outputs.version }}
|
docker pull ${{ env.GITEA_IMAGE }}:${{ steps.metadata.outputs.version }}
|
||||||
docker pull git.biss.click/biss/eis-dotnet:${{ steps.metadata.outputs.version }}
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ src/Jiaowu.Api/wwwroot/
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
!.env.docker.example
|
||||||
|
|||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend
|
||||||
|
WORKDIR /source
|
||||||
|
COPY web/package.json web/package-lock.json ./web/
|
||||||
|
RUN npm --prefix web ci
|
||||||
|
COPY web/ ./web/
|
||||||
|
RUN npm --prefix web run build
|
||||||
|
|
||||||
|
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
|
||||||
|
ARG TARGETARCH
|
||||||
|
WORKDIR /source
|
||||||
|
COPY .env.example ./
|
||||||
|
COPY src/Jiaowu.Api/Jiaowu.Api.csproj ./src/Jiaowu.Api/
|
||||||
|
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
|
||||||
|
arch="${TARGETARCH/amd64/x64}" && \
|
||||||
|
dotnet restore ./src/Jiaowu.Api/Jiaowu.Api.csproj --arch "$arch"
|
||||||
|
COPY src/Jiaowu.Api/ ./src/Jiaowu.Api/
|
||||||
|
COPY --from=frontend /source/src/Jiaowu.Api/wwwroot/ ./src/Jiaowu.Api/wwwroot/
|
||||||
|
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
|
||||||
|
arch="${TARGETARCH/amd64/x64}" && \
|
||||||
|
dotnet publish ./src/Jiaowu.Api/Jiaowu.Api.csproj \
|
||||||
|
--configuration Release \
|
||||||
|
--arch "$arch" \
|
||||||
|
--no-restore \
|
||||||
|
--no-self-contained \
|
||||||
|
--output /app/publish \
|
||||||
|
-p:BuildFrontendOnPublish=false \
|
||||||
|
-p:DebugSymbols=false \
|
||||||
|
-p:DebugType=None
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||||
|
WORKDIR /app
|
||||||
|
ENV ASPNETCORE_ENVIRONMENT=Production \
|
||||||
|
ASPNETCORE_HTTP_PORTS=8080 \
|
||||||
|
DOTNET_EnableDiagnostics=0
|
||||||
|
EXPOSE 8080
|
||||||
|
COPY --from=build /app/publish/ ./
|
||||||
|
ARG UID=10001
|
||||||
|
RUN adduser \
|
||||||
|
--disabled-password \
|
||||||
|
--gecos "" \
|
||||||
|
--home "/nonexistent" \
|
||||||
|
--shell "/sbin/nologin" \
|
||||||
|
--no-create-home \
|
||||||
|
--uid "${UID}" \
|
||||||
|
appuser
|
||||||
|
USER appuser
|
||||||
|
ENTRYPOINT ["dotnet", "Jiaowu.Api.dll"]
|
||||||
@@ -58,19 +58,27 @@ dotnet publish src/Jiaowu.Api -c Release -o .artifacts/publish
|
|||||||
|
|
||||||
如需在特殊流水线中跳过自动前端构建,可传入 `-p:BuildFrontendOnPublish=false`。
|
如需在特殊流水线中跳过自动前端构建,可传入 `-p:BuildFrontendOnPublish=false`。
|
||||||
|
|
||||||
将发布包复制到目标服务器后,再通过 Windows 服务、容器编排平台或密钥管理系统,
|
将发布包复制到目标服务器后,把发布包中的 `.env.example` 复制为 `.env`,并填写
|
||||||
为 **应用运行进程** 注入配置。下面仅演示在当前 PowerShell 会话中配置;变量只对该
|
真实配置。应用会在启动时自动读取**可执行文件所在目录**的 `.env`:
|
||||||
会话及其启动的子进程生效:
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:ASPNETCORE_ENVIRONMENT = 'Production'
|
$copyParams = @{
|
||||||
$env:Database__Provider = 'MySql'
|
LiteralPath = '.artifacts\publish\.env.example'
|
||||||
$env:Jwt__Key = '至少32字节的随机生产密钥'
|
Destination = '.artifacts\publish\.env'
|
||||||
$env:AllowedHosts = 'jiaowu.example.edu.cn'
|
}
|
||||||
|
Copy-Item @copyParams
|
||||||
```
|
```
|
||||||
|
|
||||||
这些值由 `Jiaowu.Api` 在每次启动时读取。不要把真实连接串或密钥写入仓库中的
|
`.env` 使用 `KEY=VALUE` 格式,允许空行、以 `#` 开头的注释、可选的 `export` 前缀,
|
||||||
`appsettings*.json`,证书路径也必须是目标服务器上的实际路径。
|
以及单引号或双引号值。双引号值支持 `\n`、`\r`、`\t`、`\\` 和 `\"`;不执行变量
|
||||||
|
替换或命令。真实进程环境变量的优先级高于 `.env`,因此 Windows 服务、Docker、
|
||||||
|
Kubernetes 或密钥管理系统仍可覆盖文件中的值。
|
||||||
|
|
||||||
|
如需把配置文件放到其他位置,通过 `JIAOWU_ENV_FILE` 指定绝对路径;相对路径按进程
|
||||||
|
当前工作目录解析。显式指定但文件不存在、行格式错误或引号没有闭合时,应用会拒绝
|
||||||
|
启动。不要把真实 `.env` 提交到仓库或打进发布包;Linux/macOS 建议设置权限
|
||||||
|
`chmod 600 .env`,Windows 应通过 ACL 只允许服务账号和管理员读取。连接串中的证书
|
||||||
|
路径必须是运行服务器上的实际路径。
|
||||||
|
|
||||||
数据库应明确使用 `utf8mb4`;MySQL 8.4 的默认排序规则为
|
数据库应明确使用 `utf8mb4`;MySQL 8.4 的默认排序规则为
|
||||||
`utf8mb4_0900_ai_ci`。新建数据库时可执行:
|
`utf8mb4_0900_ai_ci`。新建数据库时可执行:
|
||||||
@@ -81,18 +89,19 @@ CREATE DATABASE `jiaowu`
|
|||||||
COLLATE utf8mb4_0900_ai_ci;
|
COLLATE utf8mb4_0900_ai_ci;
|
||||||
```
|
```
|
||||||
|
|
||||||
首次部署或版本升级时,先在目标服务器设置具备 DDL 权限的迁移账号连接串,并单独
|
首次部署或版本升级时,从 `.env` 复制一份不纳入版本控制的 `.env.migrate`,只将
|
||||||
执行迁移:
|
连接串改成具备 DDL 权限的迁移账号,然后单独执行迁移:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:ConnectionStrings__MySql = 'Server=db.example.edu.cn;Port=3306;Database=jiaowu;User=MIGRATION_USER;Password=MIGRATION_PASSWORD;SslMode=VerifyFull;SslCa=C:\certs\mysql-ca.pem;'
|
$env:JIAOWU_ENV_FILE = (Resolve-Path -LiteralPath '.artifacts\publish\.env.migrate').Path
|
||||||
& '.artifacts\publish\Jiaowu.Api.exe' --migrate-only
|
& '.artifacts\publish\Jiaowu.Api.exe' --migrate-only
|
||||||
```
|
```
|
||||||
|
|
||||||
迁移成功后,将连接串替换为仅具备应用所需 DML 权限的运行账号,再启动服务:
|
迁移成功后删除 `.env.migrate`,清除 `JIAOWU_ENV_FILE`,应用便会读取发布目录中的
|
||||||
|
`.env`;其中应配置仅具备应用所需 DML 权限的运行账号:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:ConnectionStrings__MySql = 'Server=db.example.edu.cn;Port=3306;Database=jiaowu;User=APP_USER;Password=APP_PASSWORD;SslMode=VerifyFull;SslCa=C:\certs\mysql-ca.pem;'
|
Remove-Item -LiteralPath 'Env:JIAOWU_ENV_FILE'
|
||||||
& '.artifacts\publish\Jiaowu.Api.exe'
|
& '.artifacts\publish\Jiaowu.Api.exe'
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -108,19 +117,13 @@ $env:ConnectionStrings__MySql = 'Server=db.example.edu.cn;Port=3306;Database=jia
|
|||||||
|
|
||||||
如需验证 Production 配置和 MySQL 8.4 部署链路,请新建专用的空数据库(例如
|
如需验证 Production 配置和 MySQL 8.4 部署链路,请新建专用的空数据库(例如
|
||||||
`jiaowu_demo`),不要向准备承载真实业务的数据库插入演示数据。先按前述步骤执行
|
`jiaowu_demo`),不要向准备承载真实业务的数据库插入演示数据。先按前述步骤执行
|
||||||
`--migrate-only`,停止该环境的应用实例,再改用应用运行账号执行:
|
`--migrate-only`,停止该环境的应用实例,再从 `.env.example` 复制并编辑
|
||||||
|
`.env.demo`,配置演示数据库、运行账号和临时管理员:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:ConnectionStrings__MySql = 'Server=db.example.edu.cn;Port=3306;Database=jiaowu_demo;User=APP_USER;Password=APP_PASSWORD;SslMode=VerifyFull;SslCa=C:\certs\mysql-ca.pem;'
|
$env:JIAOWU_ENV_FILE = (Resolve-Path -LiteralPath '.artifacts\publish\.env.demo').Path
|
||||||
$env:SeedAdmin__UserName = 'demo-admin'
|
|
||||||
$env:SeedAdmin__Password = '请替换为演示环境专用的强密码'
|
|
||||||
$env:SeedAdmin__DisplayName = '演示环境管理员'
|
|
||||||
|
|
||||||
& '.artifacts\publish\Jiaowu.Api.exe' --seed-demo-data --confirm-production-demo-data
|
& '.artifacts\publish\Jiaowu.Api.exe' --seed-demo-data --confirm-production-demo-data
|
||||||
|
Remove-Item -LiteralPath 'Env:JIAOWU_ENV_FILE'
|
||||||
Remove-Item -LiteralPath 'Env:SeedAdmin__UserName'
|
|
||||||
Remove-Item -LiteralPath 'Env:SeedAdmin__Password'
|
|
||||||
Remove-Item -LiteralPath 'Env:SeedAdmin__DisplayName'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
该命令仅允许在非 Development 环境运行,且必须同时提供确认参数。它会再次检查迁移
|
该命令仅允许在非 Development 环境运行,且必须同时提供确认参数。它会再次检查迁移
|
||||||
@@ -155,6 +158,68 @@ SQLite 只用于本地开发:新库通过 `EnsureCreated` 建立,已有开
|
|||||||
- `/health/live`:只检查进程存活。
|
- `/health/live`:只检查进程存活。
|
||||||
- `/health`、`/health/ready`:实际检查数据库连接,失败时返回 HTTP 503。
|
- `/health`、`/health/ready`:实际检查数据库连接,失败时返回 HTTP 503。
|
||||||
|
|
||||||
|
## 跨平台发布与 Docker
|
||||||
|
|
||||||
|
`.gitea/workflows/publish.yml` 只在推送 `v*` 标签或手动运行时执行,普通分支 push
|
||||||
|
不会触发耗时发布。默认生成以下自包含程序包,目标服务器无需另装 .NET:
|
||||||
|
|
||||||
|
- Windows x64:`.zip`
|
||||||
|
- Linux x64、Linux ARM64:`.tar.gz`
|
||||||
|
- `SHA256SUMS`:所有压缩包的 SHA-256 校验值
|
||||||
|
|
||||||
|
手动运行时启用 `include_extended_platforms`,还会生成 Windows ARM64、macOS x64
|
||||||
|
和 macOS ARM64。Windows 使用 `Jiaowu.Api.exe` 启动,Linux/macOS 使用
|
||||||
|
`./Jiaowu.Api`;各压缩包都包含 `.env.example`。
|
||||||
|
|
||||||
|
工作流同时使用 Buildx 构建 `linux/amd64`、`linux/arm64` 镜像并推送至 Gitea
|
||||||
|
Container Registry:
|
||||||
|
|
||||||
|
```text
|
||||||
|
git.biss.click/biss/academic-affairs-system
|
||||||
|
```
|
||||||
|
|
||||||
|
仓库的 Actions 权限必须允许内置 `GITEA_TOKEN` 写入 Packages 和 Releases。版本标签
|
||||||
|
会创建 Gitea Release;手动运行只保留工作流产物并推送
|
||||||
|
`manual-<run-number>`、`sha-<commit>` 镜像标签。
|
||||||
|
|
||||||
|
本地使用 Docker 时,先复制并编辑配置:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Copy-Item -LiteralPath '.env.docker.example' -Destination '.env.docker'
|
||||||
|
```
|
||||||
|
|
||||||
|
先使用迁移账号配置文件执行迁移,再使用应用账号配置文件启动容器;MySQL CA 文件通过
|
||||||
|
只读卷挂载,不会进入镜像:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$image = 'git.biss.click/biss/academic-affairs-system:1.0.0'
|
||||||
|
|
||||||
|
$migrateArgs = @(
|
||||||
|
'run', '--rm'
|
||||||
|
'--env-file', '.env.docker.migrate'
|
||||||
|
'--mount', 'type=bind,source=C:\certs\mysql-ca.pem,target=/run/secrets/mysql-ca.pem,readonly'
|
||||||
|
$image
|
||||||
|
'--migrate-only'
|
||||||
|
)
|
||||||
|
& docker @migrateArgs
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw '数据库迁移失败。' }
|
||||||
|
|
||||||
|
$runArgs = @(
|
||||||
|
'run', '--detach'
|
||||||
|
'--name', 'jiaowu'
|
||||||
|
'--restart', 'unless-stopped'
|
||||||
|
'--env-file', '.env.docker'
|
||||||
|
'--publish', '8080:8080'
|
||||||
|
'--mount', 'type=bind,source=C:\certs\mysql-ca.pem,target=/run/secrets/mysql-ca.pem,readonly'
|
||||||
|
$image
|
||||||
|
)
|
||||||
|
& docker @runArgs
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw '容器启动失败。' }
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker 镜像不包含 `.env`、数据库密码或 JWT 密钥。容器以非 root 用户运行,监听
|
||||||
|
8080 端口;生产环境仍应由反向代理负责 HTTPS、访问日志和请求大小限制。
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
version="${1:?Usage: publish-platform-packages.sh <version> [output-dir] [default|all]}"
|
||||||
|
output_dir="${2:-artifacts/packages}"
|
||||||
|
profile="${3:-default}"
|
||||||
|
|
||||||
|
case "$profile" in
|
||||||
|
default)
|
||||||
|
runtimes=(win-x64 linux-x64 linux-arm64)
|
||||||
|
;;
|
||||||
|
all)
|
||||||
|
runtimes=(win-x64 win-arm64 linux-x64 linux-arm64 osx-x64 osx-arm64)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown platform profile: $profile" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repository_root="$(cd "$script_dir/.." && pwd)"
|
||||||
|
project="$repository_root/src/Jiaowu.Api/Jiaowu.Api.csproj"
|
||||||
|
|
||||||
|
mkdir -p "$output_dir"
|
||||||
|
output_dir="$(cd "$output_dir" && pwd)"
|
||||||
|
if find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit | grep -q .; then
|
||||||
|
echo "Output directory must be empty: $output_dir" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
temporary_root="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$temporary_root"' EXIT
|
||||||
|
|
||||||
|
for runtime in "${runtimes[@]}"; do
|
||||||
|
package_name="jiaowu-${version}-${runtime}"
|
||||||
|
package_root="$temporary_root/$package_name"
|
||||||
|
|
||||||
|
dotnet publish "$project" \
|
||||||
|
--configuration Release \
|
||||||
|
--runtime "$runtime" \
|
||||||
|
--self-contained true \
|
||||||
|
--output "$package_root" \
|
||||||
|
-p:BuildFrontendOnPublish=false \
|
||||||
|
-p:Version="$version" \
|
||||||
|
-p:DebugSymbols=false \
|
||||||
|
-p:DebugType=None \
|
||||||
|
-p:PublishReadyToRun=false
|
||||||
|
|
||||||
|
cp "$repository_root/.env.example" "$package_root/.env.example"
|
||||||
|
cp "$repository_root/README.md" "$package_root/README.md"
|
||||||
|
|
||||||
|
if [[ "$runtime" == win-* ]]; then
|
||||||
|
(
|
||||||
|
cd "$temporary_root"
|
||||||
|
zip -q -r "$output_dir/$package_name.zip" "$package_name"
|
||||||
|
)
|
||||||
|
else
|
||||||
|
tar -C "$temporary_root" -czf "$output_dir/$package_name.tar.gz" "$package_name"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$package_root"
|
||||||
|
done
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$output_dir"
|
||||||
|
find . -maxdepth 1 -type f \
|
||||||
|
\( -name '*.zip' -o -name '*.tar.gz' \) \
|
||||||
|
-printf '%f\n' |
|
||||||
|
sort |
|
||||||
|
xargs sha256sum > SHA256SUMS
|
||||||
|
)
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Configuration;
|
||||||
|
|
||||||
|
public static class EnvironmentFile
|
||||||
|
{
|
||||||
|
public const string PathVariableName = "JIAOWU_ENV_FILE";
|
||||||
|
|
||||||
|
public static string? Load(string? path = null)
|
||||||
|
{
|
||||||
|
var configuredPath = path;
|
||||||
|
if (string.IsNullOrWhiteSpace(configuredPath))
|
||||||
|
{
|
||||||
|
configuredPath = Environment.GetEnvironmentVariable(PathVariableName);
|
||||||
|
}
|
||||||
|
|
||||||
|
var isExplicit = !string.IsNullOrWhiteSpace(configuredPath);
|
||||||
|
var resolvedPath = isExplicit
|
||||||
|
? Path.GetFullPath(configuredPath!, Environment.CurrentDirectory)
|
||||||
|
: Path.Combine(AppContext.BaseDirectory, ".env");
|
||||||
|
|
||||||
|
if (!File.Exists(resolvedPath))
|
||||||
|
{
|
||||||
|
if (isExplicit)
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException(
|
||||||
|
$"环境变量文件不存在:{resolvedPath}",
|
||||||
|
resolvedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var values = Parse(resolvedPath);
|
||||||
|
foreach (var (key, value) in values)
|
||||||
|
{
|
||||||
|
if (Environment.GetEnvironmentVariable(key) is null)
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> Parse(string path)
|
||||||
|
{
|
||||||
|
var values = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
var lineNumber = 0;
|
||||||
|
foreach (var sourceLine in File.ReadLines(path, Encoding.UTF8))
|
||||||
|
{
|
||||||
|
lineNumber++;
|
||||||
|
var line = sourceLine.Trim();
|
||||||
|
if (line.Length == 0 || line.StartsWith('#'))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.StartsWith("export ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
line = line[7..].TrimStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
var separatorIndex = line.IndexOf('=');
|
||||||
|
if (separatorIndex <= 0)
|
||||||
|
{
|
||||||
|
throw InvalidLine(path, lineNumber, "缺少 KEY=VALUE 分隔符");
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = line[..separatorIndex].Trim();
|
||||||
|
if (!IsValidKey(key))
|
||||||
|
{
|
||||||
|
throw InvalidLine(path, lineNumber, $"变量名无效:{key}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawValue = line[(separatorIndex + 1)..].Trim();
|
||||||
|
values[key] = ParseValue(path, lineNumber, rawValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ParseValue(string path, int lineNumber, string rawValue)
|
||||||
|
{
|
||||||
|
if (rawValue.Length == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawValue[0] == '\'')
|
||||||
|
{
|
||||||
|
if (rawValue.Length < 2 || rawValue[^1] != '\'')
|
||||||
|
{
|
||||||
|
throw InvalidLine(path, lineNumber, "单引号值没有正确闭合");
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawValue[1..^1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawValue[0] != '"')
|
||||||
|
{
|
||||||
|
return rawValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawValue.Length < 2 || rawValue[^1] != '"')
|
||||||
|
{
|
||||||
|
throw InvalidLine(path, lineNumber, "双引号值没有正确闭合");
|
||||||
|
}
|
||||||
|
|
||||||
|
var value = rawValue[1..^1];
|
||||||
|
var result = new StringBuilder(value.Length);
|
||||||
|
for (var index = 0; index < value.Length; index++)
|
||||||
|
{
|
||||||
|
var current = value[index];
|
||||||
|
if (current != '\\' || index == value.Length - 1)
|
||||||
|
{
|
||||||
|
result.Append(current);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var escaped = value[++index];
|
||||||
|
switch (escaped)
|
||||||
|
{
|
||||||
|
case 'n':
|
||||||
|
result.Append('\n');
|
||||||
|
break;
|
||||||
|
case 'r':
|
||||||
|
result.Append('\r');
|
||||||
|
break;
|
||||||
|
case 't':
|
||||||
|
result.Append('\t');
|
||||||
|
break;
|
||||||
|
case '\\':
|
||||||
|
result.Append('\\');
|
||||||
|
break;
|
||||||
|
case '"':
|
||||||
|
result.Append('"');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
result.Append('\\');
|
||||||
|
result.Append(escaped);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsValidKey(string key)
|
||||||
|
{
|
||||||
|
if (key.Length == 0 || !(char.IsAsciiLetter(key[0]) || key[0] == '_'))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return key.All(character =>
|
||||||
|
char.IsAsciiLetterOrDigit(character) || character == '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FormatException InvalidLine(
|
||||||
|
string path,
|
||||||
|
int lineNumber,
|
||||||
|
string reason) =>
|
||||||
|
new($"{path} 第 {lineNumber} 行无效:{reason}。");
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Remove="wwwroot\**\*" />
|
<Content Remove="wwwroot\**\*" />
|
||||||
<Content Update="appsettings.Development.json" CopyToPublishDirectory="Never" />
|
<Content Update="appsettings.Development.json" CopyToPublishDirectory="Never" />
|
||||||
|
<Content
|
||||||
|
Include="..\..\.env.example"
|
||||||
|
Link=".env.example"
|
||||||
|
CopyToPublishDirectory="PreserveNewest" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Configuration;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
using Jiaowu.Api.Infrastructure.Exams;
|
using Jiaowu.Api.Infrastructure.Exams;
|
||||||
using Jiaowu.Api.Infrastructure.Middleware;
|
using Jiaowu.Api.Infrastructure.Middleware;
|
||||||
@@ -15,6 +16,8 @@ using Microsoft.IdentityModel.Tokens;
|
|||||||
using Microsoft.OpenApi.Models;
|
using Microsoft.OpenApi.Models;
|
||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
|
|
||||||
|
EnvironmentFile.Load();
|
||||||
|
|
||||||
var migrateOnly = args.Contains("--migrate-only", StringComparer.OrdinalIgnoreCase);
|
var migrateOnly = args.Contains("--migrate-only", StringComparer.OrdinalIgnoreCase);
|
||||||
var seedDemoData = args.Contains("--seed-demo-data", StringComparer.OrdinalIgnoreCase);
|
var seedDemoData = args.Contains("--seed-demo-data", StringComparer.OrdinalIgnoreCase);
|
||||||
var confirmProductionDemoData = args.Contains(
|
var confirmProductionDemoData = args.Contains(
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using Jiaowu.Api.Infrastructure.Configuration;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class EnvironmentFileTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Load_reads_env_file_without_overwriting_process_environment()
|
||||||
|
{
|
||||||
|
var suffix = Guid.NewGuid().ToString("N");
|
||||||
|
var existingKey = $"JIAOWU_TEST_EXISTING_{suffix}";
|
||||||
|
var fileKey = $"JIAOWU_TEST_FILE_{suffix}";
|
||||||
|
var pathKey = $"JIAOWU_TEST_PATH_{suffix}";
|
||||||
|
var directory = CreateTemporaryDirectory();
|
||||||
|
var path = Path.Combine(directory, ".env");
|
||||||
|
await File.WriteAllLinesAsync(
|
||||||
|
path,
|
||||||
|
[
|
||||||
|
"# comment",
|
||||||
|
$"export {existingKey}=from-file",
|
||||||
|
$"{fileKey}=first",
|
||||||
|
$"{fileKey}='value;with#characters'",
|
||||||
|
$"{pathKey}=\"C:\\certs\\mysql-ca.pem\""
|
||||||
|
]);
|
||||||
|
Environment.SetEnvironmentVariable(existingKey, "from-process");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var loadedPath = EnvironmentFile.Load(path);
|
||||||
|
|
||||||
|
Assert.Equal(Path.GetFullPath(path), loadedPath);
|
||||||
|
Assert.Equal(
|
||||||
|
"from-process",
|
||||||
|
Environment.GetEnvironmentVariable(existingKey));
|
||||||
|
Assert.Equal(
|
||||||
|
"value;with#characters",
|
||||||
|
Environment.GetEnvironmentVariable(fileKey));
|
||||||
|
Assert.Equal(
|
||||||
|
@"C:\certs\mysql-ca.pem",
|
||||||
|
Environment.GetEnvironmentVariable(pathKey));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable(existingKey, null);
|
||||||
|
Environment.SetEnvironmentVariable(fileKey, null);
|
||||||
|
Environment.SetEnvironmentVariable(pathKey, null);
|
||||||
|
Directory.Delete(directory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_rejects_an_explicit_missing_file()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"jiaowu-missing-{Guid.NewGuid():N}",
|
||||||
|
".env");
|
||||||
|
|
||||||
|
var exception = Assert.Throws<FileNotFoundException>(
|
||||||
|
() => EnvironmentFile.Load(path));
|
||||||
|
|
||||||
|
Assert.Equal(Path.GetFullPath(path), exception.FileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Load_reports_the_line_number_for_malformed_values()
|
||||||
|
{
|
||||||
|
var directory = CreateTemporaryDirectory();
|
||||||
|
var path = Path.Combine(directory, ".env");
|
||||||
|
await File.WriteAllLinesAsync(
|
||||||
|
path,
|
||||||
|
[
|
||||||
|
"# comment",
|
||||||
|
"Jwt__Key=\"not-closed"
|
||||||
|
]);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var exception = Assert.Throws<FormatException>(
|
||||||
|
() => EnvironmentFile.Load(path));
|
||||||
|
|
||||||
|
Assert.Contains("第 2 行", exception.Message);
|
||||||
|
Assert.Contains("没有正确闭合", exception.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Directory.Delete(directory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateTemporaryDirectory()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"jiaowu-env-tests",
|
||||||
|
Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(path);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user