From 6faaf5392dadbb20fef2b17cd75afa5beae68a4d Mon Sep 17 00:00:00 2001 From: jokerxue <2509699647@qq.com> Date: Wed, 25 Mar 2026 11:15:29 +0800 Subject: [PATCH] APP 登录门店对接 --- 美国版/Food Labeling Management App UniApp/src/components/LocationPicker.vue | 53 ++++++++++++++++++++++++++++++++++++++++------------- 美国版/Food Labeling Management App UniApp/src/components/SideMenu.vue | 6 ++---- 美国版/Food Labeling Management App UniApp/src/env.d.ts | 9 +++++++++ 美国版/Food Labeling Management App UniApp/src/locales/en.ts | 4 ++++ 美国版/Food Labeling Management App UniApp/src/locales/zh.ts | 4 ++++ 美国版/Food Labeling Management App UniApp/src/pages/index/index.vue | 12 ++++++++++++ 美国版/Food Labeling Management App UniApp/src/pages/login/login.vue | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------- 美国版/Food Labeling Management App UniApp/src/pages/more/location.vue | 59 ++++++++++++++++++++++++++++++++++++++++++----------------- 美国版/Food Labeling Management App UniApp/src/pages/more/more.vue | 10 ++++------ 美国版/Food Labeling Management App UniApp/src/pages/more/profile.vue | 4 ++-- 美国版/Food Labeling Management App UniApp/src/pages/store-select/store-select.vue | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------- 美国版/Food Labeling Management App UniApp/src/services/usAppAuth.ts | 165 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 美国版/Food Labeling Management App UniApp/src/utils/apiBase.ts | 17 +++++++++++++++++ 美国版/Food Labeling Management App UniApp/src/utils/authSession.ts | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 美国版/Food Labeling Management App UniApp/src/utils/stores.ts | 34 ++++++++++++++++++++-------------- 美国版/Food Labeling Management App UniApp/vite.config.ts | 6 ++++++ 美国版App登录接口说明.md | 158 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 17 files changed, 703 insertions(+), 84 deletions(-) create mode 100644 美国版/Food Labeling Management App UniApp/src/services/usAppAuth.ts create mode 100644 美国版/Food Labeling Management App UniApp/src/utils/apiBase.ts create mode 100644 美国版/Food Labeling Management App UniApp/src/utils/authSession.ts create mode 100644 美国版App登录接口说明.md diff --git a/美国版/Food Labeling Management App UniApp/src/components/LocationPicker.vue b/美国版/Food Labeling Management App UniApp/src/components/LocationPicker.vue index 2d4fb63..aa87715 100644 --- a/美国版/Food Labeling Management App UniApp/src/components/LocationPicker.vue +++ b/美国版/Food Labeling Management App UniApp/src/components/LocationPicker.vue @@ -1,7 +1,7 @@ diff --git a/美国版/Food Labeling Management App UniApp/src/pages/more/location.vue b/美国版/Food Labeling Management App UniApp/src/pages/more/location.vue index 6b7bedb..3e86f88 100644 --- a/美国版/Food Labeling Management App UniApp/src/pages/more/location.vue +++ b/美国版/Food Labeling Management App UniApp/src/pages/more/location.vue @@ -23,12 +23,11 @@ {{ t('location.currentStore') }} - {{ t(currentStore.nameKey) }} + {{ currentStore.locationName }} - {{ currentStore.address }} - {{ currentStore.city }} + {{ currentStore.fullAddress }} @@ -104,8 +103,8 @@ - {{ t(s.nameKey) }} - {{ s.address }}, {{ s.city }} + {{ s.locationName }} + {{ s.fullAddress }} @@ -130,25 +129,52 @@ diff --git a/美国版/Food Labeling Management App UniApp/src/pages/more/profile.vue b/美国版/Food Labeling Management App UniApp/src/pages/more/profile.vue index 9361ace..42c09f1 100644 --- a/美国版/Food Labeling Management App UniApp/src/pages/more/profile.vue +++ b/美国版/Food Labeling Management App UniApp/src/pages/more/profile.vue @@ -86,8 +86,8 @@ import { getStatusBarHeight } from '../../utils/statusBar' const { t } = useI18n() const statusBarHeight = getStatusBarHeight() const isMenuOpen = ref(false) -const name = ref(uni.getStorageSync('userName') || 'John Smith') -const email = ref('john.smith@company.com') +const name = ref(uni.getStorageSync('userName') || 'Employee') +const email = ref(uni.getStorageSync('user_email') || '') const phone = ref('+1 (555) 123-4567') const employeeId = ref('EMP-2024-001') diff --git a/美国版/Food Labeling Management App UniApp/src/pages/store-select/store-select.vue b/美国版/Food Labeling Management App UniApp/src/pages/store-select/store-select.vue index ba1a62f..480dbee 100644 --- a/美国版/Food Labeling Management App UniApp/src/pages/store-select/store-select.vue +++ b/美国版/Food Labeling Management App UniApp/src/pages/store-select/store-select.vue @@ -12,6 +12,9 @@ + + {{ t('login.noStoresBound') }} + - {{ t(store.nameKey) }} + {{ store.locationName }} - {{ store.address }} + {{ store.fullAddress }} - {{ t('location.storeManager') }}: {{ store.manager }} - {{ t('location.storePhone') }}: {{ store.phone }} + {{ store.locationCode }} - {{ t('common.confirm') }} + {{ loading ? '…' : t('common.confirm') }} @@ -145,6 +178,16 @@ const handleConfirm = () => { padding-bottom: 24rpx; } +.empty-hint { + padding: 48rpx 24rpx; + text-align: center; +} + +.empty-text { + font-size: 28rpx; + color: #6b7280; +} + .card { padding: 32rpx; background: #ffffff; @@ -204,6 +247,11 @@ const handleConfirm = () => { margin-bottom: 6rpx; } +.info.muted { + color: #9ca3af; + font-size: 24rpx; +} + .card-check { width: 44rpx; height: 44rpx; diff --git a/美国版/Food Labeling Management App UniApp/src/services/usAppAuth.ts b/美国版/Food Labeling Management App UniApp/src/services/usAppAuth.ts new file mode 100644 index 0000000..7b60c6a --- /dev/null +++ b/美国版/Food Labeling Management App UniApp/src/services/usAppAuth.ts @@ -0,0 +1,165 @@ +import { buildApiUrl } from '../utils/apiBase' + +/** 与后端 UsAppBoundLocationDto 对齐 */ +export interface UsAppBoundLocationDto { + id: string + locationCode: string + locationName: string + fullAddress: string + state: boolean +} + +export interface UsAppLoginInput { + email: string + password: string + uuid?: string + code?: string +} + +export interface UsAppLoginOutputDto { + token: string + refreshToken: string + locations: UsAppBoundLocationDto[] +} + +function normalizeLocation(raw: Record): UsAppBoundLocationDto { + return { + id: String(raw.id ?? raw.Id ?? ''), + locationCode: String(raw.locationCode ?? raw.LocationCode ?? ''), + locationName: String(raw.locationName ?? raw.LocationName ?? ''), + fullAddress: String(raw.fullAddress ?? raw.FullAddress ?? ''), + state: raw.state !== false && raw.State !== false, + } +} + +function normalizeLoginOutput(raw: unknown): UsAppLoginOutputDto { + const o = raw as Record + const locs = o.locations ?? o.Locations + const arr = Array.isArray(locs) ? locs : [] + return { + token: String(o.token ?? o.Token ?? ''), + refreshToken: String(o.refreshToken ?? o.RefreshToken ?? ''), + locations: arr.map((x) => normalizeLocation(x as Record)), + } +} + +function normalizeLocationList(raw: unknown): UsAppBoundLocationDto[] { + const arr = Array.isArray(raw) ? raw : [] + return arr.map((x) => normalizeLocation(x as Record)) +} + +/** + * 取出真实业务负载:支持 ABP `result`、以及项目统一包装 `{ succeeded, data }`(data 内为 token / 数组等) + */ +function unwrap(data: unknown): T { + if (data == null || typeof data !== 'object') return data as T + const o = data as Record + if ('result' in o && o.result !== undefined) { + return o.result as T + } + const payload = o.data ?? o.Data + if (payload !== undefined && payload !== null) { + return payload as T + } + return data as T +} + +/** 统一解析后端错误文案(含统一返回体里的 errors、succeeded 等) */ +function parseErrorMessage(data: unknown): string { + if (data == null) return 'Request failed' + if (typeof data === 'string') return data + if (typeof data === 'object') { + const o = data as Record + const errorsRaw = o.errors ?? o.Errors + if (typeof errorsRaw === 'string' && errorsRaw.trim()) return errorsRaw.trim() + if (Array.isArray(errorsRaw)) { + const parts = errorsRaw.map((x) => String(x)).filter(Boolean) + if (parts.length) return parts.join('; ') + } + const err = o.error as Record | undefined + if (err && typeof err.message === 'string') return err.message + if (typeof o.message === 'string') return o.message + if (typeof o.error_description === 'string') return o.error_description + } + return 'Request failed' +} + +/** HTTP 200 但业务失败(如 succeeded: false、体内 statusCode 403) */ +function isBusinessFailurePayload(data: unknown): boolean { + if (data == null || typeof data !== 'object') return false + const o = data as Record + if (o.succeeded === false || o.Succeeded === false) return true + const inner = o.statusCode ?? o.StatusCode + if (typeof inner === 'number' && inner >= 400) return true + return false +} + +function request(options: { + path: string + method: 'GET' | 'POST' + data?: unknown + auth?: boolean +}): Promise { + const header: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json', + } + if (options.auth) { + const token = uni.getStorageSync('access_token') + if (token) header.Authorization = `Bearer ${token}` + } + + return new Promise((resolve, reject) => { + uni.request({ + url: buildApiUrl(options.path), + method: options.method, + data: options.data, + header, + success: (res) => { + const status = res.statusCode ?? 0 + if (status >= 400) { + reject(new Error(parseErrorMessage(res.data))) + return + } + if (isBusinessFailurePayload(res.data)) { + reject(new Error(parseErrorMessage(res.data))) + return + } + try { + const body = unwrap(res.data as unknown) + resolve(body) + } catch { + reject(new Error('Invalid response')) + } + }, + fail: (err) => { + reject(new Error(err.errMsg || 'Network error')) + }, + }) + }) +} + +/** POST /api/app/us-app-auth/login */ +export async function usAppLogin(input: UsAppLoginInput): Promise { + const raw = await request({ + path: '/api/app/us-app-auth/login', + method: 'POST', + data: { + email: input.email.trim(), + password: input.password, + ...(input.uuid ? { uuid: input.uuid } : {}), + ...(input.code != null ? { code: input.code } : {}), + }, + }) + return normalizeLoginOutput(raw) +} + +/** GET /api/app/us-app-auth/my-locations */ +export async function usAppFetchMyLocations(): Promise { + const raw = await request({ + path: '/api/app/us-app-auth/my-locations', + method: 'GET', + auth: true, + }) + return normalizeLocationList(raw) +} diff --git a/美国版/Food Labeling Management App UniApp/src/utils/apiBase.ts b/美国版/Food Labeling Management App UniApp/src/utils/apiBase.ts new file mode 100644 index 0000000..df5d3e0 --- /dev/null +++ b/美国版/Food Labeling Management App UniApp/src/utils/apiBase.ts @@ -0,0 +1,17 @@ +/** + * 美国版后端 API 根地址(不含末尾 /)。 + * - H5 开发:可在 .env.development 留空,配合 vite proxy 走同源 /api + * - App / 生产:在 .env 中设置 VITE_US_API_BASE,例如 http://192.168.1.10:19001 + */ +export function getApiBaseUrl(): string { + const fromEnv = (import.meta.env.VITE_US_API_BASE as string | undefined)?.trim() + if (fromEnv) return fromEnv.replace(/\/$/, '') + if (import.meta.env.DEV && typeof window !== 'undefined') return '' + return 'http://flus-test.3ffoodsafety.com' +} + +export function buildApiUrl(path: string): string { + const base = getApiBaseUrl() + const p = path.startsWith('/') ? path : `/${path}` + return base ? `${base}${p}` : p +} diff --git a/美国版/Food Labeling Management App UniApp/src/utils/authSession.ts b/美国版/Food Labeling Management App UniApp/src/utils/authSession.ts new file mode 100644 index 0000000..99a43cf --- /dev/null +++ b/美国版/Food Labeling Management App UniApp/src/utils/authSession.ts @@ -0,0 +1,83 @@ +import type { UsAppBoundLocationDto } from '../services/usAppAuth' + +const KEY_ACCESS = 'access_token' +const KEY_REFRESH = 'refresh_token' +const KEY_LOCATIONS = 'bound_locations_json' +const KEY_LOGGED = 'isLoggedIn' +const KEY_USER = 'userName' +const KEY_EMAIL = 'user_email' +const KEY_REMEMBER = 'login_remember_me' +const KEY_SAVED_EMAIL = 'login_saved_email' +const KEY_SAVED_PASSWORD = 'login_saved_password' + +export function saveAuthSession(payload: { + token: string + refreshToken: string + locations: UsAppBoundLocationDto[] + /** 展示用:无姓名接口时用邮箱本地部分或全文 */ + displayName: string + email: string +}): void { + uni.setStorageSync(KEY_ACCESS, payload.token) + uni.setStorageSync(KEY_REFRESH, payload.refreshToken) + uni.setStorageSync(KEY_LOCATIONS, JSON.stringify(payload.locations ?? [])) + uni.setStorageSync(KEY_LOGGED, 'true') + uni.setStorageSync(KEY_USER, payload.displayName) + uni.setStorageSync(KEY_EMAIL, payload.email) +} + +export function setBoundLocations(locations: UsAppBoundLocationDto[]): void { + uni.setStorageSync(KEY_LOCATIONS, JSON.stringify(locations ?? [])) +} + +export function getBoundLocations(): UsAppBoundLocationDto[] { + try { + const raw = uni.getStorageSync(KEY_LOCATIONS) + if (!raw || typeof raw !== 'string') return [] + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return [] + return parsed as UsAppBoundLocationDto[] + } catch { + return [] + } +} + +export function clearAuthSession(): void { + uni.removeStorageSync(KEY_ACCESS) + uni.removeStorageSync(KEY_REFRESH) + uni.removeStorageSync(KEY_LOCATIONS) + uni.removeStorageSync(KEY_LOGGED) + uni.removeStorageSync(KEY_USER) + uni.removeStorageSync(KEY_EMAIL) + uni.removeStorageSync('storeId') + uni.removeStorageSync('storeName') + uni.removeStorageSync('storeLocationCode') +} + +export function isLoggedIn(): boolean { + return !!uni.getStorageSync(KEY_ACCESS) && uni.getStorageSync(KEY_LOGGED) === 'true' +} + +export function getAccessToken(): string { + return uni.getStorageSync(KEY_ACCESS) || '' +} + +export function saveRememberPreference(remember: boolean, email: string, password: string): void { + uni.setStorageSync(KEY_REMEMBER, remember ? '1' : '0') + if (remember) { + uni.setStorageSync(KEY_SAVED_EMAIL, email) + uni.setStorageSync(KEY_SAVED_PASSWORD, password) + } else { + uni.removeStorageSync(KEY_SAVED_EMAIL) + uni.removeStorageSync(KEY_SAVED_PASSWORD) + } +} + +export function loadSavedCredentials(): { email: string; password: string; remember: boolean } { + const remember = uni.getStorageSync(KEY_REMEMBER) === '1' + return { + remember, + email: remember ? (uni.getStorageSync(KEY_SAVED_EMAIL) || '') : '', + password: remember ? (uni.getStorageSync(KEY_SAVED_PASSWORD) || '') : '', + } +} diff --git a/美国版/Food Labeling Management App UniApp/src/utils/stores.ts b/美国版/Food Labeling Management App UniApp/src/utils/stores.ts index 3cc4907..d0ab4a8 100644 --- a/美国版/Food Labeling Management App UniApp/src/utils/stores.ts +++ b/美国版/Food Labeling Management App UniApp/src/utils/stores.ts @@ -1,22 +1,28 @@ -export interface StoreInfo { - id: string - nameKey: string - address: string - city: string -} +import type { UsAppBoundLocationDto } from '../services/usAppAuth' +import { getBoundLocations } from './authSession' + +export type StoreInfo = UsAppBoundLocationDto -export const storeList: StoreInfo[] = [ - { id: '1', nameKey: 'login.store1', address: '123 Main St', city: 'New York, NY 10001' }, - { id: '2', nameKey: 'login.store2', address: '456 Oak Ave', city: 'Brooklyn, NY 11201' }, - { id: '3', nameKey: 'login.store3', address: '789 Pine Rd', city: 'Queens, NY 11354' }, - { id: '4', nameKey: 'login.store4', address: '321 Elm St', city: 'Manhattan, NY 10002' }, -] +export { getBoundLocations } from './authSession' export function getCurrentStoreId(): string { - return uni.getStorageSync('storeId') || '1' + return uni.getStorageSync('storeId') || '' } -export function switchStore(id: string, storeName: string) { +export function switchStore(id: string, storeName: string, locationCode?: string) { uni.setStorageSync('storeId', id) uni.setStorageSync('storeName', storeName) + if (locationCode != null && locationCode !== '') { + uni.setStorageSync('storeLocationCode', locationCode) + } +} + +/** 顶部药丸展示用,如 LOC-1 */ +export function getCurrentLocationCode(): string { + const saved = uni.getStorageSync('storeLocationCode') + if (saved) return saved + const id = getCurrentStoreId() + if (!id) return '' + const row = getBoundLocations().find((l) => l.id === id) + return row?.locationCode || '' } diff --git a/美国版/Food Labeling Management App UniApp/vite.config.ts b/美国版/Food Labeling Management App UniApp/vite.config.ts index ce0c914..dbd56c0 100644 --- a/美国版/Food Labeling Management App UniApp/vite.config.ts +++ b/美国版/Food Labeling Management App UniApp/vite.config.ts @@ -8,5 +8,11 @@ export default defineConfig({ plugins: [uni()], server: { open: "/", + proxy: { + "/api": { + target: "http://flus-test.3ffoodsafety.com", + changeOrigin: true, + }, + }, }, }); diff --git a/美国版App登录接口说明.md b/美国版App登录接口说明.md new file mode 100644 index 0000000..5733cf5 --- /dev/null +++ b/美国版App登录接口说明.md @@ -0,0 +1,158 @@ +# 美国版 App 登录接口说明 + +## 概述 + +美国版移动端认证由 `food-labeling-us` 模块的 **`UsAppAuthAppService`** 提供,采用 ABP 约定式动态 API。宿主统一前缀为 **`/api/app`**,建议以 Swagger 为准核对路径(本地示例:`http://localhost:19001/swagger`,搜索 `UsAppAuth`)。 + +| 说明 | 内容 | +|------|------| +| 账号标识 | 使用 **`User.Email`**(邮箱)登录,邮箱比对**忽略大小写** | +| 密码 | 与 Web 共用 `User` 表,校验方式与 RBAC **`AccountManager`** 一致(盐值 + `MD5Helper.SHA2Encode`) | +| 验证码 | 当配置 **`Rbac:EnableCaptcha`** 为 `true` 时,需先拉取图形验证码,本接口入参传 `uuid`、`code`;未开启时可传空或不传 | + +--- + +## 接口 1:App 登录 + +签发 **Access Token**、**Refresh Token**,并返回当前用户在 **`userlocation`** 中绑定的门店列表(关联 **`location`** 表详情)。 + +### HTTP + +- **方法**:`POST` +- **路径**:`/api/app/us-app-auth/login` +- **Content-Type**:`application/json` +- **鉴权**:无需登录(匿名) + +### 请求体参数(UsAppLoginInputVo) + +| 参数名(JSON) | 类型 | 必填 | 说明 | +|----------------|------|------|------| +| `email` | string | 是 | 登录邮箱,对应数据库 `User.Email` | +| `password` | string | 是 | 明文密码 | +| `uuid` | string | 条件 | 图形验证码 UUID;**开启验证码时必填** | +| `code` | string | 条件 | 图形验证码;**开启验证码时必填** | + +### 传参示例(请求 Body) + +未开启图形验证码时: + +```json +{ + "email": "admin@example.com", + "password": "123456" +} +``` + +开启图形验证码时(需与系统验证码接口返回的 `uuid`、用户输入的验证码一致): + +```json +{ + "email": "test@example.com", + "password": "您的密码", + "uuid": "验证码接口返回的 uuid", + "code": "用户看到的验证码" +} +``` + +### 响应体(UsAppLoginOutputDto) + +| 字段(JSON) | 类型 | 说明 | +|--------------|------|------| +| `token` | string | 访问令牌(Bearer),后续业务接口放在 Header `Authorization: Bearer {token}` | +| `refreshToken` | string | 刷新令牌(与系统账号体系一致,用于刷新 access token,具体用法与 Web 一致) | +| `locations` | array | 绑定门店列表,元素见下表 | + +#### `locations[]` 元素(UsAppBoundLocationDto) + +| 字段(JSON) | 类型 | 说明 | +|--------------|------|------| +| `id` | string | 门店主键(Guid 字符串) | +| `locationCode` | string | 业务编码,如 LOC-1 | +| `locationName` | string | 门店名称 | +| `fullAddress` | string | 拼接后的完整地址(街道、城市、州、邮编等;无数据时可能为 `"无"`) | +| `state` | bool | 门店是否启用 | + +### 响应示例 + +```json +{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "locations": [ + { + "id": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f", + "locationCode": "LOC-1", + "locationName": "Downtown Kitchen", + "fullAddress": "123 Main St, New York, NY 10001", + "state": true + } + ] +} +``` + +### 常见错误提示(业务异常文案) + +- 邮箱或密码为空:`请输入合理数据!` +- 邮箱在库中不存在(未删除且启用用户中无匹配邮箱):`登录失败!邮箱不存在!` +- 密码错误:`登录失败!用户名或密码错误!`(与 `UserConst.Login_Error` 一致) +- 验证码错误(开启验证码时):`验证码错误` + +--- + +## 接口 2:获取当前账号绑定门店 + +无需重新登录即可刷新 **`userlocation`** 绑定门店列表(例如切换门店前先同步列表)。 + +### HTTP + +- **方法**:`GET` +- **路径**:`/api/app/us-app-auth/my-locations` +- **鉴权**:需要登录,请求头携带 **`Authorization: Bearer {token}`**(使用接口 1 返回的 `token`) + +### 请求参数 + +无 Query / Body 参数;用户身份由 JWT 解析。 + +### 传参示例 + +```http +GET /api/app/us-app-auth/my-locations HTTP/1.1 +Host: localhost:19001 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +若前端统一约定 GET 使用 `data` 封装,可自行在客户端组装;本接口服务端**不读取额外 Query 参数**。 + +### 响应体 + +与登录接口中 **`locations`** 相同:**`UsAppBoundLocationDto[]`**(数组)。 + +### 响应示例 + +```json +[ + { + "id": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f", + "locationCode": "LOC-1", + "locationName": "Downtown Kitchen", + "fullAddress": "123 Main St, New York, NY 10001", + "state": true + } +] +``` + +### 常见错误 + +- 未登录或 Token 无效:按网关/ABP 返回 401 及统一错误体 +- 无用户上下文:`用户未登录` + +--- + +## 与其他登录方式的区别 + +| 场景 | 说明 | +|------|------| +| Web 管理端 | 仍使用 RBAC **`AccountService.PostLoginAsync`**,一般为人 **`userName`** + 密码 | +| 美国版 App | **仅**本模块 **`/api/app/us-app-auth/login`** 使用 **邮箱 + 密码** | + +两者共用同一 `User` 表与 JWT 体系;App 端需保证账号已维护 **`Email`** 字段,否则无法通过邮箱登录。 -- libgit2 0.21.4