Files
memind/learn-assistant-family.mjs
T
john 348e192800 feat(learn): add family learning assistant API routes and public pages
Wire /learn static delivery, parent/child portal routes, and Tang page build scripts for 103 deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 17:09:52 +08:00

272 lines
8.4 KiB
JavaScript

import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
const TANG_OWNER_ID = 'a70ff537-8908-486e-9b6c-042e07cc25db';
const LEGACY_TANG_SLUG = 'tang';
const INVITE_TTL_MS = 7 * 24 * 3600 * 1000;
async function readJson(filePath, fallback) {
try {
return JSON.parse(await fs.readFile(filePath, 'utf8'));
} catch (error) {
if (error?.code === 'ENOENT') return fallback;
throw error;
}
}
async function writeJson(filePath, value) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
function memberLabel(userId, displayName) {
return displayName?.trim() || `用户 ${String(userId).slice(0, 8)}`;
}
export function createLearnAssistantFamilyRegistry({ dataRoot, seedFamilyDefaults } = {}) {
if (!dataRoot) throw new Error('dataRoot is required');
const registryPath = path.join(dataRoot, 'registry.json');
const invitesDir = path.join(dataRoot, 'invites');
const familiesDir = path.join(dataRoot, 'families');
async function loadRegistry() {
return readJson(registryPath, {
families: {},
slugToFamilyId: {},
userToFamilyId: {},
});
}
async function saveRegistry(registry) {
await writeJson(registryPath, registry);
}
function familyStorePath(familyId) {
return path.join(familiesDir, familyId);
}
async function ensureFamilyStore(familyId) {
await fs.mkdir(familyStorePath(familyId), { recursive: true });
}
async function migrateLegacyOwnerStore(ownerId, familyId) {
const legacyDir = path.join(dataRoot, ownerId);
const targetDir = familyStorePath(familyId);
try {
await fs.access(legacyDir);
} catch {
return { migrated: false };
}
try {
await fs.access(targetDir);
return { migrated: false, reason: 'target_exists' };
} catch {
await fs.mkdir(path.dirname(targetDir), { recursive: true });
await fs.rename(legacyDir, targetDir);
return { migrated: true, from: legacyDir, to: targetDir };
}
}
async function ensureLegacyTangFamily(registry) {
if (registry.slugToFamilyId[LEGACY_TANG_SLUG]) {
return registry.families[registry.slugToFamilyId[LEGACY_TANG_SLUG]];
}
const familyId = crypto.randomUUID();
const now = new Date().toISOString();
const family = {
id: familyId,
slug: LEGACY_TANG_SLUG,
name: '唐家庭',
ownerUserId: TANG_OWNER_ID,
createdAt: now,
members: [{ userId: TANG_OWNER_ID, role: 'owner', joinedAt: now, displayName: '唐' }],
};
registry.families[familyId] = family;
registry.slugToFamilyId[LEGACY_TANG_SLUG] = familyId;
registry.userToFamilyId[TANG_OWNER_ID] = familyId;
await ensureFamilyStore(familyId);
await migrateLegacyOwnerStore(TANG_OWNER_ID, familyId);
if (seedFamilyDefaults) {
try {
await fs.access(path.join(familyStorePath(familyId), 'children.json'));
} catch {
await seedFamilyDefaults(familyId);
}
}
await saveRegistry(registry);
return family;
}
async function getFamilyBySlug(slug) {
const registry = await loadRegistry();
await ensureLegacyTangFamily(registry);
const fresh = await loadRegistry();
const familyId = fresh.slugToFamilyId[String(slug ?? '').trim()];
if (!familyId) {
const err = new Error('家庭不存在');
err.statusCode = 404;
throw err;
}
return fresh.families[familyId];
}
async function getFamilyById(familyId) {
const registry = await loadRegistry();
const family = registry.families[familyId];
if (!family) {
const err = new Error('家庭不存在');
err.statusCode = 404;
throw err;
}
return family;
}
async function getFamilyForUser(userId, { displayName } = {}) {
if (!userId) {
const err = new Error('请先登录 Memind');
err.statusCode = 401;
throw err;
}
let registry = await loadRegistry();
await ensureLegacyTangFamily(registry);
registry = await loadRegistry();
let familyId = registry.userToFamilyId[userId];
if (familyId && registry.families[familyId]) {
return { family: registry.families[familyId], created: false };
}
familyId = crypto.randomUUID();
const slug = `home_${familyId.replace(/-/g, '').slice(0, 8)}`;
const now = new Date().toISOString();
const family = {
id: familyId,
slug,
name: `${memberLabel(userId, displayName)}的家庭`,
ownerUserId: userId,
createdAt: now,
members: [{ userId, role: 'owner', joinedAt: now, displayName: displayName ?? null }],
};
registry.families[familyId] = family;
registry.slugToFamilyId[slug] = familyId;
registry.userToFamilyId[userId] = familyId;
await ensureFamilyStore(familyId);
await saveRegistry(registry);
return { family, created: true };
}
function assertFamilyMember(family, userId) {
const member = family.members.find((m) => m.userId === userId);
if (!member) {
const err = new Error('无权访问该家庭');
err.statusCode = 403;
throw err;
}
return member;
}
async function requireFamilyMember(userId, familyId) {
const family = await getFamilyById(familyId);
assertFamilyMember(family, userId);
return family;
}
async function requireFamilyForUser(userId, { displayName } = {}) {
const { family } = await getFamilyForUser(userId, { displayName });
assertFamilyMember(family, userId);
return family;
}
async function createInvite(userId, { displayName } = {}) {
const family = await requireFamilyForUser(userId, { displayName });
const member = assertFamilyMember(family, userId);
if (member.role !== 'owner') {
const err = new Error('仅主家长可邀请协家长');
err.statusCode = 403;
throw err;
}
const token = crypto.randomBytes(16).toString('hex');
const invite = {
token,
familyId: family.id,
invitedBy: userId,
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + INVITE_TTL_MS).toISOString(),
};
await writeJson(path.join(invitesDir, `${token}.json`), invite);
return { token, familyId: family.id, familySlug: family.slug, expiresAt: invite.expiresAt };
}
async function acceptInvite(userId, token, { displayName } = {}) {
if (!token) {
const err = new Error('邀请码无效');
err.statusCode = 400;
throw err;
}
const invite = await readJson(path.join(invitesDir, `${token}.json`), null);
if (!invite) {
const err = new Error('邀请不存在或已失效');
err.statusCode = 404;
throw err;
}
if (new Date(invite.expiresAt).getTime() < Date.now()) {
const err = new Error('邀请已过期');
err.statusCode = 410;
throw err;
}
const registry = await loadRegistry();
const existingFamilyId = registry.userToFamilyId[userId];
if (existingFamilyId && existingFamilyId !== invite.familyId) {
const err = new Error('你已加入其他家庭,需先退出后再接受新邀请');
err.statusCode = 409;
throw err;
}
const family = registry.families[invite.familyId];
if (!family) {
const err = new Error('家庭不存在');
err.statusCode = 404;
throw err;
}
if (!family.members.some((m) => m.userId === userId)) {
family.members.push({
userId,
role: 'parent',
joinedAt: new Date().toISOString(),
displayName: displayName ?? null,
});
registry.userToFamilyId[userId] = family.id;
registry.families[family.id] = family;
await saveRegistry(registry);
}
await fs.unlink(path.join(invitesDir, `${token}.json`)).catch(() => {});
return { family, joined: true };
}
function familyUrls(family, publicBase = 'https://m.tkmind.cn') {
const childUrl = `${publicBase}/learn/child.html?f=${encodeURIComponent(family.slug)}`;
const parentUrl = `${publicBase}/learn/parent.html?f=${encodeURIComponent(family.slug)}`;
const joinPath = `${publicBase}/learn/join.html?token=`;
return { childUrl, parentUrl, joinPath };
}
return {
LEGACY_TANG_SLUG,
familyStorePath,
loadRegistry,
ensureLegacyTangFamily,
getFamilyBySlug,
getFamilyById,
getFamilyForUser,
requireFamilyForUser,
requireFamilyMember,
createInvite,
acceptInvite,
familyUrls,
resolveFamilyIdFromSlug: async (slug) => (await getFamilyBySlug(slug)).id,
};
}