From 69ef5a07045dc745429b1c1d1266a2b32cb6635c Mon Sep 17 00:00:00 2001 From: weijuesen Date: Fri, 14 Aug 2026 09:03:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=B0=8F=E7=A8=8B?= =?UTF-8?q?=E5=BA=8F=E7=A6=BB=E7=BA=BF=E5=B7=A1=E6=A3=80=E4=B8=8E=E5=8F=AF?= =?UTF-8?q?=E9=9D=A0=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + miniapp/package.json | 1 + miniapp/src/api/inspections.ts | 22 +- miniapp/src/api/request.ts | 29 +++ .../src/pages/inspection/index.module.scss | 27 +++ miniapp/src/pages/inspection/index.tsx | 72 ++++++- miniapp/src/services/offlineQueue.test.ts | 99 +++++++++ miniapp/src/services/offlineQueue.ts | 196 ++++++++++++++++++ miniapp/src/services/offlineQueueApp.ts | 19 ++ miniapp/src/store/useStore.ts | 9 + scripts/verify.ps1 | 1 + server-go/internal/database/migrate.go | 2 +- server-go/internal/database/migrate_test.go | 4 + server-go/internal/handler/inspection.go | 11 +- server-go/internal/model/inspection.go | 2 +- .../000007_inspection_idempotency.down.sql | 16 ++ .../000007_inspection_idempotency.up.sql | 17 ++ 后续工作计划.md | 4 +- 开发交接记录.md | 30 +++ 19 files changed, 543 insertions(+), 19 deletions(-) create mode 100644 miniapp/src/services/offlineQueue.test.ts create mode 100644 miniapp/src/services/offlineQueue.ts create mode 100644 miniapp/src/services/offlineQueueApp.ts create mode 100644 server-go/migrations/000007_inspection_idempotency.down.sql create mode 100644 server-go/migrations/000007_inspection_idempotency.up.sql diff --git a/README.md b/README.md index 31103a5..04e871b 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/miniapp/package.json b/miniapp/package.json index 952ea0a..f1b023f 100644 --- a/miniapp/package.json +++ b/miniapp/package.json @@ -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", diff --git a/miniapp/src/api/inspections.ts b/miniapp/src/api/inspections.ts index 7988676..6f05575 100644 --- a/miniapp/src/api/inspections.ts +++ b/miniapp/src/api/inspections.ts @@ -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 => { const token = Taro.getStorageSync('token'); - const idempotencyKey = `insp-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - const header: Record = { 'Idempotency-Key': idempotencyKey }; + const key = idempotencyKey || `insp-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const header: Record = { '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); diff --git a/miniapp/src/api/request.ts b/miniapp/src/api/request.ts index 7b9ee18..a5ece0c 100644 --- a/miniapp/src/api/request.ts +++ b/miniapp/src/api/request.ts @@ -7,6 +7,7 @@ export interface RequestOptions { data?: Record; params?: Record; header?: Record; + _retried?: boolean; } function buildQueryString(params?: Record): string { @@ -21,6 +22,28 @@ function buildQueryString(params?: Record): string { return `?${qs}`; } +export async function refreshSession(): Promise { + 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(options: RequestOptions): Promise { const token = Taro.getStorageSync('token'); const baseUrl = getApiBaseUrl(); @@ -46,6 +69,12 @@ export async function request(options: RequestOptions): Promise }); if (res.statusCode === 401) { + if (!options._retried) { + options._retried = true; + if (await refreshSession()) { + return request(options); + } + } console.warn('[Request] 401 未授权,清除登录状态'); Taro.removeStorageSync('token'); Taro.removeStorageSync('refreshToken'); diff --git a/miniapp/src/pages/inspection/index.module.scss b/miniapp/src/pages/inspection/index.module.scss index 78e1b13..609f118 100644 --- a/miniapp/src/pages/inspection/index.module.scss +++ b/miniapp/src/pages/inspection/index.module.scss @@ -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; diff --git a/miniapp/src/pages/inspection/index.tsx b/miniapp/src/pages/inspection/index.tsx index f35fc48..8c973f1 100644 --- a/miniapp/src/pages/inspection/index.tsx +++ b/miniapp/src/pages/inspection/index.tsx @@ -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 = { 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(null); const [history, setHistory] = useState([]); const [loadingHistory, setLoadingHistory] = useState(false); + const [offlineItems, setOfflineItems] = useState([]); + 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((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} 巡检记录 + 离线队列(剩余 {queueStats.remaining}) + + 待同步 {queueStats.pending} · 失败 {queueStats.failed} · 冲突 {queueStats.conflict} + + {offlineItems.map((item) => ( + + + {item.state} + {item.idempotencyKey.slice(0, 18)} + + {item.error ? {item.error} : null} + + ))} + {history.map((rec) => ( diff --git a/miniapp/src/services/offlineQueue.test.ts b/miniapp/src/services/offlineQueue.test.ts new file mode 100644 index 0000000..0f42c9c --- /dev/null +++ b/miniapp/src/services/offlineQueue.test.ts @@ -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(); + 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); +}); diff --git a/miniapp/src/services/offlineQueue.ts b/miniapp/src/services/offlineQueue.ts new file mode 100644 index 0000000..7e8c98a --- /dev/null +++ b/miniapp/src/services/offlineQueue.ts @@ -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; diff --git a/miniapp/src/services/offlineQueueApp.ts b/miniapp/src/services/offlineQueueApp.ts new file mode 100644 index 0000000..2ad7294 --- /dev/null +++ b/miniapp/src/services/offlineQueueApp.ts @@ -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 }; diff --git a/miniapp/src/store/useStore.ts b/miniapp/src/store/useStore.ts index 89e5d05..251ee81 100644 --- a/miniapp/src/store/useStore.ts +++ b/miniapp/src/store/useStore.ts @@ -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; + syncOfflineQueue: () => Promise; } export const useStore = create((set, get) => ({ @@ -49,4 +51,11 @@ export const useStore = create((set, get) => ({ } set({ isReady: true }); }, + syncOfflineQueue: async () => { + let synced = 0; + do { + const result = await offlineQueue.syncOnce(); + synced = result.synced; + } while (synced > 0); + }, })); diff --git a/scripts/verify.ps1 b/scripts/verify.ps1 index f2ba91b..f6e5870 100644 --- a/scripts/verify.ps1 +++ b/scripts/verify.ps1 @@ -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 { diff --git a/server-go/internal/database/migrate.go b/server-go/internal/database/migrate.go index e03fa38..64f3b1e 100644 --- a/server-go/internal/database/migrate.go +++ b/server-go/internal/database/migrate.go @@ -13,7 +13,7 @@ import ( ) // CurrentSchemaVersion 是当前后端代码期望的迁移版本。 -const CurrentSchemaVersion = "6" +const CurrentSchemaVersion = "7" // RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。 func RunMigrations(db *gorm.DB) error { diff --git a/server-go/internal/database/migrate_test.go b/server-go/internal/database/migrate_test.go index 44d0a26..36060b2 100644 --- a/server-go/internal/database/migrate_test.go +++ b/server-go/internal/database/migrate_test.go @@ -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) + } } diff --git a/server-go/internal/handler/inspection.go b/server-go/internal/handler/inspection.go index c08fa8f..dbf5822 100644 --- a/server-go/internal/handler/inspection.go +++ b/server-go/internal/handler/inspection.go @@ -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 } diff --git a/server-go/internal/model/inspection.go b/server-go/internal/model/inspection.go index 0c943be..a5a683b 100644 --- a/server-go/internal/model/inspection.go +++ b/server-go/internal/model/inspection.go @@ -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"` diff --git a/server-go/migrations/000007_inspection_idempotency.down.sql b/server-go/migrations/000007_inspection_idempotency.down.sql new file mode 100644 index 0000000..2b2ab40 --- /dev/null +++ b/server-go/migrations/000007_inspection_idempotency.down.sql @@ -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; diff --git a/server-go/migrations/000007_inspection_idempotency.up.sql b/server-go/migrations/000007_inspection_idempotency.up.sql new file mode 100644 index 0000000..b1376ed --- /dev/null +++ b/server-go/migrations/000007_inspection_idempotency.up.sql @@ -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; diff --git a/后续工作计划.md b/后续工作计划.md index 06a919e..c6020bd 100644 --- a/后续工作计划.md +++ b/后续工作计划.md @@ -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 建立规格追踪、端到端验收与发布门禁 | 未开始 | 无 | diff --git a/开发交接记录.md b/开发交接记录.md index d55a4a9..e179794 100644 --- a/开发交接记录.md +++ b/开发交接记录.md @@ -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` 步骤。