自动构建

This commit is contained in:
2026-07-25 21:15:32 +08:00 Unverified
parent 2ac873c347
commit 1595d6f0c0
12 changed files with 579 additions and 65 deletions
+18
View File
@@ -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
+13
View File
@@ -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
+21
View File
@@ -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=系统管理员
+44 -41
View File
@@ -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 }}
```
+1
View File
@@ -15,3 +15,4 @@ src/Jiaowu.Api/wwwroot/
.env
.env.*
!.env.example
!.env.docker.example
+49
View File
@@ -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"]
+89 -24
View File
@@ -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
+72
View File
@@ -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}。");
}
+4
View File
@@ -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>
+3
View File
@@ -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;
}
}