feat(binding): verify 4G relations before Banban unbind

This commit is contained in:
stu2not
2026-09-23 17:47:42 +08:00
parent e5ba92ba13
commit 515567f6d4
26 changed files with 2185 additions and 29 deletions
+2
View File
@@ -3,6 +3,7 @@ export default defineAppConfig({
"pages/device/index",
"pages/login/index",
"pages/bind/index",
"pages/traffic-binding/index",
"pages/notification/index",
"pages/chat/index",
"pages/chat/detail/index",
@@ -12,6 +13,7 @@ export default defineAppConfig({
"pages/family-invite/index",
"pages/webview/index",
],
navigateToMiniProgramAppIdList: ['wx56e1cfc52aab630f'],
window: {
backgroundTextStyle: "light",
navigationBarBackgroundColor: "#FFF9EA",
+3
View File
@@ -1,7 +1,10 @@
import './app.scss'
import { PropsWithChildren } from 'react'
import { useDidShow } from '@tarojs/taro'
import { captureTrafficReturn } from './services/traffic-binding'
function App(props: PropsWithChildren) {
useDidShow((options) => { captureTrafficReturn(options) })
return props.children
}
+5
View File
@@ -26,6 +26,7 @@ import {
validateChildNameInput,
} from '@/services/child'
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
import { trafficPageUrl } from '@/services/traffic-binding'
import './index.scss'
@@ -678,6 +679,10 @@ export default function Bind() {
<Button className='bind-result-button primary' onClick={() => Taro.reLaunch({ url: bindResultDestination })}>
{bindResultDestination.includes('/sleep/') ? '去关联宝贝' : '返回首页'}
</Button>
{!isAddingCard && <Button className='bind-result-button primary' onClick={() => Taro.navigateTo({ url: trafficPageUrl(boundDeviceId) })}>
关联流量服务
</Button>}
{!isAddingCard && <Text className='bind-result-description'>设备已绑定。流量查询和充值需另外关联,可稍后在管理页完成。</Text>}
</>
) : (
<>
+37 -11
View File
@@ -10,6 +10,7 @@ import Taro, { useDidHide, useDidShow, useShareAppMessage } from '@tarojs/taro'
import { authorizeParentPhone, clearToken, getParent, getToken, Parent } from '@/services/auth'
import { DEVICE_UNAVAILABLE_MESSAGE } from '@/services/api'
import { getCurrentUserId } from '@/services/session'
import { TrafficOverview, getTrafficOverview, trafficLinked, openTrafficPage, openTrafficPortal, flushTrafficReceipt } from '@/services/traffic-binding'
import {
BindingListItem,
BindingCardItem,
@@ -19,7 +20,6 @@ import {
loadCurrentChildBindingContext,
setSelectedBindingDeviceId,
updateBindingCardName,
unbindDevice,
} from '@/services/binding'
import {
Child,
@@ -77,6 +77,20 @@ export default function Sleep() {
const [children, setChildren] = useState<Child[]>([])
const [bindings, setBindings] = useState<BindingListItem[]>([])
const [binding, setBinding] = useState<BindingListItem | null>(null)
const [trafficOverview, setTrafficOverview] = useState<TrafficOverview | null>(null)
const [trafficBusy, setTrafficBusy] = useState(false)
const trafficLock = useRef(false)
useEffect(() => {
let cancelled = false
setTrafficOverview(null)
if (binding?.device_id) {
void flushTrafficReceipt().catch(() => null)
.then(() => getTrafficOverview(binding.device_id))
.then((value) => { if (!cancelled) setTrafficOverview(value) })
.catch(() => { /* Core device management remains available. */ })
}
return () => { cancelled = true }
}, [binding])
const [currentChild, setCurrentChild] = useState<Child | null>(null)
const [firmwareStatus, setFirmwareStatus] = useState<DeviceFirmwareStatus | null>(null)
const [showFirmwareProgress, setShowFirmwareProgress] = useState(false)
@@ -651,12 +665,7 @@ export default function Sleep() {
if (!result.confirm) return
try {
await unbindDevice(targetBinding.device_id)
if (binding?.device_id === targetBinding.device_id) {
clearSelectedBindingDeviceId()
}
Taro.showToast({ title: '已删除设备', icon: 'success' })
await loadData()
await openTrafficPage(targetBinding.device_id, 'unbind')
} catch (error: any) {
Taro.showToast({
title: error?.message || '删除设备失败,请重试',
@@ -834,10 +843,7 @@ export default function Sleep() {
if (!result.confirm) return
try {
await unbindDevice(binding.device_id)
clearSelectedBindingDeviceId()
Taro.showToast({ title: '已解绑', icon: 'success' })
await loadData()
await openTrafficPage(binding.device_id, 'unbind')
} catch (error: any) {
Taro.showToast({
title: error?.message || '解绑失败,请重试',
@@ -956,6 +962,18 @@ export default function Sleep() {
return
}
if (['关联流量服务', '继续确认流量服务', '继续解绑', '流量查询', '套餐充值'].includes(item.name)) {
if (!binding || trafficLock.current) return
trafficLock.current = true
setTrafficBusy(true)
const operation = item.name === '流量查询' || item.name === '套餐充值'
? openTrafficPortal(binding.device_id, item.name === '套餐充值' ? 'recharge' : 'device')
: openTrafficPage(binding.device_id, item.name === '继续解绑' ? 'unbind' : 'bind')
void operation.catch((error: any) => Taro.showToast({ title: error.message || error.errMsg || '操作未完成,请重试', icon: 'none' }))
.finally(() => { trafficLock.current = false; setTrafficBusy(false) })
return
}
if (item.name === '我的卡片') {
setShowCardsModal(true)
return
@@ -1129,6 +1147,14 @@ export default function Sleep() {
value: binding?.device_id ? `当前: ${binding.device_id}` : '未绑定',
arrow: true,
},
...(binding ? (trafficLinked(trafficOverview) && trafficOverview?.device_id === binding.device_id
? [
{ icon: require('../../assets/ui/menu-binding.png'), iconBgClass: 'green', name: '流量查询', value: '', arrow: true, disabled: !isFamilyOwner || trafficBusy },
{ icon: require('../../assets/ui/menu-binding.png'), iconBgClass: 'green', name: '套餐充值', value: '', arrow: true, disabled: !isFamilyOwner || trafficBusy },
]
: [{ icon: require('../../assets/ui/menu-binding.png'), iconBgClass: 'green',
name: trafficOverview?.unbind ? '继续解绑' : trafficOverview?.bind ? '继续确认流量服务' : '关联流量服务',
value: isFamilyOwner ? '不影响伴伴使用' : '仅设备归属账号可操作', arrow: true, disabled: !isFamilyOwner || trafficBusy }]) : []),
{
icon: require('../../assets/ui/menu-cards.png'),
iconBgClass: 'green',
@@ -0,0 +1 @@
export default definePageConfig({ navigationBarTitleText: '流量服务关联' })
@@ -0,0 +1,7 @@
.traffic-binding-page {
min-height: 100vh; box-sizing: border-box; padding: 72rpx 40rpx;
.title { display: block; font-size: 38rpx; font-weight: bold; margin-bottom: 30rpx; }
.description { display: block; font-size: 28rpx; line-height: 1.7; margin-bottom: 30rpx; }
.error { display: block; color: #a52a2a; margin-bottom: 24rpx; }
button { margin-top: 24rpx; }
}
@@ -0,0 +1,69 @@
import { View, Text, Button } from '@tarojs/components'
import Taro, { useDidShow, useRouter } from '@tarojs/taro'
import { useRef, useState } from 'react'
import { getToken } from '@/services/auth'
import { clearSelectedBindingDeviceId } from '@/services/binding'
import { getPageStyle } from '@/utils/page-style'
import { TrafficOperation, getTrafficOperation, resumeTrafficOperation,
flushTrafficReceipt, finishTrafficUnbind, jumpTraffic } from '@/services/traffic-binding'
import './index.scss'
export default function TrafficBindingPage() {
const { params } = useRouter()
const deviceId = String(params.device_id || '')
const action = params.action === 'unbind' ? 'unbind' : 'bind'
const [operation, setOperation] = useState<TrafficOperation | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const lock = useRef(false)
const recover = async () => {
if (lock.current) return
if (!getToken()) { Taro.navigateTo({ url: '/pages/login/index' }); return }
lock.current = true
setBusy(true)
try {
const current = await resumeTrafficOperation(deviceId, action)
setOperation(current)
if (action === 'unbind' && current.status === 'completed') clearSelectedBindingDeviceId()
setError('')
} catch (e: any) { setError(e.message || '暂时无法确认,请重试') }
finally { lock.current = false; setBusy(false) }
}
useDidShow(() => { void recover() })
const proceed = async () => {
if (!operation) { await recover(); return }
if (lock.current) return
lock.current = true
setBusy(true)
setError('')
try {
await flushTrafficReceipt()
let current = await getTrafficOperation(operation.operation_id)
if (current.status !== 'completed') {
const result = await jumpTraffic(current)
if (!result) return
current = result
if (action === 'unbind') current = await finishTrafficUnbind(current)
}
setOperation(current)
if (action === 'unbind' && current.status === 'completed') clearSelectedBindingDeviceId()
} catch (e: any) { setError(e.message || e.errMsg || '操作未完成,请继续重试') }
finally { lock.current = false; setBusy(false) }
}
const completed = operation?.status === 'completed'
const finalUnbind = action === 'unbind' && operation?.traffic_status === 'server_confirmed'
return <View className='traffic-binding-page' style={getPageStyle(true)}>
<Text className='title'>{action === 'bind' ? completed ? '流量服务已关联' : '设备已绑定,流量服务待关联' : completed ? '设备已解除绑定' : '解除设备与流量服务绑定'}</Text>
<Text className='description'>{action === 'bind'
? completed ? '可以查询流量和购买套餐了。' : '伴伴设备可以正常使用。继续前往流量管理小程序登录并关联,也可以稍后完成。'
: completed ? '设备已解绑,流量侧已确认没有本次有效绑定。' : finalUnbind ? '流量后端已确认可以解绑,点击完成伴伴解绑。' : '先确认流量侧原绑定已解除,再解除伴伴绑定;套餐和充值记录保留。继续处理会停止本次流量关联,暂不支持取消恢复。'}</Text>
{operation?.last_error === 'HANDOFF_RELATION_UNKNOWN' && <Text className='error'>流量后端无法确认原绑定记录,伴伴绑定仍保留,请联系服务人员核查。</Text>}
{operation?.last_error === 'TRAFFIC_UNBIND_REQUIRED' && <Text className='description'>原流量绑定仍有效,请继续前往流量管理小程序解绑。</Text>}
{operation?.status === 'partial_failed' && <Text className='description'>上次流量操作未完成,可以继续重试。</Text>}
{error && <Text className='error'>{error}</Text>}
{!completed && <Button disabled={busy} onClick={proceed}>{busy ? '处理中…' : finalUnbind ? '完成伴伴解绑' : '继续处理'}</Button>}
<Button disabled={busy} onClick={() => Taro.switchTab({ url: '/pages/sleep/index' })}>{completed ? '返回管理' : '稍后处理'}</Button>
</View>
}
+13 -4
View File
@@ -12,7 +12,7 @@ const ERROR_DETAIL_MAP: Record<string, string> = {
}
class ApiError extends Error {
constructor(public status: number, message: string) {
constructor(public status: number, message: string, public code?: string) {
super(message)
}
}
@@ -21,6 +21,7 @@ function getErrorDetail(data: any): string {
if (!data) return ERROR_DETAIL_MAP['Request failed']
if (typeof data.errMsg === 'string') return data.errMsg
if (typeof data.detail === 'string') return ERROR_DETAIL_MAP[data.detail] || data.detail
if (typeof data.detail?.message === 'string') return data.detail.message
if (Array.isArray(data.detail)) {
return data.detail.map((item: any) => String(item?.msg || item)).join('; ')
}
@@ -48,25 +49,33 @@ async function request<T>(
}
let response
let timer: ReturnType<typeof setTimeout> | undefined
try {
response = await Taro.request({
const task = Taro.request({
url: requestUrl,
method: requestMethod,
data: options.data,
header: headers,
timeout: REQUEST_TIMEOUT_MS,
})
response = await Promise.race([task, new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new Error('请求超时,请重试'))
task.abort?.()
}, REQUEST_TIMEOUT_MS)
})])
} catch (error: any) {
const message = String(error?.errMsg || error?.message || 'request:fail')
throw new ApiError(0, message)
}
} finally { if (timer) clearTimeout(timer) }
if (response.statusCode === 401) {
handleUnauthorized({ redirect: !url.startsWith('/banban/auth/') })
}
if (response.statusCode >= 400) {
throw new ApiError(response.statusCode, getErrorDetail(response.data))
const body = response.data as any
throw new ApiError(response.statusCode, getErrorDetail(body), body?.detail?.code || body?.code)
}
return response.data as T
+157
View File
@@ -0,0 +1,157 @@
import Taro from '@tarojs/taro'
import { request } from './api'
import { getCurrentUserId } from './session'
export const TRAFFIC_APP_ID = 'wx56e1cfc52aab630f'
export type TrafficAction = 'bind' | 'unbind'
export interface TrafficOperation {
operation_id: string
device_id: string
action: TrafficAction
status: 'pending' | 'partial_failed' | 'completed'
traffic_status: 'pending' | 'client_confirmed' | 'server_confirmed'
last_error?: string | null
}
export interface TrafficOverview {
device_id: string
binding_instance_id: string
bind: TrafficOperation | null
unbind: TrafficOperation | null
}
const key = () => `trafficBinding:${getCurrentUserId()}`
type Pending = { operation: TrafficOperation; receipt?: any }
export function getPendingTraffic(): Pending | null {
if (!getCurrentUserId()) return null
const value = Taro.getStorageSync(key())
return value?.operation?.operation_id ? value : null
}
export function rememberOperation(operation: TrafficOperation) {
if (!getCurrentUserId()) return
const previous = getPendingTraffic()
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 data = options.referrerInfo.extraData
const pending = getPendingTraffic()
if (!pending || !data || data.operationId !== pending.operation.operation_id
|| data.action !== pending.operation.action || data.requestId !== `joint-${data.operationId}`
|| typeof data.success !== 'boolean' || !/^[A-Za-z0-9_]{1,64}$/.test(data.code || '')) return false
if (data.success && (data.code !== 'OK' || !/^[A-Z0-9]{18,22}$/.test(data.iccid || ''))) return false
// Only store the bounded receipt, never another app's tokens or arbitrary extraData.
Taro.setStorageSync(key(), { operation: pending.operation, receipt: {
action: data.action, request_id: data.requestId, success: data.success, code: data.code,
iccid: data.success ? data.iccid : null,
} })
return true
}
export function trafficLinked(overview: TrafficOverview | null): boolean {
return Boolean(overview?.bind?.status === 'completed' && overview.bind.traffic_status === 'server_confirmed' && !overview?.unbind)
}
export async function getTrafficOverview(deviceId: string) {
const user = getCurrentUserId()
const overview = await request<TrafficOverview>(`/banban/joint-bindings/devices/${encodeURIComponent(deviceId)}`)
assertSameUser(user)
if (overview.bind && !overview.unbind) overview.bind = await checkTrafficOperation(overview.bind.operation_id)
return overview
}
export function getTrafficOperation(operationId: string) {
return request<TrafficOperation>(`/banban/joint-bindings/${encodeURIComponent(operationId)}`)
}
export async function checkTrafficOperation(operationId: string) {
const user = getCurrentUserId()
const operation = await request<TrafficOperation>(`/banban/joint-bindings/${encodeURIComponent(operationId)}/check`, { method: 'POST' })
assertSameUser(user)
return operation
}
export async function startTrafficOperation(deviceId: string, action: TrafficAction) {
const user = getCurrentUserId()
const operation = await request<TrafficOperation>('/banban/joint-bindings', { method: 'POST', data: { device_id: deviceId, action } })
assertSameUser(user)
rememberOperation(operation)
return operation
}
export async function flushTrafficReceipt(): Promise<TrafficOperation | null> {
const pending = getPendingTraffic()
if (!pending?.receipt) return null
const user = getCurrentUserId()
let result: TrafficOperation
try {
result = await request<TrafficOperation>(`/banban/joint-bindings/${pending.operation.operation_id}/result`, {
method: 'POST', data: pending.receipt,
})
} catch (error: any) {
assertSameUser(user)
if (!['STALE_OPERATION', 'OPERATION_NOT_FOUND'].includes(error.code)) throw error
// A previous binding instance must not prevent this device from being bound again.
if (getPendingTraffic()?.operation.operation_id === pending.operation.operation_id) Taro.setStorageSync(key(), null)
return null
}
assertSameUser(user)
if (getPendingTraffic()?.operation.operation_id === result.operation_id) Taro.setStorageSync(key(), { operation: result })
return result
}
export async function resumeTrafficOperation(deviceId: string, action: TrafficAction) {
await flushTrafficReceipt()
const pending = getPendingTraffic()
if (pending?.operation.device_id === deviceId && pending.operation.action === action) {
try { return await checkTrafficOperation(pending.operation.operation_id) }
catch (error: any) {
if (!['STALE_OPERATION', 'OPERATION_NOT_FOUND'].includes(error.code)) throw error
}
}
return startTrafficOperation(deviceId, action)
}
export async function finishTrafficUnbind(operation: TrafficOperation) {
const user = getCurrentUserId()
const result = await request<TrafficOperation>(`/banban/joint-bindings/${operation.operation_id}/finish-unbind`, { method: 'POST' })
assertSameUser(user)
rememberOperation(result)
return result
}
function targetVersion(): 'trial' | 'release' {
return Taro.getAccountInfoSync().miniProgram.envVersion === 'release' ? 'release' : 'trial'
}
function assertSameUser(user: number) {
if (!user || getCurrentUserId() !== user) throw new Error('账号已变化,请重新进入')
}
async function navigate(options: any, withinApp = false) {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([withinApp ? Taro.navigateTo(options) : Taro.navigateToMiniProgram(options), new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error('跳转等待已结束,请重新进入确认')), 15000)
})])
} finally { if (timer) clearTimeout(timer) }
}
export async function jumpTraffic(operation: TrafficOperation) {
const user = getCurrentUserId()
const data = await request<any>(`/banban/joint-bindings/${operation.operation_id}/prepare`, { method: 'POST' })
assertSameUser(user)
rememberOperation(data.operation)
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('目标小程序配置不一致')
await navigate({
appId: TRAFFIC_APP_ID, path: 'pages/handoff/index', envVersion: targetVersion(),
extraData: { operationId: operation.operation_id, action: operation.action,
requestId: data.request_id, sourceBindingId: data.source_binding_id,
handoffToken: data.handoff_token, iccid: data.iccid, deviceId: operation.device_id,
recoveryOnly: data.recovery_only === true },
})
return null
}
export async function openTrafficPortal(deviceId: string, destination: 'device' | 'recharge') {
const user = getCurrentUserId()
const data = await request<any>(`/banban/joint-bindings/devices/${encodeURIComponent(deviceId)}/portal`)
assertSameUser(user)
if (data.target_app_id !== TRAFFIC_APP_ID) throw new Error('目标小程序配置不一致')
await navigate({ appId: TRAFFIC_APP_ID, path: 'pages/handoff/index', envVersion: targetVersion(),
extraData: { action: 'view', destination, iccid: data.iccid, deviceId,
sourceBindingId: data.source_binding_id } })
}
export function trafficPageUrl(deviceId: string, action: TrafficAction = 'bind') {
return `/pages/traffic-binding/index?device_id=${encodeURIComponent(deviceId)}&action=${action}`
}
export function openTrafficPage(deviceId: string, action: TrafficAction = 'bind') {
return navigate({ url: trafficPageUrl(deviceId, action) }, true)
}