diff --git a/apps/miniprogram/src/pages/login/index.tsx b/apps/miniprogram/src/pages/login/index.tsx index 50d1679..9ec5dc5 100644 --- a/apps/miniprogram/src/pages/login/index.tsx +++ b/apps/miniprogram/src/pages/login/index.tsx @@ -99,6 +99,24 @@ export default function Login() { } }; + // DevTools 中 getPhoneNumber 不可用,直接传 mock 数据绕过微信 SDK + const handleDevBindPhone = async () => { + try { + const success = await bindPhone('dev_mock', 'dev_mock'); + if (success) { + navigateAfterLogin(); + } + } catch (err: unknown) { + Taro.showModal({ + title: '绑定失败', + content: err instanceof Error ? err.message : '绑定失败', + confirmText: '重新登录', + cancelText: '取消', + success: (res) => { if (res.confirm) setNeedBind(false); }, + }); + } + }; + return ( {/* 品牌区 */} @@ -120,15 +138,29 @@ export default function Login() { ) : ( - + {/* 真机:微信手机号授权 */} + {!(IS_DEV || IS_SIMULATOR) && ( + + )} + {/* DevTools:跳过微信 SDK 直接调后端(后端 wechat_dev_mode 会用 mock 手机号) */} + {(IS_DEV || IS_SIMULATOR) && ( + + )} )} diff --git a/apps/miniprogram/src/utils/secure-storage-aes.ts b/apps/miniprogram/src/utils/secure-storage-aes.ts index f552f69..d771a50 100644 --- a/apps/miniprogram/src/utils/secure-storage-aes.ts +++ b/apps/miniprogram/src/utils/secure-storage-aes.ts @@ -13,6 +13,49 @@ declare const wx: { getRandomValuesSync?: (params: { length: number }) => ArrayBuffer; } | undefined; +// 小程序环境无 TextEncoder/TextDecoder,用纯 JS 替代 +function utf8Encode(str: string): Uint8Array { + const bytes: number[] = []; + for (let i = 0; i < str.length; i++) { + let code = str.charCodeAt(i); + if (code < 0x80) { + bytes.push(code); + } else if (code < 0x800) { + bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f)); + } else if (code >= 0xd800 && code <= 0xdbff) { + // surrogate pair + const hi = code; + const lo = str.charCodeAt(++i); + code = ((hi - 0xd800) << 10) + (lo - 0xdc00) + 0x10000; + bytes.push(0xf0 | (code >> 18), 0x80 | ((code >> 12) & 0x3f), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f)); + } else { + bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f)); + } + } + return new Uint8Array(bytes); +} + +function utf8Decode(bytes: Uint8Array): string { + let str = ''; + let i = 0; + while (i < bytes.length) { + const b = bytes[i++]; + if (b < 0x80) { + str += String.fromCharCode(b); + } else if (b < 0xe0) { + str += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i++] & 0x3f)); + } else if (b < 0xf0) { + str += String.fromCharCode(((b & 0x0f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f)); + } else { + const cp = ((b & 0x07) << 18) | ((bytes[i++] & 0x3f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f); + const hi = ((cp - 0x10000) >> 10) + 0xd800; + const lo = ((cp - 0x10000) & 0x3ff) + 0xdc00; + str += String.fromCharCode(hi, lo); + } + } + return str; +} + function getEncryptionKey(): Uint8Array | null { const hex = process.env.TARO_APP_ENCRYPTION_KEY || ''; if (hex && /^[0-9a-fA-F]{64}$/.test(hex)) { @@ -52,7 +95,7 @@ function aesEncrypt(plaintext: string): string | null { if (!key) return null; const nonce = generateNonce(); const cipher = gcm(key, nonce); - const data = new TextEncoder().encode(plaintext); + const data = utf8Encode(plaintext); const ciphertext = cipher.encrypt(data); const combined = new Uint8Array(nonce.length + ciphertext.length); combined.set(nonce, 0); @@ -73,7 +116,7 @@ function aesDecrypt(encoded: string): string | null { const ciphertext = combined.slice(NONCE_LENGTH); const cipher = gcm(key, nonce); const plaintext = cipher.decrypt(ciphertext); - return new TextDecoder().decode(plaintext); + return utf8Decode(plaintext); } catch { return null; } @@ -96,10 +139,16 @@ export function secureSet(key: string, value: string): void { const encrypted = aesEncrypt(value); if (encrypted) { Taro.setStorageSync(STORAGE_PREFIX + key, encrypted); - } else { - // dev mode: store plaintext with prefix for compatibility - Taro.setStorageSync(STORAGE_PREFIX + key, value); + return; } + // 密钥不可用时的降级策略 + if (process.env.NODE_ENV === 'production') { + // 生产环境不允许明文存储敏感数据 + console.error(`[secure-storage] 拒绝明文写入 production key: ${key} — ENCRYPTION_KEY 未配置`); + return; + } + // dev mode: store plaintext with prefix for compatibility + Taro.setStorageSync(STORAGE_PREFIX + key, value); } export function secureGet(key: string): string {