feat(mp): AES-256-GCM 加密存储 + 安全日志 + ErrorBoundary 升级 + BLE 并发修复

- secure-storage-aes: AES-256-GCM 替代 XOR,保留 XOR 迁移读取
- crypto-polyfill: wx.getRandomValuesSync → crypto.getRandomValues
- logger.ts: dev/prod 区分日志级别,生产不输出详情
- ErrorBoundary: 错误分类(network/render/unknown) + 结构化日志
- DataSyncScheduler: isSyncing 互斥防并发重复同步
- app.tsx 首行导入 crypto-polyfill
This commit is contained in:
iven
2026-05-22 00:13:37 +08:00
parent 29543ef0e7
commit 21f8040994
7 changed files with 329 additions and 115 deletions

View File

@@ -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';

View File

@@ -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<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, retryCount: 0 };
this.state = { hasError: false, retryCount: 0, errorCategory: 'unknown' };
}
static getDerivedStateFromError(): Partial<State> {
return { hasError: true };
static getDerivedStateFromError(error: Error): Partial<State> {
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<Props, State> {
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 (
<View className='error-boundary'>
<View className='error-icon-wrap'>
<Text className='error-icon-text'>!</Text>
</View>
<Text className='error-title'></Text>
<Text className='error-desc'>
{exceeded ? '请重启小程序' : '请返回重试'}
</Text>
<Text className='error-title'>{title}</Text>
<Text className='error-desc'>{desc}</Text>
{!exceeded && (
<View
className='error-retry-btn'
onClick={this.handleRetry}
>
<View className='error-retry-btn' onClick={this.handleRetry}>
<Text className='error-retry-text'></Text>
</View>
)}

View File

@@ -26,6 +26,7 @@ export interface SyncResult {
export class DataSyncScheduler {
private config: Required<SyncSchedulerConfig>;
private timerId: ReturnType<typeof setInterval> | null = null;
private isSyncing = false;
constructor(config?: SyncSchedulerConfig) {
this.config = { ...DEFAULT_CONFIG, ...config };
@@ -40,14 +41,21 @@ export class DataSyncScheduler {
/** 执行同步并记录时间戳 */
async recordSync(syncFn: () => Promise<SyncResult>): Promise<SyncResult> {
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 写入失败不影响主流程
}
}
}

View File

@@ -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<T extends ArrayBufferView>(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;
}

View File

@@ -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}`);
}
}

View File

@@ -0,0 +1,167 @@
/**
* AES-256-GCM 加密存储 — 替代 XOR 加密
* 依赖 @noble/ciphers + crypto-polyfillwx.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
}
}

View File

@@ -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';