后端迁移到fastapi (#3)

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-07-18 20:49:09 +08:00
parent 217b81c506
commit 51743645e2
71 changed files with 845 additions and 3580 deletions
+68 -116
View File
@@ -1,149 +1,102 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { supabase } from '@/lib/supabase'
import type { User } from '@supabase/supabase-js'
import {
AUTH_EXPIRED_EVENT,
AUTH_UPDATED_EVENT,
apiRequest,
clearAccessToken,
refreshSession,
setAccessToken,
} from '@/lib/api'
import type { AuthResponse, AuthUser } from '@/types/api'
import type { Profile } from '@/types/database'
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const user = ref<AuthUser | null>(null)
const profile = ref<Profile | null>(null)
const loading = ref(true)
let listenersAttached = false
const isLoggedIn = computed(() => !!user.value)
const isAdmin = computed(() => profile.value?.role === 'admin')
async function fetchProfile(userId?: string) {
const id = userId ?? user.value?.id
if (!id) return
const { data } = await supabase
.from('profiles')
.select('*')
.eq('id', id)
.single()
if (data) {
profile.value = data as Profile
}
function applyAuth(auth: AuthResponse) {
setAccessToken(auth.access_token)
user.value = auth.user
profile.value = auth.profile
}
async function ensureUsernameAvailable(username: string, currentUserId?: string) {
let query = supabase
.from('profiles')
.select('id')
.eq('username', username)
.limit(1)
if (currentUserId) {
query = query.neq('id', currentUserId)
}
const { data, error } = await query
if (error) throw error
if (data?.length) {
throw new Error('这个昵称已经被使用,请换一个。')
}
}
async function login(email: string, password: string) {
const { data, error } = await supabase.auth.signInWithPassword({ email, password })
if (error) {
if (error.message.includes('Invalid login credentials')) {
throw new Error('邮箱或密码错误')
}
if (error.message.includes('Email not confirmed')) {
throw new Error('邮箱未确认,请先查收确认邮件')
}
throw error
}
await fetchProfile(data.user.id)
user.value = data.user
}
async function register(email: string, password: string, username: string) {
await ensureUsernameAvailable(username)
const { error } = await supabase.auth.signUp({
email,
password,
options: {
data: { username },
emailRedirectTo: `${window.location.origin}/auth/confirm`,
},
})
if (error) {
if (error.message.includes('already registered')) {
throw new Error('该邮箱已被注册')
}
if (error.message.includes('Password')) {
throw new Error('密码强度不足,请使用至少6位密码')
}
throw error
}
}
async function logout() {
const { error } = await supabase.auth.signOut()
if (error) throw error
function clearAuth() {
clearAccessToken()
user.value = null
profile.value = null
}
async function updateUsername(username: string) {
if (!user.value) throw new Error('请先登录。')
async function login(email: string, password: string) {
const auth = await apiRequest<AuthResponse>('/auth/login', {
method: 'POST',
auth: false,
body: { email, password },
})
applyAuth(auth)
}
await ensureUsernameAvailable(username, user.value.id)
async function register(email: string, password: string, username: string) {
await apiRequest('/auth/register', {
method: 'POST',
auth: false,
body: { email, password, username },
})
}
const { data, error } = await supabase
.from('profiles')
.update({ username })
.eq('id', user.value.id)
.select('*')
.single()
if (error) {
if (error.code === '23505') {
throw new Error('这个昵称已经被使用,请换一个。')
}
throw error
async function logout() {
try {
await apiRequest('/auth/logout', { method: 'POST', retry: false })
} finally {
clearAuth()
}
}
profile.value = data as Profile
async function updateUsername(username: string) {
const updatedProfile = await apiRequest<Profile>('/profiles/me', {
method: 'PATCH',
body: { username },
})
profile.value = updatedProfile
}
async function updatePassword(password: string) {
const { error } = await supabase.auth.updateUser({ password })
if (error) throw error
const auth = await apiRequest<AuthResponse>('/auth/password', {
method: 'PATCH',
body: { password },
})
applyAuth(auth)
}
async function sendPasswordReset(email: string) {
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/reset-password`,
await apiRequest('/auth/forgot-password', {
method: 'POST',
auth: false,
body: { email },
})
if (error) {
if (error.message.includes('rate limit')) {
throw new Error('发送过于频繁,请稍后再试。')
}
throw error
}
}
async function initialize() {
const { data: { session } } = await supabase.auth.getSession()
const initialUser = session?.user ?? null
if (initialUser) await fetchProfile(initialUser.id)
user.value = initialUser
loading.value = false
if (!listenersAttached) {
window.addEventListener(AUTH_UPDATED_EVENT, event => {
applyAuth((event as CustomEvent<AuthResponse>).detail)
})
window.addEventListener(AUTH_EXPIRED_EVENT, clearAuth)
listenersAttached = true
}
supabase.auth.onAuthStateChange((_event, session) => {
const nextUser = session?.user ?? null
if (nextUser) {
fetchProfile(nextUser.id).then(() => {
user.value = nextUser
})
} else {
user.value = null
profile.value = null
}
})
try {
applyAuth(await refreshSession())
} catch {
clearAuth()
} finally {
loading.value = false
}
}
return {
@@ -152,7 +105,6 @@ export const useAuthStore = defineStore('auth', () => {
loading,
isLoggedIn,
isAdmin,
fetchProfile,
login,
register,
logout,
+6 -9
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { supabase } from '@/lib/supabase'
import { defineStore } from 'pinia'
import { apiRequest } from '@/lib/api'
import type { CloudType } from '@/types/database'
export const useCloudsStore = defineStore('clouds', () => {
@@ -10,14 +10,11 @@ export const useCloudsStore = defineStore('clouds', () => {
async function fetchCloudTypes() {
if (cloudTypes.value.length > 0) return
loading.value = true
const { data } = await supabase
.from('cloud_types')
.select('*')
.order('id')
if (data) {
cloudTypes.value = data as CloudType[]
try {
cloudTypes.value = await apiRequest<CloudType[]>('/cloud-types', { auth: false })
} finally {
loading.value = false
}
loading.value = false
}
return { cloudTypes, loading, fetchCloudTypes }
+12 -71
View File
@@ -1,7 +1,8 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { supabase } from '@/lib/supabase'
import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import type { ApiCollection } from '@/types/api'
import type { CloudType, UserCollection } from '@/types/database'
export interface CollectionPreviewCloud {
@@ -18,27 +19,7 @@ export interface CollectionEntry extends UserCollection {
}
function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message
if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') {
return error.message
}
return '图鉴收藏加载失败'
}
function toCollectionCloudMap(rows: Array<Record<string, unknown>> | null) {
return new Map(
(rows || []).map(row => [
row.id as string,
{
id: row.id as string,
image_url: row.image_url as string,
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
captured_at: (row.captured_at as string | null) ?? null,
created_at: row.created_at as string,
location_name: (row.location_name as string | null) ?? null,
} satisfies CollectionPreviewCloud,
]),
)
return error instanceof Error ? error.message : '图鉴收藏加载失败'
}
export const useEncyclopediaStore = defineStore('encyclopedia', () => {
@@ -52,30 +33,18 @@ export const useEncyclopediaStore = defineStore('encyclopedia', () => {
const collectionLoadedForUserId = ref<string | null>(null)
const collectionError = ref('')
const collectionMap = computed(() => {
return new Map(myCollection.value.map(item => [item.cloud_type_id, item]))
})
const collectionMap = computed(() => new Map(myCollection.value.map(item => [item.cloud_type_id, item])))
const unlockedCount = computed(() => myCollection.value.length)
const unlockProgress = computed(() => `${unlockedCount.value}/${cloudTypes.value.length || 10}`)
const unlockPercent = computed(() => {
if (!cloudTypes.value.length) return 0
return Math.round(unlockedCount.value / cloudTypes.value.length * 100)
})
const unlockPercent = computed(() => cloudTypes.value.length
? Math.round(unlockedCount.value / cloudTypes.value.length * 100)
: 0)
async function fetchCloudTypes(force = false) {
if (cloudTypesLoaded.value && !force) return
loadingCloudTypes.value = true
try {
const { data, error } = await supabase
.from('cloud_types')
.select('*')
.order('id')
if (error) throw error
cloudTypes.value = (data || []) as CloudType[]
cloudTypes.value = await apiRequest<CloudType[]>('/cloud-types', { auth: false })
cloudTypesLoaded.value = true
} finally {
loadingCloudTypes.value = false
@@ -84,49 +53,21 @@ export const useEncyclopediaStore = defineStore('encyclopedia', () => {
async function fetchMyCollection(force = false) {
const userId = authStore.user?.id ?? null
if (!userId) {
myCollection.value = []
collectionLoadedForUserId.value = null
collectionError.value = ''
return
}
if (!force && collectionLoadedForUserId.value === userId) return
loadingCollection.value = true
collectionError.value = ''
try {
const { data, error } = await supabase
.from('user_collections')
.select('*')
.eq('user_id', userId)
.order('unlocked_at', { ascending: true })
if (error) throw error
const collectionRows = (data || []) as UserCollection[]
const firstCloudIds = collectionRows
.map(item => item.first_cloud_id)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
let firstCloudMap = new Map<string, CollectionPreviewCloud>()
if (firstCloudIds.length) {
const { data: firstCloudData, error: firstCloudError } = await supabase
.from('clouds')
.select('id,image_url,thumbnail_url,captured_at,created_at,location_name')
.in('id', firstCloudIds)
if (firstCloudError) throw firstCloudError
firstCloudMap = toCollectionCloudMap(firstCloudData as Array<Record<string, unknown>> | null)
}
myCollection.value = collectionRows.map(item => ({
...item,
firstCloud: item.first_cloud_id ? firstCloudMap.get(item.first_cloud_id) ?? null : null,
const rows = await apiRequest<ApiCollection[]>('/collections/me')
myCollection.value = rows.map(({ first_cloud, cloud_type: _cloudType, ...collection }) => ({
...collection,
firstCloud: first_cloud,
}))
collectionLoadedForUserId.value = userId
} catch (error) {
+70 -141
View File
@@ -1,6 +1,8 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { supabase } from '@/lib/supabase'
import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import type { ApiCloud, ApiPage, PublicProfile } from '@/types/api'
import type { CloudType, Profile } from '@/types/database'
export interface ProfileCloudItem {
@@ -21,52 +23,36 @@ export interface ProfileCloudItem {
cloudTypeRarity: CloudType['rarity']
}
function toProfileCloud(row: Record<string, unknown>) {
const cloudTypes = Array.isArray(row.cloud_types) ? row.cloud_types : row.cloud_types ? [row.cloud_types] : []
const cloudType = cloudTypes[0] as Record<string, unknown> | undefined
function toProfileCloud(row: ApiCloud): ProfileCloudItem {
return {
id: row.id as string,
cloud_type_id: (row.cloud_type_id as number | null) ?? null,
custom_cloud_type: (row.custom_cloud_type as string | null) ?? null,
image_url: row.image_url as string,
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
latitude: (row.latitude as number | null) ?? null,
longitude: (row.longitude as number | null) ?? null,
location_name: (row.location_name as string | null) ?? null,
description: (row.description as string | null) ?? null,
captured_at: (row.captured_at as string | null) ?? null,
created_at: row.created_at as string,
status: row.status as ProfileCloudItem['status'],
is_hidden: (row.is_hidden as boolean) ?? false,
cloudTypeName: (cloudType?.name as string) || (row.custom_cloud_type as string) || '未知',
cloudTypeRarity: (cloudType?.rarity as CloudType['rarity']) || 'common',
} satisfies ProfileCloudItem
id: row.id,
cloud_type_id: row.cloud_type_id,
custom_cloud_type: row.custom_cloud_type,
image_url: row.image_url,
thumbnail_url: row.thumbnail_url,
latitude: row.latitude,
longitude: row.longitude,
location_name: row.location_name,
description: row.description,
captured_at: row.captured_at,
created_at: row.created_at,
status: row.status,
is_hidden: row.is_hidden,
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
cloudTypeRarity: row.cloud_type_rarity || 'common',
}
}
function getSupabaseErrorCode(error: unknown) {
if (!error || typeof error !== 'object' || !('code' in error)) return null
const code = (error as { code?: unknown }).code
return typeof code === 'string' ? code : null
}
function getCloudStoragePath(publicUrl: string | null) {
if (!publicUrl) return null
try {
const url = new URL(publicUrl)
const marker = '/storage/v1/object/public/clouds/'
const markerIndex = url.pathname.indexOf(marker)
if (markerIndex === -1) return null
const path = url.pathname.slice(markerIndex + marker.length)
return path ? decodeURIComponent(path) : null
} catch {
return null
function toProfile(profile: PublicProfile): Profile {
return {
...profile,
role: 'user',
is_disabled: false,
}
}
export const useProfileStore = defineStore('profile-page', () => {
const authStore = useAuthStore()
const profilesById = ref<Record<string, Profile>>({})
const cloudsByKey = ref<Record<string, ProfileCloudItem[]>>({})
const loadingKeys = ref<Record<string, boolean>>({})
@@ -75,23 +61,32 @@ export const useProfileStore = defineStore('profile-page', () => {
const makeKey = (userId: string, isOwnProfile: boolean) => `${userId}:${isOwnProfile ? 'own' : 'public'}`
function getProfile(userId: string | null) {
if (!userId) return null
return profilesById.value[userId] ?? null
return userId ? profilesById.value[userId] ?? null : null
}
function getClouds(userId: string | null, isOwnProfile: boolean) {
if (!userId) return []
return cloudsByKey.value[makeKey(userId, isOwnProfile)] ?? []
return userId ? cloudsByKey.value[makeKey(userId, isOwnProfile)] ?? [] : []
}
function getError(userId: string | null, isOwnProfile: boolean) {
if (!userId) return ''
return errorByKey.value[makeKey(userId, isOwnProfile)] ?? ''
return userId ? errorByKey.value[makeKey(userId, isOwnProfile)] ?? '' : ''
}
function isLoaded(userId: string | null, isOwnProfile: boolean) {
if (!userId) return false
return makeKey(userId, isOwnProfile) in cloudsByKey.value
return !!userId && makeKey(userId, isOwnProfile) in cloudsByKey.value
}
async function fetchAllClouds(userId: string, isOwnProfile: boolean) {
const requestOptions = { auth: isOwnProfile }
const first = await apiRequest<ApiPage<ApiCloud>>(`/profiles/${userId}/clouds?page=1&page_size=100`, requestOptions)
if (first.total_pages <= 1) return first.items
const remaining = await Promise.all(
Array.from({ length: first.total_pages - 1 }, (_, index) =>
apiRequest<ApiPage<ApiCloud>>(`/profiles/${userId}/clouds?page=${index + 2}&page_size=100`, requestOptions),
),
)
return [first, ...remaining].flatMap(page => page.items)
}
async function fetchProfilePage(userId: string, isOwnProfile: boolean, force = false) {
@@ -100,34 +95,13 @@ export const useProfileStore = defineStore('profile-page', () => {
loadingKeys.value[key] = true
errorByKey.value[key] = ''
try {
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.single()
if (profileError) throw profileError
profilesById.value[userId] = profile as Profile
let cloudsQuery = supabase
.from('clouds')
.select('id,cloud_type_id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity)')
.eq('user_id', userId)
.order('captured_at', { ascending: false, nullsFirst: false })
.order('created_at', { ascending: false })
if (!isOwnProfile) {
cloudsQuery = cloudsQuery
.eq('status', 'approved')
.eq('is_hidden', false)
}
const { data: cloudRows, error: cloudsError } = await cloudsQuery
if (cloudsError) throw cloudsError
cloudsByKey.value[key] = ((cloudRows || []) as Array<Record<string, unknown>>).map(toProfileCloud)
const profilePromise = isOwnProfile && authStore.profile?.id === userId
? Promise.resolve(authStore.profile)
: apiRequest<PublicProfile>(`/profiles/${userId}`, { auth: false }).then(toProfile)
const [profile, cloudRows] = await Promise.all([profilePromise, fetchAllClouds(userId, isOwnProfile)])
profilesById.value[userId] = profile
cloudsByKey.value[key] = cloudRows.map(toProfileCloud)
} catch (error) {
errorByKey.value[key] = error instanceof Error ? error.message : '个人主页加载失败'
cloudsByKey.value[key] = []
@@ -137,8 +111,7 @@ export const useProfileStore = defineStore('profile-page', () => {
}
function isLoading(userId: string | null, isOwnProfile: boolean) {
if (!userId) return false
return !!loadingKeys.value[makeKey(userId, isOwnProfile)]
return !!userId && !!loadingKeys.value[makeKey(userId, isOwnProfile)]
}
function patchCachedCloud(userId: string, cloudId: string, patch: Partial<ProfileCloudItem>) {
@@ -146,9 +119,8 @@ export const useProfileStore = defineStore('profile-page', () => {
const key = makeKey(userId, isOwnProfile)
const current = cloudsByKey.value[key]
if (!current) continue
cloudsByKey.value[key] = current
.map(item => (item.id === cloudId ? { ...item, ...patch } : item))
.map(item => item.id === cloudId ? { ...item, ...patch } : item)
.filter(item => isOwnProfile || (item.status === 'approved' && !item.is_hidden))
}
}
@@ -158,8 +130,7 @@ export const useProfileStore = defineStore('profile-page', () => {
for (const isOwnProfile of [true, false]) {
const key = makeKey(userId, isOwnProfile)
const current = cloudsByKey.value[key]
if (!current) continue
cloudsByKey.value[key] = current.filter(item => !idSet.has(item.id))
if (current) cloudsByKey.value[key] = current.filter(item => !idSet.has(item.id))
}
}
@@ -178,80 +149,38 @@ export const useProfileStore = defineStore('profile-page', () => {
captured_at: string | null
is_hidden: boolean
}) {
const { data, error } = await supabase
.from('clouds')
.update(patch)
.eq('id', cloudId)
.eq('user_id', userId)
.select('id,cloud_type_id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity)')
.single()
if (error) throw error
const updated = toProfileCloud(data as Record<string, unknown>)
const updated = toProfileCloud(await apiRequest<ApiCloud>(`/clouds/${cloudId}`, {
method: 'PATCH',
body: patch,
}))
patchCachedCloud(userId, cloudId, updated)
return updated
}
async function updateCloudVisibility(userId: string, cloudId: string, isHidden: boolean) {
const { data, error } = await supabase
.from('clouds')
.update({ is_hidden: isHidden })
.eq('id', cloudId)
.eq('user_id', userId)
.select('id')
if (error) throw error
if (!data?.length) {
throw new Error('私密状态没有写入数据库,请检查 clouds 表的 UPDATE RLS policy。')
}
await apiRequest<ApiCloud>(`/clouds/${cloudId}`, {
method: 'PATCH',
body: { is_hidden: isHidden },
})
patchCachedCloud(userId, cloudId, { is_hidden: isHidden })
}
async function deleteClouds(userId: string, cloudIds: string[]) {
if (!cloudIds.length) return 0
const { data, error } = await supabase
.from('clouds')
.delete()
.eq('user_id', userId)
.in('id', cloudIds)
.select('id,image_url,thumbnail_url')
if (error) {
if (getSupabaseErrorCode(error) === '23503') {
throw new Error('这张照片仍被图鉴收藏记录引用,请先在数据库把 user_collections.first_cloud_id 外键改为 ON DELETE SET NULL。')
}
throw error
}
const deletedIds = (data || []).map(item => item.id as string)
if (deletedIds.length !== cloudIds.length) {
throw new Error('图片没有真正从数据库删除,请检查 clouds 表的 DELETE RLS policy。')
}
const storagePaths = Array.from(new Set(
(data || [])
.flatMap(item => [
getCloudStoragePath((item.image_url as string | null) ?? null),
getCloudStoragePath((item.thumbnail_url as string | null) ?? null),
])
.filter((path): path is string => !!path),
))
if (storagePaths.length) {
const { error: storageError } = await supabase.storage
.from('clouds')
.remove(storagePaths)
if (storageError) {
throw new Error(`图片数据库记录已删除,但 Supabase Storage 文件清理失败:${storageError.message}`)
if (cloudIds.length === 1) {
await apiRequest(`/clouds/${cloudIds[0]}`, { method: 'DELETE' })
} else {
for (let offset = 0; offset < cloudIds.length; offset += 100) {
await apiRequest('/clouds/batch-delete', {
method: 'POST',
body: { ids: cloudIds.slice(offset, offset + 100) },
})
}
}
removeCachedClouds(userId, deletedIds)
return deletedIds.length
removeCachedClouds(userId, cloudIds)
return cloudIds.length
}
return {