feat(deploy): production deployment setup and fixes

- Debian-based api image (bookworm-slim), docker/debian mirrors, prisma
  binaryTargets for openssl 3.0
- nginx: admin SPA under /admin, TLS via acme.sh (ZeroSSL) + auto-renewal
  cron, http->https redirect
- prisma: add origin_goods.delisted migration, sync missing schema
  (good_image/tag_font_color/good_tags), fix users.createdAt Timestamptz
- api: CORS wildcard reflection, helmet CORP cross-origin, price
  backfill in persistProductDetail, categoryIcon ancestor fallback,
  mediaByColor per-color gallery in public goods detail
- admin: /admin base path (vite + router)
- import-data.mjs: udt_name casting, serial sequence advance fix
This commit is contained in:
yeuimu
2026-08-26 14:23:09 +08:00
parent be0b90e68f
commit 6c61a4e871
982 changed files with 74156 additions and 179393 deletions
+65
View File
@@ -0,0 +1,65 @@
/**
* Export all application tables from the local (source) database to a JSON
* file, preserving column types for the matching import-data.mjs script.
*
* Usage (from apps/api, against the local DB in .env):
* node scripts/export-data.mjs <output.json>
*/
import { PrismaClient } from '@prisma/client';
import { writeFileSync } from 'node:fs';
const TABLES = [
'users',
'countries',
'categories',
'tag_groups',
'tags',
'positions',
'origin_goods',
'origin_good_variants',
'origin_good_details',
'goods',
'good_tags',
'sync_logs',
];
const prisma = new PrismaClient();
// Serialize values losslessly; import side uses information_schema to restore types.
function serialize(value) {
if (value === null || value === undefined) return null;
if (typeof value === 'bigint') return value.toString();
if (value instanceof Date) return value.toISOString();
if (typeof value === 'object' && Buffer.isBuffer(value)) return value.toString('base64');
if (typeof value === 'object') return JSON.stringify(value); // jsonb
return value;
}
async function main() {
const out = process.argv[2];
if (!out) {
console.error('Usage: node scripts/export-data.mjs <output.json>');
process.exit(1);
}
const dump = { exportedAt: new Date().toISOString(), tables: {} };
for (const table of TABLES) {
const rows = await prisma.$queryRawUnsafe(`SELECT * FROM "${table}"`);
dump.tables[table] = rows.map((row) => {
const o = {};
for (const [k, v] of Object.entries(row)) o[k] = serialize(v);
return o;
});
console.log(`${table}: ${rows.length} rows`);
}
writeFileSync(out, JSON.stringify(dump));
console.log(`Wrote ${out}`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+106
View File
@@ -0,0 +1,106 @@
/**
* Import a dump produced by export-data.mjs into the current database.
* Tables are truncated first (order-independent via session_replication_role)
* and columns are cast back to their real types using information_schema.
*
* Usage (inside the api container):
* node scripts/import-data.mjs <dump.json>
*/
import { PrismaClient, Prisma } from '@prisma/client';
import { readFileSync } from 'node:fs';
const prisma = new PrismaClient();
function toLiteral(value, udtName) {
if (value === null) return Prisma.sql`NULL`;
const target = Prisma.raw(`"${udtName}"`);
switch (udtName) {
case 'int2':
case 'int4':
case 'int8':
return Prisma.sql`${BigInt(value)}::${target}`;
case 'float4':
case 'float8':
case 'numeric':
return Prisma.sql`${Number(value)}::${target}`;
case 'bool':
return Prisma.sql`${!!value}::${target}`;
case 'timestamptz':
case 'timestamp':
return Prisma.sql`${new Date(value).toISOString()}::${target}`;
case 'date':
return Prisma.sql`${String(value)}::${target}`;
case 'jsonb':
case 'json':
return Prisma.sql`${typeof value === 'string' ? value : JSON.stringify(value)}::${target}`;
case 'bytea':
return Prisma.sql`${Buffer.from(value, 'base64')}::bytea`;
default:
// text, varchar, enums and anything else: pass as text and cast
return Prisma.sql`${String(value)}::${target}`;
}
}
async function main() {
const file = process.argv[2];
if (!file) {
console.error('Usage: node scripts/import-data.mjs <dump.json>');
process.exit(1);
}
const dump = JSON.parse(readFileSync(file, 'utf8'));
// Suspend FK checks during bulk load (postgres superuser not required for
// session_replication_role in the compose postgres where app user owns db).
await prisma.$executeRawUnsafe(`SET session_replication_role = replica`);
const summary = {};
for (const [table, rows] of Object.entries(dump.tables)) {
if (rows.length === 0) {
summary[table] = 0;
continue;
}
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "${table}" CASCADE`);
const colTypes = {};
const info = await prisma.$queryRawUnsafe(
`SELECT column_name, udt_name FROM information_schema.columns WHERE table_name = '${table}'`,
);
for (const c of info) colTypes[c.column_name] = c.udt_name;
const columns = Object.keys(rows[0]);
const colList = Prisma.raw(columns.map((c) => `"${c}"`).join(', '));
const CHUNK = 200;
for (let i = 0; i < rows.length; i += CHUNK) {
const tuples = rows.slice(i, i + CHUNK).map(
(r) =>
Prisma.sql`(${Prisma.join(
columns.map((c) => toLiteral(r[c], colTypes[c])),
)})`,
);
await prisma.$executeRaw(
Prisma.sql`INSERT INTO ${Prisma.raw(`"${table}"`)} (${colList}) VALUES ${Prisma.join(tuples)}`,
);
}
// Keep sequences ahead of imported serial ids
// Keep serial sequences ahead of imported ids
const idCol = columns.find(
(c) => c === 'id' || c === `${table.replace(/s$/, '')}_id`,
);
if (idCol) {
await prisma.$executeRawUnsafe(
`SELECT setval(pg_get_serial_sequence('"${table}"', '${idCol}'), COALESCE((SELECT MAX("${idCol}") FROM "${table}"), 1))`,
);
}
summary[table] = rows.length;
}
await prisma.$executeRawUnsafe(`SET session_replication_role = DEFAULT`);
console.log('Imported:', JSON.stringify(summary, null, 2));
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+42
View File
@@ -0,0 +1,42 @@
/**
* Set the admin credentials for the production deployment:
* rename/disable any existing admin and create/update user `inkreach`.
*
* Usage (inside the api container): node scripts/set-admin.mjs
* Reads NEW_ADMIN_USER / NEW_ADMIN_PASSWORD from env.
*/
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main() {
const username = process.env.NEW_ADMIN_USER;
const password = process.env.NEW_ADMIN_PASSWORD;
if (!username || !password) {
console.error('NEW_ADMIN_USER / NEW_ADMIN_PASSWORD must be set');
process.exit(1);
}
const passwordHash = await bcrypt.hash(password, 10);
await prisma.user.upsert({
where: { username },
create: { username, passwordHash },
update: { passwordHash, tokenVersion: { increment: 1 } },
});
// Remove every other admin so only `inkreach` can sign in.
const others = await prisma.user.deleteMany({
where: { username: { not: username } },
});
const total = await prisma.user.count();
console.log(`Admin '${username}' set. Demoted ${others.count} other user(s). Total users: ${total}.`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());