package gzev import ( "context" "database/sql" "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "time" ) type ReportDay struct { Date string Rows, Known int64 } type DailyReport struct { Date, From, Through, Latest, LatestKnown, LastSuccess string Expected, Fetched, RetryDates int AuthExpired bool Recent []ReportDay Missing []string } // LoadDailyReport reads one consistent database snapshot. NULL mileage stays missing; // a reported zero counts as known data. func LoadDailyReport(ctx context.Context, db *sql.DB, account, from string, now time.Time) (DailyReport, error) { today := ShanghaiToday(now) first, err := ParseDate(from) if err != nil || !first.Before(today) { return DailyReport{}, errors.New("report start must be before today") } r := DailyReport{Date: today.Format("2006-01-02"), From: from, Through: today.AddDate(0, 0, -1).Format("2006-01-02")} r.Expected = int(today.Sub(first).Hours() / 24) tx, err := db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true, Isolation: sql.LevelRepeatableRead}) if err != nil { return r, err } defer tx.Rollback() rows, err := tx.QueryContext(ctx, `SELECT DATE_FORMAT(stat_date,'%Y-%m-%d'),last_success_at FROM gzev_mileage_day WHERE account=? AND stat_date BETWEEN ? AND ? ORDER BY stat_date`, account, from, r.Through) if err != nil { return r, err } seen := map[string]bool{} var latestSuccess time.Time for rows.Next() { var date string var success time.Time if err = rows.Scan(&date, &success); err != nil { rows.Close() return r, err } seen[date] = true r.Fetched++ r.Latest = date if success.After(latestSuccess) { latestSuccess = success } } err = rows.Err() rows.Close() if err != nil { return r, err } if !latestSuccess.IsZero() { r.LastSuccess = latestSuccess.In(today.Location()).Format("2006-01-02 15:04:05") } for d := first; d.Before(today); d = d.AddDate(0, 0, 1) { date := d.Format("2006-01-02") if !seen[date] { r.Missing = append(r.Missing, date) } } var known sql.NullString err = tx.QueryRowContext(ctx, `SELECT DATE_FORMAT(MAX(stat_date),'%Y-%m-%d') FROM gzev_daily_mileage WHERE account=? AND stat_date BETWEEN ? AND ? AND hydrogen_km IS NOT NULL`, account, from, r.Through).Scan(&known) if err != nil { return r, err } r.LatestKnown = known.String recentFrom := today.AddDate(0, 0, -7).Format("2006-01-02") if recentFrom < from { recentFrom = from } rows, err = tx.QueryContext(ctx, `SELECT DATE_FORMAT(stat_date,'%Y-%m-%d'),COUNT(*),COUNT(hydrogen_km) FROM gzev_daily_mileage WHERE account=? AND stat_date BETWEEN ? AND ? GROUP BY stat_date ORDER BY stat_date`, account, recentFrom, r.Through) if err != nil { return r, err } for rows.Next() { var d ReportDay if err = rows.Scan(&d.Date, &d.Rows, &d.Known); err != nil { rows.Close() return r, err } r.Recent = append(r.Recent, d) } err = rows.Err() rows.Close() if err != nil { return r, err } err = tx.QueryRowContext(ctx, `SELECT active FROM gzev_auth_alert WHERE account=?`, account).Scan(&r.AuthExpired) if err != nil && !errors.Is(err, sql.ErrNoRows) { return r, err } err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gzev_mileage_attempt WHERE account=? AND stat_date BETWEEN ? AND ?`, account, from, r.Through).Scan(&r.RetryDates) if err != nil { return r, err } return r, tx.Commit() } func (r DailyReport) Message() (string, string) { conclusion := "同步已覆盖全部目标日期。" switch { case r.AuthExpired: conclusion = "登录已失效,需要更新登录状态;已入库数据仍保留。" case len(r.Missing) > 0: conclusion = fmt.Sprintf("同步尚未补齐,仍有 %d 个日期待采集。", len(r.Missing)) default: for _, d := range r.Recent { if d.Date == r.Through { if d.Known == 0 { conclusion = "日期已补齐,但昨天上游纯氢里程全部为空,仍需后续重采。" } else if d.Known < d.Rows { conclusion = "日期已补齐,但昨天仍有部分车辆的上游纯氢里程缺失。" } } } } show := func(v string) string { if v == "" { return "暂无" } return v } var b strings.Builder fmt.Fprintf(&b, "**结论:%s**\n\n统计截至:%s(北京时间,昨天及更早完整自然日)\n\n日期采集:%d / %d 天;最新采集日期:%s\n\n最新有纯氢里程值的日期:%s\n\n最后成功采集:%s(北京时间)\n\n待补日期:%d 天;失败待重试:%d 天\n", conclusion, r.Through, r.Fetched, r.Expected, show(r.Latest), show(r.LatestKnown), show(r.LastSuccess), len(r.Missing), r.RetryDates) if len(r.Missing) > 0 { n := len(r.Missing) if n > 14 { n = 14 } fmt.Fprintf(&b, "\n待补日期(最多列出14天):%s\n", strings.Join(r.Missing[:n], "、")) } if r.AuthExpired { fmt.Fprintf(&b, "\n[更新广州平台登录状态](https://vehicle.d.lnoneos.com/gzev/session/)\n") } b.WriteString("\n最近7天:\n\n| 日期 | 车辆日记录 | 纯氢有值 | 纯氢缺失 |\n|---|---:|---:|---:|\n") for _, d := range r.Recent { fmt.Fprintf(&b, "| %s | %d | %d | %d |\n", d.Date, d.Rows, d.Known, d.Rows-d.Known) } b.WriteString("\n有值包含平台明确上报的 0;缺失不计为 0。日期采集完成不代表上游字段齐全。每小时 :15 持续同步及重采近期数据。") return "纯氢里程每日汇报 · " + r.Date, b.String() } type reportReceipt struct { Date string `json:"date"` AttemptedAt time.Time `json:"attempted_at"` AcceptedAt *time.Time `json:"accepted_at,omitempty"` } // Persist the attempt before sending: an ambiguous timeout must not cause a // duplicate push when the same day's service is started again. func DeliverDailyReport(path, date string, send func() error) (bool, error) { sent := false err := recoveryLock(path, func() error { var receipt reportReceipt b, err := os.ReadFile(path) if err == nil { if err = json.Unmarshal(b, &receipt); err != nil { return errors.New("invalid daily report receipt") } if receipt.Date == date { if receipt.AcceptedAt != nil { return nil } return errors.New("daily report already attempted; delivery acceptance unknown, inspect before retrying") } } else if !os.IsNotExist(err) { return err } receipt = reportReceipt{Date: date, AttemptedAt: time.Now().UTC()} write := func() error { f, err := os.CreateTemp(filepath.Dir(path), ".daily-report-*") if err != nil { return err } defer os.Remove(f.Name()) err = json.NewEncoder(f).Encode(receipt) if err == nil { err = f.Sync() } closeErr := f.Close() if err != nil { return err } if closeErr != nil { return closeErr } return os.Rename(f.Name(), path) } if err = write(); err != nil { return err } if err = send(); err != nil { return err } now := time.Now().UTC() receipt.AcceptedAt = &now if err = write(); err != nil { return err } sent = true return nil }) return sent, err }