import Taro from '@tarojs/taro' import { frontendPreviewProfile, isFrontendPreview } from './frontendPreview' import { createResourceCache } from '../utils/resourceCache' import { getAvatarDemoProfile, isAvatarDemo, saveAvatarDemoImage, updateAvatarDemoProfile } from './avatarDemo' export const BASE_URL = typeof API_BASE_URL === 'undefined' ? 'http://127.0.0.1:8080' : API_BASE_URL const profileCache = createResourceCache(30000) const profileListeners = new Set<(profile: UserProfile | null) => void>() export const getToken = (): string => Taro.getStorageSync('token') || '' const profileKey = () => isFrontendPreview() ? 'preview' : isAvatarDemo() ? 'avatar-demo' : `user:${Taro.getStorageSync('token') || ''}` const cloneProfile = (profile: UserProfile): UserProfile => JSON.parse(JSON.stringify(profile)) export class AuthenticationError extends Error { constructor(message = '请先登录') { super(message); this.name = 'AuthenticationError' } } export function subscribeProfile(listener: (profile: UserProfile | null) => void) { profileListeners.add(listener) return () => { profileListeners.delete(listener) } } function publishProfile(profile: UserProfile | null) { profileListeners.forEach(listener => listener(profile ? cloneProfile(profile) : null)) } export function clearLogin() { Taro.removeStorageSync('token') profileCache.clear() publishProfile(null) } function normalizeProfile(profile: UserProfile): UserProfile { return { ...profile, nickname: profile.nickname || '', birthday: profile.birthday || '', birthHour: profile.birthHour || '', province: profile.province || '', city: profile.city || '', district: profile.district || '', gender: profile.gender || '未知', avatarUrl: profile.avatarUrl || '' } } function storeProfile(profile: UserProfile, key = profileKey()) { if (key !== profileKey()) throw new AuthenticationError('登录状态已变更,请重新打开页面') const data = normalizeProfile(profile) profileCache.seed(key, cloneProfile(data)) publishProfile(data) return data } export function acceptLoginSession(session: LoginSession) { if (!session.token || !session.user) throw new Error('登录响应不完整,请重试') Taro.setStorageSync('token', session.token) return storeProfile(session.user) } export function getCachedProfile(): UserProfile | null { const profile = profileCache.peek(profileKey()) return profile ? cloneProfile(profile) : null } type ApiResult = { code: number message: string data: T } type RequestOptions = Omit function unwrap(status: number, body: ApiResult, token: string, protectedRequest: boolean): T { const expired = status === 401 || status === 403 || (body?.code !== 0 && /请先登录|登录状态.*过期|重新登录/.test(body?.message || '')) if (expired && protectedRequest) { if (token === getToken()) clearLogin() throw new AuthenticationError('登录状态已失效,请重新登录') } if (status < 200 || status >= 300 || !body || body.code !== 0) { throw new Error(body?.message && typeof body.message === 'string' ? body.message : `请求失败(${status || '网络异常'}),请稍后重试`) } if (protectedRequest && token !== getToken()) throw new AuthenticationError('登录状态已变更,请重新打开页面') return body.data } export async function request(url: string, options: RequestOptions = {}): Promise { if (isAvatarDemo()) { if (url === '/api/user/profile' && (!options.method || options.method === 'GET')) return getAvatarDemoProfile() as T if (url === '/api/user/profile' && options.method === 'PUT') return updateAvatarDemoProfile(options.data as Partial) as T throw new Error('已登录演示仅支持本地资料与头像,不请求真实后端') } if (isFrontendPreview()) { if (url === '/api/user/profile' && (!options.method || options.method === 'GET')) { return { ...frontendPreviewProfile, elementReport: { ...frontendPreviewProfile.elementReport, scores: [] } } as T } throw new Error('纯前端预览暂不请求后端或保存修改') } const token = Taro.getStorageSync('token') const protectedRequest = !/^\/api\/(auth|content)\//.test(url) if (protectedRequest && !token) throw new AuthenticationError() const headers = { 'content-type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(options.header || {}) } const res = await Taro.request>({ url: `${BASE_URL}${url}`, ...options, timeout: options.timeout || 15000, credentials: 'omit', header: headers }) return unwrap(res.statusCode, res.data, token, protectedRequest) } export function resolveAssetUrl(url?: string) { if (!url) return '' if (url.startsWith('cos://')) return '' // Only the backend may resolve private object references. return /^(https?:\/\/|wxfile:\/\/|blob:|data:image\/)/.test(url) ? url : `${BASE_URL}${url.startsWith('/') ? url : `/${url}`}` } async function uploadAvatar(filePath: string): Promise { if (isAvatarDemo()) return storeProfile(saveAvatarDemoImage(filePath)) if (isFrontendPreview()) throw new Error('纯前端预览暂不上传头像或保存修改') const token = Taro.getStorageSync('token') if (!token) throw new AuthenticationError() const key = profileKey() const response = await Taro.uploadFile({ url: `${BASE_URL}/api/user/avatar`, filePath, name: 'file', timeout: 45000, withCredentials: false, header: token ? { Authorization: `Bearer ${token}` } : {} }) let result: ApiResult try { result = JSON.parse(response.data) as ApiResult } catch (error) { if (response.statusCode === 401 || response.statusCode === 403) unwrap(response.statusCode, null, token, true) throw new Error('头像上传失败') } const profile = unwrap(response.statusCode, result, token, true) return storeProfile(profile, key) } export type LoginSession = { token: string; user: UserProfile } export type WxLoginResponse = { sessionKey: string; phoneBound: boolean; token?: string; user?: UserProfile } export const api = { wxLogin: (code: string) => request('/api/auth/wx-login', { method: 'POST', data: { code } }), sendSms: (phone: string) => request<{ mockCode?: string }>('/api/auth/sms', { method: 'POST', data: { phone } }), bindPhone: (sessionKey: string, phone: string, captcha: string) => request('/api/auth/bind-phone', { method: 'POST', data: { sessionKey, phone, captcha } }), profile: (force = false) => profileCache.load(profileKey(), () => request('/api/user/profile').then(normalizeProfile), force).then(data => { publishProfile(data) return cloneProfile(data) }), updateProfile: async (data: Partial) => { const key = profileKey() const profile = await request('/api/user/profile', { method: 'PUT', data }) return storeProfile(profile, key) }, uploadAvatar, exchangeStatus: () => request<{ points: number; available: boolean; message: string }>('/api/points/exchange-status'), agreement: (type: string) => request<{ title: string; body: string }>(`/api/content/agreement/${type}`), follow: () => request>('/api/content/follow'), homeOverview: () => request('/api/home/overview'), tiangong: () => request('/api/tiangong/items'), tiangongDetail: (id: string) => request(`/api/tiangong/items/${id}`), dailyHome: () => request('/api/daily/home'), pinDailyCard: (cardId: string) => request<{ pinnedCardId: string | null }>(`/api/daily/cards/${cardId}/pin`, { method: 'POST' }) } export type ElementScore = { name: string color: string value: number } export type UserProfile = { id: number avatarUrl?: string nickname: string phone?: string gender: string birthday: string birthHour: string province: string city: string district: string points?: number remainingEdits: number elementReport: { scores: ElementScore[] favorable: string unfavorable: string dayMaster: string } } export type CalendarDay = { day: number weekName: string label: string } export type LuckyPanel = { title: string value: string accent: string } export type FortuneCard = { id: string title: string category: string categoryIcon: string verdict: string avoid: string summary: string detail: string color: string gradient: string mascot: string } export type LifeTip = { id: string title: string icon: string content: string color: string mark: string } export type DailyHome = { date: string lunarDate: string weekName: string fortuneLevel: string greeting: string dailySummary: string calendarDays: CalendarDay[] luckyPanels: LuckyPanel[] cards: FortuneCard[] lifeTips: LifeTip[] pinnedCardId?: string | null } export type DayFortune = { label: '昨日' | '今日' | '明日' date: string weekName: string lunarDate: string status: string } export type LuckyColor = { name: string value: string } export type DimensionItem = { id: string title: string icon: string headline: string detail: string suitable: boolean } export type HomeOverview = { days: DayFortune[] activeDay: '昨日' | '今日' | '明日' summary: string overallIndex: number luckyColors: LuckyColor[] luckyNumbers: string[] luckyDirection: string luckyHours: Array<{ branch: string; time: string }> dimensions: DimensionItem[] } export type TiangongItem = { id: string name: string imageUrl: string unlocked: boolean } export type TiangongDetail = { id: string name: string title: string imageUrl: string introduction: string blessing: string }