feat: 添加环境变量管理工具和部署配置改版
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m33s

主要改动:
- 新增交互式环境配置脚本 (scripts/setup-env.sh)
- 新增本地启动快捷脚本 (scripts/run-local.sh)
- 新增环境变量模板文件 (.env.example)
- 部署模式改版:使用嵌入式配置 + 环境变量覆盖
- 添加对象存储功能支持
- 改进 IoT 卡片导入任务
- 优化 OpenAPI 文档生成
- 删除旧的配置文件,改用嵌入式默认配置
This commit is contained in:
2026-01-26 10:28:29 +08:00
parent 194078674a
commit 45aa7deb87
94 changed files with 6532 additions and 1967 deletions

View File

@@ -0,0 +1,78 @@
# bootstrap-init Specification
## Purpose
TBD - created by archiving change deployment-self-init. Update Purpose after archive.
## Requirements
### Requirement: 集中化目录初始化
系统 SHALL 在应用启动时通过 `bootstrap.EnsureDirectories()` 函数统一创建所有必需的运行时目录。
目录列表:
- 临时文件目录(从 `config.Storage.TempDir` 读取)
- 应用日志目录(从 `config.Logging.AppLog.Filename` 提取目录部分)
- 访问日志目录(从 `config.Logging.AccessLog.Filename` 提取目录部分)
#### Scenario: 成功创建所有目录
- **WHEN** 应用启动且所有目录路径可写
- **THEN** 系统创建所有必需目录,权限为 0755
- **AND** 函数返回 nil
#### Scenario: 目录已存在
- **WHEN** 应用启动且目录已存在
- **THEN** 系统跳过创建,不报错
- **AND** 函数返回 nil
#### Scenario: 配置路径为空
- **WHEN** 某个目录配置为空字符串
- **THEN** 系统跳过该目录的创建
- **AND** 不影响其他目录的创建
### Requirement: 权限降级策略
系统 SHALL 在目录创建权限不足时自动降级到系统临时目录。
#### Scenario: 权限不足时降级
- **WHEN** 创建目录因权限不足失败os.IsPermission 为 true
- **THEN** 系统使用 `os.TempDir()/junhong/<原目录名>` 作为降级路径
- **AND** 记录 WARN 级别日志,包含原路径和降级路径
- **AND** 函数返回降级后的路径
#### Scenario: 非权限错误
- **WHEN** 创建目录失败且不是权限问题
- **THEN** 系统返回错误,应用启动失败
- **AND** 错误信息包含目录路径和原始错误
### Requirement: 初始化顺序
系统 SHALL 确保目录初始化在所有组件初始化之前完成。
#### Scenario: 正确的初始化顺序
- **WHEN** 应用启动
- **THEN** 执行顺序为:
1. config.Load() 加载配置
2. bootstrap.EnsureDirectories() 创建目录
3. logger.Init() 初始化日志
4. 其他组件初始化
#### Scenario: 目录初始化失败
- **WHEN** `bootstrap.EnsureDirectories()` 返回错误
- **THEN** 应用立即退出,不继续初始化其他组件
- **AND** 错误信息输出到 stderr
### Requirement: 移除分散的目录创建逻辑
系统 SHALL 移除各组件中分散的目录创建代码。
#### Scenario: S3Provider 不再创建目录
- **WHEN** 初始化 S3Provider
- **THEN** 不再调用 `os.MkdirAll` 创建临时目录
- **AND** 假设目录已由 bootstrap 创建

View File

@@ -0,0 +1,133 @@
# embedded-config Specification
## Purpose
TBD - created by archiving change deployment-self-init. Update Purpose after archive.
## Requirements
### Requirement: 配置嵌入
系统 SHALL 使用 Go 的 `go:embed` 指令将默认配置文件嵌入二进制文件。
嵌入文件位置:`pkg/config/defaults/config.yaml`
#### Scenario: 加载嵌入配置
- **WHEN** 调用 `config.Load()`
- **THEN** 系统从嵌入的 `defaults/config.yaml` 读取默认配置
- **AND** 无需外部配置文件即可启动
#### Scenario: 嵌入配置包含完整结构
- **WHEN** 读取嵌入配置
- **THEN** 配置包含所有配置节server、database、redis、storage、logging、queue、jwt、middleware
### Requirement: 环境变量覆盖
系统 SHALL 支持通过环境变量覆盖嵌入的默认配置值。
环境变量格式:`JUNHONG_{SECTION}_{KEY}`
#### Scenario: 环境变量覆盖配置
- **WHEN** 设置环境变量 `JUNHONG_DATABASE_HOST=myhost`
- **THEN** `config.Database.Host` 的值为 "myhost"
- **AND** 覆盖嵌入配置中的默认值
#### Scenario: 嵌套配置覆盖
- **WHEN** 设置环境变量 `JUNHONG_LOGGING_LEVEL=debug`
- **THEN** `config.Logging.Level` 的值为 "debug"
#### Scenario: 未设置环境变量
- **WHEN** 未设置某个配置的环境变量
- **THEN** 使用嵌入配置中的默认值
### Requirement: 配置优先级
系统 SHALL 按以下优先级应用配置(高到低):
1. 环境变量 (JUNHONG_*)
2. 嵌入默认值 (go:embed)
#### Scenario: 优先级验证
- **WHEN** 嵌入配置中 `server.address` 为 ":3000"
- **AND** 设置环境变量 `JUNHONG_SERVER_ADDRESS=:8080`
- **THEN** 最终 `config.Server.Address` 为 ":8080"
### Requirement: 必填配置验证
系统 SHALL 在加载配置后验证必填配置项是否已设置。
必填配置项:
- `database.host`
- `database.user`
- `database.password`
- `database.dbname`
- `redis.address`
- `jwt.secret_key`
#### Scenario: 必填配置缺失
- **WHEN** 必填配置项为空且未通过环境变量设置
- **THEN** `config.Load()` 返回错误
- **AND** 错误信息明确指出缺失的配置项和对应的环境变量名
#### Scenario: 必填配置通过环境变量提供
- **WHEN** 所有必填配置通过环境变量设置
- **THEN** `config.Load()` 成功返回配置
### Requirement: 删除外部配置文件支持
系统 SHALL 移除对外部配置文件的支持。
#### Scenario: 不读取 configs 目录
- **WHEN** 应用启动
- **THEN** 不读取 `configs/*.yaml` 文件
- **AND** 不依赖 `CONFIG_PATH``CONFIG_ENV` 环境变量
### Requirement: 删除配置热重载
系统 SHALL 移除配置热重载功能。
#### Scenario: 不监听配置文件变化
- **WHEN** 应用运行中
- **THEN** 不使用 fsnotify 监听文件变化
- **AND** 删除 `pkg/config/watcher.go`
#### Scenario: 配置变更需重启
- **WHEN** 需要更改配置
- **THEN** 必须重启应用使新配置生效
### Requirement: 环境变量前缀
系统 SHALL 使用 `JUNHONG_` 作为环境变量前缀。
#### Scenario: 前缀隔离
- **WHEN** 存在环境变量 `DATABASE_HOST=other`
- **AND** 存在环境变量 `JUNHONG_DATABASE_HOST=correct`
- **THEN** `config.Database.Host` 为 "correct"
- **AND** 忽略无前缀的 `DATABASE_HOST`
### Requirement: 敏感配置处理
系统 SHALL 确保敏感配置不嵌入二进制文件。
敏感配置项(嵌入值为空):
- `database.password`
- `redis.password`
- `jwt.secret_key`
- `storage.s3.access_key_id`
- `storage.s3.secret_access_key`
#### Scenario: 敏感配置默认为空
- **WHEN** 读取嵌入配置
- **THEN** 敏感配置项的值为空字符串
- **AND** 必须通过环境变量提供实际值

View File

@@ -16,18 +16,22 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose
**导入参数**:
- `carrier_id`: 运营商 ID(BIGINT,必填)
- `carrier_type`: 运营商类型(VARCHAR(10),CMCC/CUCC/CTCC/CBN)
- `batch_no`: 批次号(VARCHAR(100),可选)
- `file_name`: 原始文件名(VARCHAR(255),可选)
**待导入数据**:
- `card_list`: 待导入卡列表(JSONB,结构: [{iccid, msisdn}],替代原 iccid_list)
**进度统计**:
- `total_count`: 总数(INT,CSV 文件总行数)
- `success_count`: 成功数(INT,成功导入的 ICCID 数量)
- `success_count`: 成功数(INT,成功导入的数量)
- `skip_count`: 跳过数(INT,因重复等原因跳过的数量)
- `fail_count`: 失败数(INT,因格式错误等原因失败的数量)
**结果详情**:
- `skipped_items`: 跳过记录详情(JSONB,结构: [{line, iccid, reason}])
- `failed_items`: 失败记录详情(JSONB,结构: [{line, iccid, reason}])
- `skipped_items`: 跳过记录详情(JSONB,结构: [{line, iccid, msisdn, reason}])
- `failed_items`: 失败记录详情(JSONB,结构: [{line, iccid, msisdn, reason}])
**时间和错误**:
- `started_at`: 开始处理时间(TIMESTAMP,可空)
@@ -43,23 +47,9 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose
#### Scenario: 创建导入任务
- **WHEN** 管理员上传 CSV 文件发起导入
- **THEN** 系统创建导入任务记录,`status` 为 1(待处理),`total_count` 为 CSV 行数,返回任务 ID
#### Scenario: 导入任务开始处理
- **WHEN** Worker 开始处理导入任务
- **THEN** 系统将任务 `status` 从 1(待处理) 变更为 2(处理中),`started_at` 记录当前时间
#### Scenario: 导入任务完成
- **WHEN** Worker 完成导入任务处理
- **THEN** 系统将任务 `status` 变更为 3(已完成),`completed_at` 记录当前时间,更新 `success_count``skip_count``fail_count`
#### Scenario: 导入任务失败
- **WHEN** Worker 处理导入任务时发生严重错误(如文件损坏)
- **THEN** 系统将任务 `status` 变更为 4(失败),`error_message` 记录错误信息
- **GIVEN** 管理员上传包含 ICCID 和 MSISDN 两列的 CSV 文件
- **WHEN** 系统解析 CSV 并创建导入任务
- **THEN** 系统创建导入任务记录,`card_list` 包含 [{iccid, msisdn}] 结构,`status` 为 1(待处理)
---
@@ -174,3 +164,76 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose
- **WHEN** 更新导入任务时 success_count + skip_count + fail_count > total_count
- **THEN** 系统拒绝更新,返回错误信息"统计数量不一致"
### Requirement: CSV 文件格式规范
系统 SHALL 要求 CSV 文件必须包含 ICCID 和 MSISDN 两列。
**文件格式要求**:
- 第一列: ICCID必填不能为空
- 第二列: MSISDN/接入号(必填,不能为空)
- 支持表头行(自动识别并跳过)
- 表头识别关键字: iccid/卡号 + msisdn/接入号/手机号
**解析规则**:
- 自动去除首尾空格
- 跳过空行
- 第一行为表头时自动跳过
- 列数不足 2 列的文件拒绝导入
- ICCID 为空的行记录为失败
- MSISDN 为空的行记录为失败
#### Scenario: 解析标准双列 CSV 文件
- **GIVEN** CSV 文件内容为:
```
iccid,msisdn
89860012345678901234,13800000001
89860012345678901235,13800000002
```
- **WHEN** 系统解析该 CSV 文件
- **THEN** 解析结果包含 2 条有效记录,每条包含 ICCID 和 MSISDN
#### Scenario: 拒绝单列 CSV 文件
- **GIVEN** CSV 文件内容仅包含 ICCID 单列
- **WHEN** 系统尝试解析该 CSV 文件
- **THEN** 系统返回错误 "CSV 文件格式错误:缺少 MSISDN 列"
#### Scenario: MSISDN 为空的行记录失败
- **GIVEN** CSV 文件内容为:
```
iccid,msisdn
89860012345678901234,13800000001
89860012345678901235,
```
- **WHEN** 系统解析该 CSV 文件
- **THEN** 第一条记录解析成功,第二条记录标记为失败,原因为 "MSISDN 不能为空"
#### Scenario: ICCID 为空的行记录失败
- **GIVEN** CSV 文件内容为:
```
iccid,msisdn
89860012345678901234,13800000001
,13800000002
```
- **WHEN** 系统解析该 CSV 文件
- **THEN** 第一条记录解析成功,第二条记录标记为失败,原因为 "ICCID 不能为空"
---
### Requirement: 导入时填充 MSISDN 字段
系统 SHALL 在创建 IoT 卡记录时填充 MSISDN 字段。
**处理规则**:
- 从 `card_list` 中获取 ICCID 和 MSISDN
- 创建 `IotCard` 记录时同时设置 `iccid` 和 `msisdn` 字段
#### Scenario: 创建卡记录时填充 MSISDN
- **GIVEN** 导入任务包含卡数据 [{iccid: "898600...", msisdn: "13800000001"}]
- **WHEN** Worker 处理导入任务创建卡记录
- **THEN** 创建的 `IotCard` 记录 `iccid` 为 "898600...",`msisdn` 为 "13800000001"

View File

@@ -0,0 +1,219 @@
# object-storage Specification
## Purpose
TBD - created by archiving change add-object-storage. Update Purpose after archive.
## Requirements
### Requirement: Provider 接口
系统 SHALL 提供统一的对象存储 Provider 接口,支持 S3 兼容的对象存储服务。
接口定义:
```go
type Provider interface {
Upload(ctx context.Context, key string, reader io.Reader, contentType string) error
Download(ctx context.Context, key string, writer io.Writer) error
DownloadToTemp(ctx context.Context, key string) (localPath string, cleanup func(), err error)
Delete(ctx context.Context, key string) error
Exists(ctx context.Context, key string) (bool, error)
GetUploadURL(ctx context.Context, key string, contentType string, expires time.Duration) (string, error)
GetDownloadURL(ctx context.Context, key string, expires time.Duration) (string, error)
}
```
#### Scenario: 创建 S3 Provider
- **WHEN** 系统启动时读取 storage 配置
- **THEN** 系统 SHALL 创建 S3Provider 实例并验证连接
#### Scenario: 配置缺失
- **WHEN** storage 配置未设置或不完整
- **THEN** 系统 SHALL 记录警告日志并跳过初始化(不影响启动)
---
### Requirement: 文件上传
系统 SHALL 支持通过 Provider 接口上传文件到对象存储。
#### Scenario: 上传成功
- **WHEN** 调用 `Upload(ctx, "imports/test.csv", reader, "text/csv")`
- **THEN** 文件 SHALL 被上传到配置的 Bucket 中指定路径
- **THEN** 方法 SHALL 返回 nil
#### Scenario: 上传失败
- **WHEN** 对象存储服务不可用
- **THEN** 方法 SHALL 返回包含错误详情的 error
---
### Requirement: 文件下载
系统 SHALL 支持从对象存储下载文件。
#### Scenario: 下载到 Writer
- **WHEN** 调用 `Download(ctx, "imports/test.csv", writer)`
- **THEN** 文件内容 SHALL 被写入到提供的 writer
#### Scenario: 下载到临时文件
- **WHEN** 调用 `DownloadToTemp(ctx, "imports/test.csv")`
- **THEN** 系统 SHALL 下载文件到临时目录
- **THEN** 方法 SHALL 返回本地文件路径和 cleanup 函数
- **THEN** 调用 cleanup() 后临时文件 SHALL 被删除
#### Scenario: 文件不存在
- **WHEN** 下载的文件在对象存储中不存在
- **THEN** 方法 SHALL 返回 "文件不存在" 错误
---
### Requirement: 文件删除
系统 SHALL 支持从对象存储删除文件。
#### Scenario: 删除成功
- **WHEN** 调用 `Delete(ctx, "imports/test.csv")`
- **THEN** 文件 SHALL 从对象存储中删除
- **THEN** 方法 SHALL 返回 nil
#### Scenario: 删除不存在的文件
- **WHEN** 删除的文件不存在
- **THEN** 方法 SHALL 返回 nil幂等操作
---
### Requirement: 文件存在性检查
系统 SHALL 支持检查文件是否存在于对象存储。
#### Scenario: 文件存在
- **WHEN** 调用 `Exists(ctx, "imports/test.csv")` 且文件存在
- **THEN** 方法 SHALL 返回 (true, nil)
#### Scenario: 文件不存在
- **WHEN** 调用 `Exists(ctx, "imports/test.csv")` 且文件不存在
- **THEN** 方法 SHALL 返回 (false, nil)
---
### Requirement: 预签名上传 URL
系统 SHALL 支持生成预签名上传 URL允许前端直接上传文件到对象存储。
#### Scenario: 生成上传 URL
- **WHEN** 调用 `GetUploadURL(ctx, "imports/test.csv", "text/csv", 15*time.Minute)`
- **THEN** 方法 SHALL 返回有效的预签名 URL
- **THEN** URL SHALL 在指定时间15分钟后过期
- **THEN** 使用该 URL 的 PUT 请求 SHALL 能成功上传文件
#### Scenario: URL 过期后
- **WHEN** 使用过期的预签名 URL 上传
- **THEN** 对象存储 SHALL 返回 403 Forbidden
---
### Requirement: 预签名下载 URL
系统 SHALL 支持生成预签名下载 URL允许用户直接从对象存储下载文件。
#### Scenario: 生成下载 URL
- **WHEN** 调用 `GetDownloadURL(ctx, "exports/report.xlsx", 24*time.Hour)`
- **THEN** 方法 SHALL 返回有效的预签名 URL
- **THEN** URL SHALL 在指定时间24小时后过期
- **THEN** 使用该 URL 的 GET 请求 SHALL 能下载文件
---
### Requirement: 获取上传 URL API
系统 SHALL 提供 API 接口供前端获取预签名上传 URL。
接口定义:
```
POST /api/admin/storage/upload-url
Authorization: Bearer <token>
Content-Type: application/json
Request:
{
"file_name": "cards.csv",
"content_type": "text/csv",
"purpose": "iot_import"
}
Response:
{
"code": 0,
"message": "success",
"data": {
"upload_url": "http://obs-helf.cucloud.cn/cmp/imports/2025/01/24/abc123.csv?X-Amz-...",
"file_key": "imports/2025/01/24/abc123.csv",
"expires_in": 900
}
}
```
#### Scenario: 获取上传 URL 成功
- **WHEN** 已认证用户调用 POST /api/admin/storage/upload-url
- **AND** 请求包含有效的 file_name、content_type、purpose
- **THEN** 系统 SHALL 返回预签名上传 URL 和 file_key
- **THEN** file_key 格式 SHALL 为 `{purpose}/{year}/{month}/{day}/{uuid}.{ext}`
#### Scenario: 参数缺失
- **WHEN** 请求缺少必填参数
- **THEN** 系统 SHALL 返回 400 错误
#### Scenario: 未认证
- **WHEN** 请求未携带有效 Token
- **THEN** 系统 SHALL 返回 401 错误
---
### Requirement: 文件路径规范
系统 SHALL 按照规范生成文件路径。
路径格式:`{purpose}/{year}/{month}/{day}/{uuid}.{ext}`
支持的 purpose 值:
- `iot_import``imports/`
- `export``exports/`
- `attachment``attachments/`
#### Scenario: 生成导入文件路径
- **WHEN** purpose 为 "iot_import"file_name 为 "cards.csv"
- **THEN** 生成的 file_key SHALL 匹配 `imports/\d{4}/\d{2}/\d{2}/[a-f0-9-]+\.csv`
#### Scenario: 未知 purpose
- **WHEN** purpose 值不在支持列表中
- **THEN** 系统 SHALL 返回错误 "不支持的文件用途"
---
### Requirement: 配置结构
系统 SHALL 支持通过配置文件配置对象存储参数。
```yaml
storage:
provider: "s3"
s3:
endpoint: "http://obs-helf.cucloud.cn"
region: "cn-langfang-2"
bucket: "cmp"
access_key_id: "${OSS_ACCESS_KEY_ID}"
secret_access_key: "${OSS_SECRET_ACCESS_KEY}"
use_ssl: false
path_style: true
presign:
upload_expires: "15m"
download_expires: "24h"
temp_dir: "/tmp/junhong-storage"
```
#### Scenario: 环境变量替换
- **WHEN** 配置值为 `${ENV_VAR}` 格式
- **THEN** 系统 SHALL 从环境变量读取实际值
#### Scenario: 临时目录不存在
- **WHEN** temp_dir 目录不存在
- **THEN** 系统 SHALL 自动创建该目录

View File

@@ -0,0 +1,60 @@
# openapi-markdown-description Specification
## Purpose
TBD - created by archiving change add-openapi-markdown-description. Update Purpose after archive.
## Requirements
### Requirement: RouteSpec 支持 Description 字段
RouteSpec 结构体 SHALL 包含 `Description` 字段,类型为 `string`,用于设置接口的详细 Markdown 说明。
#### Scenario: Description 字段为空时不影响生成
- **WHEN** RouteSpec.Description 为空字符串
- **THEN** 生成的 OpenAPI 规范中该接口不包含 description 字段
#### Scenario: Description 字段有内容时写入 OpenAPI
- **WHEN** RouteSpec.Description 包含非空内容
- **THEN** 生成的 OpenAPI 规范中该接口的 description 字段包含该内容
### Requirement: Description 支持 Markdown 语法
生成器 SHALL 原样保留 Description 字段的 Markdown 内容,不进行转义或处理,以便 OpenAPI 工具(如 Apifox正确渲染。
#### Scenario: 支持基础 Markdown 格式
- **WHEN** Description 包含 Markdown 标题、列表、表格、代码块
- **THEN** 生成的 OpenAPI YAML 文件中保留完整的 Markdown 格式
#### Scenario: 支持多行内容
- **WHEN** Description 包含多行文本
- **THEN** 生成的 OpenAPI YAML 文件使用 YAML 多行字符串格式正确表示
### Requirement: AddOperation 方法处理 Description
AddOperation 方法 SHALL 接受 description 参数并设置到 openapi3.Operation.Description 字段。
#### Scenario: 普通接口设置 Description
- **WHEN** 调用 AddOperation 且 description 参数非空
- **THEN** 生成的 Operation 对象包含 Description 字段
### Requirement: AddMultipartOperation 方法处理 Description
AddMultipartOperation 方法 SHALL 与 AddOperation 一致,支持 description 参数。
#### Scenario: 文件上传接口设置 Description
- **WHEN** 调用 AddMultipartOperation 且 description 参数非空
- **THEN** 生成的 multipart/form-data 接口包含 Description 字段
### Requirement: Register 函数传递 Description
Register 函数 SHALL 从 RouteSpec 中提取 Description 字段并传递给文档生成器。
#### Scenario: Register 调用时传递 Description
- **WHEN** 调用 Register 函数注册路由
- **THEN** RouteSpec.Description 被传递到对应的 AddOperation 或 AddMultipartOperation 调用