fix: retry transient DingDrive sync failures

This commit is contained in:
Codex 2026-10-12 09:39:06 +08:00
parent 5c252fb272
commit 912b8a4242
2 changed files with 529 additions and 0 deletions

470
scripts/ding_daily_sync.py Normal file
View File

@ -0,0 +1,470 @@
#!/usr/bin/env python3
"""Transactional DingDrive -> cloud -> LAN replication. No app/network monkeypatch imports."""
import argparse
import csv
import fcntl
import gzip
import hashlib
import importlib.util
import io
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
import time
from datetime import datetime, date, timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo
import pymysql
ROOT = Path(__file__).resolve().parents[1]
STATE = ROOT / 'var/ding-sync'
SSH = ['ssh', '-i', '/home/dieya/.ssh/id_ed25519_dycg_restore', '-o', 'BatchMode=yes',
'-o', 'ConnectTimeout=15', 'ubuntu@82.156.135.91']
REMOTE = '/opt/dy-dashboard/var/ding-sync'
DWS = '/home/ubuntu/.npm-global/lib/node_modules/dingtalk-workspace-cli/vendor/dws'
SPACE = '28814565790'
FOLDERS = {'inventory': 'NkDwLng8ZLGrXmrnFxYq3vRqVKMEvZBY',
'outbound': '3NwLYZXWynyBjRB4tZjwNxDvVkyEqBQm'}
SOURCES = {'wdt', 'jdbz', 'jdgj', 'jdjwt', 'tmgj', 'dymuto', 'dyqsimelr', 'jdsmj', 'jdmrt'}
PATTERNS = {'inventory': re.compile(r'^(20\d{6})-wdt-kcmx-([\w]+)\.csv$'),
'outbound': re.compile(r'^(20\d{6})-([a-z0-9]+)-cksj\.csv$')}
COLS = {'inventory': ['data_date', 'source', 'warehouse_code', 'goods_code', 'product_code',
'product_name', 'brand', 'cost_price', 'available_qty', 'production_in_transit',
'purchase_in_transit', 'category1', 'category2', 'category3', 'created_time'],
'outbound': ['货品编号', '货品名称', '品牌', '仓库', '店铺', '一级类目', '二级类目',
'三级类目', '货品数量', '货品成本', '日期', '数据来源', '数据类型']}
TABLES = {'inventory': 'inventory_detail', 'outbound': 'daily_outbound'}
SPECIALS = {'POZ-FM-0184-TM-SM': '.055', 'POZ-XFS-9014-TM-MD': '.062',
'POZ-FM-9742-TM-MD': '.181', 'POZ-MM-0580-TM-MD': '.22',
**{f'POZ-SKS-{n}-LD': '.1' for n in ('3374', '3367', '3350', '3343')}}
DWS_RETRY_DELAYS = (2, 5, 10)
def now():
return datetime.now(ZoneInfo('Asia/Shanghai'))
def command(args, timeout=180, retry_delays=()):
"""Run a command, optionally retrying without exposing credential-bearing output."""
attempts = len(retry_delays) + 1
failure = 'failed'
for attempt in range(attempts):
try:
result = subprocess.run(args, capture_output=True, timeout=timeout)
if not result.returncode:
return result.stdout
failure = f'failed (exit {result.returncode})'
except subprocess.TimeoutExpired:
failure = f'timed out after {timeout}s'
if attempt < len(retry_delays):
time.sleep(retry_delays[attempt])
# Never echo command output: authentication errors may contain tokens.
raise RuntimeError(f'{Path(args[0]).name} {failure} after {attempts} attempts')
def dws_command(args, timeout=180):
# A single daily job may recover from brief DingDrive/IPaaS failures. This
# is bounded in-process retrying, not polling or an additional schedule.
return command(args, timeout=timeout, retry_delays=DWS_RETRY_DELAYS)
def encode(value):
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(',', ':'), default=str).encode()
def atomic(path, value):
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=path.parent)
try:
with os.fdopen(fd, 'wb') as out:
out.write(value if isinstance(value, bytes) else encode(value))
out.flush()
os.fsync(out.fileno())
os.replace(tmp, path)
finally:
if os.path.exists(tmp):
os.unlink(tmp)
def read_json(path, default=None):
return json.loads(path.read_bytes()) if path.exists() else default
def clean(v):
return str(v or '').replace('"', '').replace('=', '').strip()
def number(v, integer=False):
value = Decimal(clean(v).replace(',', '') or '0')
if not value.is_finite() or (integer and value != value.to_integral_value()):
raise ValueError('Invalid numeric CSV field')
return int(value) if integer else format(value.quantize(Decimal('.0001')), 'f')
def bad(code):
return clean(code).upper().rstrip('::') in {'', 'NA', 'N/A', '合计'}
def csv_reader(path):
raw = path.read_bytes()
for encoding in ('utf-8-sig', 'gb18030'):
try:
content = raw.decode(encoding)
break
except UnicodeDecodeError:
continue
else:
raise ValueError('Unrecognized CSV encoding')
reader = csv.DictReader(io.StringIO(content))
if not reader.fieldnames:
raise ValueError('Missing CSV header')
reader.fieldnames = [clean(x).lstrip('\ufeff') for x in reader.fieldnames]
return reader
def pick(row, keys):
return next((clean(row[k]) for k in keys if clean(row.get(k))), '')
def parse(path, kind, day, key, products):
reader = csv_reader(path)
headers = set(reader.fieldnames)
if kind == 'inventory':
required = {'货品编号', '货品名称', '成本价', '采购在途', '生产在途量'}
qty_col = '库存' if '库存' in headers else '可用库存'
required.add(qty_col)
elif key == 'wdt':
required = {'货品编号', '货品名称', '货品数量', '货品成本'}
else:
required = set()
if not headers.intersection({'货品编号', '产品编码', '商品编码', '编码', 'product_code', 'goods_code'}):
raise ValueError(f'{path.name}: Missing product code column')
if not headers.intersection({'货品数量', '数量', '出库量', 'qty', 'quantity'}):
raise ValueError(f'{path.name}: Missing quantity column')
if required - headers:
raise ValueError(f'{path.name}: missing columns {sorted(required - headers)}; retained existing data')
rows = []
valid_codes = 0
for row in reader:
code = pick(row, ('货品编号', '产品编码', '商品编码', '编码', 'product_code', 'goods_code'))
if bad(code):
continue
valid_codes += 1
if kind == 'inventory':
name = clean(row.get('货品名称'))
if name in {'-', '--'}:
continue
ct = clean(row.get('单品创建时间'))
created = None
if ct:
created = datetime.fromisoformat(ct).strftime('%Y-%m-%d %H:%M:%S')
rows.append([day, 'wdt', key, clean(row.get('商家编码')), code, name,
clean(row.get('品牌')), number(SPECIALS.get(code, row.get('成本价'))),
number(row.get(qty_col), True), number(row.get('生产在途量'), True),
number(row.get('采购在途'), True), clean(row.get('一级类目')),
clean(row.get('二级类目')), clean(row.get('三级类目')), created])
else:
if key == 'wdt' and clean(row.get('日期')):
row_day = clean(row['日期'])[:10].replace('/', '-')
if date.fromisoformat(row_day).isoformat() != day:
raise ValueError(f'{path.name}: row date does not match filename')
qty = number(pick(row, ('货品数量', '数量', '出库量', 'qty', 'quantity')), True)
p = row if key == 'wdt' else products.get(code, {})
name = clean(p.get('货品名称') if key == 'wdt' else p.get('product_name'))
brand = clean(p.get('品牌') if key == 'wdt' else p.get('brand'))
if qty <= 0 or name in {'-', '--'} or '潘思宇代发' in brand:
continue
cats = ['一级类目', '二级类目', '三级类目'] if key == 'wdt' else ['category1', 'category2', 'category3']
rows.append([code, name or code, brand, clean(row.get('仓库')) if key == 'wdt' else '',
clean(row.get('店铺')) if key == 'wdt' else '', *[clean(p.get(x)) for x in cats],
qty, number(p.get('货品成本') if key == 'wdt' else p.get('cost_price')),
day, key, '出库数据'])
if not rows and (kind == 'inventory' or not valid_codes):
raise ValueError(f'{path.name}: no valid records; refusing replacement')
return rows
def connect(local):
spec = importlib.util.spec_from_file_location('sync_config', ROOT / 'config.py')
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
config = dict(mod.DB_CONFIG)
if local:
config.update(user='dy_test', password=(ROOT / '.local-test-db-password').read_text().strip(),
unix_socket='/run/mysqld/mysqld.sock')
config.update(cursorclass=pymysql.cursors.DictCursor, autocommit=False)
return pymysql.connect(**config)
def where(packet):
if packet['kind'] == 'inventory':
return 'data_date=%s AND source=%s AND warehouse_code=%s', (packet['date'], 'wdt', packet['key'])
return '`日期`=%s AND `数据来源`=%s', (packet['date'], packet['key'])
def normalize(rows, kind):
money_index = 7 if kind == 'inventory' else 9
result = []
for row in rows:
r = [str(v) if isinstance(v, (date, datetime)) else v for v in row]
r[money_index] = number(r[money_index])
result.append(r)
return result
def digest(rows, kind):
return hashlib.sha256(b'\n'.join(sorted(encode(r) for r in normalize(rows, kind)))).hexdigest()
def db_rows(conn, p):
condition, params = where(p)
with conn.cursor() as cur:
cur.execute('SELECT `' + '`,`'.join(COLS[p['kind']]) + '` FROM ' + TABLES[p['kind']] + ' WHERE ' + condition, params)
return [[r[c] for c in COLS[p['kind']]] for r in cur.fetchall()]
def refresh():
# Load cache modules directly, avoiding services/__init__ and any DB/AI side effects.
for name, function in (('cache', 'refresh_all_caches'), ('simple_cache', 'clear')):
spec = importlib.util.spec_from_file_location('sync_' + name, ROOT / 'services' / (name + '.py'))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
getattr(module, function)()
def apply(conn, packets, dry_run=False):
changed = []
for p in packets:
if p['kind'] not in COLS or p['date'] != date.fromisoformat(p['date']).isoformat():
raise ValueError('Invalid packet target')
if any(len(r) != len(COLS[p['kind']]) for r in p['rows']):
raise ValueError('Invalid packet shape')
old = db_rows(conn, p)
if digest(old, p['kind']) != digest(p['rows'], p['kind']):
changed.append(p)
if not dry_run:
condition, params = where(p)
with conn.cursor() as cur:
cur.execute('SELECT * FROM ' + TABLES[p['kind']] + ' WHERE ' + condition, params)
atomic(STATE / 'backups' / (now().strftime('%Y%m%dT%H%M%S%f') + '.json.gz'),
gzip.compress(encode({'target': {k: p[k] for k in ('kind', 'date', 'key')}, 'rows': cur.fetchall()})))
cur.execute('DELETE FROM ' + TABLES[p['kind']] + ' WHERE ' + condition, params)
sql = 'INSERT INTO ' + TABLES[p['kind']] + ' (`' + '`,`'.join(COLS[p['kind']]) + '`) VALUES (' + ','.join(['%s'] * len(COLS[p['kind']])) + ')'
if p['rows']:
cur.executemany(sql, p['rows'])
if digest(db_rows(conn, p), p['kind']) != digest(p['rows'], p['kind']):
raise RuntimeError('Database readback mismatch; transaction rolled back')
if dry_run:
conn.rollback()
else:
conn.commit()
# Refresh even after interrupted commit; publication/ack happens only after refresh succeeds.
if packets:
refresh()
return [{'kind': p['kind'], 'date': p['date'], 'key': p['key'], 'rows': len(p['rows'])} for p in changed]
def list_files(kind):
items, cursor, seen = [], None, set()
while True:
args = [DWS, 'drive', 'list', '--space-id', SPACE, '--folder', FOLDERS[kind], '--limit', '50', '--format', 'json']
if cursor:
args.extend(['--cursor', cursor])
response = json.loads(dws_command(args))
if response.get('success') is False or 'result' not in response:
raise RuntimeError('DingDrive list failed')
items.extend(response['result'].get('items', []))
cursor = response['result'].get('nextToken')
if not cursor:
return items
if cursor in seen:
raise RuntimeError('Repeated DingDrive pagination cursor')
seen.add(cursor)
def cloud(conn, args):
config = read_json(STATE / 'settings.json')
target = args.target_date or now().date()
old = read_json(STATE / 'manifest.json', {'entries': {}})
# Publish only this run's dates. Missing files never become a catch-up queue.
entries = {}
packets, missing = [], []
with conn.cursor() as cur:
cur.execute('SELECT product_code,product_name,brand,category1,category2,category3,cost_price FROM products')
products = {r['product_code']: r for r in cur.fetchall()}
for kind in FOLDERS:
files = {}
for item in list_files(kind):
m = PATTERNS[kind].fullmatch(item.get('name', ''))
if not m or item.get('type') != 'FILE':
continue
day, key = m.groups()
day = datetime.strptime(day, '%Y%m%d').date()
key = 'dymuto' if key == 'dymtuo' else key
if kind == 'outbound' and key not in SOURCES:
continue
identity = (day, key)
if identity not in files or item['modifyTime'] > files[identity]['modifyTime']:
files[identity] = item
begin = target - timedelta(days=kind == 'outbound')
end = target - timedelta(days=kind == 'outbound')
day = begin
while day <= end:
expected = set(config['warehouses']) if kind == 'inventory' else SOURCES
uploaded = {k for d, k in files if d == day}
for key in sorted(expected - uploaded):
missing.append(f'{kind}:{day}:{key}')
for key in sorted(uploaded):
item = files[(day, key)]
identity = f'{kind}:{day}:{key}'
fingerprint = hashlib.sha256(encode([item['fileId'], item.get('md5'), item['modifyTime'], item['fileSize']])).hexdigest()
existing = old['entries'].get(identity)
if existing and existing['source_fingerprint'] == fingerprint:
entries[identity] = existing
continue
dest = STATE / 'downloads' / item['name']
dest.parent.mkdir(parents=True, exist_ok=True)
dws_command([DWS, 'drive', 'download', '--node', item['fileId'], '--space-id', SPACE, '--output', str(dest), '--format', 'json'])
if dest.stat().st_size != item['fileSize']:
raise ValueError(f'{item["name"]}: incomplete download')
p = {'kind': kind, 'date': str(day), 'key': key,
'rows': parse(dest, kind, str(day), key, products)}
blob = gzip.compress(encode(p), mtime=0)
sha = hashlib.sha256(blob).hexdigest()
atomic(STATE / 'packets' / (sha + '.json.gz'), blob)
entries[identity] = {'packet': sha, 'source_fingerprint': fingerprint,
'rows': len(p['rows']), 'source_name': item['name']}
packets.append(p)
day += timedelta(days=1)
changed = apply(conn, packets, args.dry_run)
if not args.dry_run and not packets and not (STATE / 'manifest.json').exists():
refresh()
coverage = {kind: sum(identity.startswith(f'{kind}:{target - timedelta(days=kind == "outbound")}:')
for identity in entries) for kind in FOLDERS}
status = {'checked_at': now().isoformat(), 'target_inventory': str(target),
'target_outbound': str(target - timedelta(days=1)), 'missing': missing,
'state': 'success', 'missing_policy': 'skip', 'changed': changed,
'cache_refreshed': not args.dry_run, 'files': len(entries), 'coverage': coverage}
if not args.dry_run:
manifest = {'entries': entries, 'missing': missing, 'missing_policy': 'skip', 'target_inventory': str(target),
'target_outbound': status['target_outbound'], 'coverage': coverage}
manifest['version'] = hashlib.sha256(encode(manifest)).hexdigest()
status['version'] = manifest['version']
atomic(STATE / 'status.json', status)
atomic(STATE / 'manifest.json', manifest)
print(json.dumps(status, ensure_ascii=False))
def local(conn, args):
manifest = json.loads(command(SSH + ['cat', REMOTE + '/manifest.json']))
cloud_status = json.loads(command(SSH + ['cat', REMOTE + '/status.json']))
target = (args.target_date or now().date()).isoformat()
if manifest.get('target_inventory') != target or manifest.get('missing_policy') != 'skip':
raise RuntimeError('No requested-date single-run publication; other dates will not be imported')
if not cloud_status.get('cache_refreshed') or cloud_status.get('version') != manifest['version']:
raise RuntimeError('Cloud update incomplete or failed; waiting for a validated publication')
old = read_json(STATE / 'local-status.json', {})
packets = []
ledger = read_json(STATE / 'local-ledger.json', {})
for identity, entry in sorted(manifest['entries'].items()):
sha = entry['packet']
if not re.fullmatch('[a-f0-9]{64}', sha):
raise ValueError('Invalid packet hash')
if ledger.get(identity) == sha:
continue
path = STATE / 'packets' / (sha + '.json.gz')
if not path.exists():
atomic(path, command(SSH + ['cat', REMOTE + '/packets/' + path.name]))
blob = path.read_bytes()
if hashlib.sha256(blob).hexdigest() != sha:
# Keep the corrupt artifact for diagnosis; let the next attempt fetch a clean copy.
path.rename(path.with_suffix('.corrupt-' + now().strftime('%Y%m%dT%H%M%S%f')))
raise ValueError('Packet checksum mismatch')
p = json.loads(gzip.decompress(blob))
if identity != f'{p["kind"]}:{p["date"]}:{p["key"]}':
raise ValueError('Packet identity mismatch')
packets.append(p)
if not packets and old.get('version') == manifest['version'] and old.get('cache_refreshed'):
# Keep online evidence current, without re-invalidating valid caches.
status = {**old, 'checked_at': now().isoformat()}
else:
changed = apply(conn, packets, args.dry_run)
status = {'checked_at': now().isoformat(), 'version': manifest['version'],
'target_inventory': manifest['target_inventory'], 'target_outbound': manifest['target_outbound'],
'missing': manifest['missing'], 'state': 'success', 'missing_policy': 'skip',
'changed': changed, 'cache_refreshed': not args.dry_run, 'files': len(manifest['entries']),
'coverage': manifest['coverage']}
if not args.dry_run:
atomic(STATE / 'local-ledger.json', {k: v['packet'] for k, v in manifest['entries'].items()})
atomic(STATE / 'local-status.json', status)
# Exact local readback + cache completion are acknowledged to cloud.
result = subprocess.run(SSH + ['umask 077; tee ' + REMOTE + '/local-ack.json.tmp >/dev/null && mv ' + REMOTE + '/local-ack.json.tmp ' + REMOTE + '/local-ack.json'], input=encode(status), capture_output=True, timeout=45)
if result.returncode:
raise RuntimeError('Cloud acknowledgement failed; no automatic retry')
if old.get('version') != status['version']:
env = dict(os.environ, DBUS_SESSION_BUS_ADDRESS=f'unix:path=/run/user/{os.getuid()}/bus', XDG_RUNTIME_DIR=f'/run/user/{os.getuid()}')
msg = (f"库存 {status['target_inventory']}:{status['coverage']['inventory']}仓库;"
f"出库 {status['target_outbound']}:{status['coverage']['outbound']}来源。两端已同步这些文件并刷新缓存。")
if status['missing']:
msg += f" 本次未上传的{len(status['missing'])}份文件已跳过,不等待补录。"
try:
notification = subprocess.run(['notify-send', '采购数据同步结果', msg], env=env, capture_output=True, timeout=10)
status['desktop_notification'] = 'delivered' if notification.returncode == 0 else 'desktop_unavailable'
except (OSError, subprocess.TimeoutExpired):
status['desktop_notification'] = 'desktop_unavailable'
atomic(STATE / 'local-status.json', status)
print(json.dumps(status, ensure_ascii=False))
def main():
parser = argparse.ArgumentParser()
parser.add_argument('mode', choices=['cloud', 'local'])
parser.add_argument('--dry-run', action='store_true')
parser.add_argument('--target-date', type=date.fromisoformat,
help='Manual recovery date (YYYY-MM-DD); scheduled runs omit this option')
args = parser.parse_args()
if args.target_date and args.target_date > now().date():
parser.error('--target-date cannot be in the future')
os.umask(0o077)
STATE.mkdir(parents=True, exist_ok=True)
with open(STATE / (args.mode + '.lock'), 'w') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print('Already running; skipped')
return
conn = None
try:
conn = connect(args.mode == 'local')
with conn.cursor() as cur:
cur.execute("SELECT GET_LOCK('dy_ding_daily_sync', 0) AS acquired")
if not cur.fetchone()['acquired']:
raise RuntimeError('Another database import is running')
(cloud if args.mode == 'cloud' else local)(conn, args)
except Exception as error:
if conn:
conn.rollback()
failure = {'checked_at': now().isoformat(), 'state': 'error', 'error': str(error), 'cache_refreshed': False}
if not args.dry_run:
atomic(STATE / ('status.json' if args.mode == 'cloud' else 'local-status.json'), failure)
if args.mode == 'local':
# One best-effort failure receipt allows cloud to distinguish failure from offline.
try:
subprocess.run(SSH + ['umask 077; tee ' + REMOTE + '/local-ack.json.tmp >/dev/null && mv ' + REMOTE + '/local-ack.json.tmp ' + REMOTE + '/local-ack.json'],
input=encode(failure), capture_output=True, timeout=30)
except (OSError, subprocess.TimeoutExpired):
pass
print(json.dumps(failure, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
finally:
if conn:
conn.close()
if __name__ == '__main__':
main()

View File

@ -0,0 +1,59 @@
import importlib.util
from pathlib import Path
import subprocess
import unittest
from unittest import mock
SCRIPT = Path(__file__).resolve().parents[1] / 'scripts' / 'ding_daily_sync.py'
SPEC = importlib.util.spec_from_file_location('ding_daily_sync_retry_test', SCRIPT)
SYNC = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(SYNC)
class Result:
def __init__(self, returncode, stdout=b''):
self.returncode = returncode
self.stdout = stdout
class DingCommandRetryTest(unittest.TestCase):
def test_dws_command_retries_then_succeeds(self):
results = iter([Result(1), Result(1), Result(0, b'published')])
sleeps = []
with mock.patch.object(SYNC.subprocess, 'run', side_effect=lambda *args, **kwargs: next(results)), \
mock.patch.object(SYNC.time, 'sleep', side_effect=sleeps.append):
self.assertEqual(SYNC.dws_command([SYNC.DWS, 'drive', 'list']), b'published')
self.assertEqual(sleeps, [2, 5])
def test_dws_command_stops_after_bounded_attempts(self):
calls = []
def run(*args, **kwargs):
calls.append(args)
return Result(1)
with mock.patch.object(SYNC.subprocess, 'run', side_effect=run), \
mock.patch.object(SYNC.time, 'sleep'):
with self.assertRaisesRegex(RuntimeError, r'failed \(exit 1\) after 4 attempts'):
SYNC.dws_command([SYNC.DWS, 'drive', 'download'])
self.assertEqual(len(calls), 4)
def test_dws_command_retries_timeout_without_leaking_output(self):
calls = 0
def run(*args, **kwargs):
nonlocal calls
calls += 1
if calls == 1:
raise subprocess.TimeoutExpired(args[0], kwargs['timeout'])
return Result(0, b'ok')
with mock.patch.object(SYNC.subprocess, 'run', side_effect=run), \
mock.patch.object(SYNC.time, 'sleep'):
self.assertEqual(SYNC.dws_command([SYNC.DWS, 'drive', 'list']), b'ok')
self.assertEqual(calls, 2)
if __name__ == '__main__':
unittest.main()