refactor: 个人自用,移除登录与云同步全链路

站点只给本人用,云乐坊 SSO 授权、收藏/历史云同步、获客归因和账号切换记账都没有收益,却被登录状态检查和同步失败提示持续打扰。

- 删除 useYunlefunAuth / yunlefunAuth / yunlefunSsoConfig / yunlefunAuth 适配器、useUserDataSync / userDataSync / userData 适配器、三个 client 插件与 /auth/callback 回调页
- 抽出 adapters/cloudbase.ts,让「反馈一下」和匿名筛选信号不再依赖登录态;cook-feedback 云函数保留
- 删除 cook-user-data 云函数与 cook_user_data 权限规则,移除 @yunlefun/sso 依赖
- 「我的」页删掉登录按钮、账号卡、四个统计格和退出登录;「关于」页删掉项目作者、致谢名单和上游宣传链接
- 本地收藏/历史与导出导入保留,删除同步专用的 lastUid 记账

取舍见 docs/decisions/0005-drop-login-and-sync.md
This commit is contained in:
toor
2026-10-22 15:52:54 +08:00
committed by trae-solo-agent
parent 7408591fac
commit 8ff7b879b9
54 changed files with 214 additions and 3049 deletions
-145
View File
@@ -1,145 +0,0 @@
import type { AcquisitionContext, AcquisitionTouch } from '@yunlefun/sso'
import { sanitizeAcquisitionContext } from '@yunlefun/sso'
const FIRST_TOUCH_KEY = 'cook:acquisition:v1:first-touch'
const REGISTER_TOUCH_KEY = 'cook:acquisition:v1:register-touch'
const FIRST_TOUCH_TTL_MS = 30 * 24 * 60 * 60 * 1000
const UTM_RE = /^[\p{L}\p{N}\p{M} ._/-]*$/u
let fallback: AcquisitionContext | null = null
const pendingWrites = new Map<string, { previousValue: string | null | undefined, touch: AcquisitionTouch }>()
type AcquisitionBrowser = Pick<Window, 'location' | 'document' | 'localStorage' | 'sessionStorage'>
/** Called once at document entry, before auth can consume or redirect a callback. */
export function captureCookAcquisition(
browser: AcquisitionBrowser | undefined = typeof window === 'undefined' ? undefined : window,
now = Date.now(),
): AcquisitionContext | null {
if (!browser)
return null
try {
if (/^\/auth(?:\/|$)/.test(browser.location.pathname))
return readCookAcquisition(browser, now)
const query = new URLSearchParams(browser.location.search)
const touch = readTouch({
capturedAt: now,
landingHost: browser.location.hostname,
landingPath: browser.location.pathname,
referrerHost: readReferrerHost(browser.document.referrer, browser.location.hostname),
utmSource: readUtm(query.get('utm_source')),
utmMedium: readUtm(query.get('utm_medium')),
utmCampaign: readUtm(query.get('utm_campaign')),
}, now)
if (!touch)
return null
const firstRead = readStoredTouch(browser, 'localStorage', FIRST_TOUCH_KEY, now)
const storedFirst = firstRead.accessible ? firstRead.touch : validFallbackTouch(fallback?.firstTouch, now)
const firstTouch = storedFirst && storedFirst.capturedAt + FIRST_TOUCH_TTL_MS > now
? storedFirst
: touch
fallback = { version: 1, firstTouch, registerTouch: touch }
writeStoredTouch(browser, 'localStorage', FIRST_TOUCH_KEY, firstTouch)
writeStoredTouch(browser, 'sessionStorage', REGISTER_TOUCH_KEY, touch)
return fallback
}
catch {
return readCookAcquisition(browser, now)
}
}
/** Read at auth start so both host and redirect paths use the same entry snapshot. */
export function readCookAcquisition(
browser: AcquisitionBrowser | undefined = typeof window === 'undefined' ? undefined : window,
now = Date.now(),
): AcquisitionContext | null {
if (!browser)
return null
const registerRead = readStoredTouch(browser, 'sessionStorage', REGISTER_TOUCH_KEY, now)
const registerTouch = registerRead.accessible ? registerRead.touch : validFallbackTouch(fallback?.registerTouch, now)
if (!registerTouch) {
// Readable deletion/corruption is authoritative; never resurrect an old campaign from memory.
fallback = null
return null
}
const firstRead = readStoredTouch(browser, 'localStorage', FIRST_TOUCH_KEY, now)
const storedFirst = firstRead.accessible ? firstRead.touch : validFallbackTouch(fallback?.firstTouch, now)
const firstTouch = storedFirst && storedFirst.capturedAt + FIRST_TOUCH_TTL_MS > now
? storedFirst
: registerTouch
fallback = sanitizeAcquisitionContext({ version: 1, firstTouch, registerTouch })
return fallback
}
function readTouch(input: unknown, now: number): AcquisitionTouch | null {
const touch = sanitizeAcquisitionContext({ version: 1, firstTouch: input, registerTouch: input })?.firstTouch
return touch && touch.capturedAt <= now && touch.capturedAt + FIRST_TOUCH_TTL_MS > now ? touch : null
}
function validFallbackTouch(touch: AcquisitionTouch | undefined, now: number): AcquisitionTouch | null {
return touch ? readTouch(touch, now) : null
}
function readStoredTouch(browser: AcquisitionBrowser, storage: 'localStorage' | 'sessionStorage', key: string, now: number) {
let value: string | null
try {
value = browser[storage].getItem(key)
}
catch {
return { accessible: false, touch: null }
}
const pending = pendingWrites.get(key)
if (pending && value === pending.previousValue) {
// Quota failures can leave the previous campaign readable. Prefer the new
// document's unpersisted touch while that exact previous value is unchanged.
const touch = readTouch(pending.touch, now)
if (!touch)
pendingWrites.delete(key)
return { accessible: true, touch }
}
pendingWrites.delete(key)
try {
return { accessible: true, touch: value ? readTouch(JSON.parse(value), now) : null }
}
catch {
return { accessible: true, touch: null }
}
}
function writeStoredTouch(browser: AcquisitionBrowser, storage: 'localStorage' | 'sessionStorage', key: string, touch: AcquisitionTouch) {
let previousValue: string | null | undefined
try {
previousValue = browser[storage].getItem(key)
}
catch {
// The fallback is validated separately when storage access is denied.
}
try {
browser[storage].setItem(key, JSON.stringify(touch))
pendingWrites.delete(key)
}
catch {
// Storage restrictions affect attribution only; auth must remain usable.
pendingWrites.set(key, { previousValue, touch })
}
}
function readReferrerHost(input: string, landingHost: string): string {
try {
const url = new URL(input)
const host = url.hostname.replace(/\.$/, '')
const internal = host === landingHost || host === 'yunle.fun'
|| host.endsWith('.yunle.fun') || host === 'cook.yunyoujun.cn'
return (url.protocol === 'https:' || url.protocol === 'http:')
&& !url.username && !url.password && !internal
? host
: ''
}
catch {
return ''
}
}
function readUtm(input: string | null): string {
const value = input?.trim() ?? ''
return value.length <= 120 && UTM_RE.test(value) ? value : ''
}
-159
View File
@@ -1,159 +0,0 @@
import type { UserData } from './userData'
import {
createEmptyUserData,
isSameUserData,
mergeUserData,
normalizeUserData,
USER_DATA_VERSION,
} from './userData'
export interface UserDataDocument extends UserData {
version: typeof USER_DATA_VERSION
updatedAt: number
}
export interface UserDataRemote {
/** Resolves null when the user has no document yet. */
read: (uid: string) => Promise<unknown | null>
write: (uid: string, document: UserDataDocument) => Promise<void>
}
export interface UserDataLocal {
read: () => UserData
write: (data: UserData) => void
/** The account this device last synced with. */
readLastUid: () => string
writeLastUid: (uid: string) => void
}
export class UserDataSyncError extends Error {
constructor(public readonly code: 'invalid_remote' | 'remote_failed', options?: { cause?: unknown }) {
super(code === 'invalid_remote' ? '云端数据格式无法识别' : '云端同步失败', options)
this.name = 'UserDataSyncError'
}
}
/**
* One round of read → merge → write.
*
* - Same account (or a device that never synced): local and cloud are merged.
* - Another account synced here last: its data is already in its own cloud
* document, so the cloud replaces local instead of leaking into this account.
* - An unrecognised cloud document is never overwritten.
*/
export async function syncUserData(
uid: string,
local: UserDataLocal,
remote: UserDataRemote,
now = Date.now(),
): Promise<UserData> {
let raw: unknown | null
try {
raw = await remote.read(uid)
}
catch (error) {
throw new UserDataSyncError('remote_failed', { cause: error })
}
const cloud = raw === null ? createEmptyUserData() : normalizeUserData(raw)
if (!cloud)
throw new UserDataSyncError('invalid_remote')
const lastUid = local.readLastUid()
const current = local.read()
const base = lastUid && lastUid !== uid ? createEmptyUserData() : current
const merged = mergeUserData(base, cloud, now)
if (!isSameUserData(merged, current))
local.write(merged)
local.writeLastUid(uid)
if (raw === null || !isSameUserData(merged, cloud)) {
try {
await remote.write(uid, { version: USER_DATA_VERSION, ...merged, updatedAt: now })
}
catch (error) {
throw new UserDataSyncError('remote_failed', { cause: error })
}
}
return merged
}
export interface UserDataSyncScheduler {
/** Run a sync after `delay` ms; repeated requests are coalesced. */
request: (delay?: number) => void
stop: () => void
}
export interface UserDataSyncSchedulerOptions {
run: () => Promise<void>
onError?: (error: unknown) => void
/** Defaults to retrying every failure. */
shouldRetry?: (error: unknown) => boolean
maxRetryDelay?: number
}
/**
* Serialises sync rounds: at most one runs at a time, a request made while one
* is running triggers another round afterwards, and failures retry with
* exponential backoff until a round succeeds or `stop()` is called.
*/
export function createUserDataSyncScheduler(options: UserDataSyncSchedulerOptions): UserDataSyncScheduler {
const maxRetryDelay = options.maxRetryDelay ?? 30_000
let timer: ReturnType<typeof setTimeout> | undefined
let running = false
let pending = false
let stopped = false
let retry = 0
function request(delay = 0): void {
stopped = false
if (running) {
pending = true
return
}
clearTimeout(timer)
timer = setTimeout(flush, delay)
}
async function flush(): Promise<void> {
timer = undefined
if (stopped || running)
return
running = true
pending = false
let retryDelay: number | undefined
try {
await options.run()
retry = 0
}
catch (error) {
options.onError?.(error)
if (options.shouldRetry?.(error) ?? true) {
retryDelay = Math.min(maxRetryDelay, 1000 * 2 ** retry)
retry += 1
}
}
finally {
running = false
}
if (stopped)
return
if (pending)
request()
else if (retryDelay !== undefined)
request(retryDelay)
}
return {
request,
stop() {
stopped = true
pending = false
retry = 0
clearTimeout(timer)
timer = undefined
},
}
}
-197
View File
@@ -1,197 +0,0 @@
import type {
SsoAuthorizationResult,
SsoRedirectResult,
} from '@yunlefun/sso'
export interface YunlefunAccount {
avatarUrl?: string
displayName: string
uid: string
}
export type YunlefunAuthStatus
= | 'checking'
| 'error'
| 'idle'
| 'signed-in'
| 'signed-out'
| 'signing-in'
export interface YunlefunAuthSnapshot {
account: YunlefunAccount | null
errorMessage: string
status: YunlefunAuthStatus
}
export type YunlefunAuthorizationResult
= | SsoAuthorizationResult
| Extract<SsoRedirectResult, { ok: false }>
export interface YunlefunAuthorizationAdapter {
consumeInitial: () => Promise<YunlefunAuthorizationResult | null>
start: () => Promise<YunlefunAuthorizationResult | null>
}
export interface YunlefunIdentityAdapter {
adopt: (authorization: SsoAuthorizationResult) => Promise<YunlefunAccount | null>
restore: () => Promise<YunlefunAccount | null>
signOut: () => Promise<void>
}
export interface YunlefunAuthController {
handleAuthorization: (authorization: YunlefunAuthorizationResult) => Promise<void>
initialize: () => Promise<void>
signIn: () => Promise<void>
signOut: () => Promise<void>
snapshot: () => Readonly<YunlefunAuthSnapshot>
}
interface YunlefunAuthControllerOptions {
identity: YunlefunIdentityAdapter
web: YunlefunAuthorizationAdapter
}
export function createYunlefunAuthController(
options: YunlefunAuthControllerOptions,
): YunlefunAuthController {
const authorization = options.web
let initialized = false
let state: YunlefunAuthSnapshot = {
account: null,
errorMessage: '',
status: 'idle',
}
async function initialize(): Promise<void> {
if (initialized)
return
initialized = true
update({ status: 'checking' })
try {
const result = await authorization.consumeInitial()
if (result) {
if (!result.ok) {
applyFailure(result.reason)
return
}
await adopt(result)
return
}
const account = await options.identity.restore()
update({
account,
status: account ? 'signed-in' : 'signed-out',
})
}
catch {
update({
account: null,
errorMessage: '登录状态检查失败,请稍后重试',
status: 'error',
})
}
}
async function signIn(): Promise<void> {
update({
errorMessage: '',
status: 'signing-in',
})
try {
const result = await authorization.start()
if (result)
await handleAuthorization(result)
}
catch {
update({
errorMessage: '无法打开登录授权,请稍后重试',
status: 'error',
})
}
}
async function handleAuthorization(result: YunlefunAuthorizationResult): Promise<void> {
if (!result.ok) {
applyFailure(result.reason)
return
}
try {
await adopt(result)
}
catch {
update({
account: null,
errorMessage: '登录身份验证失败,请重新授权',
status: 'error',
})
}
}
async function signOut(): Promise<void> {
try {
await options.identity.signOut()
update({
account: null,
errorMessage: '',
status: 'signed-out',
})
}
catch {
update({
errorMessage: '退出登录失败,请稍后重试',
status: 'error',
})
}
}
async function adopt(result: SsoAuthorizationResult): Promise<void> {
const account = await options.identity.adopt(result)
if (!account)
throw new Error('SSO identity is unavailable')
update({
account,
errorMessage: '',
status: 'signed-in',
})
}
function applyFailure(reason: Extract<SsoRedirectResult, { ok: false }>['reason']): void {
if (reason === 'not_authenticated') {
update({
account: null,
errorMessage: '',
status: 'signed-out',
})
return
}
const messages = {
access_denied: '登录授权已取消',
invalid_request: '登录请求无效,请刷新后重试',
server_error: '登录服务响应异常,请稍后重试',
} as const
update({
account: null,
errorMessage: messages[reason],
status: 'error',
})
}
function update(patch: Partial<YunlefunAuthSnapshot>): void {
state = { ...state, ...patch }
}
return {
handleAuthorization,
initialize,
signIn,
signOut,
snapshot: () => Object.freeze({ ...state }),
}
}
-114
View File
@@ -1,114 +0,0 @@
import type { RedirectSsoOptions, SsoCodeExchangeOptions } from '@yunlefun/sso'
import {
readSsoClientId,
readSsoRedirectUri,
readSsoScope,
} from '@yunlefun/sso'
export interface YunlefunSsoConfigInput {
clientId?: string
cloudbaseEnv?: string
exchangeUrl?: string
redirectUri?: string
redirectUris?: string | string[]
scope?: string
ssoOrigin?: string
}
export interface YunlefunSsoConfigContext {
currentOrigin: string
}
export interface YunlefunSsoConfig {
cloudbaseEnv: string
exchange: SsoCodeExchangeOptions
redirect: RedirectSsoOptions
}
export type YunlefunSsoConfigResult
= | { ok: true, config: YunlefunSsoConfig }
| { ok: false, message: string }
const CLOUDBASE_ENV_RE = /^[a-z0-9][a-z0-9-]{0,63}$/i
const TRAILING_SLASH_RE = /\/+$/
export function resolveYunlefunSsoConfig(
input: YunlefunSsoConfigInput,
context: YunlefunSsoConfigContext,
): YunlefunSsoConfigResult {
const clientId = readSsoClientId(input.clientId)
const cloudbaseEnv = normalizeValue(input.cloudbaseEnv)
const exchangeUrl = readSsoRedirectUri(input.exchangeUrl)
const webRedirectUris = readWebRedirectUris(input.redirectUris ?? input.redirectUri)
const redirectUri = webRedirectUris.find(uri => new URL(uri).origin === context.currentOrigin)
const scope = readSsoScope(input.scope)
const ssoOrigin = normalizeHttpsOrigin(input.ssoOrigin)
if (!clientId
|| !CLOUDBASE_ENV_RE.test(cloudbaseEnv)
|| !exchangeUrl
|| webRedirectUris.length === 0
|| scope.length === 0
|| !ssoOrigin) {
return {
ok: false,
message: '登录服务尚未完成 SSO v3 配置',
}
}
if (!redirectUri) {
return {
ok: false,
message: '当前域名尚未登记为登录回跳地址',
}
}
return {
ok: true,
config: {
cloudbaseEnv,
exchange: { exchangeUrl },
redirect: {
clientId,
redirectUri,
scope,
ssoOrigin,
},
},
}
}
function readWebRedirectUris(value: string | string[] | undefined): string[] {
const candidates = Array.isArray(value)
? value
: typeof value === 'string'
? value.split(/[\n,]/)
: []
return [...new Set(candidates
.map(candidate => readSsoRedirectUri(candidate.trim()))
.filter((candidate): candidate is string => Boolean(candidate)))]
}
function normalizeHttpsOrigin(value: string | undefined): string {
const normalized = normalizeValue(value).replace(TRAILING_SLASH_RE, '')
if (!normalized)
return ''
try {
const url = new URL(normalized)
return url.protocol === 'https:'
&& !url.username
&& !url.password
&& url.origin === normalized
? url.origin
: ''
}
catch {
return ''
}
}
function normalizeValue(value: string | undefined): string {
return typeof value === 'string' ? value.trim() : ''
}