diff --git a/apps/miniprogram/src/app.tsx b/apps/miniprogram/src/app.tsx index 3d601da..b381a43 100644 --- a/apps/miniprogram/src/app.tsx +++ b/apps/miniprogram/src/app.tsx @@ -1,5 +1,6 @@ +import './utils/crypto-polyfill'; import { useEffect, useRef, PropsWithChildren } from 'react'; -import Taro, { useDidShow, useDidHide } from '@tarojs/taro'; +import { useDidShow, useDidHide } from '@tarojs/taro'; import ErrorBoundary from './components/ErrorBoundary'; import { flushEvents } from './services/analytics'; import { useAuthStore } from './stores/auth'; diff --git a/apps/miniprogram/src/components/ErrorBoundary/index.tsx b/apps/miniprogram/src/components/ErrorBoundary/index.tsx index 81bc751..09aef9a 100644 --- a/apps/miniprogram/src/components/ErrorBoundary/index.tsx +++ b/apps/miniprogram/src/components/ErrorBoundary/index.tsx @@ -1,31 +1,77 @@ -import React, { Component } from 'react'; +import { Component } from 'react'; import { View, Text } from '@tarojs/components'; import './index.scss'; interface Props { children: React.ReactNode; + fallback?: React.ReactNode; } interface State { hasError: boolean; retryCount: number; + errorCategory: ErrorCategory; } +type ErrorCategory = 'network' | 'render' | 'unknown'; + const MAX_RETRIES = 3; +function classifyError(error: Error): ErrorCategory { + const msg = error.message?.toLowerCase() || ''; + if ( + msg.includes('network') || + msg.includes('fetch') || + msg.includes('timeout') || + msg.includes('request:fail') + ) { + return 'network'; + } + if ( + msg.includes('cannot read properties') || + msg.includes('is not defined') || + msg.includes('is not a function') || + msg.includes('render') + ) { + return 'render'; + } + return 'unknown'; +} + +function logError(error: Error, info: React.ErrorInfo, category: ErrorCategory): void { + const isDev = process.env.NODE_ENV === 'development'; + const entry = { + ts: new Date().toISOString(), + category, + message: error.message, + stack: isDev ? error.stack : undefined, + componentStack: isDev ? info.componentStack : undefined, + }; + + if (isDev) { + console.error('[ErrorBoundary]', JSON.stringify(entry, null, 2)); + } else { + console.error('[ErrorBoundary]', entry.ts, entry.category, entry.message); + } +} + export default class ErrorBoundary extends Component { constructor(props: Props) { super(props); - this.state = { hasError: false, retryCount: 0 }; + this.state = { hasError: false, retryCount: 0, errorCategory: 'unknown' }; } - static getDerivedStateFromError(): Partial { - return { hasError: true }; + static getDerivedStateFromError(error: Error): Partial { + return { hasError: true, errorCategory: classifyError(error) }; } componentDidCatch(error: Error, info: React.ErrorInfo) { - console.error('[ErrorBoundary]', error, info.componentStack); - this.setState((prev) => ({ retryCount: prev.retryCount + 1 })); + const category = classifyError(error); + logError(error, info, category); + this.setState((prev) => ({ + retryCount: prev.retryCount + 1, + errorCategory: category, + })); } handleRetry = () => { @@ -34,21 +80,28 @@ export default class ErrorBoundary extends Component { render() { if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + const exceeded = this.state.retryCount >= MAX_RETRIES; + const isNetwork = this.state.errorCategory === 'network'; + const title = isNetwork ? '网络连接失败' : '页面出了点问题'; + const desc = exceeded + ? '请重启小程序后重试' + : isNetwork + ? '请检查网络后重试' + : '请返回重试'; + return ( ! - 页面出了点问题 - - {exceeded ? '请重启小程序' : '请返回重试'} - + {title} + {desc} {!exceeded && ( - + 重新加载 )} diff --git a/apps/miniprogram/src/services/ble/DataSyncScheduler.ts b/apps/miniprogram/src/services/ble/DataSyncScheduler.ts index 300f6ed..2c252c5 100644 --- a/apps/miniprogram/src/services/ble/DataSyncScheduler.ts +++ b/apps/miniprogram/src/services/ble/DataSyncScheduler.ts @@ -26,6 +26,7 @@ export interface SyncResult { export class DataSyncScheduler { private config: Required; private timerId: ReturnType | null = null; + private isSyncing = false; constructor(config?: SyncSchedulerConfig) { this.config = { ...DEFAULT_CONFIG, ...config }; @@ -40,14 +41,21 @@ export class DataSyncScheduler { /** 执行同步并记录时间戳 */ async recordSync(syncFn: () => Promise): Promise { + if (this.isSyncing) { + return { success: false, uploadedCount: 0, error: '同步进行中' }; + } + this.isSyncing = true; try { const result = await syncFn(); if (result.success) { this.saveRecord({ lastSyncAt: Date.now() }); } return result; - } catch (e: any) { - return { success: false, uploadedCount: 0, error: e.message || '同步失败' }; + } catch (e: unknown) { + const message = e instanceof Error ? e.message : '同步失败'; + return { success: false, uploadedCount: 0, error: message }; + } finally { + this.isSyncing = false; } } @@ -96,7 +104,6 @@ export class DataSyncScheduler { Taro.setStorageSync(this.config.storageKey, JSON.stringify(record)); } catch (err) { console.warn('[ble-sync] Storage 操作失败:', err); - // Storage 写入失败不影响主流程 } } } diff --git a/apps/miniprogram/src/utils/crypto-polyfill.ts b/apps/miniprogram/src/utils/crypto-polyfill.ts new file mode 100644 index 0000000..2513327 --- /dev/null +++ b/apps/miniprogram/src/utils/crypto-polyfill.ts @@ -0,0 +1,34 @@ +/** + * 微信小程序 crypto.getRandomValues polyfill + * 使用 wx.getRandomValuesSync,不可用时回退到 Math.random + * 必须在 app.tsx 首行导入 + */ + +declare const wx: { + getRandomValuesSync?: (params: { length: number }) => ArrayBuffer; +} | undefined; + +if (typeof globalThis.crypto === 'undefined' || !globalThis.crypto.getRandomValues) { + const wxCrypto = { + getRandomValues(arr: T): T { + if (typeof wx !== 'undefined' && typeof wx.getRandomValuesSync === 'function') { + const buf = wx.getRandomValuesSync({ length: arr.byteLength }); + const view = new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength); + const src = new Uint8Array(buf as ArrayBuffer); + for (let i = 0; i < view.length; i++) { + view[i] = src[i] ?? (Math.random() * 256) | 0; + } + return arr; + } + // fallback: Math.random + const view = new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength); + for (let i = 0; i < view.length; i++) { + view[i] = (Math.random() * 256) | 0; + } + return arr; + }, + }; + + // @ts-expect-error — polyfill + globalThis.crypto = wxCrypto; +} diff --git a/apps/miniprogram/src/utils/logger.ts b/apps/miniprogram/src/utils/logger.ts new file mode 100644 index 0000000..350b155 --- /dev/null +++ b/apps/miniprogram/src/utils/logger.ts @@ -0,0 +1,44 @@ +type LogLevel = 'info' | 'warn' | 'error'; + +const IS_DEV = process.env.NODE_ENV === 'development'; + +function formatMessage(level: LogLevel, module: string, message: string): string { + return `[${level.toUpperCase()}][${module}] ${message}`; +} + +function safeDetail(detail: unknown): string { + if (IS_DEV) { + return typeof detail === 'object' ? JSON.stringify(detail) : String(detail); + } + return ''; +} + +export const logger = { + info(module: string, message: string, detail?: unknown): void { + if (IS_DEV) { + console.log(formatMessage('info', module, message), detail ?? ''); + } + }, + + warn(module: string, message: string, detail?: unknown): void { + console.warn(formatMessage('warn', module, message), safeDetail(detail)); + }, + + error(module: string, message: string, detail?: unknown): void { + console.error(formatMessage('error', module, message), safeDetail(detail)); + }, +}; + +export function safeWarn(module: string, message: string): void { + if (IS_DEV) { + console.warn(`[${module}] ${message}`); + } +} + +export function safeError(module: string, message: string, detail?: unknown): void { + if (IS_DEV) { + console.error(`[${module}] ${message}`, detail ?? ''); + } else { + console.error(`[${module}] ${message}`); + } +} diff --git a/apps/miniprogram/src/utils/secure-storage-aes.ts b/apps/miniprogram/src/utils/secure-storage-aes.ts new file mode 100644 index 0000000..a8a8c84 --- /dev/null +++ b/apps/miniprogram/src/utils/secure-storage-aes.ts @@ -0,0 +1,167 @@ +/** + * AES-256-GCM 加密存储 — 替代 XOR 加密 + * 依赖 @noble/ciphers + crypto-polyfill(wx.getRandomValuesSync) + */ +import Taro from '@tarojs/taro'; +import { gcm } from '@noble/ciphers/aes.js'; + +const STORAGE_PREFIX = '_es_'; +const AES_MARKER = 'aes:'; +const NONCE_LENGTH = 12; // GCM 标准 nonce 长度 + +declare const wx: { + getRandomValuesSync?: (params: { length: number }) => ArrayBuffer; +} | undefined; + +function getEncryptionKey(): Uint8Array { + const hex = process.env.TARO_APP_ENCRYPTION_KEY || ''; + if (hex && /^[0-9a-fA-F]{64}$/.test(hex)) { + return new Uint8Array(hex.match(/.{2}/g)!.map((b) => parseInt(b, 16))); + } + // derive 32 bytes from passphrase + const passphrase = process.env.TARO_APP_ENCRYPTION_KEY || 'hms-default-key'; + const bytes = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + bytes[i] = passphrase.charCodeAt(i % passphrase.length) ^ ((i * 37) & 0xff); + } + return bytes; +} + +function generateNonce(): Uint8Array { + const nonce = new Uint8Array(NONCE_LENGTH); + if (typeof globalThis.crypto?.getRandomValues === 'function') { + globalThis.crypto.getRandomValues(nonce); + } else if (typeof wx !== 'undefined' && typeof wx.getRandomValuesSync === 'function') { + const buf = wx.getRandomValuesSync({ length: NONCE_LENGTH }); + const src = new Uint8Array(buf as ArrayBuffer); + nonce.set(src); + } else { + for (let i = 0; i < NONCE_LENGTH; i++) { + nonce[i] = (Math.random() * 256) | 0; + } + } + return nonce; +} + +function aesEncrypt(plaintext: string): string { + const key = getEncryptionKey(); + const nonce = generateNonce(); + const cipher = gcm(key, nonce); + const data = new TextEncoder().encode(plaintext); + const ciphertext = cipher.encrypt(data); + // nonce(12) + ciphertext 打包 + const combined = new Uint8Array(nonce.length + ciphertext.length); + combined.set(nonce, 0); + combined.set(ciphertext, nonce.length); + return AES_MARKER + Taro.arrayBufferToBase64(combined.buffer as ArrayBuffer); +} + +function aesDecrypt(encoded: string): string | null { + try { + const b64 = encoded.slice(AES_MARKER.length); + const buf = Taro.base64ToArrayBuffer(b64); + const combined = new Uint8Array(buf); + if (combined.length < NONCE_LENGTH) return null; + + const nonce = combined.slice(0, NONCE_LENGTH); + const ciphertext = combined.slice(NONCE_LENGTH); + const key = getEncryptionKey(); + const cipher = gcm(key, nonce); + const plaintext = cipher.decrypt(ciphertext); + return new TextDecoder().decode(plaintext); + } catch { + return null; + } +} + +// XOR decryption for reading legacy data +function xorDecrypt(data: string, key: string): string { + let result = ''; + for (let i = 0; i < data.length; i++) { + result += String.fromCharCode(data.charCodeAt(i) ^ key.charCodeAt(i % key.length)); + } + return result; +} + +function fromBase64(b64: string): string { + try { + const buffer = Taro.base64ToArrayBuffer(b64); + return new TextDecoder().decode(new Uint8Array(buffer)); + } catch { + return ''; + } +} + +const LEGACY_KEY = process.env.TARO_APP_ENCRYPTION_KEY || 'hms-default-key'; + +export function secureSet(key: string, value: string): void { + if (!value) { + Taro.removeStorageSync(STORAGE_PREFIX + key); + return; + } + const encrypted = aesEncrypt(value); + Taro.setStorageSync(STORAGE_PREFIX + key, encrypted); +} + +export function secureGet(key: string): string { + const prefixedKey = STORAGE_PREFIX + key; + const raw = Taro.getStorageSync(prefixedKey); + if (!raw || typeof raw !== 'string') { + // fallback: 明文键(兼容 MCP 注入) + const plain = Taro.getStorageSync(key); + return plain && typeof plain === 'string' ? plain : ''; + } + + // AES 格式 + if (raw.startsWith(AES_MARKER)) { + const decrypted = aesDecrypt(raw); + if (decrypted !== null) return decrypted; + } + + // XOR 格式(legacy) + try { + const decoded = fromBase64(raw); + if (decoded) { + return xorDecrypt(decoded, LEGACY_KEY); + } + } catch { + // fallthrough + } + + // 明文 fallback + return raw; +} + +export function secureRemove(key: string): void { + Taro.removeStorageSync(STORAGE_PREFIX + key); +} + +const MIGRATION_KEYS = [ + 'access_token', 'refresh_token', 'token_expires_at', + 'user_data', 'user_roles', 'tenant_id', 'wechat_openid', +]; + +export function migrateLegacyStorage(): void { + try { + for (const key of MIGRATION_KEYS) { + const prefixed = STORAGE_PREFIX + key; + const already = Taro.getStorageSync(prefixed); + if (already) { + // re-encrypt with AES if not already + if (typeof already === 'string' && !already.startsWith(AES_MARKER)) { + const value = secureGet(key); + if (value) secureSet(key, value); + } + continue; + } + + const legacy = Taro.getStorageSync(key); + if (!legacy || typeof legacy !== 'string') continue; + + secureSet(key, legacy); + Taro.removeStorageSync(key); + } + } catch { + // migration best-effort + } +} diff --git a/apps/miniprogram/src/utils/secure-storage.ts b/apps/miniprogram/src/utils/secure-storage.ts index 0e4b77c..2f6df1e 100644 --- a/apps/miniprogram/src/utils/secure-storage.ts +++ b/apps/miniprogram/src/utils/secure-storage.ts @@ -1,97 +1,5 @@ -import Taro from '@tarojs/taro'; - -const ENCRYPTION_KEY = process.env.TARO_APP_ENCRYPTION_KEY || 'hms-default-key'; - -function xorEncrypt(data: string, key: string): string { - let result = ''; - for (let i = 0; i < data.length; i++) { - result += String.fromCharCode(data.charCodeAt(i) ^ key.charCodeAt(i % key.length)); - } - return result; -} - -function toBase64(str: string): string { - try { - const encoder = new TextEncoder(); - const uint8 = encoder.encode(str); - return Taro.arrayBufferToBase64(uint8.buffer as ArrayBuffer); - } catch { - return ''; - } -} - -function fromBase64(b64: string): string { - try { - const buffer = Taro.base64ToArrayBuffer(b64); - const decoder = new TextDecoder(); - return decoder.decode(new Uint8Array(buffer)); - } catch { - return ''; - } -} - -const STORAGE_PREFIX = '_es_'; - -export function secureSet(key: string, value: string): void { - if (!value) { - Taro.removeStorageSync(STORAGE_PREFIX + key); - return; - } - const encrypted = xorEncrypt(value, ENCRYPTION_KEY); - const encoded = toBase64(encrypted); - if (encoded) { - Taro.setStorageSync(STORAGE_PREFIX + key, encoded); - } else { - Taro.setStorageSync(STORAGE_PREFIX + key, value); - } -} - -export function secureGet(key: string): string { - const prefixedKey = STORAGE_PREFIX + key; - const raw = Taro.getStorageSync(prefixedKey); - if (!raw || typeof raw !== 'string') { - // fallback: 尝试读取明文键(兼容 MCP 注入等场景) - const plain = Taro.getStorageSync(key); - return (plain && typeof plain === 'string') ? plain : ''; - } - - // 始终尝试 base64 解码 + XOR 解密(secureSet 的写入格式) - try { - const decoded = fromBase64(raw); - if (decoded) { - return xorEncrypt(decoded, ENCRYPTION_KEY); - } - } catch { - // fallthrough — 可能是未加密的旧数据 - } - - // fallback: 兼容未加密的旧数据(明文 JSON/JWT 或其他值) - return raw; -} - -export function secureRemove(key: string): void { - Taro.removeStorageSync(STORAGE_PREFIX + key); -} - -const MIGRATION_KEYS = [ - 'access_token', 'refresh_token', 'token_expires_at', - 'user_data', 'user_roles', 'tenant_id', 'wechat_openid', -]; - -export function migrateLegacyStorage(): void { - try { - for (const key of MIGRATION_KEYS) { - const prefixed = STORAGE_PREFIX + key; - const already = Taro.getStorageSync(prefixed); - if (already) continue; - - const legacy = Taro.getStorageSync(key); - if (!legacy || typeof legacy !== 'string') continue; - - secureSet(key, legacy); - Taro.removeStorageSync(key); - } - } catch { - // migration best-effort - } -} +/** + * 安全存储 — AES-256-GCM 加密 + * 重导出 secure-storage-aes,保留旧导入路径兼容 + */ +export { secureSet, secureGet, secureRemove, migrateLegacyStorage } from './secure-storage-aes';