feat: 实现小程序离线巡检与可靠同步
This commit is contained in:
@@ -71,6 +71,7 @@
|
||||
- `ai-service/`:FastAPI + ONNX Runtime(默认 mock 模式;`POST /detect` 返回框/类别/置信度、`modelVersion`、`isMock`、`abnormalProbability`,`POST /internal/stream-tasks` 受限拉流任务,`GET /metrics` 监控指标)
|
||||
- 拍照上传 → AI 检测 → 风险评分(0-100 分,绿/黄/橙/红四级,只消费 AI 异常概率,缺失项不按 0 参与)→ 巡检记录(`inspection_records`,`Idempotency-Key` 幂等)
|
||||
- 小程序「拍照巡检」页;Web「巡检记录」页(技术员复查)
|
||||
- 小程序离线巡检:拍照先入本地持久队列,联网后串行上传并复用同一幂等键;401 自动刷新后重试
|
||||
|
||||
### 1.10 知识库与阶段风险提示(计划 #10/#13)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"framework": "React"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "ts-node --transpile-only src/services/offlineQueue.test.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build:weapp": "taro build --type weapp",
|
||||
"build:swan": "taro build --type swan",
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { getApiBaseUrl } from './config';
|
||||
import { get } from './request';
|
||||
import { get, refreshSession } from './request';
|
||||
import type { InspectionRecord } from '@/types';
|
||||
|
||||
export const uploadInspection = (
|
||||
filePath: string,
|
||||
roomId?: string,
|
||||
idempotencyKey?: string,
|
||||
retried?: boolean,
|
||||
): Promise<InspectionRecord> => {
|
||||
const token = Taro.getStorageSync('token');
|
||||
const idempotencyKey = `insp-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const header: Record<string, string> = { 'Idempotency-Key': idempotencyKey };
|
||||
const key = idempotencyKey || `insp-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const header: Record<string, string> = { 'Idempotency-Key': key };
|
||||
if (token) {
|
||||
header['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
@@ -24,8 +26,20 @@ export const uploadInspection = (
|
||||
success: (res) => {
|
||||
try {
|
||||
const body = JSON.parse(res.data || '{}');
|
||||
if (res.statusCode === 401 && !retried) {
|
||||
refreshSession().then((ok) => {
|
||||
if (ok) {
|
||||
uploadInspection(filePath, roomId, key, true).then(resolve, reject);
|
||||
} else {
|
||||
reject(new Error('登录已过期,请重新登录'));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (res.statusCode >= 400) {
|
||||
reject(new Error(body?.error || `上传失败 (${res.statusCode})`));
|
||||
const err = new Error(body?.error || `上传失败 (${res.statusCode})`) as Error & { statusCode?: number };
|
||||
err.statusCode = res.statusCode;
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(body as InspectionRecord);
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface RequestOptions {
|
||||
data?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
header?: Record<string, string>;
|
||||
_retried?: boolean;
|
||||
}
|
||||
|
||||
function buildQueryString(params?: Record<string, unknown>): string {
|
||||
@@ -21,6 +22,28 @@ function buildQueryString(params?: Record<string, unknown>): string {
|
||||
return `?${qs}`;
|
||||
}
|
||||
|
||||
export async function refreshSession(): Promise<boolean> {
|
||||
const refreshToken = Taro.getStorageSync('refreshToken');
|
||||
if (!refreshToken) return false;
|
||||
try {
|
||||
const res = await Taro.request({
|
||||
url: `${getApiBaseUrl()}/auth/refresh`,
|
||||
method: 'POST',
|
||||
data: { refreshToken },
|
||||
header: { 'Content-Type': 'application/json' },
|
||||
timeout: 15000,
|
||||
});
|
||||
const body = res.data as { accessToken?: string; refreshToken?: string; user?: unknown };
|
||||
if (res.statusCode !== 200 || !body.accessToken) return false;
|
||||
Taro.setStorageSync('token', body.accessToken);
|
||||
if (body.refreshToken) Taro.setStorageSync('refreshToken', body.refreshToken);
|
||||
if (body.user) Taro.setStorageSync('user', body.user);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T = unknown>(options: RequestOptions): Promise<T> {
|
||||
const token = Taro.getStorageSync('token');
|
||||
const baseUrl = getApiBaseUrl();
|
||||
@@ -46,6 +69,12 @@ export async function request<T = unknown>(options: RequestOptions): Promise<T>
|
||||
});
|
||||
|
||||
if (res.statusCode === 401) {
|
||||
if (!options._retried) {
|
||||
options._retried = true;
|
||||
if (await refreshSession()) {
|
||||
return request<T>(options);
|
||||
}
|
||||
}
|
||||
console.warn('[Request] 401 未授权,清除登录状态');
|
||||
Taro.removeStorageSync('token');
|
||||
Taro.removeStorageSync('refreshToken');
|
||||
|
||||
@@ -120,6 +120,33 @@
|
||||
margin: $spacing-lg 0 $spacing-md;
|
||||
}
|
||||
|
||||
.queueMeta {
|
||||
font-size: $font-size-xs;
|
||||
color: $color-text-tertiary;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.offlineCard {
|
||||
background: #fff8e6;
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-md;
|
||||
margin-bottom: $spacing-sm;
|
||||
box-shadow: $shadow-card;
|
||||
}
|
||||
|
||||
.offlineStatus {
|
||||
font-size: $font-size-xs;
|
||||
color: $color-warning;
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
|
||||
.offlineError {
|
||||
display: block;
|
||||
font-size: $font-size-xs;
|
||||
color: $color-error;
|
||||
margin-top: $spacing-xs;
|
||||
}
|
||||
|
||||
.historyList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -2,7 +2,10 @@ import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Image, ScrollView, Text, View } from '@tarojs/components';
|
||||
import Taro, { usePullDownRefresh } from '@tarojs/taro';
|
||||
import styles from './index.module.scss';
|
||||
import { listInspections, uploadInspection } from '@/api/inspections';
|
||||
import { listInspections } from '@/api/inspections';
|
||||
import { offlineQueue } from '@/services/offlineQueueApp';
|
||||
import type { OfflineInspectionItem } from '@/services/offlineQueue';
|
||||
import { useStore } from '@/store/useStore';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
import type { InspectionRecord } from '@/types';
|
||||
|
||||
@@ -14,11 +17,19 @@ const CLASS_LABELS: Record<string, string> = {
|
||||
const classLabel = (cls: string) => CLASS_LABELS[cls] || cls;
|
||||
|
||||
const InspectionPage: React.FC = () => {
|
||||
const syncOfflineQueue = useStore((s) => s.syncOfflineQueue);
|
||||
const [imagePath, setImagePath] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [result, setResult] = useState<InspectionRecord | null>(null);
|
||||
const [history, setHistory] = useState<InspectionRecord[]>([]);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
const [offlineItems, setOfflineItems] = useState<OfflineInspectionItem[]>([]);
|
||||
const [queueStats, setQueueStats] = useState({ total: 0, pending: 0, synced: 0, failed: 0, conflict: 0, remaining: 30 });
|
||||
|
||||
const refreshQueue = useCallback(() => {
|
||||
setOfflineItems(offlineQueue.list());
|
||||
setQueueStats(offlineQueue.stats());
|
||||
}, []);
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
setLoadingHistory(true);
|
||||
@@ -31,13 +42,38 @@ const InspectionPage: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshQueue();
|
||||
loadHistory();
|
||||
}, [loadHistory]);
|
||||
syncOfflineQueue().then(() => {
|
||||
refreshQueue();
|
||||
loadHistory();
|
||||
});
|
||||
}, [loadHistory, refreshQueue, syncOfflineQueue]);
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
loadHistory().then(() => Taro.stopPullDownRefresh());
|
||||
syncOfflineQueue()
|
||||
.then(() => {
|
||||
refreshQueue();
|
||||
loadHistory();
|
||||
})
|
||||
.finally(() => Taro.stopPullDownRefresh());
|
||||
});
|
||||
|
||||
const persistImage = (tempPath: string) =>
|
||||
new Promise<string>((resolve) => {
|
||||
const taroAny = Taro as any;
|
||||
const fs = taroAny.getFileSystemManager?.();
|
||||
if (!fs || !fs.saveFile) {
|
||||
resolve(tempPath);
|
||||
return;
|
||||
}
|
||||
fs.saveFile({
|
||||
tempFilePath: tempPath,
|
||||
success: (res: { savedFilePath: string }) => resolve(res.savedFilePath),
|
||||
fail: () => resolve(tempPath),
|
||||
});
|
||||
});
|
||||
|
||||
const handleChooseImage = () => {
|
||||
Taro.chooseImage({
|
||||
count: 1,
|
||||
@@ -60,9 +96,11 @@ const InspectionPage: React.FC = () => {
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const rec = await uploadInspection(imagePath);
|
||||
setResult(rec);
|
||||
Taro.showToast({ title: rec.aiStatus === 'done' ? '检测完成' : '检测失败', icon: 'none' });
|
||||
const savedPath = await persistImage(imagePath);
|
||||
offlineQueue.enqueue(savedPath);
|
||||
Taro.showToast({ title: '已保存到离线队列', icon: 'none' });
|
||||
await syncOfflineQueue();
|
||||
refreshQueue();
|
||||
loadHistory();
|
||||
} catch (err) {
|
||||
Taro.showToast({
|
||||
@@ -136,6 +174,28 @@ const InspectionPage: React.FC = () => {
|
||||
) : null}
|
||||
|
||||
<Text className={styles.sectionTitle}>巡检记录</Text>
|
||||
<Text className={styles.sectionTitle}>离线队列(剩余 {queueStats.remaining})</Text>
|
||||
<View className={styles.queueMeta}>
|
||||
待同步 {queueStats.pending} · 失败 {queueStats.failed} · 冲突 {queueStats.conflict}
|
||||
</View>
|
||||
{offlineItems.map((item) => (
|
||||
<View key={item.id} className={styles.offlineCard}>
|
||||
<View className={styles.historyHeader}>
|
||||
<Text className={styles.offlineStatus}>{item.state}</Text>
|
||||
<Text className={styles.historyTime}>{item.idempotencyKey.slice(0, 18)}</Text>
|
||||
</View>
|
||||
{item.error ? <Text className={styles.offlineError}>{item.error}</Text> : null}
|
||||
</View>
|
||||
))}
|
||||
<Button
|
||||
className={styles.btnPrimary}
|
||||
onClick={() => {
|
||||
offlineQueue.clearSynced();
|
||||
refreshQueue();
|
||||
}}
|
||||
>
|
||||
清理已同步
|
||||
</Button>
|
||||
<View className={styles.historyList}>
|
||||
{history.map((rec) => (
|
||||
<View key={rec.id} className={styles.historyCard}>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import * as assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { createOfflineQueue, type QueueStorage } from './offlineQueue';
|
||||
|
||||
function memoryStorage(): QueueStorage {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key) => data.get(key) ?? null,
|
||||
setItem: (key, value) => void data.set(key, value),
|
||||
removeItem: (key) => void data.delete(key),
|
||||
};
|
||||
}
|
||||
|
||||
test('offline queue persists and restores pending items', () => {
|
||||
const storage = memoryStorage();
|
||||
const queue = createOfflineQueue({
|
||||
storage,
|
||||
uploader: async () => ({ id: 'server-1' }),
|
||||
now: () => 1000,
|
||||
idGenerator: () => 'id-1',
|
||||
});
|
||||
queue.enqueue('/tmp/a.png', 'room-1');
|
||||
const restored = createOfflineQueue({
|
||||
storage,
|
||||
uploader: async () => ({ id: 'server-1' }),
|
||||
now: () => 2000,
|
||||
idGenerator: () => 'id-2',
|
||||
});
|
||||
assert.equal(restored.list().length, 1);
|
||||
assert.equal(restored.list()[0].idempotencyKey, 'insp-id-1');
|
||||
});
|
||||
|
||||
test('offline queue deduplicates repeated clicks', () => {
|
||||
const queue = createOfflineQueue({
|
||||
storage: memoryStorage(),
|
||||
uploader: async () => ({ id: 'server-1' }),
|
||||
now: () => 1000,
|
||||
idGenerator: () => 'id-1',
|
||||
});
|
||||
const first = queue.enqueue('/tmp/a.png', 'room-1');
|
||||
const second = queue.enqueue('/tmp/a.png', 'room-1');
|
||||
assert.equal(first.id, second.id);
|
||||
assert.equal(queue.stats().total, 1);
|
||||
});
|
||||
|
||||
test('offline queue marks permanent 4xx as failed', async () => {
|
||||
const queue = createOfflineQueue({
|
||||
storage: memoryStorage(),
|
||||
uploader: async () => {
|
||||
const err: Error & { statusCode?: number } = new Error('bad request');
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
},
|
||||
now: () => 1000,
|
||||
idGenerator: () => 'id-1',
|
||||
});
|
||||
queue.enqueue('/tmp/a.png');
|
||||
const result = await queue.syncOnce();
|
||||
assert.equal(result.synced, 0);
|
||||
assert.equal(queue.list()[0].state, 'failed');
|
||||
});
|
||||
|
||||
test('offline queue retries with backoff and fails permanently', async () => {
|
||||
let attempts = 0;
|
||||
let now = 1000;
|
||||
const queue = createOfflineQueue({
|
||||
storage: memoryStorage(),
|
||||
uploader: async () => {
|
||||
attempts += 1;
|
||||
throw new Error('network');
|
||||
},
|
||||
now: () => now,
|
||||
idGenerator: () => 'id-1',
|
||||
maxAttempts: 2,
|
||||
backoffBase: 1000,
|
||||
});
|
||||
queue.enqueue('/tmp/a.png');
|
||||
await queue.syncOnce();
|
||||
assert.equal(queue.list()[0].state, 'pending');
|
||||
assert.equal(queue.list()[0].attempts, 1);
|
||||
now = 5000;
|
||||
await queue.syncOnce();
|
||||
assert.equal(queue.list()[0].state, 'failed');
|
||||
assert.equal(queue.list()[0].attempts, 2);
|
||||
});
|
||||
|
||||
test('offline queue removes synced references', async () => {
|
||||
const queue = createOfflineQueue({
|
||||
storage: memoryStorage(),
|
||||
uploader: async () => ({ id: 'server-1' }),
|
||||
now: () => 1000,
|
||||
idGenerator: () => 'id-1',
|
||||
});
|
||||
queue.enqueue('/tmp/a.png');
|
||||
await queue.syncOnce();
|
||||
assert.equal(queue.stats().synced, 1);
|
||||
assert.equal(queue.clearSynced(), 1);
|
||||
assert.equal(queue.stats().total, 0);
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
export type OfflineState = 'pending' | 'uploading' | 'synced' | 'conflict' | 'failed';
|
||||
|
||||
export interface OfflineInspectionItem {
|
||||
id: string;
|
||||
idempotencyKey: string;
|
||||
imagePath: string;
|
||||
roomId?: string;
|
||||
state: OfflineState;
|
||||
attempts: number;
|
||||
nextAttemptAt: number;
|
||||
error?: string;
|
||||
serverId?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface QueueStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
export interface QueueUploadInput {
|
||||
imagePath: string;
|
||||
roomId?: string;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
export interface QueueUploader {
|
||||
(input: QueueUploadInput): Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export interface OfflineQueueDeps {
|
||||
storage: QueueStorage;
|
||||
uploader: QueueUploader;
|
||||
now?: () => number;
|
||||
idGenerator?: () => string;
|
||||
maxItems?: number;
|
||||
maxAttempts?: number;
|
||||
backoffBase?: number;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'offline_inspection_queue';
|
||||
export const OFFLINE_QUEUE_STORAGE_KEY = STORAGE_KEY;
|
||||
|
||||
function loadQueue(storage: QueueStorage): OfflineInspectionItem[] {
|
||||
const raw = storage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveQueue(storage: QueueStorage, items: OfflineInspectionItem[]): void {
|
||||
storage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
export function createOfflineQueue(deps: OfflineQueueDeps) {
|
||||
const now = deps.now || (() => Date.now());
|
||||
const idGenerator = deps.idGenerator || (() => `offline-${now()}-${Math.random().toString(36).slice(2, 10)}`);
|
||||
const maxItems = deps.maxItems || 30;
|
||||
const maxAttempts = deps.maxAttempts || 8;
|
||||
const backoffBase = deps.backoffBase || 30000;
|
||||
|
||||
const enqueue = (imagePath: string, roomId?: string): OfflineInspectionItem => {
|
||||
const items = loadQueue(deps.storage);
|
||||
const duplicate = items.find(
|
||||
(item) =>
|
||||
item.imagePath === imagePath &&
|
||||
item.roomId === roomId &&
|
||||
(item.state === 'pending' || item.state === 'uploading'),
|
||||
);
|
||||
if (duplicate) return duplicate;
|
||||
if (items.length >= maxItems) {
|
||||
throw new Error('离线巡检队列已满,请先清理已同步记录');
|
||||
}
|
||||
const timestamp = now();
|
||||
const item: OfflineInspectionItem = {
|
||||
id: idGenerator(),
|
||||
idempotencyKey: `insp-${idGenerator()}`,
|
||||
imagePath,
|
||||
roomId,
|
||||
state: 'pending',
|
||||
attempts: 0,
|
||||
nextAttemptAt: timestamp,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
items.unshift(item);
|
||||
saveQueue(deps.storage, items);
|
||||
return item;
|
||||
};
|
||||
|
||||
const list = (): OfflineInspectionItem[] => loadQueue(deps.storage);
|
||||
|
||||
const remove = (id: string): void => {
|
||||
saveQueue(deps.storage, loadQueue(deps.storage).filter((item) => item.id !== id));
|
||||
};
|
||||
|
||||
const clearSynced = (): number => {
|
||||
const items = loadQueue(deps.storage);
|
||||
const before = items.length;
|
||||
saveQueue(deps.storage, items.filter((item) => item.state !== 'synced'));
|
||||
return before - loadQueue(deps.storage).length;
|
||||
};
|
||||
|
||||
const retry = (id: string): OfflineInspectionItem | null => {
|
||||
const items = loadQueue(deps.storage);
|
||||
const item = items.find((entry) => entry.id === id);
|
||||
if (!item || item.state === 'synced') return null;
|
||||
item.state = 'pending';
|
||||
item.error = undefined;
|
||||
item.nextAttemptAt = now();
|
||||
item.updatedAt = now();
|
||||
saveQueue(deps.storage, items);
|
||||
return item;
|
||||
};
|
||||
|
||||
const syncOnce = async (): Promise<{
|
||||
synced: number;
|
||||
item?: OfflineInspectionItem;
|
||||
error?: string;
|
||||
}> => {
|
||||
const items = loadQueue(deps.storage);
|
||||
const item = items.find((entry) => entry.state === 'pending' && entry.nextAttemptAt <= now());
|
||||
if (!item) return { synced: 0 };
|
||||
item.state = 'uploading';
|
||||
item.updatedAt = now();
|
||||
saveQueue(deps.storage, items);
|
||||
try {
|
||||
const result = await deps.uploader({
|
||||
imagePath: item.imagePath,
|
||||
roomId: item.roomId,
|
||||
idempotencyKey: item.idempotencyKey,
|
||||
});
|
||||
const updated = loadQueue(deps.storage);
|
||||
const current = updated.find((entry) => entry.id === item.id);
|
||||
if (current) {
|
||||
current.state = 'synced';
|
||||
current.serverId = result.id;
|
||||
current.error = undefined;
|
||||
current.updatedAt = now();
|
||||
saveQueue(deps.storage, updated);
|
||||
}
|
||||
return { synced: 1, item: current || item };
|
||||
} catch (err) {
|
||||
const updated = loadQueue(deps.storage);
|
||||
const current = updated.find((entry) => entry.id === item.id);
|
||||
if (!current) return { synced: 0, error: errorMessage(err) };
|
||||
current.attempts += 1;
|
||||
current.error = errorMessage(err);
|
||||
const status = (err as { statusCode?: number }).statusCode;
|
||||
const permanent =
|
||||
status !== undefined &&
|
||||
status >= 400 &&
|
||||
status < 500 &&
|
||||
status !== 401 &&
|
||||
status !== 408 &&
|
||||
status !== 429;
|
||||
if (permanent) {
|
||||
current.state = status === 409 ? 'conflict' : 'failed';
|
||||
} else if (current.attempts >= maxAttempts) {
|
||||
current.state = 'failed';
|
||||
} else {
|
||||
current.state = 'pending';
|
||||
current.nextAttemptAt = now() + backoffBase * Math.min(Math.pow(2, current.attempts - 1), 16);
|
||||
}
|
||||
current.updatedAt = now();
|
||||
saveQueue(deps.storage, updated);
|
||||
return { synced: 0, item: current, error: current.error };
|
||||
}
|
||||
};
|
||||
|
||||
const stats = () => {
|
||||
const items = loadQueue(deps.storage);
|
||||
return {
|
||||
total: items.length,
|
||||
pending: items.filter((item) => item.state === 'pending' || item.state === 'uploading').length,
|
||||
synced: items.filter((item) => item.state === 'synced').length,
|
||||
failed: items.filter((item) => item.state === 'failed').length,
|
||||
conflict: items.filter((item) => item.state === 'conflict').length,
|
||||
remaining: Math.max(0, maxItems - items.length),
|
||||
};
|
||||
};
|
||||
|
||||
return { enqueue, list, remove, clearSynced, retry, syncOnce, stats };
|
||||
}
|
||||
|
||||
export type OfflineQueue = ReturnType<typeof createOfflineQueue>;
|
||||
@@ -0,0 +1,19 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { uploadInspection } from '@/api/inspections';
|
||||
import { createOfflineQueue, OFFLINE_QUEUE_STORAGE_KEY } from './offlineQueue';
|
||||
|
||||
const storage = {
|
||||
getItem: (key: string) => {
|
||||
const value = Taro.getStorageSync(key);
|
||||
return typeof value === 'string' && value ? value : null;
|
||||
},
|
||||
setItem: (key: string, value: string) => Taro.setStorageSync(key, value),
|
||||
removeItem: (key: string) => Taro.removeStorageSync(key),
|
||||
};
|
||||
|
||||
export const offlineQueue = createOfflineQueue({
|
||||
storage,
|
||||
uploader: (input) => uploadInspection(input.imagePath, input.roomId, input.idempotencyKey),
|
||||
});
|
||||
|
||||
export { OFFLINE_QUEUE_STORAGE_KEY };
|
||||
@@ -3,6 +3,7 @@ import Taro from '@tarojs/taro';
|
||||
import type { User } from '@/types';
|
||||
import { getCurrentUser } from '@/api/auth';
|
||||
import { wsManager } from '@/utils/ws';
|
||||
import { offlineQueue } from '@/services/offlineQueueApp';
|
||||
|
||||
interface AppState {
|
||||
token: string | null;
|
||||
@@ -13,6 +14,7 @@ interface AppState {
|
||||
logout: () => void;
|
||||
isLoggedIn: () => boolean;
|
||||
restoreSession: () => Promise<void>;
|
||||
syncOfflineQueue: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useStore = create<AppState>((set, get) => ({
|
||||
@@ -49,4 +51,11 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
}
|
||||
set({ isReady: true });
|
||||
},
|
||||
syncOfflineQueue: async () => {
|
||||
let synced = 0;
|
||||
do {
|
||||
const result = await offlineQueue.syncOnce();
|
||||
synced = result.synced;
|
||||
} while (synced > 0);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -59,6 +59,7 @@ try {
|
||||
|
||||
Push-Location (Join-Path $root 'miniapp')
|
||||
try {
|
||||
Invoke-Check 'miniapp test' { npm test }
|
||||
Invoke-Check 'miniapp typecheck' { npm run typecheck }
|
||||
Invoke-Check 'miniapp build:weapp' { npm run build:weapp }
|
||||
} finally {
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
// CurrentSchemaVersion 是当前后端代码期望的迁移版本。
|
||||
const CurrentSchemaVersion = "6"
|
||||
const CurrentSchemaVersion = "7"
|
||||
|
||||
// RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。
|
||||
func RunMigrations(db *gorm.DB) error {
|
||||
|
||||
@@ -116,4 +116,8 @@ func TestEmbeddedMigrationsIncludeBaseline(t *testing.T) {
|
||||
if err != nil || next != 6 {
|
||||
t.Fatalf("expected biosecurity migration version 6, got %d (err %v)", next, err)
|
||||
}
|
||||
next, err = driver.Next(next)
|
||||
if err != nil || next != 7 {
|
||||
t.Fatalf("expected inspection idempotency migration version 7, got %d (err %v)", next, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,10 +70,11 @@ func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient,
|
||||
return func(c *gin.Context) {
|
||||
idemKey := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
|
||||
roomID := strings.TrimSpace(c.PostForm("roomId"))
|
||||
userID := currentUserID(c)
|
||||
|
||||
if idemKey != "" {
|
||||
if idemKey != "" && userID != nil {
|
||||
var exist model.InspectionRecord
|
||||
if db.Where("idempotency_key = ?", idemKey).First(&exist).Error == nil {
|
||||
if db.Where("user_id = ? AND idempotency_key = ?", *userID, idemKey).First(&exist).Error == nil {
|
||||
c.JSON(http.StatusOK, exist)
|
||||
return
|
||||
}
|
||||
@@ -113,7 +114,7 @@ func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient,
|
||||
imageURL := s3.Endpoint() + "/" + bucket + "/" + key
|
||||
|
||||
rec := model.InspectionRecord{
|
||||
UserID: currentUserID(c),
|
||||
UserID: userID,
|
||||
ImageURL: &imageURL,
|
||||
AIStatus: "done",
|
||||
}
|
||||
@@ -221,9 +222,9 @@ func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient,
|
||||
})
|
||||
if txErr != nil {
|
||||
// 并发幂等:唯一索引冲突时返回已有记录
|
||||
if idemKey != "" {
|
||||
if idemKey != "" && userID != nil {
|
||||
var exist model.InspectionRecord
|
||||
if db.Where("idempotency_key = ?", idemKey).First(&exist).Error == nil {
|
||||
if db.Where("user_id = ? AND idempotency_key = ?", *userID, idemKey).First(&exist).Error == nil {
|
||||
c.JSON(http.StatusOK, exist)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ type InspectionRecord struct {
|
||||
ModelVersion *string `gorm:"column:model_version;size:64" json:"modelVersion,omitempty"`
|
||||
IsMock *bool `gorm:"column:is_mock;default:false" json:"isMock,omitempty"`
|
||||
AIStatus string `gorm:"column:ai_status;size:16;default:done" json:"aiStatus"`
|
||||
IdempotencyKey *string `gorm:"column:idempotency_key;size:128;uniqueIndex" json:"idempotencyKey,omitempty"`
|
||||
IdempotencyKey *string `gorm:"column:idempotency_key;size:128" json:"idempotencyKey,omitempty"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||
RoomName *string `gorm:"-" json:"roomName,omitempty"`
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
DROP INDEX IF EXISTS idx_inspection_records_user_idempotency;
|
||||
|
||||
-- 恢复全局唯一索引前,先处理跨用户重复,保留所有记录。
|
||||
UPDATE inspection_records AS r
|
||||
SET idempotency_key = r.idempotency_key || '#' || r.id
|
||||
WHERE r.idempotency_key IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM inspection_records AS x
|
||||
WHERE x.idempotency_key = r.idempotency_key
|
||||
AND x.id <> r.id
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_inspection_records_idempotency_key
|
||||
ON inspection_records (idempotency_key)
|
||||
WHERE idempotency_key IS NOT NULL;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 保留全部历史记录,只对同用户重复键追加记录 ID,避免迁移删除或丢失数据。
|
||||
UPDATE inspection_records AS r
|
||||
SET idempotency_key = r.idempotency_key || '#' || r.id
|
||||
WHERE r.idempotency_key IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM inspection_records AS x
|
||||
WHERE x.user_id IS NOT DISTINCT FROM r.user_id
|
||||
AND x.idempotency_key = r.idempotency_key
|
||||
AND x.id <> r.id
|
||||
);
|
||||
|
||||
DROP INDEX IF EXISTS idx_inspection_records_idempotency_key;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_inspection_records_user_idempotency
|
||||
ON inspection_records (user_id, idempotency_key)
|
||||
WHERE idempotency_key IS NOT NULL;
|
||||
@@ -1,6 +1,6 @@
|
||||
# 后续工作计划
|
||||
|
||||
> **完成状态(2026-08-14 更新)**:#5-#24、#27 已完成,Task 0/1/2/4/5/6/8/9/10 整改代码完成(详见 `开发交接记录.md`);#1-4 因物理机问题挂起;#23/#26 骨架完成;Task 3/7 延后到最后处理;微信/天气真实数据待凭证。
|
||||
> **完成状态(2026-08-14 更新)**:#5-#24、#27 已完成,Task 0/1/2/4/5/6/8/9/10/11 整改代码完成(详见 `开发交接记录.md`);#1-4 因物理机问题挂起;#23/#26 骨架完成;Task 3/7 延后到最后处理;微信/天气真实数据待凭证。
|
||||
|
||||
## 整改实施计划 Wave 0-4(2026-08-13 启动)
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
| Wave 2 | 工程可靠性 | Task 8 建立可靠通知、吊销与跨实例状态 | 部分可用 | 待开发服务器迁移部署与 Redis/微信真实联调 |
|
||||
| Wave 2 | 工程可靠性 | Task 9 建立统一检测任务、样本链与发病事件 | 部分可用 | 待开发服务器迁移部署与端到端联调 |
|
||||
| Wave 3 | 业务闭环 | Task 10 补齐消毒、种源与二维码身份链 | 部分可用 | 待开发服务器迁移部署与真实二维码/现场扫码联调 |
|
||||
| Wave 3 | 业务闭环 | Task 11 实现小程序离线巡检与可靠同步 | 未开始 | 无 |
|
||||
| Wave 3 | 业务闭环 | Task 11 实现小程序离线巡检与可靠同步 | 部分可用 | 待开发服务器迁移部署与开发者工具离线/重启联调 |
|
||||
| Wave 3 | 业务闭环 | Task 12 完善环境规则、会诊治理、知识审核与效果评估 | 未开始 | 无 |
|
||||
| Wave 4 | 验收与发布 | Task 13 建立可观测性、容量与恢复验证 | 未开始 | 无 |
|
||||
| Wave 4 | 验收与发布 | Task 14 建立规格追踪、端到端验收与发布门禁 | 未开始 | 无 |
|
||||
|
||||
@@ -1058,3 +1058,33 @@ MVP 沿用 IoTDB(现状);TDengine 作为生产规模化候选(先基准
|
||||
- 本任务前分支提交为 `cc93c9a`;回滚可还原 Task 10 提交;
|
||||
- 数据库回滚执行 `000006_biosecurity.down.sql`,可删除种源、消毒、二维码映射表和 `batches.seed_source_id`;
|
||||
- 页面回滚需同时还原 Web 路由/菜单、小程序页面与设置入口,并移除 `biosecurity` 权限种子。
|
||||
|
||||
## 2026-08-14 整改 Task 11:实现小程序离线巡检与可靠同步
|
||||
|
||||
### 做了什么
|
||||
|
||||
- 新增小程序本地持久队列 `offlineQueue.ts`,队列状态为 `pending/uploading/synced/conflict/failed`;
|
||||
- 拍照巡检改为先保存图片到本地队列,网络恢复后串行上传;同一 `idempotencyKey` 创建后不再变化,重试复用;
|
||||
- 上传 401 时先刷新 token 再重试一次;5xx/超时按指数退避重试,4xx 永久失败并展示错误,409 标记为冲突;
|
||||
- 巡检页展示离线队列数量、剩余容量、失败/冲突和清理已同步入口;
|
||||
- 登录恢复后会尝试同步离线队列;`scripts/verify.ps1` 新增 `miniapp test`,队列单测进入统一门禁;
|
||||
- 服务端幂等键增加用户作用域:新增 `000007_inspection_idempotency` 迁移,唯一索引改为 `(user_id, idempotency_key)`,迁移先对既有同用户冲突键追加记录 ID,不删除任何记录。
|
||||
|
||||
### 设计思路与决策依据
|
||||
|
||||
- 离线上传不能依赖临时文件路径,页面入队前先用文件系统保存为本地持久路径;
|
||||
- 幂等键必须客户端生成且固定,服务端按用户+键查询和唯一约束,避免不同用户撞键导致误返回;
|
||||
- 4xx 通常是数据或权限问题,重试不会解决,所以进入人工处理;网络类错误才退避重试;
|
||||
- 队列容量先按条目数量限制,页面显示剩余名额;后续需要更严格空间控制时可再引入文件大小统计。
|
||||
|
||||
### 验证结果
|
||||
|
||||
- `scripts/verify.ps1` exit 0:Go test/vet/build、Web test/lint/build、小程序 test/typecheck/build、APP typecheck/lint、AI pytest 15/15 均通过;
|
||||
- 队列单测 5/5:重启恢复、重复点击、4xx 永久失败、指数退避后最终失败、同步后清理引用;
|
||||
- 未部署开发服务器,未执行 `000007` 迁移;未在微信开发者工具完成离线→重启→联网截图取证。
|
||||
|
||||
### 回滚点
|
||||
|
||||
- 本任务前分支提交为 `6b222ab`;回滚可还原 Task 11 提交;
|
||||
- 数据库回滚执行 `000007_inspection_idempotency.down.sql` 可恢复全局唯一索引,但已被迁移追加后缀的历史键不会自动还原;
|
||||
- 小程序回滚需还原队列服务、巡检页、请求刷新逻辑和 store,并移除 `scripts/verify.ps1` 中的 `miniapp test` 步骤。
|
||||
|
||||
Reference in New Issue
Block a user