feat(api): 开发平台、门店相关代码
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
export * from './collection';
|
||||
export * from './auth';
|
||||
export * from './stores';
|
||||
|
||||
@@ -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';
|
||||
@@ -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)',
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user