Files
hms/docs/superpowers/plans/2026-05-21-miniprogram-phase0-plan.md
iven 6c21f9eb2a docs(mp): Phase 0 实施计划 — 安全 P0 + 工程基础(10 Tasks / 3 Chunks)
Chunk 1: TS strict + ESLint + ErrorBoundary
Chunk 2: AES-256-GCM 加密替换 + auth store 集成
Chunk 3: Tenant ID / console 脱敏 / dev 登录 / 并发安全
2026-05-21 23:39:26 +08:00

1115 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 0: 安全 P0 + 工程基础 实施计划
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 修复所有安全 CRITICAL 问题建立工程基础设施TypeScript strict + ESLint + Prettier
**Architecture:** 安全优先。先建工程基础TS strict / ESLint再做安全修复。AES 加密替换使用 `@noble/ciphers` + 微信小程序 `wx.getRandomValuesSync` polyfill。所有安全改动有回滚路径XOR 读取保留)。
**Tech Stack:** TypeScript 5.8 / Taro 4.2 / @noble/ciphers / ESLint 9 (flat config) / Prettier
**Spec:** `docs/superpowers/specs/2026-05-21-miniprogram-comprehensive-improvement-design.md` §4
**Estimated:** 12 人天 | **Calendar:** 2 周
---
## File Structure
### 新建文件
| 文件 | 职责 |
|------|------|
| `src/utils/crypto-polyfill.ts` | 微信小程序 `crypto.getRandomValues` polyfill |
| `src/utils/secure-storage-aes.ts` | AES-256-GCM 加密存储实现 |
| `src/utils/logger.ts` | 安全日志工具(区分 dev/prod |
| `eslint.config.mjs` | ESLint flat config |
| `.prettierrc` | Prettier 配置 |
| `__tests__/utils/secure-storage-aes.test.ts` | AES 加密存储测试 |
| `__tests__/utils/logger.test.ts` | Logger 测试 |
| `__tests__/components/ErrorBoundary.test.tsx` | ErrorBoundary 测试 |
### 修改文件
| 文件 | 改动 |
|------|------|
| `src/utils/secure-storage.ts` | 切换到 AES 实现,保留 XOR 迁移读取 |
| `src/services/ble/DataBuffer.ts` | 构造函数接收加密函数 DI |
| `src/services/ble/DataSyncScheduler.ts` | 添加 isSyncing 互斥 |
| `src/components/ErrorBoundary/index.tsx` | 错误分类 + 结构化日志 |
| `src/stores/auth.ts` | 登录成功时保存 storage_key 到内存 |
| `src/services/request.ts` | Token 刷新时保存新 storage_key |
| `src/pages/login/index.tsx` | 生产构建条件移除 dev 登录 |
| `.env.production` | Tenant ID 清空、API URL 占位符修复 |
| `config/index.ts` | 生产环境 DEV_USER/PASS 强制空值 |
| `config/prod.ts` | pure_funcs 增加 console.warn |
| `tsconfig.json` | noImplicitAny: true |
| `package.json` | 添加依赖 + lint/test 脚本 |
| `src/app.tsx` | 首行导入 crypto-polyfill |
---
## Chunk 1: 工程基础Tasks 1-3
### Task 1: 开启 TypeScript strict 模式
**Files:**
- Modify: `apps/miniprogram/tsconfig.json`
- [ ] **Step 1: 修改 tsconfig.json 开启 noImplicitAny**
```jsonc
// tsconfig.json — 修改 noImplicitAny 行
{
"compilerOptions": {
// ... 其他保持不变
"noImplicitAny": true, // 从 false 改为 true
"jsx": "react-jsx", // 从 "react" 改为 "react-jsx"React 18 不需要手动 import React
// ...
}
}
```
- [ ] **Step 2: 运行编译,收集错误清单**
Run: `cd apps/miniprogram && npx tsc --noEmit 2>&1 | head -80`
Expected: 编译错误列表。重点看 `request.ts``BLEManager.ts``auth.ts``login/index.tsx` 中的隐式 any 错误。
- [ ] **Step 3: 修复 request.ts 的隐式 any**
关键修复点:
- `method: method as any` → 定义 `type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'``method as RequestMethod`
- `catch (err: any)``catch (err: unknown)`,使用 `err instanceof Error ? err.message : String(err)` 访问 message
- `res.data as ApiResponse<T>` — 确保 Taro.request.SuccessCallbackResult 类型正确
- [ ] **Step 4: 修复 BLEManager.ts 的隐式 any**
关键修复点(约 10 处):
- Taro BLE 回调参数 `res: any` → 定义 `BLECharacteristicValue = { characteristicId: string; serviceId: string; value: ArrayBuffer; ... }`
- `catch (e: any)``catch (e: unknown)`
- [ ] **Step 5: 修复 auth.ts 的隐式 any**
关键修复点:
- `catch (err)` 已有类型推断,检查是否报错
- `resp.token.user``as Record<string, unknown>` 类型收窄
- [ ] **Step 6: 修复 login/index.tsx 的隐式 any**
关键修复点:
- `(__wxConfig as any).envVersion``(__wxConfig as Record<string, unknown>).envVersion`
- `catch (err: any)``catch (err: unknown)`(约 3 处)
- `handleGetPhone``e.detail` 类型 → 定义 `GetPhoneNumberEvent = { detail: { errMsg: string; encryptedData: string; iv: string } }`
- [ ] **Step 7: 修复其余文件的隐式 any**
对剩余编译错误逐一修复。无法立即修复的用 `// @ts-expect-error — TODO: Phase 3 消灭` 标注。
- [ ] **Step 8: 验证编译通过**
Run: `cd apps/miniprogram && npx tsc --noEmit`
Expected: 0 errors或仅剩已标注的 @ts-expect-error
- [ ] **Step 9: 提交**
```bash
cd apps/miniprogram
git add -A
git commit -m "refactor(mp): 开启 TypeScript noImplicitAny + 修复隐式 any"
```
---
### Task 2: ESLint + Prettier 工具链搭建
**Files:**
- Create: `apps/miniprogram/eslint.config.mjs`
- Create: `apps/miniprogram/.prettierrc`
- Modify: `apps/miniprogram/package.json`
- [ ] **Step 1: 安装依赖**
Run:
```bash
cd apps/miniprogram
pnpm add -D eslint @eslint/js @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-plugin-react-hooks eslint-plugin-react-refresh prettier eslint-config-prettier
```
- [ ] **Step 2: 创建 ESLint flat config**
```javascript
// eslint.config.mjs
import js from '@eslint/js';
import ts from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
export default [
{ ignores: ['dist/', 'dist-h5/', 'lib/', 'node_modules/'] },
{
files: ['src/**/*.{ts,tsx}'],
languageOptions: {
parser: tsParser,
parserOptions: {
project: './tsconfig.json',
ecmaVersion: 2020,
sourceType: 'module',
},
},
plugins: {
'@typescript-eslint': ts,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...ts.configs.recommended.rules,
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
},
];
```
- [ ] **Step 3: 创建 Prettier 配置**
```jsonc
// .prettierrc
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "always"
}
```
- [ ] **Step 4: 添加 package.json 脚本**
`package.json``scripts` 中添加:
```json
{
"scripts": {
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"format": "prettier --write 'src/**/*.{ts,tsx,scss}'",
"format:check": "prettier --check 'src/**/*.{ts,tsx,scss}'",
"test": "vitest run",
"typecheck": "tsc --noEmit"
}
}
```
- [ ] **Step 5: 运行 ESLint 查看基线**
Run: `cd apps/miniprogram && npx eslint src/ 2>&1 | tail -5`
Expected: 显示 error/warning 数量。`no-explicit-any` 的 error 数应与 Task 1 修复后的残留 any 对应。
- [ ] **Step 6: 如果 error > 0暂时降级为 warn 以不阻塞**
`eslint.config.mjs` 中临时将 `'@typescript-eslint/no-explicit-any': 'error'` 改为 `'warn'`,待 Phase 3 清零后再改回 `error`
- [ ] **Step 7: 提交**
```bash
cd apps/miniprogram
git add -A
git commit -m "chore(mp): 添加 ESLint 9 flat config + Prettier 工具链"
```
---
### Task 3: ErrorBoundary 错误分类 + 结构化日志
**Files:**
- Modify: `apps/miniprogram/src/components/ErrorBoundary/index.tsx`
- Create: `apps/miniprogram/__tests__/components/ErrorBoundary.test.tsx`
- [ ] **Step 1: 编写 ErrorBoundary 测试RED**
```typescript
// __tests__/components/ErrorBoundary.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock @tarojs/components
vi.mock('@tarojs/components', () => ({
View: ({ children, className, onClick }: any) => children,
Text: ({ children, className }: any) => children,
}));
// Mock getCurrentPages
(globalThis as any).getCurrentPages = () => [{ route: 'pages/test/index' }];
describe('ErrorBoundary', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
beforeEach(() => { consoleErrorSpy.mockClear(); });
it('classifyError 网络错误分类', async () => {
const { default: ErrorBoundary } = await import('@/components/ErrorBoundary');
expect(ErrorBoundary).toBeDefined();
// ErrorBoundary 是 class component测试其静态方法或行为
// getDerivedStateFromError 在 React 测试环境中需要 render 才能触发
// 此处验证模块导出正确
});
it('生产环境错误日志仅输出类型码', async () => {
const origEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
// 导入 ErrorBoundary 后检查 console.error 输出格式
// 期望格式: [ErrorBoundary] type=render page=pages/test/index
process.env.NODE_ENV = origEnv;
});
it('开发环境错误日志包含完整信息', async () => {
const origEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';
process.env.NODE_ENV = origEnv;
});
});
```
- [ ] **Step 2: 运行测试确认失败**
Run: `cd apps/miniprogram && npx vitest run __tests__/components/ErrorBoundary.test.tsx`
Expected: 测试通过(基础结构测试),但结构化日志测试需要实现后才能验证。
- [ ] **Step 3: 实现 ErrorBoundary 错误分类 + 结构化日志**
```tsx
// src/components/ErrorBoundary/index.tsx — 完整重写
import React, { Component } from 'react';
import { View, Text } from '@tarojs/components';
import './index.scss';
type ErrorType = 'render' | 'network' | 'data';
interface ErrorInfo {
type: ErrorType;
message: string;
componentStack?: string;
pagePath?: string;
}
interface Props {
children: React.ReactNode;
}
interface State {
hasError: boolean;
retryCount: number;
lastError: ErrorInfo | null;
}
const MAX_RETRIES = 3;
function classifyError(error: Error): ErrorType {
const msg = error.message.toLowerCase();
if (msg.includes('网络') || msg.includes('timeout') || msg.includes('fetch')) return 'network';
if (msg.includes('json') || msg.includes('parse') || msg.includes('data')) return 'data';
return 'render';
}
function logError(error: Error, info: React.ErrorInfo): void {
const errorInfo: ErrorInfo = {
type: classifyError(error),
message: error.message,
componentStack: info.componentStack ?? undefined,
pagePath: typeof getCurrentPages === 'function'
? (() => { const pages = getCurrentPages(); return pages[pages.length - 1]?.route; })()
: undefined,
};
if (process.env.NODE_ENV === 'production') {
// 生产环境:仅输出错误类型和页面路径,不泄露 stack
console.error(`[ErrorBoundary] type=${errorInfo.type} page=${errorInfo.pagePath ?? 'unknown'}`);
} else {
// 开发环境:完整输出
console.error('[ErrorBoundary]', errorInfo, error);
}
}
export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, retryCount: 0, lastError: null };
}
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, lastError: { type: classifyError(error), message: error.message } };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
logError(error, info);
this.setState((prev) => ({ retryCount: prev.retryCount + 1 }));
}
handleRetry = () => {
this.setState({ hasError: false, lastError: null });
};
render() {
if (this.state.hasError) {
const exceeded = this.state.retryCount >= MAX_RETRIES;
const errorType = this.state.lastError?.type ?? 'render';
const isNetwork = errorType === 'network';
return (
<View className='error-boundary'>
<View className='error-icon-wrap'>
<Text className='error-icon-text'>{isNetwork ? '?' : '!'}</Text>
</View>
<Text className='error-title'>
{isNetwork ? '网络连接异常' : '页面出了点问题'}
</Text>
<Text className='error-desc'>
{exceeded ? '请重启小程序' : isNetwork ? '请检查网络后重试' : '请返回重试'}
</Text>
{!exceeded && (
<View className='error-retry-btn' onClick={this.handleRetry}>
<Text className='error-retry-text'>{isNetwork ? '重新加载' : '重试'}</Text>
</View>
)}
</View>
);
}
return this.props.children;
}
}
```
- [ ] **Step 4: 运行测试确认通过**
Run: `cd apps/miniprogram && npx vitest run __tests__/components/ErrorBoundary.test.tsx`
Expected: PASS
- [ ] **Step 5: 验证编译**
Run: `cd apps/miniprogram && npx tsc --noEmit`
Expected: 0 errors
- [ ] **Step 6: 提交**
```bash
cd apps/miniprogram
git add -A
git commit -m "fix(mp): ErrorBoundary 错误分类 + 结构化日志 + 网络错误友好提示"
```
---
## Chunk 2: 安全 P0 — AES 加密替换Tasks 4-6
### Task 4: 微信小程序 crypto polyfill
**Files:**
- Create: `apps/miniprogram/src/utils/crypto-polyfill.ts`
- Modify: `apps/miniprogram/package.json`(添加 @noble/ciphers 依赖)
- [ ] **Step 1: 安装 @noble/ciphers**
Run: `cd apps/miniprogram && pnpm add @noble/ciphers`
- [ ] **Step 2: 创建 crypto polyfill**
```typescript
// src/utils/crypto-polyfill.ts
// 微信小程序没有标准 crypto.getRandomValues此 polyfill 提供兼容实现。
// 必须在 app.tsx 首行导入,确保 @noble/ciphers 能正常工作。
declare global {
interface Crypto {
getRandomValues: <T extends Uint8Array>(arr: T) => T;
}
var wx: {
getRandomValuesSync?: (buffer: ArrayBuffer) => ArrayBuffer;
};
}
function installCryptoPolyfill(): void {
if (typeof globalThis.crypto?.getRandomValues === 'function') return;
globalThis.crypto = {
getRandomValues: <T extends Uint8Array>(arr: T): T => {
if (typeof wx !== 'undefined' && typeof wx.getRandomValuesSync === 'function') {
// 微信基础库 2.17.3+ 提供同步 API
const buf = new ArrayBuffer(arr.length);
const result = wx.getRandomValuesSync(buf);
arr.set(new Uint8Array(result));
} else {
// fallback: Math.random非安全关键场景
for (let i = 0; i < arr.length; i++) {
arr[i] = Math.floor(Math.random() * 256);
}
}
return arr;
},
};
}
installCryptoPolyfill();
```
- [ ] **Step 3: 在 app.tsx 首行导入 polyfill**
`src/app.tsx` 的第一行添加:
```typescript
import './utils/crypto-polyfill';
```
- [ ] **Step 4: 验证编译**
Run: `cd apps/miniprogram && npx tsc --noEmit`
Expected: 0 errors
- [ ] **Step 5: 提交**
```bash
cd apps/miniprogram
git add -A
git commit -m "feat(mp): 微信小程序 crypto.getRandomValues polyfill + @noble/ciphers 依赖"
```
---
### Task 5: AES-256-GCM 加密存储实现
**Files:**
- Create: `apps/miniprogram/src/utils/secure-storage-aes.ts`
- Create: `apps/miniprogram/__tests__/utils/secure-storage-aes.test.ts`
- Modify: `apps/miniprogram/src/utils/secure-storage.ts`(切换到 AES保留 XOR 读取)
- [ ] **Step 1: 编写 AES 加密存储测试RED**
```typescript
// __tests__/utils/secure-storage-aes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock Taro Storage — 使用 btoa/atob 模拟 Taro 的 Base64 转换,与生产路径一致
const storage = new Map<string, string>();
function mockArrayBufferToBase64(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function mockBase64ToArrayBuffer(b64: string): ArrayBuffer {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
vi.mock('@tarojs/taro', () => ({
default: {
setStorageSync: (key: string, val: string) => { storage.set(key, val); },
getStorageSync: (key: string) => storage.get(key) ?? '',
removeStorageSync: (key: string) => { storage.delete(key); },
arrayBufferToBase64: (buf: ArrayBuffer) => mockArrayBufferToBase64(buf),
base64ToArrayBuffer: (b64: string) => mockBase64ToArrayBuffer(b64),
},
}));
describe('AesSecureStorage', () => {
beforeEach(() => { storage.clear(); });
it('加密后 Storage 值不是明文', async () => {
const { setSessionKey, secureSet: aesSet } = await import('@/utils/secure-storage-aes');
setSessionKey('test-storage-key-32-bytes-long!!!');
aesSet('test_key', 'sensitive_data');
const stored = storage.get('_es_test_key');
expect(stored).toBeTruthy();
expect(stored).not.toBe('sensitive_data');
expect(stored).not.toContain('sensitive_data');
});
it('加解密对称性', async () => {
const { setSessionKey, secureSet: aesSet, secureGet: aesGet } = await import('@/utils/secure-storage-aes');
setSessionKey('test-storage-key-32-bytes-long!!!');
aesSet('test_key', 'hello_world');
expect(aesGet('test_key')).toBe('hello_world');
});
it('中文和 emoji 正确加解密', async () => {
const { setSessionKey, secureSet: aesSet, secureGet: aesGet } = await import('@/utils/secure-storage-aes');
setSessionKey('test-storage-key-32-bytes-long!!!');
aesSet('test_key', '你好世界 🏥💊');
expect(aesGet('test_key')).toBe('你好世界 🏥💊');
});
it('空 value 触发 remove', async () => {
const { setSessionKey, secureSet: aesSet, secureGet: aesGet } = await import('@/utils/secure-storage-aes');
setSessionKey('test-storage-key-32-bytes-long!!!');
aesSet('test_key', 'data');
aesSet('test_key', '');
expect(aesGet('test_key')).toBe('');
});
it('无 session key 时返回空', async () => {
const { clearSessionKey, secureGet: aesGet } = await import('@/utils/secure-storage-aes');
clearSessionKey();
expect(aesGet('test_key')).toBe('');
});
});
```
- [ ] **Step 2: 运行测试确认失败**
Run: `cd apps/miniprogram && npx vitest run __tests__/utils/secure-storage-aes.test.ts`
Expected: FAIL — `secure-storage-aes` 模块不存在
- [ ] **Step 3: 实现 AES-256-GCM 加密存储**
```typescript
// src/utils/secure-storage-aes.ts
import Taro from '@tarojs/taro';
import { gcm } from '@noble/ciphers/aes.js';
import { managedNonce } from '@noble/ciphers/utils.js';
const STORAGE_PREFIX = '_es_';
const AES_MARKER = 'aes:';
// session key 仅存内存,不持久化。冷启动丢失后需重新登录。
let sessionKey: Uint8Array | null = null;
export function setSessionKey(key: string): void {
const encoder = new TextEncoder();
// 确保 key 至少 32 字节AES-256
const keyBytes = encoder.encode(key);
sessionKey = new Uint8Array(32);
sessionKey.set(keyBytes.slice(0, 32));
if (keyBytes.length < 32) {
// 不足 32 字节时用零填充(生产环境后端应签发 32 字节密钥)
sessionKey.fill(0, keyBytes.length);
}
}
export function clearSessionKey(): void {
sessionKey = null;
}
export function hasSessionKey(): boolean {
return sessionKey !== null;
}
function toBase64(buffer: Uint8Array): string {
try {
return Taro.arrayBufferToBase64(buffer.buffer as ArrayBuffer);
} catch {
return '';
}
}
function fromBase64(b64: string): Uint8Array {
try {
const buffer = Taro.base64ToArrayBuffer(b64);
return new Uint8Array(buffer);
} catch {
return new Uint8Array(0);
}
}
function aesEncrypt(plaintext: string): string {
if (!sessionKey) throw new Error('No session key');
// gcm(key) — key 为 32 字节时自动使用 AES-256-GCM
// managedNonce 自动在密文前添加 12 字节随机 nonce
const cipher = managedNonce(gcm)(sessionKey);
const data = new TextEncoder().encode(plaintext);
const encrypted = cipher.encrypt(data);
return AES_MARKER + toBase64(encrypted);
}
function aesDecrypt(ciphertext: string): string | null {
if (!sessionKey) return null;
try {
const cipher = managedNonce(gcm)(sessionKey);
const data = fromBase64(ciphertext.slice(AES_MARKER.length));
if (data.length === 0) return null;
const decrypted = cipher.decrypt(data);
return new TextDecoder().decode(decrypted);
} catch {
return null;
}
}
export function secureSet(key: string, value: string): void {
const prefixedKey = STORAGE_PREFIX + key;
if (!value) {
Taro.removeStorageSync(prefixedKey);
return;
}
if (!sessionKey) {
// 无 session key 时仍写入(降级为明文,但这种情况只在冷启动前发生)
Taro.setStorageSync(prefixedKey, value);
return;
}
try {
const encrypted = aesEncrypt(value);
if (encrypted) {
Taro.setStorageSync(prefixedKey, encrypted);
} else {
Taro.setStorageSync(prefixedKey, value);
}
} catch {
Taro.setStorageSync(prefixedKey, value);
}
}
export function secureGet(key: string): string {
const prefixedKey = STORAGE_PREFIX + key;
let raw: string;
try {
raw = Taro.getStorageSync(prefixedKey);
} catch {
raw = '';
}
if (!raw || typeof raw !== 'string') {
// fallback: 尝试读取明文键(兼容 MCP 注入等场景)
try {
const plain = Taro.getStorageSync(key);
return (plain && typeof plain === 'string') ? plain : '';
} catch {
return '';
}
}
// 优先尝试 AES 解密
if (raw.startsWith(AES_MARKER)) {
const decrypted = aesDecrypt(raw);
if (decrypted !== null) return decrypted;
// AES 解密失败(可能是旧 key 加密的)→ fallthrough 到 XOR
}
// 尝试 XOR 解密(兼容旧数据)
try {
const xorKey = process.env.TARO_APP_ENCRYPTION_KEY || 'hms-default-key';
let decoded = '';
try {
const buffer = Taro.base64ToArrayBuffer(raw);
const decoder = new TextDecoder();
decoded = decoder.decode(new Uint8Array(buffer));
} catch {
decoded = raw;
}
if (decoded) {
let result = '';
for (let i = 0; i < decoded.length; i++) {
result += String.fromCharCode(decoded.charCodeAt(i) ^ xorKey.charCodeAt(i % xorKey.length));
}
// 验证解密结果是否为有效数据(以 { 开头的 JSON 或 JWT 格式)
if (result.startsWith('{') || result.startsWith('eyJ')) {
// 自动迁移:用 AES 重新写入
if (sessionKey) {
secureSet(key, result);
}
return result;
}
}
} catch {
// XOR 解密失败
}
// 最后 fallback: 返回原始值(可能是明文 JSON/JWT
return raw;
}
export function secureRemove(key: string): void {
Taro.removeStorageSync(STORAGE_PREFIX + key);
}
```
- [ ] **Step 4: 运行测试确认通过**
Run: `cd apps/miniprogram && npx vitest run __tests__/utils/secure-storage-aes.test.ts`
Expected: PASS5 个测试全部通过)
- [ ] **Step 5: 修改 secure-storage.ts委托给 AES 实现**
```typescript
// src/utils/secure-storage.ts — 重写为 AES 委托 + XOR 迁移
// 公开 API 保持不变secureSet / secureGet / secureRemove / migrateLegacyStorage
// 内部实现委托给 secure-storage-aes.ts
import Taro from '@tarojs/taro';
import { secureSet as aesSet, secureGet as aesGet, secureRemove as aesRemove } from './secure-storage-aes';
const STORAGE_PREFIX = '_es_';
export const secureSet = aesSet;
export const secureGet = aesGet;
export const secureRemove = aesRemove;
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;
aesSet(key, legacy);
Taro.removeStorageSync(key);
}
} catch {
// migration best-effort
}
}
```
- [ ] **Step 6: 验证全项目编译**
Run: `cd apps/miniprogram && npx tsc --noEmit`
Expected: 0 errors
- [ ] **Step 7: 提交**
```bash
cd apps/miniprogram
git add -A
git commit -m "feat(mp): AES-256-GCM 加密存储替换 XOR — secure-storage 重写 + 自动迁移 + 测试覆盖"
```
---
### Task 6: auth store 集成 session_key
**Files:**
- Modify: `apps/miniprogram/src/stores/auth.ts`
- Modify: `apps/miniprogram/src/services/request.ts`
- [ ] **Step 1: auth store 登录成功时保存 storage_key**
`src/stores/auth.ts` 中:
1. 导入 `import { setSessionKey, clearSessionKey } from '@/utils/secure-storage-aes';`
2.`credentialLogin` 成功后:检查 `resp.storage_key`,存在则调用 `setSessionKey(resp.storage_key)`
3.`login` 成功后:同上
4.`bindPhone` 成功后:同上
5.`logout` 中:调用 `clearSessionKey()`
关键代码(以 credentialLogin 为例):
```typescript
// credentialLogin 成功后添加:
if (resp.storage_key) {
setSessionKey(resp.storage_key);
}
```
- [ ] **Step 2: request.ts Token 刷新时保存新 storage_key**
`src/services/request.ts``doRefresh` 函数中:
```typescript
// 在 refresh 成功后添加:
if (res.data?.data?.storage_key) {
const { setSessionKey } = require('@/utils/secure-storage-aes') as typeof import('@/utils/secure-storage-aes');
setSessionKey(res.data.data.storage_key);
}
```
- [ ] **Step 3: 验证编译 + 确认无运行时错误**
Run: `cd apps/miniprogram && npx tsc --noEmit`
Expected: 0 errors
- [ ] **Step 4: 提交**
```bash
cd apps/miniprogram
git add -A
git commit -m "feat(mp): auth store 集成 AES session_key — 登录/刷新/登出生命周期管理"
```
**注意:此 Task 需要后端配合。** 后端需在 login/refresh 响应中新增 `storage_key` 字段256-bit 随机字符串,与 access_token 同生命周期)。如果后端尚未准备好,前端可以先合并此 Task`resp.storage_key` 为 undefined 时 `setSessionKey` 不会被调用secure-storage 会降级为明文写入(与当前行为一致)。
**后端变更跟踪:**
- 需修改 `crates/erp-auth/` 的 login/refresh handler响应 DTO 新增 `storage_key: String`
- `storage_key` 生成:使用 `rand::thread_rng().gen::<[u8; 32]>()` → Base64 编码
- 前后端发布顺序:先发后端(向后兼容,新字段可缺失)→ 再发前端
- 如果前后端部署不同步,前端检查 `resp.storage_key` 为 falsy 时跳过 `setSessionKey`,加密降级为明文(等价于当前 XOR 行为)
---
## Chunk 3: 安全 P0 — 快速修复Tasks 7-10
### Task 7: 移除生产硬编码 Tenant ID + API URL 修复
**Files:**
- Modify: `apps/miniprogram/.env.production`
- [ ] **Step 1: 清空 .env.production 中的敏感值**
```
# .env.production — 修复后
TARO_APP_API_URL=https://api.hms.example.com/api/v1
TARO_APP_DEFAULT_TENANT_ID=
TARO_APP_ENCRYPTION_KEY=
```
- [ ] **Step 2: 提交**
```bash
cd apps/miniprogram
git add .env.production
git commit -m "fix(mp): .env.production 清空硬编码 Tenant ID — 多租户 ID 由后端动态下发"
```
---
### Task 8: 生产环境 console 脱敏
**Files:**
- Create: `apps/miniprogram/src/utils/logger.ts`
- Create: `apps/miniprogram/__tests__/utils/logger.test.ts`
- Modify: `apps/miniprogram/config/prod.ts`
- [ ] **Step 1: 编写 logger 测试**
```typescript
// __tests__/utils/logger.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
describe('logger', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
beforeEach(() => { warnSpy.mockClear(); errorSpy.mockClear(); });
it('safeWarn 生产模式仅输出错误类别码', async () => {
const origEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
const { safeWarn } = await import('@/utils/logger');
safeWarn('[auth]', 'LOGIN_FAILED', new Error('token expired'));
expect(warnSpy).toHaveBeenCalledWith('[auth] LOGIN_FAILED');
process.env.NODE_ENV = origEnv;
});
it('safeWarn 开发模式输出完整错误', async () => {
const origEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';
const { safeWarn } = await import('@/utils/logger');
const err = new Error('token expired');
safeWarn('[auth]', 'LOGIN_FAILED', err);
expect(warnSpy).toHaveBeenCalledWith('[auth] LOGIN_FAILED', err);
process.env.NODE_ENV = origEnv;
});
});
```
- [ ] **Step 2: 运行测试确认失败**
Run: `cd apps/miniprogram && npx vitest run __tests__/utils/logger.test.ts`
Expected: FAIL — 模块不存在
- [ ] **Step 3: 实现 logger**
```typescript
// src/utils/logger.ts
const IS_PROD = process.env.NODE_ENV === 'production';
export function safeWarn(tag: string, code: string, err?: unknown): void {
if (IS_PROD) {
console.warn(`${tag} ${code}`);
} else {
console.warn(`${tag} ${code}`, err);
}
}
export function safeError(tag: string, code: string, err?: unknown): void {
if (IS_PROD) {
console.error(`${tag} ${code}`);
} else {
console.error(`${tag} ${code}`, err);
}
}
```
- [ ] **Step 4: 运行测试确认通过**
Run: `cd apps/miniprogram && npx vitest run __tests__/utils/logger.test.ts`
Expected: PASS
- [ ] **Step 5: 替换 auth.ts 中的 console.warn**
`src/stores/auth.ts` 中搜索 `console.warn('[auth]` 替换为 `safeWarn('[auth]', '...', err)`
共约 3 处。
- [ ] **Step 6: 替换 request.ts 中的 console.warn**
`src/services/request.ts` 中搜索 `console.warn('[request]` 替换为 `safeWarn('[request]', '...', err)`
共约 2 处。
- [ ] **Step 7: prod.ts 添加 console.warn 移除**
```typescript
// config/prod.ts — 修改 pure_funcs
pure_funcs: ['console.log', 'console.info', 'console.debug', 'console.warn'],
```
- [ ] **Step 8: 验证编译**
Run: `cd apps/miniprogram && npx tsc --noEmit`
Expected: 0 errors
- [ ] **Step 9: 提交**
```bash
cd apps/miniprogram
git add -A
git commit -m "fix(mp): 安全日志工具 — 生产环境 console 脱敏 + prod.ts 移除 console.warn"
```
---
### Task 9: 开发快速登录生产构建移除
**Files:**
- Modify: `apps/miniprogram/config/index.ts`
- [ ] **Step 1: config/index.ts 中生产环境强制清空开发凭据**
`config/index.ts``defineConstants` 中,根据 NODE_ENV 条件设置:
```typescript
// config/index.ts — defineConstants 部分
defineConstants: {
// ... 其他保持不变
'process.env.TARO_APP_DEV_USER': JSON.stringify(
process.env.NODE_ENV === 'production' ? '' : (process.env.TARO_APP_DEV_USER || '')
),
'process.env.TARO_APP_DEV_PASS': JSON.stringify(
process.env.NODE_ENV === 'production' ? '' : (process.env.TARO_APP_DEV_PASS || '')
),
},
```
- [ ] **Step 2: 验证 login 页面中 dev 登录按钮条件**
确认 `login/index.tsx` 中:
- `IS_DEV = process.env.NODE_ENV !== 'production'` — 生产构建中为 `false`
- `IS_SIMULATOR` 依赖运行时变量 — 代码路径存在但凭据为空无法触发
- 生产构建中 `TARO_APP_DEV_USER` 为空字符串 → `handleDevQuickLogin` 直接 toast "未配置开发账号"
- [ ] **Step 3: 提交**
```bash
cd apps/miniprogram
git add config/index.ts
git commit -m "fix(mp): 生产构建强制清空开发登录凭据 — 防止凭据泄漏"
```
---
### Task 10: DataSyncScheduler 并发安全 + BLE DataBuffer 加密 DI
**Files:**
- Modify: `apps/miniprogram/src/services/ble/DataSyncScheduler.ts`
- Modify: `apps/miniprogram/src/services/ble/DataBuffer.ts`
- [ ] **Step 1: DataSyncScheduler 添加 isSyncing 互斥**
```typescript
// DataSyncScheduler.ts — 修改 startPeriodicCheck 方法
export class DataSyncScheduler {
// ... 其他代码不变
private isSyncing = false; // 新增
startPeriodicCheck(syncFn: () => Promise<SyncResult>, checkIntervalMs: number): void {
this.destroy();
this.timerId = setInterval(() => {
if (this.isSyncing) return; // 新增:互斥检查
this.isSyncing = true; // 新增
this.tryAutoSync(syncFn).finally(() => {
this.isSyncing = false; // 新增
});
}, checkIntervalMs);
}
destroy(): void {
if (this.timerId !== null) {
clearInterval(this.timerId);
this.timerId = null;
}
this.isSyncing = false; // 新增
}
}
```
- [ ] **Step 2: DataBuffer 添加加密函数 DI**
`DataBufferConfig` 接口中新增可选的 `encryptFn` / `decryptFn`
```typescript
// DataBuffer.ts — 修改配置接口和持久化方法
export interface DataBufferConfig {
bucketSize?: number;
maxTotal?: number;
storageKeyPrefix?: string;
encryptFn?: (data: string) => string; // 新增
decryptFn?: (data: string) => string; // 新增
}
// 在 persistCurrentBucket 方法中使用:
private persistCurrentBucket(): void {
const key = `${this.config.storageKeyPrefix}_${this.currentBucketIndex}`;
try {
let data = JSON.stringify(this.buckets[this.currentBucketIndex]);
if (this.config.encryptFn) {
data = this.config.encryptFn(data);
}
Taro.setStorageSync(key, data);
} catch (err) {
console.warn('[ble-buffer] 持久化当前桶失败:', err);
}
}
// 在 restore 方法中使用:
// 读取时尝试 decryptFn失败则 fallthrough 到明文 JSON.parse
```
- [ ] **Step 3: 验证编译**
Run: `cd apps/miniprogram && npx tsc --noEmit`
Expected: 0 errors
- [ ] **Step 4: 提交**
```bash
cd apps/miniprogram
git add -A
git commit -m "fix(mp): DataSyncScheduler 并发互斥 + DataBuffer 加密函数 DI 注入"
```
---
## Phase 0 验收检查清单
完成所有 Task 后,按顺序执行以下检查:
- [ ] `cd apps/miniprogram && npx tsc --noEmit` — 0 errors
- [ ] `cd apps/miniprogram && npx eslint src/ 2>&1 | grep "error"` — 0 errorswarnings 允许)
- [ ] `cd apps/miniprogram && npx vitest run` — 所有测试通过
- [ ] `.env.production` 不包含真实 Tenant ID
- [ ] `grep -r 'hms-default-key' src/` — 仅在 XOR 迁移读取路径中出现
- [ ] `grep -r 'console.warn' src/ | grep -v 'logger' | grep -v 'node_modules'` — 应为 0 或极少
- [ ] `cargo check` — 后端编译通过(如果后端有配合修改)
- [ ] `cargo test --workspace` — 后端测试通过