feat(auth): 正式登录系统——华为账号登录(Account Kit,client_id 占位拦截防 crash)+ 邮箱验证码登录(发码冷却倒计时);dev-token 与服务器地址挪入开发者高级设置;client_id 填入 6917613748100335131

This commit is contained in:
xsl
2026-09-15 00:03:13 +08:00
parent eb86810a77
commit 2b0df49aa7
4 changed files with 392 additions and 66 deletions
+39 -6
View File
@@ -4,8 +4,10 @@
* - access 短令牌放内存 + preferences(local 明文可接受:V1 骨架,无支付;
* 正式版应迁 @ohos.security.asset,注释留迁移点)
* - refresh 一次性轮换:每次 refresh 换新对
* - dev-token 登录:POST /auth/dev-token(后端 DEV_AUTH_ENABLED 本地可用;
* 华为账号/短信登录是后续通道,接口按多通道预留)
* - dev-token 登录:POST /auth/dev-token(后端 DEV_AUTH_ENABLED 本地可用,
* 挪进登录页「高级设置(开发者)」折叠区,仅供本机联调)
* - 华为账号登录:POST /auth/huawei(Account Kit idToken 换双令牌)
* - 邮箱验证码登录:POST /auth/email/code + /auth/email/login
* - ArkTS 约束:所有请求/响应都是具名 interface;字面量先声明类型再传
*/
@@ -29,6 +31,11 @@ interface AuthWireResp {
user_id: string
}
interface EmailCodeWireResp {
sent: boolean
cooldown_seconds: number
}
const PREF_NAME = 'meida_auth'
const KEY_TOKENS = 'tokens'
const KEY_BASE_URL = 'base_url'
@@ -74,12 +81,38 @@ export class AuthStore {
return await AuthStore.prefs!.get(KEY_BASE_URL, DEFAULT_BASE_URL) as string
}
/** dev-token 登录(本地后端开发通道)。失败由 ApiClient 抛 ApiError。 */
/** dev-token 登录(本机联调通道,入口在登录页高级设置折叠区)。 */
static async loginByDevToken(deviceKey: string): Promise<void> {
const req: Record<string, Object> = { 'device_key': deviceKey }
// 登录本身不能走带鉴权的 post()——ApiClient 对未登录一律 40100 短路,
// 登录接口会被自己的守卫拦死(鸡生蛋)。必须走无鉴权通道。
const data = await ApiClient.postWithoutAuth<AuthWireResp>('/api/v1/auth/dev-token', req)
await AuthStore.loginAndSave('/api/v1/auth/dev-token', req)
}
/** 华为账号登录:Account Kit idToken 换双令牌。失败由 ApiClient 抛 ApiError。 */
static async loginByHuaweiIdToken(idToken: string): Promise<void> {
const req: Record<string, Object> = { 'id_token': idToken }
await AuthStore.loginAndSave('/api/v1/auth/huawei', req)
}
/** 发送邮箱验证码。成功返回服务端给的 cooldown_seconds(前端冷却倒计时用)。 */
static async sendEmailCode(email: string): Promise<number> {
const req: Record<string, Object> = { 'email': email }
const data = await ApiClient.postWithoutAuth<EmailCodeWireResp>('/api/v1/auth/email/code', req)
return data.cooldown_seconds
}
/** 邮箱验证码登录:同双令牌持久化。 */
static async loginByEmail(email: string, code: string): Promise<void> {
const req: Record<string, Object> = { 'email': email, 'code': code }
await AuthStore.loginAndSave('/api/v1/auth/email/login', req)
}
/**
* 各登录通道公共收尾:无鉴权通道换双令牌并持久化。
* 登录本身不能走带鉴权的 post()——ApiClient 对未登录一律 40100 短路,
* 登录接口会被自己的守卫拦死(鸡生蛋)。必须走无鉴权通道。
*/
private static async loginAndSave(path: string, req: Record<string, Object>): Promise<void> {
const data = await ApiClient.postWithoutAuth<AuthWireResp>(path, req)
await AuthStore.saveTokens(AuthStore.fromWire(data))
}
@@ -0,0 +1,76 @@
/**
* 华为账号授权登录封装(Account Kit)。
*
* 本机 SDK 声明为准(sdk/default/hms/ets/api/@hms.core.authentication.d.ts):
* - `new authentication.HuaweiIDProvider().createAuthorizationWithHuaweiIDRequest()`
* - `new authentication.AuthenticationController(context).executeRequest(request)`
* → Promise<AuthenticationResponse>,成功时 as AuthorizationWithHuaweiIDResponse
* 取 `data.idToken`(即后端 POST /auth/huawei 的 id_token)
* - 用户取消授权:executeRequest reject,BusinessError.code = 1001502012
* (AuthenticationErrorCode.USER_CANCELED),UI 层静默处理
*
* client_id 来自 module.json5 模块级 metadata(AGC 平台申请后填入);
* 仍是占位值时 LoginPage 直接拦截给提示,不会真正调 SDK。
*/
import { authentication } from '@kit.AccountKit'
import { bundleManager } from '@kit.AbilityKit'
import { BusinessError } from '@kit.BasicServicesKit'
import { ContextHolder } from './ContextHolder'
/** module.json5 metadata 里的 client_id 占位值(未接 AGC 前保持此值)。 */
export const HUAWEI_CLIENT_ID_PLACEHOLDER: string = 'TODO_AGC_CLIENT_ID'
/** Account Kit 错误码:用户取消授权。 */
const ERR_USER_CANCELED: number = 1001502012
export class HuaweiLogin {
/** 读取本模块 module.json5 里配置的 client_id;读取失败或未配置返回占位值。 */
static async resolveClientId(): Promise<string> {
try {
const flag = bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_HAP_MODULE |
bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_METADATA
const info = await bundleManager.getBundleInfoForSelf(flag)
for (const hap of info.hapModulesInfo) {
for (const item of hap.metadata) {
if (item.name === 'client_id') {
return item.value
}
}
}
} catch (_e) {
// 读取失败按未配置处理,页面给「暂未开放」提示
}
return HUAWEI_CLIENT_ID_PLACEHOLDER
}
/** 是否已完成 AGC 配置(真实 client_id 才能拉起授权页)。 */
static isConfigured(clientId: string): boolean {
return clientId !== '' && clientId !== HUAWEI_CLIENT_ID_PLACEHOLDER
}
/** 是否用户主动取消授权(取消不算错误,UI 静默返回)。 */
static isUserCancelled(e: Object): boolean {
if (!(e instanceof Error)) {
return false
}
const be = e as BusinessError
return be.code === ERR_USER_CANCELED
}
/** 拉起华为账号授权页(非静默,scopes: openid + profile),返回 idToken。 */
static async requestIdToken(): Promise<string> {
const controller = new authentication.AuthenticationController(ContextHolder.get())
const provider = new authentication.HuaweiIDProvider()
const request = provider.createAuthorizationWithHuaweiIDRequest()
request.scopes = ['openid', 'profile']
request.forceAuthorization = true
const response = await controller.executeRequest(request)
const authResp = response as authentication.AuthorizationWithHuaweiIDResponse
const idToken = authResp.data?.idToken
if (idToken === undefined || idToken === '') {
throw new Error('华为授权成功但未返回登录凭证')
}
return idToken
}
}
+271 -60
View File
@@ -1,9 +1,11 @@
/**
* LoginPage(F1)—— dev-token 开发登录页。
* LoginPage(F1)—— 正式登录页:华为账号登录 + 邮箱验证码登录。
*
* 闭环:设备标识(随机生成一次并持久化,可改)→ AuthStore.loginByDevToken
* → 成功 router.replaceUrl HomePage;失败 catch ApiError 展示 message+hint。
* 高级设置:折叠区编辑 baseUrl(AuthStore.setBaseUrl),真机连开发机靠它。
* 主区(白卡):
* - 华为账号一键登录:Account Kit 拉起授权页拿 idToken → /auth/huawei
* (client_id 未配置时给「暂未开放」提示,不调 SDK)
* - 邮箱验证码登录:sendEmailCode(60s 冷却倒计时)→ /auth/email/login
* 高级设置(开发者,默认收起):dev-token 内测登录 + 服务器地址编辑,本机联调用。
* 已登录态进入本页:aboutToAppear 直接跳 HomePage。
*
* ArkTS 约束(对齐 core/AuthStore 风格):
@@ -13,14 +15,22 @@
import router from '@ohos.router'
import preferences from '@ohos.data.preferences'
import { promptAction } from '@kit.ArkUI'
import { ApiError } from '../core/ApiTypes'
import { AuthStore, DEFAULT_BASE_URL } from '../core/AuthStore'
import { ContextHolder } from '../core/ContextHolder'
import { HuaweiLogin } from '../core/HuaweiLogin'
import { COLOR_PRIMARY } from '../core/Theme'
import { ErrorBanner } from '../components/f1/ErrorBanner'
const PREF_NAME_LOGIN = 'meida_login'
const KEY_DEVICE_KEY = 'device_key'
const EMAIL_CODE_LENGTH: number = 6
/** 宽松邮箱校验:非空局部 @ 非空局部 . 非空局部。 */
function isEmail(text: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text)
}
/** 随机设备标识:dev- + 16 位 hex,生成一次后随 preferences 持久化。 */
function generateDeviceKey(): string {
@@ -35,20 +45,32 @@ function generateDeviceKey(): string {
@Entry
@Component
struct LoginPage {
@State huaweiClientId: string = ''
@State huaweiLogging: boolean = false
@State emailInput: string = ''
@State emailCode: string = ''
@State sendingCode: boolean = false
@State sendCooldown: number = 0
@State emailLogging: boolean = false
@State deviceKey: string = ''
@State devLogging: boolean = false
@State baseUrlInput: string = ''
@State showAdvanced: boolean = false
@State logging: boolean = false
@State savingUrl: boolean = false
@State errMessage: string = ''
@State errHint: string = ''
@State savedTip: string = ''
private cooldownTimer: number = -1
aboutToAppear(): void {
this.bootstrap()
}
/** 具名 async 初始化:确保令牌已载入 → 已登录直跳 → 回填设备标识与地址。 */
aboutToDisappear(): void {
this.stopCooldownTick()
}
/** 具名 async 初始化:令牌载入 → 已登录直跳 → 回填表单与 client_id。 */
private async bootstrap(): Promise<void> {
try {
await AuthStore.init()
@@ -73,11 +95,112 @@ struct LoginPage {
this.deviceKey = generateDeviceKey()
}
this.baseUrlInput = await AuthStore.getBaseUrl()
this.huaweiClientId = await HuaweiLogin.resolveClientId()
}
/** 登录主流程:校验 → loading → dev-token → 跳首页;失败横幅展示。 */
private async doLogin(): Promise<void> {
if (this.logging) {
/** 华为账号登录:占位 client_id 直接提示;取消静默;其余 ApiError 走横幅。 */
private async doHuaweiLogin(): Promise<void> {
if (this.huaweiLogging) {
return
}
this.errMessage = ''
this.errHint = ''
if (!HuaweiLogin.isConfigured(this.huaweiClientId)) {
this.errMessage = '华为登录暂未开放'
this.errHint = '请先用邮箱验证码登录,正式开放后第一时间支持'
return
}
this.huaweiLogging = true
try {
const idToken = await HuaweiLogin.requestIdToken()
await AuthStore.loginByHuaweiIdToken(idToken)
router.replaceUrl({ url: 'pages/HomePage' })
} catch (e) {
if (HuaweiLogin.isUserCancelled(e)) {
// 用户取消授权:不报错
} else if (e instanceof ApiError) {
this.errMessage = e.message
this.errHint = e.hint !== undefined ? e.hint : ''
} else {
this.errMessage = '华为授权失败,请稍后重试'
this.errHint = '也可使用邮箱验证码登录'
}
} finally {
this.huaweiLogging = false
}
}
/** 发送邮箱验证码:校验格式 → 请求 → 按服务端 cooldown 启动倒计时。 */
private async doSendCode(): Promise<void> {
if (this.sendingCode || this.sendCooldown > 0) {
return
}
const email = this.emailInput.trim()
this.errMessage = ''
this.errHint = ''
if (!isEmail(email)) {
this.errMessage = '邮箱格式不正确'
this.errHint = '示例:name@example.com'
return
}
this.sendingCode = true
try {
const cooldown = await AuthStore.sendEmailCode(email)
this.sendCooldown = cooldown > 0 ? cooldown : 60
this.startCooldownTick()
promptAction.showToast({ message: '验证码已发送,请查收邮件', duration: 2000 })
} catch (e) {
if (e instanceof ApiError) {
this.errMessage = e.message
this.errHint = e.hint !== undefined ? e.hint : ''
} else {
this.errMessage = '验证码发送失败,请检查网络后重试'
this.errHint = ''
}
} finally {
this.sendingCode = false
}
}
/** 邮箱验证码登录:校验 → 请求 → 跳首页;失败横幅展示。 */
private async doEmailLogin(): Promise<void> {
if (this.emailLogging) {
return
}
const email = this.emailInput.trim()
const code = this.emailCode.trim()
this.errMessage = ''
this.errHint = ''
if (!isEmail(email)) {
this.errMessage = '邮箱格式不正确'
this.errHint = '示例:name@example.com'
return
}
if (code.length !== EMAIL_CODE_LENGTH) {
this.errMessage = '请输入 6 位数字验证码'
this.errHint = ''
return
}
this.emailLogging = true
try {
await AuthStore.loginByEmail(email, code)
router.replaceUrl({ url: 'pages/HomePage' })
} catch (e) {
if (e instanceof ApiError) {
this.errMessage = e.message
this.errHint = e.hint !== undefined ? e.hint : ''
} else {
this.errMessage = '登录失败,请检查网络后重试'
this.errHint = ''
}
} finally {
this.emailLogging = false
}
}
/** dev-token 内测登录(高级设置里,本机联调用)。 */
private async doDevLogin(): Promise<void> {
if (this.devLogging) {
return
}
const key = this.deviceKey.trim()
@@ -89,7 +212,7 @@ struct LoginPage {
this.errHint = '可点「随机生成」重新生成一个'
return
}
this.logging = true
this.devLogging = true
this.persistDeviceKey(key)
try {
await AuthStore.loginByDevToken(key)
@@ -99,12 +222,11 @@ struct LoginPage {
this.errMessage = e.message
this.errHint = e.hint !== undefined ? e.hint : ''
} else {
// 连接层失败(未进统一信封,#25):给内测口径指引,不改 ApiClient 全局文案
this.errMessage = '无法连接内测服务,请确认已连接指定网络,或联系测试人员'
this.errHint = ''
}
} finally {
this.logging = false
this.devLogging = false
}
}
@@ -119,6 +241,24 @@ struct LoginPage {
}
}
private startCooldownTick(): void {
this.stopCooldownTick()
this.cooldownTimer = setInterval(() => {
if (this.sendCooldown > 0) {
this.sendCooldown -= 1
} else {
this.stopCooldownTick()
}
}, 1000)
}
private stopCooldownTick(): void {
if (this.cooldownTimer !== -1) {
clearInterval(this.cooldownTimer)
this.cooldownTimer = -1
}
}
private async saveBaseUrl(): Promise<void> {
const url = this.baseUrlInput.trim()
this.savedTip = ''
@@ -170,7 +310,7 @@ struct LoginPage {
Column() {
Scroll() {
Column({ space: 22 }) {
// 品牌区
// 品牌区(正式产品口径)
Column({ space: 10 }) {
Text('美搭')
.fontSize(48)
@@ -180,54 +320,94 @@ struct LoginPage {
Text('AI 穿搭美学助手 · 每天三分钟,搭出好气质')
.fontSize(14)
.fontColor('#8A8A8A')
// 内测标识(#25):常驻,不藏进高级设置
Text('内测版 · 需连接指定网络后使用')
.fontSize(12)
.fontColor('#B3554D')
.padding({ left: 14, right: 14, top: 5, bottom: 5 })
.borderRadius(14)
.backgroundColor('#F7E3EA')
}
.margin({ top: 72, bottom: 8 })
// 内测账号标识(#25:话术去开发黑话,说明账号机制)
Column({ space: 8 }) {
Row({ space: 8 }) {
Text('内测账号标识')
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#2B2B2B')
Blank()
Text('随机生成')
.fontSize(13)
.fontColor(COLOR_PRIMARY)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(12)
.backgroundColor('#F7E3EA')
.onClick(() => this.regenDeviceKey())
// 主登录卡:华为账号 + 邮箱验证码
Column({ space: 14 }) {
Button(this.huaweiLogging ? '正在拉起华为授权…' : '华为账号一键登录')
.width('100%')
.height(50)
.fontSize(17)
.fontWeight(FontWeight.Medium)
.borderRadius(25)
.backgroundColor(COLOR_PRIMARY)
.fontColor('#FFFFFF')
.enabled(!this.huaweiLogging)
.onClick(() => this.doHuaweiLogin())
// 分隔线:或使用邮箱登录
Row({ space: 12 }) {
Divider()
.layoutWeight(1)
.strokeWidth(1)
.color('#E5E7EB')
Text('或使用邮箱登录')
.fontSize(12)
.fontColor('#B3B3B3')
Divider()
.layoutWeight(1)
.strokeWidth(1)
.color('#E5E7EB')
}
.width('100%')
TextInput({ text: this.deviceKey, placeholder: 'dev-xxxxxxxxxxxxxxxx' })
TextInput({ text: this.emailInput, placeholder: '邮箱地址' })
.height(48)
.fontSize(15)
.type(InputType.Email)
.borderRadius(12)
.backgroundColor('#FFFFFF')
.backgroundColor('#F7F5F6')
.padding({ left: 14, right: 14 })
.onChange((value: string) => {
this.deviceKey = value
this.emailInput = value
})
Text('无需注册手机号;同一标识对应同一账号,换一个标识相当于新账号')
.fontSize(11)
.fontColor('#B3B3B3')
Row({ space: 10 }) {
TextInput({ text: this.emailCode, placeholder: '6 位验证码' })
.height(48)
.fontSize(15)
.type(InputType.Number)
.maxLength(EMAIL_CODE_LENGTH)
.borderRadius(12)
.backgroundColor('#F7F5F6')
.padding({ left: 14, right: 14 })
.layoutWeight(1)
.onChange((value: string) => {
this.emailCode = value.replace(/[^0-9]/g, '').slice(0, EMAIL_CODE_LENGTH)
})
Button(this.sendCooldown > 0 ? `${this.sendCooldown}s 后重发` : (this.sendingCode ? '发送中…' : '发送验证码'))
.fontSize(14)
.height(48)
.borderRadius(12)
.backgroundColor(this.sendCooldown > 0 ? '#F0E0E6' : COLOR_PRIMARY)
.fontColor(this.sendCooldown > 0 ? '#B3B3B3' : '#FFFFFF')
.enabled(!this.sendingCode && this.sendCooldown <= 0)
.onClick(() => this.doSendCode())
}
.width('100%')
Button(this.emailLogging ? '正在登录…' : '登录')
.width('100%')
.height(50)
.fontSize(17)
.fontWeight(FontWeight.Medium)
.borderRadius(25)
.backgroundColor(COLOR_PRIMARY)
.fontColor('#FFFFFF')
.enabled(!this.emailLogging && isEmail(this.emailInput.trim()) && this.emailCode.trim().length === EMAIL_CODE_LENGTH)
.onClick(() => this.doEmailLogin())
}
.width('100%')
.padding(14)
.borderRadius(16)
.backgroundColor('#FFFFFF')
// 高级设置(服务器地址,真机连开发机靠它)
// 高级设置(开发者):dev-token 内测登录 + 服务器地址,默认收起
Column({ space: 10 }) {
Row() {
Text('高级设置 · 服务器地址')
Text('高级设置(开发者)')
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#2B2B2B')
@@ -244,11 +424,54 @@ struct LoginPage {
if (this.showAdvanced) {
Column({ space: 8 }) {
TextInput({ text: this.baseUrlInput, placeholder: DEFAULT_BASE_URL })
.height(46)
// dev-token 内测登录(本机联调通道)
Row({ space: 8 }) {
Text('内测账号标识')
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor('#2B2B2B')
Blank()
Text('随机生成')
.fontSize(12)
.fontColor(COLOR_PRIMARY)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(12)
.backgroundColor('#F7E3EA')
.onClick(() => this.regenDeviceKey())
}
.width('100%')
TextInput({ text: this.deviceKey, placeholder: 'dev-xxxxxxxxxxxxxxxx' })
.height(44)
.fontSize(14)
.borderRadius(12)
.backgroundColor('#FFFFFF')
.backgroundColor('#F7F5F6')
.padding({ left: 14, right: 14 })
.onChange((value: string) => {
this.deviceKey = value
})
Button(this.devLogging ? '正在进入…' : '内测通道进入')
.width('100%')
.height(42)
.fontSize(15)
.borderRadius(21)
.backgroundColor('#F0E0E6')
.fontColor(COLOR_PRIMARY)
.enabled(!this.devLogging && this.deviceKey.trim() !== '')
.onClick(() => this.doDevLogin())
Divider()
.width('100%')
.strokeWidth(1)
.color('#E5E7EB')
.margin({ top: 4, bottom: 4 })
TextInput({ text: this.baseUrlInput, placeholder: DEFAULT_BASE_URL })
.height(44)
.fontSize(14)
.borderRadius(12)
.backgroundColor('#F7F5F6')
.padding({ left: 14, right: 14 })
.onChange((value: string) => {
this.baseUrlInput = value
@@ -281,7 +504,7 @@ struct LoginPage {
}
.width('100%')
Text('内测版需连接指定网络;如无法登录,请联系测试人员')
Text('内测通道仅供开发联调;如无法登录,请联系测试人员')
.fontSize(11)
.fontColor('#B3B3B3')
}
@@ -294,19 +517,7 @@ struct LoginPage {
.borderRadius(16)
.backgroundColor('#FFFFFF')
// 登录按钮
Button(this.logging ? '正在进入…' : '进入美搭')
.width('100%')
.height(50)
.fontSize(17)
.fontWeight(FontWeight.Medium)
.borderRadius(25)
.backgroundColor(COLOR_PRIMARY)
.fontColor('#FFFFFF')
.enabled(!this.logging && this.deviceKey.trim() !== '')
.onClick(() => this.doLogin())
// 错误横幅(登录/地址保存失败)
// 错误横幅(登录/发送验证码/地址保存失败)
ErrorBanner({
message: this.errMessage,
hint: this.errHint,
+6
View File
@@ -23,6 +23,12 @@
],
"installationFree": false,
"pages": "$profile:main_pages",
"metadata": [
{
"name": "client_id",
"value": "6917613748100335131"
}
],
"abilities": [
{
"name": "EntryAbility",