feat: add local auth billing and usage management
This commit is contained in:
150
scripts/bootstrap-admin.mjs
Normal file
150
scripts/bootstrap-admin.mjs
Normal file
@@ -0,0 +1,150 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { randomBytes, scryptSync } from "node:crypto";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
loadEnvFile(".env");
|
||||
loadEnvFile(".env.local");
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const phone = normalizePhone(args.phone || process.env.ZHINIAN_BOOTSTRAP_ADMIN_PHONE || "");
|
||||
const password = args.password || process.env.ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD || "";
|
||||
const displayName = args.name || process.env.ZHINIAN_BOOTSTRAP_ADMIN_NAME || "平台超级管理员";
|
||||
|
||||
if (!/^\+?[0-9]{6,20}$/.test(phone)) fail("请通过 --phone 或 ZHINIAN_BOOTSTRAP_ADMIN_PHONE 提供有效手机号。");
|
||||
if (password.length < 8) fail("请通过 --password 或 ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD 提供至少 8 位密码。");
|
||||
|
||||
const credential = hashPassword(password);
|
||||
const now = new Date().toISOString();
|
||||
const supabase = getSupabase();
|
||||
|
||||
if (supabase) {
|
||||
const { data: existing, error: lookupError } = await supabase.from("platform_users").select("id, role, password_hash").eq("role", "super_admin").limit(1).maybeSingle();
|
||||
if (lookupError) fail(lookupError.message);
|
||||
const { data: phoneOwner, error: phoneLookupError } = await supabase.from("platform_users").select("id").eq("phone", phone).limit(1).maybeSingle();
|
||||
if (phoneLookupError) fail(phoneLookupError.message);
|
||||
if (phoneOwner && phoneOwner.id !== existing?.id) fail("该手机号已经绑定其他账号,不能初始化为超级管理员。");
|
||||
if (existing && existing.password_hash) fail("平台已经存在超级管理员,初始化已停止。");
|
||||
if (existing) {
|
||||
const { error } = await supabase.from("platform_users").update({
|
||||
phone,
|
||||
display_name: displayName,
|
||||
password_hash: credential.hash,
|
||||
password_salt: credential.salt,
|
||||
status: "active",
|
||||
failed_login_count: 0,
|
||||
locked_until: null,
|
||||
session_version: 1,
|
||||
updated_at: now
|
||||
}).eq("id", existing.id);
|
||||
if (error) fail(error.message);
|
||||
console.log(`已初始化超级管理员:${phone}(${existing.id})`);
|
||||
} else {
|
||||
const user = {
|
||||
id: `user_${randomBytes(8).toString("hex")}`,
|
||||
phone,
|
||||
display_name: displayName,
|
||||
role: "super_admin",
|
||||
organization_id: null,
|
||||
status: "active",
|
||||
password_hash: credential.hash,
|
||||
password_salt: credential.salt,
|
||||
failed_login_count: 0,
|
||||
locked_until: null,
|
||||
session_version: 1,
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
};
|
||||
const { error } = await supabase.from("platform_users").insert(user);
|
||||
if (error) fail(error.message);
|
||||
console.log(`已初始化超级管理员:${phone}(${user.id})`);
|
||||
}
|
||||
} else {
|
||||
const dataDirectory = process.env.ZHINIAN_DATA_DIR || join(process.cwd(), ".runtime", "data");
|
||||
await mkdir(dataDirectory, { recursive: true });
|
||||
const path = join(dataDirectory, "platform-accounts.json");
|
||||
const state = await readState(path);
|
||||
const existing = state.users.find((user) => user.role === "super_admin");
|
||||
const phoneOwner = state.users.find((user) => user.phone === phone && user.id !== existing?.id);
|
||||
if (phoneOwner) fail("该手机号已经绑定其他账号,不能初始化为超级管理员。");
|
||||
if (existing && existing.passwordHash) fail("平台已经存在超级管理员,初始化已停止。");
|
||||
const user = existing || {
|
||||
id: `user_${randomBytes(8).toString("hex")}`,
|
||||
phone,
|
||||
displayName,
|
||||
role: "super_admin",
|
||||
status: "active",
|
||||
failedLoginCount: 0,
|
||||
sessionVersion: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
Object.assign(user, {
|
||||
phone,
|
||||
displayName,
|
||||
passwordHash: credential.hash,
|
||||
passwordSalt: credential.salt,
|
||||
organizationId: undefined,
|
||||
failedLoginCount: 0,
|
||||
lockedUntil: undefined,
|
||||
sessionVersion: 1,
|
||||
updatedAt: now
|
||||
});
|
||||
if (!existing) state.users.push(user);
|
||||
await writeFile(path, JSON.stringify(state, null, 2));
|
||||
console.log(`已初始化超级管理员:${phone}(${user.id})`);
|
||||
}
|
||||
|
||||
function getSupabase() {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
return url && key ? createClient(url, key, { auth: { persistSession: false } }) : null;
|
||||
}
|
||||
|
||||
function hashPassword(value) {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
return { salt, hash: scryptSync(value, salt, 64).toString("hex") };
|
||||
}
|
||||
|
||||
function normalizePhone(value) {
|
||||
return value.trim().replace(/[\s()-]/g, "");
|
||||
}
|
||||
|
||||
function parseArgs(values) {
|
||||
const result = {};
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const value = values[index];
|
||||
if (!value.startsWith("--")) continue;
|
||||
result[value.slice(2)] = values[index + 1] && !values[index + 1].startsWith("--") ? values[++index] : "true";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function readState(path) {
|
||||
if (!existsSync(path)) return { users: [], organizations: [], migrations: [] };
|
||||
try {
|
||||
return JSON.parse(await readFile(path, "utf8"));
|
||||
} catch {
|
||||
return { users: [], organizations: [], migrations: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function loadEnvFile(path) {
|
||||
if (!existsSync(path)) return;
|
||||
const text = requireFile(path);
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)\s*$/);
|
||||
if (!match || process.env[match[1]]) continue;
|
||||
process.env[match[1]] = match[2].replace(/^['"]|['"]$/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
function requireFile(path) {
|
||||
return readFileSync(path, "utf8");
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`初始化失败:${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
226
scripts/import-legacy-accounts.mjs
Normal file
226
scripts/import-legacy-accounts.mjs
Normal file
@@ -0,0 +1,226 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { randomBytes, scryptSync } from "node:crypto";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
loadEnvFile(".env");
|
||||
loadEnvFile(".env.local");
|
||||
|
||||
const inputPath = process.argv[2];
|
||||
if (!inputPath) fail("用法:npm run migrate:accounts -- path/to/legacy-accounts.json");
|
||||
|
||||
const input = JSON.parse(await readFile(inputPath, "utf8"));
|
||||
const accounts = Array.isArray(input.accounts) ? input.accounts : [];
|
||||
if (!accounts.length) fail("迁移文件中的 accounts 不能为空。");
|
||||
const organizations = Array.isArray(input.organizations) ? input.organizations : [];
|
||||
const supabase = getSupabase();
|
||||
|
||||
if (supabase) {
|
||||
await migrateSupabase(accounts, organizations, supabase);
|
||||
} else {
|
||||
await migrateLocal(accounts, organizations);
|
||||
}
|
||||
|
||||
console.log(`已迁移 ${accounts.length} 个账号及其历史归属。`);
|
||||
|
||||
async function migrateSupabase(accounts, organizations, supabase) {
|
||||
for (const organization of organizations) {
|
||||
if (!organization?.id || !organization?.name) continue;
|
||||
const { error } = await supabase.from("platform_organizations").upsert({
|
||||
id: String(organization.id),
|
||||
name: String(organization.name),
|
||||
status: organization.status === "disabled" ? "disabled" : "active",
|
||||
archive_owner_id: `archive:${organization.id}`
|
||||
}, { onConflict: "id" });
|
||||
if (error) fail(error.message);
|
||||
}
|
||||
for (const account of accounts) {
|
||||
const record = normalizeAccount(account);
|
||||
const { data: existing, error: lookupError } = await supabase.from("platform_users").select("id").eq("phone", record.phone).maybeSingle();
|
||||
if (lookupError) fail(lookupError.message);
|
||||
const userId = existing?.id || `user_${randomBytes(8).toString("hex")}`;
|
||||
const credential = hashPassword(record.password);
|
||||
const now = new Date().toISOString();
|
||||
const { error: userError } = await supabase.from("platform_users").upsert({
|
||||
id: userId,
|
||||
phone: record.phone,
|
||||
display_name: record.displayName,
|
||||
role: record.role,
|
||||
organization_id: record.organizationId || null,
|
||||
status: "active",
|
||||
password_hash: credential.hash,
|
||||
password_salt: credential.salt,
|
||||
failed_login_count: 0,
|
||||
locked_until: null,
|
||||
session_version: 1,
|
||||
legacy_subject: record.legacyOwnerId,
|
||||
updated_at: now
|
||||
}, { onConflict: "id" });
|
||||
if (userError) fail(userError.message);
|
||||
await reassignSupabaseOwner(supabase, record.legacyOwnerId, userId, record);
|
||||
const { error: mappingError } = await supabase.from("platform_account_migrations").upsert({
|
||||
id: `migration_${randomBytes(8).toString("hex")}`,
|
||||
legacy_owner_id: record.legacyOwnerId,
|
||||
legacy_phone: record.phone,
|
||||
platform_user_id: userId
|
||||
}, { onConflict: "legacy_owner_id" });
|
||||
if (mappingError) fail(mappingError.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function reassignSupabaseOwner(supabase, legacyOwnerId, userId, account) {
|
||||
for (const table of ["assets", "generation_jobs", "projects", "image_templates"] ) {
|
||||
const { error } = await supabase.from(table).update({ owner_id: userId }).eq("owner_id", legacyOwnerId);
|
||||
if (error) fail(error.message);
|
||||
}
|
||||
const usagePatch = {
|
||||
owner_id: userId,
|
||||
account_username: account.phone,
|
||||
account_display_name: account.displayName,
|
||||
organization_id: account.organizationId || null
|
||||
};
|
||||
const { error } = await supabase.from("usage_events").update(usagePatch).eq("owner_id", legacyOwnerId);
|
||||
if (error) fail(error.message);
|
||||
}
|
||||
|
||||
async function migrateLocal(accounts, organizations) {
|
||||
const dataDirectory = process.env.ZHINIAN_DATA_DIR || join(process.cwd(), ".runtime", "data");
|
||||
await mkdir(dataDirectory, { recursive: true });
|
||||
const path = join(dataDirectory, "platform-accounts.json");
|
||||
const state = await readState(path);
|
||||
const now = new Date().toISOString();
|
||||
for (const organization of organizations) {
|
||||
if (!organization?.id || !organization?.name) continue;
|
||||
const next = {
|
||||
id: String(organization.id),
|
||||
name: String(organization.name),
|
||||
status: organization.status === "disabled" ? "disabled" : "active",
|
||||
archiveOwnerId: `archive:${organization.id}`,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
const index = state.organizations.findIndex((item) => item.id === next.id);
|
||||
if (index >= 0) state.organizations[index] = { ...state.organizations[index], ...next };
|
||||
else state.organizations.push(next);
|
||||
}
|
||||
for (const account of accounts) {
|
||||
const record = normalizeAccount(account);
|
||||
const credential = hashPassword(record.password);
|
||||
let user = state.users.find((item) => item.phone === record.phone);
|
||||
if (!user) {
|
||||
user = {
|
||||
id: `user_${randomBytes(8).toString("hex")}`,
|
||||
phone: record.phone,
|
||||
displayName: record.displayName,
|
||||
role: record.role,
|
||||
organizationId: record.organizationId,
|
||||
status: "active",
|
||||
failedLoginCount: 0,
|
||||
sessionVersion: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
state.users.push(user);
|
||||
}
|
||||
Object.assign(user, {
|
||||
displayName: record.displayName,
|
||||
role: record.role,
|
||||
organizationId: record.organizationId,
|
||||
status: "active",
|
||||
passwordHash: credential.hash,
|
||||
passwordSalt: credential.salt,
|
||||
failedLoginCount: 0,
|
||||
lockedUntil: undefined,
|
||||
sessionVersion: (user.sessionVersion || 1) + 1,
|
||||
legacySubject: record.legacyOwnerId,
|
||||
updatedAt: now
|
||||
});
|
||||
reassignLocalOwner(state, record.legacyOwnerId, user.id, record);
|
||||
const migration = {
|
||||
id: `migration_${randomBytes(8).toString("hex")}`,
|
||||
legacyOwnerId: record.legacyOwnerId,
|
||||
legacyPhone: record.phone,
|
||||
platformUserId: user.id,
|
||||
createdAt: now
|
||||
};
|
||||
const mappingIndex = state.migrations.findIndex((item) => item.legacyOwnerId === record.legacyOwnerId);
|
||||
if (mappingIndex >= 0) state.migrations[mappingIndex] = migration;
|
||||
else state.migrations.push(migration);
|
||||
}
|
||||
await writeFile(path, JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
function reassignLocalOwner(state, legacyOwnerId, userId, account) {
|
||||
for (const collection of [state.assets, state.generationJobs, state.projects, state.imageTemplates]) {
|
||||
for (const item of collection) if (item.ownerId === legacyOwnerId) item.ownerId = userId;
|
||||
}
|
||||
for (const event of state.usageEvents) {
|
||||
if (event.ownerId !== legacyOwnerId) continue;
|
||||
event.ownerId = userId;
|
||||
event.accountUsername = account.phone;
|
||||
event.accountDisplayName = account.displayName;
|
||||
event.organizationId = account.organizationId;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAccount(account) {
|
||||
if (!account?.legacyOwnerId || !account?.phone || !account?.password || !account?.displayName) {
|
||||
fail("每个账号必须提供 legacyOwnerId、phone、displayName 和 password。");
|
||||
}
|
||||
const phone = String(account.phone).trim().replace(/[\s()-]/g, "");
|
||||
if (!/^\+?[0-9]{6,20}$/.test(phone)) fail(`手机号格式不正确:${phone}`);
|
||||
const role = account.role === "super_admin" || account.role === "organization_admin" ? account.role : "user";
|
||||
if (role !== "super_admin" && !account.organizationId) fail(`普通账号缺少 organizationId:${phone}`);
|
||||
return {
|
||||
legacyOwnerId: String(account.legacyOwnerId),
|
||||
phone,
|
||||
displayName: String(account.displayName).trim(),
|
||||
password: String(account.password),
|
||||
role,
|
||||
organizationId: account.organizationId ? String(account.organizationId) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
function hashPassword(value) {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
return { salt, hash: scryptSync(value, salt, 64).toString("hex") };
|
||||
}
|
||||
|
||||
function getSupabase() {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
return url && key ? createClient(url, key, { auth: { persistSession: false } }) : null;
|
||||
}
|
||||
|
||||
function loadEnvFile(path) {
|
||||
if (!existsSync(path)) return;
|
||||
for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)\s*$/);
|
||||
if (!match || process.env[match[1]]) continue;
|
||||
process.env[match[1]] = match[2].replace(/^['"]|['"]$/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`迁移失败:${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function readState(path) {
|
||||
try {
|
||||
const raw = JSON.parse(await readFile(path, "utf8"));
|
||||
return {
|
||||
users: Array.isArray(raw.users) ? raw.users : [],
|
||||
organizations: Array.isArray(raw.organizations) ? raw.organizations : [],
|
||||
migrations: Array.isArray(raw.migrations) ? raw.migrations : [],
|
||||
assets: Array.isArray(raw.assets) ? raw.assets : [],
|
||||
generationJobs: Array.isArray(raw.generationJobs) ? raw.generationJobs : [],
|
||||
usageEvents: Array.isArray(raw.usageEvents) ? raw.usageEvents : [],
|
||||
projects: Array.isArray(raw.projects) ? raw.projects : [],
|
||||
imageTemplates: Array.isArray(raw.imageTemplates) ? raw.imageTemplates : []
|
||||
};
|
||||
} catch {
|
||||
return { users: [], organizations: [], migrations: [], assets: [], generationJobs: [], usageEvents: [], projects: [], imageTemplates: [] };
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,11 @@ console.log(JSON.stringify({
|
||||
'/',
|
||||
'/create',
|
||||
'/assets',
|
||||
'/image-edit',
|
||||
'/billing',
|
||||
'/settings'
|
||||
],
|
||||
imageCapabilities: [
|
||||
'image.generate',
|
||||
'image.inpaint',
|
||||
'image.upscale'
|
||||
'image.generate'
|
||||
],
|
||||
videoCapabilities: [
|
||||
'video.generate'
|
||||
|
||||
Reference in New Issue
Block a user