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

@@ -2,33 +2,49 @@ package utils
import (
"encoding/csv"
"errors"
"io"
"strings"
)
// CardInfo 卡信息ICCID + MSISDN
type CardInfo struct {
ICCID string
MSISDN string
}
// CSVParseResult CSV 解析结果
type CSVParseResult struct {
ICCIDs []string
Cards []CardInfo
TotalCount int
ParseErrors []CSVParseError
}
// CSVParseError CSV 解析错误
type CSVParseError struct {
Line int
ICCID string
MSISDN string
Reason string
}
func ParseICCIDFromCSV(reader io.Reader) (*CSVParseResult, error) {
// ErrInvalidCSVFormat CSV 格式错误
var ErrInvalidCSVFormat = errors.New("CSV 文件格式错误:缺少 MSISDN 列,文件必须包含 ICCID 和 MSISDN 两列")
// ParseCardCSV 解析包含 ICCID 和 MSISDN 两列的 CSV 文件
func ParseCardCSV(reader io.Reader) (*CSVParseResult, error) {
csvReader := csv.NewReader(reader)
csvReader.FieldsPerRecord = -1
csvReader.TrimLeadingSpace = true
result := &CSVParseResult{
ICCIDs: make([]string, 0),
Cards: make([]CardInfo, 0),
ParseErrors: make([]CSVParseError, 0),
}
lineNum := 0
headerSkipped := false
for {
record, err := csvReader.Read()
if err == io.EOF {
@@ -48,23 +64,69 @@ func ParseICCIDFromCSV(reader io.Reader) (*CSVParseResult, error) {
continue
}
iccid := strings.TrimSpace(record[0])
if iccid == "" {
if len(record) < 2 {
if lineNum == 1 && !headerSkipped {
firstCol := strings.TrimSpace(record[0])
if isICCIDHeader(firstCol) {
return nil, ErrInvalidCSVFormat
}
}
result.ParseErrors = append(result.ParseErrors, CSVParseError{
Line: lineNum,
ICCID: strings.TrimSpace(record[0]),
Reason: "列数不足:缺少 MSISDN 列",
})
result.TotalCount++
continue
}
if lineNum == 1 && isHeader(iccid) {
iccid := strings.TrimSpace(record[0])
msisdn := strings.TrimSpace(record[1])
if lineNum == 1 && !headerSkipped && isHeader(iccid, msisdn) {
headerSkipped = true
continue
}
result.TotalCount++
result.ICCIDs = append(result.ICCIDs, iccid)
if iccid == "" {
result.ParseErrors = append(result.ParseErrors, CSVParseError{
Line: lineNum,
MSISDN: msisdn,
Reason: "ICCID 不能为空",
})
continue
}
if msisdn == "" {
result.ParseErrors = append(result.ParseErrors, CSVParseError{
Line: lineNum,
ICCID: iccid,
Reason: "MSISDN 不能为空",
})
continue
}
result.Cards = append(result.Cards, CardInfo{
ICCID: iccid,
MSISDN: msisdn,
})
}
return result, nil
}
func isHeader(value string) bool {
func isICCIDHeader(value string) bool {
lower := strings.ToLower(value)
return lower == "iccid" || lower == "卡号" || lower == "号码"
}
func isMSISDNHeader(value string) bool {
lower := strings.ToLower(value)
return lower == "msisdn" || lower == "接入号" || lower == "手机号" || lower == "电话" || lower == "号码"
}
func isHeader(col1, col2 string) bool {
return isICCIDHeader(col1) && isMSISDNHeader(col2)
}

View File

@@ -8,89 +8,133 @@ import (
"github.com/stretchr/testify/require"
)
func TestParseICCIDFromCSV(t *testing.T) {
func TestParseCardCSV(t *testing.T) {
tests := []struct {
name string
csvContent string
wantICCIDs []string
wantCards []CardInfo
wantTotalCount int
wantErrorCount int
wantError error
}{
{
name: "单列ICCID无表头",
csvContent: "89860012345678901234\n89860012345678901235\n89860012345678901236",
wantICCIDs: []string{"89860012345678901234", "89860012345678901235", "89860012345678901236"},
wantTotalCount: 3,
wantErrorCount: 0,
},
{
name: "单列ICCID有表头-iccid",
csvContent: "iccid\n89860012345678901234\n89860012345678901235",
wantICCIDs: []string{"89860012345678901234", "89860012345678901235"},
name: "标准双列无表头",
csvContent: "89860012345678901234,13800000001\n89860012345678901235,13800000002",
wantCards: []CardInfo{
{ICCID: "89860012345678901234", MSISDN: "13800000001"},
{ICCID: "89860012345678901235", MSISDN: "13800000002"},
},
wantTotalCount: 2,
wantErrorCount: 0,
},
{
name: "单列ICCID有表头-ICCID大写",
csvContent: "ICCID\n89860012345678901234",
wantICCIDs: []string{"89860012345678901234"},
name: "标准双列有表头-英文",
csvContent: "iccid,msisdn\n89860012345678901234,13800000001\n89860012345678901235,13800000002",
wantCards: []CardInfo{
{ICCID: "89860012345678901234", MSISDN: "13800000001"},
{ICCID: "89860012345678901235", MSISDN: "13800000002"},
},
wantTotalCount: 2,
wantErrorCount: 0,
},
{
name: "标准双列有表头-中文",
csvContent: "卡号,接入号\n89860012345678901234,13800000001",
wantCards: []CardInfo{
{ICCID: "89860012345678901234", MSISDN: "13800000001"},
},
wantTotalCount: 1,
wantErrorCount: 0,
},
{
name: "单列ICCID有表头-号",
csvContent: "卡号\n89860012345678901234",
wantICCIDs: []string{"89860012345678901234"},
name: "标准双列有表头-手机号",
csvContent: "ICCID,手机号\n89860012345678901234,13800000001",
wantCards: []CardInfo{
{ICCID: "89860012345678901234", MSISDN: "13800000001"},
},
wantTotalCount: 1,
wantErrorCount: 0,
},
{
name: "单列ICCID有表头-号码",
csvContent: "号码\n89860012345678901234",
wantICCIDs: []string{"89860012345678901234"},
wantTotalCount: 1,
name: "单列CSV格式拒绝-有表头",
csvContent: "iccid\n89860012345678901234",
wantCards: nil,
wantTotalCount: 0,
wantErrorCount: 0,
wantError: ErrInvalidCSVFormat,
},
{
name: "单列CSV格式-无表头记录错误",
csvContent: "89860012345678901234\n89860012345678901235",
wantCards: []CardInfo{},
wantTotalCount: 2,
wantErrorCount: 2,
},
{
name: "MSISDN为空记录失败",
csvContent: "iccid,msisdn\n89860012345678901234,13800000001\n89860012345678901235,",
wantCards: []CardInfo{{ICCID: "89860012345678901234", MSISDN: "13800000001"}},
wantTotalCount: 2,
wantErrorCount: 1,
},
{
name: "ICCID为空记录失败",
csvContent: "iccid,msisdn\n89860012345678901234,13800000001\n,13800000002",
wantCards: []CardInfo{{ICCID: "89860012345678901234", MSISDN: "13800000001"}},
wantTotalCount: 2,
wantErrorCount: 1,
},
{
name: "空文件",
csvContent: "",
wantICCIDs: []string{},
wantCards: []CardInfo{},
wantTotalCount: 0,
wantErrorCount: 0,
},
{
name: "只有表头",
csvContent: "iccid",
wantICCIDs: []string{},
csvContent: "iccid,msisdn",
wantCards: []CardInfo{},
wantTotalCount: 0,
wantErrorCount: 0,
},
{
name: "包含空行",
csvContent: "89860012345678901234\n\n89860012345678901235\n \n89860012345678901236",
wantICCIDs: []string{"89860012345678901234", "89860012345678901235", "89860012345678901236"},
wantTotalCount: 3,
wantErrorCount: 0,
},
{
name: "ICCID前后有空格",
csvContent: " 89860012345678901234 \n89860012345678901235",
wantICCIDs: []string{"89860012345678901234", "89860012345678901235"},
name: "包含空行",
csvContent: "89860012345678901234,13800000001\n\n89860012345678901235,13800000002",
wantCards: []CardInfo{
{ICCID: "89860012345678901234", MSISDN: "13800000001"},
{ICCID: "89860012345678901235", MSISDN: "13800000002"},
},
wantTotalCount: 2,
wantErrorCount: 0,
},
{
name: "多列CSV只取第一列",
csvContent: "89860012345678901234,额外数据,更多数据\n89860012345678901235,忽略,忽略",
wantICCIDs: []string{"89860012345678901234", "89860012345678901235"},
name: "ICCID和MSISDN前后有空格",
csvContent: " 89860012345678901234 , 13800000001 ",
wantCards: []CardInfo{
{ICCID: "89860012345678901234", MSISDN: "13800000001"},
},
wantTotalCount: 1,
wantErrorCount: 0,
},
{
name: "多于两列只取前两列",
csvContent: "89860012345678901234,13800000001,额外数据\n89860012345678901235,13800000002,忽略",
wantCards: []CardInfo{
{ICCID: "89860012345678901234", MSISDN: "13800000001"},
{ICCID: "89860012345678901235", MSISDN: "13800000002"},
},
wantTotalCount: 2,
wantErrorCount: 0,
},
{
name: "Windows换行符CRLF",
csvContent: "89860012345678901234\r\n89860012345678901235\r\n89860012345678901236",
wantICCIDs: []string{"89860012345678901234", "89860012345678901235", "89860012345678901236"},
wantTotalCount: 3,
name: "Windows换行符CRLF",
csvContent: "89860012345678901234,13800000001\r\n89860012345678901235,13800000002",
wantCards: []CardInfo{
{ICCID: "89860012345678901234", MSISDN: "13800000001"},
{ICCID: "89860012345678901235", MSISDN: "13800000002"},
},
wantTotalCount: 2,
wantErrorCount: 0,
},
}
@@ -98,34 +142,78 @@ func TestParseICCIDFromCSV(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reader := strings.NewReader(tt.csvContent)
result, err := ParseICCIDFromCSV(reader)
result, err := ParseCardCSV(reader)
if tt.wantError != nil {
require.ErrorIs(t, err, tt.wantError)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantICCIDs, result.ICCIDs, "ICCIDs 不匹配")
assert.Equal(t, tt.wantCards, result.Cards, "Cards 不匹配")
assert.Equal(t, tt.wantTotalCount, result.TotalCount, "TotalCount 不匹配")
assert.Equal(t, tt.wantErrorCount, len(result.ParseErrors), "ParseErrors 数量不匹配")
})
}
}
func TestParseCardCSV_ErrorDetails(t *testing.T) {
t.Run("MSISDN为空时记录详细错误", func(t *testing.T) {
csvContent := "iccid,msisdn\n89860012345678901234,"
reader := strings.NewReader(csvContent)
result, err := ParseCardCSV(reader)
require.NoError(t, err)
require.Len(t, result.ParseErrors, 1)
assert.Equal(t, 2, result.ParseErrors[0].Line)
assert.Equal(t, "89860012345678901234", result.ParseErrors[0].ICCID)
assert.Equal(t, "MSISDN 不能为空", result.ParseErrors[0].Reason)
})
t.Run("ICCID为空时记录详细错误", func(t *testing.T) {
csvContent := "iccid,msisdn\n,13800000001"
reader := strings.NewReader(csvContent)
result, err := ParseCardCSV(reader)
require.NoError(t, err)
require.Len(t, result.ParseErrors, 1)
assert.Equal(t, 2, result.ParseErrors[0].Line)
assert.Equal(t, "13800000001", result.ParseErrors[0].MSISDN)
assert.Equal(t, "ICCID 不能为空", result.ParseErrors[0].Reason)
})
t.Run("列数不足时记录详细错误", func(t *testing.T) {
csvContent := "89860012345678901234"
reader := strings.NewReader(csvContent)
result, err := ParseCardCSV(reader)
require.NoError(t, err)
require.Len(t, result.ParseErrors, 1)
assert.Equal(t, 1, result.ParseErrors[0].Line)
assert.Equal(t, "89860012345678901234", result.ParseErrors[0].ICCID)
assert.Contains(t, result.ParseErrors[0].Reason, "列数不足")
})
}
func TestIsHeader(t *testing.T) {
tests := []struct {
value string
col1 string
col2 string
expected bool
}{
{"iccid", true},
{"ICCID", true},
{"Iccid", true},
{"号", true},
{"号码", true},
{"89860012345678901234", false},
{"", false},
{"id", false},
{"card", false},
{"iccid", "msisdn", true},
{"ICCID", "MSISDN", true},
{"卡号", "接入号", true},
{"号码", "手机号", true},
{"iccid", "电话", true},
{"89860012345678901234", "13800000001", false},
{"iccid", "", false},
{"", "msisdn", false},
}
for _, tt := range tests {
t.Run(tt.value, func(t *testing.T) {
result := isHeader(tt.value)
t.Run(tt.col1+"_"+tt.col2, func(t *testing.T) {
result := isHeader(tt.col1, tt.col2)
assert.Equal(t, tt.expected, result)
})
}