From 3fd750989f923139424fe1c05bd68bc8ac942a5a Mon Sep 17 00:00:00 2001 From: stu2not Date: Wed, 23 Sep 2026 20:19:14 +0800 Subject: [PATCH] feat: improve Banban UI and binding diagnostics --- .../src/assets/fonts/Muyao-Headers.ttf | Bin 0 -> 10372 bytes banban-mini/src/assets/fonts/README.md | 7 + banban-mini/src/custom-tab-bar/index.scss | 14 +- banban-mini/src/pages/bind/index.tsx | 4 + banban-mini/src/pages/chat/detail/index.scss | 19 +- banban-mini/src/pages/chat/detail/index.tsx | 2 + banban-mini/src/pages/chat/index.scss | 37 +- banban-mini/src/pages/chat/index.tsx | 9 +- banban-mini/src/pages/location/index.scss | 12 - banban-mini/src/pages/sleep/index.scss | 11 - .../src/pages/traffic-binding/index.tsx | 16 +- banban-mini/src/services/traffic-binding.ts | 35 +- banban-mini/src/styles/ui-theme.scss | 2 +- banban-mini/src/utils/artwork.ts | 10 +- banban-mini/src/utils/chat-preview.ts | 4 + banban-mini/tests/chat-presentation.test.cjs | 79 +++ docs/banban-mini-design-atlas/README.md | 23 + .../design-system.css | 647 +++++++++++++++++ .../design-system.html | 209 ++++++ .../overview-original-resolution.css | 145 ++++ .../overview-original-resolution.html | 116 +++ .../overview-readable.css | 80 +++ .../overview-readable.html | 116 +++ docs/banban-mini-design-atlas/overview-v2.css | 84 +++ .../banban-mini-design-atlas/overview-v2.html | 115 +++ docs/banban-mini-design-atlas/overview.css | 242 +++++++ docs/banban-mini-design-atlas/overview.html | 115 +++ .../render-overview.m | 156 ++++ docs/banban-mini-design-atlas/shared.css | 130 ++++ ...ommercial-map-and-sms-application-guide.md | 664 ++++++++++++++++++ ...al-mqtt-stability-concurrency-test-plan.md | 507 +++++++++++++ docs/设备直接绑定接口联调文档(1).md | 431 ++++++++++++ docs/设备直接绑定接口联调文档.md | 265 +++++++ talkingq-url/implementations/minimax_tts.py | 2 +- 34 files changed, 4247 insertions(+), 61 deletions(-) create mode 100644 banban-mini/src/assets/fonts/Muyao-Headers.ttf create mode 100644 banban-mini/src/assets/fonts/README.md create mode 100644 banban-mini/src/utils/chat-preview.ts create mode 100644 banban-mini/tests/chat-presentation.test.cjs create mode 100644 docs/banban-mini-design-atlas/README.md create mode 100644 docs/banban-mini-design-atlas/design-system.css create mode 100644 docs/banban-mini-design-atlas/design-system.html create mode 100644 docs/banban-mini-design-atlas/overview-original-resolution.css create mode 100644 docs/banban-mini-design-atlas/overview-original-resolution.html create mode 100644 docs/banban-mini-design-atlas/overview-readable.css create mode 100644 docs/banban-mini-design-atlas/overview-readable.html create mode 100644 docs/banban-mini-design-atlas/overview-v2.css create mode 100644 docs/banban-mini-design-atlas/overview-v2.html create mode 100644 docs/banban-mini-design-atlas/overview.css create mode 100644 docs/banban-mini-design-atlas/overview.html create mode 100644 docs/banban-mini-design-atlas/render-overview.m create mode 100644 docs/banban-mini-design-atlas/shared.css create mode 100644 docs/commercial-map-and-sms-application-guide.md create mode 100644 docs/local-mqtt-stability-concurrency-test-plan.md create mode 100644 docs/设备直接绑定接口联调文档(1).md create mode 100644 docs/设备直接绑定接口联调文档.md diff --git a/banban-mini/src/assets/fonts/Muyao-Headers.ttf b/banban-mini/src/assets/fonts/Muyao-Headers.ttf new file mode 100644 index 0000000000000000000000000000000000000000..d6cb27a5533c035943b7716ce1c85031d6065444 GIT binary patch literal 10372 zcmaKyYpgA2S;yy{d2e%{S+`mHeqU?%oU>1R_GK+BO`xGIScEDK1QCQ&C{*bMOVJ{T zMFLg`hM;_q03qR`+>G%9mJp4C9}FmnHxeX)4;CfigC^n^we|PBvk%1&ux9VQX5M*k z^F06O|6FEADvF{kdL&As?K|$g{`k#{FVdn8X??{j@4NcC&Bf=Q$Mqj@{ejoq`^Hy) z>D3z-qbPn=6zzZWp1ZHUYU_n>`FZYrn%~7eG$h|u|G@db@VkG{eGfgHd`vyU_(!?k zzW0GwUcLF&pL~Mxx5)K|w65OAeJ|JdU%l_{_RAOVjiUK8Y1@C@1FwIGeX*ZI z){C~~On(3M%jSzOuW$N(l*cqg(bw<(X<0A&&R4(p?P%@E$JKY#XSvLx{&pCn9((e! z=rRp!(W?4Rm`R3Q`>4=TtKW@^&?GDU|4*X+N4+O9GFiB}-G6gFi5}sJl#NvQ$S`@F zYoBdC(KGKjefMd<2lZI=9PafiJg#2K`8MOF`&zUU9Y>dd(5Ld;eXpz3;yteD=fYEnj=|?sxs! zyHB5dGKzlu{XhDfKYagZUis;deEf-z-uuB1ytjP!7amRDdgHJB{zG>>_>J?uveb|M z#CIll>#OF1{iOSA|E=uN{FdTBtIyY;YtzB~$aF0-Xytv%s$4ayQ=967I#xfSUZh^C z?oz*lIP7@TS!efV^TmA8&6dZV<+`(r<>`D$({i?)x!K&#cin7HZcWD4jqQAPMx(QC zPyWz4Ihk~m#d6VE?%R%wZaFzQJw9QE`E1D@z8A}G5mu1fGiT?Po(z;989Q-qk5yzP zW^{{gOvfcVnsBmMGIVjaJY}M@#R-SZxtz)MuG?idyF3L;F%O+(J!Wz=bPKr*8=3bH z!HqK>fS=i%K`h{AY;I&{bHvfv8G9dDcRHW�zf9-Vx2%&O}V|wPb57GdYpRw<5q( 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 literal 0 HcmV?d00001 diff --git a/banban-mini/src/assets/fonts/README.md b/banban-mini/src/assets/fonts/README.md new file mode 100644 index 0000000..fe49402 --- /dev/null +++ b/banban-mini/src/assets/fonts/README.md @@ -0,0 +1,7 @@ +`Muyao-Headers.ttf` is a subset of `Muyao-Softbrush.ttf` for the login and binding headings: 伴伴、补全绑定、添加设备卡片、扫码贴卡绑定设备. Glyph outlines and advances are unchanged. Only the subset is bundled so the mini-program stays below the main-package size limit. + +When changing those headings, regenerate the subset with FontTools: + +```sh +pyftsubset Muyao-Softbrush.ttf --text='伴伴补全绑定添加设备卡片扫码贴卡绑定设备' --output-file=Muyao-Headers.ttf --layout-features='*' --name-IDs='*' --name-legacy --name-languages='*' +``` diff --git a/banban-mini/src/custom-tab-bar/index.scss b/banban-mini/src/custom-tab-bar/index.scss index e8742ce..d2d96d8 100644 --- a/banban-mini/src/custom-tab-bar/index.scss +++ b/banban-mini/src/custom-tab-bar/index.scss @@ -5,6 +5,7 @@ left: 0; right: 0; display: flex; + overflow: hidden; padding: 14px 20px calc(12px + env(safe-area-inset-bottom)); background: #fffdf7; border: 2px solid #df9e55; @@ -15,11 +16,20 @@ .companion-tab { flex: 1; min-width: 0; + overflow: hidden; display: flex; flex-direction: column; align-items: center; color: #d6b18a; } .companion-tab.active { color: #ad681d; } -.companion-tab-icon { width: 62px; height: 52px; } -.companion-tab-label { margin-top: 4px; font-size: 24px; font-weight: 600; } +.companion-tab-icon { display: block; flex-shrink: 0; width: 62px; height: 52px; } +.companion-tab-label { display: block; margin-top: 4px; font-size: 24px; font-weight: 600; line-height: 1.4; white-space: nowrap; } + +.companion-tab-bar::-webkit-scrollbar, +.companion-tab::-webkit-scrollbar, +.companion-tab-label::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} diff --git a/banban-mini/src/pages/bind/index.tsx b/banban-mini/src/pages/bind/index.tsx index 6bb597c..db49b26 100644 --- a/banban-mini/src/pages/bind/index.tsx +++ b/banban-mini/src/pages/bind/index.tsx @@ -511,7 +511,10 @@ export default function Bind() { scanType: ['qrCode', 'barCode'], success: (res) => { setIsScanning(false) + console.info('[bind] scanCode success response:', res) + console.info('[bind] scanCode raw result:', res.result || '') const parsed = parseBindingPayload(res.result || '') + console.info('[bind] scanCode parsed payload:', parsed) resetBindSessionState() if (parsed.deviceId) setDeviceId(parsed.deviceId) if (parsed.serialNumber) setSerialNumber(parsed.serialNumber) @@ -523,6 +526,7 @@ export default function Bind() { }, fail: (err) => { setIsScanning(false) + console.warn('[bind] scanCode failed response:', err) if (err.errMsg && err.errMsg.includes('cancel')) return Taro.showToast({ title: '扫码失败,请重试', diff --git a/banban-mini/src/pages/chat/detail/index.scss b/banban-mini/src/pages/chat/detail/index.scss index 76b30c7..f667cff 100644 --- a/banban-mini/src/pages/chat/detail/index.scss +++ b/banban-mini/src/pages/chat/detail/index.scss @@ -32,13 +32,15 @@ .character-info { flex: 1; + min-width: 0; display: flex; align-items: center; .character-avatar { width: 72px; height: 72px; - background: linear-gradient(135deg, #FF8C42 0%, #FFB347 100%); + flex-shrink: 0; + background: #fff1d4; border-radius: 18px; display: flex; align-items: center; @@ -48,10 +50,16 @@ } .character-text { + flex: 1; + min-width: 0; display: flex; flex-direction: column; .character-name { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; font-size: 32px; font-weight: 600; color: #1A1A1A; @@ -61,6 +69,7 @@ .character-desc { font-size: 24px; color: #999999; + word-break: break-word; } } } @@ -124,8 +133,8 @@ .user-avatar { width: 68px; height: 68px; - background: #FFE4C4; - border-radius: 50%; + background: #fff1d4; + border-radius: 18px; display: flex; align-items: center; justify-content: center; @@ -139,7 +148,7 @@ .peer-avatar { width: 68px; height: 68px; - background: #FEF3E8; + background: #fff1d4; border-radius: 18px; display: flex; align-items: center; @@ -253,6 +262,6 @@ letter-spacing: 2px; } -.chat-avatar-art { width: 100%; height: 100%; } +.chat-avatar-art { display: block; width: 100%; height: 100%; } .user-avatar { margin-left: 14px; } .message-item.user .message-bubble { margin: 0; } diff --git a/banban-mini/src/pages/chat/detail/index.tsx b/banban-mini/src/pages/chat/detail/index.tsx index a7e2281..1c5e220 100644 --- a/banban-mini/src/pages/chat/detail/index.tsx +++ b/banban-mini/src/pages/chat/detail/index.tsx @@ -768,6 +768,8 @@ export default function ChatDetail() { diff --git a/banban-mini/src/pages/chat/index.scss b/banban-mini/src/pages/chat/index.scss index b88c6f3..14daf89 100644 --- a/banban-mini/src/pages/chat/index.scss +++ b/banban-mini/src/pages/chat/index.scss @@ -6,20 +6,8 @@ padding-bottom: calc(120px + env(safe-area-inset-bottom)); } -.page-header { - padding: 80px 32px 24px; - - .page-title { - font-size: 44px; - font-weight: 700; - color: #1A1A1A; - display: block; - line-height: 1.2; - } -} - .conversation-list { - height: calc(100vh - 200px); + height: calc(100vh - var(--bb-navigation-bar-top) - var(--bb-navigation-bar-height) - 144px - env(safe-area-inset-bottom)); padding: 0 24px; box-sizing: border-box; overflow: hidden; @@ -113,7 +101,7 @@ height: 52px; padding: 0 18px; border-radius: 999px; - background: #FFF2E7; + background: #fff1d4; &:active { opacity: 0.72; @@ -159,7 +147,7 @@ } &.parent { - background: #E8F8F0; + background: #fff1d4; text { color: #16A34A; @@ -262,17 +250,23 @@ color: #999999; flex-shrink: 0; margin-left: 16px; + white-space: nowrap; } } .conversation-name-wrap { + flex: 1; display: flex; align-items: center; - flex-wrap: wrap; min-width: 0; } .conversation-name { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; font-size: 32px; font-weight: 600; color: #1A1A1A; @@ -280,6 +274,7 @@ } .conversation-tag { + flex-shrink: 0; height: 40px; padding: 0 14px; border-radius: 999px; @@ -301,12 +296,17 @@ } .conversation-footer { + min-width: 0; display: flex; justify-content: space-between; align-items: center; .last-message { + display: block; + min-width: 0; + max-width: 100%; font-size: 28px; + line-height: 1.5; color: #666666; flex: 1; overflow: hidden; @@ -318,6 +318,9 @@ .conversation-meta { display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; margin-top: 10px; font-size: 24px; color: #A0A0A0; @@ -369,5 +372,5 @@ color: #1A1A1A; } -.conversation-avatar-art { width: 100%; height: 100%; } +.conversation-avatar-art { display: block; width: 100%; height: 100%; } .conversation-avatar { background: #fff1d4; padding: 8px; box-sizing: border-box; } diff --git a/banban-mini/src/pages/chat/index.tsx b/banban-mini/src/pages/chat/index.tsx index c455b91..1e16b31 100644 --- a/banban-mini/src/pages/chat/index.tsx +++ b/banban-mini/src/pages/chat/index.tsx @@ -1,4 +1,5 @@ import { getParticipantArtwork } from '@/utils/artwork' +import { formatConversationPreview } from '@/utils/chat-preview' import PageTitle from '@/components/page-title' import { getPageStyle } from '@/utils/page-style' import { setActiveTabRoute } from '@/utils/tab-bar' @@ -172,7 +173,7 @@ export default function Chat() { 当前宝贝暂无会话 ) : ( - + {conversations.map((conversation) => ( {conversation.time} - - {conversation.lastMessage} - + + {formatConversationPreview(conversation.lastMessage)} + {conversation.childName && {conversation.childName}} diff --git a/banban-mini/src/pages/location/index.scss b/banban-mini/src/pages/location/index.scss index d46b2cf..c90edac 100644 --- a/banban-mini/src/pages/location/index.scss +++ b/banban-mini/src/pages/location/index.scss @@ -6,18 +6,6 @@ padding-bottom: calc(120px + env(safe-area-inset-bottom)); } -.page-header { - padding: 80px 32px 24px; - - .page-title { - font-size: 44px; - font-weight: 700; - color: #1A1A1A; - display: block; - line-height: 1.2; - } -} - .mode-switch { margin: 0 24px 20px; padding: 8px; diff --git a/banban-mini/src/pages/sleep/index.scss b/banban-mini/src/pages/sleep/index.scss index 3b7b073..0174f03 100644 --- a/banban-mini/src/pages/sleep/index.scss +++ b/banban-mini/src/pages/sleep/index.scss @@ -6,17 +6,6 @@ padding-bottom: calc(120px + env(safe-area-inset-bottom)); } -.page-header { - padding: 80px 32px 24px; - - .page-title { - font-size: 44px; - font-weight: 700; - color: #1A1A1A; - display: block; - } -} - .user-card { background: #FFFFFF; border-radius: 20px; diff --git a/banban-mini/src/pages/traffic-binding/index.tsx b/banban-mini/src/pages/traffic-binding/index.tsx index f07bea5..b793ad3 100644 --- a/banban-mini/src/pages/traffic-binding/index.tsx +++ b/banban-mini/src/pages/traffic-binding/index.tsx @@ -27,7 +27,13 @@ export default function TrafficBindingPage() { setOperation(current) if (action === 'unbind' && current.status === 'completed') clearSelectedBindingDeviceId() setError('') - } catch (e: any) { setError(e.message || '暂时无法确认,请重试') } + } catch (e: any) { + console.error('[traffic-binding] recover:error', { + deviceId, action, operationId: operation?.operation_id || null, + code: e?.code || null, message: e?.message || String(e), + }) + setError(e.message || '暂时无法确认,请重试') + } finally { lock.current = false; setBusy(false) } } useDidShow(() => { void recover() }) @@ -49,7 +55,13 @@ export default function TrafficBindingPage() { } setOperation(current) if (action === 'unbind' && current.status === 'completed') clearSelectedBindingDeviceId() - } catch (e: any) { setError(e.message || e.errMsg || '操作未完成,请继续重试') } + } catch (e: any) { + console.error('[traffic-binding] continue:error', { + deviceId, action, operationId: operation?.operation_id || null, + code: e?.code || null, message: e?.message || e?.errMsg || String(e), + }) + setError(e.message || e.errMsg || '操作未完成,请继续重试') + } finally { lock.current = false; setBusy(false) } } const completed = operation?.status === 'completed' diff --git a/banban-mini/src/services/traffic-binding.ts b/banban-mini/src/services/traffic-binding.ts index b7fa622..872b8fb 100644 --- a/banban-mini/src/services/traffic-binding.ts +++ b/banban-mini/src/services/traffic-binding.ts @@ -3,6 +3,9 @@ import { request } from './api' import { getCurrentUserId } from './session' export const TRAFFIC_APP_ID = 'wx56e1cfc52aab630f' +function trafficLog(event: string, details: Record = {}) { + console.info(`[traffic-binding] ${event}`, details) +} export type TrafficAction = 'bind' | 'unbind' export interface TrafficOperation { operation_id: string @@ -32,7 +35,11 @@ export function rememberOperation(operation: TrafficOperation) { Taro.setStorageSync(key(), { operation, receipt: previous?.operation.operation_id === operation.operation_id ? previous.receipt : undefined }) } export function captureTrafficReturn(options: any): boolean { - if (options?.referrerInfo?.appId !== TRAFFIC_APP_ID) return false + const sourceAppId = options?.referrerInfo?.appId + if (sourceAppId !== TRAFFIC_APP_ID) { + trafficLog('return:ignored-app', { sourceAppId: sourceAppId || null, expectedAppId: TRAFFIC_APP_ID }) + return false + } const data = options.referrerInfo.extraData const pending = getPendingTraffic() if (!pending || !data || data.operationId !== pending.operation.operation_id @@ -104,10 +111,17 @@ export async function resumeTrafficOperation(deviceId: string, action: TrafficAc return startTrafficOperation(deviceId, action) } export async function finishTrafficUnbind(operation: TrafficOperation) { + trafficLog('finish-unbind:start', { + operationId: operation.operation_id, deviceId: operation.device_id, + status: operation.status, trafficStatus: operation.traffic_status, + }) const user = getCurrentUserId() const result = await request(`/banban/joint-bindings/${operation.operation_id}/finish-unbind`, { method: 'POST' }) assertSameUser(user) rememberOperation(result) + trafficLog('finish-unbind:response', { + operationId: result.operation_id, status: result.status, trafficStatus: result.traffic_status, + }) return result } function targetVersion(): 'trial' | 'release' { @@ -126,11 +140,28 @@ async function navigate(options: any, withinApp = false) { } export async function jumpTraffic(operation: TrafficOperation) { const user = getCurrentUserId() + trafficLog('continue:start', { + operationId: operation.operation_id, deviceId: operation.device_id, + action: operation.action, status: operation.status, trafficStatus: operation.traffic_status, + }) const data = await request(`/banban/joint-bindings/${operation.operation_id}/prepare`, { method: 'POST' }) assertSameUser(user) rememberOperation(data.operation) + const targetAppMatches = data.target_app_id === TRAFFIC_APP_ID + trafficLog('continue:prepare-response', { + operationId: operation.operation_id, action: operation.action, + status: data.operation?.status || null, trafficStatus: data.operation?.traffic_status || null, + targetAppId: data.target_app_id || null, expectedAppId: TRAFFIC_APP_ID, + targetAppMatches, hasHandoffToken: typeof data.handoff_token === 'string' && data.handoff_token.length > 0, + hasRequestId: typeof data.request_id === 'string' && data.request_id.length > 0, + }) if (data.operation.status === 'completed' || data.operation.traffic_status === 'server_confirmed') return data.operation as TrafficOperation - if (data.target_app_id !== TRAFFIC_APP_ID) throw new Error('目标小程序配置不一致') + if (!targetAppMatches) { + trafficLog('continue:target-app-mismatch', { + operationId: operation.operation_id, targetAppId: data.target_app_id || null, expectedAppId: TRAFFIC_APP_ID, + }) + throw new Error('目标小程序配置不一致') + } await navigate({ appId: TRAFFIC_APP_ID, path: 'pages/handoff/index', envVersion: targetVersion(), extraData: { operationId: operation.operation_id, action: operation.action, diff --git a/banban-mini/src/styles/ui-theme.scss b/banban-mini/src/styles/ui-theme.scss index 97dce5d..5e75e50 100644 --- a/banban-mini/src/styles/ui-theme.scss +++ b/banban-mini/src/styles/ui-theme.scss @@ -1,6 +1,6 @@ @font-face { font-family: 'MuyaoSoftbrush'; - src: url('../assets/fonts/Muyao-Softbrush.ttf') format('truetype'); + src: url('../assets/fonts/Muyao-Headers.ttf') format('truetype'); font-weight: 400; font-style: normal; } diff --git a/banban-mini/src/utils/artwork.ts b/banban-mini/src/utils/artwork.ts index caf779d..b519572 100644 --- a/banban-mini/src/utils/artwork.ts +++ b/banban-mini/src/utils/artwork.ts @@ -1,15 +1,17 @@ const roleArtwork: Record = { - banban: require('../assets/ui/role-banban.png'), + banban: require('../assets/ui/role-tuntunzai.png'), baikeboshi: require('../assets/ui/role-baike.png'), zhuli: require('../assets/ui/role-zhuli.png'), tuntunzai: require('../assets/ui/role-tuntunzai.png'), } export function getRoleArtwork(roleKey?: string): string { - return roleArtwork[String(roleKey || '').toLowerCase()] || roleArtwork.banban + return roleArtwork[String(roleKey || '').trim().toLowerCase()] || roleArtwork.banban } -export function getParticipantArtwork(kind?: string, _roleKey?: string): string { - if (kind === 'parent') return require('../assets/ui/avatar-parent.png') +export function getParticipantArtwork(kind?: string, roleKey?: string): string { + const participantKind = String(kind || '').trim().toLowerCase() + if (participantKind === 'parent') return require('../assets/ui/avatar-parent.png') + if (participantKind === 'ai') return getRoleArtwork(roleKey) return roleArtwork.tuntunzai } diff --git a/banban-mini/src/utils/chat-preview.ts b/banban-mini/src/utils/chat-preview.ts new file mode 100644 index 0000000..9faa111 --- /dev/null +++ b/banban-mini/src/utils/chat-preview.ts @@ -0,0 +1,4 @@ +// Keep line breaks in message history; only flatten the conversation-list preview. +export function formatConversationPreview(value?: string | null): string { + return String(value || '').replace(/\s+/g, ' ').trim() +} diff --git a/banban-mini/tests/chat-presentation.test.cjs b/banban-mini/tests/chat-presentation.test.cjs new file mode 100644 index 0000000..07da1f0 --- /dev/null +++ b/banban-mini/tests/chat-presentation.test.cjs @@ -0,0 +1,79 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const { test } = require('node:test') +const ts = require('typescript') + +function loadTypeScript(file, mocks = {}) { + const source = fs.readFileSync(path.join(__dirname, '../src', file), 'utf8') + const { outputText } = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017, esModuleInterop: true }, + }) + const module = { exports: {} } + new Function('require', 'module', 'exports', outputText)((id) => { + if (Object.hasOwn(mocks, id)) return mocks[id] + if (id.endsWith('.png')) return path.basename(id) + return require(id) + }, module, module.exports) + return module.exports +} + +const { getParticipantArtwork, getRoleArtwork } = loadTypeScript('utils/artwork.ts') +const { formatConversationPreview } = loadTypeScript('utils/chat-preview.ts') +const story = '好呀,我给你讲一个《小水滴的旅行》的故事。\r\n\r\n有一天,小水滴在云妈妈的怀抱里睡着了。\n小水滴落到了一片荷叶上。\u2028小水滴继续向前,来到了大河里。\n这个故事告诉我们,水是不断循环的。💧' +const roles = ['Banban', 'Baikeboshi', 'Zhuli', 'Tuntunzai'] +const { getConversations, getMessages } = loadTypeScript('services/chat.ts', { + '@tarojs/taro': {}, + './auth': { getCurrentUserId: () => 1 }, + './binding': { loadCurrentChildBindingContext: async () => ({ + currentChild: { child_id: 7, child_name: '拌拌' }, + currentBinding: { device_id: 'fixture-device' }, + }) }, + './session': {}, + './api': { request: async (url) => { + if (url.endsWith('/roles')) return roles.map(role_key => ({ role_key, name: role_key })) + if (url.includes('/ai-conversations')) return { items: roles.map((role_key, index) => ({ + conversation_id: 10 + index, role_key, message_count: 1, last_message_preview: story, + })) } + if (url.includes('/conversations?')) return { items: [ + { conversation_id: 1, conversation_type_name: 'parent_child', peer_type: 'parent', peer_id: '1' }, + { conversation_id: 2, conversation_type_name: 'parent_child', peer_type: 'parent', peer_id: '2', peer_name: '右右' }, + { conversation_id: 3, conversation_type_name: 'child_peer', peer_type: 'child', peer_id: '42', peer_name: '伴伴2号' }, + ] } + if (url.includes('/messages?')) return { items: [{ + id: 100, conversation_id: 11, role_key: 'Baikeboshi', is_user: false, content: story, + }] } + throw new Error(`Unexpected fixture request: ${url}`) + } }, +}) + +test('real conversation mapping gives both parent entries the flower avatar and preserves specialist AI roles', async () => { + const conversations = await getConversations() + const artworkById = Object.fromEntries(conversations.map(item => [ + item.id, getParticipantArtwork(item.peerKind, item.roleKey), + ])) + assert.equal(artworkById[1], 'avatar-parent.png') + assert.equal(artworkById[2], 'avatar-parent.png') + assert.equal(artworkById[3], 'role-tuntunzai.png') + assert.equal(artworkById[10], 'role-tuntunzai.png') + assert.equal(artworkById[11], 'role-baike.png') + assert.equal(artworkById[12], 'role-zhuli.png') +}) + +test('the role chooser and conversation avatars agree for each AI role', () => { + for (const role of roles) { + assert.equal(getParticipantArtwork('ai', role), getRoleArtwork(role)) + assert.equal(getParticipantArtwork('ai', ` ${role.toUpperCase()} `), getRoleArtwork(role)) + } +}) + +test('a multiline story becomes a single-line list preview while message history keeps its paragraphs', async () => { + const conversation = (await getConversations()).find(item => item.id === 11) + const preview = formatConversationPreview(conversation.lastMessage) + assert.doesNotMatch(preview, /[\r\n\u2028\u2029]/) + assert.match(preview, /故事。 有一天/) + assert.ok(preview.endsWith('💧')) + assert.equal(conversation.lastMessage, story) + const messages = await getMessages(11, 'ai') + assert.equal(messages[0].content, story) +}) diff --git a/docs/banban-mini-design-atlas/README.md b/docs/banban-mini-design-atlas/README.md new file mode 100644 index 0000000..1e4768f --- /dev/null +++ b/docs/banban-mini-design-atlas/README.md @@ -0,0 +1,23 @@ +# 伴伴小程序当前页面全景 + +本目录中的 `banban-page-state-overview.png` 根据当前 `banban-mini` 构建生成,包含 11 个页面路由的 26 张真实微信开发者工具截图。 + +## 内容来源 + +- 页面主体全部来自微信开发者工具中的实际小程序页面,不使用 HTML 重画小程序界面。 +- 游客态直接由当前构建生成。 +- 登录态使用本地固定展示数据驱动当前页面组件,不连接生产接口,也不包含真实用户数据。 +- `actual-screens/` 保留总览中使用的原始截图。 + +## 重新渲染 + +在 macOS 上运行: + +```bash +CLANG_MODULE_CACHE_PATH=/tmp/banban-clang-cache clang -fobjc-arc \ + -framework AppKit -framework WebKit render-overview.m \ + -o /tmp/banban-overview-renderer +/tmp/banban-overview-renderer overview.html banban-page-state-overview.png +``` + +`overview.html` 和 `overview.css` 只负责截图编排、分组标题和状态说明,不模拟小程序组件。 diff --git a/docs/banban-mini-design-atlas/design-system.css b/docs/banban-mini-design-atlas/design-system.css new file mode 100644 index 0000000..66503c2 --- /dev/null +++ b/docs/banban-mini-design-atlas/design-system.css @@ -0,0 +1,647 @@ +@import url("./shared.css"); + +.system-poster { + min-height: 2520px; + background: #fffdf9; +} + +.system-header { + display: grid; + grid-template-columns: 1fr 360px; + gap: 50px; + min-height: 300px; + align-items: center; + padding: 58px 64px 52px; + border-bottom: 1px solid #eee8df; +} + +.brand-lockup { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 24px; +} + +.brand-mark { + display: flex; + width: 122px; + height: 122px; + align-items: center; + justify-content: center; + border-radius: 28px; + background: linear-gradient(135deg, var(--orange), var(--orange-deep)); + box-shadow: 0 18px 36px rgba(255, 140, 66, 0.26); + font-size: 64px; +} + +.brand-note { + max-width: 180px; +} + +.brand-note strong { + display: block; + margin-bottom: 6px; + font-size: 22px; +} + +.brand-note span { + color: var(--muted); + font-size: 14px; + line-height: 1.55; +} + +.system-layout { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 0; +} + +.system-main { + padding: 44px 42px 52px 54px; + border-right: 1px solid #eee8df; +} + +.system-aside { + padding: 44px 54px 52px 42px; + background: #fbfaf7; +} + +.spec-section { + padding: 30px 0 34px; + border-bottom: 1px solid #ece8e2; +} + +.spec-section:first-child { padding-top: 0; } +.spec-section:last-child { border-bottom: 0; } + +.spec-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 24px; +} + +.spec-heading h2 { + margin: 0; + font-size: 25px; + line-height: 1.3; +} + +.spec-heading span { + color: var(--quiet); + font-size: 13px; +} + +.button-showcase { + display: grid; + grid-template-columns: 1.45fr 1fr; + gap: 20px 24px; +} + +.control-sample { + min-width: 0; +} + +.sample-label { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 9px; + color: #4b5563; + font-size: 13px; +} + +.sample-label code { + color: var(--quiet); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; +} + +.ds-btn { + display: flex; + width: 100%; + height: 62px; + align-items: center; + justify-content: center; + gap: 10px; + border: 0; + border-radius: 14px; + background: var(--orange); + color: #ffffff; + font-size: 19px; + font-weight: 760; + box-shadow: 0 10px 24px rgba(255, 140, 66, 0.24); +} + +.ds-btn.compact { + width: 214px; + height: 54px; + margin: 0 auto; + font-size: 17px; +} + +.ds-btn.green { background: #07c160; box-shadow: 0 10px 24px rgba(7, 193, 96, 0.18); } +.ds-btn.dark { background: #1f2937; box-shadow: 0 10px 24px rgba(31, 41, 55, 0.18); } +.ds-btn.pressed { transform: translateY(2px); background: var(--orange-deep); box-shadow: 0 4px 10px rgba(255, 107, 53, 0.22); } +.ds-btn.focused { outline: 3px solid rgba(255, 140, 66, 0.32); outline-offset: 3px; } +.ds-btn.disabled { background: #e5e7eb; color: #a9afb7; box-shadow: none; } +.ds-btn.loading { background: #f2f0ed; color: #929292; box-shadow: none; } + +.spinner { + width: 18px; + height: 18px; + border: 2px solid rgba(146, 146, 146, 0.35); + border-top-color: #929292; + border-radius: 50%; +} + +.state-strip { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 12px; +} + +.state-cell .ds-btn { + height: 50px; + font-size: 15px; + border-radius: 12px; +} + +.audio-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; +} + +.audio-btn { + display: flex; + height: 58px; + align-items: center; + justify-content: center; + gap: 10px; + border: 1px solid #ffd0b1; + border-radius: 14px; + background: #fffaf6; + color: var(--orange-deep); + font-size: 16px; + font-weight: 730; +} + +.audio-btn.playing { + border-color: var(--orange); + background: var(--orange-soft); +} + +.audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--orange); + color: #ffffff; + font-size: 12px; +} + +.secondary-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; +} + +.secondary-btn { + display: flex; + height: 54px; + align-items: center; + justify-content: center; + border: 1px solid #ffb786; + border-radius: 13px; + background: #ffffff; + color: var(--orange-deep); + font-size: 16px; + font-weight: 700; +} + +.secondary-btn.neutral { border-color: #d8dde4; color: #4b5563; } +.secondary-btn.danger { border-color: #e7a4b2; color: var(--red); } + +.text-actions { + display: flex; + gap: 48px; + margin-top: 22px; + padding: 0 10px; +} + +.text-action { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--orange-deep); + font-size: 16px; + font-weight: 700; +} + +.icon-grid { + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: 12px; +} + +.icon-sample { + text-align: center; +} + +.round-icon { + display: flex; + width: 54px; + height: 54px; + align-items: center; + justify-content: center; + margin: 0 auto 8px; + border: 1px solid #e5e7eb; + border-radius: 50%; + background: #ffffff; + color: #374151; + font-size: 22px; + box-shadow: 0 4px 12px rgba(31, 41, 55, 0.06); +} + +.icon-sample span:last-child { + color: var(--muted); + font-size: 11px; +} + +.compound-grid { + display: grid; + grid-template-columns: 1.3fr 1fr; + gap: 26px; +} + +.segment { + display: grid; + grid-template-columns: repeat(4, 1fr); + overflow: hidden; + border: 1px solid #e5e7eb; + border-radius: 13px; + background: #ffffff; +} + +.segment span { + display: flex; + height: 52px; + align-items: center; + justify-content: center; + border-right: 1px solid #eeeeee; + color: #4b5563; + font-size: 14px; +} + +.segment span:last-child { border-right: 0; } +.segment .active { background: var(--orange); color: #ffffff; font-weight: 750; } + +.switch-row, +.slider-row { + display: flex; + min-height: 48px; + align-items: center; + justify-content: space-between; + gap: 18px; + color: #4b5563; + font-size: 14px; +} + +.switch { + position: relative; + width: 54px; + height: 30px; + border-radius: 999px; + background: var(--orange); +} + +.switch::after { + position: absolute; + top: 4px; + right: 4px; + width: 22px; + height: 22px; + border-radius: 50%; + background: #ffffff; + box-shadow: 0 2px 6px rgba(31, 41, 55, 0.18); + content: ""; +} + +.slider { + position: relative; + flex: 1; + height: 7px; + border-radius: 999px; + background: linear-gradient(90deg, var(--orange) 0 62%, #e5e7eb 62% 100%); +} + +.slider::after { + position: absolute; + top: 50%; + left: 62%; + width: 20px; + height: 20px; + transform: translate(-50%, -50%); + border: 3px solid #ffffff; + border-radius: 50%; + background: var(--orange); + box-shadow: 0 2px 8px rgba(31, 41, 55, 0.18); + content: ""; +} + +.bottom-nav-demo { + display: grid; + grid-template-columns: repeat(4, 1fr); + max-width: 650px; + margin: 0 auto; + padding: 14px 20px 10px; + border: 1px solid #ececec; + border-radius: 18px 18px 8px 8px; + background: #ffffff; + box-shadow: var(--shadow-soft); +} + +.feedback-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 12px; +} + +.status-chip-demo { + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + gap: 7px; + border-radius: 999px; + background: #f3f4f6; + color: #4b5563; + font-size: 13px; + font-weight: 700; +} + +.status-chip-demo::before { + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; + content: ""; +} + +.status-chip-demo.online { background: #e8f8f0; color: #15803d; } +.status-chip-demo.syncing { background: #e8f0fe; color: #2563eb; } +.status-chip-demo.sleeping { background: #f0e6ff; color: #7c3aed; } +.status-chip-demo.low { background: #fff7e8; color: #c2410c; } +.status-chip-demo.alarm { background: #fff1f2; color: #be123c; } + +.feedback-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + margin-top: 18px; +} + +.feedback-banner { + display: flex; + min-height: 56px; + align-items: center; + gap: 10px; + padding: 0 16px; + border-left: 4px solid #16a34a; + border-radius: 0 10px 10px 0; + background: #f0fdf4; + color: #14532d; + font-size: 14px; + font-weight: 650; +} + +.feedback-banner.warning { + border-left-color: var(--orange); + background: #fff7ed; + color: #9a3412; +} + +.card-demo-grid { + display: grid; + grid-template-columns: 1.15fr 1fr; + gap: 16px; +} + +.status-card-demo { + min-height: 164px; + padding: 22px; + border-radius: 18px; + background: linear-gradient(135deg, var(--orange), var(--orange-deep)); + color: #ffffff; + box-shadow: 0 14px 28px rgba(255, 140, 66, 0.22); +} + +.status-card-demo .demo-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.18); + font-size: 11px; + font-weight: 700; +} + +.status-card-demo strong { + display: block; + margin-top: 18px; + font-size: 38px; + line-height: 1; +} + +.status-card-demo span:last-child { + display: block; + margin-top: 7px; + color: rgba(255, 255, 255, 0.82); + font-size: 12px; +} + +.empty-card-demo { + display: flex; + min-height: 164px; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + border: 1px solid #ececec; + border-radius: 18px; + background: #ffffff; + text-align: center; + box-shadow: var(--shadow-soft); +} + +.empty-card-demo b { + margin: 8px 0 4px; + font-size: 15px; +} + +.empty-card-demo span { + color: var(--muted); + font-size: 12px; +} + +.nav-demo-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + color: #999999; + font-size: 12px; +} + +.nav-demo-item.active { color: var(--orange); font-weight: 760; } + +.nav-demo-item img { + width: 30px; + height: 30px; + object-fit: contain; +} + +.aside-panel { + padding: 28px 0 32px; + border-bottom: 1px solid #e9e4dc; +} + +.aside-panel:first-child { padding-top: 0; } +.aside-panel:last-child { border-bottom: 0; } + +.aside-panel h2 { + margin: 0 0 18px; + font-size: 22px; +} + +.rule-list { + display: grid; + gap: 13px; +} + +.rule { + display: grid; + grid-template-columns: 24px 1fr; + gap: 10px; + align-items: start; + color: #4b5563; + font-size: 14px; + line-height: 1.55; +} + +.rule-mark { + display: flex; + width: 20px; + height: 20px; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--orange); + color: #ffffff; + font-size: 11px; + font-weight: 800; +} + +.token-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px 12px; +} + +.color-token { + min-width: 0; +} + +.swatch { + width: 52px; + height: 52px; + margin-bottom: 8px; + border: 1px solid rgba(31, 41, 55, 0.06); + border-radius: 50%; +} + +.color-token code { + display: block; + color: #4b5563; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; +} + +.color-token span { + color: var(--quiet); + font-size: 11px; +} + +.spec-table { + width: 100%; + border-collapse: collapse; +} + +.spec-table td { + padding: 11px 0; + border-bottom: 1px solid #ebe7e1; + color: #4b5563; + font-size: 13px; + line-height: 1.45; + vertical-align: top; +} + +.spec-table tr:last-child td { border-bottom: 0; } +.spec-table td:first-child { width: 42%; color: var(--quiet); } +.spec-table td:last-child { color: var(--ink); font-weight: 650; } + +.type-sample { + margin-bottom: 14px; +} + +.type-sample:last-child { margin-bottom: 0; } + +.type-sample strong { + display: block; + color: var(--ink); +} + +.type-sample span { + color: var(--quiet); + font-size: 11px; +} + +.type-display strong { font-size: 32px; } +.type-title strong { font-size: 22px; } +.type-body strong { font-size: 16px; } +.type-caption strong { font-size: 13px; } + +.spacing-scale { + display: grid; + gap: 10px; +} + +.space-row { + display: grid; + grid-template-columns: 42px 1fr 52px; + gap: 10px; + align-items: center; + color: var(--muted); + font-size: 12px; +} + +.space-bar { + height: 10px; + border-radius: 3px; + background: var(--orange-soft); +} + +.inventory-note { + padding: 16px; + border-left: 4px solid var(--orange); + border-radius: 0 8px 8px 0; + background: #fff7ed; + color: #9a3412; + font-size: 13px; + line-height: 1.6; +} diff --git a/docs/banban-mini-design-atlas/design-system.html b/docs/banban-mini-design-atlas/design-system.html new file mode 100644 index 0000000..ee31b88 --- /dev/null +++ b/docs/banban-mini-design-atlas/design-system.html @@ -0,0 +1,209 @@ + + + + + + 伴伴 · 交互控件现状规范 + + + +
+
+
+
BANBAN MINI PROGRAM · UI INVENTORY
+

伴伴 · 交互控件现状规范

+

从现有页面 TSX、SCSS 与 tabBar 配置提取。以真实实现为准,不将参考图的视觉语言替换进产品。

+
+ 当前实现 + 750rpx 设计稿 + 微信小程序 +
+
+
+
🤖
+
让关爱随时随地设备、家庭和陪伴体验中的温暖反馈。
+
+
+ +
+
+
+

1. 主要操作

页面主任务 · 每个视图只保留一个视觉主按钮
+
+
橙色主操作88–96rpx
+
紧凑主操作88rpx
+
微信登录#07C160
+
设备控制#1F2937
+
+
+ +
+

2. 状态展示

以“保存”为例
+
+
默认
+
按下
+
聚焦
+
禁用
+
加载
+
+
+ +
+

3. 音频与留言操作

固定高度,避免播放状态改变布局
+
+
▶播放留言
+
↻再次播放
+
Ⅱ播放中
+
+
+ +
+

4. 次要、文字与危险操作

降低层级,不与主任务争夺注意力
+
+
稍后设置
+
取消
+
解除绑定
+
+
+
查看往期来信 ›
+
管理设备 ›
+
暂不登录,先浏览
+
+
+ +
+

5. 圆形图标按钮

72–80rpx · 熟悉符号优先
+
+
‹
返回
+
×
关闭
+
•••
更多
+
▶
播放
+
↻
刷新
+
✉
留言
+
⌁
绑定
+
⚙
设置
+
+
+ +
+

6. 复合控件

分段、开关与音量
+
+
+
分段控制68rpx
+
当前位置今日轨迹最近点位全部
+
+
+
允许家庭通知
+
音量60%
+
+
+
+ +
+

7. 底部导航

来自现有 app.config.ts 与 tab-icons
+
+ + + + +
+
+ +
+

8. 状态标签与反馈

设备状态、告警和操作结果
+ + +
+ +
+

9. 状态卡片与空状态

信息概览和下一步引导
+
+
● 已完成绑定82%小雨的伴伴 · 在线
+
🤖
先绑定一台伴伴设备绑定后查看设备状态
+
+
+
+ + +
+ +
+ 伴伴 · 交互控件现状规范 + Source: app.config.ts + pages/**/*.scss · 2026.08 +
+
+ + diff --git a/docs/banban-mini-design-atlas/overview-original-resolution.css b/docs/banban-mini-design-atlas/overview-original-resolution.css new file mode 100644 index 0000000..4ecf386 --- /dev/null +++ b/docs/banban-mini-design-atlas/overview-original-resolution.css @@ -0,0 +1,145 @@ +@import url("./overview.css"); + +html, +body.atlas-original-resolution, +.atlas-original-resolution .atlas { + width: 430px; +} + +.atlas-original-resolution .atlas-header { + display: block; + min-height: 0; + padding: 34px 24px 30px; +} + +.atlas-original-resolution .eyebrow { + margin-bottom: 8px; + font-size: 10px; +} + +.atlas-original-resolution h1 { + font-size: 31px; + line-height: 1.2; +} + +.atlas-original-resolution .header-copy > p { + margin-top: 14px; + font-size: 13px; + line-height: 1.7; +} + +.atlas-original-resolution .resolution-badge { + display: inline-flex; + margin-top: 14px; + padding: 7px 10px; + border: 1px solid #f1c79f; + border-radius: 6px; + background: #fff4e8; + color: #a85d25; + font-size: 12px; + font-weight: 750; +} + +.atlas-original-resolution .legend { + gap: 8px 14px; + margin-top: 18px; + font-size: 11px; +} + +.atlas-original-resolution .metrics { + margin-top: 24px; + gap: 8px; +} + +.atlas-original-resolution .metric { + min-height: 82px; + padding: 14px; +} + +.atlas-original-resolution .metric strong { + margin-bottom: 5px; + font-size: 28px; +} + +.atlas-original-resolution .metric span { + font-size: 11px; +} + +.atlas-original-resolution .flow { + padding: 38px 19px 48px; +} + +.atlas-original-resolution .flow-heading { + display: block; + min-height: 0; + margin: 0 2px 24px; +} + +.atlas-original-resolution .flow-number { + top: -16px; + font-size: 54px; +} + +.atlas-original-resolution .flow-eyebrow { + margin-bottom: 7px; + font-size: 10px; +} + +.atlas-original-resolution h2 { + font-size: 26px; + line-height: 1.25; +} + +.atlas-original-resolution .flow-heading > p { + max-width: none; + margin-top: 12px; + font-size: 12px; + line-height: 1.65; + text-align: left; +} + +.atlas-original-resolution .state-grid { + grid-template-columns: minmax(0, 1fr); + gap: 24px; +} + +.atlas-original-resolution .state-card { + box-shadow: 0 7px 18px rgba(70, 52, 37, 0.09); +} + +.atlas-original-resolution .state-card img { + image-rendering: auto; +} + +.atlas-original-resolution .state-copy { + grid-template-columns: 38px minmax(0, 1fr); + gap: 12px; + min-height: 104px; + padding: 17px; +} + +.atlas-original-resolution .state-copy > b { + width: 34px; + height: 34px; + font-size: 12px; +} + +.atlas-original-resolution .state-copy h3 { + margin-bottom: 6px; + font-size: 19px; +} + +.atlas-original-resolution .state-copy p { + font-size: 14px; + line-height: 1.55; +} + +.atlas-original-resolution footer { + min-height: 130px; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 8px; + padding: 24px; + font-size: 10px; +} diff --git a/docs/banban-mini-design-atlas/overview-original-resolution.html b/docs/banban-mini-design-atlas/overview-original-resolution.html new file mode 100644 index 0000000..b6a2ed9 --- /dev/null +++ b/docs/banban-mini-design-atlas/overview-original-resolution.html @@ -0,0 +1,116 @@ + + + + + + 伴伴小程序当前页面全景 · 原始像素版 + + + +
+
+
+
BANBAN MINI PROGRAM · CURRENT BUILD
+

伴伴小程序 / 当前页面全景

+
单列原始像素版
+

全部页面画面来自当前 banban-mini 构建,并由微信开发者工具直接截图。登录态使用本地固定展示数据,不包含线上用户数据。

+
+ 游客与入口 + 设备与绑定 + 会话与定位 + 管理与家庭 +
+
+
+
11当前路由
+
26真实截图状态
+
4核心 Tab
+
390 px微信模拟器原始截图宽度
+
+
+ +
+
+
+ 01 +

ENTRY & DEVICE

+

进入、登录与设备首页

+
+

从游客浏览、登录入口到设备绑定后的状态、告警和设备详情。

+
+
+
设备首页游客态
01

首页 · 游客态

未登录时的设备入口

+
登录页
02

登录 · 默认

微信登录与协议确认

+
已绑定设备首页
03

首页 · 已绑定

电量、休眠、音量与告警

+
设备详情展开
04

首页 · 详情展开

告警位置与设备详细信息

+
通知链接失效
05

通知 · 链接失效

无效通知的回退页面

+
设备扫码绑定入口
06

绑定 · 扫码入口

扫码或填写设备信息

+
+
+ +
+
+
+ 02 +

BINDING & DEVICE CONTROL

+

设备二维码、贴卡与休眠

+
+

当前绑定页真实展示二维码识别、等待贴卡、追加卡片和休眠配置。

+
+
+
设备二维码已识别
07

绑定 · 已识别

设备号和序列号已回填

+
等待设备贴卡
08

绑定 · 等待贴卡

绑卡指令和倒计时状态

+
添加设备卡片
09

卡片 · 添加入口

为当前设备追加新卡

+
定时休眠设置
10

设备 · 定时休眠

休眠时段和时区配置

+
+
+ +
+
+
+ 03 +

MESSAGES & LOCATION

+

亲子会话与设备定位

+
+

覆盖游客预览、会话列表、AI 与家长消息,以及绑定设备后的实时位置。

+
+
+
玩伴游客态
11

玩伴 · 游客态

未登录时的会话能力预览

+
会话列表
12

玩伴 · 会话列表

AI、家长与宝贝会话

+
AI 会话详情
13

会话 · AI 玩伴

宝贝与伴伴的消息记录

+
家长会话详情
14

会话 · 家长沟通

文字、语音和留言控制

+
定位游客态
15

定位 · 游客态

地图与位置能力预览

+
设备当前位置
16

定位 · 当前位置

地图、地址和定位参数

+
+
+ +
+
+
+ 04 +

MANAGEMENT & FAMILY

+

管理、资料与家庭共享

+
+

当前管理主页与真实交互弹窗,包含宝贝、卡片、角色、家庭和邀请状态。

+
+
+
管理游客态
17

管理 · 游客态

管理功能的登录前预览

+
管理总览
18

管理 · 账号总览

账号、设备和系统版本

+
管理功能菜单
19

管理 · 功能列表

卡片、角色、家庭和通知

+
切换当前宝贝弹窗
20

宝贝 · 切换资料

当前、修改和删除操作

+
我的卡片弹窗
21

卡片 · 卡片列表

卡片名称、UUID 和编辑

+
切换 AI 角色弹窗
22

角色 · AI 角色

当前角色和可选角色

+
家庭成员弹窗
23

家庭 · 成员列表

主成员、共享成员和邀请

+
有效家庭邀请
24

邀请 · 等待加入

有效家庭共享邀请

+
过期家庭邀请
25

邀请 · 已失效

过期邀请的禁用状态

+
未登录邀请跳转登录
26

邀请 · 未登录跳转

进入邀请前的登录承接页

+
+
+ +
+ Generated from the current banban-mini build + WeChat DevTools screenshots · local fixtures only · 2026-09-09 +
+
+ + diff --git a/docs/banban-mini-design-atlas/overview-readable.css b/docs/banban-mini-design-atlas/overview-readable.css new file mode 100644 index 0000000..ee51cb1 --- /dev/null +++ b/docs/banban-mini-design-atlas/overview-readable.css @@ -0,0 +1,80 @@ +@import url("./overview.css"); + +.atlas-readable .atlas-header { + min-height: 430px; + grid-template-columns: minmax(0, 1fr) 420px; + padding: 68px 76px; + background: #fffdf9; +} + +.atlas-readable .readable-badge { + display: inline-flex; + margin-top: 20px; + padding: 8px 12px; + border: 1px solid #f1c79f; + border-radius: 6px; + background: #fff4e8; + color: #a85d25; + font-size: 15px; + font-weight: 750; +} + +.atlas-readable .flow { + padding: 64px 72px 78px; +} + +.atlas-readable .flow-heading { + min-height: 150px; + margin-bottom: 36px; +} + +.atlas-readable .flow-heading > p { + max-width: 510px; + font-size: 17px; +} + +.atlas-readable h2 { + font-size: 38px; +} + +.atlas-readable .state-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 34px 30px; +} + +.atlas-readable .state-card { + border-color: rgba(57, 40, 28, 0.14); + box-shadow: 0 12px 30px rgba(70, 52, 37, 0.1); +} + +.atlas-readable .state-card img { + image-rendering: auto; +} + +.atlas-readable .state-copy { + grid-template-columns: 48px minmax(0, 1fr); + gap: 16px; + min-height: 132px; + padding: 22px; +} + +.atlas-readable .state-copy > b { + width: 44px; + height: 44px; + font-size: 15px; +} + +.atlas-readable .state-copy h3 { + margin-bottom: 8px; + font-size: 24px; +} + +.atlas-readable .state-copy p { + font-size: 17px; + line-height: 1.6; +} + +.atlas-readable footer { + min-height: 112px; + font-size: 15px; +} diff --git a/docs/banban-mini-design-atlas/overview-readable.html b/docs/banban-mini-design-atlas/overview-readable.html new file mode 100644 index 0000000..4967001 --- /dev/null +++ b/docs/banban-mini-design-atlas/overview-readable.html @@ -0,0 +1,116 @@ + + + + + + 伴伴小程序当前页面全景 · 清晰阅读版 + + + +
+
+
+
BANBAN MINI PROGRAM · CURRENT BUILD
+

伴伴小程序 / 当前页面全景

+
高清阅读版 · 双列展示
+

全部页面画面来自当前 banban-mini 构建,并由微信开发者工具直接截图。登录态使用本地固定展示数据,不包含线上用户数据。

+
+ 游客与入口 + 设备与绑定 + 会话与定位 + 管理与家庭 +
+
+
+
11当前路由
+
26真实截图状态
+
4核心 Tab
+
390 px微信模拟器原始截图宽度
+
+
+ +
+
+
+ 01 +

ENTRY & DEVICE

+

进入、登录与设备首页

+
+

从游客浏览、登录入口到设备绑定后的状态、告警和设备详情。

+
+
+
设备首页游客态
01

首页 · 游客态

未登录时的设备入口

+
登录页
02

登录 · 默认

微信登录与协议确认

+
已绑定设备首页
03

首页 · 已绑定

电量、休眠、音量与告警

+
设备详情展开
04

首页 · 详情展开

告警位置与设备详细信息

+
通知链接失效
05

通知 · 链接失效

无效通知的回退页面

+
设备扫码绑定入口
06

绑定 · 扫码入口

扫码或填写设备信息

+
+
+ +
+
+
+ 02 +

BINDING & DEVICE CONTROL

+

设备二维码、贴卡与休眠

+
+

当前绑定页真实展示二维码识别、等待贴卡、追加卡片和休眠配置。

+
+
+
设备二维码已识别
07

绑定 · 已识别

设备号和序列号已回填

+
等待设备贴卡
08

绑定 · 等待贴卡

绑卡指令和倒计时状态

+
添加设备卡片
09

卡片 · 添加入口

为当前设备追加新卡

+
定时休眠设置
10

设备 · 定时休眠

休眠时段和时区配置

+
+
+ +
+
+
+ 03 +

MESSAGES & LOCATION

+

亲子会话与设备定位

+
+

覆盖游客预览、会话列表、AI 与家长消息,以及绑定设备后的实时位置。

+
+
+
玩伴游客态
11

玩伴 · 游客态

未登录时的会话能力预览

+
会话列表
12

玩伴 · 会话列表

AI、家长与宝贝会话

+
AI 会话详情
13

会话 · AI 玩伴

宝贝与伴伴的消息记录

+
家长会话详情
14

会话 · 家长沟通

文字、语音和留言控制

+
定位游客态
15

定位 · 游客态

地图与位置能力预览

+
设备当前位置
16

定位 · 当前位置

地图、地址和定位参数

+
+
+ +
+
+
+ 04 +

MANAGEMENT & FAMILY

+

管理、资料与家庭共享

+
+

当前管理主页与真实交互弹窗,包含宝贝、卡片、角色、家庭和邀请状态。

+
+
+
管理游客态
17

管理 · 游客态

管理功能的登录前预览

+
管理总览
18

管理 · 账号总览

账号、设备和系统版本

+
管理功能菜单
19

管理 · 功能列表

卡片、角色、家庭和通知

+
切换当前宝贝弹窗
20

宝贝 · 切换资料

当前、修改和删除操作

+
我的卡片弹窗
21

卡片 · 卡片列表

卡片名称、UUID 和编辑

+
切换 AI 角色弹窗
22

角色 · AI 角色

当前角色和可选角色

+
家庭成员弹窗
23

家庭 · 成员列表

主成员、共享成员和邀请

+
有效家庭邀请
24

邀请 · 等待加入

有效家庭共享邀请

+
过期家庭邀请
25

邀请 · 已失效

过期邀请的禁用状态

+
未登录邀请跳转登录
26

邀请 · 未登录跳转

进入邀请前的登录承接页

+
+
+ +
+ Generated from the current banban-mini build + WeChat DevTools screenshots · local fixtures only · 2026-09-09 +
+
+ + diff --git a/docs/banban-mini-design-atlas/overview-v2.css b/docs/banban-mini-design-atlas/overview-v2.css new file mode 100644 index 0000000..41681b0 --- /dev/null +++ b/docs/banban-mini-design-atlas/overview-v2.css @@ -0,0 +1,84 @@ +@import url("./overview.css"); + +.atlas-v2 { + background: #eceae6; +} + +.atlas-v2 .atlas-header { + min-height: 360px; + padding: 58px 66px; + border-bottom: 0; + background: #302b27; + color: #fffdf8; +} + +.atlas-v2 .eyebrow { + color: #ffad69; +} + +.atlas-v2 .header-copy > p { + color: rgba(255, 253, 248, 0.7); +} + +.atlas-v2 .legend { + color: rgba(255, 253, 248, 0.78); +} + +.atlas-v2 .metric { + border-color: rgba(255, 255, 255, 0.1); + background: #413a34; +} + +.atlas-v2 .metric strong { + color: #fffdf8; +} + +.atlas-v2 .metric span { + color: rgba(255, 253, 248, 0.65); +} + +.atlas-v2 .flow { + padding: 48px 50px 58px; +} + +.atlas-v2 .flow-entry { background: #fff8ed; } +.atlas-v2 .flow-binding { background: #eef7e7; } +.atlas-v2 .flow-social { background: #eaf5f8; } +.atlas-v2 .flow-manage { background: #fff0ed; } + +.atlas-v2 .flow-heading { + min-height: 112px; + margin-bottom: 24px; + padding: 0 8px 20px; + border-bottom: 2px solid rgba(57, 40, 28, 0.12); +} + +.atlas-v2 .flow-number { + top: -22px; + color: rgba(57, 40, 28, 0.08); + font-size: 78px; +} + +.atlas-v2 .state-grid { + gap: 18px; +} + +.atlas-v2 .state-card { + border-color: rgba(57, 40, 28, 0.1); + box-shadow: 0 7px 18px rgba(57, 40, 28, 0.09); +} + +.atlas-v2 .state-copy { + min-height: 92px; + padding: 14px; +} + +.atlas-v2 .state-copy > b { + background: #ff8c42; + color: #2d2118; +} + +.atlas-v2 footer { + min-height: 92px; + background: #1f1c19; +} diff --git a/docs/banban-mini-design-atlas/overview-v2.html b/docs/banban-mini-design-atlas/overview-v2.html new file mode 100644 index 0000000..158984c --- /dev/null +++ b/docs/banban-mini-design-atlas/overview-v2.html @@ -0,0 +1,115 @@ + + + + + + 伴伴小程序当前页面全景 V2 + + + +
+
+
+
BANBAN MINI PROGRAM · CURRENT BUILD
+

伴伴小程序 / 当前页面全景

+

全部页面画面来自当前 banban-mini 构建,并由微信开发者工具直接截图。登录态使用本地固定展示数据,不包含线上用户数据。

+
+ 游客与入口 + 设备与绑定 + 会话与定位 + 管理与家庭 +
+
+
+
11当前路由
+
26真实截图状态
+
4核心 Tab
+
390 px微信模拟器原始截图宽度
+
+
+ +
+
+
+ 01 +

ENTRY & DEVICE

+

进入、登录与设备首页

+
+

从游客浏览、登录入口到设备绑定后的状态、告警和设备详情。

+
+
+
设备首页游客态
01

首页 · 游客态

未登录时的设备入口

+
登录页
02

登录 · 默认

微信登录与协议确认

+
已绑定设备首页
03

首页 · 已绑定

电量、休眠、音量与告警

+
设备详情展开
04

首页 · 详情展开

告警位置与设备详细信息

+
通知链接失效
05

通知 · 链接失效

无效通知的回退页面

+
设备扫码绑定入口
06

绑定 · 扫码入口

扫码或填写设备信息

+
+
+ +
+
+
+ 02 +

BINDING & DEVICE CONTROL

+

设备二维码、贴卡与休眠

+
+

当前绑定页真实展示二维码识别、等待贴卡、追加卡片和休眠配置。

+
+
+
设备二维码已识别
07

绑定 · 已识别

设备号和序列号已回填

+
等待设备贴卡
08

绑定 · 等待贴卡

绑卡指令和倒计时状态

+
添加设备卡片
09

卡片 · 添加入口

为当前设备追加新卡

+
定时休眠设置
10

设备 · 定时休眠

休眠时段和时区配置

+
+
+ +
+
+
+ 03 +

MESSAGES & LOCATION

+

亲子会话与设备定位

+
+

覆盖游客预览、会话列表、AI 与家长消息,以及绑定设备后的实时位置。

+
+
+
玩伴游客态
11

玩伴 · 游客态

未登录时的会话能力预览

+
会话列表
12

玩伴 · 会话列表

AI、家长与宝贝会话

+
AI 会话详情
13

会话 · AI 玩伴

宝贝与伴伴的消息记录

+
家长会话详情
14

会话 · 家长沟通

文字、语音和留言控制

+
定位游客态
15

定位 · 游客态

地图与位置能力预览

+
设备当前位置
16

定位 · 当前位置

地图、地址和定位参数

+
+
+ +
+
+
+ 04 +

MANAGEMENT & FAMILY

+

管理、资料与家庭共享

+
+

当前管理主页与真实交互弹窗,包含宝贝、卡片、角色、家庭和邀请状态。

+
+
+
管理游客态
17

管理 · 游客态

管理功能的登录前预览

+
管理总览
18

管理 · 账号总览

账号、设备和系统版本

+
管理功能菜单
19

管理 · 功能列表

卡片、角色、家庭和通知

+
切换当前宝贝弹窗
20

宝贝 · 切换资料

当前、修改和删除操作

+
我的卡片弹窗
21

卡片 · 卡片列表

卡片名称、UUID 和编辑

+
切换 AI 角色弹窗
22

角色 · AI 角色

当前角色和可选角色

+
家庭成员弹窗
23

家庭 · 成员列表

主成员、共享成员和邀请

+
有效家庭邀请
24

邀请 · 等待加入

有效家庭共享邀请

+
过期家庭邀请
25

邀请 · 已失效

过期邀请的禁用状态

+
未登录邀请跳转登录
26

邀请 · 未登录跳转

进入邀请前的登录承接页

+
+
+ +
+ Generated from the current banban-mini build + WeChat DevTools screenshots · local fixtures only · 2026-09-09 +
+
+ + diff --git a/docs/banban-mini-design-atlas/overview.css b/docs/banban-mini-design-atlas/overview.css new file mode 100644 index 0000000..15beccf --- /dev/null +++ b/docs/banban-mini-design-atlas/overview.css @@ -0,0 +1,242 @@ +:root { + color-scheme: light; + --ink: #39281c; + --muted: #77685c; + --line: rgba(73, 54, 38, 0.12); + --orange: #ff8c42; + --green: #b8ec20; + --blue: #77bfea; + --coral: #ef7f72; +} + +* { box-sizing: border-box; } + +html, +body { + margin: 0; + background: #f3f0ea; + color: var(--ink); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; + letter-spacing: 0; +} + +body { width: 1440px; } + +.atlas { + width: 1440px; + overflow: hidden; + background: #f8f6f1; +} + +.atlas-header { + display: grid; + min-height: 390px; + grid-template-columns: minmax(0, 1fr) 440px; + gap: 58px; + align-items: center; + padding: 64px 72px 58px; + border-bottom: 1px solid var(--line); + background: #fffdf9; +} + +.eyebrow, +.flow-eyebrow { + margin: 0 0 12px; + color: #a36a38; + font-size: 15px; + font-weight: 750; + line-height: 1.4; + text-transform: uppercase; +} + +h1 { + max-width: 760px; + margin: 0; + font-size: 54px; + font-weight: 820; + line-height: 1.12; +} + +.header-copy > p { + max-width: 790px; + margin: 20px 0 0; + color: var(--muted); + font-size: 18px; + line-height: 1.72; +} + +.legend { + display: flex; + flex-wrap: wrap; + gap: 12px 24px; + margin-top: 26px; + color: #695b50; + font-size: 14px; +} + +.legend span { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.dot { + display: inline-block; + width: 9px; + height: 9px; + border-radius: 50%; +} + +.dot.orange { background: var(--orange); } +.dot.green { background: #80b917; } +.dot.blue { background: #4d9dce; } +.dot.coral { background: var(--coral); } + +.metrics { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.metric { + min-height: 124px; + padding: 22px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; +} + +.metric.wide { grid-column: span 2; } + +.metric strong { + display: block; + margin-bottom: 9px; + color: #28211c; + font-size: 40px; + line-height: 1; +} + +.metric span { + color: var(--muted); + font-size: 14px; + line-height: 1.4; +} + +.flow { + padding: 54px 52px 66px; + border-bottom: 1px solid var(--line); +} + +.flow-entry { background: #fff9f1; } +.flow-binding { background: #f3f9ec; } +.flow-social { background: #f0f7fa; } +.flow-manage { background: #fff5f3; } + +.flow-heading { + display: flex; + min-height: 130px; + align-items: flex-end; + justify-content: space-between; + gap: 40px; + margin: 0 6px 30px; +} + +.flow-heading > div { position: relative; } + +.flow-number { + position: absolute; + top: -32px; + left: 0; + color: rgba(57, 40, 28, 0.09); + font-size: 88px; + font-weight: 850; + line-height: 1; +} + +.flow-eyebrow { position: relative; } + +h2 { + position: relative; + margin: 0; + font-size: 34px; + line-height: 1.2; +} + +.flow-heading > p { + max-width: 440px; + margin: 0 0 2px; + color: var(--muted); + font-size: 15px; + line-height: 1.7; + text-align: right; +} + +.state-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 24px 20px; + align-items: start; +} + +.state-card { + min-width: 0; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 10px 24px rgba(70, 52, 37, 0.08); +} + +.state-card img { + display: block; + width: 100%; + height: auto; + border-bottom: 1px solid var(--line); + background: #fff9ea; +} + +.state-copy { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: 12px; + min-height: 100px; + padding: 16px; +} + +.state-copy > b { + display: flex; + width: 30px; + height: 30px; + align-items: center; + justify-content: center; + border-radius: 50%; + background: #35291f; + color: #fff; + font-size: 11px; + line-height: 1; +} + +.state-copy h3 { + margin: 0 0 6px; + font-size: 17px; + line-height: 1.35; +} + +.state-copy p { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.55; +} + +footer { + display: flex; + min-height: 100px; + align-items: center; + justify-content: space-between; + gap: 30px; + padding: 0 64px; + background: #312820; + color: rgba(255, 255, 255, 0.72); + font-size: 13px; +} diff --git a/docs/banban-mini-design-atlas/overview.html b/docs/banban-mini-design-atlas/overview.html new file mode 100644 index 0000000..31fd797 --- /dev/null +++ b/docs/banban-mini-design-atlas/overview.html @@ -0,0 +1,115 @@ + + + + + + 伴伴小程序当前页面全景 + + + +
+
+
+
BANBAN MINI PROGRAM · CURRENT BUILD
+

伴伴小程序 / 当前页面全景

+

全部页面画面来自当前 banban-mini 构建,并由微信开发者工具直接截图。登录态使用本地固定展示数据,不包含线上用户数据。

+
+ 游客与入口 + 设备与绑定 + 会话与定位 + 管理与家庭 +
+
+
+
11当前路由
+
26真实截图状态
+
4核心 Tab
+
390 px微信模拟器原始截图宽度
+
+
+ +
+
+
+ 01 +

ENTRY & DEVICE

+

进入、登录与设备首页

+
+

从游客浏览、登录入口到设备绑定后的状态、告警和设备详情。

+
+
+
设备首页游客态
01

首页 · 游客态

未登录时的设备入口

+
登录页
02

登录 · 默认

微信登录与协议确认

+
已绑定设备首页
03

首页 · 已绑定

电量、休眠、音量与告警

+
设备详情展开
04

首页 · 详情展开

告警位置与设备详细信息

+
通知链接失效
05

通知 · 链接失效

无效通知的回退页面

+
设备扫码绑定入口
06

绑定 · 扫码入口

扫码或填写设备信息

+
+
+ +
+
+
+ 02 +

BINDING & DEVICE CONTROL

+

设备二维码、贴卡与休眠

+
+

当前绑定页真实展示二维码识别、等待贴卡、追加卡片和休眠配置。

+
+
+
设备二维码已识别
07

绑定 · 已识别

设备号和序列号已回填

+
等待设备贴卡
08

绑定 · 等待贴卡

绑卡指令和倒计时状态

+
添加设备卡片
09

卡片 · 添加入口

为当前设备追加新卡

+
定时休眠设置
10

设备 · 定时休眠

休眠时段和时区配置

+
+
+ +
+
+
+ 03 +

MESSAGES & LOCATION

+

亲子会话与设备定位

+
+

覆盖游客预览、会话列表、AI 与家长消息,以及绑定设备后的实时位置。

+
+
+
玩伴游客态
11

玩伴 · 游客态

未登录时的会话能力预览

+
会话列表
12

玩伴 · 会话列表

AI、家长与宝贝会话

+
AI 会话详情
13

会话 · AI 玩伴

宝贝与伴伴的消息记录

+
家长会话详情
14

会话 · 家长沟通

文字、语音和留言控制

+
定位游客态
15

定位 · 游客态

地图与位置能力预览

+
设备当前位置
16

定位 · 当前位置

地图、地址和定位参数

+
+
+ +
+
+
+ 04 +

MANAGEMENT & FAMILY

+

管理、资料与家庭共享

+
+

当前管理主页与真实交互弹窗,包含宝贝、卡片、角色、家庭和邀请状态。

+
+
+
管理游客态
17

管理 · 游客态

管理功能的登录前预览

+
管理总览
18

管理 · 账号总览

账号、设备和系统版本

+
管理功能菜单
19

管理 · 功能列表

卡片、角色、家庭和通知

+
切换当前宝贝弹窗
20

宝贝 · 切换资料

当前、修改和删除操作

+
我的卡片弹窗
21

卡片 · 卡片列表

卡片名称、UUID 和编辑

+
切换 AI 角色弹窗
22

角色 · AI 角色

当前角色和可选角色

+
家庭成员弹窗
23

家庭 · 成员列表

主成员、共享成员和邀请

+
有效家庭邀请
24

邀请 · 等待加入

有效家庭共享邀请

+
过期家庭邀请
25

邀请 · 已失效

过期邀请的禁用状态

+
未登录邀请跳转登录
26

邀请 · 未登录跳转

进入邀请前的登录承接页

+
+
+ +
+ Generated from the current banban-mini build + WeChat DevTools screenshots · local fixtures only · 2026-09-09 +
+
+ + diff --git a/docs/banban-mini-design-atlas/render-overview.m b/docs/banban-mini-design-atlas/render-overview.m new file mode 100644 index 0000000..03a5f84 --- /dev/null +++ b/docs/banban-mini-design-atlas/render-overview.m @@ -0,0 +1,156 @@ +#import +#import +#import + +static CGFloat PageWidth = 1440.0; +static CGFloat OutputScale = 1.0; +static CGFloat SharpenIntensity = 0.0; + +@interface PageRenderer : NSObject +@property(nonatomic, strong) WKWebView *webView; +@property(nonatomic, strong) NSURL *inputURL; +@property(nonatomic, strong) NSURL *outputURL; +@end + +@implementation PageRenderer + +- (instancetype)initWithInputURL:(NSURL *)inputURL outputURL:(NSURL *)outputURL { + self = [super init]; + if (self) { + _inputURL = inputURL; + _outputURL = outputURL; + _webView = [[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, PageWidth, 900)]; + _webView.navigationDelegate = self; + } + return self; +} + +- (void)start { + NSURL *repositoryRoot = self.inputURL.URLByDeletingLastPathComponent + .URLByDeletingLastPathComponent.URLByDeletingLastPathComponent; + [self.webView loadFileURL:self.inputURL allowingReadAccessToURL:repositoryRoot]; +} + +- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation { + NSString *heightScript = @"Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)"; + [webView evaluateJavaScript:heightScript completionHandler:^(id result, NSError *error) { + if (error || ![result isKindOfClass:NSNumber.class]) { + [self fail:[NSString stringWithFormat:@"Could not read page height: %@", error.localizedDescription ?: @"unknown error"]]; + return; + } + + CGFloat pageHeight = [(NSNumber *)result doubleValue]; + [webView setFrameSize:NSMakeSize(PageWidth, pageHeight)]; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.8 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + WKSnapshotConfiguration *configuration = [[WKSnapshotConfiguration alloc] init]; + configuration.rect = CGRectMake(0, 0, PageWidth, pageHeight); + configuration.snapshotWidth = @(PageWidth * OutputScale); + + [webView takeSnapshotWithConfiguration:configuration completionHandler:^(NSImage *image, NSError *snapshotError) { + NSBitmapImageRep *bitmap = nil; + if (image && SharpenIntensity > 0.0) { + CIImage *inputImage = [[CIImage alloc] initWithData:image.TIFFRepresentation]; + CIFilter *sharpen = [CIFilter filterWithName:@"CIUnsharpMask"]; + [sharpen setValue:inputImage forKey:kCIInputImageKey]; + [sharpen setValue:@(1.0) forKey:kCIInputRadiusKey]; + [sharpen setValue:@(SharpenIntensity) forKey:kCIInputIntensityKey]; + + CIImage *outputImage = sharpen.outputImage; + CIContext *context = [CIContext contextWithOptions:nil]; + CGImageRef outputCGImage = outputImage + ? [context createCGImage:outputImage fromRect:inputImage.extent] + : nil; + if (outputCGImage) { + bitmap = [[NSBitmapImageRep alloc] initWithCGImage:outputCGImage]; + CGImageRelease(outputCGImage); + } + } else if (image) { + bitmap = [[NSBitmapImageRep alloc] initWithData:image.TIFFRepresentation]; + } + NSData *pngData = [bitmap representationUsingType:NSBitmapImageFileTypePNG properties:@{}]; + if (snapshotError || !pngData) { + [self fail:[NSString stringWithFormat:@"Could not create snapshot: %@", snapshotError.localizedDescription ?: @"unknown error"]]; + return; + } + + NSError *writeError = nil; + if (![pngData writeToURL:self.outputURL options:NSDataWritingAtomic error:&writeError]) { + [self fail:[NSString stringWithFormat:@"Could not write PNG: %@", writeError.localizedDescription]]; + return; + } + + printf("Rendered %dx%d at %.2fx, sharpen %.2f -> %s\n", + (int)PageWidth, + (int)pageHeight, + OutputScale, + SharpenIntensity, + self.outputURL.path.UTF8String); + exit(0); + }]; + }); + }]; +} + +- (void)webView:(WKWebView *)webView didFailNavigation:(WKNavigation *)navigation withError:(NSError *)error { + [self fail:[NSString stringWithFormat:@"Navigation failed: %@", error.localizedDescription]]; +} + +- (void)webView:(WKWebView *)webView didFailProvisionalNavigation:(WKNavigation *)navigation withError:(NSError *)error { + [self fail:[NSString stringWithFormat:@"Page load failed: %@", error.localizedDescription]]; +} + +- (void)fail:(NSString *)message { + fprintf(stderr, "%s\n", message.UTF8String); + exit(1); +} + +@end + +int main(int argc, const char *argv[]) { + @autoreleasepool { + if (argc < 3 || argc > 6) { + fprintf(stderr, "Usage: render-overview [page-width] [output-scale] [sharpen-intensity]\n"); + return 2; + } + + if (argc >= 4) { + double requestedWidth = strtod(argv[3], NULL); + if (requestedWidth < 320.0 || requestedWidth > 4096.0) { + fprintf(stderr, "Page width must be between 320 and 4096 pixels\n"); + return 2; + } + PageWidth = (CGFloat)requestedWidth; + } + + if (argc >= 5) { + double requestedScale = strtod(argv[4], NULL); + if (requestedScale < 1.0 || requestedScale > 4.0) { + fprintf(stderr, "Output scale must be between 1 and 4\n"); + return 2; + } + OutputScale = (CGFloat)requestedScale; + } + + if (argc == 6) { + double requestedSharpness = strtod(argv[5], NULL); + if (requestedSharpness < 0.0 || requestedSharpness > 1.0) { + fprintf(stderr, "Sharpen intensity must be between 0 and 1\n"); + return 2; + } + SharpenIntensity = (CGFloat)requestedSharpness; + } + + [NSApplication sharedApplication]; + [NSApp setActivationPolicy:NSApplicationActivationPolicyProhibited]; + + NSString *inputPath = [NSString stringWithUTF8String:argv[1]].stringByStandardizingPath; + NSString *outputPath = [NSString stringWithUTF8String:argv[2]].stringByStandardizingPath; + PageRenderer *renderer = [[PageRenderer alloc] + initWithInputURL:[NSURL fileURLWithPath:inputPath] + outputURL:[NSURL fileURLWithPath:outputPath]]; + [renderer start]; + [NSRunLoop.mainRunLoop run]; + } + return 0; +} diff --git a/docs/banban-mini-design-atlas/shared.css b/docs/banban-mini-design-atlas/shared.css new file mode 100644 index 0000000..826143d --- /dev/null +++ b/docs/banban-mini-design-atlas/shared.css @@ -0,0 +1,130 @@ +:root { + --orange: #ff8c42; + --orange-deep: #ff6b35; + --orange-soft: #fef3e8; + --ink: #1a1a1a; + --muted: #666666; + --quiet: #999999; + --line: #ece9e5; + --surface: #ffffff; + --canvas: #f5f7fa; + --green: #16a34a; + --green-soft: #e8f8f0; + --blue: #5b8ff9; + --blue-soft: #e8f0fe; + --red: #be123c; + --red-soft: #fff1f2; + --purple: #8b5cf6; + --purple-soft: #f0e6ff; + --yellow: #f59e0b; + --yellow-soft: #fff7e8; + --shadow: 0 10px 30px rgba(31, 41, 55, 0.08); + --shadow-soft: 0 4px 16px rgba(31, 41, 55, 0.06); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + background: #eceff3; + color: var(--ink); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + letter-spacing: 0; +} + +button, +input { + font: inherit; + letter-spacing: 0; +} + +.poster { + width: 1440px; + margin: 0 auto; + overflow: hidden; + background: #fbfaf8; +} + +.poster-kicker { + display: inline-flex; + align-items: center; + gap: 10px; + color: var(--orange-deep); + font-size: 16px; + font-weight: 700; +} + +.poster-kicker::before { + width: 24px; + height: 4px; + border-radius: 999px; + background: var(--orange); + content: ""; +} + +.poster-title { + margin: 14px 0 8px; + font-size: 56px; + line-height: 1.12; + font-weight: 800; +} + +.poster-subtitle { + margin: 0; + color: var(--muted); + font-size: 22px; + line-height: 1.6; +} + +.meta-chip { + display: inline-flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 0 18px; + border: 1px solid #ebe7e1; + border-radius: 8px; + background: rgba(255, 255, 255, 0.86); + color: #4b5563; + font-size: 16px; + font-weight: 650; +} + +.section-eyebrow { + color: var(--orange-deep); + font-size: 15px; + font-weight: 800; +} + +.section-title { + margin: 8px 0 4px; + font-size: 30px; + line-height: 1.25; + font-weight: 800; +} + +.section-note { + margin: 0; + color: var(--muted); + font-size: 16px; + line-height: 1.55; +} + +.poster-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 34px 56px 40px; + border-top: 1px solid #ece7df; + color: #7b7b7b; + font-size: 14px; +} + +.footer-brand { + color: var(--ink); + font-weight: 800; +} diff --git a/docs/commercial-map-and-sms-application-guide.md b/docs/commercial-map-and-sms-application-guide.md new file mode 100644 index 0000000..3925f0f --- /dev/null +++ b/docs/commercial-map-and-sms-application-guide.md @@ -0,0 +1,664 @@ +# 商用高德地图 Key 与阿里云短信申请指南 + +更新日期:2026-07-09 + +本文档用于指导申请人基于当前 Banban / 伴伴项目申请: + +- 高德开放平台商用地图能力:服务端 Web 服务 API Key,主要使用逆地理编码。 +- 阿里云短信服务:国内通知短信,主要用于设备告警通知家长,预留设备留言通知。 + +本文档重点是申请流程、填写内容、材料清单和审核注意事项。代码位置只用于确认本项目实际需要申请哪些能力。 + +## 1. 当前项目要申请什么 + +### 1.1 高德地图 + +当前项目需要申请高德开放平台的 Web 服务 API Key。 + +用途: + +- 后端接收设备上报的经纬度。 +- 后端调用高德逆地理编码接口,把经纬度转换成可读地址。 +- 小程序展示设备当前位置、轨迹点、告警位置。 +- 告警短信里使用较短的地址描述,例如“浙江省杭州市萧山区扬帆路附近”。 + +当前不应申请为主要接入项: + +- Web 端 JavaScript API Key:当前不是浏览器 Web 页面直接渲染高德地图。 +- Android / iOS SDK Key:当前不是原生 App SDK 接入。 +- 小程序端高德 SDK Key:当前小程序地图展示使用小程序 `Map` 组件,地址解析在后端完成。 + +如果后续改成小程序前端直接调用高德 SDK,再单独新增对应 Key,不建议和服务端 Key 混用。 + +### 1.2 阿里云短信 + +当前项目需要申请阿里云短信服务的国内通知短信。 + +主要模板: + +- 设备告警通知:必需。 + +可选模板: + +- 设备留言通知:当前后端已有短信服务方法,但现有设备留言主链路更多是微信公众号模板通知;如果产品希望短信也覆盖留言,需要申请模板并补上触发调用。 + +当前不应申请为主要短信类型: + +- 验证码短信:当前这项需求不是注册/登录验证码。 +- 营销短信:项目场景是安全通知,不是营销推广。 +- 国际/港澳台短信:除非产品明确要发给境外手机号。 + +## 2. 申请前统一准备 + +### 2.1 企业资料 + +申请高德和阿里云短信前,建议先准备以下资料: + +- 企业营业执照扫描件或照片。 +- 统一社会信用代码。 +- 企业全称。 +- 企业简称,如有。 +- 法定代表人姓名。 +- 法定代表人证件号码,按平台要求填写。 +- 法定代表人证件照片,按平台要求上传。 +- 申请经办人姓名。 +- 申请经办人手机号。 +- 申请经办人身份证正反面照片,按平台要求上传。 +- 企业客服电话或产品服务电话,如有。 +- 企业邮箱。 +- 企业官网,如有。 +- 微信小程序名称。 +- 微信小程序 AppID。 +- 微信小程序截图:登录页、设备绑定页、定位页、告警/通知页、手机号授权或绑定页。 +- 产品隐私政策链接或截图。 +- 用户协议链接或截图。 + +### 2.2 项目信息 + +建议统一使用以下描述,避免两个平台材料口径不一致: + +- 产品名称:伴伴智能设备 / Banban。 +- 产品类型:智能儿童陪伴设备及家长端微信小程序。 +- 使用对象:已购买或绑定设备的家长用户。 +- 核心功能:家长通过小程序查看绑定设备状态、位置、轨迹、告警记录和设备留言。 +- 定位来源:绑定设备主动上报定位,不是后台持续读取家长手机位置。 +- 通知对象:已绑定设备且在系统内留存手机号的家长或家庭成员。 +- 通知目的:安全告警、设备状态和亲子沟通提醒。 +- 是否营销:否。 + +### 2.3 技术联系人要提前确认的信息 + +申请前让技术负责人确认: + +- 生产后端公网域名。 +- 生产后端服务器出口公网 IP。 +- 预计日调用量。 +- 预计峰值 QPS。 +- 是否需要多环境 Key:生产、测试、开发是否分开。 +- 短信签名希望使用的名称:例如“伴伴”或公司简称。 +- 当前短信模板变量名是否保持代码默认值:`conference`、`code`、`time`、`address`。 + +## 3. 高德地图 Key 申请流程 + +### 3.1 注册并认证账号 + +1. 打开高德开放平台:https://lbs.amap.com/ +2. 点击登录。 +3. 使用企业邮箱、手机号或阿里云账号等方式登录。 +4. 进入账号中心或控制台。 +5. 找到实名认证 / 企业认证入口。 +6. 选择企业认证。 +7. 填写企业名称和统一社会信用代码。 +8. 上传营业执照。 +9. 填写联系人姓名、手机号、邮箱。 +10. 提交认证。 +11. 等待认证通过。 + +注意: + +- 商用项目建议使用企业主体账号,不要使用员工个人账号。 +- 如果后续需要购买配额、申请发票、签署服务协议,企业账号更容易闭环。 + +### 3.2 创建应用 + +1. 登录高德开放平台控制台。 +2. 进入“应用管理”。 +3. 点击“创建新应用”。 +4. 应用名称填写:`伴伴设备定位服务`。 +5. 应用类型按控制台可选项选择最接近的业务类型,例如“生活服务”“工具”“物联网/硬件设备”。如果没有完全匹配项,选择通用类型。 +6. 应用描述填写: + +```text +伴伴设备定位服务用于智能设备上报定位后的地址解析。后端服务接收已绑定设备上报的经纬度,调用高德 Web 服务逆地理编码接口转换为可读地址,用于家长在微信小程序中查看设备当前位置、轨迹点和告警位置。定位数据仅用于已绑定设备的安全查看和告警通知,不用于营销推广。 +``` + +7. 提交创建。 + +### 3.3 添加 Web 服务 Key + +1. 进入刚创建的应用。 +2. 点击“添加 Key”或“添加新 Key”。 +3. Key 名称填写:`banban-server-prod-regeo`。 +4. 服务平台选择:`Web服务`。 +5. 如果控制台要求选择服务能力,选择: + - 地理/逆地理编码。 + - 逆地理编码。 +6. 如果控制台支持 IP 白名单,填写生产后端服务器出口公网 IP。 +7. 如果还没有固定生产 IP,先不要随意填写内网 IP;等服务器确定后再补充。 +8. 保存 Key。 +9. 复制生成的 Key,交给后端配置。 + +建议: + +- 生产 Key、测试 Key 分开创建。 +- Key 名称里带环境后缀:`prod`、`test`、`dev`。 +- 不要把生产 Key 放到小程序前端代码。 +- 不要把生产 Key 提交到 Git。 + +### 3.4 高德商用配额确认 + +创建 Key 后,还要确认普通配额是否满足商用上线: + +1. 在高德控制台查看 Web 服务 API 的日调用量限制。 +2. 查看逆地理编码接口的 QPS 限制。 +3. 估算当前项目调用量。 +4. 如果普通配额不够,进入高德开放平台的升级、购买或商务咨询入口。 +5. 选择适合的流量包、QPS 包或商业授权服务。 +6. 保存订单、合同、发票信息,交给财务或项目负责人归档。 + +估算方式: + +```text +日调用量 = 日活设备数 * 单设备日定位上报次数 * 需要解析地址的比例 +峰值 QPS = 峰值在线设备数 * 单设备峰值上报频率 +``` + +当前项目有地址解析去重和异步解析机制,但申请时应保守估算,不要按最低调用量报。 + +### 3.5 高德申请材料填写建议 + +业务场景可填写: + +```text +本产品为伴伴智能设备及家长端微信小程序。设备由家长绑定到儿童档案后,会按业务事件上报定位信息。服务端调用高德 Web 服务逆地理编码能力,将经纬度转换为可读地址,供家长查看设备当前位置、历史轨迹和设备告警位置。所有定位展示均基于用户主动绑定的设备,不用于广告、画像或营销。 +``` + +接口用途可填写: + +```text +使用逆地理编码接口,将设备上报的 GCJ-02 经纬度转换为格式化地址,展示在小程序定位页、轨迹页、告警记录页,并在紧急告警短信中展示简短位置。 +``` + +数据安全说明可填写: + +```text +高德 Key 仅配置在服务端环境变量中,不放入微信小程序前端包。服务端只在绑定关系校验通过后处理对应设备定位。定位数据仅对绑定家长和授权家庭成员展示。系统保存必要的当前位置和轨迹记录,用于设备安全查看和告警追溯。 +``` + +## 4. 阿里云短信申请流程 + +### 4.1 注册、实名认证并开通短信服务 + +1. 打开阿里云官网:https://www.aliyun.com/ +2. 登录或注册账号。 +3. 进入账号中心。 +4. 完成企业实名认证。 +5. 搜索“短信服务”。 +6. 进入短信服务控制台。 +7. 点击开通短信服务。 +8. 同意服务协议。 +9. 确认账号余额、支付方式和发票主体。 + +注意: + +- 生产项目建议用企业实名认证账号。 +- 短信费用通常按发送量计费,申请前确认财务充值或后付费权限。 +- 不建议使用个人账号申请生产短信服务。 + +### 4.2 创建 RAM 子账号和 AccessKey + +建议不要用阿里云主账号 AccessKey 直接接入后端。 + +1. 进入阿里云 RAM 访问控制。 +2. 创建用户,例如:`banban-sms-prod`。 +3. 访问方式选择 OpenAPI 调用访问。 +4. 创建 AccessKey。 +5. 复制 `AccessKey ID` 和 `AccessKey Secret`。 +6. 给 RAM 用户授权短信服务发送相关权限。 +7. 保存 AccessKey 到密码管理器。 +8. 只把密钥交给后端部署负责人。 + +后端需要配置: + +```env +SMS_ENABLED=true +SMS_PROVIDER=aliyun +ALIYUN_SMS_ACCESS_KEY_ID=实际 AccessKey ID +ALIYUN_SMS_ACCESS_KEY_SECRET=实际 AccessKey Secret +ALIYUN_SMS_SIGN_NAME=审核通过的短信签名 +ALIYUN_SMS_ALARM_TEMPLATE_CODE=审核通过的告警模板 Code +ALIYUN_SMS_LEAVE_MESSAGE_TEMPLATE_CODE=审核通过的留言模板 Code +``` + +如果只启用告警短信,可以先不配置 `ALIYUN_SMS_LEAVE_MESSAGE_TEMPLATE_CODE`。 + +### 4.3 申请短信资质 + +短信资质是签名和模板申请前的重要步骤。控制台名称可能是“资质管理”“资质申请”或“国内消息资质”,以阿里云当前页面为准。 + +操作步骤: + +1. 进入阿里云短信服务控制台。 +2. 进入“资质管理”。 +3. 点击“新增资质”或“申请资质”。 +4. 选择资质用途:国内短信。 +5. 选择主体类型:企业。 +6. 填写企业名称。 +7. 填写统一社会信用代码。 +8. 上传营业执照。 +9. 填写法定代表人姓名。 +10. 填写法定代表人证件类型和证件号码。 +11. 按要求上传法定代表人证件照。 +12. 填写经办人姓名和手机号。 +13. 上传经办人证件照。 +14. 如果经办人不是法人,按要求上传授权委托书。 +15. 填写业务网站、App 或小程序信息。 +16. 上传产品截图和用户接收短信场景截图。 +17. 提交审核。 + +资料说明: + +- 如果签名使用公司全称或简称,通常需要营业执照支撑。 +- 如果签名使用产品名、小程序名或商标名,需要准备产品归属证明、软著、商标证书、小程序后台截图等材料。 +- 如果代其他公司发送短信,需要准备他用授权材料。 + +建议申请主体: + +```text +企业自用资质 +``` + +建议业务说明: + +```text +本产品为伴伴智能设备及家长端微信小程序。用户购买或绑定设备后,可在小程序中绑定儿童档案和家长手机号。设备触发紧急告警时,系统向已绑定家长手机号发送通知短信,提醒家长及时打开小程序查看设备状态和位置。短信仅用于设备安全通知,不用于营销推广。 +``` + +### 4.4 申请短信签名 + +短信签名是短信开头的品牌标识,例如: + +```text +【伴伴】 +``` + +操作步骤: + +1. 进入阿里云短信服务控制台。 +2. 进入“国内消息”。 +3. 进入“签名管理”。 +4. 点击“添加签名”。 +5. 签名用途选择:自用。 +6. 签名来源根据实际情况选择: + - 公司简称。 + - App 应用。 + - 小程序。 + - 网站。 + - 商标名。 +7. 签名名称填写:`伴伴`。 +8. 上传证明材料。 +9. 填写申请说明。 +10. 提交审核。 + +如果使用“伴伴”作为签名,材料取决于“伴伴”在企业名、产品名、小程序名或商标中的归属: + +- 如果营业执照企业名称或简称包含“伴伴”,上传营业执照即可作为主要证明。 +- 如果微信小程序名称是“伴伴”或包含“伴伴”,上传小程序后台基础信息截图。 +- 如果“伴伴”是商标,上传商标注册证或受理通知书。 +- 如果“伴伴”只是内部项目名,审核风险较高,建议改用公司简称或已经能证明归属的产品名。 + +签名申请说明建议: + +```text +“伴伴”为本公司智能设备及家长端微信小程序产品名称。该签名用于向已绑定设备的家长发送设备告警等通知短信,不用于营销推广。用户在小程序中完成设备绑定和手机号授权后才会接收相关通知。 +``` + +审核注意: + +- 签名不要包含“测试”“Demo”“通知中心”等泛称。 +- 签名要能证明归属。 +- 签名不要使用第三方品牌、平台名或容易混淆的名称。 + +### 4.5 申请短信模板:设备告警通知 + +这是当前项目必须申请的模板。 + +控制台选择: + +- 模板类型:通知短信。 +- 模板名称:`设备告警通知`。 +- 适用场景:设备安全告警通知。 +- 是否营销:否。 + +当前后端模板变量名: + +- `conference`:当前代码用于儿童名或设备名。 +- `code`:当前代码用于事件名称,例如“设备告警”。 +- `time`:事件时间。 +- `address`:地址或兜底文案。 + +告警模板正文建议: + +```text +您绑定的${conference}触发${code},时间${time},位置${address},请及时打开小程序查看。 +``` + +变量示例: + +```text +conference = 小明 +code = 设备告警 +time = 2026-07-09 15:30 +address = 浙江省杭州市萧山区扬帆路附近 +``` + +模板申请说明建议: + +```text +该模板用于伴伴智能设备触发紧急告警后,向已绑定设备的家长手机号发送安全通知。接收人均为小程序内已绑定设备并授权留存手机号的家长或家庭成员。短信内容包含儿童/设备名称、告警类型、告警时间和简短位置,提醒家长及时打开小程序查看详情。该模板不用于营销推广。 +``` + +场景截图建议上传: + +- 小程序设备绑定成功页。 +- 小程序当前孩子或设备管理页。 +- 小程序定位页。 +- 小程序告警记录或系统通知页。 +- 手机号授权/绑定入口截图。 + +审核风险点: + +- 模板里不要写“点击链接”“加微信”“领取”“优惠”等营销词。 +- 地址变量不要过长。当前后端会把地址裁剪到 30 个字符,并过滤特殊字符。 +- 如果阿里云认为变量名 `conference` 不符合语义,可选择两种处理方式: + - 先按当前代码变量名提交,并在变量说明里解释 `conference` 表示儿童/设备名称。 + - 或者让技术先把后端变量名改为 `child_name` / `event_type` / `event_time` / `address`,再提交模板。 + +### 4.6 申请短信模板:设备留言通知 + +这是预留模板。只有产品明确希望“设备留言也发短信”时才必须申请。 + +控制台选择: + +- 模板类型:通知短信。 +- 模板名称:`设备留言通知`。 +- 适用场景:设备留言提醒。 +- 是否营销:否。 + +模板正文建议: + +```text +您绑定的${conference}收到${code},时间${time},请打开${address}查看。 +``` + +变量示例: + +```text +conference = 小明 +code = 设备留言 +time = 2026-07-09 15:30 +address = 小程序 +``` + +模板申请说明建议: + +```text +该模板用于伴伴智能设备向家长发送语音留言后,提醒已绑定家长进入小程序查看留言内容。接收人均为小程序内已绑定设备并留存手机号的家长或家庭成员。短信仅用于亲子沟通提醒和设备消息提醒,不用于营销推广。 +``` + +注意: + +- 当前代码有 `notify_leave_message` 服务方法,但实际留言主链路更多调用微信公众号模板通知。 +- 如果要启用这条短信,需要后端在留言成功后调用 `sms_notification_service.schedule_leave_message_notification(...)`,否则即使模板审核通过也不会发送。 + +### 4.7 模板变量说明建议 + +提交模板时,如果控制台要求填写变量含义,可按下表填写: + +| 变量名 | 业务含义 | 示例 | 长度建议 | +| --- | --- | --- | --- | +| `conference` | 儿童名、设备名或绑定对象名称 | 小明 | 20 字以内 | +| `code` | 通知事件类型 | 设备告警 | 10 字以内 | +| `time` | 事件发生时间 | 2026-07-09 15:30 | 固定格式 | +| `address` | 告警位置或查看入口 | 浙江省杭州市萧山区扬帆路附近 | 30 字以内 | + +更推荐的长期变量名: + +| 推荐变量名 | 当前变量名 | 说明 | +| --- | --- | --- | +| `child_name` | `conference` | 更贴合儿童/设备名称语义 | +| `event_type` | `code` | 更贴合告警/留言事件语义 | +| `event_time` | `time` | 更清晰 | +| `address` | `address` | 可保留 | + +如果现在不改代码,模板必须使用当前变量名。 + +### 4.8 等待运营商实名报备 + +阿里云短信的资质、签名、模板审核通过后,并不一定代表可以立刻稳定向所有号码发送。还要关注运营商实名报备状态。 + +操作步骤: + +1. 在短信服务控制台查看资质审核状态。 +2. 查看签名审核状态。 +3. 查看模板审核状态。 +4. 查看实名报备或运营商报备状态。 +5. 等全部状态正常后再做生产发送。 + +上线前不要只用“模板审核通过”判断完成。 + +### 4.9 测试发送 + +测试前确认: + +- `SMS_ENABLED=true`。 +- `SMS_DRY_RUN=false`。 +- AccessKey 可用。 +- 签名已审核通过。 +- 模板已审核通过。 +- 模板变量名和后端一致。 +- 接收手机号是已绑定家长手机号。 + +建议测试流程: + +1. 先用阿里云控制台提供的测试发送功能验证签名和模板。 +2. 用测试手机号发送告警模板。 +3. 确认短信内容正常展示。 +4. 再在后端测试环境配置真实参数。 +5. 触发一条设备告警。 +6. 确认后端日志返回 `OK`。 +7. 确认家长手机号收到短信。 +8. 确认同一设备短时间内不会重复轰炸。 + +当前后端有防抖配置: + +```env +SMS_ALARM_DEDUP_SECONDS=300 +SMS_LEAVE_MESSAGE_DEDUP_SECONDS=60 +``` + +## 5. 材料清单 + +### 5.1 高德材料清单 + +- 企业营业执照。 +- 企业名称。 +- 统一社会信用代码。 +- 联系人姓名。 +- 联系人手机号。 +- 联系人邮箱。 +- 产品名称。 +- 产品介绍。 +- 小程序名称和 AppID。 +- 小程序截图。 +- 服务端域名。 +- 服务端出口公网 IP。 +- 使用接口说明:逆地理编码。 +- 使用场景说明:设备位置、轨迹、告警位置。 +- 预计日调用量。 +- 预计峰值 QPS。 +- 隐私政策或定位授权说明。 + +### 5.2 阿里云短信材料清单 + +- 企业营业执照。 +- 统一社会信用代码。 +- 法定代表人姓名。 +- 法定代表人证件号码。 +- 法定代表人证件照片。 +- 经办人姓名。 +- 经办人手机号。 +- 经办人身份证正反面。 +- 经办人授权委托书,如平台要求。 +- 产品名称。 +- 小程序名称和 AppID。 +- 小程序后台基础信息截图。 +- 小程序设备绑定页截图。 +- 手机号授权或绑定页截图。 +- 告警记录或通知页截图。 +- 定位页截图。 +- 隐私政策。 +- 用户协议。 +- 短信签名归属证明。 +- 短信模板申请说明。 +- 测试手机号。 + +## 6. 后端配置交接表 + +高德申请完成后交付: + +| 配置项 | 示例 | 说明 | +| --- | --- | --- | +| `Amap_key` | `xxxxxxxx` | 高德 Web 服务 Key | +| `AMAP_REVERSE_GEOCODE_URL` | `https://restapi.amap.com/v3/geocode/regeo` | 一般保持默认 | +| `AMAP_HTTP_TIMEOUT_SECONDS` | `5` | 一般保持默认 | + +阿里云短信申请完成后交付: + +| 配置项 | 示例 | 说明 | +| --- | --- | --- | +| `SMS_ENABLED` | `true` | 是否启用短信 | +| `SMS_PROVIDER` | `aliyun` | 当前只支持阿里云 | +| `SMS_DRY_RUN` | `false` | 生产必须关闭 dry run | +| `ALIYUN_SMS_ACCESS_KEY_ID` | `LTAI...` | RAM 子账号 AK | +| `ALIYUN_SMS_ACCESS_KEY_SECRET` | `***` | RAM 子账号 SK | +| `ALIYUN_SMS_SIGN_NAME` | `伴伴` | 审核通过的签名,不带方括号 | +| `ALIYUN_SMS_ALARM_TEMPLATE_CODE` | `SMS_123456789` | 告警模板 Code | +| `ALIYUN_SMS_LEAVE_MESSAGE_TEMPLATE_CODE` | `SMS_987654321` | 留言模板 Code,可选 | +| `ALIYUN_SMS_ENDPOINT` | `dysmsapi.aliyuncs.com` | 一般保持默认 | + +安全要求: + +- AccessKey Secret 不要发到群聊。 +- 不要提交到 Git。 +- 不要写进小程序前端代码。 +- 生产、测试分开使用不同 AccessKey 和模板更好。 +- 离职或泄露风险时及时轮换密钥。 + +## 7. 审核失败常见原因 + +### 7.1 高德 + +- 账号未完成企业认证。 +- Key 选错平台,把服务端 Web 服务误选成 Web JS API。 +- 没有说明清楚商用场景。 +- 没有评估调用量和 QPS。 +- Key 暴露在前端代码。 +- 生产服务没有固定出口 IP,无法配置安全限制。 + +### 7.2 阿里云短信 + +- 签名无法证明归属。 +- 使用产品名但没有小程序、商标或软著证明。 +- 模板内容像营销短信。 +- 模板变量过多或变量含义不清。 +- 模板变量可能导致整句内容不可控。 +- 申请验证码模板但实际是通知场景。 +- 没有上传用户绑定手机号或接收通知的页面截图。 +- 资质、签名、模板虽然通过,但运营商实名报备未完成。 +- AccessKey 使用主账号,安全风险高。 + +## 8. 推荐申请顺序 + +建议按以下顺序推进: + +1. 确认企业主体和产品签名名称。 +2. 准备营业执照、法人、经办人、小程序截图、隐私政策。 +3. 申请高德企业认证。 +4. 创建高德应用和 Web 服务 Key。 +5. 确认高德商用配额。 +6. 申请阿里云企业实名认证。 +7. 开通阿里云短信服务。 +8. 创建 RAM 子账号和 AccessKey。 +9. 申请短信资质。 +10. 申请短信签名。 +11. 申请设备告警通知模板。 +12. 需要时申请设备留言通知模板。 +13. 等运营商实名报备完成。 +14. 控制台测试发送。 +15. 后端测试环境配置。 +16. 触发真实设备告警验证。 +17. 生产环境配置。 +18. 上线后观察发送成功率、失败码和费用。 + +## 9. 可直接复制的申请说明 + +### 9.1 高德用途说明 + +```text +本产品为伴伴智能设备及家长端微信小程序。用户完成设备绑定后,设备会在定位、轨迹和紧急告警等场景上报经纬度。服务端调用高德 Web 服务逆地理编码接口,将经纬度转换为可读地址,用于家长在小程序内查看设备当前位置、历史轨迹和告警位置。定位数据仅用于已绑定设备的安全查看和告警追溯,不用于广告投放、营销推广或用户画像。 +``` + +### 9.2 阿里云短信资质业务说明 + +```text +本产品为伴伴智能设备及家长端微信小程序。家长购买或绑定设备后,可在小程序中绑定儿童档案和手机号。当设备触发紧急告警时,系统会向已绑定家长手机号发送通知短信,提醒家长及时打开小程序查看设备状态和位置。短信接收人均为已绑定设备并留存手机号的家长或家庭成员,短信仅用于设备安全通知,不用于营销推广。 +``` + +### 9.3 阿里云签名说明 + +```text +“伴伴”为本公司智能设备及家长端微信小程序产品名称。该短信签名用于向已绑定设备的家长发送设备告警等通知短信。用户在小程序中完成设备绑定和手机号授权后才会接收相关短信。该签名不用于营销推广。 +``` + +### 9.4 阿里云告警模板说明 + +```text +该模板用于伴伴智能设备触发紧急告警后,向已绑定设备的家长手机号发送安全通知。短信内容包含儿童/设备名称、告警类型、告警时间和简短位置,提醒家长及时打开小程序查看详情。接收人均为小程序内已绑定设备并授权留存手机号的家长或家庭成员。该模板不用于营销推广。 +``` + +### 9.5 阿里云留言模板说明 + +```text +该模板用于伴伴智能设备向家长发送语音留言后,提醒已绑定家长进入小程序查看留言内容。接收人均为小程序内已绑定设备并留存手机号的家长或家庭成员。短信仅用于亲子沟通提醒和设备消息提醒,不用于营销推广。 +``` + +## 10. 官方参考链接 + +- 高德开放平台:https://lbs.amap.com/ +- 高德 Web 服务 Key 获取说明:https://lbs.amap.com/api/webservice/guide/create-project/get-key +- 高德逆地理编码接口:https://lbs.amap.com/api/webservice/guide/api/georegeo +- 阿里云短信服务新手指引:https://help.aliyun.com/zh/sms/getting-started/get-started-with-sms +- 阿里云短信资质说明:https://help.aliyun.com/zh/sms/user-guide/qualification-application-description +- 阿里云短信签名申请:https://help.aliyun.com/zh/sms/user-guide/create-signatures +- 阿里云短信模板申请:https://help.aliyun.com/zh/sms/user-guide/create-message-templates-1 +- 阿里云 SendSms API:https://help.aliyun.com/zh/sms/developer-reference/api-dysmsapi-2017-05-25-sendsms + +## 11. 当前项目代码锚点 + +- 高德逆地理编码服务:`talkingq-url/banban/service/amap_geocode.py` +- 当前位置地址解析:`talkingq-url/banban/service/location.py` +- 告警地址解析:`talkingq-url/banban/service/device_alarm.py` +- 短信发送服务:`talkingq-url/banban/service/sms_notification.py` +- 短信配置项:`talkingq-url/config.py` +- 设备告警触发短信:`talkingq-url/handlers/mqtt_handler.py` diff --git a/docs/local-mqtt-stability-concurrency-test-plan.md b/docs/local-mqtt-stability-concurrency-test-plan.md new file mode 100644 index 0000000..bb11e35 --- /dev/null +++ b/docs/local-mqtt-stability-concurrency-test-plan.md @@ -0,0 +1,507 @@ +# 本地 MQTT 并发与稳定性测试方案 + +更新日期:2026-07-14 + +本文档用于说明 Banban / TalkingQ 后端在不调用真实外部服务的前提下,如何做本地全流程并发和稳定性测试。测试重点是 MQTT 设备接入、消息解析、服务端回包、GPS 查询响应、告警处理、数据库落库、后端健康状态和资源变化。 + +## 1. 测试目标 + +### 1.1 并发度 + +并发度用于回答系统同时承载多少设备连接和消息流量。 + +本项目里主要看: + +- 同时在线的 MQTT 设备客户端数量。 +- 设备状态上报 `msg_id=000` 的吞吐。 +- 告警事件 `msg_id=010` 的吞吐和落库。 +- 服务端 GPS 查询 `msg_id=001` 的下发和设备响应。 +- 服务端事件响应 `event_resp` 是否完整返回。 + +### 1.2 稳定性 + +稳定性用于回答系统在持续运行一段时间后是否保持正确和健康。 + +本项目里主要看: + +- 设备连接是否持续保持。 +- 上行消息和服务端回包是否丢失。 +- 数据库写入是否持续成功。 +- 后端健康检查是否持续返回 `200`。 +- 错误日志是否新增。 +- CPU、内存、数据库连接数、MQTT 连接数是否持续爬升或堆积。 + +## 2. 测试边界 + +### 2.1 本次覆盖 + +- 本地 FastAPI 后端。 +- 本地 MQTT broker。 +- 本地 MySQL。 +- 真实 MQTT 客户端连接,每个模拟设备一个 MQTT client。 +- MQTT 主流程: + - `device/{device_id}/event` 上行。 + - `device/{device_id}/event_resp` 下行响应。 + - `device/{device_id}/command` 服务端命令。 + - `device/{device_id}/response` 设备响应。 +- 状态上报、告警、GPS 查询、异步持久化、告警落库。 + +### 2.2 本次不覆盖 + +- 线上 MQTT broker。 +- 线上服务器网络和 TLS。 +- 真实高德逆地理编码调用。 +- 真实短信发送。 +- 真实设备固件网络抖动。 +- 断线重连风暴。 +- 慢数据库、数据库断连、外部服务长超时。 + +这些场景需要单独做线上灰度压测或故障注入测试。 + +## 3. 外部服务隔离策略 + +本地全流程测试要验证主流程,但不能依赖真实外部服务。 + +推荐策略: + +- GPS 响应使用无效坐标 `lat=0,lng=0`。 +- 后端应忽略无效 MQTT 定位,不调用高德解析。 +- 告警测试账号不配置可用家长手机号。 +- 后端短信链路应走“无手机号跳过”,不调用短信服务。 +- 不使用真实 COS 上传、微信模板消息或其他公网依赖作为通过条件。 + +通过标准不是“外部服务成功”,而是“外部服务不可用或被跳过时,MQTT 主流程、回包和落库不受影响”。 + +## 4. 测试环境 + +### 4.1 本地服务 + +在 `talkingq-url` 目录运行: + +```bash +.venv/bin/amqtt +``` + +启动后本地 MQTT broker 监听: + +```text +127.0.0.1:1883 +``` + +启动后端: + +```bash +.venv/bin/uvicorn main:app --host 127.0.0.1 --port 8080 +``` + +健康检查: + +```bash +curl -sS -o /dev/null -w 'openapi_http=%{http_code} time=%{time_total}\n' \ + http://127.0.0.1:8080/openapi.json +``` + +预期: + +```text +openapi_http=200 +``` + +### 4.2 配置要求 + +`talkingq-url/.env` 应指向本地服务: + +```text +DB_HOST=127.0.0.1 +DB_PORT=3306 +TALKINGQ_MQTT_BROKER=127.0.0.1 +TALKINGQ_MQTT_PORT=1883 +``` + +### 4.3 测试设备数据 + +建议使用专门的压测设备 ID,避免污染真实设备: + +```text +TalkingQ_LOAD000001 +TalkingQ_LOAD000002 +... +TalkingQ_LOAD000500 +``` + +测试前确认: + +- `device_auth` 有对应设备。 +- `device_bindings` 有对应绑定。 +- 告警落库使用的 `owner_user_id`、`child_id` 不超过当前数据库字段范围。 + +检查示例: + +```sql +SELECT COUNT(*) FROM device_auth WHERE device_id LIKE 'TalkingQ_LOAD%'; +SELECT COUNT(*) FROM device_bindings WHERE device_id LIKE 'TalkingQ_LOAD%'; +``` + +## 5. 压测脚本要求 + +压测脚本应满足以下行为: + +- 每个设备使用一个独立 MQTT client。 +- 每个 client 订阅: + - `device/{device_id}/event_resp` + - `device/{device_id}/command` +- 每个 client 周期性发布 `msg_id=000` 设备信息。 +- 按固定比例发布 `msg_id=010` 告警。 +- 收到服务端 `msg_id=001` GPS 查询后,回复 `device/{device_id}/response`。 +- 默认 GPS 模式使用 `invalid`,返回 `0,0`,避免真实高德调用。 +- 统计发送数、回包数、GPS 命令数、GPS 响应数、发布延迟。 + +状态上报 payload 示例: + +```json +{ + "msg_id": "000", + "data": { + "id": "TalkingQ_LOAD000001", + "power": 79, + "signal": 4, + "version": "load-client-1.0.0", + "voice": 60, + "imei": "990000000000001", + "device_type": 2, + "latitude": 0, + "longitude": 0, + "coord_type": "gcj02" + } +} +``` + +告警 payload 示例: + +```json +{ + "msg_id": "010" +} +``` + +GPS 响应 payload 示例: + +```json +{ + "msg_id": "001", + "status": "success", + "data": { + "latitude": 0, + "longitude": 0, + "coord_type": "gcj02", + "accuracy_m": 10, + "source": 1, + "battery_pct": 80 + } +} +``` + +## 6. 推荐测试阶段 + +### 6.1 冒烟测试 + +目的:确认脚本、broker、后端、DB 基本可用。 + +建议参数: + +```text +20 个 MQTT client +1 轮状态上报 +每 10 台设备触发 1 次告警 +settle 5 秒 +``` + +通过标准: + +- client 连接成功数等于请求数。 +- `sent_total == event_resp_total`。 +- `resp_000 == sent_000`。 +- `resp_010 == sent_010`。 +- `command_001 == gps_response_sent`。 + +### 6.2 短时并发测试 + +目的:验证较高设备规模下的瞬时吞吐。 + +建议参数: + +```text +500 个 device_id +3 轮状态上报 +每 10 台设备触发 1 次告警 +settle 20 秒 +``` + +通过标准: + +- 所有事件收到 `event_resp`。 +- 告警落库数量与发送数量一致。 +- 健康检查仍返回 `200`。 +- 新增错误日志为 `0`。 + +### 6.3 真实连接稳定性测试 + +目的:验证 500 个独立 MQTT client 持续在线时,主流程是否稳定。 + +建议参数: + +```text +500 个 MQTT client +持续 30 分钟 +每 15 秒每台设备上报一次 msg_id=000 +每 60 秒每 20 台设备触发一次 msg_id=010 +GPS mode = invalid +settle 20 秒 +``` + +通过标准: + +- 500 个 client 全部连接成功。 +- 连接数在测试期间保持稳定。 +- `sent_total == event_resp_total`。 +- `sent_000 == resp_000`。 +- `sent_010 == resp_010`。 +- `command_001 == gps_response_sent`。 +- 30 分钟内后端健康检查持续返回 `200`。 +- 新增错误日志为 `0`。 +- MySQL 连接数没有持续堆积。 +- 后端和 broker RSS 没有持续单调增长。 + +### 6.4 长时稳定性测试 + +目的:把 30 分钟结论提升为更可靠的上线前参考。 + +建议参数: + +```text +500 个 MQTT client +持续 2 小时 +或过夜 8-12 小时 +保持 15 秒状态上报和 60 秒告警节奏 +``` + +增加观察项: + +- 日志滚动后是否还能正常写入。 +- 定时任务是否干扰 MQTT 主流程。 +- 数据库表增长后查询是否变慢。 +- 后端进程 RSS 是否长期爬升。 +- broker 是否出现连接残留。 + +## 7. 监控指标 + +建议每 60 秒记录一次: + +```text +sample +elapsed_s +backend_cpu +backend_mem +backend_rss_kb +broker_cpu +broker_mem +broker_rss_kb +mqtt_established +mysql_threads +mysql_processes +http_code +http_time_s +new_error_lines +``` + +采集方式示例: + +```bash +ps -o %cpu=,%mem=,rss= -p +ps -o %cpu=,%mem=,rss= -p +lsof -nP -iTCP:1883 -sTCP:ESTABLISHED +curl -sS -o /dev/null -w '%{http_code} %{time_total}' http://127.0.0.1:8080/openapi.json +``` + +MySQL 连接数: + +```sql +SHOW STATUS LIKE 'Threads_connected'; +SHOW PROCESSLIST; +``` + +错误日志过滤: + +```bash +rg -n "(ERROR|failed|DataError|Out of range|告警地址解析失败|当前位置地址解析失败|amap|高德)" \ + talkingq-url/logs/talkingq_YYYY-MM-DD.log +``` + +## 8. 本次执行结果 + +执行日期:2026-07-14 + +### 8.1 单元测试 + +```text +86 passed +``` + +### 8.2 500 客户端 30 分钟稳定性测试 + +测试参数: + +```text +devices_requested=500 +duration_seconds=1800 +info_interval_seconds=15 +alarm_every=20 +alarm_interval_seconds=60 +gps_mode=invalid +``` + +结果: + +```text +elapsed_seconds=1820.053 +client_ready_success=500 +client_ready_failed=0 +sent_total=60750 +event_resp_total=60750 +sent_000=60000 +resp_000=60000 +sent_010=750 +resp_010=750 +command_001=750 +gps_response_sent=750 +publish_latency_ms_avg=29.301 +publish_latency_ms_p95=48.727 +``` + +### 8.3 数据库验证 + +```text +device_settings_load=500 +device_alarm_events_load_total=877 +device_alarm_events_load_last_40m=750 +child_location_current_load=0 +mysql_threads_connected=6 +``` + +说明: + +- `device_alarm_events_load_last_40m=750` 与本轮告警发送数一致。 +- `child_location_current_load=0` 是预期结果,因为 GPS 使用 `0,0`,后端忽略无效定位。 + +### 8.4 资源变化 + +后端: + +```text +压测前 RSS 约 129.9MB +500 客户端连接后 RSS 约 131-132MB +中途回落到约 120-121MB +结束后约 121.6MB +CPU 基本 0.1%-0.2% +``` + +MQTT broker: + +```text +压测前 RSS 约 43.9MB +500 客户端连接后 RSS 约 73-75MB +后半程稳定在约 69-70MB +结束后约 70.1MB +CPU 基本 0.0%-0.3% +``` + +连接数: + +```text +压测前 mqtt_established=2 +压测中 mqtt_established=1002 +压测后 mqtt_established=2 +``` + +说明:本机 loopback 下 `lsof` 会看到连接两端,`1002` 约等于 `501` 条 MQTT 连接,包括 500 个设备客户端和 1 条后端服务连接。 + +### 8.5 日志验证 + +测试窗口内新增错误日志: + +```text +0 +``` + +未发现: + +- `ERROR` +- `failed` +- `DataError` +- `Out of range` +- `告警地址解析失败` +- `当前位置地址解析失败` +- `amap` +- `高德` + +## 9. 本次结论 + +本地环境下,当前项目已验证可以稳定承载: + +```text +500 个独立 MQTT 客户端 +持续 30 分钟 +60750 条事件完整回包 +750 条告警完整落库 +750 次 GPS 查询完整响应 +``` + +测试期间: + +- 后端健康检查持续 `200`。 +- 新增错误日志为 `0`。 +- MySQL 连接没有堆积。 +- 后端和 broker 内存没有持续爬升。 +- 外部服务未调用时,MQTT 主流程不受影响。 + +因此可以判断:本地单实例下,500 台设备级 MQTT 连接和当前消息节奏的 30 分钟稳定性通过。 + +## 10. 风险和下一步 + +### 10.1 当前结论不能直接等同线上容量 + +原因: + +- 本地 broker 是 `amqtt`,不是线上 broker。 +- 本地网络没有公网延迟、TLS、丢包、移动网络抖动。 +- 高德和短信没有真实调用。 +- 测试时长是 30 分钟,不是 24 小时。 +- 没有模拟断线重连风暴。 + +### 10.2 建议补充测试 + +上线前建议继续做: + +- 500 客户端持续 2 小时。 +- 500 客户端过夜 8-12 小时。 +- 断线重连测试:一次性断开 20%、50%、100% 客户端后重连。 +- 慢 DB 测试:模拟 DB 写入变慢,确认 MQTT 回包和主流程是否可控。 +- 高德失败测试:模拟超时、HTTP 500、无效 Key,确认地址解析失败不影响告警落库。 +- 短信失败测试:模拟短信服务超时或失败,确认告警主流程不被拖垮。 +- 线上灰度测试:在真实 broker 和服务器规格下从 50、100、300、500 逐步提升。 + +## 11. 通过标准汇总 + +一次测试可判定通过,需要同时满足: + +- 客户端连接成功数等于目标数。 +- `sent_total == event_resp_total`。 +- `sent_000 == resp_000`。 +- `sent_010 == resp_010`。 +- `command_001 == gps_response_sent`。 +- 告警新增落库数量等于 `sent_010`。 +- 健康检查全程 `200`。 +- 新增错误日志为 `0`。 +- MySQL 连接数没有持续增长。 +- 后端和 broker RSS 没有持续单调增长。 +- 测试结束后 MQTT 连接数回落到基线。 diff --git a/docs/设备直接绑定接口联调文档(1).md b/docs/设备直接绑定接口联调文档(1).md new file mode 100644 index 0000000..c40cdce --- /dev/null +++ b/docs/设备直接绑定接口联调文档(1).md @@ -0,0 +1,431 @@ +# 设备直接绑定接口联调文档 + +## 1. 适用范围 + +本文档用于 Banban 小程序与 4G 管理后端之间的“设备无法获取绑定码时直接绑定”联调。 + +生产环境推荐使用 `handoffToken` 两段式流程: + +1. Banban 后端使用服务凭证,向 4G 管理后端申请一次性 `handoffToken`。 +2. 4G 小程序用户完成微信登录后,携带程序用户 Token 和 `handoffToken` 完成设备绑定。 + +该流程仍然复用普通绑定码绑定的事务逻辑,成功后会: + +- 创建当前 4G 小程序用户与设备的有效绑定关系; +- 将 `sim_card.binding_status` 更新为 `bound`; +- 作废该设备尚未使用的绑定验证码; +- 清理绑定验证码缓存; +- 写入绑定审计记录。 + +handoff 申请接口会在服务 Token 校验通过后,按 ICCID 幂等创建或复用 4G 后端的 `sim_card` 档案。新建档案初始状态为 `active`、`unbound`、`sync_status=pending`;消费 handoffToken 后才会创建用户绑定并将绑定状态更新为 `bound`。因此 Banban 不需要提前调用 4G 后端的设备注册接口。 + +该自动建档能力只对已认证的 Banban 服务接口开放,不代表可以通过公开接口任意写入 ICCID。ICCID 格式仍必须合法;如果已有档案状态为非 `active`,申请会被拒绝。 + +## 2. 联调环境与配置 + +将 `` 替换为实际的 4G 管理后端域名: + +```text +BASE_URL=https:// +``` + +4G 后端需要配置并重启后才会生效: + +```bash +HANDOFF_ENABLED=true +HANDOFF_TOKEN_TTL=5m +HANDOFF_TOKEN_PEPPER=<独立的高强度随机值> +BANBAN_HANDOFF_SERVICE_TOKEN=<独立的高强度随机值> +WECHAT_APP_ID=<4G 小程序 AppID> +DIRECT_ICCID_BINDING_ENABLED=false +``` + +说明: + +- `BANBAN_HANDOFF_SERVICE_TOKEN` 只允许保存在 Banban 后端服务端,不能下发到小程序或前端页面。 +- `HANDOFF_TOKEN_PEPPER` 只允许保存在 4G 后端服务端,不能与服务 Token 共用。 +- `HANDOFF_TOKEN_TTL=5m` 表示凭证默认有效 5 分钟,具体有效期以申请接口返回的 `expiresIn` 为准。 +- 当前环境的 `BINDING_ALLOWED_ORIGINS` 可以继续保留 `https://banban.api.talkingq.com,http://192.168.101.151`,但 handoff 流程不依赖 Origin 证明设备归属。 + +## 3. 完整调用时序 + +```text +Banban 后端 4G 管理后端 4G 小程序 + | | | + |-- X-Service-Token + ICCID -------->| | + |<--------- handoffToken ------------| | + | | | + | |<-- wx.login code --------| + | |---- accessToken -------->| + | | | + | |<-- accessToken + token --| + | |---- 完成设备绑定 -------->| +``` + +Banban 后端申请凭证的接口只能由服务端调用;4G 小程序消费凭证的接口只能由登录后的 `PROGRAM_USER` 调用。 + +## 4. 接口一:Banban 后端申请一次性 handoffToken + +### 4.1 请求地址 + +```http +POST /internal/v1/handoff-tokens +``` + +完整地址: + +```text +https:///internal/v1/handoff-tokens +``` + +必须使用 HTTPS。该接口不使用小程序 JWT,而是使用服务间 Token。 + +### 4.2 请求头 + +```http +X-Service-Token: +Content-Type: application/json +X-Request-Id: banban-handoff-20260922-0001 +``` + +`X-Service-Token` 的值必须与 4G 后端环境变量 `BANBAN_HANDOFF_SERVICE_TOKEN` 完全一致。 + +`X-Request-Id` 仅用于 HTTP 请求追踪,建议使用 8 至 64 位的字母、数字、点、下划线或短横线。真正的业务幂等键是请求体中的 `requestId`。 + +### 4.3 请求体 + +```json +{ + "iccid": "898604E91123D1334901", + "sourceBindingId": "banban-binding-100001", + "requestId": "banban-handoff-20260922-0001" +} +``` + +| 字段 | 类型 | 必填 | 约束与说明 | +| --- | --- | --- | --- | +| `iccid` | string | 是 | 18 至 22 位字母或数字,后端会统一转为大写;已登记的 active 档案会复用,未登记时由 4G 后端自动建档 | +| `sourceBindingId` | string | 是 | Banban 侧绑定关系的稳定唯一 ID,最长 128 个字符;同一绑定关系后续重试必须保持不变 | +| `requestId` | string | 是 | 本次申请的幂等请求号,最长 64 个字符;网络超时重试必须保持不变 | + +`sourceBindingId` 不要使用每次随机生成的值,否则无法实现申请接口幂等。`requestId` 建议由 Banban 后端生成,例如 `banban-handoff-20260922-0001`。 + +### 4.4 curl 示例 + +以下命令只能在 Banban 后端服务器或安全的服务端环境执行: + +```bash +export BASE_URL="https://" +export BANBAN_HANDOFF_SERVICE_TOKEN="<服务端密钥>" + +curl -i -sS -X POST "$BASE_URL/internal/v1/handoff-tokens" \ + -H "X-Service-Token: $BANBAN_HANDOFF_SERVICE_TOKEN" \ + -H "Content-Type: application/json" \ + -H "X-Request-Id: banban-handoff-20260922-0001" \ + --data '{ + "iccid": "898604E91123D1334901", + "sourceBindingId": "banban-binding-100001", + "requestId": "banban-handoff-20260922-0001" + }' +``` + +### 4.5 成功响应 + +HTTP 状态码为 `200`: + +```json +{ + "code": "OK", + "message": "success", + "data": { + "targetAppId": "<4G 小程序 AppID>", + "handoffToken": "<一次性凭证>", + "expiresIn": 300 + }, + "requestId": "banban-handoff-20260922-0001", + "timestamp": "2026-09-22T03:30:00Z" +} +``` + +| 字段 | 说明 | +| --- | --- | +| `data.targetAppId` | 允许消费该凭证的目标小程序 AppID,必须与 4G 小程序登录身份一致 | +| `data.handoffToken` | 一次性绑定凭证,只能传给 4G 小程序的绑定接口,不要写入日志或返回给不可信客户端 | +| `data.expiresIn` | 剩余有效秒数,消费时以服务端校验结果为准 | + +相同的 `sourceBindingId + requestId` 重试时: + +- 凭证仍为 `active` 且未过期:返回同一凭证,不会重复创建; +- 对应 ICCID 发生变化:返回 `IDEMPOTENCY_CONFLICT`; +- 凭证已消费:返回 `HANDOFF_TOKEN_USED`; +- 凭证已过期:返回 `HANDOFF_TOKEN_EXPIRED`。 + +## 5. 接口二:4G 小程序消费 handoffToken 完成绑定 + +### 5.1 先获取 4G 小程序 accessToken + +4G 小程序先调用微信 `wx.login()`,再把返回的临时 `code` 交给 4G 后端: + +```http +POST /api/v1/program/auth/login +``` + +请求: + +```http +Content-Type: application/json +X-Request-Id: program-login-20260922-0001 +``` + +```json +{ + "code": "<4G 小程序 wx.login 返回的 code>" +} +``` + +curl 示例: + +```bash +curl -i -sS -X POST "$BASE_URL/api/v1/program/auth/login" \ + -H "Content-Type: application/json" \ + -H "X-Request-Id: program-login-20260922-0001" \ + --data '{"code":"<4G 小程序 wx.login 返回的 code>"}' +``` + +成功响应中的 `data.accessToken` 是后续绑定接口需要的程序用户 Token: + +```json +{ + "code": "OK", + "message": "success", + "data": { + "accessToken": "", + "expiresAt": "2026-09-22T15:30:00Z", + "user": { + "userId": 10001, + "status": "active" + } + }, + "requestId": "program-login-20260922-0001", + "timestamp": "2026-09-22T03:31:00Z" +} +``` + +必须使用 4G 小程序自己的 `wx.login()` code,不能使用 Banban 小程序的登录 code,也不能使用 `SUPER_ADMIN`、`FACTORY_PROVISIONER` 或设备 Token。 + +### 5.2 请求地址 + +```http +POST /api/v1/program/bindings/handoff +``` + +完整地址: + +```text +https:///api/v1/program/bindings/handoff +``` + +### 5.3 请求头 + +```http +Authorization: Bearer +Content-Type: application/json +X-Request-Id: program-handoff-20260922-0001 +``` + +该接口不要求 `Origin` 白名单。权限来自程序用户 JWT、目标小程序 AppID 校验和一次性 `handoffToken`。 + +### 5.4 请求体 + +```json +{ + "handoffToken": "<接口一返回的一次性凭证>", + "requestId": "program-handoff-20260922-0001" +} +``` + +| 字段 | 类型 | 必填 | 约束与说明 | +| --- | --- | --- | --- | +| `handoffToken` | string | 是 | 接口一返回的一次性凭证,最长 256 个字符;不要自行修改或解码 | +| `requestId` | string | 是 | 本次绑定的幂等请求号,最长 64 个字符;网络超时重试时保持不变 | + +### 5.5 curl 示例 + +```bash +export PROGRAM_ACCESS_TOKEN="<4G 小程序登录返回的 accessToken>" +export HANDOFF_TOKEN="<接口一返回的 handoffToken>" + +curl -i -sS -X POST "$BASE_URL/api/v1/program/bindings/handoff" \ + -H "Authorization: Bearer $PROGRAM_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -H "X-Request-Id: program-handoff-20260922-0001" \ + --data "{\"handoffToken\":\"$HANDOFF_TOKEN\",\"requestId\":\"program-handoff-20260922-0001\"}" +``` + +### 5.6 成功响应 + +HTTP 状态码为 `200`: + +```json +{ + "code": "OK", + "message": "success", + "data": { + "iccid": "898604E91123D1334901", + "alreadyBound": false + }, + "requestId": "program-handoff-20260922-0001", + "timestamp": "2026-09-22T03:32:00Z" +} +``` + +| 字段 | 说明 | +| --- | --- | +| `data.iccid` | 实际绑定成功的设备 ICCID | +| `data.alreadyBound` | `false` 表示本次新建绑定;`true` 表示重复请求返回已有绑定结果 | + +绑定、作废旧验证码、更新 SIM 绑定状态和审计记录在同一个数据库事务中完成。接口成功后,可以调用 `GET /api/v1/program/devices` 验证当前用户的设备列表。 + +## 6. 幂等与重试规则 + +### 6.1 申请凭证重试 + +Banban 后端申请接口超时或未收到响应时,使用相同的 `sourceBindingId` 和 `requestId` 重试,不要重新生成请求号。这样可以复用原有有效凭证,避免创建多条 handoff 记录。 + +### 6.2 消费凭证重试 + +4G 小程序消费接口超时或网络断开时,使用相同的 `handoffToken` 和 `requestId` 重试: + +- 如果事务已经成功提交,通常返回 `alreadyBound=true`; +- 如果凭证已被其他用户消费,返回 `DEVICE_ALREADY_BOUND` 或 `HANDOFF_TOKEN_USED`; +- 不要因为超时就重新向 Banban 申请新的 `sourceBindingId`,应先使用原请求参数重试或查询绑定结果。 + +### 6.3 凭证安全规则 + +- `handoffToken` 只能在服务端申请、在 4G 小程序绑定流程中消费一次; +- 不要把 `BANBAN_HANDOFF_SERVICE_TOKEN` 放在小程序、浏览器代码、页面参数或日志中; +- 不要把完整 `handoffToken` 写入业务日志、埋点或群聊; +- `handoffToken` 过期、篡改或消费后不可恢复,需按原绑定关系重新申请。 + +## 7. 错误码与处理建议 + +统一错误响应结构如下,`data` 为 `null`: + +```json +{ + "code": "HANDOFF_TOKEN_EXPIRED", + "message": "跨系统绑定凭证已过期", + "data": null, + "requestId": "program-handoff-20260922-0001", + "timestamp": "2026-09-22T03:40:00Z" +} +``` + +### 7.1 申请凭证接口 + +| HTTP 状态 | `code` | 含义与处理 | +| --- | --- | --- | +| 400 | `INVALID_REQUEST` | ICCID、`sourceBindingId` 或 `requestId` 格式不符合约束 | +| 401 | `HANDOFF_SERVICE_UNAUTHORIZED` | 缺少或错误的 `X-Service-Token`;确认服务端密钥配置一致 | +| 403 | `DEVICE_DISABLED` | 设备状态不是 `active`;先恢复设备状态 | +| 409 | `IDEMPOTENCY_CONFLICT` | 相同幂等键对应了不同 ICCID;检查 `sourceBindingId` 和 `requestId` | +| 409 | `HANDOFF_TOKEN_USED` | 原凭证已经消费;按当前绑定结果处理或重新发起新业务请求 | +| 410 | `HANDOFF_TOKEN_EXPIRED` | 原凭证已过期;使用相同业务关系重新申请凭证 | +| 429 | `RATE_LIMITED` | 触发申请频率限制;降低重试频率 | +| 503 | `HANDOFF_DISABLED` | 后端未启用 handoff 流程;检查 `HANDOFF_ENABLED` | +| 503 | `CONFIGURATION_ERROR` | handoff 密钥、4G AppID 或 TTL 配置缺失/不合法 | +| 500 | `INTERNAL_ERROR` | 自动建档后无法读取 SIM 档案;需要联系后端排查数据库或事务状态 | + +### 7.2 消费凭证接口 + +| HTTP 状态 | `code` | 含义与处理 | +| --- | --- | --- | +| 400 | `INVALID_REQUEST` | 请求体字段缺失或超过长度限制 | +| 400 | `HANDOFF_TOKEN_INVALID` | 凭证不存在、被篡改或已失效 | +| 401 | `UNAUTHORIZED` | 未登录、Token 过期或 Token 不是程序用户 Token | +| 403 | `HANDOFF_TARGET_APP_MISMATCH` | 当前登录的小程序不是接口一返回的 `targetAppId` | +| 403 | `DEVICE_DISABLED` | 设备当前被禁用 | +| 409 | `HANDOFF_TOKEN_USED` | 凭证已使用且无法匹配当前用户的重试 | +| 409 | `DEVICE_ALREADY_BOUND` | 设备已经绑定其他程序用户 | +| 409 | `IDEMPOTENCY_CONFLICT` | 相同 `requestId` 对应了不同设备或不同绑定操作 | +| 410 | `HANDOFF_TOKEN_EXPIRED` | 凭证已经过期 | +| 429 | `RATE_LIMITED` | 用户、IP 或凭证消费频率过高 | +| 503 | `HANDOFF_DISABLED` | 后端未启用 handoff 流程 | + +## 8. 兼容接口:裸 ICCID 直接绑定(逐步下线) + +旧接口地址: + +```http +POST /api/v1/program/bindings/by-iccid +``` + +该接口要求: + +- 使用 `PROGRAM_USER` 的 `Authorization: Bearer` Token; +- 携带与页面完全一致的 `Origin`; +- `Origin` 必须存在于 `BINDING_ALLOWED_ORIGINS`; +- 后端配置 `DIRECT_ICCID_BINDING_ENABLED=true`。 + +当前环境已配置: + +```bash +DIRECT_ICCID_BINDING_ENABLED=false +``` + +因此当前调用该接口会返回 HTTP `410` 和 `DIRECT_BINDING_DISABLED`。只有临时兼容旧版本联调时,才允许在服务端显式开启: + +```bash +DIRECT_ICCID_BINDING_ENABLED=true +BINDING_ALLOWED_ORIGINS=https://banban.api.talkingq.com,http://192.168.101.151 +``` + +旧接口请求示例: + +```http +POST /api/v1/program/bindings/by-iccid +Origin: https://banban.api.talkingq.com +Authorization: Bearer +Content-Type: application/json +X-Request-Id: direct-bind-20260922-0001 +``` + +```json +{ + "iccid": "898604E91123D1334901", + "requestId": "direct-bind-20260922-0001" +} +``` + +该接口无法证明 Banban 后端实际拥有该 ICCID,不应作为新的跨系统正式方案。新接入方必须使用本文档第 4、5 节的 handoffToken 流程。 + +## 9. 联调验收清单 + +建议按以下顺序验收: + +1. 使用尚未登记的测试 ICCID,申请接口返回 `code=OK`、`targetAppId`、`handoffToken` 和正数 `expiresIn`。 +2. 查询数据库,确认该 ICCID 已自动写入 `sim_card`,初始为 `status=active`、`binding_status=unbound`、`sync_status=pending`。 +3. 使用 4G 小程序的 `wx.login()` code 登录,确认返回 `PROGRAM_USER` 的 `accessToken`。 +4. 使用该 `accessToken` 消费 handoffToken,返回 `code=OK`、正确 ICCID、`alreadyBound=false`。 +5. 查询 `GET /api/v1/program/devices`,确认设备出现在当前 4G 小程序用户列表中。 +6. 使用相同 `sourceBindingId + requestId` 重试申请接口,确认不会重复创建 SIM 档案或凭证。 +7. 使用相同用户、相同 handoffToken 和相同绑定 `requestId` 重试消费接口,确认返回幂等结果或可确认已绑定。 +8. 绑定成功后调用流量详情接口,确认设备可以进入 `pending` 或同步后的流量状态,而不是 `DEVICE_NOT_FOUND`。 +9. 使用其他小程序身份消费,确认返回 `HANDOFF_TARGET_APP_MISMATCH`。 +10. 使用过期或篡改后的凭证,确认分别返回 `HANDOFF_TOKEN_EXPIRED` 或 `HANDOFF_TOKEN_INVALID`。 +11. 使用已绑定到其他用户的设备,确认返回 `DEVICE_ALREADY_BOUND`,不会覆盖原绑定关系。 +12. 确认数据库中该设备的 `sim_card.binding_status` 为 `bound`,且 `user_device_binding.status` 为 `active`。 +13. 确认旧 `/bindings/by-iccid` 接口在 `DIRECT_ICCID_BINDING_ENABLED=false` 时返回 `DIRECT_BINDING_DISABLED`。 + +## 10. 联调排查信息 + +请保留以下信息用于后端排查,但不要提交 Token 或完整 ICCID: + +- 请求路径和 HTTP 状态码; +- 响应中的 `code`、`message`、`requestId`; +- 请求头中的 `X-Request-Id`; +- Banban 的 `sourceBindingId` 和脱敏后的 ICCID; +- 调用时间(含时区); +- 是否使用了相同幂等参数重试。 + +不要在工单、群聊、代码仓库或日志中粘贴 `X-Service-Token`、`PROGRAM_USER_ACCESS_TOKEN` 或完整 `handoffToken`。 diff --git a/docs/设备直接绑定接口联调文档.md b/docs/设备直接绑定接口联调文档.md new file mode 100644 index 0000000..0a63672 --- /dev/null +++ b/docs/设备直接绑定接口联调文档.md @@ -0,0 +1,265 @@ +# 设备直接绑定接口联调文档 + +## 1. 接口说明 + +该接口用于设备无法正常获取绑定验证码时,由白名单来源的业务页面发起直接绑定。 + +接口完成的业务效果与验证码绑定一致: + +- 创建当前程序用户与设备的有效绑定关系; +- 将设备绑定状态更新为 `bound`; +- 作废该设备尚未使用的绑定验证码; +- 清理绑定验证码缓存; +- 写入绑定审计记录。 + +接口不会自动创建不存在的 ICCID。调用前,设备必须已经登记在后端 `sim_card` 表中,并且设备状态为 `active`。 + +## 2. 接口地址 + +```http +POST /api/v1/program/bindings/by-iccid +``` + +生产环境示例: + +```text +https:///api/v1/program/bindings/by-iccid +``` + +将 `` 替换为实际 4G 管理后端域名。 + +## 3. 调用前提 + +### 3.1 程序用户登录 + +调用方必须先通过程序用户登录接口获取 `PROGRAM_USER` 的 `accessToken`: + +```http +POST /api/v1/program/auth/login +``` + +后续请求携带: + +```http +Authorization: Bearer +``` + +不能使用 `SUPER_ADMIN`、`FACTORY_PROVISIONER` 或设备 Token 调用此接口。 + +### 3.2 来源白名单 + +当前默认允许以下 Origin: + +```text +https://banban.api.talkingq.com +http://192.168.101.151 +``` + +请求必须携带与页面实际来源完全一致的 `Origin` 请求头: + +```http +Origin: https://banban.api.talkingq.com +``` + +或: + +```http +Origin: http://192.168.101.151 +``` + +白名单由环境变量配置: + +```bash +BINDING_ALLOWED_ORIGINS=https://banban.api.talkingq.com,http://192.168.101.151 +``` + +多个来源使用英文逗号分隔。来源匹配包含协议、主机和端口,不能只填写裸域名或裸 IP。 + +例如,下面这些值不是同一个 Origin: + +```text +http://192.168.101.151 +https://192.168.101.151 +http://192.168.101.151:8080 +``` + +如果页面实际通过 HTTPS 或非默认端口访问,需要把实际 Origin 加入 `BINDING_ALLOWED_ORIGINS` 后重启后端。 + +## 4. 请求格式 + +### 4.1 请求头 + +```http +Origin: https://banban.api.talkingq.com +Authorization: Bearer +Content-Type: application/json +X-Request-Id: direct-bind-20260920-0001 +``` + +`Origin` 是来源白名单校验字段;`Authorization` 是业务身份认证字段;`X-Request-Id` 用于日志追踪,建议每次请求使用唯一值。 + +### 4.2 请求体 + +```json +{ + "iccid": "898604E91123D1334901", + "requestId": "direct-bind-20260920-0001" +} +``` + +字段说明: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `iccid` | string | 是 | 设备 ICCID,18 至 22 位字母或数字,后端会统一转为大写 | +| `requestId` | string | 是 | 幂等请求号,最长 64 个字符;同一用户重试必须使用相同值 | + +## 5. curl 联调示例 + +请将 Token 和 ICCID 替换为真实值,不要把 Token 提交到代码仓库或群聊。 + +```bash +curl -i -X POST \ + "https:///api/v1/program/bindings/by-iccid" \ + -H "Origin: https://banban.api.talkingq.com" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -H "X-Request-Id: direct-bind-20260920-0001" \ + --data '{ + "iccid": "898604E91123D1334901", + "requestId": "direct-bind-20260920-0001" + }' +``` + +使用 IP 来源联调时,将 `Origin` 改为: + +```http +Origin: http://192.168.101.151 +``` + +## 6. 成功响应 + +```json +{ + "code": "OK", + "message": "success", + "data": { + "iccid": "898604E91123D1334901", + "bindingStatus": "active", + "idempotent": false, + "boundAt": "2026-09-20T15:30:00" + }, + "requestId": "http-request-id", + "timestamp": "2026-09-20T07:30:00Z" +} +``` + +字段说明: + +| 字段 | 说明 | +| --- | --- | +| `data.iccid` | 实际完成绑定的 ICCID | +| `data.bindingStatus` | 成功时为 `active`,表示用户绑定关系有效 | +| `data.idempotent` | `false` 表示本次新建绑定,`true` 表示重复请求已返回原绑定结果 | +| `data.boundAt` | 绑定时间 | + +## 7. 幂等重试规则 + +请求超时或网络断开时,使用同一个 `requestId` 重试,不要生成新的请求号: + +```json +{ + "iccid": "898604E91123D1334901", + "requestId": "direct-bind-20260920-0001" +} +``` + +同一程序用户、同一 ICCID、同一 `requestId` 重试时,接口返回 `idempotent=true`。 + +同一 `requestId` 改用其他 ICCID 时,接口返回 `IDEMPOTENCY_CONFLICT`。 + +## 8. 常见失败响应 + +### 8.1 来源不在白名单 + +```http +HTTP/1.1 403 Forbidden +``` + +可能原因: + +- 未携带 `Origin`; +- Origin 协议不一致,例如配置了 HTTPS 但请求使用 HTTP; +- IP 使用了非默认端口; +- 后端环境变量未更新或服务未重启。 + +### 8.2 未登录或 Token 无效 + +```json +{ + "code": "UNAUTHORIZED" +} +``` + +请确认使用的是程序用户登录返回的 `accessToken`,并且 Token 尚未过期或撤销。 + +### 8.3 设备不存在 + +```json +{ + "code": "DEVICE_NOT_FOUND" +} +``` + +表示 ICCID 尚未登记到后端。该接口不会自动创建设备档案,应先通过设备接入或预置流程登记设备。 + +### 8.4 设备已被其他用户绑定 + +```json +{ + "code": "DEVICE_ALREADY_BOUND" +} +``` + +不能通过该接口覆盖其他用户的绑定关系,需要先由当前绑定用户解绑或由管理员执行解绑流程。 + +### 8.5 设备已禁用 + +```json +{ + "code": "DEVICE_DISABLED" +} +``` + +需要先恢复设备状态为 `active`。 + +### 8.6 参数错误 + +```json +{ + "code": "INVALID_REQUEST" +} +``` + +请检查 `iccid` 是否为 18 至 22 位字母数字,以及 `requestId` 是否为空或超过 64 个字符。 + +## 9. 浏览器联调注意事项 + +- 前端请求必须使用 `Authorization` 请求头携带 Token,不能只依赖 Cookie; +- 页面来源必须与白名单完全一致; +- 浏览器会先发送 CORS 预检请求,后端已经允许 `POST`、`OPTIONS` 以及 `Authorization`、`Content-Type`、`X-Request-Id` 请求头; +- 如果通过后端服务调用,HTTP 客户端通常不会自动添加 `Origin`,需要按接口要求主动设置; +- `Origin` 不是加密签名,不能替代登录 Token; +- 联调日志中可以使用响应里的 `requestId` 联系后端排查。 + +## 10. 验收检查 + +建议至少验证以下场景: + +1. 使用 `https://banban.api.talkingq.com` 来源和有效程序用户 Token,绑定一个未绑定的 active ICCID,返回 `idempotent=false`。 +2. 使用相同用户、相同 ICCID、相同 `requestId` 再次调用,返回 `idempotent=true`。 +3. 使用其他 Origin 调用,返回 HTTP 403。 +4. 不携带 `Authorization`,返回 `UNAUTHORIZED`。 +5. 使用已绑定到其他用户的 ICCID,返回 `DEVICE_ALREADY_BOUND`。 +6. 使用不存在的 ICCID,返回 `DEVICE_NOT_FOUND`。 +7. 确认数据库中 `sim_card.binding_status` 为 `bound`,并且 `user_device_binding.status` 为 `active`。 diff --git a/talkingq-url/implementations/minimax_tts.py b/talkingq-url/implementations/minimax_tts.py index a7efdd2..fa87795 100644 --- a/talkingq-url/implementations/minimax_tts.py +++ b/talkingq-url/implementations/minimax_tts.py @@ -122,7 +122,7 @@ class MiniMaxTTS(TTS, BaseService): "voice_setting": { "voice_id": self.voice_id, "speed": speed, - "vol": 2.0, + "vol": 0.7, "pitch": 0 }, "audio_setting": {