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

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

View File

@@ -140,17 +140,16 @@ if err := initDefaultAdmin(deps, services); err != nil {
### 自定义配置
`configs/config.yaml` 中添加
通过环境变量自定义
```yaml
default_admin:
username: "自定义用户名"
password: "自定义密码"
phone: "自定义手机号"
```bash
export JUNHONG_DEFAULT_ADMIN_USERNAME="自定义用户名"
export JUNHONG_DEFAULT_ADMIN_PASSWORD="自定义密码"
export JUNHONG_DEFAULT_ADMIN_PHONE="自定义手机号"
```
**注意**
- 配置项为可选,不参与 `Validate()` 验证
- 配置项为可选,不参与 `ValidateRequired()` 验证
- 任何字段留空则使用代码默认值
- 密码必须足够复杂(建议包含大小写字母、数字、特殊字符)
@@ -192,12 +191,11 @@ go run cmd/api/main.go
### 场景3使用自定义配置
**配置文件** (`configs/config.yaml`)
```yaml
default_admin:
username: "myadmin"
password: "MySecurePass@2024"
phone: "13900000000"
**设置环境变量**
```bash
export JUNHONG_DEFAULT_ADMIN_USERNAME="myadmin"
export JUNHONG_DEFAULT_ADMIN_PASSWORD="MySecurePass@2024"
export JUNHONG_DEFAULT_ADMIN_PHONE="13900000000"
```
**启动服务**
@@ -230,11 +228,11 @@ go run cmd/api/main.go
- ✅ 包含时间戳、用户名、手机号
- ⚠️ 日志中不会记录明文密码
### 4. 配置文件安全
### 4. 配置安全
- ⚠️ `config.yaml` 中的密码是明文存储
- ⚠️ 确保配置文件访问权限受限(不要提交到公开仓库
- ⚠️ 生产环境建议使用环境变量或密钥管理服务
- ✅ 配置通过环境变量设置,不存储在代码仓库中
- ⚠️ 确保环境变量安全(使用密钥管理服务或加密存储
- ⚠️ 生产环境务必修改默认密码
## 手动创建管理员(备用方案)
@@ -285,7 +283,8 @@ func main() {
- `pkg/constants/constants.go` - 默认值常量定义
- `pkg/config/config.go` - 配置结构定义
- `configs/config.yaml` - 配置示例
- `pkg/config/defaults/config.yaml` - 嵌入式默认配置
- `docs/environment-variables.md` - 环境变量配置文档
- `internal/service/account/service.go` - CreateSystemAccount 方法
- `internal/bootstrap/admin.go` - initDefaultAdmin 函数
- `internal/bootstrap/bootstrap.go` - Bootstrap 主流程

View File

@@ -148,6 +148,77 @@ components:
description: 总卡数量
type: integer
type: object
DtoAllocateStandaloneCardsRequest:
properties:
batch_no:
description: 批次号selection_type=filter时可选
maxLength: 100
type: string
carrier_id:
description: 运营商IDselection_type=filter时可选
minimum: 0
nullable: true
type: integer
iccid_end:
description: 结束ICCIDselection_type=range时必填
maxLength: 20
type: string
iccid_start:
description: 起始ICCIDselection_type=range时必填
maxLength: 20
type: string
iccids:
description: ICCID列表selection_type=list时必填最多1000个
items:
type: string
nullable: true
type: array
remark:
description: 备注
maxLength: 500
type: string
selection_type:
description: 选卡方式 (list:ICCID列表, range:号段范围, filter:筛选条件)
enum:
- list
- range
- filter
type: string
status:
description: 卡状态 (1:在库, 2:已分销)selection_type=filter时可选
maximum: 4
minimum: 1
nullable: true
type: integer
to_shop_id:
description: 目标店铺ID
minimum: 1
type: integer
required:
- to_shop_id
- selection_type
type: object
DtoAllocateStandaloneCardsResponse:
properties:
allocation_no:
description: 分配单号
type: string
fail_count:
description: 失败数
type: integer
failed_items:
description: 失败项列表
items:
$ref: '#/components/schemas/DtoAllocationFailedItem'
nullable: true
type: array
success_count:
description: 成功数
type: integer
total_count:
description: 待分配总数
type: integer
type: object
DtoAllocatedDevice:
properties:
card_count:
@@ -167,6 +238,15 @@ components:
nullable: true
type: array
type: object
DtoAllocationFailedItem:
properties:
iccid:
description: ICCID
type: string
reason:
description: 失败原因
type: string
type: object
DtoApproveWithdrawalReq:
properties:
account_name:
@@ -198,6 +278,156 @@ components:
required:
- payment_type
type: object
DtoAssetAllocationRecordDetailResponse:
properties:
allocation_name:
description: 分配类型名称
type: string
allocation_no:
description: 分配单号
type: string
allocation_type:
description: 分配类型 (allocate:分配, recall:回收)
type: string
asset_id:
description: 资产ID
minimum: 0
type: integer
asset_identifier:
description: 资产标识符ICCID或设备号
type: string
asset_type:
description: 资产类型 (iot_card:物联网卡, device:设备)
type: string
asset_type_name:
description: 资产类型名称
type: string
created_at:
description: 创建时间
format: date-time
type: string
from_owner_id:
description: 来源所有者ID
minimum: 0
nullable: true
type: integer
from_owner_name:
description: 来源所有者名称
type: string
from_owner_type:
description: 来源所有者类型
type: string
id:
description: 记录ID
minimum: 0
type: integer
operator_id:
description: 操作人ID
minimum: 0
type: integer
operator_name:
description: 操作人名称
type: string
related_card_count:
description: 关联卡数量
type: integer
related_card_ids:
description: 关联卡ID列表
items:
minimum: 0
type: integer
type: array
related_device_id:
description: 关联设备ID
minimum: 0
nullable: true
type: integer
remark:
description: 备注
type: string
to_owner_id:
description: 目标所有者ID
minimum: 0
type: integer
to_owner_name:
description: 目标所有者名称
type: string
to_owner_type:
description: 目标所有者类型
type: string
type: object
DtoAssetAllocationRecordResponse:
properties:
allocation_name:
description: 分配类型名称
type: string
allocation_no:
description: 分配单号
type: string
allocation_type:
description: 分配类型 (allocate:分配, recall:回收)
type: string
asset_id:
description: 资产ID
minimum: 0
type: integer
asset_identifier:
description: 资产标识符ICCID或设备号
type: string
asset_type:
description: 资产类型 (iot_card:物联网卡, device:设备)
type: string
asset_type_name:
description: 资产类型名称
type: string
created_at:
description: 创建时间
format: date-time
type: string
from_owner_id:
description: 来源所有者ID
minimum: 0
nullable: true
type: integer
from_owner_name:
description: 来源所有者名称
type: string
from_owner_type:
description: 来源所有者类型
type: string
id:
description: 记录ID
minimum: 0
type: integer
operator_id:
description: 操作人ID
minimum: 0
type: integer
operator_name:
description: 操作人名称
type: string
related_card_count:
description: 关联卡数量
type: integer
related_device_id:
description: 关联设备ID
minimum: 0
nullable: true
type: integer
remark:
description: 备注
type: string
to_owner_id:
description: 目标所有者ID
minimum: 0
type: integer
to_owner_name:
description: 目标所有者名称
type: string
to_owner_type:
description: 目标所有者类型
type: string
type: object
DtoAssignPermissionsParams:
properties:
perm_ids:
@@ -832,6 +1062,55 @@ components:
description: 失败原因
type: string
type: object
DtoGetUploadURLRequest:
properties:
content_type:
description: 文件 MIME 类型text/csv留空则自动推断
maxLength: 100
type: string
file_name:
description: 文件名cards.csv
maxLength: 255
minLength: 1
type: string
purpose:
description: 文件用途 (iot_import:ICCID导入, export:数据导出, attachment:附件)
type: string
required:
- file_name
- purpose
type: object
DtoGetUploadURLResponse:
properties:
expires_in:
description: URL 有效期(秒)
type: integer
file_key:
description: 文件路径标识,上传成功后用于调用业务接口
type: string
upload_url:
description: 预签名上传 URL使用 PUT 方法上传文件
type: string
type: object
DtoImportIotCardRequest:
properties:
batch_no:
description: 批次号
maxLength: 100
type: string
carrier_id:
description: 运营商ID
minimum: 1
type: integer
file_key:
description: 对象存储文件路径(通过 /storage/upload-url 获取)
maxLength: 500
minLength: 1
type: string
required:
- carrier_id
- file_key
type: object
DtoImportIotCardResponse:
properties:
message:
@@ -853,6 +1132,9 @@ components:
line:
description: 行号
type: integer
msisdn:
description: 接入号
type: string
reason:
description: 原因
type: string
@@ -985,6 +1267,27 @@ components:
description: 总数
type: integer
type: object
DtoListAssetAllocationRecordResponse:
properties:
list:
description: 分配记录列表
items:
$ref: '#/components/schemas/DtoAssetAllocationRecordResponse'
nullable: true
type: array
page:
description: 当前页码
type: integer
page_size:
description: 每页数量
type: integer
total:
description: 总数
type: integer
total_pages:
description: 总页数
type: integer
type: object
DtoListImportTaskResponse:
properties:
list:
@@ -1261,6 +1564,71 @@ components:
description: 成功数量
type: integer
type: object
DtoRecallStandaloneCardsRequest:
properties:
batch_no:
description: 批次号selection_type=filter时可选
maxLength: 100
type: string
carrier_id:
description: 运营商IDselection_type=filter时可选
minimum: 0
nullable: true
type: integer
from_shop_id:
description: 来源店铺ID被回收方
minimum: 1
type: integer
iccid_end:
description: 结束ICCIDselection_type=range时必填
maxLength: 20
type: string
iccid_start:
description: 起始ICCIDselection_type=range时必填
maxLength: 20
type: string
iccids:
description: ICCID列表selection_type=list时必填最多1000个
items:
type: string
nullable: true
type: array
remark:
description: 备注
maxLength: 500
type: string
selection_type:
description: 选卡方式 (list:ICCID列表, range:号段范围, filter:筛选条件)
enum:
- list
- range
- filter
type: string
required:
- from_shop_id
- selection_type
type: object
DtoRecallStandaloneCardsResponse:
properties:
allocation_no:
description: 回收单号
type: string
fail_count:
description: 失败数
type: integer
failed_items:
description: 失败项列表
items:
$ref: '#/components/schemas/DtoAllocationFailedItem'
nullable: true
type: array
success_count:
description: 成功数
type: integer
total_count:
description: 待回收总数
type: integer
type: object
DtoRecalledDevice:
properties:
card_count:
@@ -2297,19 +2665,6 @@ components:
- message
- timestamp
type: object
FormDataDtoImportIotCardRequest:
properties:
batch_no:
description: 批次号
maxLength: 100
type: string
carrier_id:
description: 运营商ID
minimum: 1
type: integer
required:
- carrier_id
type: object
ModelPermission:
properties:
available_for_role_types:
@@ -2779,6 +3134,179 @@ paths:
summary: 分配角色
tags:
- 账号相关
/api/admin/asset-allocation-records:
get:
parameters:
- description: 页码
in: query
name: page
schema:
description: 页码
minimum: 1
type: integer
- description: 每页数量
in: query
name: page_size
schema:
description: 每页数量
maximum: 100
minimum: 1
type: integer
- description: 分配类型 (allocate:分配, recall:回收)
in: query
name: allocation_type
schema:
description: 分配类型 (allocate:分配, recall:回收)
enum:
- allocate
- recall
type: string
- description: 资产类型 (iot_card:物联网卡, device:设备)
in: query
name: asset_type
schema:
description: 资产类型 (iot_card:物联网卡, device:设备)
enum:
- iot_card
- device
type: string
- description: 资产标识符ICCID或设备号模糊查询
in: query
name: asset_identifier
schema:
description: 资产标识符ICCID或设备号模糊查询
maxLength: 50
type: string
- description: 分配单号(精确匹配)
in: query
name: allocation_no
schema:
description: 分配单号(精确匹配)
maxLength: 50
type: string
- description: 来源店铺ID
in: query
name: from_shop_id
schema:
description: 来源店铺ID
minimum: 0
nullable: true
type: integer
- description: 目标店铺ID
in: query
name: to_shop_id
schema:
description: 目标店铺ID
minimum: 0
nullable: true
type: integer
- description: 操作人ID
in: query
name: operator_id
schema:
description: 操作人ID
minimum: 0
nullable: true
type: integer
- description: 创建时间起始
in: query
name: created_at_start
schema:
description: 创建时间起始
format: date-time
nullable: true
type: string
- description: 创建时间结束
in: query
name: created_at_end
schema:
description: 创建时间结束
format: date-time
nullable: true
type: string
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/DtoListAssetAllocationRecordResponse'
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 分配记录列表
tags:
- 资产分配记录
/api/admin/asset-allocation-records/{id}:
get:
parameters:
- description: 记录ID
in: path
name: id
required: true
schema:
description: 记录ID
minimum: 1
type: integer
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/DtoAssetAllocationRecordDetailResponse'
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 分配记录详情
tags:
- 资产分配记录
/api/admin/commission/withdrawal-requests:
get:
parameters:
@@ -4006,27 +4534,37 @@ paths:
- 企业客户管理
/api/admin/iot-cards/import:
post:
parameters:
- description: 运营商ID
in: query
name: carrier_id
required: true
schema:
description: 运营商ID
minimum: 1
type: integer
- description: 批次号
in: query
name: batch_no
schema:
description: 批次号
maxLength: 100
type: string
description: |-
## ⚠️ 接口变更说明BREAKING CHANGE
本接口已从 `multipart/form-data` 改为 `application/json`。
### 完整导入流程
1. **获取上传 URL**: 调用 `POST /api/admin/storage/upload-url`
2. **上传 CSV 文件**: 使用预签名 URL 上传文件到对象存储
3. **调用本接口**: 使用返回的 `file_key` 提交导入任务
### 请求示例
```json
{
"carrier_id": 1,
"batch_no": "BATCH-2025-01",
"file_key": "imports/2025/01/24/abc123.csv"
}
```
### CSV 文件格式
- 必须包含两列:`iccid`, `msisdn`
- 首行为表头
- 编码UTF-8
requestBody:
content:
application/x-www-form-urlencoded:
application/json:
schema:
$ref: '#/components/schemas/FormDataDtoImportIotCardRequest'
$ref: '#/components/schemas/DtoImportIotCardRequest'
responses:
"200":
content:
@@ -4060,7 +4598,7 @@ paths:
description: 服务器内部错误
security:
- BearerAuth: []
summary: 批量导入ICCID
summary: 批量导入IoT卡ICCID+MSISDN
tags:
- IoT卡管理
/api/admin/iot-cards/import-tasks:
@@ -4340,6 +4878,92 @@ paths:
summary: 单卡列表(未绑定设备)
tags:
- IoT卡管理
/api/admin/iot-cards/standalone/allocate:
post:
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DtoAllocateStandaloneCardsRequest'
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/DtoAllocateStandaloneCardsResponse'
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 批量分配单卡
tags:
- IoT卡管理
/api/admin/iot-cards/standalone/recall:
post:
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DtoRecallStandaloneCardsRequest'
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/DtoRecallStandaloneCardsResponse'
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 批量回收单卡
tags:
- IoT卡管理
/api/admin/login:
post:
requestBody:
@@ -6740,6 +7364,99 @@ paths:
summary: 代理商佣金列表
tags:
- 代理商佣金管理
/api/admin/storage/upload-url:
post:
description: |-
## 文件上传流程
本接口用于获取对象存储的预签名上传 URL实现前端直传文件到对象存储。
### 完整流程
1. **调用本接口** 获取预签名 URL 和 file_key
2. **使用预签名 URL 上传文件** 发起 PUT 请求直接上传到对象存储
3. **调用业务接口** 使用 file_key 调用相关业务接口(如 ICCID 导入)
### 前端上传示例
```javascript
// 1. 获取预签名 URL
const { data } = await api.post('/storage/upload-url', {
file_name: 'cards.csv',
content_type: 'text/csv',
purpose: 'iot_import'
});
// 2. 上传文件到对象存储
await fetch(data.upload_url, {
method: 'PUT',
headers: { 'Content-Type': 'text/csv' },
body: file
});
// 3. 调用业务接口
await api.post('/iot-cards/import', {
carrier_id: 1,
batch_no: 'BATCH-2025-01',
file_key: data.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 |
### 注意事项
- 预签名 URL 有效期 **15 分钟**,请及时使用
- 上传时 Content-Type 需与请求时一致
- file_key 在上传成功后永久有效,用于后续业务接口调用
- 上传失败时可重新调用本接口获取新的 URL
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DtoGetUploadURLRequest'
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/DtoGetUploadURLResponse'
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 获取文件上传预签名 URL
tags:
- 对象存储
/api/admin/tasks/{id}:
get:
parameters:

View File

@@ -173,15 +173,65 @@ func registerXxxRoutes(
```go
type RouteSpec struct {
Summary string // 操作摘要(中文,简短)
Input interface{} // 请求参数 DTO
Output interface{} // 响应结果 DTO
Tags []string // 分类标签(用于文档分组)
Auth bool // 是否需要认证
Summary string // 操作摘要(中文,简短,一行
Description string // 详细说明,支持 Markdown 语法(可选)
Input interface{} // 请求参数 DTO
Output interface{} // 响应结果 DTO
Tags []string // 分类标签(用于文档分组)
Auth bool // 是否需要认证
}
```
### 4. 完整示例
### 4. Description 字段Markdown 说明)
`Description` 字段用于添加接口的详细说明,支持 **CommonMark Markdown** 语法。Apifox 等 OpenAPI 工具会正确渲染这些 Markdown 内容。
**使用场景**
- 业务规则说明
- 请求频率限制
- 注意事项
- 错误码说明
- 数据格式说明
**示例**
```go
Register(router, doc, basePath, "POST", "/login", handler.Login, RouteSpec{
Summary: "后台登录",
Description: `## 登录说明
**请求频率限制**:每分钟最多 10 次
### 注意事项
1. 密码错误 5 次后账号将被锁定 30 分钟
2. Token 有效期为 24 小时
### 返回码说明
| 错误码 | 说明 |
|--------|------|
| 1001 | 用户名或密码错误 |
| 1002 | 账号已被锁定 |
`,
Tags: []string{"认证"},
Input: new(dto.LoginRequest),
Output: new(dto.LoginResponse),
Auth: false,
})
```
**支持的 Markdown 语法**
- 标题:`#``##``###`
- 列表:`-``1.`
- 表格:`| 列1 | 列2 |`
- 代码:`` `code` `` 和 ` ```code block``` `
- 强调:`**粗体**``*斜体*`
- 链接:`[文本](url)`
**最佳实践**
- 保持简洁,控制在 500 字以内
- 使用结构化的 Markdown标题、列表、表格提高可读性
- 避免使用 HTML 标签(兼容性较差)
### 5. 完整示例
```go
func registerShopRoutes(router fiber.Router, handler *admin.ShopHandler, doc *openapi.Generator, basePath string) {

View File

@@ -30,16 +30,18 @@
### 配置项
`configs/config.yaml`配置 Token 有效期:
通过环境变量配置 Token 有效期:
```yaml
jwt:
secret_key: "your-secret-key-here"
token_duration: 3600 # JWT 有效期(个人客户,秒)
access_token_ttl: 86400 # Access Token 有效期B端,秒)
refresh_token_ttl: 604800 # Refresh Token 有效期B端秒)
```bash
# JWT 配置
export JUNHONG_JWT_SECRET_KEY="your-secret-key-here"
export JUNHONG_JWT_TOKEN_DURATION="24h" # JWT 有效期(个人客户
export JUNHONG_JWT_ACCESS_TOKEN_TTL="24h" # Access Token 有效期B端
export JUNHONG_JWT_REFRESH_TOKEN_TTL="168h" # Refresh Token 有效期B端7天
```
详细配置说明见 [环境变量配置文档](environment-variables.md)
---
## 在路由中集成认证

View File

@@ -44,7 +44,7 @@
│ │ │
│ ┌───────────▼─────────────────────┐ │
│ │ 服务运行中 │ │
│ │ - API: 0.0.0.0:8088 │ │
│ │ - API: 0.0.0.0:3000 │ │
│ │ - Worker: 后台任务处理 │ │
│ └──────────────────────────────────┘ │
└───────────────────────────────────────┘
@@ -98,81 +98,34 @@ docker ps | grep runner
mkdir -p /home/qycard001/app/junhong_cmp
cd /home/qycard001/app/junhong_cmp
# 创建必要的子目录
mkdir -p configs logs
# 创建日志目录(配置已嵌入二进制文件,无需 configs 目录)
mkdir -p logs
```
### 1.3 准备配置文件
### 1.3 配置说明
#### 创建 `.env` 文件(数据库迁移配置)
系统使用**嵌入式配置 + 环境变量覆盖**机制:
- 默认配置已编译在二进制文件中
- 通过 `docker-compose.prod.yml` 中的环境变量覆盖配置
- 环境变量前缀:`JUNHONG_`
- 格式:`JUNHONG_{配置路径}`,路径分隔符用下划线替代点号
**无需手动创建配置文件**,所有配置在 `docker-compose.prod.yml``environment` 中管理。
### 1.4 部署文件
`docker-compose.prod.yml` 由 CI/CD 自动从代码仓库复制到部署目录,无需手动操作。
如需手动部署,可从代码仓库复制:
```bash
cat > /home/qycard001/app/junhong_cmp/.env << 'EOF'
MIGRATIONS_DIR=migrations
DB_HOST=cxd.whcxd.cn
DB_PORT=16159
DB_USER=erp_pgsql
DB_PASSWORD=erp_2025
DB_NAME=junhong_cmp_test
DB_SSLMODE=disable
EOF
```
#### 创建 `configs/config.yaml`(应用配置)
```bash
cat > /home/qycard001/app/junhong_cmp/configs/config.yaml << 'EOF'
server:
port: 8088
read_timeout: 60
write_timeout: 60
database:
host: cxd.whcxd.cn
port: 16159
user: erp_pgsql
password: erp_2025
dbname: junhong_cmp_test
sslmode: disable
max_open_conns: 100
max_idle_conns: 10
redis:
host: 你的Redis地址
port: 6379
password: ""
db: 0
logging:
level: info
output: logs/app.log
max_size: 100
max_backups: 7
max_age: 30
compress: true
middleware:
enable_rate_limiter: false
EOF
```
**重要**:将 `你的Redis地址` 替换为实际的 Redis 地址。
### 1.4 复制部署文件
从代码仓库复制 `docker-compose.prod.yml` 到服务器:
```bash
# 在服务器上执行
cd /home/qycard001/app/junhong_cmp
# 方式1: 使用 Git推荐
# 方式1: 使用 Git
git clone <你的仓库地址> temp
cp temp/docker-compose.prod.yml ./docker-compose.prod.yml
rm -rf temp
# 方式2: 从本地上传
# 在本地执行:
# scp -P 52022 docker-compose.prod.yml qycard001@47.111.166.169:/home/qycard001/app/junhong_cmp/
```
@@ -251,7 +204,7 @@ docker-compose -f docker-compose.prod.yml logs -f
```bash
# 测试 API 健康检查
curl http://localhost:8088/health
curl http://localhost:3000/health
# 预期输出:
# {"code":0,"msg":"ok","data":{"status":"healthy"},"timestamp":1234567890}
@@ -397,9 +350,9 @@ docker system prune -a -f --volumes
**排查步骤**
1. 查看容器日志:`docker-compose -f docker-compose.prod.yml logs api`
2. 检查配置文件是否正确数据库连接、Redis 连接)
2. 检查 `docker-compose.prod.yml` 中的环境变量配置是否正确数据库连接、Redis 连接)
3. 确认外部依赖PostgreSQL、Redis是否可访问
4. 手动测试健康检查:`curl http://localhost:8088/health`
4. 手动测试健康检查:`curl http://localhost:3000/health`
### Q2: 数据库迁移失败
@@ -470,7 +423,7 @@ docker restart docker-runner-01
```bash
# 仅开放必要端口
sudo ufw allow 52022/tcp # SSH
sudo ufw allow 8088/tcp # API如果需要外部访问
sudo ufw allow 3000/tcp # API如果需要外部访问
sudo ufw enable
```
@@ -480,7 +433,7 @@ docker restart docker-runner-01
4. **备份策略**
- 定期备份数据库
- 定期备份配置文件(`.env`、`config.yaml`
- 定期备份 `docker-compose.prod.yml`(包含所有配置
---

View File

@@ -0,0 +1,212 @@
# 环境变量配置文档
## 概述
君鸿卡管系统使用嵌入式配置机制,默认配置编译在二进制文件中,通过环境变量进行覆盖。
**环境变量前缀**: `JUNHONG_`
**格式规则**: 配置路径中的 `.` 替换为 `_`,全部大写
## 必填配置
以下配置没有合理的默认值,必须通过环境变量设置:
### 数据库配置
| 环境变量 | 说明 | 示例 |
|---------|------|------|
| `JUNHONG_DATABASE_HOST` | 数据库主机地址 | `localhost` |
| `JUNHONG_DATABASE_PORT` | 数据库端口 | `5432` |
| `JUNHONG_DATABASE_USER` | 数据库用户名 | `postgres` |
| `JUNHONG_DATABASE_PASSWORD` | 数据库密码 | `your_password` |
| `JUNHONG_DATABASE_DBNAME` | 数据库名称 | `junhong_cmp` |
### Redis 配置
| 环境变量 | 说明 | 示例 |
|---------|------|------|
| `JUNHONG_REDIS_ADDRESS` | Redis 主机地址 | `localhost` |
### JWT 配置
| 环境变量 | 说明 | 示例 |
|---------|------|------|
| `JUNHONG_JWT_SECRET_KEY` | JWT 签名密钥(生产环境必须修改) | `your-secret-key` |
## 可选配置
以下配置有合理的默认值,可按需覆盖:
### 服务器配置
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_SERVER_ADDRESS` | `:3000` | 服务监听地址 |
| `JUNHONG_SERVER_READ_TIMEOUT` | `30s` | 读取超时时间 |
| `JUNHONG_SERVER_WRITE_TIMEOUT` | `30s` | 写入超时时间 |
| `JUNHONG_SERVER_SHUTDOWN_TIMEOUT` | `30s` | 优雅关闭超时 |
| `JUNHONG_SERVER_PREFORK` | `false` | 是否启用预分叉模式 |
### 数据库连接池
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_DATABASE_SSLMODE` | `disable` | SSL 模式 |
| `JUNHONG_DATABASE_MAX_OPEN_CONNS` | `25` | 最大打开连接数 |
| `JUNHONG_DATABASE_MAX_IDLE_CONNS` | `10` | 最大空闲连接数 |
| `JUNHONG_DATABASE_CONN_MAX_LIFETIME` | `1h` | 连接最大生命周期 |
### Redis 配置
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_REDIS_PORT` | `6379` | Redis 端口 |
| `JUNHONG_REDIS_PASSWORD` | `""` | Redis 密码 |
| `JUNHONG_REDIS_DB` | `0` | Redis 数据库编号 |
| `JUNHONG_REDIS_POOL_SIZE` | `100` | 连接池大小 |
| `JUNHONG_REDIS_MIN_IDLE_CONNS` | `10` | 最小空闲连接数 |
| `JUNHONG_REDIS_DIAL_TIMEOUT` | `5s` | 连接超时 |
| `JUNHONG_REDIS_READ_TIMEOUT` | `3s` | 读取超时 |
| `JUNHONG_REDIS_WRITE_TIMEOUT` | `3s` | 写入超时 |
### 日志配置
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_LOGGING_LEVEL` | `info` | 日志级别 (debug/info/warn/error) |
| `JUNHONG_LOGGING_DEVELOPMENT` | `false` | 开发模式(启用彩色输出) |
| `JUNHONG_LOGGING_APP_LOG_FILENAME` | `logs/app.log` | 应用日志文件路径 |
| `JUNHONG_LOGGING_APP_LOG_MAX_SIZE` | `100` | 日志文件最大大小 (MB) |
| `JUNHONG_LOGGING_APP_LOG_MAX_BACKUPS` | `7` | 最大备份文件数 |
| `JUNHONG_LOGGING_APP_LOG_MAX_AGE` | `30` | 日志保留天数 |
| `JUNHONG_LOGGING_APP_LOG_COMPRESS` | `true` | 是否压缩旧日志 |
| `JUNHONG_LOGGING_ACCESS_LOG_FILENAME` | `logs/access.log` | 访问日志文件路径 |
### JWT 配置
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_JWT_TOKEN_DURATION` | `24h` | Token 有效期 |
| `JUNHONG_JWT_ACCESS_TOKEN_TTL` | `24h` | Access Token TTL |
| `JUNHONG_JWT_REFRESH_TOKEN_TTL` | `168h` | Refresh Token TTL (7天) |
### 队列配置
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_QUEUE_CONCURRENCY` | `10` | 并发 Worker 数量 |
| `JUNHONG_QUEUE_RETRY_MAX` | `3` | 最大重试次数 |
| `JUNHONG_QUEUE_TIMEOUT` | `30m` | 任务超时时间 |
### 限流中间件
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_MIDDLEWARE_ENABLE_RATE_LIMITER` | `false` | 启用限流 |
| `JUNHONG_MIDDLEWARE_RATE_LIMITER_MAX` | `100` | 最大请求数 |
| `JUNHONG_MIDDLEWARE_RATE_LIMITER_EXPIRATION` | `1m` | 时间窗口 |
| `JUNHONG_MIDDLEWARE_RATE_LIMITER_STORAGE` | `memory` | 存储后端 (memory/redis) |
### 对象存储配置
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_STORAGE_PROVIDER` | `""` | 存储提供商 (s3) |
| `JUNHONG_STORAGE_TEMP_DIR` | `/tmp/junhong` | 临时文件目录 |
| `JUNHONG_STORAGE_S3_ENDPOINT` | `""` | S3 端点 |
| `JUNHONG_STORAGE_S3_REGION` | `""` | S3 区域 |
| `JUNHONG_STORAGE_S3_BUCKET` | `""` | S3 存储桶 |
| `JUNHONG_STORAGE_S3_ACCESS_KEY_ID` | `""` | S3 访问密钥 ID |
| `JUNHONG_STORAGE_S3_SECRET_ACCESS_KEY` | `""` | S3 访问密钥 |
| `JUNHONG_STORAGE_S3_USE_SSL` | `true` | 是否使用 SSL |
| `JUNHONG_STORAGE_S3_PATH_STYLE` | `true` | 是否使用路径风格 |
| `JUNHONG_STORAGE_PRESIGN_UPLOAD_EXPIRES` | `1h` | 预签名上传 URL 有效期 |
| `JUNHONG_STORAGE_PRESIGN_DOWNLOAD_EXPIRES` | `1h` | 预签名下载 URL 有效期 |
### 短信配置
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_SMS_GATEWAY_URL` | `""` | 短信网关 URL |
| `JUNHONG_SMS_USERNAME` | `""` | 短信账号 |
| `JUNHONG_SMS_PASSWORD` | `""` | 短信密码 |
| `JUNHONG_SMS_SIGNATURE` | `""` | 短信签名 |
| `JUNHONG_SMS_TIMEOUT` | `10s` | 请求超时 |
### 默认管理员
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_DEFAULT_ADMIN_USERNAME` | `admin` | 默认管理员用户名 |
| `JUNHONG_DEFAULT_ADMIN_PASSWORD` | `Admin@123456` | 默认管理员密码 |
| `JUNHONG_DEFAULT_ADMIN_PHONE` | `13800000000` | 默认管理员手机号 |
## Docker Compose 示例
```yaml
version: '3.8'
services:
api:
image: registry.boss160.cn/junhong/cmp-fiber-api:latest
environment:
- JUNHONG_DATABASE_HOST=postgres
- JUNHONG_DATABASE_PORT=5432
- JUNHONG_DATABASE_USER=junhong
- JUNHONG_DATABASE_PASSWORD=secret123
- JUNHONG_DATABASE_DBNAME=junhong_cmp
- JUNHONG_REDIS_ADDRESS=redis
- JUNHONG_JWT_SECRET_KEY=your-production-secret-key
- JUNHONG_LOGGING_LEVEL=info
volumes:
- ./logs:/app/logs
ports:
- "3000:3000"
worker:
image: registry.boss160.cn/junhong/cmp-fiber-worker:latest
environment:
- JUNHONG_DATABASE_HOST=postgres
- JUNHONG_DATABASE_PORT=5432
- JUNHONG_DATABASE_USER=junhong
- JUNHONG_DATABASE_PASSWORD=secret123
- JUNHONG_DATABASE_DBNAME=junhong_cmp
- JUNHONG_REDIS_ADDRESS=redis
- JUNHONG_JWT_SECRET_KEY=your-production-secret-key
volumes:
- ./logs:/app/logs
postgres:
image: postgres:14
environment:
- POSTGRES_USER=junhong
- POSTGRES_PASSWORD=secret123
- POSTGRES_DB=junhong_cmp
redis:
image: redis:6
```
## 本地开发
本地开发可以创建 `.env` 文件(不要提交到 Git
```bash
# .env
JUNHONG_DATABASE_HOST=localhost
JUNHONG_DATABASE_PORT=5432
JUNHONG_DATABASE_USER=postgres
JUNHONG_DATABASE_PASSWORD=postgres
JUNHONG_DATABASE_DBNAME=junhong_cmp_dev
JUNHONG_REDIS_ADDRESS=localhost
JUNHONG_JWT_SECRET_KEY=dev-secret-key
JUNHONG_LOGGING_LEVEL=debug
JUNHONG_LOGGING_DEVELOPMENT=true
```
然后使用 `source .env` 加载环境变量后运行:
```bash
source .env
go run cmd/api/main.go
```

View File

@@ -0,0 +1,163 @@
# 对象存储使用指南
本文档介绍如何在后端代码中使用对象存储服务。
## 配置
通过环境变量配置对象存储:
```bash
# 存储提供商
export JUNHONG_STORAGE_PROVIDER="s3"
# S3 配置
export JUNHONG_STORAGE_S3_ENDPOINT="http://obs-helf.cucloud.cn"
export JUNHONG_STORAGE_S3_REGION="cn-langfang-2"
export JUNHONG_STORAGE_S3_BUCKET="cmp"
export JUNHONG_STORAGE_S3_ACCESS_KEY_ID="YOUR_ACCESS_KEY"
export JUNHONG_STORAGE_S3_SECRET_ACCESS_KEY="YOUR_SECRET_KEY"
export JUNHONG_STORAGE_S3_USE_SSL="false"
export JUNHONG_STORAGE_S3_PATH_STYLE="true"
# 预签名 URL 配置
export JUNHONG_STORAGE_PRESIGN_UPLOAD_EXPIRES="15m"
export JUNHONG_STORAGE_PRESIGN_DOWNLOAD_EXPIRES="24h"
# 临时文件目录
export JUNHONG_STORAGE_TEMP_DIR="/tmp/junhong-storage"
```
详细配置说明见 [环境变量配置文档](../environment-variables.md)
## StorageService 使用
### 获取预签名上传 URL
```go
result, err := storageService.GetUploadURL(ctx, "iot_import", "cards.csv", "text/csv")
if err != nil {
return err
}
// result.URL - 预签名上传 URL
// result.FileKey - 文件路径(用于后续业务接口)
// result.ExpiresIn - URL 有效期(秒)
```
### 下载文件到临时目录
```go
localPath, cleanup, err := storageService.DownloadToTemp(ctx, fileKey)
if err != nil {
return err
}
defer cleanup() // 处理完成后自动删除临时文件
// 使用 localPath 读取文件内容
f, _ := os.Open(localPath)
defer f.Close()
```
### 直接上传文件
```go
reader := bytes.NewReader(content)
err := storageService.Provider().Upload(ctx, fileKey, reader, "text/csv")
```
### 检查文件是否存在
```go
exists, err := storageService.Provider().Exists(ctx, fileKey)
```
### 删除文件
```go
err := storageService.Provider().Delete(ctx, fileKey)
```
## Purpose 类型
| Purpose | 说明 | 生成路径 | ContentType |
|---------|------|---------|-------------|
| iot_import | ICCID 导入 | imports/YYYY/MM/DD/uuid.csv | text/csv |
| export | 数据导出 | exports/YYYY/MM/DD/uuid.xlsx | application/vnd.openxmlformats... |
| attachment | 附件上传 | attachments/YYYY/MM/DD/uuid.ext | 自动检测 |
## 错误处理
存储相关错误码定义在 `pkg/errors/codes.go`
| 错误码 | 说明 |
|-------|------|
| 1090 | 对象存储服务未配置 |
| 1091 | 文件上传失败 |
| 1092 | 文件下载失败 |
| 1093 | 文件不存在 |
| 1094 | 不支持的文件用途 |
| 1095 | 不支持的文件类型 |
## 在 Handler 中使用
```go
type MyHandler struct {
storageService *storage.Service
}
func (h *MyHandler) Upload(c *fiber.Ctx) error {
var req dto.GetUploadURLRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "参数解析失败")
}
result, err := h.storageService.GetUploadURL(
c.UserContext(),
req.Purpose,
req.FileName,
req.ContentType,
)
if err != nil {
return errors.New(errors.CodeStorageUploadFailed, err.Error())
}
return response.Success(c, result)
}
```
## 在 Worker 中使用
```go
func (h *TaskHandler) HandleTask(ctx context.Context, task *asynq.Task) error {
// 从任务记录获取文件路径
fileKey := importTask.StorageKey
// 下载到临时文件
localPath, cleanup, err := h.storageService.DownloadToTemp(ctx, fileKey)
if err != nil {
return err
}
defer cleanup()
// 解析文件
f, _ := os.Open(localPath)
defer f.Close()
// 处理文件内容...
}
```
## 测试验证
运行对象存储功能测试:
```bash
go run scripts/test_storage.go
```
测试内容包括:
1. 生成预签名上传 URL
2. 上传测试文件
3. 检查文件是否存在
4. 下载到临时文件
5. 删除测试文件

View File

@@ -0,0 +1,250 @@
# 对象存储前端接入指南
## 文件上传流程
```
前端 后端 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. 返回任务创建成功 │
│ ◄───────────────────────── │
```
## 获取预签名 URL 接口
### 请求
```http
POST /api/admin/storage/upload-url
Content-Type: application/json
Authorization: Bearer {token}
{
"file_name": "cards.csv",
"content_type": "text/csv",
"purpose": "iot_import"
}
```
### 响应
```json
{
"code": 0,
"message": "成功",
"data": {
"upload_url": "http://obs-helf.cucloud.cn/cmp/imports/2025/01/24/abc123.csv?X-Amz-Algorithm=...",
"file_key": "imports/2025/01/24/abc123.csv",
"expires_in": 900
}
}
```
### purpose 可选值
| 值 | 说明 | 生成路径 |
|---|------|---------|
| iot_import | ICCID 导入 | imports/YYYY/MM/DD/uuid.csv |
| export | 数据导出 | exports/YYYY/MM/DD/uuid.xlsx |
| attachment | 附件上传 | attachments/YYYY/MM/DD/uuid.ext |
## 使用预签名 URL 上传文件
获取到 `upload_url` 后,直接使用 PUT 请求上传文件到对象存储:
```javascript
const response = await fetch(upload_url, {
method: 'PUT',
headers: {
'Content-Type': content_type
},
body: file
});
if (response.ok) {
console.log('上传成功');
} else {
console.error('上传失败:', response.status);
}
```
## ICCID 导入接口变更BREAKING CHANGE
### 变更前
```http
POST /api/admin/iot-cards/import
Content-Type: multipart/form-data
carrier_id=1
batch_no=BATCH-2025-01
file=@cards.csv
```
### 变更后
```http
POST /api/admin/iot-cards/import
Content-Type: application/json
Authorization: Bearer {token}
{
"carrier_id": 1,
"batch_no": "BATCH-2025-01",
"file_key": "imports/2025/01/24/abc123.csv"
}
```
## 完整代码示例TypeScript
```typescript
interface UploadURLResponse {
upload_url: string;
file_key: string;
expires_in: number;
}
async function uploadAndImportCards(
file: File,
carrierId: number,
batchNo: string
): Promise<void> {
// 1. 获取预签名上传 URL
const urlResponse = await fetch('/api/admin/storage/upload-url', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`
},
body: JSON.stringify({
file_name: file.name,
content_type: file.type || 'text/csv',
purpose: 'iot_import'
})
});
if (!urlResponse.ok) {
throw new Error('获取上传 URL 失败');
}
const { data } = await urlResponse.json();
const { upload_url, file_key } = data as UploadURLResponse;
// 2. 上传文件到对象存储
const uploadResponse = await fetch(upload_url, {
method: 'PUT',
headers: {
'Content-Type': file.type || 'text/csv'
},
body: file
});
if (!uploadResponse.ok) {
throw new Error('文件上传失败');
}
// 3. 调用导入接口
const importResponse = await fetch('/api/admin/iot-cards/import', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`
},
body: JSON.stringify({
carrier_id: carrierId,
batch_no: batchNo,
file_key: file_key
})
});
if (!importResponse.ok) {
throw new Error('导入任务创建失败');
}
console.log('导入任务已创建');
}
```
## 错误处理和重试策略
### 预签名 URL 过期
预签名 URL 有效期为 15 分钟。如果上传时 URL 已过期,需要重新获取:
```typescript
async function uploadWithRetry(file: File, purpose: string, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
const { upload_url, file_key } = await getUploadURL(file.name, file.type, purpose);
try {
await uploadFile(upload_url, file);
return file_key;
} catch (error) {
if (i === maxRetries - 1) throw error;
console.warn(`上传失败,重试 ${i + 1}/${maxRetries}`);
}
}
}
```
### 网络错误
对象存储上传可能因网络问题失败,建议实现重试机制:
```typescript
async function uploadFile(url: string, file: File, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file
});
if (response.ok) return;
if (response.status >= 500) {
// 服务端错误,可重试
continue;
}
throw new Error(`上传失败: ${response.status}`);
} catch (error) {
if (i === retries - 1) throw error;
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
}
}
}
```
## 常见问题
### Q: 上传时报 CORS 错误
确保对象存储已配置 CORS 规则允许前端域名访问。
### Q: 预签名 URL 无法使用
1. 检查 URL 是否过期15 分钟有效期)
2. 确保 Content-Type 与获取 URL 时指定的一致
3. 检查文件大小是否超过限制
### Q: file_key 可以重复使用吗
可以。file_key 一旦上传成功就永久有效,可以在多个业务接口中使用同一个 file_key。