348e192800
Wire /learn static delivery, parent/child portal routes, and Tang page build scripts for 103 deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
272 lines
8.4 KiB
JavaScript
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,
|
|
};
|
|
}
|