chore: snapshot production code before Apple Design UI refinement
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
package dailygeo
|
||||
|
||||
import "math"
|
||||
|
||||
func WGS84ToGCJ02(longitude float64, latitude float64) (float64, float64) {
|
||||
if longitude < 72.004 || longitude > 137.8347 || latitude < 0.8293 || latitude > 55.8271 {
|
||||
return longitude, latitude
|
||||
}
|
||||
const semiMajorAxis = 6378245.0
|
||||
const eccentricitySquared = 0.006693421622965943
|
||||
longitudeOffset := transformGCJLongitude(longitude-105, latitude-35)
|
||||
latitudeOffset := transformGCJLatitude(longitude-105, latitude-35)
|
||||
radianLatitude := latitude / 180 * math.Pi
|
||||
magic := 1 - eccentricitySquared*math.Pow(math.Sin(radianLatitude), 2)
|
||||
squareRootMagic := math.Sqrt(magic)
|
||||
convertedLatitude := latitude + latitudeOffset*180/((semiMajorAxis*(1-eccentricitySquared))/(magic*squareRootMagic)*math.Pi)
|
||||
convertedLongitude := longitude + longitudeOffset*180/(semiMajorAxis/squareRootMagic*math.Cos(radianLatitude)*math.Pi)
|
||||
return convertedLongitude, convertedLatitude
|
||||
}
|
||||
|
||||
func transformGCJLatitude(longitude float64, latitude float64) float64 {
|
||||
value := -100 + 2*longitude + 3*latitude + 0.2*latitude*latitude + 0.1*longitude*latitude + 0.2*math.Sqrt(math.Abs(longitude))
|
||||
value += (20*math.Sin(6*longitude*math.Pi) + 20*math.Sin(2*longitude*math.Pi)) * 2 / 3
|
||||
value += (20*math.Sin(latitude*math.Pi) + 40*math.Sin(latitude/3*math.Pi)) * 2 / 3
|
||||
value += (160*math.Sin(latitude/12*math.Pi) + 320*math.Sin(latitude*math.Pi/30)) * 2 / 3
|
||||
return value
|
||||
}
|
||||
|
||||
func transformGCJLongitude(longitude float64, latitude float64) float64 {
|
||||
value := 300 + longitude + 2*latitude + 0.1*longitude*longitude + 0.1*longitude*latitude + 0.1*math.Sqrt(math.Abs(longitude))
|
||||
value += (20*math.Sin(6*longitude*math.Pi) + 20*math.Sin(2*longitude*math.Pi)) * 2 / 3
|
||||
value += (20*math.Sin(longitude*math.Pi) + 40*math.Sin(longitude/3*math.Pi)) * 2 / 3
|
||||
value += (150*math.Sin(longitude/12*math.Pi) + 300*math.Sin(longitude/30*math.Pi)) * 2 / 3
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package dailygeo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Address struct{ Province, City, Region, Adcode string }
|
||||
type Resolver interface {
|
||||
Resolve(context.Context, float64, float64) (Address, error)
|
||||
}
|
||||
type AMap struct {
|
||||
Key, BaseURL string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
func (a *AMap) Resolve(ctx context.Context, lng, lat float64) (Address, error) {
|
||||
if a.Key == "" {
|
||||
return Address{}, fmt.Errorf("AMAP_API_KEY is not configured")
|
||||
}
|
||||
base := a.BaseURL
|
||||
if base == "" {
|
||||
base = "https://restapi.amap.com"
|
||||
}
|
||||
u, e := url.Parse(base + "/v3/geocode/regeo")
|
||||
if e != nil {
|
||||
return Address{}, fmt.Errorf("invalid geocoder URL")
|
||||
}
|
||||
x, y := WGS84ToGCJ02(lng, lat)
|
||||
q := u.Query()
|
||||
q.Set("key", a.Key)
|
||||
q.Set("location", fmt.Sprintf("%.6f,%.6f", x, y))
|
||||
q.Set("extensions", "base")
|
||||
q.Set("radius", "1000")
|
||||
u.RawQuery = q.Encode()
|
||||
req, e := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if e != nil {
|
||||
return Address{}, fmt.Errorf("invalid geocoder request")
|
||||
}
|
||||
client := a.Client
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
resp, e := client.Do(req)
|
||||
if e != nil {
|
||||
return Address{}, fmt.Errorf("geocoder request failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return Address{}, fmt.Errorf("geocoder HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Status string
|
||||
Infocode string
|
||||
Regeocode struct {
|
||||
AddressComponent struct{ Province, City, District, Adcode json.RawMessage } `json:"addressComponent"`
|
||||
}
|
||||
}
|
||||
if e = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); e != nil {
|
||||
return Address{}, fmt.Errorf("invalid geocoder response")
|
||||
}
|
||||
if body.Status != "1" {
|
||||
return Address{}, fmt.Errorf("geocoder rejected: %s", body.Infocode)
|
||||
}
|
||||
p, c := textValue(body.Regeocode.AddressComponent.Province), textValue(body.Regeocode.AddressComponent.City)
|
||||
if c == "" {
|
||||
switch p {
|
||||
case "北京市", "上海市", "天津市", "重庆市", "香港特别行政区", "澳门特别行政区":
|
||||
c = p
|
||||
}
|
||||
}
|
||||
// Province-administered county-level cities (for example 潜江市) are
|
||||
// returned in district, with city=[], by AMap.
|
||||
if c == "" && p != "" {
|
||||
district := textValue(body.Regeocode.AddressComponent.District)
|
||||
if strings.HasSuffix(district, "市") {
|
||||
c = district
|
||||
}
|
||||
}
|
||||
if p == "" || c == "" {
|
||||
return Address{}, fmt.Errorf("geocoder returned incomplete province/city")
|
||||
}
|
||||
return Address{p, c, Region(p), textValue(body.Regeocode.AddressComponent.Adcode)}, nil
|
||||
}
|
||||
func textValue(raw json.RawMessage) string {
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
return s
|
||||
}
|
||||
var ss []string
|
||||
if json.Unmarshal(raw, &ss) == nil {
|
||||
return strings.Join(ss, "")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func Region(province string) string {
|
||||
groups := []struct {
|
||||
name string
|
||||
provinces []string
|
||||
}{
|
||||
{"华东", []string{"上海", "江苏", "浙江", "安徽", "福建", "江西", "山东", "台湾"}},
|
||||
{"华北", []string{"北京", "天津", "河北", "山西", "内蒙古"}},
|
||||
{"华中", []string{"河南", "湖北", "湖南"}},
|
||||
{"华南", []string{"广东", "广西", "海南", "香港", "澳门"}},
|
||||
{"东北", []string{"辽宁", "吉林", "黑龙江"}},
|
||||
{"西南", []string{"重庆", "四川", "贵州", "云南", "西藏"}},
|
||||
{"西北", []string{"陕西", "甘肃", "青海", "宁夏", "新疆"}},
|
||||
}
|
||||
for _, g := range groups {
|
||||
for _, p := range g.provinces {
|
||||
if strings.HasPrefix(province, p) {
|
||||
return g.name
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package dailygeo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var Shanghai = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
var identifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
|
||||
type Point struct {
|
||||
VIN, Protocol string
|
||||
Time time.Time
|
||||
Longitude, Latitude float64
|
||||
}
|
||||
type Worker struct {
|
||||
RequestInterval time.Duration
|
||||
DB, TD *sql.DB
|
||||
Database string
|
||||
Resolver Resolver
|
||||
Concurrency int
|
||||
requestMu sync.Mutex
|
||||
nextRequest time.Time
|
||||
}
|
||||
|
||||
func New(db, td *sql.DB, database string, r Resolver) *Worker {
|
||||
return &Worker{DB: db, TD: td, Database: database, Resolver: r, Concurrency: 3}
|
||||
}
|
||||
func validPoint(p Point) bool {
|
||||
return p.VIN != "" && !(p.Longitude == -0.999999 && p.Latitude == -0.999999) && !p.Time.IsZero() && p.Longitude != 0 && p.Latitude != 0 && !math.IsNaN(p.Longitude) && !math.IsNaN(p.Latitude) && math.Abs(p.Longitude) <= 180 && math.Abs(p.Latitude) <= 90
|
||||
}
|
||||
func newer(a, b Point) bool {
|
||||
return a.Time.After(b.Time) || (a.Time.Equal(b.Time) && a.Protocol < b.Protocol)
|
||||
}
|
||||
|
||||
// Last() is applied only to non-null valid coordinates, partitioned by VIN and
|
||||
// protocol. All selected columns therefore refer to the same final source row.
|
||||
func (w *Worker) RefreshDay(ctx context.Context, date string) (int, error) {
|
||||
day, e := time.ParseInLocation("2006-01-02", date, Shanghai)
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
if w.TD == nil || !identifier.MatchString(w.Database) {
|
||||
return 0, fmt.Errorf("historical location store unavailable")
|
||||
}
|
||||
q := fmt.Sprintf(`SELECT vin,protocol,LAST(ts),LAST(longitude),LAST(latitude) FROM %s.vehicle_locations WHERE ts >= '%s' AND ts < '%s' AND NOT (longitude = -0.999999 AND latitude = -0.999999) AND longitude <> 0 AND longitude BETWEEN -180 AND 180 AND latitude <> 0 AND latitude BETWEEN -90 AND 90 PARTITION BY vin,protocol`, w.Database, day.Format(time.RFC3339), day.AddDate(0, 0, 1).Format(time.RFC3339))
|
||||
rows, e := w.TD.QueryContext(ctx, q)
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
points := map[string]Point{}
|
||||
for rows.Next() {
|
||||
var p Point
|
||||
if e = rows.Scan(&p.VIN, &p.Protocol, &p.Time, &p.Longitude, &p.Latitude); e != nil {
|
||||
rows.Close()
|
||||
return 0, e
|
||||
}
|
||||
if !validPoint(p) || p.Time.In(Shanghai).Format("2006-01-02") != date {
|
||||
continue
|
||||
}
|
||||
if old, ok := points[p.VIN]; !ok || newer(p, old) {
|
||||
points[p.VIN] = p
|
||||
}
|
||||
}
|
||||
e = rows.Err()
|
||||
rows.Close()
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
// Repair the device's invalid-location sentinel before applying real points.
|
||||
// Clearing its timestamp allows an earlier valid point from this day to win.
|
||||
_, e = w.DB.ExecContext(ctx, `UPDATE vehicle_daily_geography SET longitude=NULL,latitude=NULL,location_time=NULL,province='',city='',region='',adcode='',status='NO_LOCATION',attempts=0,last_error='',next_attempt_at=NULL,resolved_at=NULL WHERE stat_date=? AND longitude=-0.999999 AND latitude=-0.999999`, date)
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
for _, p := range points {
|
||||
if e = w.SavePoint(ctx, p); e != nil {
|
||||
return 0, e
|
||||
}
|
||||
}
|
||||
// A successful historical lookup with no point is distinct from an unavailable
|
||||
// history service. Never record NO_LOCATION when the lookup itself failed.
|
||||
_, e = w.DB.ExecContext(ctx, `INSERT IGNORE INTO vehicle_daily_geography(vin,stat_date,status) SELECT vin,stat_date,'NO_LOCATION' FROM vehicle_open_daily_energy WHERE energy_type='HYDROGEN' AND stat_date=?`, date)
|
||||
return len(points), e
|
||||
}
|
||||
|
||||
const savePointSQL = `INSERT INTO vehicle_daily_geography(vin,stat_date,longitude,latitude,location_time,source_protocol,status) VALUES(?,?,?,?,?,?,'PENDING') ON DUPLICATE KEY UPDATE
|
||||
province=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',province),
|
||||
city=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',city),
|
||||
region=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',region),
|
||||
adcode=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',adcode),
|
||||
status=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'PENDING',status),
|
||||
attempts=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),0,attempts),
|
||||
next_attempt_at=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),NULL,next_attempt_at),
|
||||
last_error=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',last_error),
|
||||
resolved_at=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),NULL,resolved_at),
|
||||
longitude=IF(location_time IS NULL OR VALUES(location_time)>location_time,VALUES(longitude),longitude),
|
||||
latitude=IF(location_time IS NULL OR VALUES(location_time)>location_time,VALUES(latitude),latitude),
|
||||
source_protocol=IF(location_time IS NULL OR VALUES(location_time)>location_time,VALUES(source_protocol),source_protocol),
|
||||
location_time=IF(location_time IS NULL OR VALUES(location_time)>location_time,VALUES(location_time),location_time)`
|
||||
|
||||
func (w *Worker) SavePoint(ctx context.Context, p Point) error {
|
||||
if !validPoint(p) {
|
||||
return fmt.Errorf("invalid historical location")
|
||||
}
|
||||
_, e := w.DB.ExecContext(ctx, savePointSQL, p.VIN, p.Time.In(Shanghai).Format("2006-01-02"), math.Round(p.Longitude*1e6)/1e6, math.Round(p.Latitude*1e6)/1e6, p.Time.In(Shanghai), p.Protocol)
|
||||
return e
|
||||
}
|
||||
|
||||
type pending struct {
|
||||
VIN, Date string
|
||||
Longitude, Latitude float64
|
||||
}
|
||||
|
||||
func (w *Worker) ResolvePending(ctx context.Context, from, to string, limit int) (int, int, error) {
|
||||
if limit <= 0 || limit > 5000 {
|
||||
limit = 500
|
||||
}
|
||||
rows, e := w.DB.QueryContext(ctx, `SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),longitude,latitude FROM vehicle_daily_geography WHERE stat_date BETWEEN ? AND ? AND status IN ('PENDING','ERROR') AND (next_attempt_at IS NULL OR next_attempt_at<=NOW(3)) ORDER BY stat_date DESC,vin LIMIT ?`, from, to, limit)
|
||||
if e != nil {
|
||||
return 0, 0, e
|
||||
}
|
||||
items := []pending{}
|
||||
for rows.Next() {
|
||||
var p pending
|
||||
if e = rows.Scan(&p.VIN, &p.Date, &p.Longitude, &p.Latitude); e != nil {
|
||||
rows.Close()
|
||||
return 0, 0, e
|
||||
}
|
||||
items = append(items, p)
|
||||
}
|
||||
e = rows.Err()
|
||||
rows.Close()
|
||||
if e != nil {
|
||||
return 0, 0, e
|
||||
}
|
||||
jobs := make(chan pending, len(items))
|
||||
for _, p := range items {
|
||||
jobs <- p
|
||||
}
|
||||
close(jobs)
|
||||
count := w.Concurrency
|
||||
if count < 1 {
|
||||
count = 1
|
||||
}
|
||||
if count > 8 {
|
||||
count = 8
|
||||
}
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
ok, failed := 0, 0
|
||||
var first error
|
||||
for i := 0; i < count; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for p := range jobs {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
e := w.resolveOne(ctx, p)
|
||||
mu.Lock()
|
||||
if e == nil {
|
||||
ok++
|
||||
} else {
|
||||
failed++
|
||||
if first == nil {
|
||||
first = e
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if ctx.Err() != nil {
|
||||
return ok, failed, ctx.Err()
|
||||
}
|
||||
return ok, failed, first
|
||||
}
|
||||
func (w *Worker) resolveOne(ctx context.Context, p pending) error {
|
||||
var a Address
|
||||
e := w.DB.QueryRowContext(ctx, `SELECT province,city,region,adcode FROM vehicle_geography_cache WHERE longitude=? AND latitude=? AND resolved_at>=DATE_SUB(NOW(),INTERVAL 180 DAY)`, p.Longitude, p.Latitude).Scan(&a.Province, &a.City, &a.Region, &a.Adcode)
|
||||
if e != nil && e != sql.ErrNoRows {
|
||||
return e
|
||||
}
|
||||
if e == sql.ErrNoRows {
|
||||
// At most three external calls in flight by default; quota/network failures
|
||||
// are persisted and retried with backoff, never exported as a guessed city.
|
||||
w.requestMu.Lock()
|
||||
delay := time.Until(w.nextRequest)
|
||||
if delay > 0 {
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
w.requestMu.Unlock()
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
interval := w.RequestInterval
|
||||
if interval <= 0 {
|
||||
interval = 20 * time.Millisecond
|
||||
}
|
||||
w.nextRequest = time.Now().Add(interval)
|
||||
w.requestMu.Unlock()
|
||||
rctx, cancel := context.WithTimeout(ctx, 12*time.Second)
|
||||
a, e = w.Resolver.Resolve(rctx, p.Longitude, p.Latitude)
|
||||
cancel()
|
||||
if e != nil {
|
||||
_, saveErr := w.DB.ExecContext(ctx, `UPDATE vehicle_daily_geography SET status='ERROR',attempts=attempts+1,last_error=?,next_attempt_at=DATE_ADD(NOW(3),INTERVAL LEAST(3600,60*POW(2,LEAST(attempts,6))) SECOND) WHERE vin=? AND stat_date=? AND longitude=? AND latitude=?`, e.Error(), p.VIN, p.Date, p.Longitude, p.Latitude)
|
||||
if saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
return e
|
||||
}
|
||||
_, e = w.DB.ExecContext(ctx, `INSERT INTO vehicle_geography_cache(longitude,latitude,province,city,region,adcode) VALUES(?,?,?,?,?,?) ON DUPLICATE KEY UPDATE province=VALUES(province),city=VALUES(city),region=VALUES(region),adcode=VALUES(adcode),resolved_at=NOW(3)`, p.Longitude, p.Latitude, a.Province, a.City, a.Region, a.Adcode)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
_, e = w.DB.ExecContext(ctx, `UPDATE vehicle_daily_geography SET province=?,city=?,region=?,adcode=?,status='RESOLVED',last_error='',attempts=0,next_attempt_at=NULL,resolved_at=NOW(3) WHERE vin=? AND stat_date=? AND longitude=? AND latitude=?`, a.Province, a.City, a.Region, a.Adcode, p.VIN, p.Date, p.Longitude, p.Latitude)
|
||||
return e
|
||||
}
|
||||
|
||||
// An advisory lock is tied to this dedicated connection and released even on
|
||||
// cancellation/crash, preventing multiple API replicas from running the loop.
|
||||
func (w *Worker) WithLock(ctx context.Context, name string, fn func() error) error {
|
||||
conn, e := w.DB.Conn(ctx)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer conn.Close()
|
||||
var acquired int
|
||||
if e = conn.QueryRowContext(ctx, `SELECT GET_LOCK(?,0)`, name).Scan(&acquired); e != nil {
|
||||
return e
|
||||
}
|
||||
if acquired != 1 {
|
||||
return fmt.Errorf("daily geography worker already running")
|
||||
}
|
||||
defer conn.ExecContext(context.Background(), `SELECT RELEASE_LOCK(?)`, name)
|
||||
return fn()
|
||||
}
|
||||
func (w *Worker) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
round := 0
|
||||
for {
|
||||
e := w.WithLock(ctx, "vehicle_daily_geography_live", func() error {
|
||||
today := time.Now().In(Shanghai)
|
||||
date := today.Format("2006-01-02")
|
||||
rctx, c := context.WithTimeout(ctx, 50*time.Second)
|
||||
defer c()
|
||||
if _, e := w.RefreshDay(rctx, date); e != nil {
|
||||
return e
|
||||
}
|
||||
if round%60 == 0 {
|
||||
for d := 1; d <= 2; d++ {
|
||||
if _, e := w.RefreshDay(rctx, today.AddDate(0, 0, -d).Format("2006-01-02")); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
n, failed, e := w.ResolvePending(rctx, today.AddDate(0, 0, -2).Format("2006-01-02"), date, 500)
|
||||
log.Printf("daily_geography resolved=%d failed=%d", n, failed)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
// Historical retry is independent of the live queue. The same lock used
|
||||
// by the CLI prevents duplicate external calls during a bulk backfill.
|
||||
_ = w.WithLock(rctx, "vehicle_daily_geography_backfill", func() error {
|
||||
n, failed, e := w.ResolvePending(rctx, "2000-01-01", today.AddDate(0, 0, -3).Format("2006-01-02"), 200)
|
||||
if n+failed > 0 {
|
||||
log.Printf("daily_geography_history_retry resolved=%d failed=%d error=%v", n, failed, e)
|
||||
}
|
||||
return e
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if e != nil {
|
||||
log.Printf("daily_geography: %v", e)
|
||||
}
|
||||
round++
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package dailygeo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type resolverFunc func(context.Context, float64, float64) (Address, error)
|
||||
|
||||
func (f resolverFunc) Resolve(c context.Context, x, y float64) (Address, error) { return f(c, x, y) }
|
||||
func TestAMapMunicipalityAndRegion(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("key") != "test-key" {
|
||||
t.Error("missing credential")
|
||||
}
|
||||
w.Write([]byte(`{"status":"1","regeocode":{"addressComponent":{"province":"上海市","city":[],"adcode":"310101"}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
a, e := (&AMap{Key: "test-key", BaseURL: server.URL}).Resolve(context.Background(), 121.47, 31.23)
|
||||
if e != nil || a.Province != "上海市" || a.City != "上海市" || a.Region != "华东" {
|
||||
t.Fatalf("address=%+v error=%v", a, e)
|
||||
}
|
||||
for p, want := range map[string]string{"广东省": "华南", "四川省": "西南", "陕西省": "西北", "山西省": "华北", "湖北省": "华中", "吉林省": "东北", "": ""} {
|
||||
if Region(p) != want {
|
||||
t.Fatal(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestAMapQuotaFailureDoesNotLeakKey(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"status":"0","infocode":"10021"}`)) }))
|
||||
defer server.Close()
|
||||
_, e := (&AMap{Key: "secret-value", BaseURL: server.URL}).Resolve(context.Background(), 121, 31)
|
||||
if e == nil || !strings.Contains(e.Error(), "10021") || strings.Contains(e.Error(), "secret-value") {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
func TestCachedCoordinateNeedsNoGeocoding(t *testing.T) {
|
||||
db, m, e := sqlmock.New()
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer db.Close()
|
||||
m.ExpectQuery("SELECT province,city,region,adcode").WithArgs(121.0, 31.0).WillReturnRows(sqlmock.NewRows([]string{"province", "city", "region", "adcode"}).AddRow("上海市", "上海市", "华东", "310000"))
|
||||
m.ExpectExec("UPDATE vehicle_daily_geography SET province=.*WHERE vin=\\? AND stat_date=\\? AND longitude=\\? AND latitude=\\?").WithArgs("上海市", "上海市", "华东", "310000", "VIN1", "2026-08-31", 121.0, 31.0).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
w := New(db, nil, "", resolverFunc(func(context.Context, float64, float64) (Address, error) {
|
||||
t.Fatal("cache hit must not call external API")
|
||||
return Address{}, nil
|
||||
}))
|
||||
if e = w.resolveOne(context.Background(), pending{"VIN1", "2026-08-31", 121, 31}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = m.ExpectationsWereMet(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
func TestResolverFailurePersistsRetryInsteadOfFalseUnknown(t *testing.T) {
|
||||
db, m, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
m.ExpectQuery("SELECT province,city,region,adcode").WillReturnError(sql.ErrNoRows)
|
||||
m.ExpectExec("UPDATE vehicle_daily_geography SET status='ERROR'.*next_attempt_at=.*WHERE vin=\\? AND stat_date=\\? AND longitude=\\? AND latitude=\\?").WithArgs("quota", "VIN1", "2026-08-31", 121.0, 31.0).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
w := New(db, nil, "", resolverFunc(func(context.Context, float64, float64) (Address, error) { return Address{}, errors.New("quota") }))
|
||||
if e := w.resolveOne(context.Background(), pending{"VIN1", "2026-08-31", 121, 31}); e == nil {
|
||||
t.Fatal("expected failure")
|
||||
}
|
||||
if e := m.ExpectationsWereMet(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
func TestHistoricalFailureDoesNotMarkMissingLocation(t *testing.T) {
|
||||
db, m, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
td, tm, _ := sqlmock.New()
|
||||
defer td.Close()
|
||||
tm.ExpectQuery("SELECT vin,protocol,LAST").WillReturnError(errors.New("historical store unavailable"))
|
||||
w := New(db, td, "test_ts", nil)
|
||||
if _, e := w.RefreshDay(context.Background(), "2026-08-31"); e == nil {
|
||||
t.Fatal("expected history error")
|
||||
}
|
||||
if e := m.ExpectationsWereMet(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e := tm.ExpectationsWereMet(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
func TestHistoryUsesLatestSourceInsideBusinessDate(t *testing.T) {
|
||||
db, m, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
td, tm, _ := sqlmock.New()
|
||||
defer td.Close()
|
||||
early := time.Date(2026, 8, 31, 2, 0, 0, 0, Shanghai)
|
||||
late := early.Add(20 * time.Hour)
|
||||
tm.ExpectQuery("SELECT vin,protocol,LAST.*2026-08-31T00:00:00\\+08:00.*2026-09-01T00:00:00\\+08:00.*PARTITION BY vin,protocol").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "ts", "longitude", "latitude"}).AddRow("VIN1", "GB32960", late, 121.0, 31.0).AddRow("VIN1", "JT808", early, 113.0, 23.0).AddRow("VIN2", "JT808", late.Add(5*time.Hour), 110.0, 20.0))
|
||||
m.ExpectExec("UPDATE vehicle_daily_geography SET longitude=NULL").WithArgs("2026-08-31").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
m.ExpectExec(regexp.QuoteMeta(savePointSQL)).WithArgs("VIN1", "2026-08-31", 121.0, 31.0, late, "GB32960").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
m.ExpectExec("INSERT IGNORE INTO vehicle_daily_geography").WithArgs("2026-08-31").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
w := New(db, td, "test_ts", nil)
|
||||
n, e := w.RefreshDay(context.Background(), "2026-08-31")
|
||||
if e != nil || n != 1 {
|
||||
t.Fatalf("n=%d error=%v", n, e)
|
||||
}
|
||||
if e = m.ExpectationsWereMet(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = tm.ExpectationsWereMet(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvinceAdministeredCity(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"status":"1","regeocode":{"addressComponent":{"province":"湖北省","city":[],"district":"潜江市","adcode":"429005"}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
a, e := (&AMap{Key: "test", BaseURL: server.URL}).Resolve(context.Background(), 112.807848, 30.381828)
|
||||
if e != nil || a.City != "潜江市" || a.Region != "华中" {
|
||||
t.Fatalf("address=%+v error=%v", a, e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidLocationSentinel(t *testing.T) {
|
||||
p := Point{VIN: "VIN1", Time: time.Now(), Longitude: -0.999999, Latitude: -0.999999}
|
||||
if validPoint(p) {
|
||||
t.Fatal("invalid device sentinel accepted")
|
||||
}
|
||||
p.Longitude, p.Latitude = 114.3, 30.5
|
||||
if !validPoint(p) {
|
||||
t.Fatal("valid location rejected")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user