feat(auth): 游客浏览模式 + needUserInfo 授权链路修复
- PUBLIC_PAGES 放开四个 tab 页(微信审核要求不强制授权),游客可浏览, 首页/事件/设备页未登录展示引导登录空态不再强制踢登录;mine 游客态 菜单收起只留登录入口;守卫单测同步 - needUserInfo 实测首登 Content 为 null:openId 改为可缺省的加速字段, 授权重调靠新 code 即可,登录页不再因缺 openId 阻断;补缺省分支单测 - http 层首登 Content 为 null 时先初始化再挂 message/token 字段 - 生产网关域名替换占位符(aiotcloud.celex-technology.com)
This commit is contained in:
parent
da86294fde
commit
42b6ceb69c
|
|
@ -9,7 +9,7 @@ import { ApiError, BusinessError, AuthExpiredError } from './errors'
|
|||
// 网关分服务前缀:登录走 user 服务,其余业务接口走 biz 服务(后端约定 2026-09-17)
|
||||
const GATEWAY = process.env.NODE_ENV === 'development'
|
||||
? 'http://192.168.10.11:39101'
|
||||
: 'https://PROD_PLACEHOLDER' // 正式域名占位符,上线前必须替换(spec D2)
|
||||
: 'https://aiotcloud.celex-technology.com' // 线上网关域名(2026-09-22 接入)
|
||||
const USER_BASE = `${GATEWAY}/api/user`
|
||||
const BIZ_BASE = `${GATEWAY}/api/biz`
|
||||
|
||||
|
|
@ -122,13 +122,16 @@ export async function request<T>(
|
|||
throw new BusinessError(body.Message || '业务错误', bizCode)
|
||||
}
|
||||
// 200 成功内的子状态(Message 携带,如 needUserInfo/noMobile),附到返回值供调用方分支处理
|
||||
// 首登场景 Content 为 null(仅返回绑定凭证),需先初始化才能挂字段
|
||||
const subState = body.Message
|
||||
if (subState && subState !== '') {
|
||||
if (!body.Content) body.Content = {} as T & { message?: string }
|
||||
;(body.Content as Record<string, unknown>).message = subState
|
||||
}
|
||||
// 后端把登录 token 放响应头 Token(仅登录接口返回;Content.code 恒为 null)——附到返回值供登录流程落地
|
||||
// 后端把登录 token 放响应头 Token(仅登录接口返回)——附到返回值供登录流程落地
|
||||
const resToken = pickResponseToken(res.header)
|
||||
if (resToken) {
|
||||
if (!body.Content) body.Content = {} as T & { message?: string }
|
||||
;(body.Content as Record<string, unknown>).token = resToken
|
||||
}
|
||||
return body.Content
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { Navbar } from '@/components/navbar'
|
|||
import { Loading } from '@/components/loading'
|
||||
import { StateView } from '@/components/state-view'
|
||||
import { BottomSheet } from '@/components/bottom-sheet'
|
||||
import { store } from '@/store'
|
||||
import { storage } from '@/utils/storage'
|
||||
import { getHomeDeviceList, type HomeDevice } from '@/api/homeDevice'
|
||||
import { getRoomList, type HomeRoom } from '@/api/homeRoom'
|
||||
import { getHomeList, type HomeInfo } from '@/api/home'
|
||||
|
|
@ -47,6 +49,11 @@ export default function DeviceList() {
|
|||
const [rooms, setRooms] = useState<HomeRoom[]>([])
|
||||
const [filterRoomId, setFilterRoomId] = useState('')
|
||||
|
||||
// 游客浏览:未登录不拉数据,展示引导登录(微信审核要求不强制授权)
|
||||
const isGuest = () =>
|
||||
!store.getState().auth.token && !storage.get<string>('auth_token')
|
||||
const isGuestEmpty = isGuest()
|
||||
|
||||
// 设备列表随家庭 + 房间筛选联动
|
||||
const loadDevices = useCallback(async (homeId?: string, roomId?: string) => {
|
||||
const targetHomeId = homeId ?? currentFamily?.homeId
|
||||
|
|
@ -109,6 +116,12 @@ export default function DeviceList() {
|
|||
}
|
||||
|
||||
useDidShow(() => {
|
||||
if (isGuest()) {
|
||||
setDeviceList([])
|
||||
setLoading(false)
|
||||
setLoadError(false)
|
||||
return
|
||||
}
|
||||
loadDevices(undefined, filterRoomId)
|
||||
loadFamilies()
|
||||
loadRooms()
|
||||
|
|
@ -124,6 +137,7 @@ export default function DeviceList() {
|
|||
}
|
||||
const goAdd = () => navigateTo(ROUTES.DEVICE_ADD)
|
||||
const goRoom = () => navigateTo(ROUTES.SCENE_SELECT)
|
||||
const goLogin = () => navigateTo(ROUTES.LOGIN)
|
||||
|
||||
const openFamilySheet = () => {
|
||||
setPendingFamily(currentFamily)
|
||||
|
|
@ -213,7 +227,7 @@ export default function DeviceList() {
|
|||
)}
|
||||
|
||||
{/* 「我的设备」分区标题:数量随筛选联动 */}
|
||||
{!loading && !loadError && (
|
||||
{!loading && !loadError && !isGuestEmpty && (
|
||||
<View className={styles.sectionTitle}>
|
||||
<Text>我的设备</Text>
|
||||
<Text className={styles.sectionCount}>{deviceList.length}</Text>
|
||||
|
|
@ -223,6 +237,8 @@ export default function DeviceList() {
|
|||
{/* 设备网格 */}
|
||||
{loading ? (
|
||||
<View className={styles.stateWrap}><Loading /></View>
|
||||
) : isGuestEmpty ? (
|
||||
<StateView status="error" mode="list" text="登录后查看你的设备" retryText="去登录" onRetry={goLogin} />
|
||||
) : loadError ? (
|
||||
<StateView status="error" onRetry={() => loadDevices()} />
|
||||
) : deviceList.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { useDidShow } from '@tarojs/taro'
|
|||
import { View, Text } from '@tarojs/components'
|
||||
import { Navbar } from '@/components/navbar'
|
||||
import { Empty } from '@/components/empty'
|
||||
import { store } from '@/store'
|
||||
import { storage } from '@/utils/storage'
|
||||
import { getHomeList, type HomeInfo } from '@/api/home'
|
||||
import { resolveCurrentHome } from '@/store/currentHome'
|
||||
import { getRoomList, type HomeRoom } from '@/api/homeRoom'
|
||||
|
|
@ -48,8 +50,15 @@ export default function Event() {
|
|||
]
|
||||
|
||||
// 当前家庭 + 其房间列表:与智能设备页共享选择(resolveCurrentHome 读持久化),
|
||||
// 失败时保留"全部"选项不阻塞筛选
|
||||
// 失败时保留"全部"选项不阻塞筛选;游客(未登录)跳过全部请求
|
||||
const [guest, setGuest] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!store.getState().auth.token && !storage.get<string>('auth_token')) {
|
||||
setGuest(true)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
getHomeList()
|
||||
.then(async (list) => {
|
||||
const first = resolveCurrentHome(list)
|
||||
|
|
@ -62,6 +71,7 @@ export default function Event() {
|
|||
}, [])
|
||||
|
||||
useDidShow(() => {
|
||||
if (guest) return
|
||||
// 进页首拉:home 就绪与否都调一次,home 为空时后端会报错并保持空态
|
||||
if (home) loadAlarms(filters)
|
||||
})
|
||||
|
|
@ -176,7 +186,16 @@ export default function Event() {
|
|||
{/* 数据层:抽屉锚点(筛选栏下缘展开,遮罩盖住本层全部) */}
|
||||
<View className={styles.contentArea}>
|
||||
<View className={styles.contentBody}>
|
||||
{alarmList.length === 0 ? (
|
||||
{guest ? (
|
||||
<>
|
||||
<View className={styles.emptyArea}>
|
||||
<Empty text="登录后查看告警消息" mode="message" />
|
||||
</View>
|
||||
<View className={styles.emptyHint}>
|
||||
<Text>浏览功能无需登录,查看家人告警时再登录</Text>
|
||||
</View>
|
||||
</>
|
||||
) : alarmList.length === 0 ? (
|
||||
<>
|
||||
<View className={styles.emptyArea}>
|
||||
<Empty text="暂无告警数据" mode="message" />
|
||||
|
|
|
|||
|
|
@ -12,6 +12,28 @@
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
// 游客浏览引导登录按钮(微信审核要求:不强制授权,用户自行选择)
|
||||
.guestLoginBtn {
|
||||
margin-top: 32px;
|
||||
padding: 0 64px;
|
||||
height: 80px;
|
||||
background: $color-primary-grad;
|
||||
border-radius: $radius-pill;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.guestLoginBtnPressed {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.guestLoginText {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
// ===== 家庭圈入口:渐变横幅卡 =====
|
||||
.familyEntry {
|
||||
margin: 24px;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { getAlarmList, type HomeAlarm } from '@/api/homeAlarm'
|
|||
import { store } from '@/store'
|
||||
import { storage } from '@/utils/storage'
|
||||
import { ROUTES } from '@/constants/routes'
|
||||
import { navigateTo, reLaunch } from '@/utils/navigation'
|
||||
import { navigateTo } from '@/utils/navigation'
|
||||
import { splitAlerts } from './splitAlerts'
|
||||
import styles from './index.module.scss'
|
||||
|
||||
|
|
@ -19,6 +19,8 @@ export default function Index() {
|
|||
const [alertList, setAlertList] = useState<HomeAlarm[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState(false)
|
||||
// 游客浏览标记:未登录不拉数据、不弹错误,展示引导登录空态
|
||||
const [guest, setGuest] = useState(false)
|
||||
|
||||
// 每次进入页面重拉默认家庭的全部告警(与事件页"全部"筛选同参数)
|
||||
const loadAlerts = useCallback(async () => {
|
||||
|
|
@ -41,12 +43,16 @@ export default function Index() {
|
|||
}
|
||||
}, [])
|
||||
|
||||
// 冷启动直达兜底:小程序入口页不受 addInterceptor 保护,未登录时踢到登录页
|
||||
// 每次进入页面重拉当前家庭告警;未登录(游客浏览)展示引导登录空态,不强制跳登录
|
||||
useDidShow(() => {
|
||||
if (!store.getState().auth.token && !storage.get<string>('auth_token')) {
|
||||
reLaunch(ROUTES.LOGIN)
|
||||
setAlertList([])
|
||||
setLoading(false)
|
||||
setLoadError(false)
|
||||
setGuest(true)
|
||||
return
|
||||
}
|
||||
setGuest(false)
|
||||
loadAlerts()
|
||||
})
|
||||
|
||||
|
|
@ -61,6 +67,13 @@ export default function Index() {
|
|||
|
||||
{loading ? (
|
||||
<View className={styles.stateWrap}><Loading /></View>
|
||||
) : guest ? (
|
||||
<View className={styles.stateWrap}>
|
||||
<Empty text="登录后查看家人动态" mode="message" subText="浏览功能无需登录,需要查看告警与设备时再登录" />
|
||||
<View className={styles.guestLoginBtn} hoverClass={styles.guestLoginBtnPressed} onClick={() => navigateTo(ROUTES.LOGIN)}>
|
||||
<Text className={styles.guestLoginText}>去登录</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : loadError ? (
|
||||
<StateView status="error" onRetry={loadAlerts} />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -55,11 +55,9 @@ export default function Login() {
|
|||
// 手机号授权暂缓(后端字段未定):提示并保持当前态,等后端确认后接续
|
||||
throw new Error('后端要求绑定手机号,该流程暂未开通')
|
||||
}
|
||||
// openId 兜底链:响应 openId → 本地缓存(needUserInfo 首次已写入)→ 当前 state
|
||||
const openIdNext =
|
||||
result.openId ?? storage.get<string>(WX_OPENID_STORAGE_KEY) ?? openId
|
||||
if (!openIdNext) throw new Error('响应缺少 openId,无法继续授权')
|
||||
setOpenId(openIdNext)
|
||||
// needUserInfo 实测 Content 为 null(无 openId),授权重调靠新 code 即可,
|
||||
// openId 仅作有则传的加速字段,缺失不再阻断
|
||||
setOpenId(result.openId ?? storage.get<string>(WX_OPENID_STORAGE_KEY) ?? '')
|
||||
setNeedAuth(true)
|
||||
setLoading(false)
|
||||
return
|
||||
|
|
@ -85,14 +83,15 @@ export default function Login() {
|
|||
}
|
||||
}
|
||||
|
||||
// needUserInfo:getUserInfo 授权用户资料(后端文档约定的授权接口)
|
||||
// needUserInfo 二次授权:wx.getUserInfo 拿 iv/encryptedData(后端文档约定,
|
||||
// getUserProfile 的资料后端不认;getUserInfo 不受 tap 上下文限制,授权弹窗由微信拉起)
|
||||
const handleAuthUserInfo = async () => {
|
||||
if (loading || !openId) return
|
||||
if (loading) return
|
||||
setLoading(true)
|
||||
try {
|
||||
applyResult(await completeUserInfoLogin(openId))
|
||||
applyResult(await completeUserInfoLogin(openId || undefined))
|
||||
} catch (e) {
|
||||
// 授权失败/取消:保留待授权态,用户可重试
|
||||
// 授权/登录失败:保留待授权态,用户可重试
|
||||
console.error('[login-page] completeUserInfoLogin 失败', e)
|
||||
toast.error(e instanceof Error && e.message ? e.message : '授权失败')
|
||||
setLoading(false)
|
||||
|
|
|
|||
|
|
@ -41,8 +41,9 @@ export async function performWxLogin(): Promise<WxMiniappLoginResponse> {
|
|||
}
|
||||
|
||||
// 重调登录接口的公共尾部:换新 code(一次性)→ 组装参数 → 调用 → 缓存 openId
|
||||
// openId 可缺省:needUserInfo 首登响应 Content 为 null(无 openId),靠 code + 授权资料即可重调
|
||||
async function reloginWith(
|
||||
openId: string,
|
||||
openId: string | undefined,
|
||||
extra: { iv?: string; encryptedData?: string; phoneCode?: string }
|
||||
): Promise<WxMiniappLoginResponse> {
|
||||
let code: string
|
||||
|
|
@ -52,7 +53,7 @@ async function reloginWith(
|
|||
} catch {
|
||||
throw new Error('获取微信登录凭证失败,请重试')
|
||||
}
|
||||
const params = { appId: resolveAppId(), code, openId, ...extra }
|
||||
const params = { appId: resolveAppId(), code, ...(openId ? { openId } : {}), ...extra }
|
||||
const result = await wxMiniappLogin(params)
|
||||
if (result.openId) storage.set(WX_OPENID_STORAGE_KEY, result.openId)
|
||||
return result
|
||||
|
|
@ -60,7 +61,7 @@ async function reloginWith(
|
|||
|
||||
// needUserInfo 二次授权:getUserInfo 拿 iv/encryptedData → 重调登录接口补用户资料
|
||||
// 后端文档约定:wx.login 返回 needUserInfo 时须用 wx.getUserInfo 授权(getUserProfile 拿的资料后端不认)
|
||||
export async function completeUserInfoLogin(openId: string): Promise<WxMiniappLoginResponse> {
|
||||
export async function completeUserInfoLogin(openId: string | undefined): Promise<WxMiniappLoginResponse> {
|
||||
let profile: { iv: string; encryptedData: string }
|
||||
try {
|
||||
const r = await Taro.getUserInfo({ withCredentials: true })
|
||||
|
|
|
|||
|
|
@ -45,6 +45,12 @@ export default function Mine() {
|
|||
</View>
|
||||
|
||||
<View className={styles.menuCard}>
|
||||
{loggedOut ? (
|
||||
<ListRowGroup>
|
||||
{/* 游客态:菜单收起,只留一个登录入口(微信审核要求不强制授权) */}
|
||||
<ListRow label="登录后使用完整功能" arrow onClick={goLogin} />
|
||||
</ListRowGroup>
|
||||
) : (
|
||||
<ListRowGroup>
|
||||
<ListRow label="配置设备" arrow onClick={() => navigateTo(ROUTES.DEVICE_BLE_CONFIG)} />
|
||||
<ListRow label="场所管理" arrow onClick={() => navigateTo(ROUTES.SCENE_SELECT)} />
|
||||
|
|
@ -52,6 +58,7 @@ export default function Mine() {
|
|||
<ListRow label="当前版本" value="2.8.44" />
|
||||
<ListRow label="退出登录" arrow danger onClick={logout} />
|
||||
</ListRowGroup>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,8 +5,15 @@ import { ROUTES } from '@/constants/routes'
|
|||
// 在拦截器内改写 url 会把 http 请求地址误改成页面路径(request:fail invalid url),
|
||||
// 故不用全局拦截器,改为在 navigation 统一出口做跳转前校验。
|
||||
|
||||
// 登录页及无需登录即可访问的页面(放行名单)
|
||||
export const PUBLIC_PAGES = new Set<string>([ROUTES.LOGIN])
|
||||
// 游客可浏览页面(微信审核要求:进入首页即要求授权登录会被拒,
|
||||
// tab 页与登录页放开,操作型页面按需登录)
|
||||
export const PUBLIC_PAGES = new Set<string>([
|
||||
ROUTES.LOGIN,
|
||||
ROUTES.HOME,
|
||||
ROUTES.DEVICE_LIST,
|
||||
ROUTES.EVENT,
|
||||
ROUTES.MINE
|
||||
])
|
||||
|
||||
// 登录页路径(navigation 守卫改投目标)
|
||||
export const ROUTES_LOGIN = ROUTES.LOGIN
|
||||
|
|
|
|||
|
|
@ -136,6 +136,8 @@ describe('needUserInfo 首次登录分支', () => {
|
|||
})
|
||||
|
||||
describe('completeUserInfoLogin 二次授权', () => {
|
||||
// 2026-09-24 定稿:内部走 wx.getUserInfo 取资料(后端不认 getUserProfile),
|
||||
// openId 可缺省(needUserInfo 首登响应 Content 为 null)
|
||||
it('getUserInfo(withCredentials) → 换新 code → 带 iv/encryptedData/openId 重调登录', async () => {
|
||||
mockGetUserInfo.mockResolvedValue({ iv: 'iv-1', encryptedData: 'enc-1' })
|
||||
mockWxMiniappLogin.mockResolvedValue(result({ openId: 'o-need' }))
|
||||
|
|
@ -151,6 +153,18 @@ describe('completeUserInfoLogin 二次授权', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('openId 缺省时请求体不带 openId 字段(needUserInfo 首登无 openId 场景)', async () => {
|
||||
mockGetUserInfo.mockResolvedValue({ iv: 'iv-1', encryptedData: 'enc-1' })
|
||||
mockWxMiniappLogin.mockResolvedValue(result())
|
||||
await completeUserInfoLogin(undefined)
|
||||
expect(mockWxMiniappLogin).toHaveBeenCalledWith({
|
||||
appId: 'wx-appid-live',
|
||||
code: 'wx-code-1',
|
||||
iv: 'iv-1',
|
||||
encryptedData: 'enc-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('用户拒绝授权(errMsg 形状 reject)抛带 message 的 Error', async () => {
|
||||
mockGetUserInfo.mockRejectedValue({ errMsg: 'getUserInfo:fail auth deny' })
|
||||
await expect(completeUserInfoLogin('o-need')).rejects.toThrow('getUserInfo:fail auth deny')
|
||||
|
|
|
|||
|
|
@ -52,13 +52,16 @@ describe('navigation 登录守卫', () => {
|
|||
expect(mockNavigateTo).toHaveBeenCalledWith({ url: ROUTES.LOGIN })
|
||||
})
|
||||
|
||||
it('未登录 reLaunch/redirectTo/switchTab 同样被守卫', () => {
|
||||
reLaunch(ROUTES.HOME)
|
||||
redirectTo(ROUTES.DEVICE_ADD)
|
||||
switchTab(ROUTES.EVENT)
|
||||
it('未登录 reLaunch/redirectTo 到受保护页被守卫(tab 页在游客白名单放行)', () => {
|
||||
reLaunch(ROUTES.DEVICE_ADD)
|
||||
redirectTo(ROUTES.FAMILY)
|
||||
expect(mockReLaunch).toHaveBeenCalledWith({ url: ROUTES.LOGIN })
|
||||
expect(mockRedirectTo).toHaveBeenCalledWith({ url: ROUTES.LOGIN })
|
||||
expect(mockSwitchTab).toHaveBeenCalledWith({ url: ROUTES.LOGIN })
|
||||
})
|
||||
|
||||
it('未登录 switchTab 到 tab 页:放行(游客模式白名单,微信审核要求)', () => {
|
||||
switchTab(ROUTES.EVENT)
|
||||
expect(mockSwitchTab).toHaveBeenCalledWith({ url: ROUTES.EVENT })
|
||||
})
|
||||
|
||||
it('已登录 navigateTo:原样放行', () => {
|
||||
|
|
@ -85,9 +88,13 @@ describe('navigation 登录守卫', () => {
|
|||
})
|
||||
|
||||
describe('isProtectedPage', () => {
|
||||
it('登录页返回 false,其余页面返回 true', () => {
|
||||
it('登录页与四个 tab 页返回 false(游客可浏览),其余页面返回 true', () => {
|
||||
expect(isProtectedPage(ROUTES.LOGIN)).toBe(false)
|
||||
expect(isProtectedPage(ROUTES.HOME)).toBe(true)
|
||||
expect(isProtectedPage(ROUTES.HOME)).toBe(false)
|
||||
expect(isProtectedPage(ROUTES.DEVICE_LIST)).toBe(false)
|
||||
expect(isProtectedPage(ROUTES.EVENT)).toBe(false)
|
||||
expect(isProtectedPage(ROUTES.MINE)).toBe(false)
|
||||
expect(isProtectedPage(ROUTES.FAMILY)).toBe(true)
|
||||
expect(isProtectedPage(`${ROUTES.LOGIN}?from=xxx`)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue