自动构建
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:
|
||||
push:
|
||||
@@ -12,12 +12,16 @@ on:
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
GITEA_IMAGE: git.biss.click/biss/academic-affairs-system
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Test, build and publish
|
||||
name: Test, package and publish
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
code: read
|
||||
packages: write
|
||||
releases: write
|
||||
|
||||
steps:
|
||||
@@ -27,40 +31,45 @@ jobs:
|
||||
- name: Set up .NET
|
||||
uses: https://github.com/actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
dotnet-version: "10.0.302"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: https://github.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
cache-dependency-path: src/Eis.Web/ClientApp/package-lock.json
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Build Vue application
|
||||
working-directory: src/Eis.Web/ClientApp
|
||||
working-directory: web
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Run tests
|
||||
run: dotnet test Eis.slnx --configuration Release
|
||||
- name: Run backend tests
|
||||
run: dotnet test Jiaowu.slnx --configuration Release
|
||||
|
||||
- name: Determine package version
|
||||
id: package_version
|
||||
- name: Determine package version and platform profile
|
||||
id: package
|
||||
shell: bash
|
||||
env:
|
||||
REF_TYPE: ${{ gitea.ref_type }}
|
||||
REF_NAME: ${{ gitea.ref_name }}
|
||||
RUN_NUMBER: ${{ gitea.run_number }}
|
||||
INCLUDE_EXTENDED_PLATFORMS: ${{ inputs.include_extended_platforms }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
if [[ "$REF_TYPE" == "tag" && "$REF_NAME" == v* ]]; then
|
||||
version="${REF_NAME#v}"
|
||||
else
|
||||
version="0.0.0-dev.${GITHUB_RUN_NUMBER:-0}"
|
||||
version="0.0.0-dev.${RUN_NUMBER:-0}"
|
||||
fi
|
||||
|
||||
profile=default
|
||||
if [[ "${INCLUDE_EXTENDED_PLATFORMS:-false}" == "true" ]]; then
|
||||
profile=all
|
||||
fi
|
||||
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "profile=$profile" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -68,14 +77,14 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
bash ./scripts/publish-platform-packages.sh \
|
||||
"${{ steps.package_version.outputs.version }}" \
|
||||
"${{ steps.package.outputs.version }}" \
|
||||
"artifacts/packages" \
|
||||
"${{ steps.package_version.outputs.profile }}"
|
||||
"${{ steps.package.outputs.profile }}"
|
||||
|
||||
- name: Upload platform packages as workflow artifact
|
||||
uses: https://github.com/christopherHX/gitea-upload-artifact@v4
|
||||
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/*
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -85,34 +94,26 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
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
|
||||
uses: https://github.com/docker/login-action@v3
|
||||
with:
|
||||
registry: git.biss.click
|
||||
username: ${{ vars.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
- name: Generate image tags and labels
|
||||
id: metadata
|
||||
uses: https://github.com/docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
docker.io/${{ vars.DOCKERHUB_IMAGE }}
|
||||
git.biss.click/biss/eis-dotnet
|
||||
images: ${{ env.GITEA_IMAGE }}
|
||||
flavor: latest=auto
|
||||
tags: |
|
||||
type=sha,format=short,prefix=sha-
|
||||
type=semver,pattern={{version}}
|
||||
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
|
||||
with:
|
||||
context: .
|
||||
@@ -121,15 +122,17 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.metadata.outputs.tags }}
|
||||
labels: ${{ steps.metadata.outputs.labels }}
|
||||
cache-from: type=registry,ref=docker.io/${{ vars.DOCKERHUB_IMAGE }}:buildcache
|
||||
cache-to: type=registry,ref=docker.io/${{ vars.DOCKERHUB_IMAGE }}:buildcache,mode=max
|
||||
cache-from: type=registry,ref=${{ env.GITEA_IMAGE }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.GITEA_IMAGE }}:buildcache,mode=max
|
||||
|
||||
- name: Detect release type
|
||||
id: release_type
|
||||
id: release
|
||||
if: gitea.ref_type == 'tag'
|
||||
shell: bash
|
||||
env:
|
||||
REF_NAME: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
case "$GITHUB_REF_NAME" in
|
||||
case "$REF_NAME" in
|
||||
*-*) prerelease=true ;;
|
||||
*) prerelease=false ;;
|
||||
esac
|
||||
@@ -142,21 +145,21 @@ jobs:
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
tag_name: ${{ gitea.ref_name }}
|
||||
name: ${{ gitea.ref_name }}
|
||||
prerelease: ${{ steps.release_type.outputs.prerelease }}
|
||||
prerelease: ${{ steps.release.outputs.prerelease }}
|
||||
files: |
|
||||
artifacts/packages/*
|
||||
body: |
|
||||
已发布以下自包含程序包,无需预装 .NET 运行时:
|
||||
已发布大学教务管理系统 ${{ gitea.ref_name }}:
|
||||
|
||||
- Windows x64:下载对应的 `.zip`
|
||||
- Linux x64 / ARM64:下载对应的 `.tar.gz`
|
||||
- 使用 `SHA256SUMS` 校验下载文件
|
||||
- Windows x64:`.zip`
|
||||
- Linux x64 / ARM64:`.tar.gz`
|
||||
- `SHA256SUMS`:发布包校验值
|
||||
- 自包含程序包无需预装 .NET 或 ASP.NET Core Runtime
|
||||
|
||||
Windows ARM64 与 macOS Intel / Apple Silicon 包可在 Actions 页面选择扩展平台后手动构建。
|
||||
Windows ARM64 与 macOS Intel / Apple Silicon 包可通过手动运行工作流并启用扩展平台生成。
|
||||
|
||||
Docker 多架构镜像(linux/amd64、linux/arm64):
|
||||
|
||||
```text
|
||||
docker pull docker.io/${{ vars.DOCKERHUB_IMAGE }}:${{ steps.metadata.outputs.version }}
|
||||
docker pull git.biss.click/biss/eis-dotnet:${{ steps.metadata.outputs.version }}
|
||||
docker pull ${{ env.GITEA_IMAGE }}:${{ steps.metadata.outputs.version }}
|
||||
```
|
||||
|
||||
@@ -15,3 +15,4 @@ src/Jiaowu.Api/wwwroot/
|
||||
.env
|
||||
.env.*
|
||||
!.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`。
|
||||
|
||||
将发布包复制到目标服务器后,再通过 Windows 服务、容器编排平台或密钥管理系统,
|
||||
为 **应用运行进程** 注入配置。下面仅演示在当前 PowerShell 会话中配置;变量只对该
|
||||
会话及其启动的子进程生效:
|
||||
将发布包复制到目标服务器后,把发布包中的 `.env.example` 复制为 `.env`,并填写
|
||||
真实配置。应用会在启动时自动读取**可执行文件所在目录**的 `.env`:
|
||||
|
||||
```powershell
|
||||
$env:ASPNETCORE_ENVIRONMENT = 'Production'
|
||||
$env:Database__Provider = 'MySql'
|
||||
$env:Jwt__Key = '至少32字节的随机生产密钥'
|
||||
$env:AllowedHosts = 'jiaowu.example.edu.cn'
|
||||
$copyParams = @{
|
||||
LiteralPath = '.artifacts\publish\.env.example'
|
||||
Destination = '.artifacts\publish\.env'
|
||||
}
|
||||
Copy-Item @copyParams
|
||||
```
|
||||
|
||||
这些值由 `Jiaowu.Api` 在每次启动时读取。不要把真实连接串或密钥写入仓库中的
|
||||
`appsettings*.json`,证书路径也必须是目标服务器上的实际路径。
|
||||
`.env` 使用 `KEY=VALUE` 格式,允许空行、以 `#` 开头的注释、可选的 `export` 前缀,
|
||||
以及单引号或双引号值。双引号值支持 `\n`、`\r`、`\t`、`\\` 和 `\"`;不执行变量
|
||||
替换或命令。真实进程环境变量的优先级高于 `.env`,因此 Windows 服务、Docker、
|
||||
Kubernetes 或密钥管理系统仍可覆盖文件中的值。
|
||||
|
||||
如需把配置文件放到其他位置,通过 `JIAOWU_ENV_FILE` 指定绝对路径;相对路径按进程
|
||||
当前工作目录解析。显式指定但文件不存在、行格式错误或引号没有闭合时,应用会拒绝
|
||||
启动。不要把真实 `.env` 提交到仓库或打进发布包;Linux/macOS 建议设置权限
|
||||
`chmod 600 .env`,Windows 应通过 ACL 只允许服务账号和管理员读取。连接串中的证书
|
||||
路径必须是运行服务器上的实际路径。
|
||||
|
||||
数据库应明确使用 `utf8mb4`;MySQL 8.4 的默认排序规则为
|
||||
`utf8mb4_0900_ai_ci`。新建数据库时可执行:
|
||||
@@ -81,18 +89,19 @@ CREATE DATABASE `jiaowu`
|
||||
COLLATE utf8mb4_0900_ai_ci;
|
||||
```
|
||||
|
||||
首次部署或版本升级时,先在目标服务器设置具备 DDL 权限的迁移账号连接串,并单独
|
||||
执行迁移:
|
||||
首次部署或版本升级时,从 `.env` 复制一份不纳入版本控制的 `.env.migrate`,只将
|
||||
连接串改成具备 DDL 权限的迁移账号,然后单独执行迁移:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
迁移成功后,将连接串替换为仅具备应用所需 DML 权限的运行账号,再启动服务:
|
||||
迁移成功后删除 `.env.migrate`,清除 `JIAOWU_ENV_FILE`,应用便会读取发布目录中的
|
||||
`.env`;其中应配置仅具备应用所需 DML 权限的运行账号:
|
||||
|
||||
```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'
|
||||
```
|
||||
|
||||
@@ -108,19 +117,13 @@ $env:ConnectionStrings__MySql = 'Server=db.example.edu.cn;Port=3306;Database=jia
|
||||
|
||||
如需验证 Production 配置和 MySQL 8.4 部署链路,请新建专用的空数据库(例如
|
||||
`jiaowu_demo`),不要向准备承载真实业务的数据库插入演示数据。先按前述步骤执行
|
||||
`--migrate-only`,停止该环境的应用实例,再改用应用运行账号执行:
|
||||
`--migrate-only`,停止该环境的应用实例,再从 `.env.example` 复制并编辑
|
||||
`.env.demo`,配置演示数据库、运行账号和临时管理员:
|
||||
|
||||
```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:SeedAdmin__UserName = 'demo-admin'
|
||||
$env:SeedAdmin__Password = '请替换为演示环境专用的强密码'
|
||||
$env:SeedAdmin__DisplayName = '演示环境管理员'
|
||||
|
||||
$env:JIAOWU_ENV_FILE = (Resolve-Path -LiteralPath '.artifacts\publish\.env.demo').Path
|
||||
& '.artifacts\publish\Jiaowu.Api.exe' --seed-demo-data --confirm-production-demo-data
|
||||
|
||||
Remove-Item -LiteralPath 'Env:SeedAdmin__UserName'
|
||||
Remove-Item -LiteralPath 'Env:SeedAdmin__Password'
|
||||
Remove-Item -LiteralPath 'Env:SeedAdmin__DisplayName'
|
||||
Remove-Item -LiteralPath 'Env:JIAOWU_ENV_FILE'
|
||||
```
|
||||
|
||||
该命令仅允许在非 Development 环境运行,且必须同时提供确认参数。它会再次检查迁移
|
||||
@@ -155,6 +158,68 @@ SQLite 只用于本地开发:新库通过 `EnsureCreated` 建立,已有开
|
||||
- `/health/live`:只检查进程存活。
|
||||
- `/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
|
||||
|
||||
@@ -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>
|
||||
<Content Remove="wwwroot\**\*" />
|
||||
<Content Update="appsettings.Development.json" CopyToPublishDirectory="Never" />
|
||||
<Content
|
||||
Include="..\..\.env.example"
|
||||
Link=".env.example"
|
||||
CopyToPublishDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Configuration;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Exams;
|
||||
using Jiaowu.Api.Infrastructure.Middleware;
|
||||
@@ -15,6 +16,8 @@ using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using System.Threading.RateLimiting;
|
||||
|
||||
EnvironmentFile.Load();
|
||||
|
||||
var migrateOnly = args.Contains("--migrate-only", StringComparer.OrdinalIgnoreCase);
|
||||
var seedDemoData = args.Contains("--seed-demo-data", StringComparer.OrdinalIgnoreCase);
|
||||
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