feat: implement third-party order sync

This commit is contained in:
2026-06-07 02:05:48 +08:00
commit aa593449bb
398 changed files with 51460 additions and 0 deletions
+56
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+76
View File
@@ -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;
}
+42
View File
@@ -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);
}
}
+52
View File
@@ -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();
});
});
+31
View File
@@ -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 {}
+213
View File
@@ -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,
})),
);
}
}
+13
View File
@@ -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');
});
});
+22
View File
@@ -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);
}
+4
View File
@@ -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');
@@ -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;
@@ -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;
@@ -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;
@@ -0,0 +1,7 @@
UPDATE roles
SET permissions = JSON_SET(
COALESCE(permissions, JSON_OBJECT()),
'$."order-logs"',
JSON_ARRAY('read', 'write')
)
WHERE code = 'super_admin';
+12
View File
@@ -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;
}
+35
View File
@@ -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);
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+25
View File
@@ -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
}
}