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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user