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
|
||||
});
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user