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,2 @@
schema: spec-driven
created: 2026-01-24

View File

@@ -0,0 +1,233 @@
# 通用对象存储 - 技术设计
## Context
### 背景
当前系统的 ICCID 导入功能采用传统的文件上传方式:前端上传 CSV 文件到后端,后端解析后处理。这种方式存在以下问题:
1. **性能瓶颈**:大文件上传占用后端带宽和内存
2. **扩展性差**:未来导出功能也需要文件存储能力
3. **安全风险**:文件处理在内存中进行,存在 OOM 风险
### 现状
- 联通云对象存储CUCloud OSS已开通Bucket `cmp` 已创建
- 联通云 OSS 兼容 AWS S3 API支持预签名 URL已验证
- 项目使用 `github.com/aws/aws-sdk-go` v1 版本
### 约束
- 本项目作为公司后端模板,设计需要通用化
- 联通云 OSS 的 Endpoint 格式:`http://obs-{region}.cucloud.cn`
- 同一时刻只使用一个云存储提供商(不需要多云并存)
## Goals / Non-Goals
### Goals
1. **通用对象存储能力**:提供可复用的对象存储包 `pkg/storage/`
2. **预签名 URL 支持**:前端直传,不经过后端
3. **ICCID 导入改造**:集成对象存储,提升性能
4. **配置驱动**:通过配置文件切换不同云存储
### Non-Goals
1. **不实现导出功能**:只准备能力,导出功能后续单独开发
2. **不实现多云并存**:同一时刻只用一个云
3. **不删除对象存储文件**:导入完成后只删除本地临时文件
4. **不实现断点续传**小文件CSV不需要
## Decisions
### Decision 1: 使用 AWS SDK v1 而非 v2
**选择**`github.com/aws/aws-sdk-go`v1
**理由**
- 联通云官方文档推荐使用 v1
- 已验证 v1 在联通云上的预签名功能正常工作
- v1 的 API 更简洁,学习成本低
**备选方案**
- AWS SDK v2API 更现代,但联通云文档无示例,兼容性未知
- MinIO Go Client功能更丰富但增加额外依赖
### Decision 2: Provider 接口设计
**选择**:定义简洁的 `Provider` 接口
```go
type Provider interface {
// 上传文件
Upload(ctx context.Context, key string, reader io.Reader, contentType string) error
// 下载文件到 io.Writer
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)
// 生成上传预签名 URL
GetUploadURL(ctx context.Context, key string, contentType string, expires time.Duration) (string, error)
// 生成下载预签名 URL
GetDownloadURL(ctx context.Context, key string, expires time.Duration) (string, error)
}
```
**理由**
- 接口方法覆盖导入导出所需的全部操作
- `DownloadToTemp` 封装临时文件管理,调用者无需关心清理
- 不暴露 Bucket 参数,由实现内部管理(配置驱动)
### Decision 3: 文件路径规范
**选择**`{purpose}/{year}/{month}/{day}/{uuid}.{ext}`
```
imports/2025/01/24/550e8400-e29b-41d4.csv
exports/2025/01/24/123456-cards.xlsx
attachments/2025/01/24/license.pdf
```
**理由**
- 按日期组织便于管理和清理
- UUID 保证唯一性
- purpose 前缀区分业务场景
### Decision 4: 配置结构
**选择**:嵌套配置,支持多种预签名有效期
```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"
```
**理由**
- 凭证通过环境变量注入,不硬编码
- 预签名有效期可配置,适应不同场景
- `path_style: true` 确保联通云兼容性
### Decision 5: Service 层封装
**选择**:创建 `StorageService` 封装业务逻辑
```go
type StorageService struct {
provider Provider
config *config.StorageConfig
}
// 获取上传 URL自动生成 file_key
func (s *StorageService) GetUploadURL(ctx context.Context, purpose, fileName string) (*PresignResult, error)
// 下载到临时文件(自动清理)
func (s *StorageService) DownloadToTemp(ctx context.Context, fileKey string) (string, func(), error)
```
**理由**
- 封装 file_key 生成逻辑(日期 + UUID
- 统一管理临时文件清理
- Handler 层只关心业务参数
### Decision 6: 导入接口改造
**选择**:移除文件上传,改为传递 file_key
**Before**:
```
POST /api/admin/iot-cards/import
Content-Type: multipart/form-data
carrier_id, batch_no, file
```
**After**:
```
POST /api/admin/iot-cards/import
Content-Type: application/json
{
"carrier_id": 1,
"batch_no": "BATCH-2025-01",
"file_key": "imports/2025/01/24/abc123.csv"
}
```
**理由**
- JSON 接口更简洁
- 文件已在对象存储,只需传路径
- Worker 从对象存储下载处理
## Risks / Trade-offs
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| 联通云服务不可用 | 无法上传/下载文件 | 1) 配置超时和重试 2) 监控告警 |
| 预签名 URL 泄露 | 文件可能被非法访问 | 1) 短有效期15分钟 2) 使用 HTTPS |
| 临时文件未清理 | 磁盘空间占用 | 1) defer cleanup() 2) 定期清理任务 |
| **BREAKING** 接口变更 | 前端需要适配 | 1) 与前端团队同步 2) 提供迁移文档 |
## 包结构
```
pkg/storage/
├── storage.go # Provider 接口定义
├── types.go # 公共类型PresignResult, Config
├── s3.go # S3 兼容实现
└── service.go # StorageService 封装
internal/service/storage/
└── service.go # 业务层 Service可选如需更多业务逻辑
internal/handler/admin/
└── storage.go # StorageHandler获取上传 URL
```
## Migration Plan
### 部署步骤
1. **配置准备**
- 在各环境配置文件中添加 `storage` 配置块
- 设置环境变量 `OSS_ACCESS_KEY_ID``OSS_SECRET_ACCESS_KEY`
2. **数据库迁移**
- 执行迁移添加 `storage_bucket``storage_key` 字段
3. **代码部署**
- 部署新版本后端代码
4. **前端适配**
- 前端发布新版本,使用新的上传流程
### 回滚策略
- 数据库字段为可空,不影响回滚
- 旧版前端可继续使用(需保留旧接口一段时间,或不回滚)
## Open Questions
1. **是否需要文件大小限制?**
- 建议CSV 文件限制 10MB
- 待确认:具体限制值
2. **是否需要文件类型校验?**
- 建议:只允许 `.csv` 文件
- 待确认:是否需要更严格的校验
3. **旧接口保留多久?**
- 建议:不保留,直接切换
- 待确认:与前端团队协调

View File

@@ -0,0 +1,97 @@
# 通用对象存储能力
## Why
当前 ICCID 导入功能通过后端接收上传文件占用服务器带宽和内存大文件处理效率低。同时未来的导出功能也需要文件存储能力。需要接入联通云对象存储S3 兼容),采用预签名 URL 方案实现前端直传,提升性能和安全性。
此外,本项目作为公司后端模板项目,对象存储应设计为通用能力,方便复用到其他系统。
## What Changes
- **新增通用对象存储包**`pkg/storage/` 提供 S3 兼容的对象存储能力
- 支持上传、下载、删除、检查存在性
- 支持生成预签名上传/下载 URL
- 可扩展支持多云(阿里云、腾讯云等,仅需更换 Endpoint
- **新增存储 API 接口**:供前端获取预签名上传 URL
- `POST /api/admin/storage/upload-url`:获取上传预签名 URL
- **改造 ICCID 导入流程**
- 移除原有的文件上传处理(`c.FormFile`
- 改为接收 `file_key` 参数(对象存储路径)
- Worker 从对象存储下载文件后处理
- 处理完成后删除本地临时文件
- **数据模型变更**
- `IotCardImportTask` 新增 `storage_bucket``storage_key` 字段
- 需要数据库迁移
- **配置结构扩展**
- 新增 `storage` 配置块endpoint、region、bucket、credentials
## Capabilities
### New Capabilities
- `object-storage`: 通用对象存储能力,提供 S3 兼容的文件上传、下载、删除、预签名 URL 生成功能
### Modified Capabilities
- `iot-card-import`: ICCID 导入流程改造,从直接上传改为对象存储集成
## Impact
### 代码变更
| 层级 | 变更内容 |
|------|----------|
| `pkg/storage/` | 新增Provider 接口、S3 实现、配置类型 |
| `pkg/config/` | 修改:新增 StorageConfig 结构 |
| `configs/` | 修改:新增 storage 配置块 |
| `internal/bootstrap/` | 修改:初始化 Storage Provider |
| `internal/handler/admin/` | 新增StorageHandler获取上传 URL|
| `internal/handler/admin/` | 修改IotCardImportHandler移除文件上传|
| `internal/service/iot_card_import/` | 修改:接收 file_key 而非文件流 |
| `internal/task/` | 修改:从对象存储下载文件处理 |
| `internal/model/` | 修改IotCardImportTask 新增字段 |
| `internal/routes/` | 修改:新增 storage 路由 |
| `migrations/` | 新增:添加 storage 字段迁移 |
### API 变更
| 接口 | 变更类型 | 说明 |
|------|----------|------|
| `POST /api/admin/storage/upload-url` | 新增 | 获取预签名上传 URL |
| `POST /api/admin/iot-cards/import` | **BREAKING** | 移除文件上传,改为传 file_key |
### 依赖变更
| 依赖 | 说明 |
|------|------|
| `github.com/aws/aws-sdk-go` | 新增AWS S3 兼容 SDK已验证联通云支持 |
### 配置变更
```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" # 临时文件目录
```
### 前端适配
前端需要配合修改上传流程:
1. 先调用 `POST /api/admin/storage/upload-url` 获取预签名 URL
2. 直接 PUT 到预签名 URL 上传文件
3. 上传成功后调用导入接口,传入 `file_key`

View File

@@ -0,0 +1,217 @@
# 对象存储能力规格
## ADDED 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,219 @@
# 对象存储集成 - 任务清单
## 1. 基础设施
- [x] 1.1 在 `pkg/config/config.go` 添加 Storage 配置结构体
- [x] 1.2 在 `configs/config.yaml` 添加 storage 配置块(含环境变量占位符)
- [x] 1.3 创建 `pkg/storage/` 目录结构
## 2. Provider 实现
- [x] 2.1 创建 `pkg/storage/storage.go` - Provider 接口定义
- [x] 2.2 创建 `pkg/storage/types.go` - 公共类型PresignResult、Config
- [x] 2.3 创建 `pkg/storage/s3.go` - S3Provider 实现
- [x] 2.3.1 实现 NewS3Provider 构造函数
- [x] 2.3.2 实现 Upload 方法
- [x] 2.3.3 实现 Download 方法
- [x] 2.3.4 实现 DownloadToTemp 方法(含 cleanup 函数)
- [x] 2.3.5 实现 Delete 方法
- [x] 2.3.6 实现 Exists 方法
- [x] 2.3.7 实现 GetUploadURL 方法
- [x] 2.3.8 实现 GetDownloadURL 方法
- [x] 2.4 创建 `pkg/storage/service.go` - StorageService 封装
- [x] 2.4.1 实现 GenerateFileKey 方法purpose + 日期 + UUID
- [x] 2.4.2 实现 GetUploadURL 方法(生成 key + 获取预签名)
- [x] 2.4.3 实现 DownloadToTemp 方法(透传 + 日志)
## 3. Bootstrap 集成
- [x] 3.1 在 `internal/bootstrap/` 添加 storage 初始化逻辑
- [x] 3.2 在 `cmd/api/main.go` 集成 StorageService可选配置缺失时跳过
- [x] 3.3 在 `cmd/worker/main.go` 集成 StorageService
## 4. API 接口
- [x] 4.1 创建 `internal/model/dto/storage.go` - 请求/响应 DTO
- [x] 4.1.1 GetUploadURLRequestfile_name, content_type, purpose
- [x] 4.1.2 GetUploadURLResponseupload_url, file_key, expires_in
- [x] 4.2 创建 `internal/handler/admin/storage.go` - StorageHandler
- [x] 4.2.1 实现 GetUploadURL 方法
- [x] 4.3 注册路由 POST /api/admin/storage/upload-url
- [x] 4.3.1 在 RouteSpec.Description 添加前端使用流程说明Markdown 格式)
- [x] 4.4 更新 `cmd/api/docs.go``cmd/gendocs/main.go` 添加 StorageHandler
## 5. ICCID 导入改造
- [x] 5.1 修改 `internal/model/iot_card_import_task.go`
- [x] 5.1.1 添加 StorageBucket 字段
- [x] 5.1.2 添加 StorageKey 字段
- [x] 5.2 创建数据库迁移文件添加新字段
- [x] 5.3 修改 `internal/model/dto/iot_card_import.go`
- [x] 5.3.1 将 CreateImportTaskRequest 从 multipart 改为 JSON
- [x] 5.3.2 添加 FileKey 字段,移除 File 字段
- [x] 5.4 修改 `internal/handler/admin/iot_card_import.go`
- [x] 5.4.1 移除 c.FormFile() 逻辑
- [x] 5.4.2 改为接收 JSON body 解析 file_key
- [x] 5.4.3 保存 storage_bucket 和 storage_key 到任务记录
- [x] 5.6 更新导入接口的 RouteSpec.Description
- [x] 5.6.1 说明接口变更BREAKING: multipart → JSON
- [x] 5.6.2 说明完整导入流程(先获取上传 URL → 上传文件 → 调用导入接口)
- [x] 5.5 修改 `internal/task/iot_card_import.go`
- [x] 5.5.1 从任务记录获取 storage_key
- [x] 5.5.2 调用 StorageService.DownloadToTemp 下载文件
- [x] 5.5.3 处理完成后调用 cleanup() 删除临时文件
- [x] 5.5.4 保留原有 CSV 解析逻辑
## 6. 错误码
- [x] 6.1 在 `pkg/errors/codes.go` 添加存储相关错误码
- [x] 6.1.1 ErrStorageUploadFailed
- [x] 6.1.2 ErrStorageDownloadFailed
- [x] 6.1.3 ErrStorageFileNotFound
- [x] 6.1.4 ErrStorageInvalidPurpose
## 7. 测试
- [x] 7.1 创建 `scripts/test_storage.go` - 对象存储功能验证脚本
- [x] 7.2 联通云后台验证文件上传成功
- [x] 7.3 现有 Worker 测试通过
## 8. 文档
- [x] 8.1 创建 `docs/object-storage/使用指南.md` - 后端开发指南
- [x] 8.1.1 StorageService 使用示例
- [x] 8.1.2 配置说明
- [x] 8.1.3 错误处理
- [x] 8.2 创建 `docs/object-storage/前端接入指南.md` - 前端接入说明
- [x] 8.2.1 文件上传完整流程(时序图)
- [x] 8.2.2 获取预签名 URL 接口说明
- [x] 8.2.3 使用预签名 URL 上传文件(含代码示例)
- [x] 8.2.4 ICCID 导入接口变更说明BREAKING CHANGE
- [x] 8.2.5 错误处理和重试策略
- [x] 8.3 更新 README.md 添加对象存储功能说明
---
## 附录:前端接入指南内容大纲
### A. 文件上传流程(时序图)
```
前端 后端 API 对象存储
│ │ │
│ 1. POST /storage/upload-url │
│ {file_name, content_type, purpose} │
│ ─────────────────────────► │
│ │ │
│ 2. 返回 {upload_url, file_key, expires_in} │
│ ◄───────────────────────── │
│ │ │
│ 3. PUT upload_url (文件内容) │
│ ─────────────────────────────────────────────────► │
│ │ │
│ 4. 上传成功 (200 OK) │
│ ◄───────────────────────────────────────────────── │
│ │ │
│ 5. POST /iot-cards/import │
│ {carrier_id, batch_no, file_key} │
│ ─────────────────────────► │
│ │ │
│ 6. 返回任务创建成功 │
│ ◄───────────────────────── │
```
### B. 接口说明RouteSpec.Description 内容参考)
#### 获取上传 URL 接口
```markdown
## 文件上传流程
### 第一步:获取预签名 URL
调用本接口获取上传 URL 和 file_key。
### 第二步:直接上传到对象存储
使用返回的 `upload_url` 发起 PUT 请求上传文件:
\`\`\`javascript
const response = await fetch(upload_url, {
method: 'PUT',
headers: { 'Content-Type': content_type },
body: file
});
\`\`\`
### 第三步:使用 file_key 调用业务接口
上传成功后,使用 `file_key` 调用相关业务接口(如 ICCID 导入)。
### 注意事项
- 预签名 URL 有效期 15 分钟,请及时使用
- 上传失败时可重新获取 URL 重试
- file_key 在上传成功后永久有效
### purpose 可选值
| 值 | 说明 | 生成路径 |
|---|------|---------|
| iot_import | ICCID 导入 | imports/YYYY/MM/DD/uuid.csv |
| export | 数据导出 | exports/YYYY/MM/DD/uuid.xlsx |
| attachment | 附件上传 | attachments/YYYY/MM/DD/uuid.ext |
```
#### ICCID 导入接口
```markdown
## ⚠️ 接口变更说明BREAKING CHANGE
本接口已从 `multipart/form-data` 改为 `application/json`
### 变更前
\`\`\`
POST /api/admin/iot-cards/import
Content-Type: multipart/form-data
carrier_id, batch_no, file (文件)
\`\`\`
### 变更后
\`\`\`
POST /api/admin/iot-cards/import
Content-Type: application/json
{
"carrier_id": 1,
"batch_no": "BATCH-2025-01",
"file_key": "imports/2025/01/24/abc123.csv"
}
\`\`\`
### 完整导入流程
1. 调用 `POST /api/admin/storage/upload-url` 获取上传 URL
2. 使用预签名 URL 上传 CSV 文件
3. 使用返回的 `file_key` 调用本接口
```
### C. 前端代码示例TypeScript
```typescript
// 完整的文件上传流程
async function uploadAndImport(file: File, carrierId: number, batchNo: string) {
// 1. 获取预签名 URL
const { data } = await api.post('/storage/upload-url', {
file_name: file.name,
content_type: file.type || 'text/csv',
purpose: 'iot_import'
});
const { upload_url, file_key } = data;
// 2. 上传文件到对象存储
await fetch(upload_url, {
method: 'PUT',
headers: { 'Content-Type': file.type || 'text/csv' },
body: file
});
// 3. 调用导入接口
return api.post('/iot-cards/import', {
carrier_id: carrierId,
batch_no: batchNo,
file_key: file_key
});
}
```

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-24

View File

@@ -0,0 +1,114 @@
## Context
当前项目使用 `github.com/swaggest/openapi-go/openapi3` 库生成 OpenAPI 3.0.3 规范文档。路由注册通过 `RouteSpec` 结构体传递元数据,但目前只支持 `Summary` 字段作为接口的简短描述。
OpenAPI 规范的 Operation 对象包含两个描述字段:
- `summary`: 简短摘要(通常一行)
- `description`: 详细说明,**支持 CommonMark Markdown 语法**
swaggest 库的 `openapi3.Operation` 结构体已包含 `Description *string` 字段,只需在代码中设置即可。
## Goals / Non-Goals
**Goals:**
-`RouteSpec` 中新增 `Description` 字段
- 支持在接口文档中添加 Markdown 格式的详细说明
- 保持向后兼容Description 为可选字段
- 更新 API 文档规范
**Non-Goals:**
- 不修改 DTO 字段的 description 标签处理逻辑
- 不修改现有路由注册代码(新字段可选)
- 不扩展其他 OpenAPI 字段(如 externalDocs、deprecated 等)
## Decisions
### 决策 1: Description 字段类型
**选择**: 使用 `string` 类型
**原因**:
-`Summary` 字段保持一致
- 空字符串表示无描述,语义清晰
- 避免指针类型带来的 nil 检查复杂度
**备选方案**:
- `*string` 指针类型 - 增加使用复杂度,无实际收益
### 决策 2: 函数签名变更策略
**选择**: 不修改 `AddOperation` 函数签名,通过 `RouteSpec.Description` 传递
**原因**:
- 保持 API 稳定性
- `Register` 函数已封装了 `RouteSpec`,只需从中提取 Description
- 避免破坏性变更
**备选方案**:
- 修改 `AddOperation` 增加 description 参数 - 需要修改所有调用点,不必要
### 决策 3: 空值处理
**选择**: 空字符串时不设置 OpenAPI 的 description 字段
**原因**:
- 生成更简洁的 YAML
- 与 swaggest 库的 omitempty 行为一致
- 保持现有生成文件格式不变
## Risks / Trade-offs
**[风险] Markdown 语法在不同工具中渲染差异**
→ 缓解: 建议使用 CommonMark 基础语法(标题、列表、表格、代码块),避免扩展语法
**[风险] 过长的 Description 影响文档可读性**
→ 缓解: 在规范文档中建议控制长度,复杂说明可使用折叠或链接到外部文档
**[权衡] 不修改函数签名 vs 显式参数**
→ 选择封装在 RouteSpec 中,牺牲一定的显式性换取稳定性
## 实现方案
### 文件变更清单
1. **`internal/routes/registry.go`**
- RouteSpec 新增 Description 字段
2. **`pkg/openapi/generator.go`**
- AddOperation: 设置 op.Description
- AddMultipartOperation: 设置 op.Description
3. **`docs/api-documentation-guide.md`**
- 新增 Description 字段使用说明
- 补充 Markdown 语法示例
### 代码变更示例
```go
// internal/routes/registry.go
type RouteSpec struct {
Summary string // 简短摘要
Description string // 详细说明,支持 Markdown
Input interface{}
Output interface{}
Tags []string
Auth bool
FileUploads []FileUploadField
}
```
```go
// pkg/openapi/generator.go - AddOperation
func (g *Generator) AddOperation(...) {
op := openapi3.Operation{
Summary: &summary,
Tags: tags,
}
// 新增: 设置 Description
if description != "" {
op.Description = &description
}
// ...
}
```

View File

@@ -0,0 +1,36 @@
## Why
当前项目的 OpenAPI 文档生成模块只支持通过 `RouteSpec.Summary` 设置接口的简短摘要,无法添加详细的 Markdown 格式说明。前端团队使用 Apifox 查看 API 文档时,需要在某些接口上看到更详细的使用说明、注意事项、业务规则等信息。
OpenAPI 规范的 Operation 对象支持 `description` 字段,且该字段明确支持 **CommonMark** Markdown 语法。Apifox 作为 OpenAPI 工具,能够正确渲染这些 Markdown 内容。因此需要扩展当前的文档生成模块以支持此功能。
## What Changes
-`RouteSpec` 结构体中新增 `Description` 字段,用于设置接口的详细 Markdown 说明
- 修改 `pkg/openapi/generator.go` 中的 `AddOperation``AddMultipartOperation` 方法,将 Description 写入 OpenAPI 规范
- 更新 `internal/routes/registry.go` 中的 `Register` 函数以传递 Description 参数
- 更新 API 文档规范,说明 Description 字段的使用方法和 Markdown 语法支持
## Capabilities
### New Capabilities
- `openapi-markdown-description`: 支持在 OpenAPI 接口文档中添加 Markdown 格式的详细描述,包括表格、列表、代码块等富文本内容
### Modified Capabilities
<!-- 无现有能力需要修改,这是纯新增功能 -->
## Impact
**受影响的代码**:
- `internal/routes/registry.go` - RouteSpec 结构体定义和 Register 函数
- `pkg/openapi/generator.go` - AddOperation 和 AddMultipartOperation 方法
**受影响的文档**:
- `docs/api-documentation-guide.md` - 需要补充 Description 字段使用说明
**不受影响**:
- 所有现有路由注册代码Description 字段为可选,空值时行为与当前一致)
- 生成的 OpenAPI 文件格式(符合 OpenAPI 3.0.3 规范)
- Apifox 导入流程(无需任何配置变更)

View File

@@ -0,0 +1,56 @@
## ADDED 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 调用

View File

@@ -0,0 +1,24 @@
## 1. RouteSpec 结构体扩展
- [x] 1.1 在 `internal/routes/registry.go` 的 RouteSpec 结构体中新增 Description 字段
## 2. OpenAPI 生成器修改
- [x] 2.1 修改 `pkg/openapi/generator.go` 的 AddOperation 方法签名,增加 description 参数
- [x] 2.2 在 AddOperation 方法中设置 op.Description 字段
- [x] 2.3 修改 AddMultipartOperation 方法签名,增加 description 参数
- [x] 2.4 在 AddMultipartOperation 方法中设置 op.Description 字段
## 3. Register 函数更新
- [x] 3.1 更新 `internal/routes/registry.go` 的 Register 函数,传递 spec.Description 给生成器
## 4. 文档更新
- [x] 4.1 更新 `docs/api-documentation-guide.md`,新增 Description 字段使用说明
- [x] 4.2 补充 Markdown 语法示例和最佳实践
## 5. 验证
- [x] 5.1 运行 `go run cmd/gendocs/main.go` 验证文档生成正常
- [x] 5.2 检查生成的 YAML 文件格式正确

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-24

View File

@@ -0,0 +1,35 @@
# Change: 修复 ICCID 导入 CSV 格式支持 MSISDN
## Why
当前批量导入 ICCID 接口只支持单列 CSV仅 ICCID但 IoT 卡模型包含 MSISDN接入号/手机号码)字段,导入时无法填充该重要字段。运营商提供的卡资料必须同时包含 ICCID 和 MSISDN缺少接入号的卡无法正常使用。
## What Changes
- **BREAKING**: CSV 格式变更为必须包含两列ICCID, MSISDN不再支持单列格式
- 修改 CSV 解析逻辑要求两列格式,缺少 MSISDN 的行视为格式错误
- 修改导入任务模型存储 ICCID 和 MSISDN 的映射关系
- 修改导入任务处理逻辑,创建卡记录时填充 MSISDN 字段
- 更新 API 文档描述新的 CSV 格式要求
## Capabilities
### New Capabilities
(无新增能力)
### Modified Capabilities
- `iot-card-import-task`: 导入任务需要存储 ICCID-MSISDN 映射CSV 解析结果结构变更,强制要求双列格式
## Impact
- 受影响的代码:
- `pkg/utils/csv.go` - CSV 解析函数
- `internal/model/iot_card_import_task.go` - 导入任务模型
- `internal/task/iot_card_import.go` - 导入任务处理逻辑
- `internal/service/iot_card_import/service.go` - 导入服务
- 受影响的 API:
- `POST /api/admin/iot-cards/import` - CSV 格式要求变更(**BREAKING**
- 数据库: 需要迁移更新 `iccid_list` 字段结构(从 `[]string` 改为 `[{iccid, msisdn}]`
- 用户影响: 现有单列 CSV 文件需要补充 MSISDN 列后才能导入

View File

@@ -0,0 +1,126 @@
## MODIFIED Requirements
### Requirement: 导入任务实体定义
系统 SHALL 定义 IoT 卡导入任务(IotCardImportTask)实体,用于跟踪 IoT 卡批量导入的进度和结果。
**实体字段**:
**任务信息**:
- `id`: 任务 ID(主键,BIGINT)
- `task_no`: 任务编号(VARCHAR(50),唯一,格式: IMP-YYYYMMDD-XXXXXX)
- `status`: 任务状态(INT,1-待处理 2-处理中 3-已完成 4-失败)
**导入参数**:
- `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,成功导入的卡数量)
- `skip_count`: 跳过数(INT,因重复等原因跳过的数量)
- `fail_count`: 失败数(INT,因格式错误等原因失败的数量)
**结果详情**:
- `skipped_items`: 跳过记录详情(JSONB,结构: [{line, iccid, msisdn, reason}])
- `failed_items`: 失败记录详情(JSONB,结构: [{line, iccid, msisdn, reason}])
**时间和错误**:
- `started_at`: 开始处理时间(TIMESTAMP,可空)
- `completed_at`: 完成时间(TIMESTAMP,可空)
- `error_message`: 任务级错误信息(TEXT,可空,如文件解析失败等)
**系统字段**:
- `shop_id`: 店铺 ID(BIGINT,可空,记录发起导入的店铺)
- `created_at`: 创建时间(TIMESTAMP,自动填充)
- `updated_at`: 更新时间(TIMESTAMP,自动填充)
- `creator`: 创建人 ID(BIGINT)
- `updater`: 更新人 ID(BIGINT)
#### Scenario: 创建导入任务
- **GIVEN** 管理员上传包含 ICCID 和 MSISDN 两列的 CSV 文件
- **WHEN** 系统解析 CSV 并创建导入任务
- **THEN** 系统创建导入任务记录,`card_list` 包含 [{iccid, msisdn}] 结构,`status` 为 1(待处理)
---
## ADDED Requirements
### 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,31 @@
## 1. CSV 解析改造
- [x] 1.1 修改 `pkg/utils/csv.go``CSVParseResult` 结构体,添加 `Cards []CardInfo` 替代 `ICCIDs []string`
- [x] 1.2 修改 `ParseICCIDFromCSV` 函数为 `ParseCardCSV`,支持解析 ICCID + MSISDN 两列
- [x] 1.3 添加列数校验,单列 CSV 直接返回错误
- [x] 1.4 添加 ICCID/MSISDN 非空校验,空值记录为解析错误
- [x] 1.5 更新表头识别逻辑,支持 msisdn/接入号/手机号 关键字
- [x] 1.6 更新 `pkg/utils/csv_test.go` 测试用例
## 2. 导入任务模型改造
- [x] 2.1 创建数据库迁移:将 `iccid_list` 字段重命名为 `card_list`,类型保持 JSONB
- [x] 2.2 修改 `internal/model/iot_card_import_task.go`,定义 `CardListJSON` 类型为 `[]CardInfo{ICCID, MSISDN}`
- [x] 2.3 更新 `ImportResultItem` 结构体添加 `MSISDN` 字段
## 3. 导入服务改造
- [x] 3.1 修改 `internal/service/iot_card_import/service.go`,调用新的 `ParseCardCSV` 函数
- [x] 3.2 将解析结果的 `Cards` 存入任务的 `card_list` 字段
## 4. 导入任务处理改造
- [x] 4.1 修改 `internal/task/iot_card_import.go``getICCIDsFromTask` 改为 `getCardsFromTask`
- [x] 4.2 修改 `processBatch` 函数,创建卡记录时同时填充 `ICCID``MSISDN`
- [x] 4.3 更新失败/跳过记录的结构,包含 MSISDN 信息
- [x] 4.4 更新 `internal/task/iot_card_import_test.go` 测试用例
## 5. API 文档更新
- [x] 5.1 更新路由注册中 CSV 文件字段的描述,说明必须包含 ICCID 和 MSISDN 两列
- [x] 5.2 重新生成 OpenAPI 文档

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-24

View File

@@ -0,0 +1,281 @@
# 技术设计:部署自初始化
## Context
### 当前状态
1. **目录创建分散**
- `pkg/storage/s3.go` 在初始化时创建临时目录
- 日志目录依赖 Dockerfile 预创建
- 没有统一的初始化入口
2. **配置管理复杂**
- 4 个外部配置文件:`config.yaml``config.dev.yaml``config.staging.yaml``config.prod.yaml`
- `pkg/config/watcher.go` 实现热重载(开发阶段不需要)
- 必须手动拷贝配置文件才能启动
3. **部署流程繁琐**
- 需要手动创建目录结构
- 需要手动拷贝配置文件
- Docker Compose 挂载 configs 目录
### 约束
- 使用 Go 1.16+ 的 `go:embed` 特性
- 保持 Viper 作为配置解析库
- 容器内以非 root 用户 (appuser:1000) 运行
## Goals / Non-Goals
**Goals:**
- 应用启动时自动创建所有必需目录
- 配置嵌入二进制,无需外部配置文件
- 环境变量作为配置覆盖机制
- 部署流程简化到 1 步
**Non-Goals:**
- 配置热重载(开发阶段移除)
- 多配置文件支持(统一用嵌入默认值 + 环境变量)
- 向后兼容旧的配置方式
## Decisions
### Decision 1: 目录初始化放在应用层
**选择**: 在 `main.go` 启动时调用集中化的目录初始化函数
**备选方案**:
| 方案 | 优点 | 缺点 |
|------|------|------|
| Dockerfile RUN mkdir | 镜像固定 | 不支持运行时配置路径 |
| Entrypoint 脚本 | 运行时动态 | Shell 脚本维护成本高 |
| **应用代码初始化** | 跨环境通用、错误处理清晰 | 无 |
**理由**: 应用代码可以读取配置中的路径,提供 Go 级别的错误处理并在所有部署环境Docker、K8s、裸机通用。
### Decision 2: 配置嵌入 + 环境变量覆盖
**选择**: 使用 `go:embed` 嵌入默认配置,环境变量覆盖
**配置优先级**:
```
环境变量 (JUNHONG_*) > 嵌入默认值
```
**备选方案**:
| 方案 | 优点 | 缺点 |
|------|------|------|
| 纯外部文件 | 运行时可改 | 部署复杂 |
| 纯环境变量 | 12-Factor | 复杂配置难表达 |
| **嵌入 + 环境变量** | 开箱即用 + 灵活覆盖 | 改默认值需重编译 |
**理由**: 嵌入配置保证"开箱即用",环境变量覆盖满足生产环境定制需求。开发阶段不需要频繁改默认值。
### Decision 3: 环境变量命名规范
**选择**: `JUNHONG_` 前缀 + 下划线分隔
**格式**: `JUNHONG_{SECTION}_{KEY}`
**示例**:
```bash
JUNHONG_DATABASE_HOST=localhost
JUNHONG_DATABASE_PORT=5432
JUNHONG_REDIS_ADDRESS=localhost:6379
JUNHONG_SERVER_ADDRESS=:3000
```
**理由**:
- 前缀避免与系统环境变量冲突
- 下划线分隔便于 Viper 的 `SetEnvKeyReplacer` 处理
- 符合业界惯例(类似 `POSTGRES_``REDIS_` 等)
### Decision 4: 删除配置热重载
**选择**: 删除 `pkg/config/watcher.go` 及相关逻辑
**理由**:
- 开发阶段,配置变更频率低
- 重启容器即可应用新配置
- 减少代码复杂度
### Decision 5: 目录降级策略
**选择**: 权限不足时使用系统临时目录作为 fallback
```go
if err := os.MkdirAll(dir, 0755); err != nil {
if os.IsPermission(err) {
fallback := filepath.Join(os.TempDir(), "junhong", filepath.Base(dir))
os.MkdirAll(fallback, 0755)
return fallback, nil
}
return "", err
}
```
**理由**: 提高容错性,即使权限配置不当也能启动(降级运行)。
## Architecture
### 启动流程
```
main.go
├── 1. config.Load() // 加载嵌入配置 + 环境变量覆盖
│ ├── 读取 go:embed 的 defaults/config.yaml
│ ├── 应用 JUNHONG_* 环境变量覆盖
│ └── 验证必填配置
├── 2. bootstrap.EnsureDirectories(cfg) // 创建所有必需目录
│ ├── 临时文件目录
│ ├── 日志目录
│ └── 其他运行时目录
├── 3. logger.Init(cfg) // 初始化日志
├── 4. database.Init(cfg) // 初始化数据库
└── 5. ... 其他组件初始化
```
### 目录结构
```
pkg/
├── bootstrap/
│ └── directories.go # 新增:目录初始化
├── config/
│ ├── config.go # 保留:配置结构定义
│ ├── loader.go # 重写:嵌入配置加载
│ ├── embedded.go # 新增go:embed 逻辑
│ ├── defaults/
│ │ └── config.yaml # 新增:嵌入的默认配置
│ └── watcher.go # 删除
```
### 嵌入配置内容
```yaml
# pkg/config/defaults/config.yaml
server:
address: ":3000"
read_timeout: 30s
write_timeout: 30s
shutdown_timeout: 30s
prefork: false
database:
host: "" # 必须通过 JUNHONG_DATABASE_HOST 设置
port: 5432
user: "" # 必须通过 JUNHONG_DATABASE_USER 设置
password: "" # 必须通过 JUNHONG_DATABASE_PASSWORD 设置
dbname: "" # 必须通过 JUNHONG_DATABASE_DBNAME 设置
sslmode: "disable"
max_open_conns: 25
max_idle_conns: 10
conn_max_lifetime: 5m
redis:
address: "" # 必须通过 JUNHONG_REDIS_ADDRESS 设置
port: 6379
password: ""
db: 0
pool_size: 10
min_idle_conns: 5
dial_timeout: 5s
read_timeout: 3s
write_timeout: 3s
storage:
provider: "s3"
temp_dir: "/tmp/junhong-storage"
s3:
endpoint: ""
region: ""
bucket: ""
access_key_id: ""
secret_access_key: ""
use_ssl: false
path_style: true
presign:
upload_expires: 15m
download_expires: 24h
logging:
level: "info"
development: false
app_log:
filename: "/app/logs/app.log"
max_size: 100
max_backups: 3
max_age: 7
compress: true
access_log:
filename: "/app/logs/access.log"
max_size: 100
max_backups: 3
max_age: 7
compress: true
queue:
concurrency: 10
retry_max: 5
timeout: 10m
jwt:
secret_key: "" # 必须通过 JUNHONG_JWT_SECRET_KEY 设置
token_duration: 24h
access_token_ttl: 24h
refresh_token_ttl: 168h
middleware:
enable_rate_limiter: false
rate_limiter:
max: 100
expiration: 1m
storage: "memory"
```
## Risks / Trade-offs
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| 嵌入配置修改需重新编译 | 低(开发阶段) | 敏感配置通过环境变量,嵌入的只是默认值 |
| 环境变量泄露 | 中 | 使用 K8s Secrets 或 Docker Secrets 管理 |
| 目录降级后行为不一致 | 低 | 降级时打印 WARN 日志,明确告知 |
| 删除热重载后调试不便 | 低 | 开发时直接重启进程,生产用 rolling update |
## Migration Plan
### 实施步骤
1. **新增目录初始化模块**
- 创建 `pkg/bootstrap/directories.go`
- 在 main.go 中调用
2. **新增配置嵌入模块**
- 创建 `pkg/config/defaults/config.yaml`
- 创建 `pkg/config/embedded.go`
- 重写 `pkg/config/loader.go`
3. **清理旧配置逻辑**
- 删除 `pkg/config/watcher.go`
- 删除 `configs/*.yaml`
4. **更新 Docker 配置**
- 更新 `Dockerfile.api``Dockerfile.worker`
- 重写 `docker-compose.prod.yml`
5. **更新文档**
- 更新 README.md 部署说明
- 更新环境变量文档
### 回滚策略
如需回滚,恢复 git 提交即可(开发阶段无生产数据风险)。
## Open Questions
无。设计已明确,可直接实施。

View File

@@ -0,0 +1,104 @@
# 提案:部署自初始化
## Why
当前应用部署需要手动创建目录结构、拷贝配置文件,过程繁琐且容易出错。临时目录创建逻辑分散在各组件中,非 root 用户可能因权限问题导致启动失败。配置文件必须外部提供,无法实现"开箱即用"的部署体验。
本变更旨在实现**应用自初始化**,让应用启动时自动创建所需目录、使用嵌入的默认配置,大幅简化部署流程。
## What Changes
### 1. 集中化目录初始化
- 新增 `pkg/bootstrap/directories.go`,在应用启动时统一创建所有必需目录
- 移除各组件(如 `s3.go`)中分散的目录创建逻辑
- 提供降级策略:权限不足时自动使用备用路径
### 2. 配置嵌入机制
- 使用 `go:embed` 将默认配置嵌入二进制文件
- 配置优先级:**环境变量 > 嵌入默认值**(移除外部配置文件依赖)
- 敏感配置(数据库密码等)通过环境变量提供
- **移除** configs/ 目录和配置文件热重载机制(开发阶段不需要)
### 3. Docker 部署简化
- Dockerfile 预创建关键目录并设置正确权限
- **移除** configs 目录挂载,全部使用环境变量
- docker-compose 只挂载日志目录(持久化需求)
### 4. 环境变量规范化
- 统一环境变量前缀:`JUNHONG_`
- 支持嵌套配置:`JUNHONG_DATABASE_HOST``JUNHONG_REDIS_ADDRESS`
- 配置验证:必填配置未设置时启动失败并给出明确提示
### 5. 清理冗余
- **删除** `pkg/config/watcher.go`(配置热重载)
- **删除** `configs/*.yaml` 外部配置文件
- **删除** docker-compose 中的 configs 卷挂载
- **简化** `pkg/config/loader.go`
## Capabilities
### New Capabilities
- `bootstrap-init`: 应用启动时的集中化初始化机制,包括目录创建、配置加载、验证等
- `embedded-config`: 配置嵌入机制,使用 go:embed + 环境变量覆盖,无需外部配置文件
### Modified Capabilities
- `dependency-injection`: 调整 bootstrap 流程,在组件初始化前完成目录和配置的准备
## Impact
### 代码变更
| 文件/目录 | 变更类型 | 说明 |
|-----------|----------|------|
| `pkg/bootstrap/directories.go` | 新增 | 目录初始化逻辑 |
| `pkg/config/embedded.go` | 新增 | 配置嵌入和加载逻辑 |
| `pkg/config/defaults/config.yaml` | 新增 | 嵌入的默认配置文件 |
| `pkg/config/loader.go` | 重写 | 简化为嵌入配置 + 环境变量 |
| `pkg/config/watcher.go` | **删除** | 不再需要热重载 |
| `pkg/storage/s3.go` | 修改 | 移除目录创建逻辑 |
| `cmd/api/main.go` | 修改 | 调用目录初始化 |
| `cmd/worker/main.go` | 修改 | 调用目录初始化 |
| `internal/bootstrap/` | 修改 | 调整初始化顺序 |
| `configs/*.yaml` | **删除** | 配置嵌入后不再需要 |
### Docker 变更
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `Dockerfile.api` | 修改 | 预创建目录、删除 COPY configs |
| `Dockerfile.worker` | 修改 | 预创建目录、删除 COPY configs |
| `docker-compose.prod.yml` | 重写 | 纯环境变量配置、删除 configs 挂载 |
| `docker/entrypoint-api.sh` | 简化 | 只保留迁移逻辑 |
### 部署流程变更
**变更前**5 步):
```bash
# 1. SSH 到服务器
# 2. 创建目录 mkdir -p /opt/junhong_cmp/{configs,logs}
# 3. 复制 docker-compose.prod.yml
# 4. 复制配置文件到 configs/
# 5. docker-compose up -d
```
**变更后**1 步):
```bash
# docker-compose up -dCI/CD 自动部署,或手动拉取 compose 文件)
```
### 依赖
- Go 1.16+`go:embed` 支持)
- 无新增外部依赖
## 预期收益
| 指标 | 变更前 | 变更后 |
|------|--------|--------|
| 首次部署步骤 | 5 步 | 1 步 |
| 配置文件 | 4 个外部文件 | 0 个(嵌入) |
| 权限失败风险 | 高 | 低(降级策略) |
| 环境可移植性 | Docker only | Docker/K8s/裸机 |
| 配置热重载 | 支持 | 移除(开发阶段不需要) |

View File

@@ -0,0 +1,78 @@
# bootstrap-init 规范
应用启动时的集中化初始化机制,确保所有必需目录在组件初始化前创建完成。
## ADDED 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 规范
配置嵌入机制,使用 go:embed 将默认配置嵌入二进制文件,通过环境变量覆盖。
## ADDED 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

@@ -0,0 +1,82 @@
# 实施任务清单
## 1. 配置嵌入模块
- [x] 1.1 创建 `pkg/config/defaults/config.yaml` 嵌入配置文件
- [x] 1.2 创建 `pkg/config/embedded.go`,实现 go:embed 加载逻辑
- [x] 1.3 重写 `pkg/config/loader.go`,使用嵌入配置 + 环境变量覆盖
- [x] 1.4 更新 `pkg/config/config.go` 中的 Validate() 方法,添加必填配置验证
- [x] 1.5 删除 `pkg/config/watcher.go` 配置热重载模块
- [x] 1.6 编写配置加载单元测试
## 2. 目录初始化模块
- [x] 2.1 创建 `pkg/bootstrap/directories.go`,实现 EnsureDirectories() 函数
- [x] 2.2 实现权限降级策略(权限不足时使用临时目录)
- [x] 2.3 编写目录初始化单元测试
- [x] 2.4 移除 `pkg/storage/s3.go` 中的目录创建逻辑
## 3. 应用入口改造
- [x] 3.1 更新 `cmd/api/main.go`,在配置加载后调用 bootstrap.EnsureDirectories()
- [x] 3.2 更新 `cmd/worker/main.go`,同样调用目录初始化
- [x] 3.3 调整 `internal/bootstrap/` 中的初始化顺序(无需修改,顺序正确)
## 4. Docker 配置更新
- [x] 4.1 更新 `Dockerfile.api`:预创建目录、移除 COPY configs
- [x] 4.2 更新 `Dockerfile.worker`:预创建目录、移除 COPY configs
- [x] 4.3 重写 `docker-compose.prod.yml`:纯环境变量配置
- [x] 4.4 简化 `docker/entrypoint-api.sh`:移除配置相关逻辑
## 5. 清理旧文件
- [x] 5.1 删除 `configs/config.yaml`
- [x] 5.2 删除 `configs/config.dev.yaml`
- [x] 5.3 删除 `configs/config.staging.yaml`
- [x] 5.4 删除 `configs/config.prod.yaml`
- [x] 5.5 删除 `configs/` 目录(如果为空)
## 6. 文档更新
- [x] 6.1 更新 README.md 部署说明
- [x] 6.2 更新环境变量列表文档(创建 docs/environment-variables.md
- [x] 6.3 更新关键文档auth-usage-guide, object-storage, add-default-admin-init
## 7. 验证
- [x] 7.1 本地运行测试:`go test ./...`config 和 bootstrap 测试通过)
- [x] 7.2 本地 Docker 构建测试API 和 Worker 镜像构建成功)
- [x] 7.3 本地 docker-compose 启动测试(需要外部 PostgreSQL/Redis配置验证通过
- [x] 7.4 验证环境变量覆盖功能TestLoad_EnvOverride 通过)
---
## 实施完成总结
**完成日期**: 2026-01-24
### 主要变更
1. **配置嵌入机制**
- 默认配置嵌入二进制文件 (`pkg/config/defaults/config.yaml`)
- 环境变量覆盖使用 `JUNHONG_` 前缀
- 移除配置热重载功能
2. **目录自动初始化**
- `pkg/bootstrap/directories.go` 实现 `EnsureDirectories()`
- 支持权限降级(无权限时使用临时目录)
3. **Docker 部署简化**
- 移除 `COPY configs` 指令
- 纯环境变量配置
- 更新 `docker-compose.prod.yml``entrypoint-api.sh`
4. **文档更新**
- 创建 `docs/environment-variables.md` 完整环境变量文档
- 更新 README.md 部署说明
- 更新相关功能文档
### 后续工作(可选)
- 更新剩余的旧文档rate-limiting.md, deployment-guide.md 等)

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 调用