architecture: 数据唯一权威迁至服务器——本地降级为实时缓存,写后即时推送+快照重建;账号登录转为云端原生(新增 /api/auth/password、/api/auth/delete、每日自动备份)
This commit is contained in:
+114
-107
@@ -1,27 +1,9 @@
|
||||
import Dexie, { type Table } from 'dexie'
|
||||
// 身份认证:账号与密码哈希只保存在服务器(PBKDF2)
|
||||
// 本机仅保存"会话标识"与自动登录凭据(localStorage),用于免重复登录
|
||||
// 业务数据一律在服务器,本机数据库只是缓存(由同步引擎全量刷新)
|
||||
import { bindDatabase, db, unbindDatabase, WorklogDB } from './schema'
|
||||
import { lockVault } from './vault'
|
||||
import { pbkdf2Hash, randomSaltB64 } from '../lib/crypto'
|
||||
|
||||
interface UserRecord {
|
||||
id: string
|
||||
username: string
|
||||
pwd_hash: string
|
||||
pwd_salt: string
|
||||
legacy_imported_at?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 用户注册表:独立小库,仅存用户名与密码哈希,不存任何业务数据
|
||||
class UserRegistry extends Dexie {
|
||||
user!: Table<UserRecord, string>
|
||||
constructor() {
|
||||
super('intern_worklog_users')
|
||||
this.version(1).stores({ user: 'id, &username' })
|
||||
}
|
||||
}
|
||||
|
||||
const registry = new UserRegistry()
|
||||
import { getSyncBase } from '../lib/server'
|
||||
|
||||
const LEGACY_DB_NAME = 'intern_worklog'
|
||||
const SESSION_KEY = 'dsh_session_user'
|
||||
@@ -31,8 +13,8 @@ export interface Session {
|
||||
username: string
|
||||
}
|
||||
|
||||
// 会话持久化到 localStorage:关闭浏览器后仍保持登录,无需每次使用都登录
|
||||
// 安全边界:密码库主密码不随会话保存,每次启动仍需解锁
|
||||
// ---------- 本地会话与凭据 ----------
|
||||
|
||||
export function getSession(): Session | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY)
|
||||
@@ -42,14 +24,16 @@ export function getSession(): Session | null {
|
||||
} catch { /* 忽略损坏会话 */ }
|
||||
return null
|
||||
}
|
||||
|
||||
function setSession(s: Session): void {
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(s))
|
||||
}
|
||||
|
||||
function clearSession(): void {
|
||||
localStorage.removeItem(SESSION_KEY)
|
||||
}
|
||||
|
||||
// 云端凭据:与本地登录同账号同密码,用于登录后自动云同步(保存在本机浏览器)
|
||||
// 云端凭据:同用户名同密码,用于应用启动时免输密码的自动登录
|
||||
const CREDS_KEY = 'dsh_cloud_creds'
|
||||
export function setCloudCreds(username: string, password: string): void {
|
||||
localStorage.setItem(CREDS_KEY, JSON.stringify({ username, password }))
|
||||
@@ -67,6 +51,8 @@ export function clearCloudCreds(): void {
|
||||
localStorage.removeItem(CREDS_KEY)
|
||||
}
|
||||
|
||||
// ---------- 用户数据库(本地缓存实例) ----------
|
||||
|
||||
let openedFor: string | null = null
|
||||
|
||||
export function openUserDatabase(userId: string): void {
|
||||
@@ -76,42 +62,76 @@ export function openUserDatabase(userId: string): void {
|
||||
openedFor = userId
|
||||
}
|
||||
|
||||
export async function createUser(username: string, password: string, migrateLegacy: boolean): Promise<Session> {
|
||||
const name = username.trim()
|
||||
if (name.length < 2 || name.length > 20) throw new Error('用户名需 2 到 20 个字符')
|
||||
if (password.length < 6) throw new Error('登录密码至少 6 位')
|
||||
const exists = await registry.user.where('username').equals(name).first()
|
||||
if (exists) throw new Error('用户名已存在')
|
||||
const salt = randomSaltB64()
|
||||
const hash = await pbkdf2Hash(password, salt)
|
||||
const id = 'u_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 6)
|
||||
await registry.user.add({ id, username: name, pwd_hash: hash, pwd_salt: salt, created_at: new Date().toISOString() })
|
||||
openUserDatabase(id)
|
||||
let copied = 0
|
||||
if (migrateLegacy && await legacyDataExists()) {
|
||||
copied = await migrateLegacyInto()
|
||||
await registry.user.update(id, { legacy_imported_at: new Date().toISOString() })
|
||||
// ---------- 登录 / 注册(全部经由服务器) ----------
|
||||
|
||||
async function serverAuth(path: 'login' | 'register', username: string, password: string): Promise<{ userId: string; username: string }> {
|
||||
const base = await getSyncBase()
|
||||
let resp: Response
|
||||
try {
|
||||
resp = await fetch(base + '/api/auth/' + path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username.trim(), password })
|
||||
})
|
||||
} catch {
|
||||
throw new Error('无法连接同步服务器:请检查网络后重试')
|
||||
}
|
||||
const session: Session = { userId: id, username: name }
|
||||
const j = await resp.json().catch(() => ({}))
|
||||
if (!resp.ok) {
|
||||
const msg = (j && j.error) || ('服务返回 ' + resp.status)
|
||||
if (path === 'register' && resp.status === 409) throw new Error('云端已有同名账号且密码一致,直接登录即可')
|
||||
throw new Error(String(msg))
|
||||
}
|
||||
if (!j.userId || !j.username) throw new Error('登录响应异常')
|
||||
return { userId: String(j.userId), username: String(j.username) }
|
||||
}
|
||||
|
||||
async function establishSession(username: string, password: string, path: 'login' | 'register'): Promise<Session> {
|
||||
const info = await serverAuth(path, username, password)
|
||||
openUserDatabase(info.userId)
|
||||
const session: Session = { userId: info.userId, username: info.username }
|
||||
setSession(session)
|
||||
setCloudCreds(session.username, password)
|
||||
setCloudCreds(info.username, password)
|
||||
return session
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string): Promise<Session> {
|
||||
const name = username.trim()
|
||||
const rec = await registry.user.where('username').equals(name).first()
|
||||
if (!rec) throw new Error('用户不存在')
|
||||
const hash = await pbkdf2Hash(password, rec.pwd_salt)
|
||||
if (hash !== rec.pwd_hash) throw new Error('密码不正确')
|
||||
openUserDatabase(rec.id)
|
||||
const session: Session = { userId: rec.id, username: rec.username }
|
||||
setSession(session)
|
||||
setCloudCreds(session.username, password)
|
||||
return session
|
||||
const name = (username || '').trim()
|
||||
if (!name || !password) throw new Error('请输入用户名与密码')
|
||||
return establishSession(name, password, 'login')
|
||||
}
|
||||
|
||||
export interface RegisterOptions { migrateLegacy?: boolean }
|
||||
|
||||
export async function createUser(username: string, password: string): Promise<Session>
|
||||
export async function createUser(username: string, password: string, migrateLegacy: boolean): Promise<Session>
|
||||
export async function createUser(username: string, password: string, _migrateLegacy?: boolean): Promise<Session> {
|
||||
const name = (username || '').trim()
|
||||
if (name.length < 2 || name.length > 20) throw new Error('用户名需 2 到 20 个字符')
|
||||
if (password.length < 6) throw new Error('登录密码至少 6 位')
|
||||
return establishSession(name, password, 'register')
|
||||
}
|
||||
|
||||
// 启动引导:已保存过凭据则静默登录,直接进入应用
|
||||
export async function bootstrapSession(): Promise<Session | null> {
|
||||
const creds = getCloudCreds()
|
||||
if (!creds) return null
|
||||
try {
|
||||
return await establishSession(creds.username, creds.password, 'login')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function logoutUser(): Promise<void> {
|
||||
lockVault()
|
||||
unbindDatabase()
|
||||
openedFor = null
|
||||
// 保留云端凭据:退出登录后再次打开仍可一键重新进入;如需彻底退出请删除用户
|
||||
clearSession()
|
||||
}
|
||||
|
||||
export function forgetDevice(): void {
|
||||
lockVault()
|
||||
unbindDatabase()
|
||||
openedFor = null
|
||||
@@ -123,67 +143,54 @@ export async function changeLoginPassword(oldPwd: string, newPwd: string): Promi
|
||||
const s = getSession()
|
||||
if (!s) throw new Error('未登录')
|
||||
if (newPwd.length < 6) throw new Error('新密码至少 6 位')
|
||||
const rec = await registry.user.get(s.userId)
|
||||
if (!rec) throw new Error('用户不存在')
|
||||
const oldHash = await pbkdf2Hash(oldPwd, rec.pwd_salt)
|
||||
if (oldHash !== rec.pwd_hash) throw new Error('旧密码不正确')
|
||||
const salt = randomSaltB64()
|
||||
const hash = await pbkdf2Hash(newPwd, salt)
|
||||
await registry.user.update(rec.id, { pwd_hash: hash, pwd_salt: salt })
|
||||
const creds = getCloudCreds()
|
||||
if (!creds || creds.password !== oldPwd) throw new Error('旧密码不正确')
|
||||
const base = await getSyncBase()
|
||||
let resp: Response
|
||||
try {
|
||||
resp = await fetch(base + '/api/auth/password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getCachedToken() },
|
||||
body: JSON.stringify({ oldPassword: oldPwd, newPassword: newPwd })
|
||||
})
|
||||
} catch {
|
||||
throw new Error('无法连接同步服务器')
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const j = await resp.json().catch(() => ({}))
|
||||
throw new Error((j && j.error) || ('修改失败 ' + resp.status))
|
||||
}
|
||||
setCloudCreds(creds.username, newPwd)
|
||||
}
|
||||
|
||||
export async function deleteCurrentUser(): Promise<void> {
|
||||
const s = getSession()
|
||||
if (!s) throw new Error('未登录')
|
||||
lockVault()
|
||||
unbindDatabase()
|
||||
openedFor = null
|
||||
await Dexie.delete(LEGACY_DB_NAME + '_' + s.userId)
|
||||
await registry.user.delete(s.userId)
|
||||
clearSession()
|
||||
clearCloudCreds()
|
||||
const base = await getSyncBase()
|
||||
let resp: Response
|
||||
try {
|
||||
resp = await fetch(base + '/api/auth/delete', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + getCachedToken() }
|
||||
})
|
||||
} catch {
|
||||
throw new Error('无法连接同步服务器')
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const j = await resp.json().catch(() => ({}))
|
||||
throw new Error((j && j.error) || ('删除失败 ' + resp.status))
|
||||
}
|
||||
forgetDevice()
|
||||
}
|
||||
|
||||
// 旧单机数据检测与迁移(迁移只复制,不删除原库,作为安全兜底)
|
||||
// 令牌由同步引擎负责登录换取/续期;此处读取共享的本地令牌副本
|
||||
import { getToken } from '../lib/server'
|
||||
function getCachedToken(): string {
|
||||
return getToken()
|
||||
}
|
||||
|
||||
// 兼容旧导出:纯在线模式下不再存在本机旧数据迁移(账号与数据都在服务器)
|
||||
export async function legacyDataExists(): Promise<boolean> {
|
||||
return Dexie.exists(LEGACY_DB_NAME)
|
||||
}
|
||||
|
||||
async function migrateLegacyInto(): Promise<number> {
|
||||
if (!db) throw new Error('用户数据库未就绪')
|
||||
const legacy = new WorklogDB(LEGACY_DB_NAME)
|
||||
const legacyData = await Promise.all([
|
||||
legacy.project.toArray(),
|
||||
legacy.work_log.toArray(),
|
||||
legacy.log_item.toArray(),
|
||||
legacy.attendance.toArray(),
|
||||
legacy.weekly_note.toArray(),
|
||||
legacy.setting.toArray(),
|
||||
legacy.memo.toArray(),
|
||||
legacy.credential.toArray()
|
||||
])
|
||||
await db.transaction('rw', [db.project, db.work_log, db.log_item, db.attendance, db.weekly_note, db.setting, db.memo, db.credential], async () => {
|
||||
await Promise.all([
|
||||
db.project.clear(), db.work_log.clear(), db.log_item.clear(),
|
||||
db.attendance.clear(), db.weekly_note.clear(), db.setting.clear(),
|
||||
db.memo.clear(), db.credential.clear()
|
||||
])
|
||||
if (legacyData[0].length) await db.project.bulkAdd(legacyData[0] as never[])
|
||||
if (legacyData[1].length) await db.work_log.bulkAdd(legacyData[1] as never[])
|
||||
if (legacyData[2].length) await db.log_item.bulkAdd(legacyData[2] as never[])
|
||||
if (legacyData[3].length) await db.attendance.bulkAdd(legacyData[3] as never[])
|
||||
if (legacyData[4].length) await db.weekly_note.bulkAdd(legacyData[4] as never[])
|
||||
if (legacyData[5].length) await db.setting.bulkAdd(legacyData[5] as never[])
|
||||
if (legacyData[6].length) await db.memo.bulkAdd(legacyData[6] as never[])
|
||||
if (legacyData[7].length) await db.credential.bulkAdd(legacyData[7] as never[])
|
||||
})
|
||||
const count = legacyData[0].length + legacyData[1].length + legacyData[2].length +
|
||||
legacyData[3].length + legacyData[4].length + legacyData[6].length + legacyData[7].length
|
||||
legacy.close()
|
||||
return count
|
||||
}
|
||||
|
||||
function showToastCopied(count: number): void {
|
||||
// 迁移完成的反馈由登录后的轻提示呈现(此处在会话建立前,暂存标记)
|
||||
try { sessionStorage.setItem('dsh_migrate_toast', String(count)) } catch { /* 忽略 */ }
|
||||
void LEGACY_DB_NAME
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user