package app import ( "context" "database/sql" "fmt" "hash/fnv" "regexp" "strings" "time" "unicode" "git.5iot.com/iotlcm-public/iotlcm-app/internal/model" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" _ "github.com/taosdata/driver-go/v3/taosRestful" _ "github.com/taosdata/driver-go/v3/taosWS" ) const ( appDiagnosisDailyStable = "t_sim_card_usage_daily" appDiagnosisMonthlyStable = "t_sim_card_usage_monthly" ) var appDiagnosisIdentifierPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) type appDiagnosisTrafficConfig struct { Enabled bool DriverName string Dsn string DbName string DailyDbName string MonthlyDbName string MaxOpenConns int MaxIdleConns int } // dbNameFor 返回指定超级表所在 database。 // // 日流量与月流量分库存储(两库 duration 不同,而 duration 只能建库时确定); // 未单独配置 dailyDbName/monthlyDbName 时回退到 DbName,与改造前行为一致。 func (c appDiagnosisTrafficConfig) dbNameFor(stable string) string { if stable == appDiagnosisDailyStable && c.DailyDbName != "" { return c.DailyDbName } if stable == appDiagnosisMonthlyStable && c.MonthlyDbName != "" { return c.MonthlyDbName } return c.DbName } type appDiagnosisTrafficRecord struct { Ts time.Time UsageMb float64 PackageCode string Iccid string } func (s *sAppDiagnosis) queryDiagnosisDailyFlow(ctx context.Context, iccid string, days int) (*model.AppDiagnosisFlowModel, error) { if days <= 0 { days = 7 } now := time.Now() startTime := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -(days - 1)) endTime := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()) records, err := appDiagnosisQueryTrafficRows(ctx, iccid, appDiagnosisDailyStable, startTime, endTime) if err != nil { return nil, gerror.Wrap(err, "查询SIM卡使用流量失败,请稍后重试") } usageMap := make(map[string]float64, len(records)) for _, item := range records { usageMap[item.Ts.Format("2006-01-02")] = item.UsageMb } res := &model.AppDiagnosisFlowModel{ Times: make([]string, 0, days), Flow: make([]float64, 0, days), Unit: "MB", Granularity: "day", HasData: len(records) > 0, } for i := 0; i < days; i++ { current := startTime.AddDate(0, 0, i) res.Times = append(res.Times, current.Format("01-02")) res.Flow = append(res.Flow, usageMap[current.Format("2006-01-02")]) } return res, nil } func (s *sAppDiagnosis) queryDiagnosisMonthlyFlow(ctx context.Context, iccid string, months int) (*model.AppDiagnosisFlowModel, error) { if months <= 0 { months = 6 } now := time.Now() currentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) startMonth := currentMonth.AddDate(0, -(months - 1), 0) endTime := time.Date(now.Year(), now.Month()+1, 0, 23, 59, 59, 0, now.Location()) records, err := appDiagnosisQueryTrafficRows(ctx, iccid, appDiagnosisMonthlyStable, startMonth, endTime) if err != nil { return nil, gerror.Wrap(err, "查询SIM卡使用流量失败,请稍后重试") } usageMap := make(map[string]float64, len(records)) for _, item := range records { usageMap[item.Ts.Format("2006-01")] = item.UsageMb } res := &model.AppDiagnosisFlowModel{ Times: make([]string, 0, months), Flow: make([]float64, 0, months), Unit: "MB", Granularity: "month", HasData: len(records) > 0, } for i := 0; i < months; i++ { current := startMonth.AddDate(0, i, 0) key := current.Format("2006-01") res.Times = append(res.Times, key) res.Flow = append(res.Flow, usageMap[key]) } return res, nil } func appDiagnosisQueryTrafficRows(ctx context.Context, iccid string, stable string, startTime, endTime time.Time) ([]appDiagnosisTrafficRecord, error) { iccid = strings.TrimSpace(iccid) if iccid == "" { return nil, gerror.New("traffic usage query iccid is empty") } cfg := appDiagnosisGetTrafficConfig(ctx) if !cfg.Enabled || strings.TrimSpace(cfg.Dsn) == "" { g.Log().Debug(ctx, "app diagnosis traffic query skipped, tdengine disabled") return []appDiagnosisTrafficRecord{}, nil } dbName := cfg.dbNameFor(stable) if err := appDiagnosisValidateTableConfig(dbName, stable); err != nil { return nil, err } driverName := strings.TrimSpace(cfg.DriverName) if driverName == "" { driverName = "taosWS" } db, err := sql.Open(driverName, cfg.Dsn) if err != nil { return nil, gerror.Wrap(err, "tdengine open failed") } defer db.Close() db.SetMaxOpenConns(cfg.MaxOpenConns) db.SetMaxIdleConns(cfg.MaxIdleConns) table := appDiagnosisQualify(dbName, appDiagnosisChildTrafficTableName(stable, iccid)) rows, err := db.QueryContext(ctx, fmt.Sprintf( "SELECT ts, usage_mb, package_code, iccid FROM %s%s ORDER BY ts ASC", table, appDiagnosisWhereRangeSQL(startTime, endTime), )) if err != nil { if appDiagnosisIsTableNotExist(err) { return []appDiagnosisTrafficRecord{}, nil } return nil, gerror.Wrap(err, "tdengine query traffic usage failed") } defer rows.Close() list := make([]appDiagnosisTrafficRecord, 0) for rows.Next() { var item appDiagnosisTrafficRecord if err = rows.Scan(&item.Ts, &item.UsageMb, &item.PackageCode, &item.Iccid); err != nil { return nil, gerror.Wrap(err, "tdengine scan traffic usage failed") } list = append(list, item) } if err = rows.Err(); err != nil { return nil, gerror.Wrap(err, "tdengine rows traffic usage failed") } return list, nil } func appDiagnosisGetTrafficConfig(ctx context.Context) appDiagnosisTrafficConfig { // dbName 只是兑底键名,不决定实际访问哪个库;默认取日库,避免兑底到不存在的库名。 dbName := strings.TrimSpace(g.Cfg().MustGet(ctx, "tsdb.tdengine.dbName", "iotlcm_daily").String()) return appDiagnosisTrafficConfig{ Enabled: strings.EqualFold(g.Cfg().MustGet(ctx, "tsdb.database", "TdEngine").String(), "TdEngine"), DriverName: strings.TrimSpace(g.Cfg().MustGet(ctx, "tsdb.tdengine.type", "taosWS").String()), Dsn: strings.TrimSpace(g.Cfg().MustGet(ctx, "tsdb.tdengine.dsn", "").String()), DbName: dbName, DailyDbName: strings.TrimSpace(g.Cfg().MustGet(ctx, "tsdb.tdengine.dailyDbName", dbName).String()), MonthlyDbName: strings.TrimSpace(g.Cfg().MustGet(ctx, "tsdb.tdengine.monthlyDbName", dbName).String()), MaxOpenConns: g.Cfg().MustGet(ctx, "tsdb.tdengine.maxOpenConns", 100).Int(), MaxIdleConns: g.Cfg().MustGet(ctx, "tsdb.tdengine.maxIdleConns", 10).Int(), } } func appDiagnosisValidateTableConfig(database string, stable string) error { if !appDiagnosisIdentifierPattern.MatchString(database) { return gerror.Newf("invalid tdengine database name: %s", database) } if !appDiagnosisIdentifierPattern.MatchString(stable) { return gerror.Newf("invalid tdengine stable name: %s", stable) } return nil } func appDiagnosisQualify(database, table string) string { return fmt.Sprintf("%s.%s", database, table) } func appDiagnosisChildTrafficTableName(stable string, iccid string) string { prefix := "sim_card_usage_m_" if stable == appDiagnosisDailyStable { prefix = "sim_card_usage_d_" } return appDiagnosisChildTableNameWithPrefix(prefix, iccid) } func appDiagnosisChildTableNameWithPrefix(prefix string, value string) string { var builder strings.Builder builder.WriteString(prefix) for _, r := range strings.ToLower(value) { if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' { builder.WriteRune(r) } } name := builder.String() if name == prefix || len(name) > 190 { return fmt.Sprintf("%s%x", prefix, appDiagnosisHashString(value)) } return name } func appDiagnosisHashString(value string) uint32 { hash := fnv.New32a() _, _ = hash.Write([]byte(value)) return hash.Sum32() } func appDiagnosisWhereRangeSQL(startTime, endTime time.Time) string { clauses := make([]string, 0, 2) if !startTime.IsZero() { clauses = append(clauses, "ts >= "+appDiagnosisSQLTime(startTime)) } if !endTime.IsZero() { clauses = append(clauses, "ts <= "+appDiagnosisSQLTime(endTime)) } if len(clauses) == 0 { return "" } return " WHERE " + strings.Join(clauses, " AND ") } func appDiagnosisSQLTime(value time.Time) string { return appDiagnosisSQLString(value.Format("2006-01-02 15:04:05.000")) } func appDiagnosisSQLString(value string) string { return "'" + strings.ReplaceAll(value, "'", "''") + "'" } func appDiagnosisIsTableNotExist(err error) bool { if err == nil { return false } text := strings.ToLower(err.Error()) return strings.Contains(text, "table does not exist") || strings.Contains(text, "table not exist") || strings.Contains(text, "does not exist") }