feat: 添加环境变量管理工具和部署配置改版
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m33s
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:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-24
|
||||
233
openspec/changes/archive/2026-01-24-add-object-storage/design.md
Normal file
233
openspec/changes/archive/2026-01-24-add-object-storage/design.md
Normal 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 v2:API 更现代,但联通云文档无示例,兼容性未知
|
||||
- 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. **旧接口保留多久?**
|
||||
- 建议:不保留,直接切换
|
||||
- 待确认:与前端团队协调
|
||||
@@ -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`
|
||||
@@ -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 自动创建该目录
|
||||
219
openspec/changes/archive/2026-01-24-add-object-storage/tasks.md
Normal file
219
openspec/changes/archive/2026-01-24-add-object-storage/tasks.md
Normal 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 GetUploadURLRequest(file_name, content_type, purpose)
|
||||
- [x] 4.1.2 GetUploadURLResponse(upload_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
|
||||
});
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-24
|
||||
@@ -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
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
@@ -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 导入流程(无需任何配置变更)
|
||||
@@ -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 调用
|
||||
@@ -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 文件格式正确
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-24
|
||||
@@ -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 列后才能导入
|
||||
@@ -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"
|
||||
@@ -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 文档
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-24
|
||||
@@ -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
|
||||
|
||||
无。设计已明确,可直接实施。
|
||||
@@ -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 -d(CI/CD 自动部署,或手动拉取 compose 文件)
|
||||
```
|
||||
|
||||
### 依赖
|
||||
|
||||
- Go 1.16+(`go:embed` 支持)
|
||||
- 无新增外部依赖
|
||||
|
||||
## 预期收益
|
||||
|
||||
| 指标 | 变更前 | 变更后 |
|
||||
|------|--------|--------|
|
||||
| 首次部署步骤 | 5 步 | 1 步 |
|
||||
| 配置文件 | 4 个外部文件 | 0 个(嵌入) |
|
||||
| 权限失败风险 | 高 | 低(降级策略) |
|
||||
| 环境可移植性 | Docker only | Docker/K8s/裸机 |
|
||||
| 配置热重载 | 支持 | 移除(开发阶段不需要) |
|
||||
@@ -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 创建
|
||||
@@ -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** 必须通过环境变量提供实际值
|
||||
@@ -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 等)
|
||||
78
openspec/specs/bootstrap-init/spec.md
Normal file
78
openspec/specs/bootstrap-init/spec.md
Normal 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 创建
|
||||
|
||||
133
openspec/specs/embedded-config/spec.md
Normal file
133
openspec/specs/embedded-config/spec.md
Normal 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** 必须通过环境变量提供实际值
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
219
openspec/specs/object-storage/spec.md
Normal file
219
openspec/specs/object-storage/spec.md
Normal 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 自动创建该目录
|
||||
|
||||
60
openspec/specs/openapi-markdown-description/spec.md
Normal file
60
openspec/specs/openapi-markdown-description/spec.md
Normal 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 调用
|
||||
|
||||
Reference in New Issue
Block a user