feat: implement third-party order sync
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
/build
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# temp directory
|
||||
.temp
|
||||
.tmp
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "@study-work/backend",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"db:migrate": "node scripts/run-sql-migrations.js",
|
||||
"dev": "nest start --watch",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "pnpm lint:eslint && pnpm lint:stylelint",
|
||||
"lint:eslint": "eslint \"src/**/*.{ts,js}\" --cache",
|
||||
"lint:stylelint": "stylelint \"src/**/*.{css,less,scss}\" --cache --allow-empty-input",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"mysql2": "^3.22.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.30",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$|.*\\.module\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
function loadRootEnv() {
|
||||
const envPath = path.resolve(__dirname, '../../../.env');
|
||||
if (!fs.existsSync(envPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(envPath, 'utf8');
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
const separatorIndex = trimmed.indexOf('=');
|
||||
if (separatorIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
const key = trimmed.slice(0, separatorIndex).trim();
|
||||
const value = trimmed
|
||||
.slice(separatorIndex + 1)
|
||||
.trim()
|
||||
.replace(/^"|"$/g, '');
|
||||
process.env[key] ??= value;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
loadRootEnv();
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error('DATABASE_URL is required');
|
||||
}
|
||||
|
||||
const connection = await mysql.createConnection(process.env.DATABASE_URL);
|
||||
const migrationsDir = path.resolve(__dirname, '../src/database/migrations');
|
||||
const files = fs
|
||||
.readdirSync(migrationsDir)
|
||||
.filter((file) => file.endsWith('.sql'))
|
||||
.sort();
|
||||
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
for (const file of files) {
|
||||
const [rows] = await connection.query(
|
||||
'SELECT version FROM schema_migrations WHERE version = ?',
|
||||
[file],
|
||||
);
|
||||
if (rows.length > 0) {
|
||||
console.log(`skip ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
|
||||
await connection.beginTransaction();
|
||||
try {
|
||||
for (const statement of sql.split(/;\s*(?:\r?\n|$)/)) {
|
||||
const trimmed = statement.trim();
|
||||
if (trimmed) {
|
||||
await connection.query(trimmed);
|
||||
}
|
||||
}
|
||||
await connection.query(
|
||||
'INSERT INTO schema_migrations (version) VALUES (?)',
|
||||
[file],
|
||||
);
|
||||
await connection.commit();
|
||||
console.log(`applied ${file}`);
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
|
||||
export const ADMIN_ROLE = 'super_admin';
|
||||
export const AGENT_ROLE = 'agent';
|
||||
export const NORMAL_USER_ROLE = 'user';
|
||||
|
||||
export function getRoleCodes(user: User) {
|
||||
return (user.roles || []).map((role) => role.code);
|
||||
}
|
||||
|
||||
export function isAdmin(user: User) {
|
||||
return getRoleCodes(user).includes(ADMIN_ROLE);
|
||||
}
|
||||
|
||||
export function isAgent(user: User) {
|
||||
return getRoleCodes(user).includes(AGENT_ROLE);
|
||||
}
|
||||
|
||||
export function assertCanManageUsers(user: User) {
|
||||
if (!isAdmin(user) && !isAgent(user)) {
|
||||
throw new ForbiddenException('No permission to manage users');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertAdmin(user: User) {
|
||||
if (!isAdmin(user)) {
|
||||
throw new ForbiddenException('Only administrators can perform this action');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertCanManageTarget(actor: User, target: User) {
|
||||
if (isAdmin(actor)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isAgent(actor) &&
|
||||
(target.parentId === actor.id || target.parentPath?.includes(`/${actor.id}/`))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new ForbiddenException('No permission to manage this user');
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
import type { AuthenticatedRequest } from '../auth/types/authenticated-request';
|
||||
import { AdminLogsService } from './admin-logs.service';
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Controller('api/admin')
|
||||
export class AdminLogsController {
|
||||
constructor(private readonly adminLogsService: AdminLogsService) {}
|
||||
|
||||
@Get('audit-logs')
|
||||
listAuditLogs(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Query() query: Record<string, unknown>,
|
||||
) {
|
||||
return this.adminLogsService.listAuditLogs(request.user, query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AuditLog } from '../audit/entities/audit-log.entity';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { assertAdmin } from './admin-access';
|
||||
|
||||
@Injectable()
|
||||
export class AdminLogsService {
|
||||
constructor(
|
||||
@InjectRepository(AuditLog)
|
||||
private readonly auditLogsRepository: Repository<AuditLog>,
|
||||
) {}
|
||||
|
||||
async listAuditLogs(actor: User, query: Record<string, unknown>) {
|
||||
assertAdmin(actor);
|
||||
const page = Math.max(Number(query.page || 1), 1);
|
||||
const pageSize = Math.min(Math.max(Number(query.pageSize || 10), 1), 100);
|
||||
const actorUsername = String(query.actorUsername || '').trim();
|
||||
const action = String(query.action || '').trim();
|
||||
const loginResult = String(query.loginResult || '').trim();
|
||||
|
||||
const builder = this.auditLogsRepository
|
||||
.createQueryBuilder('log')
|
||||
.orderBy('log.createdAt', 'DESC')
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
|
||||
if (actorUsername) {
|
||||
builder.andWhere('log.actorUsername LIKE :actorUsername', {
|
||||
actorUsername: `%${actorUsername}%`,
|
||||
});
|
||||
}
|
||||
|
||||
if (action) {
|
||||
builder.andWhere('log.action LIKE :action', { action: `%${action}%` });
|
||||
}
|
||||
|
||||
if (loginResult) {
|
||||
builder.andWhere(
|
||||
'log.action = :loginAction AND JSON_UNQUOTE(JSON_EXTRACT(log.metadata, "$.loginResult")) = :loginResult',
|
||||
{ loginAction: 'auth.login', loginResult },
|
||||
);
|
||||
}
|
||||
|
||||
const [list, total] = await builder.getManyAndCount();
|
||||
return { list, total, page, pageSize };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Body, Controller, Get, Param, Patch, Req, UseGuards } from '@nestjs/common';
|
||||
import { OperationLog } from '../audit/operation-log.decorator';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
import type { AuthenticatedRequest } from '../auth/types/authenticated-request';
|
||||
import { AdminPermissionsService } from './admin-permissions.service';
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Controller('api/admin/permissions')
|
||||
export class AdminPermissionsController {
|
||||
constructor(private readonly permissionsService: AdminPermissionsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Req() request: AuthenticatedRequest) {
|
||||
return this.permissionsService.listPermissions(request.user);
|
||||
}
|
||||
|
||||
@Patch('roles/:code')
|
||||
@OperationLog({
|
||||
action: 'admin.permissions.update_role',
|
||||
resourceType: 'role',
|
||||
resourceIdParam: 'code',
|
||||
metadataFromBody: ['permissions'],
|
||||
description: '更新角色权限',
|
||||
})
|
||||
updateRole(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('code') code: string,
|
||||
@Body() body: { permissions?: Record<string, string[]> },
|
||||
) {
|
||||
return this.permissionsService.updateRolePermissions(
|
||||
request.user,
|
||||
code,
|
||||
body.permissions || {},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Role } from '../users/entities/role.entity';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { assertAdmin } from './admin-access';
|
||||
import { normalizePermissions, permissionResources } from './permission-resources';
|
||||
|
||||
@Injectable()
|
||||
export class AdminPermissionsService {
|
||||
constructor(
|
||||
@InjectRepository(Role)
|
||||
private readonly rolesRepository: Repository<Role>,
|
||||
) {}
|
||||
|
||||
async listPermissions(actor: User) {
|
||||
assertAdmin(actor);
|
||||
const roles = await this.rolesRepository.find({ order: { code: 'ASC' } });
|
||||
return {
|
||||
resources: permissionResources,
|
||||
roles: roles.map((role) => ({
|
||||
code: role.code,
|
||||
name: role.name,
|
||||
permissions: role.permissions || {},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async updateRolePermissions(
|
||||
actor: User,
|
||||
roleCode: string,
|
||||
permissions: Record<string, string[]>,
|
||||
) {
|
||||
assertAdmin(actor);
|
||||
const role = await this.rolesRepository.findOne({ where: { code: roleCode } });
|
||||
if (!role) {
|
||||
throw new BadRequestException('角色不存在');
|
||||
}
|
||||
if (role.code === 'super_admin') {
|
||||
throw new BadRequestException('超级管理员权限不可修改');
|
||||
}
|
||||
|
||||
const normalized = normalizePermissions(permissions);
|
||||
|
||||
role.permissions = normalized;
|
||||
await this.rolesRepository.save(role);
|
||||
return {
|
||||
code: role.code,
|
||||
name: role.name,
|
||||
permissions: role.permissions,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Delete,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { OperationLog } from '../audit/operation-log.decorator';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
import type { AuthenticatedRequest } from '../auth/types/authenticated-request';
|
||||
import { AdminUsersService } from './admin-users.service';
|
||||
import type {
|
||||
AssignUserRoleDto,
|
||||
CreateAdminUserDto,
|
||||
UpdateAdminUserDto,
|
||||
UpdateUserEnabledDto,
|
||||
UpdateUserPermissionsDto,
|
||||
} from './dto/admin-user.dto';
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Controller('api/admin')
|
||||
export class AdminUsersController {
|
||||
constructor(private readonly adminUsersService: AdminUsersService) {}
|
||||
|
||||
@Get('roles')
|
||||
listRoles(@Req() request: AuthenticatedRequest) {
|
||||
return this.adminUsersService.listRoles(request.user);
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
listUsers(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Query() query: Record<string, unknown>,
|
||||
) {
|
||||
return this.adminUsersService.listUsers(request.user, query);
|
||||
}
|
||||
|
||||
@Post('users')
|
||||
@OperationLog({
|
||||
action: 'admin.users.create',
|
||||
resourceType: 'user',
|
||||
resourceIdFromResult: 'id',
|
||||
metadataFromBody: ['name', 'email', 'roleCode', 'parentId', 'mustChangePassword'],
|
||||
description: '新建用户',
|
||||
})
|
||||
createUser(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Body() dto: CreateAdminUserDto,
|
||||
) {
|
||||
return this.adminUsersService.createUser(request.user, dto);
|
||||
}
|
||||
|
||||
@Patch('users/:id/enabled')
|
||||
@OperationLog({
|
||||
action: 'admin.users.update_enabled',
|
||||
resourceType: 'user',
|
||||
resourceIdParam: 'id',
|
||||
metadataFromBody: ['enabled'],
|
||||
description: '更新用户启停状态',
|
||||
})
|
||||
updateEnabled(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateUserEnabledDto,
|
||||
) {
|
||||
return this.adminUsersService.updateEnabled(request.user, id, dto);
|
||||
}
|
||||
|
||||
@Patch('users/:id')
|
||||
@OperationLog({
|
||||
action: 'admin.users.update',
|
||||
resourceType: 'user',
|
||||
resourceIdParam: 'id',
|
||||
metadataFromBody: ['name', 'email', 'roleCode', 'enabled', 'mustChangePassword'],
|
||||
description: '编辑用户信息',
|
||||
})
|
||||
updateUser(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateAdminUserDto,
|
||||
) {
|
||||
return this.adminUsersService.updateUser(request.user, id, dto);
|
||||
}
|
||||
|
||||
@Patch('users/:id/role')
|
||||
@OperationLog({
|
||||
action: 'admin.users.assign_role',
|
||||
resourceType: 'user',
|
||||
resourceIdParam: 'id',
|
||||
metadataFromBody: ['roleCode'],
|
||||
description: '调整用户角色',
|
||||
})
|
||||
assignRole(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: AssignUserRoleDto,
|
||||
) {
|
||||
return this.adminUsersService.assignRole(request.user, id, dto);
|
||||
}
|
||||
|
||||
@Get('users/:id/permissions')
|
||||
getUserPermissions(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.adminUsersService.getUserPermissions(request.user, id);
|
||||
}
|
||||
|
||||
@Patch('users/:id/permissions')
|
||||
@OperationLog({
|
||||
action: 'admin.users.update_permissions',
|
||||
resourceType: 'user',
|
||||
resourceIdParam: 'id',
|
||||
metadataFromBody: ['permissions'],
|
||||
description: '设置用户权限',
|
||||
})
|
||||
updateUserPermissions(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateUserPermissionsDto,
|
||||
) {
|
||||
return this.adminUsersService.updateUserPermissions(request.user, id, dto);
|
||||
}
|
||||
|
||||
@Delete('users/:id/permissions')
|
||||
@OperationLog({
|
||||
action: 'admin.users.reset_permissions',
|
||||
resourceType: 'user',
|
||||
resourceIdParam: 'id',
|
||||
description: '恢复用户默认权限',
|
||||
})
|
||||
resetUserPermissions(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.adminUsersService.resetUserPermissions(request.user, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Brackets, Repository } from 'typeorm';
|
||||
import { Role } from '../users/entities/role.entity';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import {
|
||||
AGENT_ROLE,
|
||||
ADMIN_ROLE,
|
||||
NORMAL_USER_ROLE,
|
||||
assertCanManageTarget,
|
||||
assertCanManageUsers,
|
||||
isAdmin,
|
||||
isAgent,
|
||||
} from './admin-access';
|
||||
import type {
|
||||
AssignUserRoleDto,
|
||||
CreateAdminUserDto,
|
||||
UpdateAdminUserDto,
|
||||
UpdateUserEnabledDto,
|
||||
UpdateUserPermissionsDto,
|
||||
} from './dto/admin-user.dto';
|
||||
import { normalizePermissions, permissionResources } from './permission-resources';
|
||||
|
||||
const MANAGEABLE_ROLE_CODES = [AGENT_ROLE, NORMAL_USER_ROLE];
|
||||
|
||||
@Injectable()
|
||||
export class AdminUsersService {
|
||||
constructor(
|
||||
@InjectRepository(Role)
|
||||
private readonly rolesRepository: Repository<Role>,
|
||||
@InjectRepository(User)
|
||||
private readonly usersRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
async listUsers(actor: User, query: Record<string, unknown>) {
|
||||
assertCanManageUsers(actor);
|
||||
|
||||
const page = Math.max(Number(query.page || 1), 1);
|
||||
const pageSize = Math.min(Math.max(Number(query.pageSize || 10), 1), 100);
|
||||
const keyword = String(query.keyword || '').trim();
|
||||
|
||||
const builder = this.usersRepository
|
||||
.createQueryBuilder('user')
|
||||
.leftJoinAndSelect('user.roles', 'role')
|
||||
.orderBy('user.createdAt', 'DESC')
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
|
||||
if (isAgent(actor)) {
|
||||
builder.andWhere(
|
||||
'(user.parentId = :actorId OR user.parentPath LIKE :actorPath)',
|
||||
{
|
||||
actorId: actor.id,
|
||||
actorPath: `%/${actor.id}/%`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
builder.andWhere(
|
||||
new Brackets((qb) => {
|
||||
qb.where('user.name LIKE :keyword', { keyword: `%${keyword}%` }).orWhere(
|
||||
'user.email LIKE :keyword',
|
||||
{ keyword: `%${keyword}%` },
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const [users, total] = await builder.getManyAndCount();
|
||||
|
||||
return {
|
||||
list: users.map((user) => this.toUserItem(user)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async listRoles(actor: User) {
|
||||
assertCanManageUsers(actor);
|
||||
const roles = await this.rolesRepository.find({
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
|
||||
const allowedCodes = isAdmin(actor)
|
||||
? [ADMIN_ROLE, AGENT_ROLE, NORMAL_USER_ROLE]
|
||||
: MANAGEABLE_ROLE_CODES;
|
||||
|
||||
return roles
|
||||
.filter((role) => allowedCodes.includes(role.code))
|
||||
.map((role) => ({
|
||||
code: role.code,
|
||||
name: role.name,
|
||||
}));
|
||||
}
|
||||
|
||||
async createUser(actor: User, dto: CreateAdminUserDto) {
|
||||
assertCanManageUsers(actor);
|
||||
|
||||
const name = String(dto.name || '').trim();
|
||||
const email = String(dto.email || '').trim();
|
||||
const password = String(dto.password || '');
|
||||
const roleCode = String(dto.roleCode || NORMAL_USER_ROLE);
|
||||
|
||||
if (!name || !email || !password) {
|
||||
throw new BadRequestException('name, email and password are required');
|
||||
}
|
||||
|
||||
this.assertAssignableRole(actor, roleCode);
|
||||
|
||||
const existed = await this.usersRepository.findOne({ where: { email } });
|
||||
if (existed) {
|
||||
throw new BadRequestException('email already exists');
|
||||
}
|
||||
|
||||
const parentId = isAdmin(actor) ? dto.parentId || null : actor.id;
|
||||
let parentPath: string | null = null;
|
||||
if (parentId) {
|
||||
const parent = await this.usersRepository.findOne({ where: { id: parentId } });
|
||||
if (!parent) {
|
||||
throw new BadRequestException('parent user does not exist');
|
||||
}
|
||||
if (!isAdmin(actor) && !this.isDescendantOf(parent, actor)) {
|
||||
throw new ForbiddenException('cannot create user under this parent');
|
||||
}
|
||||
parentPath = `${parent.parentPath || '/'}${parent.id}/`;
|
||||
}
|
||||
|
||||
const role = await this.getRoleByCode(roleCode);
|
||||
const user = this.usersRepository.create({
|
||||
id: randomUUID(),
|
||||
name,
|
||||
email,
|
||||
passwordHash: await bcrypt.hash(password, 10),
|
||||
enabled: true,
|
||||
parentId,
|
||||
parentPath,
|
||||
tokenVersion: 0,
|
||||
mustChangePassword: dto.mustChangePassword ?? true,
|
||||
roles: [role],
|
||||
});
|
||||
|
||||
await this.usersRepository.save(user);
|
||||
|
||||
return this.toUserItem(user);
|
||||
}
|
||||
|
||||
async updateEnabled(
|
||||
actor: User,
|
||||
userId: string,
|
||||
dto: UpdateUserEnabledDto,
|
||||
) {
|
||||
const user = await this.getManageableUser(actor, userId);
|
||||
user.enabled = Boolean(dto.enabled);
|
||||
await this.usersRepository.save(user);
|
||||
|
||||
return this.toUserItem(user);
|
||||
}
|
||||
|
||||
async updateUser(
|
||||
actor: User,
|
||||
userId: string,
|
||||
dto: UpdateAdminUserDto,
|
||||
) {
|
||||
const user = await this.getManageableUser(actor, userId);
|
||||
const name = String(dto.name ?? user.name).trim();
|
||||
const email = String(dto.email ?? user.email).trim();
|
||||
const roleCode = String(dto.roleCode || user.roles?.[0]?.code || '');
|
||||
|
||||
if (!name || !email) {
|
||||
throw new BadRequestException('用户名和邮箱不能为空');
|
||||
}
|
||||
|
||||
this.assertAssignableRole(actor, roleCode);
|
||||
|
||||
const existed = await this.usersRepository.findOne({ where: { email } });
|
||||
if (existed && existed.id !== user.id) {
|
||||
throw new BadRequestException('邮箱已被其他用户使用');
|
||||
}
|
||||
|
||||
const role = await this.getRoleByCode(roleCode);
|
||||
user.name = name;
|
||||
user.email = email;
|
||||
user.roles = [role];
|
||||
if (typeof dto.enabled === 'boolean') {
|
||||
user.enabled = dto.enabled;
|
||||
}
|
||||
if (typeof dto.mustChangePassword === 'boolean') {
|
||||
user.mustChangePassword = dto.mustChangePassword;
|
||||
}
|
||||
|
||||
await this.usersRepository.save(user);
|
||||
|
||||
return this.toUserItem(user);
|
||||
}
|
||||
|
||||
async assignRole(
|
||||
actor: User,
|
||||
userId: string,
|
||||
dto: AssignUserRoleDto,
|
||||
) {
|
||||
const roleCode = String(dto.roleCode || '');
|
||||
this.assertAssignableRole(actor, roleCode);
|
||||
|
||||
const user = await this.getManageableUser(actor, userId);
|
||||
const role = await this.getRoleByCode(roleCode);
|
||||
user.roles = [role];
|
||||
|
||||
await this.usersRepository.save(user);
|
||||
|
||||
return this.toUserItem(user);
|
||||
}
|
||||
|
||||
async getUserPermissions(actor: User, userId: string) {
|
||||
const user = await this.getManageableUser(actor, userId, { allowSelf: true });
|
||||
const rolePermissions = this.getRolePermissions(user.roles || []);
|
||||
const customPermissions = user.permissions ?? null;
|
||||
|
||||
return {
|
||||
resources: permissionResources,
|
||||
rolePermissions,
|
||||
customPermissions,
|
||||
effectivePermissions: customPermissions ?? rolePermissions,
|
||||
isCustom: Boolean(customPermissions),
|
||||
canEdit: isAdmin(actor),
|
||||
};
|
||||
}
|
||||
|
||||
async updateUserPermissions(
|
||||
actor: User,
|
||||
userId: string,
|
||||
dto: UpdateUserPermissionsDto,
|
||||
) {
|
||||
if (!isAdmin(actor)) {
|
||||
throw new ForbiddenException('Only system administrators can set user permissions');
|
||||
}
|
||||
|
||||
const user = await this.getManageableUser(actor, userId, { allowSelf: true });
|
||||
user.permissions = normalizePermissions(dto.permissions || {});
|
||||
await this.usersRepository.save(user);
|
||||
|
||||
return this.getUserPermissions(actor, user.id);
|
||||
}
|
||||
|
||||
async resetUserPermissions(actor: User, userId: string) {
|
||||
if (!isAdmin(actor)) {
|
||||
throw new ForbiddenException('Only system administrators can set user permissions');
|
||||
}
|
||||
|
||||
const user = await this.getManageableUser(actor, userId, { allowSelf: true });
|
||||
user.permissions = null;
|
||||
await this.usersRepository.save(user);
|
||||
|
||||
return this.getUserPermissions(actor, user.id);
|
||||
}
|
||||
|
||||
private async getManageableUser(
|
||||
actor: User,
|
||||
userId: string,
|
||||
options: { allowSelf?: boolean } = {},
|
||||
) {
|
||||
assertCanManageUsers(actor);
|
||||
|
||||
const user = await this.usersRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('user not found');
|
||||
}
|
||||
|
||||
if (!options.allowSelf && user.id === actor.id) {
|
||||
throw new ForbiddenException('cannot manage yourself here');
|
||||
}
|
||||
|
||||
assertCanManageTarget(actor, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
private assertAssignableRole(actor: User, roleCode: string) {
|
||||
if (isAdmin(actor)) {
|
||||
if (![ADMIN_ROLE, AGENT_ROLE, NORMAL_USER_ROLE].includes(roleCode)) {
|
||||
throw new BadRequestException('invalid role');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAgent(actor) && MANAGEABLE_ROLE_CODES.includes(roleCode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new ForbiddenException('cannot assign this role');
|
||||
}
|
||||
|
||||
private async getRoleByCode(code: string) {
|
||||
const role = await this.rolesRepository.findOne({ where: { code } });
|
||||
if (!role) {
|
||||
throw new BadRequestException('role does not exist');
|
||||
}
|
||||
|
||||
return role;
|
||||
}
|
||||
|
||||
private toUserItem(user: User) {
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
enabled: user.enabled,
|
||||
parentId: user.parentId,
|
||||
parentPath: user.parentPath,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
roles: (user.roles || []).map((role) => ({
|
||||
code: role.code,
|
||||
name: role.name,
|
||||
})),
|
||||
roleCode: user.roles?.[0]?.code,
|
||||
roleName: user.roles?.[0]?.name,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private getRolePermissions(roles: Role[]) {
|
||||
const permissions: Record<string, string[]> = {};
|
||||
|
||||
for (const role of roles) {
|
||||
const rolePermissions = role.permissions;
|
||||
if (Array.isArray(rolePermissions)) {
|
||||
if (rolePermissions.includes('*')) {
|
||||
permissions['*'] = ['*'];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rolePermissions) {
|
||||
for (const [resource, actions] of Object.entries(rolePermissions)) {
|
||||
permissions[resource] = Array.from(
|
||||
new Set([...(permissions[resource] || []), ...actions]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return permissions;
|
||||
}
|
||||
|
||||
private isDescendantOf(target: User, actor: User) {
|
||||
return target.id === actor.id || Boolean(target.parentPath?.includes(`/${actor.id}/`));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { AuditLog } from '../audit/entities/audit-log.entity';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { Role } from '../users/entities/role.entity';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { AdminLogsController } from './admin-logs.controller';
|
||||
import { AdminLogsService } from './admin-logs.service';
|
||||
import { AdminPermissionsController } from './admin-permissions.controller';
|
||||
import { AdminPermissionsService } from './admin-permissions.service';
|
||||
import { AdminUsersController } from './admin-users.controller';
|
||||
import { AdminUsersService } from './admin-users.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuditModule,
|
||||
AuthModule,
|
||||
TypeOrmModule.forFeature([AuditLog, Role, User]),
|
||||
],
|
||||
controllers: [
|
||||
AdminLogsController,
|
||||
AdminPermissionsController,
|
||||
AdminUsersController,
|
||||
],
|
||||
providers: [AdminLogsService, AdminPermissionsService, AdminUsersService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
@@ -0,0 +1,28 @@
|
||||
export type CreateAdminUserDto = {
|
||||
name?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
roleCode?: string;
|
||||
parentId?: string | null;
|
||||
mustChangePassword?: boolean;
|
||||
};
|
||||
|
||||
export type UpdateUserEnabledDto = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type UpdateAdminUserDto = {
|
||||
name?: string;
|
||||
email?: string;
|
||||
roleCode?: string;
|
||||
enabled?: boolean;
|
||||
mustChangePassword?: boolean;
|
||||
};
|
||||
|
||||
export type AssignUserRoleDto = {
|
||||
roleCode?: string;
|
||||
};
|
||||
|
||||
export type UpdateUserPermissionsDto = {
|
||||
permissions?: Record<string, string[]>;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
export const permissionResources = [
|
||||
{ resource: 'catalog/categories', name: '分类管理', actions: ['read', 'write'] },
|
||||
{ resource: 'catalog/products', name: '商品管理', actions: ['read', 'write'] },
|
||||
{ resource: 'orders', name: '订单管理', actions: ['read', 'write'] },
|
||||
{ resource: 'order-logs', name: '日志查询', actions: ['read', 'write'] },
|
||||
{ resource: 'admin/users', name: '用户管理', actions: ['read', 'write'] },
|
||||
{ resource: 'admin/audit-logs', name: '操作日志', actions: ['read'] },
|
||||
{ resource: 'admin/api-call-logs', name: '接口日志', actions: ['read'] },
|
||||
{ resource: 'admin/third-party', name: '第三方接口', actions: ['read', 'write'] },
|
||||
{ resource: 'admin/permissions', name: '权限配置', actions: ['read', 'write'] },
|
||||
];
|
||||
|
||||
export function normalizePermissions(permissions: Record<string, string[]>) {
|
||||
const allowedResources = new Set(
|
||||
permissionResources.map((item) => item.resource),
|
||||
);
|
||||
const allowedActionsByResource = new Map(
|
||||
permissionResources.map((item) => [item.resource, new Set(item.actions)]),
|
||||
);
|
||||
const normalized: Record<string, string[]> = {};
|
||||
|
||||
for (const [resource, actions] of Object.entries(permissions || {})) {
|
||||
if (!allowedResources.has(resource) || !Array.isArray(actions)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const allowedActions = allowedActionsByResource.get(resource);
|
||||
const nextActions = Array.from(
|
||||
new Set(actions.filter((action) => allowedActions?.has(action))),
|
||||
);
|
||||
if (nextActions.length) {
|
||||
normalized[resource] = nextActions;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { AuditModule } from './audit/audit.module';
|
||||
import { OperationLogInterceptor } from './audit/operation-log.interceptor';
|
||||
import { validateEnv } from './config/env';
|
||||
import { packageEnvPath, rootEnvPath } from './config/paths';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { CatalogModule } from './catalog/catalog.module';
|
||||
import { OrderLogsModule } from './order-logs/order-logs.module';
|
||||
import { OrdersModule } from './orders/orders.module';
|
||||
import { ThirdPartyModule } from './third-party/third-party.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
envFilePath: [rootEnvPath, packageEnvPath],
|
||||
isGlobal: true,
|
||||
validate: validateEnv,
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
DatabaseModule,
|
||||
AuditModule,
|
||||
AuthModule,
|
||||
AdminModule,
|
||||
ThirdPartyModule,
|
||||
CatalogModule,
|
||||
OrdersModule,
|
||||
OrderLogsModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: OperationLogInterceptor,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuditLog } from './entities/audit-log.entity';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AuditLog])],
|
||||
providers: [AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AuditLog } from './entities/audit-log.entity';
|
||||
|
||||
type WriteAuditLogInput = {
|
||||
actorId?: string | null;
|
||||
actorUsername?: string | null;
|
||||
action: string;
|
||||
resourceType?: string | null;
|
||||
resourceId?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
ipAddress?: string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(
|
||||
@InjectRepository(AuditLog)
|
||||
private readonly auditLogsRepository: Repository<AuditLog>,
|
||||
) {}
|
||||
|
||||
async write(input: WriteAuditLogInput) {
|
||||
const entity = this.auditLogsRepository.create({
|
||||
actorId: input.actorId ?? null,
|
||||
actorUsername: input.actorUsername ?? null,
|
||||
action: input.action,
|
||||
resourceType: input.resourceType ?? null,
|
||||
resourceId: input.resourceId ?? null,
|
||||
metadata: input.metadata ?? null,
|
||||
ipAddress: input.ipAddress ?? null,
|
||||
});
|
||||
|
||||
await this.auditLogsRepository.save(entity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('audit_logs')
|
||||
export class AuditLog {
|
||||
@PrimaryGeneratedColumn('increment', { type: 'bigint', unsigned: true })
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'actor_id', type: 'varchar', length: 36, nullable: true })
|
||||
actorId: string | null;
|
||||
|
||||
@Column({ name: 'actor_username', type: 'varchar', length: 64, nullable: true })
|
||||
actorUsername: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 64 })
|
||||
action: string;
|
||||
|
||||
@Column({ name: 'resource_type', type: 'varchar', length: 64, nullable: true })
|
||||
resourceType: string | null;
|
||||
|
||||
@Column({ name: 'resource_id', type: 'varchar', length: 64, nullable: true })
|
||||
resourceId: string | null;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
metadata: Record<string, unknown> | null;
|
||||
|
||||
@Column({ name: 'ip_address', type: 'varchar', length: 64, nullable: true })
|
||||
ipAddress: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const OPERATION_LOG_METADATA = Symbol('OPERATION_LOG_METADATA');
|
||||
|
||||
export type OperationLogOptions = {
|
||||
action: string;
|
||||
resourceType?: string;
|
||||
resourceIdParam?: string;
|
||||
resourceIdFromResult?: string;
|
||||
metadataFromBody?: string[];
|
||||
metadataFromQuery?: string[];
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export function OperationLog(options: OperationLogOptions) {
|
||||
return SetMetadata(OPERATION_LOG_METADATA, options);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { CallHandler, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { lastValueFrom, of, throwError } from 'rxjs';
|
||||
import { AuditService } from './audit.service';
|
||||
import { OperationLogInterceptor } from './operation-log.interceptor';
|
||||
|
||||
function createContext(request: Record<string, unknown>) {
|
||||
return {
|
||||
getHandler: () => function handler() {},
|
||||
getClass: () => class Controller {},
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request,
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
describe('OperationLogInterceptor', () => {
|
||||
it('writes a success operation log with masked request body', async () => {
|
||||
const write = jest.fn();
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn().mockReturnValue({
|
||||
action: 'admin.users.create',
|
||||
resourceType: 'user',
|
||||
resourceIdFromResult: 'id',
|
||||
metadataFromBody: ['name', 'password'],
|
||||
}),
|
||||
} as unknown as Reflector;
|
||||
const interceptor = new OperationLogInterceptor(
|
||||
{ write } as unknown as AuditService,
|
||||
reflector,
|
||||
);
|
||||
|
||||
const request = {
|
||||
user: { id: 'actor-1', name: 'root' },
|
||||
params: {},
|
||||
query: {},
|
||||
body: { name: 'demo', password: '123456' },
|
||||
headers: { 'user-agent': 'vitest' },
|
||||
ip: '127.0.0.1',
|
||||
};
|
||||
const next = {
|
||||
handle: () => of({ id: 'user-1' }),
|
||||
} as CallHandler;
|
||||
|
||||
await expect(lastValueFrom(interceptor.intercept(createContext(request), next))).resolves.toEqual({
|
||||
id: 'user-1',
|
||||
});
|
||||
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actorId: 'actor-1',
|
||||
actorUsername: 'root',
|
||||
action: 'admin.users.create',
|
||||
resourceType: 'user',
|
||||
resourceId: 'user-1',
|
||||
ipAddress: '127.0.0.1',
|
||||
metadata: expect.objectContaining({
|
||||
status: 'success',
|
||||
request: expect.objectContaining({
|
||||
body: { name: 'demo', password: '******' },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes a failed operation log and rethrows the error', async () => {
|
||||
const write = jest.fn();
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn().mockReturnValue({
|
||||
action: 'admin.users.update',
|
||||
resourceType: 'user',
|
||||
resourceIdParam: 'id',
|
||||
}),
|
||||
} as unknown as Reflector;
|
||||
const interceptor = new OperationLogInterceptor(
|
||||
{ write } as unknown as AuditService,
|
||||
reflector,
|
||||
);
|
||||
const error = new Error('保存失败');
|
||||
const request = {
|
||||
user: { id: 'actor-1', name: 'root' },
|
||||
params: { id: 'user-1' },
|
||||
query: {},
|
||||
body: {},
|
||||
headers: {},
|
||||
ip: '127.0.0.1',
|
||||
};
|
||||
const next = {
|
||||
handle: () => throwError(() => error),
|
||||
} as CallHandler;
|
||||
|
||||
await expect(
|
||||
lastValueFrom(interceptor.intercept(createContext(request), next)),
|
||||
).rejects.toThrow('保存失败');
|
||||
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'admin.users.update',
|
||||
resourceId: 'user-1',
|
||||
metadata: expect.objectContaining({
|
||||
status: 'failed',
|
||||
error: {
|
||||
name: 'Error',
|
||||
message: '保存失败',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { Request } from 'express';
|
||||
import { catchError, from, mergeMap, Observable, throwError } from 'rxjs';
|
||||
import { AuditService } from './audit.service';
|
||||
import {
|
||||
OPERATION_LOG_METADATA,
|
||||
type OperationLogOptions,
|
||||
} from './operation-log.decorator';
|
||||
|
||||
const sensitiveKeys = [
|
||||
'password',
|
||||
'oldPassword',
|
||||
'newPassword',
|
||||
'passwordHash',
|
||||
'token',
|
||||
'key',
|
||||
'secret',
|
||||
'studentPassword',
|
||||
];
|
||||
|
||||
type RequestWithUser = Request & {
|
||||
user?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class OperationLogInterceptor implements NestInterceptor {
|
||||
constructor(
|
||||
private readonly auditService: AuditService,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const options = this.reflector.getAllAndOverride<OperationLogOptions>(
|
||||
OPERATION_LOG_METADATA,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!options) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||
|
||||
return next.handle().pipe(
|
||||
mergeMap((result) =>
|
||||
from(
|
||||
this.writeLog({
|
||||
options,
|
||||
request,
|
||||
result,
|
||||
status: 'success',
|
||||
durationMs: Date.now() - startedAt,
|
||||
}),
|
||||
).pipe(mergeMap(() => [result])),
|
||||
),
|
||||
catchError((error: unknown) =>
|
||||
from(
|
||||
this.writeLog({
|
||||
options,
|
||||
request,
|
||||
status: 'failed',
|
||||
durationMs: Date.now() - startedAt,
|
||||
error,
|
||||
}),
|
||||
).pipe(mergeMap(() => throwError(() => error))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async writeLog(input: {
|
||||
options: OperationLogOptions;
|
||||
request: RequestWithUser;
|
||||
result?: unknown;
|
||||
status: 'success' | 'failed';
|
||||
durationMs: number;
|
||||
error?: unknown;
|
||||
}) {
|
||||
const { options, request, result, status, durationMs, error } = input;
|
||||
const actor = request.user;
|
||||
|
||||
await this.auditService.write({
|
||||
actorId: actor?.id ?? null,
|
||||
actorUsername: actor?.name || actor?.username || null,
|
||||
action: options.action,
|
||||
resourceType: options.resourceType ?? null,
|
||||
resourceId: this.getResourceId(options, request, result),
|
||||
metadata: {
|
||||
status,
|
||||
description: options.description,
|
||||
durationMs,
|
||||
request: this.pickRequestMetadata(request, options),
|
||||
error: error ? this.toErrorMetadata(error) : undefined,
|
||||
},
|
||||
ipAddress: this.getIp(request),
|
||||
});
|
||||
}
|
||||
|
||||
private getResourceId(
|
||||
options: OperationLogOptions,
|
||||
request: RequestWithUser,
|
||||
result?: unknown,
|
||||
) {
|
||||
if (options.resourceIdParam) {
|
||||
return String(request.params?.[options.resourceIdParam] ?? '') || null;
|
||||
}
|
||||
|
||||
if (options.resourceIdFromResult) {
|
||||
const value = this.readPath(result, options.resourceIdFromResult);
|
||||
return value == null ? null : String(value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private pickRequestMetadata(
|
||||
request: RequestWithUser,
|
||||
options: OperationLogOptions,
|
||||
) {
|
||||
return {
|
||||
params: this.maskValue(request.params || {}),
|
||||
query: this.pickAndMask(request.query, options.metadataFromQuery),
|
||||
body: this.pickAndMask(request.body, options.metadataFromBody),
|
||||
userAgent: request.headers['user-agent'] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private pickAndMask(value: unknown, keys?: string[]) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const source = value as Record<string, unknown>;
|
||||
if (!keys?.length) {
|
||||
return this.maskValue(source);
|
||||
}
|
||||
|
||||
const picked: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
if (key in source) {
|
||||
picked[key] = source[key];
|
||||
}
|
||||
}
|
||||
|
||||
return this.maskValue(picked);
|
||||
}
|
||||
|
||||
private maskValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => this.maskValue(item));
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const masked: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (this.isSensitiveKey(key)) {
|
||||
masked[key] = '******';
|
||||
} else {
|
||||
masked[key] = this.maskValue(item);
|
||||
}
|
||||
}
|
||||
|
||||
return masked;
|
||||
}
|
||||
|
||||
private isSensitiveKey(key: string) {
|
||||
const normalized = key.toLowerCase();
|
||||
return sensitiveKeys.some((item) => normalized.includes(item.toLowerCase()));
|
||||
}
|
||||
|
||||
private toErrorMetadata(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return { message: String(error) };
|
||||
}
|
||||
|
||||
private readPath(value: unknown, path: string) {
|
||||
return path.split('.').reduce<unknown>((current, key) => {
|
||||
if (!current || typeof current !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (current as Record<string, unknown>)[key];
|
||||
}, value);
|
||||
}
|
||||
|
||||
private getIp(request: Request) {
|
||||
const forwardedFor = request.headers['x-forwarded-for'];
|
||||
if (typeof forwardedFor === 'string') {
|
||||
return forwardedFor.split(',')[0]?.trim() || null;
|
||||
}
|
||||
|
||||
return request.ip || request.socket.remoteAddress || null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { OperationLog } from '../audit/operation-log.decorator';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthGuard } from './auth.guard';
|
||||
import type { ChangePasswordDto, LoginDto, RegisterDto } from './dto/login.dto';
|
||||
import type { AuthenticatedRequest } from './types/authenticated-request';
|
||||
import type { Request } from 'express';
|
||||
|
||||
@Controller('api/user')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
login(@Body() dto: LoginDto, @Req() request: Request) {
|
||||
return this.authService.login(dto, request);
|
||||
}
|
||||
|
||||
@Post('register')
|
||||
@OperationLog({
|
||||
action: 'auth.register',
|
||||
resourceType: 'user',
|
||||
resourceIdFromResult: 'user.id',
|
||||
metadataFromBody: ['name', 'email'],
|
||||
description: '注册用户',
|
||||
})
|
||||
register(@Body() dto: RegisterDto) {
|
||||
return this.authService.register(dto);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Post('change-password')
|
||||
@OperationLog({
|
||||
action: 'auth.change_password',
|
||||
resourceType: 'user',
|
||||
resourceIdFromResult: 'user.id',
|
||||
metadataFromBody: [],
|
||||
description: '修改密码',
|
||||
})
|
||||
changePassword(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Body() dto: ChangePasswordDto,
|
||||
) {
|
||||
return this.authService.changePassword(request.user, dto);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Post('refresh-token')
|
||||
refreshToken(@Req() request: AuthenticatedRequest) {
|
||||
return this.authService.refreshToken(request.user);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Post('logout')
|
||||
@OperationLog({
|
||||
action: 'auth.logout',
|
||||
resourceType: 'user',
|
||||
resourceIdFromResult: 'id',
|
||||
metadataFromBody: [],
|
||||
description: '退出登录',
|
||||
})
|
||||
logout(@Req() request: AuthenticatedRequest) {
|
||||
return this.authService.logout(request.user);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('userInfo')
|
||||
userInfo(@Req() request: AuthenticatedRequest) {
|
||||
return this.authService.toUserInfo(request.user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import type { AuthenticatedRequest } from './types/authenticated-request';
|
||||
import { UsersService } from '../users/users.service';
|
||||
|
||||
type JwtPayload = {
|
||||
sub: string;
|
||||
username: string;
|
||||
tokenVersion?: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly usersService: UsersService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const token = this.extractToken(request);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('Missing authorization token');
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
|
||||
const user = await this.usersService.findById(payload.sub);
|
||||
if (!user || !user.enabled) {
|
||||
throw new UnauthorizedException('Invalid user');
|
||||
}
|
||||
if ((payload.tokenVersion ?? 0) !== (user.tokenVersion ?? 0)) {
|
||||
throw new UnauthorizedException('Expired authorization token');
|
||||
}
|
||||
request.user = user;
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid authorization token');
|
||||
}
|
||||
}
|
||||
|
||||
private extractToken(request: AuthenticatedRequest) {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { Role } from '../users/entities/role.entity';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { AuthGuard } from './auth.guard';
|
||||
|
||||
describe('AuthGuard provider wiring', () => {
|
||||
it('resolves with JwtService and UsersService dependencies', async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthGuard,
|
||||
UsersService,
|
||||
{
|
||||
provide: JwtService,
|
||||
useValue: {
|
||||
verifyAsync: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(User),
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Role),
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: AuditService,
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
expect(moduleRef.get(AuthGuard)).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { Role } from '../users/entities/role.entity';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthGuard } from './auth.guard';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuditModule,
|
||||
UsersModule,
|
||||
TypeOrmModule.forFeature([Role]),
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.getOrThrow<string>('AUTH_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: '8h',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthGuard, AuthService],
|
||||
exports: [AuthGuard, JwtModule, UsersModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import type { Request } from 'express';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { Role } from '../users/entities/role.entity';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import type { ChangePasswordDto, LoginDto, RegisterDto } from './dto/login.dto';
|
||||
|
||||
type LoginResult = 'success' | 'failed';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly auditService: AuditService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly usersService: UsersService,
|
||||
@InjectRepository(Role)
|
||||
private readonly rolesRepository: Repository<Role>,
|
||||
) {}
|
||||
|
||||
async login(dto: LoginDto, request: Request) {
|
||||
const loginName = (dto.userName || dto.username || '').trim();
|
||||
const password = dto.password || '';
|
||||
|
||||
if (!loginName || !password) {
|
||||
await this.writeLoginAudit(loginName, 'failed', request, 'empty credentials');
|
||||
throw new BadRequestException('用户名或密码不能为空');
|
||||
}
|
||||
|
||||
const user = await this.usersService.findByLoginName(loginName);
|
||||
if (!user || !user.enabled) {
|
||||
await this.writeLoginAudit(loginName, 'failed', request, 'user not found or disabled');
|
||||
throw new BadRequestException('账号或密码错误');
|
||||
}
|
||||
|
||||
const passwordMatched = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!passwordMatched) {
|
||||
await this.writeLoginAudit(loginName, 'failed', request, 'bad password', user);
|
||||
throw new BadRequestException('账号或密码错误');
|
||||
}
|
||||
|
||||
await this.usersService.markLoginSuccess(user.id);
|
||||
await this.writeLoginAudit(loginName, 'success', request, null, user);
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
token: await this.signUserToken(user),
|
||||
user: this.toUserInfo(user),
|
||||
};
|
||||
}
|
||||
|
||||
async register(dto: RegisterDto) {
|
||||
const name = String(dto.name || '').trim();
|
||||
const email = String(dto.email || '').trim();
|
||||
const password = String(dto.password || '');
|
||||
|
||||
if (!name || !email || !password) {
|
||||
throw new BadRequestException('用户名、邮箱和密码不能为空');
|
||||
}
|
||||
if (password.length < 6) {
|
||||
throw new BadRequestException('密码长度不能少于 6 位');
|
||||
}
|
||||
|
||||
const existed = await this.usersService.findByEmail(email);
|
||||
if (existed) {
|
||||
throw new BadRequestException('邮箱已被注册');
|
||||
}
|
||||
|
||||
const normalRole = await this.rolesRepository.findOne({ where: { code: 'user' } });
|
||||
const user = new User();
|
||||
user.id = randomUUID();
|
||||
user.name = name;
|
||||
user.email = email;
|
||||
user.passwordHash = await bcrypt.hash(password, 10);
|
||||
user.enabled = true;
|
||||
user.parentId = null;
|
||||
user.parentPath = null;
|
||||
user.tokenVersion = 0;
|
||||
user.mustChangePassword = false;
|
||||
user.roles = normalRole ? [normalRole] : [];
|
||||
|
||||
await this.usersService.save(user);
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
token: await this.signUserToken(user),
|
||||
user: this.toUserInfo(user),
|
||||
};
|
||||
}
|
||||
|
||||
async changePassword(user: User, dto: ChangePasswordDto) {
|
||||
const oldPassword = String(dto.oldPassword || '');
|
||||
const newPassword = String(dto.newPassword || '');
|
||||
if (!oldPassword || !newPassword) {
|
||||
throw new BadRequestException('原密码和新密码不能为空');
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
throw new BadRequestException('新密码长度不能少于 6 位');
|
||||
}
|
||||
|
||||
const matched = await bcrypt.compare(oldPassword, user.passwordHash);
|
||||
if (!matched) {
|
||||
throw new BadRequestException('原密码不正确');
|
||||
}
|
||||
|
||||
user.passwordHash = await bcrypt.hash(newPassword, 10);
|
||||
user.mustChangePassword = false;
|
||||
user.tokenVersion = (user.tokenVersion || 0) + 1;
|
||||
await this.usersService.save(user);
|
||||
|
||||
return { status: 'ok', token: await this.signUserToken(user) };
|
||||
}
|
||||
|
||||
async refreshToken(user: User) {
|
||||
return {
|
||||
status: 'ok',
|
||||
token: await this.signUserToken(user),
|
||||
user: this.toUserInfo(user),
|
||||
};
|
||||
}
|
||||
|
||||
async logout(user: User) {
|
||||
await this.usersService.incrementTokenVersion(user.id);
|
||||
return { status: 'ok' };
|
||||
}
|
||||
|
||||
toUserInfo(user: User) {
|
||||
const primaryRole = user.roles?.[0];
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.name,
|
||||
email: user.email,
|
||||
role: primaryRole?.code,
|
||||
roleName: primaryRole?.name,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
avatar:
|
||||
'https://lf1-xgcdn-tos.pstatp.com/obj/vcloud/vadmin/start.8e0e4855ee346a46ccff8ff3e24db27b.png',
|
||||
permissions: user.permissions ?? this.getPermissions(user.roles || []),
|
||||
};
|
||||
}
|
||||
|
||||
private signUserToken(user: User) {
|
||||
return this.jwtService.signAsync({
|
||||
sub: user.id,
|
||||
username: user.name,
|
||||
tokenVersion: user.tokenVersion || 0,
|
||||
});
|
||||
}
|
||||
|
||||
private getPermissions(roles: Role[]) {
|
||||
const permissions: Record<string, string[]> = {};
|
||||
|
||||
for (const role of roles) {
|
||||
const rolePermissions = role.permissions;
|
||||
if (Array.isArray(rolePermissions)) {
|
||||
if (rolePermissions.includes('*')) {
|
||||
permissions['*'] = ['*'];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rolePermissions) {
|
||||
for (const [resource, actions] of Object.entries(rolePermissions)) {
|
||||
permissions[resource] = Array.from(
|
||||
new Set([...(permissions[resource] || []), ...actions]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return permissions;
|
||||
}
|
||||
|
||||
private async writeLoginAudit(
|
||||
username: string,
|
||||
result: LoginResult,
|
||||
request: Request,
|
||||
failureReason: string | null,
|
||||
user?: User,
|
||||
) {
|
||||
const loginName = username || '(empty)';
|
||||
await this.auditService.write({
|
||||
actorId: user?.id ?? null,
|
||||
actorUsername: user?.name ?? loginName,
|
||||
action: 'auth.login',
|
||||
resourceType: 'user',
|
||||
resourceId: user?.id ?? null,
|
||||
metadata: {
|
||||
loginResult: result,
|
||||
loginName,
|
||||
failureReason,
|
||||
userAgent: request.headers['user-agent'] ?? null,
|
||||
},
|
||||
ipAddress: this.getIp(request),
|
||||
});
|
||||
}
|
||||
|
||||
private getIp(request: Request) {
|
||||
const forwardedFor = request.headers['x-forwarded-for'];
|
||||
if (typeof forwardedFor === 'string') {
|
||||
return forwardedFor.split(',')[0]?.trim() || null;
|
||||
}
|
||||
|
||||
return request.ip || request.socket.remoteAddress || null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export type LoginDto = {
|
||||
userName?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
export type RegisterDto = {
|
||||
name?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
export type ChangePasswordDto = {
|
||||
oldPassword?: string;
|
||||
newPassword?: string;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Request } from 'express';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
|
||||
export type AuthenticatedRequest = Request & {
|
||||
user: User;
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { OperationLog } from '../audit/operation-log.decorator';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
import type { AuthenticatedRequest } from '../auth/types/authenticated-request';
|
||||
import { CatalogService } from './catalog.service';
|
||||
import type {
|
||||
CatalogListQuery,
|
||||
CreateCatalogCategoryDto,
|
||||
CreateCatalogProductDto,
|
||||
UpdateCatalogCategoryDto,
|
||||
UpdateCatalogProductDto,
|
||||
UpdateEnabledDto,
|
||||
} from './dto/catalog.dto';
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Controller('api/admin/catalog')
|
||||
export class CatalogController {
|
||||
constructor(private readonly catalogService: CatalogService) {}
|
||||
|
||||
@Get('categories')
|
||||
listCategories(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Query() query: CatalogListQuery,
|
||||
) {
|
||||
return this.catalogService.listCategories(request.user, query);
|
||||
}
|
||||
|
||||
@Post('categories')
|
||||
@OperationLog({
|
||||
action: 'catalog.categories.create',
|
||||
resourceType: 'category',
|
||||
resourceIdFromResult: 'id',
|
||||
metadataFromBody: ['name', 'sortOrder', 'enabled'],
|
||||
description: '新建自营分类',
|
||||
})
|
||||
createCategory(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Body() dto: CreateCatalogCategoryDto,
|
||||
) {
|
||||
return this.catalogService.createCategory(request.user, dto);
|
||||
}
|
||||
|
||||
@Patch('categories/:id')
|
||||
@OperationLog({
|
||||
action: 'catalog.categories.update',
|
||||
resourceType: 'category',
|
||||
resourceIdParam: 'id',
|
||||
metadataFromBody: ['name', 'sortOrder', 'enabled'],
|
||||
description: '编辑自营分类',
|
||||
})
|
||||
updateCategory(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateCatalogCategoryDto,
|
||||
) {
|
||||
return this.catalogService.updateCategory(request.user, id, dto);
|
||||
}
|
||||
|
||||
@Patch('categories/:id/enabled')
|
||||
@OperationLog({
|
||||
action: 'catalog.categories.update_enabled',
|
||||
resourceType: 'category',
|
||||
resourceIdParam: 'id',
|
||||
metadataFromBody: ['enabled'],
|
||||
description: '更新分类启停状态',
|
||||
})
|
||||
updateCategoryEnabled(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateEnabledDto,
|
||||
) {
|
||||
return this.catalogService.updateCategoryEnabled(request.user, id, dto);
|
||||
}
|
||||
|
||||
@Delete('categories/:id')
|
||||
@OperationLog({
|
||||
action: 'catalog.categories.delete',
|
||||
resourceType: 'category',
|
||||
resourceIdParam: 'id',
|
||||
description: '删除自营分类',
|
||||
})
|
||||
deleteCategory(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.catalogService.deleteCategory(request.user, id);
|
||||
}
|
||||
|
||||
@Post('categories/sync-third-party')
|
||||
@OperationLog({
|
||||
action: 'catalog.categories.sync_third_party',
|
||||
resourceType: 'category',
|
||||
description: '同步第三方分类',
|
||||
})
|
||||
syncThirdPartyCategories(@Req() request: AuthenticatedRequest) {
|
||||
return this.catalogService.syncThirdPartyCategories(request.user);
|
||||
}
|
||||
|
||||
@Get('sync-jobs')
|
||||
listSyncJobs(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Query() query: CatalogListQuery,
|
||||
) {
|
||||
return this.catalogService.listSyncJobs(request.user, query);
|
||||
}
|
||||
|
||||
@Get('products')
|
||||
listProducts(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Query() query: CatalogListQuery,
|
||||
) {
|
||||
return this.catalogService.listProducts(request.user, query);
|
||||
}
|
||||
|
||||
@Post('products')
|
||||
@OperationLog({
|
||||
action: 'catalog.products.create',
|
||||
resourceType: 'product',
|
||||
resourceIdFromResult: 'id',
|
||||
metadataFromBody: ['name', 'categoryId', 'price', 'fulfillmentType', 'enabled'],
|
||||
description: '新建自营商品',
|
||||
})
|
||||
createProduct(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Body() dto: CreateCatalogProductDto,
|
||||
) {
|
||||
return this.catalogService.createProduct(request.user, dto);
|
||||
}
|
||||
|
||||
@Patch('products/:id')
|
||||
@OperationLog({
|
||||
action: 'catalog.products.update',
|
||||
resourceType: 'product',
|
||||
resourceIdParam: 'id',
|
||||
metadataFromBody: ['name', 'categoryId', 'price', 'fulfillmentType', 'enabled'],
|
||||
description: '编辑自营商品',
|
||||
})
|
||||
updateProduct(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateCatalogProductDto,
|
||||
) {
|
||||
return this.catalogService.updateProduct(request.user, id, dto);
|
||||
}
|
||||
|
||||
@Patch('products/:id/enabled')
|
||||
@OperationLog({
|
||||
action: 'catalog.products.update_enabled',
|
||||
resourceType: 'product',
|
||||
resourceIdParam: 'id',
|
||||
metadataFromBody: ['enabled'],
|
||||
description: '更新商品启停状态',
|
||||
})
|
||||
updateProductEnabled(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateEnabledDto,
|
||||
) {
|
||||
return this.catalogService.updateProductEnabled(request.user, id, dto);
|
||||
}
|
||||
|
||||
@Delete('products/:id')
|
||||
@OperationLog({
|
||||
action: 'catalog.products.delete',
|
||||
resourceType: 'product',
|
||||
resourceIdParam: 'id',
|
||||
description: '删除商品',
|
||||
})
|
||||
deleteProduct(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.catalogService.deleteProduct(request.user, id);
|
||||
}
|
||||
|
||||
@Post('products/sync-third-party')
|
||||
@OperationLog({
|
||||
action: 'catalog.products.sync_third_party',
|
||||
resourceType: 'product',
|
||||
metadataFromQuery: ['remoteCategoryId'],
|
||||
description: '同步第三方商品',
|
||||
})
|
||||
syncThirdPartyProducts(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Query() query: CatalogListQuery,
|
||||
) {
|
||||
return this.catalogService.syncThirdPartyProducts(request.user, query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { Category } from '../third-party/entities/category.entity';
|
||||
import { Course } from '../third-party/entities/course.entity';
|
||||
import { ThirdPartyModule } from '../third-party/third-party.module';
|
||||
import { CatalogController } from './catalog.controller';
|
||||
import { CatalogService } from './catalog.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
ThirdPartyModule,
|
||||
TypeOrmModule.forFeature([Category, Course]),
|
||||
],
|
||||
controllers: [CatalogController],
|
||||
providers: [CatalogService],
|
||||
})
|
||||
export class CatalogModule {}
|
||||
@@ -0,0 +1,581 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Brackets, Repository } from 'typeorm';
|
||||
import { assertAdmin } from '../admin/admin-access';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { Category, SourceType } from '../third-party/entities/category.entity';
|
||||
import {
|
||||
Course,
|
||||
OrderFormField,
|
||||
OrderFormFieldType,
|
||||
} from '../third-party/entities/course.entity';
|
||||
import { ThirdPartyCatalogService } from '../third-party/third-party-catalog.service';
|
||||
import type {
|
||||
CatalogListQuery,
|
||||
CreateCatalogCategoryDto,
|
||||
CreateCatalogProductDto,
|
||||
FulfillmentType,
|
||||
OrderFormFieldDto,
|
||||
UpdateCatalogCategoryDto,
|
||||
UpdateCatalogProductDto,
|
||||
UpdateEnabledDto,
|
||||
} from './dto/catalog.dto';
|
||||
|
||||
const SOURCE_ALL = 'all';
|
||||
const SELF_OWNED_SOURCE: SourceType = 'self_owned';
|
||||
const DEFAULT_SELF_FULFILLMENT: FulfillmentType = 'local_only';
|
||||
const FULFILLMENT_TYPES = new Set<FulfillmentType>([
|
||||
'third_party_api',
|
||||
'local_only',
|
||||
'manual',
|
||||
]);
|
||||
const ORDER_FORM_FIELD_TYPES = new Set([
|
||||
'text',
|
||||
'password',
|
||||
'phone',
|
||||
'number',
|
||||
'textarea',
|
||||
'select',
|
||||
'checkbox',
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class CatalogService {
|
||||
constructor(
|
||||
@InjectRepository(Category)
|
||||
private readonly categoriesRepository: Repository<Category>,
|
||||
@InjectRepository(Course)
|
||||
private readonly productsRepository: Repository<Course>,
|
||||
private readonly thirdPartyCatalogService: ThirdPartyCatalogService,
|
||||
) {}
|
||||
|
||||
async listCategories(actor: User, query: CatalogListQuery) {
|
||||
assertAdmin(actor);
|
||||
const { page, pageSize } = this.getPagination(query);
|
||||
const keyword = String(query.keyword || '').trim();
|
||||
const sourceType = this.normalizeSourceType(query.sourceType);
|
||||
const enabled = this.normalizeEnabled(query.enabled);
|
||||
|
||||
const builder = this.categoriesRepository
|
||||
.createQueryBuilder('category')
|
||||
.orderBy('category.sortOrder', 'ASC')
|
||||
.addOrderBy('category.updatedAt', 'DESC')
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
|
||||
if (sourceType) {
|
||||
builder.andWhere('category.sourceType = :sourceType', { sourceType });
|
||||
}
|
||||
if (typeof enabled === 'boolean') {
|
||||
builder.andWhere('category.enabled = :enabled', { enabled });
|
||||
}
|
||||
if (keyword) {
|
||||
builder.andWhere(
|
||||
new Brackets((qb) => {
|
||||
qb.where('category.name LIKE :keyword', {
|
||||
keyword: `%${keyword}%`,
|
||||
})
|
||||
.orWhere('category.externalId LIKE :keyword', {
|
||||
keyword: `%${keyword}%`,
|
||||
})
|
||||
.orWhere('category.remoteCategoryId LIKE :keyword', {
|
||||
keyword: `%${keyword}%`,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const [list, total] = await builder.getManyAndCount();
|
||||
return { list, total, page, pageSize };
|
||||
}
|
||||
|
||||
async createCategory(actor: User, dto: CreateCatalogCategoryDto) {
|
||||
assertAdmin(actor);
|
||||
const name = this.normalizeRequiredString(dto.name, '分类名称不能为空');
|
||||
const category = this.categoriesRepository.create({
|
||||
id: randomUUID(),
|
||||
sourceType: SELF_OWNED_SOURCE,
|
||||
apiAccountId: null,
|
||||
provider: null,
|
||||
externalId: null,
|
||||
remoteCategoryId: null,
|
||||
name,
|
||||
sortOrder: this.normalizeInteger(dto.sortOrder, 0),
|
||||
enabled: dto.enabled ?? true,
|
||||
rawPayload: null,
|
||||
lastSyncedAt: null,
|
||||
});
|
||||
|
||||
return this.categoriesRepository.save(category);
|
||||
}
|
||||
|
||||
async updateCategory(
|
||||
actor: User,
|
||||
id: string,
|
||||
dto: UpdateCatalogCategoryDto,
|
||||
) {
|
||||
assertAdmin(actor);
|
||||
const category = await this.getCategory(id);
|
||||
this.assertSelfOwned(category, '第三方分类只能同步或停用,不能手工编辑');
|
||||
|
||||
if (dto.name !== undefined) {
|
||||
category.name = this.normalizeRequiredString(dto.name, '分类名称不能为空');
|
||||
}
|
||||
if (dto.sortOrder !== undefined) {
|
||||
category.sortOrder = this.normalizeInteger(dto.sortOrder, 0);
|
||||
}
|
||||
if (typeof dto.enabled === 'boolean') {
|
||||
category.enabled = dto.enabled;
|
||||
}
|
||||
|
||||
return this.categoriesRepository.save(category);
|
||||
}
|
||||
|
||||
async updateCategoryEnabled(actor: User, id: string, dto: UpdateEnabledDto) {
|
||||
assertAdmin(actor);
|
||||
const category = await this.getCategory(id);
|
||||
category.enabled = Boolean(dto.enabled);
|
||||
return this.categoriesRepository.save(category);
|
||||
}
|
||||
|
||||
async deleteCategory(actor: User, id: string) {
|
||||
assertAdmin(actor);
|
||||
const category = await this.getCategory(id);
|
||||
this.assertSelfOwned(category, '第三方分类不能删除,请使用停用');
|
||||
|
||||
const productCount = await this.productsRepository.count({
|
||||
where: { categoryId: category.id },
|
||||
});
|
||||
if (productCount > 0) {
|
||||
throw new BadRequestException('分类下已有商品,不能删除');
|
||||
}
|
||||
|
||||
await this.categoriesRepository.remove(category);
|
||||
return { id, deleted: true };
|
||||
}
|
||||
|
||||
async listProducts(actor: User, query: CatalogListQuery) {
|
||||
assertAdmin(actor);
|
||||
const { page, pageSize } = this.getPagination(query);
|
||||
const keyword = String(query.keyword || '').trim();
|
||||
const sourceType = this.normalizeSourceType(query.sourceType);
|
||||
const enabled = this.normalizeEnabled(query.enabled);
|
||||
const categoryId = String(query.categoryId || '').trim();
|
||||
const remoteCategoryId = String(query.remoteCategoryId || '').trim();
|
||||
|
||||
const builder = this.productsRepository
|
||||
.createQueryBuilder('product')
|
||||
.orderBy('product.sortOrder', 'ASC')
|
||||
.addOrderBy('product.updatedAt', 'DESC')
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
|
||||
if (sourceType) {
|
||||
builder.andWhere('product.sourceType = :sourceType', { sourceType });
|
||||
}
|
||||
if (typeof enabled === 'boolean') {
|
||||
builder.andWhere('product.enabled = :enabled', { enabled });
|
||||
}
|
||||
if (categoryId) {
|
||||
builder.andWhere('product.categoryId = :categoryId', { categoryId });
|
||||
}
|
||||
if (remoteCategoryId) {
|
||||
builder.andWhere('product.remoteCategoryId = :remoteCategoryId', {
|
||||
remoteCategoryId,
|
||||
});
|
||||
}
|
||||
if (keyword) {
|
||||
builder.andWhere(
|
||||
new Brackets((qb) => {
|
||||
qb.where('product.name LIKE :keyword', {
|
||||
keyword: `%${keyword}%`,
|
||||
})
|
||||
.orWhere('product.externalId LIKE :keyword', {
|
||||
keyword: `%${keyword}%`,
|
||||
})
|
||||
.orWhere('product.remoteCourseId LIKE :keyword', {
|
||||
keyword: `%${keyword}%`,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const [products, total] = await builder.getManyAndCount();
|
||||
const categoryMap = await this.getCategoryMap(products);
|
||||
return {
|
||||
list: products.map((product) => this.toProductItem(product, categoryMap)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async createProduct(actor: User, dto: CreateCatalogProductDto) {
|
||||
assertAdmin(actor);
|
||||
const category = await this.getOptionalCategory(dto.categoryId);
|
||||
if (category) {
|
||||
this.assertSelfOwned(category, '自营商品只能选择自营分类');
|
||||
}
|
||||
|
||||
const product = this.productsRepository.create({
|
||||
id: randomUUID(),
|
||||
sourceType: SELF_OWNED_SOURCE,
|
||||
apiAccountId: null,
|
||||
provider: null,
|
||||
externalId: null,
|
||||
categoryId: category?.id ?? null,
|
||||
remoteCategoryId: null,
|
||||
remoteCourseId: null,
|
||||
name: this.normalizeRequiredString(dto.name, '商品名称不能为空'),
|
||||
price: this.normalizePrice(dto.price),
|
||||
coverUrl: this.normalizeOptionalString(dto.coverUrl),
|
||||
content: this.normalizeOptionalString(dto.content),
|
||||
stock: this.normalizeNullableInteger(dto.stock),
|
||||
salesLimit: this.normalizeNullableInteger(dto.salesLimit),
|
||||
fulfillmentType: this.normalizeFulfillmentType(
|
||||
dto.fulfillmentType,
|
||||
DEFAULT_SELF_FULFILLMENT,
|
||||
),
|
||||
afterSalesNote: this.normalizeOptionalString(dto.afterSalesNote),
|
||||
orderFormSchema: this.normalizeOrderFormSchema(dto.orderFormSchema),
|
||||
sortOrder: this.normalizeInteger(dto.sortOrder, 0),
|
||||
isFavorite: false,
|
||||
enabled: dto.enabled ?? true,
|
||||
rawPayload: null,
|
||||
lastSyncedAt: null,
|
||||
});
|
||||
|
||||
const saved = await this.productsRepository.save(product);
|
||||
return this.toProductItem(saved, category ? new Map([[category.id, category]]) : new Map());
|
||||
}
|
||||
|
||||
async updateProduct(
|
||||
actor: User,
|
||||
id: string,
|
||||
dto: UpdateCatalogProductDto,
|
||||
) {
|
||||
assertAdmin(actor);
|
||||
const product = await this.getProduct(id);
|
||||
this.assertSelfOwned(product, '第三方商品只能同步或停用,不能手工编辑');
|
||||
|
||||
if (dto.categoryId !== undefined) {
|
||||
const category = await this.getOptionalCategory(dto.categoryId);
|
||||
if (category) {
|
||||
this.assertSelfOwned(category, '自营商品只能选择自营分类');
|
||||
}
|
||||
product.categoryId = category?.id ?? null;
|
||||
}
|
||||
if (dto.name !== undefined) {
|
||||
product.name = this.normalizeRequiredString(dto.name, '商品名称不能为空');
|
||||
}
|
||||
if (dto.price !== undefined) {
|
||||
product.price = this.normalizePrice(dto.price);
|
||||
}
|
||||
if (dto.coverUrl !== undefined) {
|
||||
product.coverUrl = this.normalizeOptionalString(dto.coverUrl);
|
||||
}
|
||||
if (dto.content !== undefined) {
|
||||
product.content = this.normalizeOptionalString(dto.content);
|
||||
}
|
||||
if (dto.stock !== undefined) {
|
||||
product.stock = this.normalizeNullableInteger(dto.stock);
|
||||
}
|
||||
if (dto.salesLimit !== undefined) {
|
||||
product.salesLimit = this.normalizeNullableInteger(dto.salesLimit);
|
||||
}
|
||||
if (dto.fulfillmentType !== undefined) {
|
||||
product.fulfillmentType = this.normalizeFulfillmentType(
|
||||
dto.fulfillmentType,
|
||||
DEFAULT_SELF_FULFILLMENT,
|
||||
);
|
||||
}
|
||||
if (dto.afterSalesNote !== undefined) {
|
||||
product.afterSalesNote = this.normalizeOptionalString(dto.afterSalesNote);
|
||||
}
|
||||
if (dto.orderFormSchema !== undefined) {
|
||||
product.orderFormSchema = this.normalizeOrderFormSchema(dto.orderFormSchema);
|
||||
}
|
||||
if (dto.sortOrder !== undefined) {
|
||||
product.sortOrder = this.normalizeInteger(dto.sortOrder, 0);
|
||||
}
|
||||
if (typeof dto.enabled === 'boolean') {
|
||||
product.enabled = dto.enabled;
|
||||
}
|
||||
|
||||
const saved = await this.productsRepository.save(product);
|
||||
const categoryMap = await this.getCategoryMap([saved]);
|
||||
return this.toProductItem(saved, categoryMap);
|
||||
}
|
||||
|
||||
async updateProductEnabled(actor: User, id: string, dto: UpdateEnabledDto) {
|
||||
assertAdmin(actor);
|
||||
const product = await this.getProduct(id);
|
||||
product.enabled = Boolean(dto.enabled);
|
||||
const saved = await this.productsRepository.save(product);
|
||||
const categoryMap = await this.getCategoryMap([saved]);
|
||||
return this.toProductItem(saved, categoryMap);
|
||||
}
|
||||
|
||||
async deleteProduct(actor: User, id: string) {
|
||||
assertAdmin(actor);
|
||||
const product = await this.getProduct(id);
|
||||
await this.productsRepository.remove(product);
|
||||
return { id, deleted: true };
|
||||
}
|
||||
|
||||
async syncThirdPartyCategories(actor: User) {
|
||||
assertAdmin(actor);
|
||||
return this.thirdPartyCatalogService.syncThirdPartyCatalog({
|
||||
triggerType: 'manual',
|
||||
});
|
||||
}
|
||||
|
||||
async syncThirdPartyProducts(actor: User, query: CatalogListQuery) {
|
||||
assertAdmin(actor);
|
||||
const remoteCategoryId = this.normalizeOptionalString(
|
||||
query.remoteCategoryId,
|
||||
);
|
||||
return this.thirdPartyCatalogService.syncThirdPartyCatalog({
|
||||
triggerType: 'manual',
|
||||
remoteCategoryId,
|
||||
});
|
||||
}
|
||||
|
||||
async listSyncJobs(actor: User, query: CatalogListQuery) {
|
||||
assertAdmin(actor);
|
||||
return this.thirdPartyCatalogService.listSyncJobs(query);
|
||||
}
|
||||
|
||||
private getPagination(query: CatalogListQuery) {
|
||||
return {
|
||||
page: Math.max(Number(query.page || 1), 1),
|
||||
pageSize: Math.min(Math.max(Number(query.pageSize || 10), 1), 100),
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeSourceType(sourceType?: SourceType | 'all') {
|
||||
if (!sourceType || sourceType === SOURCE_ALL) {
|
||||
return null;
|
||||
}
|
||||
if (sourceType === 'third_party' || sourceType === SELF_OWNED_SOURCE) {
|
||||
return sourceType;
|
||||
}
|
||||
throw new BadRequestException('商品来源不正确');
|
||||
}
|
||||
|
||||
private normalizeEnabled(value?: string) {
|
||||
if (value === undefined || value === '' || value === SOURCE_ALL) {
|
||||
return null;
|
||||
}
|
||||
if (value === 'true' || value === '1') {
|
||||
return true;
|
||||
}
|
||||
if (value === 'false' || value === '0') {
|
||||
return false;
|
||||
}
|
||||
throw new BadRequestException('启停状态不正确');
|
||||
}
|
||||
|
||||
private normalizeRequiredString(value: unknown, message: string) {
|
||||
const normalized = String(value ?? '').trim();
|
||||
if (!normalized) {
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizeOptionalString(value: unknown) {
|
||||
const normalized = String(value ?? '').trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
private normalizeInteger(value: unknown, fallback: number) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
const numberValue = Number(value);
|
||||
if (!Number.isInteger(numberValue)) {
|
||||
throw new BadRequestException('排序必须是整数');
|
||||
}
|
||||
return numberValue;
|
||||
}
|
||||
|
||||
private normalizeNullableInteger(value: unknown) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null;
|
||||
}
|
||||
const numberValue = Number(value);
|
||||
if (!Number.isInteger(numberValue) || numberValue < 0) {
|
||||
throw new BadRequestException('库存和限购必须是非负整数');
|
||||
}
|
||||
return numberValue;
|
||||
}
|
||||
|
||||
private normalizePrice(value: unknown) {
|
||||
const numberValue = Number(value ?? 0);
|
||||
if (!Number.isFinite(numberValue) || numberValue < 0) {
|
||||
throw new BadRequestException('商品价格必须是非负数字');
|
||||
}
|
||||
return numberValue.toFixed(2);
|
||||
}
|
||||
|
||||
private normalizeFulfillmentType(
|
||||
value: unknown,
|
||||
fallback: FulfillmentType,
|
||||
) {
|
||||
const normalized = String(value || fallback) as FulfillmentType;
|
||||
if (!FULFILLMENT_TYPES.has(normalized)) {
|
||||
throw new BadRequestException('交付类型不正确');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizeOrderFormSchema(value: unknown): OrderFormField[] | null {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
throw new BadRequestException('下单表单配置必须是数组');
|
||||
}
|
||||
if (value.length > 30) {
|
||||
throw new BadRequestException('下单表单字段不能超过 30 个');
|
||||
}
|
||||
|
||||
const keys = new Set<string>();
|
||||
return value.map((field, index) => {
|
||||
if (!field || typeof field !== 'object' || Array.isArray(field)) {
|
||||
throw new BadRequestException('下单表单字段格式不正确');
|
||||
}
|
||||
const item = field as OrderFormFieldDto;
|
||||
const key = this.normalizeFieldKey(item.key, index);
|
||||
if (keys.has(key)) {
|
||||
throw new BadRequestException(`下单字段 ${key} 重复`);
|
||||
}
|
||||
keys.add(key);
|
||||
|
||||
const label = this.normalizeRequiredString(
|
||||
item.label,
|
||||
'下单字段名称不能为空',
|
||||
);
|
||||
const type = String(item.type || 'text') as OrderFormFieldType;
|
||||
if (!ORDER_FORM_FIELD_TYPES.has(type)) {
|
||||
throw new BadRequestException(`下单字段 ${label} 类型不正确`);
|
||||
}
|
||||
const options =
|
||||
type === 'select'
|
||||
? this.normalizeFieldOptions(item.options, label)
|
||||
: undefined;
|
||||
|
||||
const normalizedField: OrderFormField = {
|
||||
key,
|
||||
label,
|
||||
type,
|
||||
required: Boolean(item.required),
|
||||
placeholder: this.normalizeOptionalString(item.placeholder),
|
||||
...(options ? { options } : {}),
|
||||
};
|
||||
return normalizedField;
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeFieldKey(value: unknown, index: number) {
|
||||
const normalized = String(value || `field_${index + 1}`)
|
||||
.trim()
|
||||
.replace(/\s+/g, '_');
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9_]{0,49}$/.test(normalized)) {
|
||||
throw new BadRequestException(
|
||||
'下单字段标识只能使用字母、数字、下划线,并且必须以字母开头',
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizeFieldOptions(
|
||||
value: OrderFormFieldDto['options'],
|
||||
fieldLabel: string,
|
||||
) {
|
||||
if (!Array.isArray(value) || !value.length) {
|
||||
throw new BadRequestException(`下拉字段 ${fieldLabel} 至少需要一个选项`);
|
||||
}
|
||||
return value.map((option) => {
|
||||
const label = this.normalizeRequiredString(
|
||||
option?.label,
|
||||
`下拉字段 ${fieldLabel} 的选项名称不能为空`,
|
||||
);
|
||||
const optionValue = this.normalizeRequiredString(
|
||||
option?.value,
|
||||
`下拉字段 ${fieldLabel} 的选项值不能为空`,
|
||||
);
|
||||
return { label, value: optionValue };
|
||||
});
|
||||
}
|
||||
|
||||
private async getCategory(id: string) {
|
||||
const category = await this.categoriesRepository.findOne({ where: { id } });
|
||||
if (!category) {
|
||||
throw new NotFoundException('分类不存在');
|
||||
}
|
||||
return category;
|
||||
}
|
||||
|
||||
private async getOptionalCategory(id?: string | null) {
|
||||
const categoryId = String(id || '').trim();
|
||||
if (!categoryId) {
|
||||
return null;
|
||||
}
|
||||
return this.getCategory(categoryId);
|
||||
}
|
||||
|
||||
private async getProduct(id: string) {
|
||||
const product = await this.productsRepository.findOne({ where: { id } });
|
||||
if (!product) {
|
||||
throw new NotFoundException('商品不存在');
|
||||
}
|
||||
return product;
|
||||
}
|
||||
|
||||
private assertSelfOwned(
|
||||
entity: Category | Course,
|
||||
message: string,
|
||||
) {
|
||||
if (entity.sourceType !== SELF_OWNED_SOURCE) {
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
}
|
||||
|
||||
private async getCategoryMap(products: Course[]) {
|
||||
const categoryIds = Array.from(
|
||||
new Set(products.map((product) => product.categoryId).filter(Boolean)),
|
||||
) as string[];
|
||||
if (!categoryIds.length) {
|
||||
return new Map<string, Category>();
|
||||
}
|
||||
|
||||
const categories = await this.categoriesRepository
|
||||
.createQueryBuilder('category')
|
||||
.where('category.id IN (:...categoryIds)', { categoryIds })
|
||||
.getMany();
|
||||
|
||||
return new Map(categories.map((category) => [category.id, category]));
|
||||
}
|
||||
|
||||
private toProductItem(
|
||||
product: Course,
|
||||
categoryMap: Map<string, Category>,
|
||||
) {
|
||||
const category = product.categoryId
|
||||
? categoryMap.get(product.categoryId)
|
||||
: null;
|
||||
|
||||
return {
|
||||
...product,
|
||||
categoryName: category?.name ?? null,
|
||||
categorySourceType: category?.sourceType ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { SourceType } from '../../third-party/entities/category.entity';
|
||||
|
||||
export type FulfillmentType = 'third_party_api' | 'local_only' | 'manual';
|
||||
export type OrderFormFieldType =
|
||||
| 'text'
|
||||
| 'password'
|
||||
| 'phone'
|
||||
| 'number'
|
||||
| 'textarea'
|
||||
| 'select'
|
||||
| 'checkbox';
|
||||
|
||||
export type OrderFormFieldDto = {
|
||||
key?: string;
|
||||
label?: string;
|
||||
type?: OrderFormFieldType;
|
||||
required?: boolean;
|
||||
placeholder?: string | null;
|
||||
options?: Array<{ label?: string; value?: string }>;
|
||||
};
|
||||
|
||||
export type CreateCatalogCategoryDto = {
|
||||
name?: string;
|
||||
sortOrder?: number;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type UpdateCatalogCategoryDto = {
|
||||
name?: string;
|
||||
sortOrder?: number;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type CreateCatalogProductDto = {
|
||||
categoryId?: string | null;
|
||||
name?: string;
|
||||
price?: number | string;
|
||||
coverUrl?: string | null;
|
||||
content?: string | null;
|
||||
stock?: number | null;
|
||||
salesLimit?: number | null;
|
||||
fulfillmentType?: FulfillmentType;
|
||||
afterSalesNote?: string | null;
|
||||
orderFormSchema?: OrderFormFieldDto[] | null;
|
||||
sortOrder?: number;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type UpdateCatalogProductDto = {
|
||||
categoryId?: string | null;
|
||||
name?: string;
|
||||
price?: number | string;
|
||||
coverUrl?: string | null;
|
||||
content?: string | null;
|
||||
stock?: number | null;
|
||||
salesLimit?: number | null;
|
||||
fulfillmentType?: FulfillmentType;
|
||||
afterSalesNote?: string | null;
|
||||
orderFormSchema?: OrderFormFieldDto[] | null;
|
||||
sortOrder?: number;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type UpdateEnabledDto = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type CatalogListQuery = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
sourceType?: SourceType | 'all';
|
||||
enabled?: string;
|
||||
categoryId?: string;
|
||||
remoteCategoryId?: string;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export type ApiResponse<T = unknown> = {
|
||||
code: number;
|
||||
data: T | null;
|
||||
msg: string;
|
||||
};
|
||||
|
||||
export const API_SUCCESS_CODE = 0;
|
||||
export const API_SUCCESS_MESSAGE = 'success';
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import type { ApiResponse } from './api-response.type';
|
||||
|
||||
type ExceptionResponseBody = {
|
||||
message?: string | string[];
|
||||
msg?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const response = host.switchToHttp().getResponse<Response>();
|
||||
const status =
|
||||
exception instanceof HttpException
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
response.status(status).json({
|
||||
code: status,
|
||||
data: null,
|
||||
msg: this.getMessage(exception),
|
||||
} satisfies ApiResponse<null>);
|
||||
}
|
||||
|
||||
private getMessage(exception: unknown) {
|
||||
if (exception instanceof HttpException) {
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
if (typeof exceptionResponse === 'string') {
|
||||
return exceptionResponse;
|
||||
}
|
||||
|
||||
if (this.isExceptionResponseBody(exceptionResponse)) {
|
||||
const message =
|
||||
exceptionResponse.msg ||
|
||||
exceptionResponse.message ||
|
||||
exceptionResponse.error;
|
||||
|
||||
if (Array.isArray(message)) {
|
||||
return message.join('; ');
|
||||
}
|
||||
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
return exception.message;
|
||||
}
|
||||
|
||||
if (exception instanceof Error && exception.message) {
|
||||
return exception.message;
|
||||
}
|
||||
|
||||
return 'Internal server error';
|
||||
}
|
||||
|
||||
private isExceptionResponseBody(value: unknown): value is ExceptionResponseBody {
|
||||
return Boolean(value && typeof value === 'object');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, map } from 'rxjs';
|
||||
import {
|
||||
API_SUCCESS_CODE,
|
||||
API_SUCCESS_MESSAGE,
|
||||
type ApiResponse,
|
||||
} from './api-response.type';
|
||||
|
||||
@Injectable()
|
||||
export class ResponseInterceptor<T>
|
||||
implements NestInterceptor<T, ApiResponse<T>>
|
||||
{
|
||||
intercept(
|
||||
_context: ExecutionContext,
|
||||
next: CallHandler<T>,
|
||||
): Observable<ApiResponse<T>> {
|
||||
return next.handle().pipe(
|
||||
map((data) => ({
|
||||
code: API_SUCCESS_CODE,
|
||||
data: data ?? null,
|
||||
msg: API_SUCCESS_MESSAGE,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { validateEnv } from './env';
|
||||
|
||||
describe('validateEnv', () => {
|
||||
it('accepts the minimum backend environment', () => {
|
||||
const env = validateEnv({
|
||||
DATABASE_URL: 'mysql://root:123456@127.0.0.1:3306/work_admin',
|
||||
AUTH_SECRET: 'replace-with-a-long-random-secret',
|
||||
});
|
||||
|
||||
expect(env.PORT).toBe(3001);
|
||||
expect(env.DATABASE_URL).toContain('work_admin');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.string().default('development'),
|
||||
PORT: z.coerce.number().int().positive().default(3001),
|
||||
DATABASE_URL: z.string().url(),
|
||||
AUTH_SECRET: z.string().min(16, 'AUTH_SECRET must be at least 16 characters'),
|
||||
WK_BASE_URL: z.string().url().default('https://biedawo.org/api.php'),
|
||||
WK_APP_UID: z.string().optional(),
|
||||
WK_APP_KEY: z.string().optional(),
|
||||
WK_DEBUG_LOG: z.string().optional(),
|
||||
CATALOG_SYNC_CRON_ENABLED: z.string().optional(),
|
||||
CATALOG_SYNC_CRON: z.string().default('0 */2 * * *'),
|
||||
THIRD_PARTY_ORDER_SYNC_CRON_ENABLED: z.string().optional(),
|
||||
THIRD_PARTY_ORDER_SYNC_CRON: z.string().default('*/30 * * * *'),
|
||||
});
|
||||
|
||||
export type AppEnv = z.infer<typeof envSchema>;
|
||||
|
||||
export function validateEnv(config: Record<string, unknown>): AppEnv {
|
||||
return envSchema.parse(config);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const rootEnvPath = join(__dirname, '../../../../.env');
|
||||
export const packageEnvPath = join(__dirname, '../../.env');
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
const isTest = process.env.NODE_ENV === 'test';
|
||||
|
||||
@Module({
|
||||
imports: isTest
|
||||
? []
|
||||
: [
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: () => ({
|
||||
type: 'mysql',
|
||||
url: process.env.DATABASE_URL,
|
||||
autoLoadEntities: true,
|
||||
synchronize: false,
|
||||
migrationsRun: false,
|
||||
timezone: 'Z',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
@@ -0,0 +1,114 @@
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
code VARCHAR(80) NOT NULL,
|
||||
name VARCHAR(80) NOT NULL,
|
||||
permissions JSON NULL,
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_roles_code (code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
name VARCHAR(80) NOT NULL,
|
||||
email VARCHAR(160) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
enabled TINYINT NOT NULL DEFAULT 1,
|
||||
last_login_at TIMESTAMP NULL,
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_users_email (email)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
role_id VARCHAR(36) NOT NULL,
|
||||
PRIMARY KEY (user_id, role_id),
|
||||
KEY idx_user_roles_user_id (user_id),
|
||||
KEY idx_user_roles_role_id (role_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
SET @users_last_login_at_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'users'
|
||||
AND column_name = 'last_login_at'
|
||||
);
|
||||
SET @users_last_login_at_sql := IF(
|
||||
@users_last_login_at_exists = 0,
|
||||
'ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP NULL',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE users_last_login_at_stmt FROM @users_last_login_at_sql;
|
||||
EXECUTE users_last_login_at_stmt;
|
||||
DEALLOCATE PREPARE users_last_login_at_stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS login_logs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_id VARCHAR(36) NULL,
|
||||
username VARCHAR(64) NOT NULL,
|
||||
result VARCHAR(32) NOT NULL,
|
||||
failure_reason VARCHAR(255) NULL,
|
||||
ip_address VARCHAR(64) NULL,
|
||||
user_agent VARCHAR(512) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_login_logs_user_id (user_id),
|
||||
KEY idx_login_logs_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE login_logs MODIFY COLUMN user_id VARCHAR(36) NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
actor_id VARCHAR(36) NULL,
|
||||
actor_username VARCHAR(64) NULL,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
resource_type VARCHAR(64) NULL,
|
||||
resource_id VARCHAR(64) NULL,
|
||||
metadata JSON NULL,
|
||||
ip_address VARCHAR(64) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_audit_logs_actor_id (actor_id),
|
||||
KEY idx_audit_logs_created_at (created_at),
|
||||
KEY idx_audit_logs_action (action)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE audit_logs MODIFY COLUMN actor_id VARCHAR(36) NULL;
|
||||
|
||||
INSERT INTO roles (id, code, name, permissions)
|
||||
SELECT
|
||||
'738cbba4-f6f9-444c-ada3-65fa28285aa1',
|
||||
'super_admin',
|
||||
'超级管理员',
|
||||
JSON_ARRAY('*')
|
||||
WHERE NOT EXISTS (SELECT 1 FROM roles WHERE code = 'super_admin');
|
||||
|
||||
INSERT INTO users (id, name, email, password_hash, enabled)
|
||||
SELECT
|
||||
'0f4e6efb-bfb4-4f12-b76c-20e6d0a5aa31',
|
||||
'root',
|
||||
'root@local.dev',
|
||||
'$2b$10$VV/DpPjPNgSDM5fj1B2JxOxFanDrpcwt4qgm27ez8sN4Y8LpfYGna',
|
||||
1
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM users
|
||||
WHERE name = 'root' OR email = 'root@local.dev'
|
||||
);
|
||||
|
||||
INSERT INTO user_roles (user_id, role_id)
|
||||
SELECT users.id, roles.id
|
||||
FROM users
|
||||
JOIN roles ON roles.code = 'super_admin'
|
||||
WHERE (users.name = 'root' OR users.email = 'root@local.dev')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM user_roles
|
||||
WHERE user_roles.user_id = users.id
|
||||
AND user_roles.role_id = roles.id
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
SET @users_parent_id_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'users'
|
||||
AND column_name = 'parent_id'
|
||||
);
|
||||
SET @users_parent_id_sql := IF(
|
||||
@users_parent_id_exists = 0,
|
||||
'ALTER TABLE users ADD COLUMN parent_id VARCHAR(36) NULL AFTER enabled',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE users_parent_id_stmt FROM @users_parent_id_sql;
|
||||
EXECUTE users_parent_id_stmt;
|
||||
DEALLOCATE PREPARE users_parent_id_stmt;
|
||||
|
||||
SET @users_parent_id_index_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'users'
|
||||
AND index_name = 'idx_users_parent_id'
|
||||
);
|
||||
SET @users_parent_id_index_sql := IF(
|
||||
@users_parent_id_index_exists = 0,
|
||||
'CREATE INDEX idx_users_parent_id ON users (parent_id)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE users_parent_id_index_stmt FROM @users_parent_id_index_sql;
|
||||
EXECUTE users_parent_id_index_stmt;
|
||||
DEALLOCATE PREPARE users_parent_id_index_stmt;
|
||||
|
||||
INSERT INTO roles (id, code, name, permissions)
|
||||
SELECT
|
||||
'60e6fb3f-7b03-4e37-a4c1-1690c7aabf01',
|
||||
'agent',
|
||||
'代理',
|
||||
JSON_OBJECT('admin/users', JSON_ARRAY('read', 'write'))
|
||||
WHERE NOT EXISTS (SELECT 1 FROM roles WHERE code = 'agent');
|
||||
|
||||
INSERT INTO roles (id, code, name, permissions)
|
||||
SELECT
|
||||
'c6805ef4-bc20-4ec8-93a7-2e8ea1b33677',
|
||||
'user',
|
||||
'普通用户',
|
||||
JSON_OBJECT()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM roles WHERE code = 'user');
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
SET @users_parent_path_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'users' AND column_name = 'parent_path'
|
||||
);
|
||||
SET @users_parent_path_sql := IF(
|
||||
@users_parent_path_exists = 0,
|
||||
'ALTER TABLE users ADD COLUMN parent_path VARCHAR(1024) NULL AFTER parent_id',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE users_parent_path_stmt FROM @users_parent_path_sql;
|
||||
EXECUTE users_parent_path_stmt;
|
||||
DEALLOCATE PREPARE users_parent_path_stmt;
|
||||
|
||||
SET @users_token_version_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'users' AND column_name = 'token_version'
|
||||
);
|
||||
SET @users_token_version_sql := IF(
|
||||
@users_token_version_exists = 0,
|
||||
'ALTER TABLE users ADD COLUMN token_version INT NOT NULL DEFAULT 0 AFTER parent_path',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE users_token_version_stmt FROM @users_token_version_sql;
|
||||
EXECUTE users_token_version_stmt;
|
||||
DEALLOCATE PREPARE users_token_version_stmt;
|
||||
|
||||
SET @users_must_change_password_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'users' AND column_name = 'must_change_password'
|
||||
);
|
||||
SET @users_must_change_password_sql := IF(
|
||||
@users_must_change_password_exists = 0,
|
||||
'ALTER TABLE users ADD COLUMN must_change_password TINYINT NOT NULL DEFAULT 0 AFTER token_version',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE users_must_change_password_stmt FROM @users_must_change_password_sql;
|
||||
EXECUTE users_must_change_password_stmt;
|
||||
DEALLOCATE PREPARE users_must_change_password_stmt;
|
||||
|
||||
SET @users_parent_path_index_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE() AND table_name = 'users' AND index_name = 'idx_users_parent_path'
|
||||
);
|
||||
SET @users_parent_path_index_sql := IF(
|
||||
@users_parent_path_index_exists = 0,
|
||||
'CREATE INDEX idx_users_parent_path ON users (parent_path(255))',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE users_parent_path_index_stmt FROM @users_parent_path_index_sql;
|
||||
EXECUTE users_parent_path_index_stmt;
|
||||
DEALLOCATE PREPARE users_parent_path_index_stmt;
|
||||
|
||||
UPDATE roles
|
||||
SET permissions = JSON_OBJECT(
|
||||
'*', JSON_ARRAY('*'),
|
||||
'admin/users', JSON_ARRAY('read', 'write'),
|
||||
'admin/login-logs', JSON_ARRAY('read'),
|
||||
'admin/audit-logs', JSON_ARRAY('read'),
|
||||
'admin/permissions', JSON_ARRAY('read', 'write')
|
||||
)
|
||||
WHERE code = 'super_admin';
|
||||
|
||||
UPDATE roles
|
||||
SET permissions = JSON_OBJECT(
|
||||
'admin/users', JSON_ARRAY('read', 'write')
|
||||
)
|
||||
WHERE code = 'agent';
|
||||
|
||||
UPDATE roles
|
||||
SET permissions = JSON_OBJECT()
|
||||
WHERE code = 'user';
|
||||
@@ -0,0 +1,136 @@
|
||||
CREATE TABLE IF NOT EXISTS api_call_logs (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
provider VARCHAR(64) NOT NULL DEFAULT 'biedawo',
|
||||
act VARCHAR(80) NOT NULL,
|
||||
endpoint VARCHAR(255) NOT NULL,
|
||||
method VARCHAR(12) NOT NULL,
|
||||
request_payload JSON NULL,
|
||||
response_payload JSON NULL,
|
||||
success TINYINT NOT NULL DEFAULT 0,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'failure',
|
||||
status_code INT NULL,
|
||||
duration_ms INT NULL,
|
||||
error_message TEXT NULL,
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
SET @api_call_logs_provider_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'api_call_logs'
|
||||
AND column_name = 'provider'
|
||||
);
|
||||
SET @api_call_logs_provider_sql := IF(
|
||||
@api_call_logs_provider_exists = 0,
|
||||
'ALTER TABLE api_call_logs ADD COLUMN provider VARCHAR(64) NOT NULL DEFAULT ''biedawo'' AFTER id',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE api_call_logs_provider_stmt FROM @api_call_logs_provider_sql;
|
||||
EXECUTE api_call_logs_provider_stmt;
|
||||
DEALLOCATE PREPARE api_call_logs_provider_stmt;
|
||||
|
||||
SET @api_call_logs_status_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'api_call_logs'
|
||||
AND column_name = 'status'
|
||||
);
|
||||
SET @api_call_logs_status_sql := IF(
|
||||
@api_call_logs_status_exists = 0,
|
||||
'ALTER TABLE api_call_logs ADD COLUMN status VARCHAR(16) NOT NULL DEFAULT ''failure'' AFTER success',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE api_call_logs_status_stmt FROM @api_call_logs_status_sql;
|
||||
EXECUTE api_call_logs_status_stmt;
|
||||
DEALLOCATE PREPARE api_call_logs_status_stmt;
|
||||
|
||||
SET @api_call_logs_endpoint_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'api_call_logs'
|
||||
AND column_name = 'endpoint'
|
||||
);
|
||||
SET @api_call_logs_endpoint_sql := IF(
|
||||
@api_call_logs_endpoint_exists = 0,
|
||||
'ALTER TABLE api_call_logs ADD COLUMN endpoint VARCHAR(255) NOT NULL DEFAULT '''' AFTER act',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE api_call_logs_endpoint_stmt FROM @api_call_logs_endpoint_sql;
|
||||
EXECUTE api_call_logs_endpoint_stmt;
|
||||
DEALLOCATE PREPARE api_call_logs_endpoint_stmt;
|
||||
|
||||
SET @api_call_logs_success_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'api_call_logs'
|
||||
AND column_name = 'success'
|
||||
);
|
||||
SET @api_call_logs_success_sql := IF(
|
||||
@api_call_logs_success_exists = 0,
|
||||
'ALTER TABLE api_call_logs ADD COLUMN success TINYINT NOT NULL DEFAULT 0 AFTER response_payload',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE api_call_logs_success_stmt FROM @api_call_logs_success_sql;
|
||||
EXECUTE api_call_logs_success_stmt;
|
||||
DEALLOCATE PREPARE api_call_logs_success_stmt;
|
||||
|
||||
SET @api_call_logs_status_code_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'api_call_logs'
|
||||
AND column_name = 'status_code'
|
||||
);
|
||||
SET @api_call_logs_status_code_sql := IF(
|
||||
@api_call_logs_status_code_exists = 0,
|
||||
'ALTER TABLE api_call_logs ADD COLUMN status_code INT NULL AFTER status',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE api_call_logs_status_code_stmt FROM @api_call_logs_status_code_sql;
|
||||
EXECUTE api_call_logs_status_code_stmt;
|
||||
DEALLOCATE PREPARE api_call_logs_status_code_stmt;
|
||||
|
||||
SET @api_call_logs_created_at_index_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'api_call_logs'
|
||||
AND index_name = 'idx_api_call_logs_created_at'
|
||||
);
|
||||
SET @api_call_logs_created_at_index_sql := IF(
|
||||
@api_call_logs_created_at_index_exists = 0,
|
||||
'CREATE INDEX idx_api_call_logs_created_at ON api_call_logs (created_at)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE api_call_logs_created_at_index_stmt FROM @api_call_logs_created_at_index_sql;
|
||||
EXECUTE api_call_logs_created_at_index_stmt;
|
||||
DEALLOCATE PREPARE api_call_logs_created_at_index_stmt;
|
||||
|
||||
SET @api_call_logs_provider_created_at_index_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'api_call_logs'
|
||||
AND index_name = 'idx_api_call_logs_provider_created_at'
|
||||
);
|
||||
SET @api_call_logs_provider_created_at_index_sql := IF(
|
||||
@api_call_logs_provider_created_at_index_exists = 0,
|
||||
'CREATE INDEX idx_api_call_logs_provider_created_at ON api_call_logs (provider, created_at)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE api_call_logs_provider_created_at_index_stmt FROM @api_call_logs_provider_created_at_index_sql;
|
||||
EXECUTE api_call_logs_provider_created_at_index_stmt;
|
||||
DEALLOCATE PREPARE api_call_logs_provider_created_at_index_stmt;
|
||||
|
||||
SET @api_call_logs_act_created_at_index_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'api_call_logs'
|
||||
AND index_name = 'idx_api_call_logs_act_created_at'
|
||||
);
|
||||
SET @api_call_logs_act_created_at_index_sql := IF(
|
||||
@api_call_logs_act_created_at_index_exists = 0,
|
||||
'CREATE INDEX idx_api_call_logs_act_created_at ON api_call_logs (act, created_at)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE api_call_logs_act_created_at_index_stmt FROM @api_call_logs_act_created_at_index_sql;
|
||||
EXECUTE api_call_logs_act_created_at_index_stmt;
|
||||
DEALLOCATE PREPARE api_call_logs_act_created_at_index_stmt;
|
||||
@@ -0,0 +1,11 @@
|
||||
UPDATE roles
|
||||
SET permissions = JSON_OBJECT(
|
||||
'*', JSON_ARRAY('*'),
|
||||
'admin/users', JSON_ARRAY('read', 'write'),
|
||||
'admin/login-logs', JSON_ARRAY('read'),
|
||||
'admin/audit-logs', JSON_ARRAY('read'),
|
||||
'admin/api-call-logs', JSON_ARRAY('read'),
|
||||
'admin/third-party', JSON_ARRAY('read', 'write'),
|
||||
'admin/permissions', JSON_ARRAY('read', 'write')
|
||||
)
|
||||
WHERE code = 'super_admin';
|
||||
@@ -0,0 +1,307 @@
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
source_type VARCHAR(32) NOT NULL DEFAULT 'third_party',
|
||||
api_account_id VARCHAR(36) NULL DEFAULT 'env-biedawo',
|
||||
provider VARCHAR(64) NULL DEFAULT 'biedawo',
|
||||
external_id VARCHAR(160) NULL,
|
||||
remote_category_id VARCHAR(120) NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
enabled TINYINT NOT NULL DEFAULT 1,
|
||||
raw_payload JSON NULL,
|
||||
last_synced_at DATETIME NULL,
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS courses (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
source_type VARCHAR(32) NOT NULL DEFAULT 'third_party',
|
||||
api_account_id VARCHAR(36) NULL DEFAULT 'env-biedawo',
|
||||
provider VARCHAR(64) NULL DEFAULT 'biedawo',
|
||||
external_id VARCHAR(160) NULL,
|
||||
category_id VARCHAR(36) NULL,
|
||||
remote_category_id VARCHAR(120) NULL,
|
||||
remote_course_id VARCHAR(160) NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
content TEXT NULL,
|
||||
is_favorite TINYINT NOT NULL DEFAULT 0,
|
||||
enabled TINYINT NOT NULL DEFAULT 1,
|
||||
raw_payload JSON NULL,
|
||||
last_synced_at DATETIME NULL,
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
SET @categories_source_type_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'categories' AND column_name = 'source_type'
|
||||
);
|
||||
SET @categories_source_type_sql := IF(
|
||||
@categories_source_type_exists = 0,
|
||||
'ALTER TABLE categories ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''third_party'' AFTER id',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE categories_source_type_stmt FROM @categories_source_type_sql;
|
||||
EXECUTE categories_source_type_stmt;
|
||||
DEALLOCATE PREPARE categories_source_type_stmt;
|
||||
|
||||
SET @categories_api_account_id_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'categories' AND column_name = 'api_account_id'
|
||||
);
|
||||
SET @categories_api_account_id_sql := IF(
|
||||
@categories_api_account_id_exists = 0,
|
||||
'ALTER TABLE categories ADD COLUMN api_account_id VARCHAR(36) NULL DEFAULT ''env-biedawo'' AFTER source_type',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE categories_api_account_id_stmt FROM @categories_api_account_id_sql;
|
||||
EXECUTE categories_api_account_id_stmt;
|
||||
DEALLOCATE PREPARE categories_api_account_id_stmt;
|
||||
|
||||
SET @categories_provider_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'categories' AND column_name = 'provider'
|
||||
);
|
||||
SET @categories_provider_sql := IF(
|
||||
@categories_provider_exists = 0,
|
||||
'ALTER TABLE categories ADD COLUMN provider VARCHAR(64) NULL DEFAULT ''biedawo'' AFTER source_type',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE categories_provider_stmt FROM @categories_provider_sql;
|
||||
EXECUTE categories_provider_stmt;
|
||||
DEALLOCATE PREPARE categories_provider_stmt;
|
||||
|
||||
SET @categories_external_id_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'categories' AND column_name = 'external_id'
|
||||
);
|
||||
SET @categories_external_id_sql := IF(
|
||||
@categories_external_id_exists = 0,
|
||||
'ALTER TABLE categories ADD COLUMN external_id VARCHAR(160) NULL AFTER provider',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE categories_external_id_stmt FROM @categories_external_id_sql;
|
||||
EXECUTE categories_external_id_stmt;
|
||||
DEALLOCATE PREPARE categories_external_id_stmt;
|
||||
|
||||
SET @categories_enabled_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'categories' AND column_name = 'enabled'
|
||||
);
|
||||
SET @categories_enabled_sql := IF(
|
||||
@categories_enabled_exists = 0,
|
||||
'ALTER TABLE categories ADD COLUMN enabled TINYINT NOT NULL DEFAULT 1 AFTER sort_order',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE categories_enabled_stmt FROM @categories_enabled_sql;
|
||||
EXECUTE categories_enabled_stmt;
|
||||
DEALLOCATE PREPARE categories_enabled_stmt;
|
||||
|
||||
SET @categories_last_synced_nullable := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'categories'
|
||||
AND column_name = 'last_synced_at'
|
||||
AND is_nullable = 'NO'
|
||||
);
|
||||
SET @categories_last_synced_sql := IF(
|
||||
@categories_last_synced_nullable > 0,
|
||||
'ALTER TABLE categories MODIFY COLUMN last_synced_at DATETIME NULL',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE categories_last_synced_stmt FROM @categories_last_synced_sql;
|
||||
EXECUTE categories_last_synced_stmt;
|
||||
DEALLOCATE PREPARE categories_last_synced_stmt;
|
||||
|
||||
UPDATE categories
|
||||
SET source_type = COALESCE(NULLIF(source_type, ''), 'third_party'),
|
||||
provider = COALESCE(NULLIF(provider, ''), 'biedawo'),
|
||||
external_id = COALESCE(NULLIF(external_id, ''), remote_category_id)
|
||||
WHERE source_type IS NULL
|
||||
OR provider IS NULL
|
||||
OR external_id IS NULL
|
||||
OR source_type = ''
|
||||
OR provider = ''
|
||||
OR external_id = '';
|
||||
|
||||
SET @courses_source_type_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'source_type'
|
||||
);
|
||||
SET @courses_source_type_sql := IF(
|
||||
@courses_source_type_exists = 0,
|
||||
'ALTER TABLE courses ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''third_party'' AFTER id',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE courses_source_type_stmt FROM @courses_source_type_sql;
|
||||
EXECUTE courses_source_type_stmt;
|
||||
DEALLOCATE PREPARE courses_source_type_stmt;
|
||||
|
||||
SET @courses_api_account_id_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'api_account_id'
|
||||
);
|
||||
SET @courses_api_account_id_sql := IF(
|
||||
@courses_api_account_id_exists = 0,
|
||||
'ALTER TABLE courses ADD COLUMN api_account_id VARCHAR(36) NULL DEFAULT ''env-biedawo'' AFTER source_type',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE courses_api_account_id_stmt FROM @courses_api_account_id_sql;
|
||||
EXECUTE courses_api_account_id_stmt;
|
||||
DEALLOCATE PREPARE courses_api_account_id_stmt;
|
||||
|
||||
SET @courses_provider_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'provider'
|
||||
);
|
||||
SET @courses_provider_sql := IF(
|
||||
@courses_provider_exists = 0,
|
||||
'ALTER TABLE courses ADD COLUMN provider VARCHAR(64) NULL DEFAULT ''biedawo'' AFTER source_type',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE courses_provider_stmt FROM @courses_provider_sql;
|
||||
EXECUTE courses_provider_stmt;
|
||||
DEALLOCATE PREPARE courses_provider_stmt;
|
||||
|
||||
SET @courses_external_id_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'external_id'
|
||||
);
|
||||
SET @courses_external_id_sql := IF(
|
||||
@courses_external_id_exists = 0,
|
||||
'ALTER TABLE courses ADD COLUMN external_id VARCHAR(160) NULL AFTER provider',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE courses_external_id_stmt FROM @courses_external_id_sql;
|
||||
EXECUTE courses_external_id_stmt;
|
||||
DEALLOCATE PREPARE courses_external_id_stmt;
|
||||
|
||||
SET @courses_price_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'price'
|
||||
);
|
||||
SET @courses_price_sql := IF(
|
||||
@courses_price_exists = 0,
|
||||
'ALTER TABLE courses ADD COLUMN price DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER name',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE courses_price_stmt FROM @courses_price_sql;
|
||||
EXECUTE courses_price_stmt;
|
||||
DEALLOCATE PREPARE courses_price_stmt;
|
||||
|
||||
SET @courses_content_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'content'
|
||||
);
|
||||
SET @courses_content_sql := IF(
|
||||
@courses_content_exists = 0,
|
||||
'ALTER TABLE courses ADD COLUMN content TEXT NULL AFTER price',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE courses_content_stmt FROM @courses_content_sql;
|
||||
EXECUTE courses_content_stmt;
|
||||
DEALLOCATE PREPARE courses_content_stmt;
|
||||
|
||||
SET @courses_raw_payload_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'raw_payload'
|
||||
);
|
||||
SET @courses_raw_payload_sql := IF(
|
||||
@courses_raw_payload_exists = 0,
|
||||
'ALTER TABLE courses ADD COLUMN raw_payload JSON NULL AFTER enabled',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE courses_raw_payload_stmt FROM @courses_raw_payload_sql;
|
||||
EXECUTE courses_raw_payload_stmt;
|
||||
DEALLOCATE PREPARE courses_raw_payload_stmt;
|
||||
|
||||
SET @courses_last_synced_nullable := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'courses'
|
||||
AND column_name = 'last_synced_at'
|
||||
AND is_nullable = 'NO'
|
||||
);
|
||||
SET @courses_last_synced_sql := IF(
|
||||
@courses_last_synced_nullable > 0,
|
||||
'ALTER TABLE courses MODIFY COLUMN last_synced_at DATETIME NULL',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE courses_last_synced_stmt FROM @courses_last_synced_sql;
|
||||
EXECUTE courses_last_synced_stmt;
|
||||
DEALLOCATE PREPARE courses_last_synced_stmt;
|
||||
|
||||
UPDATE courses
|
||||
SET source_type = COALESCE(NULLIF(source_type, ''), 'third_party'),
|
||||
provider = COALESCE(NULLIF(provider, ''), 'biedawo'),
|
||||
external_id = COALESCE(NULLIF(external_id, ''), remote_course_id)
|
||||
WHERE source_type IS NULL
|
||||
OR provider IS NULL
|
||||
OR external_id IS NULL
|
||||
OR source_type = ''
|
||||
OR provider = ''
|
||||
OR external_id = '';
|
||||
|
||||
SET @categories_source_provider_external_index_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'categories'
|
||||
AND index_name = 'idx_categories_source_provider_external'
|
||||
);
|
||||
SET @categories_source_provider_external_index_sql := IF(
|
||||
@categories_source_provider_external_index_exists = 0,
|
||||
'CREATE INDEX idx_categories_source_provider_external ON categories (source_type, provider, external_id)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE categories_source_provider_external_index_stmt FROM @categories_source_provider_external_index_sql;
|
||||
EXECUTE categories_source_provider_external_index_stmt;
|
||||
DEALLOCATE PREPARE categories_source_provider_external_index_stmt;
|
||||
|
||||
SET @categories_provider_remote_index_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'categories'
|
||||
AND index_name = 'idx_categories_provider_remote'
|
||||
);
|
||||
SET @categories_provider_remote_index_sql := IF(
|
||||
@categories_provider_remote_index_exists = 0,
|
||||
'CREATE INDEX idx_categories_provider_remote ON categories (provider, remote_category_id)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE categories_provider_remote_index_stmt FROM @categories_provider_remote_index_sql;
|
||||
EXECUTE categories_provider_remote_index_stmt;
|
||||
DEALLOCATE PREPARE categories_provider_remote_index_stmt;
|
||||
|
||||
SET @courses_source_provider_external_category_index_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'courses'
|
||||
AND index_name = 'idx_courses_source_provider_external_category'
|
||||
);
|
||||
SET @courses_source_provider_external_category_index_sql := IF(
|
||||
@courses_source_provider_external_category_index_exists = 0,
|
||||
'CREATE INDEX idx_courses_source_provider_external_category ON courses (source_type, provider, external_id, remote_category_id)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE courses_source_provider_external_category_index_stmt FROM @courses_source_provider_external_category_index_sql;
|
||||
EXECUTE courses_source_provider_external_category_index_stmt;
|
||||
DEALLOCATE PREPARE courses_source_provider_external_category_index_stmt;
|
||||
|
||||
SET @courses_provider_category_updated_index_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'courses'
|
||||
AND index_name = 'idx_courses_provider_category_updated'
|
||||
);
|
||||
SET @courses_provider_category_updated_index_sql := IF(
|
||||
@courses_provider_category_updated_index_exists = 0,
|
||||
'CREATE INDEX idx_courses_provider_category_updated ON courses (provider, remote_category_id, updated_at)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE courses_provider_category_updated_index_stmt FROM @courses_provider_category_updated_index_sql;
|
||||
EXECUTE courses_provider_category_updated_index_stmt;
|
||||
DEALLOCATE PREPARE courses_provider_category_updated_index_stmt;
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
SET @login_logs_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'login_logs'
|
||||
);
|
||||
|
||||
SET @merge_login_logs_sql := IF(
|
||||
@login_logs_exists > 0,
|
||||
'INSERT INTO audit_logs (
|
||||
actor_id,
|
||||
actor_username,
|
||||
action,
|
||||
resource_type,
|
||||
resource_id,
|
||||
metadata,
|
||||
ip_address,
|
||||
created_at
|
||||
)
|
||||
SELECT
|
||||
login_logs.user_id,
|
||||
login_logs.username,
|
||||
''auth.login'',
|
||||
''user'',
|
||||
login_logs.user_id,
|
||||
JSON_OBJECT(
|
||||
''loginResult'', login_logs.result,
|
||||
''loginName'', login_logs.username,
|
||||
''failureReason'', login_logs.failure_reason,
|
||||
''userAgent'', login_logs.user_agent,
|
||||
''migratedFrom'', ''login_logs''
|
||||
),
|
||||
login_logs.ip_address,
|
||||
login_logs.created_at
|
||||
FROM login_logs
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM audit_logs
|
||||
WHERE audit_logs.action = ''auth.login''
|
||||
AND audit_logs.created_at = login_logs.created_at
|
||||
AND JSON_UNQUOTE(JSON_EXTRACT(audit_logs.metadata, ''$.loginName'')) = login_logs.username
|
||||
AND JSON_UNQUOTE(JSON_EXTRACT(audit_logs.metadata, ''$.loginResult'')) = login_logs.result
|
||||
)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE merge_login_logs_stmt FROM @merge_login_logs_sql;
|
||||
EXECUTE merge_login_logs_stmt;
|
||||
DEALLOCATE PREPARE merge_login_logs_stmt;
|
||||
|
||||
UPDATE roles
|
||||
SET permissions = JSON_REMOVE(permissions, '$."admin/login-logs"')
|
||||
WHERE JSON_CONTAINS_PATH(permissions, 'one', '$."admin/login-logs"');
|
||||
|
||||
UPDATE roles
|
||||
SET permissions = JSON_SET(
|
||||
COALESCE(permissions, JSON_OBJECT()),
|
||||
'$."admin/audit-logs"',
|
||||
JSON_ARRAY('read')
|
||||
)
|
||||
WHERE code = 'super_admin';
|
||||
|
||||
SET @drop_login_logs_sql := IF(
|
||||
@login_logs_exists > 0,
|
||||
'DROP TABLE login_logs',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE drop_login_logs_stmt FROM @drop_login_logs_sql;
|
||||
EXECUTE drop_login_logs_stmt;
|
||||
DEALLOCATE PREPARE drop_login_logs_stmt;
|
||||
@@ -0,0 +1,15 @@
|
||||
SET @users_permissions_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'users'
|
||||
AND column_name = 'permissions'
|
||||
);
|
||||
SET @users_permissions_sql := IF(
|
||||
@users_permissions_exists = 0,
|
||||
'ALTER TABLE users ADD COLUMN permissions JSON NULL AFTER must_change_password',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE users_permissions_stmt FROM @users_permissions_sql;
|
||||
EXECUTE users_permissions_stmt;
|
||||
DEALLOCATE PREPARE users_permissions_stmt;
|
||||
@@ -0,0 +1,114 @@
|
||||
SET @courses_cover_url_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'cover_url'
|
||||
);
|
||||
SET @courses_cover_url_sql := IF(
|
||||
@courses_cover_url_exists = 0,
|
||||
'ALTER TABLE courses ADD COLUMN cover_url VARCHAR(500) NULL AFTER price',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE courses_cover_url_stmt FROM @courses_cover_url_sql;
|
||||
EXECUTE courses_cover_url_stmt;
|
||||
DEALLOCATE PREPARE courses_cover_url_stmt;
|
||||
|
||||
SET @courses_stock_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'stock'
|
||||
);
|
||||
SET @courses_stock_sql := IF(
|
||||
@courses_stock_exists = 0,
|
||||
'ALTER TABLE courses ADD COLUMN stock INT NULL AFTER content',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE courses_stock_stmt FROM @courses_stock_sql;
|
||||
EXECUTE courses_stock_stmt;
|
||||
DEALLOCATE PREPARE courses_stock_stmt;
|
||||
|
||||
SET @courses_sales_limit_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'sales_limit'
|
||||
);
|
||||
SET @courses_sales_limit_sql := IF(
|
||||
@courses_sales_limit_exists = 0,
|
||||
'ALTER TABLE courses ADD COLUMN sales_limit INT NULL AFTER stock',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE courses_sales_limit_stmt FROM @courses_sales_limit_sql;
|
||||
EXECUTE courses_sales_limit_stmt;
|
||||
DEALLOCATE PREPARE courses_sales_limit_stmt;
|
||||
|
||||
SET @courses_fulfillment_type_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'fulfillment_type'
|
||||
);
|
||||
SET @courses_fulfillment_type_sql := IF(
|
||||
@courses_fulfillment_type_exists = 0,
|
||||
'ALTER TABLE courses ADD COLUMN fulfillment_type VARCHAR(40) NOT NULL DEFAULT ''third_party_api'' AFTER sales_limit',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE courses_fulfillment_type_stmt FROM @courses_fulfillment_type_sql;
|
||||
EXECUTE courses_fulfillment_type_stmt;
|
||||
DEALLOCATE PREPARE courses_fulfillment_type_stmt;
|
||||
|
||||
SET @courses_after_sales_note_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'after_sales_note'
|
||||
);
|
||||
SET @courses_after_sales_note_sql := IF(
|
||||
@courses_after_sales_note_exists = 0,
|
||||
'ALTER TABLE courses ADD COLUMN after_sales_note TEXT NULL AFTER fulfillment_type',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE courses_after_sales_note_stmt FROM @courses_after_sales_note_sql;
|
||||
EXECUTE courses_after_sales_note_stmt;
|
||||
DEALLOCATE PREPARE courses_after_sales_note_stmt;
|
||||
|
||||
SET @courses_sort_order_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'sort_order'
|
||||
);
|
||||
SET @courses_sort_order_sql := IF(
|
||||
@courses_sort_order_exists = 0,
|
||||
'ALTER TABLE courses ADD COLUMN sort_order INT NOT NULL DEFAULT 0 AFTER after_sales_note',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE courses_sort_order_stmt FROM @courses_sort_order_sql;
|
||||
EXECUTE courses_sort_order_stmt;
|
||||
DEALLOCATE PREPARE courses_sort_order_stmt;
|
||||
|
||||
UPDATE courses
|
||||
SET fulfillment_type = CASE
|
||||
WHEN source_type = 'self_owned' THEN 'local_only'
|
||||
ELSE 'third_party_api'
|
||||
END
|
||||
WHERE fulfillment_type IS NULL OR fulfillment_type = '';
|
||||
|
||||
SET @categories_source_enabled_index_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'categories'
|
||||
AND index_name = 'idx_categories_source_enabled_sort'
|
||||
);
|
||||
SET @categories_source_enabled_index_sql := IF(
|
||||
@categories_source_enabled_index_exists = 0,
|
||||
'CREATE INDEX idx_categories_source_enabled_sort ON categories (source_type, enabled, sort_order)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE categories_source_enabled_index_stmt FROM @categories_source_enabled_index_sql;
|
||||
EXECUTE categories_source_enabled_index_stmt;
|
||||
DEALLOCATE PREPARE categories_source_enabled_index_stmt;
|
||||
|
||||
SET @courses_source_enabled_index_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'courses'
|
||||
AND index_name = 'idx_courses_source_enabled_sort'
|
||||
);
|
||||
SET @courses_source_enabled_index_sql := IF(
|
||||
@courses_source_enabled_index_exists = 0,
|
||||
'CREATE INDEX idx_courses_source_enabled_sort ON courses (source_type, enabled, sort_order, updated_at)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE courses_source_enabled_index_stmt FROM @courses_source_enabled_index_sql;
|
||||
EXECUTE courses_source_enabled_index_stmt;
|
||||
DEALLOCATE PREPARE courses_source_enabled_index_stmt;
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
ALTER TABLE categories
|
||||
MODIFY COLUMN api_account_id VARCHAR(36) NULL DEFAULT NULL,
|
||||
MODIFY COLUMN provider VARCHAR(64) NULL DEFAULT NULL,
|
||||
MODIFY COLUMN external_id VARCHAR(160) NULL,
|
||||
MODIFY COLUMN remote_category_id VARCHAR(120) NULL;
|
||||
|
||||
ALTER TABLE courses
|
||||
MODIFY COLUMN api_account_id VARCHAR(36) NULL DEFAULT NULL,
|
||||
MODIFY COLUMN provider VARCHAR(64) NULL DEFAULT NULL,
|
||||
MODIFY COLUMN external_id VARCHAR(160) NULL,
|
||||
MODIFY COLUMN remote_category_id VARCHAR(120) NULL,
|
||||
MODIFY COLUMN remote_course_id VARCHAR(160) NULL;
|
||||
@@ -0,0 +1,94 @@
|
||||
CREATE TABLE IF NOT EXISTS catalog_sync_jobs (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
provider VARCHAR(64) NOT NULL,
|
||||
trigger_type VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
started_at DATETIME(6) NOT NULL,
|
||||
finished_at DATETIME(6) NULL,
|
||||
category_total INT NOT NULL DEFAULT 0,
|
||||
product_total INT NOT NULL DEFAULT 0,
|
||||
category_created INT NOT NULL DEFAULT 0,
|
||||
category_updated INT NOT NULL DEFAULT 0,
|
||||
product_created INT NOT NULL DEFAULT 0,
|
||||
product_updated INT NOT NULL DEFAULT 0,
|
||||
product_removed INT NOT NULL DEFAULT 0,
|
||||
error_message TEXT NULL,
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (id),
|
||||
INDEX idx_catalog_sync_jobs_provider_started (provider, started_at),
|
||||
INDEX idx_catalog_sync_jobs_status_started (status, started_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
SET @courses_sync_status_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'sync_status'
|
||||
);
|
||||
SET @courses_sync_status_sql := IF(
|
||||
@courses_sync_status_exists = 0,
|
||||
'ALTER TABLE courses ADD COLUMN sync_status VARCHAR(32) NOT NULL DEFAULT ''active'' AFTER enabled',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE courses_sync_status_stmt FROM @courses_sync_status_sql;
|
||||
EXECUTE courses_sync_status_stmt;
|
||||
DEALLOCATE PREPARE courses_sync_status_stmt;
|
||||
|
||||
SET @courses_last_seen_at_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'last_seen_at'
|
||||
);
|
||||
SET @courses_last_seen_at_sql := IF(
|
||||
@courses_last_seen_at_exists = 0,
|
||||
'ALTER TABLE courses ADD COLUMN last_seen_at DATETIME NULL AFTER last_synced_at',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE courses_last_seen_at_stmt FROM @courses_last_seen_at_sql;
|
||||
EXECUTE courses_last_seen_at_stmt;
|
||||
DEALLOCATE PREPARE courses_last_seen_at_stmt;
|
||||
|
||||
SET @courses_last_seen_sync_job_id_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'last_seen_sync_job_id'
|
||||
);
|
||||
SET @courses_last_seen_sync_job_id_sql := IF(
|
||||
@courses_last_seen_sync_job_id_exists = 0,
|
||||
'ALTER TABLE courses ADD COLUMN last_seen_sync_job_id VARCHAR(36) NULL AFTER last_seen_at',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE courses_last_seen_sync_job_id_stmt FROM @courses_last_seen_sync_job_id_sql;
|
||||
EXECUTE courses_last_seen_sync_job_id_stmt;
|
||||
DEALLOCATE PREPARE courses_last_seen_sync_job_id_stmt;
|
||||
|
||||
SET @courses_removed_at_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'removed_at'
|
||||
);
|
||||
SET @courses_removed_at_sql := IF(
|
||||
@courses_removed_at_exists = 0,
|
||||
'ALTER TABLE courses ADD COLUMN removed_at DATETIME NULL AFTER last_seen_sync_job_id',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE courses_removed_at_stmt FROM @courses_removed_at_sql;
|
||||
EXECUTE courses_removed_at_stmt;
|
||||
DEALLOCATE PREPARE courses_removed_at_stmt;
|
||||
|
||||
UPDATE courses
|
||||
SET sync_status = 'active',
|
||||
last_seen_at = COALESCE(last_seen_at, last_synced_at)
|
||||
WHERE source_type = 'third_party'
|
||||
AND (sync_status IS NULL OR sync_status = '');
|
||||
|
||||
SET @courses_sync_status_index_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'courses'
|
||||
AND index_name = 'idx_courses_sync_status_seen'
|
||||
);
|
||||
SET @courses_sync_status_index_sql := IF(
|
||||
@courses_sync_status_index_exists = 0,
|
||||
'CREATE INDEX idx_courses_sync_status_seen ON courses (source_type, provider, sync_status, last_seen_at)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE courses_sync_status_index_stmt FROM @courses_sync_status_index_sql;
|
||||
EXECUTE courses_sync_status_index_stmt;
|
||||
DEALLOCATE PREPARE courses_sync_status_index_stmt;
|
||||
@@ -0,0 +1,12 @@
|
||||
SET @courses_order_form_schema_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'order_form_schema'
|
||||
);
|
||||
SET @courses_order_form_schema_sql := IF(
|
||||
@courses_order_form_schema_exists = 0,
|
||||
'ALTER TABLE courses ADD COLUMN order_form_schema JSON NULL AFTER after_sales_note',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE courses_order_form_schema_stmt FROM @courses_order_form_schema_sql;
|
||||
EXECUTE courses_order_form_schema_stmt;
|
||||
DEALLOCATE PREPARE courses_order_form_schema_stmt;
|
||||
@@ -0,0 +1,60 @@
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
order_no VARCHAR(40) NOT NULL,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
source_type VARCHAR(32) NOT NULL,
|
||||
provider VARCHAR(64) NULL,
|
||||
total_amount DECIMAL(10, 2) NOT NULL,
|
||||
payable_amount DECIMAL(10, 2) NOT NULL,
|
||||
paid_amount DECIMAL(10, 2) NOT NULL DEFAULT 0,
|
||||
payment_status VARCHAR(32) NOT NULL,
|
||||
fulfillment_status VARCHAR(32) NOT NULL,
|
||||
remark VARCHAR(500) NULL,
|
||||
paid_at DATETIME NULL,
|
||||
closed_at DATETIME NULL,
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_orders_order_no (order_no),
|
||||
KEY idx_orders_user_created (user_id, created_at),
|
||||
KEY idx_orders_status_created (payment_status, fulfillment_status, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
order_id VARCHAR(36) NOT NULL,
|
||||
product_id VARCHAR(36) NOT NULL,
|
||||
product_name VARCHAR(255) NOT NULL,
|
||||
source_type VARCHAR(32) NOT NULL,
|
||||
external_product_id VARCHAR(160) NULL,
|
||||
provider VARCHAR(64) NULL,
|
||||
unit_price DECIMAL(10, 2) NOT NULL,
|
||||
quantity INT NOT NULL DEFAULT 1,
|
||||
total_amount DECIMAL(10, 2) NOT NULL,
|
||||
payload JSON NULL,
|
||||
encrypted_payload JSON NULL,
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_order_items_order_id (order_id),
|
||||
KEY idx_order_items_product_id (product_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_fulfillments (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
order_id VARCHAR(36) NOT NULL,
|
||||
order_item_id VARCHAR(36) NOT NULL,
|
||||
fulfillment_type VARCHAR(40) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
provider VARCHAR(64) NULL,
|
||||
external_order_no VARCHAR(160) NULL,
|
||||
request_payload JSON NULL,
|
||||
response_payload JSON NULL,
|
||||
error_message VARCHAR(500) NULL,
|
||||
submitted_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_order_fulfillments_order_id (order_id),
|
||||
KEY idx_order_fulfillments_status (status, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE IF NOT EXISTS order_actions (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
order_id VARCHAR(36) NOT NULL,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
source_type VARCHAR(32) NOT NULL,
|
||||
provider VARCHAR(64) NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
operator_id VARCHAR(36) NULL,
|
||||
request_payload JSON NULL,
|
||||
response_payload JSON NULL,
|
||||
error_message VARCHAR(500) NULL,
|
||||
remark VARCHAR(500) NULL,
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_order_actions_order_id (order_id, created_at),
|
||||
KEY idx_order_actions_action_created (action, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE IF NOT EXISTS third_party_orders (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
provider VARCHAR(64) NOT NULL,
|
||||
external_order_no VARCHAR(160) NOT NULL,
|
||||
username VARCHAR(160) NULL,
|
||||
school VARCHAR(255) NULL,
|
||||
course_name VARCHAR(255) NULL,
|
||||
remote_status VARCHAR(120) NULL,
|
||||
local_order_id VARCHAR(36) NULL,
|
||||
raw_payload JSON NULL,
|
||||
first_seen_at DATETIME NOT NULL,
|
||||
last_seen_at DATETIME NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_third_party_orders_provider_external (provider, external_order_no),
|
||||
KEY idx_third_party_orders_last_seen (provider, last_seen_at),
|
||||
KEY idx_third_party_orders_local_order (local_order_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
UPDATE roles
|
||||
SET permissions = JSON_SET(
|
||||
COALESCE(permissions, JSON_OBJECT()),
|
||||
'$."order-logs"',
|
||||
JSON_ARRAY('read', 'write')
|
||||
)
|
||||
WHERE code = 'super_admin';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { HttpExceptionFilter } from './common/response/http-exception.filter';
|
||||
import { ResponseInterceptor } from './common/response/response.interceptor';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new ResponseInterceptor());
|
||||
await app.listen(Number(process.env.PORT ?? 3001));
|
||||
}
|
||||
bootstrap();
|
||||
@@ -0,0 +1,18 @@
|
||||
export type ThirdPartyOrderLogType = 'normal' | 'zhs' | 'yjy';
|
||||
|
||||
export type QueryThirdPartyOrderLogDto = {
|
||||
orderId?: string;
|
||||
type?: ThirdPartyOrderLogType;
|
||||
};
|
||||
|
||||
export type QueryStreamOrderLogDto = {
|
||||
orderId?: string;
|
||||
};
|
||||
|
||||
export type OrderLogHistoryQuery = {
|
||||
page?: number | string;
|
||||
pageSize?: number | string;
|
||||
type?: string;
|
||||
status?: string;
|
||||
orderId?: string;
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Body, Controller, Get, Post, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { OperationLog } from '../audit/operation-log.decorator';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
import type { AuthenticatedRequest } from '../auth/types/authenticated-request';
|
||||
import type {
|
||||
OrderLogHistoryQuery,
|
||||
QueryStreamOrderLogDto,
|
||||
QueryThirdPartyOrderLogDto,
|
||||
} from './dto/order-logs.dto';
|
||||
import { OrderLogsService } from './order-logs.service';
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Controller('api/admin/order-logs')
|
||||
export class OrderLogsController {
|
||||
constructor(private readonly orderLogsService: OrderLogsService) {}
|
||||
|
||||
@Post('query')
|
||||
@OperationLog({
|
||||
action: 'order_logs.query',
|
||||
resourceType: 'order_log',
|
||||
metadataFromBody: ['orderId', 'type'],
|
||||
description: '查询第三方订单日志',
|
||||
})
|
||||
queryThirdPartyLog(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Body() dto: QueryThirdPartyOrderLogDto,
|
||||
) {
|
||||
return this.orderLogsService.queryThirdPartyLog(request.user, dto);
|
||||
}
|
||||
|
||||
@Post('stream')
|
||||
@OperationLog({
|
||||
action: 'order_logs.stream',
|
||||
resourceType: 'order_log',
|
||||
metadataFromBody: ['orderId'],
|
||||
description: '查询第三方 Stream 日志',
|
||||
})
|
||||
queryStreamLog(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Body() dto: QueryStreamOrderLogDto,
|
||||
) {
|
||||
return this.orderLogsService.queryStreamLog(request.user, dto);
|
||||
}
|
||||
|
||||
@Get('history')
|
||||
listHistory(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Query() query: OrderLogHistoryQuery,
|
||||
) {
|
||||
return this.orderLogsService.listHistory(request.user, query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { ApiCallLog } from '../third-party/entities/api-call-log.entity';
|
||||
import { ThirdPartyModule } from '../third-party/third-party.module';
|
||||
import { OrderLogsController } from './order-logs.controller';
|
||||
import { OrderLogsService } from './order-logs.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, ThirdPartyModule, TypeOrmModule.forFeature([ApiCallLog])],
|
||||
controllers: [OrderLogsController],
|
||||
providers: [OrderLogsService],
|
||||
})
|
||||
export class OrderLogsModule {}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { assertAdmin } from '../admin/admin-access';
|
||||
import { ApiCallLog } from '../third-party/entities/api-call-log.entity';
|
||||
import { maskSensitivePayload } from '../third-party/third-party-security';
|
||||
import { ThirdPartyClientService } from '../third-party/third-party-client.service';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import type {
|
||||
OrderLogHistoryQuery,
|
||||
QueryStreamOrderLogDto,
|
||||
QueryThirdPartyOrderLogDto,
|
||||
ThirdPartyOrderLogType,
|
||||
} from './dto/order-logs.dto';
|
||||
|
||||
const logTypeActs: Record<ThirdPartyOrderLogType, string> = {
|
||||
normal: 'cha_logwk',
|
||||
zhs: 'cha_log',
|
||||
yjy: 'get_yjy_study_log',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class OrderLogsService {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly thirdPartyClientService: ThirdPartyClientService,
|
||||
@InjectRepository(ApiCallLog)
|
||||
private readonly apiCallLogsRepository: Repository<ApiCallLog>,
|
||||
) {}
|
||||
|
||||
async queryThirdPartyLog(actor: User, dto: QueryThirdPartyOrderLogDto) {
|
||||
assertAdmin(actor);
|
||||
const orderId = this.getRequiredOrderId(dto.orderId);
|
||||
const type = this.getLogType(dto.type);
|
||||
const act = logTypeActs[type];
|
||||
const response = await this.thirdPartyClientService.call(act, {
|
||||
id: orderId,
|
||||
});
|
||||
|
||||
return {
|
||||
provider: 'biedawo',
|
||||
type,
|
||||
act,
|
||||
orderId,
|
||||
queriedAt: new Date().toISOString(),
|
||||
response,
|
||||
entries: this.normalizeEntries(response),
|
||||
};
|
||||
}
|
||||
|
||||
async queryStreamLog(actor: User, dto: QueryStreamOrderLogDto) {
|
||||
assertAdmin(actor);
|
||||
const orderId = this.getRequiredOrderId(dto.orderId);
|
||||
const startedAt = Date.now();
|
||||
const requestUrl = this.buildStreamLogUrl(orderId);
|
||||
let httpStatus: number | null = null;
|
||||
let responsePayload: unknown | null = null;
|
||||
let errorMessage: string | null = null;
|
||||
let status: 'success' | 'failure' = 'failure';
|
||||
let logged = false;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15_000);
|
||||
try {
|
||||
const response = await fetch(requestUrl, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
});
|
||||
httpStatus = response.status;
|
||||
const text = await response.text();
|
||||
responsePayload = this.parseStreamText(text);
|
||||
status = response.ok ? 'success' : 'failure';
|
||||
if (!response.ok) {
|
||||
errorMessage = `HTTP ${response.status}`;
|
||||
}
|
||||
|
||||
await this.writeStreamCallLog({
|
||||
orderId,
|
||||
requestUrl,
|
||||
status,
|
||||
httpStatus,
|
||||
durationMs: Date.now() - startedAt,
|
||||
responsePayload,
|
||||
errorMessage,
|
||||
});
|
||||
logged = true;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new BadRequestException(`第三方 Stream 日志请求失败:HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 'biedawo',
|
||||
type: 'stream',
|
||||
act: 'streamLogs',
|
||||
orderId,
|
||||
queriedAt: new Date().toISOString(),
|
||||
response: responsePayload,
|
||||
entries: this.normalizeStreamEntries(responsePayload),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = this.getErrorMessage(error);
|
||||
if (!errorMessage) {
|
||||
errorMessage = message;
|
||||
}
|
||||
if (!httpStatus) {
|
||||
responsePayload = null;
|
||||
}
|
||||
if (!logged) {
|
||||
await this.writeStreamCallLog({
|
||||
orderId,
|
||||
requestUrl,
|
||||
status: 'failure',
|
||||
httpStatus,
|
||||
durationMs: Date.now() - startedAt,
|
||||
responsePayload,
|
||||
errorMessage,
|
||||
});
|
||||
}
|
||||
if (error instanceof BadRequestException) {
|
||||
throw error;
|
||||
}
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
}
|
||||
|
||||
async listHistory(actor: User, query: OrderLogHistoryQuery) {
|
||||
assertAdmin(actor);
|
||||
const page = Math.max(Number(query.page || 1), 1);
|
||||
const pageSize = Math.min(Math.max(Number(query.pageSize || 10), 1), 100);
|
||||
const type = String(query.type || '').trim();
|
||||
const status = String(query.status || '').trim();
|
||||
const orderId = String(query.orderId || '').trim();
|
||||
const acts = type ? [this.typeToAct(type)] : Object.values(logTypeActs).concat('streamLogs');
|
||||
|
||||
const builder = this.apiCallLogsRepository
|
||||
.createQueryBuilder('log')
|
||||
.where('log.act IN (:...acts)', { acts })
|
||||
.orderBy('log.createdAt', 'DESC')
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
|
||||
if (status) {
|
||||
builder.andWhere('log.status = :status', { status });
|
||||
}
|
||||
if (orderId) {
|
||||
builder.andWhere(
|
||||
'JSON_UNQUOTE(JSON_EXTRACT(log.requestPayload, "$.id")) LIKE :orderId',
|
||||
{ orderId: `%${orderId}%` },
|
||||
);
|
||||
}
|
||||
|
||||
const [list, total] = await builder.getManyAndCount();
|
||||
return { list, total, page, pageSize };
|
||||
}
|
||||
|
||||
private getRequiredOrderId(value: unknown) {
|
||||
const orderId = String(value || '').trim();
|
||||
if (!orderId) {
|
||||
throw new BadRequestException('请填写第三方订单 ID');
|
||||
}
|
||||
return orderId;
|
||||
}
|
||||
|
||||
private getLogType(value: unknown): ThirdPartyOrderLogType {
|
||||
const type = String(value || 'normal').trim() as ThirdPartyOrderLogType;
|
||||
if (!Object.hasOwn(logTypeActs, type)) {
|
||||
throw new BadRequestException('不支持的日志类型');
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
private typeToAct(type: string) {
|
||||
if (type === 'stream') {
|
||||
return 'streamLogs';
|
||||
}
|
||||
return logTypeActs[this.getLogType(type)];
|
||||
}
|
||||
|
||||
private buildStreamLogUrl(orderId: string) {
|
||||
const apiUrl = new URL(this.configService.getOrThrow<string>('WK_BASE_URL'));
|
||||
const streamUrl = new URL('/api/streamLogs', apiUrl.origin);
|
||||
streamUrl.searchParams.set('id', orderId);
|
||||
return streamUrl.toString();
|
||||
}
|
||||
|
||||
private parseStreamText(text: string) {
|
||||
const lines = text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const events = lines.map((line) => {
|
||||
if (!line.startsWith('data:')) {
|
||||
return { raw: line };
|
||||
}
|
||||
const data = line.replace(/^data:\s*/, '');
|
||||
try {
|
||||
return JSON.parse(data) as unknown;
|
||||
} catch {
|
||||
return { raw: data };
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
raw: text,
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeEntries(response: unknown) {
|
||||
const data = this.extractData(response);
|
||||
if (Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
if (data && typeof data === 'object') {
|
||||
const body = data as Record<string, unknown>;
|
||||
for (const key of ['list', 'logs', 'records', 'rows']) {
|
||||
if (Array.isArray(body[key])) {
|
||||
return body[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private normalizeStreamEntries(response: unknown) {
|
||||
if (!response || typeof response !== 'object') {
|
||||
return [];
|
||||
}
|
||||
const events = (response as { events?: unknown }).events;
|
||||
return Array.isArray(events) ? events : [];
|
||||
}
|
||||
|
||||
private extractData(response: unknown) {
|
||||
if (!response || typeof response !== 'object') {
|
||||
return response;
|
||||
}
|
||||
const body = response as Record<string, unknown>;
|
||||
return body.data ?? body.result ?? body.rows ?? body;
|
||||
}
|
||||
|
||||
private async writeStreamCallLog(input: {
|
||||
orderId: string;
|
||||
requestUrl: string;
|
||||
status: 'success' | 'failure';
|
||||
httpStatus: number | null;
|
||||
durationMs: number;
|
||||
responsePayload: unknown | null;
|
||||
errorMessage: string | null;
|
||||
}) {
|
||||
await this.thirdPartyClientService.writeLog({
|
||||
act: 'streamLogs',
|
||||
method: 'GET',
|
||||
url: input.requestUrl,
|
||||
status: input.status,
|
||||
httpStatus: input.httpStatus,
|
||||
durationMs: input.durationMs,
|
||||
requestPayload: maskSensitivePayload({
|
||||
act: 'streamLogs',
|
||||
id: input.orderId,
|
||||
}),
|
||||
responsePayload: maskSensitivePayload(input.responsePayload),
|
||||
errorMessage: input.errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
private getErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.name === 'AbortError' ? '第三方 Stream 日志请求超时' : error.message;
|
||||
}
|
||||
return '第三方 Stream 日志请求失败';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { SourceType } from '../../third-party/entities/category.entity';
|
||||
|
||||
export type ThirdPartyCourseQueryDto = {
|
||||
productId?: string;
|
||||
school?: string;
|
||||
account?: string;
|
||||
password?: string;
|
||||
expand?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type SelectedCourseDto = {
|
||||
courseName?: string;
|
||||
courseId?: string | null;
|
||||
raw?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type CreateOrderDto = {
|
||||
productId?: string;
|
||||
quantity?: number;
|
||||
orderPayload?: Record<string, unknown> | null;
|
||||
selectedCourse?: SelectedCourseDto | null;
|
||||
expand?: Record<string, unknown> | null;
|
||||
remark?: string | null;
|
||||
};
|
||||
|
||||
export type OrderListQuery = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
sourceType?: SourceType | 'all';
|
||||
paymentStatus?: string;
|
||||
fulfillmentStatus?: string;
|
||||
};
|
||||
|
||||
export type ThirdPartyOrderAction =
|
||||
| 'budan'
|
||||
| 'gaimi'
|
||||
| 'stop'
|
||||
| 'priority'
|
||||
| 'convert'
|
||||
| 'update-time'
|
||||
| 'update-cycle';
|
||||
|
||||
export type ThirdPartyOrderActionDto = {
|
||||
externalOrderNo?: string | null;
|
||||
username?: string | null;
|
||||
newPwd?: string | null;
|
||||
remark?: string | string[] | null;
|
||||
city?: string | null;
|
||||
tag?: string | null;
|
||||
config?: Record<string, unknown> | string | null;
|
||||
autoReset?: boolean | number | string | null;
|
||||
convertToClassId?: string | number | null;
|
||||
time?: string | number | null;
|
||||
cycle?: string | number | null;
|
||||
};
|
||||
|
||||
export type ThirdPartyOrderListSyncDto = {
|
||||
page?: string | number | null;
|
||||
limit?: string | number | null;
|
||||
recent?: string | number | null;
|
||||
};
|
||||
|
||||
export type LocalOrderAction =
|
||||
| 'process'
|
||||
| 'complete'
|
||||
| 'cancel'
|
||||
| 'close'
|
||||
| 'remark';
|
||||
|
||||
export type LocalOrderActionDto = {
|
||||
remark?: string | null;
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryColumn,
|
||||
} from 'typeorm';
|
||||
import type { SourceType } from '../../third-party/entities/category.entity';
|
||||
|
||||
export type OrderActionStatus = 'success' | 'failed';
|
||||
|
||||
@Entity('order_actions')
|
||||
@Index('idx_order_actions_order_id', ['orderId', 'createdAt'])
|
||||
@Index('idx_order_actions_action_created', ['action', 'createdAt'])
|
||||
export class OrderAction {
|
||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'order_id', type: 'varchar', length: 36 })
|
||||
orderId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 64 })
|
||||
action: string;
|
||||
|
||||
@Column({ name: 'source_type', type: 'varchar', length: 32 })
|
||||
sourceType: SourceType;
|
||||
|
||||
@Column({ type: 'varchar', length: 64, nullable: true })
|
||||
provider: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 32 })
|
||||
status: OrderActionStatus;
|
||||
|
||||
@Column({ name: 'operator_id', type: 'varchar', length: 36, nullable: true })
|
||||
operatorId: string | null;
|
||||
|
||||
@Column({ name: 'request_payload', type: 'json', nullable: true })
|
||||
requestPayload: Record<string, unknown> | null;
|
||||
|
||||
@Column({ name: 'response_payload', type: 'json', nullable: true })
|
||||
responsePayload: unknown | null;
|
||||
|
||||
@Column({ name: 'error_message', type: 'varchar', length: 500, nullable: true })
|
||||
errorMessage: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||
remark: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 6 })
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import type { FulfillmentStatus } from './order.entity';
|
||||
|
||||
export type FulfillmentType = 'third_party_api' | 'local_only' | 'manual';
|
||||
|
||||
@Entity('order_fulfillments')
|
||||
@Index('idx_order_fulfillments_order_id', ['orderId'])
|
||||
@Index('idx_order_fulfillments_status', ['status', 'createdAt'])
|
||||
export class OrderFulfillment {
|
||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'order_id', type: 'varchar', length: 36 })
|
||||
orderId: string;
|
||||
|
||||
@Column({ name: 'order_item_id', type: 'varchar', length: 36 })
|
||||
orderItemId: string;
|
||||
|
||||
@Column({ name: 'fulfillment_type', type: 'varchar', length: 40 })
|
||||
fulfillmentType: FulfillmentType;
|
||||
|
||||
@Column({ type: 'varchar', length: 32 })
|
||||
status: FulfillmentStatus;
|
||||
|
||||
@Column({ type: 'varchar', length: 64, nullable: true })
|
||||
provider: string | null;
|
||||
|
||||
@Column({ name: 'external_order_no', type: 'varchar', length: 160, nullable: true })
|
||||
externalOrderNo: string | null;
|
||||
|
||||
@Column({ name: 'request_payload', type: 'json', nullable: true })
|
||||
requestPayload: Record<string, unknown> | null;
|
||||
|
||||
@Column({ name: 'response_payload', type: 'json', nullable: true })
|
||||
responsePayload: unknown | null;
|
||||
|
||||
@Column({ name: 'error_message', type: 'varchar', length: 500, nullable: true })
|
||||
errorMessage: string | null;
|
||||
|
||||
@Column({ name: 'submitted_at', type: 'datetime', nullable: true })
|
||||
submittedAt: Date | null;
|
||||
|
||||
@Column({ name: 'completed_at', type: 'datetime', nullable: true })
|
||||
completedAt: Date | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 6 })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 6 })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryColumn,
|
||||
} from 'typeorm';
|
||||
import type { SourceType } from '../../third-party/entities/category.entity';
|
||||
|
||||
@Entity('order_items')
|
||||
@Index('idx_order_items_order_id', ['orderId'])
|
||||
@Index('idx_order_items_product_id', ['productId'])
|
||||
export class OrderItem {
|
||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'order_id', type: 'varchar', length: 36 })
|
||||
orderId: string;
|
||||
|
||||
@Column({ name: 'product_id', type: 'varchar', length: 36 })
|
||||
productId: string;
|
||||
|
||||
@Column({ name: 'product_name', type: 'varchar', length: 255 })
|
||||
productName: string;
|
||||
|
||||
@Column({ name: 'source_type', type: 'varchar', length: 32 })
|
||||
sourceType: SourceType;
|
||||
|
||||
@Column({ name: 'external_product_id', type: 'varchar', length: 160, nullable: true })
|
||||
externalProductId: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 64, nullable: true })
|
||||
provider: string | null;
|
||||
|
||||
@Column({ name: 'unit_price', type: 'decimal', precision: 10, scale: 2 })
|
||||
unitPrice: string;
|
||||
|
||||
@Column({ type: 'int', default: 1 })
|
||||
quantity: number;
|
||||
|
||||
@Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2 })
|
||||
totalAmount: string;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
payload: Record<string, unknown> | null;
|
||||
|
||||
@Column({ name: 'encrypted_payload', type: 'json', nullable: true })
|
||||
encryptedPayload: Record<string, unknown> | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 6 })
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import type { SourceType } from '../../third-party/entities/category.entity';
|
||||
|
||||
export type PaymentStatus =
|
||||
| 'unpaid'
|
||||
| 'paying'
|
||||
| 'paid'
|
||||
| 'pay_failed'
|
||||
| 'closed'
|
||||
| 'refunding'
|
||||
| 'refunded'
|
||||
| 'partial_refunded';
|
||||
|
||||
export type FulfillmentStatus =
|
||||
| 'pending'
|
||||
| 'submitting'
|
||||
| 'submitted'
|
||||
| 'processing'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'canceled';
|
||||
|
||||
@Entity('orders')
|
||||
@Index('uk_orders_order_no', ['orderNo'], { unique: true })
|
||||
@Index('idx_orders_user_created', ['userId', 'createdAt'])
|
||||
@Index('idx_orders_status_created', [
|
||||
'paymentStatus',
|
||||
'fulfillmentStatus',
|
||||
'createdAt',
|
||||
])
|
||||
export class Order {
|
||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'order_no', type: 'varchar', length: 40 })
|
||||
orderNo: string;
|
||||
|
||||
@Column({ name: 'user_id', type: 'varchar', length: 36 })
|
||||
userId: string;
|
||||
|
||||
@Column({ name: 'source_type', type: 'varchar', length: 32 })
|
||||
sourceType: SourceType;
|
||||
|
||||
@Column({ type: 'varchar', length: 64, nullable: true })
|
||||
provider: string | null;
|
||||
|
||||
@Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2 })
|
||||
totalAmount: string;
|
||||
|
||||
@Column({ name: 'payable_amount', type: 'decimal', precision: 10, scale: 2 })
|
||||
payableAmount: string;
|
||||
|
||||
@Column({
|
||||
name: 'paid_amount',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
default: 0,
|
||||
})
|
||||
paidAmount: string;
|
||||
|
||||
@Column({ name: 'payment_status', type: 'varchar', length: 32 })
|
||||
paymentStatus: PaymentStatus;
|
||||
|
||||
@Column({ name: 'fulfillment_status', type: 'varchar', length: 32 })
|
||||
fulfillmentStatus: FulfillmentStatus;
|
||||
|
||||
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||
remark: string | null;
|
||||
|
||||
@Column({ name: 'paid_at', type: 'datetime', nullable: true })
|
||||
paidAt: Date | null;
|
||||
|
||||
@Column({ name: 'closed_at', type: 'datetime', nullable: true })
|
||||
closedAt: Date | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 6 })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 6 })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
export const THIRD_PARTY_ORDER_PROVIDER = 'biedawo';
|
||||
|
||||
@Entity('third_party_orders')
|
||||
@Index('uk_third_party_orders_provider_external', ['provider', 'externalOrderNo'], {
|
||||
unique: true,
|
||||
})
|
||||
@Index('idx_third_party_orders_last_seen', ['provider', 'lastSeenAt'])
|
||||
@Index('idx_third_party_orders_local_order', ['localOrderId'])
|
||||
export class ThirdPartyOrder {
|
||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 64 })
|
||||
provider: string;
|
||||
|
||||
@Column({ name: 'external_order_no', type: 'varchar', length: 160 })
|
||||
externalOrderNo: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 160, nullable: true })
|
||||
username: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
school: string | null;
|
||||
|
||||
@Column({ name: 'course_name', type: 'varchar', length: 255, nullable: true })
|
||||
courseName: string | null;
|
||||
|
||||
@Column({ name: 'remote_status', type: 'varchar', length: 120, nullable: true })
|
||||
remoteStatus: string | null;
|
||||
|
||||
@Column({ name: 'local_order_id', type: 'varchar', length: 36, nullable: true })
|
||||
localOrderId: string | null;
|
||||
|
||||
@Column({ name: 'raw_payload', type: 'json', nullable: true })
|
||||
rawPayload: Record<string, unknown> | null;
|
||||
|
||||
@Column({ name: 'first_seen_at', type: 'datetime' })
|
||||
firstSeenAt: Date;
|
||||
|
||||
@Column({ name: 'last_seen_at', type: 'datetime' })
|
||||
lastSeenAt: Date;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 6 })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 6 })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { OperationLog } from '../audit/operation-log.decorator';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
import type { AuthenticatedRequest } from '../auth/types/authenticated-request';
|
||||
import type { ThirdPartyOrderListSyncDto } from './dto/orders.dto';
|
||||
import { OrdersService } from './orders.service';
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Controller('api/admin/third-party/orders')
|
||||
export class OrdersThirdPartyAdminController {
|
||||
constructor(private readonly ordersService: OrdersService) {}
|
||||
|
||||
@Post('sync')
|
||||
@OperationLog({
|
||||
action: 'third_party.orders.sync',
|
||||
resourceType: 'order',
|
||||
metadataFromBody: ['page', 'limit', 'recent'],
|
||||
description: '拉取第三方订单列表并同步本地订单',
|
||||
})
|
||||
syncThirdPartyOrderList(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Body() dto: ThirdPartyOrderListSyncDto,
|
||||
) {
|
||||
return this.ordersService.syncThirdPartyOrderList(request.user, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { OrdersService } from './orders.service';
|
||||
|
||||
@Injectable()
|
||||
export class OrdersThirdPartySyncTask {
|
||||
private readonly logger = new Logger(OrdersThirdPartySyncTask.name);
|
||||
|
||||
constructor(private readonly ordersService: OrdersService) {}
|
||||
|
||||
@Cron(process.env.THIRD_PARTY_ORDER_SYNC_CRON || '*/30 * * * *')
|
||||
async handleOrderSync() {
|
||||
if (process.env.THIRD_PARTY_ORDER_SYNC_CRON_ENABLED !== 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ordersService.syncThirdPartyOrderListFromTask({
|
||||
page: process.env.THIRD_PARTY_ORDER_SYNC_PAGE,
|
||||
limit: process.env.THIRD_PARTY_ORDER_SYNC_LIMIT,
|
||||
recent: process.env.THIRD_PARTY_ORDER_SYNC_RECENT,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { OperationLog } from '../audit/operation-log.decorator';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
import type { AuthenticatedRequest } from '../auth/types/authenticated-request';
|
||||
import type {
|
||||
CreateOrderDto,
|
||||
LocalOrderAction,
|
||||
LocalOrderActionDto,
|
||||
OrderListQuery,
|
||||
ThirdPartyOrderAction,
|
||||
ThirdPartyOrderActionDto,
|
||||
ThirdPartyOrderListSyncDto,
|
||||
ThirdPartyCourseQueryDto,
|
||||
} from './dto/orders.dto';
|
||||
import { OrdersService } from './orders.service';
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Controller('api/admin/orders')
|
||||
export class OrdersController {
|
||||
constructor(private readonly ordersService: OrdersService) {}
|
||||
|
||||
@Post('course-query')
|
||||
@OperationLog({
|
||||
action: 'orders.course_query',
|
||||
resourceType: 'order',
|
||||
metadataFromBody: ['productId', 'school', 'account'],
|
||||
description: '第三方查课',
|
||||
})
|
||||
queryThirdPartyCourses(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Body() dto: ThirdPartyCourseQueryDto,
|
||||
) {
|
||||
return this.ordersService.queryThirdPartyCourses(request.user, dto);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@OperationLog({
|
||||
action: 'orders.create',
|
||||
resourceType: 'order',
|
||||
resourceIdFromResult: 'id',
|
||||
metadataFromBody: ['productId', 'quantity', 'remark'],
|
||||
description: '创建订单',
|
||||
})
|
||||
createOrder(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Body() dto: CreateOrderDto,
|
||||
) {
|
||||
return this.ordersService.createOrder(request.user, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
listOrders(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Query() query: OrderListQuery,
|
||||
) {
|
||||
return this.ordersService.listOrders(request.user, query);
|
||||
}
|
||||
|
||||
@Get(':idOrNo')
|
||||
getOrderDetail(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('idOrNo') idOrNo: string,
|
||||
) {
|
||||
return this.ordersService.getOrderDetail(request.user, idOrNo);
|
||||
}
|
||||
|
||||
@Patch(':idOrNo/mark-paid')
|
||||
@OperationLog({
|
||||
action: 'orders.mark_paid',
|
||||
resourceType: 'order',
|
||||
resourceIdParam: 'idOrNo',
|
||||
description: '标记订单已支付并触发履约',
|
||||
})
|
||||
markOrderPaid(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('idOrNo') idOrNo: string,
|
||||
) {
|
||||
return this.ordersService.markOrderPaid(request.user, idOrNo);
|
||||
}
|
||||
|
||||
@Post('third-party/sync-list')
|
||||
@OperationLog({
|
||||
action: 'orders.third_party.sync_list',
|
||||
resourceType: 'order',
|
||||
metadataFromBody: ['page', 'limit', 'recent'],
|
||||
description: '拉取第三方订单列表并同步本地订单',
|
||||
})
|
||||
syncThirdPartyOrderList(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Body() dto: ThirdPartyOrderListSyncDto,
|
||||
) {
|
||||
return this.ordersService.syncThirdPartyOrderList(request.user, dto);
|
||||
}
|
||||
|
||||
@Post(':idOrNo/third-party/refresh')
|
||||
@OperationLog({
|
||||
action: 'orders.third_party.refresh',
|
||||
resourceType: 'order',
|
||||
resourceIdParam: 'idOrNo',
|
||||
metadataFromBody: ['externalOrderNo', 'username'],
|
||||
description: '第三方订单查单刷新',
|
||||
})
|
||||
refreshThirdPartyOrder(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('idOrNo') idOrNo: string,
|
||||
@Body() dto: ThirdPartyOrderActionDto,
|
||||
) {
|
||||
return this.ordersService.refreshThirdPartyOrder(
|
||||
request.user,
|
||||
idOrNo,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':idOrNo/third-party/:action')
|
||||
@OperationLog({
|
||||
action: 'orders.third_party.action',
|
||||
resourceType: 'order',
|
||||
resourceIdParam: 'idOrNo',
|
||||
metadataFromBody: [
|
||||
'externalOrderNo',
|
||||
'username',
|
||||
'convertToClassId',
|
||||
'time',
|
||||
'cycle',
|
||||
'autoReset',
|
||||
],
|
||||
description: '第三方订单操作',
|
||||
})
|
||||
runThirdPartyOrderAction(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('idOrNo') idOrNo: string,
|
||||
@Param('action') action: ThirdPartyOrderAction,
|
||||
@Body() dto: ThirdPartyOrderActionDto,
|
||||
) {
|
||||
return this.ordersService.runThirdPartyOrderAction(
|
||||
request.user,
|
||||
idOrNo,
|
||||
action,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':idOrNo/local/:action')
|
||||
@OperationLog({
|
||||
action: 'orders.local.action',
|
||||
resourceType: 'order',
|
||||
resourceIdParam: 'idOrNo',
|
||||
metadataFromBody: ['remark'],
|
||||
description: '自营订单本地操作',
|
||||
})
|
||||
runLocalOrderAction(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('idOrNo') idOrNo: string,
|
||||
@Param('action') action: LocalOrderAction,
|
||||
@Body() dto: LocalOrderActionDto,
|
||||
) {
|
||||
return this.ordersService.runLocalOrderAction(
|
||||
request.user,
|
||||
idOrNo,
|
||||
action,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { ThirdPartyModule } from '../third-party/third-party.module';
|
||||
import { Course } from '../third-party/entities/course.entity';
|
||||
import { OrderAction } from './entities/order-action.entity';
|
||||
import { OrderFulfillment } from './entities/order-fulfillment.entity';
|
||||
import { OrderItem } from './entities/order-item.entity';
|
||||
import { Order } from './entities/order.entity';
|
||||
import { ThirdPartyOrder } from './entities/third-party-order.entity';
|
||||
import { OrdersController } from './orders.controller';
|
||||
import { OrdersThirdPartyAdminController } from './orders-third-party-admin.controller';
|
||||
import { OrdersThirdPartySyncTask } from './orders-third-party-sync.task';
|
||||
import { OrdersService } from './orders.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
ThirdPartyModule,
|
||||
TypeOrmModule.forFeature([
|
||||
Course,
|
||||
Order,
|
||||
OrderItem,
|
||||
OrderFulfillment,
|
||||
OrderAction,
|
||||
ThirdPartyOrder,
|
||||
]),
|
||||
],
|
||||
controllers: [OrdersController, OrdersThirdPartyAdminController],
|
||||
providers: [OrdersService, OrdersThirdPartySyncTask],
|
||||
exports: [OrdersService],
|
||||
})
|
||||
export class OrdersModule {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
export type ApiCallLogStatus = 'success' | 'failure';
|
||||
|
||||
@Entity('api_call_logs')
|
||||
@Index('idx_api_call_logs_provider_created_at', ['provider', 'createdAt'])
|
||||
@Index('idx_api_call_logs_act_created_at', ['act', 'createdAt'])
|
||||
export class ApiCallLog {
|
||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 64, default: 'biedawo' })
|
||||
provider: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 80 })
|
||||
act: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 12 })
|
||||
method: string;
|
||||
|
||||
@Column({ name: 'endpoint', type: 'varchar', length: 255 })
|
||||
url: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 16 })
|
||||
status: ApiCallLogStatus;
|
||||
|
||||
@Column({ type: 'tinyint', default: 0 })
|
||||
success: boolean;
|
||||
|
||||
@Column({ name: 'status_code', type: 'int', nullable: true })
|
||||
httpStatus: number | null;
|
||||
|
||||
@Column({ name: 'duration_ms', type: 'int', nullable: true })
|
||||
durationMs: number | null;
|
||||
|
||||
@Column({ name: 'request_payload', type: 'json', nullable: true })
|
||||
requestPayload: Record<string, unknown> | null;
|
||||
|
||||
@Column({ name: 'response_payload', type: 'json', nullable: true })
|
||||
responsePayload: unknown | null;
|
||||
|
||||
@Column({ name: 'error_message', type: 'text', nullable: true })
|
||||
errorMessage: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 6 })
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
export type CatalogSyncTriggerType = 'manual' | 'scheduled';
|
||||
export type CatalogSyncJobStatus = 'running' | 'success' | 'failed';
|
||||
|
||||
@Entity('catalog_sync_jobs')
|
||||
@Index('idx_catalog_sync_jobs_provider_started', ['provider', 'startedAt'])
|
||||
@Index('idx_catalog_sync_jobs_status_started', ['status', 'startedAt'])
|
||||
export class CatalogSyncJob {
|
||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 64 })
|
||||
provider: string;
|
||||
|
||||
@Column({ name: 'trigger_type', type: 'varchar', length: 32 })
|
||||
triggerType: CatalogSyncTriggerType;
|
||||
|
||||
@Column({ type: 'varchar', length: 32 })
|
||||
status: CatalogSyncJobStatus;
|
||||
|
||||
@Column({ name: 'started_at', type: 'datetime', precision: 6 })
|
||||
startedAt: Date;
|
||||
|
||||
@Column({ name: 'finished_at', type: 'datetime', precision: 6, nullable: true })
|
||||
finishedAt: Date | null;
|
||||
|
||||
@Column({ name: 'category_total', type: 'int', default: 0 })
|
||||
categoryTotal: number;
|
||||
|
||||
@Column({ name: 'product_total', type: 'int', default: 0 })
|
||||
productTotal: number;
|
||||
|
||||
@Column({ name: 'category_created', type: 'int', default: 0 })
|
||||
categoryCreated: number;
|
||||
|
||||
@Column({ name: 'category_updated', type: 'int', default: 0 })
|
||||
categoryUpdated: number;
|
||||
|
||||
@Column({ name: 'product_created', type: 'int', default: 0 })
|
||||
productCreated: number;
|
||||
|
||||
@Column({ name: 'product_updated', type: 'int', default: 0 })
|
||||
productUpdated: number;
|
||||
|
||||
@Column({ name: 'product_removed', type: 'int', default: 0 })
|
||||
productRemoved: number;
|
||||
|
||||
@Column({ name: 'error_message', type: 'text', nullable: true })
|
||||
errorMessage: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 6 })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 6 })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
export type SourceType = 'third_party' | 'self_owned';
|
||||
|
||||
@Entity('categories')
|
||||
@Index('idx_categories_source_provider_external', [
|
||||
'sourceType',
|
||||
'provider',
|
||||
'externalId',
|
||||
])
|
||||
@Index('idx_categories_provider_remote', ['provider', 'remoteCategoryId'])
|
||||
export class Category {
|
||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'source_type', type: 'varchar', length: 32 })
|
||||
sourceType: SourceType;
|
||||
|
||||
@Column({ name: 'api_account_id', type: 'varchar', length: 36, nullable: true })
|
||||
apiAccountId: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 64, nullable: true })
|
||||
provider: string | null;
|
||||
|
||||
@Column({ name: 'external_id', type: 'varchar', length: 160, nullable: true })
|
||||
externalId: string | null;
|
||||
|
||||
@Column({ name: 'remote_category_id', type: 'varchar', length: 120, nullable: true })
|
||||
remoteCategoryId: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 160 })
|
||||
name: string;
|
||||
|
||||
@Column({ name: 'sort_order', type: 'int', default: 0 })
|
||||
sortOrder: number;
|
||||
|
||||
@Column({ type: 'tinyint', default: 1 })
|
||||
enabled: boolean;
|
||||
|
||||
@Column({ name: 'raw_payload', type: 'json', nullable: true })
|
||||
rawPayload: Record<string, unknown> | null;
|
||||
|
||||
@Column({ name: 'last_synced_at', type: 'datetime', nullable: true })
|
||||
lastSyncedAt: Date | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 6 })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 6 })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import type { SourceType } from './category.entity';
|
||||
|
||||
export type ProductSyncStatus = 'active' | 'removed';
|
||||
|
||||
@Entity('courses')
|
||||
@Index('idx_courses_source_provider_external_category', [
|
||||
'sourceType',
|
||||
'provider',
|
||||
'externalId',
|
||||
'remoteCategoryId',
|
||||
])
|
||||
@Index('idx_courses_provider_category_updated', [
|
||||
'provider',
|
||||
'remoteCategoryId',
|
||||
'updatedAt',
|
||||
])
|
||||
export class Course {
|
||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'source_type', type: 'varchar', length: 32 })
|
||||
sourceType: SourceType;
|
||||
|
||||
@Column({ name: 'api_account_id', type: 'varchar', length: 36, nullable: true })
|
||||
apiAccountId: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 64, nullable: true })
|
||||
provider: string | null;
|
||||
|
||||
@Column({ name: 'external_id', type: 'varchar', length: 160, nullable: true })
|
||||
externalId: string | null;
|
||||
|
||||
@Column({ name: 'category_id', type: 'varchar', length: 36, nullable: true })
|
||||
categoryId: string | null;
|
||||
|
||||
@Column({ name: 'remote_category_id', type: 'varchar', length: 120, nullable: true })
|
||||
remoteCategoryId: string | null;
|
||||
|
||||
@Column({ name: 'remote_course_id', type: 'varchar', length: 160, nullable: true })
|
||||
remoteCourseId: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||
price: string;
|
||||
|
||||
@Column({ name: 'cover_url', type: 'varchar', length: 500, nullable: true })
|
||||
coverUrl: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
content: string | null;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
stock: number | null;
|
||||
|
||||
@Column({ name: 'sales_limit', type: 'int', nullable: true })
|
||||
salesLimit: number | null;
|
||||
|
||||
@Column({ name: 'fulfillment_type', type: 'varchar', length: 40, default: 'third_party_api' })
|
||||
fulfillmentType: string;
|
||||
|
||||
@Column({ name: 'after_sales_note', type: 'text', nullable: true })
|
||||
afterSalesNote: string | null;
|
||||
|
||||
@Column({ name: 'order_form_schema', type: 'json', nullable: true })
|
||||
orderFormSchema: OrderFormField[] | null;
|
||||
|
||||
@Column({ name: 'sort_order', type: 'int', default: 0 })
|
||||
sortOrder: number;
|
||||
|
||||
@Column({ name: 'is_favorite', type: 'tinyint', default: 0 })
|
||||
isFavorite: boolean;
|
||||
|
||||
@Column({ type: 'tinyint', default: 1 })
|
||||
enabled: boolean;
|
||||
|
||||
@Column({ name: 'sync_status', type: 'varchar', length: 32, default: 'active' })
|
||||
syncStatus: ProductSyncStatus;
|
||||
|
||||
@Column({ name: 'raw_payload', type: 'json', nullable: true })
|
||||
rawPayload: Record<string, unknown> | null;
|
||||
|
||||
@Column({ name: 'last_synced_at', type: 'datetime', nullable: true })
|
||||
lastSyncedAt: Date | null;
|
||||
|
||||
@Column({ name: 'last_seen_at', type: 'datetime', nullable: true })
|
||||
lastSeenAt: Date | null;
|
||||
|
||||
@Column({ name: 'last_seen_sync_job_id', type: 'varchar', length: 36, nullable: true })
|
||||
lastSeenSyncJobId: string | null;
|
||||
|
||||
@Column({ name: 'removed_at', type: 'datetime', nullable: true })
|
||||
removedAt: Date | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 6 })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 6 })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type OrderFormFieldType =
|
||||
| 'text'
|
||||
| 'password'
|
||||
| 'phone'
|
||||
| 'number'
|
||||
| 'textarea'
|
||||
| 'select'
|
||||
| 'checkbox';
|
||||
|
||||
export type OrderFormField = {
|
||||
key: string;
|
||||
label: string;
|
||||
type: OrderFormFieldType;
|
||||
required: boolean;
|
||||
placeholder?: string | null;
|
||||
options?: Array<{ label: string; value: string }>;
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Controller, Get, Post, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { OperationLog } from '../audit/operation-log.decorator';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
import type { AuthenticatedRequest } from '../auth/types/authenticated-request';
|
||||
import { ThirdPartyAdminService } from './third-party-admin.service';
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Controller('api/admin/third-party')
|
||||
export class ThirdPartyAdminController {
|
||||
constructor(private readonly thirdPartyAdminService: ThirdPartyAdminService) {}
|
||||
|
||||
@Get('config-status')
|
||||
getConfigStatus(@Req() request: AuthenticatedRequest) {
|
||||
return this.thirdPartyAdminService.getConfigStatus(request.user);
|
||||
}
|
||||
|
||||
@Post('test-connection')
|
||||
@OperationLog({
|
||||
action: 'third_party.test_connection',
|
||||
resourceType: 'third_party',
|
||||
description: '测试第三方接口连接',
|
||||
})
|
||||
testConnection(@Req() request: AuthenticatedRequest) {
|
||||
return this.thirdPartyAdminService.testConnection(request.user);
|
||||
}
|
||||
|
||||
@Get('api-call-logs')
|
||||
listApiCallLogs(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Query() query: Record<string, unknown>,
|
||||
) {
|
||||
return this.thirdPartyAdminService.listApiCallLogs(request.user, query);
|
||||
}
|
||||
|
||||
@Post('categories/sync')
|
||||
@OperationLog({
|
||||
action: 'third_party.categories.sync',
|
||||
resourceType: 'category',
|
||||
description: '同步第三方分类',
|
||||
})
|
||||
syncCategories(@Req() request: AuthenticatedRequest) {
|
||||
return this.thirdPartyAdminService.syncCategories(request.user);
|
||||
}
|
||||
|
||||
@Get('categories')
|
||||
listCategories(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Query() query: Record<string, unknown>,
|
||||
) {
|
||||
return this.thirdPartyAdminService.listCategories(request.user, query);
|
||||
}
|
||||
|
||||
@Post('courses/sync')
|
||||
@OperationLog({
|
||||
action: 'third_party.courses.sync',
|
||||
resourceType: 'course',
|
||||
metadataFromQuery: ['remoteCategoryId'],
|
||||
description: '同步第三方课程',
|
||||
})
|
||||
syncCourses(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Query() query: Record<string, unknown>,
|
||||
) {
|
||||
return this.thirdPartyAdminService.syncCourses(request.user, query);
|
||||
}
|
||||
|
||||
@Get('courses')
|
||||
listCourses(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Query() query: Record<string, unknown>,
|
||||
) {
|
||||
return this.thirdPartyAdminService.listCourses(request.user, query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { assertAdmin } from '../admin/admin-access';
|
||||
import { ApiCallLog } from './entities/api-call-log.entity';
|
||||
import { ThirdPartyCatalogService } from './third-party-catalog.service';
|
||||
import { ThirdPartyClientService } from './third-party-client.service';
|
||||
|
||||
@Injectable()
|
||||
export class ThirdPartyAdminService {
|
||||
constructor(
|
||||
private readonly thirdPartyClientService: ThirdPartyClientService,
|
||||
private readonly thirdPartyCatalogService: ThirdPartyCatalogService,
|
||||
@InjectRepository(ApiCallLog)
|
||||
private readonly apiCallLogsRepository: Repository<ApiCallLog>,
|
||||
) {}
|
||||
|
||||
async testConnection(actor: User) {
|
||||
assertAdmin(actor);
|
||||
const response = await this.thirdPartyClientService.testConnection();
|
||||
return {
|
||||
provider: 'biedawo',
|
||||
act: 'getcate',
|
||||
connected: true,
|
||||
response,
|
||||
};
|
||||
}
|
||||
|
||||
getConfigStatus(actor: User) {
|
||||
assertAdmin(actor);
|
||||
return this.thirdPartyClientService.getConfigStatus();
|
||||
}
|
||||
|
||||
async listApiCallLogs(actor: User, query: Record<string, unknown>) {
|
||||
assertAdmin(actor);
|
||||
const page = Math.max(Number(query.page || 1), 1);
|
||||
const pageSize = Math.min(Math.max(Number(query.pageSize || 10), 1), 100);
|
||||
const act = String(query.act || '').trim();
|
||||
const status = String(query.status || '').trim();
|
||||
const provider = String(query.provider || '').trim();
|
||||
const keyword = String(query.keyword || '').trim();
|
||||
|
||||
const builder = this.apiCallLogsRepository
|
||||
.createQueryBuilder('log')
|
||||
.orderBy('log.createdAt', 'DESC')
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
|
||||
if (act) {
|
||||
builder.andWhere('log.act LIKE :act', { act: `%${act}%` });
|
||||
}
|
||||
if (status) {
|
||||
builder.andWhere('log.status = :status', { status });
|
||||
}
|
||||
if (provider) {
|
||||
builder.andWhere('log.provider = :provider', { provider });
|
||||
}
|
||||
if (keyword) {
|
||||
builder.andWhere(
|
||||
'(log.act LIKE :keyword OR log.errorMessage LIKE :keyword OR log.url LIKE :keyword)',
|
||||
{ keyword: `%${keyword}%` },
|
||||
);
|
||||
}
|
||||
|
||||
const [list, total] = await builder.getManyAndCount();
|
||||
return { list, total, page, pageSize };
|
||||
}
|
||||
|
||||
async syncCategories(actor: User) {
|
||||
assertAdmin(actor);
|
||||
return this.thirdPartyCatalogService.syncCategories();
|
||||
}
|
||||
|
||||
async syncCourses(actor: User, query: Record<string, unknown>) {
|
||||
assertAdmin(actor);
|
||||
const remoteCategoryId = String(query.remoteCategoryId || query.fenlei || '').trim();
|
||||
return this.thirdPartyCatalogService.syncCourses(remoteCategoryId || undefined);
|
||||
}
|
||||
|
||||
async listCategories(actor: User, query: Record<string, unknown>) {
|
||||
assertAdmin(actor);
|
||||
return this.thirdPartyCatalogService.listCategories(query);
|
||||
}
|
||||
|
||||
async listCourses(actor: User, query: Record<string, unknown>) {
|
||||
assertAdmin(actor);
|
||||
return this.thirdPartyCatalogService.listCourses(query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { ThirdPartyCatalogService } from './third-party-catalog.service';
|
||||
|
||||
@Injectable()
|
||||
export class ThirdPartyCatalogSyncTask {
|
||||
private readonly logger = new Logger(ThirdPartyCatalogSyncTask.name);
|
||||
|
||||
constructor(private readonly thirdPartyCatalogService: ThirdPartyCatalogService) {}
|
||||
|
||||
@Cron(process.env.CATALOG_SYNC_CRON || '0 */2 * * *')
|
||||
async handleCatalogSync() {
|
||||
if (process.env.CATALOG_SYNC_CRON_ENABLED !== 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.thirdPartyCatalogService.syncThirdPartyCatalog({
|
||||
triggerType: 'scheduled',
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { Category } from './entities/category.entity';
|
||||
import { CatalogSyncJob } from './entities/catalog-sync-job.entity';
|
||||
import { Course } from './entities/course.entity';
|
||||
import { OrderItem } from '../orders/entities/order-item.entity';
|
||||
import { ThirdPartyCatalogService } from './third-party-catalog.service';
|
||||
|
||||
function createRepositoryMock<T extends object>() {
|
||||
return {
|
||||
create: jest.fn((input: Partial<T>) => input),
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
exist: jest.fn(),
|
||||
remove: jest.fn(async (input: Partial<T>) => input),
|
||||
save: jest.fn(async (input: Partial<T>) => input),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ThirdPartyCatalogService', () => {
|
||||
const client = {
|
||||
call: jest.fn(),
|
||||
};
|
||||
const catalogSyncJobsRepository = createRepositoryMock<CatalogSyncJob>();
|
||||
const categoriesRepository = createRepositoryMock<Category>();
|
||||
const coursesRepository = createRepositoryMock<Course>();
|
||||
const orderItemsRepository = createRepositoryMock<OrderItem>();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('syncs remote categories into local cache', async () => {
|
||||
client.call.mockResolvedValue({
|
||||
code: 1,
|
||||
data: [
|
||||
{ id: '100', name: '大学课程', sort: 2 },
|
||||
{ cid: '200', title: '职业培训' },
|
||||
],
|
||||
});
|
||||
categoriesRepository.findOne.mockResolvedValue(null);
|
||||
const service = new ThirdPartyCatalogService(
|
||||
client as never,
|
||||
catalogSyncJobsRepository as never,
|
||||
categoriesRepository as never,
|
||||
coursesRepository as never,
|
||||
orderItemsRepository as never,
|
||||
);
|
||||
|
||||
const result = await service.syncCategories();
|
||||
|
||||
expect(client.call).toHaveBeenCalledWith('getcate');
|
||||
expect(categoriesRepository.save).toHaveBeenCalledTimes(2);
|
||||
expect(categoriesRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceType: 'third_party',
|
||||
provider: 'biedawo',
|
||||
externalId: '100',
|
||||
remoteCategoryId: '100',
|
||||
name: '大学课程',
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
total: 2,
|
||||
created: 2,
|
||||
updated: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('syncs remote courses and links cached categories', async () => {
|
||||
client.call.mockResolvedValue({
|
||||
code: 1,
|
||||
data: [{ kcid: 'kc-1', kcname: '形势与政策', fenlei: '100', price: '12.5' }],
|
||||
});
|
||||
categoriesRepository.findOne.mockResolvedValue({ id: 'category-id' });
|
||||
coursesRepository.findOne.mockResolvedValue(null);
|
||||
const service = new ThirdPartyCatalogService(
|
||||
client as never,
|
||||
catalogSyncJobsRepository as never,
|
||||
categoriesRepository as never,
|
||||
coursesRepository as never,
|
||||
orderItemsRepository as never,
|
||||
);
|
||||
|
||||
const result = await service.syncCourses('100');
|
||||
|
||||
expect(client.call).toHaveBeenCalledWith('getclass', { fenlei: '100' });
|
||||
expect(coursesRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceType: 'third_party',
|
||||
provider: 'biedawo',
|
||||
externalId: 'kc-1',
|
||||
remoteCourseId: 'kc-1',
|
||||
remoteCategoryId: '100',
|
||||
categoryId: 'category-id',
|
||||
name: '形势与政策',
|
||||
price: '12.50',
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
remoteCategoryId: '100',
|
||||
total: 1,
|
||||
created: 1,
|
||||
updated: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks missing third-party products as removed when orders already exist', async () => {
|
||||
client.call
|
||||
.mockResolvedValueOnce({
|
||||
code: 1,
|
||||
data: [{ id: '100', name: '大学课程' }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
code: 1,
|
||||
data: [],
|
||||
});
|
||||
categoriesRepository.findOne.mockResolvedValue(null);
|
||||
coursesRepository.find.mockResolvedValue([
|
||||
{
|
||||
id: 'product-id',
|
||||
sourceType: 'third_party',
|
||||
provider: 'biedawo',
|
||||
externalId: 'kc-removed',
|
||||
remoteCategoryId: '100',
|
||||
enabled: true,
|
||||
syncStatus: 'active',
|
||||
},
|
||||
]);
|
||||
orderItemsRepository.exist.mockResolvedValue(true);
|
||||
const service = new ThirdPartyCatalogService(
|
||||
client as never,
|
||||
catalogSyncJobsRepository as never,
|
||||
categoriesRepository as never,
|
||||
coursesRepository as never,
|
||||
orderItemsRepository as never,
|
||||
);
|
||||
|
||||
const result = await service.syncThirdPartyCatalog({
|
||||
triggerType: 'manual',
|
||||
remoteCategoryId: '100',
|
||||
});
|
||||
|
||||
expect(orderItemsRepository.exist).toHaveBeenCalledWith({
|
||||
where: { productId: 'product-id' },
|
||||
});
|
||||
expect(coursesRepository.remove).not.toHaveBeenCalled();
|
||||
expect(coursesRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'product-id',
|
||||
enabled: false,
|
||||
syncStatus: 'removed',
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
categoryTotal: 1,
|
||||
productRemoved: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,619 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Not, Repository } from 'typeorm';
|
||||
import { OrderItem } from '../orders/entities/order-item.entity';
|
||||
import {
|
||||
CatalogSyncJob,
|
||||
CatalogSyncTriggerType,
|
||||
} from './entities/catalog-sync-job.entity';
|
||||
import { Category } from './entities/category.entity';
|
||||
import { Course, OrderFormField } from './entities/course.entity';
|
||||
import { ThirdPartyClientService } from './third-party-client.service';
|
||||
|
||||
const PROVIDER = 'biedawo';
|
||||
const API_ACCOUNT_ID = 'env-biedawo';
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
type NormalizedCategory = {
|
||||
externalId: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
rawPayload: UnknownRecord;
|
||||
};
|
||||
|
||||
type NormalizedCourse = {
|
||||
externalId: string;
|
||||
remoteCategoryId: string | null;
|
||||
name: string;
|
||||
price: string;
|
||||
content: string | null;
|
||||
rawPayload: UnknownRecord;
|
||||
};
|
||||
|
||||
type CatalogSyncOptions = {
|
||||
triggerType: CatalogSyncTriggerType;
|
||||
remoteCategoryId?: string | null;
|
||||
};
|
||||
|
||||
type SyncStats = {
|
||||
categoryTotal: number;
|
||||
productTotal: number;
|
||||
categoryCreated: number;
|
||||
categoryUpdated: number;
|
||||
productCreated: number;
|
||||
productUpdated: number;
|
||||
productRemoved: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ThirdPartyCatalogService {
|
||||
private readonly logger = new Logger(ThirdPartyCatalogService.name);
|
||||
private catalogSyncRunning = false;
|
||||
|
||||
constructor(
|
||||
private readonly thirdPartyClientService: ThirdPartyClientService,
|
||||
@InjectRepository(CatalogSyncJob)
|
||||
private readonly catalogSyncJobsRepository: Repository<CatalogSyncJob>,
|
||||
@InjectRepository(Category)
|
||||
private readonly categoriesRepository: Repository<Category>,
|
||||
@InjectRepository(Course)
|
||||
private readonly coursesRepository: Repository<Course>,
|
||||
@InjectRepository(OrderItem)
|
||||
private readonly orderItemsRepository: Repository<OrderItem>,
|
||||
) {}
|
||||
|
||||
async syncThirdPartyCatalog(options: CatalogSyncOptions) {
|
||||
if (this.catalogSyncRunning) {
|
||||
return {
|
||||
provider: PROVIDER,
|
||||
skipped: true,
|
||||
reason: 'catalog sync is already running',
|
||||
};
|
||||
}
|
||||
|
||||
this.catalogSyncRunning = true;
|
||||
const now = new Date();
|
||||
const job = this.catalogSyncJobsRepository.create({
|
||||
id: randomUUID(),
|
||||
provider: PROVIDER,
|
||||
triggerType: options.triggerType,
|
||||
status: 'running',
|
||||
startedAt: now,
|
||||
finishedAt: null,
|
||||
categoryTotal: 0,
|
||||
productTotal: 0,
|
||||
categoryCreated: 0,
|
||||
categoryUpdated: 0,
|
||||
productCreated: 0,
|
||||
productUpdated: 0,
|
||||
productRemoved: 0,
|
||||
errorMessage: null,
|
||||
});
|
||||
await this.catalogSyncJobsRepository.save(job);
|
||||
|
||||
try {
|
||||
const stats = await this.syncCatalogSnapshot(
|
||||
job.id,
|
||||
now,
|
||||
options.remoteCategoryId,
|
||||
);
|
||||
Object.assign(job, {
|
||||
...stats,
|
||||
status: 'success',
|
||||
finishedAt: new Date(),
|
||||
});
|
||||
await this.catalogSyncJobsRepository.save(job);
|
||||
return {
|
||||
provider: PROVIDER,
|
||||
jobId: job.id,
|
||||
triggerType: job.triggerType,
|
||||
status: job.status,
|
||||
...stats,
|
||||
startedAt: job.startedAt,
|
||||
finishedAt: job.finishedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
job.status = 'failed';
|
||||
job.finishedAt = new Date();
|
||||
job.errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
await this.catalogSyncJobsRepository.save(job);
|
||||
throw error;
|
||||
} finally {
|
||||
this.catalogSyncRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
async syncCategories() {
|
||||
const items = await this.fetchCategories();
|
||||
const syncedAt = new Date();
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const existing = await this.categoriesRepository.findOne({
|
||||
where: {
|
||||
sourceType: 'third_party',
|
||||
provider: PROVIDER,
|
||||
externalId: item.externalId,
|
||||
},
|
||||
});
|
||||
const entity = this.categoriesRepository.create({
|
||||
id: existing?.id ?? randomUUID(),
|
||||
sourceType: 'third_party',
|
||||
apiAccountId: API_ACCOUNT_ID,
|
||||
provider: PROVIDER,
|
||||
externalId: item.externalId,
|
||||
remoteCategoryId: item.externalId,
|
||||
name: item.name,
|
||||
sortOrder: item.sortOrder,
|
||||
enabled: true,
|
||||
rawPayload: item.rawPayload,
|
||||
lastSyncedAt: syncedAt,
|
||||
});
|
||||
await this.categoriesRepository.save(entity);
|
||||
if (existing) {
|
||||
updated += 1;
|
||||
} else {
|
||||
created += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
provider: PROVIDER,
|
||||
total: items.length,
|
||||
created,
|
||||
updated,
|
||||
syncedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async syncCourses(remoteCategoryId?: string) {
|
||||
const items = await this.fetchCourses(remoteCategoryId);
|
||||
const syncedAt = new Date();
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const category = item.remoteCategoryId
|
||||
? await this.categoriesRepository.findOne({
|
||||
where: {
|
||||
sourceType: 'third_party',
|
||||
provider: PROVIDER,
|
||||
externalId: item.remoteCategoryId,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const existing = await this.coursesRepository.findOne({
|
||||
where: {
|
||||
sourceType: 'third_party',
|
||||
provider: PROVIDER,
|
||||
externalId: item.externalId,
|
||||
remoteCategoryId: item.remoteCategoryId ?? '',
|
||||
},
|
||||
});
|
||||
const entity = this.coursesRepository.create({
|
||||
id: existing?.id ?? randomUUID(),
|
||||
sourceType: 'third_party',
|
||||
apiAccountId: API_ACCOUNT_ID,
|
||||
provider: PROVIDER,
|
||||
externalId: item.externalId,
|
||||
categoryId: category?.id ?? existing?.categoryId ?? null,
|
||||
remoteCategoryId: item.remoteCategoryId ?? '',
|
||||
remoteCourseId: item.externalId,
|
||||
name: item.name,
|
||||
price: item.price,
|
||||
content: item.content,
|
||||
isFavorite: existing?.isFavorite ?? false,
|
||||
enabled: true,
|
||||
syncStatus: 'active',
|
||||
rawPayload: item.rawPayload,
|
||||
lastSyncedAt: syncedAt,
|
||||
lastSeenAt: syncedAt,
|
||||
removedAt: null,
|
||||
});
|
||||
await this.coursesRepository.save(entity);
|
||||
if (existing) {
|
||||
updated += 1;
|
||||
} else {
|
||||
created += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
provider: PROVIDER,
|
||||
remoteCategoryId: remoteCategoryId || null,
|
||||
total: items.length,
|
||||
created,
|
||||
updated,
|
||||
syncedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async listCategories(query: Record<string, unknown>) {
|
||||
const page = Math.max(Number(query.page || 1), 1);
|
||||
const pageSize = Math.min(Math.max(Number(query.pageSize || 10), 1), 100);
|
||||
const keyword = String(query.keyword || '').trim();
|
||||
|
||||
const builder = this.categoriesRepository
|
||||
.createQueryBuilder('category')
|
||||
.where('category.sourceType = :sourceType', { sourceType: 'third_party' })
|
||||
.andWhere('category.provider = :provider', { provider: PROVIDER })
|
||||
.orderBy('category.sortOrder', 'ASC')
|
||||
.addOrderBy('category.updatedAt', 'DESC')
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
|
||||
if (keyword) {
|
||||
builder.andWhere(
|
||||
'(category.name LIKE :keyword OR category.externalId LIKE :keyword)',
|
||||
{ keyword: `%${keyword}%` },
|
||||
);
|
||||
}
|
||||
|
||||
const [list, total] = await builder.getManyAndCount();
|
||||
return { list, total, page, pageSize };
|
||||
}
|
||||
|
||||
async listSyncJobs(query: Record<string, unknown>) {
|
||||
const page = Math.max(Number(query.page || 1), 1);
|
||||
const pageSize = Math.min(Math.max(Number(query.pageSize || 10), 1), 100);
|
||||
|
||||
const [list, total] = await this.catalogSyncJobsRepository.findAndCount({
|
||||
order: { startedAt: 'DESC' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
|
||||
return { list, total, page, pageSize };
|
||||
}
|
||||
|
||||
private async syncCatalogSnapshot(
|
||||
jobId: string,
|
||||
syncedAt: Date,
|
||||
remoteCategoryId?: string | null,
|
||||
): Promise<SyncStats> {
|
||||
const categoryItems = await this.fetchCategories();
|
||||
const scopedRemoteCategoryId = String(remoteCategoryId || '').trim();
|
||||
const syncCategoryItems = scopedRemoteCategoryId
|
||||
? categoryItems.filter((item) => item.externalId === scopedRemoteCategoryId)
|
||||
: categoryItems;
|
||||
const stats: SyncStats = {
|
||||
categoryTotal: syncCategoryItems.length,
|
||||
productTotal: 0,
|
||||
categoryCreated: 0,
|
||||
categoryUpdated: 0,
|
||||
productCreated: 0,
|
||||
productUpdated: 0,
|
||||
productRemoved: 0,
|
||||
};
|
||||
|
||||
const categoryMap = new Map<string, Category>();
|
||||
for (const [index, item] of categoryItems.entries()) {
|
||||
const existing = await this.categoriesRepository.findOne({
|
||||
where: {
|
||||
sourceType: 'third_party',
|
||||
provider: PROVIDER,
|
||||
externalId: item.externalId,
|
||||
},
|
||||
});
|
||||
const changed =
|
||||
!existing ||
|
||||
existing.name !== item.name ||
|
||||
existing.sortOrder !== item.sortOrder ||
|
||||
JSON.stringify(existing.rawPayload ?? null) !==
|
||||
JSON.stringify(item.rawPayload ?? null);
|
||||
const category = this.categoriesRepository.create({
|
||||
id: existing?.id ?? randomUUID(),
|
||||
sourceType: 'third_party',
|
||||
apiAccountId: API_ACCOUNT_ID,
|
||||
provider: PROVIDER,
|
||||
externalId: item.externalId,
|
||||
remoteCategoryId: item.externalId,
|
||||
name: item.name,
|
||||
sortOrder: item.sortOrder ?? index,
|
||||
enabled: true,
|
||||
rawPayload: item.rawPayload,
|
||||
lastSyncedAt: syncedAt,
|
||||
});
|
||||
const saved = await this.categoriesRepository.save(category);
|
||||
categoryMap.set(item.externalId, saved);
|
||||
if (existing) {
|
||||
if (changed) stats.categoryUpdated += 1;
|
||||
} else {
|
||||
stats.categoryCreated += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const seenKeys = new Set<string>();
|
||||
for (const category of syncCategoryItems) {
|
||||
const courses = await this.fetchCourses(category.externalId);
|
||||
stats.productTotal += courses.length;
|
||||
for (const item of courses) {
|
||||
const remoteCategoryId = item.remoteCategoryId || category.externalId;
|
||||
const key = this.getProductKey(remoteCategoryId, item.externalId);
|
||||
seenKeys.add(key);
|
||||
const localCategory = categoryMap.get(remoteCategoryId) ?? null;
|
||||
const existing = await this.coursesRepository.findOne({
|
||||
where: {
|
||||
sourceType: 'third_party',
|
||||
provider: PROVIDER,
|
||||
externalId: item.externalId,
|
||||
remoteCategoryId,
|
||||
},
|
||||
});
|
||||
const changed =
|
||||
!existing ||
|
||||
existing.name !== item.name ||
|
||||
String(existing.price) !== item.price ||
|
||||
existing.content !== item.content ||
|
||||
existing.categoryId !== (localCategory?.id ?? null) ||
|
||||
existing.syncStatus !== 'active' ||
|
||||
JSON.stringify(existing.rawPayload ?? null) !==
|
||||
JSON.stringify(item.rawPayload ?? null);
|
||||
const product = this.coursesRepository.create({
|
||||
id: existing?.id ?? randomUUID(),
|
||||
sourceType: 'third_party',
|
||||
apiAccountId: API_ACCOUNT_ID,
|
||||
provider: PROVIDER,
|
||||
externalId: item.externalId,
|
||||
categoryId: localCategory?.id ?? existing?.categoryId ?? null,
|
||||
remoteCategoryId,
|
||||
remoteCourseId: item.externalId,
|
||||
name: item.name,
|
||||
price: item.price,
|
||||
coverUrl: existing?.coverUrl ?? null,
|
||||
content: item.content,
|
||||
stock: existing?.stock ?? null,
|
||||
salesLimit: existing?.salesLimit ?? null,
|
||||
fulfillmentType: 'third_party_api',
|
||||
afterSalesNote: existing?.afterSalesNote ?? null,
|
||||
orderFormSchema: existing?.orderFormSchema ?? this.getDefaultThirdPartyOrderFormSchema(),
|
||||
sortOrder: existing?.sortOrder ?? 0,
|
||||
isFavorite: existing?.isFavorite ?? false,
|
||||
enabled: true,
|
||||
syncStatus: 'active',
|
||||
rawPayload: item.rawPayload,
|
||||
lastSyncedAt: syncedAt,
|
||||
lastSeenAt: syncedAt,
|
||||
lastSeenSyncJobId: jobId,
|
||||
removedAt: null,
|
||||
});
|
||||
await this.coursesRepository.save(product);
|
||||
if (existing) {
|
||||
if (changed) stats.productUpdated += 1;
|
||||
} else {
|
||||
stats.productCreated += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const existingProducts = await this.coursesRepository.find({
|
||||
where: {
|
||||
sourceType: 'third_party',
|
||||
provider: PROVIDER,
|
||||
...(scopedRemoteCategoryId
|
||||
? { remoteCategoryId: scopedRemoteCategoryId }
|
||||
: {}),
|
||||
syncStatus: Not('removed'),
|
||||
},
|
||||
});
|
||||
|
||||
for (const product of existingProducts) {
|
||||
const key = this.getProductKey(
|
||||
product.remoteCategoryId ?? '',
|
||||
product.externalId ?? product.remoteCourseId ?? '',
|
||||
);
|
||||
if (!seenKeys.has(key)) {
|
||||
await this.retireMissingProduct(product, syncedAt);
|
||||
stats.productRemoved += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
private async retireMissingProduct(product: Course, syncedAt: Date) {
|
||||
const hasOrders = await this.orderItemsRepository.exist({
|
||||
where: { productId: product.id },
|
||||
});
|
||||
if (!hasOrders) {
|
||||
await this.coursesRepository.remove(product);
|
||||
return;
|
||||
}
|
||||
|
||||
product.enabled = false;
|
||||
product.syncStatus = 'removed';
|
||||
product.removedAt = syncedAt;
|
||||
await this.coursesRepository.save(product);
|
||||
}
|
||||
|
||||
private async fetchCategories() {
|
||||
const response = await this.thirdPartyClientService.call('getcate');
|
||||
return this.extractRecords(response).map((item, index) =>
|
||||
this.normalizeCategory(item, index),
|
||||
);
|
||||
}
|
||||
|
||||
private async fetchCourses(remoteCategoryId?: string) {
|
||||
const payload = remoteCategoryId ? { fenlei: remoteCategoryId } : {};
|
||||
const response = await this.thirdPartyClientService.call('getclass', payload);
|
||||
return this.extractRecords(response).map((item) =>
|
||||
this.normalizeCourse(item, remoteCategoryId),
|
||||
);
|
||||
}
|
||||
|
||||
private getProductKey(remoteCategoryId: string, externalId: string) {
|
||||
return `${PROVIDER}:${remoteCategoryId}:${externalId}`;
|
||||
}
|
||||
|
||||
private getDefaultThirdPartyOrderFormSchema(): OrderFormField[] {
|
||||
return [
|
||||
{
|
||||
key: 'school',
|
||||
label: '学校',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '请输入学校名称',
|
||||
},
|
||||
{
|
||||
key: 'account',
|
||||
label: '账号',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '请输入学习账号',
|
||||
},
|
||||
{
|
||||
key: 'password',
|
||||
label: '密码',
|
||||
type: 'password',
|
||||
required: true,
|
||||
placeholder: '请输入学习密码',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async listCourses(query: Record<string, unknown>) {
|
||||
const page = Math.max(Number(query.page || 1), 1);
|
||||
const pageSize = Math.min(Math.max(Number(query.pageSize || 10), 1), 100);
|
||||
const keyword = String(query.keyword || '').trim();
|
||||
const remoteCategoryId = String(query.remoteCategoryId || '').trim();
|
||||
|
||||
const builder = this.coursesRepository
|
||||
.createQueryBuilder('course')
|
||||
.where('course.sourceType = :sourceType', { sourceType: 'third_party' })
|
||||
.andWhere('course.provider = :provider', { provider: PROVIDER })
|
||||
.orderBy('course.updatedAt', 'DESC')
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
|
||||
if (keyword) {
|
||||
builder.andWhere(
|
||||
'(course.name LIKE :keyword OR course.externalId LIKE :keyword)',
|
||||
{ keyword: `%${keyword}%` },
|
||||
);
|
||||
}
|
||||
if (remoteCategoryId) {
|
||||
builder.andWhere('course.remoteCategoryId = :remoteCategoryId', {
|
||||
remoteCategoryId,
|
||||
});
|
||||
}
|
||||
|
||||
const [list, total] = await builder.getManyAndCount();
|
||||
return { list, total, page, pageSize };
|
||||
}
|
||||
|
||||
private extractRecords(response: unknown): UnknownRecord[] {
|
||||
const value = this.unwrapResponse(response);
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter(this.isRecord);
|
||||
}
|
||||
if (this.isRecord(value)) {
|
||||
return Object.entries(value).map(([key, item]) =>
|
||||
this.isRecord(item) ? { id: key, ...item } : { id: key, name: item },
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private unwrapResponse(response: unknown): unknown {
|
||||
if (!this.isRecord(response)) {
|
||||
return response;
|
||||
}
|
||||
return response.data ?? response.rows ?? response.list ?? response.result ?? response;
|
||||
}
|
||||
|
||||
private normalizeCategory(
|
||||
item: UnknownRecord,
|
||||
index: number,
|
||||
): NormalizedCategory {
|
||||
const externalId = this.readString(
|
||||
item,
|
||||
['id', 'cid', 'cateid', 'category_id', 'fenlei', 'value'],
|
||||
`category-${index + 1}`,
|
||||
);
|
||||
return {
|
||||
externalId,
|
||||
name: this.readString(item, ['name', 'title', 'label', 'fenlei_name'], externalId),
|
||||
sortOrder: this.readNumber(item, ['sort', 'sort_order', 'order'], index),
|
||||
rawPayload: item,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeCourse(
|
||||
item: UnknownRecord,
|
||||
fallbackRemoteCategoryId?: string,
|
||||
): NormalizedCourse {
|
||||
const externalId = this.readString(item, [
|
||||
'id',
|
||||
'kcid',
|
||||
'cid',
|
||||
'course_id',
|
||||
'class_id',
|
||||
'value',
|
||||
]);
|
||||
return {
|
||||
externalId,
|
||||
remoteCategoryId:
|
||||
this.readOptionalString(item, [
|
||||
'fenlei',
|
||||
'category_id',
|
||||
'cateid',
|
||||
'cid',
|
||||
'sortid',
|
||||
]) ??
|
||||
fallbackRemoteCategoryId ??
|
||||
'',
|
||||
name: this.readString(item, ['name', 'kcname', 'title', 'label'], externalId),
|
||||
price: this.readPrice(item),
|
||||
content: this.readOptionalString(item, ['content', 'desc', 'description', 'remark']),
|
||||
rawPayload: item,
|
||||
};
|
||||
}
|
||||
|
||||
private readString(
|
||||
item: UnknownRecord,
|
||||
keys: string[],
|
||||
fallback?: string,
|
||||
): string {
|
||||
return this.readOptionalString(item, keys) ?? fallback ?? randomUUID();
|
||||
}
|
||||
|
||||
private readOptionalString(item: UnknownRecord, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
const value = item[key];
|
||||
if (value !== undefined && value !== null && String(value).trim()) {
|
||||
return String(value).trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private readNumber(item: UnknownRecord, keys: string[], fallback: number) {
|
||||
for (const key of keys) {
|
||||
const value = Number(item[key]);
|
||||
if (Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private readPrice(item: UnknownRecord) {
|
||||
const value = this.readOptionalString(item, [
|
||||
'price',
|
||||
'money',
|
||||
'amount',
|
||||
'cost',
|
||||
'sell_price',
|
||||
]);
|
||||
const numberValue = Number(value);
|
||||
return Number.isFinite(numberValue) ? numberValue.toFixed(2) : '0.00';
|
||||
}
|
||||
|
||||
private isRecord(value: unknown): value is UnknownRecord {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ThirdPartyClientService } from './third-party-client.service';
|
||||
|
||||
describe('ThirdPartyClientService', () => {
|
||||
const save = jest.fn();
|
||||
const create = jest.fn((input) => input);
|
||||
const repository = { create, save };
|
||||
const config = {
|
||||
getOrThrow: jest.fn((key: string) => {
|
||||
if (key === 'WK_BASE_URL') {
|
||||
return 'https://biedawo.org/api.php';
|
||||
}
|
||||
throw new Error(`Missing config ${key}`);
|
||||
}),
|
||||
get: jest.fn((key: string) => {
|
||||
const values: Record<string, string> = {
|
||||
WK_BASE_URL: 'https://biedawo.org/api.php',
|
||||
WK_APP_UID: '10001',
|
||||
WK_APP_KEY: 'plain-key',
|
||||
};
|
||||
return values[key];
|
||||
}),
|
||||
};
|
||||
const debugConfig = {
|
||||
...config,
|
||||
get: jest.fn((key: string) => {
|
||||
const values: Record<string, string> = {
|
||||
WK_BASE_URL: 'https://biedawo.org/api.php',
|
||||
WK_APP_UID: '10001',
|
||||
WK_APP_KEY: 'plain-key',
|
||||
WK_DEBUG_LOG: 'true',
|
||||
};
|
||||
return values[key];
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('writes a masked failure log when third-party business response fails', async () => {
|
||||
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ code: 0, msg: '无效账号' }),
|
||||
} as Response);
|
||||
const service = new ThirdPartyClientService(
|
||||
config as unknown as ConfigService,
|
||||
repository as never,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.call('get', {
|
||||
platform: 100,
|
||||
user: 'student001',
|
||||
pass: 'student-password',
|
||||
}),
|
||||
).rejects.toThrow('第三方接口返回失败:无效账号');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
href: 'https://biedawo.org/api.php?act=get',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: expect.any(URLSearchParams),
|
||||
}),
|
||||
);
|
||||
const [, requestInit] = fetchMock.mock.calls[0];
|
||||
expect((requestInit?.body as URLSearchParams).get('uid')).toBe('10001');
|
||||
expect((requestInit?.body as URLSearchParams).get('key')).toBe('plain-key');
|
||||
expect((requestInit?.body as URLSearchParams).get('act')).toBeNull();
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
act: 'get',
|
||||
status: 'failure',
|
||||
httpStatus: 200,
|
||||
requestPayload: expect.objectContaining({
|
||||
key: 'pl****ey',
|
||||
pass: 'st****rd',
|
||||
}),
|
||||
responsePayload: { code: 0, msg: '无效账号' },
|
||||
errorMessage: '无效账号',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes safe third-party config status', () => {
|
||||
const service = new ThirdPartyClientService(
|
||||
config as unknown as ConfigService,
|
||||
repository as never,
|
||||
);
|
||||
|
||||
expect(service.getConfigStatus()).toEqual({
|
||||
provider: 'biedawo',
|
||||
baseUrl: 'https://biedawo.org/api.php',
|
||||
uidConfigured: true,
|
||||
keyConfigured: true,
|
||||
debugLogEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('writes a failure log and returns a client error when network request fails', async () => {
|
||||
jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockRejectedValue(new TypeError('fetch failed'));
|
||||
const service = new ThirdPartyClientService(
|
||||
config as unknown as ConfigService,
|
||||
repository as never,
|
||||
);
|
||||
|
||||
await expect(service.call('getcate')).rejects.toThrow('fetch failed');
|
||||
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
act: 'getcate',
|
||||
status: 'failure',
|
||||
httpStatus: null,
|
||||
responsePayload: null,
|
||||
errorMessage: 'fetch failed',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('prints masked request diagnostics when debug logging is enabled', async () => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ code: 1, data: [] }),
|
||||
} as Response);
|
||||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
const service = new ThirdPartyClientService(
|
||||
debugConfig as unknown as ConfigService,
|
||||
repository as never,
|
||||
);
|
||||
|
||||
await service.call('getcate');
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'[third-party] request',
|
||||
expect.objectContaining({
|
||||
act: 'getcate',
|
||||
method: 'POST',
|
||||
url: 'https://biedawo.org/api.php?act=getcate',
|
||||
body: expect.objectContaining({
|
||||
uid: '10001',
|
||||
key: 'pl****ey',
|
||||
}),
|
||||
keyLength: 9,
|
||||
keyPreview: 'plai****-key',
|
||||
keyHasOuterWhitespace: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ApiCallLog } from './entities/api-call-log.entity';
|
||||
import { maskSensitivePayload } from './third-party-security';
|
||||
|
||||
export type ThirdPartyPayload = Record<string, unknown>;
|
||||
|
||||
type CallOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ThirdPartyClientService {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
@InjectRepository(ApiCallLog)
|
||||
private readonly apiCallLogsRepository: Repository<ApiCallLog>,
|
||||
) {}
|
||||
|
||||
async call<T = unknown>(
|
||||
act: string,
|
||||
payload: ThirdPartyPayload = {},
|
||||
options: CallOptions = {},
|
||||
): Promise<T> {
|
||||
const startedAt = Date.now();
|
||||
const baseUrl = this.configService.getOrThrow<string>('WK_BASE_URL');
|
||||
const requestUrl = new URL(baseUrl);
|
||||
requestUrl.searchParams.set('act', act);
|
||||
let httpStatus: number | null = null;
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
let logged = false;
|
||||
let maskedRequestPayload: Record<string, unknown> = maskSensitivePayload({
|
||||
act,
|
||||
...payload,
|
||||
});
|
||||
|
||||
try {
|
||||
const bodyPayload = {
|
||||
...payload,
|
||||
uid: this.getUid(),
|
||||
key: this.getApiKey(),
|
||||
};
|
||||
maskedRequestPayload = maskSensitivePayload({ act, ...bodyPayload });
|
||||
const requestBody = this.toUrlEncodedBody(bodyPayload);
|
||||
this.debugLogRequest(act, requestUrl.toString(), requestBody);
|
||||
const controller = new AbortController();
|
||||
timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
options.timeoutMs ?? 15_000,
|
||||
);
|
||||
|
||||
const response = await fetch(requestUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: requestBody,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
httpStatus = response.status;
|
||||
const { parsedResponse, responseText } = await this.parseResponse(response);
|
||||
const maskedResponsePayload = maskSensitivePayload(parsedResponse);
|
||||
const businessError = this.getBusinessError(parsedResponse);
|
||||
const errorMessage = response.ok ? businessError : `HTTP ${response.status}`;
|
||||
const status = response.ok && !businessError ? 'success' : 'failure';
|
||||
this.debugLogResponse({
|
||||
act,
|
||||
status,
|
||||
httpStatus,
|
||||
durationMs: Date.now() - startedAt,
|
||||
responseText,
|
||||
parsedResponse: maskedResponsePayload,
|
||||
errorMessage,
|
||||
});
|
||||
|
||||
await this.writeLog({
|
||||
act,
|
||||
url: requestUrl.toString(),
|
||||
status,
|
||||
httpStatus,
|
||||
durationMs: Date.now() - startedAt,
|
||||
requestPayload: maskedRequestPayload,
|
||||
responsePayload: maskedResponsePayload,
|
||||
errorMessage,
|
||||
});
|
||||
logged = true;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new BadRequestException(`第三方接口请求失败:HTTP ${response.status}`);
|
||||
}
|
||||
if (businessError) {
|
||||
throw new BadRequestException(`第三方接口返回失败:${businessError}`);
|
||||
}
|
||||
|
||||
return parsedResponse as T;
|
||||
} catch (error) {
|
||||
if (logged) {
|
||||
throw error;
|
||||
}
|
||||
const message = this.getErrorMessage(error);
|
||||
await this.writeLog({
|
||||
act,
|
||||
url: requestUrl.toString(),
|
||||
status: 'failure',
|
||||
httpStatus,
|
||||
durationMs: Date.now() - startedAt,
|
||||
requestPayload: maskedRequestPayload,
|
||||
responsePayload: null,
|
||||
errorMessage: message,
|
||||
});
|
||||
throw this.toClientException(error);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
return this.call('getcate');
|
||||
}
|
||||
|
||||
getConfigStatus() {
|
||||
const apiKey = this.configService.get<string>('WK_APP_KEY');
|
||||
|
||||
return {
|
||||
provider: 'biedawo',
|
||||
baseUrl: this.configService.get<string>('WK_BASE_URL') ?? null,
|
||||
uidConfigured: Boolean(this.configService.get<string>('WK_APP_UID')),
|
||||
keyConfigured: Boolean(apiKey),
|
||||
debugLogEnabled: this.isDebugEnabled(),
|
||||
};
|
||||
}
|
||||
|
||||
private getUid() {
|
||||
const uid = this.configService.get<string>('WK_APP_UID');
|
||||
if (!uid) {
|
||||
throw new BadRequestException('缺少 WK_APP_UID,无法调用第三方接口');
|
||||
}
|
||||
return uid;
|
||||
}
|
||||
|
||||
private getApiKey() {
|
||||
const apiKey = this.configService.get<string>('WK_APP_KEY');
|
||||
if (!apiKey) {
|
||||
throw new BadRequestException('缺少 WK_APP_KEY,无法调用第三方接口');
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
private toUrlEncodedBody(payload: ThirdPartyPayload) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
params.set(
|
||||
key,
|
||||
typeof value === 'object' ? JSON.stringify(value) : String(value),
|
||||
);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
private async parseResponse(response: Response) {
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return { parsedResponse: null, responseText: text };
|
||||
}
|
||||
|
||||
try {
|
||||
return { parsedResponse: JSON.parse(text) as unknown, responseText: text };
|
||||
} catch {
|
||||
return { parsedResponse: { raw: text }, responseText: text };
|
||||
}
|
||||
}
|
||||
|
||||
private debugLogRequest(
|
||||
act: string,
|
||||
url: string,
|
||||
requestBody: URLSearchParams,
|
||||
) {
|
||||
if (!this.isDebugEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestPayload = Object.fromEntries(requestBody.entries());
|
||||
const key = requestBody.get('key') ?? '';
|
||||
const uid = requestBody.get('uid') ?? '';
|
||||
console.log('[third-party] request', {
|
||||
act,
|
||||
method: 'POST',
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: maskSensitivePayload(requestPayload),
|
||||
bodyString: this.maskBodyString(requestBody.toString()),
|
||||
uidLength: uid.length,
|
||||
uidHasOuterWhitespace: uid !== uid.trim(),
|
||||
keyLength: key.length,
|
||||
keyPreview: this.previewSecret(key),
|
||||
keyHasOuterWhitespace: key !== key.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
private debugLogResponse(input: {
|
||||
act: string;
|
||||
status: 'success' | 'failure';
|
||||
httpStatus: number | null;
|
||||
durationMs: number;
|
||||
responseText: string;
|
||||
parsedResponse: unknown;
|
||||
errorMessage: string | null;
|
||||
}) {
|
||||
if (!this.isDebugEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[third-party] response', {
|
||||
act: input.act,
|
||||
status: input.status,
|
||||
httpStatus: input.httpStatus,
|
||||
durationMs: input.durationMs,
|
||||
responseText: this.maskResponseText(input.responseText),
|
||||
parsedResponse: input.parsedResponse,
|
||||
errorMessage: input.errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
private isDebugEnabled() {
|
||||
return ['1', 'true', 'yes', 'on'].includes(
|
||||
String(this.configService.get<string>('WK_DEBUG_LOG') || '').toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
private maskBodyString(value: string) {
|
||||
const params = new URLSearchParams(value);
|
||||
for (const key of Array.from(params.keys())) {
|
||||
if (/key|pass|password|secret|token|authorization/i.test(key)) {
|
||||
params.set(key, String(maskSensitivePayload({ [key]: params.get(key) })[key]));
|
||||
}
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
private maskResponseText(value: string) {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(maskSensitivePayload(JSON.parse(value)));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
private previewSecret(value: string) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
if (value.length <= 8) {
|
||||
return '*'.repeat(value.length);
|
||||
}
|
||||
return `${value.slice(0, 4)}****${value.slice(-4)}`;
|
||||
}
|
||||
|
||||
async writeLog(input: {
|
||||
act: string;
|
||||
url: string;
|
||||
method?: string;
|
||||
status: 'success' | 'failure';
|
||||
httpStatus: number | null;
|
||||
durationMs: number;
|
||||
requestPayload: Record<string, unknown>;
|
||||
responsePayload: unknown | null;
|
||||
errorMessage: string | null;
|
||||
}) {
|
||||
const entity = this.apiCallLogsRepository.create({
|
||||
id: randomUUID(),
|
||||
provider: 'biedawo',
|
||||
act: input.act,
|
||||
method: input.method ?? 'POST',
|
||||
url: input.url,
|
||||
status: input.status,
|
||||
success: input.status === 'success',
|
||||
httpStatus: input.httpStatus,
|
||||
durationMs: input.durationMs,
|
||||
requestPayload: input.requestPayload,
|
||||
responsePayload: input.responsePayload,
|
||||
errorMessage: input.errorMessage,
|
||||
});
|
||||
await this.apiCallLogsRepository.save(entity);
|
||||
}
|
||||
|
||||
private getErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.name === 'AbortError' ? '第三方接口请求超时' : error.message;
|
||||
}
|
||||
return '第三方接口请求失败';
|
||||
}
|
||||
|
||||
private toClientException(error: unknown) {
|
||||
if (error instanceof BadRequestException) {
|
||||
return error;
|
||||
}
|
||||
return new BadRequestException(this.getErrorMessage(error));
|
||||
}
|
||||
|
||||
private getBusinessError(response: unknown) {
|
||||
if (!response || typeof response !== 'object' || !('code' in response)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = response as { code?: unknown; msg?: unknown; message?: unknown };
|
||||
if (body.code === 1 || body.code === '1' || body.code === 200 || body.code === '200') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return String(body.msg || body.message || `业务状态码 ${String(body.code)}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { maskSensitivePayload } from './third-party-security';
|
||||
|
||||
describe('third-party security helpers', () => {
|
||||
it('masks sensitive fields recursively', () => {
|
||||
const masked = maskSensitivePayload({
|
||||
uid: '10001',
|
||||
key: 'abcdef123456',
|
||||
expand: {
|
||||
pass: 'student-password',
|
||||
score: 95,
|
||||
},
|
||||
});
|
||||
|
||||
expect(masked).toEqual({
|
||||
uid: '10001',
|
||||
key: 'ab****56',
|
||||
expand: {
|
||||
pass: 'st****rd',
|
||||
score: 95,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
const sensitiveKeyPattern = /(key|pass|password|secret|token|authorization)/i;
|
||||
|
||||
export function maskValue(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return value;
|
||||
}
|
||||
const text = String(value);
|
||||
if (text.length <= 4) {
|
||||
return '****';
|
||||
}
|
||||
return `${text.slice(0, 2)}****${text.slice(-2)}`;
|
||||
}
|
||||
|
||||
export function maskSensitivePayload<T>(payload: T): T {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload.map((item) => maskSensitivePayload(item)) as T;
|
||||
}
|
||||
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return payload;
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(payload).map(([key, value]) => [
|
||||
key,
|
||||
sensitiveKeyPattern.test(key)
|
||||
? maskValue(value)
|
||||
: maskSensitivePayload(value),
|
||||
]),
|
||||
) as T;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { OrderItem } from '../orders/entities/order-item.entity';
|
||||
import { ApiCallLog } from './entities/api-call-log.entity';
|
||||
import { CatalogSyncJob } from './entities/catalog-sync-job.entity';
|
||||
import { Category } from './entities/category.entity';
|
||||
import { Course } from './entities/course.entity';
|
||||
import { ThirdPartyAdminController } from './third-party-admin.controller';
|
||||
import { ThirdPartyAdminService } from './third-party-admin.service';
|
||||
import { ThirdPartyCatalogService } from './third-party-catalog.service';
|
||||
import { ThirdPartyCatalogSyncTask } from './third-party-catalog-sync.task';
|
||||
import { ThirdPartyClientService } from './third-party-client.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
TypeOrmModule.forFeature([
|
||||
ApiCallLog,
|
||||
CatalogSyncJob,
|
||||
Category,
|
||||
Course,
|
||||
OrderItem,
|
||||
]),
|
||||
],
|
||||
controllers: [ThirdPartyAdminController],
|
||||
providers: [
|
||||
ThirdPartyAdminService,
|
||||
ThirdPartyCatalogService,
|
||||
ThirdPartyCatalogSyncTask,
|
||||
ThirdPartyClientService,
|
||||
],
|
||||
exports: [ThirdPartyCatalogService, ThirdPartyClientService],
|
||||
})
|
||||
export class ThirdPartyModule {}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToMany,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
|
||||
@Entity('roles')
|
||||
export class Role {
|
||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 80, unique: true })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 80 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
permissions: Record<string, string[]> | string[] | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 6 })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 6 })
|
||||
updatedAt: Date;
|
||||
|
||||
@ManyToMany(() => User, (user) => user.roles)
|
||||
users: User[];
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { Role } from './role.entity';
|
||||
|
||||
@Entity('users')
|
||||
export class User {
|
||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 80 })
|
||||
name: string;
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column({ type: 'varchar', length: 160 })
|
||||
email: string;
|
||||
|
||||
@Column({ name: 'password_hash', type: 'varchar', length: 255 })
|
||||
passwordHash: string;
|
||||
|
||||
@Column({ type: 'tinyint', default: 1 })
|
||||
enabled: boolean;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'parent_id', type: 'varchar', length: 36, nullable: true })
|
||||
parentId: string | null;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'parent_path', type: 'varchar', length: 1024, nullable: true })
|
||||
parentPath: string | null;
|
||||
|
||||
@Column({ name: 'token_version', type: 'int', default: 0 })
|
||||
tokenVersion: number;
|
||||
|
||||
@Column({ name: 'must_change_password', type: 'tinyint', default: 0 })
|
||||
mustChangePassword: boolean;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
permissions: Record<string, string[]> | null;
|
||||
|
||||
@Column({ name: 'last_login_at', type: 'timestamp', nullable: true })
|
||||
lastLoginAt: Date | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 6 })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 6 })
|
||||
updatedAt: Date;
|
||||
|
||||
@ManyToMany(() => Role, (role) => role.users, { eager: true })
|
||||
@JoinTable({
|
||||
name: 'user_roles',
|
||||
joinColumn: { name: 'user_id', referencedColumnName: 'id' },
|
||||
inverseJoinColumn: { name: 'role_id', referencedColumnName: 'id' },
|
||||
})
|
||||
roles: Role[];
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Role } from './entities/role.entity';
|
||||
import { User } from './entities/user.entity';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Role, User])],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from './entities/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly usersRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
findByLoginName(loginName: string) {
|
||||
return this.usersRepository
|
||||
.createQueryBuilder('user')
|
||||
.leftJoinAndSelect('user.roles', 'role')
|
||||
.where('user.name = :loginName', { loginName })
|
||||
.orWhere('user.email = :loginName', { loginName })
|
||||
.getOne();
|
||||
}
|
||||
|
||||
findById(id: string) {
|
||||
return this.usersRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
findByEmail(email: string) {
|
||||
return this.usersRepository.findOne({
|
||||
where: { email },
|
||||
});
|
||||
}
|
||||
|
||||
async markLoginSuccess(userId: string) {
|
||||
await this.usersRepository.update(userId, {
|
||||
lastLoginAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
async save(user: User) {
|
||||
return this.usersRepository.save(user);
|
||||
}
|
||||
|
||||
async incrementTokenVersion(userId: string) {
|
||||
await this.usersRepository.increment({ id: userId }, 'tokenVersion', 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"resolvePackageJsonExports": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
VITE_CLERK_PUBLISHABLE_KEY=
|
||||
@@ -0,0 +1,34 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Test coverage
|
||||
/coverage
|
||||
|
||||
# Vitest artifacts (browser screenshots/attachments)
|
||||
**/__screenshots__/
|
||||
.vitest-attachments/
|
||||
@@ -0,0 +1,19 @@
|
||||
# Ignore everything
|
||||
/*
|
||||
|
||||
# Except these files & folders
|
||||
!/src
|
||||
!index.html
|
||||
!package.json
|
||||
!tailwind.config.js
|
||||
!tsconfig.json
|
||||
!tsconfig.node.json
|
||||
!vite.config.ts
|
||||
!.prettierrc
|
||||
!README.md
|
||||
!eslint.config.js
|
||||
!postcss.config.js
|
||||
!.vscode/
|
||||
|
||||
# Ignore auto generated routeTree.gen.ts
|
||||
/src/routeTree.gen.ts
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"arrowParens": "always",
|
||||
"semi": false,
|
||||
"tabWidth": 2,
|
||||
"printWidth": 80,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "lf",
|
||||
"plugins": [
|
||||
"@trivago/prettier-plugin-sort-imports",
|
||||
"prettier-plugin-tailwindcss"
|
||||
],
|
||||
"tailwindFunctions": [
|
||||
"cn",
|
||||
"clsx"
|
||||
],
|
||||
"tailwindStylesheet": "./src/styles/index.css",
|
||||
"importOrder": [
|
||||
"^path$",
|
||||
"^vite$",
|
||||
"^@vitejs/(.*)$",
|
||||
"^react$",
|
||||
"^react-dom/client$",
|
||||
"^react/(.*)$",
|
||||
"^globals$",
|
||||
"^zod$",
|
||||
"^axios$",
|
||||
"^date-fns$",
|
||||
"^react-hook-form$",
|
||||
"^use-intl$",
|
||||
"^@radix-ui/(.*)$",
|
||||
"^@hookform/resolvers/zod$",
|
||||
"^@tanstack/react-query$",
|
||||
"^@tanstack/react-router$",
|
||||
"^@tanstack/react-table$",
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^@/assets/(.*)",
|
||||
"^@/api/(.*)$",
|
||||
"^@/stores/(.*)$",
|
||||
"^@/lib/(.*)$",
|
||||
"^@/utils/(.*)$",
|
||||
"^@/constants/(.*)$",
|
||||
"^@/context/(.*)$",
|
||||
"^@/hooks/(.*)$",
|
||||
"^@/components/layouts/(.*)$",
|
||||
"^@/components/ui/(.*)$",
|
||||
"^@/components/errors/(.*)$",
|
||||
"^@/components/(.*)$",
|
||||
"^@/features/(.*)$",
|
||||
"^[./]"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,936 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as ClerkRouteRouteImport } from './routes/clerk/route'
|
||||
import { Route as AuthenticatedRouteRouteImport } from './routes/_authenticated/route'
|
||||
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
|
||||
import { Route as AuthenticatedTicketsRouteImport } from './routes/_authenticated/tickets'
|
||||
import { Route as AuthenticatedSelfOwnedOrderRouteImport } from './routes/_authenticated/self-owned-order'
|
||||
import { Route as AuthenticatedOrdersRouteImport } from './routes/_authenticated/orders'
|
||||
import { Route as AuthenticatedOrderLogsRouteImport } from './routes/_authenticated/order-logs'
|
||||
import { Route as AuthenticatedCourseQueryRouteImport } from './routes/_authenticated/course-query'
|
||||
import { Route as errors503RouteImport } from './routes/(errors)/503'
|
||||
import { Route as errors500RouteImport } from './routes/(errors)/500'
|
||||
import { Route as errors404RouteImport } from './routes/(errors)/404'
|
||||
import { Route as errors403RouteImport } from './routes/(errors)/403'
|
||||
import { Route as errors401RouteImport } from './routes/(errors)/401'
|
||||
import { Route as authSignUpRouteImport } from './routes/(auth)/sign-up'
|
||||
import { Route as authSignIn2RouteImport } from './routes/(auth)/sign-in-2'
|
||||
import { Route as authSignInRouteImport } from './routes/(auth)/sign-in'
|
||||
import { Route as authOtpRouteImport } from './routes/(auth)/otp'
|
||||
import { Route as authForgotPasswordRouteImport } from './routes/(auth)/forgot-password'
|
||||
import { Route as ClerkAuthenticatedRouteRouteImport } from './routes/clerk/_authenticated/route'
|
||||
import { Route as ClerkauthRouteRouteImport } from './routes/clerk/(auth)/route'
|
||||
import { Route as AuthenticatedSettingsRouteRouteImport } from './routes/_authenticated/settings/route'
|
||||
import { Route as AuthenticatedUsersIndexRouteImport } from './routes/_authenticated/users/index'
|
||||
import { Route as AuthenticatedTasksIndexRouteImport } from './routes/_authenticated/tasks/index'
|
||||
import { Route as AuthenticatedSettingsIndexRouteImport } from './routes/_authenticated/settings/index'
|
||||
import { Route as AuthenticatedHelpCenterIndexRouteImport } from './routes/_authenticated/help-center/index'
|
||||
import { Route as AuthenticatedChatsIndexRouteImport } from './routes/_authenticated/chats/index'
|
||||
import { Route as AuthenticatedAppsIndexRouteImport } from './routes/_authenticated/apps/index'
|
||||
import { Route as ClerkAuthenticatedUserManagementRouteImport } from './routes/clerk/_authenticated/user-management'
|
||||
import { Route as ClerkauthSignUpRouteImport } from './routes/clerk/(auth)/sign-up'
|
||||
import { Route as ClerkauthSignInRouteImport } from './routes/clerk/(auth)/sign-in'
|
||||
import { Route as AuthenticatedSystemUsersRouteImport } from './routes/_authenticated/system/users'
|
||||
import { Route as AuthenticatedSystemThirdPartyRouteImport } from './routes/_authenticated/system/third-party'
|
||||
import { Route as AuthenticatedSystemAuditLogsRouteImport } from './routes/_authenticated/system/audit-logs'
|
||||
import { Route as AuthenticatedSettingsNotificationsRouteImport } from './routes/_authenticated/settings/notifications'
|
||||
import { Route as AuthenticatedSettingsDisplayRouteImport } from './routes/_authenticated/settings/display'
|
||||
import { Route as AuthenticatedSettingsAppearanceRouteImport } from './routes/_authenticated/settings/appearance'
|
||||
import { Route as AuthenticatedSettingsAccountRouteImport } from './routes/_authenticated/settings/account'
|
||||
import { Route as AuthenticatedErrorsErrorRouteImport } from './routes/_authenticated/errors/$error'
|
||||
import { Route as AuthenticatedCatalogProductsRouteImport } from './routes/_authenticated/catalog/products'
|
||||
import { Route as AuthenticatedCatalogCategoriesRouteImport } from './routes/_authenticated/catalog/categories'
|
||||
|
||||
const ClerkRouteRoute = ClerkRouteRouteImport.update({
|
||||
id: '/clerk',
|
||||
path: '/clerk',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthenticatedRouteRoute = AuthenticatedRouteRouteImport.update({
|
||||
id: '/_authenticated',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedTicketsRoute = AuthenticatedTicketsRouteImport.update({
|
||||
id: '/tickets',
|
||||
path: '/tickets',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedSelfOwnedOrderRoute =
|
||||
AuthenticatedSelfOwnedOrderRouteImport.update({
|
||||
id: '/self-owned-order',
|
||||
path: '/self-owned-order',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedOrdersRoute = AuthenticatedOrdersRouteImport.update({
|
||||
id: '/orders',
|
||||
path: '/orders',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedOrderLogsRoute = AuthenticatedOrderLogsRouteImport.update({
|
||||
id: '/order-logs',
|
||||
path: '/order-logs',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedCourseQueryRoute =
|
||||
AuthenticatedCourseQueryRouteImport.update({
|
||||
id: '/course-query',
|
||||
path: '/course-query',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const errors503Route = errors503RouteImport.update({
|
||||
id: '/(errors)/503',
|
||||
path: '/503',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const errors500Route = errors500RouteImport.update({
|
||||
id: '/(errors)/500',
|
||||
path: '/500',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const errors404Route = errors404RouteImport.update({
|
||||
id: '/(errors)/404',
|
||||
path: '/404',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const errors403Route = errors403RouteImport.update({
|
||||
id: '/(errors)/403',
|
||||
path: '/403',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const errors401Route = errors401RouteImport.update({
|
||||
id: '/(errors)/401',
|
||||
path: '/401',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const authSignUpRoute = authSignUpRouteImport.update({
|
||||
id: '/(auth)/sign-up',
|
||||
path: '/sign-up',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const authSignIn2Route = authSignIn2RouteImport.update({
|
||||
id: '/(auth)/sign-in-2',
|
||||
path: '/sign-in-2',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const authSignInRoute = authSignInRouteImport.update({
|
||||
id: '/(auth)/sign-in',
|
||||
path: '/sign-in',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const authOtpRoute = authOtpRouteImport.update({
|
||||
id: '/(auth)/otp',
|
||||
path: '/otp',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const authForgotPasswordRoute = authForgotPasswordRouteImport.update({
|
||||
id: '/(auth)/forgot-password',
|
||||
path: '/forgot-password',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ClerkAuthenticatedRouteRoute = ClerkAuthenticatedRouteRouteImport.update({
|
||||
id: '/_authenticated',
|
||||
getParentRoute: () => ClerkRouteRoute,
|
||||
} as any)
|
||||
const ClerkauthRouteRoute = ClerkauthRouteRouteImport.update({
|
||||
id: '/(auth)',
|
||||
getParentRoute: () => ClerkRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedSettingsRouteRoute =
|
||||
AuthenticatedSettingsRouteRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedUsersIndexRoute = AuthenticatedUsersIndexRouteImport.update({
|
||||
id: '/users/',
|
||||
path: '/users/',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedTasksIndexRoute = AuthenticatedTasksIndexRouteImport.update({
|
||||
id: '/tasks/',
|
||||
path: '/tasks/',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedSettingsIndexRoute =
|
||||
AuthenticatedSettingsIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AuthenticatedSettingsRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedHelpCenterIndexRoute =
|
||||
AuthenticatedHelpCenterIndexRouteImport.update({
|
||||
id: '/help-center/',
|
||||
path: '/help-center/',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedChatsIndexRoute = AuthenticatedChatsIndexRouteImport.update({
|
||||
id: '/chats/',
|
||||
path: '/chats/',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedAppsIndexRoute = AuthenticatedAppsIndexRouteImport.update({
|
||||
id: '/apps/',
|
||||
path: '/apps/',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const ClerkAuthenticatedUserManagementRoute =
|
||||
ClerkAuthenticatedUserManagementRouteImport.update({
|
||||
id: '/user-management',
|
||||
path: '/user-management',
|
||||
getParentRoute: () => ClerkAuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const ClerkauthSignUpRoute = ClerkauthSignUpRouteImport.update({
|
||||
id: '/sign-up',
|
||||
path: '/sign-up',
|
||||
getParentRoute: () => ClerkauthRouteRoute,
|
||||
} as any)
|
||||
const ClerkauthSignInRoute = ClerkauthSignInRouteImport.update({
|
||||
id: '/sign-in',
|
||||
path: '/sign-in',
|
||||
getParentRoute: () => ClerkauthRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedSystemUsersRoute =
|
||||
AuthenticatedSystemUsersRouteImport.update({
|
||||
id: '/system/users',
|
||||
path: '/system/users',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedSystemThirdPartyRoute =
|
||||
AuthenticatedSystemThirdPartyRouteImport.update({
|
||||
id: '/system/third-party',
|
||||
path: '/system/third-party',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedSystemAuditLogsRoute =
|
||||
AuthenticatedSystemAuditLogsRouteImport.update({
|
||||
id: '/system/audit-logs',
|
||||
path: '/system/audit-logs',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedSettingsNotificationsRoute =
|
||||
AuthenticatedSettingsNotificationsRouteImport.update({
|
||||
id: '/notifications',
|
||||
path: '/notifications',
|
||||
getParentRoute: () => AuthenticatedSettingsRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedSettingsDisplayRoute =
|
||||
AuthenticatedSettingsDisplayRouteImport.update({
|
||||
id: '/display',
|
||||
path: '/display',
|
||||
getParentRoute: () => AuthenticatedSettingsRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedSettingsAppearanceRoute =
|
||||
AuthenticatedSettingsAppearanceRouteImport.update({
|
||||
id: '/appearance',
|
||||
path: '/appearance',
|
||||
getParentRoute: () => AuthenticatedSettingsRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedSettingsAccountRoute =
|
||||
AuthenticatedSettingsAccountRouteImport.update({
|
||||
id: '/account',
|
||||
path: '/account',
|
||||
getParentRoute: () => AuthenticatedSettingsRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedErrorsErrorRoute =
|
||||
AuthenticatedErrorsErrorRouteImport.update({
|
||||
id: '/errors/$error',
|
||||
path: '/errors/$error',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedCatalogProductsRoute =
|
||||
AuthenticatedCatalogProductsRouteImport.update({
|
||||
id: '/catalog/products',
|
||||
path: '/catalog/products',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedCatalogCategoriesRoute =
|
||||
AuthenticatedCatalogCategoriesRouteImport.update({
|
||||
id: '/catalog/categories',
|
||||
path: '/catalog/categories',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof AuthenticatedIndexRoute
|
||||
'/clerk': typeof ClerkauthRouteRouteWithChildren
|
||||
'/settings': typeof AuthenticatedSettingsRouteRouteWithChildren
|
||||
'/forgot-password': typeof authForgotPasswordRoute
|
||||
'/otp': typeof authOtpRoute
|
||||
'/sign-in': typeof authSignInRoute
|
||||
'/sign-in-2': typeof authSignIn2Route
|
||||
'/sign-up': typeof authSignUpRoute
|
||||
'/401': typeof errors401Route
|
||||
'/403': typeof errors403Route
|
||||
'/404': typeof errors404Route
|
||||
'/500': typeof errors500Route
|
||||
'/503': typeof errors503Route
|
||||
'/course-query': typeof AuthenticatedCourseQueryRoute
|
||||
'/order-logs': typeof AuthenticatedOrderLogsRoute
|
||||
'/orders': typeof AuthenticatedOrdersRoute
|
||||
'/self-owned-order': typeof AuthenticatedSelfOwnedOrderRoute
|
||||
'/tickets': typeof AuthenticatedTicketsRoute
|
||||
'/catalog/categories': typeof AuthenticatedCatalogCategoriesRoute
|
||||
'/catalog/products': typeof AuthenticatedCatalogProductsRoute
|
||||
'/errors/$error': typeof AuthenticatedErrorsErrorRoute
|
||||
'/settings/account': typeof AuthenticatedSettingsAccountRoute
|
||||
'/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute
|
||||
'/settings/display': typeof AuthenticatedSettingsDisplayRoute
|
||||
'/settings/notifications': typeof AuthenticatedSettingsNotificationsRoute
|
||||
'/system/audit-logs': typeof AuthenticatedSystemAuditLogsRoute
|
||||
'/system/third-party': typeof AuthenticatedSystemThirdPartyRoute
|
||||
'/system/users': typeof AuthenticatedSystemUsersRoute
|
||||
'/clerk/sign-in': typeof ClerkauthSignInRoute
|
||||
'/clerk/sign-up': typeof ClerkauthSignUpRoute
|
||||
'/clerk/user-management': typeof ClerkAuthenticatedUserManagementRoute
|
||||
'/apps/': typeof AuthenticatedAppsIndexRoute
|
||||
'/chats/': typeof AuthenticatedChatsIndexRoute
|
||||
'/help-center/': typeof AuthenticatedHelpCenterIndexRoute
|
||||
'/settings/': typeof AuthenticatedSettingsIndexRoute
|
||||
'/tasks/': typeof AuthenticatedTasksIndexRoute
|
||||
'/users/': typeof AuthenticatedUsersIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/clerk': typeof ClerkauthRouteRouteWithChildren
|
||||
'/forgot-password': typeof authForgotPasswordRoute
|
||||
'/otp': typeof authOtpRoute
|
||||
'/sign-in': typeof authSignInRoute
|
||||
'/sign-in-2': typeof authSignIn2Route
|
||||
'/sign-up': typeof authSignUpRoute
|
||||
'/401': typeof errors401Route
|
||||
'/403': typeof errors403Route
|
||||
'/404': typeof errors404Route
|
||||
'/500': typeof errors500Route
|
||||
'/503': typeof errors503Route
|
||||
'/course-query': typeof AuthenticatedCourseQueryRoute
|
||||
'/order-logs': typeof AuthenticatedOrderLogsRoute
|
||||
'/orders': typeof AuthenticatedOrdersRoute
|
||||
'/self-owned-order': typeof AuthenticatedSelfOwnedOrderRoute
|
||||
'/tickets': typeof AuthenticatedTicketsRoute
|
||||
'/': typeof AuthenticatedIndexRoute
|
||||
'/catalog/categories': typeof AuthenticatedCatalogCategoriesRoute
|
||||
'/catalog/products': typeof AuthenticatedCatalogProductsRoute
|
||||
'/errors/$error': typeof AuthenticatedErrorsErrorRoute
|
||||
'/settings/account': typeof AuthenticatedSettingsAccountRoute
|
||||
'/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute
|
||||
'/settings/display': typeof AuthenticatedSettingsDisplayRoute
|
||||
'/settings/notifications': typeof AuthenticatedSettingsNotificationsRoute
|
||||
'/system/audit-logs': typeof AuthenticatedSystemAuditLogsRoute
|
||||
'/system/third-party': typeof AuthenticatedSystemThirdPartyRoute
|
||||
'/system/users': typeof AuthenticatedSystemUsersRoute
|
||||
'/clerk/sign-in': typeof ClerkauthSignInRoute
|
||||
'/clerk/sign-up': typeof ClerkauthSignUpRoute
|
||||
'/clerk/user-management': typeof ClerkAuthenticatedUserManagementRoute
|
||||
'/apps': typeof AuthenticatedAppsIndexRoute
|
||||
'/chats': typeof AuthenticatedChatsIndexRoute
|
||||
'/help-center': typeof AuthenticatedHelpCenterIndexRoute
|
||||
'/settings': typeof AuthenticatedSettingsIndexRoute
|
||||
'/tasks': typeof AuthenticatedTasksIndexRoute
|
||||
'/users': typeof AuthenticatedUsersIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/_authenticated': typeof AuthenticatedRouteRouteWithChildren
|
||||
'/clerk': typeof ClerkRouteRouteWithChildren
|
||||
'/_authenticated/settings': typeof AuthenticatedSettingsRouteRouteWithChildren
|
||||
'/clerk/(auth)': typeof ClerkauthRouteRouteWithChildren
|
||||
'/clerk/_authenticated': typeof ClerkAuthenticatedRouteRouteWithChildren
|
||||
'/(auth)/forgot-password': typeof authForgotPasswordRoute
|
||||
'/(auth)/otp': typeof authOtpRoute
|
||||
'/(auth)/sign-in': typeof authSignInRoute
|
||||
'/(auth)/sign-in-2': typeof authSignIn2Route
|
||||
'/(auth)/sign-up': typeof authSignUpRoute
|
||||
'/(errors)/401': typeof errors401Route
|
||||
'/(errors)/403': typeof errors403Route
|
||||
'/(errors)/404': typeof errors404Route
|
||||
'/(errors)/500': typeof errors500Route
|
||||
'/(errors)/503': typeof errors503Route
|
||||
'/_authenticated/course-query': typeof AuthenticatedCourseQueryRoute
|
||||
'/_authenticated/order-logs': typeof AuthenticatedOrderLogsRoute
|
||||
'/_authenticated/orders': typeof AuthenticatedOrdersRoute
|
||||
'/_authenticated/self-owned-order': typeof AuthenticatedSelfOwnedOrderRoute
|
||||
'/_authenticated/tickets': typeof AuthenticatedTicketsRoute
|
||||
'/_authenticated/': typeof AuthenticatedIndexRoute
|
||||
'/_authenticated/catalog/categories': typeof AuthenticatedCatalogCategoriesRoute
|
||||
'/_authenticated/catalog/products': typeof AuthenticatedCatalogProductsRoute
|
||||
'/_authenticated/errors/$error': typeof AuthenticatedErrorsErrorRoute
|
||||
'/_authenticated/settings/account': typeof AuthenticatedSettingsAccountRoute
|
||||
'/_authenticated/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute
|
||||
'/_authenticated/settings/display': typeof AuthenticatedSettingsDisplayRoute
|
||||
'/_authenticated/settings/notifications': typeof AuthenticatedSettingsNotificationsRoute
|
||||
'/_authenticated/system/audit-logs': typeof AuthenticatedSystemAuditLogsRoute
|
||||
'/_authenticated/system/third-party': typeof AuthenticatedSystemThirdPartyRoute
|
||||
'/_authenticated/system/users': typeof AuthenticatedSystemUsersRoute
|
||||
'/clerk/(auth)/sign-in': typeof ClerkauthSignInRoute
|
||||
'/clerk/(auth)/sign-up': typeof ClerkauthSignUpRoute
|
||||
'/clerk/_authenticated/user-management': typeof ClerkAuthenticatedUserManagementRoute
|
||||
'/_authenticated/apps/': typeof AuthenticatedAppsIndexRoute
|
||||
'/_authenticated/chats/': typeof AuthenticatedChatsIndexRoute
|
||||
'/_authenticated/help-center/': typeof AuthenticatedHelpCenterIndexRoute
|
||||
'/_authenticated/settings/': typeof AuthenticatedSettingsIndexRoute
|
||||
'/_authenticated/tasks/': typeof AuthenticatedTasksIndexRoute
|
||||
'/_authenticated/users/': typeof AuthenticatedUsersIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/clerk'
|
||||
| '/settings'
|
||||
| '/forgot-password'
|
||||
| '/otp'
|
||||
| '/sign-in'
|
||||
| '/sign-in-2'
|
||||
| '/sign-up'
|
||||
| '/401'
|
||||
| '/403'
|
||||
| '/404'
|
||||
| '/500'
|
||||
| '/503'
|
||||
| '/course-query'
|
||||
| '/order-logs'
|
||||
| '/orders'
|
||||
| '/self-owned-order'
|
||||
| '/tickets'
|
||||
| '/catalog/categories'
|
||||
| '/catalog/products'
|
||||
| '/errors/$error'
|
||||
| '/settings/account'
|
||||
| '/settings/appearance'
|
||||
| '/settings/display'
|
||||
| '/settings/notifications'
|
||||
| '/system/audit-logs'
|
||||
| '/system/third-party'
|
||||
| '/system/users'
|
||||
| '/clerk/sign-in'
|
||||
| '/clerk/sign-up'
|
||||
| '/clerk/user-management'
|
||||
| '/apps/'
|
||||
| '/chats/'
|
||||
| '/help-center/'
|
||||
| '/settings/'
|
||||
| '/tasks/'
|
||||
| '/users/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/clerk'
|
||||
| '/forgot-password'
|
||||
| '/otp'
|
||||
| '/sign-in'
|
||||
| '/sign-in-2'
|
||||
| '/sign-up'
|
||||
| '/401'
|
||||
| '/403'
|
||||
| '/404'
|
||||
| '/500'
|
||||
| '/503'
|
||||
| '/course-query'
|
||||
| '/order-logs'
|
||||
| '/orders'
|
||||
| '/self-owned-order'
|
||||
| '/tickets'
|
||||
| '/'
|
||||
| '/catalog/categories'
|
||||
| '/catalog/products'
|
||||
| '/errors/$error'
|
||||
| '/settings/account'
|
||||
| '/settings/appearance'
|
||||
| '/settings/display'
|
||||
| '/settings/notifications'
|
||||
| '/system/audit-logs'
|
||||
| '/system/third-party'
|
||||
| '/system/users'
|
||||
| '/clerk/sign-in'
|
||||
| '/clerk/sign-up'
|
||||
| '/clerk/user-management'
|
||||
| '/apps'
|
||||
| '/chats'
|
||||
| '/help-center'
|
||||
| '/settings'
|
||||
| '/tasks'
|
||||
| '/users'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/_authenticated'
|
||||
| '/clerk'
|
||||
| '/_authenticated/settings'
|
||||
| '/clerk/(auth)'
|
||||
| '/clerk/_authenticated'
|
||||
| '/(auth)/forgot-password'
|
||||
| '/(auth)/otp'
|
||||
| '/(auth)/sign-in'
|
||||
| '/(auth)/sign-in-2'
|
||||
| '/(auth)/sign-up'
|
||||
| '/(errors)/401'
|
||||
| '/(errors)/403'
|
||||
| '/(errors)/404'
|
||||
| '/(errors)/500'
|
||||
| '/(errors)/503'
|
||||
| '/_authenticated/course-query'
|
||||
| '/_authenticated/order-logs'
|
||||
| '/_authenticated/orders'
|
||||
| '/_authenticated/self-owned-order'
|
||||
| '/_authenticated/tickets'
|
||||
| '/_authenticated/'
|
||||
| '/_authenticated/catalog/categories'
|
||||
| '/_authenticated/catalog/products'
|
||||
| '/_authenticated/errors/$error'
|
||||
| '/_authenticated/settings/account'
|
||||
| '/_authenticated/settings/appearance'
|
||||
| '/_authenticated/settings/display'
|
||||
| '/_authenticated/settings/notifications'
|
||||
| '/_authenticated/system/audit-logs'
|
||||
| '/_authenticated/system/third-party'
|
||||
| '/_authenticated/system/users'
|
||||
| '/clerk/(auth)/sign-in'
|
||||
| '/clerk/(auth)/sign-up'
|
||||
| '/clerk/_authenticated/user-management'
|
||||
| '/_authenticated/apps/'
|
||||
| '/_authenticated/chats/'
|
||||
| '/_authenticated/help-center/'
|
||||
| '/_authenticated/settings/'
|
||||
| '/_authenticated/tasks/'
|
||||
| '/_authenticated/users/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
AuthenticatedRouteRoute: typeof AuthenticatedRouteRouteWithChildren
|
||||
ClerkRouteRoute: typeof ClerkRouteRouteWithChildren
|
||||
authForgotPasswordRoute: typeof authForgotPasswordRoute
|
||||
authOtpRoute: typeof authOtpRoute
|
||||
authSignInRoute: typeof authSignInRoute
|
||||
authSignIn2Route: typeof authSignIn2Route
|
||||
authSignUpRoute: typeof authSignUpRoute
|
||||
errors401Route: typeof errors401Route
|
||||
errors403Route: typeof errors403Route
|
||||
errors404Route: typeof errors404Route
|
||||
errors500Route: typeof errors500Route
|
||||
errors503Route: typeof errors503Route
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/clerk': {
|
||||
id: '/clerk'
|
||||
path: '/clerk'
|
||||
fullPath: '/clerk'
|
||||
preLoaderRoute: typeof ClerkRouteRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_authenticated': {
|
||||
id: '/_authenticated'
|
||||
path: ''
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof AuthenticatedRouteRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_authenticated/': {
|
||||
id: '/_authenticated/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof AuthenticatedIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/tickets': {
|
||||
id: '/_authenticated/tickets'
|
||||
path: '/tickets'
|
||||
fullPath: '/tickets'
|
||||
preLoaderRoute: typeof AuthenticatedTicketsRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/self-owned-order': {
|
||||
id: '/_authenticated/self-owned-order'
|
||||
path: '/self-owned-order'
|
||||
fullPath: '/self-owned-order'
|
||||
preLoaderRoute: typeof AuthenticatedSelfOwnedOrderRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/orders': {
|
||||
id: '/_authenticated/orders'
|
||||
path: '/orders'
|
||||
fullPath: '/orders'
|
||||
preLoaderRoute: typeof AuthenticatedOrdersRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/order-logs': {
|
||||
id: '/_authenticated/order-logs'
|
||||
path: '/order-logs'
|
||||
fullPath: '/order-logs'
|
||||
preLoaderRoute: typeof AuthenticatedOrderLogsRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/course-query': {
|
||||
id: '/_authenticated/course-query'
|
||||
path: '/course-query'
|
||||
fullPath: '/course-query'
|
||||
preLoaderRoute: typeof AuthenticatedCourseQueryRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/(errors)/503': {
|
||||
id: '/(errors)/503'
|
||||
path: '/503'
|
||||
fullPath: '/503'
|
||||
preLoaderRoute: typeof errors503RouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/(errors)/500': {
|
||||
id: '/(errors)/500'
|
||||
path: '/500'
|
||||
fullPath: '/500'
|
||||
preLoaderRoute: typeof errors500RouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/(errors)/404': {
|
||||
id: '/(errors)/404'
|
||||
path: '/404'
|
||||
fullPath: '/404'
|
||||
preLoaderRoute: typeof errors404RouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/(errors)/403': {
|
||||
id: '/(errors)/403'
|
||||
path: '/403'
|
||||
fullPath: '/403'
|
||||
preLoaderRoute: typeof errors403RouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/(errors)/401': {
|
||||
id: '/(errors)/401'
|
||||
path: '/401'
|
||||
fullPath: '/401'
|
||||
preLoaderRoute: typeof errors401RouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/(auth)/sign-up': {
|
||||
id: '/(auth)/sign-up'
|
||||
path: '/sign-up'
|
||||
fullPath: '/sign-up'
|
||||
preLoaderRoute: typeof authSignUpRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/(auth)/sign-in-2': {
|
||||
id: '/(auth)/sign-in-2'
|
||||
path: '/sign-in-2'
|
||||
fullPath: '/sign-in-2'
|
||||
preLoaderRoute: typeof authSignIn2RouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/(auth)/sign-in': {
|
||||
id: '/(auth)/sign-in'
|
||||
path: '/sign-in'
|
||||
fullPath: '/sign-in'
|
||||
preLoaderRoute: typeof authSignInRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/(auth)/otp': {
|
||||
id: '/(auth)/otp'
|
||||
path: '/otp'
|
||||
fullPath: '/otp'
|
||||
preLoaderRoute: typeof authOtpRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/(auth)/forgot-password': {
|
||||
id: '/(auth)/forgot-password'
|
||||
path: '/forgot-password'
|
||||
fullPath: '/forgot-password'
|
||||
preLoaderRoute: typeof authForgotPasswordRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/clerk/_authenticated': {
|
||||
id: '/clerk/_authenticated'
|
||||
path: ''
|
||||
fullPath: '/clerk'
|
||||
preLoaderRoute: typeof ClerkAuthenticatedRouteRouteImport
|
||||
parentRoute: typeof ClerkRouteRoute
|
||||
}
|
||||
'/clerk/(auth)': {
|
||||
id: '/clerk/(auth)'
|
||||
path: ''
|
||||
fullPath: '/clerk'
|
||||
preLoaderRoute: typeof ClerkauthRouteRouteImport
|
||||
parentRoute: typeof ClerkRouteRoute
|
||||
}
|
||||
'/_authenticated/settings': {
|
||||
id: '/_authenticated/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/settings'
|
||||
preLoaderRoute: typeof AuthenticatedSettingsRouteRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/users/': {
|
||||
id: '/_authenticated/users/'
|
||||
path: '/users'
|
||||
fullPath: '/users/'
|
||||
preLoaderRoute: typeof AuthenticatedUsersIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/tasks/': {
|
||||
id: '/_authenticated/tasks/'
|
||||
path: '/tasks'
|
||||
fullPath: '/tasks/'
|
||||
preLoaderRoute: typeof AuthenticatedTasksIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/settings/': {
|
||||
id: '/_authenticated/settings/'
|
||||
path: '/'
|
||||
fullPath: '/settings/'
|
||||
preLoaderRoute: typeof AuthenticatedSettingsIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedSettingsRouteRoute
|
||||
}
|
||||
'/_authenticated/help-center/': {
|
||||
id: '/_authenticated/help-center/'
|
||||
path: '/help-center'
|
||||
fullPath: '/help-center/'
|
||||
preLoaderRoute: typeof AuthenticatedHelpCenterIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/chats/': {
|
||||
id: '/_authenticated/chats/'
|
||||
path: '/chats'
|
||||
fullPath: '/chats/'
|
||||
preLoaderRoute: typeof AuthenticatedChatsIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/apps/': {
|
||||
id: '/_authenticated/apps/'
|
||||
path: '/apps'
|
||||
fullPath: '/apps/'
|
||||
preLoaderRoute: typeof AuthenticatedAppsIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/clerk/_authenticated/user-management': {
|
||||
id: '/clerk/_authenticated/user-management'
|
||||
path: '/user-management'
|
||||
fullPath: '/clerk/user-management'
|
||||
preLoaderRoute: typeof ClerkAuthenticatedUserManagementRouteImport
|
||||
parentRoute: typeof ClerkAuthenticatedRouteRoute
|
||||
}
|
||||
'/clerk/(auth)/sign-up': {
|
||||
id: '/clerk/(auth)/sign-up'
|
||||
path: '/sign-up'
|
||||
fullPath: '/clerk/sign-up'
|
||||
preLoaderRoute: typeof ClerkauthSignUpRouteImport
|
||||
parentRoute: typeof ClerkauthRouteRoute
|
||||
}
|
||||
'/clerk/(auth)/sign-in': {
|
||||
id: '/clerk/(auth)/sign-in'
|
||||
path: '/sign-in'
|
||||
fullPath: '/clerk/sign-in'
|
||||
preLoaderRoute: typeof ClerkauthSignInRouteImport
|
||||
parentRoute: typeof ClerkauthRouteRoute
|
||||
}
|
||||
'/_authenticated/system/users': {
|
||||
id: '/_authenticated/system/users'
|
||||
path: '/system/users'
|
||||
fullPath: '/system/users'
|
||||
preLoaderRoute: typeof AuthenticatedSystemUsersRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/system/third-party': {
|
||||
id: '/_authenticated/system/third-party'
|
||||
path: '/system/third-party'
|
||||
fullPath: '/system/third-party'
|
||||
preLoaderRoute: typeof AuthenticatedSystemThirdPartyRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/system/audit-logs': {
|
||||
id: '/_authenticated/system/audit-logs'
|
||||
path: '/system/audit-logs'
|
||||
fullPath: '/system/audit-logs'
|
||||
preLoaderRoute: typeof AuthenticatedSystemAuditLogsRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/settings/notifications': {
|
||||
id: '/_authenticated/settings/notifications'
|
||||
path: '/notifications'
|
||||
fullPath: '/settings/notifications'
|
||||
preLoaderRoute: typeof AuthenticatedSettingsNotificationsRouteImport
|
||||
parentRoute: typeof AuthenticatedSettingsRouteRoute
|
||||
}
|
||||
'/_authenticated/settings/display': {
|
||||
id: '/_authenticated/settings/display'
|
||||
path: '/display'
|
||||
fullPath: '/settings/display'
|
||||
preLoaderRoute: typeof AuthenticatedSettingsDisplayRouteImport
|
||||
parentRoute: typeof AuthenticatedSettingsRouteRoute
|
||||
}
|
||||
'/_authenticated/settings/appearance': {
|
||||
id: '/_authenticated/settings/appearance'
|
||||
path: '/appearance'
|
||||
fullPath: '/settings/appearance'
|
||||
preLoaderRoute: typeof AuthenticatedSettingsAppearanceRouteImport
|
||||
parentRoute: typeof AuthenticatedSettingsRouteRoute
|
||||
}
|
||||
'/_authenticated/settings/account': {
|
||||
id: '/_authenticated/settings/account'
|
||||
path: '/account'
|
||||
fullPath: '/settings/account'
|
||||
preLoaderRoute: typeof AuthenticatedSettingsAccountRouteImport
|
||||
parentRoute: typeof AuthenticatedSettingsRouteRoute
|
||||
}
|
||||
'/_authenticated/errors/$error': {
|
||||
id: '/_authenticated/errors/$error'
|
||||
path: '/errors/$error'
|
||||
fullPath: '/errors/$error'
|
||||
preLoaderRoute: typeof AuthenticatedErrorsErrorRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/catalog/products': {
|
||||
id: '/_authenticated/catalog/products'
|
||||
path: '/catalog/products'
|
||||
fullPath: '/catalog/products'
|
||||
preLoaderRoute: typeof AuthenticatedCatalogProductsRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/catalog/categories': {
|
||||
id: '/_authenticated/catalog/categories'
|
||||
path: '/catalog/categories'
|
||||
fullPath: '/catalog/categories'
|
||||
preLoaderRoute: typeof AuthenticatedCatalogCategoriesRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthenticatedSettingsRouteRouteChildren {
|
||||
AuthenticatedSettingsAccountRoute: typeof AuthenticatedSettingsAccountRoute
|
||||
AuthenticatedSettingsAppearanceRoute: typeof AuthenticatedSettingsAppearanceRoute
|
||||
AuthenticatedSettingsDisplayRoute: typeof AuthenticatedSettingsDisplayRoute
|
||||
AuthenticatedSettingsNotificationsRoute: typeof AuthenticatedSettingsNotificationsRoute
|
||||
AuthenticatedSettingsIndexRoute: typeof AuthenticatedSettingsIndexRoute
|
||||
}
|
||||
|
||||
const AuthenticatedSettingsRouteRouteChildren: AuthenticatedSettingsRouteRouteChildren =
|
||||
{
|
||||
AuthenticatedSettingsAccountRoute: AuthenticatedSettingsAccountRoute,
|
||||
AuthenticatedSettingsAppearanceRoute: AuthenticatedSettingsAppearanceRoute,
|
||||
AuthenticatedSettingsDisplayRoute: AuthenticatedSettingsDisplayRoute,
|
||||
AuthenticatedSettingsNotificationsRoute:
|
||||
AuthenticatedSettingsNotificationsRoute,
|
||||
AuthenticatedSettingsIndexRoute: AuthenticatedSettingsIndexRoute,
|
||||
}
|
||||
|
||||
const AuthenticatedSettingsRouteRouteWithChildren =
|
||||
AuthenticatedSettingsRouteRoute._addFileChildren(
|
||||
AuthenticatedSettingsRouteRouteChildren,
|
||||
)
|
||||
|
||||
interface AuthenticatedRouteRouteChildren {
|
||||
AuthenticatedSettingsRouteRoute: typeof AuthenticatedSettingsRouteRouteWithChildren
|
||||
AuthenticatedCourseQueryRoute: typeof AuthenticatedCourseQueryRoute
|
||||
AuthenticatedOrderLogsRoute: typeof AuthenticatedOrderLogsRoute
|
||||
AuthenticatedOrdersRoute: typeof AuthenticatedOrdersRoute
|
||||
AuthenticatedSelfOwnedOrderRoute: typeof AuthenticatedSelfOwnedOrderRoute
|
||||
AuthenticatedTicketsRoute: typeof AuthenticatedTicketsRoute
|
||||
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
|
||||
AuthenticatedCatalogCategoriesRoute: typeof AuthenticatedCatalogCategoriesRoute
|
||||
AuthenticatedCatalogProductsRoute: typeof AuthenticatedCatalogProductsRoute
|
||||
AuthenticatedErrorsErrorRoute: typeof AuthenticatedErrorsErrorRoute
|
||||
AuthenticatedSystemAuditLogsRoute: typeof AuthenticatedSystemAuditLogsRoute
|
||||
AuthenticatedSystemThirdPartyRoute: typeof AuthenticatedSystemThirdPartyRoute
|
||||
AuthenticatedSystemUsersRoute: typeof AuthenticatedSystemUsersRoute
|
||||
AuthenticatedAppsIndexRoute: typeof AuthenticatedAppsIndexRoute
|
||||
AuthenticatedChatsIndexRoute: typeof AuthenticatedChatsIndexRoute
|
||||
AuthenticatedHelpCenterIndexRoute: typeof AuthenticatedHelpCenterIndexRoute
|
||||
AuthenticatedTasksIndexRoute: typeof AuthenticatedTasksIndexRoute
|
||||
AuthenticatedUsersIndexRoute: typeof AuthenticatedUsersIndexRoute
|
||||
}
|
||||
|
||||
const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
|
||||
AuthenticatedSettingsRouteRoute: AuthenticatedSettingsRouteRouteWithChildren,
|
||||
AuthenticatedCourseQueryRoute: AuthenticatedCourseQueryRoute,
|
||||
AuthenticatedOrderLogsRoute: AuthenticatedOrderLogsRoute,
|
||||
AuthenticatedOrdersRoute: AuthenticatedOrdersRoute,
|
||||
AuthenticatedSelfOwnedOrderRoute: AuthenticatedSelfOwnedOrderRoute,
|
||||
AuthenticatedTicketsRoute: AuthenticatedTicketsRoute,
|
||||
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
|
||||
AuthenticatedCatalogCategoriesRoute: AuthenticatedCatalogCategoriesRoute,
|
||||
AuthenticatedCatalogProductsRoute: AuthenticatedCatalogProductsRoute,
|
||||
AuthenticatedErrorsErrorRoute: AuthenticatedErrorsErrorRoute,
|
||||
AuthenticatedSystemAuditLogsRoute: AuthenticatedSystemAuditLogsRoute,
|
||||
AuthenticatedSystemThirdPartyRoute: AuthenticatedSystemThirdPartyRoute,
|
||||
AuthenticatedSystemUsersRoute: AuthenticatedSystemUsersRoute,
|
||||
AuthenticatedAppsIndexRoute: AuthenticatedAppsIndexRoute,
|
||||
AuthenticatedChatsIndexRoute: AuthenticatedChatsIndexRoute,
|
||||
AuthenticatedHelpCenterIndexRoute: AuthenticatedHelpCenterIndexRoute,
|
||||
AuthenticatedTasksIndexRoute: AuthenticatedTasksIndexRoute,
|
||||
AuthenticatedUsersIndexRoute: AuthenticatedUsersIndexRoute,
|
||||
}
|
||||
|
||||
const AuthenticatedRouteRouteWithChildren =
|
||||
AuthenticatedRouteRoute._addFileChildren(AuthenticatedRouteRouteChildren)
|
||||
|
||||
interface ClerkauthRouteRouteChildren {
|
||||
ClerkauthSignInRoute: typeof ClerkauthSignInRoute
|
||||
ClerkauthSignUpRoute: typeof ClerkauthSignUpRoute
|
||||
}
|
||||
|
||||
const ClerkauthRouteRouteChildren: ClerkauthRouteRouteChildren = {
|
||||
ClerkauthSignInRoute: ClerkauthSignInRoute,
|
||||
ClerkauthSignUpRoute: ClerkauthSignUpRoute,
|
||||
}
|
||||
|
||||
const ClerkauthRouteRouteWithChildren = ClerkauthRouteRoute._addFileChildren(
|
||||
ClerkauthRouteRouteChildren,
|
||||
)
|
||||
|
||||
interface ClerkAuthenticatedRouteRouteChildren {
|
||||
ClerkAuthenticatedUserManagementRoute: typeof ClerkAuthenticatedUserManagementRoute
|
||||
}
|
||||
|
||||
const ClerkAuthenticatedRouteRouteChildren: ClerkAuthenticatedRouteRouteChildren =
|
||||
{
|
||||
ClerkAuthenticatedUserManagementRoute:
|
||||
ClerkAuthenticatedUserManagementRoute,
|
||||
}
|
||||
|
||||
const ClerkAuthenticatedRouteRouteWithChildren =
|
||||
ClerkAuthenticatedRouteRoute._addFileChildren(
|
||||
ClerkAuthenticatedRouteRouteChildren,
|
||||
)
|
||||
|
||||
interface ClerkRouteRouteChildren {
|
||||
ClerkauthRouteRoute: typeof ClerkauthRouteRouteWithChildren
|
||||
ClerkAuthenticatedRouteRoute: typeof ClerkAuthenticatedRouteRouteWithChildren
|
||||
}
|
||||
|
||||
const ClerkRouteRouteChildren: ClerkRouteRouteChildren = {
|
||||
ClerkauthRouteRoute: ClerkauthRouteRouteWithChildren,
|
||||
ClerkAuthenticatedRouteRoute: ClerkAuthenticatedRouteRouteWithChildren,
|
||||
}
|
||||
|
||||
const ClerkRouteRouteWithChildren = ClerkRouteRoute._addFileChildren(
|
||||
ClerkRouteRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
AuthenticatedRouteRoute: AuthenticatedRouteRouteWithChildren,
|
||||
ClerkRouteRoute: ClerkRouteRouteWithChildren,
|
||||
authForgotPasswordRoute: authForgotPasswordRoute,
|
||||
authOtpRoute: authOtpRoute,
|
||||
authSignInRoute: authSignInRoute,
|
||||
authSignIn2Route: authSignIn2Route,
|
||||
authSignUpRoute: authSignUpRoute,
|
||||
errors401Route: errors401Route,
|
||||
errors403Route: errors403Route,
|
||||
errors404Route: errors404Route,
|
||||
errors500Route: errors500Route,
|
||||
errors503Route: errors503Route,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
@@ -0,0 +1,119 @@
|
||||
# Shadcn Admin Dashboard
|
||||
|
||||
Admin Dashboard UI crafted with Shadcn and Vite. Built with responsiveness and accessibility in mind.
|
||||
|
||||

|
||||
|
||||
[](https://go.clerk.com/GttUAaK)
|
||||
|
||||
I've been creating dashboard UIs at work and for my personal projects. I always wanted to make a reusable collection of dashboard UI for future projects; and here it is now. While I've created a few custom components, some of the code is directly adapted from ShadcnUI examples.
|
||||
|
||||
> This is not a starter project (template) though. I'll probably make one in the future.
|
||||
|
||||
## Features
|
||||
|
||||
- Light/dark mode
|
||||
- Responsive
|
||||
- Accessible
|
||||
- With built-in Sidebar component
|
||||
- Global search command
|
||||
- 10+ pages
|
||||
- Extra custom components
|
||||
- RTL support
|
||||
|
||||
<details>
|
||||
<summary>Customized Components (click to expand)</summary>
|
||||
|
||||
This project uses Shadcn UI components, but some have been slightly modified for better RTL (Right-to-Left) support and other improvements. These customized components differ from the original Shadcn UI versions.
|
||||
|
||||
If you want to update components using the Shadcn CLI (e.g., `npx shadcn@latest add <component>`), it's generally safe for non-customized components. For the listed customized ones, you may need to manually merge changes to preserve the project's modifications and avoid overwriting RTL support or other updates.
|
||||
|
||||
> If you don't require RTL support, you can safely update the 'RTL Updated Components' via the Shadcn CLI, as these changes are primarily for RTL compatibility. The 'Modified Components' may have other customizations to consider.
|
||||
|
||||
### Modified Components
|
||||
|
||||
- scroll-area
|
||||
- sonner
|
||||
- separator
|
||||
|
||||
### RTL Updated Components
|
||||
|
||||
- alert-dialog
|
||||
- calendar
|
||||
- command
|
||||
- dialog
|
||||
- dropdown-menu
|
||||
- select
|
||||
- table
|
||||
- sheet
|
||||
- sidebar
|
||||
- switch
|
||||
|
||||
**Notes:**
|
||||
|
||||
- **Modified Components**: These have general updates, potentially including RTL adjustments.
|
||||
- **RTL Updated Components**: These have specific changes for RTL language support (e.g., layout, positioning).
|
||||
- For implementation details, check the source files in `src/components/ui/`.
|
||||
- All other Shadcn UI components in the project are standard and can be safely updated via the CLI.
|
||||
|
||||
</details>
|
||||
|
||||
## Tech Stack
|
||||
|
||||
**UI:** [ShadcnUI](https://ui.shadcn.com) (TailwindCSS + RadixUI)
|
||||
|
||||
**Build Tool:** [Vite](https://vitejs.dev/)
|
||||
|
||||
**Routing:** [TanStack Router](https://tanstack.com/router/latest)
|
||||
|
||||
**Type Checking:** [TypeScript](https://www.typescriptlang.org/)
|
||||
|
||||
**Linting/Formatting:** [ESLint](https://eslint.org/) & [Prettier](https://prettier.io/)
|
||||
|
||||
**Icons:** [Lucide Icons](https://lucide.dev/icons/), [Tabler Icons](https://tabler.io/icons) (Brand icons only)
|
||||
|
||||
**Auth (partial):** [Clerk](https://go.clerk.com/GttUAaK)
|
||||
|
||||
## Run Locally
|
||||
|
||||
Clone the project
|
||||
|
||||
```bash
|
||||
git clone https://github.com/satnaing/shadcn-admin.git
|
||||
```
|
||||
|
||||
Go to the project directory
|
||||
|
||||
```bash
|
||||
cd shadcn-admin
|
||||
```
|
||||
|
||||
Install dependencies
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Start the server
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
## Sponsoring this project ❤️
|
||||
|
||||
If you find this project helpful or use this in your own work, consider [sponsoring me](https://github.com/sponsors/satnaing) to support development and maintenance. You can [buy me a coffee](https://buymeacoffee.com/satnaing) as well. Don’t worry, every penny helps. Thank you! 🙏
|
||||
|
||||
For questions or sponsorship inquiries, feel free to reach out at [satnaingdev@gmail.com](mailto:satnaingdev@gmail.com).
|
||||
|
||||
### Current Sponsor
|
||||
|
||||
- [Clerk](https://go.clerk.com/GttUAaK) - authentication and user management for the modern web
|
||||
|
||||
## Author
|
||||
|
||||
Crafted with 🤍 by [@satnaing](https://github.com/satnaing)
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the [MIT License](https://choosealicense.com/licenses/mit/)
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="/images/favicon.svg"
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="/images/favicon_light.svg"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
href="/images/favicon.png"
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
href="/images/favicon_light.png"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>Shadcn Admin</title>
|
||||
<meta name="title" content="Shadcn Admin" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Admin Dashboard UI built with Shadcn and Vite."
|
||||
/>
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://shadcn-admin.netlify.app" />
|
||||
<meta property="og:title" content="Shadcn Admin" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Admin Dashboard UI built with Shadcn and Vite."
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://shadcn-admin.netlify.app/images/shadcn-admin.png"
|
||||
/>
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content="https://shadcn-admin.netlify.app" />
|
||||
<meta property="twitter:title" content="Shadcn Admin" />
|
||||
<meta
|
||||
property="twitter:description"
|
||||
content="Admin Dashboard UI built with Shadcn and Vite."
|
||||
/>
|
||||
<meta
|
||||
property="twitter:image"
|
||||
content="https://shadcn-admin.netlify.app/images/shadcn-admin.png"
|
||||
/>
|
||||
|
||||
<!-- font family -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Manrope:wght@200..800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<meta name="theme-color" content="#fff" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { KnipConfig } from 'knip'
|
||||
|
||||
const config: KnipConfig = {
|
||||
ignore: [
|
||||
'src/components/ui/**',
|
||||
'src/components/layout/app-title.tsx',
|
||||
'src/tanstack-table.d.ts',
|
||||
],
|
||||
}
|
||||
|
||||
export default config
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user