diff --git a/app/schema.sql b/app/schema.sql index e5a5486..b41ef94 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -203,6 +203,15 @@ CREATE TRIGGER IF NOT EXISTS footprint_au AFTER UPDATE OF search_text ON footpri END; CREATE INDEX IF NOT EXISTS idx_footprint_start ON footprint(start_date); +CREATE TABLE IF NOT EXISTS profile ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT, + content TEXT NOT NULL DEFAULT '{}', + deleted_at TEXT, + origin TEXT, + hlc TEXT +); + CREATE INDEX IF NOT EXISTS idx_footprint_type ON footprint(type_id); CREATE INDEX IF NOT EXISTS idx_footprint_group ON footprint(group_id); CREATE INDEX IF NOT EXISTS idx_footprint_hlc ON footprint(hlc); diff --git a/app/server/db.mjs b/app/server/db.mjs index 5d9c863..c059dfb 100644 --- a/app/server/db.mjs +++ b/app/server/db.mjs @@ -1107,6 +1107,34 @@ function listFootprints(d, query) { return { ok: true }; } + case 'profile_get': { + // 个人信息(单行 JSON):供个人信息页与简历生成使用 + const row = d.prepare('SELECT * FROM profile WHERE deleted_at IS NULL ORDER BY id DESC LIMIT 1').get(); + let content = {}; + if (row) { try { content = JSON.parse(row.content || '{}'); } catch { content = {}; } } + return { content }; + } + + case 'profile_upsert': { + const p = payload; + // 单行语义:更新现有行或插入首行 + const content = JSON.stringify(p.content ?? {}); + const existing = d.prepare('SELECT * FROM profile WHERE deleted_at IS NULL ORDER BY id DESC LIMIT 1').get(); + withTx(d, () => { + const h = hlcNext(d); + if (existing) { + d.prepare('UPDATE profile SET content=?, hlc=? WHERE id=?').run(content, h, existing.id); + touchOplog(d, 'profile', existing.uuid, 'upsert', h); + } else { + const uuid = uuidv7(); + d.prepare('INSERT INTO profile (content, uuid, hlc, origin) VALUES (?,?,?,?)') + .run(content, uuid, h, getDeviceId(d)); + touchOplog(d, 'profile', uuid, 'upsert', h); + } + }); + return { ok: true }; + } + case 'view_list': return { items: d.prepare('SELECT * FROM saved_view WHERE deleted_at IS NULL ORDER BY id').all().map(v => ({ ...v, filter: JSON.parse(v.filter || '{}'), sort: JSON.parse(v.sort || '{}'), is_default: !!v.is_default diff --git a/scripts/test-integration.mjs b/scripts/test-integration.mjs index 99242da..289315f 100644 --- a/scripts/test-integration.mjs +++ b/scripts/test-integration.mjs @@ -86,6 +86,16 @@ ok(tSet.body?.ok === true, 'attachment_set_thumb 回填缩略图', tSet.body); const tGet = await api(A, 'POST', '/api/attachment_thumb', { id: rawUp.b?.data?.item?.id }, T); ok(tGet.body?.data?.data_b64 === Buffer.from('RAWSUMB').toString('base64'), 'attachment_thumb 读回 raw 缩略图', tGet.body); +// 个人信息:upsert → get 往返 +const profileContent = { info: { name: '张三', school: '重庆城市科技学院', major: '网络工程' }, resume: { selfIntro: '热爱安全研究' } }; +const pUp = await api(A, 'POST', '/api/profile_upsert', { content: profileContent }, T); +ok(pUp.body?.ok === true, 'profile_upsert', pUp.body); +const pGet = await api(A, 'POST', '/api/profile_get', {}, T); +ok(pGet.body?.data?.content?.info?.name === '张三' && pGet.body?.data?.content?.resume?.selfIntro === '热爱安全研究', 'profile_get 往返一致', pGet.body); +const pUp2 = await api(A, 'POST', '/api/profile_upsert', { content: { ...profileContent, info: { ...profileContent.info, name: '李四' } } }, T); +const pGet2 = await api(A, 'POST', '/api/profile_get', {}, T); +ok(pGet2.body?.data?.content?.info?.name === '李四', 'profile 单行更新语义', pGet2.body); + // 删除整条记录 → 附件级联清理 const fpDel = await api(A, 'POST', '/api/footprint_delete', { id: fpId }, T); ok(fpDel.body?.ok === true, '删除整条记录'); diff --git a/sync-server/src/index.mjs b/sync-server/src/index.mjs index f80bd03..9377651 100644 --- a/sync-server/src/index.mjs +++ b/sync-server/src/index.mjs @@ -319,6 +319,20 @@ const server = http.createServer(async (req, res) => { }); } + // 个人信息证件照:经图床网关直传 COS,返回访问 URL + if (req.method === 'POST' && route === '/api/profile_photo') { + const body = await readJson(req); + if (!LSKY_API || !LSKY_TOKEN) return send(400, false, '未配置图床,无法上传证件照'); + try { + const buf = Buffer.from(body.data_b64 || '', 'base64'); + if (!buf.length) return send(400, false, '空文件'); + const up = await lskyUpload(buf, body.file_name || 'profile-photo.png', body.mime || 'image/png'); + return send(200, true, { url: sanitizeImageUrl(up.url) }); + } catch (e) { + return send(500, false, '证件照上传失败: ' + e.message); + } + } + // 附件上传(原始二进制 PUT):无 base64 膨胀;图床上传改为后台异步,保存立即返回 if (req.method === 'PUT' && route === '/sync/attachment/raw') { const q = url.searchParams;