feat(api): 开发平台、门店相关代码

This commit is contained in:
R524809
2026-09-03 16:04:44 +08:00
parent 35fcf8776e
commit ada02e9135
40 changed files with 7014 additions and 5807 deletions
+1
View File
@@ -1,2 +1,3 @@
export * from './collection';
export * from './auth';
export * from './stores';
+77
View File
@@ -0,0 +1,77 @@
import { z } from 'zod';
const uuidSchema = z.uuid();
const currencyCodeSchema = z
.string()
.regex(/^[A-Z]{3}$/, '必须是 ISO 4217 大写币种代码');
/** 创建门店时唯一允许提交明文 API Key 的请求契约。 */
export const createStoreRequestSchema = z
.object({
platform_id: uuidSchema,
name: z.string().trim().min(1).max(120),
client_id: z.string().trim().min(1).max(120),
api_key: z.string().min(1).max(8_192),
currency_code: currencyCodeSchema,
is_default: z.boolean().default(false),
note: z.string().trim().max(2_000).nullable().optional(),
})
.strict();
/** 修改门店不允许变更平台或平台账户,避免授权连接身份被悄然替换。 */
export const updateStoreRequestSchema = z
.object({
name: z.string().trim().min(1).max(120).optional(),
currency_code: currencyCodeSchema.optional(),
note: z.string().trim().max(2_000).nullable().optional(),
api_key: z.string().min(1).max(8_192).optional(),
})
.strict()
.refine((value) => Object.keys(value).length > 0, {
message: '至少需要提交一个可修改字段',
});
export const listStoresQuerySchema = z
.object({
platform_id: uuidSchema,
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
})
.strict();
export type CreateStoreRequest = z.infer<typeof createStoreRequestSchema>;
export type UpdateStoreRequest = z.infer<typeof updateStoreRequestSchema>;
export type ListStoresQuery = z.infer<typeof listStoresQuerySchema>;
export type Store = {
id: string;
platform_id: string;
name: string;
platform_client_id: string;
currency_code: string;
is_default: boolean;
note: string | null;
status: string;
credential_status: string;
credential_checked_at: string | null;
last_error: { code: string; message: string; at: string } | null;
api_key_expires_at: string | null;
product_limit: number | null;
product_limit_checked_at: string | null;
warehouse_count: number;
warehouses_synced_at: string | null;
cookie_status: null;
watermark_template: { id: string; name: string } | null;
created_at: string;
updated_at: string;
revision: number;
};
export type Operation = {
id: string;
kind: string;
status: string;
created_at: string;
completed_at: string | null;
error: { code: string; message: string } | null;
};
@@ -0,0 +1,10 @@
-- 平台默认上下文属于平台能力,不属于门店;此迁移同时修正已初始化的本地数据库。
SET search_path TO atlas, public;
UPDATE platforms
SET capabilities = capabilities || jsonb_build_object(
'default_market_code', 'RU',
'default_locale', 'ru-RU'
),
updated_at = now()
WHERE code = 'OZON';
+4
View File
@@ -8,6 +8,7 @@ const migrationsDirectory = join(
dirname(fileURLToPath(import.meta.url)),
'../migrations',
);
// 迁移只能使用拥有 schema/table 的专用角色,绝不允许复用 API 的最小权限连接。
const connectionString = process.env.MIGRATOR_DATABASE_URL;
if (!connectionString) {
@@ -24,6 +25,7 @@ async function migrate() {
.sort();
const client = await pool.connect();
try {
// 迁移账本与业务 DDL 同库保存,用于阻止已应用 migration 被悄悄改写。
await client.query('CREATE SCHEMA IF NOT EXISTS atlas');
await client.query(`CREATE TABLE IF NOT EXISTS atlas.schema_migrations (
filename TEXT PRIMARY KEY,
@@ -41,6 +43,7 @@ async function migrate() {
[filename],
);
if (applied.rowCount) {
// 已应用文件的内容变化意味着历史与当前 schema 可能不一致,必须人工处理。
if (applied.rows[0].checksum !== checksum)
throw new Error(
`Applied migration was modified: ${filename}`,
@@ -49,6 +52,7 @@ async function migrate() {
}
await client.query('BEGIN');
try {
// 单个文件原子提交;失败时不会留下半份 DDL 或 migration 账本记录。
await client.query(sql);
await client.query(
'INSERT INTO atlas.schema_migrations (filename, checksum) VALUES ($1, $2)',
+4 -4
View File
@@ -11,7 +11,7 @@ export class Database {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
// atlas_app_login is NOINHERIT by design; application DML always uses the least-privileged group role.
// atlas_app_login 是 NOINHERIT 登录角色;应用 DML 必须降为最小权限的 atlas_app。
await client.query('SET LOCAL ROLE atlas_app');
const result = await work(client);
await client.query('COMMIT');
@@ -24,13 +24,13 @@ export class Database {
}
}
/** Every tenant-scoped query must run through this method. */
/** 每个带工作空间边界的业务读写都必须通过此方法执行。 */
public async workspaceTransaction<T>(
workspaceId: string,
work: (transaction: DatabaseTransaction) => Promise<T>,
): Promise<T> {
return this.transaction(async (transaction) => {
// SET LOCAL is scoped to this transaction and cannot leak a tenant id through the pool.
// SET LOCAL 仅作用于当前事务,连接归还池后不会泄漏租户 ID。
await transaction.query(
"SELECT set_config('app.workspace_id', $1, true)",
[workspaceId],
@@ -43,7 +43,7 @@ export class Database {
text: string,
values?: unknown[],
) {
// Even non-tenant reads use the restricted role; callers cannot accidentally use the login role's privileges.
// 即使不带租户上下文的查询也使用受限角色,调用方不会误用登录角色权限。
return this.transaction((transaction) =>
transaction.query<Row>(text, values),
);