sync: 同步 v1.0.4 并脱敏配置与镜像

This commit is contained in:
MiFaZhan
2026-10-29 14:36:49 +08:00
parent 743e8e6e6e
commit 2206a393f7
516 changed files with 18119 additions and 2253 deletions
@@ -0,0 +1,622 @@
package simcardquery
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/tdengine"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// usageHistoryMonths 近一年月流水的月份个数(不含当月)。
// 例:当前 2026-10,导出 2026-09 ~ 2025-10 共 12 个月。
const usageHistoryMonths = 12
// usageHistoryTDEngineChunk 单次向 TDengine 查询的 ICCID 条数。
// 父表按 iccid 标签过滤会剪枝到对应子表,500 条一批可把单次查询控制在秒级。
const usageHistoryTDEngineChunk = 500
// dept 模式没有输入号码,靠公司范围圈卡,必须设上限,否则顶级账号一次拖全库。
// deptExportMaxRows 公司模式(含下级)单次导出上限。
//
// 与号码模式的 20000 分开取值:号码模式受「用户手上有多少号码」约束,
// 公司模式是整批圈卡,按 2 万设限只能靠不断切分下级公司,反而更容易出错。
//
// 取值依据(实测,见 export_cost_test.go):5 万行写 Excel 约 3.2s、文件 4.5MB,
// 远低于导出中心 10 分钟超时 / 100MB 上限;真正的约束是当前实现会把整批行
// 先构造成内存切片再分页写出,5 万行内存占用约 50MB 量级,10 万行开始偏高。
// 若要支持 10 万以上,需要把公司模式改成「按页直接查库」的流式导出。
const deptExportMaxRows = 50000
var usageHistoryFixedFields = []string{"iccid", "msisdn", "imsi", "outStockTime", "expireAt"}
// 月用量列的取值口径:
//
// 查询失败(服务异常)→ 留空,原因写进「失败原因」列
// 查到了但该月无记录 → "-"
// 该月有记录且用量为 0 → 0
//
// 失败格留空而不是写文字:这样整列是「数字 + 空 + 横杠」,Excel 求和/透视不会被文本干扰;
// 同时「失败原因」列会写明该卡流量查询失败,信息不会丢。
const usageNoDataText = "-"
// usageQueryFailedNote 流量查询失败时写入「失败原因」列的说明。
const usageQueryFailedNote = "该卡近12个月流量数据本次查询失败,月份列留空,可稍后重新导出"
// cardUsageResult 单张卡的历史用量查询结果。
type cardUsageResult struct {
// Months 键为 "YYYY-MM";缺失表示查询成功但该月无记录。
Months map[string]float64
// Failed 表示该卡所属批次的流量查询失败,数据不可信。
Failed bool
}
// ExportUsageHistory 月历史用量查询导出。
func (s *Service) ExportUsageHistory(ctx context.Context, in *model.SimCardUsageHistoryExportParam, input ExcelInput) (*exportcenter.SubmitResult, error) {
months := LastCompleteMonths(time.Now(), usageHistoryMonths)
columns := resolveUsageHistoryColumns(in, months)
// 公司模式的卡列表必须在后台任务中解析,避免提交请求同步读取最多 2 万张卡。
// 号码模式仍在提交阶段校验输入格式,实际查库统一延迟到导出 runner。
var lines []inputLine
var err error
if in.Mode != model.SimCardQueryModeDept {
lines, err = parseInputs(ctx, &in.SimCardQueryExportParam, input)
if err != nil {
return nil, err
}
}
stats := &resolveStats{}
once := &sync.Once{}
var cached []*model.SimCardUsageHistoryRow
var cacheErr error
resolve := func(ctx context.Context) ([]*model.SimCardUsageHistoryRow, error) {
once.Do(func() {
switch in.Mode {
case model.SimCardQueryModeDept:
cached, cacheErr = s.resolveDeptRows(ctx, in, months, stats)
case model.SimCardQueryModeRange:
cached, cacheErr = s.resolveUsageRangeRows(ctx, &in.SimCardQueryExportParam, months, stats)
default:
cached, cacheErr = s.resolveUsageHistoryRows(ctx, lines, months, stats)
}
})
return cached, cacheErr
}
probeTotal := len(lines)
switch in.Mode {
case model.SimCardQueryModeRange:
// 号段模式:号段包含多少个号码就是预计条数,与是否查到无关。
if size, sizeErr := rangeSize(&in.SimCardQueryExportParam); sizeErr == nil {
probeTotal = int(size)
} else {
probeTotal = -1
}
case model.SimCardQueryModeDept:
// 公司卡数只在后台 runner 中查询;提交探针不扫描业务表,条数未知。
probeTotal = -1
}
return exportcenter.Submit(ctx, &exportcenter.Request{
BizKey: "tSimCardUsageHistory",
BizName: "月历史用量查询",
PermCode: "cpm:iot:tSimCardUsageHistory:view",
SheetName: "月历史用量",
QueryParam: usageHistorySnapshot(in, months, probeTotal),
Columns: columns,
RowFields: append(append([]string{}, usageHistoryFixedFields...), append(months, queryResultField, failReasonField)...),
Tags: func() ([]string, error) {
return usageHistoryTags(months), nil
},
ToRow: func(v interface{}) []interface{} {
row, ok := v.(*model.SimCardUsageHistoryRow)
if !ok || row == nil {
return nil
}
values := make([]interface{}, 0, len(usageHistoryFixedFields)+len(months)+2)
values = append(values, row.Iccid, row.Msisdn, row.Imsi, formatTime(row.OutStockTime), formatTime(row.ExpireAt))
values = append(values, formatMonthValues(row, months)...)
return append(values, row.QueryResult, row.FailReason)
},
Fetch: func(ctx context.Context, page, pageSize int) ([]interface{}, int, error) {
if pageSize <= 1 && page <= 1 {
// probeTotal 为 -1 表示提交阶段无法确定条数(按公司模式),放行由后台实际取数。
return nil, probeTotal, nil
}
rows, err := resolve(ctx)
if err != nil {
return nil, 0, err
}
total := len(rows)
start := (page - 1) * pageSize
if start >= total {
return nil, total, nil
}
end := start + pageSize
if end > total {
end = total
}
out := make([]interface{}, 0, end-start)
for _, row := range rows[start:end] {
out = append(out, row)
}
return out, total, nil
},
Result: stats.resultSummary,
})
}
// resolveUsageHistoryRows 号码模式:解析输入 → 查卡 → 查近 12 个月用量。
func (s *Service) resolveUsageHistoryRows(ctx context.Context, lines []inputLine, months []string, stats *resolveStats) ([]*model.SimCardUsageHistoryRow, error) {
values := collectValues(lines)
cards, err := queryCardsByValues(ctx, values)
if err != nil {
return nil, err
}
byIccid := indexCards(cards, func(card *cardBrief) string { return card.Iccid })
byMsisdn := indexCards(cards, func(card *cardBrief) string { return card.Msisdn })
byImsi := indexCards(cards, func(card *cardBrief) string { return card.Imsi })
iccids := make([]string, 0, len(cards))
for _, card := range cards {
if card.Iccid != "" {
iccids = append(iccids, card.Iccid)
}
}
usage, err := queryMonthlyUsage(ctx, iccids, months)
if err != nil {
return nil, err
}
rows := make([]*model.SimCardUsageHistoryRow, 0, len(lines))
// 同一张卡重复出现只导出一次(同号码重复填写,或 ICCID 与其 MSISDN 各写一行)。
exported := make(map[int64]struct{}, len(lines))
for _, line := range lines {
row := &model.SimCardUsageHistoryRow{MonthValues: map[string]float64{}}
card, reason := matchCard(line, byIccid, byMsisdn, byImsi)
if card == nil {
row.Iccid = firstValue(line.Values)
row.QueryResult = queryResultMiss
row.FailReason = inputFailure(line, reason)
stats.add(false)
rows = append(rows, row)
continue
}
if _, ok := exported[card.Id]; ok {
stats.addDup()
continue
}
exported[card.Id] = struct{}{}
rows = append(rows, newUsageRow(card, usage))
stats.add(true)
}
return rows, nil
}
// resolveUsageRangeRows 号段模式:按起止号段取卡,再补近 12 个月用量。
func (s *Service) resolveUsageRangeRows(ctx context.Context, in *model.SimCardQueryExportParam, months []string, stats *resolveStats) ([]*model.SimCardUsageHistoryRow, error) {
cards, err := queryCardsByRange(ctx, in)
if err != nil {
return nil, err
}
iccids := make([]string, 0, len(cards))
for _, card := range cards {
if card.Iccid != "" {
iccids = append(iccids, card.Iccid)
}
}
usage, err := queryMonthlyUsage(ctx, iccids, months)
if err != nil {
return nil, err
}
// 按号段逐个号码输出:命中出行数据,未命中出「未查询到数据」的失败行,
// 保证导出条数与号段大小一致(号段里可能一部分可见、一部分不可见)。
dimension := strings.TrimSpace(in.Dimension)
index := rangeCardIndex(cards, dimension)
size, sizeErr := rangeSize(in)
if sizeErr != nil {
size = int64(len(cards))
}
numbers := rangeNumbers(in, size)
rows := make([]*model.SimCardUsageHistoryRow, 0, len(numbers))
for _, number := range numbers {
if card, ok := index[number]; ok {
rows = append(rows, newUsageRow(card, usage))
stats.add(true)
continue
}
row := &model.SimCardUsageHistoryRow{
MonthValues: map[string]float64{},
QueryResult: queryResultMiss,
FailReason: rangeMissReason,
}
switch dimension {
case "msisdn":
row.Msisdn = number
case "imsi":
row.Imsi = number
default:
row.Iccid = number
}
stats.add(false)
rows = append(rows, row)
}
return rows, nil
}
// resolveDeptRows 在后台按提交人的数据权限重新解析公司(含下级)卡列表。
func (s *Service) resolveDeptRows(ctx context.Context, in *model.SimCardUsageHistoryExportParam, months []string, stats *resolveStats) ([]*model.SimCardUsageHistoryRow, error) {
iccids, err := s.cardsByDept(ctx, in)
if err != nil {
return nil, err
}
cards, err := queryCardsByIccids(ctx, iccids)
if err != nil {
return nil, err
}
usage, err := queryMonthlyUsage(ctx, iccids, months)
if err != nil {
return nil, err
}
rows := make([]*model.SimCardUsageHistoryRow, 0, len(cards))
for _, card := range cards {
stats.add(true)
rows = append(rows, newUsageRow(card, usage))
}
return rows, nil
}
// cardsByDept 取公司(含全部下级公司)名下的 ICCID 列表。
//
// 选中公司即包含下级,与 DeptOptions 的卡数统计口径一致:
// 顶级公司自身通常不挂卡,只查本级会得到 0 条。
func (s *Service) cardsByDept(ctx context.Context, in *model.SimCardUsageHistoryExportParam) ([]string, error) {
if in.DeptId <= 0 {
return nil, gerror.New("请选择所属公司")
}
columns := dao.TSimCardInfo.Columns()
base := func() *gdb.Model {
mod := handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{
FilterTenant: true,
FilterAuth: true,
}).WhereIn(columns.DeptId, deptSubtreeIDs(ctx, in.DeptId))
if start := strings.TrimSpace(in.ExpireStartTime); start != "" {
mod = mod.WhereGTE(columns.FlowPkgEndAt, start)
}
if end := strings.TrimSpace(in.ExpireEndTime); end != "" {
mod = mod.WhereLTE(columns.FlowPkgEndAt, end)
}
return mod
}
count, err := base().Fields(columns.Iccid).Count()
if err != nil {
return nil, gerror.Wrap(err, "统计公司名下卡数量失败")
}
if count <= 0 {
return nil, gerror.New("该公司(含下级)下没有符合条件可导出的卡")
}
if count > deptExportMaxRows {
return nil, gerror.Newf(
"该公司(含下级)共 %d 张卡,超过单次导出上限 %d 张,请改选下级公司或改用号码导入",
count, deptExportMaxRows,
)
}
values, err := base().Fields(columns.Iccid).OrderAsc(columns.Id).Limit(deptExportMaxRows).Array()
if err != nil {
return nil, gerror.Wrap(err, "查询公司名下卡失败")
}
out := make([]string, 0, len(values))
for _, value := range values {
if iccid := strings.TrimSpace(value.String()); iccid != "" {
out = append(out, iccid)
}
}
return out, nil
}
// queryCardsByIccids 按 ICCID 批量取导出所需字段。
func queryCardsByIccids(ctx context.Context, iccids []string) ([]*cardBrief, error) {
if len(iccids) == 0 {
return nil, nil
}
columns := dao.TSimCardInfo.Columns()
out := make([]*cardBrief, 0, len(iccids))
for start := 0; start < len(iccids); start += chunkSize {
end := start + chunkSize
if end > len(iccids) {
end = len(iccids)
}
list := make([]*cardBrief, 0, end-start)
err := handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{
FilterTenant: true,
FilterAuth: true,
}).
Fields(
columns.Id, columns.Iccid, columns.Msisdn, columns.Imsi,
columns.OutStockTime, columns.FlowPkgEndAt,
).
WhereIn(columns.Iccid, iccids[start:end]).
Scan(&list)
if err != nil {
return nil, gerror.Wrap(err, "查询卡信息失败,请稍后重试")
}
out = append(out, list...)
}
return out, nil
}
// queryMonthlyUsage 从 TDengine 取多卡的多月用量。
//
// 每张卡带上自己的查询状态,三种情况必须在导出里可区分:
// - Failed=true:该批查询异常,月份格写「查询失败」
// - Months 里缺该月:查询成功但没有该月记录,月份格写 "-"
// - Months 里有该月且为 0:查询成功、零用量,月份格写 0
//
// 部分批次失败不拖垮整单,只有全部批次失败(服务整体不可用)才返回错误。
func queryMonthlyUsage(ctx context.Context, iccids []string, months []string) (map[string]*cardUsageResult, error) {
result := make(map[string]*cardUsageResult, len(iccids))
if len(iccids) == 0 || len(months) == 0 {
return result, nil
}
if !tdengine.Enabled(ctx) {
g.Log().Warning(ctx, "[simcardquery] 流量库未启用")
return nil, gerror.New("流量数据服务暂时不可用,本次导出未能完成。请稍后重试,若持续出现可联系技术支持。")
}
cfg := tdengine.GetConfig(ctx)
if err := validateTDEngineNames(cfg.DbName, cfg.MonthlyStable); err != nil {
g.Log().Errorf(ctx, "[simcardquery] 流量库配置异常: %v", err)
return nil, gerror.New("流量数据服务暂时不可用,本次导出未能完成。请稍后重试,若持续出现可联系技术支持。")
}
start, end, err := monthRange(months)
if err != nil {
return nil, err
}
table := fmt.Sprintf("%s.%s", cfg.DbName, cfg.MonthlyStable)
// 用秒级时间字面量:月表写入点是当月最后一天 23:59:59,秒级精度已能覆盖。
startText := start.Format("2006-01-02 15:04:05")
endText := end.Format("2006-01-02 15:04:05")
batches, failedBatches := 0, 0
for chunkStart := 0; chunkStart < len(iccids); chunkStart += usageHistoryTDEngineChunk {
chunkEnd := chunkStart + usageHistoryTDEngineChunk
if chunkEnd > len(iccids) {
chunkEnd = len(iccids)
}
chunk := iccids[chunkStart:chunkEnd]
// TDengine 驱动不会给字符串参数补引号,用 ? 绑定会让时间条件变成
// `ts >= 2025-10-01 00:00:00` 这种非法表达式(报 syntax error)。
// 因此这里把 ICCID 与时间都拼成带引号的字面量,
// ICCID 必须经过字符白名单校验,杜绝拼接注入。
quoted := make([]string, 0, len(chunk))
safe := make([]string, 0, len(chunk))
for _, iccid := range chunk {
if !isSafeIccid(iccid) {
continue
}
safe = append(safe, iccid)
quoted = append(quoted, "'"+iccid+"'")
}
if len(quoted) == 0 {
continue
}
batches++
// 先按「查询成功、暂无记录」建条目;一旦本批出错再整体标记为查询失败。
for _, iccid := range safe {
if result[iccid] == nil {
result[iccid] = &cardUsageResult{Months: make(map[string]float64, len(months))}
}
}
markFailedBatch := func() {
for _, iccid := range safe {
if entry := result[iccid]; entry != nil {
entry.Failed = true
entry.Months = map[string]float64{}
}
}
}
sql := fmt.Sprintf(
"SELECT iccid, ts, usage_mb FROM %s WHERE iccid IN (%s) AND ts >= '%s' AND ts <= '%s'",
table, strings.Join(quoted, ","), startText, endText,
)
rows, queryErr := tdengine.Query(ctx, sql)
if queryErr != nil {
// 单批失败不拖垮整个导出:本批卡片标记为「查询失败」,其余批次照常。
failedBatches++
markFailedBatch()
g.Log().Errorf(ctx, "[simcardquery] 查询月流量失败 cards=%d range=%s~%s err=%v",
len(chunk), startText, endText, queryErr)
continue
}
latest := make(map[string]map[string]time.Time, len(chunk))
scanFailed := false
for rows.Next() {
var iccid string
var ts time.Time
var usage float64
if scanErr := rows.Scan(&iccid, &ts, &usage); scanErr != nil {
scanFailed = true
g.Log().Errorf(ctx, "[simcardquery] 解析月流量结果失败 err=%v", scanErr)
break
}
key := ts.Format("2006-01")
if latest[iccid] == nil {
latest[iccid] = make(map[string]time.Time, len(months))
}
previous, exists := latest[iccid][key]
if exists && !ts.After(previous) {
continue
}
entry := result[iccid]
if entry == nil {
entry = &cardUsageResult{Months: make(map[string]float64, len(months))}
result[iccid] = entry
}
latest[iccid][key] = ts
entry.Months[key] = usage
}
if !scanFailed {
if rowsErr := rows.Err(); rowsErr != nil {
scanFailed = true
g.Log().Errorf(ctx, "[simcardquery] 遍历月流量结果失败 err=%v", rowsErr)
}
}
rows.Close()
if scanFailed {
failedBatches++
markFailedBatch()
}
}
// 全部批次都失败才判定服务不可用。
if batches > 0 && failedBatches == batches {
g.Log().Errorf(ctx, "[simcardquery] 流量服务全部批次查询失败 batches=%d", batches)
return nil, gerror.New("流量数据服务暂时不可用,本次导出未能完成。请稍后重试,若持续出现可联系技术支持。")
}
if failedBatches > 0 {
g.Log().Warningf(ctx, "[simcardquery] 流量服务部分批次失败 batches=%d failed=%d", batches, failedBatches)
}
return result, nil
}
// formatMonthValues 把某行的月度用量格式化成导出的单元格值,三种状态严格区分:
//
// 查询失败(服务异常)→ 留空(原因见「失败原因」列)
// 查询正常、该月无记录 → "-"
// 查询正常、有记录 → 数值(零用量即 0)
func formatMonthValues(row *model.SimCardUsageHistoryRow, months []string) []interface{} {
values := make([]interface{}, 0, len(months))
for _, month := range months {
if row.UsageQueryFailed || row.QueryResult == queryResultMiss {
// 整卡流量查询失败,或这张卡本身没查到:月份列留空,
// 原因由「失败原因」列说明,保证月份列始终是「数字 + 半角横杠 + 空」。
values = append(values, "")
continue
}
usage, exists := row.MonthValues[month]
if !exists {
values = append(values, usageNoDataText)
continue
}
values = append(values, roundMb(usage))
}
return values
}
// newUsageRow 组装月历史导出行,并统一处理「流量查询失败」的说明。
func newUsageRow(card *cardBrief, usage map[string]*cardUsageResult) *model.SimCardUsageHistoryRow {
row := &model.SimCardUsageHistoryRow{
Iccid: card.Iccid,
Msisdn: card.Msisdn,
Imsi: card.Imsi,
OutStockTime: card.OutStockTime,
ExpireAt: card.ExpireAt,
MonthValues: usageMonths(usage, card.Iccid),
UsageQueryFailed: usageFailed(usage, card.Iccid),
QueryResult: queryResultHit,
}
if row.UsageQueryFailed {
row.FailReason = usageQueryFailedNote
}
return row
}
// usageMonths 取某卡的月度用量;该卡查询失败或没有条目时返回空 map。
func usageMonths(usage map[string]*cardUsageResult, iccid string) map[string]float64 {
if entry := usage[iccid]; entry != nil {
return entry.Months
}
return map[string]float64{}
}
// usageFailed 该卡的流量数据是否查询失败。
func usageFailed(usage map[string]*cardUsageResult, iccid string) bool {
if entry := usage[iccid]; entry != nil {
return entry.Failed
}
return false
}
// LastCompleteMonths 返回最近 N 个「已结束的月份」,按时间倒序(最近的在前)。
// 当月不计入:当月数据仍在累积,导出口径会和下个月对不上。
func LastCompleteMonths(now time.Time, count int) []string {
months := make([]string, 0, count)
cursor := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).AddDate(0, -1, 0)
for i := 0; i < count; i++ {
months = append(months, cursor.Format("2006-01"))
cursor = cursor.AddDate(0, -1, 0)
}
return months
}
// monthRange 把月份清单换算成查询时间范围(含首尾)。
func monthRange(months []string) (time.Time, time.Time, error) {
if len(months) == 0 {
return time.Time{}, time.Time{}, gerror.New("月份清单为空")
}
parsed := make([]time.Time, 0, len(months))
for _, month := range months {
value, err := time.ParseInLocation("2006-01", month, time.Local)
if err != nil {
return time.Time{}, time.Time{}, gerror.Wrapf(err, "月份格式错误:%s", month)
}
parsed = append(parsed, value)
}
sort.Slice(parsed, func(i, j int) bool { return parsed[i].Before(parsed[j]) })
start := parsed[0]
end := parsed[len(parsed)-1].AddDate(0, 1, 0).Add(-time.Second)
return start, end, nil
}
// isSafeIccid ICCID 拼进 SQL 字面量前的白名单校验。
// ICCID 正常只含数字,个别批次可能带字母;除此之外一律拒绝,避免拼接注入。
func isSafeIccid(value string) bool {
if value == "" || len(value) > 32 {
return false
}
for _, r := range value {
switch {
case r >= '0' && r <= '9':
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
default:
return false
}
}
return true
}
func validateTDEngineNames(database, stable string) error {
if database == "" || stable == "" {
return gerror.New("流量库配置不完整")
}
for _, value := range []string{database, stable} {
for _, r := range value {
if !(r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
return gerror.Newf("流量库标识非法:%s", value)
}
}
}
return nil
}
func formatTime(value *gtime.Time) string {
if value == nil || value.IsZero() {
return ""
}
return value.Format("Y-m-d H:i:s")
}