feat(extension): 采集插件功能开发
This commit is contained in:
@@ -10,6 +10,7 @@ import { StoresModule } from './stores/stores.module.js';
|
||||
import { WatermarksModule } from './watermarks/watermarks.module.js';
|
||||
import { MediaModule } from './media/media.module.js';
|
||||
import { CollectionModule } from './collection/collection.module.js';
|
||||
import { ExtensionModule } from './extension/extension.module.js';
|
||||
|
||||
@Controller()
|
||||
class HealthController {
|
||||
@@ -47,6 +48,7 @@ class HealthController {
|
||||
WatermarksModule,
|
||||
MediaModule,
|
||||
CollectionModule,
|
||||
ExtensionModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
|
||||
@@ -51,6 +51,8 @@ export type AccessTokenClaims = {
|
||||
exp: number;
|
||||
iat: number;
|
||||
};
|
||||
|
||||
export type ExtensionAccessTokenClaims = AccessTokenClaims & { typ: 'extension'; wid: string };
|
||||
export function signAccessToken(
|
||||
claims: AccessTokenClaims,
|
||||
secret: string,
|
||||
@@ -92,3 +94,20 @@ export function verifyAccessToken(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Extension tokens have an explicit audience so they can never be accepted by
|
||||
* the normal Web/API guard, even though both are signed by the same key. */
|
||||
export function signExtensionAccessToken(
|
||||
claims: ExtensionAccessTokenClaims,
|
||||
secret: string,
|
||||
): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
||||
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
||||
const signature = createHmac('sha256', secret).update(`${header}.${payload}`).digest('base64url');
|
||||
return `${header}.${payload}.${signature}`;
|
||||
}
|
||||
|
||||
export function verifyExtensionAccessToken(token: string, secret: string): ExtensionAccessTokenClaims | null {
|
||||
const claims = verifyAccessToken(token, secret) as Partial<ExtensionAccessTokenClaims> | null;
|
||||
return claims?.typ === 'extension' ? claims as ExtensionAccessTokenClaims : null;
|
||||
}
|
||||
|
||||
@@ -16,5 +16,6 @@ import { IdempotencyService } from '../common/idempotency.service.js';
|
||||
{ provide: APP_GUARD, useExisting: AccessTokenGuard },
|
||||
{ provide: APP_GUARD, useExisting: WorkspaceContextGuard },
|
||||
],
|
||||
exports: [AuthService, IdempotencyService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -176,6 +176,26 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolves an existing Web refresh cookie without rotating it. This is used
|
||||
* only to complete an interactive extension authorization request in the
|
||||
* user's browser; the cookie itself is never returned to the extension. */
|
||||
public async resolveWebRefreshSession(refreshToken: string | undefined) {
|
||||
if (!refreshToken) return null;
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const sessions = await transaction.query<UserRow & { session_id: string }>(
|
||||
`SELECT u.id, u.email, u.display_name, u.avatar_asset_id, u.email_verified_at, u.created_at,
|
||||
u.revision, u.status, u.password_hash, s.id AS session_id
|
||||
FROM atlas.user_sessions s JOIN atlas.users u ON u.id = s.user_id
|
||||
WHERE s.refresh_token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now()`,
|
||||
[tokenHash(refreshToken)],
|
||||
);
|
||||
const user = sessions.rows[0];
|
||||
if (!user || user.status !== 'active') return null;
|
||||
const workspace = await findWorkspaceInTransaction(transaction, user.id);
|
||||
return { userId: user.id, workspace };
|
||||
});
|
||||
}
|
||||
|
||||
public async logout(sessionId: string) {
|
||||
await this.database.query(
|
||||
'UPDATE atlas.user_sessions SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL',
|
||||
|
||||
@@ -14,6 +14,9 @@ export class CollectionController {
|
||||
if (!platformId) throw new ApiError(400, 'PLATFORM_CONTEXT_REQUIRED', '请求缺少 X-Atlas-Platform-Id');
|
||||
return this.collection.list(workspace.id, requireUuid('X-Atlas-Platform-Id', platformId), parse(listCollectionItemsQuerySchema, query));
|
||||
}
|
||||
@Get(':id') async detail(@Param('id') id: string, @CurrentWorkspace() workspace: Workspace) {
|
||||
return { data: await this.collection.detail(workspace.id, requireUuid('collection_item_id', id)) };
|
||||
}
|
||||
@Post() async create(@Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) {
|
||||
const result = await this.collection.create(workspace.id, request.auth.userId, requireKey(key), parse(createCollectionItemRequestSchema, body)); response.status(result.status); return result.body;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,16 @@ export class CollectionService {
|
||||
const item = await tx.query<{ id: string }>(`INSERT INTO atlas.collection_items (workspace_id, source_kind, source_item_key, source_title, note, status) VALUES (current_setting('app.workspace_id')::uuid, 'manual', $1, $2, $3, 'ready_for_edit') RETURNING id`, [randomUUID(), input.title ?? null, input.note ?? null]);
|
||||
const product = await tx.query<{ id: string }>(`INSERT INTO atlas.products (workspace_id, collection_item_id, source_title) VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`, [item.rows[0].id, input.title ?? null]);
|
||||
await tx.query(`INSERT INTO atlas.product_variants (workspace_id, product_id, display_name, sort_order) VALUES (current_setting('app.workspace_id')::uuid, $1, '默认 SKU', 0)`, [product.rows[0].id]);
|
||||
return { status: 201, body: { data: { collection_item: await this.requireItem(tx, item.rows[0].id), product_id: product.rows[0].id, editor_entry_path: `/collection-items/${item.rows[0].id}/edit` } } };
|
||||
return { status: 201, body: { data: { collection_item: await this.requireItem(tx, item.rows[0].id), product_id: product.rows[0].id, editor_entry_path: `/products/${product.rows[0].id}` } } };
|
||||
});
|
||||
}
|
||||
async detail(workspaceId: string, id: string) {
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const item = await this.requireItem(tx, id);
|
||||
const source = await tx.query<{ normalized_payload: unknown; raw_evidence: unknown; parser_version: string | null; collected_at: Date | string | null }>('SELECT normalized_payload, raw_evidence, parser_version, collected_at FROM atlas.collection_items WHERE id = $1 AND deleted_at IS NULL', [id]);
|
||||
const variants = await tx.query<{ id: string; source_variant_key: string | null; source_sku: string | null; display_name: string | null; sort_order: number }>('SELECT id, source_variant_key, source_sku, display_name, sort_order FROM atlas.product_variants WHERE product_id = $1 AND deleted_at IS NULL ORDER BY sort_order, id', [item.product_id]);
|
||||
const transfers = await tx.query<{ id: string; source_variant_id: string | null; role: string; source_url: string; status: string; asset_id: string | null; error_payload: unknown; created_at: Date | string; completed_at: Date | string | null }>('SELECT id, source_variant_id, role::text AS role, source_url, status::text AS status, asset_id, error_payload, created_at, completed_at FROM atlas.collection_asset_transfers WHERE collection_item_id = $1 ORDER BY created_at, id', [id]);
|
||||
return { ...item, normalized_payload: source.rows[0]?.normalized_payload ?? {}, raw_evidence: source.rows[0]?.raw_evidence ?? {}, parser_version: source.rows[0]?.parser_version ?? null, collected_at: source.rows[0]?.collected_at ? iso(source.rows[0].collected_at) : null, variants: variants.rows, asset_transfers: transfers.rows.map((transfer) => ({ ...transfer, created_at: iso(transfer.created_at), completed_at: transfer.completed_at ? iso(transfer.completed_at) : null })) };
|
||||
});
|
||||
}
|
||||
async update(workspaceId: string, userId: string, id: string, key: string, expected: number, input: UpdateCollectionItemRequest) {
|
||||
|
||||
@@ -25,7 +25,12 @@ const environmentSchema = z.object({
|
||||
.enum(['development', 'test', 'production'])
|
||||
.default('development'),
|
||||
API_PORT: z.coerce.number().int().min(1).max(65_535).default(8200),
|
||||
API_PUBLIC_ORIGIN: z.url().default('http://localhost:8200'),
|
||||
WEB_ORIGIN: z.url().default('http://localhost:8100'),
|
||||
// The redirect URI is deliberately exact. A wildcard would allow another
|
||||
// installed extension to exchange a code created from the user's Web session.
|
||||
EXTENSION_COLLECTOR_CLIENT_ID: z.string().min(1).max(128).default('atlas-collector'),
|
||||
EXTENSION_COLLECTOR_REDIRECT_URI: z.url().default('https://atlas-collector.chromiumapp.org/callback'),
|
||||
DATABASE_URL: z.url(),
|
||||
REDIS_URL: z.url(),
|
||||
OBJECT_STORAGE_ENDPOINT: z.url().default('http://localhost:9000'),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { CanActivate, ExecutionContext, Inject, Injectable } from '@nestjs/common';
|
||||
import { ExtensionService } from './extension.service.js';
|
||||
|
||||
export type ExtensionAuthenticatedRequest = {
|
||||
header: (name: string) => string | undefined;
|
||||
extensionAuth: { userId: string; workspaceId: string; sessionId: string; clientId: string };
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ExtensionAccessGuard implements CanActivate {
|
||||
public constructor(@Inject(ExtensionService) private readonly extension: ExtensionService) {}
|
||||
public async canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest<ExtensionAuthenticatedRequest>();
|
||||
request.extensionAuth = await this.extension.authenticate(request.header('authorization'));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Body, Controller, Get, Headers, Inject, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
import { collectedProductPayloadSchema } from '@atlas/contracts';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { Public } from '../common/public.decorator.js';
|
||||
import { ExtensionAccessGuard, type ExtensionAuthenticatedRequest } from './extension-auth.guard.js';
|
||||
import { ExtensionService } from './extension.service.js';
|
||||
|
||||
type RequestLike = { header: (name: string) => string | undefined };
|
||||
type ResponseLike = { redirect: (status: number, url: string) => void; status: (code: number) => ResponseLike };
|
||||
const authorizeSchema = z.object({ client_id: z.string().min(1).max(128), redirect_uri: z.url(), state: z.string().min(16).max(1_000), code_challenge: z.string().regex(/^[A-Za-z0-9_-]{43,128}$/), code_challenge_method: z.literal('S256') }).strict();
|
||||
const exchangeSchema = z.object({ client_id: z.string().min(1).max(128), code: z.string().min(32).max(1_000), redirect_uri: z.url(), code_verifier: z.string().min(43).max(128) }).strict();
|
||||
const refreshSchema = z.object({ refresh_token: z.string().min(32).max(1_000) }).strict();
|
||||
|
||||
@Controller('extension')
|
||||
export class ExtensionController {
|
||||
public constructor(@Inject(ExtensionService) private readonly extension: ExtensionService) {}
|
||||
@Get('authorize') @Public()
|
||||
public async authorize(@Query() query: unknown, @Req() request: RequestLike, @Res() response: ResponseLike) {
|
||||
const result = await this.extension.authorize(parse(authorizeSchema, query) as z.infer<typeof authorizeSchema>, cookie(request.header('cookie'), 'atlas_refresh'));
|
||||
const target = 'redirect_url' in result ? result.redirect_url : result.login_url;
|
||||
if (!target) throw new ApiError(500, 'EXTENSION_AUTHORIZATION_REDIRECT_MISSING', '扩展授权跳转地址缺失');
|
||||
response.redirect(302, target);
|
||||
}
|
||||
@Post('auth/exchange') @Public()
|
||||
public async exchange(@Body() body: unknown, @Res({ passthrough: true }) response: ResponseLike) { const result = await this.extension.exchange(parse(exchangeSchema, body) as z.infer<typeof exchangeSchema>); response.status(result.status); return result.body; }
|
||||
@Post('auth/refresh') @Public()
|
||||
public async refresh(@Body() body: unknown, @Res({ passthrough: true }) response: ResponseLike) { const input = parse(refreshSchema, body) as z.infer<typeof refreshSchema>; const result = await this.extension.refresh(input.refresh_token); response.status(result.status); return result.body; }
|
||||
@Post('auth/revoke') @Public() @UseGuards(ExtensionAccessGuard)
|
||||
public async revoke(@Req() request: ExtensionAuthenticatedRequest, @Res({ passthrough: true }) response: ResponseLike) { await this.extension.revoke(request.extensionAuth.workspaceId, request.extensionAuth.sessionId); response.status(204); }
|
||||
@Get('session') @Public() @UseGuards(ExtensionAccessGuard)
|
||||
public session(@Req() request: ExtensionAuthenticatedRequest) { return { data: { authenticated: true, workspace: { id: request.extensionAuth.workspaceId, available: true }, can_collect: true } }; }
|
||||
@Post('materials') @Public() @UseGuards(ExtensionAccessGuard)
|
||||
public async materials(@Req() request: ExtensionAuthenticatedRequest, @Headers('idempotency-key') key: string | undefined, @Body() body: unknown, @Res({ passthrough: true }) response: ResponseLike) {
|
||||
if (!key || !z.uuid().safeParse(key).success) throw new ApiError(400, 'IDEMPOTENCY_KEY_REQUIRED', '请求缺少或格式错误的 Idempotency-Key');
|
||||
const result = await this.extension.ingest(request.extensionAuth.workspaceId, request.extensionAuth.userId, key, parse(collectedProductPayloadSchema, body)); response.status(result.status); return result.body;
|
||||
}
|
||||
}
|
||||
function parse<T extends { safeParse: (value: unknown) => { success: boolean; data?: unknown; error?: { issues: Array<{ path: PropertyKey[]; message: string }> } } }>(schema: T, value: unknown) { const result = schema.safeParse(value); if (result.success) return result.data as never; throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', result.error?.issues.map((item) => ({ field: item.path.join('.'), code: 'INVALID_FIELD', message: item.message }))); }
|
||||
function cookie(header: string | undefined, name: string) { return header?.split(';').map((part) => part.trim()).find((part) => part.startsWith(`${name}=`))?.slice(name.length + 1); }
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module.js';
|
||||
import { ExtensionAccessGuard } from './extension-auth.guard.js';
|
||||
import { ExtensionController } from './extension.controller.js';
|
||||
import { ExtensionService } from './extension.service.js';
|
||||
@Module({ imports: [AuthModule], controllers: [ExtensionController], providers: [ExtensionService, ExtensionAccessGuard] })
|
||||
export class ExtensionModule {}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { CollectedProductPayload } from '@atlas/contracts';
|
||||
import type { Database } from '@atlas/database';
|
||||
import { createOpaqueToken, signExtensionAccessToken, tokenHash, verifyExtensionAccessToken } from '../auth/auth.crypto.js';
|
||||
import { AuthService } from '../auth/auth.service.js';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
|
||||
type AuthorizeInput = { client_id: string; redirect_uri: string; state: string; code_challenge: string; code_challenge_method: 'S256' };
|
||||
type ExchangeInput = { client_id: string; code: string; redirect_uri: string; code_verifier: string };
|
||||
type ExtensionSession = { id: string; workspace_id: string; user_id: string; client_id: string; expires_at: Date | string; revoked_at: Date | string | null };
|
||||
|
||||
@Injectable()
|
||||
export class ExtensionService {
|
||||
public constructor(
|
||||
@Inject(DATABASE) private readonly database: Database,
|
||||
@Inject(AuthService) private readonly auth: AuthService,
|
||||
@Inject(IdempotencyService) private readonly idempotency: IdempotencyService,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
public async authorize(input: AuthorizeInput, refreshToken: string | undefined) {
|
||||
this.assertClient(input.client_id, input.redirect_uri);
|
||||
const session = await this.auth.resolveWebRefreshSession(refreshToken);
|
||||
if (!session) return { login_url: `${this.config.WEB_ORIGIN}/login?continue=${encodeURIComponent(this.authorizationUrl(input))}` };
|
||||
// Prefixing a high-entropy secret with the workspace UUID lets the server
|
||||
// establish RLS before looking up the code; it is not an authorization secret.
|
||||
const rawCode = `${session.workspace.id}.${createOpaqueToken()}`;
|
||||
await this.database.workspaceTransaction(session.workspace.id, async (tx) => {
|
||||
await tx.query(`INSERT INTO atlas.extension_authorization_codes
|
||||
(workspace_id, user_id, client_id, redirect_uri, code_hash, code_challenge, expires_at)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, now() + interval '60 seconds')`,
|
||||
[session.userId, input.client_id, input.redirect_uri, tokenHash(rawCode), input.code_challenge]);
|
||||
});
|
||||
const callback = new URL(input.redirect_uri);
|
||||
callback.searchParams.set('code', rawCode);
|
||||
callback.searchParams.set('state', input.state);
|
||||
return { redirect_url: callback.toString() };
|
||||
}
|
||||
|
||||
public async exchange(input: ExchangeInput) {
|
||||
this.assertClient(input.client_id, input.redirect_uri);
|
||||
const hash = tokenHash(input.code); const workspaceId = workspacePrefix(input.code);
|
||||
if (!workspaceId) throw new ApiError(401, 'EXTENSION_AUTHORIZATION_INVALID', '扩展授权码无效或已过期');
|
||||
const code = await this.database.workspaceTransaction(workspaceId, async (tx) => (await tx.query<{ workspace_id: string; user_id: string; client_id: string; redirect_uri: string; code_challenge: string }>(
|
||||
`SELECT workspace_id, user_id, client_id, redirect_uri, code_challenge FROM atlas.extension_authorization_codes WHERE code_hash = $1 AND consumed_at IS NULL AND expires_at > now()`, [hash])).rows[0]);
|
||||
if (!code || code.client_id !== input.client_id || code.redirect_uri !== input.redirect_uri || !constantEquals(code.code_challenge, pkceChallenge(input.code_verifier))) {
|
||||
throw new ApiError(401, 'EXTENSION_AUTHORIZATION_INVALID', '扩展授权码无效或已过期');
|
||||
}
|
||||
return this.database.workspaceTransaction(code.workspace_id, async (tx) => {
|
||||
const consumed = await tx.query(`UPDATE atlas.extension_authorization_codes SET consumed_at = now()
|
||||
WHERE code_hash = $1 AND consumed_at IS NULL AND expires_at > now()`, [hash]);
|
||||
if (!consumed.rowCount) throw new ApiError(401, 'EXTENSION_AUTHORIZATION_INVALID', '扩展授权码无效或已过期');
|
||||
return this.createSession(tx, code.workspace_id, code.user_id, code.client_id);
|
||||
});
|
||||
}
|
||||
|
||||
public async refresh(refreshToken: string) {
|
||||
const workspaceId = workspacePrefix(refreshToken); if (!workspaceId) throw new ApiError(401, 'EXTENSION_SESSION_EXPIRED', '扩展登录已过期');
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const result = await tx.query<ExtensionSession>(`SELECT id, workspace_id, user_id, client_id, expires_at, revoked_at FROM atlas.extension_sessions WHERE refresh_token_hash = $1 AND revoked_at IS NULL AND expires_at > now() FOR UPDATE`, [tokenHash(refreshToken)]);
|
||||
const old = result.rows[0];
|
||||
if (!old) throw new ApiError(401, 'EXTENSION_SESSION_EXPIRED', '扩展登录已过期');
|
||||
await tx.query('UPDATE atlas.extension_sessions SET revoked_at = now(), last_used_at = now() WHERE id = $1', [old.id]);
|
||||
return this.createSession(tx, old.workspace_id, old.user_id, old.client_id);
|
||||
});
|
||||
}
|
||||
|
||||
public async revoke(workspaceId: string, sessionId: string) {
|
||||
await this.database.workspaceTransaction(workspaceId, (tx) => tx.query('UPDATE atlas.extension_sessions SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL', [sessionId]));
|
||||
}
|
||||
|
||||
public async authenticate(header: string | undefined) {
|
||||
const token = header?.replace(/^Bearer\s+/i, '');
|
||||
const claims = token ? verifyExtensionAccessToken(token, this.config.AUTH_JWT_SECRET) : null;
|
||||
if (!claims) throw new ApiError(401, 'EXTENSION_SESSION_EXPIRED', '扩展登录已过期');
|
||||
const session = await this.database.workspaceTransaction(claims.wid, async (tx) => (await tx.query<ExtensionSession>(`SELECT id, workspace_id, user_id, client_id, expires_at, revoked_at FROM atlas.extension_sessions WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > now()`, [claims.sid, claims.sub])).rows[0]);
|
||||
if (!session) throw new ApiError(401, 'EXTENSION_SESSION_EXPIRED', '扩展登录已过期');
|
||||
return { userId: session.user_id, workspaceId: session.workspace_id, sessionId: session.id, clientId: session.client_id };
|
||||
}
|
||||
|
||||
public async ingest(workspaceId: string, userId: string, key: string, payload: CollectedProductPayload) {
|
||||
return this.idempotency.run({ workspaceId, userId, method: 'POST', path: '/api/extension/materials', key, request: payload }, async (tx) => {
|
||||
const platform = await tx.query<{ id: string; code: string }>(`SELECT id, code FROM atlas.platforms
|
||||
WHERE code = $1 AND status = 'active' AND coalesce((capabilities->>'can_be_source')::boolean, false)`, [payload.source.platform.toUpperCase()]);
|
||||
if (!platform.rows[0]) throw new ApiError(422, 'SOURCE_PLATFORM_UNAVAILABLE', '该来源平台当前不可采集');
|
||||
const existing = await tx.query<{ id: string; product_id: string }>(`SELECT c.id, p.id AS product_id FROM atlas.collection_items c JOIN atlas.products p ON p.collection_item_id = c.id
|
||||
WHERE c.source_platform_id = $1 AND c.source_item_key = $2 AND c.deleted_at IS NULL`, [platform.rows[0].id, payload.source.itemKey]);
|
||||
if (existing.rows[0]) return { status: 200, body: { data: await this.materialResponse(tx, existing.rows[0].id, existing.rows[0].product_id, true) } };
|
||||
const verifiedVariants = payload.variants.filter((variant) => variant.sourceVariantKey.trim().length > 0);
|
||||
const status = verifiedVariants.length || !payload.variantAxes.length ? 'materializing' : 'needs_review';
|
||||
const sourcePrice = payload.common.sourcePrice;
|
||||
const item = await tx.query<{ id: string }>(`INSERT INTO atlas.collection_items
|
||||
(workspace_id, source_kind, source_platform_id, source_item_key, source_url, source_title, source_currency_code, source_min_price, source_max_price, status, raw_evidence, normalized_payload, parser_version, collected_at)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, 'platform', $1, $2, $3, $4, $5, $6, $6, $7::atlas.collection_item_status, $8, $9, $10, $11) RETURNING id`,
|
||||
[platform.rows[0].id, payload.source.itemKey, payload.source.canonicalUrl, payload.common.title?.value ?? null, sourcePrice?.currencyCode ?? null, sourcePrice?.amount ?? null, status, JSON.stringify(payload.evidence), JSON.stringify(payload), payload.source.parserVersion, payload.source.collectedAt]);
|
||||
const product = await tx.query<{ id: string }>(`INSERT INTO atlas.products (workspace_id, collection_item_id, source_title)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`, [item.rows[0].id, payload.common.title?.value ?? null]);
|
||||
const variantRows = verifiedVariants.length ? verifiedVariants : [{ sourceVariantKey: null, sourceSku: undefined, displayName: '默认 SKU' }];
|
||||
const variants = new Map<string, string>();
|
||||
for (let index = 0; index < variantRows.length; index += 1) {
|
||||
const variant = variantRows[index];
|
||||
const inserted = await tx.query<{ id: string }>(`INSERT INTO atlas.product_variants (workspace_id, product_id, source_variant_key, source_sku, display_name, sort_order)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5) RETURNING id`, [product.rows[0].id, variant.sourceVariantKey, variant.sourceSku ?? null, variant.displayName, index]);
|
||||
if (variant.sourceVariantKey) variants.set(variant.sourceVariantKey, inserted.rows[0].id);
|
||||
}
|
||||
for (const asset of payload.assets) {
|
||||
await tx.query(`INSERT INTO atlas.collection_asset_transfers (workspace_id, collection_item_id, product_id, source_variant_id, role, source_url, status, error_payload)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4::atlas.media_candidate_role, $5, 'queued', $6)`,
|
||||
[item.rows[0].id, product.rows[0].id, asset.variantSourceKey ? variants.get(asset.variantSourceKey) ?? null : null, asset.role, asset.url, JSON.stringify({ relation: asset.relation, referer_origin: asset.refererOrigin ?? null, evidence_ref: asset.evidenceRef ?? null, sort_order: asset.sortOrder })]);
|
||||
}
|
||||
if (payload.assets.length) await tx.query(`INSERT INTO atlas.outbox_events (workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, 'collection_item', $1, 'collection.assets.materialize.requested', jsonb_build_object('collection_item_id', $1), $2)`, [item.rows[0].id, `collection.assets.materialize:${item.rows[0].id}`]);
|
||||
else await tx.query(`UPDATE atlas.collection_items SET status = CASE WHEN status = 'needs_review' THEN status ELSE 'ready_for_edit' END, updated_at = now() WHERE id = $1`, [item.rows[0].id]);
|
||||
return { status: 201, body: { data: await this.materialResponse(tx, item.rows[0].id, product.rows[0].id, false) } };
|
||||
});
|
||||
}
|
||||
|
||||
private async materialResponse(tx: { query: Database['query'] }, collectionItemId: string, productId: string, reused: boolean) {
|
||||
const count = await tx.query<{ total: string; queued: string; succeeded: string; failed: string }>(`SELECT count(*)::text AS total, count(*) FILTER (WHERE status = 'queued')::text AS queued, count(*) FILTER (WHERE status = 'succeeded')::text AS succeeded, count(*) FILTER (WHERE status = 'failed')::text AS failed FROM atlas.collection_asset_transfers WHERE collection_item_id = $1`, [collectionItemId]);
|
||||
const status = await tx.query<{ status: string; source_title: string | null }>('SELECT status, source_title FROM atlas.collection_items WHERE id = $1', [collectionItemId]);
|
||||
return { collection_item: { id: collectionItemId, status: status.rows[0]?.status ?? 'received', source_title: status.rows[0]?.source_title ?? null, variant_count: 0 }, product_id: productId, reused, editor_entry_path: `/collection-test/${collectionItemId}`, materialization: { total: Number(count.rows[0]?.total ?? 0), queued: Number(count.rows[0]?.queued ?? 0), succeeded: Number(count.rows[0]?.succeeded ?? 0), failed: Number(count.rows[0]?.failed ?? 0) } };
|
||||
}
|
||||
|
||||
private async createSession(tx: { query: Database['query'] }, workspaceId: string, userId: string, clientId: string) {
|
||||
const refreshToken = `${workspaceId}.${createOpaqueToken()}`; const id = randomUUID();
|
||||
await tx.query(`INSERT INTO atlas.extension_sessions (id, workspace_id, user_id, client_id, refresh_token_hash, expires_at)
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $3, $4, now() + interval '30 days')`, [id, userId, clientId, tokenHash(refreshToken)]);
|
||||
const accessToken = signExtensionAccessToken({ sub: userId, sid: id, wid: workspaceId, typ: 'extension', iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 15 * 60 }, this.config.AUTH_JWT_SECRET);
|
||||
return { status: 200, body: { data: { access_token: accessToken, expires_in: 900, refresh_token: refreshToken, refresh_token_expires_in: 2_592_000, token_type: 'Bearer' } } };
|
||||
}
|
||||
private assertClient(clientId: string, redirectUri: string) { if (clientId !== this.config.EXTENSION_COLLECTOR_CLIENT_ID || redirectUri !== this.config.EXTENSION_COLLECTOR_REDIRECT_URI) throw new ApiError(422, 'EXTENSION_CLIENT_INVALID', '扩展客户端或回调地址未登记'); }
|
||||
private authorizationUrl(input: AuthorizeInput) { const url = new URL(`${this.config.API_PUBLIC_ORIGIN}/api/extension/authorize`); for (const [key, value] of Object.entries(input)) url.searchParams.set(key, value); return url.toString(); }
|
||||
}
|
||||
function pkceChallenge(verifier: string) { return createHash('sha256').update(verifier).digest('base64url'); }
|
||||
function constantEquals(left: string, right: string) { return left.length === right.length && left.split('').every((value, index) => value === right[index]); }
|
||||
function workspacePrefix(value: string) { const prefix = value.split('.', 1)[0]; return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(prefix) ? prefix : null; }
|
||||
@@ -35,7 +35,15 @@ export async function createApplication() {
|
||||
}
|
||||
|
||||
app.enableCors({
|
||||
origin: config.WEB_ORIGIN,
|
||||
origin: (origin, callback) => {
|
||||
// Extension API calls are bearer-token authenticated. Allow only Chrome
|
||||
// extension origins in addition to the configured Web application.
|
||||
if (!origin || origin === config.WEB_ORIGIN || /^chrome-extension:\/\/[a-p]{32}$/.test(origin)) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
callback(null, false);
|
||||
},
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: [
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import type { Database } from '@atlas/database';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { afterAll, beforeAll, describe, it } from 'vitest';
|
||||
import { DATABASE } from '../src/database/database.module.js';
|
||||
import { createApplication } from '../src/main.js';
|
||||
|
||||
let app: NestExpressApplication; let database: Database; let baseUrl = ''; let workspaceId = ''; let userId = '';
|
||||
const redirectUri = 'https://atlas-collector.chromiumapp.org/callback';
|
||||
async function request(path: string, init: RequestInit = {}) { const response = await fetch(`${baseUrl}${path}`, { redirect: 'manual', ...init, headers: { 'Content-Type': 'application/json', ...init.headers } }); return { response, body: response.status === 204 || response.status === 302 ? undefined : await response.json() as any }; }
|
||||
function challenge(verifier: string) { return createHash('sha256').update(verifier).digest('base64url'); }
|
||||
|
||||
describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
beforeAll(async () => { Object.assign(process.env, { NODE_ENV: 'test', SWAGGER_ENABLED: 'false', AUTH_REGISTER_IP_LIMIT: '100', AUTH_REGISTER_EMAIL_LIMIT: '100' }); const created = await createApplication(); app = created.app; database = app.get<Database>(DATABASE); await app.listen(0, '127.0.0.1'); baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`; });
|
||||
afterAll(async () => { await database.workspaceTransaction(workspaceId, async (tx) => { await tx.query('DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.outbox_events WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.collection_asset_transfers WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.product_variants WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.products WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.collection_items WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.extension_authorization_codes WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.extension_sessions WHERE workspace_id = $1', [workspaceId]); }); await database.query('DELETE FROM atlas.workspace_memberships WHERE user_id = $1', [userId]); await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [workspaceId]); await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]); await app.close(); });
|
||||
|
||||
it('通过已有 Web 会话完成 PKCE 并幂等写入来源商品', async () => {
|
||||
const registration = await request('/api/auth/register', { method: 'POST', body: JSON.stringify({ email: `extension-${randomUUID()}@example.test`, password: 'correct-horse-battery-staple-123', password_confirmation: 'correct-horse-battery-staple-123' }) });
|
||||
assert.equal(registration.response.status, 201); userId = registration.body.data.user.id; workspaceId = registration.body.data.workspace.id; const webAccessToken = registration.body.data.access_token as string;
|
||||
const cookie = registration.response.headers.get('set-cookie')?.match(/atlas_refresh=[^;]+/)?.[0]; assert.ok(cookie);
|
||||
const verifier = 'a'.repeat(64); const state = 'b'.repeat(32);
|
||||
const authorize = await request(`/api/extension/authorize?${new URLSearchParams({ client_id: 'atlas-collector', redirect_uri: redirectUri, state, code_challenge: challenge(verifier), code_challenge_method: 'S256' })}`, { headers: { Cookie: cookie } });
|
||||
assert.equal(authorize.response.status, 302); const callback = new URL(authorize.response.headers.get('location')!); assert.equal(callback.origin, 'https://atlas-collector.chromiumapp.org'); assert.equal(callback.searchParams.get('state'), state);
|
||||
const exchange = await request('/api/extension/auth/exchange', { method: 'POST', body: JSON.stringify({ client_id: 'atlas-collector', redirect_uri: redirectUri, code: callback.searchParams.get('code'), code_verifier: verifier }) });
|
||||
assert.equal(exchange.response.status, 200); const accessToken = exchange.body.data.access_token as string; assert.ok(exchange.body.data.refresh_token);
|
||||
const session = await request('/api/extension/session', { headers: { Authorization: `Bearer ${accessToken}` } }); assert.equal(session.response.status, 200); assert.equal(session.body.data.workspace.id, workspaceId);
|
||||
const payload = { source: { platform: 'ozon', itemKey: '123456789', canonicalUrl: 'https://www.ozon.ru/product/demo-123456789/', collectedAt: new Date().toISOString(), parserVersion: 'test-1' }, common: { title: { value: '来源商品', confidence: 'verified' }, attributes: [], categories: [] }, variantAxes: [], variants: [{ sourceVariantKey: '123456789', sourceSku: '123456789', displayName: '默认 SKU', selections: [], confidence: 'verified', evidenceRefs: ['page'] }], assets: [], evidence: [{ ref: 'page', kind: 'page', path: 'test' }] };
|
||||
const first = await request('/api/extension/materials', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Idempotency-Key': randomUUID() }, body: JSON.stringify(payload) }); assert.equal(first.response.status, 201); assert.equal(first.body.data.reused, false);
|
||||
const second = await request('/api/extension/materials', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Idempotency-Key': randomUUID() }, body: JSON.stringify(payload) }); assert.equal(second.response.status, 200); assert.equal(second.body.data.reused, true); assert.equal(second.body.data.collection_item.id, first.body.data.collection_item.id);
|
||||
const inspection = await request(`/api/collection-items/${first.body.data.collection_item.id}`, { headers: { Authorization: `Bearer ${webAccessToken}` } }); assert.equal(inspection.response.status, 200); assert.equal(inspection.body.data.variants[0].source_variant_key, '123456789'); assert.equal(inspection.body.data.normalized_payload.source.platform, 'ozon');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user