33 Commits

Author SHA1 Message Date
joey5628 66ca8b496c feat(web): 页面样式修改 2026-09-03 23:08:14 +08:00
joey5628 5bdf1645d2 fix(web): ant-breadcrumb-link 改为 inline-flex 垂直居中
仅 align-items 不生效(block 布局),补 display: inline-flex,
面包屑每一层(nav/ol/li/link/crumb)均为 flex 垂直居中。
2026-09-03 23:07:26 +08:00
joey5628 1b0cdc33aa fix(web): 面包屑导航盒子高度对齐 collapse-trigger 的 28px
.page-breadcrumb 设为 28px 高并在内部垂直居中,与折叠按钮
盒子边界完全一致。
2026-09-03 23:00:50 +08:00
joey5628 d199c4d6df fix(web): 面包屑条目与分隔图标垂直居中对齐
不同字号图标(15px 条目图标/12px 分隔箭头/14px 文字)在行内
基线对齐时视觉中心错位,改为 flex 垂直居中。
2026-09-03 22:56:25 +08:00
joey5628 aa3076c434 style(web): 面包屑 RightOutlined 分隔图标调整为 12px 2026-09-03 22:51:42 +08:00
joey5628 eecec5e0d0 feat(web): 面包屑分隔符改用 RightOutlined 图标 2026-09-03 22:48:44 +08:00
joey5628 c0473d607a revert(web): 按钮恢复默认字号高度内边距,全局圆角调整为 10px
移除 Button 组件 token 覆盖(字体 14px、高度 34/26px、内边距 15/7px
恢复 antd 默认);--ant-border-radius 12→10px,与设置卡片 10px 一致。
输入类控件 16px/40px 保持不变。
2026-09-03 22:45:02 +08:00
joey5628 27b9e07b62 style(web): 设置卡片圆角改为 10px
卡片是自定义 CSS,与全局 12px 控件圆角区分为 10px。
2026-09-03 22:42:45 +08:00
joey5628 6879e7a8b7 feat(web): 按钮内边距调整为 20px,全局圆角调整为 12px
设置卡片自定义 CSS 圆角同步 8→12px。
2026-09-03 22:41:18 +08:00
joey5628 9164fc962e feat(web): 按钮横向内边距加大
Button 组件 token paddingInline 15→22、paddingInlineSM 7→14,
配合 40px 高度与 16px 字号整体更协调。
2026-09-03 22:21:57 +08:00
joey5628 24a25dcbd1 feat(web): 输入控件与按钮放大、设置卡片收窄加阴影、设置页增加取消按钮
- 主题 components:Input/Select/InputNumber/DatePicker/Button 文本
  16px、高度 40px(小号按钮 32px);表格/菜单等正文保持 14px
- 设置卡片 max-width 720→560px,四周柔和阴影
- 修改密码页保存按钮右侧、修改资料页重置按钮右侧增加取消按钮,
  点击回首页
2026-09-03 22:13:11 +08:00
joey5628 5bf5f80b34 fix(web): 必填表单项恢复 Label 前红色星号
店铺创建/编辑弹窗与个人资料、修改密码表单误设了
requiredMark={false},必填项没有必填标识。移除该属性恢复
antd 默认必填星号(可选字段不受影响)。
2026-09-03 22:04:05 +08:00
joey5628 46d40b184b feat(web): 店铺必填项拦截纯空格输入,API Key 放宽到契约上限
- 店铺名称/Client ID/API Key 的 required 规则加 whitespace: true,
  与服务端 trim 后非空校验对齐
- API Key 输入 maxLength 200 → 8192(packages/contracts 契约上限),
  避免误拦较长的真实平台密钥
- 浏览器实测:空提交与纯空格提交均展示四条必填错误且不发请求,
  正常填写创建成功
2026-09-03 22:00:33 +08:00
joey5628 446baf5939 feat(web): 店铺管理页与页头平台选择器接入真实 API
- 页头平台选择器:PlatformProvider 加载平台目录与平台上下文
  (X-Atlas-Platform-Id header),平台 id 仅作为非敏感 UI 偏好存
  localStorage,默认取目录第一家(Ozon 优先)
- 店铺管理页覆盖 docs/api/stores-and-platforms.md 已实现接口:
  游标分页列表、创建(202+operation)、编辑(If-Match 乐观锁、
  409 刷新提示)、设为默认、重新验证凭据、仓库同步(422 脱敏
  错误展示)、软删除(DEFAULT_STORE_REPLACEMENT_REQUIRED 提示)
- services/api.ts 新增 getList(保留 {data, meta} 分页信封)与
  apiErrorCode;services/stores.ts 按契约生成 Idempotency-Key
- @atlas/contracts 补充只读响应类型 Platform / PlatformContext /
  CursorPaginationMeta / StoreOperationResponse(无行为变更)
- API Key 仅存在于提交瞬间的内存表单,不落任何存储
- 浏览器实测:注册→平台切换→创建/编辑/设默认/删除保护/同步
  错误路径→刷新会话恢复全部通过;typecheck + production build 通过
2026-09-03 21:40:02 +08:00
joey5628 8655b7805f fix(api): stores 控制器显式 @Inject 服务依赖
tsx/esbuild 不生成 design:paramtypes 装饰器元数据,裸类型构造参数在
dev 运行时注入 undefined,GET /api/platforms 返回 500。改为与
auth.controller.ts 一致的显式 @Inject(...)。

跨领域改动说明:前端联调平台上下文时发现该阻断性 bug,修复方式
沿用仓库既有模式,不影响服务层(原本即显式 token 注入)。
2026-09-03 21:39:53 +08:00
R524809 3ab999d0b2 Merge branch 'feat-0.2.0' into zcode/frontend 2026-09-03 17:12:51 +08:00
R524809 cc61967e3b feat(web-auth): 前端修改资料、修改密码优化 2026-09-03 17:12:36 +08:00
R524809 dc39e3e4e5 chore(package): 添加清楚端口占用的快捷命令 2026-09-03 17:11:44 +08:00
R524809 2d435143ab Merge branch 'feat-0.2.0' into zcode/frontend 2026-09-03 16:57:04 +08:00
R524809 06a3a4da75 feat(web): 开发账户修改、密码修改功能 2026-09-03 16:56:53 +08:00
R524809 a5b89ddb1d feat(api): 账户资料与密码安全 2026-09-03 16:56:20 +08:00
R524809 46d98ab11e feat(merge): 合入主分支 2026-09-03 16:10:15 +08:00
R524809 ffa86f8d55 feat(web): 登录、注册功能 2026-09-03 16:06:13 +08:00
R524809 ada02e9135 feat(api): 开发平台、门店相关代码 2026-09-03 16:04:44 +08:00
R524809 35fcf8776e feat: 合并主分支后端代码 2026-09-03 13:16:08 +08:00
R524809 df40ddde8c feat(web): 初始化前端项目 2026-09-03 13:11:38 +08:00
R524809 037928c778 feat(api): 开发auth、login等接口,规范格式化 2026-09-03 13:11:06 +08:00
R524809 195b6d25e9 docs: add mandatory AI collaboration contract 2026-09-03 09:19:29 +08:00
joey5628 403f6cfa27 feat(web): 搭建应用骨架并完善开发环境配置 2026-09-02 22:42:21 +08:00
R524809 09e5207c83 docs: 更新初始化交接文档 2026-09-02 17:56:42 +08:00
R524809 1a2caba2b3 feat: 初始化项目 2026-09-02 17:47:25 +08:00
R524809 04c94e75db feat: 方案设计、数据库和接口设计基本完成 2026-09-02 14:47:18 +08:00
joey5628 a3fe066a5f feat: 采集插件需求和方案 2026-09-01 23:14:38 +08:00
147 changed files with 19434 additions and 98 deletions
+77
View File
@@ -0,0 +1,77 @@
# Local PostgreSQL bootstrap administrator. It creates roles but must never be used by API or Worker.
POSTGRES_ADMIN_USER=atlas_admin
POSTGRES_ADMIN_PASSWORD=change-me-local-only
# Database name and host port exposed by local Docker Compose.
POSTGRES_DB=atlas
POSTGRES_PORT=5432
# Password for the schema-owning migrator role; only the migration runner may use it.
ATLAS_MIGRATOR_PASSWORD=change-me-local-only
# Password for the least-privileged API/Worker login role.
ATLAS_APP_PASSWORD=change-me-local-only
# API/Worker application connection. This role has no table ownership and is constrained by RLS.
DATABASE_URL=postgresql://atlas_app_login:change-me-local-only@localhost:5432/atlas
# Migration-only connection. Do not put this value in production API or Worker environments.
MIGRATOR_DATABASE_URL=postgresql://atlas_migrator:change-me-local-only@localhost:5432/atlas
# Runtime mode controls production-only safeguards such as Secure cookies.
NODE_ENV=development
# Browser origin allowed to send credentialed requests to this API.
WEB_ORIGIN=http://localhost:8100
# HTTP listener port for the NestJS API.
API_PORT=8200
# HMAC signing secret for short-lived access JWTs; replace with a unique 32+ character secret.
AUTH_JWT_SECRET=replace-with-a-random-secret-of-at-least-32-characters
# Separate HMAC secret used to pseudonymize IP/email values in Redis rate-limit keys.
AUTH_RATE_LIMIT_KEY_SECRET=replace-with-a-different-random-secret-of-at-least-32-characters
# AES-GCM 密钥材料,用来加密门店 API Key;只保存在服务端,轮换时变更版本号但保留旧密钥解密能力。
STORE_SECRET_ENCRYPTION_KEY=replace-with-a-random-secret-of-at-least-32-characters
# 当前门店密钥使用的加密密钥版本,会随密钥轮换递增。
STORE_SECRET_ENCRYPTION_KEY_VERSION=v1
# 对幂等请求内容生成 HMAC 摘要的独立密钥,避免数据库保存原始请求体或 API Key。
IDEMPOTENCY_KEY_SECRET=replace-with-a-different-random-secret-of-at-least-32-characters
# Server-side minimum password length. The browser must not weaken this rule.
AUTH_PASSWORD_MIN_LENGTH=12
# Maximum JSON or URL-encoded request size in bytes. Media files must use object-storage direct upload.
API_BODY_LIMIT_BYTES=1048576
# Shared fixed-window duration for authentication rate limits, in seconds.
AUTH_RATE_LIMIT_WINDOW_SECONDS=900
# Maximum login attempts per IP inside the window.
AUTH_LOGIN_IP_LIMIT=10
# Maximum login attempts per normalized email inside the window.
AUTH_LOGIN_EMAIL_LIMIT=5
# Maximum registration attempts per IP inside the window.
AUTH_REGISTER_IP_LIMIT=5
# Maximum registration attempts per normalized email inside the window.
AUTH_REGISTER_EMAIL_LIMIT=3
# Maximum refresh attempts per IP inside the window; refresh tokens have no trusted email before validation.
AUTH_REFRESH_IP_LIMIT=30
# 密码修改在同一限流窗口内允许的每 IP 最大次数。
AUTH_CHANGE_PASSWORD_IP_LIMIT=10
# 密码修改在同一限流窗口内允许的每已认证用户最大次数。
AUTH_CHANGE_PASSWORD_USER_LIMIT=5
# Comma-separated trusted reverse-proxy IPs/CIDRs. Keep empty for direct local access; never set to true.
TRUSTED_PROXY_CIDRS=
# Whether to expose Swagger UI at /api-docs.
SWAGGER_ENABLED=true
# Minimum structured-log severity: error, warn, log, or debug.
LOG_LEVEL=log
# Redis endpoint used by BullMQ and shared authentication rate limits.
REDIS_URL=redis://localhost:6379
# Local MinIO root account; it is only for Docker bootstrap, not browser credentials.
MINIO_ROOT_USER=atlas_minio_local
# Local MinIO root password; replace outside disposable local development.
MINIO_ROOT_PASSWORD=change-me-local-only
# S3-compatible API port for local MinIO.
MINIO_PORT=9000
# MinIO administrative console port.
MINIO_CONSOLE_PORT=9001
# Local Vite frontend port.
WEB_PORT=8100
# Local WXT browser-extension development port.
EXTENSION_PORT=8110
+12
View File
@@ -11,7 +11,19 @@
# Common future runtime/build artifacts
node_modules/
dist/
.output/
.venv/
__pycache__/
*.py[cod]
*.log
node_modules/
.pnpm-store/
.turbo/
dist/
coverage/
.env
.env.*
!.env.example
*.log
.DS_Store
data/
+1
View File
@@ -0,0 +1 @@
24.20.0
+7
View File
@@ -0,0 +1,7 @@
node_modules/
.turbo/
dist/
apps/extension/.output/
apps/extension/.wxt/
docs/**
!docs/architecture/project-structure.md
+9
View File
@@ -0,0 +1,9 @@
{
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always",
"printWidth": 80,
"tabWidth": 4,
"useTabs": false
}
+9
View File
@@ -0,0 +1,9 @@
{
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always",
"printWidth": 80,
"tabWidth": 4,
"useTabs": false
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@atlas/api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "node scripts/dev.mjs",
"start": "node dist/main.js",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit",
"test": "vitest run"
},
"dependencies": {
"@atlas/contracts": "workspace:*",
"@atlas/database": "workspace:*",
"@nestjs/common": "12.0.1",
"@nestjs/config": "12.0.0",
"@nestjs/core": "12.0.1",
"@nestjs/platform-express": "12.0.1",
"@nestjs/swagger": "12.0.1",
"helmet": "8.3.0",
"ioredis": "6.0.0",
"reflect-metadata": "0.2.2",
"rxjs": "7.8.2",
"zod": "4.5.4"
},
"devDependencies": {
"@nestjs/testing": "12.0.1",
"@types/express": "5.0.6",
"@types/node": "26.3.0",
"tsx": "4.23.13",
"typescript": "7.0.2",
"vitest": "^4.1.11"
}
}
+72
View File
@@ -0,0 +1,72 @@
import { spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const repositoryRoot = resolve(scriptDirectory, '../../..');
const environmentFile = resolve(repositoryRoot, '.env');
const localEnvironment = existsSync(environmentFile)
? parseEnvironment(readFileSync(environmentFile, 'utf8'))
: {};
const apiPort = process.env.API_PORT ?? localEnvironment.API_PORT ?? '8200';
const swaggerEnabled =
(process.env.SWAGGER_ENABLED ??
localEnvironment.SWAGGER_ENABLED ??
'true') === 'true';
const nodeEnvironment =
process.env.NODE_ENV ?? localEnvironment.NODE_ENV ?? 'development';
const swaggerUrl = `http://localhost:${apiPort}/api-docs`;
const tsxCommand = process.platform === 'win32' ? 'tsx.cmd' : 'tsx';
const apiProcess = spawn(tsxCommand, ['watch', 'src/main.ts'], {
shell: process.platform === 'win32',
stdio: 'inherit',
});
if (
process.platform === 'darwin' &&
nodeEnvironment === 'development' &&
swaggerEnabled
) {
void openSwaggerAfterApiIsReady();
}
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () => apiProcess.kill(signal));
}
apiProcess.on('exit', (code, signal) => {
if (signal) process.exitCode = 1;
else process.exitCode = code ?? 0;
});
function parseEnvironment(content) {
return Object.fromEntries(
content
.split(/\r?\n/)
.map((line) => line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/))
.filter(Boolean)
.map((match) => [match[1], match[2]]),
);
}
async function openSwaggerAfterApiIsReady() {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
try {
const response = await fetch(`http://localhost:${apiPort}/health`);
if (response.ok) {
// 开发启动器进程不会随 tsx watch 的热重启重建,因此只会打开一次。
spawn('open', [swaggerUrl], {
detached: true,
stdio: 'ignore',
}).unref();
return;
}
} catch {
// API 仍在启动或热重启,继续等待到超时为止。
}
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));
}
console.warn(`Swagger 未在 30 秒内就绪:${swaggerUrl}`);
}
+47
View File
@@ -0,0 +1,47 @@
import { Controller, Get, Inject, Module } from '@nestjs/common';
import type { Database } from '@atlas/database';
import { AuthModule } from './auth/auth.module.js';
import { ApiError } from './common/api-error.js';
import { Public } from './common/public.decorator.js';
import { AppConfigModule } from './config/app-config.module.js';
import { DATABASE, DatabaseModule } from './database/database.module.js';
import { SecurityModule } from './security/security.module.js';
import { StoresModule } from './stores/stores.module.js';
@Controller()
class HealthController {
public constructor(@Inject(DATABASE) private readonly database: Database) {}
@Get('health')
@Public()
check() {
return { status: 'ok', service: 'api' };
}
@Get('ready')
@Public()
async ready() {
try {
await this.database.query('SELECT 1');
return { status: 'ready', service: 'api' };
} catch {
throw new ApiError(
503,
'DEPENDENCY_UNAVAILABLE',
'服务依赖暂不可用',
);
}
}
}
@Module({
imports: [
AppConfigModule,
DatabaseModule,
SecurityModule,
AuthModule,
StoresModule,
],
controllers: [HealthController],
})
export class AppModule {}
+323
View File
@@ -0,0 +1,323 @@
import {
Body,
Controller,
Get,
Headers,
HttpCode,
Inject,
Patch,
Post,
Req,
Res,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiBody,
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { createHash } from 'node:crypto';
import {
changePasswordRequestSchema,
loginRequestSchema,
registerRequestSchema,
updateCurrentUserRequestSchema,
type Workspace,
} from '@atlas/contracts';
import { z } from 'zod';
import { ApiError } from '../common/api-error.js';
import { Public } from '../common/public.decorator.js';
import type { AuthenticatedRequest } from './auth.guard.js';
import { AuthService, type RequestMetadata } from './auth.service.js';
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
const refreshCookie = 'atlas_refresh';
// 控制器只负责 HTTP 边界;账户、会话与工作空间的一致性由 AuthService 的事务保证。
type HttpRequest = {
header: (name: string) => string | undefined;
ip?: string;
};
type HttpResponse = {
cookie: (
name: string,
value: string,
options: Record<string, unknown>,
) => void;
clearCookie: (name: string, options: Record<string, unknown>) => void;
};
@ApiTags('Authentication')
@Controller('auth')
export class AuthController {
public constructor(
@Inject(AuthService) private readonly authService: AuthService,
) {}
@Post('register')
@Public()
@ApiOperation({ summary: '注册账户并创建个人工作空间' })
@ApiBody({
schema: {
example: {
email: 'owner@example.com',
password: 'a-strong-password',
password_confirmation: 'a-strong-password',
display_name: 'Joey',
},
},
})
public async register(
@Body() body: unknown,
@Req() request: HttpRequest,
@Res({ passthrough: true }) response: HttpResponse,
) {
const result = await this.authService.register(
parse(registerRequestSchema, body),
metadata(request),
);
setRefreshCookie(response, result.refreshToken);
return { data: result.payload };
}
@Post('login')
@Public()
@HttpCode(200)
@ApiOperation({ summary: '使用邮箱和密码登录' })
@ApiBody({
schema: {
example: {
email: 'owner@example.com',
password: 'a-strong-password',
},
},
})
public async login(
@Body() body: unknown,
@Req() request: HttpRequest,
@Res({ passthrough: true }) response: HttpResponse,
) {
const result = await this.authService.login(
parse(loginRequestSchema, body),
metadata(request),
);
setRefreshCookie(response, result.refreshToken);
return { data: result.payload };
}
@Post('refresh')
@Public()
@HttpCode(200)
@ApiOperation({ summary: '轮换 refresh cookie 并获取新的 access token' })
public async refresh(
@Req() request: HttpRequest,
@Res({ passthrough: true }) response: HttpResponse,
) {
try {
// refresh token 不进入 JSON body,仅从浏览器自动发送的 HttpOnly cookie 读取。
const result = await this.authService.refresh(
readCookie(request.header('cookie'), refreshCookie),
metadata(request),
);
setRefreshCookie(response, result.refreshToken);
return {
data: {
access_token: result.payload.access_token,
access_token_expires_in:
result.payload.access_token_expires_in,
},
};
} catch (error) {
clearRefreshCookie(response);
throw error;
}
}
@Post('logout')
@HttpCode(204)
@ApiBearerAuth('access-token')
@ApiOperation({ summary: '撤销当前登录会话' })
@ApiNoContentResponse({ description: '会话已撤销' })
@ApiUnauthorizedResponse({ description: 'access token 无效或已过期' })
public async logout(
@Req() request: AuthenticatedRequest,
@Res({ passthrough: true }) response: HttpResponse,
) {
await this.authService.logout(request.auth.sessionId);
clearRefreshCookie(response);
}
@Get('me')
@ApiBearerAuth('access-token')
@ApiOperation({ summary: '获取当前用户、工作空间和页头初始上下文' })
@ApiOkResponse({ description: '当前身份和工作空间' })
@ApiUnauthorizedResponse({ description: 'access token 无效或已过期' })
public async me(
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
) {
return {
data: await this.authService.me(request.auth.userId, workspace),
};
}
@Patch('me')
@ApiBearerAuth('access-token')
@ApiOperation({ summary: '按版本更新当前账户的显示名称或头像素材引用' })
@ApiBody({
schema: {
example: {
display_name: 'Joey',
avatar_asset_id: 'b2b592e8-96b0-4d2b-afc4-2c6462f2065b',
},
},
})
public async updateProfile(
@Body() body: unknown,
@Headers('if-match') ifMatch: string | undefined,
@Headers('idempotency-key') idempotencyKey: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
) {
return (
await this.authService.updateProfile(
workspace,
request.auth.userId,
requireIdempotencyKey(idempotencyKey),
parseRevision(ifMatch),
parse(updateCurrentUserRequestSchema, body),
)
).body;
}
@Post('change-password')
@HttpCode(200)
@ApiBearerAuth('access-token')
@ApiOperation({ summary: '修改密码、撤销旧会话并轮换当前会话' })
@ApiBody({
schema: {
example: {
old_password: 'old-strong-password',
password: 'new-strong-password',
password_confirmation: 'new-strong-password',
},
},
})
public async changePassword(
@Body() body: unknown,
@Req() request: AuthenticatedRequest & HttpRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: HttpResponse,
) {
const result = await this.authService.changePassword(
workspace,
request.auth.userId,
request.auth.sessionId,
parse(changePasswordRequestSchema, body),
metadata(request),
);
setRefreshCookie(response, result.refreshToken);
return {
data: {
access_token: result.payload.access_token,
access_token_expires_in: result.payload.access_token_expires_in,
},
};
}
}
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 ReturnType<T['safeParse']> extends {
data: infer Data;
}
? Data
: never;
throw new ApiError(
422,
'VALIDATION_FAILED',
'请求参数不符合要求',
result.error?.issues.map((issue) => ({
field: issue.path.join('.'),
code: 'INVALID_FIELD',
message: issue.message,
})),
);
}
function metadata(request: HttpRequest): RequestMetadata {
return {
ip: request.ip ?? null,
ipHash: request.ip
? createHash('sha256').update(request.ip).digest('base64url')
: null,
userAgent: request.header('user-agent') ?? null,
};
}
function cookieOptions() {
// cookie 路径限制在认证接口,避免无关请求携带长期会话凭证。
return {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/api/auth',
maxAge: 30 * 24 * 60 * 60 * 1000,
};
}
function setRefreshCookie(response: HttpResponse, token: string) {
response.cookie(refreshCookie, token, cookieOptions());
}
function clearRefreshCookie(response: HttpResponse) {
response.clearCookie(refreshCookie, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/api/auth',
});
}
function readCookie(header: string | undefined, name: string) {
// 只解析本服务需要的单个 cookie,避免引入与认证无关的 cookie 状态。
return header
?.split(';')
.map((part) => part.trim())
.find((part) => part.startsWith(`${name}=`))
?.slice(name.length + 1);
}
function requireIdempotencyKey(value: string | undefined): string {
if (!value) {
throw new ApiError(
400,
'IDEMPOTENCY_KEY_REQUIRED',
'请求缺少 Idempotency-Key',
);
}
if (z.uuid().safeParse(value).success) return value;
throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
{
field: 'Idempotency-Key',
code: 'INVALID_FIELD',
message: 'Idempotency-Key 必须是 UUID',
},
]);
}
function parseRevision(value: string | undefined): number {
const match = value?.match(/^"([1-9]\d*)"$/);
if (!match) {
throw new ApiError(
400,
'IF_MATCH_REQUIRED',
'请求必须携带形如 "1" 的 If-Match',
);
}
return Number(match[1]);
}
+94
View File
@@ -0,0 +1,94 @@
import {
createHash,
createHmac,
randomBytes,
scrypt as scryptCallback,
timingSafeEqual,
} from 'node:crypto';
import { promisify } from 'node:util';
const scrypt = promisify(scryptCallback) as (
password: string,
salt: string,
keyLength: number,
options: { N: number; r: number; p: number; maxmem: number },
) => Promise<Buffer>;
// 使用较高成本的 scrypt 参数,控制离线破解密码哈希时的时间与内存成本。
const scryptOptions = { N: 16_384, r: 8, p: 1, maxmem: 32 * 1024 * 1024 };
export async function hashPassword(password: string): Promise<string> {
// 每个密码使用独立随机盐,数据库泄露时不能直接复用预计算结果。
const salt = randomBytes(16).toString('base64url');
const derived = (await scrypt(password, salt, 64, scryptOptions)) as Buffer;
return `scrypt$${salt}$${derived.toString('base64url')}`;
}
export async function verifyPassword(
password: string,
encoded: string,
): Promise<boolean> {
const [algorithm, salt, expected] = encoded.split('$');
if (algorithm !== 'scrypt' || !salt || !expected) return false;
const actual = (await scrypt(password, salt, 64, scryptOptions)) as Buffer;
const expectedBuffer = Buffer.from(expected, 'base64url');
return (
expectedBuffer.length === actual.length &&
timingSafeEqual(expectedBuffer, actual)
);
}
export function createOpaqueToken(): string {
return randomBytes(48).toString('base64url');
}
export function tokenHash(token: string): string {
// refresh token 只以摘要形式存库;服务端无需保存可直接用于登录的原文。
return createHash('sha256').update(token).digest('base64url');
}
export type AccessTokenClaims = {
sub: string;
sid: string;
exp: number;
iat: number;
};
export function signAccessToken(
claims: AccessTokenClaims,
secret: string,
): string {
// access token 是短期自包含凭证;真正可撤销的会话状态仍以 PostgreSQL 为准。
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 verifyAccessToken(
token: string,
secret: string,
): AccessTokenClaims | null {
const [header, payload, signature] = token.split('.');
if (!header || !payload || !signature) return null;
const expected = createHmac('sha256', secret)
.update(`${header}.${payload}`)
.digest();
const actual = Buffer.from(signature, 'base64url');
// 恒定时间比较可避免根据签名比对耗时推测正确前缀。
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
return null;
try {
const claims = JSON.parse(
Buffer.from(payload, 'base64url').toString('utf8'),
) as AccessTokenClaims;
return typeof claims.sub === 'string' &&
typeof claims.sid === 'string' &&
typeof claims.exp === 'number' &&
claims.exp > Math.floor(Date.now() / 1000)
? claims
: null;
} catch {
return null;
}
}
+46
View File
@@ -0,0 +1,46 @@
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Workspace } from '@atlas/contracts';
import { IS_PUBLIC_ENDPOINT } from '../common/public.decorator.js';
import { AuthService } from './auth.service.js';
export type AuthenticatedRequest = {
header: (name: string) => string | undefined;
auth: {
userId: string;
sessionId: string;
workspace?: Workspace;
};
};
@Injectable()
export class AccessTokenGuard implements CanActivate {
public constructor(
@Inject(Reflector) private readonly reflector: Reflector,
@Inject(AuthService) private readonly authService: AuthService,
) {}
public async canActivate(context: ExecutionContext) {
// 公开端点必须显式声明,避免新路由因忘记添加守卫而意外暴露。
if (
this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_ENDPOINT, [
context.getHandler(),
context.getClass(),
])
) {
return true;
}
const request = context
.switchToHttp()
.getRequest<AuthenticatedRequest>();
// 只在此处解析 Authorization,后续守卫和控制器共享已验证的会话身份。
request.auth = await this.authService.authenticate(
request.header('authorization'),
);
return true;
}
}
+20
View File
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuthController } from './auth.controller.js';
import { AccessTokenGuard } from './auth.guard.js';
import { AuthService } from './auth.service.js';
import { WorkspaceContextGuard } from '../workspace/workspace-context.guard.js';
import { IdempotencyService } from '../common/idempotency.service.js';
@Module({
controllers: [AuthController],
providers: [
AuthService,
IdempotencyService,
AccessTokenGuard,
WorkspaceContextGuard,
{ provide: APP_GUARD, useExisting: AccessTokenGuard },
{ provide: APP_GUARD, useExisting: WorkspaceContextGuard },
],
})
export class AuthModule {}
+512
View File
@@ -0,0 +1,512 @@
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import type {
AuthSessionResponse,
AuthUser,
ChangePasswordRequest,
LoginRequest,
RegisterRequest,
UpdateCurrentUserRequest,
Workspace,
} from '@atlas/contracts';
import type { Database, DatabaseTransaction } from '@atlas/database';
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';
import { AuthRateLimiter } from '../security/auth-rate-limiter.service.js';
import {
createOpaqueToken,
hashPassword,
signAccessToken,
tokenHash,
verifyAccessToken,
verifyPassword,
} from './auth.crypto.js';
type UserRow = {
id: string;
email: string;
display_name: string | null;
avatar_asset_id: string | null;
email_verified_at: Date | null;
created_at: Date;
revision: number;
status: 'active' | 'disabled' | 'pending_review';
password_hash: string;
};
type WorkspaceRow = { id: string; name: string; role: Workspace['role'] };
@Injectable()
export class AuthService {
// access token 短期存在于浏览器内存;refresh session 的有效期更长且可服务端撤销。
private readonly accessTtlSeconds = 900;
private readonly refreshTtlDays = 30;
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(APP_CONFIG) private readonly config: AppConfig,
@Inject(AuthRateLimiter)
private readonly rateLimiter: AuthRateLimiter,
@Inject(IdempotencyService)
private readonly idempotency: IdempotencyService,
) {}
public async register(input: RegisterRequest, metadata: RequestMetadata) {
if (input.password !== input.password_confirmation)
throw validationError(
'password_confirmation',
'PASSWORD_CONFIRMATION_MISMATCH',
'两次输入的密码不一致',
);
if (input.password.length < this.config.AUTH_PASSWORD_MIN_LENGTH)
throw validationError(
'password',
'PASSWORD_TOO_SHORT',
`密码至少需要 ${this.config.AUTH_PASSWORD_MIN_LENGTH} 个字符`,
);
const email = input.email.trim().toLowerCase();
// 限流必须早于密码哈希和用户查询,避免攻击者消耗 CPU 或探测账户状态。
await this.rateLimiter.consume('register', { ip: metadata.ip, email });
const passwordHash = await hashPassword(input.password);
try {
return await this.database.transaction(async (transaction) => {
// 用户、个人工作空间和 owner membership 必须原子创建,不能留下半成品租户。
const user = await insertUser(
transaction,
email,
passwordHash,
input.display_name?.trim() || null,
);
const workspace = await createPersonalWorkspace(
transaction,
user.id,
user.display_name ?? email,
);
return this.createSessionResponse(
transaction,
user,
workspace,
metadata,
);
});
} catch (error: unknown) {
if (isUniqueViolation(error))
throw new ApiError(
409,
'EMAIL_ALREADY_REGISTERED',
'该邮箱已注册',
[
{
field: 'email',
code: 'EMAIL_ALREADY_REGISTERED',
message: '该邮箱已注册',
},
],
);
throw error;
}
}
public async login(input: LoginRequest, metadata: RequestMetadata) {
const email = input.email.trim().toLowerCase();
// 限流与 citext 查询使用同一规范化邮箱,避免大小写绕过限流桶。
await this.rateLimiter.consume('login', { ip: metadata.ip, email });
const result = await this.database.query<UserRow>(
'SELECT id, email, display_name, avatar_asset_id, email_verified_at, created_at, revision, status, password_hash FROM atlas.users WHERE email = $1',
[email],
);
const user = result.rows[0];
if (
!user ||
!(await verifyPassword(input.password, user.password_hash))
)
throw new ApiError(401, 'INVALID_CREDENTIALS', '邮箱或密码错误');
if (user.status === 'disabled')
throw new ApiError(403, 'ACCOUNT_DISABLED', '账户已停用');
const workspace = await this.findWorkspace(user.id);
return this.database.transaction(async (transaction) => {
await transaction.query(
'UPDATE atlas.users SET last_login_at = now() WHERE id = $1',
[user.id],
);
return this.createSessionResponse(
transaction,
user,
workspace,
metadata,
);
});
}
public async refresh(
refreshToken: string | undefined,
metadata: RequestMetadata,
) {
// refresh token 验证前没有可信邮箱,只能按 IP 维度限流。
await this.rateLimiter.consume('refresh', { ip: metadata.ip });
if (!refreshToken)
throw new ApiError(401, 'SESSION_EXPIRED', '登录会话已过期');
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() FOR UPDATE`,
[tokenHash(refreshToken)],
);
const user = sessions.rows[0];
if (!user || user.status !== 'active')
throw new ApiError(401, 'SESSION_EXPIRED', '登录会话已过期');
// 行锁保证并发 refresh 时旧 token 只能成功使用一次。
await transaction.query(
'UPDATE atlas.user_sessions SET revoked_at = now() WHERE id = $1',
[user.session_id],
);
const workspace = await findWorkspaceInTransaction(
transaction,
user.id,
);
return this.createSessionResponse(
transaction,
user,
workspace,
metadata,
);
});
}
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',
[sessionId],
);
}
public async authenticate(accessToken: string | undefined) {
const token = accessToken?.replace(/^Bearer\s+/i, '');
const claims = token
? verifyAccessToken(token, this.config.AUTH_JWT_SECRET)
: null;
if (!claims) throw new UnauthorizedException();
// JWT 签名有效仍不足够;必须确认对应 session 未撤销且未过期。
const active = await this.database.query(
'SELECT 1 FROM atlas.user_sessions WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > now()',
[claims.sid, claims.sub],
);
if (!active.rowCount) throw new UnauthorizedException();
return { userId: claims.sub, sessionId: claims.sid };
}
public async me(userId: string, workspace: Workspace) {
// workspace 由全局守卫解析,避免控制器或服务各自选择租户造成边界不一致。
const users = await this.database.query<UserRow>(
'SELECT id, email, display_name, avatar_asset_id, email_verified_at, created_at, revision, status, password_hash FROM atlas.users WHERE id = $1',
[userId],
);
const user = users.rows[0];
if (!user) throw new UnauthorizedException();
const platforms = await this.database.workspaceTransaction(
workspace.id,
async (transaction) => {
const rows = await transaction.query<{
id: string;
code: string;
name: string;
}>(
"SELECT id, code, name FROM atlas.platforms WHERE status = 'active' ORDER BY CASE WHEN code = 'OZON' THEN 0 ELSE 1 END, name",
);
return rows.rows;
},
);
return {
user: publicUser(user),
workspace,
platforms,
suggested_context: {
platform_id: platforms[0]?.id ?? null,
default_store: null,
},
};
}
public async updateProfile(
workspace: Workspace,
userId: string,
idempotencyKey: string,
revision: number,
input: UpdateCurrentUserRequest,
) {
return this.idempotency.run(
{
workspaceId: workspace.id,
userId,
method: 'PATCH',
path: '/api/auth/me',
key: idempotencyKey,
request: { if_match: revision, ...input },
},
async (transaction) => {
const user = await findUserInTransaction(
transaction,
userId,
true,
);
if (user.revision !== revision) {
throw versionConflict(user.revision);
}
if (input.avatar_asset_id) {
// 头像只能引用当前工作空间内已完成入库的素材,不能接受任意对象路径或 URL。
const asset = await transaction.query<{ id: string }>(
`SELECT id FROM atlas.media_assets WHERE id = $1`,
[input.avatar_asset_id],
);
if (!asset.rows[0]) {
throw new ApiError(
422,
'AVATAR_ASSET_UNAVAILABLE',
'头像素材不存在或不属于当前工作空间',
);
}
}
const updated = await transaction.query<UserRow>(
`UPDATE atlas.users
SET display_name = CASE WHEN $1 THEN $2 ELSE display_name END,
avatar_asset_id = CASE WHEN $3 THEN $4::uuid ELSE avatar_asset_id END,
revision = revision + 1, updated_at = now()
WHERE id = $5
RETURNING id, email, display_name, avatar_asset_id, email_verified_at,
created_at, revision, status, password_hash`,
[
Object.hasOwn(input, 'display_name'),
input.display_name ?? null,
Object.hasOwn(input, 'avatar_asset_id'),
input.avatar_asset_id ?? null,
userId,
],
);
return {
status: 200,
body: { data: publicUser(updated.rows[0]) },
};
},
);
}
public async changePassword(
workspace: Workspace,
userId: string,
sessionId: string,
input: ChangePasswordRequest,
metadata: RequestMetadata,
) {
if (input.password !== input.password_confirmation) {
throw validationError(
'password_confirmation',
'PASSWORD_CONFIRMATION_MISMATCH',
'两次输入的密码不一致',
);
}
if (input.password.length < this.config.AUTH_PASSWORD_MIN_LENGTH) {
throw validationError(
'password',
'PASSWORD_TOO_SHORT',
`密码至少需要 ${this.config.AUTH_PASSWORD_MIN_LENGTH} 个字符`,
);
}
// 修改密码按已认证用户和来源 IP 双维度限流,避免已泄露会话被用于高频猜测旧密码。
await this.rateLimiter.consume('change_password', {
ip: metadata.ip,
userId,
});
return this.database.transaction(async (transaction) => {
const session = await transaction.query<{ id: string }>(
`SELECT id FROM atlas.user_sessions
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > now()
FOR UPDATE`,
[sessionId, userId],
);
if (!session.rows[0]) throw new UnauthorizedException();
const user = await findUserInTransaction(transaction, userId, true);
if (user.status !== 'active') {
throw new ApiError(403, 'ACCOUNT_DISABLED', '账户已停用');
}
if (
!(await verifyPassword(input.old_password, user.password_hash))
) {
throw validationError(
'old_password',
'CURRENT_PASSWORD_INVALID',
'当前密码错误',
);
}
const passwordHash = await hashPassword(input.password);
const updated = await transaction.query<UserRow>(
`UPDATE atlas.users SET password_hash = $1, updated_at = now()
WHERE id = $2
RETURNING id, email, display_name, avatar_asset_id, email_verified_at,
created_at, revision, status, password_hash`,
[passwordHash, userId],
);
// 先撤销所有旧 session,再创建新 session;旧 access token 会立即失效。
await transaction.query(
`UPDATE atlas.user_sessions SET revoked_at = now()
WHERE user_id = $1 AND revoked_at IS NULL`,
[userId],
);
return this.createSessionResponse(
transaction,
updated.rows[0],
workspace,
metadata,
);
});
}
private async findWorkspace(userId: string) {
const result = await this.database.query<WorkspaceRow>(
`SELECT w.id, w.name, m.role FROM atlas.workspace_memberships m JOIN atlas.workspaces w ON w.id = m.workspace_id WHERE m.user_id = $1 AND w.status = 'active' ORDER BY m.joined_at LIMIT 1`,
[userId],
);
return requireWorkspace(result.rows[0]);
}
private async createSessionResponse(
transaction: DatabaseTransaction,
user: UserRow,
workspace: WorkspaceRow,
metadata: RequestMetadata,
) {
// refresh token 仅通过 HttpOnly cookie 交给浏览器,响应体只暴露短期 access token。
const refreshToken = createOpaqueToken();
const session = await transaction.query<{ id: string }>(
`INSERT INTO atlas.user_sessions (user_id, refresh_token_hash, expires_at, ip_hash, user_agent)
VALUES ($1, $2, now() + ($3 || ' days')::interval, $4, $5) RETURNING id`,
[
user.id,
tokenHash(refreshToken),
this.refreshTtlDays,
metadata.ipHash,
metadata.userAgent,
],
);
const now = Math.floor(Date.now() / 1000);
const accessToken = signAccessToken(
{
sub: user.id,
sid: session.rows[0].id,
iat: now,
exp: now + this.accessTtlSeconds,
},
this.config.AUTH_JWT_SECRET,
);
const payload: AuthSessionResponse = {
access_token: accessToken,
access_token_expires_in: this.accessTtlSeconds,
user: publicUser(user),
workspace,
};
return { payload, refreshToken };
}
}
export type RequestMetadata = {
ip: string | null;
ipHash: string | null;
userAgent: string | null;
};
function validationError(field: string, code: string, message: string) {
return new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
{ field, code, message },
]);
}
function isUniqueViolation(error: unknown): error is { code: string } {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code: string }).code === '23505'
);
}
async function findUserInTransaction(
transaction: DatabaseTransaction,
userId: string,
lock: boolean,
): Promise<UserRow> {
const result = await transaction.query<UserRow>(
`SELECT id, email, display_name, avatar_asset_id, email_verified_at, created_at,
revision, status, password_hash
FROM atlas.users WHERE id = $1${lock ? ' FOR UPDATE' : ''}`,
[userId],
);
if (!result.rows[0]) throw new UnauthorizedException();
return result.rows[0];
}
function versionConflict(currentRevision: number) {
return new ApiError(409, 'VERSION_CONFLICT', '账户资料已被其他操作更新', [
{
field: 'If-Match',
code: 'VERSION_CONFLICT',
message: `当前版本为 ${currentRevision}`,
current_revision: currentRevision,
},
]);
}
function publicUser(user: UserRow): AuthUser {
// 白名单映射防止 password_hash 等内部字段随查询结果泄露给客户端。
return {
id: user.id,
email: user.email,
display_name: user.display_name,
avatar_asset_id: user.avatar_asset_id,
email_verified_at: user.email_verified_at?.toISOString() ?? null,
created_at: user.created_at.toISOString(),
revision: user.revision,
};
}
async function insertUser(
transaction: DatabaseTransaction,
email: string,
passwordHash: string,
displayName: string | null,
) {
// slug 使用用户 UUID,显示名称变化不会影响个人工作空间的稳定标识。
const result = await transaction.query<UserRow>(
'INSERT INTO atlas.users (email, password_hash, display_name) VALUES ($1, $2, $3) RETURNING id, email, display_name, avatar_asset_id, email_verified_at, created_at, revision, status, password_hash',
[email, passwordHash, displayName],
);
return result.rows[0];
}
async function createPersonalWorkspace(
transaction: DatabaseTransaction,
userId: string,
label: string,
) {
const workspace = await transaction.query<{ id: string; name: string }>(
'INSERT INTO atlas.workspaces (name, slug) VALUES ($1, $2) RETURNING id, name',
[`${label} 的工作空间`, `personal-${userId}`],
);
await transaction.query(
"INSERT INTO atlas.workspace_memberships (workspace_id, user_id, role) VALUES ($1, $2, 'owner')",
[workspace.rows[0].id, userId],
);
return { ...workspace.rows[0], role: 'owner' as const };
}
async function findWorkspaceInTransaction(
transaction: DatabaseTransaction,
userId: string,
): Promise<WorkspaceRow> {
const result = await transaction.query<WorkspaceRow>(
`SELECT w.id, w.name, m.role FROM atlas.workspace_memberships m JOIN atlas.workspaces w ON w.id = m.workspace_id WHERE m.user_id = $1 AND w.status = 'active' ORDER BY m.joined_at LIMIT 1`,
[userId],
);
return requireWorkspace(result.rows[0]);
}
function requireWorkspace(workspace: WorkspaceRow | undefined): WorkspaceRow {
if (!workspace)
throw new ApiError(
403,
'WORKSPACE_MEMBERSHIP_REQUIRED',
'当前账户没有可用工作空间',
);
return workspace;
}
+64
View File
@@ -0,0 +1,64 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { ApiError } from './api-error.js';
import { AppLogger } from '../logging/app-logger.js';
@Catch()
export class ApiErrorFilter implements ExceptionFilter {
public constructor(private readonly logger: AppLogger) {}
public catch(exception: unknown, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse<{
status: (status: number) => {
json: (body: unknown) => unknown;
};
}>();
const request = host.switchToHttp().getRequest<{
requestId?: string;
method?: string;
originalUrl?: string;
}>();
const requestId = request.requestId ?? `req_${randomUUID()}`;
if (exception instanceof ApiError) {
// 已知业务错误保留其稳定 code 与 details,方便前端精确处理。
const body = exception.getResponse() as {
error: Record<string, unknown>;
};
return response
.status(exception.getStatus())
.json({ error: { ...body.error, request_id: requestId } });
}
if (exception instanceof HttpException) {
// Nest 内部异常不泄露原始 message,避免暴露认证或解析实现细节。
return response.status(exception.getStatus()).json({
error: {
code: 'HTTP_ERROR',
message: '请求无法处理',
request_id: requestId,
},
});
}
this.logger.error(
'unhandled_exception',
exception instanceof Error ? exception.stack : undefined,
{
request_id: requestId,
method: request.method,
path: request.originalUrl,
},
);
// 未预期异常只返回通用信息,request_id 用于从结构化日志定位根因。
return response.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: '服务器暂时无法处理请求',
request_id: requestId,
},
});
}
}
+13
View File
@@ -0,0 +1,13 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export class ApiError extends HttpException {
// 业务代码只能通过此类型抛出可预期错误,最终由全局过滤器统一封装为 error 信封。
public constructor(
status: HttpStatus,
code: string,
message: string,
details?: unknown[],
) {
super({ error: { code, message, details } }, status);
}
}
+133
View File
@@ -0,0 +1,133 @@
import { createHmac } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import type { Database, DatabaseTransaction } from '@atlas/database';
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
import { DATABASE } from '../database/database.module.js';
import { ApiError } from './api-error.js';
type IdempotentResponse = { status: number; body: unknown };
type IdempotencyInput = {
workspaceId: string;
userId: string;
method: 'POST' | 'PUT' | 'PATCH' | 'DELETE';
path: string;
key: string;
request: unknown;
};
type IdempotencyRow = {
request_fingerprint: string;
status: 'in_progress' | 'completed';
response_status: number | null;
response_body: unknown;
};
@Injectable()
export class IdempotencyService {
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
/**
* 幂等记录与业务写入共用同一个工作空间事务。只有两者都提交,重试才会得到可靠的历史响应。
*/
public async run(
input: IdempotencyInput,
work: (transaction: DatabaseTransaction) => Promise<IdempotentResponse>,
): Promise<IdempotentResponse> {
const fingerprint = createHmac(
'sha256',
this.config.IDEMPOTENCY_KEY_SECRET,
)
.update(canonicalize(input.request))
.digest('base64url');
return this.database.workspaceTransaction(
input.workspaceId,
async (transaction) => {
const inserted = await transaction.query<IdempotencyRow>(
`INSERT INTO atlas.idempotency_requests
(workspace_id, user_id, http_method, request_path, idempotency_key, request_fingerprint)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (workspace_id, user_id, http_method, request_path, idempotency_key) DO NOTHING
RETURNING request_fingerprint, status, response_status, response_body`,
[
input.workspaceId,
input.userId,
input.method,
input.path,
input.key,
fingerprint,
],
);
if (!inserted.rows[0]) {
// 冲突插入会等待首个事务结束;随后行锁确保只能读取完整的持久化结果。
const existing = await transaction.query<IdempotencyRow>(
`SELECT request_fingerprint, status, response_status, response_body
FROM atlas.idempotency_requests
WHERE workspace_id = $1 AND user_id = $2 AND http_method = $3
AND request_path = $4 AND idempotency_key = $5
FOR UPDATE`,
[
input.workspaceId,
input.userId,
input.method,
input.path,
input.key,
],
);
const row = existing.rows[0];
if (!row || row.request_fingerprint !== fingerprint) {
throw new ApiError(
409,
'IDEMPOTENCY_KEY_REUSED',
'幂等键已用于不同请求',
);
}
if (row.status !== 'completed' || !row.response_status) {
throw new ApiError(
409,
'IDEMPOTENCY_REQUEST_IN_PROGRESS',
'相同请求仍在处理中',
);
}
return {
status: row.response_status,
body: row.response_body,
};
}
const response = await work(transaction);
await transaction.query(
`UPDATE atlas.idempotency_requests
SET status = 'completed', response_status = $1, response_body = $2,
completed_at = now()
WHERE workspace_id = $3 AND user_id = $4 AND http_method = $5
AND request_path = $6 AND idempotency_key = $7`,
[
response.status,
response.body,
input.workspaceId,
input.userId,
input.method,
input.path,
input.key,
],
);
return response;
},
);
}
}
/** 规范化排序后再签名,使 JSON 字段顺序不会改变同一请求的身份。 */
function canonicalize(value: unknown): string {
if (value === null || typeof value !== 'object')
return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`;
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalize(record[key])}`)
.join(',')}}`;
}
+6
View File
@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_ENDPOINT = 'atlas:is-public-endpoint';
// 全局认证守卫只跳过显式标记的端点,避免新接口因遗漏守卫而意外公开。
export const Public = () => SetMetadata(IS_PUBLIC_ENDPOINT, true);
+141
View File
@@ -0,0 +1,141 @@
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { z } from 'zod';
export const APP_CONFIG = Symbol('APP_CONFIG');
function findEnvironmentFile(): string {
const directories = [
process.cwd(),
resolve(process.cwd(), '..'),
resolve(process.cwd(), '../..'),
];
const environmentFile = directories
.map((directory) => resolve(directory, '.env'))
.find((file) => existsSync(file));
return environmentFile ?? resolve(process.cwd(), '.env');
}
// 此 schema 是 API 运行配置的唯一来源;默认值只允许用于不含秘密的本地开发场景。
const environmentSchema = z.object({
NODE_ENV: z
.enum(['development', 'test', 'production'])
.default('development'),
API_PORT: z.coerce.number().int().min(1).max(65_535).default(8200),
WEB_ORIGIN: z.url().default('http://localhost:8100'),
DATABASE_URL: z.url(),
REDIS_URL: z.url(),
AUTH_JWT_SECRET: z.string().min(32),
AUTH_RATE_LIMIT_KEY_SECRET: z.string().min(32),
STORE_SECRET_ENCRYPTION_KEY: z.string().min(32),
STORE_SECRET_ENCRYPTION_KEY_VERSION: z
.string()
.min(1)
.max(64)
.default('v1'),
IDEMPOTENCY_KEY_SECRET: z.string().min(32),
AUTH_PASSWORD_MIN_LENGTH: z.coerce
.number()
.int()
.min(12)
.max(256)
.default(12),
API_BODY_LIMIT_BYTES: z.coerce
.number()
.int()
.min(1_024)
.max(10 * 1024 * 1024)
.default(1_048_576),
AUTH_RATE_LIMIT_WINDOW_SECONDS: z.coerce
.number()
.int()
.min(1)
.max(86_400)
.default(900),
AUTH_LOGIN_IP_LIMIT: z.coerce.number().int().min(1).max(1000).default(10),
AUTH_LOGIN_EMAIL_LIMIT: z.coerce.number().int().min(1).max(1000).default(5),
AUTH_REGISTER_IP_LIMIT: z.coerce.number().int().min(1).max(1000).default(5),
AUTH_REGISTER_EMAIL_LIMIT: z.coerce
.number()
.int()
.min(1)
.max(1000)
.default(3),
AUTH_REFRESH_IP_LIMIT: z.coerce
.number()
.int()
.min(1)
.max(10_000)
.default(30),
AUTH_CHANGE_PASSWORD_IP_LIMIT: z.coerce
.number()
.int()
.min(1)
.max(1_000)
.default(10),
AUTH_CHANGE_PASSWORD_USER_LIMIT: z.coerce
.number()
.int()
.min(1)
.max(1_000)
.default(5),
TRUSTED_PROXY_CIDRS: z.string().default(''),
SWAGGER_ENABLED: z.enum(['true', 'false']).default('true'),
LOG_LEVEL: z.enum(['error', 'warn', 'log', 'debug']).default('log'),
});
type Environment = z.infer<typeof environmentSchema>;
export type AppConfig = Omit<
Environment,
'TRUSTED_PROXY_CIDRS' | 'SWAGGER_ENABLED'
> & {
trustedProxyCidrs: string[];
swaggerEnabled: boolean;
};
function validateEnvironment(
environment: Record<string, unknown>,
): Environment {
const result = environmentSchema.safeParse(environment);
if (result.success) return result.data;
// 在 Nest 开始监听前失败,避免半配置 API 对外提供不安全服务。
throw new Error(
`Invalid environment configuration: ${result.error.issues.map((issue) => issue.path.join('.')).join(', ')}`,
);
}
function createAppConfig(environment: Environment): AppConfig {
return {
...environment,
// 只有显式给出代理网段时才信任转发头,避免客户端伪造来源 IP。
trustedProxyCidrs: environment.TRUSTED_PROXY_CIDRS.split(',')
.map((value) => value.trim())
.filter(Boolean),
swaggerEnabled: environment.SWAGGER_ENABLED === 'true',
};
}
@Global()
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
cache: true,
// Turbo 从 apps/api 运行脚本,但共享开发环境文件位于仓库根目录。
envFilePath: findEnvironmentFile(),
}),
],
providers: [
{
provide: APP_CONFIG,
// 导出规范化配置,业务服务不得散落读取 process.env。
useFactory: (): AppConfig =>
createAppConfig(validateEnvironment(process.env)),
},
],
exports: [APP_CONFIG],
})
export class AppConfigModule {}
+26
View File
@@ -0,0 +1,26 @@
import { Global, Inject, Module, OnModuleDestroy } from '@nestjs/common';
import { createApplicationDatabase, type Database } from '@atlas/database';
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
export const DATABASE = Symbol('DATABASE');
@Global()
@Module({
providers: [
{
provide: DATABASE,
inject: [APP_CONFIG],
// 业务代码只能拿到受限应用连接,迁移连接串绝不注入 API 容器。
useFactory: (config: AppConfig): Database =>
createApplicationDatabase(config.DATABASE_URL),
},
],
exports: [DATABASE],
})
export class DatabaseModule implements OnModuleDestroy {
public constructor(@Inject(DATABASE) private readonly database: Database) {}
public async onModuleDestroy() {
// Nest 关闭时主动释放连接池,避免测试和热重启遗留连接。
await this.database.close();
}
}
+62
View File
@@ -0,0 +1,62 @@
import type { LoggerService } from '@nestjs/common';
type LogLevel = 'error' | 'warn' | 'log' | 'debug';
const severity: Record<LogLevel, number> = {
error: 0,
warn: 1,
log: 2,
debug: 3,
};
// 按字段名递归脱敏,日志中不得出现会话、密码、密钥或授权头原文。
const sensitiveKeys = /authorization|cookie|password|secret|token|api[_-]?key/i;
export class AppLogger implements LoggerService {
public constructor(private readonly minimumLevel: LogLevel) {}
public log(message: unknown, context?: unknown): void {
this.write('log', message, context);
}
public error(message: unknown, stack?: string, context?: unknown): void {
this.write('error', message, { stack, context });
}
public warn(message: unknown, context?: unknown): void {
this.write('warn', message, context);
}
public debug(message: unknown, context?: unknown): void {
this.write('debug', message, context);
}
public verbose(message: unknown, context?: unknown): void {
this.debug(message, context);
}
private write(level: LogLevel, message: unknown, context?: unknown): void {
if (severity[level] > severity[this.minimumLevel]) return;
const entry = {
timestamp: new Date().toISOString(),
level,
message: typeof message === 'string' ? message : sanitize(message),
context: sanitize(context),
};
// 每行独立 JSON,方便日志采集系统按字段索引 request_id、状态码等上下文。
const output = JSON.stringify(entry);
if (level === 'error') console.error(output);
else console.log(output);
}
}
function sanitize(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sanitize);
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
sensitiveKeys.test(key) ? '[REDACTED]' : sanitize(child),
]),
);
}
return value;
}
+144
View File
@@ -0,0 +1,144 @@
import 'reflect-metadata';
import { randomUUID } from 'node:crypto';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import type { NextFunction, Request, Response } from 'express';
import helmet from 'helmet';
import { AppModule } from './app.module.js';
import { ApiErrorFilter } from './common/api-error.filter.js';
import { APP_CONFIG, type AppConfig } from './config/app-config.module.js';
import { AppLogger } from './logging/app-logger.js';
type RequestWithId = Request & {
requestId?: string;
};
export async function createApplication() {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
bodyParser: false,
// 自定义日志器安装前保留 Nest 启动日志,避免配置错误没有可见诊断。
logger: ['error', 'warn', 'log'],
});
const config = app.get<AppConfig>(APP_CONFIG);
const logger = new AppLogger(config.LOG_LEVEL);
app.useLogger(logger);
if (config.trustedProxyCidrs.length > 0) {
// 仅在部署明确声明代理网段时信任转发头,避免伪造客户端 IP。
app.getHttpAdapter()
.getInstance()
.set('trust proxy', config.trustedProxyCidrs);
}
app.enableCors({
origin: config.WEB_ORIGIN,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: [
'Authorization',
'Content-Type',
'Idempotency-Key',
'If-Match',
'X-Atlas-Platform-Id',
'X-Request-Id',
],
exposedHeaders: ['X-Request-Id'],
});
// Helmet、body 限制、请求 ID 与全局错误过滤器必须在业务路由前注册。
app.use(helmet({ contentSecurityPolicy: false }));
app.useBodyParser('json', { limit: config.API_BODY_LIMIT_BYTES });
app.useBodyParser('urlencoded', {
limit: config.API_BODY_LIMIT_BYTES,
extended: true,
});
app.use(
(request: RequestWithId, response: Response, next: NextFunction) => {
const typedRequest = request;
const typedResponse = response;
const suppliedId = typedRequest.headers['x-request-id'];
// 合法调用方提供的 request ID 可贯穿上游链路;其他情况由服务端生成。
const requestId =
typeof suppliedId === 'string' &&
/^[A-Za-z0-9_-]{8,128}$/.test(suppliedId)
? suppliedId
: `req_${randomUUID()}`;
const startedAt = performance.now();
typedRequest.requestId = requestId;
typedResponse.setHeader('X-Request-Id', requestId);
typedResponse.on('finish', () => {
logger.log('http_request_completed', {
request_id: requestId,
method: typedRequest.method,
path: typedRequest.originalUrl,
status_code: typedResponse.statusCode,
duration_ms: Math.round(performance.now() - startedAt),
});
});
next();
},
);
app.useGlobalFilters(new ApiErrorFilter(logger));
app.setGlobalPrefix('api', { exclude: ['health', 'ready'] });
app.enableShutdownHooks();
if (config.swaggerEnabled) {
// Swagger 仅描述运行时已注册路由;冻结接口契约仍以 docs/api 为准。
const swaggerConfig = new DocumentBuilder()
.setTitle('Atlas Seller Kit API')
.setDescription('Atlas 卖家工作台 HTTP API 文档')
.setVersion('0.1.0')
.addBearerAuth(
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
'access-token',
)
.build();
const swaggerDocument = SwaggerModule.createDocument(
app,
swaggerConfig,
);
SwaggerModule.setup('api-docs', app, swaggerDocument, {
swaggerOptions: { persistAuthorization: true },
});
}
return { app, config, logger };
}
export async function bootstrap() {
const { app, config, logger } = await createApplication();
await app.listen(config.API_PORT);
logger.log('api_started', {
port: config.API_PORT,
swagger_enabled: config.swaggerEnabled,
});
}
// 测试复用应用工厂时不自动监听固定端口,只有直接运行入口才启动服务。
if (
process.argv[1] &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
void bootstrap().catch((error: unknown) => {
const details =
error instanceof Error
? {
name: error.name,
message: error.message,
stack: error.stack,
}
: error;
console.error(
JSON.stringify({
timestamp: new Date().toISOString(),
level: 'error',
message: 'api_startup_failed',
error: details,
}),
);
process.exitCode = 1;
});
}
@@ -0,0 +1,131 @@
import {
Inject,
Injectable,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { createHmac } from 'node:crypto';
import { Redis } from 'ioredis';
import { ApiError } from '../common/api-error.js';
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
type AuthAction = 'login' | 'register' | 'refresh' | 'change_password';
type Limit = { ip: number; email?: number; user?: number };
@Injectable()
export class AuthRateLimiter implements OnModuleInit, OnModuleDestroy {
private readonly redis: Redis;
public constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {
// Redis 让限流计数在多个 API 实例间共享,不会随单个实例重启清零。
this.redis = new Redis(config.REDIS_URL, {
enableOfflineQueue: false,
lazyConnect: true,
maxRetriesPerRequest: 1,
});
}
public async onModuleInit(): Promise<void> {
// 在开始接收认证请求前完成连接,避免首个请求命中 Redis 连接竞态而被误判为 503。
await this.redis.connect();
}
public async consume(
action: AuthAction,
identifiers: { ip: string | null; email?: string; userId?: string },
): Promise<void> {
const limit = this.limitFor(action);
await this.consumeBucket(
action,
'ip',
identifiers.ip ?? 'unknown',
limit.ip,
);
if (identifiers.email && limit.email) {
await this.consumeBucket(
action,
'email',
identifiers.email,
limit.email,
);
}
if (identifiers.userId && limit.user) {
await this.consumeBucket(
action,
'user',
identifiers.userId,
limit.user,
);
}
}
public async onModuleDestroy(): Promise<void> {
if (this.redis.status !== 'ready') {
this.redis.disconnect();
return;
}
await this.redis.quit();
}
private async consumeBucket(
action: AuthAction,
dimension: 'ip' | 'email' | 'user',
identifier: string,
limit: number,
): Promise<void> {
const digest = createHmac(
'sha256',
this.config.AUTH_RATE_LIMIT_KEY_SECRET,
)
.update(identifier)
.digest('base64url');
// Redis key 只保存 IP/邮箱的 HMAC,不暴露可被日志或运维工具读取的原文。
const key = `atlas:auth-rate-limit:${action}:${dimension}:${digest}`;
try {
// INCR 与首次 EXPIRE 必须原子执行,避免并发下产生永不过期的限流 key。
const count = await this.redis.eval(
"local value = redis.call('INCR', KEYS[1]); if value == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]); end; return value;",
1,
key,
this.config.AUTH_RATE_LIMIT_WINDOW_SECONDS,
);
if (Number(count) > limit) {
throw new ApiError(
429,
'AUTH_RATE_LIMITED',
'请求过于频繁,请稍后重试',
);
}
} catch (error) {
if (error instanceof ApiError) throw error;
// 认证限流故障时必须拒绝请求;静默跳过会扩大暴力破解风险。
throw new ApiError(
503,
'AUTH_RATE_LIMIT_UNAVAILABLE',
'认证服务暂时不可用',
);
}
}
private limitFor(action: AuthAction): Limit {
if (action === 'login') {
return {
ip: this.config.AUTH_LOGIN_IP_LIMIT,
email: this.config.AUTH_LOGIN_EMAIL_LIMIT,
};
}
if (action === 'register') {
return {
ip: this.config.AUTH_REGISTER_IP_LIMIT,
email: this.config.AUTH_REGISTER_EMAIL_LIMIT,
};
}
if (action === 'change_password') {
return {
ip: this.config.AUTH_CHANGE_PASSWORD_IP_LIMIT,
user: this.config.AUTH_CHANGE_PASSWORD_USER_LIMIT,
};
}
return { ip: this.config.AUTH_REFRESH_IP_LIMIT };
}
}
+6
View File
@@ -0,0 +1,6 @@
import { Global, Module } from '@nestjs/common';
import { AuthRateLimiter } from './auth-rate-limiter.service.js';
@Global()
@Module({ providers: [AuthRateLimiter], exports: [AuthRateLimiter] })
export class SecurityModule {}
+125
View File
@@ -0,0 +1,125 @@
import { Inject, Injectable } from '@nestjs/common';
import type { Database, DatabaseTransaction } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import { DATABASE } from '../database/database.module.js';
type PlatformRow = {
id: string;
code: string;
name: string;
capabilities: Record<string, unknown>;
supported_currencies: string[];
};
@Injectable()
export class PlatformsService {
public constructor(@Inject(DATABASE) private readonly database: Database) {}
public async list() {
const rows = await this.database.query<PlatformRow>(
`SELECT p.id, p.code, p.name, p.capabilities,
coalesce(array_agg(pcs.currency_code ORDER BY pcs.currency_code)
FILTER (WHERE pcs.is_enabled AND c.is_enabled), ARRAY[]::char(3)[]) AS supported_currencies
FROM atlas.platforms p
LEFT JOIN atlas.platform_currency_support pcs ON pcs.platform_id = p.id
LEFT JOIN atlas.currencies c ON c.code = pcs.currency_code
WHERE p.status = 'active'
GROUP BY p.id
ORDER BY CASE WHEN p.code = 'OZON' THEN 0 ELSE 1 END, p.name`,
);
return rows.rows.map((platform) => ({
id: platform.id,
code: platform.code,
name: platform.name,
supported_currencies: platform.supported_currencies,
// 能力未配置时使用 null,而不是把 Ozon 的市场或语言假设扩散到通用模型。
default_market_code: stringCapability(
platform.capabilities,
'default_market_code',
),
default_locale: stringCapability(
platform.capabilities,
'default_locale',
),
}));
}
public async context(workspaceId: string, platformId: string) {
return this.database.workspaceTransaction(
workspaceId,
async (transaction) => {
const platform = await this.requireActivePlatform(
transaction,
platformId,
);
const stores = await transaction.query<{
id: string;
name: string;
status: string;
credential_status: string;
count: string;
}>(
`SELECT s.id, s.name, s.status, s.credential_status,
count(*) OVER ()::text AS count
FROM atlas.stores s
WHERE s.platform_id = $1 AND s.deleted_at IS NULL AND s.is_default
LIMIT 1`,
[platformId],
);
// 默认门店不存在时仍要正确返回总数,不能用默认行的窗口函数猜测。
const count = await transaction.query<{ count: string }>(
`SELECT count(*)::text AS count FROM atlas.stores
WHERE platform_id = $1 AND deleted_at IS NULL`,
[platformId],
);
const defaultStore = stores.rows[0];
return {
platform: {
id: platform.id,
code: platform.code,
name: platform.name,
},
default_store: defaultStore
? {
id: defaultStore.id,
name: defaultStore.name,
status: defaultStore.status,
credential_status: defaultStore.credential_status,
}
: null,
store_count: Number(count.rows[0]?.count ?? 0),
};
},
);
}
private async requireActivePlatform(
transaction: DatabaseTransaction,
platformId: string,
) {
const rows = await transaction.query<{
id: string;
code: string;
name: string;
}>(
`SELECT id, code, name FROM atlas.platforms WHERE id = $1 AND status = 'active'`,
[platformId],
);
if (!rows.rows[0]) {
throw new ApiError(
422,
'PLATFORM_UNAVAILABLE',
'平台不存在或已停用',
);
}
return rows.rows[0];
}
}
function stringCapability(
capabilities: Record<string, unknown>,
key: string,
): string | null {
const value = capabilities[key];
return typeof value === 'string' ? value : null;
}
@@ -0,0 +1,37 @@
import {
createCipheriv,
createHash,
createHmac,
randomBytes,
} from 'node:crypto';
export type EncryptedStoreSecret = {
ciphertext: Buffer;
nonce: Buffer;
fingerprint: string;
};
/**
* 密钥只在此边界短暂以明文存在。数据库保存 AES-GCM 密文和随机 nonce,
* fingerprint 是不可逆 HMAC,仅用于安全地识别同一份密钥。
*/
export function encryptStoreSecret(
plaintext: string,
keyMaterial: string,
): EncryptedStoreSecret {
const key = createHash('sha256').update(keyMaterial).digest();
const nonce = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, nonce);
const ciphertext = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
cipher.getAuthTag(),
]);
return {
ciphertext,
nonce,
fingerprint: createHmac('sha256', key)
.update(plaintext)
.digest('base64url'),
};
}
+340
View File
@@ -0,0 +1,340 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
Inject,
Param,
Patch,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import {
ApiAcceptedResponse,
ApiBearerAuth,
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import {
createStoreRequestSchema,
listStoresQuerySchema,
updateStoreRequestSchema,
} from '@atlas/contracts';
import { z } from 'zod';
import { ApiError } from '../common/api-error.js';
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
import type { Workspace } from '@atlas/contracts';
import { PlatformsService } from './platforms.service.js';
import { StoresService } from './stores.service.js';
type HttpResponse = { status: (status: number) => HttpResponse };
@ApiTags('Platforms')
@ApiBearerAuth('access-token')
@Controller()
export class PlatformsController {
// tsx/esbuild 不生成 design:paramtypes 元数据,控制器依赖必须显式 @Inject(与 auth.controller 一致)
public constructor(
@Inject(PlatformsService)
private readonly platformsService: PlatformsService,
) {}
@Get('platforms')
@ApiOperation({ summary: '获取已启用的平台及非敏感能力目录' })
@ApiOkResponse({ description: '平台目录' })
public async list() {
return { data: await this.platformsService.list() };
}
@Get('platform-context')
@ApiOperation({ summary: '获取页头所选平台的默认门店上下文' })
@ApiOkResponse({ description: '平台、默认门店和门店总数' })
public async context(
@Headers('x-atlas-platform-id') platformId: string | undefined,
@CurrentWorkspace() workspace: Workspace,
) {
if (!platformId) {
throw new ApiError(
400,
'PLATFORM_CONTEXT_REQUIRED',
'请求缺少 X-Atlas-Platform-Id',
);
}
return {
data: await this.platformsService.context(
workspace.id,
requireUuid('X-Atlas-Platform-Id', platformId),
),
};
}
}
@ApiTags('Stores')
@ApiBearerAuth('access-token')
@Controller('stores')
export class StoresController {
public constructor(
@Inject(StoresService) private readonly storesService: StoresService,
) {}
@Get()
@ApiOperation({ summary: '按平台分页列出当前工作空间的门店' })
@ApiOkResponse({ description: '门店列表与不透明游标' })
public async list(
@Query() query: unknown,
@Headers('x-atlas-platform-id') platformHeader: string | undefined,
@CurrentWorkspace() workspace: Workspace,
) {
if (platformHeader) {
throw new ApiError(
400,
'PLATFORM_CONTEXT_CONFLICT',
'门店管理列表不能同时使用平台 header 和 platform_id 查询参数',
);
}
return this.storesService.list(
workspace.id,
parse(listStoresQuerySchema, query),
);
}
@Get(':storeId')
@ApiOperation({ summary: '读取单个门店' })
public async get(
@Param('storeId') storeId: string,
@CurrentWorkspace() workspace: Workspace,
) {
return {
data: await this.storesService.get(
workspace.id,
requireUuid('store_id', storeId),
),
};
}
@Post()
@ApiOperation({ summary: '创建门店连接,并请求异步凭据验证' })
@ApiAcceptedResponse({ description: '已加密保存凭据,并创建验证操作' })
public async create(
@Body() body: unknown,
@Headers('idempotency-key') idempotencyKey: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: HttpResponse,
) {
return withStatus(
response,
await this.storesService.create(
workspace.id,
request.auth.userId,
requireIdempotencyKey(idempotencyKey),
parse(createStoreRequestSchema, body),
),
);
}
@Patch(':storeId')
@ApiOperation({ summary: '按版本编辑门店;更新密钥会重新请求验证' })
@ApiOkResponse({ description: '普通字段已更新' })
@ApiAcceptedResponse({ description: '新密钥已保存,并创建验证操作' })
public async update(
@Param('storeId') storeId: string,
@Body() body: unknown,
@Headers('idempotency-key') idempotencyKey: string | undefined,
@Headers('if-match') ifMatch: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: HttpResponse,
) {
return withStatus(
response,
await this.storesService.update(
workspace.id,
request.auth.userId,
requireUuid('store_id', storeId),
requireIdempotencyKey(idempotencyKey),
parseRevision(ifMatch),
parse(updateStoreRequestSchema, body),
),
);
}
@Post(':storeId/set-default')
@ApiOperation({ summary: '设为当前平台默认门店' })
public async setDefault(
@Param('storeId') storeId: string,
@Headers('idempotency-key') idempotencyKey: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: HttpResponse,
) {
return withStatus(
response,
await this.storesService.setDefault(
workspace.id,
request.auth.userId,
requireUuid('store_id', storeId),
requireIdempotencyKey(idempotencyKey),
),
);
}
@Post(':storeId/credential-verifications')
@ApiOperation({ summary: '手动请求重新验证门店凭据' })
@ApiAcceptedResponse({ description: '凭据验证操作已入队' })
public async credentialVerification(
@Param('storeId') storeId: string,
@Headers('idempotency-key') idempotencyKey: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: HttpResponse,
) {
return withStatus(
response,
await this.storesService.requestCredentialVerification(
workspace.id,
request.auth.userId,
requireUuid('store_id', storeId),
requireIdempotencyKey(idempotencyKey),
),
);
}
@Post(':storeId/warehouse-syncs')
@ApiOperation({ summary: '请求异步同步门店仓库' })
@ApiAcceptedResponse({ description: '仓库同步操作已入队' })
public async warehouseSync(
@Param('storeId') storeId: string,
@Headers('idempotency-key') idempotencyKey: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: HttpResponse,
) {
return withStatus(
response,
await this.storesService.requestWarehouseSync(
workspace.id,
request.auth.userId,
requireUuid('store_id', storeId),
requireIdempotencyKey(idempotencyKey),
),
);
}
@Get(':storeId/warehouses')
@ApiOperation({ summary: '读取最近成功同步的仓库摘要' })
public async warehouses(
@Param('storeId') storeId: string,
@CurrentWorkspace() workspace: Workspace,
) {
return this.storesService.warehouses(
workspace.id,
requireUuid('store_id', storeId),
);
}
@Delete(':storeId')
@HttpCode(204)
@ApiOperation({ summary: '软删除门店连接并撤销当前密钥' })
@ApiNoContentResponse({ description: '门店已删除' })
public async remove(
@Param('storeId') storeId: string,
@Headers('idempotency-key') idempotencyKey: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: HttpResponse,
) {
return withStatus(
response,
await this.storesService.remove(
workspace.id,
request.auth.userId,
requireUuid('store_id', storeId),
requireIdempotencyKey(idempotencyKey),
),
);
}
}
@ApiTags('Operations')
@ApiBearerAuth('access-token')
@Controller('operations')
export class OperationsController {
public constructor(
@Inject(StoresService) private readonly storesService: StoresService,
) {}
@Get(':operationId')
@ApiOperation({ summary: '读取当前工作空间的异步操作状态' })
public async get(
@Param('operationId') operationId: string,
@CurrentWorkspace() workspace: Workspace,
) {
return {
data: await this.storesService.operation(
workspace.id,
requireUuid('operation_id', operationId),
),
};
}
}
function withStatus(
response: HttpResponse,
result: { status: number; body: unknown },
) {
response.status(result.status);
return result.body;
}
function requireIdempotencyKey(value: string | undefined): string {
if (!value) {
throw new ApiError(
400,
'IDEMPOTENCY_KEY_REQUIRED',
'请求缺少 Idempotency-Key',
);
}
return requireUuid('Idempotency-Key', value);
}
function requireUuid(field: string, value: string): string {
if (z.uuid().safeParse(value).success) return value;
throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
{ field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` },
]);
}
function parseRevision(value: string | undefined): number {
const match = value?.match(/^"([1-9]\d*)"$/);
if (!match) {
throw new ApiError(
400,
'IF_MATCH_REQUIRED',
'请求必须携带形如 "1" 的 If-Match',
);
}
return Number(match[1]);
}
function parse<T>(schema: z.ZodType<T>, value: unknown): T {
const result = schema.safeParse(value);
if (result.success) return result.data;
throw new ApiError(
422,
'VALIDATION_FAILED',
'请求参数不符合要求',
result.error.issues.map((issue) => ({
field: issue.path.join('.'),
code: 'INVALID_FIELD',
message: issue.message,
})),
);
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { IdempotencyService } from '../common/idempotency.service.js';
import {
OperationsController,
PlatformsController,
StoresController,
} from './stores.controller.js';
import { PlatformsService } from './platforms.service.js';
import { StoresService } from './stores.service.js';
@Module({
controllers: [PlatformsController, StoresController, OperationsController],
providers: [IdempotencyService, PlatformsService, StoresService],
})
export class StoresModule {}
+730
View File
@@ -0,0 +1,730 @@
import { Inject, Injectable } from '@nestjs/common';
import type {
CreateStoreRequest,
ListStoresQuery,
Operation,
Store,
UpdateStoreRequest,
} from '@atlas/contracts';
import type { Database, DatabaseTransaction } from '@atlas/database';
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';
import { encryptStoreSecret } from './store-secret.crypto.js';
type StoreRow = Omit<Store, keyof { created_at: never; updated_at: never }> & {
credential_checked_at: Date | string | null;
api_key_expires_at: Date | string | null;
product_limit_checked_at: Date | string | null;
warehouses_synced_at: Date | string | null;
created_at: Date | string;
updated_at: Date | string;
};
type StoreLockRow = {
id: string;
platform_id: string;
revision: number;
is_default: boolean;
status: string;
};
type OperationRow = {
id: string;
kind: string;
status: string;
created_at: Date | string;
completed_at: Date | string | null;
error_code: string | null;
error_message: string | null;
};
type WarehouseRow = {
id: string;
platform_warehouse_id: string;
name: string;
fulfillment: string;
is_rfbs: boolean | null;
status: string;
synced_at: Date | string;
};
@Injectable()
export class StoresService {
public constructor(
@Inject(IdempotencyService)
private readonly idempotency: IdempotencyService,
@Inject(DATABASE) private readonly database: Database,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
public async list(workspaceId: string, query: ListStoresQuery) {
const cursor = query.cursor ? decodeCursor(query.cursor) : null;
return this.database.workspaceTransaction(
workspaceId,
async (transaction: DatabaseTransaction) => {
const rows = await transaction.query<StoreRow>(
`${storeSelect}
WHERE s.platform_id = $1 AND s.deleted_at IS NULL
AND ($2::timestamptz IS NULL OR (s.created_at, s.id) < ($2::timestamptz, $3::uuid))
GROUP BY s.id, binding.value
ORDER BY s.created_at DESC, s.id DESC
LIMIT $4`,
[
query.platform_id,
cursor?.createdAt ?? null,
cursor?.id ?? null,
query.limit + 1,
],
);
const hasMore = rows.rows.length > query.limit;
const items = rows.rows.slice(0, query.limit).map(toStore);
const last = items.at(-1);
return {
data: items,
meta: {
next_cursor:
hasMore && last
? encodeCursor(last.created_at, last.id)
: null,
has_more: hasMore,
},
};
},
);
}
public async get(workspaceId: string, storeId: string): Promise<Store> {
return this.database.workspaceTransaction(
workspaceId,
async (transaction: DatabaseTransaction) =>
this.requireStore(transaction, storeId),
);
}
public async create(
workspaceId: string,
userId: string,
idempotencyKey: string,
input: CreateStoreRequest,
) {
return this.idempotency.run(
{
workspaceId,
userId,
method: 'POST',
path: '/api/stores',
key: idempotencyKey,
request: input,
},
async (transaction) => {
await this.assertPlatformCurrency(
transaction,
input.platform_id,
input.currency_code,
);
// 同一平台的默认店铺修改串行化,避免两个并发创建都错误地判断自己是首店。
await lockPlatform(transaction, workspaceId, input.platform_id);
const existing = await transaction.query<{ id: string }>(
`SELECT id FROM atlas.stores
WHERE platform_id = $1 AND platform_client_id = $2
LIMIT 1`,
[input.platform_id, input.client_id],
);
if (existing.rows[0]) {
throw new ApiError(
409,
'STORE_CONNECTION_ALREADY_EXISTS',
'该平台账户已绑定为门店',
);
}
const currentStores = await transaction.query<{
count: string;
}>(
`SELECT count(*)::text AS count FROM atlas.stores
WHERE platform_id = $1 AND deleted_at IS NULL`,
[input.platform_id],
);
const becomesDefault =
Number(currentStores.rows[0]?.count ?? 0) === 0 ||
input.is_default;
if (becomesDefault) {
await transaction.query(
`UPDATE atlas.stores SET is_default = false, revision = revision + 1, updated_at = now()
WHERE platform_id = $1 AND is_default AND deleted_at IS NULL`,
[input.platform_id],
);
}
const created = await transaction.query<{ id: string }>(
`INSERT INTO atlas.stores
(workspace_id, platform_id, name, platform_client_id, currency_code, is_default, note)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6)
RETURNING id`,
[
input.platform_id,
input.name,
input.client_id,
input.currency_code,
becomesDefault,
input.note ?? null,
],
);
const storeId = created.rows[0].id;
await this.replaceApiKey(transaction, storeId, input.api_key);
const operation = await this.createOperation(
transaction,
userId,
storeId,
'store_credential_verification',
);
return {
status: 202,
body: {
data: {
store: await this.requireStore(
transaction,
storeId,
),
operation,
},
},
};
},
);
}
public async update(
workspaceId: string,
userId: string,
storeId: string,
idempotencyKey: string,
revision: number,
input: UpdateStoreRequest,
) {
return this.idempotency.run(
{
workspaceId,
userId,
method: 'PATCH',
path: `/api/stores/${storeId}`,
key: idempotencyKey,
request: { if_match: revision, ...input },
},
async (transaction) => {
const current = await this.lockStore(transaction, storeId);
this.assertRevision(current, revision);
if (input.currency_code) {
await this.assertPlatformCurrency(
transaction,
current.platform_id,
input.currency_code,
);
}
const replacingKey = input.api_key !== undefined;
await transaction.query(
`UPDATE atlas.stores
SET name = COALESCE($1, name), currency_code = COALESCE($2, currency_code),
note = CASE WHEN $3 THEN $4 ELSE note END,
status = CASE WHEN $5 THEN 'verifying'::atlas.store_status ELSE status END,
credential_status = CASE WHEN $5 THEN 'unknown'::atlas.credential_status ELSE credential_status END,
last_error_code = CASE WHEN $5 THEN NULL ELSE last_error_code END,
last_error_message = CASE WHEN $5 THEN NULL ELSE last_error_message END,
last_error_at = CASE WHEN $5 THEN NULL ELSE last_error_at END,
revision = revision + 1, updated_at = now()
WHERE id = $6`,
[
input.name ?? null,
input.currency_code ?? null,
Object.hasOwn(input, 'note'),
input.note ?? null,
replacingKey,
storeId,
],
);
if (input.api_key !== undefined) {
await this.replaceApiKey(
transaction,
storeId,
input.api_key,
);
const operation = await this.createOperation(
transaction,
userId,
storeId,
'store_credential_verification',
);
return {
status: 202,
body: {
data: {
store: await this.requireStore(
transaction,
storeId,
),
operation,
},
},
};
}
return {
status: 200,
body: {
data: await this.requireStore(transaction, storeId),
},
};
},
);
}
public async setDefault(
workspaceId: string,
userId: string,
storeId: string,
idempotencyKey: string,
) {
return this.idempotency.run(
{
workspaceId,
userId,
method: 'POST',
path: `/api/stores/${storeId}/set-default`,
key: idempotencyKey,
request: {},
},
async (transaction) => {
const current = await this.lockStore(transaction, storeId);
await lockPlatform(
transaction,
workspaceId,
current.platform_id,
);
await transaction.query(
`UPDATE atlas.stores SET is_default = false, revision = revision + 1, updated_at = now()
WHERE platform_id = $1 AND is_default AND deleted_at IS NULL AND id <> $2`,
[current.platform_id, storeId],
);
if (!current.is_default) {
await transaction.query(
`UPDATE atlas.stores SET is_default = true, revision = revision + 1, updated_at = now()
WHERE id = $1`,
[storeId],
);
}
return {
status: 200,
body: {
data: await this.requireStore(transaction, storeId),
},
};
},
);
}
public async requestCredentialVerification(
workspaceId: string,
userId: string,
storeId: string,
idempotencyKey: string,
) {
return this.requestOperation(
workspaceId,
userId,
storeId,
idempotencyKey,
'credential-verifications',
'store_credential_verification',
false,
);
}
public async requestWarehouseSync(
workspaceId: string,
userId: string,
storeId: string,
idempotencyKey: string,
) {
return this.requestOperation(
workspaceId,
userId,
storeId,
idempotencyKey,
'warehouse-syncs',
'store_warehouse_sync',
true,
);
}
public async remove(
workspaceId: string,
userId: string,
storeId: string,
idempotencyKey: string,
) {
return this.idempotency.run(
{
workspaceId,
userId,
method: 'DELETE',
path: `/api/stores/${storeId}`,
key: idempotencyKey,
request: {},
},
async (transaction) => {
const current = await this.lockStore(transaction, storeId);
if (current.is_default) {
const replacement = await transaction.query<{ id: string }>(
`SELECT id FROM atlas.stores
WHERE platform_id = $1 AND id <> $2 AND deleted_at IS NULL LIMIT 1`,
[current.platform_id, storeId],
);
if (replacement.rows[0]) {
throw new ApiError(
422,
'DEFAULT_STORE_REPLACEMENT_REQUIRED',
'删除默认门店前必须先选择同平台的其他默认门店',
);
}
}
await transaction.query(
`UPDATE atlas.stores
SET deleted_at = now(), status = 'deleted', is_default = false,
revision = revision + 1, updated_at = now()
WHERE id = $1`,
[storeId],
);
// 删除连接后立刻撤销可用密钥,避免后台任务或未来恢复流程继续使用它。
await transaction.query(
`UPDATE atlas.store_secrets SET revoked_at = now(), updated_at = now()
WHERE store_id = $1 AND revoked_at IS NULL`,
[storeId],
);
return { status: 204, body: null };
},
);
}
public async warehouses(workspaceId: string, storeId: string) {
return this.database.workspaceTransaction(
workspaceId,
async (transaction: DatabaseTransaction) => {
const store = await this.requireStore(transaction, storeId);
const rows = await transaction.query<WarehouseRow>(
`SELECT id, platform_warehouse_id, name, fulfillment, is_rfbs, status, synced_at
FROM atlas.store_warehouses WHERE store_id = $1 ORDER BY name, id`,
[storeId],
);
return {
data: rows.rows.map((row) => ({
...row,
synced_at: toIso(row.synced_at),
})),
meta: { warehouses_synced_at: store.warehouses_synced_at },
};
},
);
}
public async operation(workspaceId: string, operationId: string) {
return this.database.workspaceTransaction(
workspaceId,
async (transaction: DatabaseTransaction) => {
const rows = await transaction.query<OperationRow>(
`SELECT id, kind, status, created_at, completed_at, error_code, error_message
FROM atlas.operations WHERE id = $1`,
[operationId],
);
if (!rows.rows[0]) throw notFound('操作');
return toOperation(rows.rows[0]);
},
);
}
private async requestOperation(
workspaceId: string,
userId: string,
storeId: string,
idempotencyKey: string,
pathSuffix: string,
kind: string,
requiresValidCredential: boolean,
) {
return this.idempotency.run(
{
workspaceId,
userId,
method: 'POST',
path: `/api/stores/${storeId}/${pathSuffix}`,
key: idempotencyKey,
request: {},
},
async (transaction) => {
const store = await this.requireStore(transaction, storeId);
if (store.status === 'disabled') {
throw new ApiError(422, 'STORE_DISABLED', '门店已停用');
}
if (
requiresValidCredential &&
store.credential_status !== 'valid'
) {
throw new ApiError(
422,
'STORE_CREDENTIAL_UNAVAILABLE',
'门店凭据尚未验证通过,无法同步仓库',
);
}
const operation = await this.createOperation(
transaction,
userId,
storeId,
kind,
);
return { status: 202, body: { data: { operation } } };
},
);
}
private async assertPlatformCurrency(
transaction: DatabaseTransaction,
platformId: string,
currencyCode: string,
) {
const platform = await transaction.query<{ id: string }>(
`SELECT id FROM atlas.platforms WHERE id = $1 AND status = 'active'`,
[platformId],
);
if (!platform.rows[0]) {
throw new ApiError(
422,
'PLATFORM_UNAVAILABLE',
'平台不存在或已停用',
);
}
const supported = await transaction.query<{ currency_code: string }>(
`SELECT pcs.currency_code FROM atlas.platform_currency_support pcs
JOIN atlas.currencies c ON c.code = pcs.currency_code
WHERE pcs.platform_id = $1 AND pcs.currency_code = $2
AND pcs.is_enabled AND c.is_enabled`,
[platformId, currencyCode],
);
if (!supported.rows[0]) {
throw new ApiError(
422,
'PLATFORM_CURRENCY_UNSUPPORTED',
'该平台不支持此默认计价币种',
);
}
}
private async lockStore(transaction: DatabaseTransaction, storeId: string) {
const rows = await transaction.query<StoreLockRow>(
`SELECT id, platform_id, revision, is_default, status
FROM atlas.stores WHERE id = $1 AND deleted_at IS NULL FOR UPDATE`,
[storeId],
);
if (!rows.rows[0]) throw notFound('门店');
return rows.rows[0];
}
private assertRevision(current: StoreLockRow, expected: number) {
if (current.revision !== expected) {
throw new ApiError(
409,
'VERSION_CONFLICT',
'门店已被其他操作更新',
[
{
field: 'If-Match',
code: 'VERSION_CONFLICT',
message: `当前版本为 ${current.revision}`,
current_revision: current.revision,
},
],
);
}
}
private async replaceApiKey(
transaction: DatabaseTransaction,
storeId: string,
apiKey: string,
) {
const secret = encryptStoreSecret(
apiKey,
this.config.STORE_SECRET_ENCRYPTION_KEY,
);
const existing = await transaction.query<{ id: string }>(
`SELECT id FROM atlas.store_secrets
WHERE store_id = $1 AND kind = 'seller_api_key' AND fingerprint = $2
FOR UPDATE`,
[storeId, secret.fingerprint],
);
await transaction.query(
`UPDATE atlas.store_secrets SET revoked_at = now(), updated_at = now()
WHERE store_id = $1 AND kind = 'seller_api_key' AND revoked_at IS NULL`,
[storeId],
);
if (existing.rows[0]) {
// 同一密钥可被重新提交用于验证;恢复历史行避免违反防重指纹唯一约束。
await transaction.query(
`UPDATE atlas.store_secrets
SET secret_ciphertext = $1, secret_nonce = $2, encryption_key_version = $3,
status = 'unknown', revoked_at = NULL, updated_at = now()
WHERE id = $4`,
[
secret.ciphertext,
secret.nonce,
this.config.STORE_SECRET_ENCRYPTION_KEY_VERSION,
existing.rows[0].id,
],
);
return;
}
await transaction.query(
`INSERT INTO atlas.store_secrets
(workspace_id, store_id, kind, secret_ciphertext, secret_nonce, encryption_key_version, fingerprint)
VALUES (current_setting('app.workspace_id')::uuid, $1, 'seller_api_key', $2, $3, $4, $5)`,
[
storeId,
secret.ciphertext,
secret.nonce,
this.config.STORE_SECRET_ENCRYPTION_KEY_VERSION,
secret.fingerprint,
],
);
}
private async createOperation(
transaction: DatabaseTransaction,
userId: string,
storeId: string,
kind: string,
): Promise<Operation> {
const created = await transaction.query<OperationRow>(
`INSERT INTO atlas.operations
(workspace_id, kind, resource_type, resource_id, created_by_user_id)
VALUES (current_setting('app.workspace_id')::uuid, $1, 'store', $2, $3)
RETURNING id, kind, status, created_at, completed_at, error_code, error_message`,
[kind, storeId, userId],
);
const operation = toOperation(created.rows[0]);
await transaction.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, 'store', $1, $2, $3, $4)`,
[
storeId,
`${kind}.requested`,
{ store_id: storeId, operation_id: operation.id },
operation.id,
],
);
return operation;
}
private async requireStore(
transaction: DatabaseTransaction,
storeId: string,
): Promise<Store> {
const rows = await transaction.query<StoreRow>(
`${storeSelect}
WHERE s.id = $1 AND s.deleted_at IS NULL
GROUP BY s.id, binding.value`,
[storeId],
);
if (!rows.rows[0]) throw notFound('门店');
return toStore(rows.rows[0]);
}
}
const storeSelect = `
SELECT s.id, s.platform_id, s.name, s.platform_client_id, s.currency_code,
s.is_default, s.note, s.status, s.credential_status, s.credential_checked_at,
CASE WHEN s.last_error_code IS NULL THEN NULL
ELSE jsonb_build_object('code', s.last_error_code, 'message', s.last_error_message, 'at', s.last_error_at)
END AS last_error,
s.api_key_expires_at, s.product_limit, s.product_limit_checked_at,
count(sw.id)::integer AS warehouse_count, max(sw.synced_at) AS warehouses_synced_at,
binding.value AS watermark_template, s.created_at, s.updated_at, s.revision
FROM atlas.stores s
LEFT JOIN atlas.store_warehouses sw ON sw.store_id = s.id
LEFT JOIN LATERAL (
SELECT jsonb_build_object('id', wt.id, 'name', wt.name) AS value
FROM atlas.store_watermark_bindings wb
JOIN atlas.watermark_templates wt ON wt.id = wb.template_id
WHERE wb.store_id = s.id AND wb.unbound_at IS NULL
LIMIT 1
) binding ON true`;
function toStore(row: StoreRow): Store {
return {
...row,
credential_checked_at: toIso(row.credential_checked_at),
api_key_expires_at: toIso(row.api_key_expires_at),
product_limit_checked_at: toIso(row.product_limit_checked_at),
warehouses_synced_at: toIso(row.warehouses_synced_at),
created_at: toRequiredIso(row.created_at),
updated_at: toRequiredIso(row.updated_at),
cookie_status: null,
};
}
function toOperation(row: OperationRow): Operation {
return {
id: row.id,
kind: row.kind,
status: row.status,
created_at: toRequiredIso(row.created_at),
completed_at: toIso(row.completed_at),
error:
row.error_code && row.error_message
? { code: row.error_code, message: row.error_message }
: null,
};
}
function toIso(value: Date | string | null): string | null {
if (value === null) return null;
return value instanceof Date ? value.toISOString() : value;
}
function toRequiredIso(value: Date | string): string {
return value instanceof Date ? value.toISOString() : value;
}
function notFound(resource: string) {
return new ApiError(404, 'RESOURCE_NOT_FOUND', `${resource}不存在`);
}
async function lockPlatform(
transaction: DatabaseTransaction,
workspaceId: string,
platformId: string,
) {
await transaction.query(
`SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`,
[`${workspaceId}:${platformId}`],
);
}
function encodeCursor(createdAt: string, id: string): string {
return Buffer.from(JSON.stringify({ createdAt, id })).toString('base64url');
}
function decodeCursor(cursor: string): { createdAt: string; id: string } {
try {
const value = JSON.parse(
Buffer.from(cursor, 'base64url').toString('utf8'),
) as { createdAt?: unknown; id?: unknown };
if (
typeof value.createdAt !== 'string' ||
Number.isNaN(Date.parse(value.createdAt)) ||
typeof value.id !== 'string' ||
!/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(value.id)
) {
throw new Error('invalid cursor');
}
return { createdAt: value.createdAt, id: value.id };
} catch {
throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效', [
{ field: 'cursor', code: 'INVALID_FIELD', message: '分页游标无效' },
]);
}
}
@@ -0,0 +1,13 @@
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
import type { Workspace } from '@atlas/contracts';
import type { WorkspaceAuthenticatedRequest } from './workspace-context.guard.js';
// 受保护接口经全局工作空间守卫验证后,只能从这里读取当前工作空间。
export const CurrentWorkspace = createParamDecorator(
(_data: unknown, context: ExecutionContext): Workspace => {
const request = context
.switchToHttp()
.getRequest<WorkspaceAuthenticatedRequest>();
return request.auth.workspace;
},
);
@@ -0,0 +1,63 @@
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
} from '@nestjs/common';
import type { Workspace } from '@atlas/contracts';
import type { Database } from '@atlas/database';
import { Reflector } from '@nestjs/core';
import { ApiError } from '../common/api-error.js';
import { IS_PUBLIC_ENDPOINT } from '../common/public.decorator.js';
import { DATABASE } from '../database/database.module.js';
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
export type WorkspaceAuthenticatedRequest = AuthenticatedRequest & {
auth: AuthenticatedRequest['auth'] & { workspace: Workspace };
};
@Injectable()
export class WorkspaceContextGuard implements CanActivate {
public constructor(
@Inject(Reflector) private readonly reflector: Reflector,
@Inject(DATABASE) private readonly database: Database,
) {}
public async canActivate(context: ExecutionContext): Promise<boolean> {
if (
this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_ENDPOINT, [
context.getHandler(),
context.getClass(),
])
) {
return true;
}
const request = context
.switchToHttp()
.getRequest<AuthenticatedRequest>();
// 当前一期每位用户只有一个个人工作空间;后续多人协作时此处将按显式选择的工作空间校验 membership。
const workspace = await this.findCurrentWorkspace(request.auth.userId);
if (!workspace) {
throw new ApiError(
403,
'WORKSPACE_MEMBERSHIP_REQUIRED',
'当前账户没有可用工作空间',
);
}
// 后续控制器和服务只使用此处验证过的上下文,绝不相信客户端传入 workspace_id。
request.auth.workspace = workspace;
return true;
}
private async findCurrentWorkspace(
userId: string,
): Promise<Workspace | null> {
// 排序规则是首期“唯一有效工作空间”的临时选择策略,不是未来的工作空间切换协议。
const result = await this.database.query<Workspace>(
`SELECT w.id, w.name, m.role FROM atlas.workspace_memberships m JOIN atlas.workspaces w ON w.id = m.workspace_id WHERE m.user_id = $1 AND w.status = 'active' ORDER BY m.joined_at LIMIT 1`,
[userId],
);
return result.rows[0] ?? null;
}
}
+430
View File
@@ -0,0 +1,430 @@
import assert from 'node:assert/strict';
import { 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';
type SuccessResponse<T> = { data: T };
type ErrorResponse = {
error: {
code: string;
message: string;
request_id: string;
details?: Array<{ field: string; code: string; message: string }>;
};
};
type SessionPayload = {
access_token: string;
access_token_expires_in: number;
user: {
id: string;
email: string;
display_name: string | null;
avatar_asset_id: string | null;
revision: number;
};
workspace: { id: string; name: string; role: string };
};
const password = 'correct-horse-battery-staple-123';
const testUsers: Array<{ userId: string; workspaceId: string }> = [];
let app: NestExpressApplication;
let database: Database;
let baseUrl: string;
function email(label: string): string {
return `${label}-${randomUUID()}@example.test`;
}
async function request(
path: string,
init: RequestInit = {},
): Promise<{ response: Response; body: unknown }> {
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...init.headers,
},
});
return {
response,
body: response.status === 204 ? undefined : await response.json(),
};
}
function expectSuccess<T>(body: unknown): SuccessResponse<T> {
assert.ok(body && typeof body === 'object' && 'data' in body);
return body as SuccessResponse<T>;
}
function expectError(body: unknown): ErrorResponse {
assert.ok(body && typeof body === 'object' && 'error' in body);
return body as ErrorResponse;
}
function refreshCookie(response: Response): string {
const cookie = response.headers.get('set-cookie');
assert.ok(cookie, '认证成功响应必须写入 refresh cookie');
assert.match(cookie, /HttpOnly/i);
const match = cookie.match(/atlas_refresh=[^;]+/);
assert.ok(match, '响应必须包含 atlas_refresh cookie');
return match[0];
}
describe('认证 HTTP 集成测试', () => {
beforeAll(async () => {
Object.assign(process.env, {
NODE_ENV: 'test',
SWAGGER_ENABLED: 'false',
AUTH_LOGIN_IP_LIMIT: '100',
AUTH_LOGIN_EMAIL_LIMIT: '2',
AUTH_REGISTER_IP_LIMIT: '100',
AUTH_REGISTER_EMAIL_LIMIT: '100',
AUTH_REFRESH_IP_LIMIT: '100',
});
const application = await createApplication();
app = application.app;
database = app.get<Database>(DATABASE);
await app.listen(0, '127.0.0.1');
const address = app.getHttpServer().address() as AddressInfo;
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(async () => {
for (const user of testUsers) {
await database.workspaceTransaction(
user.workspaceId,
async (tx) => {
await tx.query(
'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1',
[user.workspaceId],
);
},
);
await database.query(
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
[user.userId],
);
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [
user.workspaceId,
]);
await database.query('DELETE FROM atlas.users WHERE id = $1', [
user.userId,
]);
}
await app.close();
});
it('健康与 readiness 端点保持公开', async () => {
const health = await request('/health');
assert.equal(health.response.status, 200);
assert.deepEqual(health.body, { status: 'ok', service: 'api' });
const readiness = await request('/ready');
assert.equal(readiness.response.status, 200);
assert.deepEqual(readiness.body, { status: 'ready', service: 'api' });
});
it('注册、重复邮箱和参数校验遵守响应契约', async () => {
const registrationEmail = email('register');
const registration = await request('/api/auth/register', {
method: 'POST',
body: JSON.stringify({
email: registrationEmail,
password,
password_confirmation: password,
display_name: '集成测试用户',
}),
});
assert.equal(registration.response.status, 201);
const payload = expectSuccess<SessionPayload>(registration.body).data;
assert.equal(payload.user.email, registrationEmail);
assert.equal(payload.workspace.role, 'owner');
assert.ok(payload.access_token);
assert.ok(payload.access_token_expires_in > 0);
refreshCookie(registration.response);
testUsers.push({
userId: payload.user.id,
workspaceId: payload.workspace.id,
});
const duplicate = await request('/api/auth/register', {
method: 'POST',
body: JSON.stringify({
email: registrationEmail,
password,
password_confirmation: password,
}),
});
assert.equal(duplicate.response.status, 409);
assert.equal(
expectError(duplicate.body).error.code,
'EMAIL_ALREADY_REGISTERED',
);
const invalid = await request('/api/auth/register', {
method: 'POST',
body: JSON.stringify({
email: 'not-an-email',
password,
password_confirmation: password,
}),
});
assert.equal(invalid.response.status, 422);
assert.equal(expectError(invalid.body).error.code, 'VALIDATION_FAILED');
});
it('登录、刷新、退出和当前用户读取可形成完整会话闭环', async () => {
const accountEmail = email('session');
const registration = await request('/api/auth/register', {
method: 'POST',
body: JSON.stringify({
email: accountEmail,
password,
password_confirmation: password,
}),
});
const registered = expectSuccess<SessionPayload>(
registration.body,
).data;
testUsers.push({
userId: registered.user.id,
workspaceId: registered.workspace.id,
});
const login = await request('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email: accountEmail, password }),
});
assert.equal(login.response.status, 200);
const loggedIn = expectSuccess<SessionPayload>(login.body).data;
const loginRefreshCookie = refreshCookie(login.response);
const currentUser = await request('/api/auth/me', {
headers: { Authorization: `Bearer ${loggedIn.access_token}` },
});
assert.equal(currentUser.response.status, 200);
const me = expectSuccess<{
user: { id: string };
workspace: { id: string };
}>(currentUser.body).data;
assert.equal(me.user.id, registered.user.id);
assert.equal(me.workspace.id, registered.workspace.id);
const refresh = await request('/api/auth/refresh', {
method: 'POST',
headers: { Cookie: loginRefreshCookie },
});
assert.equal(refresh.response.status, 200);
const refreshed = expectSuccess<{
access_token: string;
access_token_expires_in: number;
}>(refresh.body).data;
assert.ok(refreshed.access_token);
const refreshedCookie = refreshCookie(refresh.response);
const reusedRefresh = await request('/api/auth/refresh', {
method: 'POST',
headers: { Cookie: loginRefreshCookie },
});
assert.equal(reusedRefresh.response.status, 401);
assert.equal(
expectError(reusedRefresh.body).error.code,
'SESSION_EXPIRED',
);
const logout = await request('/api/auth/logout', {
method: 'POST',
headers: {
Authorization: `Bearer ${refreshed.access_token}`,
Cookie: refreshedCookie,
},
});
assert.equal(logout.response.status, 204);
assert.equal(logout.body, undefined);
const revoked = await request('/api/auth/me', {
headers: { Authorization: `Bearer ${refreshed.access_token}` },
});
assert.equal(revoked.response.status, 401);
});
it('拒绝错误密码、无效访问令牌和超过邮箱限额的登录请求', async () => {
const accountEmail = email('rejected');
const wrongPassword = await request('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email: accountEmail, password }),
});
assert.equal(wrongPassword.response.status, 401);
const wrongPasswordError = expectError(wrongPassword.body).error;
assert.equal(wrongPasswordError.code, 'INVALID_CREDENTIALS');
assert.equal(
wrongPasswordError.request_id,
wrongPassword.response.headers.get('x-request-id'),
);
const invalidToken = await request('/api/auth/me', {
headers: { Authorization: 'Bearer invalid-access-token' },
});
assert.equal(invalidToken.response.status, 401);
assert.equal(expectError(invalidToken.body).error.code, 'HTTP_ERROR');
const rateLimitedEmail = email('rate-limit');
for (const expectedStatus of [401, 401, 429]) {
const response = await request('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email: rateLimitedEmail, password }),
});
assert.equal(response.response.status, expectedStatus);
if (expectedStatus === 429) {
assert.equal(
expectError(response.body).error.code,
'AUTH_RATE_LIMITED',
);
}
}
});
it('更新资料遵守版本与幂等约束,改密会撤销旧会话并轮换当前会话', async () => {
const accountEmail = email('profile-password');
const registration = await request('/api/auth/register', {
method: 'POST',
body: JSON.stringify({
email: accountEmail,
password,
password_confirmation: password,
display_name: '原始名称',
}),
});
const registered = expectSuccess<SessionPayload>(
registration.body,
).data;
testUsers.push({
userId: registered.user.id,
workspaceId: registered.workspace.id,
});
const profileKey = randomUUID();
const update = await request('/api/auth/me', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${registered.access_token}`,
'If-Match': `"${registered.user.revision}"`,
'Idempotency-Key': profileKey,
},
body: JSON.stringify({ display_name: '更新后的名称' }),
});
assert.equal(update.response.status, 200);
const updatedUser = expectSuccess<SessionPayload['user']>(
update.body,
).data;
assert.equal(updatedUser.display_name, '更新后的名称');
assert.equal(updatedUser.avatar_asset_id, null);
assert.equal(updatedUser.revision, registered.user.revision + 1);
const replay = await request('/api/auth/me', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${registered.access_token}`,
'If-Match': `"${registered.user.revision}"`,
'Idempotency-Key': profileKey,
},
body: JSON.stringify({ display_name: '更新后的名称' }),
});
assert.equal(replay.response.status, 200);
assert.deepEqual(replay.body, update.body);
const stale = await request('/api/auth/me', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${registered.access_token}`,
'If-Match': `"${registered.user.revision}"`,
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify({ display_name: '过期修改' }),
});
assert.equal(stale.response.status, 409);
assert.equal(expectError(stale.body).error.code, 'VERSION_CONFLICT');
const invalidAvatar = await request('/api/auth/me', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${registered.access_token}`,
'If-Match': `"${updatedUser.revision}"`,
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify({ avatar_asset_id: randomUUID() }),
});
assert.equal(invalidAvatar.response.status, 422);
assert.equal(
expectError(invalidAvatar.body).error.code,
'AVATAR_ASSET_UNAVAILABLE',
);
const secondLogin = await request('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email: accountEmail, password }),
});
const otherSession = expectSuccess<SessionPayload>(
secondLogin.body,
).data;
const changed = await request('/api/auth/change-password', {
method: 'POST',
headers: { Authorization: `Bearer ${registered.access_token}` },
body: JSON.stringify({
old_password: password,
password: 'new-correct-horse-battery-staple-456',
password_confirmation: 'new-correct-horse-battery-staple-456',
}),
});
assert.equal(changed.response.status, 200);
const rotated = expectSuccess<{
access_token: string;
access_token_expires_in: number;
}>(changed.body).data;
assert.ok(rotated.access_token);
refreshCookie(changed.response);
const formerCurrent = await request('/api/auth/me', {
headers: { Authorization: `Bearer ${registered.access_token}` },
});
assert.equal(formerCurrent.response.status, 401);
const revokedOther = await request('/api/auth/me', {
headers: { Authorization: `Bearer ${otherSession.access_token}` },
});
assert.equal(revokedOther.response.status, 401);
const current = await request('/api/auth/me', {
headers: { Authorization: `Bearer ${rotated.access_token}` },
});
assert.equal(current.response.status, 200);
const oldPasswordChange = await request('/api/auth/change-password', {
method: 'POST',
headers: { Authorization: `Bearer ${rotated.access_token}` },
body: JSON.stringify({
old_password: password,
password: 'another-correct-horse-battery-staple-789',
password_confirmation:
'another-correct-horse-battery-staple-789',
}),
});
assert.equal(oldPasswordChange.response.status, 422);
assert.equal(
expectError(oldPasswordChange.body).error.code,
'VALIDATION_FAILED',
);
const newPasswordLogin = await request('/api/auth/login', {
method: 'POST',
body: JSON.stringify({
email: accountEmail,
password: 'new-correct-horse-battery-staple-456',
}),
});
assert.equal(newPasswordLogin.response.status, 200);
});
});
+311
View File
@@ -0,0 +1,311 @@
import assert from 'node:assert/strict';
import { 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';
type SuccessResponse<T> = { data: T };
type ErrorResponse = { error: { code: string } };
type Session = {
access_token: string;
user: { id: string };
workspace: { id: string };
};
type Store = {
id: string;
name: string;
is_default: boolean;
status: string;
credential_status: string;
revision: number;
};
const password = 'correct-horse-battery-staple-123';
const testUsers: Array<{ userId: string; workspaceId: string }> = [];
let app: NestExpressApplication;
let database: Database;
let baseUrl: string;
function email(label: string) {
return `${label}-${randomUUID()}@example.test`;
}
async function request(path: string, init: RequestInit = {}) {
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...init.headers },
});
return {
response,
body: response.status === 204 ? undefined : await response.json(),
};
}
function success<T>(body: unknown): SuccessResponse<T> {
assert.ok(body && typeof body === 'object' && 'data' in body);
return body as SuccessResponse<T>;
}
function error(body: unknown): ErrorResponse {
assert.ok(body && typeof body === 'object' && 'error' in body);
return body as ErrorResponse;
}
async function register(label: string): Promise<Session> {
const result = await request('/api/auth/register', {
method: 'POST',
body: JSON.stringify({
email: email(label),
password,
password_confirmation: password,
}),
});
assert.equal(result.response.status, 201);
const session = success<Session>(result.body).data;
testUsers.push({
userId: session.user.id,
workspaceId: session.workspace.id,
});
return session;
}
function headers(session: Session, idempotencyKey?: string) {
return {
Authorization: `Bearer ${session.access_token}`,
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
};
}
describe('门店 HTTP 集成测试', () => {
beforeAll(async () => {
Object.assign(process.env, {
NODE_ENV: 'test',
SWAGGER_ENABLED: 'false',
AUTH_LOGIN_IP_LIMIT: '100',
AUTH_LOGIN_EMAIL_LIMIT: '100',
AUTH_REGISTER_IP_LIMIT: '100',
AUTH_REGISTER_EMAIL_LIMIT: '100',
AUTH_REFRESH_IP_LIMIT: '100',
});
const application = await createApplication();
app = application.app;
database = app.get<Database>(DATABASE);
await app.listen(0, '127.0.0.1');
const address = app.getHttpServer().address() as AddressInfo;
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(async () => {
for (const user of testUsers) {
// 门店测试会创建操作和 Outbox;先在工作空间事务内清理这些受 RLS 保护的记录。
await database.workspaceTransaction(
user.workspaceId,
async (tx) => {
await tx.query(
'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1',
[user.workspaceId],
);
await tx.query(
'DELETE FROM atlas.outbox_events WHERE workspace_id = $1',
[user.workspaceId],
);
await tx.query(
'DELETE FROM atlas.operations WHERE workspace_id = $1',
[user.workspaceId],
);
await tx.query(
'DELETE FROM atlas.stores WHERE workspace_id = $1',
[user.workspaceId],
);
},
);
await database.query(
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
[user.userId],
);
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [
user.workspaceId,
]);
await database.query('DELETE FROM atlas.users WHERE id = $1', [
user.userId,
]);
}
await app.close();
});
it('创建门店时加密保存凭据、创建操作,并能安全重放相同请求', async () => {
const owner = await register('stores-owner');
const platforms = await request('/api/platforms', {
headers: headers(owner),
});
assert.equal(platforms.response.status, 200);
const platform = success<
Array<{
id: string;
code: string;
default_market_code: string | null;
default_locale: string | null;
}>
>(platforms.body).data.find((item) => item.code === 'OZON');
assert.ok(platform);
assert.equal(platform.default_market_code, 'RU');
assert.equal(platform.default_locale, 'ru-RU');
const idempotencyKey = randomUUID();
const createBody = {
platform_id: platform.id,
name: '测试 Ozon 主店',
client_id: `client-${randomUUID()}`,
api_key: 'secret-only-in-request',
currency_code: 'RUB',
is_default: false,
note: '首家门店仍应成为默认门店',
};
const created = await request('/api/stores', {
method: 'POST',
headers: headers(owner, idempotencyKey),
body: JSON.stringify(createBody),
});
assert.equal(created.response.status, 202);
const createdData = success<{
store: Store & { platform_client_id: string };
operation: { id: string; status: string };
}>(created.body).data;
assert.equal(createdData.store.is_default, true);
assert.equal(createdData.store.status, 'verifying');
assert.equal(createdData.store.credential_status, 'unknown');
assert.equal(
createdData.store.platform_client_id,
createBody.client_id,
);
assert.equal(createdData.operation.status, 'queued');
assert.equal(
JSON.stringify(created.body).includes(createBody.api_key),
false,
);
const replay = await request('/api/stores', {
method: 'POST',
headers: headers(owner, idempotencyKey),
body: JSON.stringify(createBody),
});
assert.equal(replay.response.status, 202);
assert.deepEqual(replay.body, created.body);
const mismatchedReplay = await request('/api/stores', {
method: 'POST',
headers: headers(owner, idempotencyKey),
body: JSON.stringify({ ...createBody, name: '不同请求' }),
});
assert.equal(mismatchedReplay.response.status, 409);
assert.equal(
error(mismatchedReplay.body).error.code,
'IDEMPOTENCY_KEY_REUSED',
);
const context = await request('/api/platform-context', {
headers: {
...headers(owner),
'X-Atlas-Platform-Id': platform.id,
},
});
assert.equal(context.response.status, 200);
assert.equal(
success<{ store_count: number; default_store: { id: string } }>(
context.body,
).data.store_count,
1,
);
const operation = await request(
`/api/operations/${createdData.operation.id}`,
{ headers: headers(owner) },
);
assert.equal(operation.response.status, 200);
assert.equal(
success<{ status: string }>(operation.body).data.status,
'queued',
);
});
it('更新版本、切换默认门店、删除和工作空间隔离都遵守契约', async () => {
const owner = await register('stores-lifecycle');
const otherOwner = await register('stores-isolation');
const platforms = await request('/api/platforms', {
headers: headers(owner),
});
const platform = success<Array<{ id: string; code: string }>>(
platforms.body,
).data.find((item) => item.code === 'OZON');
assert.ok(platform);
const first = await createStore(owner, platform.id, '第一家门店');
const updated = await request(`/api/stores/${first.id}`, {
method: 'PATCH',
headers: {
...headers(owner, randomUUID()),
'If-Match': `"${first.revision}"`,
},
body: JSON.stringify({ name: '第一家门店(已修改)' }),
});
assert.equal(updated.response.status, 200);
const updatedStore = success<Store>(updated.body).data;
assert.equal(updatedStore.revision, first.revision + 1);
const stale = await request(`/api/stores/${first.id}`, {
method: 'PATCH',
headers: {
...headers(owner, randomUUID()),
'If-Match': `"${first.revision}"`,
},
body: JSON.stringify({ name: '过期修改' }),
});
assert.equal(stale.response.status, 409);
assert.equal(error(stale.body).error.code, 'VERSION_CONFLICT');
const second = await createStore(owner, platform.id, '第二家门店');
const makeDefault = await request(
`/api/stores/${second.id}/set-default`,
{
method: 'POST',
headers: headers(owner, randomUUID()),
},
);
assert.equal(makeDefault.response.status, 200);
assert.equal(success<Store>(makeDefault.body).data.is_default, true);
const foreignRead = await request(`/api/stores/${first.id}`, {
headers: headers(otherOwner),
});
assert.equal(foreignRead.response.status, 404);
const deleted = await request(`/api/stores/${first.id}`, {
method: 'DELETE',
headers: headers(owner, randomUUID()),
});
assert.equal(deleted.response.status, 204);
const absent = await request(`/api/stores/${first.id}`, {
headers: headers(owner),
});
assert.equal(absent.response.status, 404);
});
});
async function createStore(owner: Session, platformId: string, name: string) {
const result = await request('/api/stores', {
method: 'POST',
headers: headers(owner, randomUUID()),
body: JSON.stringify({
platform_id: platformId,
name,
client_id: `client-${randomUUID()}`,
api_key: `key-${randomUUID()}`,
currency_code: 'RUB',
}),
});
assert.equal(result.response.status, 202);
return success<{ store: Store }>(result.body).data.store;
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node"],
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"noEmit": true
},
"include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
fileParallelism: false,
testTimeout: 30_000,
},
});
+55
View File
@@ -0,0 +1,55 @@
{
"compilerOptions": {
"lib": [
"ESNext",
"DOM",
"DOM.Iterable"
],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"paths": {
"@": [
".."
],
"@/*": [
"../*"
],
"~": [
".."
],
"~/*": [
"../*"
],
"@@": [
".."
],
"@@/*": [
"../*"
],
"~~": [
".."
],
"~~/*": [
"../*"
]
}
},
"include": [
"../**/*",
"./wxt.d.ts"
],
"exclude": [
"../**/node_modules",
"../.output"
]
}
+15
View File
@@ -0,0 +1,15 @@
// Generated by wxt
interface ImportMetaEnv {
readonly MANIFEST_VERSION: 2 | 3;
readonly BROWSER: string;
readonly CHROME: boolean;
readonly FIREFOX: boolean;
readonly SAFARI: boolean;
readonly EDGE: boolean;
readonly OPERA: boolean;
readonly COMMAND: "build" | "serve";
readonly ENTRYPOINT: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+81
View File
@@ -0,0 +1,81 @@
// Generated by wxt
import "wxt/browser";
declare module "wxt/browser" {
/**
* See https://developer.chrome.com/docs/extensions/reference/i18n/#method-getMessage
*/
interface GetMessageOptions {
/**
* See https://developer.chrome.com/docs/extensions/reference/i18n/#method-getMessage
*/
escapeLt?: boolean
}
export interface WxtI18n extends I18n.Static {
/**
* The extension or app ID; you might use this string to construct URLs for resources inside the extension. Even non-localized extensions can use this message.
* Note: You can't use this message in a manifest file.
*
* "<browser.runtime.id>"
*/
getMessage(
messageName: "@@extension_id",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* "<browser.i18n.getUiLocale()>"
*/
getMessage(
messageName: "@@ui_locale",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* The text direction for the current locale, either "ltr" for left-to-right languages such as English or "rtl" for right-to-left languages such as Japanese.
*
* "<ltr|rtl>"
*/
getMessage(
messageName: "@@bidi_dir",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* If the @@bidi_dir is "ltr", then this is "rtl"; otherwise, it's "ltr".
*
* "<rtl|ltr>"
*/
getMessage(
messageName: "@@bidi_reversed_dir",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* If the @@bidi_dir is "ltr", then this is "left"; otherwise, it's "right".
*
* "<left|right>"
*/
getMessage(
messageName: "@@bidi_start_edge",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* If the @@bidi_dir is "ltr", then this is "right"; otherwise, it's "left".
*
* "<right|left>"
*/
getMessage(
messageName: "@@bidi_end_edge",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
getMessage(
messageName: "@@extension_id" | "@@ui_locale" | "@@bidi_dir" | "@@bidi_reversed_dir" | "@@bidi_start_edge" | "@@bidi_end_edge",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
}
}
+21
View File
@@ -0,0 +1,21 @@
// Generated by wxt
// Types for the #import virtual module
declare module '#imports' {
export { browser, Browser } from 'wxt/browser';
export { storage, WxtStorage, WxtStorageItem, StorageArea, StorageItemKey, StorageAreaChanges, MigrationError } from 'wxt/utils/storage';
export { getAppConfig, useAppConfig } from 'wxt/utils/app-config';
export { ContentScriptContext, WxtWindowEventMap } from 'wxt/utils/content-script-context';
export { createIframeUi, IframeContentScriptUi, IframeContentScriptUiOptions } from 'wxt/utils/content-script-ui/iframe';
export { createIntegratedUi, IntegratedContentScriptUi, IntegratedContentScriptUiOptions } from 'wxt/utils/content-script-ui/integrated';
export { createShadowRootUi, ShadowRootContentScriptUi, ShadowRootContentScriptUiOptions } from 'wxt/utils/content-script-ui/shadow-root';
export { ContentScriptUi, ContentScriptUiOptions, ContentScriptOverlayAlignment, ContentScriptAppendMode, ContentScriptInlinePositioningOptions, ContentScriptOverlayPositioningOptions, ContentScriptModalPositioningOptions, ContentScriptPositioningOptions, ContentScriptAnchoredOptions, AutoMountOptions, StopAutoMount, AutoMount } from 'wxt/utils/content-script-ui/types';
export { defineAppConfig, WxtAppConfig } from 'wxt/utils/define-app-config';
export { defineBackground } from 'wxt/utils/define-background';
export { defineContentScript } from 'wxt/utils/define-content-script';
export { defineUnlistedScript } from 'wxt/utils/define-unlisted-script';
export { defineWxtPlugin } from 'wxt/utils/define-wxt-plugin';
export { injectScript, ScriptPublicPath, InjectScriptOptions } from 'wxt/utils/inject-script';
export { InvalidMatchPattern, MatchPattern } from 'wxt/utils/match-patterns';
export { useState, useCallback, useMemo, useEffect, useRef, useContext, useReducer } from 'react';
export { fakeBrowser } from 'wxt/testing/fake-browser';
}
+58
View File
@@ -0,0 +1,58 @@
// Generated by wxt
export {}
declare global {
const ContentScriptContext: typeof import('wxt/utils/content-script-context').ContentScriptContext
const InvalidMatchPattern: typeof import('wxt/utils/match-patterns').InvalidMatchPattern
const MatchPattern: typeof import('wxt/utils/match-patterns').MatchPattern
const browser: typeof import('wxt/browser').browser
const createIframeUi: typeof import('wxt/utils/content-script-ui/iframe').createIframeUi
const createIntegratedUi: typeof import('wxt/utils/content-script-ui/integrated').createIntegratedUi
const createShadowRootUi: typeof import('wxt/utils/content-script-ui/shadow-root').createShadowRootUi
const defineAppConfig: typeof import('wxt/utils/define-app-config').defineAppConfig
const defineBackground: typeof import('wxt/utils/define-background').defineBackground
const defineContentScript: typeof import('wxt/utils/define-content-script').defineContentScript
const defineUnlistedScript: typeof import('wxt/utils/define-unlisted-script').defineUnlistedScript
const defineWxtPlugin: typeof import('wxt/utils/define-wxt-plugin').defineWxtPlugin
const fakeBrowser: typeof import('wxt/testing/fake-browser').fakeBrowser
const getAppConfig: typeof import('wxt/utils/app-config').getAppConfig
const injectScript: typeof import('wxt/utils/inject-script').injectScript
const storage: typeof import('wxt/utils/storage').storage
const useAppConfig: typeof import('wxt/utils/app-config').useAppConfig
const useCallback: typeof import('react').useCallback
const useContext: typeof import('react').useContext
const useEffect: typeof import('react').useEffect
const useMemo: typeof import('react').useMemo
const useReducer: typeof import('react').useReducer
const useRef: typeof import('react').useRef
const useState: typeof import('react').useState
}
// for type re-export
declare global {
// @ts-ignore
export type { Browser } from 'wxt/browser'
import('wxt/browser')
// @ts-ignore
export type { WxtStorage, WxtStorageItem, StorageArea, StorageItemKey, StorageAreaChanges, MigrationError } from 'wxt/utils/storage'
import('wxt/utils/storage')
// @ts-ignore
export type { WxtWindowEventMap } from 'wxt/utils/content-script-context'
import('wxt/utils/content-script-context')
// @ts-ignore
export type { IframeContentScriptUi, IframeContentScriptUiOptions } from 'wxt/utils/content-script-ui/iframe'
import('wxt/utils/content-script-ui/iframe')
// @ts-ignore
export type { IntegratedContentScriptUi, IntegratedContentScriptUiOptions } from 'wxt/utils/content-script-ui/integrated'
import('wxt/utils/content-script-ui/integrated')
// @ts-ignore
export type { ShadowRootContentScriptUi, ShadowRootContentScriptUiOptions } from 'wxt/utils/content-script-ui/shadow-root'
import('wxt/utils/content-script-ui/shadow-root')
// @ts-ignore
export type { ContentScriptUi, ContentScriptUiOptions, ContentScriptOverlayAlignment, ContentScriptAppendMode, ContentScriptInlinePositioningOptions, ContentScriptOverlayPositioningOptions, ContentScriptModalPositioningOptions, ContentScriptPositioningOptions, ContentScriptAnchoredOptions, AutoMountOptions, StopAutoMount, AutoMount } from 'wxt/utils/content-script-ui/types'
import('wxt/utils/content-script-ui/types')
// @ts-ignore
export type { WxtAppConfig } from 'wxt/utils/define-app-config'
import('wxt/utils/define-app-config')
// @ts-ignore
export type { ScriptPublicPath, InjectScriptOptions } from 'wxt/utils/inject-script'
import('wxt/utils/inject-script')
}
+14
View File
@@ -0,0 +1,14 @@
// Generated by wxt
import "wxt/browser";
declare module "wxt/browser" {
export type PublicPath =
| ""
| "/"
| "/background.js"
type HtmlPublicPath = Extract<PublicPath, `${string}.html`>
export interface WxtRuntime {
getURL(path: PublicPath): string;
getURL(path: `${HtmlPublicPath}${string}`): string;
}
}
+8
View File
@@ -0,0 +1,8 @@
// Generated by wxt
/// <reference types="wxt/vite-builder-env" />
/// <reference types="@wxt-dev/module-react" />
/// <reference path="./types/paths.d.ts" />
/// <reference path="./types/i18n.d.ts" />
/// <reference path="./types/globals.d.ts" />
/// <reference path="./types/imports-module.d.ts" />
/// <reference path="./types/imports.d.ts" />
+3
View File
@@ -0,0 +1,3 @@
export default defineBackground(() => {
console.info('Atlas collector background started.');
});
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@atlas/extension",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "wxt build",
"dev": "wxt",
"typecheck": "wxt prepare && tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@atlas/contracts": "workspace:*",
"@wxt-dev/module-react": "1.2.2",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@types/react": "19.2.18",
"@types/react-dom": "19.2.5",
"typescript": "7.0.2",
"wxt": "0.21.4"
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"noEmit": true
},
"include": ["**/*.ts", "**/*.tsx", ".wxt/**/*.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'wxt';
export default defineConfig({
modules: ['@wxt-dev/module-react'],
// 扩展 dev server 固定 8110:默认取 3000–3010 第一个空闲端口,会漂移且易与常见服务冲突
dev: {
server: {
port: Number(process.env.EXTENSION_PORT ?? 8110),
strictPort: true,
},
},
manifest: {
name: 'Atlas Seller Kit Collector',
description: '将已确认的来源商品采集到 Atlas 工作空间。',
permissions: ['storage', 'identity'],
host_permissions: [
'*://*.ozon.ru/*',
'*://*.ozon.kz/*',
'*://*.ozon.by/*',
'*://*.1688.com/*',
'*://*.taobao.com/*',
'*://*.tmall.com/*',
],
},
});
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Atlas Seller Kit</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@atlas/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -b && vite build",
"dev": "vite",
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
"@ant-design/icons": "6.3.4",
"@atlas/contracts": "workspace:*",
"antd": "6.6.2",
"axios": "1.20.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-router-dom": "7.18.3"
},
"devDependencies": {
"@types/node": "26.3.0",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.5",
"@vitejs/plugin-react": "6.1.1",
"typescript": "7.0.2",
"vite": "8.2.2"
}
}
+15
View File
@@ -0,0 +1,15 @@
export default {
printWidth: 100,
tabWidth: 4,
useTabs: false,
semi: true,
singleQuote: true,
quoteProps: 'as-needed',
jsxSingleQuote: false,
trailingComma: 'es5',
bracketSpacing: true,
bracketSameLine: false,
arrowParens: 'always',
endOfLine: 'lf',
embeddedLanguageFormatting: 'auto',
};
+88
View File
@@ -0,0 +1,88 @@
/* 全局应用级样式补充(主题变量见 index.css) */
/* ── 设置类页面(修改资料 / 修改密码):内容区居中卡片 ── */
.settings-page {
display: flex;
justify-content: center;
}
.settings-card {
width: 100%;
max-width: 560px;
background: #fff;
border-radius: 10px;
/* 四周柔和阴影,与内容区背景拉开层次 */
box-shadow: 0 0 24px 0 rgba(17, 24, 39, 0.12);
}
.settings-card-title {
padding: 20px 32px;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
border-bottom: 1px solid var(--border-color);
}
.settings-card-body {
padding: 32px;
}
.settings-field-label {
font-size: 14px;
color: var(--text-primary);
margin-bottom: 12px;
}
.avatar-upload-area {
display: flex;
justify-content: center;
}
.avatar-upload-placeholder {
position: relative;
cursor: pointer;
border-radius: 50%;
}
.avatar-upload-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
color: #6b7280;
font-size: 13px;
border-radius: 50%;
background: transparent;
transition: background 0.2s;
}
.avatar-upload-placeholder:hover .avatar-upload-overlay {
background: rgba(255, 255, 255, 0.55);
}
.avatar-preview {
cursor: pointer;
}
.avatar-upload-hint {
text-align: center;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.8;
margin: 12px 0 24px;
}
.profile-form {
max-width: 420px;
margin: 0 auto;
}
.settings-actions {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 8px;
}
+40
View File
@@ -0,0 +1,40 @@
import { ConfigProvider, App as AntdApp } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import { RouterProvider } from 'react-router-dom';
import { router } from './router';
import { SessionProvider } from '@/services/session';
import './App.css';
function App() {
return (
<ConfigProvider
locale={zhCN}
theme={{
token: {
colorPrimary: '#8b5cf6',
// 全局圆角 10px,输入框/按钮/卡片等同步生效
borderRadius: 10,
// 全局正文字号 14px;输入类控件经 components 单独放大
fontSize: 14,
controlHeight: 34,
controlHeightSM: 26,
},
components: {
// 文本框:文本 16px、高度同步加高;按钮与表格/菜单等保持全局默认
Input: { fontSize: 16, controlHeight: 40 },
Select: { fontSize: 16, controlHeight: 40 },
InputNumber: { fontSize: 16, controlHeight: 40 },
DatePicker: { fontSize: 16, controlHeight: 40 },
},
}}
>
<AntdApp>
<SessionProvider>
<RouterProvider router={router} />
</SessionProvider>
</AntdApp>
</ConfigProvider>
);
}
export default App;
@@ -0,0 +1,23 @@
import { Card, Empty } from 'antd';
interface PagePlaceholderProps {
title: string;
description: string;
}
/** 业务模块实现前的统一占位页 */
const PagePlaceholder = ({ title, description }: PagePlaceholderProps) => (
<Card>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<>
<div>{title}</div>
<div>{description}</div>
</>
}
/>
</Card>
);
export default PagePlaceholder;
+18
View File
@@ -0,0 +1,18 @@
import { Spin } from 'antd';
/** 应用启动会话恢复期间的全屏占位,避免守卫闪烁跳转 */
const SessionSplash = () => (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg-color)',
}}
>
<Spin size="large" />
</div>
);
export default SessionSplash;
+16
View File
@@ -0,0 +1,16 @@
/**
* 环境变量配置(Vite 仅暴露 VITE_ 前缀变量)
*/
interface EnvConfig {
/** API 基础路径:开发环境由 vite 代理转发到 NestJS API */
apiBaseUrl: string;
appName: string;
debug: boolean;
}
export const envConfig: EnvConfig = {
apiBaseUrl: import.meta.env.VITE_API_BASE_URL ?? '/api',
appName: import.meta.env.VITE_APP_NAME ?? 'Atlas Seller Kit',
debug: import.meta.env.VITE_DEBUG === 'true',
};
+37
View File
@@ -0,0 +1,37 @@
:root {
--primary-color: #8b5cf6;
--primary-light: #a78bfa;
--primary-dark: #7c3aed;
/* 侧边菜单背景(纯色,中间调;想再调直接改这里) */
--sider-bg: #6d28d9;
--text-primary: #1f2937;
--text-secondary: #6b7280;
--bg-color: #f9fafb;
--card-bg: #ffffff;
--border-color: #e5e7eb;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
margin: 0;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--bg-color);
color: var(--text-primary);
/* 全局默认字号 14px,与 antd 主题 token 的 fontSize 保持一致 */
font-size: 14px;
line-height: 1.6;
}
#root {
min-height: 100vh;
}
+74
View File
@@ -0,0 +1,74 @@
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background:
<<<<<<< HEAD
radial-gradient(1000px 500px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%),
radial-gradient(900px 480px at 95% 110%, rgba(109, 40, 217, 0.1), transparent 55%),
======= radial-gradient(1000px 500px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%),
radial-gradient(900px 480px at 95% 110%, rgba(109, 40, 217, 0.1), transparent 55%),
>>>>>>> feat-0.2.0 var(--bg-color);
}
.auth-panel {
width: 100%;
max-width: 400px;
}
.auth-brand {
text-align: center;
margin-bottom: 24px;
}
.auth-logo {
font-size: 26px;
font-weight: 700;
color: var(--primary-dark);
}
.auth-logo-subtitle {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
.auth-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(17, 24, 39, 0.06);
padding: 32px 28px;
}
.auth-title {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
.auth-subtitle {
font-size: 14px;
color: var(--text-secondary);
margin: 4px 0 20px;
}
.auth-footer {
margin-top: 16px;
text-align: center;
font-size: 14px;
color: var(--text-secondary);
}
.auth-footer a {
color: var(--primary-color);
font-weight: 500;
}
@media (max-width: 768px) {
.auth-card {
padding: 24px 20px;
}
}
+39
View File
@@ -0,0 +1,39 @@
import { useEffect } from 'react';
import type { ReactNode } from 'react';
import './AuthLayout.css';
interface AuthLayoutProps {
/** 页面标题,如「登录」 */
title: string;
/** 标题下的一行说明 */
subtitle: string;
/** 卡片底部的切换入口 */
footer: ReactNode;
children: ReactNode;
}
/** 注册/登录共用的居中卡片布局,品牌视觉与 MainLayout 一致 */
const AuthLayout = ({ title, subtitle, footer, children }: AuthLayoutProps) => {
useEffect(() => {
document.title = `${title} - Atlas Seller Kit`;
}, [title]);
return (
<div className="auth-page">
<div className="auth-panel">
<div className="auth-brand">
<div className="auth-logo">Atlas Kit</div>
<div className="auth-logo-subtitle">商品工作台</div>
</div>
<div className="auth-card">
<div className="auth-title">{title}</div>
<div className="auth-subtitle">{subtitle}</div>
{children}
<div className="auth-footer">{footer}</div>
</div>
</div>
</div>
);
};
export default AuthLayout;
+225
View File
@@ -0,0 +1,225 @@
.main-layout {
min-height: 100vh;
}
.main-sider {
background: var(--sider-bg) !important;
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 24px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
text-align: center;
transition: padding 0.2s ease;
}
/* 折叠态收窄水平留白,与宽度动画同步 */
.ant-layout-sider-collapsed .sidebar-header {
padding: 20px 8px;
}
.logo {
position: relative;
height: 31px;
line-height: 31px;
font-size: 22px;
font-weight: 700;
color: white;
margin-bottom: 4px;
}
/* 全称与缩写叠加存放,折叠时交叉淡入淡出 */
.logo-full,
.logo-short {
display: block;
white-space: nowrap;
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.logo-short {
position: absolute;
inset: 0;
opacity: 0;
transform: scale(0.5);
}
.ant-layout-sider-collapsed .logo-full {
opacity: 0;
transform: scale(0.5);
}
.ant-layout-sider-collapsed .logo-short {
opacity: 1;
transform: scale(1);
}
/* 副标题折叠时渐隐并收起占位高度 */
.logo-subtitle {
max-height: 18px;
overflow: hidden;
opacity: 1;
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
transition:
opacity 0.2s ease,
max-height 0.2s ease;
}
.ant-layout-sider-collapsed .logo-subtitle {
max-height: 0;
opacity: 0;
}
.main-header {
background: white !important;
padding: 0 20px 0 10px !important;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow:
0 1px 3px 0 rgba(0, 0, 0, 0.1),
0 1px 2px 0 rgba(0, 0, 0, 0.06);
position: sticky;
top: 0;
z-index: 100;
height: 50px;
line-height: 1;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
/* 页头平台选择器:图标 + 无边框下拉,视觉与用户触发区一致 */
.platform-selector {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 6px;
transition: background 0.2s;
}
.platform-selector:hover {
background: #f3f4f6;
}
.platform-selector-icon {
color: var(--primary-color);
font-size: 15px;
}
/* 头像 + 用户名触发区:hover 有浅色背景反馈,暗示可下拉 */
.header-user-trigger {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
max-width: 240px;
}
.header-user-trigger:hover {
background: #f3f4f6;
}
/* 用户名超长省略 */
.header-user-name {
font-size: 13px;
font-weight: 600;
color: #1f2937;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.collapse-trigger,
.mobile-menu-trigger {
font-size: 16px;
cursor: pointer;
color: #1f2937;
border-radius: 4px;
transition: background 0.2s;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex-shrink: 0;
}
.collapse-trigger:hover,
.mobile-menu-trigger:hover {
background: #f9fafb;
}
.page-breadcrumb {
font-size: 14px;
line-height: 1;
white-space: nowrap;
/* 与 collapse-trigger 一致的 28px 盒子,内部垂直居中 */
min-height: 28px;
display: flex;
align-items: center;
}
/* 条目与分隔图标按垂直中心对齐,避免不同字号图标基线错位 */
.page-breadcrumb ol {
display: flex;
align-items: center;
}
.page-breadcrumb li {
display: inline-flex;
align-items: center;
}
.page-breadcrumb .ant-breadcrumb-link {
display: inline-flex;
align-items: center;
color: #1f2937;
}
.breadcrumb-crumb {
display: inline-flex;
align-items: center;
gap: 6px;
}
.breadcrumb-crumb .anticon {
font-size: 15px;
}
.page-breadcrumb .ant-breadcrumb-separator {
color: #9ca3af;
}
.main-content {
padding: 20px;
background: #f3f4f6;
min-height: calc(100vh - 50px);
}
@media (max-width: 768px) {
.main-content {
padding: 16px;
}
.main-header {
padding: 0 16px !important;
}
}
+189
View File
@@ -0,0 +1,189 @@
import { useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Avatar, Breadcrumb, Drawer, Dropdown, Layout } from 'antd';
import type { MenuProps } from 'antd';
import {
LockOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
RightOutlined,
UserOutlined,
} from '@ant-design/icons';
import SidebarMenu from './SidebarMenu';
import PlatformSelector from './PlatformSelector';
import { getBreadcrumbItems } from './menuConfig';
import { useSession } from '@/services/session';
import './MainLayout.css';
const { Header, Sider, Content } = Layout;
const MainLayout = () => {
const [collapsed, setCollapsed] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [loggingOut, setLoggingOut] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const { session, logout } = useSession();
const breadcrumbItems = useMemo(
() =>
getBreadcrumbItems(location.pathname).map((crumb) => ({
title: (
<span className="breadcrumb-crumb">
{crumb.icon}
<span>{crumb.label}</span>
</span>
),
})),
[location.pathname]
);
const userDisplayName = session ? (session.user.display_name ?? session.user.email) : '';
const userInitial = userDisplayName.charAt(0).toUpperCase();
const handleLogout = async () => {
setLoggingOut(true);
try {
await logout();
} finally {
setLoggingOut(false);
navigate('/login', { replace: true });
}
};
const handleUserMenuClick: MenuProps['onClick'] = ({ key }) => {
if (key === 'profile') {
navigate('/settings/profile');
} else if (key === 'password') {
navigate('/settings/password');
} else if (key === 'logout') {
void handleLogout();
}
};
const userMenuItems: MenuProps['items'] = [
{ key: 'profile', icon: <UserOutlined />, label: '修改资料' },
{ key: 'password', icon: <LockOutlined />, label: '修改密码' },
{ type: 'divider' },
{
key: 'logout',
icon: <LogoutOutlined />,
label: loggingOut ? '退出中…' : '退出登录',
disabled: loggingOut,
},
];
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
if (window.innerWidth >= 768) setMobileMenuOpen(false);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
return (
<Layout className="main-layout">
{!isMobile && (
<Sider
className="main-sider"
width={200}
collapsedWidth={60}
collapsed={collapsed}
theme="dark"
style={{
overflow: 'auto',
height: '100vh',
position: 'fixed',
left: 0,
top: 0,
bottom: 0,
}}
>
<div className="sidebar-header">
<div className="logo">
<span className="logo-full">Atlas Kit</span>
<span className="logo-short">A</span>
</div>
<div className="logo-subtitle">商品工作台</div>
</div>
<SidebarMenu collapsed={collapsed} />
</Sider>
)}
{isMobile && (
<Drawer
title="Atlas Kit"
placement="left"
onClose={() => setMobileMenuOpen(false)}
open={mobileMenuOpen}
styles={{ body: { padding: 0, background: 'var(--sider-bg)' } }}
width={200}
>
<SidebarMenu collapsed={false} onMenuClick={() => setMobileMenuOpen(false)} />
</Drawer>
)}
<Layout
className="main-content-layout"
style={{
marginLeft: isMobile ? 0 : collapsed ? 60 : 200,
transition: 'margin-left 0.2s',
}}
>
<Header className="main-header">
<div className="header-left">
{isMobile && (
<MenuFoldOutlined
className="mobile-menu-trigger"
onClick={() => setMobileMenuOpen(true)}
/>
)}
{!isMobile && (
<div
className="collapse-trigger"
onClick={() => setCollapsed(!collapsed)}
>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</div>
)}
<Breadcrumb
className="page-breadcrumb"
separator={<RightOutlined style={{ fontSize: 12 }} />}
items={breadcrumbItems}
/>
</div>
{session && (
<div className="header-right">
<PlatformSelector />
<Dropdown
menu={{ items: userMenuItems, onClick: handleUserMenuClick }}
trigger={['hover']}
placement="bottomRight"
>
<div className="header-user-trigger" title={userDisplayName}>
<Avatar
size={28}
style={{ backgroundColor: 'var(--sider-bg)' }}
>
{userInitial}
</Avatar>
<span className="header-user-name">{userDisplayName}</span>
</div>
</Dropdown>
</div>
)}
</Header>
<Content className="main-content">
<Outlet />
</Content>
</Layout>
</Layout>
);
};
export default MainLayout;
+46
View File
@@ -0,0 +1,46 @@
import { GlobalOutlined } from '@ant-design/icons';
import { Select, Tooltip } from 'antd';
import { usePlatform } from '@/services/platform';
/**
* 页头平台选择器:采集编辑、发布等动作按页头当前平台取得发布配置与默认店铺
* (docs/requirements/collector-browser-extension.md 的页头平台上下文约定)。
* 首次使用默认选中目录第一家(Ozon 优先),选择结果仅作为非敏感 UI 偏好本地记忆。
*/
const PlatformSelector = () => {
const {
platforms,
currentPlatform,
selectPlatform,
context,
contextLoading,
} = usePlatform();
if (platforms.length === 0) return null;
const tooltip = context?.default_store
? `当前平台:${currentPlatform?.name},默认店铺:${context.default_store.name}`
: '选择当前平台';
return (
<Tooltip title={tooltip}>
<div className="platform-selector">
<GlobalOutlined className="platform-selector-icon" />
<Select
variant="borderless"
loading={contextLoading && !currentPlatform}
value={currentPlatform?.id}
onChange={selectPlatform}
options={platforms.map((platform) => ({
value: platform.id,
label: platform.name,
}))}
popupMatchSelectWidth={false}
aria-label="选择当前平台"
/>
</div>
</Tooltip>
);
};
export default PlatformSelector;
+162
View File
@@ -0,0 +1,162 @@
.sidebar-menu-container {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.sidebar-menu {
background: transparent !important;
border: none;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
}
.sidebar-menu .ant-menu-item {
margin: 0 !important;
padding: 12px 20px !important;
height: auto !important;
line-height: 1.5 !important;
transition: all 0.3s !important;
font-size: 15px !important;
font-weight: 600 !important;
}
.sidebar-menu .ant-menu-item .ant-menu-title-content {
font-size: 15px !important;
font-weight: 600 !important;
/* antd 默认只过渡 color,会覆盖下面的 width/opacity 过渡;必须 !important 提权 */
transition:
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
width 0.2s ease,
opacity 0.2s ease,
margin 0.3s,
color 0.3s !important;
display: inline-block;
/* 显式宽度让折叠时 width→0 可插值,配合裁剪+渐隐平滑收起(auto→0 无法动画) */
width: 100%;
opacity: 1;
overflow: hidden;
white-space: nowrap;
vertical-align: middle;
}
.sidebar-menu .ant-menu-item:hover {
background: rgba(255, 255, 255, 0.1) !important;
}
.sidebar-menu .ant-menu-item-selected {
background: rgba(255, 255, 255, 0.2) !important;
border-left: 3px solid white;
}
.sidebar-menu .ant-menu-item-selected .ant-menu-title-content {
transform: translateX(8px);
}
.sidebar-menu .ant-menu-item-selected:hover {
background: rgba(255, 255, 255, 0.25) !important;
}
.sidebar-menu .ant-menu-item-selected:hover .ant-menu-title-content {
transform: translateX(8px);
}
.sidebar-menu .ant-menu-item-group-title {
padding: 12px 20px 8px !important;
font-size: 12px !important;
color: rgba(255, 255, 255, 0.7) !important;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 500;
/* 折叠时渐隐并收起占位高度(!important 防止被 antd 默认 transition 覆盖) */
max-height: 40px;
opacity: 1;
overflow: hidden;
transition:
opacity 0.2s ease,
max-height 0.2s ease,
padding 0.2s ease !important;
}
.sidebar-menu .ant-menu-item-icon {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 18px !important;
display: inline-flex;
align-items: center;
}
/* hover 放大只作用于非选中项;选中项图标固定 translateX(8px),hover 时不得变 transform 导致跳动 */
.sidebar-menu .ant-menu-item:hover:not(.ant-menu-item-selected) .ant-menu-item-icon {
transform: scale(1.05);
}
.sidebar-menu .ant-menu-item-selected .ant-menu-item-icon {
transform: translateX(8px);
}
@media (max-width: 768px) {
.sidebar-menu .ant-menu-item {
padding: 14px 20px !important;
}
}
/* ── 折叠态(60px):图标居中,覆盖展开态的左对齐 padding/位移 ── */
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item,
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-group-title {
padding: 12px 0 !important;
text-align: center;
}
/* 分组标题折叠时渐隐并完全收起,不占纵向空间 */
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-group-title {
max-height: 0;
opacity: 0;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-title-content,
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected .ant-menu-title-content {
transform: none !important;
}
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-item-icon,
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item:hover .ant-menu-item-icon,
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected .ant-menu-item-icon {
transform: none !important;
margin: 0;
}
/* 折叠时去掉左侧 3px 选中边框(会挤偏图标);只过渡宽度,避免 none 突变 */
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected {
border-left-width: 0 !important;
}
/* 折叠时菜单文字平滑收起:width 100%→0 插值 + 裁剪 + 渐隐;
不用 display:none(无法过渡,文字瞬间消失) */
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-title-content,
.sidebar-menu.ant-menu-vertical .ant-menu-item .ant-menu-title-content {
width: 0 !important;
opacity: 0 !important;
}
/* 兼容:部分版本折叠态用 vertical 类渲染,同样居中 */
.sidebar-menu.ant-menu-vertical .ant-menu-item,
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
padding-left: 0 !important;
padding-right: 0 !important;
}
/* 折叠态 flex 子元素居中(display:flex 已生效,缺 justify-content) */
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
justify-content: center !important;
}
+63
View File
@@ -0,0 +1,63 @@
import { useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Menu } from 'antd';
import type { MenuProps } from 'antd';
import { routeMenuConfig } from './menuConfig';
import './SidebarMenu.css';
interface SidebarMenuProps {
collapsed: boolean;
onMenuClick?: () => void;
}
const SidebarMenu = ({ collapsed, onMenuClick }: SidebarMenuProps) => {
const location = useLocation();
const navigate = useNavigate();
// 分组标题保持常驻,折叠时的隐藏由 CSS 过渡完成,避免文字瞬间消失
const menuItems: MenuProps['items'] = useMemo(() => {
const children = routeMenuConfig.map((config) => ({
key: config.key,
icon: config.icon,
label: config.label,
}));
return [
{
type: 'group' as const,
label: '主要功能',
className: 'menu-section-title',
children,
},
];
}, []);
const selectedKeys = useMemo(() => {
const path = location.pathname;
if (path === '/' || path === '') {
return [routeMenuConfig[0].key];
}
const config = routeMenuConfig.find((item) => path.startsWith(item.path));
return [config ? config.key : path];
}, [location.pathname]);
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
navigate(key);
if (onMenuClick) onMenuClick();
};
return (
<div className="sidebar-menu-container">
<Menu
theme="dark"
mode="inline"
inlineCollapsed={collapsed}
selectedKeys={selectedKeys}
items={menuItems}
onClick={handleMenuClick}
className="sidebar-menu"
/>
</div>
);
};
export default SidebarMenu;
+87
View File
@@ -0,0 +1,87 @@
import {
CalculatorOutlined,
CodeSandboxOutlined,
EditOutlined,
GlobalOutlined,
InboxOutlined,
LockOutlined,
PictureOutlined,
SettingOutlined,
ShopOutlined,
UserOutlined,
} from '@ant-design/icons';
import type { ReactNode } from 'react';
export interface RouteMenuConfig {
/** 路由路径 */
path: string;
/** 菜单键值(与 path 相同) */
key: string;
/** 菜单图标(菜单与页头面包屑共用) */
icon: ReactNode;
/** 菜单标签 */
label: string;
}
/** 左侧一级菜单,顺序与 docs/requirements/account-store-watermark.md 的导航约定一致 */
export const routeMenuConfig: RouteMenuConfig[] = [
{
path: '/online-products',
key: '/online-products',
icon: <GlobalOutlined />,
label: '在线商品',
},
{
path: '/collection',
key: '/collection',
icon: <InboxOutlined />,
label: '采集箱',
},
{
path: '/shops',
key: '/shops',
icon: <ShopOutlined />,
label: '店铺管理',
},
{
path: '/watermarks',
key: '/watermarks',
icon: <PictureOutlined />,
label: '水印管理',
},
{
path: '/pricing',
key: '/pricing',
icon: <CalculatorOutlined />,
label: '定价工具',
},
];
export interface BreadcrumbCrumb {
icon: ReactNode;
label: string;
}
/** 页头面包屑:一级菜单页显示单级;不在菜单中的页面显示两级 */
export const getBreadcrumbItems = (path: string): BreadcrumbCrumb[] => {
if (path.startsWith('/products/')) {
return [
{ icon: <CodeSandboxOutlined />, label: '商品' },
{ icon: <EditOutlined />, label: '商品编辑' },
];
}
if (path === '/settings/profile') {
return [
{ icon: <SettingOutlined />, label: '设置' },
{ icon: <UserOutlined />, label: '修改资料' },
];
}
if (path === '/settings/password') {
return [
{ icon: <SettingOutlined />, label: '设置' },
{ icon: <LockOutlined />, label: '修改密码' },
];
}
const config = routeMenuConfig.find((item) => path.startsWith(item.path)) ?? routeMenuConfig[0];
return [{ icon: config.icon, label: config.label }];
};
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);
+112
View File
@@ -0,0 +1,112 @@
import { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { App as AntdApp, Button, Form, Input } from 'antd';
import { LockOutlined, MailOutlined } from '@ant-design/icons';
import axios from 'axios';
import AuthLayout from '@/layouts/AuthLayout';
import { apiErrorDetails, apiErrorMessage } from '@/services/api';
import { authApi } from '@/services/auth';
import { useSession } from '@/services/session';
import { emailFormatRule } from '@/utils/validators';
interface LoginFormValues {
email: string;
password: string;
}
/** 登录页,契约见 docs/api/auth.md §3 */
const LoginPage = () => {
const navigate = useNavigate();
const location = useLocation();
const { message } = AntdApp.useApp();
const { applyAuthSession } = useSession();
const [form] = Form.useForm<LoginFormValues>();
const [submitting, setSubmitting] = useState(false);
const handleFinish = async (values: LoginFormValues) => {
setSubmitting(true);
try {
const session = await authApi.login({
email: values.email.trim(),
password: values.password,
});
applyAuthSession(session);
message.success('登录成功');
// 守卫记住的来源路径优先,默认进入在线商品首页
const from = (location.state as { from?: string } | null)?.from;
navigate(from && from.startsWith('/') ? from : '/online-products', {
replace: true,
});
} catch (error) {
// 契约约定登录失败统一返回 401 INVALID_CREDENTIALS,不区分邮箱不存在或密码错误
if (axios.isAxiosError(error) && error.response?.status === 401) {
message.error('邮箱或密码不正确');
return;
}
// 422 VALIDATION_FAILED 等带字段明细的错误,逐项落到对应表单项
const details = apiErrorDetails(error);
if (details.length > 0) {
form.setFields(
details.map((item) => ({
name: item.field as keyof LoginFormValues,
errors: [item.message],
}))
);
} else {
message.error(apiErrorMessage(error));
}
} finally {
setSubmitting(false);
}
};
return (
<AuthLayout
title="登录"
subtitle="登录 Atlas Seller Kit,继续管理你的商品"
footer={
<>
还没有账户?<Link to="/register">立即注册</Link>
</>
}
>
<Form<LoginFormValues>
form={form}
layout="vertical"
requiredMark={false}
onFinish={handleFinish}
>
<Form.Item
name="email"
label="邮箱"
rules={[{ required: true, message: '请输入邮箱' }, emailFormatRule]}
>
<Input
prefix={<MailOutlined style={{ color: 'var(--text-secondary)' }} />}
placeholder="请输入邮箱"
autoComplete="email"
autoFocus
/>
</Form.Item>
<Form.Item
name="password"
label="密码"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined style={{ color: 'var(--text-secondary)' }} />}
placeholder="请输入密码"
autoComplete="current-password"
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
<Button type="primary" htmlType="submit" block loading={submitting}>
登录
</Button>
</Form.Item>
</Form>
</AuthLayout>
);
};
export default LoginPage;
+147
View File
@@ -0,0 +1,147 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { App as AntdApp, Button, Form, Input } from 'antd';
import { LockOutlined, MailOutlined, UserOutlined } from '@ant-design/icons';
import axios from 'axios';
import AuthLayout from '@/layouts/AuthLayout';
import { apiErrorDetails, apiErrorMessage } from '@/services/api';
import { authApi } from '@/services/auth';
import { useSession } from '@/services/session';
import { emailFormatRule } from '@/utils/validators';
interface RegisterFormValues {
display_name?: string;
email: string;
password: string;
password_confirmation: string;
}
/** 注册页,契约见 docs/api/auth.md §2;密码长度与复杂度由服务端唯一校验,前端不自行放宽或收窄 */
const RegisterPage = () => {
const navigate = useNavigate();
const { message } = AntdApp.useApp();
const { applyAuthSession } = useSession();
const [form] = Form.useForm<RegisterFormValues>();
const [submitting, setSubmitting] = useState(false);
const handleFinish = async (values: RegisterFormValues) => {
setSubmitting(true);
try {
const session = await authApi.register({
email: values.email.trim(),
password: values.password,
password_confirmation: values.password_confirmation,
display_name: values.display_name?.trim() || undefined,
});
// 注册成功即在同一事务创建用户、个人工作空间和登录会话
applyAuthSession(session);
message.success('注册成功,已进入你的工作空间');
navigate('/online-products', { replace: true });
} catch (error) {
// 重复邮箱返回 409 EMAIL_ALREADY_REGISTERED,落到邮箱字段上提示
if (axios.isAxiosError(error) && error.response?.status === 409) {
form.setFields([{ name: 'email', errors: ['该邮箱已注册,请直接登录'] }]);
return;
}
// 422 VALIDATION_FAILED 等带字段明细的错误,逐项落到对应表单项
const details = apiErrorDetails(error);
if (details.length > 0) {
form.setFields(
details.map((item) => ({
name: item.field as keyof RegisterFormValues,
errors: [item.message],
}))
);
} else {
message.error(apiErrorMessage(error));
}
} finally {
setSubmitting(false);
}
};
return (
<AuthLayout
title="注册"
subtitle="创建账户后自动创建个人工作空间,注册即登录"
footer={
<>
已有账户?<Link to="/login">直接登录</Link>
</>
}
>
<Form<RegisterFormValues>
form={form}
layout="vertical"
requiredMark={false}
onFinish={handleFinish}
>
<Form.Item
name="display_name"
label="显示名称"
extra="选填,最多 80 个字符"
rules={[{ max: 80, message: '显示名称最多 80 个字符' }]}
>
<Input
prefix={<UserOutlined style={{ color: 'var(--text-secondary)' }} />}
placeholder="选填"
maxLength={80}
autoComplete="nickname"
/>
</Form.Item>
<Form.Item
name="email"
label="邮箱"
rules={[{ required: true, message: '请输入邮箱' }, emailFormatRule]}
>
<Input
prefix={<MailOutlined style={{ color: 'var(--text-secondary)' }} />}
placeholder="请输入邮箱"
autoComplete="email"
/>
</Form.Item>
<Form.Item
name="password"
label="密码"
extra="至少 12 个字符;长度与复杂度以服务端安全校验为准"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined style={{ color: 'var(--text-secondary)' }} />}
placeholder="请输入密码"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item
name="password_confirmation"
label="确认密码"
dependencies={['password']}
rules={[
{ required: true, message: '请再次输入密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<Input.Password
prefix={<LockOutlined style={{ color: 'var(--text-secondary)' }} />}
placeholder="请再次输入密码"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
<Button type="primary" htmlType="submit" block loading={submitting}>
创建账户
</Button>
</Form.Item>
</Form>
</AuthLayout>
);
};
export default RegisterPage;
@@ -0,0 +1,8 @@
import PagePlaceholder from '@/components/PagePlaceholder';
/** 采集箱:查看已采集的商品,进入编辑上架(业务模块尚未实现) */
const CollectionPage = () => (
<PagePlaceholder title="采集箱" description="查看已采集的商品,进入编辑上架,模块开发中。" />
);
export default CollectionPage;
@@ -0,0 +1,11 @@
import PagePlaceholder from '@/components/PagePlaceholder';
/** 在线商品:按 SKU 查看平台已上架商品与同步状态(业务模块尚未实现) */
const OnlineProductsPage = () => (
<PagePlaceholder
title="在线商品"
description="按 SKU 查看平台已上架商品与同步状态,模块开发中。"
/>
);
export default OnlineProductsPage;
@@ -0,0 +1,8 @@
import PagePlaceholder from '@/components/PagePlaceholder';
/** 定价工具:独立试算与物流计价规则(业务模块尚未实现) */
const PricingPage = () => (
<PagePlaceholder title="定价工具" description="独立试算与物流计价规则,模块开发中。" />
);
export default PricingPage;
@@ -0,0 +1,16 @@
import { useParams } from 'react-router-dom';
import PagePlaceholder from '@/components/PagePlaceholder';
/** 商品 AI 编辑页:编辑 SPU/SKU、媒体、定价与首次发布(业务模块尚未实现) */
const ProductEditPage = () => {
const { id } = useParams<{ id: string }>();
return (
<PagePlaceholder
title={`商品编辑(${id ?? '未知 ID'})`}
description="商品 AI 编辑页:SPU/SKU、媒体、定价与发布,模块开发中。"
/>
);
};
export default ProductEditPage;
@@ -0,0 +1,128 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { App as AntdApp, Button, Form, Input } from 'antd';
import axios from 'axios';
import { authApi } from '@/services/auth';
import { authToken } from '@/services/authToken';
import { apiErrorDetails, apiErrorMessage } from '@/services/api';
interface PasswordFormValues {
old_password: string;
password: string;
password_confirmation: string;
}
/**
* 修改密码页,契约见 docs/api/auth.md(POST /api/auth/change-password)。
* 成功后所有旧 session 失效:立即用响应中的新 access token 替换内存值
* (本设备经新的 refresh cookie 保持登录),清理表单状态后回到首页。
*/
const PasswordSettingsPage = () => {
const { message } = AntdApp.useApp();
const navigate = useNavigate();
const [form] = Form.useForm<PasswordFormValues>();
const [submitting, setSubmitting] = useState(false);
const handleFinish = async (values: PasswordFormValues) => {
setSubmitting(true);
try {
const result = await authApi.changePassword({
old_password: values.old_password,
password: values.password,
password_confirmation: values.password_confirmation,
});
authToken.set(result.access_token);
form.resetFields();
message.success('密码已修改');
navigate('/online-products', { replace: true });
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
message.error('原密码不正确');
} else {
const details = apiErrorDetails(error);
if (details.length > 0) {
form.setFields(
details.map((item) => ({
name: item.field as keyof PasswordFormValues,
errors: [item.message],
}))
);
} else {
message.error(apiErrorMessage(error));
}
}
} finally {
setSubmitting(false);
}
};
return (
<div className="settings-page">
<div className="settings-card">
<div className="settings-card-title">修改密码</div>
<div className="settings-card-body">
<Form<PasswordFormValues>
form={form}
layout="vertical"
onFinish={handleFinish}
className="profile-form"
>
<Form.Item
name="old_password"
label="原密码"
rules={[{ required: true, message: '请输入原密码' }]}
>
<Input.Password
placeholder="请输入原密码"
autoComplete="current-password"
/>
</Form.Item>
<Form.Item
name="password"
label="新密码"
extra="至少 12 个字符;长度与复杂度以服务端安全校验为准"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 12, message: '新密码至少需要 12 个字符' },
]}
>
<Input.Password
placeholder="请输入新密码"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item
name="password_confirmation"
label="确认新密码"
dependencies={['password']}
rules={[
{ required: true, message: '请再次输入新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的新密码不一致'));
},
}),
]}
>
<Input.Password
placeholder="请再次输入新密码"
autoComplete="new-password"
/>
</Form.Item>
<div className="settings-actions">
<Button type="primary" htmlType="submit" loading={submitting}>
保存修改
</Button>
<Button onClick={() => navigate('/')}>取消</Button>
</div>
</Form>
</div>
</div>
</div>
);
};
export default PasswordSettingsPage;
@@ -0,0 +1,114 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { App as AntdApp, Avatar, Button, Form, Input } from 'antd';
import axios from 'axios';
import { authApi } from '@/services/auth';
import { useSession } from '@/services/session';
import { apiErrorDetails, apiErrorMessage } from '@/services/api';
interface ProfileFormValues {
display_name: string;
}
/**
* 修改资料页,契约见 docs/api/auth.md(PATCH /api/auth/me,If-Match + Idempotency-Key)。
* 头像持久化依赖素材预签名直传接口(尚未交付):当前仅展示默认头像的禁用态,不提供上传。
*/
const ProfileSettingsPage = () => {
const { message } = AntdApp.useApp();
const navigate = useNavigate();
const { session, applyProfileUpdate } = useSession();
const [form] = Form.useForm<ProfileFormValues>();
const [saving, setSaving] = useState(false);
const displayName = session?.user.display_name ?? session?.user.email ?? '';
const initial = useMemo(() => displayName.charAt(0).toUpperCase(), [displayName]);
if (!session) return null;
const handleSave = async (values: ProfileFormValues) => {
setSaving(true);
try {
const user = await authApi.updateProfile(
{ display_name: values.display_name.trim() },
// 幂等键每次提交重新生成;以提交时的 revision 做乐观锁
{ revision: session.user.revision, idempotencyKey: crypto.randomUUID() }
);
applyProfileUpdate(user);
message.success('资料已更新');
} catch (error) {
// 乐观锁版本过期:服务端返回 409 与当前 revision,提示刷新后重试
if (axios.isAxiosError(error) && error.response?.status === 409) {
message.error('资料已被其他操作更新,请刷新页面获取最新资料后重试');
} else {
const details = apiErrorDetails(error);
if (details.length > 0) {
form.setFields(
details
.filter((item) => item.field !== 'If-Match')
.map((item) => ({
name: item.field as keyof ProfileFormValues,
errors: [item.message],
}))
);
} else {
message.error(apiErrorMessage(error));
}
}
} finally {
setSaving(false);
}
};
const handleReset = () => {
form.setFieldValue('display_name', session.user.display_name ?? '');
};
return (
<div className="settings-page">
<div className="settings-card">
<div className="settings-card-title">修改个人信息</div>
<div className="settings-card-body">
<div className="settings-field-label">头像</div>
<div className="avatar-upload-area">
<Avatar size={160} style={{ backgroundColor: 'var(--sider-bg)' }}>
{initial}
</Avatar>
</div>
<div className="avatar-upload-hint">头像上传即将支持,敬请期待</div>
<Form<ProfileFormValues>
form={form}
layout="vertical"
initialValues={{ display_name: session.user.display_name ?? '' }}
onFinish={handleSave}
className="profile-form"
>
<Form.Item
name="display_name"
label="昵称"
required
rules={[{ required: true, message: '请输入昵称' }]}
>
<Input
maxLength={80}
showCount
placeholder="请输入昵称"
autoComplete="nickname"
/>
</Form.Item>
<div className="settings-actions">
<Button type="primary" htmlType="submit" loading={saving}>
保存修改
</Button>
<Button onClick={handleReset}>重 置</Button>
<Button onClick={() => navigate('/')}>取消</Button>
</div>
</Form>
</div>
</div>
</div>
);
};
export default ProfileSettingsPage;
+11
View File
@@ -0,0 +1,11 @@
.shops-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.shops-load-more {
display: flex;
justify-content: center;
margin-top: 16px;
}
+623
View File
@@ -0,0 +1,623 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
App as AntdApp,
Button,
Card,
Dropdown,
Empty,
Form,
Input,
Modal,
Select,
Space,
Switch,
Table,
Tag,
Tooltip,
Typography,
} from 'antd';
import type { MenuProps, TableProps } from 'antd';
import {
ApiOutlined,
CloudDownloadOutlined,
DeleteOutlined,
MoreOutlined,
PlusOutlined,
ReloadOutlined,
WarningFilled,
} from '@ant-design/icons';
import axios from 'axios';
import type { Store } from '@atlas/contracts';
import { apiErrorCode, apiErrorMessage } from '@/services/api';
import { storesApi } from '@/services/stores';
import { usePlatform } from '@/services/platform';
import './ShopsPage.css';
const STORE_STATUS_MAP: Record<string, { color: string; text: string }> = {
verifying: { color: 'gold', text: '验证中' },
active: { color: 'green', text: '正常' },
credential_invalid: { color: 'red', text: '凭据无效' },
sync_error: { color: 'orange', text: '同步异常' },
disabled: { color: 'default', text: '已停用' },
deleted: { color: 'default', text: '已删除' },
};
const CREDENTIAL_STATUS_MAP: Record<string, { color: string; text: string }> = {
unknown: { color: 'default', text: '未验证' },
valid: { color: 'green', text: '凭据有效' },
invalid: { color: 'red', text: '凭据无效' },
expired: { color: 'red', text: '已过期' },
rotation_required: { color: 'gold', text: '待轮换' },
};
function formatDateTime(value: string | null): string {
if (!value) return '—';
const date = new Date(value);
return Number.isNaN(date.getTime())
? '—'
: date.toLocaleString('zh-CN', { hour12: false });
}
interface CreateFormValues {
name: string;
client_id: string;
api_key: string;
currency_code: string;
is_default: boolean;
note?: string;
}
interface EditFormValues {
name: string;
currency_code: string;
note?: string;
api_key?: string;
}
/**
* 店铺管理页,契约见 docs/api/stores-and-platforms.md。
* 列表按页头当前平台过滤(platform_id 查询参数);创建与密钥替换为异步验证,
* Worker 消费验证操作前店铺会停留在「验证中」状态。
*/
const ShopsPage = () => {
const { message, modal } = AntdApp.useApp();
const {
currentPlatform,
platforms,
error: platformError,
reload,
refreshContext,
} = usePlatform();
const [stores, setStores] = useState<Store[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [pendingStoreId, setPendingStoreId] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [createForm] = Form.useForm<CreateFormValues>();
const [editingStore, setEditingStore] = useState<Store | null>(null);
const [saving, setSaving] = useState(false);
const [editForm] = Form.useForm<EditFormValues>();
const platformId = currentPlatform?.id ?? null;
const currencyOptions = useMemo(
() =>
(currentPlatform?.supported_currencies ?? []).map((code) => ({
value: code,
label: code,
})),
[currentPlatform]
);
const loadStores = useCallback(
async (targetPlatformId: string) => {
setLoading(true);
try {
const result = await storesApi.list(targetPlatformId);
setStores(result.data);
setNextCursor(result.meta.next_cursor);
} catch (err) {
message.error(apiErrorMessage(err));
} finally {
setLoading(false);
}
},
[message]
);
useEffect(() => {
if (!platformId) return;
void loadStores(platformId);
}, [platformId, loadStores]);
const loadMore = async () => {
if (!platformId || !nextCursor) return;
setLoadingMore(true);
try {
const result = await storesApi.list(platformId, nextCursor);
setStores((prev) => [...prev, ...result.data]);
setNextCursor(result.meta.next_cursor);
} catch (err) {
message.error(apiErrorMessage(err));
} finally {
setLoadingMore(false);
}
};
/** 任何变更后同时刷新列表与页头默认门店摘要 */
const refreshAll = useCallback(async () => {
if (!platformId) return;
await loadStores(platformId);
await refreshContext();
}, [platformId, loadStores, refreshContext]);
const openCreate = () => {
createForm.resetFields();
createForm.setFieldsValue({ is_default: stores.length === 0 });
setCreateOpen(true);
};
const handleCreate = async (values: CreateFormValues) => {
if (!currentPlatform) return;
setCreating(true);
try {
await storesApi.create({
platform_id: currentPlatform.id,
name: values.name.trim(),
client_id: values.client_id.trim(),
api_key: values.api_key,
currency_code: values.currency_code,
is_default: values.is_default,
note: values.note?.trim() ? values.note.trim() : null,
});
message.success('店铺已创建,凭据验证进行中,稍后可刷新查看结果');
setCreateOpen(false);
await refreshAll();
} catch (err) {
message.error(apiErrorMessage(err));
} finally {
setCreating(false);
}
};
const openEdit = (store: Store) => {
setEditingStore(store);
editForm.setFieldsValue({
name: store.name,
currency_code: store.currency_code,
note: store.note ?? '',
});
};
const handleEditSave = async (values: EditFormValues) => {
if (!editingStore) return;
setSaving(true);
try {
const result = await storesApi.update(
editingStore.id,
{
name: values.name.trim(),
currency_code: values.currency_code,
note: values.note?.trim() ? values.note.trim() : null,
// 留空表示保留旧密钥;填写则替换并重新触发验证
...(values.api_key ? { api_key: values.api_key } : {}),
},
{ revision: editingStore.revision }
);
if (result && 'operation' in result) {
message.success('已保存,新密钥验证进行中,稍后可刷新查看结果');
} else {
message.success('店铺已保存');
}
setEditingStore(null);
await refreshAll();
} catch (err) {
// 乐观锁版本过期:提示后刷新列表,用户重新编辑拿到新 revision
if (axios.isAxiosError(err) && err.response?.status === 409) {
message.error('店铺信息已被其他操作更新,列表已刷新,请重新编辑');
setEditingStore(null);
await refreshAll();
} else {
message.error(apiErrorMessage(err));
}
} finally {
setSaving(false);
}
};
const withPending = async (store: Store, action: () => Promise<unknown>) => {
setPendingStoreId(store.id);
try {
await action();
await refreshAll();
} catch (err) {
message.error(apiErrorMessage(err));
} finally {
setPendingStoreId(null);
}
};
const handleSetDefault = (store: Store) =>
withPending(store, async () => {
await storesApi.setDefault(store.id);
message.success(`已将「${store.name}」设为默认店铺`);
});
const handleVerify = (store: Store) =>
withPending(store, async () => {
await storesApi.requestCredentialVerification(store.id);
message.success('已发起凭据验证,稍后可刷新查看结果');
});
const handleSyncWarehouses = (store: Store) =>
withPending(store, async () => {
await storesApi.requestWarehouseSync(store.id);
message.success('已发起仓库同步,稍后可刷新查看结果');
});
const handleDelete = (store: Store) => {
modal.confirm({
title: '删除店铺',
content: `仅删除 Atlas 内的店铺连接,不影响平台后台。确定删除「${store.name}」吗?`,
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: () =>
withPending(store, async () => {
try {
await storesApi.remove(store.id);
message.success('店铺已删除');
} catch (err) {
if (apiErrorCode(err) === 'DEFAULT_STORE_REPLACEMENT_REQUIRED') {
message.error(
'该店铺是默认店铺,请先将同平台的其他店铺设为默认,再删除'
);
return;
}
throw err;
}
}),
});
};
const rowMenu = (store: Store): MenuProps['items'] => [
{
key: 'set-default',
icon: <ApiOutlined />,
label: '设为默认店铺',
disabled: store.is_default,
},
{ key: 'verify', icon: <ReloadOutlined />, label: '重新验证凭据' },
{ key: 'sync', icon: <CloudDownloadOutlined />, label: '同步仓库' },
{ type: 'divider' },
{ key: 'delete', icon: <DeleteOutlined />, label: '删除店铺', danger: true },
];
const handleRowMenuClick =
(store: Store): MenuProps['onClick'] =>
({ key }) => {
if (key === 'set-default') void handleSetDefault(store);
else if (key === 'verify') void handleVerify(store);
else if (key === 'sync') void handleSyncWarehouses(store);
else if (key === 'delete') handleDelete(store);
};
const columns: TableProps<Store>['columns'] = [
{
title: '店铺名称',
dataIndex: 'name',
width: 220,
render: (_, store) => (
<Space size={6}>
<span>{store.name}</span>
{store.is_default && <Tag color="purple">默认</Tag>}
</Space>
),
},
{
title: 'Client ID',
dataIndex: 'platform_client_id',
width: 150,
render: (value: string) => (
<Typography.Text copyable={{ text: value }}>{value}</Typography.Text>
),
},
{
title: '币种',
dataIndex: 'currency_code',
width: 80,
},
{
title: '状态',
dataIndex: 'status',
width: 200,
render: (_, store) => {
const status = STORE_STATUS_MAP[store.status] ?? {
color: 'default',
text: store.status,
};
const credential = CREDENTIAL_STATUS_MAP[store.credential_status] ?? {
color: 'default',
text: store.credential_status,
};
return (
<Space size={4} wrap>
<Tag color={status.color}>{status.text}</Tag>
<Tag color={credential.color}>{credential.text}</Tag>
{store.last_error && (
<Tooltip
title={`${store.last_error.code}:${store.last_error.message}`}
>
<WarningFilled style={{ color: '#faad14' }} />
</Tooltip>
)}
</Space>
);
},
},
{
title: '仓库',
dataIndex: 'warehouse_count',
width: 90,
render: (count: number, store) => (
<Tooltip
title={
store.warehouses_synced_at
? `同步于 ${formatDateTime(store.warehouses_synced_at)}`
: '尚未同步仓库'
}
>
<span>{store.warehouses_synced_at ? count : '—'}</span>
</Tooltip>
),
},
{
title: '水印模板',
dataIndex: 'watermark_template',
width: 130,
render: (template: Store['watermark_template']) =>
template ? (
template.name
) : (
<Typography.Text type="secondary">未绑定</Typography.Text>
),
},
{
title: '备注',
dataIndex: 'note',
ellipsis: true,
render: (note: string | null) =>
note ? (
<Typography.Text ellipsis>{note}</Typography.Text>
) : (
<Typography.Text type="secondary">—</Typography.Text>
),
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 170,
render: (value: string) => (
<Typography.Text type="secondary">
{formatDateTime(value)}
</Typography.Text>
),
},
{
title: '操作',
key: 'actions',
width: 140,
fixed: 'right',
render: (_, store) => (
<Space size={4}>
<Button type="link" size="small" onClick={() => openEdit(store)}>
编辑
</Button>
<Dropdown
menu={{ items: rowMenu(store), onClick: handleRowMenuClick(store) }}
trigger={['click']}
>
<Button
type="text"
size="small"
icon={<MoreOutlined />}
loading={pendingStoreId === store.id}
/>
</Dropdown>
</Space>
),
},
];
if (platformError) {
return (
<Card className="shops-page">
<Empty description={`平台目录加载失败:${platformError}`}>
<Button onClick={reload}>重试</Button>
</Empty>
</Card>
);
}
return (
<div className="shops-page">
<Card
title={`店铺管理${currentPlatform ? ` · ${currentPlatform.name}` : ''}`}
extra={
<Button
type="primary"
icon={<PlusOutlined />}
disabled={!currentPlatform}
onClick={openCreate}
>
添加店铺
</Button>
}
>
{platforms.length === 0 && !currentPlatform ? (
<Empty description="暂无可用平台" />
) : (
<>
<Table
rowKey="id"
columns={columns}
dataSource={stores}
loading={loading}
pagination={false}
scroll={{ x: 1100 }}
locale={{
emptyText: currentPlatform
? `当前平台还没有店铺,点击右上角「添加店铺」完成 ${currentPlatform.name} 授权`
: '请先在页头选择平台',
}}
/>
{nextCursor && (
<div className="shops-load-more">
<Button loading={loadingMore} onClick={() => void loadMore()}>
加载更多
</Button>
</div>
)}
</>
)}
</Card>
<Modal
title={`添加${currentPlatform?.name ?? ''}店铺`}
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => createForm.submit()}
confirmLoading={creating}
okText="创建并验证"
cancelText="取消"
destroyOnHidden
maskClosable={false}
>
<Form<CreateFormValues>
form={createForm}
layout="vertical"
onFinish={handleCreate}
>
<Form.Item
name="name"
label="店铺名称"
required
rules={[{ required: true, whitespace: true, message: '请输入店铺名称' }]}
>
<Input maxLength={120} placeholder="例如:Ozon 俄罗斯主店" />
</Form.Item>
<Form.Item
name="client_id"
label="Client ID"
required
rules={[{ required: true, whitespace: true, message: '请输入平台 Client ID' }]}
>
<Input maxLength={120} placeholder="平台后台的 Client ID" />
</Form.Item>
<Form.Item
name="api_key"
label="API Key"
required
rules={[{ required: true, whitespace: true, message: '请输入平台 API Key' }]}
extra="仅用于服务端加密保存与凭据验证,不会出现在日志或响应中"
>
{/* 契约上限 8192(packages/contracts/src/stores.ts),不自行收紧 */}
<Input.Password
maxLength={8192}
placeholder="平台后台的 API Key"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item
name="currency_code"
label="默认计价币种"
required
rules={[{ required: true, message: '请选择币种' }]}
>
<Select options={currencyOptions} placeholder="选择币种" />
</Form.Item>
<Form.Item
name="is_default"
label="设为默认店铺"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name="note"
label="备注"
rules={[{ max: 2000, message: '备注不能超过 2000 字' }]}
>
<Input.TextArea rows={2} maxLength={2000} placeholder="选填" />
</Form.Item>
</Form>
</Modal>
<Modal
title={editingStore ? `编辑店铺:${editingStore.name}` : '编辑店铺'}
open={!!editingStore}
onCancel={() => setEditingStore(null)}
onOk={() => editForm.submit()}
confirmLoading={saving}
okText="保存"
cancelText="取消"
destroyOnHidden
maskClosable={false}
>
{editingStore && (
<Form<EditFormValues>
form={editForm}
layout="vertical"
onFinish={handleEditSave}
>
<Form.Item
name="name"
label="店铺名称"
required
rules={[{ required: true, message: '请输入店铺名称' }]}
>
<Input maxLength={120} />
</Form.Item>
<Form.Item
name="currency_code"
label="默认计价币种"
required
rules={[{ required: true, message: '请选择币种' }]}
>
<Select options={currencyOptions} />
</Form.Item>
<Form.Item
name="note"
label="备注"
rules={[{ max: 2000, message: '备注不能超过 2000 字' }]}
>
<Input.TextArea rows={2} maxLength={2000} placeholder="选填" />
</Form.Item>
<Form.Item
name="api_key"
label="替换 API Key"
extra="留空保留现有密钥;填写后将替换并重新验证凭据"
>
<Input.Password
maxLength={8192}
placeholder="不修改请留空"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item label="Client ID">
<Typography.Text type="secondary">
{editingStore.platform_client_id}(平台与账户不可修改)
</Typography.Text>
</Form.Item>
</Form>
)}
</Modal>
</div>
);
};
export default ShopsPage;
@@ -0,0 +1,8 @@
import PagePlaceholder from '@/components/PagePlaceholder';
/** 水印管理:维护水印模板并绑定到店铺(业务模块尚未实现) */
const WatermarksPage = () => (
<PagePlaceholder title="水印管理" description="维护水印模板并绑定到店铺,模块开发中。" />
);
export default WatermarksPage;
+86
View File
@@ -0,0 +1,86 @@
import type { ReactNode } from 'react';
import { Navigate, useLocation, createBrowserRouter } from 'react-router-dom';
import MainLayout from '@/layouts/MainLayout';
import SessionSplash from '@/components/SessionSplash';
import CollectionPage from '@/pages/collection/CollectionPage';
import LoginPage from '@/pages/auth/LoginPage';
import OnlineProductsPage from '@/pages/online-products/OnlineProductsPage';
import PasswordSettingsPage from '@/pages/settings/PasswordSettingsPage';
import PricingPage from '@/pages/pricing/PricingPage';
import ProductEditPage from '@/pages/products/ProductEditPage';
import ProfileSettingsPage from '@/pages/settings/ProfileSettingsPage';
import RegisterPage from '@/pages/auth/RegisterPage';
import ShopsPage from '@/pages/shops/ShopsPage';
import WatermarksPage from '@/pages/watermarks/WatermarksPage';
import { PlatformProvider } from '@/services/platform';
import { useSession } from '@/services/session';
/** 主布局守卫:会话恢复中显示启动屏;未认证跳登录页并记住来源路径 */
function RequireSession({ children }: { children: ReactNode }) {
const { status } = useSession();
const location = useLocation();
if (status === 'restoring') {
return <SessionSplash />;
}
if (status === 'anonymous') {
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
return children;
}
/** 认证页互斥:已登录访问 /login、/register 时回到来源页或主布局 */
function RedirectIfAuthenticated({ children }: { children: ReactNode }) {
const { status } = useSession();
const location = useLocation();
if (status === 'authenticated') {
const from = (location.state as { from?: string } | null)?.from;
return <Navigate to={from && from.startsWith('/') ? from : '/'} replace />;
}
return children;
}
export const router = createBrowserRouter([
{
path: '/',
element: (
<RequireSession>
{/* 平台上下文仅在登录后可用,随主布局挂载 */}
<PlatformProvider>
<MainLayout />
</PlatformProvider>
</RequireSession>
),
children: [
{ index: true, element: <Navigate to="/online-products" replace /> },
{ path: 'online-products', element: <OnlineProductsPage /> },
{ path: 'collection', element: <CollectionPage /> },
{ path: 'shops', element: <ShopsPage /> },
{ path: 'watermarks', element: <WatermarksPage /> },
{ path: 'pricing', element: <PricingPage /> },
// 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现
{ path: 'products/:id', element: <ProductEditPage /> },
// 个人中心设置页:从页头用户菜单进入,不在左侧菜单中出现
{ path: 'settings/profile', element: <ProfileSettingsPage /> },
{ path: 'settings/password', element: <PasswordSettingsPage /> },
],
},
{
path: 'login',
element: (
<RedirectIfAuthenticated>
<LoginPage />
</RedirectIfAuthenticated>
),
},
{
path: 'register',
element: (
<RedirectIfAuthenticated>
<RegisterPage />
</RedirectIfAuthenticated>
),
},
{ path: '*', element: <Navigate to="/" replace /> },
]);
+162
View File
@@ -0,0 +1,162 @@
import axios from 'axios';
import type {
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
InternalAxiosRequestConfig,
} from 'axios';
import { envConfig } from '@/config/env';
import { authToken } from '@/services/authToken';
declare module 'axios' {
export interface AxiosRequestConfig {
/** 401 刷新重放标记,防止无限重试 */
_retried?: boolean;
}
}
const apiClient: AxiosInstance = axios.create({
baseURL: envConfig.apiBaseUrl,
timeout: 30000,
// 不设全局 Content-Type:axios 1.x 遇到「默认 JSON 头 + FormData」会把表单转成 JSON 发送,
// 导致素材上传等表单接口解析失败。对象请求体 axios 会自动补 JSON 头。
});
// access token 仅在内存中短暂持有(docs/api/conventions.md §1),每次请求动态附加 Bearer 头
apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = authToken.get();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 认证接口自身的失败不进入刷新流程
const AUTH_PATHS = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/logout'];
let refreshTask: Promise<string | null> | null = null;
/** 并发 401 只触发一次刷新请求;刷新依赖 HttpOnly refresh cookie */
function refreshAccessToken(): Promise<string | null> {
refreshTask ??= apiClient
.post('/auth/refresh')
.then((res) => {
const token = unwrapEnvelope<{ access_token: string }>(res).access_token;
authToken.set(token);
return token;
})
.catch(() => {
authToken.clear();
return null;
})
.finally(() => {
refreshTask = null;
});
return refreshTask;
}
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
if (envConfig.debug) {
console.error('请求失败:', error.response?.data || error.message);
}
const config = error.config as AxiosRequestConfig | undefined;
const url = config?.url ?? '';
const isAuthPath = AUTH_PATHS.some((path) => url.includes(path));
// access token 过期时用 refresh cookie 换新 token 并重放一次;
// 刷新失败说明会话已失效,整页跳转登录页(后续可换成路由内跳转并保留来源)
if (error.response?.status === 401 && config && !config._retried && !isAuthPath) {
config._retried = true;
const token = await refreshAccessToken();
if (token) {
return apiClient.request(config);
}
window.location.assign('/login');
}
return Promise.reject(error);
}
);
/**
* 统一解包强制响应信封:成功为 { data: T },204 No Content 无响应体
* (docs/api/conventions.md §3.1)。服务层泛型 T 指业务负载本身;
* 列表接口使用 getList 保留分页 meta。
*/
function unwrapEnvelope<T>(response: AxiosResponse): T {
const body = response.data as { data?: unknown } | undefined;
if (body === null || body === undefined) {
return undefined as T;
}
return body.data as T;
}
/** 列表响应信封为 { data: T[], meta: PaginationMeta }(docs/api/conventions.md §3),meta 需要原样保留 */
function unwrapListEnvelope<T, M>(response: AxiosResponse): { data: T[]; meta: M } {
const body = response.data as { data?: unknown; meta?: unknown } | undefined;
return {
data: Array.isArray(body?.data) ? (body.data as T[]) : [],
meta: (body?.meta ?? {}) as M,
};
}
export const api = {
get: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
apiClient.get<T>(url, config).then((res) => unwrapEnvelope<T>(res)),
getList: <T = unknown, M = unknown>(url: string, config?: AxiosRequestConfig) =>
apiClient.get(url, config).then((res) => unwrapListEnvelope<T, M>(res)),
post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
apiClient.post<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
patch: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
apiClient.patch<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
delete: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
apiClient.delete<T>(url, config).then((res) => unwrapEnvelope<T>(res)),
};
/** 统一错误格式为 { error: { code, message, request_id } }(docs/api/conventions.md),提取可展示信息 */
export function apiErrorMessage(error: unknown): string {
if (axios.isAxiosError(error)) {
const data = error.response?.data as
{ error?: { message?: unknown }; message?: unknown } | undefined;
if (data?.error?.message) return String(data.error.message);
if (data?.message) return String(data.message);
if (error.response) return `请求失败(HTTP ${error.response.status})`;
return '网络连接失败,请稍后重试';
}
return error instanceof Error ? error.message : String(error);
}
/** 提取业务错误码(如 VERSION_CONFLICT、DEFAULT_STORE_REPLACEMENT_REQUIRED),无则返回 null */
export function apiErrorCode(error: unknown): string | null {
if (!axios.isAxiosError(error)) return null;
const code = (error.response?.data as { error?: { code?: unknown } } | undefined)?.error
?.code;
return typeof code === 'string' ? code : null;
}
export interface ApiErrorDetail {
field: string;
code: string;
message: string;
}
/** 提取错误信封中的字段级明细 details: [{ field, code, message }](docs/api/conventions.md §4) */
export function apiErrorDetails(error: unknown): ApiErrorDetail[] {
if (!axios.isAxiosError(error)) return [];
const details = (error.response?.data as { error?: { details?: unknown } } | undefined)?.error
?.details;
if (!Array.isArray(details)) return [];
return details.filter(
(item): item is ApiErrorDetail =>
!!item &&
typeof item === 'object' &&
typeof (item as ApiErrorDetail).field === 'string' &&
typeof (item as ApiErrorDetail).message === 'string'
);
}
export default apiClient;
+48
View File
@@ -0,0 +1,48 @@
import type {
AuthMeResponse,
AuthRefreshResponse,
AuthSessionResponse,
AuthUser,
ChangePasswordRequest,
LoginRequest,
RegisterRequest,
UpdateCurrentUserRequest,
} from '@atlas/contracts';
import { api } from '@/services/api';
export interface UpdateProfileOptions {
/** 提交时的乐观锁版本,取自当前会话 user.revision */
revision: number;
/** 幂等键,重试安全;成功响应携带新 revision */
idempotencyKey: string;
}
/** 账户与会话接口,契约见 docs/api/auth.md;类型全部来自 @atlas/contracts */
export const authApi = {
register: (payload: RegisterRequest) =>
api.post<AuthSessionResponse>('/auth/register', payload),
login: (payload: LoginRequest) => api.post<AuthSessionResponse>('/auth/login', payload),
refresh: () => api.post<AuthRefreshResponse>('/auth/refresh'),
logout: () => api.post<void>('/auth/logout'),
me: () => api.get<AuthMeResponse>('/auth/me'),
/**
* 修改资料:If-Match 乐观锁 + Idempotency-Key 幂等(docs/api/conventions.md §5)。
* 版本过期返回 409 VERSION_CONFLICT(details.current_revision 为服务端当前版本)。
*/
updateProfile: (payload: UpdateCurrentUserRequest, options: UpdateProfileOptions) =>
api.patch<AuthUser>('/auth/me', payload, {
headers: {
'If-Match': `"${options.revision}"`,
'Idempotency-Key': options.idempotencyKey,
},
}),
/** 修改密码:成功后所有旧 session 失效;响应携带新 access token,必须立即替换内存值 */
changePassword: (payload: ChangePasswordRequest) =>
api.post<AuthRefreshResponse>('/auth/change-password', payload),
};
+16
View File
@@ -0,0 +1,16 @@
/**
* access token 仅在内存中短暂持有(docs/api/conventions.md §1),
* 不得写入 localStorage 等持久化存储;页面刷新后由 /auth/refresh 通过
* HttpOnly cookie 恢复会话(接入会话恢复逻辑时实现)。
*/
let accessToken: string | null = null;
export const authToken = {
get: () => accessToken,
set: (token: string) => {
accessToken = token;
},
clear: () => {
accessToken = null;
},
};
+145
View File
@@ -0,0 +1,145 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { ReactNode } from 'react';
import type { Platform, PlatformContext } from '@atlas/contracts';
import { api, apiErrorMessage } from '@/services/api';
/** 平台选择的本地记忆键:仅保存平台 id 这一非敏感 UI 偏好 */
const PLATFORM_STORAGE_KEY = 'atlas.platform_id';
interface PlatformContextValue {
/** 已启用的平台目录;加载失败时为空数组并置 error */
platforms: Platform[];
/** 页头当前选中的平台;目录未加载完成或为空时为 null */
currentPlatform: Platform | null;
/** 目录加载失败信息;非空时提供重试入口 */
error: string | null;
/** 当前平台的默认门店上下文(GET /api/platform-context) */
context: PlatformContext | null;
contextLoading: boolean;
selectPlatform: (platformId: string) => void;
/** 门店变更后刷新默认门店摘要 */
refreshContext: () => Promise<void>;
/** 目录加载失败后重试 */
reload: () => void;
}
const PlatformContextContext = createContext<PlatformContextValue | null>(null);
/** 平台上下文:页头平台选择器的数据源,门店等模块据此确定当前平台(目录接口将 Ozon 排在最前) */
export function PlatformProvider({ children }: { children: ReactNode }) {
const [platforms, setPlatforms] = useState<Platform[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(() =>
localStorage.getItem(PLATFORM_STORAGE_KEY)
);
const [error, setError] = useState<string | null>(null);
const [context, setContext] = useState<PlatformContext | null>(null);
const [contextLoading, setContextLoading] = useState(false);
// 快速切换平台时丢弃过期响应与 loading:只接受最后一次请求的结果
const contextRequestIdRef = useRef(0);
const loadPlatforms = useCallback(async () => {
setError(null);
try {
const list = await api.get<Platform[]>('/platforms');
setPlatforms(list);
// 已保存的选择失效(平台下线或首次使用)时回退到目录第一家(Ozon 优先)
setSelectedId((prev) =>
prev && list.some((item) => item.id === prev) ? prev : (list[0]?.id ?? null)
);
} catch (err) {
setError(apiErrorMessage(err));
}
}, []);
useEffect(() => {
void loadPlatforms();
}, [loadPlatforms]);
const currentPlatform = useMemo(
() => platforms.find((item) => item.id === selectedId) ?? null,
[platforms, selectedId]
);
const loadContext = useCallback(async (platformId: string) => {
const requestId = ++contextRequestIdRef.current;
setContextLoading(true);
try {
const next = await api.get<PlatformContext>('/platform-context', {
headers: { 'X-Atlas-Platform-Id': platformId },
});
if (contextRequestIdRef.current !== requestId) return;
setContext(next);
} catch {
// 上下文读取失败不阻塞页面:门店页等业务接口自会报错
if (contextRequestIdRef.current !== requestId) return;
setContext(null);
} finally {
if (contextRequestIdRef.current === requestId) {
setContextLoading(false);
}
}
}, []);
useEffect(() => {
if (!currentPlatform) {
setContext(null);
return;
}
void loadContext(currentPlatform.id);
}, [currentPlatform, loadContext]);
const selectPlatform = useCallback((platformId: string) => {
setSelectedId(platformId);
localStorage.setItem(PLATFORM_STORAGE_KEY, platformId);
}, []);
const refreshContext = useCallback(async () => {
if (!currentPlatform) return;
await loadContext(currentPlatform.id);
}, [currentPlatform, loadContext]);
const value = useMemo(
() => ({
platforms,
currentPlatform,
error,
context,
contextLoading,
selectPlatform,
refreshContext,
reload: () => void loadPlatforms(),
}),
[
platforms,
currentPlatform,
error,
context,
contextLoading,
selectPlatform,
refreshContext,
loadPlatforms,
]
);
return (
<PlatformContextContext.Provider value={value}>
{children}
</PlatformContextContext.Provider>
);
}
export function usePlatform(): PlatformContextValue {
const context = useContext(PlatformContextContext);
if (!context) {
throw new Error('usePlatform 必须在 PlatformProvider 内使用');
}
return context;
}
+116
View File
@@ -0,0 +1,116 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { ReactNode } from 'react';
import axios from 'axios';
import type { AuthMeResponse, AuthSessionResponse } from '@atlas/contracts';
import { authApi } from '@/services/auth';
import { authToken } from '@/services/authToken';
export type SessionStatus = 'restoring' | 'authenticated' | 'anonymous';
export interface SessionState {
user: AuthMeResponse['user'];
workspace: AuthMeResponse['workspace'];
}
interface SessionContextValue {
status: SessionStatus;
session: SessionState | null;
/** 登录 / 注册成功后写入会话 */
applyAuthSession: (payload: AuthSessionResponse) => void;
/** 修改资料成功后更新本地会话中的用户信息 */
applyProfileUpdate: (user: SessionState['user']) => void;
/** 撤销服务端会话并清除本地状态;网络失败也保证本地登出 */
logout: () => Promise<void>;
}
const SessionContext = createContext<SessionContextValue | null>(null);
// 模块级去重:StrictMode 双挂载或并发恢复时只发一次 refresh(refresh 会轮换会话,重复调用会互相失效)
let restoreTask: Promise<void> | null = null;
/** 会话上下文:应用启动时经 HttpOnly refresh cookie 恢复会话,登录/注册/登出统一在此维护状态 */
export function SessionProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<SessionStatus>('restoring');
const [session, setSession] = useState<SessionState | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
restoreTask ??= (async () => {
try {
const refreshed = await authApi.refresh();
authToken.set(refreshed.access_token);
const me = await authApi.me();
if (!mountedRef.current) return;
setSession({ user: me.user, workspace: me.workspace });
setStatus('authenticated');
} catch {
if (!mountedRef.current) return;
authToken.clear();
setStatus('anonymous');
} finally {
restoreTask = null;
}
})();
void restoreTask;
}, []);
const applyAuthSession = useCallback((payload: AuthSessionResponse) => {
authToken.set(payload.access_token);
setSession({ user: payload.user, workspace: payload.workspace });
setStatus('authenticated');
}, []);
const applyProfileUpdate = useCallback((user: SessionState['user']) => {
setSession((prev) => (prev ? { ...prev, user } : prev));
}, []);
const logout = useCallback(async () => {
try {
await authApi.logout();
} catch (error) {
// access token 已过期时先刷新一次再重试,确保服务端会话被撤销;
// 仍失败则仅做本地登出,等待服务端会话自然过期
if (axios.isAxiosError(error) && error.response?.status === 401) {
const refreshed = await authApi.refresh().catch(() => null);
if (refreshed) {
authToken.set(refreshed.access_token);
await authApi.logout().catch(() => undefined);
}
}
} finally {
authToken.clear();
setSession(null);
setStatus('anonymous');
}
}, []);
const value = useMemo(
() => ({ status, session, applyAuthSession, applyProfileUpdate, logout }),
[status, session, applyAuthSession, applyProfileUpdate, logout]
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
}
export function useSession(): SessionContextValue {
const context = useContext(SessionContext);
if (!context) {
throw new Error('useSession 必须在 SessionProvider 内使用');
}
return context;
}
+74
View File
@@ -0,0 +1,74 @@
import type {
CreateStoreRequest,
CursorPaginationMeta,
Store,
StoreOperationResponse,
UpdateStoreRequest,
} from '@atlas/contracts';
import { api } from '@/services/api';
export interface StoreListResult {
data: Store[];
meta: CursorPaginationMeta;
}
export interface UpdateStoreOptions {
/** 提交时的乐观锁版本,取自门店资源 revision */
revision: number;
}
/**
* 门店管理接口,契约见 docs/api/stores-and-platforms.md。
* 列表必须用 platform_id 查询参数(管理列表禁止携带 X-Atlas-Platform-Id header)。
*/
export const storesApi = {
list: (platformId: string, cursor?: string) =>
api.getList<Store, CursorPaginationMeta>('/stores', {
params: { platform_id: platformId, limit: 20, ...(cursor ? { cursor } : {}) },
}),
get: (storeId: string) => api.get<Store>(`/stores/${storeId}`),
/** 创建后店铺处于 verifying 状态,凭据验证由后台异步执行 */
create: (payload: CreateStoreRequest) =>
api.post<StoreOperationResponse>('/stores', payload, {
headers: { 'Idempotency-Key': crypto.randomUUID() },
}),
/**
* 编辑:If-Match 乐观锁。未传 api_key 返回更新后的店铺;
* 传入新 api_key 返回 202 与验证 operation(响应仍是 { store, operation })。
*/
update: (
storeId: string,
payload: UpdateStoreRequest,
options: UpdateStoreOptions
) =>
api.patch<Store | StoreOperationResponse>(`/stores/${storeId}`, payload, {
headers: {
'If-Match': `"${options.revision}"`,
'Idempotency-Key': crypto.randomUUID(),
},
}),
setDefault: (storeId: string) =>
api.post<Store>(`/stores/${storeId}/set-default`, undefined, {
headers: { 'Idempotency-Key': crypto.randomUUID() },
}),
requestCredentialVerification: (storeId: string) =>
api.post<StoreOperationResponse>(`/stores/${storeId}/credential-verifications`, undefined, {
headers: { 'Idempotency-Key': crypto.randomUUID() },
}),
requestWarehouseSync: (storeId: string) =>
api.post<StoreOperationResponse>(`/stores/${storeId}/warehouse-syncs`, undefined, {
headers: { 'Idempotency-Key': crypto.randomUUID() },
}),
/** 删除当前默认店铺时同平台还有其他店铺会返回 422 DEFAULT_STORE_REPLACEMENT_REQUIRED */
remove: (storeId: string) =>
api.delete<void>(`/stores/${storeId}`, {
headers: { 'Idempotency-Key': crypto.randomUUID() },
}),
};
+9
View File
@@ -0,0 +1,9 @@
import type { Rule } from 'antd/es/form';
/** 邮箱格式校验:仅在非空时校验,避免与 required 规则在空值时同时报错(antd v6 行为) */
export const emailFormatRule: Rule = {
validator: (_, value: string) =>
!value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
? Promise.resolve()
: Promise.reject(new Error('邮箱格式不正确')),
};
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare module '*.css';
+21
View File
@@ -0,0 +1,21 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
// Web 走 Vite 打包,使用 bundler 解析;覆盖 base 中面向 NestJS 的 Node16 模块设置
"module": "ESNext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"isolatedModules": true,
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"noEmit": true,
"allowImportingTsExtensions": false,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "vite.config.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// 前端开发服务器:/api 代理到本地 NestJS API(apps/api,默认 8200)
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: Number(process.env.WEB_PORT ?? 8100),
strictPort: true,
proxy: {
'/api': {
target: process.env.API_ORIGIN ?? 'http://localhost:8200',
changeOrigin: true,
},
},
},
});
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@atlas/worker",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsx watch src/main.ts",
"start": "node dist/main.js",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@atlas/contracts": "workspace:*",
"bullmq": "6.3.4",
"ioredis": "6.0.0"
},
"devDependencies": {
"@types/node": "26.3.0",
"tsx": "4.23.13",
"typescript": "7.0.2"
}
}
+28
View File
@@ -0,0 +1,28 @@
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
// 显式构造客户端实例传给 BullMQ:原生 ESM(pnpm start)下 BullMQ 无法按 connection
// 选项自动加载 ioredis;maxRetriesPerRequest: null 是 BullMQ Worker 的要求
const connection = new IORedis(
process.env.REDIS_URL ?? 'redis://localhost:6379',
{
maxRetriesPerRequest: null,
},
);
const dispatchQueue = new Queue('atlas-dispatch', { connection });
async function bootstrap() {
await dispatchQueue.waitUntilReady();
console.info(
'Atlas Worker is ready; it will consume transactional-outbox events after that module exists.',
);
}
void bootstrap();
async function shutdown() {
await dispatchQueue.close();
}
process.once('SIGINT', () => void shutdown());
process.once('SIGTERM', () => void shutdown());
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
+24 -8
View File
@@ -6,18 +6,34 @@
| 类型 | 位置 | 内容 |
|---|---|---|
| 项目 | [`project/`](./project/) | 产品定位、交接与已确认的长期约束 |
| 项目 | [`project/`](./project/) | 产品定位、交接、协作约定与已确认的长期约束 |
| 产品需求 | [`requirements/`](./requirements/) | 页面、流程、字段和待确认事项 |
| 基础架构 | [`architecture/`](./architecture/) | 多租户、平台、门店、水印、物流和定价的数据设计及 DDL |
| 商品领域设计 | [`product/`](./product/) | 已有的多 SKU 商品编辑页、领域模型与 API 草案 |
| 项目结构 | [`architecture/project-structure.md`](./architecture/project-structure.md) | pnpm monorepo 的目录、依赖关系与本地运行边界。 |
| 外部平台资料 | [`integrations/`](./integrations/) | Ozon Seller API 的整理资料与适配依据 |
| 学习指南 | [`guides/`](./guides/) | 项目所用技术概念与设计原则的入门说明 |
| HTTP API 设计 | [`api/`](./api/) | 可评审的服务端接口契约,按模块逐步补全。 |
| 设计评审 | [`reviews/`](./reviews/) | 实现前待决问题、风险和关闭记录。 |
## 当前阅读顺序
1. [`project/HANDOFF.md`](./project/HANDOFF.md):项目边界和已经确认的商品规则。
2. [`requirements/account-store-watermark.md`](./requirements/account-store-watermark.md):账户、门店和水印需求。
3. [`requirements/pricing-tool.md`](./requirements/pricing-tool.md):独立试算、SKU 定价方案和物流规则需求。
4. [`requirements/cel-ozon-rfbs-rates-review.md`](./requirements/cel-ozon-rfbs-rates-review.md):从 CEL Ozon-rFBS 资费表提取的待审核规则。
5. [`architecture/foundation-data-model.md`](./architecture/foundation-data-model.md):基础模块数据模型和 PostgreSQL DDL。
6. [`architecture/technology-and-deployment.md`](./architecture/technology-and-deployment.md):已确认的 NestJS、PostgreSQL、Redis/BullMQ、MinIO、七牛 Kodo 与部署策略。
7. 需求确认后,才创建对应的接口和开发方案文档。
1. [`project/development-collaboration-contract.md`](./project/development-collaboration-contract.md):**所有人工与 AI 开发者必须遵守的协作、领域所有权、API 与数据约束。**
2. [`project/HANDOFF.md`](./project/HANDOFF.md):项目边界和已经确认的商品规则。
3. [`requirements/collector-browser-extension.md`](./requirements/collector-browser-extension.md):来源商品采集、采集箱、多 SKU、插件认证和 Ozon Seller 网页观察实验。
4. [`requirements/collection-box-and-online-products.md`](./requirements/collection-box-and-online-products.md):采集箱、上架后编辑限制、按 SKU 展示的在线商品和 Ozon 异步同步需求。
5. [`requirements/account-store-watermark.md`](./requirements/account-store-watermark.md):账户、门店和水印需求。
6. [`requirements/pricing-tool.md`](./requirements/pricing-tool.md):独立试算、SKU 定价方案和物流规则需求。
7. [`requirements/cel-ozon-rfbs-rates-review.md`](./requirements/cel-ozon-rfbs-rates-review.md):从 CEL Ozon-rFBS 资费表提取的待审核规则。
8. [`architecture/foundation-data-model.md`](./architecture/foundation-data-model.md):基础模块数据模型和 PostgreSQL DDL。
9. [`architecture/exchange-rates.md`](./architecture/exchange-rates.md):系统汇率、CBR、免费备用接口、更新任务和报价快照规则。
10. [`architecture/product-domain-data-model.md`](./architecture/product-domain-data-model.md):采集箱、商品编辑、发布任务和在线商品镜像的数据库设计与 SQL。
11. [`architecture/technology-and-deployment.md`](./architecture/technology-and-deployment.md):已确认的 NestJS、PostgreSQL、Redis/BullMQ、MinIO、七牛 Kodo 与部署策略。
12. [`architecture/database-security.md`](./architecture/database-security.md):迁移/应用数据库角色、RLS 强制策略与部署职责。
13. [`architecture/transactional-outbox-and-idempotency.md`](./architecture/transactional-outbox-and-idempotency.md):关键写请求去重、事务 Outbox、BullMQ 投递与验收规则。
14. [`guides/object-storage-object-key.md`](./guides/object-storage-object-key.md):对象存储、`object_key`、来源 URL 与预签名 URL 的使用边界。
15. [`guides/minio-local-deployment.md`](./guides/minio-local-deployment.md):MinIO 简介、本地 Docker 部署与应用侧使用方法。
16. [`guides/postgresql-local-development.md`](./guides/postgresql-local-development.md):原生 PostgreSQL / Docker Compose 本地数据库、角色、迁移与建表。
17. [`api/README.md`](./api/README.md):HTTP API 统一约定及第一批账户、门店、水印接口。
18. [`reviews/implementation-readiness-open-items.md`](./reviews/implementation-readiness-open-items.md):实现前必须闭环的数据库/API 事项与已确认约束。
19. 后续按模块补全定价、采集、商品编辑、发布和在线商品接口。
+41
View File
@@ -0,0 +1,41 @@
# Atlas HTTP API 设计
> 状态:第一批契约已冻结。认证、平台目录/上下文、门店基础管理和操作查询已实现;其余模块仍是设计契约。接口统一以前缀 `/api` 提供,JSON 字段使用 `snake_case`,时间使用 ISO 8601 UTC 字符串,ID 使用 UUID。
API 启动后会在 `/api-docs` 提供 Swagger UI,并在其 OpenAPI 文档中标记已实现的接口。Swagger 是运行时参考,不替代本目录的冻结契约。
运行状态端点不使用 `/api` 前缀:`GET /health` 只表明进程存活,`GET /ready` 会校验应用数据库连接,失败时返回 `503`。生产环境由环境变量控制 API 端口、CORS 来源、日志级别、请求体上限、认证限流及可信反向代理列表;缺少或格式错误的必需安全配置会阻止 API 启动。
## 1. 文档路线
| 文档 | 状态 | 范围 |
|---|---|---|
| [`conventions.md`](./conventions.md) | 部分已实现 | 鉴权、全局上下文、响应、分页、错误、并发与异步任务。 |
| [`auth.md`](./auth.md) | 已实现 | 邮箱注册、登录、刷新会话、退出、当前用户。 |
| [`stores-and-platforms.md`](./stores-and-platforms.md) | 部分已实现 | 平台目录、页头上下文、店铺授权、门店 CRUD、默认店铺、操作查询与仓库读取;平台实际验证与同步 Worker 待实现。 |
| [`watermarks-and-assets.md`](./watermarks-and-assets.md) | 已设计 | 素材直传、短时访问、水印模板、字体、店铺推荐水印。 |
| [`pricing.md`](./pricing.md) | 已设计 | 汇率、物流价卡、即时试算、定价方案、报价快照衔接。 |
| [`collection-and-extension.md`](./collection-and-extension.md) | 已设计 | 插件授权、采集写入、采集箱查询、素材转存与状态。 |
| [`product-editor-and-publishing.md`](./product-editor-and-publishing.md) | 已设计 | 商品编辑、SKU、类目、属性、AI、媒体选择、定价确认与部分 SKU 首次上架。 |
| [`online-listings.md`](./online-listings.md) | 初版已设计 | 店铺独有的在线商品镜像、读取与异步同步;编辑/认领仍待讨论。 |
旧的 [`../product/api.md`](../product/api.md) 是商品域的早期草案:已确认的产品规则可作为背景,但不能当作最终 HTTP 契约。后续商品模块会迁入本目录并替换其中的路径和细节。
## 2. 首期的身份与上下文边界
- 登录身份决定用户和当前工作空间;客户端**不能**传 `workspace_id` 作为数据归属或隔离条件。
- 首期注册原子创建一个个人工作空间与 `owner` 成员关系。API 响应仍会返回 `workspace`,为未来协作预留。
- 平台是页头全局工作上下文,而非租户边界。平台范围接口使用 `X-Atlas-Platform-Id`,或在文档明确标注时使用 `platform_id` 查询参数;服务端验证该平台存在且已启用。
- “默认店铺”由服务端按 `workspace + platform` 读取。不能相信客户端宣称某个店铺是默认店铺;需要指定店铺的写操作使用路径中的 `store_id` 并验证它属于当前工作空间和平台。
- 采集箱不带平台/店铺上下文;在线商品永远是平台 + 门店独有资源。这两个模块将在后续文档中分别约束。
## 3. 未纳入第一批的事项
- 手机号、短信登录、邮箱验证、忘记密码、第三方登录、MFA 与设备管理;
- 团队成员、工作空间切换和角色管理 UI;
- Cookie 授权/保存;
- 平台、货币、字体、物流商、类目、佣金和汇率的后台维护接口。
这些事项不会由首期客户端私自调用管理接口;运营数据由受控 seed、migration 或后台任务维护。
相关需求:[账户、门店与水印](../requirements/account-store-watermark.md)、[基础数据模型](../architecture/foundation-data-model.md)、[数据库安全](../architecture/database-security.md)。
+145
View File
@@ -0,0 +1,145 @@
# 账户与会话 API
> 首期只支持邮箱 + 密码。邮箱验证、密码找回、手机号、短信登录、第三方登录和 MFA 均不在本契约中。
## 1. 数据形状
```json
{
"user": {
"id": "uuid",
"email": "owner@example.com",
"display_name": null,
"avatar_asset_id": null,
"email_verified_at": null,
"created_at": "2026-09-02T08:00:00Z",
"revision": 1
},
"workspace": {
"id": "uuid",
"name": "owner 的工作空间",
"role": "owner"
}
}
```
`email` 返回规范化后的地址;`avatar_asset_id` 是当前头像的素材引用,普通账户响应不返回对象存储 URL。密码散列、会话哈希、手机号及任何风控信息永不返回。
## 2. 注册
`POST /api/auth/register`
```json
{
"email": "owner@example.com",
"password": "a-strong-password",
"password_confirmation": "a-strong-password",
"display_name": "Joey"
}
```
规则:
- `email` 去除首尾空格后以不区分大小写的形式判重;必须满足基础邮箱格式。
- `password` 和确认值一致;最小长度与复杂度仍待认证安全规则确认,服务端将作为唯一校验方,前端只能展示同一规则而不能自行放宽。
- `display_name` 可选,最大 80 个字符。
- 成功时在同一事务创建 `user`、个人 `workspace` 和 `owner` membership,并创建登录会话。
成功返回 `201 Created`、`Set-Cookie` refresh token 和:
```json
{
"data": {
"access_token": "eyJ…",
"access_token_expires_in": 900,
"user": { "id": "uuid", "email": "owner@example.com", "display_name": "Joey", "avatar_asset_id": null, "email_verified_at": null, "created_at": "2026-09-02T08:00:00Z", "revision": 1 },
"workspace": { "id": "uuid", "name": "Joey 的工作空间", "role": "owner" }
}
}
```
重复邮箱返回 `409 EMAIL_ALREADY_REGISTERED`。首期不通过“邮箱不存在/密码错误”以外的差异化响应泄露登录状态。
## 3. 登录
`POST /api/auth/login`
```json
{ "email": "owner@example.com", "password": "a-strong-password" }
```
成功响应与注册相同,刷新 token 写入新的 `HttpOnly` cookie。失败统一返回 `401 INVALID_CREDENTIALS`;禁用账户返回 `403 ACCOUNT_DISABLED`。接口应按 IP 与邮箱摘要双维度限流,但不保存明文密码或将其记入日志。
成功返回 `200 OK`、`Set-Cookie` refresh token 及上述响应体。
## 4. 刷新与退出
`POST /api/auth/refresh`
请求不需要 body,浏览器自动携带 refresh cookie。服务端轮换 refresh token、撤销旧 session 并以 `200 OK` 返回新的短期 access token:
```json
{ "data": { "access_token": "eyJ…", "access_token_expires_in": 900 } }
```
无效、过期或已撤销 refresh token 返回 `401 SESSION_EXPIRED`,并清除 refresh cookie。
`POST /api/auth/logout`
要求 Bearer access token;撤销当前 refresh session、清除 refresh cookie,返回 `204 No Content`。首期不提供“退出所有设备”。
## 5. 修改账户资料
`PATCH /api/auth/me`
Headers:`If-Match: "{revision}"` 与 `Idempotency-Key: {uuid}` 必填。
```json
{ "display_name": "Joey", "avatar_asset_id": "uuid" }
```
两个字段至少提交一个;传 `null` 可清除对应资料。成功返回 `200` 与更新后的 `user` 资源,`revision` 加一。`avatar_asset_id` 必须是当前工作空间中已完成素材校验的图片资产;接口不接受 URL、对象存储 key 或 base64 内容。版本过期返回 `409 VERSION_CONFLICT`,相同幂等键遵循统一重放规则。
头像上传复用 `POST /api/media/upload-intents` 的预签名直传和完成校验流程,并使用 `purpose: "user_avatar"`;在素材模块实现前,头像只能保持 `null`,不能以 base64 上传作为替代通道。
## 6. 修改密码
`POST /api/auth/change-password`
```json
{
"old_password": "old-strong-password",
"password": "new-strong-password",
"password_confirmation": "new-strong-password"
}
```
此接口要求 Bearer access token,并按来源 IP 和当前用户双维度限流。服务端在一个事务内锁定当前会话和用户、校验旧密码、更新密码散列、撤销该用户全部旧 session,并创建新的当前 session。成功返回 `200`、新的 `HttpOnly` refresh cookie 和:
```json
{ "data": { "access_token": "eyJ…", "access_token_expires_in": 900 } }
```
旧 access token 和其他设备的 session 会立即失效。此接口不使用 `Idempotency-Key`:密码变更及 session 轮换不能安全地将 access token 持久化为幂等回放响应;客户端收到网络中断后应以新旧密码重新登录或刷新会话,不得盲目自动重试。
## 7. 当前用户与初始页头数据
`GET /api/auth/me`
返回已登录用户、首期唯一工作空间、可用平台及当前页头建议值:
```json
{
"data": {
"user": { "id": "uuid", "email": "owner@example.com", "display_name": "Joey", "avatar_asset_id": null, "email_verified_at": null, "revision": 1 },
"workspace": { "id": "uuid", "name": "Joey 的工作空间", "role": "owner" },
"platforms": [{ "id": "uuid", "code": "OZON", "name": "Ozon" }],
"suggested_context": {
"platform_id": "uuid",
"default_store": null
}
}
}
```
首期建议按平台配置顺序优先 Ozon;若该平台有默认店铺,`default_store` 返回店铺摘要,否则为 `null`。这只是页头初始建议,不在服务端建立“用户全局已选平台”状态;客户端切换平台后,在后续平台范围请求传 `X-Atlas-Platform-Id`。
+148
View File
@@ -0,0 +1,148 @@
# 采集箱与浏览器插件 API
> 采集箱只属于工作空间:采集时没有目标平台、目标门店或店铺凭据。列表上架状态则按 ERP 页头当前平台汇总;它不是 Ozon 在线商品可见性,也不因切换到另一平台而继承。
## 1. 浏览器扩展授权
扩展不读取 ERP 网页 localStorage,也不用 Web 端 refresh cookie。每个发布扩展在服务端登记 `client_id` 和允许的 `chrome-extension://{extension-id}/…` 回调地址,扩展使用 PKCE 授权码流程。
1. 扩展打开:`GET /api/extension/authorize?client_id={id}&redirect_uri={uri}&state={random}&code_challenge={S256}&code_challenge_method=S256`。
2. 用户在 Atlas Web 完成登录;首期不额外展示工作空间选择/授权确认页。
3. 服务端只向已登记的 `redirect_uri` 回跳 `?code={one-time}&state={original}`。授权码 60 秒有效、只能交换一次,且绑定 client、回调地址和 PKCE challenge。
4. 扩展调用 `POST /api/extension/auth/exchange`:
```json
{
"client_id": "atlas-collector",
"code": "one-time-code",
"redirect_uri": "chrome-extension://extension-id/callback",
"code_verifier": "original-pkce-verifier"
}
```
成功返回扩展专用短期 access token 与可轮换 refresh token;令牌只存 Chrome 扩展专属存储:
```json
{ "data": {
"access_token": "eyJ…", "expires_in": 900, "refresh_token": "opaque", "refresh_token_expires_in": 2592000,
"token_type": "Bearer"
} }
```
`POST /api/extension/auth/refresh` 接收 `{ "refresh_token": "opaque" }`,轮换并返回同形状令牌;`POST /api/extension/auth/revoke` 撤销当前扩展 refresh token,返回 `204`。令牌续期、撤销和错误响应不得泄露 Web 会话或其他工作空间信息。
`GET /api/extension/session`
使用扩展 access token,返回最小化状态:
```json
{ "data": { "authenticated": true, "workspace": { "id": "uuid", "available": true }, "can_collect": true } }
```
不返回平台、默认店铺、Cookie、Ozon 凭据或 Web refresh token。令牌失效返回 `401`,插件只展示“登录 Atlas ERP”。
## 2. 插件上报采集结果
`POST /api/extension/materials`
Headers:扩展 Bearer token 与 `Idempotency-Key` 必填。请求体为已定义的 `CollectedProductPayload`,其 `source` 至少包含 `platform`、`itemKey`、`canonicalUrl`、`collectedAt`、`parserVersion`;`variants` 仅列来源有稳定证据的真实组合,媒体只传来源 URL/关系和证据,不传图片二进制。
请求体不得包含或传入 `workspace_id`、`target_platform_id`、`platform_id`、`store_id`、`api_key`、Cookie、授权头或来源站会话字段。服务端从扩展令牌解析工作空间,并将来源 `platform` 映射为系统已启用的来源平台。
服务端在一个事务中:检查工作空间采集箱上限 → 按 `workspace + source_platform + source_item_key` 查找活动记录 → 创建或复用 `collection_item`、`product`、来源确认的 `product_variants` 和持久化 `collection_asset_transfers` → 再经 outbox 投递素材 Worker。重复上报不会增加采集箱配额,也不覆盖用户已编辑草稿。
成功返回 `201`(新建)或 `200`(复用):
```json
{ "data": {
"collection_item": { "id": "uuid", "status": "materializing", "source_title": "商品标题", "variant_count": 3 },
"product_id": "uuid", "reused": false,
"editor_entry_path": "/collection-items/uuid/edit",
"materialization": { "total": 12, "queued": 12, "succeeded": 0, "failed": 0 }
} }
```
达到工作空间上限时返回 `422 COLLECTION_LIMIT_REACHED`,并附 `{current_count, limit}`;不能因重复点击或并发请求绕过 1000 条限制。单个来源素材失败不使采集事务失败,关键商品/SKU 解析失败才使记录进入 `failed` 并返回可展示的脱敏摘要。
## 3. Ozon Seller 网页本地观察
`POST /api/collection-items/{collection_item_id}/seller-enrichment-observations`
仅接受扩展 token。用户在浏览器已登录 `seller.ozon.ru` 标签页内,同源调用已白名单的四个网页内部接口后,将**净化结果**或不可用原因上报:
```json
{
"interface_code": "whitelisted-interface-code",
"source_variant_key": "optional-source-variant-id",
"status": "observed",
"observed_at": "2026-09-02T08:00:00Z",
"parser_version": "collector-1.0.0",
"sanitized_payload": { "only": "whitelisted business fields" }
}
```
不可用时使用 `status: "unavailable"` 并传 `unavailable_reason`;请求、响应在客户端和服务端均须过滤 Cookie、`Authorization`、店铺/个人无关信息、会话 ID 及完整 `company_id`。标准公开页面采集已成功时,本接口失败或未调用不得改变采集箱主状态、更不能阻断编辑。
服务端解析 `source_variant_key` 到当前采集商品的 SKU 后写入 `collection_seller_enrichment_observations`。接口代码不在白名单、记录不属于该工作空间或 payload 含禁用会话字段时返回 `422`;不把原始敏感 payload 写日志。
## 4. 采集箱列表与详情
`GET /api/collection-items?cursor={opaque}&limit=20&q={keyword}&source_platform_code={code}&listing_status={not_listed|partially_listed|listed}`
要求 `X-Atlas-Platform-Id`,按 `updated_at DESC, id DESC` 返回当前工作空间未删除记录。`q` 模糊匹配标题;`source_platform_code` 是**来源**筛选,不等于页头目标平台;`listing_status` 是按页头目标平台汇总的状态。
```json
{ "data": [{
"id": "uuid", "product_id": "uuid", "source": { "kind": "platform", "platform_code": "1688", "url": "https://…" },
"title": "商品标题", "thumbnail": { "asset_id": "uuid", "preview_url": "short-lived-or-null", "status": "succeeded" },
"source_price": { "min": "20.00", "max": "35.00", "currency_code": "CNY" },
"variant_summary": { "count": 3, "needs_review": false }, "note": "待确认材质",
"collection_status": "ready_for_edit",
"listing_summary": { "status": "partially_listed", "detail": "1 个 SKU 已导入;2 个等待首次上架", "last_error": null },
"created_at": "2026-09-02T08:00:00Z", "updated_at": "2026-09-02T08:05:00Z", "revision": 4
}], "meta": { "next_cursor": null, "has_more": false, "active_count": 12, "collection_limit": 1000 } }
```
`listing_summary.status` 映射 UI 的未上架/已部分上架/已上架;`collection_status` 是采集/素材处理状态,二者绝不混用。首张素材转存未完成时 thumbnail 为占位状态;商品未命名时 `title` 返回 `null`,前端展示“未命名商品”。
`GET /api/collection-items/{collection_item_id}`
无平台 header 时仅返回采集基础信息、SKU/素材转存摘要、净化 Seller 观察结果摘要与警告;带 header 时附加 `listing_summary`。观察结果详情只对当前工作空间成员可见。
`PATCH /api/collection-items/{collection_item_id}`
Headers:`If-Match`、`Idempotency-Key` 必填。首期仅允许修改 `note`;标题、SKU 和素材在商品编辑 API 中修改。响应返回更新后的采集记录。`If-Match` 对应记录的 `revision`,包括采集 Worker 状态更新在内的每次修改都递增;不得覆盖 Worker 的状态更新。
## 5. 手动创建、删除与素材重试
`POST /api/collection-items`
Headers:`Idempotency-Key` 必填。
```json
{ "title": "未命名手工商品", "note": "可选备注" }
```
创建 `source_kind=manual` 的采集记录、空商品草稿和默认 SKU,返回 `201`、`collection_item`、`product_id` 与 `editor_entry_path`。它仍不绑定目标平台/店铺,也不绕过之后的类目、定价和首次发布校验。
`POST /api/collection-items/{collection_item_id}/asset-retries`
Headers:`Idempotency-Key` 必填。请求 `{ "transfer_ids": ["uuid"] }`;只能重试该采集记录中 `failed` 或 `cancelled` 的转存任务。返回 `202` 和 operation。不能用它重试来源商品解析或已成功素材。
`POST /api/collection-items/bulk-delete`
Headers:`Idempotency-Key` 必填。请求 `{ "ids": ["uuid"] }`,原子软删除其中当前工作空间的全部有效记录,返回 `202`、`deleted_count` 和延迟素材清理 operation。已上架记录同样可删除,但响应/前端确认文案必须说明:不删除、不下架任何 Ozon 商品,也不移除在线商品镜像。
`POST /api/collection-items/clear`
Headers:`Idempotency-Key` 必填。先由列表读取 `active_count` 并展示二次确认;确认请求:
```json
{ "confirm": true, "expected_active_count": 12 }
```
服务端锁定工作空间后比较当前有效数量;数量变化返回 `409 COLLECTION_COUNT_CHANGED`,要求用户重新确认。匹配时软删除全部有效记录并返回 `202`、删除数与延迟清理 operation。它不影响平台商品、发布快照或在线商品。
## 6. 与商品编辑接口的边界
`editor_entry_path` 只是 ERP 前端入口。下一批商品编辑接口会在用户进入该入口时根据页头目标平台取得/创建 `product_platform_profile`、预选默认店铺、允许切换同平台店铺,并定义 SKU 编辑、AI、定价确认、图片选择与仅未导入 SKU 的首次上架。
+127
View File
@@ -0,0 +1,127 @@
# API 统一约定
## 1. 鉴权与工作空间隔离
除 `POST /api/auth/register`、`POST /api/auth/login`、`POST /api/auth/refresh` 外,所有接口都要求:
```http
Authorization: Bearer <short-lived-access-token>
```
访问令牌只携带用户会话标识,不以客户端传入的 `workspace_id` 判定数据归属。全局认证守卫先验证 session,全局工作空间守卫再解析首期唯一的有效工作空间并验证成员关系;受保护处理器从 `request.auth.workspace` 或 `@CurrentWorkspace()` 取得已验证的 `workspaceId` 与角色。业务 SQL 必须将该 `workspaceId` 传给短生命周期的 `workspaceTransaction`,由其在每一个数据库事务中执行 `SET LOCAL app.workspace_id`。RLS 仅是第二道防线,业务层仍须验证资源归属。
`/health`、`/ready`、注册、登录与 refresh 是显式标记的公开端点。新接口默认受上述两个全局守卫保护;不得因便利而自行跳过,除非先在契约中说明公开理由。
Web 登录后的 refresh token 放在 `HttpOnly`、`Secure`(生产)、`SameSite=Lax` 的 cookie 中,永不出现在 JSON、日志或前端持久化存储。access token 由前端仅在内存中短暂持有;插件使用独立授权码流程,留待采集插件接口文档定义。
## 2. 页头平台与默认店铺
平台范围接口使用:
```http
X-Atlas-Platform-Id: 57ad…-uuid
```
- 无此 header 的接口不依赖平台,例如 `GET /api/auth/me`、水印和通用素材接口。
- 需要平台却没有 header 时返回 `400 PLATFORM_CONTEXT_REQUIRED`。
- header 指向停用或不存在的平台时返回 `422 PLATFORM_UNAVAILABLE`。
- 默认店铺是服务端按工作空间和该 header 查出的状态,响应中以 `default_store` 明确返回;客户端不应缓存为事实。
列表筛选中的 `platform_id` 只用于平台目录或跨平台管理页,不能绕开数据授权;同一请求不得同时带 `X-Atlas-Platform-Id` 和语义相同的 `platform_id`。
## 3. 响应、分页和字段格式
### 3.1 强制响应信封
本项目不采用完整 JSON:API 规范,但所有业务 HTTP 接口必须使用以下响应信封;这是
前端、服务端、Worker 和扩展共同遵守的强制契约:
| 场景 | HTTP 响应体 |
|---|---|
| 成功的单资源或同步动作 | `{ "data": T }` |
| 成功的列表 | `{ "data": T[], "meta": PaginationMeta }` |
| 失败 | `{ "error": ApiError }` |
| `204 No Content` | 无响应体 |
`data` 只能位于顶层且只能出现一次;不得返回 `{ "data": { "data": ... } }`,也不得以
`{ "data": null }` 表示失败。HTTP 状态码仍表达请求结果,错误必须使用本文件第 4 节的
`error` 信封,不能混入成功响应。`meta` 只承载分页、游标或与整个响应相关的元信息,不能
替代资源字段。
### 3.2 成功与列表响应
成功响应统一为:
```json
{ "data": { "id": "uuid" } }
```
列表响应:
```json
{
"data": [{ "id": "uuid" }],
"meta": { "next_cursor": "opaque-string-or-null", "has_more": false }
}
```
- 游标是服务端不透明值,客户端只能原样传回 `cursor`;默认按文档指定稳定排序。
- `limit` 默认 `20`,最大 `100`;超出时返回 `422 VALIDATION_FAILED`。
- 金额与费率以 JSON **字符串**返回和提交,例如 `"199.90"`、`"0.125"`,避免 JavaScript 浮点误差;币种用 ISO 4217 大写代码。
- 所有时间用 UTC,例如 `"2026-09-02T08:00:00Z"`;日期用 `YYYY-MM-DD`。
- 不返回 `password_hash`、API Key、Cookie、密文、对象存储永久密钥或长期访问 URL。
## 4. 错误格式和 HTTP 状态
```json
{
"error": {
"code": "VALIDATION_FAILED",
"message": "请求参数不符合要求",
"request_id": "req_…",
"details": [
{ "field": "email", "code": "EMAIL_ALREADY_REGISTERED", "message": "该邮箱已注册" }
]
}
}
```
| 状态 | 使用场景 |
|---:|---|
| `400` | 缺少平台上下文、请求语义矛盾、无法解析的 JSON。 |
| `401` | 缺少、失效或撤销的 access token。 |
| `403` | 已登录但无工作空间成员资格或无操作权限。 |
| `404` | 当前工作空间范围内不存在该资源;不暴露其他工作空间资源。 |
| `409` | 重复唯一资源、幂等键与既有请求不一致、乐观锁冲突。 |
| `422` | 字段格式、业务状态或平台能力不满足。 |
| `429` | 登录、刷新、上传签发或重试操作触发限流。 |
| `500` | 未预期服务错误;不返回秘密或平台原始敏感响应。 |
## 5. 写操作的幂等、并发和异步
- 创建店铺、上传意图、删除水印模板等可重试写操作应携带 `Idempotency-Key: <UUID>`。服务端按“当前用户 + 工作空间 + HTTP 方法 + 规范化实际路径 + key”保存至少 24 小时(首期默认 48 小时);请求摘要保存为服务端 HMAC,而非明文请求体。相同键、相同请求返回已保存响应;相同键、不同请求返回 `409 IDEMPOTENCY_KEY_REUSED`;原请求仍在执行返回 `409 IDEMPOTENCY_REQUEST_IN_PROGRESS`。
- `PATCH /api/auth/me` 同样要求幂等键。`POST /api/auth/change-password` 是例外:其成功响应包含新 access token,不能将该 token 持久化到幂等回放表;客户端不得自动重试此操作。
- 更新可编辑资源时,读取响应携带 `revision`;客户端在 `PATCH`/`PUT` 中提交 `If-Match: "<revision>"`。版本已变化返回 `409 VERSION_CONFLICT` 和最新 `current_revision` 摘要。`revision` 是并发控制版本;不要把水印的渲染 `current_version` 当作它。
- 耗时动作(店铺凭据验证、仓库同步、媒体处理)先返回 `202 Accepted`,并在资源中返回 `operation`。客户端轮询 `GET /api/operations/{operation_id}`;后续可追加 SSE,不改变资源接口。
- 所有启动异步工作的业务写入,必须在同一 PostgreSQL 事务中写入业务资源/`operation` 和 `outbox_events`;独立 Dispatcher 再投递 BullMQ。BullMQ 和 Redis 不得作为任务存在或最终业务结果的唯一事实。
`operation` 统一形状:
```json
{
"id": "uuid",
"kind": "store_credential_verification",
"status": "queued",
"created_at": "2026-09-02T08:00:00Z",
"completed_at": null,
"error": null
}
```
操作接口只返回当前工作空间可见的任务,且任务状态不能作为唯一事实;业务资源中的状态字段才是页面最终展示依据。
`GET /api/operations/{operation_id}` 返回上述 operation 形状。任务不属于当前工作空间时返回 `404`;`error` / `error_message` 只能是可展示的脱敏摘要,不能包含 API Key、Cookie、对象存储签名或平台原始敏感响应。
## 6. 删除与软删除
首期店铺和水印模板使用软删除。成功删除返回 `204 No Content`;之后以普通 `GET` 读取返回 `404`,列表默认不包含删除记录。历史发布、素材派生和审计记录保留其快照,不因软删除被改写。
+104
View File
@@ -0,0 +1,104 @@
# 在线商品镜像与同步 API
> 在线商品是平台 + 门店独有的外部事实镜像,一行对应一个 Ozon `product_id` / `offer_id` SKU。它包含 Atlas 发布商品和外部商品;不能跨店铺、跨平台复用,也不能当作采集箱 SPU。首期接口只读镜像和触发同步,不支持在此编辑、删除、下架、改价或设置库存。
## 1. 当前门店与镜像边界
所有接口要求 `X-Atlas-Platform-Id`。`store_id` 未传时服务端取当前工作空间该平台默认店铺;没有默认店铺返回 `422 DEFAULT_STORE_REQUIRED`。显式传入的 `store_id` 必须属于当前工作空间和 header 平台。
- 采集箱的“已上架”表示 Atlas 首次导入 SKU 的成功事实;它不由本模块的 `visible`、库存或审核状态推导。
- `platform_listings.visible` 是 Ozon 同步返回的实际可见性;`null` 表示尚未获取,不能当作 `false`。
- Atlas 发布 SKU 以 `store_id + offer_id` 和回填的 Ozon `product_id` 映射。外部同步商品 `source=external_sync`,没有 Atlas 草稿关联也必须显示。
- 同步失败始终保留上一轮成功镜像;不把失败、超时或尚未见到的条目直接解释为已下架。
## 2. 在线商品列表与详情
`GET /api/platform-listings?store_id={uuid}&cursor={opaque}&limit=20&q={keyword}&visibility={all|visible|invisible|unknown}&source={atlas_published|external_sync}&moderation_status={value}&include_archived=false`
默认按 `updated_at DESC, id DESC` 返回当前店铺镜像。`q` 匹配 `name`、`offer_id`、`remote_product_id`、`remote_sku`;`visibility=unknown` 只匹配 `visible=null`;`include_archived=false` 排除同步后标记已归档的历史镜像。
```json
{ "data": [{
"id": "uuid", "store_id": "uuid", "platform_id": "uuid",
"remote_product_id": "123456", "offer_id": "atlas-abc", "remote_sku": "987654",
"name": "商品标题", "currency_code": "RUB", "selling_price": "999.00", "list_price": "1110.00",
"visible": true, "remote_state": "processed", "moderation_status": "approved", "validation_status": "success",
"stock_summary": { "total": 0, "has_data": true },
"image": { "url": "https://platform-image-or-null", "width_px": null, "height_px": null },
"errors": [], "visibility_details": {},
"source": "atlas_published", "atlas_origin": { "collection_item_id": "uuid", "product_id": "uuid", "variant_id": "uuid" },
"last_synced_at": "2026-09-02T08:00:00Z", "missing_since": null, "archived_at": null
}], "meta": {
"next_cursor": null, "has_more": false,
"store": { "id": "uuid", "name": "Ozon 主店" },
"last_successful_sync_at": "2026-09-02T08:00:00Z", "last_sync_status": "succeeded"
} }
```
`image.url` 是 Ozon 返回的图片引用或 `null`,不是 Atlas `media_assets` 的永久地址;没有图片不意味着 Atlas 素材丢失。`errors` 仅返回页面可展示的结构化摘要,隐藏 Ozon 原始响应中可能存在的无关敏感字段。
`GET /api/platform-listings/{listing_id}`
返回单个 SKU 镜像的完整业务摘要、可见性详情、库存摘要、审核/失败原因、Atlas 来源链接和最近同步信息。它不返回 `raw_payload`;需要平台原始响应排障时仅由受控运营工具读取。
## 3. 手动触发同步
`POST /api/stores/{store_id}/platform-product-sync-runs`
Headers:`Idempotency-Key` 必填。Body 可省略,或为:
```json
{ "reason": "manual_refresh" }
```
服务端验证店铺连接处于可同步状态,然后创建一次**全量**同步 run 并投递 Worker:
```text
/v3/product/list(分页,最多每页 1000)
→ /v3/product/info/list(每批最多 100)
→ 按 store + remote_product_id / offer_id upsert 镜像
→ 完整成功后将本轮未见记录标记 missing_since / archived,不物理删除
```
返回 `202 Accepted`:
```json
{ "data": { "sync_run": {
"id": "uuid", "store_id": "uuid", "status": "queued", "trigger_source": "manual",
"listed_count": 0, "upserted_count": 0, "failed_count": 0, "created_at": "2026-09-02T08:00:00Z"
} } }
```
同一工作空间同一店铺只允许一个 `queued` / `running` 全量同步;正在同步时返回 `409 SYNC_ALREADY_RUNNING` 和现有 `sync_run_id`。无有效 Seller API 凭据时返回 `422 STORE_CREDENTIAL_UNAVAILABLE`,不尝试使用浏览器 Cookie。
## 4. 同步状态与历史
`GET /api/platform-product-sync-runs/{sync_run_id}`
返回当前/历史同步 run:
```json
{ "data": {
"id": "uuid", "store_id": "uuid", "status": "running", "trigger_source": "manual",
"cursor_started": null, "cursor_finished": null,
"listed_count": 1000, "upserted_count": 100, "failed_count": 0,
"started_at": "2026-09-02T08:00:10Z", "completed_at": null,
"error": null
} }
```
`GET /api/stores/{store_id}/platform-product-sync-runs?cursor={opaque}&limit=20`
按创建时间倒序返回该店铺同步历史。失败 run 的 `error` 是可展示摘要;前端应保留并标注上一轮成功同步时间,而不是清空在线列表。
Worker 只在完成所有分页和详情批次后把 run 标记 `succeeded`。部分请求失败时 run 为 `failed`,已成功 upsert 的记录仍保留,但不执行“未见记录归档”步骤,避免将网络错误放大为商品消失。
## 5. 明确不在首期接口中的操作
- 从在线商品编辑 Ozon 商品、重新发布或更新已导入 SKU;
- 设置库存、仓库、可见性或下架;
- 认领/导入外部同步商品到采集箱;
- 自动同步的频率、手动同步冷却时间和多店并发策略;
- 运营人员查看完整 Ozon 原始 payload。
这些操作均需要单独定义 Ozon 可修改字段、审核/重试边界和审计规则,不能借用采集箱“首次上架”接口。
+188
View File
@@ -0,0 +1,188 @@
# 定价工具 API
> 所有接口要求 `X-Atlas-Platform-Id`。首期仅支持 Ozon、市场 `RU`、`rfbs` 和已审核启用的 CEL 规则。报价引擎只读取 Atlas 已持久化的汇率、佣金和运价版本;请求期间绝不直接请求 CBR、CEL 或 Ozon。
## 1. 统一口径
- API 使用规范单位:采购成本、国内费用为 `CNY` 字符串;重量为 `kg` 字符串;尺寸为 `cm` 字符串。前端的 g/mm 输入必须先转换。
- 所有比例以 `0`–`1` 字符串表示,如 `"0.20"` 是 20%。`desired_markup_rate` 是目标净利润 ÷ 采购成本;`list_price_discount_rate` 是买家相对划线价的折扣率。
- 销售价和划线价均为 `RUB`;`merchandise_value_cny` 是销售价按本次汇率折算得到的 CNY,专门用于选择 CEL 产品类型。
- 引擎枚举“佣金档 × CEL 产品类型 × 运输方式”,再回验佣金价格区间、CEL 货值区间、重量、尺寸及计费重。没有稳定有效组合时不能返回建议售价。
- 系统汇率、方案固定汇率、一次性覆盖及临时佣金率都固化到后续 SKU 报价快照,不会改写全局规则或定价方案。
## 2. 只读上下文
### 2.1 当前上下文
`GET /api/pricing/context`
返回当前平台、默认市场、当前工作空间该平台的默认店铺及默认定价方案。没有默认店铺返回 `422 DEFAULT_STORE_REQUIRED`,不猜测目标店铺。
```json
{ "data": {
"platform": { "id": "uuid", "code": "OZON", "name": "Ozon" },
"market_code": "RU",
"store": { "id": "uuid", "name": "Ozon 主店", "currency_code": "RUB" },
"default_pricing_policy": { "id": "uuid", "name": "CEL 常规" }
} }
```
### 2.2 商品类目、物流目录与系统汇率
`GET /api/platform-product-categories?parent_id={uuid|null}&q={keyword}&active_only=true`
返回 header 平台的商品类目。`parent_id`(懒加载树)与 `q`(搜索)不能同时使用。每项有 `id`、`name`、`path`、`has_children`、`pricing_rule_status`:
- `available`:当前市场、履约模式下存在唯一有效映射和正式佣金规则;
- `missing`:缺少有效映射或规则;
- `ambiguous`:多个同优先级映射,普通用户不能猜测。
不暴露佣金类目 ID 让客户端自行映射。
`GET /api/pricing/catalog?fulfillment=rfbs&store_id={uuid}`
`store_id` 省略时取默认店铺;传入时必须属于当前工作空间与 header 平台。仅返回当前已启用价卡、服务商和运输方式:
```json
{ "data": { "providers": [{
"id": "uuid", "code": "CEL", "name": "CEL",
"rate_card": { "id": "uuid", "version": "CEL-OZON-RFBS-V6.17", "effective_from": "2026-09-01T00:00:00Z" },
"transport_methods": [
{ "id": "uuid", "code": "air", "name": "陆空特快", "speed_label": "5–10 天" },
{ "id": "uuid", "code": "land_air", "name": "陆空标准", "speed_label": "10–15 天" },
{ "id": "uuid", "code": "land", "name": "陆运经济", "speed_label": "15–25 天" }
]
}] } }
```
若规则仍为 `draft` 或未通过真实账单复核,catalog 不返回它;试算请求返回 `422 PRICING_RULES_UNAVAILABLE`。
`GET /api/pricing/exchange-rate?base_currency_code=CNY&quote_currency_code=RUB`
读取最新 CBR `official_reference`:
```json
{ "data": {
"id": "uuid", "base_currency_code": "CNY", "quote_currency_code": "RUB", "rate": "12.33430000",
"source": "cbr", "kind": "official_reference", "effective_on": "2026-09-02",
"fetched_at": "2026-09-02T02:00:00Z", "is_stale": false
} }
```
没有系统汇率返回 `404 SYSTEM_EXCHANGE_RATE_NOT_FOUND`;超过 72 小时仍返回但 `is_stale=true`,前端必须明确提示。
## 3. 即时试算
`POST /api/pricing/quote-previews`
Headers:`Idempotency-Key` 必填。接口不创建 `pricing_quotes`;`preview_id` 最多可用于后续 SKU 确认 15 分钟,确认时仍会重新计算。
```json
{
"store_id": "uuid",
"fulfillment": "rfbs",
"platform_product_category_id": "uuid",
"purchase_cost_cny": "80.00",
"package": { "weight_kg": "0.990", "length_cm": "30", "width_cm": "31", "height_cm": "11" },
"pricing": {
"pricing_policy_id": "uuid",
"transport_method_ids": ["uuid", "uuid", "uuid"],
"domestic_shipping_and_label_fee_cny": "5.00",
"ad_rate": "0.03", "last_mile_rate": "0.02", "withdrawal_rate": "0.01", "other_rate": "0.02",
"desired_markup_rate": "0.20", "list_price_discount_rate": "0.10",
"fx": { "mode": "system" },
"temporary_commission_rate": null
}
}
```
规则:
- `store_id` 可省略,服务端取默认店铺;非空时必须与 header 平台一致。
- `pricing_policy_id` 非空时方案提供默认值;请求中的费用、目标、运输方式和汇率仅覆盖本次试算,不修改方案。
- 无方案时所有费用、费率、目标、汇率模式和至少一个运输方式必填。
- `fx.mode` 为 `system`、`policy_fixed` 或 `one_time_override`;后者必填 `rate`,建议附 `reason`。`policy_fixed` 只对 `fx_strategy=fixed` 方案有效。
- 正式佣金规则缺失时,仅无方案即时试算或方案 `allow_commission_override=true` 可传 `temporary_commission_rate`。它必须被明确标记为临时覆盖。
- 未指定运输方式时取方案已启用的比较方式;仍为空则取方案默认运输方式。它们必须属于同一启用价卡。
返回:
```json
{ "data": {
"preview_id": "opaque-short-lived-id", "expires_at": "2026-09-02T08:15:00Z",
"context": {
"store_id": "uuid", "platform_id": "uuid", "market_code": "RU",
"rate_card": { "id": "uuid", "version": "CEL-OZON-RFBS-V6.17" },
"exchange_rate": { "rate": "12.33430000", "source": "cbr", "effective_on": "2026-09-02", "is_stale": false }
},
"recommended_option_id": "candidate-1",
"options": [{
"id": "candidate-1", "is_valid": true,
"transport_method": { "id": "uuid", "code": "land_air", "name": "陆空标准" },
"logistics_product_type": { "code": "budget", "name": "Budget" },
"commission": { "rate": "0.120000", "source": "manual_rule", "version": "2026-09", "price_range": { "min": null, "max": "1500.00", "max_inclusive": true } },
"prices": { "selling_price_rub": "999.00", "list_price_rub": "1110.00", "merchandise_value_cny": "80.99" },
"weights": { "actual_weight_kg": "0.990", "volume_weight_kg": null, "billable_weight_kg": "0.990", "billing_basis": "actual_only" },
"fees": {
"purchase_cost_rub": "986.74", "logistics_fee_cny": "49.6600", "logistics_fee_rub": "612.55",
"domestic_fee_cny": "5.0000", "domestic_fee_rub": "61.67", "commission_fee_rub": "119.88",
"ad_fee_rub": "29.97", "last_mile_fee_rub": "19.98", "withdrawal_fee_rub": "9.99", "other_fee_rub": "19.98",
"total_cost_rub": "1860.76", "profit_rub": "-861.76", "gross_margin_rate": "-0.862"
},
"calculation_trace": { "rounding": [], "formula_steps": [], "rule_versions": [] }, "warnings": []
}],
"unavailable_options": [{ "transport_method_id": "uuid", "code": "PACKAGE_DIMENSION_EXCEEDED", "message": "最长边超过该方式限制" }]
} }
```
数值只示意响应形状,非真实 CEL 结果。每种运输方式独立成功或列出不可报价原因;一个方式失败不阻断其他方式。推荐仅按完整成本、目标利润和规则有效性计算,不承诺时效。
缺少成本、有效包装规格、类目、汇率或佣金规则时返回 `422` 和可修复字段;不返回伪造价格。
## 4. 定价方案
方案属于 `store + platform`,币种从店铺读取。方案资源的核心形状:
```json
{
"id": "uuid", "store_id": "uuid", "platform_id": "uuid", "name": "CEL 常规", "note": null,
"status": "active", "is_default": true, "fulfillment": "rfbs",
"rate_card": { "id": "uuid", "version": "CEL-OZON-RFBS-V6.17", "provider": { "code": "CEL", "name": "CEL" } },
"default_transport_method_id": "uuid", "comparison_transport_method_ids": ["uuid", "uuid"],
"fees": { "domestic_shipping_and_label_fee_cny": "5.00", "ad_rate": "0.03", "last_mile_rate": "0.02", "withdrawal_rate": "0.01", "other_rate": "0.02" },
"desired_markup_rate": "0.20", "list_price_discount_rate": "0.10", "allow_commission_override": false,
"fx": { "strategy": "system", "fixed_cny_to_rub_rate": null }, "revision": 1
}
```
`GET /api/stores/{store_id}/pricing-policies?status=active&cursor={opaque}&limit=20`
按 `is_default DESC, updated_at DESC, id DESC` 返回方案。路径店铺必须属于当前工作空间与 header 平台。
`GET /api/pricing-policies/{policy_id}` 返回完整方案。
`POST /api/stores/{store_id}/pricing-policies`
Headers:`Idempotency-Key` 必填。请求体采用上方形状的可写字段:创建时 `rate_card_id` 代替 `rate_card`,运输方式以 UUID 数组提交。首个 active 方案强制默认;后续 `is_default=true` 在同一事务取消旧默认。
`PATCH /api/pricing-policies/{policy_id}`
Headers:`If-Match`、`Idempotency-Key` 必填。仅改提交字段;变更履约、价卡或运输方式时服务端重新校验所有关联。变更只影响未来试算/SKU 新确认,绝不重算旧报价。
`POST /api/pricing-policies/{policy_id}/set-default`
Headers:`Idempotency-Key` 必填。仅 active 方案可设默认。
`DELETE /api/pricing-policies/{policy_id}`
Headers:`Idempotency-Key` 必填。将方案置为 `archived`,不物理删除快照。删除默认方案时,若还有 active 方案则返回 `422 DEFAULT_PRICING_POLICY_REPLACEMENT_REQUIRED`,必须先选择替代默认;否则允许无默认方案。
## 5. SKU 确认衔接
即时试算不落 `pricing_quotes`。商品编辑模块将定义“确认 SKU 报价”:接收 `preview_id`、SKU、最终选中候选及可选一次性覆盖,重新计算后以事务写入 `pricing_quotes + pricing_quote_options`,固化输入、汇率、佣金、CEL 规则版本和费用拆分。
客户端不能提交预览中的价格、物流费或规则 ID 作为确认事实。预览过期、规则/汇率更新或包装规格变化时,服务端拒绝旧预览并要求重新试算。临时切换方案或运输方式只进入本次 SKU 快照,绝不修改默认方案。
## 6. 后台维护边界
普通卖家端不提供佣金类目、佣金规则、物流价卡或汇率历史的写接口。它们由受控运营后台、seed/migration 或 Worker 维护;若将来开放运营 API,必须独立鉴权、审计、版本发布和回滚。
+182
View File
@@ -0,0 +1,182 @@
# 商品编辑与首次上架 API
> 此处的商品始终是采集箱内的 Atlas 草稿。所有平台范围接口要求 `X-Atlas-Platform-Id`。商品通用资料可以跨平台复用;`product_platform_profile`、SKU 首次导入状态和在线商品不能跨平台/门店复用。
## 1. 进入编辑与读取编辑器
`POST /api/collection-items/{collection_item_id}/editor-contexts`
Headers:`Idempotency-Key` 必填,页头平台由 header 指定,body 为空。服务端验证采集记录归属、平台可用,并取得或创建该商品在当前平台的 `product_platform_profile`:
- 首次创建时使用该平台默认店铺作为 `target_store_id`,并为所有未删除 SKU 建立本平台 `product_variant_platform_listing`;
- 当前平台没有默认店铺时返回 `422 DEFAULT_STORE_REQUIRED`,只给出店铺管理引导,不创建缺少目标门店的 profile;
- 已存在 profile 时只读取,绝不因再次进入而覆盖用户已选门店、类目、属性或价格;
- 不创建跨平台 profile,也不改变其他平台的上架状态。
返回:
```json
{ "data": {
"collection_item_id": "uuid", "product_id": "uuid", "profile_id": "uuid",
"target_store": { "id": "uuid", "name": "Ozon 主店", "currency_code": "RUB" },
"editor_path": "/products/uuid/editor"
} }
```
`GET /api/products/{product_id}/editor`
要求 header,返回当前平台 profile 的完整编辑快照。核心形状:
```json
{ "data": {
"product": {
"id": "uuid", "revision": 12,
"common": { "source_title": "来源标题", "brand": null, "model": null, "barcode": null, "purchase_url": "https://…", "supply_note": null },
"default_physical_spec": { "length_mm": null, "width_mm": null, "height_mm": null, "net_weight_g": null, "status": "missing" },
"default_package_spec": { "length_mm": null, "width_mm": null, "height_mm": null, "shipping_weight_g": null, "status": "missing" }
},
"profile": {
"id": "uuid", "revision": 8, "platform_id": "uuid", "market_code": "RU", "fulfillment": "rfbs",
"target_store": { "id": "uuid", "name": "Ozon 主店", "currency_code": "RUB" },
"title": "俄语标题", "description": null, "tags": [],
"category": { "id": "uuid", "platform_type_id": "123", "source": "source_capture" },
"listing_summary": { "status": "partially_listed", "detail": "1 个 SKU 已导入" }
},
"variants": [], "attributes": [], "media_candidates": [], "attribute_schema": null,
"warnings": []
} }
```
`variants` 每项同时返回通用 SKU、合并后实物/包装规格、当前平台 listing 摘要(含 `offer_id`、价格、首次导入状态、最近脱敏失败原因)及已选媒体。`attribute_schema` 仅在当前类目已确定时返回。候选和已选媒体只含 `asset_id`、尺寸、MIME、来源角色与短时预览 URL;不含 `object_key` 或永久 URL。
## 2. 原子保存草稿与 SKU 规则
`PUT /api/products/{product_id}/editor-draft`
Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key` 必填。请求是整个编辑页的原子快照:
```json
{
"common": { "source_title": "商品名称", "brand": "品牌", "model": null, "barcode": null, "purchase_url": "https://…", "supply_note": null,
"default_physical_spec": { "length_mm": "100", "width_mm": "50", "height_mm": "20", "net_weight_g": "300", "status": "confirmed" },
"default_package_spec": { "length_mm": "120", "width_mm": "60", "height_mm": "30", "shipping_weight_g": "400", "status": "confirmed" } },
"profile": { "target_store_id": "uuid", "fulfillment": "rfbs", "title": "Название", "description": "…", "tags": ["tag"],
"platform_product_category_id": "uuid", "platform_type_id": "123", "category_source": "manual" },
"variants": [{
"id": "uuid-or-null", "client_key": "new-row-key", "copy_from_variant_id": null, "display_name": "黑色 / M", "source_sku": null,
"specification_values": [{ "axis_key": "color", "value_key": "black", "value_name": "黑色" }], "purchase_cost_cny": "80.00",
"physical_spec": { "length_mm": null, "width_mm": null, "height_mm": null, "net_weight_g": null, "status": null },
"package_spec": { "length_mm": null, "width_mm": null, "height_mm": null, "shipping_weight_g": null, "status": null },
"offer_id": null,
"media_asset_ids": ["uuid", "uuid"]
}],
"attributes": [{ "scope": "common", "variant_id": null, "platform_attribute_id": "100", "complex_id": "0", "values": ["value"], "language_code": "ru", "source": "manual", "confirmation_status": "confirmed" }]
}
```
语义与校验:
- `variants` 是当前完整未删除 SKU 集合。省略既有 SKU 表示软删除;无论当前 SPU 状态都允许新增、复制、删除。`copy_from_variant_id` 仅用于新行,复制通用 SKU 数据但不复制任何平台导入映射,生成的新 SKU 从 `not_listed` 开始。
- 删除已导入 SKU 只从 Atlas 草稿及当前平台可发布集合移除,**不删除、不下架、不修改** Ozon 商品或在线镜像;其他平台历史映射保留。删除后当前平台只剩已导入 SKU 时,SPU 汇总自然为“已上架”。
- `null` 的 SKU 规格字段表示继承父商品;包装物流规格的 `status` 为 `missing`/`ambiguous` 时,不允许定价确认或首次上架,但仍允许保存草稿。实物规格只影响尺寸图,不用于物流。
- `target_store_id` 只能是当前工作空间、当前平台的活动店铺。变更目标店铺只更新**尚未导入** listing 的将来发布目标;`imported` listing 永远保留其首次导入时的店铺快照。
- 类目变化后不匹配的旧属性作为未映射草稿返回 warnings,不能自动进入发布 payload。属性由平台 schema 校验 `type`、必填、字典、多值/复杂属性和 `is_aspect`;客户端不能用任意 JSON 绕过。
- `media_asset_ids` 是本 SKU 当前平台的最终选图顺序;首期最多 15 张。格式只允许 JPG/PNG/WebP,单文件最多 20 MB,边长超过 10,000 px 拒绝,短边小于 700 px 不得作为首图并给出警告。第一张是主图。
- `common.model` 是用户填写的商品型号;采集时允许为空,但每次首次上架前必须非空。`offer_id` 是用户填写的目标平台卖家货号;采集时不得从 `source_sku` 自动生成或填入。草稿可为空,但每个待首次上架 SKU 的 `offer_id` 必须非空且在同一店铺/平台唯一。
成功返回新 `product.revision`、`profile.revision`、创建的 SKU ID/`client_key` 映射和 warnings。任一字段、属性或媒体无效时返回 `422` 且不部分保存;版本冲突返回 `409 VERSION_CONFLICT`,客户端应重新读取编辑快照。
## 3. 目标店铺、类目与属性
`PUT /api/product-platform-profiles/{profile_id}/target-store`
Headers:`If-Match`、`Idempotency-Key` 必填。Body:`{ "target_store_id": "uuid" }`。这是编辑器完整保存之外的快捷动作,遵守“仅未导入 SKU 更换未来目标店铺”的规则。
`GET /api/product-platform-profiles/{profile_id}/attribute-schema`
当前 profile 必须已有类目。返回平台适配后的字段 schema、字典端点、`is_required`、`is_collection`、`max_value_count`、`is_aspect` 和适用 scope。类别未选返回 `422 CATEGORY_REQUIRED`。
`GET /api/product-platform-profiles/{profile_id}/attribute-dictionaries/{dictionary_id}?q={keyword}&cursor={opaque}`
转发并缓存平台字典值;不让前端直接持有 Ozon 密钥。字典缺失、过期或类目/属性不匹配时返回 `422`。
`GET /api/product-platform-profiles/{profile_id}/validation`
返回 profile 与每个未删除 SKU 的本地首次上架校验:商品型号、类目/属性、标题、目标店铺、用户填写的 offer ID、确认的包装规格、已确认定价快照、图片、格式/数量及平台特有规则。已导入 SKU 单独标记 `not_publishable_again`,而不是当作校验失败。
## 4. SKU 定价确认
`POST /api/product-platform-profiles/{profile_id}/variants/{variant_id}/pricing-confirms`
Headers:`Idempotency-Key` 必填。Body:
```json
{ "preview_id": "opaque-id", "selected_option_id": "candidate-1", "one_time_overrides": null }
```
服务端重新解析 SKU 的有效包装规格、验证预览仍未过期且配置/汇率/规则未变化,重新计算后写入 `pricing_quotes`、全部 `pricing_quote_options` 和当前平台 listing 的售价/划线价/币种/报价引用。成功返回 `201` 与不可变报价摘要。
包装规格缺失或 `ambiguous`、预览失效、候选不属于预览、目标店铺不一致、物流不适用时返回 `422`,不覆盖既有价格。临时更换方案或运输方式仅写入本次报价,不改门店默认方案。
## 5. 素材选择与水印派生
媒体选择通常随第 2 节草稿一起保存。为支持图片弹窗的异步“添加水印”,提供:
`POST /api/product-variant-platform-listings/{variant_listing_id}/watermark-derivatives`
Headers:`Idempotency-Key` 必填。Body:
```json
{ "source_asset_id": "uuid", "watermark_template_id": "uuid" }
```
服务端验证 source asset 是该 SKU 当前已选图片、模板属于当前工作空间且活动;固化模板**当前版本**后创建 `media_derivatives` 和 Worker 任务,返回 `202` 及 operation。任务成功后返回独立 output asset;原图和其他 SKU 的选择均不改变。用户在图片弹窗确认后,将 output asset 写入下一次 `editor-draft.media_asset_ids` 来替换原图位置;可再次选择原图恢复。首期不在发布时自动添加水印。
`GET /api/media-derivatives/{derivative_id}` 返回任务状态、脱敏错误及成功时的输出素材短时预览;不能泄露 `object_key`。
## 6. AI 任务与人工应用
所有 AI 接口都只创建任务并返回 `202` + operation/`ai_task_id`,绝不直接改写草稿或发布数据。
| 接口 | body 核心字段 | 成功结果 |
|---|---|---|
| `POST /api/product-platform-profiles/{profile_id}/ai/category-suggestions` | 可选 `hint` | 读取采集证据和当前平台类目树,创建类目候选任务。Ozon 来源默认不自动调用,但用户可主动调用。 |
| `POST /api/product-platform-profiles/{profile_id}/ai/copy` | `fields: [title,tags,description]`、可选语言 | 创建当前平台文案候选。首期不生成富内容。 |
| `POST /api/product-platform-profiles/{profile_id}/ai/suite-plans` | `variant_ids` 必填、`prompt` 可选 | 每个目标 SKU 明确绑定,创建套图规划/生成任务。 |
| `POST /api/product-platform-profiles/{profile_id}/variants/{variant_id}/ai/dimension-images` | 可选样式 | 固化该 SKU 合并后的**实物**规格快照;实物规格缺失/待确认返回 `422`。 |
`GET /api/product-ai-tasks/{ai_task_id}` 返回状态、模型/输入摘要、候选输出或脱敏错误。
`GET /api/product-ai-tasks/{ai_task_id}/category-suggestions` 返回候选类目、置信度、依据、rank。
`POST /api/product-ai-tasks/{ai_task_id}/category-suggestions/{suggestion_id}/apply`
Headers:`If-Match`、`Idempotency-Key` 必填。用户确认后才将候选写入 profile 的类目/类型并标记 `category_source=ai_suggestion`;随后要求重新读取 schema、审查未映射属性。文案/图片 AI 输出同样由用户在下一次 `editor-draft` 中明确选择并保存。
## 7. 部分 SKU 首次上架
`POST /api/product-platform-profiles/{profile_id}/publish-jobs`
Headers:`Idempotency-Key` 必填。Body:
```json
{ "variant_ids": ["uuid", "uuid"] }
```
服务端在创建任务前重新运行第 3 节校验。只允许当前 profile 中状态为 `not_listed` 或 `failed` 的未删除 SKU;`submitting` 返回 `409 SKU_IMPORT_IN_PROGRESS`,`imported` 返回 `422 SKU_ALREADY_IMPORTED`,不得重新提交或把编辑草稿当平台更新。
校验通过后在同一事务创建 `product_publish_job` 和每个 SKU 的 `product_publish_job_item`,固化 profile、目标店铺、类目/属性、价格快照、图片访问 URL、offer ID 与平台适配请求,再投递 Worker。返回 `202`:
```json
{ "data": { "publish_job": { "id": "uuid", "status": "queued", "store_id": "uuid", "item_count": 2 } } }
```
首期 Ozon `/v3/product/import` 不传仓库或库存。Worker 按 SKU 保存提交、轮询、成功/失败和平台原因;成功 SKU 状态为 `imported` 后不能从采集箱再次上架。SPU 的未上架/已部分上架/已上架由当前平台未删除 SKU 汇总,采集箱记录始终保留。
`GET /api/product-publish-jobs/{publish_job_id}`
返回 job、每个 SKU item 的 `queued/submitting/polling/imported/failed/skipped`、Ozon task/product ID、时间和可展示的脱敏错误。发布失败不删除草稿或报价;用户修正未导入 SKU 后可以创建新的首次上架任务。在线商品镜像由另一个同步模块维护,不能以此 job 的状态替代 Ozon 当前 `visible`、库存或审核事实。
## 8. 已替代的旧草案
本文替代 [`../product/api.md`](../product/api.md) 中与商品编辑有关的路径。旧草案保留为历史背景,实施以本文件、[`pricing.md`](./pricing.md) 与 [`collection-and-extension.md`](./collection-and-extension.md) 为准。
+152
View File
@@ -0,0 +1,152 @@
# 平台上下文与店铺管理 API
## 1. 平台目录与页头上下文
`GET /api/platforms`
返回当前可选择、已启用的平台及客户端所需的非敏感能力:
```json
{
"data": [{
"id": "uuid",
"code": "OZON",
"name": "Ozon",
"supported_currencies": ["CNY", "RUB"],
"default_market_code": "RU",
"default_locale": "ru-RU"
}]
}
```
`GET /api/platform-context`
要求 `X-Atlas-Platform-Id`,返回平台摘要、该工作空间在此平台的默认店铺摘要和店铺数。没有默认店铺不是错误:`default_store` 为 `null`,编辑/发布类接口会另外要求可用店铺。
## 2. 店铺资源形状
```json
{
"id": "uuid",
"platform_id": "uuid",
"name": "Ozon 俄罗斯店",
"platform_client_id": "123456",
"currency_code": "RUB",
"is_default": true,
"note": "主店",
"status": "active",
"credential_status": "valid",
"credential_checked_at": "2026-09-02T08:00:00Z",
"last_error": null,
"api_key_expires_at": null,
"product_limit": null,
"product_limit_checked_at": null,
"warehouse_count": 3,
"warehouses_synced_at": "2026-09-02T08:01:00Z",
"cookie_status": null,
"watermark_template": { "id": "uuid", "name": "右下角品牌" },
"created_at": "2026-09-02T08:00:00Z",
"updated_at": "2026-09-02T08:00:00Z",
"revision": 1
}
```
`api_key_expires_at` 和 `product_limit` 无平台事实来源时为 `null`,前端显示 `—`;不能估算。首期 `cookie_status` 固定为 `null`,不暗示 Cookie 可用于正式发布。响应绝不包含 `api_key`、密文、指纹或仓库原始平台 payload。
## 3. 查询店铺
`GET /api/stores?platform_id={uuid}&cursor={opaque}&limit=20`
`platform_id` 必填,按 `created_at DESC, id DESC` 返回当前工作空间未删除店铺。该管理列表可使用查询参数,不能同时传平台 header。
`GET /api/stores/{store_id}`
返回店铺资源。资源不属于当前工作空间或已删除时返回 `404`。
## 4. 添加店铺授权
`POST /api/stores`
Headers:`Idempotency-Key` 必填。
```json
{
"platform_id": "uuid",
"name": "Ozon 俄罗斯店",
"client_id": "123456",
"api_key": "secret-only-in-request",
"currency_code": "RUB",
"is_default": true,
"note": "主店"
}
```
校验 `currency_code` 是否为目标平台可用币种。服务端立即把 API Key 信封加密保存、创建 `status=verifying` 的店铺,随后投递“低风险凭据验证 + 仓库同步”操作;不得将 Key 放入任务 payload、日志或响应。
返回 `202 Accepted`:
```json
{
"data": {
"store": { "id": "uuid", "status": "verifying", "credential_status": "unknown" },
"operation": { "id": "uuid", "kind": "store_credential_verification", "status": "queued" }
}
}
```
首家未删除店铺强制为默认。对已有店铺提交 `is_default=true` 时,服务端在同一事务取消旧默认店铺;校验失败不把店铺置为 `active`,失败原因以可展示的脱敏 `last_error` 摘要(`code`、`message`、`at`)返回给后续读取接口。
## 5. 编辑、默认店铺、验证与删除
`PATCH /api/stores/{store_id}`
Headers:`If-Match` 与 `Idempotency-Key` 必填。
```json
{
"name": "Ozon 俄罗斯主店",
"currency_code": "RUB",
"note": "更新备注",
"api_key": "optional-replacement-secret"
}
```
不可修改 `platform_id` 和 `client_id`,避免把一个授权连接变成另一个平台/账户。未传 `api_key` 代表保留旧密钥;传入时替换、撤销旧密钥并再次触发凭据验证。普通编辑返回 `200`;包含新 Key 时返回 `202` 和验证 `operation`。
`POST /api/stores/{store_id}/set-default`
Headers:`Idempotency-Key` 必填。路径店铺在其平台下成为默认店铺,返回更新后的店铺摘要。无 body。
`POST /api/stores/{store_id}/credential-verifications`
Headers:`Idempotency-Key` 必填。手动重试凭据校验,返回 `202` 与 operation。店铺停用或删除时返回 `422`。
`POST /api/stores/{store_id}/warehouse-syncs`
Headers:`Idempotency-Key` 必填。触发 Ozon `POST /v1/warehouse/list` 同步,返回 `202` 与 operation。凭据无效时不调用平台,返回 `422 STORE_CREDENTIAL_UNAVAILABLE`。
`DELETE /api/stores/{store_id}`
Headers:`Idempotency-Key` 必填。仅软删除 Atlas 内的店铺连接,不影响 Ozon 后台。删除当前默认店铺时:若同平台仍有其他未删除店铺,返回 `422 DEFAULT_STORE_REPLACEMENT_REQUIRED`,客户端必须先选择新的默认店铺;若没有其他店铺,可以删除,平台默认店铺变为 `null`。
## 6. 仓库与推荐水印
`GET /api/stores/{store_id}/warehouses`
返回最近一次同步成功的仓库摘要,按名称排序:
```json
{ "data": [{ "id": "uuid", "platform_warehouse_id": "123", "name": "Москва", "fulfillment": "rfbs", "is_rfbs": true, "status": "active", "synced_at": "2026-09-02T08:01:00Z" }] }
```
从未成功同步时返回空数组及 `meta.warehouses_synced_at = null`,不把空数组解释为平台没有仓库。
`PUT /api/stores/{store_id}/watermark-binding`
Headers:`Idempotency-Key` 必填。
```json
{ "template_id": "uuid" }
```
传 `{ "template_id": null }` 表示“暂不绑定”。模板必须是当前工作空间的活动模板。服务端在事务内结束旧绑定、创建新绑定(或保持未绑定),返回店铺中的 `watermark_template` 摘要。此绑定只代表图片编辑弹窗的未来推荐值;首期不会自动改写已有图片或在发布时添加水印。
+193
View File
@@ -0,0 +1,193 @@
# 水印与素材 API
> 素材主数据只保存 `storage_provider + bucket + object_key`。API 只在需要时签发短时上传/访问 URL,绝不返回或持久化对象存储的公开永久 URL。
## 1. 媒体上传:两阶段直传
水印图片和后续商品图片都复用此协议。上传意图是短时、一次性的受控动作;它不是公开文件地址。
### 1.1 创建上传意图
`POST /api/media/upload-intents`
Headers:`Idempotency-Key` 必填。
```json
{
"purpose": "watermark_image",
"filename": "brand-mark.png",
"content_type": "image/png",
"byte_size": 182736,
"checksum_sha256": "hex-sha256"
}
```
首期允许 `image/png`、`image/jpeg`、`image/webp`。服务端按用途校验大小/尺寸策略,生成随机 `object_key` 和短时预签名上传地址。返回 `201 Created`:
```json
{
"data": {
"upload_id": "uuid",
"expires_at": "2026-09-02T08:10:00Z",
"upload": {
"method": "PUT",
"url": "https://short-lived-upload-url",
"headers": { "content-type": "image/png" }
}
}
}
```
前端必须以服务器返回的 method、URL 与 headers 上传,不能自行拼接对象路径;上传 URL 仅可写入该次目标对象。
### 1.2 完成与校验上传
`POST /api/media/upload-intents/{upload_id}/complete`
Headers:`Idempotency-Key` 必填。
```json
{ "checksum_sha256": "hex-sha256" }
```
服务端检查对象存在、文件大小、MIME/魔数、哈希和图片尺寸;校验通过后创建/复用 `media_asset` 并返回 `201 Created`。图片解码或安全扫描耗时时可先返回 `202` 与 `media_processing` operation。
```json
{
"data": {
"asset": {
"id": "uuid",
"content_type": "image/png",
"byte_size": 182736,
"width_px": 800,
"height_px": 400,
"created_at": "2026-09-02T08:02:00Z"
}
}
}
```
`object_key` 是服务端内部字段,不放在普通客户端响应。上传意图、完成状态和对象清理需要在实现时增加持久化记录;它不能只存在于 Redis 或预签名 token 中。
### 1.3 临时预览 URL
`POST /api/media-assets/{asset_id}/access-urls`
```json
{ "purpose": "preview" }
```
返回短时 `url` 与 `expires_at`。仅当前工作空间的素材可签发;前端在 URL 过期后重新请求,不能把 URL 写回业务表或长期缓存。
## 2. 可用字体
`GET /api/watermark-fonts`
返回渲染服务已部署且状态为 `active` 的字体:
```json
{ "data": [{ "code": "NotoSans", "display_name": "Noto Sans" }] }
```
客户端不得读取浏览器或开发机字体列表;字体 code 是水印模板的稳定保存值。
## 3. 水印模板资源
水印模板的读取形状:
```json
{
"id": "uuid",
"name": "右下角品牌",
"kind": "image",
"current_version": 3,
"config": {
"image_asset": { "id": "uuid", "preview_url": "https://short-lived", "preview_url_expires_at": "2026-09-02T08:15:00Z" },
"opacity": "0.8000",
"scale_ratio": "0.2500",
"position": "bottom_right"
},
"created_at": "2026-09-02T08:00:00Z",
"updated_at": "2026-09-02T08:03:00Z",
"revision": 8
}
```
文字模板的 `config` 为:
```json
{
"text_content": "Atlas",
"font_code": "NotoSans",
"font_size_px": 24,
"opacity": "0.8000",
"position": "bottom_right"
}
```
`position` 只能是 `top_left`、`top_center`、`top_right`、`middle_left`、`center`、`middle_right`、`bottom_left`、`bottom_center`、`bottom_right`。`opacity` 范围 `"0"`–`"1"`;图片 `scale_ratio` 范围 `"0.1"`–`"2"`;文字 `font_size_px` 范围 12–60。
### 3.1 查询
`GET /api/watermark-templates?cursor={opaque}&limit=20`
按 `created_at DESC, id DESC` 返回当前工作空间活动模板。图片模板响应可带短时 `preview_url`;过期后应重新读取列表/详情或请求素材访问 URL。
`GET /api/watermark-templates/{template_id}`
返回模板当前版本,供编辑弹窗和“查看效果”使用。固定演示底图由前端内置,前端按 `config` 实时渲染预览,不在每次滑动 Slider 时创建服务端图片。
### 3.2 创建
`POST /api/watermark-templates`
Headers:`Idempotency-Key` 必填。
文字水印示例:
```json
{
"name": "右下角文字",
"kind": "text",
"config": {
"text_content": "Atlas",
"font_code": "NotoSans",
"font_size_px": 24,
"opacity": "0.8",
"position": "bottom_right"
}
}
```
图片水印示例:
```json
{
"name": "品牌图标",
"kind": "image",
"config": {
"image_asset_id": "uuid",
"scale_ratio": "0.25",
"opacity": "0.8",
"position": "bottom_right"
}
}
```
模板名称在当前工作空间内唯一;图片资产必须属于当前工作空间且已完成校验。成功返回 `201 Created` 与模板资源。
### 3.3 编辑与删除
`PATCH /api/watermark-templates/{template_id}`
Headers:`If-Match` 与 `Idempotency-Key` 必填。请求体与创建相同,但可只提交待改字段;若切换 `kind`,须提交新类型完整 `config`,服务端不保留旧类型专属字段。
每次影响渲染的更新都会创建新的 `watermark_template_version`,并增加模板 `current_version`。旧版本不会被覆盖,供已生成派生图追溯;模板改名只更新模板名称,不需要创建渲染版本。
`DELETE /api/watermark-templates/{template_id}`
Headers:`Idempotency-Key` 必填。服务端在同一事务软删除模板并解除所有当前店铺绑定,返回 `204 No Content`。不删除原始图片资产,也不影响已生成水印派生图和历史发布快照。
## 4. 与商品编辑的边界
本文件只定义水印模板和通用素材。商品编辑的“图片悬浮 → 编辑 → 添加水印”将在商品接口中定义:它选择这里的活动模板、创建持久化派生任务、固化模板版本,并将生成的独立素材替换当前 SKU 已选图片位置;不会在上架时临时加水印。

Some files were not shown because too many files have changed in this diff Show More