chore: commit mileage updates and accumulated project artifacts

This commit is contained in:
lingniu
2026-09-21 09:15:54 +08:00
parent 6cc872ceb6
commit 8b32ae2988
767 changed files with 572885 additions and 17 deletions
@@ -0,0 +1,52 @@
# GB32960 旧来源基线重复累计修复与数据重算 — 2026-09-18
已部署 `mileage-baseline-reset-20260918`,修复实时统计和历史回补的基线选取,并在备份后重算受影响数据。
## 线上结果
| 车辆 | 日期 | 协议 | 修复后日里程 km | 说明 |
|---|---|---|---:|---|
| 粤AGH6240 | 2026-09-08 | GB32960 | 0.2 | 原记录保留 |
| 粤AGH6240 | 2026-09-15 | GB32960 | 0 | 原 35042.9;改用 09-09 的 75429.6 基线,日末仍为 75429.6 |
| 粤AGH6240 | 2026-09-15 | JT808 | 0.1 | 未修改;中台默认去重选择该非零来源,因此默认页面结果为 0.1 |
| 粤AGG5043 | 2026-09-17 | GB32960 | 70.7 | 原 308.2 包含重复累计;42077 − 42006.3 = 70.7 |
这些结果已通过中台 HTTP 查询和公网开放接口验证;公网单日及区间分页都返回 NORMAL。此前将粤AGG5043 的 308.2 判为正确的结论已被本次完整基线核查纠正。
## 代码与规则
- GB32960 的总里程以 VIN 对应的车辆里程表为基准。旧转发来源恢复时,查找该车此前最新日期的有效来源记录(同来源或中台已选中的来源),同日优先采用已选中记录,避免因 IP 不同退回几个月前的基线。
- 实时 Writer 和 stats-backfill 共用修正后的数据库基线查询。回补同时检查窗口前的跨转发来源历史,与持久化日统计比较后采用较新的自然日基线。
- JT808、MQTT 保留原来的来源隔离。GPS 估算、协议未确认的历史导入和质量不通过的记录,不作为 GB32960 跨来源基线。
- 保留累计回退、负值抖动和按实际统计窗口计算的异常上限检查。
- 新增受控修复工具 mileage-baseline-repair:默认只读计划;应用时要求备份清单,逐条核验旧值和生产基线,在同一事务中更新来源记录及正式日统计,发现并发变化即回滚。
## 重算范围与备份
扫描 33,681 条有终端证据的 GB32960 来源记录,修正 304 辆车、1,268 条来源记录,重建 1,263 个车辆日结果。第一批 1,265 条,重投影后发现另外 3 条选源关联基线,一并修正;再次检查剩余候选为 0。没有改动原始报文或累计读数。
操作期间暂停统计 Writer 和相关定时任务,备份、重算及发布完成后已恢复。
备份目录:`/opt/lingniu-go-native/backups/mileage-baseline-reset-20260918`。
- `mileage-tables.sql.gz`:两张里程统计表的一致性快照,32,438,655 bytes,gzip 完整性校验通过。
- vehicle_daily_mileage:1,462,363 行;vehicle_daily_mileage_source:1,646,422 行。
- SHA-256:`f918217fa1d613fe6d70ef8fee3f8413ca8320f9d3cee8c1e6942a0d850898b2`。
- `apply-plan-0.json`、`apply-plan-1.json` 保留逐行修改前内容与新基线。
- 需要恢复时应在独立库还原备份并按受影响行清单恢复,不直接覆盖已恢复实时写入的整张生产表。
## 验证与已知限制
- `go test ./internal/stats ./cmd/stats-backfill ./cmd/stat-writer ./cmd/mileage-reproject` 通过。
- 新增返回旧来源的 35042.9 重复累计、来源切换增量 70.7、累计回退仍异常、JT808 来源隔离等回归。
- 全量 `go test ./...` 中,未修改的 internal/identity 包测试 `TestJT808RegistrationProjectorThrottlesLocationOnlyAfterPersist` 失败(throttled facts=1,预期 0),单独重试同样失败。该包未在本次更改或部署,不将全量测试报告为通过。
- 生产 Repository 验证覆盖 1,024 辆车、09-08~09-17 共 6,867 条结果,平台日统计差异 0、单日/区间差异 0;3 条 outside_daily_range 异常仍保留。
- 公网与中台 HTTP 实际响应保存在 http-verification.json;临时测试应用 75 已停用并清除车辆授权。
- 新 Writer readyz 正常,运行二进制 SHA 与发布产物一致;三协议各分区 Kafka lag=0,三个 worker 重试队列均为 0,发布后无 error 级日志。
- 中台默认“非零优先”的跨协议选择规则未改变,因此应在相同协议下比较中台与开放接口。BI 历史缓存未在本次清理。
## 发布
新目录:`/opt/lingniu-go-native/releases/mileage-baseline-reset-20260918`。
旧目录:`/opt/lingniu-go-native/releases/mileage-reconciliation-20260916`。
更新 stat-writer、stats-backfill,并附带修复工具;其他运行组件沿用原二进制。开放 API 继续使用已发布的中台日统计对齐逻辑,无须再次修改车辆日里程响应代码。
@@ -0,0 +1 @@
{"elapsed":"4.31959758s","normal":6864,"platformDailyMismatches":0,"platformRows":4915,"qualities":{"":4925,"CARRIED_FORWARD":1939,"outside_daily_range":3},"reconciliationMismatches":0,"rows":6867,"samples":{},"singleRangeDifferences":0,"vehicles":1024}
@@ -0,0 +1,11 @@
{
"started_at": "2026-09-18T11:57:06+0800",
"tables": {
"vehicle_daily_mileage": 1462363,
"vehicle_daily_mileage_source": 1646422
},
"database": "lingniu_vehicle_data",
"sha256": "f918217fa1d613fe6d70ef8fee3f8413ca8320f9d3cee8c1e6942a0d850898b2",
"bytes": 32438655,
"finished_at": "2026-09-18T12:00:11+0800"
}
@@ -0,0 +1,50 @@
"""Consistent logical backup of the two mileage tables; no DB writes."""
import gzip, hashlib, json, os, pathlib, time
import sys;sys.path.insert(0,"/tmp/codex-mileage-diag-pymysql.zip");sys.path.insert(0,"/tmp")
import pymysql
from db_access import connect
os.umask(0o077)
root=pathlib.Path('/opt/lingniu-go-native/backups/mileage-baseline-reset-20260918')
root.mkdir(parents=True,exist_ok=True)
path=root/'mileage-tables.sql.gz'
if path.exists(): raise RuntimeError('Backup already exists; do not overwrite')
c=connect(); manifest={'started_at':time.strftime('%Y-%m-%dT%H:%M:%S%z'),'tables':{}}
with c.cursor() as q:
q.execute('SELECT DATABASE() AS db'); db=q.fetchone()['db'];manifest['database']=db
q.execute('SHOW FULL TABLES'); entries=[r for r in q.fetchall() if list(r.values())[0] in ('vehicle_daily_mileage_source','vehicle_daily_mileage')]
tables=[list(r.values())[0] for r in entries if list(r.values())[1]=='BASE TABLE']
triggers=[]
q.execute('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY')
def literal(v):
if isinstance(v,bytes): return '0x'+v.hex()
return c.escape(v)
with gzip.open(str(path)+'.partial','wt',encoding='utf-8',compresslevel=3) as f:
f.write('SET NAMES utf8mb4;\nSET FOREIGN_KEY_CHECKS=0;\n')
for table in tables:
q.execute('SHOW CREATE TABLE `'+table+'`');ddl=list(q.fetchone().values())[1]
f.write('DROP TABLE IF EXISTS `'+table+'`;\n'+ddl+';\n')
q.execute('SHOW COLUMNS FROM `'+table+'`');cols=[r['Field'] for r in q.fetchall() if 'GENERATED' not in r['Extra']]
names=','.join('`'+v+'`' for v in cols);count=0
with c.cursor(pymysql.cursors.SSCursor) as stream:
stream.execute('SELECT '+names+' FROM `'+table+'`')
while True:
rows=stream.fetchmany(500)
if not rows:break
f.write('INSERT INTO `'+table+'` ('+names+') VALUES\n'+',\n'.join('('+','.join(literal(v) for v in row)+')' for row in rows)+';\n')
count+=len(rows)
manifest['tables'][table]=count
print(json.dumps({'table':table,'rows':count}),flush=True)
for row in entries:
if list(row.values())[1]=='VIEW':
name=list(row.values())[0];q.execute('SHOW CREATE VIEW `'+name+'`');ddl=q.fetchone()['Create View'];f.write(ddl+';\n')
for trigger in triggers:
q.execute('SHOW CREATE TRIGGER `'+trigger['Trigger']+'`');ddl=q.fetchone()['SQL Original Statement'];f.write('DELIMITER ;;\n'+ddl+';;\nDELIMITER ;\n')
f.write('SET FOREIGN_KEY_CHECKS=1;\n')
c.rollback();c.close()
os.rename(str(path)+'.partial',path)
h=hashlib.sha256()
with path.open('rb') as f:
for block in iter(lambda:f.read(1024*1024),b''):h.update(block)
manifest.update(sha256=h.hexdigest(),bytes=path.stat().st_size,finished_at=time.strftime('%Y-%m-%dT%H:%M:%S%z'))
(root/'backup-manifest.json').write_text(json.dumps(manifest,indent=2))
print(json.dumps({'complete':True,'bytes':manifest['bytes'],'sha256':manifest['sha256']}),flush=True)
@@ -0,0 +1 @@
{"release": "mileage-baseline-reset-20260918", "readyz": {"checks": {"mysql": {"status": "ok"}}, "service": "vehicle-stat-writer", "status": "ok", "time": "2026-09-18T04:02:21Z"}, "runningSHA256": "eab37f206a97c71471f02ca9cfbab8dc0378159c6a4dbb2f0e769eeb0196d45c", "restoredServices": {"lingniu-go-stat-writer.service": true, "lingniu-go-stats-backfill.timer": true, "lingniu-vehicle-open-stat.timer": true}}
@@ -0,0 +1 @@
{"sourceRowsScanned": 33681, "changedSourceRows": 1265, "vehicles": 304, "selectedChanges": 850, "newInvalid": 0, "sample": [{"plate": "\u7ca4AGH6240", "date": "2026-07-08", "old": "18151.000", "new": "18149.200", "baselineDate": "2026-04-24 12:15:31", "status": "OK"}, {"plate": "\u7ca4AGH6240", "date": "2026-09-15", "old": "35042.900", "new": "0.000", "baselineDate": "2026-09-09 19:09:35", "status": "OK"}, {"plate": "\u7ca4AGH6240", "date": "2026-09-16", "old": "5.000", "new": "5.000", "baselineDate": "2026-09-15 11:27:37", "status": "OK"}, {"plate": "\u7ca4AGG5043", "date": "2026-08-15", "old": "175.100", "new": "102.000", "baselineDate": "2026-08-14 23:27:19", "status": "OK"}, {"plate": "\u7ca4AGG5043", "date": "2026-08-16", "old": "177.600", "new": "110.200", "baselineDate": "2026-08-15 23:10:39", "status": "OK"}, {"plate": "\u7ca4AGG5043", "date": "2026-08-17", "old": "54.400", "new": "54.400", "baselineDate": "2026-08-16 22:01:11", "status": "OK"}, {"plate": "\u7ca4AGG5043", "date": "2026-08-18", "old": "73.400", "new": "42.500", "baselineDate": "2026-08-17 23:26:58", "status": "OK"}, {"plate": "\u7ca4AGG5043", "date": "2026-08-19", "old": "50.000", "new": "0.100", "baselineDate": "2026-08-18 22:18:06", "status": "OK"}, {"plate": "\u7ca4AGG5043", "date": "2026-09-12", "old": "2248.900", "new": "51.900", "baselineDate": "2026-09-11 11:01:12", "status": "OK"}, {"plate": "\u7ca4AGG5043", "date": "2026-09-17", "old": "308.200", "new": "70.700", "baselineDate": "2026-09-16 16:36:30", "status": "OK"}]}
@@ -0,0 +1 @@
{"sourceRowsScanned": 33683, "changedSourceRows": 0, "vehicles": 0, "selectedChanges": 0, "newInvalid": 0, "sample": []}
@@ -0,0 +1,45 @@
# coding: utf-8
import subprocess,json,urllib.request,urllib.error,datetime
pid=subprocess.check_output(['systemctl','show','-p','MainPID','lingniu-vehicle-platform'],universal_newlines=True).strip().split('=',1)[1]
env=dict(x.decode().split('=',1) for x in open('/proc/'+pid+'/environ','rb').read().split(b'\0') if b'=' in x)
admin_token=next(x['token'] for x in json.loads(env['AUTH_TOKENS_JSON']) if x.get('role')=='admin')
def call(base,path,token,data=None,method=None):
req=urllib.request.Request(base+path,data=None if data is None else json.dumps(data).encode(),headers={'Authorization':'Bearer '+token,'Content-Type':'application/json'},method=method)
with urllib.request.urlopen(req,timeout=25) as r:return json.load(r)
def admin(path,data=None,method=None):
return call('http://127.0.0.1:20300','/api/v2/open-platform/apps'+path,admin_token,data,method)['data']
expiry=(datetime.datetime.now(datetime.timezone.utc)+datetime.timedelta(minutes=15)).isoformat()
app_input=dict(name='release-smoke-mileage-baseline-reset-20260918',status='enabled',validFrom='2026-09-08T00:00:00+08:00',validTo=expiry)
app=admin('',app_input,'POST');aid=str(app['id']);key=app['appKey'];out={'temporaryAppId':app['id']}
try:
admin('/'+aid+'/vehicles',{'vehicles':[dict(vin=v,validFrom=app_input['validFrom'],validTo=expiry) for v in ['LB9A32A26R0LS1055','LB9A32A29R0LS1065']]},'PUT')
out['single']=[];out['platform']=[]
for plate,vin,day,want in [('粤AGH6240','LB9A32A26R0LS1055','2026-09-08',0.2),('粤AGH6240','LB9A32A26R0LS1055','2026-09-15',0),('粤AGG5043','LB9A32A29R0LS1065','2026-09-17',70.7)]:
body={'date':day,'plateNumbers':[plate],'protocolPriority':['GB32960']}
single=call('https://open.d.lnoneos.com','/api/v1/vehicles/mileage/query',key,body)
item=single['data'][0]
assert item['dailyMileageKm']==want and item['status']=='NORMAL' and not item.get('dataQuality'),single
out['single'].append(single)
path='/api/mileage/daily?vin='+vin+'&dateFrom='+day+'&dateTo='+day+'&protocol=GB32960&limit=10'
platform=call('http://127.0.0.1:20300',path,admin_token)
out['platform'].append(platform)
query={'startDate':'2026-09-08','endDate':'2026-09-17','plateNumbers':['粤AGH6240'],'protocolPriority':['GB32960'],'pageSize':3}
pages=[]
for i in range(5):
page=call('https://open.d.lnoneos.com','/api/v1/vehicles/mileage/range/query',key,query);pages.append(page)
if not page.get('nextCursor'):break
query['cursor']=page['nextCursor']
else:raise AssertionError('pagination did not complete')
rows=[x for page in pages for x in page['data']]
assert len(rows)==10,rows
target=next(x for x in rows if x['date']=='2026-09-15')
assert target['dailyMileageKm']==0 and target['status']=='NORMAL',target
assert next(x for x in rows if x['date']=='2026-09-08')['dailyMileageKm']==0.2
out['rangePages']=pages
out['platformDefault']=call('http://127.0.0.1:20300','/api/mileage/daily?vin=LB9A32A26R0LS1055&dateFrom=2026-09-15&dateTo=2026-09-15&deduplicate=1&limit=10',admin_token)
finally:
app_input['status']='disabled';disabled=admin('/'+aid,app_input,'PUT');assert disabled['status']=='disabled'
admin('/'+aid+'/vehicles',{'vehicles':[]},'PUT')
out['temporaryAppDisabled']=True;out['temporaryGrantsRemoved']=True
print(json.dumps(out))
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"uniqueChangedSources": 1268, "uniqueVehicleDays": 1263, "uniqueVehicles": 304, "updates": 1268, "metrics": ["vehicle_stat_kafka_lag{partition=\"0\",topic=\"vehicle.fields.go.gb32960.v1\"} 0", "vehicle_stat_kafka_lag{partition=\"0\",topic=\"vehicle.fields.go.jt808.v1\"} 0", "vehicle_stat_kafka_lag{partition=\"0\",topic=\"vehicle.fields.go.yutong-mqtt.v1\"} 0", "vehicle_stat_kafka_lag{partition=\"1\",topic=\"vehicle.fields.go.gb32960.v1\"} 0", "vehicle_stat_kafka_lag{partition=\"1\",topic=\"vehicle.fields.go.jt808.v1\"} 0", "vehicle_stat_kafka_lag{partition=\"1\",topic=\"vehicle.fields.go.yutong-mqtt.v1\"} 0", "vehicle_stat_kafka_lag{partition=\"2\",topic=\"vehicle.fields.go.gb32960.v1\"} 0", "vehicle_stat_kafka_lag{partition=\"2\",topic=\"vehicle.fields.go.jt808.v1\"} 0", "vehicle_stat_kafka_lag{partition=\"2\",topic=\"vehicle.fields.go.yutong-mqtt.v1\"} 0", "vehicle_stat_retry_pending_messages{worker=\"1\"} 0", "vehicle_stat_retry_pending_messages{worker=\"2\"} 0", "vehicle_stat_retry_pending_messages{worker=\"3\"} 0", "vehicle_stat_worker_active{worker=\"1\"} 1", "vehicle_stat_worker_active{worker=\"2\"} 1", "vehicle_stat_worker_active{worker=\"3\"} 1"], "recentErrors": ""}
@@ -0,0 +1 @@
{"sourceRowsScanned": 33679, "changedSourceRows": 1265, "vehicles": 304, "selectedChanges": 850, "newInvalid": 0, "sample": [{"plate": "\u7ca4AGH6240", "date": "2026-07-08", "old": "18151.000", "new": "18149.200", "baselineDate": "2026-04-24 12:15:31", "status": "OK"}, {"plate": "\u7ca4AGH6240", "date": "2026-09-15", "old": "35042.900", "new": "0.000", "baselineDate": "2026-09-09 19:09:35", "status": "OK"}, {"plate": "\u7ca4AGH6240", "date": "2026-09-16", "old": "5.000", "new": "5.000", "baselineDate": "2026-09-15 11:27:37", "status": "OK"}, {"plate": "\u7ca4AGG5043", "date": "2026-08-15", "old": "175.100", "new": "102.000", "baselineDate": "2026-08-14 23:27:19", "status": "OK"}, {"plate": "\u7ca4AGG5043", "date": "2026-08-16", "old": "177.600", "new": "110.200", "baselineDate": "2026-08-15 23:10:39", "status": "OK"}, {"plate": "\u7ca4AGG5043", "date": "2026-08-17", "old": "54.400", "new": "54.400", "baselineDate": "2026-08-16 22:01:11", "status": "OK"}, {"plate": "\u7ca4AGG5043", "date": "2026-08-18", "old": "73.400", "new": "42.500", "baselineDate": "2026-08-17 23:26:58", "status": "OK"}, {"plate": "\u7ca4AGG5043", "date": "2026-08-19", "old": "50.000", "new": "0.100", "baselineDate": "2026-08-18 22:18:06", "status": "OK"}, {"plate": "\u7ca4AGG5043", "date": "2026-09-12", "old": "2248.900", "new": "51.900", "baselineDate": "2026-09-11 11:01:12", "status": "OK"}, {"plate": "\u7ca4AGG5043", "date": "2026-09-17", "old": "308.200", "new": "70.700", "baselineDate": "2026-09-16 16:36:30", "status": "OK"}]}
@@ -0,0 +1 @@
{"sourceRowsScanned": 33681, "changedSourceRows": 3, "vehicles": 3, "selectedChanges": 1, "newInvalid": 0, "sample": []}
@@ -0,0 +1 @@
{"sourceRowsScanned": 33681, "changedSourceRows": 0, "vehicles": 0, "selectedChanges": 0, "newInvalid": 0, "sample": []}
@@ -0,0 +1 @@
{"committed":true,"reprojectedDays":1260,"updatedSources":1265}
@@ -0,0 +1 @@
{"committed":true,"reprojectedDays":3,"updatedSources":3}
@@ -0,0 +1,49 @@
# coding: utf-8
"""Prepare a read-only stale GB32960 baseline plan; apply with mileage-baseline-repair."""
import sys,pathlib,json,datetime,decimal,argparse,os,itertools
sys.path.insert(0,'/tmp/codex-mileage-diag-pymysql.zip')
sys.path.insert(0,'/tmp')
from db_access import connect
# This script only prepares a plan; the Go utility applies it atomically.
args=argparse.Namespace(apply=False)
root=pathlib.Path('/opt/lingniu-go-native/backups/mileage-baseline-reset-20260918')
os.umask(0o077);root.mkdir(parents=True,exist_ok=True)
c=connect();D=decimal.Decimal
with c.cursor() as q:
q.execute('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY')
q.execute("""SELECT * FROM vehicle_daily_mileage_source WHERE protocol='GB32960'
AND source_ip NOT IN ('legacy-mysql.lingniu-prod','manual-lingniu-prod-day-mileage')
AND COALESCE(quality_reason,'')<>'gps_coordinate_accumulation'
AND latest_total_mileage_km>0 AND latest_event_time>=TIMESTAMP(stat_date)
AND latest_event_time<TIMESTAMP(stat_date)+INTERVAL 1 DAY
ORDER BY vin,stat_date,source_key""")
rows=q.fetchall()
q.execute("SELECT vin,plate FROM vehicle_identity_binding");plates={r['vin']:r['plate'] for r in q.fetchall()}
c.rollback()
changes=[];by_source={};selected={}
def rank(r):return (r['stat_date'],r['is_selected'],r['latest_event_time'])
for (vin,day), grouped in itertools.groupby(rows,key=lambda r:(r['vin'],r['stat_date'])):
group=list(grouped)
for r in group:
if r['quality_status']!='OK' or not r['first_event_time']:continue
options=[v for v in (by_source.get((vin,r['source_key'])),selected.get(vin)) if v]
if not options:continue
prev=max(options,key=rank)
if prev['latest_event_time']<=r['first_event_time']:continue
delta=r['latest_total_mileage_km']-prev['latest_total_mileage_km']
status='OK';reason='historical_source_baseline'
if D(-1)<=delta<0:delta=D(0);reason='negative_jitter_clamped'
elif delta<0:status='INVALID_DELTA';reason='TOTAL_MILEAGE_ROLLBACK'
elif delta>D(2500*max(1,(r['latest_event_time'].date()-prev['latest_event_time'].date()).days)):status='INVALID_DELTA';reason='outside_daily_range'
changes.append(dict(before=r,baseline=dict(source_key=prev['source_key'],total=prev['latest_total_mileage_km'],time=prev['latest_event_time']),daily=delta,status=status,reason=reason))
for r in group:
if r['quality_status']!='OK':continue
by_source[(vin,r['source_key'])]=r
if r['is_selected'] and (vin not in selected or rank(r)>rank(selected[vin])):selected[vin]=r
summary={'sourceRowsScanned':len(rows),'changedSourceRows':len(changes),'vehicles':len(set(x['before']['vin'] for x in changes)),
'selectedChanges':sum(bool(x['before']['is_selected']) for x in changes),'newInvalid':sum(x['status']!='OK' for x in changes),
'sample':[{ 'plate':plates.get(x['before']['vin']), 'date':x['before']['stat_date'],'old':x['before']['daily_mileage_km'],'new':x['daily'],'baselineDate':x['baseline']['time'],'status':x['status']} for x in changes if plates.get(x['before']['vin']) in ('粤AGH6240','粤AGG5043')]}
(root/('apply-plan.json' if args.apply else 'preflight-plan.json')).write_text(json.dumps(changes,default=str))
(root/('repair-result.json' if args.apply else 'preflight.json')).write_text(json.dumps(summary,default=str,indent=2))
print(json.dumps(summary,default=str))
c.close()
@@ -0,0 +1 @@
{"release": "mileage-baseline-reset-20260918", "previous": "/opt/lingniu-go-native/releases/mileage-reconciliation-20260916", "hashes": {"stat-writer": "eab37f206a97c71471f02ca9cfbab8dc0378159c6a4dbb2f0e769eeb0196d45c", "stats-backfill": "2214fdc710ef554a73a610ae546465b836162a913645da6e4af087a252c0546c", "mileage-baseline-repair": "ee53087eb4ad5af0974f1870d82f473323bc4638936d02b7552303b954d4f2f6"}}