Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion scripts/import-osm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,23 @@ function mapSeatType(tags: Record<string, string>): string | null {
return null
}

/**
* 场所类型。先看这个点本身是什么地方(商场、餐饮、车站……),
* 这类标签比"厕所本身的 access 标签"更准——后者是给独立厕所点用的兜底逻辑,
* 商场/酒店这种从 toilets=yes 拿到的点,主标签本来就说明了自己是什么。
*
* Category 枚举里没有"酒店",酒店暂时落进 other,不为了这一个类型改数据库约束。
*/
function mapCategory(tags: Record<string, string>): string {
if (tags['shop'] === 'mall' || tags['shop'] === 'department_store') return 'mall'
if (['restaurant', 'cafe', 'fast_food', 'bar', 'pub'].includes(tags['amenity'] ?? '')) {
return 'restaurant'
}
if (tags['railway'] || tags['aeroway'] || tags['amenity'] === 'bus_station') return 'transit'
if (['university', 'college', 'school'].includes(tags['amenity'] ?? '')) return 'campus'
if (tags['leisure'] === 'park') return 'park'
if (tags['office']) return 'office'

const access = tags['access']
if (access === 'customers') return 'restaurant'
if (tags['building'] || tags['indoor'] === 'yes') return 'other'
Expand All @@ -139,14 +155,22 @@ function mapCategory(tags: Record<string, string>): string {
* 这不是 bug,是数据现状。能区分它们的是距离和评价 —— 而评价正是这个产品的护城河。
* 这里只做力所能及的事:有 ref(公厕编号)或 operator 就拼上去,多少能分出个前后。
*/
// toilet_name_proposals 表把名字长度卡在 40 字(含)以内(见 migration)。
// OSM 上偶尔有人把中英文名挤进同一个 name 标签,一条超长记录不该拖累整批导入。
const MAX_NAME_LEN = 40

function clampName(s: string): string {
return s.length > MAX_NAME_LEN ? `${s.slice(0, MAX_NAME_LEN - 1)}…` : s
}

function buildName(
tags: Record<string, string>,
): { name: string; nameEn: string | null } {
const zh = (tags['name:zh'] ?? tags['name'])?.trim()
const en = tags['name:en']?.trim()

if (zh || en) {
return { name: zh || en!, nameEn: en || null }
return { name: clampName(zh || en!), nameEn: en ? clampName(en) : null }
}

// 没名字:用管理方兜底。ref 多数是十几位的政府编号,
Expand All @@ -166,11 +190,20 @@ async function fetchOverpass(bbox: Bbox): Promise<OsmElement[]> {
const area = `${south},${west},${north},${east}`

// way / relation 也要:不少厕所是按面画的,只查 node 会漏掉一大半
//
// 两路数据源:
// 1. amenity=toilets —— 专门画出来的厕所点
// 2. toilets=yes —— 打在酒店、商场等地点本身上的"这儿有厕所"标注。
// 只认这个明确标签,不会因为"这是个商场"就猜它有厕所(CLAUDE.md:不编造未提供的事实)。
// 国内愿意打这个标签的人不多,能导入的数量本来就有限,属于数据源覆盖问题。
const query = `[out:json][timeout:90];
(
node["amenity"="toilets"](${area});
way["amenity"="toilets"](${area});
relation["amenity"="toilets"](${area});
node["toilets"="yes"](${area});
way["toilets"="yes"](${area});
relation["toilets"="yes"](${area});
);
out center tags;`

Expand Down
92 changes: 92 additions & 0 deletions src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,19 @@ export interface CurrentUser {
id: string
nickname: string | null
isAnonymous: boolean
email: string | null
}

/**
* 发验证码这一步分两种情况,但对用户来说是同一个动作(输入邮箱):
*
* - 'upgrade':当前是匿名身份,第一次绑邮箱,把匿名账号原地升级成正式账号,
* user.id 不变,历史评价不丢(CLAUDE.md 5:升级用 linkIdentity 类似的思路)。
* - 'signin':这个邮箱已经绑过别的账号了 —— 说明是老用户换设备回来找号,
* 改成登录已有账号,这次登录后会换成那个账号的 session。
*/
export type EmailLoginMode = 'upgrade' | 'signin'

/**
* 正在进行中的登录请求。
*
Expand Down Expand Up @@ -89,6 +100,85 @@ export async function setNickname(nickname: string): Promise<Result<CurrentUser>
}
}

/**
* 发验证码。先假设是"给匿名账号首次绑邮箱";如果这个邮箱已经被注册过
* (error.code === 'email_exists'),说明猜错了,改走"登录已有账号"那条路重发一次。
* 两条路用的是不同的验证码类型,所以要把选中的模式带回去,验证时才知道该传哪个 type。
*/
export async function startEmailLogin(email: string): Promise<Result<EmailLoginMode>> {
if (!isSupabaseConfigured) {
return fail('not_configured', 'Supabase 未配置 / Supabase is not configured')
}

const trimmed = email.trim()
if (!trimmed) return fail('invalid_input', '请输入邮箱 / enter an email')

try {
const { data: session } = await supabase.auth.getSession()
const isAnon = session.session?.user?.is_anonymous ?? true

if (isAnon) {
const { error } = await supabase.auth.updateUser({ email: trimmed })
if (!error) return ok('upgrade')
if (error.code !== 'email_exists') return fail(error.code ?? 'auth_error', error.message)
// 邮箱已经被注册过,往下走登录已有账号那条路
}

const { error } = await supabase.auth.signInWithOtp({
email: trimmed,
options: { shouldCreateUser: false },
})
if (error) return fail(error.code ?? 'auth_error', error.message)
return ok('signin')
} catch (e) {
return fromThrown(e, 'auth_error')
}
}

/** 校验用户从邮件里抄回来的 6 位验证码,完成升级或登录。 */
export async function verifyEmailLogin(
email: string,
token: string,
mode: EmailLoginMode,
): Promise<Result<CurrentUser>> {
if (!isSupabaseConfigured) {
return fail('not_configured', 'Supabase 未配置 / Supabase is not configured')
}

const trimmedToken = token.trim()
if (!trimmedToken) return fail('invalid_input', '请输入验证码 / enter the code')

try {
const { data, error } = await supabase.auth.verifyOtp({
email: email.trim(),
token: trimmedToken,
type: mode === 'upgrade' ? 'email_change' : 'email',
})
if (error) return fail(error.code ?? 'auth_error', error.message)
if (!data.user) return fail('auth_error', '验证失败 / verification failed')
return ok(toCurrentUser(data.user))
} catch (e) {
return fromThrown(e, 'auth_error')
}
}

/**
* 退出正式账号,换回一个新的匿名身份 —— 不是"变成没有身份",
* 这个产品从打开就得有身份(发评价要挂 user_id),退出登录只是换一个。
*/
export async function signOut(): Promise<Result<CurrentUser>> {
if (!isSupabaseConfigured) {
return fail('not_configured', 'Supabase 未配置 / Supabase is not configured')
}
try {
const { error } = await supabase.auth.signOut()
if (error) return fail(error.code ?? 'auth_error', error.message)
return signIn()
} catch (e) {
return fromThrown(e, 'auth_error')
}
}

/** 拿当前 access token,给 Edge Function 调用用。 */
export async function getAccessToken(): Promise<string | null> {
if (!isSupabaseConfigured) return null
Expand All @@ -102,6 +192,7 @@ export async function getAccessToken(): Promise<string | null> {

interface AuthUserLike {
id: string
email?: string
is_anonymous?: boolean
user_metadata?: Record<string, unknown>
}
Expand All @@ -112,5 +203,6 @@ function toCurrentUser(user: AuthUserLike): CurrentUser {
id: user.id,
nickname: typeof raw === 'string' && raw ? raw : null,
isAnonymous: user.is_anonymous ?? true,
email: user.email || null,
}
}
13 changes: 12 additions & 1 deletion src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
// UI 层的唯一数据入口。组件只从这里 import,绝不碰 supabase 客户端本身。
export * from './types'
export { isSupabaseConfigured } from './client'
export { ensureSession, getCurrentUser, setNickname, type CurrentUser } from './auth'
export {
ensureSession,
getCurrentUser,
setNickname,
startEmailLogin,
verifyEmailLogin,
signOut,
type CurrentUser,
type EmailLoginMode,
} from './auth'
export {
getNearbyToilets,
getToiletById,
Expand All @@ -19,9 +28,11 @@ export {
generateReview,
createReview,
deleteReview,
getMyReviewStats,
type GenerateInput,
type GeneratedReview,
type CreateReviewInput,
type ReviewStats,
} from './reviews'
export { reportReview } from './reports'
export { searchAddress, type GeocodeResult } from './geocode'
Expand Down
38 changes: 38 additions & 0 deletions src/api/reviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,3 +325,41 @@ export async function deleteReview(id: string): Promise<Result<true>> {
return fromThrown(e, 'db_error')
}
}

export interface ReviewStats {
aiCount: number
manualCount: number
}

/**
* 我的战绩:AI 生成 / 手写评论各写了多少条。
* 两条 count 查询并发,head:true 只要总数不要行数据,比拉全部再数快得多。
*/
export async function getMyReviewStats(): Promise<Result<ReviewStats>> {
if (!isSupabaseConfigured) return ok({ aiCount: 0, manualCount: 0 })

const session = await ensureSession()
if (session.error) return { data: null, error: session.error }

try {
const [ai, manual] = await Promise.all([
supabase
.from('reviews')
.select('id', { count: 'exact', head: true })
.eq('user_id', session.data.id)
.eq('is_ai', true),
supabase
.from('reviews')
.select('id', { count: 'exact', head: true })
.eq('user_id', session.data.id)
.eq('is_ai', false),
])

if (ai.error) return fail(ai.error.code || 'db_error', ai.error.message)
if (manual.error) return fail(manual.error.code || 'db_error', manual.error.message)

return ok({ aiCount: ai.count ?? 0, manualCount: manual.count ?? 0 })
} catch (e) {
return fromThrown(e, 'db_error')
}
}
Loading
Loading