feat:初始化
This commit is contained in:
parent
b26bb4a639
commit
f33db22e2f
15
.env
Normal file
15
.env
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# 服务端口
|
||||||
|
PORT=3000
|
||||||
|
# MySQL数据库配置
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=root
|
||||||
|
DB_PASSWORD=123456
|
||||||
|
DB_NAME=corp_website
|
||||||
|
# JWT鉴权固定配置
|
||||||
|
JWT_SECRET=corp_website_secret_key_2026ai
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
# 文件上传目录
|
||||||
|
UPLOAD_ROOT=./uploads
|
||||||
|
# 运行环境
|
||||||
|
NODE_ENV=development
|
||||||
11
.env.example
Normal file
11
.env.example
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# 复制为 .env 并按需修改
|
||||||
|
PORT=3001
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=root
|
||||||
|
DB_PASSWORD=root
|
||||||
|
DB_NAME=corp_website
|
||||||
|
JWT_SECRET=corp_website_secret_key_2026ai
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
UPLOAD_ROOT=./uploads
|
||||||
|
NODE_ENV=development
|
||||||
108
.gitea/workflows/main.yml
Normal file
108
.gitea/workflows/main.yml
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
name: main
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
name: Build and push to Aliyun ACR
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: https://gitee.com/zsqai/checkout@v4
|
||||||
|
|
||||||
|
# 准备环境变量文件:将 .env.production 复制为 .env
|
||||||
|
- name: Prepare .env file for production
|
||||||
|
run: cp .env.production .env
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: https://gitee.com/zsqai/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Aliyun Container Registry
|
||||||
|
uses: https://gitee.com/zsqai/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.ALIYUN_REGISTRY }}
|
||||||
|
username: ${{ vars.ALIYUN_USERNAME }}
|
||||||
|
password: ${{ secrets.ALIYUN_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: https://gitee.com/zsqai/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ vars.ALIYUN_REGISTRY }}/${{ vars.ALIYUN_NAMESPACE }}/${{ vars.ALIYUN_REPO }}:latest
|
||||||
|
${{ vars.ALIYUN_REGISTRY }}/${{ vars.ALIYUN_NAMESPACE }}/${{ vars.ALIYUN_REPO }}:${{ github.sha }}
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy to server
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build-and-push
|
||||||
|
environment:
|
||||||
|
name: production
|
||||||
|
url: http://${{ vars.HOST }}:8084
|
||||||
|
steps:
|
||||||
|
- name: Deploy via SSH
|
||||||
|
uses: https://gitee.com/zsqai/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ vars.HOST }}
|
||||||
|
username: root
|
||||||
|
password: ${{ secrets.DEV_HOST_PASSWORD }}
|
||||||
|
port: 22
|
||||||
|
script_stop: true
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
echo "=== 开始部署 $(date) ==="
|
||||||
|
|
||||||
|
# 登录阿里云镜像仓库
|
||||||
|
docker login --username=${{ vars.ALIYUN_USERNAME }} \
|
||||||
|
--password=${{ secrets.ALIYUN_PASSWORD }} \
|
||||||
|
${{ vars.ALIYUN_REGISTRY }}
|
||||||
|
|
||||||
|
# 拉取最新镜像
|
||||||
|
docker pull ${{ vars.ALIYUN_REGISTRY }}/${{ vars.ALIYUN_NAMESPACE }}/${{ vars.ALIYUN_REPO }}:latest
|
||||||
|
|
||||||
|
# 停止并删除旧容器
|
||||||
|
docker stop ai-personage-api 2>/dev/null || true
|
||||||
|
docker rm ai-personage-api 2>/dev/null || true
|
||||||
|
|
||||||
|
# 启动新容器(不再需要 -e 参数,因为环境变量已打包在镜像内)
|
||||||
|
docker run -d \
|
||||||
|
--name ai-personage-api \
|
||||||
|
--restart always \
|
||||||
|
-p 8084:3002 \
|
||||||
|
${{ vars.ALIYUN_REGISTRY }}/${{ vars.ALIYUN_NAMESPACE }}/${{ vars.ALIYUN_REPO }}:latest
|
||||||
|
|
||||||
|
# 清理旧镜像
|
||||||
|
docker image prune -f
|
||||||
|
|
||||||
|
# 检查容器状态
|
||||||
|
sleep 5
|
||||||
|
if docker ps --format '{{.Names}}' | grep -q "^ai-personage-api$"; then
|
||||||
|
echo "✓ 容器启动成功"
|
||||||
|
docker logs --tail 20 ai-personage-api
|
||||||
|
else
|
||||||
|
echo "✗ 容器启动失败"
|
||||||
|
docker logs ai-personage-api
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
notify:
|
||||||
|
name: Send notification
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [build-and-push, deploy]
|
||||||
|
if: always()
|
||||||
|
steps:
|
||||||
|
- name: Deployment result
|
||||||
|
run: |
|
||||||
|
if [ "${{ needs.deploy.result }}" == "success" ]; then
|
||||||
|
echo "✓ 部署成功"
|
||||||
|
echo "分支: ${{ github.ref_name }}"
|
||||||
|
echo "提交: ${{ github.sha }}"
|
||||||
|
echo "时间: $(date)"
|
||||||
|
else
|
||||||
|
echo "✗ 部署失败,请检查 CI 最终日志"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# ---------- 系统 / 编辑器 ----------
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# ---------- Node / pnpm ----------
|
||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
pnpm-debug.log*
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnp.*
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# ---------- 构建产物 ----------
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
out/
|
||||||
|
.next/
|
||||||
|
.turbo/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# ---------- 环境变量(敏感信息不入库) ----------
|
||||||
|
# 后端主配置文件按文档要求生成 .env,但强烈建议提交时改为 .env.example
|
||||||
|
server/.env
|
||||||
|
client/.env.local
|
||||||
|
|
||||||
|
# ---------- 上传文件(运行时产物) ----------
|
||||||
|
server/uploads/
|
||||||
|
|
||||||
|
# ---------- 日志 ----------
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# ---------- 测试覆盖率 ----------
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# ---------- 缓存 ----------
|
||||||
|
.cache/
|
||||||
|
.eslintcache
|
||||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"semi": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
47
Dockerfile
Normal file
47
Dockerfile
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# 构建阶段
|
||||||
|
FROM crpi-jlsdxetsdmy4ckxh.cn-shenzhen.personal.cr.aliyuncs.com/zsq_proxy/node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 添加构建参数
|
||||||
|
ARG BUILD_VERSION
|
||||||
|
ARG BUILD_TIME
|
||||||
|
ARG CACHE_BUST
|
||||||
|
|
||||||
|
ENV BUILD_VERSION=$BUILD_VERSION
|
||||||
|
ENV BUILD_TIME=$BUILD_TIME
|
||||||
|
|
||||||
|
# 强制打破 Docker 缓存
|
||||||
|
RUN echo "Cache bust: ${CACHE_BUST} - ${BUILD_VERSION} - ${BUILD_TIME}"
|
||||||
|
|
||||||
|
# 安装 pnpm
|
||||||
|
RUN npm install -g pnpm && \
|
||||||
|
pnpm config set registry https://registry.npmmirror.com
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
# 如果项目有 pnpm-lock.yaml,取消注释下面一行
|
||||||
|
# COPY pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 强制清理 .next
|
||||||
|
RUN rm -rf .next
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# 部署阶段
|
||||||
|
FROM crpi-jlsdxetsdmy4ckxh.cn-shenzhen.personal.cr.aliyuncs.com/zsq_proxy/node:20-alpine AS app
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NODE_OPTIONS="--max-old-space-size=4096"
|
||||||
|
|
||||||
|
COPY --from=build /app/.next ./.next
|
||||||
|
COPY --from=build /app/public ./public
|
||||||
|
COPY --from=build /app/package.json ./package.json
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
EXPOSE 3003
|
||||||
|
|
||||||
|
CMD ["pnpm", "start"]
|
||||||
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
59
package.json
Normal file
59
package.json
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "corp-official-website-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "corp-official-website NestJS 10 backend",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^10.2.10",
|
||||||
|
"@nestjs/config": "^3.1.1",
|
||||||
|
"@nestjs/core": "^10.2.10",
|
||||||
|
"@nestjs/jwt": "^10.2.0",
|
||||||
|
"@nestjs/passport": "^10.0.2",
|
||||||
|
"@nestjs/platform-express": "^10.2.10",
|
||||||
|
"@nestjs/serve-static": "^4.0.0",
|
||||||
|
"@nestjs/swagger": "^7.1.16",
|
||||||
|
"@nestjs/typeorm": "^10.0.1",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.0",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"mysql2": "^3.6.5",
|
||||||
|
"nest-winston": "^1.9.4",
|
||||||
|
"passport": "^0.6.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"reflect-metadata": "^0.1.13",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"typeorm": "^0.3.17",
|
||||||
|
"winston": "^3.11.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.2.1",
|
||||||
|
"@nestjs/schematics": "^10.0.2",
|
||||||
|
"@nestjs/testing": "^10.2.10",
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/multer": "^1.4.11",
|
||||||
|
"@types/node": "^20.9.2",
|
||||||
|
"@types/passport-jwt": "^3.0.13",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
|
"eslint": "^8.54.0",
|
||||||
|
"prettier": "^3.1.0",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"ts-loader": "^9.5.1",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.2.2"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": ["bcrypt", "@nestjs/core"]
|
||||||
|
}
|
||||||
|
}
|
||||||
5175
pnpm-lock.yaml
generated
Normal file
5175
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
117
src/app.module.ts
Normal file
117
src/app.module.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||||
|
|
||||||
|
import configuration from './config/configuration';
|
||||||
|
|
||||||
|
import { JwtAuthGuard, RolesGuard } from './common/guards';
|
||||||
|
import { UploadMiddleware } from './common/middlewares';
|
||||||
|
|
||||||
|
import { Admin } from './entities/admin.entity';
|
||||||
|
import { Banner } from './entities/banner.entity';
|
||||||
|
import { Manual } from './entities/manual.entity';
|
||||||
|
import { Message } from './entities/message.entity';
|
||||||
|
import { NewsCategory } from './entities/news-category.entity';
|
||||||
|
import { News } from './entities/news.entity';
|
||||||
|
import { ProductCategory } from './entities/product-category.entity';
|
||||||
|
import { Product } from './entities/product.entity';
|
||||||
|
import { SiteConfig } from './entities/site-config.entity';
|
||||||
|
import { Team } from './entities/team.entity';
|
||||||
|
|
||||||
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
|
import { AdminUserModule } from './modules/admin-user/admin-user.module';
|
||||||
|
import { BannerModule } from './modules/banner/banner.module';
|
||||||
|
import { ManualModule } from './modules/manual/manual.module';
|
||||||
|
import { MessageModule } from './modules/message/message.module';
|
||||||
|
import { NewsCategoryModule } from './modules/news-category/news-category.module';
|
||||||
|
import { NewsModule } from './modules/news/news.module';
|
||||||
|
import { ProductCategoryModule } from './modules/product-category/product-category.module';
|
||||||
|
import { ProductModule } from './modules/product/product.module';
|
||||||
|
import { SiteConfigModule } from './modules/site-config/site-config.module';
|
||||||
|
import { TeamModule } from './modules/team/team.module';
|
||||||
|
import { UploadModule } from './modules/upload/upload.module';
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
load: [configuration],
|
||||||
|
}),
|
||||||
|
TypeOrmModule.forRootAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
type: 'mysql',
|
||||||
|
host: config.get<string>('database.host'),
|
||||||
|
port: config.get<number>('database.port'),
|
||||||
|
username: config.get<string>('database.user'),
|
||||||
|
password: config.get<string>('database.password'),
|
||||||
|
database: config.get<string>('database.name'),
|
||||||
|
autoLoadEntities: true,
|
||||||
|
synchronize: false,
|
||||||
|
timezone: '+08:00',
|
||||||
|
charset: 'utf8mb4',
|
||||||
|
logging: config.get<string>('nodeEnv') === 'development',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
global: true,
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
secret: config.get<string>('jwt.secret'),
|
||||||
|
signOptions: { expiresIn: config.get<string>('jwt.expiresIn') },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
ServeStaticModule.forRoot({
|
||||||
|
rootPath: path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
(process.env.UPLOAD_ROOT ?? './uploads').replace(/^\.\/?/, ''),
|
||||||
|
),
|
||||||
|
serveRoot: '/uploads',
|
||||||
|
serveStaticOptions: { index: false },
|
||||||
|
}),
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
Admin,
|
||||||
|
Banner,
|
||||||
|
Manual,
|
||||||
|
Message,
|
||||||
|
NewsCategory,
|
||||||
|
News,
|
||||||
|
ProductCategory,
|
||||||
|
Product,
|
||||||
|
SiteConfig,
|
||||||
|
Team,
|
||||||
|
]),
|
||||||
|
AuthModule,
|
||||||
|
AdminUserModule,
|
||||||
|
BannerModule,
|
||||||
|
ManualModule,
|
||||||
|
MessageModule,
|
||||||
|
NewsCategoryModule,
|
||||||
|
NewsModule,
|
||||||
|
ProductCategoryModule,
|
||||||
|
ProductModule,
|
||||||
|
SiteConfigModule,
|
||||||
|
TeamModule,
|
||||||
|
UploadModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
// JwtAuthGuard 依赖 JwtService + Reflector,必须用 DI 注册
|
||||||
|
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||||
|
// RolesGuard 读取 @Roles() 元数据校验角色权限
|
||||||
|
{ provide: APP_GUARD, useClass: RolesGuard },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule implements NestModule {
|
||||||
|
configure(consumer: MiddlewareConsumer): void {
|
||||||
|
// middleware 在 global prefix 应用之前注册,路径用不带前缀的内部路径
|
||||||
|
consumer.apply(UploadMiddleware).forRoutes({
|
||||||
|
path: 'admin/upload',
|
||||||
|
method: RequestMethod.POST,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/common/decorators/current-admin.decorator.ts
Normal file
22
src/common/decorators/current-admin.decorator.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前登录管理员信息(由 JwtAuthGuard 解析后挂到 req.admin)
|
||||||
|
*/
|
||||||
|
export interface CurrentAdminPayload {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
role: 'super_admin' | 'normal';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CurrentAdmin = createParamDecorator(
|
||||||
|
(data: keyof CurrentAdminPayload | undefined, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
const admin: CurrentAdminPayload | undefined = request.admin;
|
||||||
|
if (!admin) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return data ? admin[data] : admin;
|
||||||
|
},
|
||||||
|
);
|
||||||
3
src/common/decorators/index.ts
Normal file
3
src/common/decorators/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './current-admin.decorator';
|
||||||
|
export * from './public.decorator';
|
||||||
|
export * from './roles.decorator';
|
||||||
8
src/common/decorators/public.decorator.ts
Normal file
8
src/common/decorators/public.decorator.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记接口为公开(无需 JWT)。
|
||||||
|
* 与 JwtAuthGuard 配合:守卫读取 IS_PUBLIC 元数据后跳过校验。
|
||||||
|
*/
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
13
src/common/decorators/roles.decorator.ts
Normal file
13
src/common/decorators/roles.decorator.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
/** 管理员角色类型 */
|
||||||
|
export type AdminRole = 'super_admin' | 'normal';
|
||||||
|
|
||||||
|
/** 元数据 key:标记接口所需角色 */
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记接口所需的角色。配合 RolesGuard 使用:
|
||||||
|
* @Roles('super_admin') 仅超级管理员可访问
|
||||||
|
*/
|
||||||
|
export const Roles = (...roles: AdminRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
39
src/common/dto/api-response.dto.ts
Normal file
39
src/common/dto/api-response.dto.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* 统一响应体
|
||||||
|
* 成功:{ code:200, msg:'操作成功', data:{} }
|
||||||
|
* 错误:{ code:401/400/403/500, msg:'错误提示', data:null }
|
||||||
|
*/
|
||||||
|
export class ApiResponse<T> {
|
||||||
|
code: number;
|
||||||
|
msg: string;
|
||||||
|
data: T | null;
|
||||||
|
|
||||||
|
constructor(code: number, msg: string, data: T | null = null) {
|
||||||
|
this.code = code;
|
||||||
|
this.msg = msg;
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
static success<T>(data: T, msg = '操作成功'): ApiResponse<T> {
|
||||||
|
return new ApiResponse<T>(200, msg, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static error(code: number, msg: string): ApiResponse<null> {
|
||||||
|
return new ApiResponse<null>(code, msg, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页查询结果通用结构 */
|
||||||
|
export interface PaginatedResult<T> {
|
||||||
|
list: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用分页 DTO 基类 */
|
||||||
|
export class PaginationDto {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
101
src/common/filters/all-exceptions.filter.ts
Normal file
101
src/common/filters/all-exceptions.filter.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import {
|
||||||
|
ArgumentsHost,
|
||||||
|
Catch,
|
||||||
|
ExceptionFilter,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { QueryFailedError } from 'typeorm';
|
||||||
|
|
||||||
|
/** MySQL 驱动抛出的错误对象结构(部分字段) */
|
||||||
|
interface MysqlDriverError {
|
||||||
|
code?: string;
|
||||||
|
errno?: number;
|
||||||
|
sqlMessage?: string;
|
||||||
|
sqlState?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 MySQL 驱动错误码翻译为面向用户的中文提示。
|
||||||
|
* 不暴露原始 SQL / 英文消息给前端。
|
||||||
|
*/
|
||||||
|
function describeDbError(err: MysqlDriverError): string {
|
||||||
|
switch (err.code) {
|
||||||
|
case 'ER_BAD_FIELD_ERROR':
|
||||||
|
return '数据库字段缺失,请联系管理员执行最新的数据库升级脚本';
|
||||||
|
case 'ER_NO_SUCH_TABLE':
|
||||||
|
return '数据库表不存在,请联系管理员核对数据库结构';
|
||||||
|
case 'ER_DUP_ENTRY':
|
||||||
|
return '数据重复,请检查唯一字段(如登录账号)';
|
||||||
|
case 'ER_ROW_IS_REFERENCED_2':
|
||||||
|
case 'ER_ROW_IS_REFERENCED':
|
||||||
|
return '该记录被其他数据引用,无法删除或修改';
|
||||||
|
case 'ER_DATA_TOO_LONG':
|
||||||
|
return '输入内容过长,请精简后重试';
|
||||||
|
case 'ER_BAD_NULL_ERROR':
|
||||||
|
return '缺少必填字段,请补全后重试';
|
||||||
|
case 'ER_LOCK_WAIT_TIMEOUT':
|
||||||
|
case 'ER_LOCK_DEADLOCK':
|
||||||
|
return '数据库繁忙,请稍后重试';
|
||||||
|
default:
|
||||||
|
return '服务器内部错误,请稍后重试';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局异常过滤器
|
||||||
|
* 把所有异常统一包装成 { code, msg, data:null } 结构。
|
||||||
|
* 所有面向用户的提示均为中文,原始英文错误仅输出到服务端日志。
|
||||||
|
*/
|
||||||
|
@Catch()
|
||||||
|
export class AllExceptionsFilter implements ExceptionFilter {
|
||||||
|
private readonly logger = new Logger(AllExceptionsFilter.name);
|
||||||
|
|
||||||
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const response = ctx.getResponse<Response>();
|
||||||
|
const request = ctx.getRequest<Request>();
|
||||||
|
|
||||||
|
const isHttp = exception instanceof HttpException;
|
||||||
|
const status = isHttp
|
||||||
|
? exception.getStatus()
|
||||||
|
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
|
||||||
|
let message = '服务器内部错误';
|
||||||
|
if (isHttp) {
|
||||||
|
const res = exception.getResponse();
|
||||||
|
if (typeof res === 'string') {
|
||||||
|
message = res;
|
||||||
|
} else if (typeof res === 'object' && res !== null) {
|
||||||
|
const r = res as { message?: unknown };
|
||||||
|
message = Array.isArray(r.message)
|
||||||
|
? r.message.join(';')
|
||||||
|
: String(r.message ?? message);
|
||||||
|
}
|
||||||
|
} else if (exception instanceof QueryFailedError) {
|
||||||
|
// TypeORM 查询失败:底层 MySQL 错误统一翻译为中文
|
||||||
|
const driverErr = (
|
||||||
|
exception as { driverError?: MysqlDriverError }
|
||||||
|
).driverError;
|
||||||
|
message = describeDbError(
|
||||||
|
driverErr ?? (exception as unknown as MysqlDriverError),
|
||||||
|
);
|
||||||
|
this.logger.error(exception.stack);
|
||||||
|
} else if (exception instanceof Error) {
|
||||||
|
// 其他未识别错误:不直接暴露英文 message,统一中文兜底
|
||||||
|
message = '服务器内部错误,请稍后重试';
|
||||||
|
this.logger.error(exception.stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = status;
|
||||||
|
response.status(status).json({
|
||||||
|
code,
|
||||||
|
msg: message,
|
||||||
|
data: null,
|
||||||
|
path: request.url,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/common/filters/index.ts
Normal file
1
src/common/filters/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './all-exceptions.filter';
|
||||||
2
src/common/guards/index.ts
Normal file
2
src/common/guards/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './jwt-auth.guard';
|
||||||
|
export * from './roles.guard';
|
||||||
65
src/common/guards/jwt-auth.guard.ts
Normal file
65
src/common/guards/jwt-auth.guard.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||||
|
|
||||||
|
const DEFAULT_JWT_SECRET = 'corp_website_secret_key_2026ai';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT 全局守卫
|
||||||
|
* - 通过 @Public() 装饰的接口跳过校验(前台公开接口)
|
||||||
|
* - 其余接口要求请求头携带 Authorization: Bearer <token>
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (isPublic) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
const token = this.extractToken(request);
|
||||||
|
if (!token) {
|
||||||
|
throw new UnauthorizedException('未登录或登录已过期');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await this.jwtService.verifyAsync<{
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
role: 'super_admin' | 'normal';
|
||||||
|
}>(token, {
|
||||||
|
secret: process.env.JWT_SECRET ?? DEFAULT_JWT_SECRET,
|
||||||
|
});
|
||||||
|
request.admin = payload;
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('登录凭证无效');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractToken(request: Request): string | null {
|
||||||
|
const authHeader = request.headers.authorization ?? '';
|
||||||
|
const [type, token] = authHeader.split(' ');
|
||||||
|
if (type === 'Bearer' && token) {
|
||||||
|
return token.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/common/guards/roles.guard.ts
Normal file
35
src/common/guards/roles.guard.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ROLES_KEY, type AdminRole } from '../decorators/roles.decorator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色守卫(全局注册,由 @Roles() 元数据驱动)
|
||||||
|
* - 未声明 @Roles() 的接口:放行(仅需登录)
|
||||||
|
* - 声明 @Roles('super_admin') 的接口:要求 request.admin.role === 'super_admin'
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(private readonly reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const required = this.reflector.getAllAndOverride<AdminRole[] | undefined>(
|
||||||
|
ROLES_KEY,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
if (!required || required.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const role: AdminRole | undefined = request.admin?.role;
|
||||||
|
if (!role || !required.includes(role)) {
|
||||||
|
throw new ForbiddenException('权限不足,需要超级管理员账号');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/common/interceptors/index.ts
Normal file
1
src/common/interceptors/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './transform.interceptor';
|
||||||
33
src/common/interceptors/transform.interceptor.ts
Normal file
33
src/common/interceptors/transform.interceptor.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import {
|
||||||
|
CallHandler,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
import { ApiResponse } from '../dto/api-response.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局统一响应拦截器
|
||||||
|
* 把所有 controller 返回值包装成 { code, msg, data } 结构
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class TransformInterceptor<T>
|
||||||
|
implements NestInterceptor<T, ApiResponse<T>>
|
||||||
|
{
|
||||||
|
intercept(
|
||||||
|
context: ExecutionContext,
|
||||||
|
next: CallHandler<T>,
|
||||||
|
): Observable<ApiResponse<T>> {
|
||||||
|
return next.handle().pipe(
|
||||||
|
map((data) => {
|
||||||
|
// 如果已经是 ApiResponse,则原样返回
|
||||||
|
if (data instanceof ApiResponse) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
return ApiResponse.success(data) as ApiResponse<T>;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/common/middlewares/index.ts
Normal file
1
src/common/middlewares/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './upload.middleware';
|
||||||
63
src/common/middlewares/upload.middleware.ts
Normal file
63
src/common/middlewares/upload.middleware.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NestMiddleware,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import multer, { Multer } from 'multer';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const ALLOWED_MIME = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
const MAX_SIZE = 2 * 1024 * 1024; // 2M
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件上传中间件(按日期分文件夹)
|
||||||
|
* 单图字段名固定:file
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class UploadMiddleware implements NestMiddleware {
|
||||||
|
private readonly upload: Multer;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const root = process.env.UPLOAD_ROOT ?? './uploads';
|
||||||
|
this.upload = multer({
|
||||||
|
storage: multer.diskStorage({
|
||||||
|
destination: (_req, _file, cb) => {
|
||||||
|
const date = new Date();
|
||||||
|
const dir = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
root.replace(/^\.\/?/, ''),
|
||||||
|
`${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}`,
|
||||||
|
);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
cb(null, dir);
|
||||||
|
},
|
||||||
|
filename: (_req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
|
const name = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`;
|
||||||
|
cb(null, name);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
if (ALLOWED_MIME.includes(file.mimetype)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
// FileFilterCallback 重载:错误时只传 Error,不传 acceptFile
|
||||||
|
cb(new Error('仅支持 jpg/png/jpeg/webp 格式图片'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
limits: { fileSize: MAX_SIZE },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
use(req: Request, _res: Response, next: NextFunction): void {
|
||||||
|
this.upload.single('file')(req, _res, (err) => {
|
||||||
|
if (err) {
|
||||||
|
next(new BadRequestException(err.message ?? '文件上传失败'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/config/configuration.ts
Normal file
47
src/config/configuration.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* 全局环境变量解析
|
||||||
|
* 所有配置项从这里统一读取,禁止在业务代码中直接 process.env
|
||||||
|
*/
|
||||||
|
export interface AppConfig {
|
||||||
|
/** 服务端口 */
|
||||||
|
port: number;
|
||||||
|
/** 运行环境 */
|
||||||
|
nodeEnv: string;
|
||||||
|
/** 文件上传根目录 */
|
||||||
|
uploadRoot: string;
|
||||||
|
/** 数据库配置 */
|
||||||
|
database: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
/** JWT 配置 */
|
||||||
|
jwt: {
|
||||||
|
secret: string;
|
||||||
|
expiresIn: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (): AppConfig => {
|
||||||
|
const port = parseInt(process.env.PORT ?? '3001', 10);
|
||||||
|
const dbPort = parseInt(process.env.DB_PORT ?? '3306', 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
port: Number.isNaN(port) ? 3001 : port,
|
||||||
|
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||||
|
uploadRoot: process.env.UPLOAD_ROOT ?? './uploads',
|
||||||
|
database: {
|
||||||
|
host: process.env.DB_HOST ?? '127.0.0.1',
|
||||||
|
port: Number.isNaN(dbPort) ? 3306 : dbPort,
|
||||||
|
user: process.env.DB_USER ?? 'root',
|
||||||
|
password: process.env.DB_PASSWORD ?? '',
|
||||||
|
name: process.env.DB_NAME ?? 'corp_website',
|
||||||
|
},
|
||||||
|
jwt: {
|
||||||
|
secret: process.env.JWT_SECRET ?? 'corp_website_secret_key_2026ai',
|
||||||
|
expiresIn: process.env.JWT_EXPIRES_IN ?? '7d',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
22
src/config/swagger.config.ts
Normal file
22
src/config/swagger.config.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swagger 全局配置
|
||||||
|
* 访问地址:http://localhost:3001/api-docs
|
||||||
|
*/
|
||||||
|
export const swaggerOptions = new DocumentBuilder()
|
||||||
|
.setTitle('企业官方网站 接口文档')
|
||||||
|
.setDescription('Next14 + Nest10 + JWT + MySQL8 全栈项目后端 API')
|
||||||
|
.setVersion('1.0.0')
|
||||||
|
.addBearerAuth(
|
||||||
|
{
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
bearerFormat: 'JWT',
|
||||||
|
name: 'Authorization',
|
||||||
|
description: '请输入 JWT Token,格式:Bearer xxxxx',
|
||||||
|
in: 'header',
|
||||||
|
},
|
||||||
|
'admin-token',
|
||||||
|
)
|
||||||
|
.build();
|
||||||
43
src/entities/admin.entity.ts
Normal file
43
src/entities/admin.entity.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台管理员表 admin
|
||||||
|
*/
|
||||||
|
@Entity('admin')
|
||||||
|
export class Admin {
|
||||||
|
/** 主键ID */
|
||||||
|
@PrimaryGeneratedColumn({ type: 'int', unsigned: true })
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
/** 登录账号 */
|
||||||
|
@Column({ type: 'varchar', length: 50, comment: '登录账号' })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
/** 加密密码(bcrypt) */
|
||||||
|
@Column({ type: 'varchar', length: 100, comment: '加密密码', select: false })
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
/** 管理员名称 */
|
||||||
|
@Column({ type: 'varchar', length: 50, comment: '管理员名称' })
|
||||||
|
nickname: string;
|
||||||
|
|
||||||
|
/** 头像地址 */
|
||||||
|
@Column({ type: 'varchar', length: 255, default: '', comment: '头像地址' })
|
||||||
|
avatar: string;
|
||||||
|
|
||||||
|
/** 角色:super_admin 超级管理员 / normal 普通管理员 */
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'normal', comment: '角色' })
|
||||||
|
role: 'super_admin' | 'normal';
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'datetime' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
42
src/entities/banner.entity.ts
Normal file
42
src/entities/banner.entity.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页轮播图 banner
|
||||||
|
*/
|
||||||
|
@Entity('banner')
|
||||||
|
export class Banner {
|
||||||
|
@PrimaryGeneratedColumn({ type: 'int', unsigned: true })
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
/** 轮播标题 */
|
||||||
|
@Column({ type: 'varchar', length: 100, comment: '轮播标题' })
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
/** 图片地址 */
|
||||||
|
@Column({ type: 'varchar', length: 255, comment: '图片地址' })
|
||||||
|
image: string;
|
||||||
|
|
||||||
|
/** 跳转链接 */
|
||||||
|
@Column({ type: 'varchar', length: 255, default: '', comment: '跳转链接' })
|
||||||
|
link: string;
|
||||||
|
|
||||||
|
/** 排序值(越小越靠前) */
|
||||||
|
@Column({ type: 'int', default: 0, comment: '排序值' })
|
||||||
|
sort: number;
|
||||||
|
|
||||||
|
/** 是否展示 1是 0否 */
|
||||||
|
@Column({ type: 'tinyint', name: 'is_show', default: 1, comment: '是否展示 1是0否' })
|
||||||
|
isShow: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'datetime' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
9
src/entities/index.ts
Normal file
9
src/entities/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export * from './admin.entity';
|
||||||
|
export * from './banner.entity';
|
||||||
|
export * from './message.entity';
|
||||||
|
export * from './news-category.entity';
|
||||||
|
export * from './news.entity';
|
||||||
|
export * from './product-category.entity';
|
||||||
|
export * from './product.entity';
|
||||||
|
export * from './site-config.entity';
|
||||||
|
export * from './team.entity';
|
||||||
78
src/entities/manual.entity.ts
Normal file
78
src/entities/manual.entity.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用手册表 manual
|
||||||
|
* - 自引用 parentId 实现多级树形结构
|
||||||
|
* - type=0 为目录节点(无 content),type=1 为文档节点(有 content)
|
||||||
|
*/
|
||||||
|
@Entity('manual')
|
||||||
|
export class Manual {
|
||||||
|
/** 主键ID */
|
||||||
|
@PrimaryGeneratedColumn({ type: 'int', unsigned: true })
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
/** 父节点ID,NULL 表示根节点 */
|
||||||
|
@Column({
|
||||||
|
name: 'parent_id',
|
||||||
|
type: 'int',
|
||||||
|
unsigned: true,
|
||||||
|
nullable: true,
|
||||||
|
comment: '父节点ID,NULL=根节点',
|
||||||
|
})
|
||||||
|
parentId: number | null;
|
||||||
|
|
||||||
|
/** 标题 */
|
||||||
|
@Column({ type: 'varchar', length: 200, comment: '标题' })
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
/** 节点类型:0=目录 1=文档 */
|
||||||
|
@Column({
|
||||||
|
type: 'tinyint',
|
||||||
|
default: 1,
|
||||||
|
comment: '类型:0=目录节点 1=文档节点',
|
||||||
|
})
|
||||||
|
type: number;
|
||||||
|
|
||||||
|
/** 文档正文(富文本 HTML 或 Markdown;目录节点为 null) */
|
||||||
|
@Column({
|
||||||
|
type: 'longtext',
|
||||||
|
nullable: true,
|
||||||
|
comment: '文档正文(目录节点为空)',
|
||||||
|
})
|
||||||
|
content: string | null;
|
||||||
|
|
||||||
|
/** 正文格式:html=富文本 markdown=Markdown */
|
||||||
|
@Column({
|
||||||
|
name: 'content_format',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 10,
|
||||||
|
default: 'html',
|
||||||
|
comment: '正文格式:html=富文本 markdown=Markdown',
|
||||||
|
})
|
||||||
|
contentFormat: 'html' | 'markdown';
|
||||||
|
|
||||||
|
/** 排序值,越小越靠前 */
|
||||||
|
@Column({ type: 'int', default: 0, comment: '排序值' })
|
||||||
|
sort: number;
|
||||||
|
|
||||||
|
/** 是否显示:1显示 0隐藏 */
|
||||||
|
@Column({
|
||||||
|
name: 'is_show',
|
||||||
|
type: 'tinyint',
|
||||||
|
default: 1,
|
||||||
|
comment: '是否显示 1是0否',
|
||||||
|
})
|
||||||
|
isShow: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'datetime' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
38
src/entities/message.entity.ts
Normal file
38
src/entities/message.entity.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户留言 message
|
||||||
|
*/
|
||||||
|
@Entity('message')
|
||||||
|
export class Message {
|
||||||
|
@PrimaryGeneratedColumn({ type: 'int', unsigned: true })
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
/** 访客姓名 */
|
||||||
|
@Column({ type: 'varchar', length: 50, comment: '访客姓名' })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/** 联系电话 */
|
||||||
|
@Column({ type: 'varchar', length: 20, comment: '联系电话' })
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
/** 邮箱 */
|
||||||
|
@Column({ type: 'varchar', length: 100, default: '', comment: '邮箱' })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
/** 留言内容 */
|
||||||
|
@Column({ type: 'text', comment: '留言内容' })
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
/** 0未读 1已读 */
|
||||||
|
@Column({ type: 'tinyint', name: 'is_read', default: 0, comment: '0未读1已读' })
|
||||||
|
isRead: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
31
src/entities/news-category.entity.ts
Normal file
31
src/entities/news-category.entity.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新闻分类 news_category
|
||||||
|
*/
|
||||||
|
@Entity('news_category')
|
||||||
|
export class NewsCategory {
|
||||||
|
@PrimaryGeneratedColumn({ type: 'int', unsigned: true })
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, comment: '分类名称' })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 0, comment: '排序' })
|
||||||
|
sort: number;
|
||||||
|
|
||||||
|
@Column({ type: 'tinyint', name: 'is_show', default: 1 })
|
||||||
|
isShow: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'datetime' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
58
src/entities/news.entity.ts
Normal file
58
src/entities/news.entity.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { NewsCategory } from './news-category.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新闻资讯 news
|
||||||
|
*/
|
||||||
|
@Entity('news')
|
||||||
|
@Index('idx_category', ['categoryId'])
|
||||||
|
export class News {
|
||||||
|
@PrimaryGeneratedColumn({ type: 'int', unsigned: true })
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int', unsigned: true, name: 'category_id' })
|
||||||
|
categoryId: number;
|
||||||
|
|
||||||
|
@ManyToOne(() => NewsCategory)
|
||||||
|
@JoinColumn({ name: 'category_id', referencedColumnName: 'id' })
|
||||||
|
category?: NewsCategory;
|
||||||
|
|
||||||
|
/** 新闻标题 */
|
||||||
|
@Column({ type: 'varchar', length: 200, comment: '新闻标题' })
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
/** 封面图 */
|
||||||
|
@Column({ type: 'varchar', length: 255, default: '', comment: '封面图' })
|
||||||
|
cover: string;
|
||||||
|
|
||||||
|
/** 简介 */
|
||||||
|
@Column({ type: 'varchar', length: 500, default: '', comment: '简介' })
|
||||||
|
intro: string;
|
||||||
|
|
||||||
|
/** 新闻正文富文本 */
|
||||||
|
@Column({ type: 'longtext', comment: '新闻正文富文本' })
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
/** 是否置顶 */
|
||||||
|
@Column({ type: 'tinyint', name: 'is_top', default: 0, comment: '是否置顶' })
|
||||||
|
isTop: number;
|
||||||
|
|
||||||
|
/** 状态:1发布 0草稿 */
|
||||||
|
@Column({ type: 'tinyint', default: 1, comment: '1发布 0草稿' })
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'datetime' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
34
src/entities/product-category.entity.ts
Normal file
34
src/entities/product-category.entity.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 产品分类 product_category
|
||||||
|
*/
|
||||||
|
@Entity('product_category')
|
||||||
|
export class ProductCategory {
|
||||||
|
@PrimaryGeneratedColumn({ type: 'int', unsigned: true })
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
/** 分类名称 */
|
||||||
|
@Column({ type: 'varchar', length: 100, comment: '分类名称' })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/** 排序值 */
|
||||||
|
@Column({ type: 'int', default: 0, comment: '排序' })
|
||||||
|
sort: number;
|
||||||
|
|
||||||
|
/** 是否展示 */
|
||||||
|
@Column({ type: 'tinyint', name: 'is_show', default: 1 })
|
||||||
|
isShow: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'datetime' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
60
src/entities/product.entity.ts
Normal file
60
src/entities/product.entity.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { ProductCategory } from './product-category.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 产品 product
|
||||||
|
*/
|
||||||
|
@Entity('product')
|
||||||
|
@Index('idx_category', ['categoryId'])
|
||||||
|
export class Product {
|
||||||
|
@PrimaryGeneratedColumn({ type: 'int', unsigned: true })
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
/** 分类ID */
|
||||||
|
@Column({ type: 'int', unsigned: true, name: 'category_id', comment: '分类ID' })
|
||||||
|
categoryId: number;
|
||||||
|
|
||||||
|
/** 所属分类(仅用于查询关联,不映射物理外键) */
|
||||||
|
@ManyToOne(() => ProductCategory)
|
||||||
|
@JoinColumn({ name: 'category_id', referencedColumnName: 'id' })
|
||||||
|
category?: ProductCategory;
|
||||||
|
|
||||||
|
/** 产品名称 */
|
||||||
|
@Column({ type: 'varchar', length: 100, comment: '产品名称' })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/** 封面图 */
|
||||||
|
@Column({ type: 'varchar', length: 255, comment: '封面图' })
|
||||||
|
cover: string;
|
||||||
|
|
||||||
|
/** 简短描述 */
|
||||||
|
@Column({ type: 'text', nullable: true, comment: '简短描述' })
|
||||||
|
desc: string | null;
|
||||||
|
|
||||||
|
/** 详情富文本 */
|
||||||
|
@Column({ type: 'longtext', nullable: true, comment: '详情富文本' })
|
||||||
|
content: string | null;
|
||||||
|
|
||||||
|
/** 排序值 */
|
||||||
|
@Column({ type: 'int', default: 0 })
|
||||||
|
sort: number;
|
||||||
|
|
||||||
|
/** 上下架 1上架 0下架 */
|
||||||
|
@Column({ type: 'tinyint', name: 'is_show', default: 1, comment: '上下架' })
|
||||||
|
isShow: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'datetime' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
58
src/entities/site-config.entity.ts
Normal file
58
src/entities/site-config.entity.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 网站基础配置 site_config(仅单条数据 id=1)
|
||||||
|
*/
|
||||||
|
@Entity('site_config')
|
||||||
|
export class SiteConfig {
|
||||||
|
@PrimaryGeneratedColumn({ type: 'int', unsigned: true })
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
/** 网站名称 */
|
||||||
|
@Column({ name: 'site_name', type: 'varchar', length: 100, comment: '网站名称' })
|
||||||
|
siteName: string;
|
||||||
|
|
||||||
|
/** logo 图片 */
|
||||||
|
@Column({ type: 'varchar', length: 255, default: '', comment: 'logo图片' })
|
||||||
|
logo: string;
|
||||||
|
|
||||||
|
/** 联系电话 */
|
||||||
|
@Column({ type: 'varchar', length: 50, default: '', comment: '联系电话' })
|
||||||
|
tel: string;
|
||||||
|
|
||||||
|
/** 公司地址 */
|
||||||
|
@Column({ type: 'varchar', length: 255, default: '', comment: '公司地址' })
|
||||||
|
address: string;
|
||||||
|
|
||||||
|
/** 商务邮箱 */
|
||||||
|
@Column({ type: 'varchar', length: 100, default: '', comment: '商务邮箱' })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
/** 底部版权 */
|
||||||
|
@Column({ type: 'varchar', length: 255, default: '', comment: '底部版权' })
|
||||||
|
copyright: string;
|
||||||
|
|
||||||
|
/** 备案号 */
|
||||||
|
@Column({ type: 'varchar', length: 100, default: '', comment: '备案号' })
|
||||||
|
icp: string;
|
||||||
|
|
||||||
|
/** 企业简介标题 */
|
||||||
|
@Column({ name: 'about_title', type: 'varchar', length: 100, default: '', comment: '企业简介标题' })
|
||||||
|
aboutTitle: string;
|
||||||
|
|
||||||
|
/** 企业简介正文 */
|
||||||
|
@Column({ name: 'about_content', type: 'longtext', nullable: true, comment: '企业简介正文' })
|
||||||
|
aboutContent: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'datetime' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
44
src/entities/team.entity.ts
Normal file
44
src/entities/team.entity.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 团队成员 team
|
||||||
|
*/
|
||||||
|
@Entity('team')
|
||||||
|
export class Team {
|
||||||
|
@PrimaryGeneratedColumn({ type: 'int', unsigned: true })
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
/** 姓名 */
|
||||||
|
@Column({ type: 'varchar', length: 50, comment: '姓名' })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/** 职位 */
|
||||||
|
@Column({ type: 'varchar', length: 100, comment: '职位' })
|
||||||
|
position: string;
|
||||||
|
|
||||||
|
/** 头像 */
|
||||||
|
@Column({ type: 'varchar', length: 255, comment: '头像' })
|
||||||
|
avatar: string;
|
||||||
|
|
||||||
|
/** 个人简介 */
|
||||||
|
@Column({ type: 'text', nullable: true, comment: '个人简介' })
|
||||||
|
desc: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 0 })
|
||||||
|
sort: number;
|
||||||
|
|
||||||
|
@Column({ type: 'tinyint', name: 'is_show', default: 1 })
|
||||||
|
isShow: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'datetime' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
167
src/main.ts
Normal file
167
src/main.ts
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import { ValidationPipe, Logger, type INestApplication } from '@nestjs/common';
|
||||||
|
import { SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import { WinstonModule } from 'nest-winston';
|
||||||
|
import * as winston from 'winston';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { swaggerOptions } from './config/swagger.config';
|
||||||
|
import { AllExceptionsFilter } from './common/filters';
|
||||||
|
import { TransformInterceptor } from './common/interceptors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动期自动升级:确保 admin 表存在 role 字段(角色权限控制所需)。
|
||||||
|
* - 通过 information_schema 检测,缺失时自动 ALTER TABLE 添加
|
||||||
|
* - 默认 'normal',并将 id=1 的初始管理员置为 'super_admin'
|
||||||
|
* - 已有 role 字段时跳过,幂等
|
||||||
|
*/
|
||||||
|
async function ensureAdminRoleColumn(app: INestApplication): Promise<void> {
|
||||||
|
const dataSource = app.get(DataSource);
|
||||||
|
const rows = (await dataSource.query(
|
||||||
|
`SELECT COUNT(*) AS cnt
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'admin'
|
||||||
|
AND COLUMN_NAME = 'role'`,
|
||||||
|
)) as Array<{ cnt: number | string }>;
|
||||||
|
const exists = Number(rows?.[0]?.cnt ?? 0) > 0;
|
||||||
|
if (exists) return;
|
||||||
|
|
||||||
|
Logger.warn('检测到 admin 表缺少 role 字段,正在自动升级数据库...', 'Migration');
|
||||||
|
await dataSource.query(
|
||||||
|
"ALTER TABLE `admin` ADD COLUMN `role` varchar(20) NOT NULL DEFAULT 'normal' " +
|
||||||
|
"COMMENT '角色:super_admin 超级管理员 / normal 普通管理员' AFTER `avatar`",
|
||||||
|
);
|
||||||
|
await dataSource.query(
|
||||||
|
"UPDATE `admin` SET `role` = 'super_admin' WHERE `id` = 1",
|
||||||
|
);
|
||||||
|
Logger.log('admin 表 role 字段已自动升级完成,初始 admin 已置为超级管理员', 'Migration');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动期自动升级:确保 manual 表存在(使用手册模块)。
|
||||||
|
* - 表不存在时自动 CREATE TABLE
|
||||||
|
* - 已存在则跳过,幂等
|
||||||
|
*/
|
||||||
|
async function ensureManualTable(app: INestApplication): Promise<void> {
|
||||||
|
const dataSource = app.get(DataSource);
|
||||||
|
const rows = (await dataSource.query(
|
||||||
|
`SELECT COUNT(*) AS cnt
|
||||||
|
FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'manual'`,
|
||||||
|
)) as Array<{ cnt: number | string }>;
|
||||||
|
const exists = Number(rows?.[0]?.cnt ?? 0) > 0;
|
||||||
|
if (exists) return;
|
||||||
|
|
||||||
|
Logger.warn('检测到 manual 表不存在,正在自动创建...', 'Migration');
|
||||||
|
await dataSource.query(
|
||||||
|
`CREATE TABLE \`manual\` (
|
||||||
|
\`id\` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
\`parent_id\` int unsigned DEFAULT NULL COMMENT '父节点ID,NULL=根节点',
|
||||||
|
\`title\` varchar(200) NOT NULL COMMENT '标题',
|
||||||
|
\`type\` tinyint(1) NOT NULL DEFAULT 1 COMMENT '类型:0=目录节点 1=文档节点',
|
||||||
|
\`content\` longtext COMMENT '文档正文(目录节点为空)',
|
||||||
|
\`content_format\` varchar(10) NOT NULL DEFAULT 'html' COMMENT '正文格式:html=富文本 markdown=Markdown',
|
||||||
|
\`sort\` int NOT NULL DEFAULT 0 COMMENT '排序值',
|
||||||
|
\`is_show\` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否显示 1是0否',
|
||||||
|
\`created_at\` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
\`updated_at\` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (\`id\`),
|
||||||
|
KEY \`idx_parent\` (\`parent_id\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='使用手册'`,
|
||||||
|
);
|
||||||
|
Logger.log('manual 表已自动创建完成', 'Migration');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动期自动升级:确保 manual 表存在 content_format 字段(持久化正文格式)。
|
||||||
|
* - 仅在 manual 表已存在但缺少该字段时执行
|
||||||
|
* - 幂等
|
||||||
|
*/
|
||||||
|
async function ensureManualContentFormatColumn(
|
||||||
|
app: INestApplication,
|
||||||
|
): Promise<void> {
|
||||||
|
const dataSource = app.get(DataSource);
|
||||||
|
const rows = (await dataSource.query(
|
||||||
|
`SELECT COUNT(*) AS cnt
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'manual'
|
||||||
|
AND COLUMN_NAME = 'content_format'`,
|
||||||
|
)) as Array<{ cnt: number | string }>;
|
||||||
|
const exists = Number(rows?.[0]?.cnt ?? 0) > 0;
|
||||||
|
if (exists) return;
|
||||||
|
|
||||||
|
Logger.warn(
|
||||||
|
'检测到 manual 表缺少 content_format 字段,正在自动升级数据库...',
|
||||||
|
'Migration',
|
||||||
|
);
|
||||||
|
await dataSource.query(
|
||||||
|
"ALTER TABLE `manual` ADD COLUMN `content_format` varchar(10) NOT NULL DEFAULT 'html' " +
|
||||||
|
"COMMENT '正文格式:html=富文本 markdown=Markdown' AFTER `content`",
|
||||||
|
);
|
||||||
|
Logger.log(
|
||||||
|
'manual 表 content_format 字段已自动升级完成',
|
||||||
|
'Migration',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap(): Promise<void> {
|
||||||
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||||
|
logger: WinstonModule.createLogger({
|
||||||
|
transports: [
|
||||||
|
new winston.transports.Console({
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.timestamp(),
|
||||||
|
winston.format.colorize(),
|
||||||
|
winston.format.printf(({ level, message, timestamp }) => {
|
||||||
|
return `${timestamp} ${level}: ${message}`;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
|
||||||
|
// 全局管道(DTO + class-validator)
|
||||||
|
// 注意:app.module.ts 的 APP_PIPE/APP_FILTER/APP_INTERCEPTOR 已删除,
|
||||||
|
// 这里统一注册避免重复执行;APP_GUARD(JwtAuthGuard 需要 DI)保留在 app.module.ts。
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
transform: true,
|
||||||
|
forbidNonWhitelisted: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 全局过滤器 + 响应拦截器
|
||||||
|
app.useGlobalFilters(new AllExceptionsFilter());
|
||||||
|
app.useGlobalInterceptors(new TransformInterceptor());
|
||||||
|
|
||||||
|
// 跨域放行(前端 3000 -> 后端 3001)
|
||||||
|
app.enableCors({
|
||||||
|
origin: true,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Swagger 文档
|
||||||
|
const document = SwaggerModule.createDocument(app, swaggerOptions);
|
||||||
|
SwaggerModule.setup('api-docs', app, document);
|
||||||
|
|
||||||
|
// 启动期自动迁移:补齐 admin.role 字段、确保 manual 表存在
|
||||||
|
await ensureAdminRoleColumn(app);
|
||||||
|
await ensureManualTable(app);
|
||||||
|
await ensureManualContentFormatColumn(app);
|
||||||
|
|
||||||
|
const port = parseInt(process.env.PORT ?? '3001', 10);
|
||||||
|
await app.listen(port);
|
||||||
|
Logger.log(`🚀 后端服务已启动: http://localhost:${port}`, 'Bootstrap');
|
||||||
|
Logger.log(`📘 Swagger 文档地址: http://localhost:${port}/api-docs`, 'Bootstrap');
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
87
src/modules/admin-user/admin-user.controller.ts
Normal file
87
src/modules/admin-user/admin-user.controller.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { PaginatedResult } from '@/common/dto/api-response.dto';
|
||||||
|
import { CurrentAdmin, Roles } from '@/common/decorators';
|
||||||
|
import { Admin } from '@/entities/admin.entity';
|
||||||
|
import { AdminUserService } from './admin-user.service';
|
||||||
|
import {
|
||||||
|
CreateAdminUserDto,
|
||||||
|
QueryAdminUserDto,
|
||||||
|
ResetPasswordDto,
|
||||||
|
UpdateAdminUserDto,
|
||||||
|
} from './dto/admin-user.dto';
|
||||||
|
|
||||||
|
@ApiTags('管理员账号 AdminUser')
|
||||||
|
@Controller()
|
||||||
|
export class AdminUserController {
|
||||||
|
constructor(private readonly service: AdminUserService) {}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Get('admin/admin-user')
|
||||||
|
@ApiOperation({ summary: '后台-管理员账号分页' })
|
||||||
|
paginate(
|
||||||
|
@Query() query: QueryAdminUserDto,
|
||||||
|
): Promise<PaginatedResult<Admin>> {
|
||||||
|
return this.service.paginate(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Get('admin/admin-user/:id')
|
||||||
|
@ApiOperation({ summary: '后台-管理员账号详情' })
|
||||||
|
detail(@Param('id', ParseIntPipe) id: number): Promise<Admin> {
|
||||||
|
return this.service.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Post('admin/admin-user')
|
||||||
|
@Roles('super_admin')
|
||||||
|
@ApiOperation({ summary: '后台-新增管理员账号(仅超管)' })
|
||||||
|
create(@Body() dto: CreateAdminUserDto): Promise<Admin> {
|
||||||
|
return this.service.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Put('admin/admin-user/:id')
|
||||||
|
@Roles('super_admin')
|
||||||
|
@ApiOperation({ summary: '后台-修改管理员账号(名称/头像/角色,仅超管)' })
|
||||||
|
update(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() dto: UpdateAdminUserDto,
|
||||||
|
@CurrentAdmin('id') currentAdminId: number,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.service.update(id, dto, currentAdminId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Put('admin/admin-user/:id/password')
|
||||||
|
@Roles('super_admin')
|
||||||
|
@ApiOperation({ summary: '后台-重置管理员密码(仅超管)' })
|
||||||
|
resetPassword(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() dto: ResetPasswordDto,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.service.resetPassword(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Delete('admin/admin-user/:id')
|
||||||
|
@Roles('super_admin')
|
||||||
|
@ApiOperation({ summary: '后台-删除管理员账号(不能删自己,仅超管)' })
|
||||||
|
remove(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@CurrentAdmin('id') currentAdminId: number,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.service.remove(id, currentAdminId);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/modules/admin-user/admin-user.module.ts
Normal file
12
src/modules/admin-user/admin-user.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Admin } from '@/entities/admin.entity';
|
||||||
|
import { AdminUserController } from './admin-user.controller';
|
||||||
|
import { AdminUserService } from './admin-user.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Admin])],
|
||||||
|
controllers: [AdminUserController],
|
||||||
|
providers: [AdminUserService],
|
||||||
|
})
|
||||||
|
export class AdminUserModule {}
|
||||||
107
src/modules/admin-user/admin-user.service.ts
Normal file
107
src/modules/admin-user/admin-user.service.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { Admin } from '@/entities/admin.entity';
|
||||||
|
import { PaginatedResult } from '@/common/dto/api-response.dto';
|
||||||
|
import type { AdminRole } from '@/common/decorators';
|
||||||
|
import { hashPassword } from '@/utils/crypto.util';
|
||||||
|
import {
|
||||||
|
CreateAdminUserDto,
|
||||||
|
QueryAdminUserDto,
|
||||||
|
ResetPasswordDto,
|
||||||
|
UpdateAdminUserDto,
|
||||||
|
} from './dto/admin-user.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminUserService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Admin)
|
||||||
|
private readonly repo: Repository<Admin>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async paginate(query: QueryAdminUserDto): Promise<PaginatedResult<Admin>> {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 10;
|
||||||
|
const qb = this.repo.createQueryBuilder('a').orderBy('a.id', 'DESC');
|
||||||
|
if (query.keyword) {
|
||||||
|
qb.andWhere('(a.username LIKE :kw OR a.nickname LIKE :kw)', {
|
||||||
|
kw: `%${query.keyword}%`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const [list, total] = await qb
|
||||||
|
.skip((page - 1) * pageSize)
|
||||||
|
.take(pageSize)
|
||||||
|
.getManyAndCount();
|
||||||
|
return { list, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(id: number): Promise<Admin> {
|
||||||
|
const item = await this.repo.findOne({ where: { id } });
|
||||||
|
if (!item) throw new NotFoundException('管理员不存在');
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateAdminUserDto): Promise<Admin> {
|
||||||
|
const exists = await this.repo.findOne({
|
||||||
|
where: { username: dto.username },
|
||||||
|
});
|
||||||
|
if (exists) {
|
||||||
|
throw new ConflictException('登录账号已存在');
|
||||||
|
}
|
||||||
|
const hashed = await hashPassword(dto.password);
|
||||||
|
const entity = this.repo.create({
|
||||||
|
username: dto.username,
|
||||||
|
password: hashed,
|
||||||
|
nickname: dto.nickname,
|
||||||
|
avatar: dto.avatar ?? '',
|
||||||
|
role: dto.role ?? 'normal',
|
||||||
|
});
|
||||||
|
return this.repo.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改管理员账号(名称/头像/角色)
|
||||||
|
* - 不允许超管把自己降级为普通(避免系统没有超管)
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
id: number,
|
||||||
|
dto: UpdateAdminUserDto,
|
||||||
|
currentAdminId: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const item = await this.detail(id);
|
||||||
|
item.nickname = dto.nickname;
|
||||||
|
item.avatar = dto.avatar ?? '';
|
||||||
|
if (dto.role && dto.role !== item.role) {
|
||||||
|
if (
|
||||||
|
id === currentAdminId &&
|
||||||
|
item.role === 'super_admin' &&
|
||||||
|
dto.role !== 'super_admin'
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException('不能将自己降级为普通管理员');
|
||||||
|
}
|
||||||
|
item.role = dto.role;
|
||||||
|
}
|
||||||
|
await this.repo.save(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPassword(id: number, dto: ResetPasswordDto): Promise<void> {
|
||||||
|
const item = await this.detail(id);
|
||||||
|
item.password = await hashPassword(dto.newPassword);
|
||||||
|
await this.repo.save(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: number, currentAdminId: number): Promise<void> {
|
||||||
|
if (id === currentAdminId) {
|
||||||
|
throw new BadRequestException('不能删除当前登录的管理员账号');
|
||||||
|
}
|
||||||
|
await this.detail(id);
|
||||||
|
await this.repo.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/modules/admin-user/dto/admin-user.dto.ts
Normal file
107
src/modules/admin-user/dto/admin-user.dto.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import type { AdminRole } from '@/common/decorators';
|
||||||
|
|
||||||
|
export class CreateAdminUserDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: '登录账号(手机号/邮箱/自定义)',
|
||||||
|
example: 'admin',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '登录账号不能为空' })
|
||||||
|
@MinLength(2, { message: '账号至少 2 个字符' })
|
||||||
|
@MaxLength(50, { message: '账号最多 50 个字符' })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '初始密码(至少 6 位)', example: '123456' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '密码不能为空' })
|
||||||
|
@MinLength(6, { message: '密码至少 6 位' })
|
||||||
|
@MaxLength(50, { message: '密码最多 50 个字符' })
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '管理员名称' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '名称不能为空' })
|
||||||
|
@MaxLength(50)
|
||||||
|
nickname: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '头像地址' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
avatar?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '角色:super_admin 超级管理员 / normal 普通管理员',
|
||||||
|
default: 'normal',
|
||||||
|
enum: ['super_admin', 'normal'],
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['super_admin', 'normal'], { message: '角色取值不合法' })
|
||||||
|
@IsOptional()
|
||||||
|
role?: AdminRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateAdminUserDto {
|
||||||
|
@ApiProperty({ description: '管理员名称' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '名称不能为空' })
|
||||||
|
@MaxLength(50)
|
||||||
|
nickname: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '头像地址' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
avatar?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '角色:super_admin 超级管理员 / normal 普通管理员',
|
||||||
|
enum: ['super_admin', 'normal'],
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['super_admin', 'normal'], { message: '角色取值不合法' })
|
||||||
|
@IsOptional()
|
||||||
|
role?: AdminRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ResetPasswordDto {
|
||||||
|
@ApiProperty({ description: '新密码(至少 6 位)' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '新密码不能为空' })
|
||||||
|
@MinLength(6, { message: '新密码至少 6 位' })
|
||||||
|
@MaxLength(50, { message: '新密码最多 50 个字符' })
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QueryAdminUserDto {
|
||||||
|
@ApiPropertyOptional({ description: '页码', default: 1 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '每页数量', default: 10 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
pageSize?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '关键词(账号/名称)' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
40
src/modules/auth/auth.controller.ts
Normal file
40
src/modules/auth/auth.controller.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import {
|
||||||
|
ChangePasswordDto,
|
||||||
|
LoginDto,
|
||||||
|
AdminLoginResult,
|
||||||
|
} from './dto/login.dto';
|
||||||
|
import { CurrentAdmin, Public } from '@/common/decorators';
|
||||||
|
|
||||||
|
@ApiTags('管理员鉴权 Auth')
|
||||||
|
@Controller('admin/auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('login')
|
||||||
|
@ApiOperation({ summary: '管理员登录', description: '无需 token,返回 JWT' })
|
||||||
|
login(@Body() dto: LoginDto): Promise<AdminLoginResult> {
|
||||||
|
return this.authService.login(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Get('profile')
|
||||||
|
@ApiOperation({ summary: '获取当前登录管理员信息' })
|
||||||
|
profile(@CurrentAdmin('id') adminId: number) {
|
||||||
|
return this.authService.getProfile(adminId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Post('change-password')
|
||||||
|
@ApiOperation({ summary: '修改当前管理员密码' })
|
||||||
|
changePassword(
|
||||||
|
@CurrentAdmin('id') adminId: number,
|
||||||
|
@Body() dto: ChangePasswordDto,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.authService.changePassword(adminId, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/modules/auth/auth.module.ts
Normal file
14
src/modules/auth/auth.module.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Admin } from '@/entities/admin.entity';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
// JwtModule 已在 AppModule 以 global: true 注册,无需再 import
|
||||||
|
imports: [TypeOrmModule.forFeature([Admin])],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService],
|
||||||
|
exports: [AuthService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
109
src/modules/auth/auth.service.ts
Normal file
109
src/modules/auth/auth.service.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
|
||||||
|
import { Admin } from '@/entities/admin.entity';
|
||||||
|
import { comparePassword, hashPassword } from '@/utils/crypto.util';
|
||||||
|
import {
|
||||||
|
AdminLoginResult,
|
||||||
|
ChangePasswordDto,
|
||||||
|
LoginDto,
|
||||||
|
} from './dto/login.dto';
|
||||||
|
|
||||||
|
/** 默认初始密码(init_db.sql 中管理员 password 为空,首次启动会自动加密写入此密码) */
|
||||||
|
const DEFAULT_INIT_PASSWORD = '123456';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Admin)
|
||||||
|
private readonly adminRepo: Repository<Admin>,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员登录
|
||||||
|
* 兼容 init_db.sql 写入的空密码:首次登录会自动 bcrypt 加密 123456
|
||||||
|
*/
|
||||||
|
async login(dto: LoginDto): Promise<AdminLoginResult> {
|
||||||
|
const admin = await this.adminRepo
|
||||||
|
.createQueryBuilder('a')
|
||||||
|
.addSelect('a.password')
|
||||||
|
.where('a.username = :username', { username: dto.username })
|
||||||
|
.getOne();
|
||||||
|
if (!admin) {
|
||||||
|
throw new UnauthorizedException('账号或密码错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始空密码 → 自动加密默认密码
|
||||||
|
if (!admin.password) {
|
||||||
|
admin.password = await hashPassword(DEFAULT_INIT_PASSWORD);
|
||||||
|
await this.adminRepo.update(admin.id, { password: admin.password });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await comparePassword(dto.password, admin.password);
|
||||||
|
if (!ok) {
|
||||||
|
throw new UnauthorizedException('账号或密码错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await this.jwtService.signAsync({
|
||||||
|
id: admin.id,
|
||||||
|
username: admin.username,
|
||||||
|
nickname: admin.nickname,
|
||||||
|
role: admin.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
admin: {
|
||||||
|
id: admin.id,
|
||||||
|
username: admin.username,
|
||||||
|
nickname: admin.nickname,
|
||||||
|
avatar: admin.avatar ?? '',
|
||||||
|
role: admin.role,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前登录管理员信息 */
|
||||||
|
async getProfile(adminId: number): Promise<Admin | null> {
|
||||||
|
return this.adminRepo.findOne({ where: { id: adminId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改密码 */
|
||||||
|
async changePassword(
|
||||||
|
adminId: number,
|
||||||
|
dto: ChangePasswordDto,
|
||||||
|
): Promise<void> {
|
||||||
|
if (dto.oldPassword === dto.newPassword) {
|
||||||
|
throw new BadRequestException('新密码不能与原密码相同');
|
||||||
|
}
|
||||||
|
const admin = await this.adminRepo
|
||||||
|
.createQueryBuilder('a')
|
||||||
|
.addSelect('a.password')
|
||||||
|
.where('a.id = :id', { id: adminId })
|
||||||
|
.getOne();
|
||||||
|
if (!admin) {
|
||||||
|
throw new NotFoundException('管理员不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!admin.password) {
|
||||||
|
admin.password = await hashPassword(DEFAULT_INIT_PASSWORD);
|
||||||
|
await this.adminRepo.update(admin.id, { password: admin.password });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await comparePassword(dto.oldPassword, admin.password);
|
||||||
|
if (!ok) {
|
||||||
|
throw new BadRequestException('原密码错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashed = await hashPassword(dto.newPassword);
|
||||||
|
await this.adminRepo.update(admin.id, { password: hashed });
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/modules/auth/dto/login.dto.ts
Normal file
39
src/modules/auth/dto/login.dto.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsString, MinLength, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@ApiProperty({ description: '登录账号', example: 'admin' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2, { message: '账号至少 2 个字符' })
|
||||||
|
@MaxLength(50, { message: '账号最多 50 个字符' })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '登录密码', example: '123456' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6, { message: '密码至少 6 个字符' })
|
||||||
|
@MaxLength(50, { message: '密码最多 50 个字符' })
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChangePasswordDto {
|
||||||
|
@ApiProperty({ description: '原密码' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6)
|
||||||
|
oldPassword: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '新密码(至少 6 位)' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6, { message: '新密码至少 6 位' })
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminLoginResult {
|
||||||
|
token: string;
|
||||||
|
admin: {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
role: 'super_admin' | 'normal';
|
||||||
|
};
|
||||||
|
}
|
||||||
76
src/modules/banner/banner.controller.ts
Normal file
76
src/modules/banner/banner.controller.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { BannerService } from './banner.service';
|
||||||
|
import {
|
||||||
|
CreateBannerDto,
|
||||||
|
QueryBannerDto,
|
||||||
|
UpdateBannerDto,
|
||||||
|
} from './dto/banner.dto';
|
||||||
|
import { Public, Roles } from '@/common/decorators';
|
||||||
|
import { PaginatedResult } from '@/common/dto/api-response.dto';
|
||||||
|
import { Banner } from '@/entities/banner.entity';
|
||||||
|
|
||||||
|
@ApiTags('轮播图 Banner')
|
||||||
|
@Controller()
|
||||||
|
export class BannerController {
|
||||||
|
constructor(private readonly service: BannerService) {}
|
||||||
|
|
||||||
|
// ---------- 前台公开 ----------
|
||||||
|
@Public()
|
||||||
|
@Get('public/banner')
|
||||||
|
@ApiOperation({ summary: '前台-轮播图列表' })
|
||||||
|
publicList(): Promise<Banner[]> {
|
||||||
|
return this.service.findPublicList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 后台 ----------
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Get('admin/banner')
|
||||||
|
@ApiOperation({ summary: '后台-轮播图分页' })
|
||||||
|
paginate(@Query() query: QueryBannerDto): Promise<PaginatedResult<Banner>> {
|
||||||
|
return this.service.paginate(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Get('admin/banner/:id')
|
||||||
|
@ApiOperation({ summary: '后台-轮播图详情' })
|
||||||
|
detail(@Param('id', ParseIntPipe) id: number): Promise<Banner> {
|
||||||
|
return this.service.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Post('admin/banner')
|
||||||
|
@ApiOperation({ summary: '后台-新增轮播图' })
|
||||||
|
create(@Body() dto: CreateBannerDto): Promise<Banner> {
|
||||||
|
return this.service.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Put('admin/banner/:id')
|
||||||
|
@ApiOperation({ summary: '后台-修改轮播图' })
|
||||||
|
update(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() dto: UpdateBannerDto,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.service.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Delete('admin/banner/:id')
|
||||||
|
@Roles('super_admin')
|
||||||
|
@ApiOperation({ summary: '后台-删除轮播图(仅超管)' })
|
||||||
|
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||||
|
return this.service.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/modules/banner/banner.module.ts
Normal file
12
src/modules/banner/banner.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Banner } from '@/entities/banner.entity';
|
||||||
|
import { BannerController } from './banner.controller';
|
||||||
|
import { BannerService } from './banner.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Banner])],
|
||||||
|
controllers: [BannerController],
|
||||||
|
providers: [BannerService],
|
||||||
|
})
|
||||||
|
export class BannerModule {}
|
||||||
79
src/modules/banner/banner.service.ts
Normal file
79
src/modules/banner/banner.service.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { Banner } from '@/entities/banner.entity';
|
||||||
|
import { PaginatedResult } from '@/common/dto/api-response.dto';
|
||||||
|
import {
|
||||||
|
CreateBannerDto,
|
||||||
|
QueryBannerDto,
|
||||||
|
UpdateBannerDto,
|
||||||
|
} from './dto/banner.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BannerService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Banner)
|
||||||
|
private readonly repo: Repository<Banner>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ---------- 前台公开 ----------
|
||||||
|
/** 前台展示列表(仅 is_show=1,按 sort 升序) */
|
||||||
|
async findPublicList(): Promise<Banner[]> {
|
||||||
|
return this.repo.find({
|
||||||
|
where: { isShow: 1 },
|
||||||
|
order: { sort: 'ASC', id: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 后台 ----------
|
||||||
|
async paginate(query: QueryBannerDto): Promise<PaginatedResult<Banner>> {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 10;
|
||||||
|
const qb = this.repo.createQueryBuilder('b').orderBy('b.sort', 'ASC').addOrderBy('b.id', 'DESC');
|
||||||
|
if (query.keyword) {
|
||||||
|
qb.andWhere('b.title LIKE :kw', { kw: `%${query.keyword}%` });
|
||||||
|
}
|
||||||
|
const [list, total] = await qb
|
||||||
|
.skip((page - 1) * pageSize)
|
||||||
|
.take(pageSize)
|
||||||
|
.getManyAndCount();
|
||||||
|
return { list, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(id: number): Promise<Banner> {
|
||||||
|
const item = await this.repo.findOne({ where: { id } });
|
||||||
|
if (!item) {
|
||||||
|
throw new NotFoundException('轮播图不存在');
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateBannerDto): Promise<Banner> {
|
||||||
|
const entity = this.repo.create({
|
||||||
|
title: dto.title,
|
||||||
|
image: dto.image,
|
||||||
|
link: dto.link ?? '',
|
||||||
|
sort: dto.sort ?? 0,
|
||||||
|
isShow: dto.isShow ?? 1,
|
||||||
|
});
|
||||||
|
return this.repo.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: number, dto: UpdateBannerDto): Promise<void> {
|
||||||
|
const item = await this.detail(id);
|
||||||
|
Object.assign(item, {
|
||||||
|
title: dto.title,
|
||||||
|
image: dto.image,
|
||||||
|
link: dto.link ?? '',
|
||||||
|
sort: dto.sort ?? 0,
|
||||||
|
isShow: dto.isShow ?? 1,
|
||||||
|
});
|
||||||
|
await this.repo.save(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: number): Promise<void> {
|
||||||
|
await this.detail(id);
|
||||||
|
await this.repo.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/modules/banner/dto/banner.dto.ts
Normal file
70
src/modules/banner/dto/banner.dto.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateBannerDto {
|
||||||
|
@ApiProperty({ description: '轮播标题' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '标题不能为空' })
|
||||||
|
@MaxLength(100)
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '图片地址' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '图片不能为空' })
|
||||||
|
image: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '跳转链接', default: '' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(255)
|
||||||
|
link?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '排序值', default: 0 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(99999)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
sort?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '是否展示 1是0否', default: 1 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(1)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
isShow?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateBannerDto extends CreateBannerDto {}
|
||||||
|
|
||||||
|
export class QueryBannerDto {
|
||||||
|
@ApiPropertyOptional({ description: '页码', default: 1 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '每页数量', default: 10 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
pageSize?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '关键词(标题)' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
76
src/modules/manual/dto/manual.dto.ts
Normal file
76
src/modules/manual/dto/manual.dto.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateManualDto {
|
||||||
|
@ApiPropertyOptional({ description: '父节点ID,NULL=根节点' })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
parentId?: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '标题' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '标题不能为空' })
|
||||||
|
@MaxLength(200)
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '类型:0=目录节点 1=文档节点',
|
||||||
|
default: 1,
|
||||||
|
})
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(1)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
type?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '正文(富文本/Markdown,目录节点为空)' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
content?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '正文格式:html=富文本 markdown=Markdown',
|
||||||
|
default: 'html',
|
||||||
|
})
|
||||||
|
@IsIn(['html', 'markdown'])
|
||||||
|
@IsOptional()
|
||||||
|
contentFormat?: 'html' | 'markdown';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '排序值', default: 0 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(99999)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
sort?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '是否显示 1是0否', default: 1 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(1)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
isShow?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateManualDto extends CreateManualDto {}
|
||||||
|
|
||||||
|
export class QueryManualDto {
|
||||||
|
@ApiPropertyOptional({ description: '关键词(标题)' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
91
src/modules/manual/manual.controller.ts
Normal file
91
src/modules/manual/manual.controller.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { Public, Roles } from '@/common/decorators';
|
||||||
|
import { Manual } from '@/entities/manual.entity';
|
||||||
|
import { ManualService, type ManualTreeNode } from './manual.service';
|
||||||
|
import {
|
||||||
|
CreateManualDto,
|
||||||
|
QueryManualDto,
|
||||||
|
UpdateManualDto,
|
||||||
|
} from './dto/manual.dto';
|
||||||
|
|
||||||
|
@ApiTags('使用手册 Manual')
|
||||||
|
@Controller()
|
||||||
|
export class ManualController {
|
||||||
|
constructor(private readonly service: ManualService) {}
|
||||||
|
|
||||||
|
// ---------- 前台 ----------
|
||||||
|
@Public()
|
||||||
|
@Get('public/manual/tree')
|
||||||
|
@ApiOperation({ summary: '前台-使用手册树形菜单' })
|
||||||
|
publicTree(): Promise<ManualTreeNode[]> {
|
||||||
|
return this.service.findPublicTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('public/manual/:id')
|
||||||
|
@ApiOperation({ summary: '前台-使用手册文档详情' })
|
||||||
|
publicDetail(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
): Promise<Manual> {
|
||||||
|
return this.service.findPublicDetail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 后台 ----------
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Get('admin/manual')
|
||||||
|
@ApiOperation({ summary: '后台-使用手册全部节点(扁平)' })
|
||||||
|
list(@Query() query: QueryManualDto): Promise<Manual[]> {
|
||||||
|
return this.service.findAll(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Get('admin/manual/tree')
|
||||||
|
@ApiOperation({ summary: '后台-使用手册树形结构' })
|
||||||
|
tree(): Promise<ManualTreeNode[]> {
|
||||||
|
return this.service.findAdminTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Get('admin/manual/:id')
|
||||||
|
@ApiOperation({ summary: '后台-使用手册节点详情' })
|
||||||
|
detail(@Param('id', ParseIntPipe) id: number): Promise<Manual> {
|
||||||
|
return this.service.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Post('admin/manual')
|
||||||
|
@ApiOperation({ summary: '后台-新增使用手册节点' })
|
||||||
|
create(@Body() dto: CreateManualDto): Promise<Manual> {
|
||||||
|
return this.service.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Put('admin/manual/:id')
|
||||||
|
@ApiOperation({ summary: '后台-修改使用手册节点' })
|
||||||
|
update(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() dto: UpdateManualDto,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.service.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Delete('admin/manual/:id')
|
||||||
|
@Roles('super_admin')
|
||||||
|
@ApiOperation({ summary: '后台-删除使用手册节点(仅超管)' })
|
||||||
|
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||||
|
return this.service.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/modules/manual/manual.module.ts
Normal file
12
src/modules/manual/manual.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Manual } from '@/entities/manual.entity';
|
||||||
|
import { ManualController } from './manual.controller';
|
||||||
|
import { ManualService } from './manual.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Manual])],
|
||||||
|
controllers: [ManualController],
|
||||||
|
providers: [ManualService],
|
||||||
|
})
|
||||||
|
export class ManualModule {}
|
||||||
200
src/modules/manual/manual.service.ts
Normal file
200
src/modules/manual/manual.service.ts
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, Like } from 'typeorm';
|
||||||
|
import { Manual } from '@/entities/manual.entity';
|
||||||
|
import {
|
||||||
|
CreateManualDto,
|
||||||
|
QueryManualDto,
|
||||||
|
UpdateManualDto,
|
||||||
|
} from './dto/manual.dto';
|
||||||
|
|
||||||
|
/** 树节点(不含正文,用于菜单渲染) */
|
||||||
|
export interface ManualTreeNode {
|
||||||
|
id: number;
|
||||||
|
parentId: number | null;
|
||||||
|
title: string;
|
||||||
|
type: number;
|
||||||
|
sort: number;
|
||||||
|
isShow: number;
|
||||||
|
children: ManualTreeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ManualService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Manual)
|
||||||
|
private readonly repo: Repository<Manual>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ---------- 前台 ----------
|
||||||
|
|
||||||
|
/** 前台-获取可见的树形菜单(不含正文) */
|
||||||
|
async findPublicTree(): Promise<ManualTreeNode[]> {
|
||||||
|
const nodes = await this.repo.find({
|
||||||
|
where: { isShow: 1 },
|
||||||
|
order: { sort: 'ASC', id: 'ASC' },
|
||||||
|
});
|
||||||
|
return this.buildTree(nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 前台-获取文档详情(含正文) */
|
||||||
|
async findPublicDetail(id: number): Promise<Manual> {
|
||||||
|
const item = await this.repo.findOne({ where: { id, isShow: 1 } });
|
||||||
|
if (!item) throw new NotFoundException('文档不存在或已下架');
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 后台 ----------
|
||||||
|
|
||||||
|
/** 后台-全部节点(扁平,含 parent 信息,供列表/下拉用) */
|
||||||
|
async findAll(query?: QueryManualDto): Promise<Manual[]> {
|
||||||
|
const where = query?.keyword
|
||||||
|
? { title: Like(`%${query.keyword}%`) }
|
||||||
|
: undefined;
|
||||||
|
return this.repo.find({
|
||||||
|
where,
|
||||||
|
order: { sort: 'ASC', id: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后台-获取所有节点的树形结构 */
|
||||||
|
async findAdminTree(): Promise<ManualTreeNode[]> {
|
||||||
|
const nodes = await this.repo.find({
|
||||||
|
order: { sort: 'ASC', id: 'ASC' },
|
||||||
|
});
|
||||||
|
return this.buildTree(nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(id: number): Promise<Manual> {
|
||||||
|
const item = await this.repo.findOne({ where: { id } });
|
||||||
|
if (!item) throw new NotFoundException('节点不存在');
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateManualDto): Promise<Manual> {
|
||||||
|
if (dto.parentId) {
|
||||||
|
const parent = await this.repo.findOne({
|
||||||
|
where: { id: dto.parentId },
|
||||||
|
});
|
||||||
|
if (!parent) {
|
||||||
|
throw new BadRequestException('父节点不存在');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const type = dto.type ?? 1;
|
||||||
|
return this.repo.save(
|
||||||
|
this.repo.create({
|
||||||
|
parentId: dto.parentId ?? null,
|
||||||
|
title: dto.title,
|
||||||
|
type,
|
||||||
|
content: type === 0 ? null : dto.content ?? null,
|
||||||
|
contentFormat:
|
||||||
|
type === 0 ? 'html' : (dto.contentFormat ?? 'html'),
|
||||||
|
sort: dto.sort ?? 0,
|
||||||
|
isShow: dto.isShow ?? 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: number, dto: UpdateManualDto): Promise<void> {
|
||||||
|
const item = await this.detail(id);
|
||||||
|
|
||||||
|
// 校验父节点
|
||||||
|
let newParentId: number | null = dto.parentId ?? null;
|
||||||
|
if (newParentId) {
|
||||||
|
if (newParentId === id) {
|
||||||
|
throw new BadRequestException('不能将自身设为父节点');
|
||||||
|
}
|
||||||
|
const parent = await this.repo.findOne({
|
||||||
|
where: { id: newParentId },
|
||||||
|
});
|
||||||
|
if (!parent) {
|
||||||
|
throw new BadRequestException('父节点不存在');
|
||||||
|
}
|
||||||
|
// 简单环路检测:父节点不能是自身的子孙
|
||||||
|
if (await this.isDescendant(id, newParentId)) {
|
||||||
|
throw new BadRequestException('不能将节点移动到其子节点下');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = dto.type ?? item.type;
|
||||||
|
Object.assign(item, {
|
||||||
|
parentId: newParentId,
|
||||||
|
title: dto.title,
|
||||||
|
type,
|
||||||
|
content: type === 0 ? null : dto.content ?? null,
|
||||||
|
contentFormat:
|
||||||
|
type === 0
|
||||||
|
? 'html'
|
||||||
|
: (dto.contentFormat ?? item.contentFormat ?? 'html'),
|
||||||
|
sort: dto.sort ?? 0,
|
||||||
|
isShow: dto.isShow ?? 1,
|
||||||
|
});
|
||||||
|
await this.repo.save(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: number): Promise<void> {
|
||||||
|
const item = await this.detail(id);
|
||||||
|
const childCount = await this.repo.count({
|
||||||
|
where: { parentId: id },
|
||||||
|
});
|
||||||
|
if (childCount > 0) {
|
||||||
|
throw new BadRequestException('该节点下还有子节点,请先删除子节点');
|
||||||
|
}
|
||||||
|
await this.repo.remove(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 工具 ----------
|
||||||
|
|
||||||
|
/** 判断 targetId 是否是 nodeId 的子孙(用于环路检测) */
|
||||||
|
private async isDescendant(
|
||||||
|
nodeId: number,
|
||||||
|
targetId: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const stack = [nodeId];
|
||||||
|
const visited = new Set<number>();
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const cur = stack.pop()!;
|
||||||
|
if (visited.has(cur)) continue;
|
||||||
|
visited.add(cur);
|
||||||
|
const children = await this.repo.find({
|
||||||
|
where: { parentId: cur },
|
||||||
|
select: ['id'],
|
||||||
|
});
|
||||||
|
for (const c of children) {
|
||||||
|
if (c.id === targetId) return true;
|
||||||
|
stack.push(c.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 扁平节点列表构建为树 */
|
||||||
|
private buildTree(nodes: Manual[]): ManualTreeNode[] {
|
||||||
|
const map = new Map<number, ManualTreeNode>();
|
||||||
|
const roots: ManualTreeNode[] = [];
|
||||||
|
for (const n of nodes) {
|
||||||
|
map.set(n.id, {
|
||||||
|
id: n.id,
|
||||||
|
parentId: n.parentId,
|
||||||
|
title: n.title,
|
||||||
|
type: n.type,
|
||||||
|
sort: n.sort,
|
||||||
|
isShow: n.isShow,
|
||||||
|
children: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const n of nodes) {
|
||||||
|
const node = map.get(n.id)!;
|
||||||
|
if (n.parentId && map.has(n.parentId)) {
|
||||||
|
map.get(n.parentId)!.children.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/modules/message/dto/message.dto.ts
Normal file
67
src/modules/message/dto/message.dto.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsEmail,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateMessageDto {
|
||||||
|
@ApiProperty({ description: '访客姓名' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '姓名不能为空' })
|
||||||
|
@MaxLength(50)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '联系电话' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '电话不能为空' })
|
||||||
|
@MaxLength(20)
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '邮箱' })
|
||||||
|
@IsEmail({}, { message: '邮箱格式不正确' })
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(100)
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '留言内容' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '留言内容不能为空' })
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QueryMessageDto {
|
||||||
|
@ApiPropertyOptional({ description: '页码', default: 1 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '每页数量', default: 10 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
pageSize?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '是否已读 0未读 1已读' })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(1)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
isRead?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '关键词' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
56
src/modules/message/message.controller.ts
Normal file
56
src/modules/message/message.controller.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { Public, Roles } from '@/common/decorators';
|
||||||
|
import { PaginatedResult } from '@/common/dto/api-response.dto';
|
||||||
|
import { Message } from '@/entities/message.entity';
|
||||||
|
import { MessageService } from './message.service';
|
||||||
|
import {
|
||||||
|
CreateMessageDto,
|
||||||
|
QueryMessageDto,
|
||||||
|
} from './dto/message.dto';
|
||||||
|
|
||||||
|
@ApiTags('留言 Message')
|
||||||
|
@Controller()
|
||||||
|
export class MessageController {
|
||||||
|
constructor(private readonly service: MessageService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('public/message')
|
||||||
|
@ApiOperation({ summary: '前台-访客提交留言' })
|
||||||
|
submit(@Body() dto: CreateMessageDto): Promise<Message> {
|
||||||
|
return this.service.submit(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Get('admin/message')
|
||||||
|
@ApiOperation({ summary: '后台-留言分页' })
|
||||||
|
paginate(@Query() query: QueryMessageDto): Promise<PaginatedResult<Message>> {
|
||||||
|
return this.service.paginate(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Put('admin/message/:id/read')
|
||||||
|
@ApiOperation({ summary: '后台-留言标记已读' })
|
||||||
|
markRead(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||||
|
return this.service.markRead(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Delete('admin/message/:id')
|
||||||
|
@Roles('super_admin')
|
||||||
|
@ApiOperation({ summary: '后台-删除留言(仅超管)' })
|
||||||
|
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||||
|
return this.service.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/modules/message/message.module.ts
Normal file
12
src/modules/message/message.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Message } from '@/entities/message.entity';
|
||||||
|
import { MessageController } from './message.controller';
|
||||||
|
import { MessageService } from './message.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Message])],
|
||||||
|
controllers: [MessageController],
|
||||||
|
providers: [MessageService],
|
||||||
|
})
|
||||||
|
export class MessageModule {}
|
||||||
67
src/modules/message/message.service.ts
Normal file
67
src/modules/message/message.service.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { Message } from '@/entities/message.entity';
|
||||||
|
import { PaginatedResult } from '@/common/dto/api-response.dto';
|
||||||
|
import {
|
||||||
|
CreateMessageDto,
|
||||||
|
QueryMessageDto,
|
||||||
|
} from './dto/message.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MessageService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Message)
|
||||||
|
private readonly repo: Repository<Message>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** 前台访客提交留言 */
|
||||||
|
async submit(dto: CreateMessageDto): Promise<Message> {
|
||||||
|
return this.repo.save(
|
||||||
|
this.repo.create({
|
||||||
|
name: dto.name,
|
||||||
|
phone: dto.phone,
|
||||||
|
email: dto.email ?? '',
|
||||||
|
content: dto.content,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后台分页 */
|
||||||
|
async paginate(query: QueryMessageDto): Promise<PaginatedResult<Message>> {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 10;
|
||||||
|
const qb = this.repo
|
||||||
|
.createQueryBuilder('m')
|
||||||
|
.orderBy('m.is_read', 'ASC')
|
||||||
|
.addOrderBy('m.id', 'DESC');
|
||||||
|
if (typeof query.isRead === 'number') {
|
||||||
|
qb.andWhere('m.is_read = :ir', { ir: query.isRead });
|
||||||
|
}
|
||||||
|
if (query.keyword) {
|
||||||
|
qb.andWhere(
|
||||||
|
'(m.name LIKE :kw OR m.phone LIKE :kw OR m.content LIKE :kw)',
|
||||||
|
{ kw: `%${query.keyword}%` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const [list, total] = await qb
|
||||||
|
.skip((page - 1) * pageSize)
|
||||||
|
.take(pageSize)
|
||||||
|
.getManyAndCount();
|
||||||
|
return { list, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async markRead(id: number): Promise<void> {
|
||||||
|
const item = await this.repo.findOne({ where: { id } });
|
||||||
|
if (!item) throw new NotFoundException('留言不存在');
|
||||||
|
item.isRead = 1;
|
||||||
|
await this.repo.save(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: number): Promise<void> {
|
||||||
|
const item = await this.repo.findOne({ where: { id } });
|
||||||
|
if (!item) throw new NotFoundException('留言不存在');
|
||||||
|
await this.repo.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/modules/news-category/dto/news-category.dto.ts
Normal file
44
src/modules/news-category/dto/news-category.dto.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateNewsCategoryDto {
|
||||||
|
@ApiProperty({ description: '分类名称' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '分类名称不能为空' })
|
||||||
|
@MaxLength(100)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '排序', default: 0 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(99999)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
sort?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '是否展示 1是0否', default: 1 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(1)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
isShow?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateNewsCategoryDto extends CreateNewsCategoryDto {}
|
||||||
|
|
||||||
|
export class QueryNewsCategoryDto {
|
||||||
|
@ApiPropertyOptional({ description: '关键词' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
72
src/modules/news-category/news-category.controller.ts
Normal file
72
src/modules/news-category/news-category.controller.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Public, Roles } from '@/common/decorators';
|
||||||
|
import { NewsCategory } from '@/entities/news-category.entity';
|
||||||
|
import { NewsCategoryService } from './news-category.service';
|
||||||
|
import {
|
||||||
|
CreateNewsCategoryDto,
|
||||||
|
QueryNewsCategoryDto,
|
||||||
|
UpdateNewsCategoryDto,
|
||||||
|
} from './dto/news-category.dto';
|
||||||
|
|
||||||
|
@ApiTags('新闻分类 NewsCategory')
|
||||||
|
@Controller()
|
||||||
|
export class NewsCategoryController {
|
||||||
|
constructor(private readonly service: NewsCategoryService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('public/news-category')
|
||||||
|
@ApiOperation({ summary: '前台-新闻分类列表' })
|
||||||
|
publicList(): Promise<NewsCategory[]> {
|
||||||
|
return this.service.findPublicList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Get('admin/news-category')
|
||||||
|
@ApiOperation({ summary: '后台-新闻分类全部列表' })
|
||||||
|
list(@Query() query: QueryNewsCategoryDto): Promise<NewsCategory[]> {
|
||||||
|
return this.service.findAll(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Get('admin/news-category/:id')
|
||||||
|
@ApiOperation({ summary: '后台-新闻分类详情' })
|
||||||
|
detail(@Param('id', ParseIntPipe) id: number): Promise<NewsCategory> {
|
||||||
|
return this.service.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Post('admin/news-category')
|
||||||
|
@ApiOperation({ summary: '后台-新增新闻分类' })
|
||||||
|
create(@Body() dto: CreateNewsCategoryDto): Promise<NewsCategory> {
|
||||||
|
return this.service.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Put('admin/news-category/:id')
|
||||||
|
@ApiOperation({ summary: '后台-修改新闻分类' })
|
||||||
|
update(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() dto: UpdateNewsCategoryDto,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.service.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Delete('admin/news-category/:id')
|
||||||
|
@Roles('super_admin')
|
||||||
|
@ApiOperation({ summary: '后台-删除新闻分类(仅超管)' })
|
||||||
|
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||||
|
return this.service.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/modules/news-category/news-category.module.ts
Normal file
13
src/modules/news-category/news-category.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { NewsCategory } from '@/entities/news-category.entity';
|
||||||
|
import { NewsCategoryController } from './news-category.controller';
|
||||||
|
import { NewsCategoryService } from './news-category.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([NewsCategory])],
|
||||||
|
controllers: [NewsCategoryController],
|
||||||
|
providers: [NewsCategoryService],
|
||||||
|
exports: [NewsCategoryService],
|
||||||
|
})
|
||||||
|
export class NewsCategoryModule {}
|
||||||
66
src/modules/news-category/news-category.service.ts
Normal file
66
src/modules/news-category/news-category.service.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { NewsCategory } from '@/entities/news-category.entity';
|
||||||
|
import {
|
||||||
|
CreateNewsCategoryDto,
|
||||||
|
QueryNewsCategoryDto,
|
||||||
|
UpdateNewsCategoryDto,
|
||||||
|
} from './dto/news-category.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NewsCategoryService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(NewsCategory)
|
||||||
|
private readonly repo: Repository<NewsCategory>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findPublicList(): Promise<NewsCategory[]> {
|
||||||
|
return this.repo.find({
|
||||||
|
where: { isShow: 1 },
|
||||||
|
order: { sort: 'ASC', id: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(query?: QueryNewsCategoryDto): Promise<NewsCategory[]> {
|
||||||
|
const qb = this.repo
|
||||||
|
.createQueryBuilder('c')
|
||||||
|
.orderBy('c.sort', 'ASC')
|
||||||
|
.addOrderBy('c.id', 'DESC');
|
||||||
|
if (query?.keyword) {
|
||||||
|
qb.andWhere('c.name LIKE :kw', { kw: `%${query.keyword}%` });
|
||||||
|
}
|
||||||
|
return qb.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(id: number): Promise<NewsCategory> {
|
||||||
|
const item = await this.repo.findOne({ where: { id } });
|
||||||
|
if (!item) throw new NotFoundException('分类不存在');
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateNewsCategoryDto): Promise<NewsCategory> {
|
||||||
|
return this.repo.save(
|
||||||
|
this.repo.create({
|
||||||
|
name: dto.name,
|
||||||
|
sort: dto.sort ?? 0,
|
||||||
|
isShow: dto.isShow ?? 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: number, dto: UpdateNewsCategoryDto): Promise<void> {
|
||||||
|
const item = await this.detail(id);
|
||||||
|
Object.assign(item, {
|
||||||
|
name: dto.name,
|
||||||
|
sort: dto.sort ?? 0,
|
||||||
|
isShow: dto.isShow ?? 1,
|
||||||
|
});
|
||||||
|
await this.repo.save(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: number): Promise<void> {
|
||||||
|
await this.detail(id);
|
||||||
|
await this.repo.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/modules/news/dto/news.dto.ts
Normal file
87
src/modules/news/dto/news.dto.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateNewsDto {
|
||||||
|
@ApiProperty({ description: '分类ID' })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Type(() => Number)
|
||||||
|
categoryId: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '新闻标题' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '标题不能为空' })
|
||||||
|
@MaxLength(200)
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '封面图' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
cover?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '简介' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(500)
|
||||||
|
intro?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '正文富文本' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '正文不能为空' })
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '是否置顶 1是0否', default: 0 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(1)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
isTop?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '状态 1发布 0草稿', default: 1 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(1)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
status?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateNewsDto extends CreateNewsDto {}
|
||||||
|
|
||||||
|
export class QueryNewsDto {
|
||||||
|
@ApiPropertyOptional({ description: '页码', default: 1 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '每页数量', default: 10 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
pageSize?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '分类ID' })
|
||||||
|
@IsInt()
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
categoryId?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '关键词(标题)' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
77
src/modules/news/news.controller.ts
Normal file
77
src/modules/news/news.controller.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { Public, Roles } from '@/common/decorators';
|
||||||
|
import { PaginatedResult } from '@/common/dto/api-response.dto';
|
||||||
|
import { News } from '@/entities/news.entity';
|
||||||
|
import { NewsService } from './news.service';
|
||||||
|
import { CreateNewsDto, QueryNewsDto, UpdateNewsDto } from './dto/news.dto';
|
||||||
|
|
||||||
|
@ApiTags('新闻 News')
|
||||||
|
@Controller()
|
||||||
|
export class NewsController {
|
||||||
|
constructor(private readonly service: NewsService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('public/news')
|
||||||
|
@ApiOperation({ summary: '前台-新闻分页' })
|
||||||
|
publicPaginate(@Query() query: QueryNewsDto): Promise<PaginatedResult<News>> {
|
||||||
|
return this.service.publicPaginate(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('public/news/:id')
|
||||||
|
@ApiOperation({ summary: '前台-新闻详情' })
|
||||||
|
publicDetail(@Param('id', ParseIntPipe) id: number): Promise<News> {
|
||||||
|
return this.service.publicDetail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Get('admin/news')
|
||||||
|
@ApiOperation({ summary: '后台-新闻分页' })
|
||||||
|
paginate(@Query() query: QueryNewsDto): Promise<PaginatedResult<News>> {
|
||||||
|
return this.service.paginate(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Get('admin/news/:id')
|
||||||
|
@ApiOperation({ summary: '后台-新闻详情' })
|
||||||
|
detail(@Param('id', ParseIntPipe) id: number): Promise<News> {
|
||||||
|
return this.service.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Post('admin/news')
|
||||||
|
@ApiOperation({ summary: '后台-新增新闻' })
|
||||||
|
create(@Body() dto: CreateNewsDto): Promise<News> {
|
||||||
|
return this.service.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Put('admin/news/:id')
|
||||||
|
@ApiOperation({ summary: '后台-修改新闻' })
|
||||||
|
update(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() dto: UpdateNewsDto,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.service.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Delete('admin/news/:id')
|
||||||
|
@Roles('super_admin')
|
||||||
|
@ApiOperation({ summary: '后台-删除新闻(仅超管)' })
|
||||||
|
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||||
|
return this.service.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/modules/news/news.module.ts
Normal file
13
src/modules/news/news.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { News } from '@/entities/news.entity';
|
||||||
|
import { NewsCategory } from '@/entities/news-category.entity';
|
||||||
|
import { NewsController } from './news.controller';
|
||||||
|
import { NewsService } from './news.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([News, NewsCategory])],
|
||||||
|
controllers: [NewsController],
|
||||||
|
providers: [NewsService],
|
||||||
|
})
|
||||||
|
export class NewsModule {}
|
||||||
105
src/modules/news/news.service.ts
Normal file
105
src/modules/news/news.service.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { News } from '@/entities/news.entity';
|
||||||
|
import { PaginatedResult } from '@/common/dto/api-response.dto';
|
||||||
|
import { CreateNewsDto, QueryNewsDto, UpdateNewsDto } from './dto/news.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NewsService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(News)
|
||||||
|
private readonly repo: Repository<News>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ---------- 前台 ----------
|
||||||
|
async publicPaginate(query: QueryNewsDto): Promise<PaginatedResult<News>> {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 10;
|
||||||
|
const qb = this.buildQb(query).andWhere('n.status = 1');
|
||||||
|
const [list, total] = await qb
|
||||||
|
.skip((page - 1) * pageSize)
|
||||||
|
.take(pageSize)
|
||||||
|
.getManyAndCount();
|
||||||
|
return { list, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async publicDetail(id: number): Promise<News> {
|
||||||
|
const item = await this.repo.findOne({
|
||||||
|
where: { id, status: 1 },
|
||||||
|
relations: ['category'],
|
||||||
|
});
|
||||||
|
if (!item) throw new NotFoundException('新闻不存在或未发布');
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 后台 ----------
|
||||||
|
async paginate(query: QueryNewsDto): Promise<PaginatedResult<News>> {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 10;
|
||||||
|
const qb = this.buildQb(query);
|
||||||
|
const [list, total] = await qb
|
||||||
|
.skip((page - 1) * pageSize)
|
||||||
|
.take(pageSize)
|
||||||
|
.getManyAndCount();
|
||||||
|
return { list, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(id: number): Promise<News> {
|
||||||
|
const item = await this.repo.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['category'],
|
||||||
|
});
|
||||||
|
if (!item) throw new NotFoundException('新闻不存在');
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateNewsDto): Promise<News> {
|
||||||
|
return this.repo.save(
|
||||||
|
this.repo.create({
|
||||||
|
categoryId: dto.categoryId,
|
||||||
|
title: dto.title,
|
||||||
|
cover: dto.cover ?? '',
|
||||||
|
intro: dto.intro ?? '',
|
||||||
|
content: dto.content,
|
||||||
|
isTop: dto.isTop ?? 0,
|
||||||
|
status: dto.status ?? 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: number, dto: UpdateNewsDto): Promise<void> {
|
||||||
|
const item = await this.detail(id);
|
||||||
|
Object.assign(item, {
|
||||||
|
categoryId: dto.categoryId,
|
||||||
|
title: dto.title,
|
||||||
|
cover: dto.cover ?? '',
|
||||||
|
intro: dto.intro ?? '',
|
||||||
|
content: dto.content,
|
||||||
|
isTop: dto.isTop ?? 0,
|
||||||
|
status: dto.status ?? 1,
|
||||||
|
});
|
||||||
|
await this.repo.save(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: number): Promise<void> {
|
||||||
|
await this.detail(id);
|
||||||
|
await this.repo.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildQb(query: QueryNewsDto) {
|
||||||
|
const qb = this.repo
|
||||||
|
.createQueryBuilder('n')
|
||||||
|
.leftJoinAndSelect('n.category', 'c')
|
||||||
|
.orderBy('n.isTop', 'DESC')
|
||||||
|
.addOrderBy('n.id', 'DESC');
|
||||||
|
if (query.categoryId) {
|
||||||
|
qb.andWhere('n.category_id = :cid', { cid: query.categoryId });
|
||||||
|
}
|
||||||
|
if (query.keyword) {
|
||||||
|
qb.andWhere('n.title LIKE :kw', { kw: `%${query.keyword}%` });
|
||||||
|
}
|
||||||
|
return qb;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/modules/product-category/dto/product-category.dto.ts
Normal file
44
src/modules/product-category/dto/product-category.dto.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateProductCategoryDto {
|
||||||
|
@ApiProperty({ description: '分类名称' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '分类名称不能为空' })
|
||||||
|
@MaxLength(100)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '排序', default: 0 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(99999)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
sort?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '是否展示 1是0否', default: 1 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(1)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
isShow?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateProductCategoryDto extends CreateProductCategoryDto {}
|
||||||
|
|
||||||
|
export class QueryProductCategoryDto {
|
||||||
|
@ApiPropertyOptional({ description: '关键词' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
72
src/modules/product-category/product-category.controller.ts
Normal file
72
src/modules/product-category/product-category.controller.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Public, Roles } from '@/common/decorators';
|
||||||
|
import { ProductCategory } from '@/entities/product-category.entity';
|
||||||
|
import { ProductCategoryService } from './product-category.service';
|
||||||
|
import {
|
||||||
|
CreateProductCategoryDto,
|
||||||
|
QueryProductCategoryDto,
|
||||||
|
UpdateProductCategoryDto,
|
||||||
|
} from './dto/product-category.dto';
|
||||||
|
|
||||||
|
@ApiTags('产品分类 ProductCategory')
|
||||||
|
@Controller()
|
||||||
|
export class ProductCategoryController {
|
||||||
|
constructor(private readonly service: ProductCategoryService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('public/product-category')
|
||||||
|
@ApiOperation({ summary: '前台-产品分类列表' })
|
||||||
|
publicList(): Promise<ProductCategory[]> {
|
||||||
|
return this.service.findPublicList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Get('admin/product-category')
|
||||||
|
@ApiOperation({ summary: '后台-产品分类全部列表' })
|
||||||
|
list(@Query() query: QueryProductCategoryDto): Promise<ProductCategory[]> {
|
||||||
|
return this.service.findAll(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Get('admin/product-category/:id')
|
||||||
|
@ApiOperation({ summary: '后台-产品分类详情' })
|
||||||
|
detail(@Param('id', ParseIntPipe) id: number): Promise<ProductCategory> {
|
||||||
|
return this.service.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Post('admin/product-category')
|
||||||
|
@ApiOperation({ summary: '后台-新增产品分类' })
|
||||||
|
create(@Body() dto: CreateProductCategoryDto): Promise<ProductCategory> {
|
||||||
|
return this.service.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Put('admin/product-category/:id')
|
||||||
|
@ApiOperation({ summary: '后台-修改产品分类' })
|
||||||
|
update(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() dto: UpdateProductCategoryDto,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.service.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Delete('admin/product-category/:id')
|
||||||
|
@Roles('super_admin')
|
||||||
|
@ApiOperation({ summary: '后台-删除产品分类(仅超管)' })
|
||||||
|
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||||
|
return this.service.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/modules/product-category/product-category.module.ts
Normal file
13
src/modules/product-category/product-category.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ProductCategory } from '@/entities/product-category.entity';
|
||||||
|
import { ProductCategoryController } from './product-category.controller';
|
||||||
|
import { ProductCategoryService } from './product-category.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([ProductCategory])],
|
||||||
|
controllers: [ProductCategoryController],
|
||||||
|
providers: [ProductCategoryService],
|
||||||
|
exports: [ProductCategoryService],
|
||||||
|
})
|
||||||
|
export class ProductCategoryModule {}
|
||||||
65
src/modules/product-category/product-category.service.ts
Normal file
65
src/modules/product-category/product-category.service.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ProductCategory } from '@/entities/product-category.entity';
|
||||||
|
import {
|
||||||
|
CreateProductCategoryDto,
|
||||||
|
QueryProductCategoryDto,
|
||||||
|
UpdateProductCategoryDto,
|
||||||
|
} from './dto/product-category.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProductCategoryService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ProductCategory)
|
||||||
|
private readonly repo: Repository<ProductCategory>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findPublicList(): Promise<ProductCategory[]> {
|
||||||
|
return this.repo.find({
|
||||||
|
where: { isShow: 1 },
|
||||||
|
order: { sort: 'ASC', id: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(query?: QueryProductCategoryDto): Promise<ProductCategory[]> {
|
||||||
|
const qb = this.repo
|
||||||
|
.createQueryBuilder('c')
|
||||||
|
.orderBy('c.sort', 'ASC')
|
||||||
|
.addOrderBy('c.id', 'DESC');
|
||||||
|
if (query?.keyword) {
|
||||||
|
qb.andWhere('c.name LIKE :kw', { kw: `%${query.keyword}%` });
|
||||||
|
}
|
||||||
|
return qb.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(id: number): Promise<ProductCategory> {
|
||||||
|
const item = await this.repo.findOne({ where: { id } });
|
||||||
|
if (!item) throw new NotFoundException('分类不存在');
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateProductCategoryDto): Promise<ProductCategory> {
|
||||||
|
const entity = this.repo.create({
|
||||||
|
name: dto.name,
|
||||||
|
sort: dto.sort ?? 0,
|
||||||
|
isShow: dto.isShow ?? 1,
|
||||||
|
});
|
||||||
|
return this.repo.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: number, dto: UpdateProductCategoryDto): Promise<void> {
|
||||||
|
const item = await this.detail(id);
|
||||||
|
Object.assign(item, {
|
||||||
|
name: dto.name,
|
||||||
|
sort: dto.sort ?? 0,
|
||||||
|
isShow: dto.isShow ?? 1,
|
||||||
|
});
|
||||||
|
await this.repo.save(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: number): Promise<void> {
|
||||||
|
await this.detail(id);
|
||||||
|
await this.repo.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/modules/product/dto/product.dto.ts
Normal file
86
src/modules/product/dto/product.dto.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateProductDto {
|
||||||
|
@ApiProperty({ description: '分类ID' })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Type(() => Number)
|
||||||
|
categoryId: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '产品名称' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '产品名称不能为空' })
|
||||||
|
@MaxLength(100)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '封面图' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '封面图不能为空' })
|
||||||
|
cover: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '简短描述' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
desc?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '详情富文本' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
content?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '排序', default: 0 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(99999)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
sort?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '上下架 1是0否', default: 1 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(1)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
isShow?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateProductDto extends CreateProductDto {}
|
||||||
|
|
||||||
|
export class QueryProductDto {
|
||||||
|
@ApiPropertyOptional({ description: '页码', default: 1 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '每页数量', default: 10 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
pageSize?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '分类ID' })
|
||||||
|
@IsInt()
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
categoryId?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '关键词(名称)' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
83
src/modules/product/product.controller.ts
Normal file
83
src/modules/product/product.controller.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { Public, Roles } from '@/common/decorators';
|
||||||
|
import { PaginatedResult } from '@/common/dto/api-response.dto';
|
||||||
|
import { Product } from '@/entities/product.entity';
|
||||||
|
import { ProductService } from './product.service';
|
||||||
|
import {
|
||||||
|
CreateProductDto,
|
||||||
|
QueryProductDto,
|
||||||
|
UpdateProductDto,
|
||||||
|
} from './dto/product.dto';
|
||||||
|
|
||||||
|
@ApiTags('产品 Product')
|
||||||
|
@Controller()
|
||||||
|
export class ProductController {
|
||||||
|
constructor(private readonly service: ProductService) {}
|
||||||
|
|
||||||
|
// ---------- 前台 ----------
|
||||||
|
@Public()
|
||||||
|
@Get('public/product')
|
||||||
|
@ApiOperation({ summary: '前台-产品分页' })
|
||||||
|
publicPaginate(@Query() query: QueryProductDto): Promise<PaginatedResult<Product>> {
|
||||||
|
return this.service.publicPaginate(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('public/product/:id')
|
||||||
|
@ApiOperation({ summary: '前台-产品详情' })
|
||||||
|
publicDetail(@Param('id', ParseIntPipe) id: number): Promise<Product> {
|
||||||
|
return this.service.publicDetail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 后台 ----------
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Get('admin/product')
|
||||||
|
@ApiOperation({ summary: '后台-产品分页' })
|
||||||
|
paginate(@Query() query: QueryProductDto): Promise<PaginatedResult<Product>> {
|
||||||
|
return this.service.paginate(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Get('admin/product/:id')
|
||||||
|
@ApiOperation({ summary: '后台-产品详情' })
|
||||||
|
detail(@Param('id', ParseIntPipe) id: number): Promise<Product> {
|
||||||
|
return this.service.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Post('admin/product')
|
||||||
|
@ApiOperation({ summary: '后台-新增产品' })
|
||||||
|
create(@Body() dto: CreateProductDto): Promise<Product> {
|
||||||
|
return this.service.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Put('admin/product/:id')
|
||||||
|
@ApiOperation({ summary: '后台-修改产品' })
|
||||||
|
update(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() dto: UpdateProductDto,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.service.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Delete('admin/product/:id')
|
||||||
|
@Roles('super_admin')
|
||||||
|
@ApiOperation({ summary: '后台-删除产品(仅超管)' })
|
||||||
|
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||||
|
return this.service.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/modules/product/product.module.ts
Normal file
13
src/modules/product/product.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Product } from '@/entities/product.entity';
|
||||||
|
import { ProductCategory } from '@/entities/product-category.entity';
|
||||||
|
import { ProductController } from './product.controller';
|
||||||
|
import { ProductService } from './product.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Product, ProductCategory])],
|
||||||
|
controllers: [ProductController],
|
||||||
|
providers: [ProductService],
|
||||||
|
})
|
||||||
|
export class ProductModule {}
|
||||||
114
src/modules/product/product.service.ts
Normal file
114
src/modules/product/product.service.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { Product } from '@/entities/product.entity';
|
||||||
|
import { PaginatedResult } from '@/common/dto/api-response.dto';
|
||||||
|
import {
|
||||||
|
CreateProductDto,
|
||||||
|
QueryProductDto,
|
||||||
|
UpdateProductDto,
|
||||||
|
} from './dto/product.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProductService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Product)
|
||||||
|
private readonly repo: Repository<Product>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ---------- 前台 ----------
|
||||||
|
async publicPaginate(query: QueryProductDto): Promise<PaginatedResult<Product>> {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 12;
|
||||||
|
const qb = this.repo
|
||||||
|
.createQueryBuilder('p')
|
||||||
|
.leftJoinAndSelect('p.category', 'c')
|
||||||
|
.where('p.is_show = 1')
|
||||||
|
.orderBy('p.sort', 'ASC')
|
||||||
|
.addOrderBy('p.id', 'DESC');
|
||||||
|
if (query.categoryId) {
|
||||||
|
qb.andWhere('p.category_id = :cid', { cid: query.categoryId });
|
||||||
|
}
|
||||||
|
if (query.keyword) {
|
||||||
|
qb.andWhere('p.name LIKE :kw', { kw: `%${query.keyword}%` });
|
||||||
|
}
|
||||||
|
const [list, total] = await qb
|
||||||
|
.skip((page - 1) * pageSize)
|
||||||
|
.take(pageSize)
|
||||||
|
.getManyAndCount();
|
||||||
|
return { list, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async publicDetail(id: number): Promise<Product> {
|
||||||
|
const item = await this.repo.findOne({
|
||||||
|
where: { id, isShow: 1 },
|
||||||
|
relations: ['category'],
|
||||||
|
});
|
||||||
|
if (!item) throw new NotFoundException('产品不存在或已下架');
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 后台 ----------
|
||||||
|
async paginate(query: QueryProductDto): Promise<PaginatedResult<Product>> {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 10;
|
||||||
|
const qb = this.repo
|
||||||
|
.createQueryBuilder('p')
|
||||||
|
.leftJoinAndSelect('p.category', 'c')
|
||||||
|
.orderBy('p.sort', 'ASC')
|
||||||
|
.addOrderBy('p.id', 'DESC');
|
||||||
|
if (query.categoryId) {
|
||||||
|
qb.andWhere('p.category_id = :cid', { cid: query.categoryId });
|
||||||
|
}
|
||||||
|
if (query.keyword) {
|
||||||
|
qb.andWhere('p.name LIKE :kw', { kw: `%${query.keyword}%` });
|
||||||
|
}
|
||||||
|
const [list, total] = await qb
|
||||||
|
.skip((page - 1) * pageSize)
|
||||||
|
.take(pageSize)
|
||||||
|
.getManyAndCount();
|
||||||
|
return { list, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(id: number): Promise<Product> {
|
||||||
|
const item = await this.repo.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['category'],
|
||||||
|
});
|
||||||
|
if (!item) throw new NotFoundException('产品不存在');
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateProductDto): Promise<Product> {
|
||||||
|
const entity = this.repo.create({
|
||||||
|
categoryId: dto.categoryId,
|
||||||
|
name: dto.name,
|
||||||
|
cover: dto.cover,
|
||||||
|
desc: dto.desc ?? null,
|
||||||
|
content: dto.content ?? null,
|
||||||
|
sort: dto.sort ?? 0,
|
||||||
|
isShow: dto.isShow ?? 1,
|
||||||
|
});
|
||||||
|
return this.repo.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: number, dto: UpdateProductDto): Promise<void> {
|
||||||
|
const item = await this.detail(id);
|
||||||
|
Object.assign(item, {
|
||||||
|
categoryId: dto.categoryId,
|
||||||
|
name: dto.name,
|
||||||
|
cover: dto.cover,
|
||||||
|
desc: dto.desc ?? null,
|
||||||
|
content: dto.content ?? null,
|
||||||
|
sort: dto.sort ?? 0,
|
||||||
|
isShow: dto.isShow ?? 1,
|
||||||
|
});
|
||||||
|
await this.repo.save(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: number): Promise<void> {
|
||||||
|
await this.detail(id);
|
||||||
|
await this.repo.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/modules/site-config/dto/site-config.dto.ts
Normal file
61
src/modules/site-config/dto/site-config.dto.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateSiteConfigDto {
|
||||||
|
@ApiPropertyOptional({ description: '网站名称' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(100)
|
||||||
|
siteName?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'logo 图片地址' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(255)
|
||||||
|
logo?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '联系电话' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(50)
|
||||||
|
tel?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '公司地址' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(255)
|
||||||
|
address?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '商务邮箱' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(100)
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '底部版权' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(255)
|
||||||
|
copyright?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '备案号' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(100)
|
||||||
|
icp?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '企业简介标题' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(100)
|
||||||
|
aboutTitle?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '企业简介正文(富文本)' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
aboutContent?: string;
|
||||||
|
}
|
||||||
33
src/modules/site-config/site-config.controller.ts
Normal file
33
src/modules/site-config/site-config.controller.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { Body, Controller, Get, Put } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Public } from '@/common/decorators';
|
||||||
|
import { SiteConfig } from '@/entities/site-config.entity';
|
||||||
|
import { SiteConfigService } from './site-config.service';
|
||||||
|
import { UpdateSiteConfigDto } from './dto/site-config.dto';
|
||||||
|
|
||||||
|
@ApiTags('网站配置 SiteConfig')
|
||||||
|
@Controller()
|
||||||
|
export class SiteConfigController {
|
||||||
|
constructor(private readonly service: SiteConfigService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('public/site-config')
|
||||||
|
@ApiOperation({ summary: '前台-获取网站基础配置' })
|
||||||
|
get(): Promise<SiteConfig> {
|
||||||
|
return this.service.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Get('admin/site-config')
|
||||||
|
@ApiOperation({ summary: '后台-获取网站配置' })
|
||||||
|
adminGet(): Promise<SiteConfig> {
|
||||||
|
return this.service.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Put('admin/site-config')
|
||||||
|
@ApiOperation({ summary: '后台-修改网站配置' })
|
||||||
|
update(@Body() dto: UpdateSiteConfigDto): Promise<SiteConfig> {
|
||||||
|
return this.service.update(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/modules/site-config/site-config.module.ts
Normal file
13
src/modules/site-config/site-config.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { SiteConfig } from '@/entities/site-config.entity';
|
||||||
|
import { SiteConfigController } from './site-config.controller';
|
||||||
|
import { SiteConfigService } from './site-config.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([SiteConfig])],
|
||||||
|
controllers: [SiteConfigController],
|
||||||
|
providers: [SiteConfigService],
|
||||||
|
exports: [SiteConfigService],
|
||||||
|
})
|
||||||
|
export class SiteConfigModule {}
|
||||||
44
src/modules/site-config/site-config.service.ts
Normal file
44
src/modules/site-config/site-config.service.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { SiteConfig } from '@/entities/site-config.entity';
|
||||||
|
import { UpdateSiteConfigDto } from './dto/site-config.dto';
|
||||||
|
|
||||||
|
const SINGLETON_ID = 1;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SiteConfigService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(SiteConfig)
|
||||||
|
private readonly repo: Repository<SiteConfig>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取唯一站点配置;不存在则自动初始化一条
|
||||||
|
*/
|
||||||
|
async get(): Promise<SiteConfig> {
|
||||||
|
let one = await this.repo.findOne({ where: { id: SINGLETON_ID } });
|
||||||
|
if (!one) {
|
||||||
|
one = this.repo.create({
|
||||||
|
id: SINGLETON_ID,
|
||||||
|
siteName: '企业官方网站',
|
||||||
|
logo: '',
|
||||||
|
tel: '',
|
||||||
|
address: '',
|
||||||
|
email: '',
|
||||||
|
copyright: '',
|
||||||
|
icp: '',
|
||||||
|
aboutTitle: '',
|
||||||
|
aboutContent: null,
|
||||||
|
});
|
||||||
|
one = await this.repo.save(one);
|
||||||
|
}
|
||||||
|
return one;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(dto: UpdateSiteConfigDto): Promise<SiteConfig> {
|
||||||
|
const one = await this.get();
|
||||||
|
Object.assign(one, dto);
|
||||||
|
return this.repo.save(one);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/modules/team/dto/team.dto.ts
Normal file
60
src/modules/team/dto/team.dto.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateTeamDto {
|
||||||
|
@ApiProperty({ description: '姓名' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(50)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '职位' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(100)
|
||||||
|
position: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '头像' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
avatar: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '个人简介' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
desc?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '排序', default: 0 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(99999)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
sort?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '是否展示 1是0否', default: 1 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(1)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
isShow?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateTeamDto extends CreateTeamDto {}
|
||||||
|
|
||||||
|
export class QueryTeamDto {
|
||||||
|
@ApiPropertyOptional({ description: '关键词(姓名/职位)' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
72
src/modules/team/team.controller.ts
Normal file
72
src/modules/team/team.controller.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Public, Roles } from '@/common/decorators';
|
||||||
|
import { Team } from '@/entities/team.entity';
|
||||||
|
import { TeamService } from './team.service';
|
||||||
|
import {
|
||||||
|
CreateTeamDto,
|
||||||
|
QueryTeamDto,
|
||||||
|
UpdateTeamDto,
|
||||||
|
} from './dto/team.dto';
|
||||||
|
|
||||||
|
@ApiTags('团队 Team')
|
||||||
|
@Controller()
|
||||||
|
export class TeamController {
|
||||||
|
constructor(private readonly service: TeamService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('public/team')
|
||||||
|
@ApiOperation({ summary: '前台-团队成员列表' })
|
||||||
|
publicList(): Promise<Team[]> {
|
||||||
|
return this.service.findPublicList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Get('admin/team')
|
||||||
|
@ApiOperation({ summary: '后台-团队成员列表' })
|
||||||
|
list(@Query() query: QueryTeamDto): Promise<Team[]> {
|
||||||
|
return this.service.findAll(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Get('admin/team/:id')
|
||||||
|
@ApiOperation({ summary: '后台-团队成员详情' })
|
||||||
|
detail(@Param('id', ParseIntPipe) id: number): Promise<Team> {
|
||||||
|
return this.service.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Post('admin/team')
|
||||||
|
@ApiOperation({ summary: '后台-新增团队成员' })
|
||||||
|
create(@Body() dto: CreateTeamDto): Promise<Team> {
|
||||||
|
return this.service.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Put('admin/team/:id')
|
||||||
|
@ApiOperation({ summary: '后台-修改团队成员' })
|
||||||
|
update(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() dto: UpdateTeamDto,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.service.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@Delete('admin/team/:id')
|
||||||
|
@Roles('super_admin')
|
||||||
|
@ApiOperation({ summary: '后台-删除团队成员(仅超管)' })
|
||||||
|
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||||
|
return this.service.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/modules/team/team.module.ts
Normal file
12
src/modules/team/team.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Team } from '@/entities/team.entity';
|
||||||
|
import { TeamController } from './team.controller';
|
||||||
|
import { TeamService } from './team.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Team])],
|
||||||
|
controllers: [TeamController],
|
||||||
|
providers: [TeamService],
|
||||||
|
})
|
||||||
|
export class TeamModule {}
|
||||||
74
src/modules/team/team.service.ts
Normal file
74
src/modules/team/team.service.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, Like } from 'typeorm';
|
||||||
|
import { Team } from '@/entities/team.entity';
|
||||||
|
import {
|
||||||
|
CreateTeamDto,
|
||||||
|
QueryTeamDto,
|
||||||
|
UpdateTeamDto,
|
||||||
|
} from './dto/team.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TeamService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Team)
|
||||||
|
private readonly repo: Repository<Team>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findPublicList(): Promise<Team[]> {
|
||||||
|
return this.repo.find({
|
||||||
|
where: { isShow: 1 },
|
||||||
|
order: { sort: 'ASC', id: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(query?: QueryTeamDto): Promise<Team[]> {
|
||||||
|
const where = query?.keyword
|
||||||
|
? [
|
||||||
|
{ name: Like(`%${query.keyword}%`) },
|
||||||
|
{ position: Like(`%${query.keyword}%`) },
|
||||||
|
]
|
||||||
|
: undefined;
|
||||||
|
return this.repo.find({
|
||||||
|
where,
|
||||||
|
order: { sort: 'ASC', id: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(id: number): Promise<Team> {
|
||||||
|
const item = await this.repo.findOne({ where: { id } });
|
||||||
|
if (!item) throw new NotFoundException('成员不存在');
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateTeamDto): Promise<Team> {
|
||||||
|
return this.repo.save(
|
||||||
|
this.repo.create({
|
||||||
|
name: dto.name,
|
||||||
|
position: dto.position,
|
||||||
|
avatar: dto.avatar,
|
||||||
|
desc: dto.desc ?? null,
|
||||||
|
sort: dto.sort ?? 0,
|
||||||
|
isShow: dto.isShow ?? 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: number, dto: UpdateTeamDto): Promise<void> {
|
||||||
|
const item = await this.detail(id);
|
||||||
|
Object.assign(item, {
|
||||||
|
name: dto.name,
|
||||||
|
position: dto.position,
|
||||||
|
avatar: dto.avatar,
|
||||||
|
desc: dto.desc ?? null,
|
||||||
|
sort: dto.sort ?? 0,
|
||||||
|
isShow: dto.isShow ?? 1,
|
||||||
|
});
|
||||||
|
await this.repo.save(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: number): Promise<void> {
|
||||||
|
await this.detail(id);
|
||||||
|
await this.repo.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/modules/upload/upload.controller.ts
Normal file
36
src/modules/upload/upload.controller.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { filePathToUrl } from '@/utils/file.util';
|
||||||
|
|
||||||
|
export interface UploadResult {
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('admin-token')
|
||||||
|
@ApiTags('文件上传 Upload')
|
||||||
|
@Controller('admin/upload')
|
||||||
|
export class UploadController {
|
||||||
|
@Post()
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '上传图片',
|
||||||
|
description: '仅支持 jpg/png/jpeg/webp,单图 2M 上限',
|
||||||
|
})
|
||||||
|
upload(@Req() req: Request): UploadResult {
|
||||||
|
const file = (req as Request & { file?: Express.Multer.File }).file;
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('请上传文件');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
url: filePathToUrl(file.path),
|
||||||
|
filename: file.filename,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/modules/upload/upload.module.ts
Normal file
7
src/modules/upload/upload.module.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { UploadController } from './upload.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [UploadController],
|
||||||
|
})
|
||||||
|
export class UploadModule {}
|
||||||
10
src/types/express.d.ts
vendored
Normal file
10
src/types/express.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { CurrentAdminPayload } from '@/common/decorators/current-admin.decorator';
|
||||||
|
|
||||||
|
declare module 'express' {
|
||||||
|
interface Request {
|
||||||
|
/** JWT 守卫解析后挂载的当前管理员信息 */
|
||||||
|
admin?: CurrentAdminPayload;
|
||||||
|
/** multer 上传后的文件(仅 /admin/upload 路由) */
|
||||||
|
file?: Express.Multer.File;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/utils/crypto.util.ts
Normal file
16
src/utils/crypto.util.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
const SALT_ROUNDS = 10;
|
||||||
|
|
||||||
|
/** bcrypt 加密明文密码 */
|
||||||
|
export async function hashPassword(plain: string): Promise<string> {
|
||||||
|
return bcrypt.hash(plain, SALT_ROUNDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** bcrypt 校验密码 */
|
||||||
|
export async function comparePassword(
|
||||||
|
plain: string,
|
||||||
|
hashed: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return bcrypt.compare(plain, hashed);
|
||||||
|
}
|
||||||
14
src/utils/file.util.ts
Normal file
14
src/utils/file.util.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把磁盘绝对路径转换成可访问的相对 URL
|
||||||
|
* 例如:/Users/xxx/server/uploads/2026/01/abc.jpg -> /uploads/2026/01/abc.jpg
|
||||||
|
*/
|
||||||
|
export function filePathToUrl(absPath: string): string {
|
||||||
|
const root = (process.env.UPLOAD_ROOT ?? './uploads').replace(/^\.\/?/, '');
|
||||||
|
const idx = absPath.replace(/\\/g, '/').indexOf(root);
|
||||||
|
if (idx >= 0) {
|
||||||
|
return '/' + absPath.replace(/\\/g, '/').slice(idx).replace(/^\/+/, '/');
|
||||||
|
}
|
||||||
|
return '/' + path.basename(absPath);
|
||||||
|
}
|
||||||
2
src/utils/index.ts
Normal file
2
src/utils/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './crypto.util';
|
||||||
|
export * from './file.util';
|
||||||
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2021",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strict": true,
|
||||||
|
"strictPropertyInitialization": false,
|
||||||
|
"noEmitOnError": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist", "test"]
|
||||||
|
}
|
||||||
BIN
uploads/2026/06/1781692209397_uqobwr.jpg
Normal file
BIN
uploads/2026/06/1781692209397_uqobwr.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 333 KiB |
Loading…
x
Reference in New Issue
Block a user