feat: sync vehicle monitoring and accumulated platform updates

Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates.

Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
This commit is contained in:
lingniu
2026-10-26 18:05:59 +08:00
parent 8b32ae2988
commit d6b957dfc8
290 changed files with 28238 additions and 1647 deletions
@@ -0,0 +1,337 @@
// Package gzev collects Guangzhou platform daily mileage without browser automation.
package gzev
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
)
const Origin = "https://gzev.d.lnoneos.com"
const MileagePath = "/evdata-web/api/operation/queryHyVehicleMileageStatistics"
var ErrAuth = errors.New("GZEV session expired; replace session file after interactive login")
var decimalPattern = regexp.MustCompile(`^[0-9]{1,11}(\.[0-9]{1,3})?$`)
// Decimal preserves source decimal precision and distinguishes missing values from zero.
type Decimal struct {
Value string
Valid bool
}
func (d *Decimal) UnmarshalJSON(b []byte) error {
*d = Decimal{}
if string(b) == "null" {
return nil
}
s := string(b)
if len(s) > 0 && s[0] == '"' {
if err := json.Unmarshal(b, &s); err != nil {
return err
}
}
if !decimalPattern.MatchString(s) {
return errors.New("invalid nonnegative mileage decimal")
}
d.Value = s
d.Valid = true
return nil
}
func (d Decimal) MarshalJSON() ([]byte, error) {
if !d.Valid {
return []byte("null"), nil
}
return json.Marshal(d.Value)
}
func (d Decimal) SQLValue() any {
if !d.Valid {
return nil
}
return d.Value
}
type Row struct {
VIN string `json:"vin"`
Plate string `json:"plateNumber"`
VehicleSeq string `json:"vehicleSeq"`
ProductionCompany string `json:"productionCompany"`
OperationCompany string `json:"operationCompany"`
Brand string `json:"brandName"`
Model string `json:"brandModel"`
DateRange string `json:"dateRange"`
Total Decimal `json:"runMileage"`
Hydrogen Decimal `json:"hyRunMileage"`
Charge Decimal `json:"chargeRunMileage"`
Raw json.RawMessage `json:"-"`
}
type Day struct {
Date string `json:"date"`
Rows []Row `json:"rows"`
Pages []json.RawMessage `json:"pages"`
FetchedAt time.Time `json:"fetched_at"`
}
type SessionCookie struct {
Name string `json:"name"`
Value string `json:"value"`
}
type Session struct {
Authorization string `json:"authorization"`
Cookies []SessionCookie `json:"cookies"`
}
func ReadSession(path string) (Session, error) {
var s Session
info, err := os.Stat(path)
if err != nil {
return s, errors.New("session file unavailable")
}
if info.Mode().Perm()&0077 != 0 {
return s, errors.New("session file must have permissions 0600")
}
b, err := os.ReadFile(path)
if err != nil {
return s, errors.New("cannot read session file")
}
if json.Unmarshal(b, &s) != nil {
return s, errors.New("invalid session JSON")
}
return s, ValidateSession(s)
}
func ValidateSession(s Session) error {
if !strings.HasPrefix(s.Authorization, "Bearer ") || len(s.Authorization) <= 7 || strings.ContainsAny(s.Authorization, "\r\n") {
return errors.New("invalid session authorization")
}
seenCookies := map[string]bool{}
for _, cookie := range s.Cookies {
if seenCookies[cookie.Name] {
return errors.New("duplicate session cookie")
}
seenCookies[cookie.Name] = true
if cookie.Name == "token" && "Bearer "+cookie.Value != s.Authorization {
return errors.New("token cookie and bearer mismatch")
}
if cookie.Name != "token" && cookie.Name != "bigs" {
return errors.New("session contains unsupported cookie")
}
if strings.ContainsAny(cookie.Value, "\r\n;") {
return errors.New("invalid session cookie")
}
}
if !seenCookies["token"] {
return errors.New("token cookie is required")
}
return nil
}
func ShanghaiToday(now time.Time) time.Time {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
n := now.In(loc)
return time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, loc)
}
func ParseDate(s string) (time.Time, error) {
return time.ParseInLocation("2006-01-02", s, time.FixedZone("Asia/Shanghai", 8*3600))
}
func ValidateDate(s string, now time.Time) error {
d, e := ParseDate(s)
if e != nil || !d.Before(ShanghaiToday(now)) {
return errors.New("date must be a completed Asia/Shanghai calendar day")
}
return nil
}
type Client struct {
HTTP *http.Client
Session Session
baseURL string // Only tests override this; production credentials stay on the fixed HTTPS origin.
PageSize int
Delay time.Duration
}
func NewClient(s Session) *Client {
return &Client{HTTP: &http.Client{Timeout: 45 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}, Session: s, baseURL: Origin, PageSize: 500, Delay: 500 * time.Millisecond}
}
func wait(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
func (c *Client) page(ctx context.Context, date string, page int) (json.RawMessage, error) {
q := url.Values{"productionCompany": {""}, "operationCompany": {""}, "vin": {""}, "plateNumber": {""}, "brandModel": {""}, "queryBeginDate": {date}, "queryEndDate": {date}, "dateRange[0]": {date}, "dateRange[1]": {date}, "queryType": {"2"}, "showMonthlyColumn": {"false"}, "pageNum": {fmt.Sprint(page)}, "pageSize": {fmt.Sprint(c.PageSize)}}
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
if err := wait(ctx, time.Duration(attempt*attempt)*time.Second); err != nil {
return nil, err
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+MileagePath+"?"+q.Encode(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", c.Session.Authorization)
req.Header.Set("Accept", "application/json")
for _, cookie := range c.Session.Cookies {
req.AddCookie(&http.Cookie{Name: cookie.Name, Value: cookie.Value})
}
resp, err := c.HTTP.Do(req)
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if attempt < 2 {
continue
}
return nil, errors.New("GZEV network request failed")
}
b, readErr := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
resp.Body.Close()
if resp.StatusCode == 401 || resp.StatusCode >= 300 && resp.StatusCode < 400 {
return nil, ErrAuth
}
if resp.StatusCode == 429 || resp.StatusCode >= 500 {
if attempt < 2 {
continue
}
return nil, fmt.Errorf("GZEV temporary HTTP failure: %d", resp.StatusCode)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("GZEV HTTP failure: %d", resp.StatusCode)
}
if readErr != nil {
return nil, errors.New("GZEV response read failed")
}
var envelope struct {
Code json.RawMessage `json:"code"`
}
if json.Unmarshal(b, &envelope) != nil {
return nil, errors.New("GZEV non-JSON response; login or upstream failure")
}
code := strings.Trim(string(envelope.Code), `"`)
if code == "401" {
return nil, ErrAuth
}
if code != "0" {
if code == "00000004" || code == "403" {
return nil, errors.New("GZEV account lacks endpoint permission")
}
return nil, errors.New("GZEV non-success business response")
}
return b, nil
}
return nil, errors.New("GZEV retries exhausted")
}
func (c *Client) FetchDay(ctx context.Context, date string) (Day, error) {
day := Day{Date: date, FetchedAt: time.Now().UTC()}
if err := wait(ctx, c.Delay); err != nil {
return day, err
}
if err := ValidateDate(date, time.Now()); err != nil {
return day, err
}
if c.PageSize < 1 || c.PageSize > 500 {
return day, errors.New("page size must be 1..500")
}
seen := map[string]bool{}
expected := -1
for page := 1; page <= 10000; page++ {
if page > 1 {
if err := wait(ctx, c.Delay); err != nil {
return day, err
}
}
b, err := c.page(ctx, date, page)
if err != nil {
return day, err
}
var env struct {
Data *struct {
PageNum int `json:"pageNum"`
Total *int `json:"total"`
List []json.RawMessage `json:"list"`
} `json:"data"`
}
if json.Unmarshal(b, &env) != nil || env.Data == nil || env.Data.Total == nil || env.Data.PageNum != page {
return day, errors.New("invalid pagination schema")
}
p := env.Data
if expected < 0 {
expected = *p.Total
}
if expected < 0 || expected > 1000000 || *p.Total != expected {
return day, errors.New("source total changed or is invalid; retry day")
}
for _, raw := range p.List {
var row Row
if err := json.Unmarshal(raw, &row); err != nil {
return day, fmt.Errorf("invalid mileage row: %w", err)
}
if len(row.VIN) != 17 || seen[row.VIN] || row.DateRange != date+" - "+date {
return day, errors.New("invalid VIN, duplicate VIN, or non-daily date range")
}
row.Raw = raw
seen[row.VIN] = true
day.Rows = append(day.Rows, row)
}
day.Pages = append(day.Pages, b)
if len(day.Rows) == expected {
return day, nil
}
if len(day.Rows) > expected || len(p.List) == 0 {
return day, errors.New("incomplete or inconsistent pagination")
}
}
return day, errors.New("pagination safety limit exceeded")
}
// VerifyAccount prevents an operator importing another account's valid session into this dataset.
func (c *Client) VerifyAccount(ctx context.Context, account string) error {
q := url.Values{"token": {strings.TrimPrefix(c.Session.Authorization, "Bearer ")}}
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/evdata-sso/api/base/getUserInfo?"+q.Encode(), nil)
if err != nil {
return errors.New("cannot create identity request")
}
req.Header.Set("Authorization", c.Session.Authorization)
for _, cookie := range c.Session.Cookies {
req.AddCookie(&http.Cookie{Name: cookie.Name, Value: cookie.Value})
}
resp, err := c.HTTP.Do(req)
if err != nil {
return errors.New("GZEV identity request failed")
}
defer resp.Body.Close()
if resp.StatusCode == 401 || resp.StatusCode >= 300 && resp.StatusCode < 400 {
return ErrAuth
}
if resp.StatusCode != 200 {
return errors.New("GZEV identity HTTP failure")
}
var body struct {
Code json.RawMessage `json:"code"`
Data struct {
LoginName string `json:"loginname"`
} `json:"data"`
}
if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body) != nil {
return errors.New("invalid identity response")
}
code := strings.Trim(string(body.Code), `"`)
if code == "401" {
return ErrAuth
}
if code != "0" || body.Data.LoginName != account {
return errors.New("GZEV session account does not match configured account, or identity check denied")
}
return nil
}
@@ -0,0 +1,147 @@
package gzev
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestDecimalPreservesNullAndZero(t *testing.T) {
for _, tc := range []struct {
in string
valid bool
value string
}{{"null", false, ""}, {`"0.0"`, true, "0.0"}, {"203.125", true, "203.125"}} {
var d Decimal
if err := json.Unmarshal([]byte(tc.in), &d); err != nil || d.Valid != tc.valid || d.Value != tc.value {
t.Fatalf("%s: %+v %v", tc.in, d, err)
}
}
for _, bad := range []string{`""`, `"NaN"`, `-1`, `"1.2345"`, `"100000000000"`} {
var d Decimal
if json.Unmarshal([]byte(bad), &d) == nil {
t.Fatalf("accepted %s", bad)
}
}
}
func TestDateShanghaiBoundary(t *testing.T) {
now := time.Date(2026, 9, 27, 16, 0, 0, 0, time.UTC)
if ValidateDate("2026-09-27", now) != nil || ValidateDate("2026-09-28", now) == nil {
t.Fatal("Shanghai day cutoff incorrect")
}
}
func TestFetchPaginationAndCookie(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.Header.Get("Authorization") != "Bearer fake" {
t.Error("missing bearer")
}
cookie, e := r.Cookie("token")
if e != nil || cookie.Value != "fake" {
t.Error("missing token cookie")
}
if r.URL.Query().Get("queryBeginDate") != "2024-01-01" || r.URL.Query().Get("queryEndDate") != "2024-01-01" || r.URL.Query().Get("queryType") != "2" {
t.Error("wrong daily query")
}
fmt.Fprintf(w, `{"code":"0","data":{"pageNum":%d,"total":2,"list":[{"vin":"LNXNEGRR7SR31945%d","dateRange":"2024-01-01 - 2024-01-01","runMileage":"12.1","hyRunMileage":null,"chargeRunMileage":"0"}]}}`, calls, calls)
}))
defer srv.Close()
c := NewClient(Session{Authorization: "Bearer fake", Cookies: []SessionCookie{{"token", "fake"}}})
c.baseURL = srv.URL
c.PageSize = 1
c.Delay = 0
day, err := c.FetchDay(context.Background(), "2024-01-01")
if err != nil || len(day.Rows) != 2 || len(day.Pages) != 2 || calls != 2 {
t.Fatalf("pagination: %+v %v", day, err)
}
if day.Rows[0].Hydrogen.Valid || !day.Rows[0].Charge.Valid {
t.Fatal("null lost")
}
}
func TestRejectsIncompleteSource(t *testing.T) {
for name, body := range map[string]string{
"missing code": `{"data":{}}`,
"missing total": `{"code":"0","data":{"pageNum":1,"list":[]}}`,
"empty page": `{"code":"0","data":{"pageNum":1,"total":2,"list":[]}}`,
"wrong date": `{"code":"0","data":{"pageNum":1,"total":1,"list":[{"vin":"LNXNEGRR7SR319456","dateRange":"2024-01-01 - 2024-01-02"}]}}`,
} {
t.Run(name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, body) }))
defer srv.Close()
c := NewClient(Session{})
c.baseURL = srv.URL
if _, err := c.FetchDay(context.Background(), "2024-01-01"); err == nil {
t.Fatal("accepted incomplete day")
}
})
}
}
func TestAuthAndPermissionAreDifferent(t *testing.T) {
for _, tc := range []struct {
code string
auth bool
}{{"401", true}, {"00000004", false}, {"403", false}} {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++; fmt.Fprintf(w, `{"code":%q}`, tc.code) }))
c := NewClient(Session{})
c.baseURL = srv.URL
_, err := c.FetchDay(context.Background(), "2024-01-01")
srv.Close()
if err == nil || errors.Is(err, ErrAuth) != tc.auth || calls != 1 {
t.Fatalf("code %s: %v calls=%d", tc.code, err, calls)
}
}
}
func TestNoCredentialRedirect(t *testing.T) {
hit := false
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hit = true }))
defer target.Close()
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, 302) }))
defer source.Close()
c := NewClient(Session{Authorization: "Bearer fake"})
c.baseURL = source.URL
_, err := c.FetchDay(context.Background(), "2024-01-01")
if hit || !errors.Is(err, ErrAuth) {
t.Fatalf("redirect leaked credentials: %v", err)
}
}
func TestReadSessionPermissionsAndCookieAllowlist(t *testing.T) {
p := filepath.Join(t.TempDir(), "session.json")
body := `{"authorization":"Bearer fake","cookies":[{"name":"token","value":"fake"}]}`
os.WriteFile(p, []byte(body), 0644)
if _, err := ReadSession(p); err == nil {
t.Fatal("accepted world-readable session")
}
os.Chmod(p, 0600)
if _, err := ReadSession(p); err != nil {
t.Fatal(err)
}
os.WriteFile(p, []byte(strings.ReplaceAll(body, `"name":"token"`, `"name":"password"`)), 0600)
if _, err := ReadSession(p); err == nil {
t.Fatal("accepted password cookie")
}
}
func TestVerifyAccountRejectsOtherLogin(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/evdata-sso/api/base/getUserInfo" || r.URL.Query().Get("token") != "fake" {
t.Error("incorrect identity contract")
}
fmt.Fprint(w, `{"code":"0","data":{"loginname":"different-account"}}`)
}))
defer srv.Close()
c := NewClient(Session{Authorization: "Bearer fake"})
c.baseURL = srv.URL
if c.VerifyAccount(context.Background(), "guangdonglingniu") == nil {
t.Fatal("mixed account data")
}
}
@@ -0,0 +1,220 @@
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
}
@@ -0,0 +1,112 @@
package gzev
import (
"context"
"errors"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
func TestDailyReportSnapshotAndMissingDates(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mock.ExpectBegin()
mock.ExpectQuery("SELECT DATE_FORMAT.*FROM gzev_mileage_day").WithArgs("account", "2026-10-20", "2026-10-22").WillReturnRows(sqlmock.NewRows([]string{"date", "success"}).AddRow("2026-10-21", time.Date(2026, 10, 23, 2, 0, 0, 0, time.UTC)).AddRow("2026-10-22", time.Date(2026, 10, 23, 2, 1, 0, 0, time.UTC)))
mock.ExpectQuery("SELECT DATE_FORMAT.*MAX.*FROM gzev_daily_mileage").WithArgs("account", "2026-10-20", "2026-10-22").WillReturnRows(sqlmock.NewRows([]string{"date"}).AddRow("2026-10-21"))
mock.ExpectQuery("SELECT DATE_FORMAT.*COUNT.*FROM gzev_daily_mileage").WithArgs("account", "2026-10-20", "2026-10-22").WillReturnRows(sqlmock.NewRows([]string{"date", "rows", "known"}).AddRow("2026-10-21", 426, 1).AddRow("2026-10-22", 426, 0))
mock.ExpectQuery("SELECT active FROM gzev_auth_alert").WithArgs("account").WillReturnRows(sqlmock.NewRows([]string{"active"}).AddRow(false))
mock.ExpectQuery("SELECT COUNT.*FROM gzev_mileage_attempt").WithArgs("account", "2026-10-20", "2026-10-22").WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectCommit()
r, err := LoadDailyReport(context.Background(), db, "account", "2026-10-20", time.Date(2026, 10, 23, 2, 30, 0, 0, time.UTC))
if err != nil {
t.Fatal(err)
}
if r.Date != "2026-10-23" || r.Expected != 3 || r.Fetched != 2 || len(r.Missing) != 1 || r.Missing[0] != "2026-10-20" || r.LastSuccess != "2026-10-23 10:01:00" || r.RetryDates != 1 {
t.Fatalf("incorrect snapshot: %+v", r)
}
_, body := r.Message()
if !strings.Contains(body, "仍有 1 个日期待采集") || !strings.Contains(body, "| 2026-10-22 | 426 | 0 | 426 |") {
t.Fatal(body)
}
if err = mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestDailyReportConclusionDistinguishesCoverageFromNull(t *testing.T) {
r := DailyReport{Date: "2026-10-23", Through: "2026-10-22", Recent: []ReportDay{{Date: "2026-10-22", Rows: 426, Known: 0}}}
_, body := r.Message()
if !strings.Contains(body, "昨天上游纯氢里程全部为空") {
t.Fatal(body)
}
r.Recent[0].Known = 426
_, body = r.Message()
if !strings.Contains(body, "同步已覆盖全部目标日期") || strings.Contains(body, "昨天上游纯氢里程全部为空") {
t.Fatal(body)
}
r.AuthExpired = true
_, body = r.Message()
if !strings.Contains(body, "登录已失效") {
t.Fatal(body)
}
}
func TestDailyReportDeliveryDeduplicatesAndPreservesAmbiguousAttempt(t *testing.T) {
for _, fail := range []bool{false, true} {
t.Run(map[bool]string{false: "accepted", true: "timeout"}[fail], func(t *testing.T) {
path := filepath.Join(t.TempDir(), "report.json")
calls := 0
send := func() error {
calls++
if fail {
return errors.New("timeout")
}
return nil
}
sent, err := DeliverDailyReport(path, "2026-10-23", send)
if sent == fail || (err != nil) != fail {
t.Fatalf("sent=%v err=%v", sent, err)
}
sent, err = DeliverDailyReport(path, "2026-10-23", send)
if sent || calls != 1 || (err != nil) != fail {
t.Fatalf("duplicate was sent: calls=%d err=%v", calls, err)
}
info, _ := os.Stat(path)
if info.Mode().Perm() != 0600 {
t.Fatal("receipt is not private")
}
if sent, err = DeliverDailyReport(path, "2026-10-24", func() error { calls++; return nil }); !sent || err != nil || calls != 2 {
t.Fatal("next day was blocked")
}
})
}
}
func TestDailyReportPushDoesNotCreateRecoveryTicket(t *testing.T) {
s := ServerChan{SendKey: "SCTtest12345678", SessionPath: filepath.Join(t.TempDir(), "session.json")}
s.HTTP = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
b, _ := io.ReadAll(r.Body)
f, _ := url.ParseQuery(string(b))
if f.Get("title") != "daily report" || f.Get("desp") != "summary" {
t.Fatal("incorrect report")
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"code":0,"data":{"errno":0}}`))}, nil
})}
if err := s.SendMessage(context.Background(), "daily report", "summary"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(s.SessionPath + ".recovery.json"); !os.IsNotExist(err) {
t.Fatal("daily summary created a recovery ticket")
}
}
@@ -0,0 +1,80 @@
package gzev
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"os"
"path/filepath"
"syscall"
"time"
)
type recoveryTicket struct {
Code string `json:"code"`
Expires time.Time `json:"expires"`
}
func recoveryLock(path string, fn func() error) error {
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
f, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
return err
}
defer f.Close()
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
return err
}
defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
return fn()
}
func EnsureRecoveryCode(path string) (string, error) {
var code string
err := recoveryLock(path, func() error {
b, err := os.ReadFile(path)
var ticket recoveryTicket
if err == nil && json.Unmarshal(b, &ticket) == nil && ticket.Expires.After(time.Now()) && len(ticket.Code) == 48 {
code = ticket.Code
return nil
}
nonce := make([]byte, 24)
if _, err := rand.Read(nonce); err != nil {
return err
}
code = hex.EncodeToString(nonce)
ticket = recoveryTicket{Code: code, Expires: time.Now().Add(48 * time.Hour)}
f, err := os.CreateTemp(filepath.Dir(path), ".recovery-*")
if err != nil {
return err
}
defer os.Remove(f.Name())
if err := json.NewEncoder(f).Encode(ticket); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(f.Name(), path)
})
return code, err
}
// ConsumeRecovery serializes all writers and only consumes a ticket after a successful update.
func ConsumeRecovery(path, code string, action func() error) error {
return recoveryLock(path, func() error {
b, err := os.ReadFile(path)
var ticket recoveryTicket
if err != nil || json.Unmarshal(b, &ticket) != nil || !ticket.Expires.After(time.Now()) || len(code) != 48 || subtle.ConstantTimeCompare([]byte(code), []byte(ticket.Code)) != 1 {
return errors.New("invalid recovery code")
}
if err := action(); err != nil {
return err
}
return os.Remove(path)
})
}
@@ -0,0 +1,84 @@
package gzev
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
)
type ServerChan struct {
SendKey, SessionPath, UploadURL string
HTTP *http.Client
}
func ServerChanFromEnv() ServerChan {
path := os.Getenv("GZEV_SESSION_FILE")
if path == "" {
path = "/var/lib/lingniu-gzev/session.json"
}
return ServerChan{SendKey: strings.TrimSpace(os.Getenv("SERVERCHAN_SENDKEY")), SessionPath: path, UploadURL: os.Getenv("GZEV_SESSION_UPLOAD_URL"), HTTP: &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}}
}
func (s ServerChan) Send(ctx context.Context) error { return s.send(ctx, false) }
func (s ServerChan) SendTest(ctx context.Context) error { return s.send(ctx, true) }
func (s ServerChan) send(ctx context.Context, test bool) error {
if !regexp.MustCompile(`^SCT[A-Za-z0-9]{8,253}$`).MatchString(s.SendKey) {
return errors.New("ServerChan SendKey missing or invalid")
}
if s.UploadURL != "https://vehicle.d.lnoneos.com/gzev/session/" {
return errors.New("unsupported recovery URL")
}
code, err := EnsureRecoveryCode(s.SessionPath + ".recovery.json")
if err != nil {
return errors.New("cannot create recovery code")
}
title := "广州里程采集登录失效"
intro := "定时采集前验证未通过,当前采集已暂停。请登录广州平台并提交新的登录状态。"
if test {
title = "广州里程采集推送测试"
intro = "这是 Server酱推送渠道连通性测试,不表示登录状态已失效。以后采集前发现登录失效,将通过此渠道通知。"
}
body := fmt.Sprintf("%s\n\n[打开登录状态上传页面](%s)\n\n通知恢复码:\n\n`%s`\n\n恢复码有效期 48 小时,成功上传后失效。上传页面支持粘贴 token 或上传会话 JSON;验证通过后,下次定时采集自动继续。请勿转发恢复码。", intro, s.UploadURL, code)
return s.SendMessage(ctx, title, body)
}
// SendMessage uses the existing configured channel without creating a recovery ticket.
func (s ServerChan) SendMessage(ctx context.Context, title, body string) error {
if !regexp.MustCompile(`^SCT[A-Za-z0-9]{8,253}$`).MatchString(s.SendKey) {
return errors.New("ServerChan SendKey missing or invalid")
}
form := url.Values{"title": {title}, "desp": {body}}
req, err := http.NewRequestWithContext(ctx, "POST", "https://sctapi.ftqq.com/"+s.SendKey+".send", strings.NewReader(form.Encode()))
if err != nil {
return errors.New("cannot create push request")
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := s.HTTP.Do(req)
if err != nil {
return errors.New("ServerChan transport failure; delivery status may be unknown")
}
defer resp.Body.Close()
var result struct {
Code *int `json:"code"`
Data struct {
Errno *int `json:"errno"`
} `json:"data"`
}
if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result) != nil || resp.StatusCode != 200 || result.Code == nil {
return errors.New("ServerChan did not confirm acceptance")
}
if *result.Code != 0 {
return fmt.Errorf("ServerChan rejected push (code=%d)", *result.Code)
}
if result.Data.Errno != nil && *result.Data.Errno != 0 {
return fmt.Errorf("ServerChan channel error (errno=%d)", *result.Data.Errno)
}
return nil
}
@@ -0,0 +1,59 @@
package gzev
import (
"context"
"io"
"net/http"
"net/url"
"path/filepath"
"strings"
"testing"
)
func TestServerChanPushAndAcceptance(t *testing.T) {
for _, response := range []string{`{"code":0,"data":{"errno":0}}`, `{"code":40001}`, `{}`, `{"code":0,"data":{"errno":1}}`} {
t.Run(response, func(t *testing.T) {
calls := 0
s := ServerChan{SendKey: "SCTtest12345678", SessionPath: filepath.Join(t.TempDir(), "session.json"), UploadURL: "https://vehicle.d.lnoneos.com/gzev/session/"}
s.HTTP = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
calls++
if r.Method != "POST" || r.URL.Host != "sctapi.ftqq.com" || r.URL.Path != "/SCTtest12345678.send" {
t.Fatal("wrong endpoint")
}
b, _ := io.ReadAll(r.Body)
form, _ := url.ParseQuery(string(b))
if form.Get("title") != "广州里程采集登录失效" || !strings.Contains(form.Get("desp"), s.UploadURL) || !strings.Contains(form.Get("desp"), "通知恢复码") {
t.Fatal("missing actionable content")
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(response)), Header: http.Header{}}, nil
})}
err := s.Send(context.Background())
if (err == nil) != (response == `{"code":0,"data":{"errno":0}}`) || calls != 1 {
t.Fatalf("unexpected acceptance: %v", err)
}
if err != nil && strings.Contains(err.Error(), s.SendKey) {
t.Fatal("key leaked")
}
})
}
}
func TestServerChanMissingKeyDoesNotCreateTicket(t *testing.T) {
s := ServerChan{SessionPath: filepath.Join(t.TempDir(), "session.json")}
if s.Send(context.Background()) == nil {
t.Fatal("unconfigured notification accepted")
}
}
func TestServerChanTestIsClearlyLabeled(t *testing.T) {
s := ServerChan{SendKey: "SCTtest12345678", SessionPath: filepath.Join(t.TempDir(), "session.json"), UploadURL: "https://vehicle.d.lnoneos.com/gzev/session/"}
s.HTTP = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
b, _ := io.ReadAll(r.Body)
f, _ := url.ParseQuery(string(b))
if !strings.Contains(f.Get("title"), "测试") || !strings.Contains(f.Get("desp"), "不表示登录状态已失效") {
t.Fatal("false expiry alert")
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"code":0}`)), Header: http.Header{}}, nil
})}
if err := s.SendTest(context.Background()); err != nil {
t.Fatal(err)
}
}
@@ -0,0 +1,122 @@
package gzev
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strings"
"time"
)
type SMS struct {
AccessKeyID, AccessKeySecret, SecurityToken, SignName, TemplateCode, TemplateParam string
HTTP *http.Client
}
func SMSFromEnv() SMS {
return SMS{AccessKeyID: os.Getenv("ALIBABA_CLOUD_ACCESS_KEY_ID"), AccessKeySecret: os.Getenv("ALIBABA_CLOUD_ACCESS_KEY_SECRET"), SecurityToken: os.Getenv("ALIBABA_CLOUD_SECURITY_TOKEN"), SignName: os.Getenv("GZEV_SMS_SIGN_NAME"), TemplateCode: os.Getenv("GZEV_SMS_TEMPLATE_CODE"), TemplateParam: os.Getenv("GZEV_SMS_TEMPLATE_PARAM"), HTTP: &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}}
}
func hashHex(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }
// ACS3 native HTTP signing follows Alibaba Cloud's published V3 specification.
func signACS3(req *http.Request, payload, accessKey, secret string) {
req.Header.Set("x-acs-content-sha256", hashHex([]byte(payload)))
headers := map[string]string{"host": req.URL.Host}
for key, values := range req.Header {
k := strings.ToLower(key)
if k == "content-type" || strings.HasPrefix(k, "x-acs-") {
headers[k] = strings.TrimSpace(strings.Join(values, ","))
}
}
names := make([]string, 0, len(headers))
for k := range headers {
names = append(names, k)
}
sort.Strings(names)
var canonical strings.Builder
for _, k := range names {
fmt.Fprintf(&canonical, "%s:%s\n", k, headers[k])
}
signed := strings.Join(names, ";")
canonicalRequest := req.Method + "\n/\n" + req.URL.RawQuery + "\n" + canonical.String() + "\n" + signed + "\n" + hashHex([]byte(payload))
stringToSign := "ACS3-HMAC-SHA256\n" + hashHex([]byte(canonicalRequest))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(stringToSign))
req.Header.Set("Authorization", "ACS3-HMAC-SHA256 Credential="+accessKey+",SignedHeaders="+signed+",Signature="+hex.EncodeToString(mac.Sum(nil)))
}
func (s SMS) Send(ctx context.Context) error {
if s.AccessKeyID == "" || s.AccessKeySecret == "" || s.SignName == "" || s.TemplateCode == "" {
return errors.New("Aliyun SMS not configured")
}
param := s.TemplateParam
if param == "" {
param = "{}"
}
var params map[string]string
if json.Unmarshal([]byte(param), &params) != nil || params == nil {
return errors.New("SMS TemplateParam must be a JSON string map")
}
// Template must contain these two variables for a user-actionable recovery message.
uploadURL := os.Getenv("GZEV_SESSION_UPLOAD_URL")
if uploadURL != "" {
if uploadURL != "https://vehicle.d.lnoneos.com/gzev/session/" {
return errors.New("unsupported recovery URL")
}
path := os.Getenv("GZEV_SESSION_FILE")
if path == "" {
path = "/var/lib/lingniu-gzev/session.json"
}
code, err := EnsureRecoveryCode(path + ".recovery.json")
if err != nil {
return errors.New("cannot create recovery code")
}
params["upload_url"] = uploadURL
params["recovery_code"] = code
data, err := json.Marshal(params)
if err != nil {
return err
}
param = string(data)
}
form := url.Values{"PhoneNumbers": {"16605225837"}, "SignName": {s.SignName}, "TemplateCode": {s.TemplateCode}, "TemplateParam": {param}}
payload := form.Encode()
req, err := http.NewRequestWithContext(ctx, "POST", "https://dysmsapi.aliyuncs.com/", strings.NewReader(payload))
if err != nil {
return err
}
nonce := make([]byte, 16)
if _, err = rand.Read(nonce); err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("x-acs-action", "SendSms")
req.Header.Set("x-acs-version", "2017-05-25")
req.Header.Set("x-acs-date", time.Now().UTC().Format("2006-01-02T15:04:05Z"))
req.Header.Set("x-acs-signature-nonce", hex.EncodeToString(nonce))
if s.SecurityToken != "" {
req.Header.Set("x-acs-security-token", s.SecurityToken)
}
signACS3(req, payload, s.AccessKeyID, s.AccessKeySecret)
resp, err := s.HTTP.Do(req)
if err != nil {
return errors.New("SMS transport failure; delivery status may be unknown")
}
defer resp.Body.Close()
var result struct {
Code string `json:"Code"`
}
if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result) != nil || resp.StatusCode != 200 || result.Code != "OK" {
return errors.New("SMS provider did not confirm acceptance")
}
return nil
}
@@ -0,0 +1,73 @@
package gzev
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"path/filepath"
"strings"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestSMSRequestAndBusinessFailure(t *testing.T) {
for _, code := range []string{"OK", "isv.BUSINESS_LIMIT_CONTROL"} {
calls := 0
s := SMS{AccessKeyID: "test-id", AccessKeySecret: "test-secret", SecurityToken: "sts-token", SignName: "测试签名", TemplateCode: "SMS_TEST", TemplateParam: `{"system":"广州里程"}`}
s.HTTP = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
calls++
b, _ := io.ReadAll(r.Body)
form, _ := url.ParseQuery(string(b))
if r.URL.String() != "https://dysmsapi.aliyuncs.com/" || form.Get("PhoneNumbers") != "16605225837" || form.Get("TemplateCode") != "SMS_TEST" {
t.Fatal("wrong SMS destination")
}
if r.Header.Get("x-acs-content-sha256") != hashHex(b) || r.Header.Get("x-acs-action") != "SendSms" || !strings.Contains(r.Header.Get("Authorization"), "x-acs-security-token") {
t.Fatal("incorrect signed request")
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"Code":"` + code + `"}`)), Header: http.Header{}}, nil
})}
err := s.Send(context.Background())
if (err == nil) != (code == "OK") || calls != 1 {
t.Fatalf("%s: %v", code, err)
}
}
}
func TestSMSMissingConfigDoesNotSend(t *testing.T) {
if (SMS{}).Send(context.Background()) == nil {
t.Fatal("missing config reported success")
}
}
func TestSMSIncludesUploadURLAndSingleUseRecovery(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.json")
t.Setenv("GZEV_SESSION_FILE", path)
t.Setenv("GZEV_SESSION_UPLOAD_URL", "https://vehicle.d.lnoneos.com/gzev/session/")
s := SMS{AccessKeyID: "id", AccessKeySecret: "secret", SignName: "sign", TemplateCode: "SMS_TEST"}
var code string
s.HTTP = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
b, _ := io.ReadAll(r.Body)
form, _ := url.ParseQuery(string(b))
var params map[string]string
if json.Unmarshal([]byte(form.Get("TemplateParam")), &params) != nil {
t.Fatal("invalid template params")
}
if params["upload_url"] != "https://vehicle.d.lnoneos.com/gzev/session/" || len(params["recovery_code"]) != 48 {
t.Fatal("missing actionable recovery information")
}
code = params["recovery_code"]
return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"Code":"OK"}`))}, nil
})}
if err := s.Send(context.Background()); err != nil {
t.Fatal(err)
}
if err := ConsumeRecovery(path+".recovery.json", code, func() error { return nil }); err != nil {
t.Fatal("SMS code not usable")
}
if err := ConsumeRecovery(path+".recovery.json", code, func() error { return nil }); err == nil {
t.Fatal("SMS code reused")
}
}
@@ -0,0 +1,168 @@
package gzev
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
)
var ErrBusy = errors.New("another GZEV worker holds the account lock")
type Store struct {
Conn *sql.Conn
Account string
}
// WithLock uses a dedicated connection: MySQL advisory locks are connection scoped.
func WithLock(ctx context.Context, db *sql.DB, account string, fn func(*Store) error) error {
conn, err := db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
sum := sha256.Sum256([]byte(account))
key := "gzev:" + hex.EncodeToString(sum[:24])
var ok int
if err := conn.QueryRowContext(ctx, "SELECT GET_LOCK(?,0)", key).Scan(&ok); err != nil {
return err
}
if ok != 1 {
return ErrBusy
}
defer func() {
releaseCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn.ExecContext(releaseCtx, "SELECT RELEASE_LOCK(?)", key)
}()
return fn(&Store{Conn: conn, Account: account})
}
func (s *Store) NeedsDay(ctx context.Context, date string, recent bool) (bool, error) {
var cooling bool
err := s.Conn.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM gzev_mileage_attempt WHERE account=? AND stat_date=? AND retry_after>UTC_TIMESTAMP(3))", s.Account, date).Scan(&cooling)
if err != nil || cooling {
return false, err
}
if recent {
return true, nil
}
var retry bool
err = s.Conn.QueryRowContext(ctx, "SELECT missing_count>0 AND last_success_at<UTC_TIMESTAMP()-INTERVAL 7 DAY FROM gzev_mileage_day WHERE account=? AND stat_date=?", s.Account, date).Scan(&retry)
if err == sql.ErrNoRows {
return true, nil
}
return retry, err
}
func (s *Store) RecordFailure(ctx context.Context, date string) error {
_, err := s.Conn.ExecContext(ctx, `INSERT INTO gzev_mileage_attempt(account,stat_date,retry_after) VALUES(?,?,UTC_TIMESTAMP()+INTERVAL 6 HOUR) ON DUPLICATE KEY UPDATE retry_after=VALUES(retry_after)`, s.Account, date)
return err
}
func (s *Store) SaveDay(ctx context.Context, d Day) error {
if err := ValidateDate(d.Date, time.Now()); err != nil {
return err
}
if len(d.Rows) == 0 {
return errors.New("empty source day requires review; not marked complete")
}
raw, err := json.Marshal(d.Pages)
if err != nil {
return err
}
hash := sha256.Sum256(raw)
checksum := hex.EncodeToString(hash[:])
tx, err := s.Conn.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Reject source regressions rather than replacing a previously measured day with nulls.
previous, err := tx.QueryContext(ctx, "SELECT vin,total_km IS NOT NULL,hydrogen_km IS NOT NULL,charge_km IS NOT NULL FROM gzev_daily_mileage WHERE account=? AND stat_date=?", s.Account, d.Date)
if err != nil {
return err
}
old := map[string][3]bool{}
for previous.Next() {
var vin string
var flags [3]bool
if err := previous.Scan(&vin, &flags[0], &flags[1], &flags[2]); err != nil {
previous.Close()
return err
}
old[vin] = flags
}
err = previous.Err()
previous.Close()
if err != nil {
return err
}
for _, r := range d.Rows {
flags := old[r.VIN]
if flags[0] && !r.Total.Valid || flags[1] && !r.Hydrogen.Valid || flags[2] && !r.Charge.Valid {
return errors.New("source regressed measured mileage to null; previous day retained")
}
delete(old, r.VIN)
}
if len(old) > 0 {
return errors.New("source vehicle list shrank; previous day retained for review")
}
_, err = tx.ExecContext(ctx, "INSERT IGNORE INTO gzev_mileage_snapshot(account,stat_date,snapshot_hash,fetched_at,raw_pages) VALUES(?,?,?,?,?)", s.Account, d.Date, checksum, d.FetchedAt.UTC(), string(raw))
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, "DELETE FROM gzev_daily_mileage WHERE account=? AND stat_date=?", s.Account, d.Date)
if err != nil {
return err
}
missing := 0
for _, r := range d.Rows {
status := "reported"
if !r.Total.Valid || !r.Hydrogen.Valid || !r.Charge.Valid {
status = "missing"
missing++
}
_, err = tx.ExecContext(ctx, `INSERT INTO gzev_daily_mileage(account,vin,stat_date,plate_number,vehicle_seq,production_company,operation_company,brand,model,total_km,hydrogen_km,charge_km,quality_status,raw_row,fetched_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, s.Account, r.VIN, d.Date, r.Plate, r.VehicleSeq, r.ProductionCompany, r.OperationCompany, r.Brand, r.Model, r.Total.SQLValue(), r.Hydrogen.SQLValue(), r.Charge.SQLValue(), status, string(r.Raw), d.FetchedAt.UTC())
if err != nil {
return err
}
}
_, err = tx.ExecContext(ctx, `INSERT INTO gzev_mileage_day(account,stat_date,row_count,missing_count,last_success_at,snapshot_hash) VALUES(?,?,?,?,?,?) ON DUPLICATE KEY UPDATE row_count=VALUES(row_count),missing_count=VALUES(missing_count),last_success_at=VALUES(last_success_at),snapshot_hash=VALUES(snapshot_hash)`, s.Account, d.Date, len(d.Rows), missing, d.FetchedAt.UTC(), checksum)
if err != nil {
return err
}
if _, err = tx.ExecContext(ctx, "DELETE FROM gzev_mileage_attempt WHERE account=? AND stat_date=?", s.Account, d.Date); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) ResolveAuth(ctx context.Context) error {
_, err := s.Conn.ExecContext(ctx, "UPDATE gzev_auth_alert SET active=FALSE,resolved_at=UTC_TIMESTAMP(3) WHERE account=? AND active=TRUE", s.Account)
return err
}
// AuthFailure persists delivery state. Retry at most hourly and remind at most daily.
// A provider timeout is ambiguous: no immediate retry to avoid duplicate notifications.
func (s *Store) AuthFailure(ctx context.Context, send func(context.Context) error) error {
_, err := s.Conn.ExecContext(ctx, `INSERT INTO gzev_auth_alert(account,first_failed_at) VALUES(?,UTC_TIMESTAMP(3)) ON DUPLICATE KEY UPDATE first_failed_at=IF(active,first_failed_at,UTC_TIMESTAMP(3)),last_attempt_at=IF(active,last_attempt_at,NULL),last_sent_at=IF(active,last_sent_at,NULL),resolved_at=NULL,active=TRUE`, s.Account)
if err != nil {
return err
}
var due bool
err = s.Conn.QueryRowContext(ctx, `SELECT (last_attempt_at IS NULL OR last_attempt_at<=UTC_TIMESTAMP()-INTERVAL 1 HOUR) AND (last_sent_at IS NULL OR last_sent_at<=UTC_TIMESTAMP()-INTERVAL 24 HOUR) FROM gzev_auth_alert WHERE account=?`, s.Account).Scan(&due)
if err != nil || !due {
return err
}
_, err = s.Conn.ExecContext(ctx, "UPDATE gzev_auth_alert SET last_attempt_at=UTC_TIMESTAMP(3) WHERE account=?", s.Account)
if err != nil {
return err
}
if err = send(ctx); err != nil {
_, persistErr := s.Conn.ExecContext(ctx, "UPDATE gzev_auth_alert SET last_error='Notification delivery failed or not configured' WHERE account=?", s.Account)
return errors.Join(fmt.Errorf("auth notification: %w", err), persistErr)
}
_, err = s.Conn.ExecContext(ctx, "UPDATE gzev_auth_alert SET last_sent_at=UTC_TIMESTAMP(3),last_error='' WHERE account=?", s.Account)
return err
}
@@ -0,0 +1,79 @@
package gzev
import (
"context"
"encoding/json"
"errors"
"github.com/DATA-DOG/go-sqlmock"
"testing"
"time"
)
func TestSaveDayRollsBackIfAnyRowFails(t *testing.T) {
db, mock, _ := sqlmock.New()
defer db.Close()
conn, _ := db.Conn(context.Background())
defer conn.Close()
store := Store{conn, "account"}
mock.ExpectBegin()
mock.ExpectQuery("SELECT vin").WithArgs("account", "2024-01-01").WillReturnRows(sqlmock.NewRows([]string{"vin", "total", "hydrogen", "charge"}))
mock.ExpectExec("INSERT IGNORE INTO gzev_mileage_snapshot").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("DELETE FROM gzev_daily_mileage").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("INSERT INTO gzev_daily_mileage").WillReturnError(errors.New("disk full"))
mock.ExpectRollback()
d := Day{Date: "2024-01-01", FetchedAt: time.Now(), Pages: []json.RawMessage{json.RawMessage(`{}`)}, Rows: []Row{{VIN: "LNXNEGRR7SR319456", Raw: json.RawMessage(`{}`)}}}
if store.SaveDay(context.Background(), d) == nil {
t.Fatal("expected transaction failure")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestSaveRejectsNullRegression(t *testing.T) {
db, mock, _ := sqlmock.New()
defer db.Close()
conn, _ := db.Conn(context.Background())
defer conn.Close()
store := Store{conn, "account"}
mock.ExpectBegin()
mock.ExpectQuery("SELECT vin").WillReturnRows(sqlmock.NewRows([]string{"vin", "total", "hydrogen", "charge"}).AddRow("LNXNEGRR7SR319456", true, false, false))
mock.ExpectRollback()
if store.SaveDay(context.Background(), Day{Date: "2024-01-01", Rows: []Row{{VIN: "LNXNEGRR7SR319456"}}}) == nil {
t.Fatal("overwrote measured value with null")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestAuthNotificationCooldown(t *testing.T) {
db, mock, _ := sqlmock.New()
defer db.Close()
conn, _ := db.Conn(context.Background())
defer conn.Close()
store := Store{conn, "account"}
mock.ExpectExec("INSERT INTO gzev_auth_alert").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery("SELECT .*last_attempt_at").WillReturnRows(sqlmock.NewRows([]string{"due"}).AddRow(false))
if err := store.AuthFailure(context.Background(), func(context.Context) error { t.Fatal("duplicate SMS sent"); return nil }); err != nil {
t.Fatal(err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestAuthNotificationFailureIsPersisted(t *testing.T) {
db, mock, _ := sqlmock.New()
defer db.Close()
conn, _ := db.Conn(context.Background())
defer conn.Close()
store := Store{conn, "account"}
mock.ExpectExec("INSERT INTO gzev_auth_alert").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery("SELECT .*last_attempt_at").WillReturnRows(sqlmock.NewRows([]string{"due"}).AddRow(true))
mock.ExpectExec("UPDATE gzev_auth_alert SET last_attempt_at").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE gzev_auth_alert SET last_error").WillReturnResult(sqlmock.NewResult(0, 1))
if err := store.AuthFailure(context.Background(), func(context.Context) error { return errors.New("not configured") }); err == nil {
t.Fatal("lost delivery failure")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
@@ -0,0 +1,161 @@
package gzev
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// UploadHandler accepts local SSH access or the configured HTTPS origin with a one-use recovery code.
type UploadHandler struct {
PublicOrigin string
RecoveryPath string
SessionPath string
Account string
Verify func(context.Context, Session, string) error
csrf string
}
func NewUploadHandler(path, account string) (*UploadHandler, error) {
nonce := make([]byte, 32)
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
return &UploadHandler{SessionPath: path, Account: account, csrf: hex.EncodeToString(nonce), Verify: func(ctx context.Context, s Session, a string) error { return NewClient(s).VerifyAccount(ctx, a) }}, nil
}
func (h *UploadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
// Keep the Origin header on same-origin form POSTs; no-referrer makes
// browsers send Origin: null, which the strict check below must reject.
w.Header().Set("Referrer-Policy", "same-origin")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
local := r.Host == "127.0.0.1:20312" || r.Host == "localhost:20312"
public := h.PublicOrigin != "" && "https://"+r.Host == h.PublicOrigin
if !local && !public {
http.Error(w, "仅允许通过 SSH 安全连接访问", 403)
return
}
if r.URL.Path != "/" && r.URL.Path != "/gzev/session/" {
http.NotFound(w, r)
return
}
if r.Method == http.MethodGet {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
uploadPage.Execute(w, map[string]string{"Account": h.Account, "CSRF": h.csrf, "Public": fmt.Sprint(h.PublicOrigin != "")})
return
}
if r.Method != http.MethodPost {
w.Header().Set("Allow", "GET, POST")
http.Error(w, "Method not allowed", 405)
return
}
expectedOrigin := "http://" + r.Host
if public {
expectedOrigin = h.PublicOrigin
}
if r.Header.Get("Origin") != expectedOrigin {
http.Error(w, "请求来源不正确,请刷新上传页面", 403)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
if err := r.ParseMultipartForm(32 << 10); err != nil {
http.Error(w, "文件或表单无效,大小不能超过 32 KB", 400)
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(h.csrf)) != 1 {
http.Error(w, "页面已过期,请刷新后重新提交", 403)
return
}
var s Session
file, _, fileErr := r.FormFile("session")
if fileErr == nil {
defer file.Close()
dec := json.NewDecoder(file)
dec.DisallowUnknownFields()
if err := dec.Decode(&s); err != nil {
http.Error(w, "会话文件格式错误", 400)
return
}
if dec.Decode(new(any)) != io.EOF {
http.Error(w, "会话文件含多余内容", 400)
return
}
} else if fileErr == http.ErrMissingFile {
token := strings.TrimSpace(r.FormValue("token"))
token = strings.TrimPrefix(token, "Bearer ")
s = Session{Authorization: "Bearer " + token, Cookies: []SessionCookie{{Name: "token", Value: token}}}
if routing := strings.TrimSpace(r.FormValue("routing")); routing != "" {
s.Cookies = append(s.Cookies, SessionCookie{Name: "bigs", Value: routing})
}
} else {
http.Error(w, "无法读取上传文件", 400)
return
}
if ValidateSession(s) != nil {
http.Error(w, "会话格式错误,只接受 token 和可选的 bigs Cookie", 400)
return
}
action := func() error {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
if err := h.Verify(ctx, s, h.Account); err != nil {
return errors.New("verification failed")
}
return WriteSession(h.SessionPath, s)
}
var err error
if h.PublicOrigin != "" {
err = ConsumeRecovery(h.RecoveryPath, r.FormValue("recovery_code"), action)
} else {
err = action()
}
if err != nil {
http.Error(w, "验证或保存未通过。请检查恢复码、登录状态及账号;现有会话未覆盖。", 422)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
io.WriteString(w, "<!doctype html><html lang=zh-CN><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'><title>更新成功</title><body><h1>登录状态已更新</h1><p>验证通过,下一次定时采集将自动继续补齐里程数据。</p><a href=/>返回</a></body></html>")
}
func WriteSession(path string, s Session) error {
if err := ValidateSession(s); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
f, err := os.CreateTemp(filepath.Dir(path), ".session-*")
if err != nil {
return err
}
name := f.Name()
defer os.Remove(name)
if err := json.NewEncoder(f).Encode(s); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(name, path)
}
var uploadPage = template.Must(template.New("upload").Parse(`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>更新广州平台登录状态</title><style>body{font:16px/1.6 system-ui,sans-serif;background:#f4f6f8;color:#14212e;margin:0;padding:32px 16px}main{max-width:620px;margin:0 auto;background:white;padding:32px;border-radius:16px}h1{font-size:26px}label{display:block;margin-top:18px}input{display:block;box-sizing:border-box;width:100%;padding:12px;border:1px solid #aab5c0;border-radius:6px;font:inherit}button{margin-top:24px;background:#125acf;color:white;border:0;border-radius:8px;padding:12px 24px;font:inherit}small{color:#536273}details{margin-top:24px}hr{border:0;border-top:1px solid #ddd;margin:24px 0}</style></head><body><main><h1>更新广州平台登录状态</h1><p>请先登录广州平台,账号:<strong>{{.Account}}</strong>。</p><p>提交后会验证账号和登录状态。验证通过后,下一次定时采集自动继续。</p><form method="post" enctype="multipart/form-data" autocomplete="off"><input type="hidden" name="csrf" value="{{.CSRF}}">{{if eq .Public "true"}}<label>通知恢复码<input type="password" name="recovery_code" required autocomplete="off" maxlength="48"></label>{{end}}<label>粘贴 token<input type="password" name="token" autocomplete="off" maxlength="8192"></label><label>bigs Cookie(可选)<input type="password" name="routing" autocomplete="off" maxlength="4096"></label><hr><label>或上传会话 JSON 文件<input type="file" name="session" accept=".json,application/json"></label><small>上传文件时优先使用文件内容。不要提交账号密码或短信验证码。</small><br><button type="submit">验证并更新</button></form><details><summary>如何取得 token?</summary><ol><li>在 Chrome 登录广州平台,完成必要的短信和图形验证码。</li><li>打开开发者工具 → Application → Cookies → gzev.d.lnoneos.com。</li><li>复制 token 的值;如有 bigs,也一并复制。</li><li>回到此页面粘贴并提交。</li></ol></details></main></body></html>`))
@@ -0,0 +1,102 @@
package gzev
import (
"bytes"
"context"
"errors"
"mime/multipart"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func uploadRequest(t *testing.T, h *UploadHandler, origin, code string) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
form := multipart.NewWriter(&body)
form.WriteField("csrf", h.csrf)
form.WriteField("token", "new-session")
form.WriteField("recovery_code", code)
form.Close()
req := httptest.NewRequest("POST", "https://vehicle.d.lnoneos.com/gzev/session/", &body)
req.Header.Set("Content-Type", form.FormDataContentType())
req.Header.Set("Origin", origin)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestPublicUploadRequiresCodeAndConsumesIt(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.json")
h, _ := NewUploadHandler(path, "expected")
h.PublicOrigin = "https://vehicle.d.lnoneos.com"
h.RecoveryPath = path + ".recovery.json"
calls := 0
h.Verify = func(_ context.Context, s Session, account string) error {
calls++
if account != "expected" || s.Authorization != "Bearer new-session" {
t.Fatal("wrong account or session")
}
return nil
}
if r := uploadRequest(t, h, h.PublicOrigin, ""); r.Code != 422 || calls != 0 {
t.Fatal("missing code reached verification")
}
code, err := EnsureRecoveryCode(h.RecoveryPath)
if err != nil {
t.Fatal(err)
}
for _, origin := range []string{"https://attacker.invalid", "null", ""} {
if r := uploadRequest(t, h, origin, code); r.Code != 403 || calls != 0 {
t.Fatalf("untrusted origin %q accepted", origin)
}
}
if r := uploadRequest(t, h, h.PublicOrigin, code); r.Code != 200 {
t.Fatalf("upload failed: %d %s", r.Code, r.Body.String())
}
session, err := ReadSession(path)
if err != nil || session.Authorization != "Bearer new-session" {
t.Fatal("session was not securely saved")
}
if r := uploadRequest(t, h, h.PublicOrigin, code); r.Code != 422 || calls != 1 {
t.Fatal("reused code accepted")
}
}
func TestInvalidSessionDoesNotOverwriteOrConsume(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.json")
os.WriteFile(path, []byte("original"), 0600)
h, _ := NewUploadHandler(path, "expected")
h.PublicOrigin = "https://vehicle.d.lnoneos.com"
h.RecoveryPath = path + ".recovery.json"
h.Verify = func(context.Context, Session, string) error { return errors.New("expired credential") }
code, _ := EnsureRecoveryCode(h.RecoveryPath)
r := uploadRequest(t, h, h.PublicOrigin, code)
if r.Code != 422 {
t.Fatal(r.Code)
}
b, _ := os.ReadFile(path)
if string(b) != "original" {
t.Fatal("overwrote valid previous file")
}
if _, err := os.Stat(h.RecoveryPath); err != nil {
t.Fatal("failed upload consumed recovery code")
}
if strings.Contains(r.Body.String(), "new-session") {
t.Fatal("response leaked token")
}
}
func TestUploadRejectsUntrustedHostAndHidesToken(t *testing.T) {
h, _ := NewUploadHandler(filepath.Join(t.TempDir(), "session.json"), "expected")
r := httptest.NewRecorder()
h.ServeHTTP(r, httptest.NewRequest("GET", "http://attacker.invalid/", nil))
if r.Code != 403 {
t.Fatal(r.Code)
}
r = httptest.NewRecorder()
h.ServeHTTP(r, httptest.NewRequest("GET", "http://127.0.0.1:20312/", nil))
if r.Code != 200 || r.Header().Get("Cache-Control") != "no-store" || r.Header().Get("Referrer-Policy") != "same-origin" || !strings.Contains(r.Body.String(), "验证并更新") {
t.Fatal("upload UI unavailable")
}
}